Amogh1221 commited on
Commit
86a32a8
·
verified ·
1 Parent(s): f504aba

Delete providers

Browse files
Files changed (47) hide show
  1. providers/__pycache__/bert_risk_signal.cpython-311.pyc +0 -0
  2. providers/__pycache__/bert_risk_signal.cpython-313.pyc +0 -0
  3. providers/__pycache__/cascade_risk_signal.cpython-311.pyc +0 -0
  4. providers/__pycache__/cascade_risk_signal.cpython-313.pyc +0 -0
  5. providers/__pycache__/chroma_DB.cpython-311.pyc +0 -0
  6. providers/__pycache__/chroma_provider.cpython-311.pyc +0 -0
  7. providers/__pycache__/chroma_vector_store.cpython-313.pyc +0 -0
  8. providers/__pycache__/clinical_risk_signal.cpython-311.pyc +0 -0
  9. providers/__pycache__/clinical_risk_signal.cpython-313.pyc +0 -0
  10. providers/__pycache__/fallback_llm_provider.cpython-313.pyc +0 -0
  11. providers/__pycache__/groq_llm_provider.cpython-313.pyc +0 -0
  12. providers/__pycache__/keyword_risk_signal.cpython-311.pyc +0 -0
  13. providers/__pycache__/keyword_risk_signal.cpython-313.pyc +0 -0
  14. providers/__pycache__/llm_risk_signal.cpython-311.pyc +0 -0
  15. providers/__pycache__/llm_risk_signal.cpython-313.pyc +0 -0
  16. providers/__pycache__/ollama_llm_provider.cpython-313.pyc +0 -0
  17. providers/__pycache__/openrouter_llm_provider.cpython-313.pyc +0 -0
  18. providers/__pycache__/pinecone_DB.cpython-311.pyc +0 -0
  19. providers/__pycache__/pinecone_provider.cpython-311.pyc +0 -0
  20. providers/__pycache__/sensevoice_stt_provider.cpython-311.pyc +0 -0
  21. providers/__pycache__/sensevoice_stt_provider.cpython-313.pyc +0 -0
  22. providers/__pycache__/speech_to_text_provider.cpython-311.pyc +0 -0
  23. providers/__pycache__/together_llm_provider.cpython-313.pyc +0 -0
  24. providers/chroma_provider.py +0 -125
  25. providers/llms/__pycache__/fallback_llm_provider.cpython-311.pyc +0 -0
  26. providers/llms/__pycache__/groq_llm_provider.cpython-311.pyc +0 -0
  27. providers/llms/__pycache__/groq_llm_provider.cpython-313.pyc +0 -0
  28. providers/llms/__pycache__/huggingface_llm_provider.cpython-311.pyc +0 -0
  29. providers/llms/__pycache__/huggingface_llm_provider.cpython-313.pyc +0 -0
  30. providers/llms/__pycache__/ollama_llm_provider.cpython-311.pyc +0 -0
  31. providers/llms/__pycache__/openrouter_llm_provider.cpython-311.pyc +0 -0
  32. providers/llms/fallback_llm_provider.py +0 -128
  33. providers/llms/groq_llm_provider.py +0 -283
  34. providers/llms/huggingface_llm_provider.py +0 -260
  35. providers/llms/ollama_llm_provider.py +0 -224
  36. providers/pinecone_provider.py +0 -54
  37. providers/safety/__pycache__/bert.cpython-311.pyc +0 -0
  38. providers/safety/__pycache__/cascade.cpython-311.pyc +0 -0
  39. providers/safety/__pycache__/clinical.cpython-311.pyc +0 -0
  40. providers/safety/__pycache__/keyword.cpython-311.pyc +0 -0
  41. providers/safety/__pycache__/llm.cpython-311.pyc +0 -0
  42. providers/safety/bert.py +0 -58
  43. providers/safety/cascade.py +0 -25
  44. providers/safety/clinical.py +0 -17
  45. providers/safety/keyword.py +0 -102
  46. providers/safety/llm.py +0 -10
  47. providers/speech_to_text_provider.py +0 -106
providers/__pycache__/bert_risk_signal.cpython-311.pyc DELETED
Binary file (2.79 kB)
 
providers/__pycache__/bert_risk_signal.cpython-313.pyc DELETED
Binary file (2.48 kB)
 
providers/__pycache__/cascade_risk_signal.cpython-311.pyc DELETED
Binary file (1.88 kB)
 
providers/__pycache__/cascade_risk_signal.cpython-313.pyc DELETED
Binary file (1.74 kB)
 
providers/__pycache__/chroma_DB.cpython-311.pyc DELETED
Binary file (7.13 kB)
 
providers/__pycache__/chroma_provider.cpython-311.pyc DELETED
Binary file (7.14 kB)
 
providers/__pycache__/chroma_vector_store.cpython-313.pyc DELETED
Binary file (6.06 kB)
 
providers/__pycache__/clinical_risk_signal.cpython-311.pyc DELETED
Binary file (1.35 kB)
 
providers/__pycache__/clinical_risk_signal.cpython-313.pyc DELETED
Binary file (1.24 kB)
 
providers/__pycache__/fallback_llm_provider.cpython-313.pyc DELETED
Binary file (5.21 kB)
 
providers/__pycache__/groq_llm_provider.cpython-313.pyc DELETED
Binary file (9.11 kB)
 
providers/__pycache__/keyword_risk_signal.cpython-311.pyc DELETED
Binary file (4.66 kB)
 
providers/__pycache__/keyword_risk_signal.cpython-313.pyc DELETED
Binary file (4.37 kB)
 
providers/__pycache__/llm_risk_signal.cpython-311.pyc DELETED
Binary file (1.04 kB)
 
providers/__pycache__/llm_risk_signal.cpython-313.pyc DELETED
Binary file (977 Bytes)
 
providers/__pycache__/ollama_llm_provider.cpython-313.pyc DELETED
Binary file (9.78 kB)
 
providers/__pycache__/openrouter_llm_provider.cpython-313.pyc DELETED
Binary file (8.26 kB)
 
providers/__pycache__/pinecone_DB.cpython-311.pyc DELETED
Binary file (3.36 kB)
 
providers/__pycache__/pinecone_provider.cpython-311.pyc DELETED
Binary file (3.37 kB)
 
providers/__pycache__/sensevoice_stt_provider.cpython-311.pyc DELETED
Binary file (5.74 kB)
 
providers/__pycache__/sensevoice_stt_provider.cpython-313.pyc DELETED
Binary file (5.02 kB)
 
providers/__pycache__/speech_to_text_provider.cpython-311.pyc DELETED
Binary file (5.74 kB)
 
providers/__pycache__/together_llm_provider.cpython-313.pyc DELETED
Binary file (8.05 kB)
 
