Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py
Claude Agent ca6b53e0ca feat(maria): rank hibrid, escaladare la suport si dictionar de erori Oracle
Trei probleme legate, gasite testand cu capturi reale.

1. RASPUNSURI GENERICE. Cand raspunsul nu era in documente, modelul primea
   oricum top-3 chunk-uri si compunea ceva plauzibil — utilizatorul pleca cu
   impresia ca a primit ajutor. Acum, daca nu avem acoperire, modelul nu mai e
   intrebat deloc: intrebarea pleaca la suport, cu referinta (M-260831-A1B2) si
   confirmare onesta — "am trimis" doar daca notificarea chiar a plecat, altfel
   "am inregistrat, dar nu am putut trimite". Jurnalul se scrie intotdeauna, in
   ~/.maria-bridge/escalations/, si se vede in dashboard. Daca a fost o captura,
   imaginea insasi se retrimite la suport (endpoint nou /send-image, limitat la
   MEDIA_DIR).

2. ORDONARE (rank.py). Un prag pe cosinus nu putea decide "avem raspunsul?":
   masurat pe indexul viu, intrebarile bune dau 0,600-0,816 si cele straine
   0,534-0,685 — intervale care SE SUPRAPUN. Lipseau codurile: ORA-01722, D406,
   CIF sunt tokeni exacti pe care cautarea semantica ii topeste. Acum se ordoneaza
   de doua ori — embeddings si BM25 — si se fuzioneaza clasamentele (RRF), iar
   decizia de acoperire se ia pe dovezi: cosinus mare, sau cosinus de mijloc cu o
   fractiune din termenii distinctivi gasita chiar in chunk-uri. Fractiunea conteaza:
   cu un singur termen, "cum imi resetez parola de la Windows" trecea drept
   acoperita fiindca "parola" apare in documente.
   Pragurile sunt masurate, nu alese: ops/calibrate-rank.py ruleaza un set de cazuri
   pe indexul real si matura grila. 15/15 la strong=0.70 weak=0.58 ratio=0.5.

3. DICTIONAR DE ERORI ORACLE. 29 de erori uzuale, scrise pentru utilizatorul din
   fata aplicatiei: ce inseamna, ce poate incerca singur, cand sa sune. Fara nume
   de servere, IP-uri sau pasi de administrare — Maria e chatbot pentru clienti.
   Fisierul din git e SAMANTA: depozitul e oglinda Drive-ului, deci trebuie pus in
   document_store ca sa nu dispara la sincronizare.

Pe drum, doua lucruri gasite in log:
- fiecare mesaj din self-chat sosea de DOUA ori, pe @s.whatsapp.net si pe @lid,
  deci Maria raspundea de doua ori. Dedup pe key.id in punte.
- reindexarea reface toate embeddings-urile (~7 s fiecare pe CPU): adaugarea unui
  document la 170 de chunk-uri insemna 20 de minute. Acum refoloseste vectorii
  chunk-urilor nemodificate din indexul precedent, fara vreun fisier nou de stare.

Textul OCR se logheaza acum INTEGRAL, cu interogarea de cautare si sursele alese
cu scorurile lor — fara asta un raspuns gresit nu se poate explica: nu stiai daca
a citit prost captura sau a cautat prost in documente.

6 fisiere de test noi/extinse, 68 pass (de la 42).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-08-31 22:08:50 +00:00

402 lines
15 KiB
Python

