MoEmad07 commited on
Commit
53e0317
·
1 Parent(s): 2297496

Added LLM Classifiers as an option and added session history

Browse files
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
- app.state.lang_detector = LanguageDetector()
23
- app.state.emotion_classifier = EmotionClassifier()
 
 
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(max_turns=10)
 
 
 
 
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 or {}
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(max_turns=10)
 
 
 
 
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.language_detector import LanguageDetector
146
- self._lang_detector = LanguageDetector()
147
  return self._lang_detector
148
 
149
  def _get_emotion_clf(self):
150
  if self._emotion_clf is None:
151
- from src.emotion_classifier import EmotionClassifier
152
- self._emotion_clf = EmotionClassifier()
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__(self, max_turns: int = 10) -> None:
 
 
 
 
 
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__(max_turns=self.max_turns)
 
 
 
 
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})"