Skip to content

Commit cf9601d

Browse files
committed
feat(v0.3.0): LLM-augmented chat, validation, and follow-up Q&A
Adds an optional LLM layer on top of the deterministic SCI engine. The engine still works without an LLM key (graceful degradation). New modules under src/sci_case_tracker/: - llm_client.py provider-agnostic httpx wrapper (Groq / OpenAI / Anthropic / OpenRouter via env vars LLM_PROVIDER, LLM_MODEL, *_API_KEY) - validator.py LLM-based reply validator. Catches mismatches like 'user asked about case X but reply is about case Y'. Fail-open: passes the original reply through if LLM is down or response is unparseable. - memory.py per-user SQLite store. Keeps last 10 conversation turns + last uploaded report (24h TTL). - router.py message classifier + chat fallback + report-followup Q&A. Heuristic classify(), then LLM dispatch. Bot wiring (bot/telegram_bot.py): - on_text now decides: sci_lookup | report_followup | chat - SCI lookups go through validator before sending - 'hi' / 'hello' / general questions get a friendly LLM reply - 'from this report list cases where ...' answers from the cached last upload (no re-fetch from SCI) - on_document caches the enriched rows in memory for follow-ups Config (in .env, all optional): LLM_PROVIDER=groq (default: groq) LLM_MODEL=llama-3.3-70b-versatile GROQ_API_KEY=gsk_... LLM_VALIDATION_ENABLED=true LLM_CHAT_FALLBACK_ENABLED=true LLM_MEMORY_TURNS=10 LLM_MEMORY_REPORT_TTL_HOURS=24 Tests: - 20 new tests (test_router, test_memory, test_validator) - All 48 tests pass (mock LLM via unittest.mock.patch) Live integration verified with Groq llama-3.3-70b: - 'hi' returns a friendly Hindi/English greeting - 'CA 7395/2024' returns SCI data, validator says 'ok' - intentional mismatch correctly flagged - Diary-vs-CaseNumber alias collision NOT flagged (false-positive fix in validator system prompt) Provider-agnostic: swap to Claude/OpenAI by changing LLM_PROVIDER and the matching *_API_KEY env var. No code changes needed.
1 parent 0ff628f commit cf9601d

11 files changed

Lines changed: 156772 additions & 13 deletions

File tree

bot/telegram_bot.py

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from sci_case_tracker import lookup, parse, SCIError # noqa: E402
3737
from sci_case_tracker.batch import process_queries, write_xlsx # noqa: E402
3838
from sci_case_tracker.extract import extract_from_bytes # noqa: E402
39+
from sci_case_tracker import llm_client, memory, router, validator # noqa: E402
3940

4041
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
4142
MAX_CASES_PER_UPLOAD = 200
@@ -159,19 +160,82 @@ async def cmd_list(update: Update, _: ContextTypes.DEFAULT_TYPE):
159160
async def on_text(update: Update, _: ContextTypes.DEFAULT_TYPE):
160161
if not _allowed(update):
161162
return
162-
text = update.message.text or ""
163-
parsed = parse(text)
164-
if parsed.get("kind") == "unknown":
165-
return # ignore non-SCI chatter
166-
await update.message.chat.send_action("typing")
167-
try:
168-
result = lookup(text, as_text=True)
169-
except SCIError as e:
170-
await update.message.reply_text(f"⚠️ SCI portal error: {e}")
163+
text = (update.message.text or "").strip()
164+
if not text:
165+
return
166+
167+
user_id = update.effective_user.id
168+
169+
# Always remember what the user just said (for chat context)
170+
memory.add_message(user_id, "user", text)
171+
172+
# Check if user has a recent uploaded report (for follow-up Q&A)
173+
report = memory.get_report(user_id)
174+
has_report = report is not None
175+
176+
# Decide which path to take
177+
kind = router.classify(text, has_recent_report=has_report)
178+
179+
# ── Path 1: SCI case lookup (deterministic engine + optional LLM validation)
180+
if kind == "sci_lookup":
181+
try:
182+
result = lookup(text, as_text=True)
183+
except SCIError as e:
184+
reply = f"⚠️ SCI portal error: {e}"
185+
await update.message.reply_text(reply)
186+
memory.add_message(user_id, "assistant", reply)
187+
return
188+
except ValueError as e:
189+
reply = f"⚠️ Could not parse: {e}"
190+
await update.message.reply_text(reply)
191+
memory.add_message(user_id, "assistant", reply)
192+
return
193+
194+
# Validate the engine's reply matches the user's question
195+
try:
196+
v = await validator.validate(text, result)
197+
except Exception as e: # noqa: BLE001
198+
log.warning("validator threw: %s", e)
199+
v = {"verdict": "ok", "skipped": True}
200+
201+
if v.get("verdict") == "mismatch":
202+
issue = v.get("issue", "reply may not match the question")
203+
suggestion = v.get("suggestion", "")
204+
warning = f"\n\n⚠️ Heads up: {issue}"
205+
if suggestion:
206+
warning += f"\nDid you mean: {suggestion}"
207+
result = result + warning
208+
209+
await update.message.reply_text(result, disable_web_page_preview=True)
210+
memory.add_message(user_id, "assistant", result)
171211
return
172-
except ValueError:
212+
213+
# ── Path 2: Follow-up question on the last uploaded report
214+
if kind == "report_followup" and report:
215+
try:
216+
reply = await router.report_followup_reply(text, report)
217+
except llm_client.LLMError as e:
218+
reply = f"⚠️ LLM unavailable: {e}\nTry again in a moment."
219+
await update.message.reply_text(reply, disable_web_page_preview=True)
220+
memory.add_message(user_id, "assistant", reply)
173221
return
174-
await update.message.reply_text(result, disable_web_page_preview=True)
222+
223+
# ── Path 3: General chat (LLM fallback)
224+
if not llm_client.is_available():
225+
# No LLM configured → silent (preserves v0.2.x behavior)
226+
return
227+
try:
228+
history = memory.recent_messages(user_id, limit=10)
229+
# Drop the just-added current message from history (we'll send it explicitly)
230+
if history and history[-1].get("content") == text:
231+
history = history[:-1]
232+
reply = await router.chat_reply(text, history=history)
233+
except llm_client.LLMError as e:
234+
log.warning("chat fallback failed: %s", e)
235+
return # silent on chat failure (don't spam errors for greetings)
236+
237+
await update.message.reply_text(reply, disable_web_page_preview=True)
238+
memory.add_message(user_id, "assistant", reply)
175239

176240

177241
async def on_document(update: Update, _: ContextTypes.DEFAULT_TYPE):
@@ -224,6 +288,12 @@ async def on_document(update: Update, _: ContextTypes.DEFAULT_TYPE):
224288
workers=8,
225289
sources=[r["source"] for r in refs],
226290
)
291+
# Cache the enriched report so the user can ask follow-up questions
292+
try:
293+
memory.store_report(update.effective_user.id, rows, filename=filename)
294+
except Exception as e: # noqa: BLE001
295+
log.warning("memory.store_report failed: %s", e)
296+
227297
successes = sum(1 for r in rows if not r["remarks"])
228298
failures = len(rows) - successes
229299
summary_lines = [f"✅ {successes} fetched · ❌ {failures} failed · {len(rows)} total\n"]

0 commit comments

Comments
 (0)