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
This commit is contained in:
Claude Agent
2026-08-31 22:08:50 +00:00
parent ecf5f2fa7a
commit ca6b53e0ca
15 changed files with 1455 additions and 71 deletions

View File

@@ -8,12 +8,14 @@ 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` pentru de ce, si de ce cautarea in index foloseste doar liniile de
eroare, nu toata captura.
`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
@@ -24,6 +26,7 @@ import requests
import config
import ocr
import rank
SYSTEM_PROMPT = (
"Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). "
@@ -49,17 +52,48 @@ 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')}"
def load_index() -> list[dict]:
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 json.loads(config.INDEX_FILE.read_text(encoding="utf-8"))
return Index(json.loads(config.INDEX_FILE.read_text(encoding="utf-8")))
except OSError:
return []
return Index([])
def embed(text: str) -> list[float]:
@@ -79,25 +113,33 @@ def cosine(a: list[float], b: list[float]) -> float:
return dot / (na * nb) if na and nb else 0.0
def retrieve(index: list[dict], question: str, top_k: int) -> list[str]:
if not index:
return []
q_vec = embed(question)
scored = [(cosine(q_vec, e["embedding"]), e["text"]) for e in index]
scored.sort(key=lambda pair: pair[0], reverse=True)
return [text for _, text in scored[:top_k]]
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(index: list[dict], question: str, search_query: str | None = None) -> str:
"""`search_query` separa CE se cauta in index de CE se trimite modelului.
La o captura de ecran, cautarea merge pe liniile de eroare, iar modelului ii
dam fereastra intreaga: contextul din jurul erorii ajuta raspunsul, dar strica
regasirea.
"""
top_k = config.get_int("TOP_K", 3)
context_chunks = retrieve(index, search_query or question, top_k)
context = "\n\n---\n\n".join(context_chunks) if context_chunks else "(fara documente indexate)"
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(
@@ -119,6 +161,14 @@ 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(
@@ -145,11 +195,14 @@ def build_image_question(caption: str, ocr_text: str) -> str:
def prepare_query(msg: dict) -> tuple[str | None, str | None, str | None]:
"""(intrebare pentru model, interogare pentru index, raspuns imediat de trimis).
"""(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")
@@ -165,28 +218,151 @@ def prepare_query(msg: dict) -> tuple[str | None, str | None, str | None]:
"mesajul de eroare ca text."
)
path = media.get("path")
try:
ocr_text = ocr.run(path)
ocr_text = ocr.run(media.get("path"))
except Exception as exc: # noqa: BLE001
print(f"[consumer] OCR esuat pe {path}: {exc}", file=sys.stderr)
print(f"[consumer] OCR esuat pe {media.get('path')}: {exc}", file=sys.stderr)
ocr_text = ""
finally:
# Capturile sunt de unica folosinta: pot contine date de client si nu
# avem niciun motiv sa le pastram pe disc dupa ce am citit textul.
try:
if path:
os.unlink(path)
except OSError:
pass
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
print(f"[consumer] OCR: {len(ocr_text)} caractere din captura", file=sys.stderr)
return build_image_question(text, ocr_text), ocr.retrieval_query(text, ocr_text), None
# 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:
@@ -194,7 +370,8 @@ def main() -> None:
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"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()
@@ -206,35 +383,15 @@ def main() -> None:
last_index_check = time.time()
resp = requests.get(f"{bridge_url()}/messages", timeout=10)
resp.raise_for_status()
messages = resp.json().get("messages", [])
for msg in messages:
for msg in resp.json().get("messages", []):
if msg.get("isGroup"):
continue
text = msg.get("text", "") or ""
if text.startswith(REPLY_PREFIX):
if (msg.get("text") or "").startswith(REPLY_PREFIX):
continue # ecoul propriului raspuns in self-chat, ignorat
sender = msg.get("from")
has_image = bool(msg.get("media"))
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)
continue
if not question:
continue
try:
reply = ask_llm(index, question, search_query)
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)
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)