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:
@@ -374,6 +374,37 @@ cosinus si BM25 — la o captura de ecran, OCR-ul aduce zeci de tokeni de zgomot
|
||||
Codul se cauta oriunde in text, nu ca token intreg: OCR-ul lipeste `[Oracle][ODBC]`
|
||||
de cod si scoate `OraJORA-06550`.
|
||||
|
||||
### Triaj: cine primeste raspuns, cine primeste intrebari
|
||||
|
||||
Nu orice mesaj e o intrebare, si nu orice raspuns e util. `rag/triaj.py` ia trei
|
||||
decizii inaintea modelului:
|
||||
|
||||
**Mesaj prea vag.** „Am o eroare" nu spune nimic. Maria cere operatiunea, ecranul si
|
||||
textul erorii (sau o captura) — nu ghiceste si nu deranjeaza suportul cu atat. Regula
|
||||
e ingusta deliberat: doar text scurt, fara captura si fara cod de eroare, in care apar
|
||||
cuvinte de acuza („nu merge", „imi da"). „Cum trimit D406" trece nestingherit.
|
||||
|
||||
**Erori care oricum ajung la programatori.** Dictionarul de erori Oracle are pe fiecare
|
||||
intrare campul `cand suni suportul`. Noua incep cu „Intotdeauna" sau „Imediat" — nu se
|
||||
rezolva din aplicatie. Pentru astea raspunsul se compune **direct din campurile
|
||||
dictionarului, fara model**, iar mesajul (si captura) pleaca automat la suport, cu
|
||||
referinta.
|
||||
|
||||
De ce fara model: avand in context chiar intrarea care spune „nu se rezolva din
|
||||
aplicatie", modelul a raspuns unui contabil sa *„verifice schema de date din aplicatia
|
||||
ROA sau sa foloseasca procedura alternativa `MI_pack_parteneri_old`"* — o procedura pe
|
||||
care a inventat-o. Textul din dictionar e deja scris pentru clienti; parafrazarea lui
|
||||
nu adauga nimic si poate strica tot. Pentru restul erorilor, unde utilizatorul chiar
|
||||
are ce incerca, raspunde modelul — cu interdictii explicite de vocabular in
|
||||
`SYSTEM_PROMPT` (procedura, schema, tabela, PL/SQL, compilare…).
|
||||
|
||||
**Cat e de urgent.** Aceeasi eroare poate bloca omul complet sau poate fi ocolita pana
|
||||
maine, si nu se deduce din text. Maria intreaba, iar raspunsul lui se ataseaza
|
||||
escaladarii deschise (`completari` in jurnal, plus un mesaj la suport), nu deschide
|
||||
alta referinta. Fereastra e de 30 de minute (`consumer.PENDING_TTL_S`), tinuta in
|
||||
`~/.maria-bridge/escalations/pending.json`; un mesaj cu captura sau cu alt cod de
|
||||
eroare e tratat ca intrebare noua, nu ca raspuns la intrebarea de urgenta.
|
||||
|
||||
### Recalibrarea, cand se schimba documentele
|
||||
|
||||
Pragurile sunt masurate, nu alese din burta — si se **muta** cand se schimba
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<eroare_ORA_00942>
|
||||
<keywords>ORA-00942 table or view does not exist tabela sau vederea nu exista</keywords>
|
||||
<mesaj_eroare>ORA-00942: table or view does not exist</mesaj_eroare>
|
||||
<ce_inseamna>Aplicatia cauta ceva in baza de date care nu exista acolo. De obicei inseamna ca versiunea aplicatiei si baza de date nu sunt aduse la aceeasi zi — tipic dupa o actualizare care nu s-a aplicat complet.</ce_inseamna>
|
||||
<ce_inseamna>Programul cauta ceva ce nu gaseste, de obicei fiindca o actualizare nu s-a aplicat complet. Nu e ceva ce ai gresit tu.</ce_inseamna>
|
||||
<rezolvare>Nu e ceva ce poti rezolva din aplicatie. Inchide programul si redeschide-l; daca eroarea revine, noteaza in ce ecran apare si anunta suportul.</rezolvare>
|
||||
<cand_suni_suportul>Intotdeauna. Spune la ce operatiune apare si daca ati facut recent o actualizare.</cand_suni_suportul>
|
||||
</eroare_ORA_00942>
|
||||
@@ -86,16 +86,16 @@
|
||||
<eroare_ORA_00904>
|
||||
<keywords>ORA-00904 invalid identifier identificator invalid coloana inexistenta</keywords>
|
||||
<mesaj_eroare>ORA-00904: invalid identifier</mesaj_eroare>
|
||||
<ce_inseamna>Ca si ORA-00942: aplicatia cere un camp care nu exista in baza de date. Aproape intotdeauna o actualizare aplicata partial.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie. Noteaza ecranul si operatiunea si anunta suportul.</rezolvare>
|
||||
<ce_inseamna>Ca si ORA-00942: programul cere o informatie pe care versiunea instalata nu o are inca. Aproape intotdeauna o actualizare aplicata pe jumatate.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie. Retine in ce ecran si la ce operatiune apare — asta scurteaza mult remedierea.</rezolvare>
|
||||
<cand_suni_suportul>Intotdeauna.</cand_suni_suportul>
|
||||
</eroare_ORA_00904>
|
||||
|
||||
<eroare_ORA_06550>
|
||||
<keywords>ORA-06550 PLS-00201 PLS-00306 wrong number or types of arguments identifier must be declared eroare compilare</keywords>
|
||||
<mesaj_eroare>ORA-06550 / PLS-00201 / PLS-00306: eroare de compilare in program</mesaj_eroare>
|
||||
<ce_inseamna>O procedura din baza de date lipseste sau e apelata gresit. Ca si ORA-00942, semnul unei actualizari incomplete.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie.</rezolvare>
|
||||
<ce_inseamna>Programul nu e adus la zi peste tot: o parte din el a fost actualizata, alta nu. Nu are legatura cu ce ai facut tu si nu apare din vina datelor tale.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie si nu ajuta sa incerci din nou. Pana se remediaza, poti lucra pe alte operatiuni.</rezolvare>
|
||||
<cand_suni_suportul>Intotdeauna. Trimite si o captura de ecran cu textul complet al erorii — contine numele procedurii, ceea ce scurteaza mult diagnosticul.</cand_suni_suportul>
|
||||
</eroare_ORA_06550>
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
<eroare_ORA_28000>
|
||||
<keywords>ORA-28000 the account is locked cont blocat</keywords>
|
||||
<mesaj_eroare>ORA-28000: the account is locked</mesaj_eroare>
|
||||
<ce_inseamna>Contul de acces la baza de date a fost blocat, de regula dupa mai multe incercari cu parola gresita.</ce_inseamna>
|
||||
<ce_inseamna>Accesul programului la date a fost blocat, de regula dupa mai multe incercari cu parola gresita. Se deblocheaza doar de catre echipa tehnica.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie si nu se rezolva incercand din nou — incercarile suplimentare nu ajuta.</rezolvare>
|
||||
<cand_suni_suportul>Imediat. Spune daca cineva a incercat sa se conecteze repetat inainte.</cand_suni_suportul>
|
||||
</eroare_ORA_28000>
|
||||
@@ -158,8 +158,8 @@
|
||||
<eroare_ORA_28001>
|
||||
<keywords>ORA-28001 the password has expired parola expirata</keywords>
|
||||
<mesaj_eroare>ORA-28001: the password has expired</mesaj_eroare>
|
||||
<ce_inseamna>Parola contului de baza de date a expirat.</ce_inseamna>
|
||||
<rezolvare>Nu incerca sa o schimbi din aplicatie.</rezolvare>
|
||||
<ce_inseamna>Parola cu care programul se conecteaza la date a expirat. Nu e parola ta de utilizator si nu tine de tine.</ce_inseamna>
|
||||
<rezolvare>Nu incerca sa o schimbi din aplicatie — se face de catre echipa tehnica.</rezolvare>
|
||||
<cand_suni_suportul>Imediat. E o operatiune de administrare.</cand_suni_suportul>
|
||||
</eroare_ORA_28001>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
129
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py
Normal file
129
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/triaj.py
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Triaj: cine primeste raspuns, cine primeste intrebari, cine cheama programatorii.
|
||||
|
||||
Trei decizii pe care cautarea in documente nu le poate lua singura:
|
||||
|
||||
1. **Mesaj prea vag.** „Am o eroare" nu e o intrebare — e inceputul uneia. Fara
|
||||
operatiunea, ecranul si textul erorii, si un om ar ghici. Maria intreaba, nu
|
||||
ghiceste (si nu deranjeaza suportul cu „am o eroare").
|
||||
|
||||
2. **Erori care oricum ajung la programatori.** Dictionarul spune, pentru fiecare
|
||||
eroare, `cand suni suportul`. Noua dintre ele incep cu „Intotdeauna" sau
|
||||
„Imediat": nu se rezolva din aplicatie, orice explicatie i-ar da utilizatorului
|
||||
nu-l ajuta sa iasa din ea. Pentru astea raspunsul se compune din chiar campurile
|
||||
dictionarului, iar mesajul pleaca automat la suport.
|
||||
|
||||
De ce fara model: pe captura cu ORA-06550, avand in context exact intrarea care
|
||||
spune „nu se rezolva din aplicatie", modelul a raspuns „verifica schema de date
|
||||
din aplicatia ROA (...) sau foloseste procedura alternativa MI_pack_parteneri_old".
|
||||
A inventat o procedura, a cerut unui contabil sa umble in schema si a contrazis
|
||||
documentul. Textul din dictionar e deja scris pentru clienti; parafrazarea lui
|
||||
nu adauga nimic si poate strica tot.
|
||||
|
||||
3. **Cat e de urgent.** Aceeasi eroare poate bloca omul complet sau poate fi ocolita
|
||||
pana maine. Nu se deduce din text, deci se intreaba — iar raspunsul lui se
|
||||
ataseaza escaladarii deja deschise, nu porneste alta.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import rank
|
||||
|
||||
# Prima vorba din `cand suni suportul`. Restul („Daca revine dupa restart") sunt
|
||||
# conditionate: acolo utilizatorul are ce incerca, deci raspunde modelul.
|
||||
_INTOTDEAUNA = ("intotdeauna", "imediat")
|
||||
|
||||
_CAMP = re.compile(r"^([a-z ]+):\s*(.*)$")
|
||||
|
||||
# Cuvintele cu care oamenii anunta o problema fara sa spuna care.
|
||||
_ACUZA = re.compile(
|
||||
r"\b(eroare|erori|eroarea|problema|probleme|nu merge|nu functioneaza|"
|
||||
r"nu mai merge|s-a blocat|crapa|imi da|nu pot)\b"
|
||||
)
|
||||
|
||||
|
||||
def camp(chunk: str, nume: str) -> str:
|
||||
"""Valoarea unui camp din chunk-ul XML randat (`mesaj eroare: ...`)."""
|
||||
tinta = nume.replace("_", " ")
|
||||
for line in chunk.splitlines():
|
||||
m = _CAMP.match(line.strip())
|
||||
if m and m.group(1).strip() == tinta:
|
||||
return m.group(2).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def nivel_suport(chunk: str) -> str | None:
|
||||
"""`"intotdeauna"` / `"imediat"` daca eroarea cere suport oricum, altfel `None`."""
|
||||
valoare = camp(chunk, "cand suni suportul").lower()
|
||||
for cuvant in _INTOTDEAUNA:
|
||||
if valoare.startswith(cuvant):
|
||||
return cuvant
|
||||
return None
|
||||
|
||||
|
||||
def prea_vag(text: str, are_imagine: bool) -> bool:
|
||||
"""Mesajul anunta o problema fara sa spuna care.
|
||||
|
||||
Deliberat ingust: doar text scurt, fara captura si fara cod de eroare. O
|
||||
intrebare adevarata („cum trimit D406") nu contine cuvintele de acuza, iar una
|
||||
cu un cod are deja tot ce trebuie ca sa fie cautata.
|
||||
"""
|
||||
if are_imagine:
|
||||
return False
|
||||
t = rank.fold(text.strip())
|
||||
if not t or rank.codes(t):
|
||||
return False
|
||||
if not _ACUZA.search(t):
|
||||
return False
|
||||
return len(rank.tokenize(t)) <= 12
|
||||
|
||||
|
||||
CERE_DETALII = (
|
||||
"Ca sa-mi dau seama despre ce e vorba, spune-mi te rog:\n"
|
||||
"1. ce faceai exact cand a aparut (ce operatiune),\n"
|
||||
"2. in ce ecran sau meniu erai,\n"
|
||||
"3. ce scrie exact in mesaj — cel mai simplu, trimite o captura de ecran cu "
|
||||
"fereastra de eroare."
|
||||
)
|
||||
|
||||
# Ce se trimite la o eroare care oricum ajunge la programatori. Explicatia si
|
||||
# indicatia sunt luate ca atare din dictionar, nu reformulate.
|
||||
_URGENT = ("Este o eroare care afecteaza toti utilizatorii, nu doar pe tine, asa ca "
|
||||
"am anuntat echipa tehnica imediat si i-am trimis {ce}")
|
||||
_NORMAL = "Am trimis {ce} mai departe, catre echipa tehnica"
|
||||
|
||||
INTREBARE_URGENTA = (
|
||||
"Spune-mi te rog daca te blocheaza complet sau poti continua pe alta "
|
||||
"operatiune — asa stiu cat e de urgent."
|
||||
)
|
||||
|
||||
|
||||
def raspuns_escaladat(chunk: str, nivel: str, ref: str, notificat: bool,
|
||||
cu_imagine: bool = False) -> str:
|
||||
"""Raspunsul pentru o eroare care oricum ajunge la programatori.
|
||||
|
||||
Compus din campurile dictionarului, fara model: textul de acolo e deja scris
|
||||
pentru clienti, iar aici nu e nimic de dedus — doar de spus limpede.
|
||||
"""
|
||||
parti: list[str] = []
|
||||
explicatie = camp(chunk, "ce inseamna")
|
||||
if explicatie:
|
||||
parti.append(explicatie)
|
||||
rezolvare = camp(chunk, "rezolvare")
|
||||
if rezolvare:
|
||||
parti.append(rezolvare)
|
||||
|
||||
ce = "mesajul si captura" if cu_imagine else "mesajul"
|
||||
trimis = (_URGENT if nivel == "imediat" else _NORMAL).format(ce=ce)
|
||||
if notificat:
|
||||
parti.append(f"{trimis} (referinta {ref}). Te contacteaza cineva.")
|
||||
else:
|
||||
# Nu promitem ce nu s-a intamplat: notificarea a esuat, dar e inregistrata.
|
||||
parti.append(
|
||||
f"Am inregistrat problema pentru echipa tehnica (referinta {ref}), dar "
|
||||
f"nu am putut sa o trimit chiar acum. Daca te blocheaza, suna-ne."
|
||||
)
|
||||
parti.append(INTREBARE_URGENTA)
|
||||
return "\n\n".join(parti)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Triajul: cand se cer detalii, cand pleaca automat la programatori."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import triaj
|
||||
|
||||
|
||||
CHUNK_06550 = """[eroare ORA 06550]
|
||||
keywords: ORA-06550 PLS-00201 eroare compilare
|
||||
mesaj eroare: ORA-06550: eroare de compilare in program
|
||||
ce inseamna: Programul nu e adus la zi peste tot.
|
||||
rezolvare: Nu se rezolva din aplicatie si nu ajuta sa incerci din nou.
|
||||
cand suni suportul: Intotdeauna. Trimite si o captura de ecran."""
|
||||
|
||||
CHUNK_04068 = """[eroare ORA 04068]
|
||||
mesaj eroare: ORA-04068: existing state of packages has been discarded
|
||||
ce inseamna: Programul a fost actualizat in timp ce aplicatia ta era deschisa.
|
||||
rezolvare: Inchide complet aplicatia si redeschide-o.
|
||||
cand suni suportul: Daca revine si dupa restartul aplicatiei."""
|
||||
|
||||
|
||||
def test_nivelul_se_citeste_din_dictionar():
|
||||
assert triaj.nivel_suport(CHUNK_06550) == "intotdeauna"
|
||||
assert triaj.nivel_suport(CHUNK_04068) is None
|
||||
assert triaj.nivel_suport("cand suni suportul: Imediat. Blocheaza tot.") == "imediat"
|
||||
|
||||
|
||||
def test_raspunsul_nu_vorbeste_ca_pentru_programatori():
|
||||
"""Cazul din 2026-09-01: modelul ii cerea unui contabil sa verifice schema."""
|
||||
text = triaj.raspuns_escaladat(CHUNK_06550, "intotdeauna", "M-260901-A1B2",
|
||||
True, cu_imagine=True)
|
||||
jos = text.lower()
|
||||
for cuvant in ("procedura", "schema", "pl/sql", "obiect", "variabila", "compilare"):
|
||||
assert cuvant not in jos, cuvant
|
||||
assert "M-260901-A1B2" in text
|
||||
assert "captura" in jos
|
||||
assert "blocheaza complet" in jos # intreaba cat e de urgent
|
||||
|
||||
|
||||
def test_cand_notificarea_nu_a_plecat_nu_promitem_ca_a_plecat():
|
||||
text = triaj.raspuns_escaladat(CHUNK_06550, "intotdeauna", "M-1", False)
|
||||
assert "am inregistrat" in text.lower()
|
||||
assert "te contacteaza cineva" not in text.lower()
|
||||
|
||||
|
||||
def test_prea_vag_doar_pentru_acuzatii_fara_continut():
|
||||
assert triaj.prea_vag("am o eroare", False)
|
||||
assert triaj.prea_vag("nu merge nimic", False)
|
||||
# o intrebare adevarata nu se opreste ca sa i se ceara detalii
|
||||
assert not triaj.prea_vag("cum trimit declaratia D406 SAF-T", False)
|
||||
# un cod inseamna ca avem deja dupa ce cauta
|
||||
assert not triaj.prea_vag("imi da ORA-01722 la salvare", False)
|
||||
# captura vorbeste de la sine
|
||||
assert not triaj.prea_vag("am o eroare", True)
|
||||
|
||||
|
||||
# --- completarea la o escaladare deschisa -----------------------------------
|
||||
|
||||
def _escaladare(ref: str) -> None:
|
||||
import config
|
||||
d = config.STATE_DIR / "escalations"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / f"{int(time.time())}-{ref}.json").write_text(
|
||||
json.dumps({"ref": ref, "text": "eroare initiala"}), encoding="utf-8")
|
||||
|
||||
|
||||
def test_raspunsul_despre_urgenta_intra_in_escaladarea_deschisa(monkeypatch):
|
||||
import config
|
||||
import consumer
|
||||
|
||||
monkeypatch.setattr(config, "get", lambda k, d=None: "" if k == "SUPPORT_JID"
|
||||
else config.DEFAULTS.get(k, d))
|
||||
_escaladare("M-TEST-1")
|
||||
consumer.pending_set("40712@s.whatsapp.net", "M-TEST-1")
|
||||
assert consumer.pending_get("40712@s.whatsapp.net")["ref"] == "M-TEST-1"
|
||||
|
||||
assert consumer.append_followup("M-TEST-1", {"id": "x"}, "ma blocheaza, nu pot factura")
|
||||
|
||||
fisier = next((config.STATE_DIR / "escalations").glob("*-M-TEST-1.json"))
|
||||
record = json.loads(fisier.read_text(encoding="utf-8"))
|
||||
assert record["completari"][0]["text"] == "ma blocheaza, nu pot factura"
|
||||
|
||||
consumer.pending_clear("40712@s.whatsapp.net")
|
||||
assert consumer.pending_get("40712@s.whatsapp.net") is None
|
||||
|
||||
|
||||
def test_completarea_expira(monkeypatch):
|
||||
import consumer
|
||||
consumer.pending_set("40799@s.whatsapp.net", "M-TEST-2")
|
||||
peste_o_ora = time.time() + consumer.PENDING_TTL_S + 1
|
||||
monkeypatch.setattr(consumer.time, "time", lambda: peste_o_ora)
|
||||
assert consumer.pending_get("40799@s.whatsapp.net") is None
|
||||
Reference in New Issue
Block a user