providers/chroma_provider.py DELETED
@@ -1,125 +0,0 @@
1
- import os
2
- from langchain_chroma import Chroma
3
- from langchain_ollama import OllamaEmbeddings
4
- from langchain_community.retrievers import BM25Retriever
5
- from langchain_classic.retrievers import EnsembleRetriever
6
-
7
- class ChromaVectorStore:
8
- """
9
- Concrete implementation of the VectorStore protocol using ChromaDB.
10
- Handles semantic embedding generation and hybrid retrieval (BM25 + MMR)
11
- for pulling clinical context during background analysis.
12
- """
13
- def __init__(self, host: str, embedding_model: str, persist_dir: str, collection_name: str):
14
- self.embeddings = OllamaEmbeddings(
15
- model=embedding_model,
16
- base_url=host,
17
- )
18
-
19
- self.vectorstore = Chroma(
20
- collection_name=collection_name,
21
- embedding_function=self.embeddings,
22
- persist_directory=persist_dir,
23
- )
24
- count = self.vectorstore._collection.count()
25
- if count == 0:
26
- print(
27
- "[RAG WARNING] ChromaDB collection is empty. "
28
- "Run `python build_vectorDB.py` to populate it before starting a session."
29
- )
30
- self.ensemble_retriever = None
31
- else:
32
- print("[RAG] Building BM25 index for Hybrid Retrieval...")
33
- import pickle
34
- bm25_cache_path = os.path.join(persist_dir, "bm25_index.pkl")
35
-
36
- bm25_retriever = None
37
- if os.path.exists(bm25_cache_path):
38
- print("[RAG] Loading BM25 index from local cache...")
39
- try:
40
- with open(bm25_cache_path, "rb") as f:
41
- bm25_retriever = pickle.load(f)
42
- except Exception as e:
43
- print(f"[RAG] Failed to load BM25 cache: {e}. Rebuilding...")
44
- bm25_retriever = None
45
-
46
- if bm25_retriever is None:
47
- print("[RAG] Building BM25 index from ChromaDB (this may take a moment for large databases)...")
48
- # Extract all stored documents in batches to avoid SQLite 'too many SQL variables' error
49
- documents = []
50
- batch_size = 5000
51
- offset = 0
52
- while True:
53
- batch = self.vectorstore.get(limit=batch_size, offset=offset)
54
- batch_docs = batch.get("documents", [])
55
- if not batch_docs:
56
- break
57
- documents.extend(batch_docs)
58
- offset += batch_size
59
-
60
- if documents:
61
- bm25_retriever = BM25Retriever.from_texts(documents)
62
- bm25_retriever.k = min(8, len(documents))
63
-
64
- # Save to cache for future restarts
65
- try:
66
- with open(bm25_cache_path, "wb") as f:
67
- pickle.dump(bm25_retriever, f)
68
- print("[RAG] BM25 index cached successfully.")
69
- except Exception as e:
70
- print(f"[RAG WARNING] Failed to cache BM25 index: {e}")
71
-
72
- if bm25_retriever:
73
- # 2. Semantic Retriever (Chroma MMR)
74
- mmr_retriever = self.vectorstore.as_retriever(
75
- search_type="mmr",
76
- search_kwargs={
77
- "k": bm25_retriever.k,
78
- "fetch_k": max(20, bm25_retriever.k * 3),
79
- "lambda_mult": 0.7
80
- }
81
- )
82
-
83
- # 3. Hybrid Ensemble (50% Lexical, 50% Semantic)
84
- self.ensemble_retriever = EnsembleRetriever(
85
- retrievers=[bm25_retriever, mmr_retriever],
86
- weights=[0.5, 0.5]
87
- )
88
- else:
89
- self.ensemble_retriever = None
90
-
91
- def retrieve(self, query: str, k: int = 8) -> str:
92
- if not query or not query.strip():
93
- return ""
94
-
95
- try:
96
- if self.ensemble_retriever:
97
- results = self.ensemble_retriever.invoke(query)
98
- # EnsembleRetriever returns a list of Document objects
99
- # We limit the final combined output to the top k matches
100
- return "\n\n".join([doc.page_content for doc in results[:k]])
101
-
102
- # Fallback if ensemble isn't ready (e.g. empty DB)
103
- count = self.vectorstore._collection.count()
104
- if count == 0:
105
- return ""
106
-
107
- actual_fetch_k = min(30, count)
108
- actual_k = min(k, actual_fetch_k)
109
-
110
- results = self.vectorstore.max_marginal_relevance_search(
111
- query,
112
- k=actual_k,
113
- fetch_k=actual_fetch_k,
114
- lambda_mult=0.7,
115
- )
116
- return "\n\n".join([doc.page_content for doc in results])
117
-
118
- except Exception as e:
119
- print(f"[RAG ERROR] Hybrid search failed: {e}. Attempting similarity fallback.")
120
- try:
121
- results = self.vectorstore.similarity_search(query, k=k)
122
- return "\n\n".join([doc.page_content for doc in results])
123
- except Exception as e2:
124
- print(f"[RAG ERROR] Similarity fallback also failed: {e2}")
125
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/llms/__pycache__/fallback_llm_provider.cpython-311.pyc DELETED
Binary file (5.79 kB)
 
providers/llms/__pycache__/groq_llm_provider.cpython-311.pyc DELETED
Binary file (9.86 kB)
 
providers/llms/__pycache__/groq_llm_provider.cpython-313.pyc DELETED
Binary file (8.03 kB)
 
providers/llms/__pycache__/huggingface_llm_provider.cpython-311.pyc DELETED
Binary file (8.67 kB)
 
providers/llms/__pycache__/huggingface_llm_provider.cpython-313.pyc DELETED
Binary file (8.32 kB)
 
providers/llms/__pycache__/ollama_llm_provider.cpython-311.pyc DELETED
Binary file (12.3 kB)
 
providers/llms/__pycache__/openrouter_llm_provider.cpython-311.pyc DELETED
Binary file (8.62 kB)
 
