Delete providers
Browse files- providers/__pycache__/bert_risk_signal.cpython-311.pyc +0 -0
- providers/__pycache__/bert_risk_signal.cpython-313.pyc +0 -0
- providers/__pycache__/cascade_risk_signal.cpython-311.pyc +0 -0
- providers/__pycache__/cascade_risk_signal.cpython-313.pyc +0 -0
- providers/__pycache__/chroma_DB.cpython-311.pyc +0 -0
- providers/__pycache__/chroma_provider.cpython-311.pyc +0 -0
- providers/__pycache__/chroma_vector_store.cpython-313.pyc +0 -0
- providers/__pycache__/clinical_risk_signal.cpython-311.pyc +0 -0
- providers/__pycache__/clinical_risk_signal.cpython-313.pyc +0 -0
- providers/__pycache__/fallback_llm_provider.cpython-313.pyc +0 -0
- providers/__pycache__/groq_llm_provider.cpython-313.pyc +0 -0
- providers/__pycache__/keyword_risk_signal.cpython-311.pyc +0 -0
- providers/__pycache__/keyword_risk_signal.cpython-313.pyc +0 -0
- providers/__pycache__/llm_risk_signal.cpython-311.pyc +0 -0
- providers/__pycache__/llm_risk_signal.cpython-313.pyc +0 -0
- providers/__pycache__/ollama_llm_provider.cpython-313.pyc +0 -0
- providers/__pycache__/openrouter_llm_provider.cpython-313.pyc +0 -0
- providers/__pycache__/pinecone_DB.cpython-311.pyc +0 -0
- providers/__pycache__/pinecone_provider.cpython-311.pyc +0 -0
- providers/__pycache__/sensevoice_stt_provider.cpython-311.pyc +0 -0
- providers/__pycache__/sensevoice_stt_provider.cpython-313.pyc +0 -0
- providers/__pycache__/speech_to_text_provider.cpython-311.pyc +0 -0
- providers/__pycache__/together_llm_provider.cpython-313.pyc +0 -0
- providers/chroma_provider.py +0 -125
- providers/llms/__pycache__/fallback_llm_provider.cpython-311.pyc +0 -0
- providers/llms/__pycache__/groq_llm_provider.cpython-311.pyc +0 -0
- providers/llms/__pycache__/groq_llm_provider.cpython-313.pyc +0 -0
- providers/llms/__pycache__/huggingface_llm_provider.cpython-311.pyc +0 -0
- providers/llms/__pycache__/huggingface_llm_provider.cpython-313.pyc +0 -0
- providers/llms/__pycache__/ollama_llm_provider.cpython-311.pyc +0 -0
- providers/llms/__pycache__/openrouter_llm_provider.cpython-311.pyc +0 -0
- providers/llms/fallback_llm_provider.py +0 -128
- providers/llms/groq_llm_provider.py +0 -283
- providers/llms/huggingface_llm_provider.py +0 -260
- providers/llms/ollama_llm_provider.py +0 -224
- providers/pinecone_provider.py +0 -54
- providers/safety/__pycache__/bert.cpython-311.pyc +0 -0
- providers/safety/__pycache__/cascade.cpython-311.pyc +0 -0
- providers/safety/__pycache__/clinical.cpython-311.pyc +0 -0
- providers/safety/__pycache__/keyword.cpython-311.pyc +0 -0
- providers/safety/__pycache__/llm.cpython-311.pyc +0 -0
- providers/safety/bert.py +0 -58
- providers/safety/cascade.py +0 -25
- providers/safety/clinical.py +0 -17
- providers/safety/keyword.py +0 -102
- providers/safety/llm.py +0 -10
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|