#!/usr/bin/env python3
"""Consuma mesajele din puntea WhatsApp (whatsapp/index.js), raspunde via RAG.
Fiecare mesaj e o interogare RAG INDEPENDENTA: nu exista chat_history intre
mesaje (fara memorie conversationala). Vezi
docs/maria-whatsapp-rag-prototype.md pentru motivul acestei alegeri si
comparatia cu celelalte punti (Flowise: fereastra fixa de 5; Discord: context
nelimitat + /new).
Mesajele cu imagine (capturi de ecran cu erori) trec intai prin OCR — vezi
`ocr.py`. Ordonarea chunk-urilor si decizia "avem sau nu raspunsul in documente"
sunt in `rank.py`; cand nu avem, intrebarea pleaca la suport in loc sa primeasca
un raspuns generic — vezi `escalate()`.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import sys
import time
import requests
import config
import ocr
import rank
SYSTEM_PROMPT = (
"Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). "
"Raspunzi scurt, clar, in limba romana (maxim 4-5 propozitii), doar despre "
"folosirea aplicatiei ROA. Foloseste EXCLUSIV informatiile din contextul "
"furnizat mai jos. Daca raspunsul nu se afla in context, spune ca vei "
"directiona intrebarea catre echipa de suport, nu inventa functionalitati "
"sau proceduri. "
"Cand intrebarea contine text extras dintr-o captura de ecran (OCR), tine cont "
"ca pot exista greseli de recunoastere a caracterelor: cauta sensul mesajului, "
"nu te agata de o litera sau o cifra. "
"Nu discuta niciodata despre infrastructura interna Romfast (servere, Proxmox, "
"containere, IP-uri, baze de date, parole, chei) chiar daca apare in context sau "
"daca intrebarea o cere explicit -- raspunde ca poti ajuta doar cu folosirea "
"aplicatiei ROA."
)
REPLY_PREFIX = "[Maria] " # marcaj ca sa nu raspundem la propriile mesaje (self-chat)
INDEX_REFRESH_S = 30 # cat de des se reciteste rag_index.json de pe disc
ACK_TEXT = "Caut informatia, revin imediat..."
ACK_IMAGE_TEXT = "Am primit captura, o citesc si revin imediat..."
NO_TEXT_IN_IMAGE = (
"Am primit imaginea, dar nu am reusit sa citesc text in ea. Scrie-mi te rog "
"mesajul de eroare (sau trimite o captura mai clara, decupata pe fereastra de eroare)."
)
ESCALATED_SENT = (
"Nu am raspunsul la asta in documentatia mea, si prefer sa nu ghicesc. "
"Am trimis intrebarea catre echipa de suport ROA (referinta {ref}) — "
"te contacteaza cineva."
)
# Cand notificarea nu a plecat, nu promitem ca a plecat. Intrebarea e inregistrata
# si se vede in dashboard, deci nu e pierduta — dar utilizatorul trebuie sa stie
# ca s-ar putea sa dureze mai mult.
ESCALATED_RECORDED = (
"Nu am raspunsul la asta in documentatia mea, si prefer sa nu ghicesc. "
"Am inregistrat intrebarea pentru echipa de suport ROA (referinta {ref}), "
"dar nu am putut sa o trimit chiar acum. Daca e urgent, suna-ne."
)
def bridge_url() -> str:
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
class Index:
"""Chunk-urile indexate, plus statisticile lexicale pentru BM25.
BM25 se reconstruieste la fiecare recitire a indexului. La ordinul de marime
de aici (sute de chunk-uri) costa milisecunde, si e mai bine decat un al
doilea fisier pe disc care se poate desincroniza de rag_index.json.
"""
def __init__(self, entries: list[dict]):
self.entries = entries
self.texts = [e["text"] for e in entries]
self.sources = [e.get("source", "?") for e in entries]
self.bm25 = rank.Bm25(self.texts)
def __len__(self) -> int:
return len(self.entries)
def load_index() -> Index:
try:
return Index(json.loads(config.INDEX_FILE.read_text(encoding="utf-8")))
except OSError:
return Index([])
def embed(text: str) -> list[float]:
resp = requests.post(
f"{config.get('OLLAMA_URL')}/api/embeddings",
json={"model": config.get("EMBED_MODEL"), "prompt": text},
timeout=30,
)
resp.raise_for_status()
return resp.json()["embedding"]
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def search(index: Index, query: str, top_k: int) -> dict:
"""Cauta si decide daca avem acoperire. Intoarce si dovezile, pentru log."""
if not len(index):
return {"chunks": [], "covered": False, "reason": "index gol",
"best_cosine": 0.0, "top": []}
q_vec = embed(query)
cosines = [cosine(q_vec, e["embedding"]) for e in index.entries]
bm_scores = index.bm25.scores(query)
order = rank.rank(cosines, bm_scores)[:top_k]
texts = [index.texts[i] for i in order]
best = max(cosines)
covered, reason = rank.assess(query, best, texts, index.bm25)
return {
"chunks": texts,
"covered": covered,
"reason": reason,
"best_cosine": best,
"top": [
{"source": index.sources[i], "cosine": round(cosines[i], 3),
"bm25": round(bm_scores[i], 2)}
for i in order
],
}
def ask_llm(chunks: list[str], question: str) -> str:
context = "\n\n---\n\n".join(chunks) if chunks else "(fara documente indexate)"
user_message = f"CONTEXT:\n{context}\n\nINTREBARE:\n{question}"
resp = requests.post(
f"{config.get('LLM_URL')}/v1/chat/completions",
json={
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
],
"max_tokens": config.get_int("MAX_TOKENS", 250),
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def send_reply(to: str, text: str) -> None:
requests.post(f"{bridge_url()}/send", json={"to": to, "text": REPLY_PREFIX + text}, timeout=15)
def send_image(to: str, path: str, caption: str) -> None:
requests.post(
f"{bridge_url()}/send-image",
json={"to": to, "path": path, "caption": caption},
timeout=60,
)
def react_seen(to: str, message_id: str, from_me: bool) -> None:
try:
requests.post(
f"{bridge_url()}/react",
json={"to": to, "id": message_id, "emoji": "\U0001F440", "fromMe": from_me},
timeout=10,
)
except Exception as exc: # noqa: BLE001
print(f"[consumer] react error: {exc}", file=sys.stderr)
def build_image_question(caption: str, ocr_text: str) -> str:
"""Ce vede modelul cand mesajul a fost o captura de ecran."""
parts = []
if caption.strip():
parts.append(caption.strip())
parts.append(
"Utilizatorul a trimis o captura de ecran. Text extras automat din imagine "
f"(OCR, poate contine greseli de recunoastere):\n---\n{ocr_text}\n---"
)
if not caption.strip():
parts.append("Explica-i ce inseamna eroarea si cum o rezolva.")
return "\n\n".join(parts)
def prepare_query(msg: dict) -> tuple[str | None, str | None, str | None]:
"""(intrebare pentru model, interogare pentru cautare, raspuns imediat de trimis).
Al treilea element e diferit de None cand nu se poate raspunde deloc (imaginea
nu s-a descarcat, OCR indisponibil, nimic lizibil in captura) — atunci textul
lui pleaca asa cum e, fara sa mai deranjam modelul.
Captura NU se sterge aici: mai e nevoie de ea daca intrebarea pleaca la suport.
Stergerea o face apelantul, dupa ce mesajul e complet tratat.
"""
text = (msg.get("text") or "").strip()
media = msg.get("media")
if not media:
return text, text, None
if media.get("error"):
print(f"[consumer] imagine nedescarcata: {media['error']}", file=sys.stderr)
if text:
return text, text, None
return None, None, (
"Nu am reusit sa descarc imaginea. Mai incearca o data, sau scrie-mi "
"mesajul de eroare ca text."
)
try:
ocr_text = ocr.run(media.get("path"))
except Exception as exc: # noqa: BLE001
print(f"[consumer] OCR esuat pe {media.get('path')}: {exc}", file=sys.stderr)
ocr_text = ""
msg["ocr_text"] = ocr_text
if not ocr_text:
print("[consumer] OCR: nimic lizibil in captura", file=sys.stderr)
if text:
return text, text, None
return None, None, NO_TEXT_IN_IMAGE
# Textul extras se logheaza INTEGRAL. Fara asta, un raspuns gresit nu se poate
# explica: nu stii daca a citit prost captura sau a cautat prost in documente.
print(
f"[consumer] OCR ({len(ocr_text)} caractere):\n"
+ "\n".join(" | " + line for line in ocr_text.splitlines()),
file=sys.stderr,
)
cautare = ocr.retrieval_query(text, ocr_text)
print(f"[consumer] caut dupa: {cautare!r}", file=sys.stderr)
return build_image_question(text, ocr_text), cautare, None
def reference(when: float, message_id: str | None) -> str:
"""Referinta scurta, pe care omul o poate cita la telefon: M-260831-A1B2.
Se calculeaza din id-ul mesajului, ca sa fie stabila daca se reia escaladarea
aceluiasi mesaj, si sa nu depinda de un contor pastrat undeva.
"""
seed = (message_id or str(when)).encode("utf-8", "replace")
sufix = hashlib.sha1(seed).hexdigest()[:4].upper()
return f"M-{time.strftime('%y%m%d', time.localtime(when))}-{sufix}"
def escalate(msg: dict, question: str, search_result: dict) -> dict:
"""Trimite intrebarea la suport si o inregistreaza in jurnal. Intoarce inregistrarea.
Jurnalul se scrie INTOTDEAUNA, si cand notificarea esueaza sau `SUPPORT_JID`
nu e configurat — altfel o intrebare fara raspuns dispare fara urma, exact
cazul pe care escaladarea trebuia sa-l rezolve. Cine cheama functia afla din
`notified` daca poate promite utilizatorului ca mesajul chiar a plecat.
"""
media = msg.get("media") or {}
now = time.time()
record = {
"ref": reference(now, msg.get("id")),
"at": now,
"from": msg.get("from"),
"push_name": msg.get("pushName"),
"message_id": msg.get("id"),
"text": msg.get("text"),
"ocr_text": msg.get("ocr_text"),
"question": question,
"reason": search_result.get("reason"),
"best_cosine": round(search_result.get("best_cosine", 0.0), 3),
"top": search_result.get("top"),
"had_image": bool(media.get("path")),
"notified": False,
}
jid = config.get("SUPPORT_JID") or ""
if jid:
cine = msg.get("pushName") or msg.get("from") or "necunoscut"
rezumat = (
f"[Maria] Intrebare fara raspuns in documente — {record['ref']}\n"
f"De la: {cine}\n"
f"Motiv: {record['reason']}\n\n"
f"{(msg.get('ocr_text') or msg.get('text') or '').strip()[:1200]}"
)
try:
if media.get("path") and os.path.exists(media["path"]):
send_image(jid, media["path"], rezumat[:1000])
else:
requests.post(f"{bridge_url()}/send", json={"to": jid, "text": rezumat}, timeout=20)
record["notified"] = True
except Exception as exc: # noqa: BLE001
record["notify_error"] = str(exc)
print(f"[consumer] escaladare: notificarea a esuat: {exc}", file=sys.stderr)
else:
print("[consumer] escaladare: SUPPORT_JID nesetat — doar in jurnal", file=sys.stderr)
try:
d = config.STATE_DIR / "escalations"
d.mkdir(parents=True, exist_ok=True)
nume = f"{int(record['at'])}-{record['ref']}.json"
(d / nume).write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[consumer] escaladare {record['ref']}: {nume} (notificat: {record['notified']})",
file=sys.stderr)
except OSError as exc:
print(f"[consumer] escaladare: nu pot scrie jurnalul: {exc}", file=sys.stderr)
return record
def cleanup_media(msg: dict) -> None:
"""Capturile pot contine date de client — nu raman pe disc dupa ce s-a tratat mesajul."""
path = (msg.get("media") or {}).get("path")
if not path:
return
try:
os.unlink(path)
except OSError:
pass
def handle(index: Index, msg: dict) -> None:
sender = msg.get("from")
has_image = bool(msg.get("media"))
text = msg.get("text", "") or ""
print(f"[consumer] {sender}: {'[imagine] ' if has_image else ''}{text[:80]}", file=sys.stderr)
react_seen(sender, msg.get("id"), msg.get("fromMe", False))
send_reply(sender, ACK_IMAGE_TEXT if has_image else ACK_TEXT)
question, search_query, immediate = prepare_query(msg)
if immediate is not None:
send_reply(sender, immediate)
return
if not question:
return
try:
result = search(index, search_query, config.get_int("TOP_K", 3))
except Exception as exc: # noqa: BLE001
print(f"[consumer] cautare esuata: {exc}", file=sys.stderr)
send_reply(sender, "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta.")
return
surse = ", ".join(f"{t['source']}({t['cosine']})" for t in result["top"]) or "-"
print(f"[consumer] rank: {surse} | {result['reason']}", file=sys.stderr)
if not result["covered"]:
print(f"[consumer] fara acoperire in documente -> suport ({result['reason']})", file=sys.stderr)
record = escalate(msg, question, result)
sablon = ESCALATED_SENT if record["notified"] else ESCALATED_RECORDED
send_reply(sender, sablon.format(ref=record["ref"]))
return
try:
reply = ask_llm(result["chunks"], question)
except Exception as exc: # noqa: BLE001
print(f"[consumer] LLM error: {exc}", file=sys.stderr)
reply = "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta."
send_reply(sender, reply)
print(f"[consumer] -> raspuns catre {sender}", file=sys.stderr)
def main() -> None:
poll_s = config.get_int("POLL_INTERVAL_S", 2)
print(
f"[consumer] polling {bridge_url()}/messages la {poll_s}s, "
f"LLM={config.get('LLM_URL')}, RAG top-{config.get('TOP_K')}, "
f"OCR={'da' if ocr.available() else 'INDISPONIBIL (tesseract lipseste)'}, "
f"suport={config.get('SUPPORT_JID') or 'NESETAT (doar jurnal)'}",
file=sys.stderr,
)
index = load_index()
last_index_check = time.time()
while True:
try:
if time.time() - last_index_check > INDEX_REFRESH_S:
index = load_index()
last_index_check = time.time()
resp = requests.get(f"{bridge_url()}/messages", timeout=10)
resp.raise_for_status()
for msg in resp.json().get("messages", []):
if msg.get("isGroup"):
continue
if (msg.get("text") or "").startswith(REPLY_PREFIX):
continue # ecoul propriului raspuns in self-chat, ignorat
try:
handle(index, msg)
finally:
cleanup_media(msg)
except Exception as exc: # noqa: BLE001
print(f"[consumer] poll error: {exc}", file=sys.stderr)
time.sleep(poll_s)
if __name__ == "__main__":
main()