ragV98 commited on
Commit
a22325f
·
1 Parent(s): 1d4183c

revamping again

Browse files
Files changed (1) hide show
  1. components/handlers/whatsapp_handlers.py +247 -140
components/handlers/whatsapp_handlers.py CHANGED
@@ -2,16 +2,17 @@
2
  import logging
3
  import os
4
  import re
5
- from typing import Optional, Dict
 
6
 
7
  from fastapi.responses import JSONResponse
8
 
9
  from components.gateways.headlines_to_wa import fetch_cached_headlines, send_to_whatsapp
10
  from components.LLMs.Mistral import MistralTogetherClient, build_messages
11
 
12
- # ------------------------------------------------------------
13
- # Utilities
14
- # ------------------------------------------------------------
15
 
16
  def _safe_send(text: str, to: str) -> dict:
17
  """Wrap send_to_whatsapp with logging & safe error handling."""
@@ -26,10 +27,9 @@ def _safe_send(text: str, to: str) -> dict:
26
  logging.exception(f"Exception while sending WhatsApp message to {to}: {e}")
27
  return {"status": "error", "error": str(e)}
28
 
29
-
30
- # ------------------------------------------------------------
31
  # Headlines
32
- # ------------------------------------------------------------
33
 
34
  def handle_headlines(from_number: str) -> JSONResponse:
35
  full_message_text = fetch_cached_headlines()
@@ -49,10 +49,9 @@ def handle_headlines(from_number: str) -> JSONResponse:
49
  )
50
  return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to send digest"})
51
 
52
-
53
- # ------------------------------------------------------------
54
  # Preferences / Greeting / Help / Unsubscribe / Small Talk
55
- # ------------------------------------------------------------
56
 
57
  def handle_preferences(from_number: str) -> JSONResponse:
