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 -94
- providers/llms/groq_llm_provider.py +0 -183
- providers/llms/ollama_llm_provider.py +0 -214
- 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 -107
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,94 +0,0 @@
|
|
| 1 |
-
from core.ports import LLM1Output, LLM2Output, LLM3Output
|
| 2 |
-
|
| 3 |
-
class FallbackLLMProvider:
|
| 4 |
-
"""
|
| 5 |
-
Composite LLM Provider that loops through a list of other providers.
|
| 6 |
-
If a provider throws an exception (e.g. rate limit / 429), it tries the next.
|
| 7 |
-
Returns safe fallback strings if all providers fail.
|
| 8 |
-
"""
|
| 9 |
-
def __init__(self, providers: list):
|
| 10 |
-
self.providers = providers
|
| 11 |
-
|
| 12 |
-
def generate_opening_context(self, profile_recap: str | None) -> list:
|
| 13 |
-
# This doesn't call an LLM directly, so we just use the first provider's logic
|
| 14 |
-
return self.providers[0].generate_opening_context(profile_recap)
|
| 15 |
-
|
| 16 |
-
def psychiatrist_response(self, context: list, patient_info: dict = None) -> LLM1Output:
|
| 17 |
-
for provider in self.providers:
|
| 18 |
-
try:
|
| 19 |
-
return provider.psychiatrist_response(context, patient_info)
|
| 20 |
-
except Exception as e:
|
| 21 |
-
print(f"[FallbackRouter] Provider {type(provider).__name__} failed for LLM1: {e}")
|
| 22 |
-
continue
|
| 23 |
-
|
| 24 |
-
# All failed
|
| 25 |
-
print("[FallbackRouter] ALL providers failed for LLM1!")
|
| 26 |
-
return LLM1Output(
|
| 27 |
-
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?",
|
| 28 |
-
intent="CONTINUE",
|
| 29 |
-
risk_flag=False,
|
| 30 |
-
clinical_summary=None
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
def internal_reasoning(self, context: list) -> LLM2Output:
|
| 34 |
-
for provider in self.providers:
|
| 35 |
-
try:
|
| 36 |
-
return provider.internal_reasoning(context)
|
| 37 |
-
except Exception as e:
|
| 38 |
-
print(f"[FallbackRouter] Provider {type(provider).__name__} failed for LLM2: {e}")
|
| 39 |
-
continue
|
| 40 |
-
|
| 41 |
-
# All failed
|
| 42 |
-
print("[FallbackRouter] ALL providers failed for LLM2!")
|
| 43 |
-
return LLM2Output(
|
| 44 |
-
assistant_message="I'm here, taking everything in. Please go on.",
|
| 45 |
-
emotional_themes=[],
|
| 46 |
-
thinking_patterns=[],
|
| 47 |
-
behavioral_patterns=[],
|
| 48 |
-
interpersonal_dynamics=[],
|
| 49 |
-
stressors=[],
|
| 50 |
-
unclear_areas=[],
|
| 51 |
-
risk_assessment="Unable to complete risk assessment due to analysis error.",
|
| 52 |
-
protective_factors=[]
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
def psychiatrist_query_response(self, context: list, retrieved_context: str) -> str:
|
| 56 |
-
for provider in self.providers:
|
| 57 |
-
try:
|
| 58 |
-
return provider.psychiatrist_query_response(context, retrieved_context)
|
| 59 |
-
except Exception as e:
|
| 60 |
-
print(f"[FallbackRouter] Provider {type(provider).__name__} failed for Query: {e}")
|
| 61 |
-
continue
|
| 62 |
-
|
| 63 |
-
return "I hear you, and we will find a way through this together. Let's keep exploring what might help."
|
| 64 |
-
|
| 65 |
-
def generate_end_of_session_profile(self, old_profile: dict, session_history: list, patient_info: dict = None) -> LLM3Output:
|
| 66 |
-
for provider in self.providers:
|
| 67 |
-
try:
|
| 68 |
-
return provider.generate_end_of_session_profile(old_profile, session_history, patient_info)
|
| 69 |
-
except Exception as e:
|
| 70 |
-
print(f"[FallbackRouter] Provider {type(provider).__name__} failed for LLM3: {e}")
|
| 71 |
-
continue
|
| 72 |
-
|
| 73 |
-
return LLM3Output(
|
| 74 |
-
session_summary="Error generating session summary.",
|
| 75 |
-
update_profile=False,
|
| 76 |
-
emotional_themes=[],
|
| 77 |
-
thinking_patterns=[],
|
| 78 |
-
behavioral_patterns=[],
|
| 79 |
-
interpersonal_dynamics=[],
|
| 80 |
-
stressors=[],
|
| 81 |
-
unclear_areas=[],
|
| 82 |
-
risk_assessment="Error.",
|
| 83 |
-
protective_factors=[]
|
| 84 |
-
)
|
| 85 |
-
|
| 86 |
-
def summarize_history(self, turns: list) -> str:
|
| 87 |
-
for provider in self.providers:
|
| 88 |
-
try:
|
| 89 |
-
return provider.summarize_history(turns)
|
| 90 |
-
except Exception as e:
|
| 91 |
-
print(f"[FallbackRouter] Provider {type(provider).__name__} failed for Summarize: {e}")
|
| 92 |
-
continue
|
| 93 |
-
|
| 94 |
-
return "[Summary unavailable]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
providers/llms/groq_llm_provider.py
DELETED
|
@@ -1,183 +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 |
-
class GroqLLMProvider:
|
| 8 |
-
"""
|
| 9 |
-
Concrete implementation of the LLMProvider protocol using the Groq API.
|
| 10 |
-
Provides true asynchronous concurrency (no local GPU threading lock required).
|
| 11 |
-
"""
|
| 12 |
-
def __init__(self, model1: str, model2: str):
|
| 13 |
-
self.model1 = model1
|
| 14 |
-
self.model2 = model2
|
| 15 |
-
# Relies on the GROQ_API_KEY environment variable being set
|
| 16 |
-
self.client = Groq()
|
| 17 |
-
|
| 18 |
-
def generate_opening_context(self, profile_recap: str | None) -> list:
|
| 19 |
-
if profile_recap:
|
| 20 |
-
return [
|
| 21 |
-
{
|
| 22 |
-
"role": "user",
|
| 23 |
-
"content": (
|
| 24 |
-
f"{profile_recap}\n\n"
|
| 25 |
-
"Start the psychiatric session. Greet the patient warmly, "
|
| 26 |
-
"briefly acknowledge continuity if it feels right, then use && "
|
| 27 |
-
"to add an open question at the end — e.g. "
|
| 28 |
-
"'Welcome back, it's good to see you. && How have things been for you since we last spoke?'"
|
| 29 |
-
),
|
| 30 |
-
}
|
| 31 |
-
]
|
| 32 |
-
else:
|
| 33 |
-
return [
|
| 34 |
-
{
|
| 35 |
-
"role": "user",
|
| 36 |
-
"content": (
|
| 37 |
-
"Start the psychiatric session. Greet the patient warmly and naturally, "
|
| 38 |
-
"then use && to add an open question at the end — e.g. "
|
| 39 |
-
"'Hello, I'm Dr. Aiden. I'm here to listen. && What brings you here today?'"
|
| 40 |
-
),
|
| 41 |
-
}
|
| 42 |
-
]
|
| 43 |
-
|
| 44 |
-
def psychiatrist_response(self, context: list, patient_info: dict = None) -> LLM1Output:
|
| 45 |
-
try:
|
| 46 |
-
sys_prompt = LLM1_SYSTEM_PROMPT
|
| 47 |
-
if patient_info:
|
| 48 |
-
demographics = (
|
| 49 |
-
f"PATIENT DEMOGRAPHICS:\n"
|
| 50 |
-
f"- Name: {patient_info.get('name', 'Unknown')}\n"
|
| 51 |
-
f"- Age: {patient_info.get('age', 'Unknown')}\n"
|
| 52 |
-
f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
|
| 53 |
-
f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
|
| 54 |
-
f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}\n\n"
|
| 55 |
-
)
|
| 56 |
-
sys_prompt = demographics + sys_prompt
|
| 57 |
-
|
| 58 |
-
messages = [{"role": "system", "content": sys_prompt}]
|
| 59 |
-
for m in context:
|
| 60 |
-
messages.append({"role": m["role"], "content": m["content"]})
|
| 61 |
-
|
| 62 |
-
response = self.client.chat.completions.create(
|
| 63 |
-
model=self.model1,
|
| 64 |
-
messages=messages,
|
| 65 |
-
response_format={"type": "json_object"},
|
| 66 |
-
temperature=0.6,
|
| 67 |
-
max_completion_tokens=1024,
|
| 68 |
-
)
|
| 69 |
-
raw = response.choices[0].message.content
|
| 70 |
-
return LLM1Output.model_validate_json(raw)
|
| 71 |
-
except Exception as e:
|
| 72 |
-
print(f"[Groq LLM1 ERROR] {e}")
|
| 73 |
-
raise e
|
| 74 |
-
|
| 75 |
-
def internal_reasoning(self, context: list) -> LLM2Output:
|
| 76 |
-
try:
|
| 77 |
-
messages = [{"role": "system", "content": LLM2_SYSTEM_PROMPT}]
|
| 78 |
-
for m in context:
|
| 79 |
-
messages.append({"role": m["role"], "content": m["content"]})
|
| 80 |
-
|
| 81 |
-
response = self.client.chat.completions.create(
|
| 82 |
-
model=self.model2,
|
| 83 |
-
messages=messages,
|
| 84 |
-
response_format={"type": "json_object"},
|
| 85 |
-
temperature=0.1,
|
| 86 |
-
max_completion_tokens=1024,
|
| 87 |
-
)
|
| 88 |
-
raw = response.choices[0].message.content
|
| 89 |
-
return LLM2Output.model_validate_json(raw)
|
| 90 |
-
except Exception as e:
|
| 91 |
-
print(f"[Groq LLM2 ERROR] {e}")
|
| 92 |
-
raise e
|
| 93 |
-
|
| 94 |
-
def psychiatrist_query_response(self, context: list, retrieved_context: str) -> str:
|
| 95 |
-
try:
|
| 96 |
-
sys_prompt = (
|
| 97 |
-
"You are a compassionate AI psychiatrist. The patient has asked for advice. "
|
| 98 |
-
"Synthesize a thoughtful response using the following clinical guidelines:\n\n"
|
| 99 |
-
f"{retrieved_context}\n\n"
|
| 100 |
-
"Keep your response empathetic, natural, and under 4 sentences. Do not use markdown."
|
| 101 |
-
)
|
| 102 |
-
messages = [{"role": "system", "content": sys_prompt}]
|
| 103 |
-
for m in context:
|
| 104 |
-
messages.append({"role": m["role"], "content": m["content"]})
|
| 105 |
-
|
| 106 |
-
response = self.client.chat.completions.create(
|
| 107 |
-
model=self.model1,
|
| 108 |
-
messages=messages,
|
| 109 |
-
temperature=0.5,
|
| 110 |
-
max_completion_tokens=512,
|
| 111 |
-
)
|
| 112 |
-
return response.choices[0].message.content.strip()
|
| 113 |
-
except Exception as e:
|
| 114 |
-
print(f"[Groq QUERY ERROR] {e}")
|
| 115 |
-
raise e
|
| 116 |
-
|
| 117 |
-
def generate_end_of_session_profile(self, old_profile: dict, session_history: list, patient_info: dict = None) -> LLM3Output:
|
| 118 |
-
try:
|
| 119 |
-
sys_prompt = LLM3_SYSTEM_PROMPT
|
| 120 |
-
if patient_info:
|
| 121 |
-
demographics = (
|
| 122 |
-
f"PATIENT DEMOGRAPHICS:\n"
|
| 123 |
-
f"- Name: {patient_info.get('name', 'Unknown')}\n"
|
| 124 |
-
f"- Age: {patient_info.get('age', 'Unknown')}\n"
|
| 125 |
-
f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
|
| 126 |
-
f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
|
| 127 |
-
f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}\n\n"
|
| 128 |
-
)
|
| 129 |
-
sys_prompt = demographics + sys_prompt
|
| 130 |
-
|
| 131 |
-
formatted_history = "\n".join(f"{t['role'].upper()}: {t['content']}" for t in session_history)
|
| 132 |
-
user_prompt = (
|
| 133 |
-
f"EXISTING PROFILE:\n{old_profile}\n\n"
|
| 134 |
-
f"RECENT SESSION TRANSCRIPT:\n{formatted_history}\n\n"
|
| 135 |
-
"Please generate the 100-200 word session_summary and the merged, deduplicated clinical profile."
|
| 136 |
-
)
|
| 137 |
-
messages = [
|
| 138 |
-
{"role": "system", "content": sys_prompt},
|
| 139 |
-
{"role": "user", "content": user_prompt}
|
| 140 |
-
]
|
| 141 |
-
|
| 142 |
-
response = self.client.chat.completions.create(
|
| 143 |
-
model=self.model1,
|
| 144 |
-
messages=messages,
|
| 145 |
-
response_format={"type": "json_object"},
|
| 146 |
-
temperature=0.2,
|
| 147 |
-
max_completion_tokens=1500,
|
| 148 |
-
)
|
| 149 |
-
raw = response.choices[0].message.content
|
| 150 |
-
return LLM3Output.model_validate_json(raw)
|
| 151 |
-
except Exception as e:
|
| 152 |
-
print(f"[Groq LLM3 ERROR] {e}")
|
| 153 |
-
raise e
|
| 154 |
-
|
| 155 |
-
def summarize_history(self, turns: list) -> str:
|
| 156 |
-
if not turns:
|
| 157 |
-
return ""
|
| 158 |
-
try:
|
| 159 |
-
formatted = "\n".join(
|
| 160 |
-
f"{t['role'].upper()}: {t['content']}" for t in turns
|
| 161 |
-
)
|
| 162 |
-
messages = [
|
| 163 |
-
{
|
| 164 |
-
"role": "system",
|
| 165 |
-
"content": (
|
| 166 |
-
"You are a clinical note-taker. Summarize the following "
|
| 167 |
-
"patient-psychiatrist conversation excerpt in 3-5 sentences, "
|
| 168 |
-
"third-person clinical register. Capture key symptoms, "
|
| 169 |
-
"themes, and any safety concerns. Be concise."
|
| 170 |
-
),
|
| 171 |
-
},
|
| 172 |
-
{"role": "user", "content": formatted},
|
| 173 |
-
]
|
| 174 |
-
response = self.client.chat.completions.create(
|
| 175 |
-
model=self.model1,
|
| 176 |
-
messages=messages,
|
| 177 |
-
temperature=0.3,
|
| 178 |
-
max_completion_tokens=300,
|
| 179 |
-
)
|
| 180 |
-
return response.choices[0].message.content.strip()
|
| 181 |
-
except Exception as e:
|
| 182 |
-
print(f"[Groq SUMMARIZE ERROR] {e}")
|
| 183 |
-
raise e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
providers/llms/ollama_llm_provider.py
DELETED
|
@@ -1,214 +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) -> LLM1Output:
|
| 50 |
-
try:
|
| 51 |
-
sys_prompt = LLM1_SYSTEM_PROMPT
|
| 52 |
-
if patient_info:
|
| 53 |
-
demographics = (
|
| 54 |
-
f"PATIENT DEMOGRAPHICS:\n"
|
| 55 |
-
f"- Name: {patient_info.get('name', 'Unknown')}\n"
|
| 56 |
-
f"- Age: {patient_info.get('age', 'Unknown')}\n"
|
| 57 |
-
f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
|
| 58 |
-
f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
|
| 59 |
-
f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}\n\n"
|
| 60 |
-
)
|
| 61 |
-
sys_prompt = demographics + sys_prompt
|
| 62 |
-
|
| 63 |
-
messages = [{"role": "system", "content": sys_prompt}]
|
| 64 |
-
for m in context:
|
| 65 |
-
messages.append({"role": m["role"], "content": m["content"]})
|
| 66 |
-
|
| 67 |
-
with self._llm_lock:
|
| 68 |
-
response = self._client().chat(
|
| 69 |
-
model=self.model1,
|
| 70 |
-
messages=messages,
|
| 71 |
-
format=LLM1Output.model_json_schema(),
|
| 72 |
-
options={"temperature": 0.6, "num_predict": 1024, "num_ctx": 8192},
|
| 73 |
-
)
|
| 74 |
-
raw = response.message.content
|
| 75 |
-
return LLM1Output.model_validate_json(raw)
|
| 76 |
-
except Exception as e:
|
| 77 |
-
print(f"[LLM1 ERROR] {e}")
|
| 78 |
-
return LLM1Output(
|
| 79 |
-
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?",
|
| 80 |
-
intent="CONTINUE",
|
| 81 |
-
risk_flag=False,
|
| 82 |
-
clinical_summary=None
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
def internal_reasoning(self, context: list) -> LLM2Output:
|
| 86 |
-
try:
|
| 87 |
-
messages = [{"role": "system", "content": LLM2_SYSTEM_PROMPT}]
|
| 88 |
-
for m in context:
|
| 89 |
-
messages.append({"role": m["role"], "content": m["content"]})
|
| 90 |
-
|
| 91 |
-
with self._llm_lock:
|
| 92 |
-
response = self._client().chat(
|
| 93 |
-
model=self.model2,
|
| 94 |
-
messages=messages,
|
| 95 |
-
format=LLM2Output.model_json_schema(),
|
| 96 |
-
options={"temperature": 0.1, "num_predict": 1024, "num_ctx": 8192},
|
| 97 |
-
keep_alive=0,
|
| 98 |
-
)
|
| 99 |
-
raw = response.message.content
|
| 100 |
-
return LLM2Output.model_validate_json(raw)
|
| 101 |
-
except Exception as e:
|
| 102 |
-
print(f"[LLM2 ERROR] {e}")
|
| 103 |
-
return LLM2Output(
|
| 104 |
-
assistant_message="I'm here, taking everything in. Please go on.",
|
| 105 |
-
emotional_themes=[],
|
| 106 |
-
thinking_patterns=[],
|
| 107 |
-
behavioral_patterns=[],
|
| 108 |
-
interpersonal_dynamics=[],
|
| 109 |
-
stressors=[],
|
| 110 |
-
unclear_areas=[],
|
| 111 |
-
risk_assessment="Unable to complete risk assessment due to analysis error.",
|
| 112 |
-
protective_factors=[]
|
| 113 |
-
)
|
| 114 |
-
|
| 115 |
-
def psychiatrist_query_response(self, context: list, retrieved_context: str) -> str:
|
| 116 |
-
try:
|
| 117 |
-
sys_prompt = (
|
| 118 |
-
"You are a compassionate AI psychiatrist. The patient has asked for advice. "
|
| 119 |
-
"Synthesize a thoughtful response using the following clinical guidelines:\n\n"
|
| 120 |
-
f"{retrieved_context}\n\n"
|
| 121 |
-
"Keep your response empathetic, natural, and under 4 sentences. Do not use markdown."
|
| 122 |
-
)
|
| 123 |
-
messages = [{"role": "system", "content": sys_prompt}]
|
| 124 |
-
for m in context:
|
| 125 |
-
messages.append({"role": m["role"], "content": m["content"]})
|
| 126 |
-
|
| 127 |
-
with self._llm_lock:
|
| 128 |
-
response = self._client().chat(
|
| 129 |
-
model=self.model1,
|
| 130 |
-
messages=messages,
|
| 131 |
-
options={"temperature": 0.5, "num_predict": 512, "num_ctx": 8192},
|
| 132 |
-
)
|
| 133 |
-
return response.message.content.strip()
|
| 134 |
-
except Exception as e:
|
| 135 |
-
print(f"[QUERY ERROR] {e}")
|
| 136 |
-
return "I hear you, and we will find a way through this together. Let's keep exploring what might help."
|
| 137 |
-
|
| 138 |
-
def generate_end_of_session_profile(self, old_profile: dict, session_history: list, patient_info: dict = None) -> LLM3Output:
|
| 139 |
-
try:
|
| 140 |
-
sys_prompt = LLM3_SYSTEM_PROMPT
|
| 141 |
-
if patient_info:
|
| 142 |
-
demographics = (
|
| 143 |
-
f"PATIENT DEMOGRAPHICS:\n"
|
| 144 |
-
f"- Name: {patient_info.get('name', 'Unknown')}\n"
|
| 145 |
-
f"- Age: {patient_info.get('age', 'Unknown')}\n"
|
| 146 |
-
f"- Gender: {patient_info.get('gender', 'Unknown')}\n"
|
| 147 |
-
f"- Nationality: {patient_info.get('nationality', 'Unknown')}\n"
|
| 148 |
-
f"- Primary Concern: {patient_info.get('primary_concern', 'Unknown')}\n\n"
|
| 149 |
-
)
|
| 150 |
-
sys_prompt = demographics + sys_prompt
|
| 151 |
-
|
| 152 |
-
formatted_history = "\n".join(f"{t['role'].upper()}: {t['content']}" for t in session_history)
|
| 153 |
-
user_prompt = (
|
| 154 |
-
f"EXISTING PROFILE:\n{old_profile}\n\n"
|
| 155 |
-
f"RECENT SESSION TRANSCRIPT:\n{formatted_history}\n\n"
|
| 156 |
-
"Please generate the 100-200 word session_summary and the merged, deduplicated clinical profile."
|
| 157 |
-
)
|
| 158 |
-
messages = [
|
| 159 |
-
{"role": "system", "content": sys_prompt},
|
| 160 |
-
{"role": "user", "content": user_prompt}
|
| 161 |
-
]
|
| 162 |
-
|
| 163 |
-
with self._llm_lock:
|
| 164 |
-
response = self._client().chat(
|
| 165 |
-
model=self.model1,
|
| 166 |
-
messages=messages,
|
| 167 |
-
format=LLM3Output.model_json_schema(),
|
| 168 |
-
options={"temperature": 0.2, "num_predict": 1500, "num_ctx": 8192},
|
| 169 |
-
)
|
| 170 |
-
raw = response.message.content
|
| 171 |
-
return LLM3Output.model_validate_json(raw)
|
| 172 |
-
except Exception as e:
|
| 173 |
-
print(f"[LLM3 ERROR] {e}")
|
| 174 |
-
return LLM3Output(
|
| 175 |
-
session_summary="Error generating session summary.",
|
| 176 |
-
emotional_themes=[],
|
| 177 |
-
thinking_patterns=[],
|
| 178 |
-
behavioral_patterns=[],
|
| 179 |
-
interpersonal_dynamics=[],
|
| 180 |
-
stressors=[],
|
| 181 |
-
unclear_areas=[],
|
| 182 |
-
risk_assessment="Error.",
|
| 183 |
-
protective_factors=[]
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
-
def summarize_history(self, turns: list) -> str:
|
| 187 |
-
if not turns:
|
| 188 |
-
return ""
|
| 189 |
-
try:
|
| 190 |
-
formatted = "\n".join(
|
| 191 |
-
f"{t['role'].upper()}: {t['content']}" for t in turns
|
| 192 |
-
)
|
| 193 |
-
messages = [
|
| 194 |
-
{
|
| 195 |
-
"role": "system",
|
| 196 |
-
"content": (
|
| 197 |
-
"You are a clinical note-taker. Summarize the following "
|
| 198 |
-
"patient-psychiatrist conversation excerpt in 3-5 sentences, "
|
| 199 |
-
"third-person clinical register. Capture key symptoms, "
|
| 200 |
-
"themes, and any safety concerns. Be concise."
|
| 201 |
-
),
|
| 202 |
-
},
|
| 203 |
-
{"role": "user", "content": formatted},
|
| 204 |
-
]
|
| 205 |
-
with self._llm_lock:
|
| 206 |
-
response = self._client().chat(
|
| 207 |
-
model=self.model1,
|
| 208 |
-
messages=messages,
|
| 209 |
-
options={"temperature": 0.3, "num_predict": 300, "num_ctx": 8192},
|
| 210 |
-
)
|
| 211 |
-
return response.message.content.strip()
|
| 212 |
-
except Exception as e:
|
| 213 |
-
print(f"[SUMMARIZE ERROR] {e}")
|
| 214 |
-
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,107 +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="./SenseVoiceSmall",
|
| 51 |
-
trust_remote_code=True,
|
| 52 |
-
remote_code="./model.py",
|
| 53 |
-
vad_model="fsmn-vad",
|
| 54 |
-
vad_kwargs={"max_single_segment_time": 30000},
|
| 55 |
-
device="cpu",
|
| 56 |
-
disable_update=True
|
| 57 |
-
)
|
| 58 |
-
print("[STARTUP] SenseVoice-Small loaded successfully.")
|
| 59 |
-
except Exception as e:
|
| 60 |
-
print(f"[WARNING] Failed to load SenseVoice: {e}")
|
| 61 |
-
self._sense_voice = None
|
| 62 |
-
|
| 63 |
-
def transcribe(self, audio_bytes: bytes) -> Dict[str, Any]:
|
| 64 |
-
"""
|
| 65 |
-
Takes raw webm/ogg bytes, runs them through SenseVoice,
|
| 66 |
-
and returns {text, emotion, event}.
|
| 67 |
-
"""
|
| 68 |
-
if not self._sense_voice:
|
| 69 |
-
print("[WARNING] STT requested but SenseVoice is not loaded.")
|
| 70 |
-
return {"text": "", "emotion": "unknown", "event": None}
|
| 71 |
-
|
| 72 |
-
# funasr expects a file path
|
| 73 |
-
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as tf:
|
| 74 |
-
tf.write(audio_bytes)
|
| 75 |
-
tf.flush()
|
| 76 |
-
temp_path = tf.name
|
| 77 |
-
|
| 78 |
-
try:
|
| 79 |
-
res = self._sense_voice.generate(
|
| 80 |
-
input=temp_path,
|
| 81 |
-
cache={},
|
| 82 |
-
language="auto",
|
| 83 |
-
use_itn=True,
|
| 84 |
-
batch_size_s=60,
|
| 85 |
-
merge_vad=True,
|
| 86 |
-
merge_length_s=15,
|
| 87 |
-
)
|
| 88 |
-
if not res or len(res) == 0:
|
| 89 |
-
return {"text": "", "emotion": "neutral", "event": None}
|
| 90 |
-
|
| 91 |
-
raw_text = res[0].get("text", "")
|
| 92 |
-
clean, emotion, event = _parse_sensevoice_output(raw_text)
|
| 93 |
-
|
| 94 |
-
return {
|
| 95 |
-
"text": clean,
|
| 96 |
-
"emotion": emotion,
|
| 97 |
-
"event": event
|
| 98 |
-
}
|
| 99 |
-
except Exception as e:
|
| 100 |
-
print(f"[ERROR] SenseVoice transcription failed: {e}")
|
| 101 |
-
return {"text": "", "emotion": "unknown", "event": None}
|
| 102 |
-
finally:
|
| 103 |
-
import os
|
| 104 |
-
try:
|
| 105 |
-
os.remove(temp_path)
|
| 106 |
-
except OSError:
|
| 107 |
-
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|