providers/llms/fallback_llm_provider.py DELETED
@@ -1,128 +0,0 @@
1
- from core.ports import LLM1Output, LLM2Output, LLM3Output
2
-
3
-
4
- def _is_rate_limit(exc: Exception) -> bool:
5
- """Return True if the exception looks like a quota / rate-limit error."""
6
- msg = str(exc).lower()
7
- return any(k in msg for k in (
8
- "429", "rate limit", "ratelimit", "too many requests",
9
- "quota", "exceeded", "tokens exhausted", "credits"
10
- ))
11
-
12
-
13
- class FallbackLLMProvider:
14
- """
15
- Composite LLM Provider that loops through a list of other providers.
16
- If a provider throws an exception (e.g. rate limit / 429), it tries the next.
17
-
18
- For LLM1 and LLM2 calls:
19
- - If all providers fail with a rate-limit/quota error, the exception is
20
- re-raised so the route can return HTTP 429 and the frontend shows a popup.
21
- - If all providers fail for any other reason, a safe fallback string is returned.
22
-
23
- For non-conversational calls (query, LLM3, summarize), the fallback string is
24
- always returned to avoid breaking secondary features.
25
- """
26
- def __init__(self, providers: list):
27
- self.providers = providers
28
-
29
- def generate_opening_context(self, profile_recap: str | None) -> list:
30
- return self.providers[0].generate_opening_context(profile_recap)
31
-
32
- def psychiatrist_response(
33
- self, context: list, patient_info: dict = None, medium_term_memory: str | None = None
34
- ) -> LLM1Output:
35
- last_error = None
36
- for provider in self.providers:
37
- try:
38
- return provider.psychiatrist_response(context, patient_info, medium_term_memory)
39
- except Exception as e:
40
- last_error = e
41
- print(f"[FallbackRouter] Provider {type(provider).__name__} failed for LLM1: {e}")
42
- continue
43
-
44
- # Surface rate-limit errors so the frontend can show a popup
45
- if last_error and _is_rate_limit(last_error):
46
- print("[FallbackRouter] Rate limit detected — re-raising for LLM1.")
47
- raise RuntimeError(f"Tokens Exhausted: {last_error}") from last_error
48
-
49
- print("[FallbackRouter] ALL providers failed for LLM1!")
50
- return LLM1Output(
51
- assistant_message="I'm here, and I'm listening. Please take your time. && Would you like to tell me more about what's on your mind?",
52
- intent="CONTINUE",
53
- risk_flag=False,
54
- clinical_summary=None
55
- )
56
-
57
- def internal_reasoning(
58
- self, context: list, stable_prefix: str | None = None
59
- ) -> LLM2Output:
60
- last_error = None
61
- for provider in self.providers:
62
- try:
63
- return provider.internal_reasoning(context, stable_prefix)
64
- except Exception as e:
65
- last_error = e
66
- print(f"[FallbackRouter] Provider {type(provider).__name__} failed for LLM2: {e}")
67
- continue
68
-
69
- # Surface rate-limit errors so the orchestrator can propagate them
70
- if last_error and _is_rate_limit(last_error):
71
- print("[FallbackRouter] Rate limit detected — re-raising for LLM2.")
72
- raise RuntimeError(f"Tokens Exhausted: {last_error}") from last_error
73
-
74
- print("[FallbackRouter] ALL providers failed for LLM2!")
75
- return LLM2Output(
76
- assistant_message="I'm here, taking everything in. Please go on.",
77
- emotional_themes=[],
78
- thinking_patterns=[],
79
- behavioral_patterns=[],
80
- interpersonal_dynamics=[],
81
- stressors=[],
82
- unclear_areas=[],
83
- risk_assessment="Unable to complete risk assessment due to analysis error.",
84
- protective_factors=[]
85
- )
86
-
87
- def psychiatrist_query_response(self, context: list, retrieved_context: str) -> str:
88
- for provider in self.providers:
89
- try:
90
- return provider.psychiatrist_query_response(context, retrieved_context)
91
- except Exception as e:
92
- print(f"[FallbackRouter] Provider {type(provider).__name__} failed for Query: {e}")
93
- continue
94
-
95
- return "I hear you, and we will find a way through this together. Let's keep exploring what might help."
96
-
97
- def generate_end_of_session_profile(
98
- self, old_profile: dict, session_history: list, patient_info: dict = None
99
- ) -> LLM3Output:
100
- for provider in self.providers:
101
- try:
102
- return provider.generate_end_of_session_profile(old_profile, session_history, patient_info)
103
- except Exception as e:
104
- print(f"[FallbackRouter] Provider {type(provider).__name__} failed for LLM3: {e}")
105
- continue
106
-
107
- return LLM3Output(
108
- session_summary="Error generating session summary.",
109
- update_profile=False,
110
- emotional_themes=[],
111
- thinking_patterns=[],
112
- behavioral_patterns=[],
113
- interpersonal_dynamics=[],
114
- stressors=[],
115
- unclear_areas=[],
116
- risk_assessment="Error.",
117
- protective_factors=[]
118
- )
119
-
120
- def summarize_history(self, turns: list) -> str:
121
- for provider in self.providers:
122
- try:
123
- return provider.summarize_history(turns)
124
- except Exception as e:
125
- print(f"[FallbackRouter] Provider {type(provider).__name__} failed for Summarize: {e}")
126
- continue
127
-
128
- return "[Summary unavailable]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/llms/groq_llm_provider.py DELETED
@@ -1,283 +0,0 @@
1
- import os
2
- import json
3
- from groq import Groq
4
- from core.ports import LLM1Output, LLM2Output, LLM3Output
5
- from core.prompts import LLM1_SYSTEM_PROMPT, LLM2_SYSTEM_PROMPT, LLM3_SYSTEM_PROMPT
6
-
7
- # ---------------------------------------------------------------------------
8
- # Groq prompt-caching helpers
9
- #
10
- # Groq (beta) caches any prefix up to the LAST message that contains a
11
- # cache_control marker. Multiple markers create cascading cache levels.
12
- #
13
- # Cache levels we exploit:
14
- # Level 1 (all calls): system prompt — constant across all patients/sessions
15
- # Level 2 (LLM1+LLM2): demographics + medium-term memory — constant within
16
- # a session; only changes when LLM2 fires and updates long_term_memory
17
- # Level 3 (dynamic): conversation history + RAG context — changes every turn
18
- # ---------------------------------------------------------------------------
19
-
20
- def _cached_msg(role: str, content: str) -> dict:
21
- """Message with Groq beta cache_control breakpoint."""
22
- return {
23
- "role": role,
24
- "content": [
25
- {
26
- "type": "text",
27
- "text": content,
28
- "cache_control": {"type": "ephemeral"},
29
- }
30
- ],
31
- }
32
-
33
-
34
- def _msg(role: str, content: str) -> dict:
35
- """Plain message (no cache marker)."""
36
- return {"role": role, "content": content}
37
-
38
-
39
- class GroqLLMProvider:
40
- """
41
- Concrete implementation of the LLMProvider protocol using the Groq API.
42
-
43
- Prompt caching strategy (Groq beta cache_control):
44
-
45
- LLM1 psychiatrist_response — two cache levels:
46
- [1] system, cached LLM1_SYSTEM_PROMPT (~700 tokens, never changes)
47
- [2] system, cached demographics + medium-term (~200-400 tokens, stable per session)
48
- memory (long_term_memory recap)
49
- [3] dynamic conversation history (changes every turn)
50
-
51
- LLM2 internal_reasoning — two cache levels:
52
- [1] system, cached LLM2_SYSTEM_PROMPT (~600 tokens, never changes)
53
- [2] user, cached demographics + profile_recap (~200-400 tokens, stable between
54
- (medium-term memory) LLM2 calls within a session)
55
- [3] dynamic history[-6:] + analysis ctx (changes every turn)
56
-
57
- Savings estimate vs. baseline (no caching):
58
- - LLM1: ~1,000-1,100 cached tokens per turn after warm-up
59
- - LLM2: ~1,200-1,400 cached tokens per ANALYZE call
60
- """
61
- def __init__(self, model1: str, model2: str):
62
- self.model1 = model1
63
- self.model2 = model2
64
- self.client = Groq()
65
-
66
- def generate_opening_context(self, profile_recap: str | None) -> list:
67
- if profile_recap:
68
- return [
69
- {
70
- "role": "user",
71
- "content": (
72
- f"{profile_recap}\n\n"
73
- "Start the psychiatric session. Greet the patient warmly, "
74
- "briefly acknowledge continuity if it feels right, then use && "
75
- "to add an open question at the end — e.g. "
76
- "'Welcome back, it's good to see you. && How have things been for you since we last spoke?'"
77
- ),
78
- }
79
- ]
80
- else:
81
- return [
82
- {
83
- "role": "user",
84
- "content": (
85
- "Start the psychiatric session. Greet the patient warmly and naturally, "
86
- "then use && to add an open question at the end — e.g. "
87
- "'Hello, I'm Dr. Aiden. I'm here to listen. && What brings you here today?'"
88
- ),
89
- }
90
- ]
91
-
92
- def psychiatrist_response(
93
- self,
94
- context: list,
95
- patient_info: dict = None,
96
- medium_term_memory: str | None = None,
97
- ) -> LLM1Output:
98
- """
99
- LLM1 fast path. Message order:
100
- [1] system, CACHED LLM1_SYSTEM_PROMPT (constant)
101
- [2] system, CACHED demographics + MTM recap (stable within session)
102
- [3] dynamic conversation history (changes every turn)
103
-
104
- `medium_term_memory` is the long_term_memory recap string built by
105
- the orchestrator from profile_store. It is constant between LLM2 updates.
106
- """
107
- try:
108
- # Level 1: constant system prompt — cached across all patients
109
- messages = [_cached_msg("system", LLM1_SYSTEM_PROMPT)]
110
-
111
- # Level 2: stable patient context — cached within a session
112
- stable_parts = []
113
- if patient_info:
114
- stable_parts.append(
115
- f"PATIENT DEMOGRAPHICS:\n"
116
- f"- Name: {patient_info.get('name', 'Unknown')}\n"
117
- f"- Age: {patient_info.get('age', 'Unknown')}\n"
118
- f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
119
- f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
120
- f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}"
121
- )
122
- if medium_term_memory:
123
- stable_parts.append(
124
- f"PATIENT MEDIUM-TERM MEMORY (prior sessions & patterns — do not quote verbatim):\n"
125
- f"{medium_term_memory}"
126
- )
127
- if stable_parts:
128
- messages.append(_cached_msg("system", "\n\n".join(stable_parts)))
129
-
130
- # Level 3: dynamic conversation history
131
- for m in context:
132
- messages.append(_msg(m["role"], m["content"]))
133
-
134
- response = self.client.chat.completions.create(
135
- model=self.model1,
136
- messages=messages,
137
- response_format={"type": "json_object"},
138
- temperature=0.6,
139
- max_completion_tokens=1024,
140
- )
141
- raw = response.choices[0].message.content
142
- return LLM1Output.model_validate_json(raw)
143
- except Exception as e:
144
- print(f"[Groq LLM1 ERROR] {e}")
145
- raise e
146
-
147
- def internal_reasoning(
148
- self,
149
- context: list,
150
- stable_prefix: str | None = None,
151
- ) -> LLM2Output:
152
- """
153
- LLM2 analysis path. Message order:
154
- [1] system, CACHED LLM2_SYSTEM_PROMPT (constant)
155
- [2] user, CACHED stable_prefix (demographics + profile_recap,
156
- stable between LLM2 calls)
157
- [3] dynamic history[-6:] + analysis ctx (changes every ANALYZE trigger)
158
-
159
- `stable_prefix` is built by the orchestrator and contains demographics +
160
- medium-term memory (profile_recap). It is constant for the lifetime of a
161
- session between LLM2 updates — giving a solid cache hit on the 70b model.
162
- """
163
- try:
164
- # Level 1: constant system prompt
165
- messages = [_cached_msg("system", LLM2_SYSTEM_PROMPT)]
166
-
167
- # Level 2: stable patient context (demographics + profile recap)
168
- if stable_prefix:
169
- messages.append(_cached_msg("user", stable_prefix))
170
-
171
- # Level 3: dynamic history + analysis context
172
- for m in context:
173
- messages.append(_msg(m["role"], m["content"]))
174
-
175
- response = self.client.chat.completions.create(
176
- model=self.model2,
177
- messages=messages,
178
- response_format={"type": "json_object"},
179
- temperature=0.1,
180
- max_completion_tokens=1024,
181
- )
182
- raw = response.choices[0].message.content
183
- return LLM2Output.model_validate_json(raw)
184
- except Exception as e:
185
- print(f"[Groq LLM2 ERROR] {e}")
186
- raise e
187
-
188
- def psychiatrist_query_response(self, context: list, retrieved_context: str) -> str:
189
- try:
190
- sys_prompt = (
191
- "You are a compassionate AI psychiatrist. The patient has asked for advice. "
192
- "Synthesize a thoughtful response using the following clinical guidelines:\n\n"
193
- f"{retrieved_context}\n\n"
194
- "Keep your response empathetic, natural, and under 4 sentences. Do not use markdown."
195
- )
196
- messages = [_msg("system", sys_prompt)]
197
- for m in context:
198
- messages.append(_msg(m["role"], m["content"]))
199
-
200
- response = self.client.chat.completions.create(
201
- model=self.model1,
202
- messages=messages,
203
- temperature=0.5,
204
- max_completion_tokens=512,
205
- )
206
- return response.choices[0].message.content.strip()
207
- except Exception as e:
208
- print(f"[Groq QUERY ERROR] {e}")
209
- raise e
210
-
211
- def generate_end_of_session_profile(
212
- self, old_profile: dict, session_history: list, patient_info: dict = None
213
- ) -> LLM3Output:
214
- try:
215
- # Level 1: constant system prompt — cached across all patients
216
- messages = [_cached_msg("system", LLM3_SYSTEM_PROMPT)]
217
-
218
- # Level 2: patient demographics — small, per-patient, no cache needed
219
- if patient_info:
220
- demographics = (
221
- f"PATIENT DEMOGRAPHICS:\n"
222
- f"- Name: {patient_info.get('name', 'Unknown')}\n"
223
- f"- Age: {patient_info.get('age', 'Unknown')}\n"
224
- f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
225
- f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
226
- f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}"
227
- )
228
- messages.append(_msg("system", demographics))
229
-
230
- # Level 3: dynamic content — old profile + session transcript
231
- formatted_history = "\n".join(
232
- f"{t['role'].upper()}: {t['content']}" for t in session_history
233
- )
234
- user_prompt = (
235
- f"EXISTING PROFILE:\n{old_profile}\n\n"
236
- f"RECENT SESSION TRANSCRIPT:\n{formatted_history}\n\n"
237
- "Please generate the 100-200 word session_summary and the merged, deduplicated clinical profile."
238
- )
239
- messages.append(_msg("user", user_prompt))
240
-
241
- response = self.client.chat.completions.create(
242
- model=self.model1,
243
- messages=messages,
244
- response_format={"type": "json_object"},
245
- temperature=0.2,
246
- max_completion_tokens=1500,
247
- )
248
- raw = response.choices[0].message.content
249
- return LLM3Output.model_validate_json(raw)
250
- except Exception as e:
251
- print(f"[Groq LLM3 ERROR] {e}")
252
- raise e
253
-
254
-
255
- def summarize_history(self, turns: list) -> str:
256
- if not turns:
257
- return ""
258
- try:
259
- formatted = "\n".join(
260
- f"{t['role'].upper()}: {t['content']}" for t in turns
261
- )
262
- messages = [
263
- {
264
- "role": "system",
265
- "content": (
266
- "You are a clinical note-taker. Summarize the following "
267
- "patient-psychiatrist conversation excerpt in 3-5 sentences, "
268
- "third-person clinical register. Capture key symptoms, "
269
- "themes, and any safety concerns. Be concise."
270
- ),
271
- },
272
- {"role": "user", "content": formatted},
273
- ]
274
- response = self.client.chat.completions.create(
275
- model=self.model1,
276
- messages=messages,
277
- temperature=0.3,
278
- max_completion_tokens=300,
279
- )
280
- return response.choices[0].message.content.strip()
281
- except Exception as e:
282
- print(f"[Groq SUMMARIZE ERROR] {e}")
283
- raise e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/llms/huggingface_llm_provider.py DELETED
@@ -1,260 +0,0 @@
1
- import os
2
- import json
3
- from huggingface_hub import InferenceClient
4
- from core.ports import LLM1Output, LLM2Output, LLM3Output
5
- from core.prompts import LLM1_SYSTEM_PROMPT, LLM2_SYSTEM_PROMPT, LLM3_SYSTEM_PROMPT
6
-
7
-
8
- def _msg(role: str, content: str) -> dict:
9
- return {"role": role, "content": content}
10
-
11
-
12
- class HuggingFaceLLMProvider:
13
- """
14
- Concrete implementation of the LLMProvider protocol using the HuggingFace
15
- Serverless Inference API (api-inference.huggingface.co).
16
-
17
- Requires HF_TOKEN set as a Space Secret. Models used:
18
- LLM1 / LLM3 : meta-llama/Llama-3.1-8B-Instruct (fast, free tier)
19
- LLM2 : meta-llama/Llama-3.3-70B-Instruct (deep analysis)
20
-
21
- NOTE: HF Inference API does NOT support prompt caching, so the
22
- stable_prefix and medium_term_memory params are merged into regular
23
- system messages instead.
24
- """
25
-
26
- def __init__(self, model1: str, model2: str):
27
- token = os.environ.get("HF_TOKEN")
28
- if not token:
29
- raise RuntimeError(
30
- "HF_TOKEN is not set. Add it as a Space Secret on HuggingFace."
31
- )
32
- self.model1 = model1
33
- self.model2 = model2
34
- # Single client — the model is passed per-call so one client is enough
35
- self.client = InferenceClient(api_key=token)
36
-
37
- # ------------------------------------------------------------------
38
- # Internal helpers
39
- # ------------------------------------------------------------------
40
- def _build_stable_system(
41
- self, patient_info: dict | None, medium_term_memory: str | None
42
- ) -> str | None:
43
- """Build the demographics + MTM system block (replaces Groq's cache_control)."""
44
- parts = []
45
- if patient_info:
46
- parts.append(
47
- f"PATIENT DEMOGRAPHICS:\n"
48
- f"- Name: {patient_info.get('name', 'Unknown')}\n"
49
- f"- Age: {patient_info.get('age', 'Unknown')}\n"
50
- f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
51
- f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
52
- f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}"
53
- )
54
- if medium_term_memory:
55
- parts.append(
56
- f"PATIENT MEDIUM-TERM MEMORY (prior sessions & patterns — do not quote verbatim):\n"
57
- f"{medium_term_memory}"
58
- )
59
- return "\n\n".join(parts) if parts else None
60
-
61
- def _call(self, model: str, messages: list, temperature: float, max_tokens: int) -> str:
62
- response = self.client.chat.completions.create(
63
- model=model,
64
- messages=messages,
65
- response_format={"type": "json_object"},
66
- temperature=temperature,
67
- max_tokens=max_tokens,
68
- )
69
- return response.choices[0].message.content
70
-
71
- # ------------------------------------------------------------------
72
- # Schema repair helpers — HF models sometimes omit fields from the
73
- # JSON schema. We inject sensible defaults before Pydantic validates.
74
- # ------------------------------------------------------------------
75
- @staticmethod
76
- def _parse_llm1(raw: str) -> LLM1Output:
77
- d = json.loads(raw)
78
- d.setdefault("intent", "CONTINUE")
79
- d.setdefault("risk_flag", False)
80
- d.setdefault("clinical_summary", None)
81
- d.setdefault("search_query", None)
82
- return LLM1Output.model_validate(d)
83
-
84
- @staticmethod
85
- def _parse_llm2(raw: str) -> LLM2Output:
86
- d = json.loads(raw)
87
- d.setdefault("assistant_message", "I'm here with you. Please continue.")
88
- d.setdefault("emotional_themes", [])
89
- d.setdefault("thinking_patterns", [])
90
- d.setdefault("behavioral_patterns", [])
91
- d.setdefault("interpersonal_dynamics", [])
92
- d.setdefault("stressors", [])
93
- d.setdefault("unclear_areas", [])
94
- d.setdefault("risk_assessment", "No immediate risk identified.")
95
- d.setdefault("protective_factors", [])
96
- return LLM2Output.model_validate(d)
97
-
98
- @staticmethod
99
- def _parse_llm3(raw: str) -> LLM3Output:
100
- d = json.loads(raw)
101
- d.setdefault("session_summary", "Session completed.")
102
- d.setdefault("update_profile", True)
103
- d.setdefault("emotional_themes", [])
104
- d.setdefault("thinking_patterns", [])
105
- d.setdefault("behavioral_patterns", [])
106
- d.setdefault("interpersonal_dynamics", [])
107
- d.setdefault("stressors", [])
108
- d.setdefault("unclear_areas", [])
109
- d.setdefault("risk_assessment", "No immediate risk identified.")
110
- d.setdefault("protective_factors", [])
111
- d.setdefault("updated_primary_concern", None)
112
- return LLM3Output.model_validate(d)
113
-
114
- def generate_opening_context(self, profile_recap: str | None) -> list:
115
- if profile_recap:
116
- return [
117
- {
118
- "role": "user",
119
- "content": (
120
- f"{profile_recap}\n\n"
121
- "Start the psychiatric session. Greet the patient warmly, "
122
- "briefly acknowledge continuity if it feels right, then use && "
123
- "to add an open question at the end — e.g. "
124
- "'Welcome back, it's good to see you. && How have things been for you since we last spoke?'"
125
- ),
126
- }
127
- ]
128
- else:
129
- return [
130
- {
131
- "role": "user",
132
- "content": (
133
- "Start the psychiatric session. Greet the patient warmly and naturally, "
134
- "then use && to add an open question at the end — e.g. "
135
- "'Hello, I'm Dr. Aiden. I'm here to listen. && What brings you here today?'"
136
- ),
137
- }
138
- ]
139
-
140
- def psychiatrist_response(
141
- self,
142
- context: list,
143
- patient_info: dict = None,
144
- medium_term_memory: str | None = None,
145
- ) -> LLM1Output:
146
- try:
147
- messages = [_msg("system", LLM1_SYSTEM_PROMPT)]
148
- stable = self._build_stable_system(patient_info, medium_term_memory)
149
- if stable:
150
- messages.append(_msg("system", stable))
151
- for m in context:
152
- messages.append(_msg(m["role"], m["content"]))
153
-
154
- raw = self._call(self.model1, messages, temperature=0.6, max_tokens=1024)
155
- return self._parse_llm1(raw)
156
- except Exception as e:
157
- print(f"[HF LLM1 ERROR] {e}")
158
- raise e
159
-
160
- def internal_reasoning(
161
- self,
162
- context: list,
163
- stable_prefix: str | None = None,
164
- ) -> LLM2Output:
165
- try:
166
- messages = [_msg("system", LLM2_SYSTEM_PROMPT)]
167
- # stable_prefix = demographics + profile recap (built by orchestrator)
168
- if stable_prefix:
169
- messages.append(_msg("user", stable_prefix))
170
- for m in context:
171
- messages.append(_msg(m["role"], m["content"]))
172
-
173
- raw = self._call(self.model2, messages, temperature=0.1, max_tokens=1024)
174
- return self._parse_llm2(raw)
175
- except Exception as e:
176
- print(f"[HF LLM2 ERROR] {e}")
177
- raise e
178
-
179
- def psychiatrist_query_response(self, context: list, retrieved_context: str) -> str:
180
- try:
181
- sys_prompt = (
182
- "You are a compassionate AI psychiatrist. The patient has asked for advice. "
183
- "Synthesize a thoughtful response using the following clinical guidelines:\n\n"
184
- f"{retrieved_context}\n\n"
185
- "Keep your response empathetic, natural, and under 4 sentences. Do not use markdown."
186
- )
187
- messages = [_msg("system", sys_prompt)]
188
- for m in context:
189
- messages.append(_msg(m["role"], m["content"]))
190
-
191
- response = self.client.chat.completions.create(
192
- model=self.model1,
193
- messages=messages,
194
- temperature=0.5,
195
- max_tokens=512,
196
- )
197
- return response.choices[0].message.content.strip()
198
- except Exception as e:
199
- print(f"[HF QUERY ERROR] {e}")
200
- raise e
201
-
202
- def generate_end_of_session_profile(
203
- self, old_profile: dict, session_history: list, patient_info: dict = None
204
- ) -> LLM3Output:
205
- try:
206
- messages = [_msg("system", LLM3_SYSTEM_PROMPT)]
207
- if patient_info:
208
- demographics = (
209
- f"PATIENT DEMOGRAPHICS:\n"
210
- f"- Name: {patient_info.get('name', 'Unknown')}\n"
211
- f"- Age: {patient_info.get('age', 'Unknown')}\n"
212
- f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
213
- f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
214
- f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}"
215
- )
216
- messages.append(_msg("system", demographics))
217
-
218
- formatted_history = "\n".join(
219
- f"{t['role'].upper()}: {t['content']}" for t in session_history
220
- )
221
- user_prompt = (
222
- f"EXISTING PROFILE:\n{old_profile}\n\n"
223
- f"RECENT SESSION TRANSCRIPT:\n{formatted_history}\n\n"
224
- "Please generate the 100-200 word session_summary and the merged, deduplicated clinical profile."
225
- )
226
- messages.append(_msg("user", user_prompt))
227
-
228
- raw = self._call(self.model1, messages, temperature=0.2, max_tokens=1500)
229
- return self._parse_llm3(raw)
230
- except Exception as e:
231
- print(f"[HF LLM3 ERROR] {e}")
232
- raise e
233
-
234
- def summarize_history(self, turns: list) -> str:
235
- if not turns:
236
- return ""
237
- try:
238
- formatted = "\n".join(
239
- f"{t['role'].upper()}: {t['content']}" for t in turns
240
- )
241
- messages = [
242
- _msg(
243
- "system",
244
- "You are a clinical note-taker. Summarize the following "
245
- "patient-psychiatrist conversation excerpt in 3-5 sentences, "
246
- "third-person clinical register. Capture key symptoms, "
247
- "themes, and any safety concerns. Be concise.",
248
- ),
249
- _msg("user", formatted),
250
- ]
251
- response = self.client.chat.completions.create(
252
- model=self.model1,
253
- messages=messages,
254
- temperature=0.3,
255
- max_tokens=300,
256
- )
257
- return response.choices[0].message.content.strip()
258
- except Exception as e:
259
- print(f"[HF SUMMARIZE ERROR] {e}")
260
- raise e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/llms/ollama_llm_provider.py DELETED
@@ -1,224 +0,0 @@
1
- import os
2
- import threading
3
- import ollama
4
- from core.ports import LLM1Output, LLM2Output, LLM3Output
5
- from core.prompts import LLM1_SYSTEM_PROMPT, LLM2_SYSTEM_PROMPT, LLM3_SYSTEM_PROMPT
6
-
7
- class OllamaLLMProvider:
8
- """
9
- Concrete implementation of the LLMProvider protocol using local Ollama models.
10
- Provides the engine for both the fast conversational responses (LLM1)
11
- and the deep background pattern analysis (LLM2).
12
- """
13
- def __init__(self, host: str, model1: str, model2: str):
14
- self.host = host
15
- self.model1 = model1
16
- self.model2 = model2
17
- # Global lock to queue requests and prevent CUDA out-of-memory errors
18
- self._llm_lock = threading.Lock()
19
-
20
- def _client(self):
21
- return ollama.Client(host=self.host)
22
-
23
- def generate_opening_context(self, profile_recap: str | None) -> list:
24
- if profile_recap:
25
- return [
26
- {
27
- "role": "user",
28
- "content": (
29
- f"{profile_recap}\n\n"
30
- "Start the psychiatric session. Greet the patient warmly, "
31
- "briefly acknowledge continuity if it feels right, then use && "
32
- "to add an open question at the end — e.g. "
33
- "'Welcome back, it's good to see you. && How have things been for you since we last spoke?'"
34
- ),
35
- }
36
- ]
37
- else:
38
- return [
39
- {
40
- "role": "user",
41
- "content": (
42
- "Start the psychiatric session. Greet the patient warmly and naturally, "
43
- "then use && to add an open question at the end — e.g. "
44
- "'Hello, I'm Dr. Aiden. I'm here to listen. && What brings you here today?'"
45
- ),
46
- }
47
- ]
48
-
49
- def psychiatrist_response(self, context: list, patient_info: dict = None, medium_term_memory: str | None = None) -> LLM1Output:
50
- try:
51
- sys_prompt = LLM1_SYSTEM_PROMPT
52
- stable_parts = []
53
- if patient_info:
54
- stable_parts.append(
55
- f"PATIENT DEMOGRAPHICS:\n"
56
- f"- Name: {patient_info.get('name', 'Unknown')}\n"
57
- f"- Age: {patient_info.get('age', 'Unknown')}\n"
58
- f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
59
- f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
60
- f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}"
61
- )
62
- if medium_term_memory:
63
- stable_parts.append(
64
- f"PATIENT MEDIUM-TERM MEMORY (prior sessions & patterns — do not quote verbatim):\n"
65
- f"{medium_term_memory}"
66
- )
67
- if stable_parts:
68
- sys_prompt = "\n\n".join(stable_parts) + "\n\n" + sys_prompt
69
-
70
- messages = [{"role": "system", "content": sys_prompt}]
71
- for m in context:
72
- messages.append({"role": m["role"], "content": m["content"]})
73
-
74
- with self._llm_lock:
75
- response = self._client().chat(
76
- model=self.model1,
77
- messages=messages,
78
- format=LLM1Output.model_json_schema(),
79
- options={"temperature": 0.6, "num_predict": 1024, "num_ctx": 8192},
80
- )
81
- raw = response.message.content
82
- return LLM1Output.model_validate_json(raw)
83
- except Exception as e:
84
- print(f"[LLM1 ERROR] {e}")
85
- return LLM1Output(
86
- assistant_message="I'm here, and I'm listening. Please take your time. && Would you like to tell me more about what's on your mind?",
87
- intent="CONTINUE",
88
- risk_flag=False,
89
- clinical_summary=None
90
- )
91
-
92
- def internal_reasoning(self, context: list, stable_prefix: str | None = None) -> LLM2Output:
93
- try:
94
- messages = [{"role": "system", "content": LLM2_SYSTEM_PROMPT}]
95
- # For Ollama there is no caching concept; merge stable_prefix as a user message
96
- if stable_prefix:
97
- messages.append({"role": "user", "content": stable_prefix})
98
- for m in context:
99
- messages.append({"role": m["role"], "content": m["content"]})
100
-
101
- with self._llm_lock:
102
- response = self._client().chat(
103
- model=self.model2,
104
- messages=messages,
105
- format=LLM2Output.model_json_schema(),
106
- options={"temperature": 0.1, "num_predict": 1024, "num_ctx": 8192},
107
- keep_alive=0,
108
- )
109
- raw = response.message.content
110
- return LLM2Output.model_validate_json(raw)
111
- except Exception as e:
112
- print(f"[LLM2 ERROR] {e}")
113
- return LLM2Output(
114
- assistant_message="I'm here, taking everything in. Please go on.",
115
- emotional_themes=[],
116
- thinking_patterns=[],
117
- behavioral_patterns=[],
118
- interpersonal_dynamics=[],
119
- stressors=[],
120
- unclear_areas=[],
121
- risk_assessment="Unable to complete risk assessment due to analysis error.",
122
- protective_factors=[]
123
- )
124
-
125
- def psychiatrist_query_response(self, context: list, retrieved_context: str) -> str:
126
- try:
127
- sys_prompt = (
128
- "You are a compassionate AI psychiatrist. The patient has asked for advice. "
129
- "Synthesize a thoughtful response using the following clinical guidelines:\n\n"
130
- f"{retrieved_context}\n\n"
131
- "Keep your response empathetic, natural, and under 4 sentences. Do not use markdown."
132
- )
133
- messages = [{"role": "system", "content": sys_prompt}]
134
- for m in context:
135
- messages.append({"role": m["role"], "content": m["content"]})
136
-
137
- with self._llm_lock:
138
- response = self._client().chat(
139
- model=self.model1,
140
- messages=messages,
141
- options={"temperature": 0.5, "num_predict": 512, "num_ctx": 8192},
142
- )
143
- return response.message.content.strip()
144
- except Exception as e:
145
- print(f"[QUERY ERROR] {e}")
146
- return "I hear you, and we will find a way through this together. Let's keep exploring what might help."
147
-
148
- def generate_end_of_session_profile(self, old_profile: dict, session_history: list, patient_info: dict = None) -> LLM3Output:
149
- try:
150
- sys_prompt = LLM3_SYSTEM_PROMPT
151
- if patient_info:
152
- demographics = (
153
- f"PATIENT DEMOGRAPHICS:\n"
154
- f"- Name: {patient_info.get('name', 'Unknown')}\n"
155
- f"- Age: {patient_info.get('age', 'Unknown')}\n"
156
- f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
157
- f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
158
- f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}\n\n"
159
- )
160
- sys_prompt = demographics + sys_prompt
161
-
162
- formatted_history = "\n".join(f"{t['role'].upper()}: {t['content']}" for t in session_history)
163
- user_prompt = (
164
- f"EXISTING PROFILE:\n{old_profile}\n\n"
165
- f"RECENT SESSION TRANSCRIPT:\n{formatted_history}\n\n"
166
- "Please generate the 100-200 word session_summary and the merged, deduplicated clinical profile."
167
- )
168
- messages = [
169
- {"role": "system", "content": sys_prompt},
170
- {"role": "user", "content": user_prompt}
171
- ]
172
-
173
- with self._llm_lock:
174
- response = self._client().chat(
175
- model=self.model1,
176
- messages=messages,
177
- format=LLM3Output.model_json_schema(),
178
- options={"temperature": 0.2, "num_predict": 1500, "num_ctx": 8192},
179
- )
180
- raw = response.message.content
181
- return LLM3Output.model_validate_json(raw)
182
- except Exception as e:
183
- print(f"[LLM3 ERROR] {e}")
184
- return LLM3Output(
185
- session_summary="Error generating session summary.",
186
- emotional_themes=[],
187
- thinking_patterns=[],
188
- behavioral_patterns=[],
189
- interpersonal_dynamics=[],
190
- stressors=[],
191
- unclear_areas=[],
192
- risk_assessment="Error.",
193
- protective_factors=[]
194
- )
195
-
196
- def summarize_history(self, turns: list) -> str:
197
- if not turns:
198
- return ""
199
- try:
200
- formatted = "\n".join(
201
- f"{t['role'].upper()}: {t['content']}" for t in turns
202
- )
203
- messages = [
204
- {
205
- "role": "system",
206
- "content": (
207
- "You are a clinical note-taker. Summarize the following "
208
- "patient-psychiatrist conversation excerpt in 3-5 sentences, "
209
- "third-person clinical register. Capture key symptoms, "
210
- "themes, and any safety concerns. Be concise."
211
- ),
212
- },
213
- {"role": "user", "content": formatted},
214
- ]
215
- with self._llm_lock:
216
- response = self._client().chat(
217
- model=self.model1,
218
- messages=messages,
219
- options={"temperature": 0.3, "num_predict": 300, "num_ctx": 8192},
220
- )
221
- return response.message.content.strip()
222
- except Exception as e:
223
- print(f"[SUMMARIZE ERROR] {e}")
224
- return "[Summary unavailable]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/pinecone_provider.py DELETED
@@ -1,54 +0,0 @@
1
- import os
2
- from langchain_pinecone import PineconeVectorStore as LangchainPinecone
3
- from langchain_ollama import OllamaEmbeddings
4
- from pinecone import Pinecone
5
-
6
- class PineconeVectorStore:
7
- """
8
- Concrete implementation of the VectorStore protocol using Pinecone.
9
- Handles semantic embedding generation and retrieval for pulling clinical context.
10
- """
11
- def __init__(self, host: str, embedding_model: str, index_name: str, api_key: str):
12
- self.embeddings = OllamaEmbeddings(
13
- model=embedding_model,
14
- base_url=host,
15
- )
16
-
17
- # Initialize the Pinecone SDK client
18
- self.pc = Pinecone(api_key=api_key)
19
-
20
- # We assume the index already exists.
21
- # If it doesn't, this will throw an error when accessed.
22
- self.index = self.pc.Index(index_name)
23
-
24
- self.vectorstore = LangchainPinecone(
25
- index=self.index,
26
- embedding=self.embeddings,
27
- text_key="text" # The default text metadata key used by langchain
28
- )
29
-
30
- # Note: Pinecone doesn't naturally support local BM25/hybrid search
31
- # in the exact same way as local Chroma without serverless Pinecone BM25 integrations.
32
- # We will use pure semantic search (Max Marginal Relevance or Similarity).
33
-
34
- def retrieve(self, query: str, k: int = 8) -> str:
35
- if not query or not query.strip():
36
- return ""
37
-
38
- try:
39
- # First try Max Marginal Relevance search for diverse results
40
- results = self.vectorstore.max_marginal_relevance_search(
41
- query,
42
- k=k,
43
- fetch_k=max(20, k * 3),
44
- lambda_mult=0.7,
45
- )
46
- return "\n\n".join([doc.page_content for doc in results])
47
- except Exception as e:
48
- print(f"[RAG ERROR] MMR search failed in Pinecone: {e}. Attempting standard similarity fallback.")
49
- try:
50
- results = self.vectorstore.similarity_search(query, k=k)
51
- return "\n\n".join([doc.page_content for doc in results])
52
- except Exception as e2:
53
- print(f"[RAG ERROR] Similarity fallback also failed in Pinecone: {e2}")
54
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/safety/__pycache__/bert.cpython-311.pyc DELETED
Binary file (3.27 kB)
 