58
  msg = (
@@ -63,7 +62,6 @@ def handle_preferences(from_number: str) -> JSONResponse:
63
  _safe_send(msg, to=from_number)
64
  return JSONResponse(status_code=200, content={"status": "success", "message": "Preferences prompt sent"})
65
 
66
-
67
  def handle_greeting(from_number: str) -> JSONResponse:
68
  msg = (
69
  "Hey! 👋 I’m NuseAI.\n"
@@ -74,7 +72,6 @@ def handle_greeting(from_number: str) -> JSONResponse:
74
  _safe_send(msg, to=from_number)
75
  return JSONResponse(status_code=200, content={"status": "success", "message": "Greeting sent"})
76
 
77
-
78
  def handle_help(from_number: str) -> JSONResponse:
79
  msg = (
80
  "Here’s how I can help:\n"
@@ -86,51 +83,39 @@ def handle_help(from_number: str) -> JSONResponse:
86
  _safe_send(msg, to=from_number)
87
  return JSONResponse(status_code=200, content={"status": "success", "message": "Help sent"})
88
 
89
-
90
  def handle_unsubscribe(from_number: str) -> JSONResponse:
91
  _safe_send("You’re unsubscribed. If you change your mind, just say *hi*.", to=from_number)
92
  return JSONResponse(status_code=200, content={"status": "success", "message": "Unsubscribed"})
93
 
94
-
95
  def handle_small_talk(from_number: str) -> JSONResponse:
96
  _safe_send("🙂 Got it. If you’d like the news, just say *headlines*.", to=from_number)
97
  return JSONResponse(status_code=200, content={"status": "success", "message": "Small talk"})
98
 
99
-
100
- # ------------------------------------------------------------
101
- # Chat Question → “Explain by number” flow (structured + quality-guarded)
102
- # ------------------------------------------------------------
103
 
104
  _HEADLINE_LINE_RE = re.compile(r"^\s*(\d+)\.\s+(.*)$")
 
 
105
 
106
  def _extract_number_ref(text: str) -> Optional[int]:
107
- """
108
- Find a referenced headline number in free text, e.g.:
109
- 'explain number 14', 'no. 7 please', '#9', '14', 'explain 14 like I am 5'
110
- Returns int or None.
111
- """
112
  s = (text or "").lower()
113
 
114
- # explicit forms
115
  m = re.search(r"(?:number|no\.?|num|#)\s*(\d+)", s)
116
  if m:
117
  return int(m.group(1))
118
 
119
- # a bare number (avoid picking up years like 2025; cap at 1..200)
120
  m2 = re.search(r"\b(\d{1,3})\b", s)
121
  if m2:
122
  n = int(m2.group(1))
123
  if 1 <= n <= 200:
124
  return n
125
-
126
  return None
127
 
128
-
129
  def _parse_rendered_digest(rendered: str) -> Dict[int, str]:
130
- """
131
- Parse the same rendered digest string you send on WhatsApp and build a map:
132
- { number -> headline_line_text }
133
- """
134
  mapping: Dict[int, str] = {}
135
  for line in (rendered or "").splitlines():
136
  m = _HEADLINE_LINE_RE.match(line)
@@ -141,150 +126,268 @@ def _parse_rendered_digest(rendered: str) -> Dict[int, str]:
141
  mapping[num] = headline_txt
142
  return mapping
143
 
 
144
 
145
- def _retrieve_context_for_headline(headline_text: str, top_k: int = 15) -> str:
146
  """
147
- Use the vector index to pull contextual passages related to the headline.
148
- - Uses a higher top_k to widen coverage (quality over speed).
149
- - Gracefully degrades if index is unavailable or not yet built.
150
  """
151
- # Defer the import so a missing/invalid index module won't break imports
152
  try:
153
- from components.indexers.news_indexer import load_news_index # type: ignore
154
- except Exception as e:
155
- logging.warning(f"Index module not available yet: {e}")
156
- return ""
157
 
158
- # Try to load the index; if persist_dir is wrong/missing, swallow and return ""
159
- try:
160
- index = load_news_index()
161
  try:
162
- # LlamaIndex v0.10+
163
- qe = index.as_query_engine(similarity_top_k=top_k)
164
  except Exception:
165
- # Older API fallback
166
- from llama_index.core.query_engine import RetrievalQueryEngine # type: ignore
167
- qe = RetrievalQueryEngine(index=index, similarity_top_k=top_k)
168
-
169
- query = (
170
- "Retrieve concise, factual context that best explains this headline:\n"
171
- f"{headline_text}\n"
172
- "Focus on who/what/when/where/why, include crucial numbers, avoid speculation."
173
- )
174
- resp = qe.query(query)
175
- return str(resp)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  except Exception as e:
177
- # Avoid noisy tracebacks in normal operation; index may simply not exist yet
178
- persist_dir = os.getenv("NEWS_INDEX_PERSIST_DIR") or os.getenv("PERSIST_DIR") or "<unset>"
179
- logging.warning(f"Vector retrieval skipped (no index at {persist_dir}): {e}")
180
  return ""
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  def _eli5_answer_structured(question: str, context: str, headline_only: Optional[str] = None) -> str:
184
  """
185
- Generate a structured, quality-guarded ELI5 answer.
186
- Format:
187
- Headline #N — <short title>
188
- Key points:
189
- • ...
190
- • ...
191
- Numbers & facts:
192
- • ...
193
- Why it matters:
194
- • ...
195
- Caveats:
196
- • ...
197
- Confidence: High/Medium/Low
198
-
199
- Rules:
200
- - 120–180 words total.
201
- - Use ONLY the provided context/headline; if missing, write “Not in context”.
202
- - No speculation; keep neutral tone; be brief.
203
  """
 
204
  sys_prompt = (
205
- "You are a rigorous, concise explainer for a news assistant. "
206
- "Produce clear, structured outputs with bullet points. "
207
- "If any detail is not present in context, write 'Not in context'. "
208
- "Avoid flowery language; be factual and neutral."
 
 
 
 
 
 
 
 
 
 
209
  )
210
 
211
- if context.strip():
212
  user_prompt = (
213
  f"QUESTION:\n{question}\n\n"
214
- f"CONTEXT (may be partial, use ONLY this):\n{context}\n\n"
215
- "Write 120–180 words in this exact structure:\n"
216
- "Headline:\n"
217
- "Key points:\n"
218
- " ...\n• ...\n• ...\n"
219
- "Numbers & facts:\n"
220
- "• ...\n• ...\n"
221
- "Why it matters:\n"
222
- "• ...\n"
223
- "Caveats:\n"
224
- "• ...\n"
225
- "Confidence: High | Medium | Low\n"
226
- "Rules:\n"
227
- "- If you can't find a detail in CONTEXT, write 'Not in context'.\n"
228
- "- Do NOT add sources or links unless they appear in CONTEXT.\n"
229
- "- Keep it short, precise, and neutral.\n"
230
  )
231
  else:
232
- # fallback: rely on the headline only
233
  headline_text = headline_only or question
234
  user_prompt = (
235
- "CONTEXT is empty. You must base the answer ONLY on the HEADLINE below; "
236
- "write 'Not in context' for any missing specifics.\n\n"
237
- f"HEADLINE:\n{headline_text}\n\n"
238
- "Write 90–140 words in this exact structure:\n"
239
- "Headline:\n"
240
- "Key points:\n"
241
- "• ...\n• ...\n"
242
- "Numbers & facts:\n"
243
- "• Not in context\n"
244
- "Why it matters:\n"
245
- "• ...\n"
246
- "Caveats:\n"
247
- "• Limited details available\n"
248
- "Confidence: Low\n"
249
  )
250
 
251
  try:
252
  llm = MistralTogetherClient()
253
  msgs = build_messages(user_prompt, sys_prompt)
254
- out, _usage = llm.chat(msgs, temperature=0.2, max_tokens=400)
255
- return out.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  except Exception as e:
257
  logging.exception(f"Mistral structured ELI5 generation failed: {e}")
258
- return (
259
- "Headline:\n"
260
- "Key points:\n"
261
- "• I couldn’t generate an explanation right now.\n"
262
- "Numbers & facts:\n"
263
- " Not in context\n"
264
- "Why it matters:\n"
265
- "• Not in context\n"
266
- "Caveats:\n"
267
- "• System error\n"
268
- "Confidence: Low"
269
  )
270
 
 
 
 
271
 
272
  def handle_chat_question(from_number: str, message_text: str) -> JSONResponse:
273
  """
274
- Smart handler:
275
- - If the user references a headline number (“explain 14 like I’m 5”),
276
- 1) Parse the number
277
- 2) Look up that numbered line from the rendered digest
278
- 3) Retrieve vector context (top_k widened for coverage)
279
- 4) Generate a STRUCTURED ELI5 answer (with quality guardrails)
280
- - Otherwise, provide a gentle hint (for now).
281
  """
282
  logging.info(f"Chat question from {from_number}: {message_text}")
283
 
284
- # 1) Try to find a headline number reference
285
  number = _extract_number_ref(message_text or "")
286
  if number is not None:
287
- # 2) Load rendered digest and map numbers to lines
288
  rendered = fetch_cached_headlines()
289
  mapping = _parse_rendered_digest(rendered)
290
  target_line = mapping.get(number)
@@ -297,18 +400,22 @@ def handle_chat_question(from_number: str, message_text: str) -> JSONResponse:
297
  )
298
  return JSONResponse(status_code=200, content={"status": "success", "message": "Number not found"})
299
 
300
- # 3) Retrieve broader context from the vector index using the headline line
301
- ctx = _retrieve_context_for_headline(target_line, top_k=15)
 
 
 
 
 
 
302
 
303
- # 4) Generate STRUCTURED ELI5 answer (works even if ctx == "")
304
  question = f"Explain headline #{number}: {target_line}"
305
  answer = _eli5_answer_structured(question, ctx, headline_only=target_line)
306
 
307
- # 5) Send back
308
  _safe_send(answer, to=from_number)
309
  return JSONResponse(status_code=200, content={"status": "success", "message": "ELI5 sent"})
310
 
311
- # No number found → for now, guide the user
312
  _safe_send(
313
  "Ask me about a specific headline by number, e.g., *explain 7 like I’m 5*.\n"
314
  "Or type *headlines* for today’s digest.",
 
2
  import logging
3
  import os
4
  import re
5
+ import itertools
6
+ from typing import Optional, Dict, List, Tuple
7
 
8
  from fastapi.responses import JSONResponse
9
 
10
  from components.gateways.headlines_to_wa import fetch_cached_headlines, send_to_whatsapp
11
  from components.LLMs.Mistral import MistralTogetherClient, build_messages
12
 
13
+ # ----------------------------
14
+ # Messaging utility
15
+ # ----------------------------
16
 
17
  def _safe_send(text: str, to: str) -> dict:
18
  """Wrap send_to_whatsapp with logging & safe error handling."""
 
27
  logging.exception(f"Exception while sending WhatsApp message to {to}: {e}")
28
  return {"status": "error", "error": str(e)}
29
 
30
+ # ----------------------------
 
31
  # Headlines
32
+ # ----------------------------
33
 
34
  def handle_headlines(from_number: str) -> JSONResponse:
35
  full_message_text = fetch_cached_headlines()
 
49
  )
50
  return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to send digest"})
51
 
52
+ # ----------------------------
 
53
  # Preferences / Greeting / Help / Unsubscribe / Small Talk
54
+ # ----------------------------
55
 
56
  def handle_preferences(from_number: str) -> JSONResponse:
57
  msg = (
 
62
  _safe_send(msg, to=from_number)
63
  return JSONResponse(status_code=200, content={"status": "success", "message": "Preferences prompt sent"})
64
 
 
65
  def handle_greeting(from_number: str) -> JSONResponse:
66
  msg = (
67
  "Hey! 👋 I’m NuseAI.\n"
 
72
  _safe_send(msg, to=from_number)
73
  return JSONResponse(status_code=200, content={"status": "success", "message": "Greeting sent"})
74
 
 
75
  def handle_help(from_number: str) -> JSONResponse:
76
  msg = (
77
  "Here’s how I can help:\n"
 
83
  _safe_send(msg, to=from_number)
84
  return JSONResponse(status_code=200, content={"status": "success", "message": "Help sent"})
85
 
 
86
  def handle_unsubscribe(from_number: str) -> JSONResponse:
87
  _safe_send("You’re unsubscribed. If you change your mind, just say *hi*.", to=from_number)
88
  return JSONResponse(status_code=200, content={"status": "success", "message": "Unsubscribed"})
89
 
 
90
  def handle_small_talk(from_number: str) -> JSONResponse:
91
  _safe_send("🙂 Got it. If you’d like the news, just say *headlines*.", to=from_number)
92
  return JSONResponse(status_code=200, content={"status": "success", "message": "Small talk"})
93
 
94
+ # ----------------------------
95
+ # Chat Question → ELI5 by number (Hybrid retrieval + better UX)
96
+ # ----------------------------
 
97
 
98
  _HEADLINE_LINE_RE = re.compile(r"^\s*(\d+)\.\s+(.*)$")
99
+ MAX_CONTEXT_CHARS = 4000 # ~2–2.5k tokens budget for LLM
100
+ CONTEXT_LOG_PREVIEW = 1200 # log up to 1.2k chars to avoid huge logs
101
 
102
  def _extract_number_ref(text: str) -> Optional[int]:
103
+ """Detect headline number references like: 'explain #14', 'no. 7', '14'."""
 
 
 
 
104
  s = (text or "").lower()
105
 
 
106
  m = re.search(r"(?:number|no\.?|num|#)\s*(\d+)", s)
107
  if m:
108
  return int(m.group(1))
109
 
 
110
  m2 = re.search(r"\b(\d{1,3})\b", s)
111
  if m2:
112
  n = int(m2.group(1))
113
  if 1 <= n <= 200:
114
  return n
 
115
  return None
116
 
 
117
  def _parse_rendered_digest(rendered: str) -> Dict[int, str]:
118
+ """Build {number -> headline text} from the rendered WhatsApp digest."""
 
 
 
119
  mapping: Dict[int, str] = {}
120
  for line in (rendered or "").splitlines():
121
  m = _HEADLINE_LINE_RE.match(line)
 
126
  mapping[num] = headline_txt
127
  return mapping
128
 
129
+ # ---------- HYBRID RETRIEVAL ----------
130
 
131
+ def _collect_doc_nodes(index, doc_ids: List[str], max_nodes: int = 300) -> List[str]:
132
  """
133
+ Given LlamaIndex 'index' and a list of document IDs, fetch lots of node texts
134
+ from those docs. Fallback-friendly across index versions.
 
135
  """
136
+ texts: List[str] = []
137
  try:
138
+ docstore = getattr(index, "docstore", None) or getattr(index, "storage_context", None).docstore # type: ignore
139
+ if not docstore:
140
+ return texts
 
141
 
142
+ # Try to scan nodes; APIs vary by version
 
 
143
  try:
144
+ all_node_ids = list(docstore._ref_doc_info.keys()) # type: ignore
 
145
  except Exception:
146
+ all_node_ids = list(getattr(docstore, "docs", {}).keys()) # type: ignore
147
+
148
+ count = 0
149
+ for node_id in all_node_ids:
150
+ try:
151
+ node = docstore.get_node(node_id)
152
+ ref_id = getattr(node, "ref_doc_id", None) or (node.metadata.get("doc_id") if hasattr(node, "metadata") else None)
153
+ if ref_id in doc_ids:
154
+ # get_content() in newer versions, else get_text()
155
+ text = str(getattr(node, "get_content", lambda: node.get_text())())
156
+ if text:
157
+ texts.append(text)
158
+ count += 1
159
+ if count >= max_nodes:
160
+ break
161
+ except Exception:
162
+ continue
163
+ except Exception:
164
+ pass
165
+ return texts
166
+
167
+ def _retrieve_context_for_headline(headline_text: str, first_top_k: int = 8) -> str:
168
+ """
169
+ HYBRID retrieval:
170
+ A) similarity search to get seed nodes
171
+ B) expand to include full documents of those nodes
172
+ C) merge/dedupe + progressive widening if too little context
173
+ Returns a single big string (bounded by MAX_CONTEXT_CHARS).
174
+ """
175
+ try:
176
+ from components.indexers.news_indexer import load_news_index # deferred import
177
  except Exception as e:
178
+ logging.warning(f"Index module not available yet: {e}")
 
 
179
  return ""
180
 
181
+ def _query(top_k: int) -> Tuple[List[str], List[str]]:
182
+ """Return (seed_texts, seed_doc_ids)."""
183
+ try:
184
+ index = load_news_index()
185
+ try:
186
+ qe = index.as_query_engine(similarity_top_k=top_k)
187
+ except Exception:
188
+ from llama_index.core.query_engine import RetrievalQueryEngine # type: ignore
189
+ qe = RetrievalQueryEngine(index=index, similarity_top_k=top_k)
190
+
191
+ q = (
192
+ "Return the most relevant passages that explain this headline:\n"
193
+ f"{headline_text}\n"
194
+ "Prioritize who/what/when/where/why and crucial numbers."
195
+ )
196
+ resp = qe.query(q)
197
+
198
+ seed_texts: List[str] = []
199
+ seed_doc_ids: List[str] = []
200
+ source_nodes = getattr(resp, "source_nodes", []) or []
201
+ for sn in source_nodes:
202
+ try:
203
+ txt = str(getattr(sn.node, "get_content", lambda: sn.node.get_text())())
204
+ if txt:
205
+ seed_texts.append(txt)
206
+ ref_id = getattr(sn.node, "ref_doc_id", None) or (sn.node.metadata.get("doc_id") if hasattr(sn.node, "metadata") else None)
207
+ if ref_id:
208
+ seed_doc_ids.append(ref_id)
209
+ except Exception:
210
+ continue
211
+
212
+ if not seed_texts:
213
+ seed_texts = [str(resp)] if str(resp).strip() else []
214
+
215
+ return seed_texts, list(dict.fromkeys(seed_doc_ids)) # dedupe doc_ids, keep order
216
+ except Exception as e:
217
+ logging.warning(f"Similarity retrieval failed (top_k={top_k}): {e}")
218
+ return [], []
219
+
220
+ # Progressive widening
221
+ for tk in (first_top_k, 15, 25):
222
+ seed_texts, doc_ids = _query(tk)
223
+
224
+ # Expand to all nodes from those documents
225
+ expanded_texts: List[str] = []
226
+ try:
227
+ if doc_ids:
228
+ index = load_news_index()
229
+ expanded_texts = _collect_doc_nodes(index, doc_ids, max_nodes=300)
230
+ except Exception as e:
231
+ logging.debug(f"Doc expansion failed: {e}")
232
+
233
+ # Merge & dedupe
234
+ combined = []
235
+ seen = set()
236
+ for chunk in itertools.chain(seed_texts, expanded_texts):
237
+ c = (chunk or "").strip()
238
+ if not c:
239
+ continue
240
+ h = hash(c[:512]) # crude dedupe
241
+ if h in seen:
242
+ continue
243
+ seen.add(h)
244
+ combined.append(c)
245
+
246
+ if not combined:
247
+ continue
248
+
249
+ # Truncate to budget
250
+ out = []
251
+ total = 0
252
+ for c in combined:
253
+ if total + len(c) > MAX_CONTEXT_CHARS:
254
+ break
255
+ out.append(c)
256
+ total += len(c)
257
+
258
+ if total > 800: # enough to answer
259
+ return "\n\n---\n\n".join(out)
260
+
261
+ # Nothing useful
262
+ return ""
263
+
264
+ # ---------- ELI5 generation (friendlier UX) ----------
265
+
266
+ def _confidence_from_context(ctx: str) -> str:
267
+ """Heuristic confidence, logged (not shown to user)."""
268
+ n = len(ctx or "")
269
+ if n > 3000: return "High"
270
+ if n > 1400: return "Medium"
271
+ return "Low"
272
+
273
+ def _format_eli5_output(headline: str, key_points: List[str], facts: List[str], why: List[str], unknowns: List[str], tips: List[str]) -> str:
274
+ """Format a compact WhatsApp-friendly message WITHOUT a confidence line."""
275
+ def _top(xs, k): return [x for x in xs if x][:k]
276
+ key_points = _top(key_points, 3)
277
+ facts = _top(facts, 2)
278
+ why = _top(why, 2)
279
+ unknowns = _top(unknowns, 2)
280
+ tips = _top(tips, 1)
281
+
282
+ parts = [
283
+ f"Headline:\n{headline}".strip(),
284
+ ("In plain words:\n" + " ".join(key_points)) if key_points else None,
285
+ ("Numbers & facts:\n• " + "\n• ".join(facts)) if facts else "Numbers & facts:\n• We don’t know yet",
286
+ ("Why it matters:\n• " + "\n• ".join(why)) if why else None,
287
+ ("What we don’t know yet:\n• " + "\n• ".join(unknowns)) if unknowns else None,
288
+ ("What you can do:\n• " + tips[0]) if tips else None,
289
+ ]
290
+ text = "\n\n".join([p for p in parts if p]).strip()
291
+ if len(text) > 900:
292
+ text = text[:900].rstrip() + "…"
293
+ return text
294
 
295
  def _eli5_answer_structured(question: str, context: str, headline_only: Optional[str] = None) -> str:
296
  """
297
+ Generate structured ELI5; we return user-friendly text,
298
+ and LOG confidence + context preview (not shown to user).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  """
300
+ # System + user prompts (strict, to prevent hallucinations)
301
  sys_prompt = (
302
+ "You are a careful explainer. Use ONLY the provided material. "
303
+ "Prefer simple words and short sentences (aim for a 10-year-old reader). "
304
+ "Never invent details. If something is missing, say 'We don’t know yet'."
305
+ )
306
+
307
+ json_schema = (
308
+ '{'
309
+ '"headline": "<short title or the headline itself>",'
310
+ '"plain_points": ["<2-3 child-friendly lines>"],'
311
+ '"facts": ["<up to 2 concrete numbers/dates/entities>"],'
312
+ '"why": ["<1-2 reasons why this matters>"],'
313
+ '"unknowns": ["<1-2 things we don’t know yet>"],'
314
+ '"tips": ["<1 short tip if applicable, else empty>"]'
315
+ '}'
316
  )
317
 
318
+ if (context or "").strip():
319
  user_prompt = (
320
  f"QUESTION:\n{question}\n\n"
321
+ f"CONTEXT (use ONLY this):\n{context}\n\n"
322
+ "Extract ONLY grounded details. Return STRICT JSON:\n"
323
+ + json_schema +
324
+ "\nRules:\n"
325
+ "- If a field has nothing grounded, use [] or empty string.\n"
326
+ "- Simple words. No citations or URLs."
 
 
 
 
 
 
 
 
 
 
327
  )
328
  else:
 
329
  headline_text = headline_only or question
330
  user_prompt = (
331
+ "There is NO additional context. Use ONLY this HEADLINE:\n"
332
+ f"{headline_text}\n\n"
333
+ "Return STRICT JSON like:\n"
334
+ + json_schema
 
 
 
 
 
 
 
 
 
 
335
  )
336
 
337
  try:
338
  llm = MistralTogetherClient()
339
  msgs = build_messages(user_prompt, sys_prompt)
340
+ raw, _usage = llm.chat(msgs, temperature=0.2, max_tokens=450)
341
+
342
+ # Parse JSON robustly
343
+ import json as _json
344
+ try:
345
+ start = raw.find("{"); end = raw.rfind("}")
346
+ payload = _json.loads(raw[start:end+1]) if (start != -1 and end != -1) else _json.loads(raw)
347
+ except Exception:
348
+ payload = {"headline": headline_only or "", "plain_points": [], "facts": [], "why": [], "unknowns": ["We don’t know yet"], "tips": []}
349
+
350
+ headline = (payload.get("headline") or (headline_only or "")).strip()
351
+ plain = [p.strip() for p in payload.get("plain_points", []) if p.strip()]
352
+ facts = [p.strip() for p in payload.get("facts", []) if p.strip()]
353
+ why = [p.strip() for p in payload.get("why", []) if p.strip()]
354
+ unknowns = [p.strip() for p in payload.get("unknowns", []) if p.strip()]
355
+ tips = [p.strip() for p in payload.get("tips", []) if p.strip()]
356
+
357
+ # Compute + LOG confidence (not shown to user)
358
+ conf = _confidence_from_context(context or "")
359
+ logging.info(f"[ELI5] confidence={conf}; ctx_len={len(context or '')}")
360
+
361
+ return _format_eli5_output(headline or (headline_only or "This item"), plain, facts, why, unknowns, tips)
362
+
363
  except Exception as e:
364
  logging.exception(f"Mistral structured ELI5 generation failed: {e}")
365
+ return _format_eli5_output(
366
+ headline_only or "This item",
367
+ ["I can’t explain more right now."],
368
+ [],
369
+ [],
370
+ ["We don’t know yet"],
371
+ [],
 
 
 
 
372
  )
373
 
374
+ # ----------------------------
375
+ # Handler
376
+ # ----------------------------
377
 
378
  def handle_chat_question(from_number: str, message_text: str) -> JSONResponse:
379
  """
380
+ If user references a headline number:
381
+ 1) Map number headline from rendered digest
382
+ 2) HYBRID retrieve context (seed + doc expansion)
383
+ 3) LOG the retrieved context (length + preview)
384
+ 4) Generate friendly ELI5 (no confidence shown to user)
385
+ Else: gentle guidance.
 
386
  """
387
  logging.info(f"Chat question from {from_number}: {message_text}")
388
 
 
389
  number = _extract_number_ref(message_text or "")
390
  if number is not None:
 
391
  rendered = fetch_cached_headlines()
392
  mapping = _parse_rendered_digest(rendered)
393
  target_line = mapping.get(number)
 
400
  )
401
  return JSONResponse(status_code=200, content={"status": "success", "message": "Number not found"})
402
 
403
+ # Retrieve context
404
+ ctx = _retrieve_context_for_headline(target_line, first_top_k=8)
405
+
406
+ # LOG the retrieved context details for debugging
407
+ ctx_len = len(ctx or "")
408
+ preview = (ctx or "")[:CONTEXT_LOG_PREVIEW].replace("\n", "\\n")
409
+ logging.info(f"[ELI5] Retrieved context len={ctx_len} for #{number}")
410
+ logging.debug(f"[ELI5] Context preview for #{number}: {preview}{'…' if ctx_len > CONTEXT_LOG_PREVIEW else ''}")
411
 
412
+ # Generate answer (no confidence shown to user)
413
  question = f"Explain headline #{number}: {target_line}"
414
  answer = _eli5_answer_structured(question, ctx, headline_only=target_line)
415
 
 
416
  _safe_send(answer, to=from_number)
417
  return JSONResponse(status_code=200, content={"status": "success", "message": "ELI5 sent"})
418
 
 
419
  _safe_send(
420
  "Ask me about a specific headline by number, e.g., *explain 7 like I’m 5*.\n"
421
  "Or type *headlines* for today’s digest.",