feat(maria): triaj — raspuns pentru utilizatori, escaladare automata la programatori

Pe captura cu ORA-06550, avand in context chiar intrarea din dictionar care spune
"nu se rezolva din aplicatie", modelul i-a raspuns unui contabil sa verifice
schema de date si sa foloseasca "procedura alternativa MI_pack_parteneri_old" —
inventata. Si nu escalada, fiindca escaladarea se declansa doar cand NU exista
acoperire in documente, iar aici exista.

rag/triaj.py, trei decizii inaintea modelului:

- **Mesaj prea vag.** "Am o eroare" -> Maria cere operatiunea, ecranul si textul
  erorii, in loc sa ghiceasca. Regula e ingusta: text scurt, fara captura, fara cod.
- **Erori marcate "Intotdeauna"/"Imediat" in dictionar** (9 din 29) -> raspunsul se
  compune din campurile dictionarului, fara model, si pleaca automat la suport cu
  referinta si captura. Textul de acolo e scris pentru clienti; parafrazarea lui nu
  adauga nimic si poate strica tot.
- **Urgenta se intreaba, nu se deduce.** Raspunsul ("ma blocheaza" / "pot continua")
  se ataseaza escaladarii deschise, nu deschide alta referinta. Fereastra 30 min.

In plus: SYSTEM_PROMPT interzice explicit vocabularul tehnic (procedura, schema,
tabela, PL/SQL, compilare, sesiune) si propunerile de verificat baza de date; cele
mai tehnice cinci intrari din dictionar rescrise in limbaj de client.

Teste: 85 pass. Calibrare 21/21. Reindexare: 5 embeddings noi, 164 refolosite.

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-09-01 05:26:33 +00:00
parent b7d75f05ee
commit 5342875aa2
5 changed files with 386 additions and 8 deletions

View File

@@ -27,6 +27,7 @@ import requests
import config
import ocr
import rank
import triaj
SYSTEM_PROMPT = (
"Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). "
@@ -38,6 +39,13 @@ SYSTEM_PROMPT = (
"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. "
"Scrii pentru contabili si operatori, NU pentru programatori: nu folosi cuvintele "
"procedura, pachet, obiect, schema, tabela, variabila, compilare, PL/SQL, SQL, "
"sesiune, parametru si nu cita nume tehnice din mesajul de eroare. Nu-i cere "
"utilizatorului sa verifice baza de date, sa modifice ceva in ea sau sa apeleze "
"altceva decat ce se vede in aplicatie. "
"Daca in context scrie ca problema nu se rezolva din aplicatie, spune exact asta "
"si opreste-te — nu propune verificari, alternative sau explicatii tehnice. "
"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 "
@@ -67,6 +75,12 @@ ESCALATED_RECORDED = (
)
FOLLOWUP_OK = (
"Multumesc, am adaugat si asta la {ref} — echipa vede detaliul cand preia "
"problema."
)
def bridge_url() -> str:
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
@@ -315,6 +329,85 @@ def escalate(msg: dict, question: str, search_result: dict) -> dict:
return record
PENDING_FILE_NAME = "pending.json"
PENDING_TTL_S = 30 * 60 # cat timp un raspuns scurt mai e „completare", nu intrebare noua
def _pending_path():
return config.STATE_DIR / "escalations" / PENDING_FILE_NAME
def _pending_all() -> dict:
try:
return json.loads(_pending_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _pending_write(data: dict) -> None:
try:
_pending_path().parent.mkdir(parents=True, exist_ok=True)
_pending_path().write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
except OSError as exc:
print(f"[consumer] nu pot scrie {PENDING_FILE_NAME}: {exc}", file=sys.stderr)
def pending_set(sender: str, ref: str) -> None:
"""Retine ca l-am intrebat pe om cat e de urgent, ca sa stiu unde duce raspunsul."""
data = _pending_all()
data[sender] = {"ref": ref, "at": time.time()}
_pending_write(data)
def pending_get(sender: str) -> dict | None:
intrare = _pending_all().get(sender)
if not intrare or time.time() - intrare.get("at", 0) > PENDING_TTL_S:
return None
return intrare
def pending_clear(sender: str) -> None:
data = _pending_all()
if data.pop(sender, None) is not None:
_pending_write(data)
def append_followup(ref: str, msg: dict, text: str) -> bool:
"""Adauga raspunsul omului la escaladarea deschisa si il trimite la suport.
Raspunsul la „te blocheaza sau poti continua?" e chiar informatia care lipseste
din escaladare. O escaladare noua ar rupe firul: aceeasi problema, alta
referinta, iar omul a citat-o deja pe prima.
"""
d = config.STATE_DIR / "escalations"
fisier = next((f for f in sorted(d.glob(f"*-{ref}.json"))), None) if d.exists() else None
if fisier is None:
return False
try:
record = json.loads(fisier.read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
record.setdefault("completari", []).append(
{"at": time.time(), "text": text, "message_id": msg.get("id")})
try:
fisier.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as exc:
print(f"[consumer] nu pot actualiza {fisier.name}: {exc}", file=sys.stderr)
jid = config.get("SUPPORT_JID") or ""
if jid:
cine = msg.get("pushName") or msg.get("from") or "necunoscut"
try:
requests.post(f"{bridge_url()}/send", json={
"to": jid,
"text": f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}",
}, timeout=20)
except Exception as exc: # noqa: BLE001
print(f"[consumer] completare netrimisa: {exc}", file=sys.stderr)
print(f"[consumer] completare la {ref}: {text[:60]!r}", file=sys.stderr)
return True
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")
@@ -332,6 +425,24 @@ def handle(index: Index, msg: dict) -> None:
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))
# Raspunsul la „te blocheaza sau poti continua?" merge la escaladarea deschisa.
# Inainte de orice altceva: nu e o intrebare noua, deci nu se cauta si nu se
# confirma cu „caut informatia".
asteptat = pending_get(sender) if not has_image else None
if asteptat and text.strip() and not rank.codes(text):
if append_followup(asteptat["ref"], msg, text.strip()):
pending_clear(sender)
send_reply(sender, FOLLOWUP_OK.format(ref=asteptat["ref"]))
return
pending_clear(sender)
# „Am o eroare", fara sa spuna care: nu ghicim si nu deranjam suportul — intrebam.
if triaj.prea_vag(text, has_image):
print("[consumer] mesaj prea vag -> cer detalii", file=sys.stderr)
send_reply(sender, triaj.CERE_DETALII)
return
send_reply(sender, ACK_IMAGE_TEXT if has_image else ACK_TEXT)
question, search_query, immediate = prepare_query(msg)
@@ -358,6 +469,18 @@ def handle(index: Index, msg: dict) -> None:
send_reply(sender, sablon.format(ref=record["ref"]))
return
# Eroare care nu se rezolva din aplicatie: raspunsul se compune din dictionar
# si pleaca automat la programatori. Vezi triaj.py pentru de ce nu prin model.
nivel = triaj.nivel_suport(result["chunks"][0]) if result["chunks"] else None
if nivel:
print(f"[consumer] eroare de nivel '{nivel}' -> raspuns fix + suport", file=sys.stderr)
record = escalate(msg, question, result)
send_reply(sender, triaj.raspuns_escaladat(
result["chunks"][0], nivel, record["ref"], record["notified"],
cu_imagine=has_image))
pending_set(sender, record["ref"])
return
try:
reply = ask_llm(result["chunks"], question)
except Exception as exc: # noqa: BLE001