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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user