providers/safety/__pycache__/cascade.cpython-311.pyc DELETED
Binary file (1.88 kB)
 
providers/safety/__pycache__/clinical.cpython-311.pyc DELETED
Binary file (1.34 kB)
 
providers/safety/__pycache__/keyword.cpython-311.pyc DELETED
Binary file (4.66 kB)
 
providers/safety/__pycache__/llm.cpython-311.pyc DELETED
Binary file (1.04 kB)
 
providers/safety/bert.py DELETED
@@ -1,58 +0,0 @@
1
- from typing import Optional
2
- from transformers import pipeline
3
- import logging
4
-
5
- from core.ports import LLM1Output, LLM2Output
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
- class BertRiskSignal:
10
- """
11
- NLP-based risk signal using a fine-tuned BERT model.
12
- Downloads and caches the model on initial run to the HF_HOME directory.
13
- Checks the patient's raw message for suicidal/self-harm ideation
14
- with much higher contextual accuracy than regex keywords.
15
- """
16
- def __init__(self, model_name: str = "Akashpaul123/bert-suicide-detection"):
17
- logger.info(f"Loading BertRiskSignal with model: {model_name}.")
18
- # Use local_files_only=True to skip HuggingFace's network version-check on startup.
19
- # This prevents 504 timeouts when the hub is unreachable or slow.
20
- # On first run (no cache), this will fail and fall back to downloading automatically.
21
- try:
22
- self.classifier = pipeline(
23
- "text-classification",
24
- model=model_name,
25
- truncation=True,
26
- max_length=512,
27
- local_files_only=True,
28
- )
29
- logger.info("BertRiskSignal loaded from local cache.")
30
- except Exception:
31
- logger.info("Local cache not found. Downloading BERT model from HuggingFace (first-time setup)...")
32
- self.classifier = pipeline(
33
- "text-classification",
34
- model=model_name,
35
- truncation=True,
36
- max_length=512,
37
- )
38
- logger.info("BertRiskSignal downloaded and loaded successfully.")
39
-
40
- def check(self, message: str, llm1_output: LLM1Output, llm2_output: Optional[LLM2Output] = None) -> bool:
41
- if not message or not message.strip():
42
- return False
43
-
44
- try:
45
- result = self.classifier(message)
46
- label = result[0]['label'].lower()
47
- score = result[0]['score']
48
-
49
- logger.info(f"BERT raw output for '{message}': {label} (score: {score:.3f})")
50
-
51
- # Outputs 'label_1' for suicide/risk & 'label_0' for non-suicide
52
- if "label_1" in label:
53
- return True
54
-
55
- return False
56
- except Exception as e:
57
- logger.error(f"Error in BertRiskSignal: {e}")
58
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/safety/cascade.py DELETED
@@ -1,25 +0,0 @@
1
- from typing import Optional
2
- from core.ports import RiskSignal, LLM1Output, LLM2Output
3
- import logging
4
-
5
- logger = logging.getLogger(__name__)
6
-
7
- class CascadeRiskSignal:
8
- """
9
- Implements a Filter-Verify (Cascade) architecture for risk signals.
10
- Passes the message through a high-speed, high-recall filter first.
11
- Only if the filter triggers does it pass the message to a slower, high-precision verifier.
12
- """
13
- def __init__(self, filter_signal: RiskSignal, verify_signal: RiskSignal):
14
- self.filter_signal = filter_signal
15
- self.verify_signal = verify_signal
16
-
17
- def check(self, message: str, llm1_output: LLM1Output, llm2_output: Optional[LLM2Output] = None) -> bool:
18
- # 1. FAST PASS
19
- if not self.filter_signal.check(message, llm1_output, llm2_output):
20
- return False
21
-
22
- logger.info("Fast-pass filter triggered. Verifying with secondary signal...")
23
-
24
- # 2. SLOW PASS (Verification)
25
- return self.verify_signal.check(message, llm1_output, llm2_output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/safety/clinical.py DELETED
@@ -1,17 +0,0 @@
1
- from typing import Optional
2
- from core.ports import LLM1Output, LLM2Output
3
-
4
- class ClinicalRiskSignal:
5
- """
6
- Risk signal that triggers based on the 'risk_assessment' output from the
7
- background LLM2 pattern analysis.
8
- """
9
- def check(self, message: str, llm1_output: LLM1Output, llm2_output: Optional[LLM2Output] = None) -> bool:
10
- if llm2_output is None:
11
- return False
12
- assessment = llm2_output.risk_assessment.strip().lower()
13
- if assessment.startswith("no safety concerns identified"):
14
- return False
15
- if assessment == "":
16
- return False
17
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/safety/keyword.py DELETED
@@ -1,102 +0,0 @@
1
- import re
2
- from typing import Optional
3
- from core.ports import LLM1Output, LLM2Output
4
-
5
- # ---------------------------------------------------------------------------
6
- # Deterministic, LLM-independent risk patterns.
7
- # Biased toward false positives — the cost of over-triggering (showing crisis
8
- # resources unnecessarily) is low; the cost of missing a real signal is high.
9
- #
10
- # This list was built and tested against a real conversation log where the
11
- # LLM-only signal (risk_flag) missed several explicit statements across
12
- # consecutive turns. Keeping this signal independent of any LLM call means
13
- # it can't fail for the same reason the model failed.
14
- # ---------------------------------------------------------------------------
15
- _RISK_PATTERNS = [
16
- # --- Direct desire to die / end one's life ---
17
- r"\b(want|wanted|wanna|going to|gonna|thinking about|think about|thought about|considering|considered|planning to|plan to)\b.{0,40}\b(kill|end|take)\b.{0,20}\b(myself|my life|it all)\b",
18
- r"\b(want|wanted|wanna|going to|gonna)\b.{0,15}\b(die|death)\b", # "wanna die", "going to die"
19
- r"\b(end(ing)? it all)\b",
20
- r"\b(suicid(e|al)|self.harm|self-harm|selfharm)\b",
21
- r"\b(don'?t want to (be here|live|exist|wake up))\b",
22
- r"\b(no (reason|point|purpose) (to|in) (live|living|go on|continue))\b",
23
- r"\b(better off (dead|without me|if i (were|was) gone))\b",
24
- r"\b(end (my life|everything|my pain|my suffering))\b",
25
- r"\b(can'?t (go on|take it|do this) anymore)\b",
26
- r"\b(wish(ing)? (i was|i were|to be) dead\b)",
27
- r"\b(plan(ning)? to (die|kill|hurt) (myself|my self))\b",
28
-
29
- # --- Specific means / methods, standalone — do NOT require a lead-in verb
30
- # since people phrase these many ways ---
31
- r"\b(kill(ing)?|hang(ing)?|hung)\b.{0,10}\bmyself\b",
32
- r"\bjump(ing)?\s+(off|from)\b",
33
- r"\b(cut(ting)?|burn(ing)?|starv(e|ing)|hitt?ing|punch(ing)?)\b.{0,10}\b(myself|my self)\b",
34
- r"\bhurt(ing)?\s+(myself|my self)\b",
35
- r"\boverdose|od'?ing\b",
36
- r"\bswallow(ing)?\s+(all\s+)?(the\s+)?pills\b",
37
-
38
- # --- Passive ideation / hopelessness ---
39
- r"\bwhat(’|'|)s?\s+the point( of (living|anything|life))?\b",
40
- r"\b(no (hope|future|reason to (stay|continue|keep going)))\b",
41
- r"\b(life (is|isn'?t) worth (living|it))\b",
42
- r"\b(just want (to disappear|it to end|to stop existing))\b",
43
- r"\b(everyone (would be|is) better off without me)\b",
44
- r"\b(giving|given|gave|give)\s+up\s+on\s+(life|everything)\b", # any tense, requires "on life/everything" to cut false positives like "gave up my seat"
45
- r"\b(too (tired|exhausted) to (go on|continue|fight))\b",
46
-
47
- # --- Self-harm urges ---
48
- r"\b(urge(s)? to (hurt|harm|cut|burn))\b",
49
-
50
- # --- Explicit direct statements ---
51
- r"\bi (don'?t|do not) (want|deserve) to (live|be alive)\b",
52
- r"\b(thinking about|thought about) (dying|death|not being here|ending it)\b",
53
-
54
- # --- Farewell / final-arrangement language — a distinct warning-sign
55
- # category in clinical suicide-risk screening (alongside direct
56
- # ideation), not just another phrasing of "I want to die" ---
57
- r"\b(this is (my )?goodbye|saying goodbye|say my goodbyes)\b",
58
- r"\b(won'?t|wont) be (around|here) (much longer|for long|anymore)\b",
59
- r"\b(take care of|look after) (my|the) (dog|cat|kids|children|family)\b.{0,40}\b(when|after) i'?m gone\b",
60
- r"\bnot (going to|gonna) be (a problem|around) (for )?much longer\b",
61
- r"\bwon'?t (have to|need to) worry about me (much longer|soon|anymore)\b",
62
-
63
- # --- Burden language — very common phrasing in real disclosures ---
64
- r"\b(such a |such)?burden (to|on) (everyone|my family|everybody|people)\b",
65
- r"\btired of being a burden\b",
66
-
67
- # --- Evasive slang / obfuscated spellings, used specifically to get
68
- # past literal-word filters — worth matching for exactly that reason ---
69
- r"\b(kms|kys)\b",
70
- r"\bunaliv(e|ing|ed)\b",
71
- r"\bsewerslide\b", # documented deliberate misspelling of "suicide"
72
-
73
- # --- Planning / preparation and access to means, kept at the level of
74
- # intent-language rather than naming specific objects, so this adds
75
- # coverage without turning into a methods list ---
76
- r"\b(have a plan|got a plan)\b",
77
- r"\b(pills|gun|rope|knife)\s+ready\b",
78
- r"\b(have|got|access to) (the means|a way) to (do it|end (it|things|my life))\b",
79
- ]
80
-
81
- _COMPILED = [re.compile(p, re.IGNORECASE | re.DOTALL) for p in _RISK_PATTERNS]
82
-
83
-
84
- class KeywordRiskSignal:
85
- """
86
- Deterministic, regex-based risk signal. Runs independently of any LLM —
87
- checks only the raw patient message text. This is intentionally the
88
- fastest and most "dumb" signal in the RiskAssessmentService: it can't be
89
- talked out of firing, doesn't depend on model quality or prompt
90
- adherence, and doesn't require a network call.
91
-
92
- Known limitation (accepted, not a bug): purely contextual pleas — e.g.
93
- "don't you think you should stop me by helping me?" — carry no risk
94
- vocabulary in isolation and won't match here. That class of statement is
95
- exactly what LLMRiskSignal (which sees conversation history) is for.
96
- Treat the two signals as complementary, not redundant.
97
- """
98
-
99
- def check(self, message: str, llm1_output: LLM1Output, llm2_output: Optional[LLM2Output] = None) -> bool:
100
- if not message or not message.strip():
101
- return False
102
- return any(pattern.search(message) for pattern in _COMPILED)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/safety/llm.py DELETED
@@ -1,10 +0,0 @@
1
- from typing import Optional
2
- from core.ports import LLM1Output, LLM2Output
3
-
4
- class LLMRiskSignal:
5
- """
6
- Risk signal that triggers based on the 'risk_flag' explicitly set by LLM1
7
- during its generation of the conversational response.
8
- """
9
- def check(self, message: str, llm1_output: LLM1Output, llm2_output: Optional[LLM2Output] = None) -> bool:
10
- return llm1_output.risk_flag
 
 
 
 
 
 
 
 
 
 
 
providers/speech_to_text_provider.py DELETED
@@ -1,106 +0,0 @@
1
- import io
2
- import re
3
- import tempfile
4
- from typing import Dict, Any
5
-
6
- _EMOTION_MAP = {
7
- "HAPPY": "happy",
8
- "SAD": "sad",
9
- "ANGRY": "angry",
10
- "NEUTRAL": "neutral",
11
- "FEARFUL": "fearful",
12
- "DISGUSTED": "disgusted",
13
- "SURPRISED": "surprised",
14
- }
15
- _KNOWN_EVENTS = {"Speech", "Laughter", "Cry", "Cough", "Sneeze", "Breath", "Noise"}
16
- _TAG_RE = re.compile(r"<\|([^|]+)\|>")
17
-
18
- def _parse_sensevoice_output(raw_text: str) -> tuple[str, str, str | None]:
19
- tags = _TAG_RE.findall(raw_text)
20
- clean = _TAG_RE.sub("", raw_text).strip()
21
-
22
- emotion = "neutral"
23
- event: str | None = None
24
-
25
- for tag in tags:
26
- upper = tag.upper()
27
- if upper in _EMOTION_MAP:
28
- emotion = _EMOTION_MAP[upper]
29
- if tag in _KNOWN_EVENTS:
30
- event = tag
31
-
32
- return clean, emotion, event
33
-
34
- class SenseVoiceSTTProvider:
35
- """
36
- Concrete implementation of the STTProvider protocol using FunASR's SenseVoiceSmall model.
37
- Transcribes audio into text while simultaneously extracting vocal emotional tone
38
- (e.g., happy, sad, angry) and acoustic events (e.g., laughter, crying).
39
- """
40
- def __init__(self):
41
- self._sense_voice = None
42
- self._init_stt()
43
-
44
- def _init_stt(self):
45
- try:
46
- from funasr import AutoModel
47
- from funasr.utils.postprocess_utils import rich_transcription_postprocess
48
-
49
- self._sense_voice = AutoModel(
50
- model="iic/SenseVoiceSmall",
51
- trust_remote_code=True,
52
- vad_model="fsmn-vad",
53
- vad_kwargs={"max_single_segment_time": 30000},
54
- device="cpu",
55
- disable_update=True
56
- )
57
- print("[STARTUP] SenseVoice-Small loaded successfully.")
58
- except Exception as e:
59
- print(f"[WARNING] Failed to load SenseVoice: {e}")
60
- self._sense_voice = None
61
-
62
- def transcribe(self, audio_bytes: bytes) -> Dict[str, Any]:
63
- """
64
- Takes raw webm/ogg bytes, runs them through SenseVoice,
65
- and returns {text, emotion, event}.
66
- """
67
- if not self._sense_voice:
68
- print("[WARNING] STT requested but SenseVoice is not loaded.")
69
- return {"text": "", "emotion": "unknown", "event": None}
70
-
71
- # funasr expects a file path
72
- with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tf:
73
- tf.write(audio_bytes)
74
- tf.flush()
75
- temp_path = tf.name
76
-
77
- try:
78
- res = self._sense_voice.generate(
79
- input=temp_path,
80
- cache={},
81
- language="auto",
82
- use_itn=True,
83
- batch_size_s=60,
84
- merge_vad=True,
85
- merge_length_s=15,
86
- )
87
- if not res or len(res) == 0:
88
- return {"text": "", "emotion": "neutral", "event": None}
89
-
90
- raw_text = res[0].get("text", "")
91
- clean, emotion, event = _parse_sensevoice_output(raw_text)
92
-
93
- return {
94
- "text": clean,
95
- "emotion": emotion,
96
- "event": event
97
- }
98
- except Exception as e:
99
- print(f"[ERROR] SenseVoice transcription failed: {e}")
100
- return {"text": "", "emotion": "unknown", "event": None}
101
- finally:
102
- import os
103
- try:
104
- os.remove(temp_path)
105
- except OSError:
106
- pass