KeenWoo commited on
Commit
d5f6a2a
·
verified ·
1 Parent(s): 56a6199

Upload 2 files

Browse files
Files changed (2) hide show
  1. alz_companion/agent.py +403 -0
  2. alz_companion/prompts.py +266 -0
alz_companion/agent.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import os
3
+ import json
4
+ import base64
5
+ import time
6
+ import tempfile
7
+ import re # <-- ADD THIS LINE
8
+
9
+ from typing import List, Dict, Any, Optional
10
+
11
+ # OpenAI for LLM (optional)
12
+ try:
13
+ from openai import OpenAI
14
+ except Exception: # pragma: no cover
15
+ OpenAI = None # type: ignore
16
+
17
+ # LangChain & RAG
18
+ from langchain.schema import Document
19
+ from langchain_community.vectorstores import FAISS
20
+ from langchain_community.embeddings import HuggingFaceEmbeddings
21
+
22
+ # TTS
23
+ try:
24
+ from gtts import gTTS
25
+ except Exception: # pragma: no cover
26
+ gTTS = None # type: ignore
27
+
28
+
29
+ from .prompts import (
30
+ SYSTEM_TEMPLATE, ANSWER_TEMPLATE_CALM, ANSWER_TEMPLATE_ADQ,
31
+ SAFETY_GUARDRAILS, RISK_FOOTER, render_emotion_guidelines, CLASSIFICATION_PROMPT,
32
+ # Add the new templates to the import list
33
+ ROUTER_PROMPT,
34
+ ANSWER_TEMPLATE_FACTUAL,
35
+ ANSWER_TEMPLATE_GENERAL_KNOWLEDGE,
36
+ ANSWER_TEMPLATE_GENERAL,
37
+ QUERY_EXPANSION_PROMPT
38
+ )
39
+
40
+ # -----------------------------
41
+ # Multimodal Processing Functions
42
+ # -----------------------------
43
+
44
+ def _openai_client() -> Optional[OpenAI]:
45
+ api_key = os.getenv("OPENAI_API_KEY", "").strip()
46
+ return OpenAI(api_key=api_key) if api_key and OpenAI else None
47
+
48
+ # In agent.py
49
+
50
+ def describe_image(image_path: str) -> str:
51
+ """Uses a vision model to describe an image for context."""
52
+ client = _openai_client()
53
+ if not client:
54
+ return "(Image description failed: OpenAI API key not configured.)"
55
+
56
+ try:
57
+ # --- FIX START ---
58
+ # Determine the MIME type based on the file extension
59
+ extension = os.path.splitext(image_path)[1].lower()
60
+ if extension == ".png":
61
+ mime_type = "image/png"
62
+ elif extension in [".jpg", ".jpeg"]:
63
+ mime_type = "image/jpeg"
64
+ elif extension == ".gif":
65
+ mime_type = "image/gif"
66
+ elif extension == ".webp":
67
+ mime_type = "image/webp"
68
+ else:
69
+ # Default to JPEG, but this handles the most common cases
70
+ mime_type = "image/jpeg"
71
+ # --- FIX END ---
72
+
73
+ with open(image_path, "rb") as image_file:
74
+ base64_image = base64.b64encode(image_file.read()).decode('utf-8')
75
+
76
+ response = client.chat.completions.create(
77
+ model="gpt-4o",
78
+ messages=[
79
+ {
80
+ "role": "user",
81
+ "content": [
82
+ {"type": "text", "text": "Describe this image in a concise, factual way for a memory journal. Focus on people, places, and key objects. For example: 'A photo of John and Mary smiling on a bench at the park.'"},
83
+ {
84
+ "type": "image_url",
85
+ # Use the dynamically determined MIME type
86
+ "image_url": {"url": f"data:{mime_type};base64,{base64_image}"}
87
+ }
88
+ ],
89
+ }
90
+ ],
91
+ max_tokens=100,
92
+ )
93
+ return response.choices[0].message.content or "No description available."
94
+ except Exception as e:
95
+ return f"[Image description error: {e}]"
96
+
97
+ # -----------------------------
98
+ # NLU Classification Function
99
+ # -----------------------------
100
+ # Since the LLM's response will now contain both a <thinking> block and a JSON block,
101
+ # we need to update the detect_tags_from_query function to correctly parse it.
102
+
103
+ # In agent.py
104
+
105
+ def detect_tags_from_query(query: str, behavior_options: list, emotion_options: list, topic_options: list, context_options: list) -> Dict[str, Any]:
106
+ """Uses a Chain-of-Thought prompt to classify the user's query."""
107
+ behavior_str = ", ".join(f'"{opt}"' for opt in behavior_options if opt != "None")
108
+ emotion_str = ", ".join(f'"{opt}"' for opt in emotion_options if opt != "None")
109
+ topic_str = ", ".join(f'"{opt}"' for opt in topic_options if opt != "None")
110
+ context_str = ", ".join(f'"{opt}"' for opt in context_options if opt != "None")
111
+
112
+ prompt = CLASSIFICATION_PROMPT.format(
113
+ behavior_options=behavior_str,
114
+ emotion_options=emotion_str,
115
+ topic_options=topic_str,
116
+ context_options=context_str,
117
+ query=query
118
+ )
119
+
120
+ messages = [{"role": "system", "content": "You are a helpful NLU classification assistant. Follow the instructions precisely."}, {"role": "user", "content": prompt}]
121
+ response_str = call_llm(messages, temperature=0.1)
122
+
123
+ print(f"\n--- NLU Full Response ---\n{response_str}\n-----------------------\n")
124
+
125
+ result_dict = {
126
+ "detected_behaviors": [], "detected_emotion": "None",
127
+ "detected_topic": "None", "detected_contexts": []
128
+ }
129
+
130
+ try:
131
+ # --- ROBUST PARSING LOGIC ---
132
+ # Find the first '{' and the last '}' to isolate the JSON object
133
+ start_brace = response_str.find('{')
134
+ end_brace = response_str.rfind('}')
135
+
136
+ if start_brace != -1 and end_brace != -1 and end_brace > start_brace:
137
+ json_str = response_str[start_brace : end_brace + 1]
138
+ result = json.loads(json_str)
139
+
140
+ # Safely process the results from the LLM
141
+ result_dict["detected_behaviors"] = [b for b in result.get("detected_behaviors", []) if b in behavior_options]
142
+ result_dict["detected_emotion"] = result.get("detected_emotion") if result.get("detected_emotion") in emotion_options else "None"
143
+ result_dict["detected_topic"] = result.get("detected_topic") if result.get("detected_topic") in topic_options else "None"
144
+ result_dict["detected_contexts"] = [c for c in result.get("detected_contexts", []) if c in context_options]
145
+ # --- END OF ROBUST LOGIC ---
146
+
147
+ return result_dict
148
+ except (json.JSONDecodeError, AttributeError) as e:
149
+ print(f"ERROR parsing CoT JSON: {e}")
150
+ return result_dict
151
+
152
+
153
+ # -----------------------------
154
+ # Embeddings & VectorStore
155
+ # -----------------------------
156
+
157
+ def _default_embeddings():
158
+ """Lightweight, widely available model."""
159
+ model_name = os.getenv("EMBEDDINGS_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
160
+ return HuggingFaceEmbeddings(model_name=model_name)
161
+
162
+ def build_or_load_vectorstore(docs: List[Document], index_path: str, is_personal: bool = False) -> FAISS:
163
+ os.makedirs(os.path.dirname(index_path), exist_ok=True)
164
+ if os.path.isdir(index_path) and os.path.exists(os.path.join(index_path, "index.faiss")):
165
+ try:
166
+ return FAISS.load_local(index_path, _default_embeddings(), allow_dangerous_deserialization=True)
167
+ except Exception:
168
+ pass
169
+
170
+ if is_personal and not docs:
171
+ docs = [Document(page_content="(This is the start of the personal memory journal.)", metadata={"source": "placeholder"})]
172
+
173
+ vs = FAISS.from_documents(docs, _default_embeddings())
174
+ vs.save_local(index_path)
175
+ return vs
176
+
177
+ def texts_from_jsonl(path: str) -> List[Document]:
178
+ out: List[Document] = []
179
+ try:
180
+ with open(path, "r", encoding="utf-8") as f:
181
+ for i, line in enumerate(f):
182
+ line = line.strip()
183
+ if not line: continue
184
+ obj = json.loads(line)
185
+ txt = obj.get("text") or ""
186
+ if not isinstance(txt, str) or not txt.strip(): continue
187
+ md = {"source": os.path.basename(path), "chunk": i}
188
+ for k in ("behaviors", "emotion"):
189
+ if k in obj: md[k] = obj[k]
190
+ out.append(Document(page_content=txt, metadata=md))
191
+ except Exception:
192
+ return []
193
+ return out
194
+
195
+ def bootstrap_vectorstore(sample_paths: List[str] | None = None, index_path: str = "data/faiss_index") -> FAISS:
196
+ docs: List[Document] = []
197
+ for p in (sample_paths or []):
198
+ try:
199
+ if p.lower().endswith(".jsonl"):
200
+ docs.extend(texts_from_jsonl(p))
201
+ else:
202
+ with open(p, "r", encoding="utf-8", errors="ignore") as fh:
203
+ docs.append(Document(page_content=fh.read(), metadata={"source": os.path.basename(p)}))
204
+ except Exception:
205
+ continue
206
+ if not docs:
207
+ docs = [Document(page_content="(empty index)", metadata={"source": "placeholder"})]
208
+ return build_or_load_vectorstore(docs, index_path=index_path)
209
+
210
+ # -----------------------------
211
+ # LLM Call
212
+ # -----------------------------
213
+ # updated the detect_tags_from_query function to call call_llm with a new stop argument,
214
+ # but I failed to update the call_llm function itself to accept that argument.
215
+ # Now fix call_llm function:
216
+ def call_llm(messages: List[Dict[str, str]], temperature: float = 0.6, stop: Optional[List[str]] = None) -> str:
217
+ """Call OpenAI Chat Completions if available; else return a fallback."""
218
+ client = _openai_client()
219
+ model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
220
+ if not client:
221
+ return "(Offline Mode: OpenAI API key not configured.)"
222
+ try:
223
+ # Prepare arguments for the API call to handle the optional 'stop' parameter
224
+ api_args = {
225
+ "model": model,
226
+ "messages": messages,
227
+ "temperature": float(temperature if temperature is not None else 0.6)
228
+ }
229
+ if stop:
230
+ api_args["stop"] = stop
231
+
232
+ resp = client.chat.completions.create(**api_args)
233
+ return (resp.choices[0].message.content or "").strip()
234
+ except Exception as e:
235
+ return f"[LLM API Error: {e}]"
236
+
237
+
238
+ # -----------------------------
239
+ # Prompting & RAG Chain
240
+ # -----------------------------
241
+
242
+ def _format_sources(docs: List[Document]) -> List[str]:
243
+ return list(set(d.metadata.get("source", "unknown") for d in docs))
244
+
245
+ # In agent.py, replace the existing make_rag_chain function with this new one to handle general & specific conversations .
246
+ # The logic for the "factual_question" path needs to be updated to perform the expansion query
247
+
248
+ def make_rag_chain(
249
+ vs_general: FAISS,
250
+ vs_personal: FAISS,
251
+ *,
252
+ role: str = "patient",
253
+ temperature: float = 0.6,
254
+ language: str = "English",
255
+ patient_name: str = "the patient",
256
+ caregiver_name: str = "the caregiver",
257
+ tone: str = "warm",
258
+ ):
259
+ """Returns a callable that performs the complete, intelligent RAG process."""
260
+
261
+ def _format_docs(docs: List[Document], default_msg: str) -> str:
262
+ if not docs: return default_msg
263
+ unique_docs = {doc.page_content: doc for doc in docs}.values()
264
+ return "\n".join([f"- {d.page_content.strip()}" for d in unique_docs])
265
+
266
+ def _answer_fn(query: str, chat_history: List[Dict[str, str]], scenario_tag: Optional[str] = None, emotion_tag: Optional[str] = None) -> Dict[str, Any]:
267
+
268
+ router_messages = [{"role": "user", "content": ROUTER_PROMPT.format(query=query)}]
269
+ query_type = call_llm(router_messages, temperature=0.0).strip().lower()
270
+ print(f"Query classified as: {query_type}")
271
+
272
+ system_message = SYSTEM_TEMPLATE.format(tone=tone, language=language, patient_name=patient_name or "the patient", caregiver_name=caregiver_name or "the caregiver", guardrails=SAFETY_GUARDRAILS)
273
+ messages = [{"role": "system", "content": system_message}]
274
+ messages.extend(chat_history)
275
+
276
+ # --- NEW 'general_knowledge_question' PATH ---
277
+ if "general_knowledge_question" in query_type:
278
+ user_prompt = ANSWER_TEMPLATE_GENERAL_KNOWLEDGE.format(question=query, language=language)
279
+ messages.append({"role": "user", "content": user_prompt})
280
+ answer = call_llm(messages, temperature=temperature)
281
+ return {"answer": answer, "sources": ["General Knowledge"]}
282
+ # --- END NEW PATH ---
283
+
284
+ elif "factual_question" in query_type:
285
+ # ... (This entire section for query expansion and factual search remains the same)
286
+ print(f"Performing query expansion for: '{query}'")
287
+ expansion_prompt = QUERY_EXPANSION_PROMPT.format(question=query)
288
+ expansion_response = call_llm([{"role": "user", "content": expansion_prompt}], temperature=0.1)
289
+
290
+ try:
291
+ clean_response = expansion_response.strip().replace("```json", "").replace("```", "")
292
+ expanded_queries = json.loads(clean_response)
293
+ search_queries = [query] + expanded_queries
294
+ except json.JSONDecodeError:
295
+ search_queries = [query]
296
+
297
+ print(f"Searching with queries: {search_queries}")
298
+ retriever_personal = vs_personal.as_retriever(search_kwargs={"k": 2})
299
+ retriever_general = vs_general.as_retriever(search_kwargs={"k": 2})
300
+
301
+ all_docs = []
302
+ for q in search_queries:
303
+ all_docs.extend(retriever_personal.invoke(q))
304
+ all_docs.extend(retriever_general.invoke(q))
305
+
306
+ context = _format_docs(all_docs, "(No relevant information found in the memory journal.)")
307
+
308
+ user_prompt = ANSWER_TEMPLATE_FACTUAL.format(context=context, question=query, language=language)
309
+ messages.append({"role": "user", "content": user_prompt})
310
+ answer = call_llm(messages, temperature=temperature)
311
+ return {"answer": answer, "sources": _format_sources(all_docs)}
312
+
313
+ elif "general_conversation" in query_type:
314
+ user_prompt = ANSWER_TEMPLATE_GENERAL.format(question=query, language=language)
315
+ messages.append({"role": "user", "content": user_prompt})
316
+ answer = call_llm(messages, temperature=temperature)
317
+ return {"answer": answer, "sources": []}
318
+
319
+ else: # Default to the original caregiving logic
320
+ # ... (This entire section for caregiving scenarios remains the same)
321
+ search_filter = {}
322
+ if scenario_tag and scenario_tag != "None":
323
+ search_filter["behaviors"] = scenario_tag.lower()
324
+ if emotion_tag and emotion_tag != "None":
325
+ search_filter["emotion"] = emotion_tag.lower()
326
+
327
+ if search_filter:
328
+ personal_docs = vs_personal.similarity_search(query, k=3, filter=search_filter)
329
+ general_docs = vs_general.similarity_search(query, k=3, filter=search_filter)
330
+ else:
331
+ retriever_personal = vs_personal.as_retriever(search_kwargs={"k": 3})
332
+ retriever_general = vs_general.as_retriever(search_kwargs={"k": 3})
333
+ personal_docs = retriever_personal.invoke(query)
334
+ general_docs = retriever_general.invoke(query)
335
+
336
+ personal_context = _format_docs(personal_docs, "(No relevant personal memories found.)")
337
+ general_context = _format_docs(general_docs, "(No general guidance found.)")
338
+
339
+ first_emotion = None
340
+ all_docs_care = personal_docs + general_docs
341
+ for doc in all_docs_care:
342
+ if "emotion" in doc.metadata and doc.metadata["emotion"]:
343
+ emotion_data = doc.metadata["emotion"]
344
+ if isinstance(emotion_data, list): first_emotion = emotion_data[0]
345
+ else: first_emotion = emotion_data
346
+ if first_emotion: break
347
+
348
+ emotions_context = render_emotion_guidelines(first_emotion or emotion_tag)
349
+ is_tagged_scenario = (scenario_tag and scenario_tag != "None") or (emotion_tag and emotion_tag != "None") or (first_emotion is not None)
350
+ template = ANSWER_TEMPLATE_ADQ if is_tagged_scenario else ANSWER_TEMPLATE_CALM
351
+
352
+ if template == ANSWER_TEMPLATE_ADQ:
353
+ user_prompt = template.format(general_context=general_context, personal_context=personal_context, question=query, scenario_tag=scenario_tag, emotions_context=emotions_context, role=role, language=language)
354
+ else:
355
+ combined_context = f"General Guidance:\n{general_context}\n\nPersonal Memories:\n{personal_context}"
356
+ user_prompt = template.format(context=combined_context, question=query, language=language)
357
+
358
+ messages.append({"role": "user", "content": user_prompt})
359
+ answer = call_llm(messages, temperature=temperature)
360
+
361
+ high_risk_scenarios = ["exit_seeking", "wandering", "elopement"]
362
+ if scenario_tag and scenario_tag.lower() in high_risk_scenarios:
363
+ answer += f"\n\n---\n{RISK_FOOTER}"
364
+
365
+ return {"answer": answer, "sources": _format_sources(all_docs_care)}
366
+
367
+ return _answer_fn
368
+
369
+
370
+ def answer_query(chain, question: str, **kwargs) -> Dict[str, Any]:
371
+ if not callable(chain): return {"answer": "[Error: RAG chain is not callable]", "sources": []}
372
+ chat_history, scenario_tag, emotion_tag = kwargs.get("chat_history", []), kwargs.get("scenario_tag"), kwargs.get("emotion_tag")
373
+ try:
374
+ return chain(question, chat_history=chat_history, scenario_tag=scenario_tag, emotion_tag=emotion_tag)
375
+ except Exception as e:
376
+ print(f"ERROR in answer_query: {e}")
377
+ return {"answer": f"[Error executing chain: {e}]", "sources": []}
378
+
379
+ # -----------------------------
380
+ # TTS & Transcription
381
+ # -----------------------------
382
+ def synthesize_tts(text: str, lang: str = "en"):
383
+ if not text or gTTS is None: return None
384
+ try:
385
+ fd, path = tempfile.mkstemp(suffix=".mp3")
386
+ os.close(fd)
387
+ tts = gTTS(text=text, lang=(lang or "en"))
388
+ tts.save(path)
389
+ return path
390
+ except Exception:
391
+ return None
392
+
393
+ def transcribe_audio(filepath: str, lang: str = "en"):
394
+ client = _openai_client()
395
+ if not client:
396
+ return "[Transcription failed: API key not configured]"
397
+ api_args = {"model": "whisper-1"}
398
+ if lang and lang != "auto":
399
+ api_args["language"] = lang
400
+ with open(filepath, "rb") as audio_file:
401
+ transcription = client.audio.transcriptions.create(file=audio_file, **api_args)
402
+ return transcription.text
403
+
alz_companion/prompts.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prompts for the Alzheimer’s AI Companion.
3
+ """
4
+
5
+ # ------------------------ Behaviour‑level tags ------------------------
6
+ BEHAVIOUR_TAGS = {
7
+ # Tags from "The Father"
8
+ "repetitive_questioning": ["validation", "gentle_redirection", "offer_distraction"],
9
+ "confusion": ["reassurance", "time_place_orientation", "photo_anchors"],
10
+ "wandering": ["walk_along_support", "simple_landmarks", "visual_cues", "safe_wandering_space"],
11
+ "agitation": ["de-escalating_tone", "validate_feelings", "reduce_stimulation", "simple_choices"],
12
+ "false_accusations": ["reassure_no_blame", "avoid_arguing", "redirect_activity"],
13
+ "address_memory_loss": ["encourage_ID_bracelet_or_GPS", "place_contact_info_in_wallet", "inform_trusted_neighbors", "avoid_quizzing_on_address"],
14
+ "hallucinations_delusions": ["avoid_arguing_or_correcting", "validate_the_underlying_emotion", "offer_reassurance_of_safety", "gently_redirect_to_real_activity", "check_for_physical_triggers"],
15
+
16
+ # Tags from "Still Alice" (and others for future use)
17
+ "exit_seeking": ["validation", "calm_presence", "safe_wandering_space", "environmental_cues"],
18
+ "aphasia": ["patience", "simple_language", "nonverbal_cues", "validation"],
19
+ "withdrawal": ["gentle_invitation", "calm_presence", "offer_familiar_comforts", "no_pressure"],
20
+ "affection": ["reciprocate_warmth", "positive_reinforcement", "simple_shared_activity"],
21
+ "sleep_disturbance": ["establish_calm_bedtime_routine", "limit_daytime_naps", "check_for_discomfort_or_pain"],
22
+ "anxiety": ["calm_reassurance", "simple_breathing_exercise", "reduce_environmental_stimuli"],
23
+ "depression_sadness": ["validate_feelings_of_sadness", "encourage_simple_pleasant_activity", "ensure_social_connection"],
24
+ "orientation_check": ["gentle_orientation_cues", "use_familiar_landmarks", "avoid_quizzing"],
25
+
26
+ # Tags from "Away from Her"
27
+ "misidentification": ["gently_correct_with_context", "use_photos_as_anchors", "respond_to_underlying_emotion", "avoid_insistent_correction"],
28
+
29
+ # Other useful tags
30
+ "sundowning_restlessness": ["predictable_routine", "soft_lighting", "low_stimulation", "familiar_music"],
31
+ "object_misplacement": ["nonconfrontational_search", "fixed_storage_spots"],
32
+
33
+ # --- New Tags from Test Fixtures ---
34
+ "validation": [],
35
+ "gentle_reorientation": [],
36
+ "de-escalation": [],
37
+ "distraction": [],
38
+ "spaced_cueing": [],
39
+ "reassurance": [],
40
+ "psychoeducation": [],
41
+ "goal_breakdown": [],
42
+ "routine_structuring": [],
43
+ "reminiscence_prompting": [],
44
+ "reframing": [],
45
+ "distress_tolerance": [],
46
+ "caregiver_communication_template": [],
47
+ "personalised_music_activation": [],
48
+ "memory_probe": [],
49
+ "safety_brief": [],
50
+ "follow_up_prompt": []
51
+ }
52
+
53
+ # ------------------------ Emotion styles & helpers ------------------------
54
+ EMOTION_STYLES = {
55
+ "confusion": {"tone": "calm, orienting, concrete", "playbook": ["Offer a simple time/place orientation cue (who/where/when).", "Reference one familiar anchor (photo/object/person).", "Use short sentences and one step at a time."]},
56
+ "fear": {"tone": "reassuring, safety-forward, gentle", "playbook": ["Acknowledge fear without contradiction.", "Provide a clear safety cue (e.g., 'You’re safe here with me').", "Reduce novelty and stimulation; suggest one safe action."]},
57
+ "anger": {"tone": "de-escalating, validating, low-arousal", "playbook": ["Validate the feeling; avoid arguing/correcting.", "Keep voice low and sentences short.", "Offer a simple choice to restore control (e.g., 'tea or water?')."]},
58
+ "sadness": {"tone": "warm, empathetic, gentle reminiscence", "playbook": ["Acknowledge loss/longing.", "Invite one comforting memory or familiar song.", "Keep pace slow; avoid tasking."]},
59
+ "warmth": {"tone": "affirming, appreciative", "playbook": ["Reflect gratitude and positive connection.", "Reinforce what’s going well.", "Keep it light; don’t overload with new info."]},
60
+ "joy": {"tone": "supportive, celebratory (but not overstimulating)", "playbook": ["Share the joy briefly; match energy gently.", "Offer a simple, pleasant follow-up activity.", "Avoid adding complex tasks."]},
61
+ "calm": {"tone": "matter-of-fact, concise, steady", "playbook": ["Keep instructions simple.", "Maintain steady pace.", "No extra soothing needed."]},
62
+ }
63
+
64
+ def render_emotion_guidelines(emotion: str | None) -> str:
65
+ e = (emotion or "").strip().lower()
66
+ if e not in EMOTION_STYLES:
67
+ return "Emotion: (auto)\nDesired tone: calm, clear.\nWhen replying, reassure if distress is apparent; prioritise validation and simple choices."
68
+ style = EMOTION_STYLES[e]
69
+ bullet = "\n".join([f"- {x}" for x in style["playbook"]])
70
+ return f"Emotion: {e}\nDesired tone: {style['tone']}\nWhen replying, follow:\n{bullet}"
71
+
72
+ # ------------------------ NLU Classification ------------------------
73
+ # Sometimes, especially with complex instructions, the AI can fail to generate the JSON correctly,
74
+ # which causes the code that reads the response to fail and return "None"
75
+ # SOLUTION: Improve with a hybrid approach with "Few-Shot" Prompting and step-by-step Chain of Thought
76
+
77
+ CLASSIFICATION_PROMPT = """You are an expert NLU engine. Your task is to analyze the user's query to deeply understand their underlying intent and classify it correctly.
78
+
79
+ --- INSTRUCTIONS ---
80
+ First, in a <thinking> block, you must reason step-by-step about the user's query by following these points:
81
+ - **Literal Meaning:** What is the user literally asking or stating?
82
+ - **Underlying Situation:** What is the deeper emotional state or situation being described? (e.g., caregiver burnout, patient confusion, a request for practical help).
83
+ - **User's Primary Goal:** Is the user's main goal **Practical Planning** (seeking a plan, strategy, or how-to advice) or **Emotional Support** (seeking comfort, validation, or reassurance)? This is the most important step.
84
+ - **Tag Selection:** Based on the primary goal, explain which tags from the provided lists are the most appropriate and why. If the goal is practical, prioritize practical tags. If the goal is emotional, prioritize emotional support tags.
85
+
86
+ # - **User's Goal:** What is their true goal? (e.g., Are they seeking factual information, emotional reassurance, or a practical action plan?).
87
+ # - **Tag Selection:** Based on the goal and situation, explain which tags from the provided lists are the most appropriate and why.
88
+
89
+ Second, after your reasoning, provide a single, valid JSON object with the final classification.
90
+
91
+ --- PROVIDED TAGS ---
92
+ Behaviors: {behavior_options}
93
+ Emotions: {emotion_options}
94
+ Topics: {topic_options}
95
+ Contexts: {context_options}
96
+
97
+ --- EXAMPLES ---
98
+
99
+ User Query: "She looked right through me today, like I wasn't even there."
100
+ <thinking>
101
+ 1. **Literal Meaning:** The user's loved one did not seem to recognize them.
102
+ 2. **Underlying Situation:** The user is expressing emotional pain and a feeling of invisibility due to the disease's progression.
103
+ 3. **User's Goal:** They are seeking comfort and a strategy to cope with this painful experience.
104
+ 4. **Tag Selection:** The goal is emotional support, so `behaviors` should be `validation` and `reminiscence_prompting`. The `emotion` is `sadness`. The `topic` is `treatment_option:reassurance`.
105
+ </thinking>
106
+ {{
107
+ "detected_behaviors": ["validation", "reminiscence_prompting"],
108
+ "detected_emotion": "sadness",
109
+ "detected_topic": "treatment_option:reassurance",
110
+ "detected_contexts": ["relationship_spouse", "setting_care_home"]
111
+ }}
112
+
113
+ User Query: "He’s withdrawn today."
114
+ <thinking>
115
+ 1. **Literal Meaning:** The patient is not engaging.
116
+ 2. **Underlying Situation:** The user is observing the patient's apathy and wants to help.
117
+ 3. **User's Goal:** They are implicitly asking for a practical action plan to engage the patient.
118
+ 4. **Tag Selection:** The problem is `withdrawal`, but the goal implies a solution. A known strategy is music, so the `behavior` should include the proactive `personalised_music_activation`. The `topic` is `treatment_option:music_therapy`. The user's tone is `calm`.
119
+ </thinking>
120
+ {{
121
+ "detected_behaviors": ["withdrawal", "personalised_music_activation"],
122
+ "detected_emotion": "calm",
123
+ "detected_topic": "treatment_option:music_therapy",
124
+ "detected_contexts": ["setting_care_home"]
125
+ }}
126
+ ---
127
+
128
+ User Query: "{query}"
129
+
130
+ <thinking>
131
+ """
132
+
133
+
134
+ # ------------------------ Guardrails ------------------------
135
+ SAFETY_GUARDRAILS = """Never provide medical diagnoses or dosing. If a situation implies imminent risk (e.g., wandering/elopement, severe agitation, choking, falls), signpost immediate support from onsite staff or emergency services. Use respectful, person‑centred language. Keep guidance concrete and stepwise."""
136
+
137
+ # ------------------------ System & Answer Templates ------------------------
138
+ SYSTEM_TEMPLATE = """You are an Alzheimer’s caregiving companion. Address the patient as {patient_name} and the caregiver as {caregiver_name}. Ground every suggestion in retrieved evidence when possible. If unsure, say so plainly.
139
+ {guardrails}
140
+ --- IMPORTANT RULE ---
141
+ You MUST write your entire response in {language} ONLY. This is a strict instruction. Do not use any other language, even if the user or the retrieved context uses a different language. Your final output must be in {language}."""
142
+
143
+ ANSWER_TEMPLATE_CALM = """Context:
144
+ {context}
145
+
146
+ ---
147
+ Question from user: {question}
148
+
149
+ ---
150
+ Instructions:
151
+ Based on the context, write a gentle and supportive response in a single, natural-sounding paragraph.
152
+ Your response should:
153
+ 1. Start by briefly and calmly acknowledging the user's situation or feeling.
154
+ 2. Weave 2-3 practical, compassionate suggestions from the context into your paragraph. Do not use a numbered or bulleted list.
155
+ 3. Conclude with a short, reassuring phrase.
156
+ 4. You MUST use the retrieved context to directly address the user's specific **Question**.
157
+ Your response in {language}:"""
158
+
159
+ # For scenarios tagged with a specific behavior (e.g., agitation, confusion)
160
+ ANSWER_TEMPLATE_ADQ = """--- General Guidance from Knowledge Base ---
161
+ {general_context}
162
+
163
+ --- Relevant Personal Memories ---
164
+ {personal_context}
165
+
166
+ ---
167
+ Care scenario: {scenario_tag}
168
+ Response Guidelines:
169
+ {emotions_context}
170
+ Question from user: {question}
171
+
172
+ ---
173
+ Instructions:
174
+ Based on ALL the information above, write a **concise, warm, and validating** response for the {role} in a single, natural-sounding paragraph. **Keep the total response to 2-4 sentences.**
175
+ If possible, weave details from the 'Relevant Personal Memories' into your suggestions to make the response feel more personal and familiar.
176
+ Pay close attention to the Response Guidelines to tailor your tone.
177
+ Your response should follow this pattern:
178
+ 1. Start by validating the user's feeling or concern with a unique, empathetic opening. DO NOT USE THE SAME OPENING PHRASE REPEATEDLY. Choose from different styles of openers, such as:
179
+ - Acknowledging the difficulty: "That sounds like a very challenging situation..."
180
+ - Expressing understanding: "I can see why that would be worrying..."
181
+ - Stating a shared goal: "Let's walk through how we can handle that..."
182
+ - Directly validating the feeling: "It's completely understandable to feel frustrated when..."
183
+ 2. Gently offer **1-2 of the most important practical steps**, combining general guidance with personal memories where appropriate. Do not use a list.
184
+ 3. If the scenario involves risk (like exit_seeking), subtly include a safety cue.
185
+ 4. End with a compassionate, de-escalation phrase.
186
+ Your response in {language}:"""
187
+
188
+ RISK_FOOTER = """If safety is a concern right now, please seek immediate assistance from onsite staff or local emergency services."""
189
+
190
+ # ------------------------ Router & Specialized Templates ------------------------
191
+
192
+ # --- NEW: Template for expanding user queries for better retrieval ---
193
+ QUERY_EXPANSION_PROMPT = """You are a helpful AI assistant. Your task is to rephrase a user's question into 3 different, semantically similar questions to improve document retrieval.
194
+ Provide the rephrased questions as a JSON list of strings.
195
+
196
+ User Question: "{question}"
197
+
198
+ JSON List:
199
+ """
200
+
201
+ # Template for routing/classifying the user's intent
202
+ ROUTER_PROMPT = """You are an expert NLU router. Your task is to classify the user's query into one of four categories:
203
+ 1. `caregiving_scenario`: The user is describing a situation, asking for advice, or expressing a concern related to Alzheimer's or caregiving.
204
+ 2. `factual_question`: The user is asking a direct question about a personal memory, person, or event that would be stored in the memory journal.
205
+ 3. `general_knowledge_question`: The user is asking a general knowledge question about the world, facts, or topics not related to personal memories or caregiving (e.g., 'What is the capital of France?', 'Who directed the movie Inception?').
206
+ 4. `general_conversation`: The user is making a general conversational remark, like a greeting, a thank you, or a simple statement that does not require a knowledge base lookup.
207
+
208
+ User Query: "{query}"
209
+
210
+ Respond with ONLY a single category name from the list above.
211
+ Category: """
212
+
213
+ # Template for answering direct factual questions
214
+ ANSWER_TEMPLATE_FACTUAL = """Context:
215
+ {context}
216
+
217
+ ---
218
+ Question from user: {question}
219
+
220
+ ---
221
+ Instructions:
222
+ Based on the provided context, directly and concisely answer the user's question.
223
+ - If the context contains the answer, state it clearly and naturally. Keep your response to a maximum of 3 sentences.
224
+ - If the context does not contain the answer, respond in a warm and friendly tone that you couldn't find a memory of that topic and gently ask if the user would like to talk more about it or add it as a new memory.
225
+ - Do not offer advice or suggestions unless they are part of the retrieved context.
226
+ - ABSOLUTELY DO NOT invent, create, or hallucinate any stories, characters, or details. Your knowledge is limited to the provided context ONLY.
227
+
228
+ Your response MUST be in {language}:"""
229
+
230
+
231
+ # --- NEW: Template for answering general knowledge questions ---
232
+ # Template for answering general knowledge questions
233
+ ANSWER_TEMPLATE_GENERAL_KNOWLEDGE = """You are a factual answering engine.
234
+ Your task is to directly answer the user's general knowledge question based on your training data.
235
+
236
+ Instructions:
237
+ - Be factual and concise. Go straight to the answer.
238
+ - If the answer requires a list of examples, provide a maximum of 3 items. Do not use numbering.
239
+ - Do NOT include apologies or disclaimers about your knowledge cutoff date.
240
+ # - Do NOT recommend external websites or other services.
241
+ # - Do NOT ask conversational follow-up questions.
242
+
243
+ User's Question: "{question}"
244
+
245
+ Your factual response in {language}:"""
246
+
247
+
248
+ # Template for general, non-RAG conversation
249
+ ANSWER_TEMPLATE_GENERAL = """You are a warm and friendly AI companion. The user has just said: "{question}".
250
+ Respond in a brief, natural, and conversational way. Do not try to provide caregiving advice unless the user asks for it.
251
+ Your response MUST be in {language}:"""
252
+
253
+
254
+ # ------------------------ Convenience exports ------------------------
255
+ __all__ = [
256
+ "SYSTEM_TEMPLATE", "ANSWER_TEMPLATE_CALM", "ANSWER_TEMPLATE_ADQ",
257
+ "SAFETY_GUARDRAILS", "RISK_FOOTER", "BEHAVIOUR_TAGS", "EMOTION_STYLES",
258
+ "render_emotion_guidelines", "CLASSIFICATION_PROMPT",
259
+
260
+ # --- New additions ---
261
+ "QUERY_EXPANSION_PROMPT"
262
+ "ROUTER_PROMPT",
263
+ "ANSWER_TEMPLATE_FACTUAL",
264
+ "ANSWER_TEMPLATE_GENERAL_KNOWLEDGE",
265
+ "ANSWER_TEMPLATE_GENERAL"
266
+ ]