Spaces:
Sleeping
Sleeping
Added LLM Classifiers as an option and added session history
Browse files- src/api/app.py +5 -4
- src/api/routes/chat.py +8 -3
- src/config.py +79 -0
- src/emotion_classifier/__init__.py +1 -0
- src/emotion_classifier/llm_emotion_classifier.py +132 -0
- src/language_detector/__init__.py +2 -1
- src/language_detector/llm_language_detector.py +121 -0
- src/ner_extractor/__init__.py +1 -1
- src/ner_extractor/ner_extractor.py +10 -0
- src/pipeline/orchestrator.py +10 -5
- src/utils/__init__.py +1 -0
- src/utils/conversation_manager.py +35 -2
- src/utils/memory_summarizer.py +104 -0
src/api/app.py
CHANGED
|
@@ -6,11 +6,10 @@ from fastapi import FastAPI
|
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
|
| 8 |
from src.intent_classifier.intent_classifier import IntentClassifier
|
| 9 |
-
from src.language_detector.language_detector import LanguageDetector
|
| 10 |
-
from src.emotion_classifier import EmotionClassifier
|
| 11 |
from src.language_translator import Translator
|
| 12 |
from src.ner_extractor import NERExtractor
|
| 13 |
from src.utils import QueryOptimizer, Embedder
|
|
|
|
| 14 |
|
| 15 |
from src.api.routes import chat, health
|
| 16 |
|
|
@@ -19,8 +18,10 @@ load_dotenv()
|
|
| 19 |
@asynccontextmanager
|
| 20 |
async def lifespan(app: FastAPI):
|
| 21 |
print("Loading NLP models into memory...")
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
app.state.intent_classifier = IntentClassifier()
|
| 25 |
app.state.translator = Translator(groq_api_key=os.environ["GROQ_API_KEY"])
|
| 26 |
app.state.ner_extractor = NERExtractor()
|
|
|
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
|
| 8 |
from src.intent_classifier.intent_classifier import IntentClassifier
|
|
|
|
|
|
|
| 9 |
from src.language_translator import Translator
|
| 10 |
from src.ner_extractor import NERExtractor
|
| 11 |
from src.utils import QueryOptimizer, Embedder
|
| 12 |
+
from src import config
|
| 13 |
|
| 14 |
from src.api.routes import chat, health
|
| 15 |
|
|
|
|
| 18 |
@asynccontextmanager
|
| 19 |
async def lifespan(app: FastAPI):
|
| 20 |
print("Loading NLP models into memory...")
|
| 21 |
+
print(f" Emotion classifier backend : {config.emotion_classifier_backend()}")
|
| 22 |
+
print(f" Language classifier backend : {config.language_classifier_backend()}")
|
| 23 |
+
app.state.lang_detector = config.build_language_detector()
|
| 24 |
+
app.state.emotion_classifier = config.build_emotion_classifier()
|
| 25 |
app.state.intent_classifier = IntentClassifier()
|
| 26 |
app.state.translator = Translator(groq_api_key=os.environ["GROQ_API_KEY"])
|
| 27 |
app.state.ner_extractor = NERExtractor()
|
src/api/routes/chat.py
CHANGED
|
@@ -16,8 +16,9 @@ from src.api.dependencies import (
|
|
| 16 |
from src.pipeline.safety_checks import SafetyChecker
|
| 17 |
from src.utils.conversation_manager import ConversationManager
|
| 18 |
from src.utils.vector_db import VectorDBManager
|
|
|
|
| 19 |
from functools import lru_cache
|
| 20 |
-
|
| 21 |
router = APIRouter()
|
| 22 |
|
| 23 |
# ── Tone map (mirrors orchestrator) ───────────────────────────────────────────
|
|
@@ -99,7 +100,11 @@ def get_safety_checker() -> SafetyChecker:
|
|
| 99 |
|
| 100 |
@lru_cache(maxsize=1)
|
| 101 |
def get_conversation_manager() -> ConversationManager:
|
| 102 |
-
return ConversationManager(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
@lru_cache(maxsize=1)
|
|
@@ -445,7 +450,7 @@ async def process_chat_message(
|
|
| 445 |
if detected_lang != "en":
|
| 446 |
response = translator.to_lang(response, detected_lang)
|
| 447 |
|
| 448 |
-
ner = ner_result
|
| 449 |
|
| 450 |
import random
|
| 451 |
|
|
|
|
| 16 |
from src.pipeline.safety_checks import SafetyChecker
|
| 17 |
from src.utils.conversation_manager import ConversationManager
|
| 18 |
from src.utils.vector_db import VectorDBManager
|
| 19 |
+
from src import config
|
| 20 |
from functools import lru_cache
|
| 21 |
+
from src.ner_extractor import NERResult
|
| 22 |
router = APIRouter()
|
| 23 |
|
| 24 |
# ── Tone map (mirrors orchestrator) ───────────────────────────────────────────
|
|
|
|
| 100 |
|
| 101 |
@lru_cache(maxsize=1)
|
| 102 |
def get_conversation_manager() -> ConversationManager:
|
| 103 |
+
return ConversationManager(
|
| 104 |
+
max_turns=10,
|
| 105 |
+
summarizer=config.build_memory_summarizer(),
|
| 106 |
+
memory_window=3,
|
| 107 |
+
)
|
| 108 |
|
| 109 |
|
| 110 |
@lru_cache(maxsize=1)
|
|
|
|
| 450 |
if detected_lang != "en":
|
| 451 |
response = translator.to_lang(response, detected_lang)
|
| 452 |
|
| 453 |
+
ner = ner_result.__dict__ if ner_result else {}
|
| 454 |
|
| 455 |
import random
|
| 456 |
|
src/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/config.py
|
| 3 |
+
==============
|
| 4 |
+
Central place to resolve runtime backend choices from environment variables.
|
| 5 |
+
|
| 6 |
+
Both the FastAPI startup (src/api/app.py) and the legacy Orchestrator
|
| 7 |
+
(src/pipeline/orchestrator.py) build their classifiers through the factory
|
| 8 |
+
functions here, so a single env var flips the behaviour everywhere instead
|
| 9 |
+
of being checked in two different places.
|
| 10 |
+
|
| 11 |
+
Env vars
|
| 12 |
+
--------
|
| 13 |
+
EMOTION_CLASSIFIER_BACKEND : "model" (default) | "llm"
|
| 14 |
+
LANGUAGE_CLASSIFIER_BACKEND : "model" (default) | "llm"
|
| 15 |
+
MEMORY_SUMMARIZATION_ENABLED : "true" (default) | "false"
|
| 16 |
+
|
| 17 |
+
GROQ_API_KEY_EMOTION_CLASSIFIER : key used when EMOTION_CLASSIFIER_BACKEND=llm
|
| 18 |
+
GROQ_API_KEY_LANGUAGE_CLASSIFIER : key used when LANGUAGE_CLASSIFIER_BACKEND=llm
|
| 19 |
+
GROQ_API_KEY_SUMMARIZER : key used for conversation memory summarization
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
from dotenv import load_dotenv
|
| 26 |
+
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
# Every other module in this codebase hardcodes this model name for Groq
|
| 30 |
+
# calls (translator, NER, intent classifier) — reuse it here for consistency.
|
| 31 |
+
DEFAULT_GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _flag(name: str, default: bool) -> bool:
|
| 35 |
+
val = os.getenv(name)
|
| 36 |
+
if val is None:
|
| 37 |
+
return default
|
| 38 |
+
return val.strip().lower() not in ("0", "false", "no", "")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def emotion_classifier_backend() -> str:
|
| 42 |
+
return os.getenv("EMOTION_CLASSIFIER_BACKEND", "model").strip().lower()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def language_classifier_backend() -> str:
|
| 46 |
+
return os.getenv("LANGUAGE_CLASSIFIER_BACKEND", "model").strip().lower()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def memory_summarization_enabled() -> bool:
|
| 50 |
+
return _flag("MEMORY_SUMMARIZATION_ENABLED", True)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def build_emotion_classifier():
|
| 54 |
+
"""Return an EmotionClassifier-compatible instance per EMOTION_CLASSIFIER_BACKEND."""
|
| 55 |
+
if emotion_classifier_backend() == "llm":
|
| 56 |
+
from src.emotion_classifier.llm_emotion_classifier import LLMEmotionClassifier
|
| 57 |
+
return LLMEmotionClassifier(api_key=os.getenv("GROQ_API_KEY_EMOTION_CLASSIFIER", ""))
|
| 58 |
+
|
| 59 |
+
from src.emotion_classifier.emotion_classifier import EmotionClassifier
|
| 60 |
+
return EmotionClassifier()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def build_language_detector():
|
| 64 |
+
"""Return a LanguageDetector-compatible instance per LANGUAGE_CLASSIFIER_BACKEND."""
|
| 65 |
+
if language_classifier_backend() == "llm":
|
| 66 |
+
from src.language_detector.llm_language_detector import LLMLanguageDetector
|
| 67 |
+
return LLMLanguageDetector(api_key=os.getenv("GROQ_API_KEY_LANGUAGE_CLASSIFIER", ""))
|
| 68 |
+
|
| 69 |
+
from src.language_detector.language_detector import LanguageDetector
|
| 70 |
+
return LanguageDetector()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def build_memory_summarizer():
|
| 74 |
+
"""Return a MemorySummarizer instance, or None if summarization is disabled."""
|
| 75 |
+
if not memory_summarization_enabled():
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
from src.utils.memory_summarizer import MemorySummarizer
|
| 79 |
+
return MemorySummarizer(api_key=os.getenv("GROQ_API_KEY_SUMMARIZER", ""))
|
src/emotion_classifier/__init__.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from .data_processor import load_tokenizer, save_tokenizer, get_dataloaders, load_data
|
| 4 |
from .emotion_classifier import EmotionClassifier
|
|
|
|
| 5 |
from .train import train, train_one_epoch, evaluate_one_epoch
|
| 6 |
from .evaluate import evaluate
|
| 7 |
from .model import EmotionClassifierModel
|
|
|
|
| 2 |
|
| 3 |
from .data_processor import load_tokenizer, save_tokenizer, get_dataloaders, load_data
|
| 4 |
from .emotion_classifier import EmotionClassifier
|
| 5 |
+
from .llm_emotion_classifier import LLMEmotionClassifier
|
| 6 |
from .train import train, train_one_epoch, evaluate_one_epoch
|
| 7 |
from .evaluate import evaluate
|
| 8 |
from .model import EmotionClassifierModel
|
src/emotion_classifier/llm_emotion_classifier.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/emotion_classifier/llm_emotion_classifier.py
|
| 3 |
+
===================================================
|
| 4 |
+
LLM-backed drop-in replacement for EmotionClassifier (the trained BiLSTM
|
| 5 |
+
model). Same public contract — classify(text) / predict(text) — so it can
|
| 6 |
+
be swapped in via EMOTION_CLASSIFIER_BACKEND=llm without touching callers.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# Mirrors EMOTION_LABELS in emotion_classifier.py — duplicated (not imported)
|
| 20 |
+
# so picking the "llm" backend doesn't force-load torch/transformers.
|
| 21 |
+
EMOTION_LABELS = ["sadness", "joy", "love", "anger", "fear", "surprise"]
|
| 22 |
+
|
| 23 |
+
_EMOTION_PROMPT = """You are an emotion classifier for a mental health support chatbot.
|
| 24 |
+
Classify the dominant emotion expressed in the user's message.
|
| 25 |
+
|
| 26 |
+
Return ONLY valid JSON, no explanation, no markdown, exactly this schema:
|
| 27 |
+
{{"emotion": "one of: sadness, joy, love, anger, fear, surprise", "confidence": 0.0 to 1.0}}
|
| 28 |
+
|
| 29 |
+
User message: "{text}"
|
| 30 |
+
JSON:"""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class LLMEmotionClassifier:
|
| 34 |
+
"""
|
| 35 |
+
Zero-shot emotion classification via Groq LLM.
|
| 36 |
+
|
| 37 |
+
Parameters
|
| 38 |
+
----------
|
| 39 |
+
api_key : Groq API key (defaults to GROQ_API_KEY_EMOTION_CLASSIFIER env var,
|
| 40 |
+
falling back to GROQ_API_KEY if unset).
|
| 41 |
+
model : Groq model name.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(
|
| 45 |
+
self,
|
| 46 |
+
api_key: Optional[str] = None,
|
| 47 |
+
model: str = "llama-3.3-70b-versatile",
|
| 48 |
+
timeout: float = 8.0,
|
| 49 |
+
) -> None:
|
| 50 |
+
self._api_key = (
|
| 51 |
+
api_key
|
| 52 |
+
or os.getenv("GROQ_API_KEY_EMOTION_CLASSIFIER", "")
|
| 53 |
+
or os.getenv("GROQ_API_KEY", "")
|
| 54 |
+
)
|
| 55 |
+
self._model = model
|
| 56 |
+
self._timeout = timeout
|
| 57 |
+
self._client = None
|
| 58 |
+
|
| 59 |
+
def _get_client(self):
|
| 60 |
+
if self._client is None:
|
| 61 |
+
from groq import Groq
|
| 62 |
+
self._client = Groq(api_key=self._api_key, timeout=self._timeout)
|
| 63 |
+
return self._client
|
| 64 |
+
|
| 65 |
+
def _classify_via_llm(self, text: str) -> dict:
|
| 66 |
+
prompt = _EMOTION_PROMPT.format(text=text)
|
| 67 |
+
resp = self._get_client().chat.completions.create(
|
| 68 |
+
model=self._model,
|
| 69 |
+
messages=[{"role": "user", "content": prompt}],
|
| 70 |
+
max_tokens=50,
|
| 71 |
+
temperature=0.0,
|
| 72 |
+
)
|
| 73 |
+
raw = resp.choices[0].message.content or "{}"
|
| 74 |
+
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip())
|
| 75 |
+
data = json.loads(raw)
|
| 76 |
+
|
| 77 |
+
emotion = str(data.get("emotion", "")).strip().lower()
|
| 78 |
+
if emotion not in EMOTION_LABELS:
|
| 79 |
+
emotion = "unknown"
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
confidence = max(0.0, min(1.0, float(data.get("confidence", 0.0))))
|
| 83 |
+
except (TypeError, ValueError):
|
| 84 |
+
confidence = 0.0
|
| 85 |
+
|
| 86 |
+
all_scores = {lbl: 0.0 for lbl in EMOTION_LABELS}
|
| 87 |
+
if emotion in all_scores:
|
| 88 |
+
all_scores[emotion] = confidence
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"emotion": emotion,
|
| 92 |
+
"confidence": confidence,
|
| 93 |
+
"all_scores": dict(sorted(all_scores.items(), key=lambda x: -x[1])),
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# ── Public API ────────────────────────────────────────────────────────────
|
| 97 |
+
|
| 98 |
+
def classify(self, text: str) -> dict:
|
| 99 |
+
"""
|
| 100 |
+
Classify a single text.
|
| 101 |
+
|
| 102 |
+
Returns
|
| 103 |
+
-------
|
| 104 |
+
{"emotion": "sadness", "confidence": 0.87, "all_scores": {...}}
|
| 105 |
+
"""
|
| 106 |
+
if not isinstance(text, str) or not text.strip():
|
| 107 |
+
return {"emotion": "unknown", "confidence": 0.0, "all_scores": {}}
|
| 108 |
+
|
| 109 |
+
if not self._api_key:
|
| 110 |
+
logger.warning("LLMEmotionClassifier: no API key set — returning unknown")
|
| 111 |
+
return {"emotion": "unknown", "confidence": 0.0, "all_scores": {}}
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
return self._classify_via_llm(text)
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
logger.warning(f"LLMEmotionClassifier failed: {exc}")
|
| 117 |
+
return {"emotion": "unknown", "confidence": 0.0, "all_scores": {}}
|
| 118 |
+
|
| 119 |
+
def predict(self, text: str) -> dict:
|
| 120 |
+
"""Alias to keep consistent with the FastAPI endpoint expectations."""
|
| 121 |
+
return self.classify(text)
|
| 122 |
+
|
| 123 |
+
def classify_batch(self, texts: list[str]) -> list[dict]:
|
| 124 |
+
return [self.classify(t) for t in texts]
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def labels(self) -> list[str]:
|
| 128 |
+
return EMOTION_LABELS
|
| 129 |
+
|
| 130 |
+
def __repr__(self) -> str:
|
| 131 |
+
mode = "ready" if self._api_key else "no-api-key"
|
| 132 |
+
return f"LLMEmotionClassifier(model='{self._model}', status={mode})"
|
src/language_detector/__init__.py
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
-
from .language_detector import LanguageDetector
|
|
|
|
|
|
| 1 |
+
from .language_detector import LanguageDetector
|
| 2 |
+
from .llm_language_detector import LLMLanguageDetector
|
src/language_detector/llm_language_detector.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/language_detector/llm_language_detector.py
|
| 3 |
+
=================================================
|
| 4 |
+
LLM-backed drop-in replacement for LanguageDetector (the TF-IDF + LR model).
|
| 5 |
+
Same public contract — detect(text) — so it can be swapped in via
|
| 6 |
+
LANGUAGE_CLASSIFIER_BACKEND=llm without touching callers.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
from typing import Any, Optional
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
_ISO_CODE_RE = re.compile(r"^[a-z]{2}$")
|
| 20 |
+
|
| 21 |
+
_LANGUAGE_PROMPT = """You are a language identification engine.
|
| 22 |
+
Identify the ISO 639-1 language code (e.g. "en", "es", "ar", "fr", "zh") of the
|
| 23 |
+
user's message.
|
| 24 |
+
|
| 25 |
+
Return ONLY valid JSON, no explanation, no markdown, exactly this schema:
|
| 26 |
+
{{"language": "two-letter ISO 639-1 code, lowercase", "confidence": 0.0 to 1.0}}
|
| 27 |
+
|
| 28 |
+
Message: "{text}"
|
| 29 |
+
JSON:"""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LLMLanguageDetector:
|
| 33 |
+
"""
|
| 34 |
+
Zero-shot language detection via Groq LLM.
|
| 35 |
+
|
| 36 |
+
Parameters
|
| 37 |
+
----------
|
| 38 |
+
api_key : Groq API key (defaults to GROQ_API_KEY_LANGUAGE_CLASSIFIER env var,
|
| 39 |
+
falling back to GROQ_API_KEY if unset).
|
| 40 |
+
model : Groq model name.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
api_key: Optional[str] = None,
|
| 46 |
+
model: str = "llama-3.3-70b-versatile",
|
| 47 |
+
timeout: float = 8.0,
|
| 48 |
+
) -> None:
|
| 49 |
+
self._api_key = (
|
| 50 |
+
api_key
|
| 51 |
+
or os.getenv("GROQ_API_KEY_LANGUAGE_CLASSIFIER", "")
|
| 52 |
+
or os.getenv("GROQ_API_KEY", "")
|
| 53 |
+
)
|
| 54 |
+
self._model = model
|
| 55 |
+
self._timeout = timeout
|
| 56 |
+
self._client = None
|
| 57 |
+
|
| 58 |
+
def _get_client(self):
|
| 59 |
+
if self._client is None:
|
| 60 |
+
from groq import Groq
|
| 61 |
+
self._client = Groq(api_key=self._api_key, timeout=self._timeout)
|
| 62 |
+
return self._client
|
| 63 |
+
|
| 64 |
+
def _detect_via_llm(self, text: str) -> dict[str, Any]:
|
| 65 |
+
prompt = _LANGUAGE_PROMPT.format(text=text)
|
| 66 |
+
resp = self._get_client().chat.completions.create(
|
| 67 |
+
model=self._model,
|
| 68 |
+
messages=[{"role": "user", "content": prompt}],
|
| 69 |
+
max_tokens=30,
|
| 70 |
+
temperature=0.0,
|
| 71 |
+
)
|
| 72 |
+
raw = resp.choices[0].message.content or "{}"
|
| 73 |
+
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip())
|
| 74 |
+
data = json.loads(raw)
|
| 75 |
+
|
| 76 |
+
lang = str(data.get("language", "")).strip().lower()
|
| 77 |
+
if not _ISO_CODE_RE.match(lang):
|
| 78 |
+
lang = "en"
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
confidence = max(0.0, min(1.0, float(data.get("confidence", 0.0))))
|
| 82 |
+
except (TypeError, ValueError):
|
| 83 |
+
confidence = 0.0
|
| 84 |
+
|
| 85 |
+
return {
|
| 86 |
+
"language": lang,
|
| 87 |
+
"confidence": confidence,
|
| 88 |
+
"method": "llm",
|
| 89 |
+
"all_scores": None,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# ── Public API ────────────────────────────────────────────────────────────
|
| 93 |
+
|
| 94 |
+
def detect(self, text: str) -> dict[str, Any]:
|
| 95 |
+
"""
|
| 96 |
+
Detect the language of *text*.
|
| 97 |
+
|
| 98 |
+
Returns
|
| 99 |
+
-------
|
| 100 |
+
{"language": "en", "confidence": 0.95, "method": "llm", "all_scores": None}
|
| 101 |
+
"""
|
| 102 |
+
text = (text or "").strip()
|
| 103 |
+
if not text:
|
| 104 |
+
return {"language": "en", "confidence": 0.0, "method": "llm_empty", "all_scores": None}
|
| 105 |
+
|
| 106 |
+
if not self._api_key:
|
| 107 |
+
logger.warning("LLMLanguageDetector: no API key set — defaulting to 'en'")
|
| 108 |
+
return {"language": "en", "confidence": 0.0, "method": "llm_no_key", "all_scores": None}
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
return self._detect_via_llm(text)
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
logger.warning(f"LLMLanguageDetector failed: {exc}")
|
| 114 |
+
return {"language": "en", "confidence": 0.0, "method": "llm_error_fallback", "all_scores": None}
|
| 115 |
+
|
| 116 |
+
def detect_batch(self, texts: "list[str]") -> "list[dict[str, Any]]":
|
| 117 |
+
return [self.detect(t) for t in texts]
|
| 118 |
+
|
| 119 |
+
def __repr__(self) -> str:
|
| 120 |
+
mode = "ready" if self._api_key else "no-api-key"
|
| 121 |
+
return f"LLMLanguageDetector(model='{self._model}', status={mode})"
|
src/ner_extractor/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
from .ner_extractor import NERExtractor
|
|
|
|
| 1 |
+
from .ner_extractor import NERExtractor, NERResult
|
src/ner_extractor/ner_extractor.py
CHANGED
|
@@ -22,11 +22,21 @@ Usage
|
|
| 22 |
|
| 23 |
from __future__ import annotations
|
| 24 |
import json, logging, os, re
|
|
|
|
| 25 |
from pathlib import Path
|
| 26 |
from typing import Optional
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
# ── Mental health vocabulary for regex fallback ───────────────────────────────
|
| 31 |
_SYMPTOMS = [
|
| 32 |
"anxiety","anxious","depression","depressed","stress","stressed","panic",
|
|
|
|
| 22 |
|
| 23 |
from __future__ import annotations
|
| 24 |
import json, logging, os, re
|
| 25 |
+
from dataclasses import dataclass, field
|
| 26 |
from pathlib import Path
|
| 27 |
from typing import Optional
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class NERResult:
|
| 34 |
+
"""Structured view of an extract() dict, for callers that prefer attribute access."""
|
| 35 |
+
symptoms: list[str] = field(default_factory=list)
|
| 36 |
+
triggers: list[str] = field(default_factory=list)
|
| 37 |
+
duration: Optional[str] = None
|
| 38 |
+
severity: str = "medium"
|
| 39 |
+
|
| 40 |
# ── Mental health vocabulary for regex fallback ───────────────────────────────
|
| 41 |
_SYMPTOMS = [
|
| 42 |
"anxiety","anxious","depression","depressed","stress","stressed","panic",
|
src/pipeline/orchestrator.py
CHANGED
|
@@ -133,8 +133,13 @@ class Orchestrator:
|
|
| 133 |
self._hybrid_search = None
|
| 134 |
self._hybrid_chunks = None
|
| 135 |
|
|
|
|
| 136 |
from src.utils.conversation_manager import ConversationManager
|
| 137 |
-
self.conversation = ConversationManager(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
self._greeting_idx = 0
|
| 140 |
|
|
@@ -142,14 +147,14 @@ class Orchestrator:
|
|
| 142 |
|
| 143 |
def _get_lang_detector(self):
|
| 144 |
if self._lang_detector is None:
|
| 145 |
-
from src
|
| 146 |
-
self._lang_detector =
|
| 147 |
return self._lang_detector
|
| 148 |
|
| 149 |
def _get_emotion_clf(self):
|
| 150 |
if self._emotion_clf is None:
|
| 151 |
-
from src
|
| 152 |
-
self._emotion_clf =
|
| 153 |
return self._emotion_clf
|
| 154 |
|
| 155 |
def _get_ner(self):
|
|
|
|
| 133 |
self._hybrid_search = None
|
| 134 |
self._hybrid_chunks = None
|
| 135 |
|
| 136 |
+
from src import config
|
| 137 |
from src.utils.conversation_manager import ConversationManager
|
| 138 |
+
self.conversation = ConversationManager(
|
| 139 |
+
max_turns=10,
|
| 140 |
+
summarizer=config.build_memory_summarizer(),
|
| 141 |
+
memory_window=3,
|
| 142 |
+
)
|
| 143 |
|
| 144 |
self._greeting_idx = 0
|
| 145 |
|
|
|
|
| 147 |
|
| 148 |
def _get_lang_detector(self):
|
| 149 |
if self._lang_detector is None:
|
| 150 |
+
from src import config
|
| 151 |
+
self._lang_detector = config.build_language_detector()
|
| 152 |
return self._lang_detector
|
| 153 |
|
| 154 |
def _get_emotion_clf(self):
|
| 155 |
if self._emotion_clf is None:
|
| 156 |
+
from src import config
|
| 157 |
+
self._emotion_clf = config.build_emotion_classifier()
|
| 158 |
return self._emotion_clf
|
| 159 |
|
| 160 |
def _get_ner(self):
|
src/utils/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
from .query_optimizer import QueryOptimizer
|
| 2 |
from .embedder import Embedder
|
|
|
|
| 3 |
#from .hybrid_search import HybridSearch
|
|
|
|
| 1 |
from .query_optimizer import QueryOptimizer
|
| 2 |
from .embedder import Embedder
|
| 3 |
+
from .memory_summarizer import MemorySummarizer
|
| 4 |
#from .hybrid_search import HybridSearch
|
src/utils/conversation_manager.py
CHANGED
|
@@ -38,9 +38,19 @@ class ConversationManager:
|
|
| 38 |
----------
|
| 39 |
max_turns : maximum turns to keep (older ones are dropped).
|
| 40 |
Keeps the system prompt lean for LLM calls.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"""
|
| 42 |
|
| 43 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
self.max_turns = max_turns
|
| 45 |
self._turns: list[Turn] = []
|
| 46 |
# Accumulated clinical context — grows throughout the session
|
|
@@ -51,6 +61,10 @@ class ConversationManager:
|
|
| 51 |
self._emotions: list[str] = []
|
| 52 |
self._turn_count = 0 # total including dropped turns
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# ── Adding turns ─────────────────────────────────────────────────────────
|
| 55 |
|
| 56 |
def add_turn(
|
|
@@ -113,6 +127,15 @@ class ConversationManager:
|
|
| 113 |
if s not in self._suggestions:
|
| 114 |
self._suggestions.append(s)
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
# ── Context retrieval ─────────────────────────────────────────────────────
|
| 117 |
|
| 118 |
def get_context_for_prompt(self, max_chars: int = 600) -> str:
|
|
@@ -125,6 +148,8 @@ class ConversationManager:
|
|
| 125 |
Topics discussed: sleep, panic attacks.
|
| 126 |
"""
|
| 127 |
parts = []
|
|
|
|
|
|
|
| 128 |
if self._symptoms:
|
| 129 |
parts.append(f"Symptoms mentioned: {', '.join(self._symptoms[:6])}")
|
| 130 |
if self._triggers:
|
|
@@ -183,6 +208,10 @@ class ConversationManager:
|
|
| 183 |
"narrative": narrative,
|
| 184 |
}
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
def get_accumulated_context(self) -> dict:
|
| 187 |
"""Return raw accumulated context dict (used by orchestrator metadata)."""
|
| 188 |
return {
|
|
@@ -199,7 +228,11 @@ class ConversationManager:
|
|
| 199 |
|
| 200 |
def reset(self) -> None:
|
| 201 |
"""Clear the session (start fresh)."""
|
| 202 |
-
self.__init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
@property
|
| 205 |
def turn_count(self) -> int:
|
|
|
|
| 38 |
----------
|
| 39 |
max_turns : maximum turns to keep (older ones are dropped).
|
| 40 |
Keeps the system prompt lean for LLM calls.
|
| 41 |
+
summarizer : optional MemorySummarizer. When provided, the last
|
| 42 |
+
`memory_window` turns are summarized via LLM after every
|
| 43 |
+
assistant turn, and the result is folded into
|
| 44 |
+
get_context_for_prompt().
|
| 45 |
+
memory_window : number of most recent turns to summarize (default 3).
|
| 46 |
"""
|
| 47 |
|
| 48 |
+
def __init__(
|
| 49 |
+
self,
|
| 50 |
+
max_turns: int = 10,
|
| 51 |
+
summarizer=None,
|
| 52 |
+
memory_window: int = 3,
|
| 53 |
+
) -> None:
|
| 54 |
self.max_turns = max_turns
|
| 55 |
self._turns: list[Turn] = []
|
| 56 |
# Accumulated clinical context — grows throughout the session
|
|
|
|
| 61 |
self._emotions: list[str] = []
|
| 62 |
self._turn_count = 0 # total including dropped turns
|
| 63 |
|
| 64 |
+
self._summarizer = summarizer
|
| 65 |
+
self._memory_window = memory_window
|
| 66 |
+
self._memory_summary = ""
|
| 67 |
+
|
| 68 |
# ── Adding turns ─────────────────────────────────────────────────────────
|
| 69 |
|
| 70 |
def add_turn(
|
|
|
|
| 127 |
if s not in self._suggestions:
|
| 128 |
self._suggestions.append(s)
|
| 129 |
|
| 130 |
+
# Refresh the rolling LLM memory summary after each assistant turn
|
| 131 |
+
# (i.e. once a full user/assistant exchange has landed).
|
| 132 |
+
if role == "assistant" and self._summarizer is not None:
|
| 133 |
+
try:
|
| 134 |
+
recent = self.get_recent_history(self._memory_window)
|
| 135 |
+
self._memory_summary = self._summarizer.summarize(recent)
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
# ── Context retrieval ─────────────────────────────────────────────────────
|
| 140 |
|
| 141 |
def get_context_for_prompt(self, max_chars: int = 600) -> str:
|
|
|
|
| 148 |
Topics discussed: sleep, panic attacks.
|
| 149 |
"""
|
| 150 |
parts = []
|
| 151 |
+
if self._memory_summary:
|
| 152 |
+
parts.append(f"Recent conversation summary: {self._memory_summary}")
|
| 153 |
if self._symptoms:
|
| 154 |
parts.append(f"Symptoms mentioned: {', '.join(self._symptoms[:6])}")
|
| 155 |
if self._triggers:
|
|
|
|
| 208 |
"narrative": narrative,
|
| 209 |
}
|
| 210 |
|
| 211 |
+
def get_memory_summary(self) -> str:
|
| 212 |
+
"""Return the current rolling LLM summary of the last `memory_window` turns."""
|
| 213 |
+
return self._memory_summary
|
| 214 |
+
|
| 215 |
def get_accumulated_context(self) -> dict:
|
| 216 |
"""Return raw accumulated context dict (used by orchestrator metadata)."""
|
| 217 |
return {
|
|
|
|
| 228 |
|
| 229 |
def reset(self) -> None:
|
| 230 |
"""Clear the session (start fresh)."""
|
| 231 |
+
self.__init__(
|
| 232 |
+
max_turns=self.max_turns,
|
| 233 |
+
summarizer=self._summarizer,
|
| 234 |
+
memory_window=self._memory_window,
|
| 235 |
+
)
|
| 236 |
|
| 237 |
@property
|
| 238 |
def turn_count(self) -> int:
|
src/utils/memory_summarizer.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/utils/memory_summarizer.py
|
| 3 |
+
=================================
|
| 4 |
+
Summarizes the last few conversation turns into a short rolling memory
|
| 5 |
+
note via Groq LLM, so ConversationManager can augment its session context
|
| 6 |
+
with an abstractive summary instead of just accumulated keyword lists.
|
| 7 |
+
|
| 8 |
+
Usage
|
| 9 |
+
-----
|
| 10 |
+
from src.utils.memory_summarizer import MemorySummarizer
|
| 11 |
+
summarizer = MemorySummarizer()
|
| 12 |
+
summary = summarizer.summarize([
|
| 13 |
+
{"role": "user", "content": "I've been anxious about work"},
|
| 14 |
+
{"role": "assistant", "content": "That sounds difficult..."},
|
| 15 |
+
{"role": "user", "content": "It's mostly the deadlines"},
|
| 16 |
+
])
|
| 17 |
+
# "User is anxious about work, mainly due to looming deadlines."
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import logging
|
| 23 |
+
import os
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
_SUMMARY_PROMPT = """Summarize the following snippet of a mental health support \
|
| 29 |
+
conversation in ONE short sentence (max 30 words). Focus on what the user is \
|
| 30 |
+
feeling and dealing with — no preamble, no quotes, just the sentence.
|
| 31 |
+
|
| 32 |
+
{transcript}
|
| 33 |
+
|
| 34 |
+
Summary:"""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class MemorySummarizer:
|
| 38 |
+
"""
|
| 39 |
+
Parameters
|
| 40 |
+
----------
|
| 41 |
+
api_key : Groq API key (defaults to GROQ_API_KEY_SUMMARIZER env var,
|
| 42 |
+
falling back to GROQ_API_KEY if unset).
|
| 43 |
+
model : Groq model name.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
api_key: Optional[str] = None,
|
| 49 |
+
model: str = "llama-3.3-70b-versatile",
|
| 50 |
+
timeout: float = 8.0,
|
| 51 |
+
) -> None:
|
| 52 |
+
self._api_key = (
|
| 53 |
+
api_key
|
| 54 |
+
or os.getenv("GROQ_API_KEY_SUMMARIZER", "")
|
| 55 |
+
or os.getenv("GROQ_API_KEY", "")
|
| 56 |
+
)
|
| 57 |
+
self._model = model
|
| 58 |
+
self._timeout = timeout
|
| 59 |
+
self._client = None
|
| 60 |
+
|
| 61 |
+
def _get_client(self):
|
| 62 |
+
if self._client is None:
|
| 63 |
+
from groq import Groq
|
| 64 |
+
self._client = Groq(api_key=self._api_key, timeout=self._timeout)
|
| 65 |
+
return self._client
|
| 66 |
+
|
| 67 |
+
def _fallback(self, turns: list[dict]) -> str:
|
| 68 |
+
"""Naive concatenation, used when no API key is set or the call fails."""
|
| 69 |
+
parts = [f"{t.get('role', '?')}: {t.get('content', '')}" for t in turns]
|
| 70 |
+
joined = " | ".join(parts)
|
| 71 |
+
return joined[:200]
|
| 72 |
+
|
| 73 |
+
def summarize(self, turns: list[dict]) -> str:
|
| 74 |
+
"""
|
| 75 |
+
Summarize a small window of turns (each {"role": ..., "content": ...}).
|
| 76 |
+
Returns an empty string if turns is empty.
|
| 77 |
+
"""
|
| 78 |
+
if not turns:
|
| 79 |
+
return ""
|
| 80 |
+
|
| 81 |
+
if not self._api_key:
|
| 82 |
+
return self._fallback(turns)
|
| 83 |
+
|
| 84 |
+
transcript = "\n".join(
|
| 85 |
+
f"{t.get('role', '?')}: {t.get('content', '')}" for t in turns
|
| 86 |
+
)
|
| 87 |
+
prompt = _SUMMARY_PROMPT.format(transcript=transcript)
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
resp = self._get_client().chat.completions.create(
|
| 91 |
+
model=self._model,
|
| 92 |
+
messages=[{"role": "user", "content": prompt}],
|
| 93 |
+
max_tokens=60,
|
| 94 |
+
temperature=0.2,
|
| 95 |
+
)
|
| 96 |
+
summary = (resp.choices[0].message.content or "").strip()
|
| 97 |
+
return summary or self._fallback(turns)
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
logger.warning(f"MemorySummarizer failed: {exc}")
|
| 100 |
+
return self._fallback(turns)
|
| 101 |
+
|
| 102 |
+
def __repr__(self) -> str:
|
| 103 |
+
mode = "llm" if self._api_key else "fallback"
|
| 104 |
+
return f"MemorySummarizer(model='{self._model}', mode={mode})"
|