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:
@@ -167,6 +167,36 @@ def maria_documents() -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def maria_escalations(limit: int = 12) -> list[dict]:
|
||||
"""Intrebarile pe care Maria le-a trimis la suport, cele mai noi primele.
|
||||
|
||||
Sursa: fisierele scrise de `rag/consumer.py:escalate`. Se citesc de pe disc la
|
||||
fiecare cerere — sunt putine si e mai bine decat inca o stare de tinut sincron.
|
||||
"""
|
||||
d = MARIA_STATE_DIR / "escalations"
|
||||
if not d.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for f in sorted(d.glob("*.json"), reverse=True)[:limit]:
|
||||
try:
|
||||
rec = json.loads(f.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
out.append({
|
||||
"ref": rec.get("ref"),
|
||||
"at": rec.get("at"),
|
||||
"from": rec.get("push_name") or rec.get("from"),
|
||||
"reason": rec.get("reason"),
|
||||
"best_cosine": rec.get("best_cosine"),
|
||||
"had_image": rec.get("had_image"),
|
||||
"notified": rec.get("notified"),
|
||||
"notify_error": rec.get("notify_error"),
|
||||
# textul citit din captura, daca a fost una: exact ce a "vazut" Maria
|
||||
"text": (rec.get("ocr_text") or rec.get("text") or "")[:600],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _maria_validate_name(name: str) -> str:
|
||||
if not name or not _MARIA_SAFE_NAME.match(name) or ".." in name or "/" in name:
|
||||
raise ValueError(f"nume de document invalid: {name!r}")
|
||||
@@ -696,9 +726,12 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
"index": maria_index_info(),
|
||||
"documents": len(maria_documents()),
|
||||
"sync": {**maria_sync_state(), "drive_remote": maria_get("DRIVE_REMOTE") or None},
|
||||
"support_jid": maria_get("SUPPORT_JID") or None,
|
||||
})
|
||||
if path == "/api/maria/documents":
|
||||
return self.send_json({"documents": maria_documents()})
|
||||
if path == "/api/maria/escalations":
|
||||
return self.send_json({"escalations": maria_escalations()})
|
||||
if path == "/api/maria/logs":
|
||||
try:
|
||||
n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000)
|
||||
|
||||
@@ -114,6 +114,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Trimise la suport <span class="count" id="mEscCount"></span></h2>
|
||||
<div class="detail" id="mSupportDetail"></div>
|
||||
<div id="mEsc" class="empty">se încarcă…</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Documente indexate <span class="count" id="mDocCount"></span></h2>
|
||||
<div class="detail" id="mIndexDetail"></div>
|
||||
@@ -434,6 +440,26 @@ async function mRefresh() {
|
||||
? 'Drive: ' + sync.drive_remote + (sync.synced_at ? ' · ultima sincronizare ' + new Date(sync.synced_at * 1000).toLocaleString('ro-RO') : ' · încă nesincronizat')
|
||||
: 'Sincronizare Drive dezactivată (DRIVE_REMOTE nesetat) — doar upload manual';
|
||||
|
||||
document.getElementById('mSupportDetail').textContent = s.support_jid
|
||||
? 'Canal de suport: ' + s.support_jid
|
||||
: 'SUPPORT_JID nesetat — escaladările se scriu doar în jurnal, nu anunță pe nimeni';
|
||||
|
||||
var escRes = await api('api/maria/escalations');
|
||||
var escaladari = escRes.ok ? (escRes.data.escalations || []) : [];
|
||||
document.getElementById('mEscCount').textContent = escaladari.length ? escaladari.length + ' recente' : '';
|
||||
document.getElementById('mEsc').className = escaladari.length ? '' : 'empty';
|
||||
document.getElementById('mEsc').innerHTML = escaladari.length ? escaladari.map(function (e) {
|
||||
var stare = e.notified
|
||||
? '<span class="pill ok">trimis</span>'
|
||||
: '<span class="pill bad">netrimis' + (e.notify_error ? ' — ' + esc(e.notify_error) : '') + '</span>';
|
||||
return '<div style="border-top:1px solid var(--border);padding:var(--space-4) 0">'
|
||||
+ '<div>' + stare + ' <code>' + esc(e.ref || '') + '</code> <strong>' + esc(e.from || '?') + '</strong> '
|
||||
+ '<span class="muted">' + (e.at ? new Date(e.at * 1000).toLocaleString('ro-RO') : '') + '</span>'
|
||||
+ (e.had_image ? ' <span class="muted">· captură</span>' : '') + '</div>'
|
||||
+ '<pre style="white-space:pre-wrap;margin:var(--space-3) 0;font-size:.85em">' + esc(e.text || '(fără text)') + '</pre>'
|
||||
+ '<div class="muted">' + esc(e.reason || '') + '</div></div>';
|
||||
}).join('') : 'nicio întrebare trimisă la suport';
|
||||
|
||||
var docsRes = await api('api/maria/documents');
|
||||
var docs = docsRes.ok ? (docsRes.data.documents || []) : [];
|
||||
var indexate = docs.filter(function (d) { return !d.shadowed_by; }).length;
|
||||
|
||||
@@ -70,6 +70,9 @@ rag/consumer.py -- polling la /messages, RAG stateless (FARA memorie intre mesa
|
||||
rag/ocr.py -- capturile de ecran -> text (tesseract), inainte de RAG
|
||||
|
|
||||
v
|
||||
rag/rank.py -- ordonare hibrida (embeddings + BM25) si decizia
|
||||
| "avem raspunsul in documente?"; daca nu, mesajul pleaca la suport
|
||||
v
|
||||
rag/store.py -- depozit documente (.txt/.md) in ~/.maria-bridge/documents/
|
||||
rag/indexer.py -- chunking + embeddings Ollama -> rag_index.json
|
||||
rag/sync.py -- rclone pull din Google Drive + reindexare conditionata
|
||||
@@ -187,6 +190,7 @@ puntii Discord — vezi `../discord-bridge/README.md` pentru URL si autentificar
|
||||
`/claude`). Acolo, sectiunea "Maria — WhatsApp + RAG":
|
||||
- start/stop/restart pentru puntea WhatsApp si consumer-ul RAG
|
||||
- starea conexiunii WhatsApp si asocierea (cod QR sau cod de 8 caractere), cand nu e conectat
|
||||
- ultimele intrebari trimise la suport, cu textul citit din captura si motivul
|
||||
- listare, adaugare si stergere documente din depozitul RAG
|
||||
- reconstruire index manual, sau sincronizare Drive imediata
|
||||
- ultimele linii din logurile fiecarui serviciu Maria (`whatsapp.log`/`rag.log`)
|
||||
@@ -245,6 +249,27 @@ ignorate deliberat: nu sunt cunostinte de suport.
|
||||
dosarului din Drive, nu o colectie care creste. Documentele adaugate manual din
|
||||
dashboard dispar la prima sincronizare daca nu exista si in Drive.
|
||||
|
||||
### Dictionarul de erori Oracle
|
||||
|
||||
`knowledge/oracle-erori-uzuale.xml` — 29 de erori Oracle uzuale (ORA-00001,
|
||||
ORA-01722, ORA-12154, ORA-28040, ORA-12954 …), fiecare cu ce inseamna, ce poate
|
||||
incerca utilizatorul singur si cand sa sune la suport. A fost scris fiindca
|
||||
utilizatorii trimit capturi cu erori Oracle, iar restul documentelor sunt despre
|
||||
e-Factura si SAF-T: modelul raspundea „ceva despre Oracle", dar nu despre eroarea
|
||||
din captura.
|
||||
|
||||
Doua lucruri de stiut:
|
||||
|
||||
- **E samanta, nu sursa.** Fisierul din git nu ajunge singur in index. Depozitul e
|
||||
o oglinda a Drive-ului, deci copia pusa direct in `~/.maria-bridge/documents/`
|
||||
**dispare la prima sincronizare**. Ca sa ramana, pune-l in `document_store` din
|
||||
Drive (pe Windows: `D:\GoogleDrive\romfast\document_store`).
|
||||
- **E scris pentru clienti, nu pentru administratori.** Nicio rezolvare nu contine
|
||||
nume de servere, IP-uri, porturi sau pasi de administrare — la erorile care chiar
|
||||
se rezolva doar tehnic (ORA-01652 spatiu plin, ORA-28000 cont blocat) textul spune
|
||||
explicit „suna la suport", nu explica ce sa faca pe server. Cand adaugi erori noi,
|
||||
pastreaza regula.
|
||||
|
||||
### Acelasi document in doua formate
|
||||
|
||||
Cand exista `X.xml` si `X.md`, **in index intra doar `.xml`** (ordinea de
|
||||
@@ -269,14 +294,106 @@ Dupa configurare, `maria-sync.timer` trage la fiecare 10 minute; reindexarea
|
||||
ruleaza DOAR daca s-a schimbat efectiv ceva in depozit (amprenta pe nume +
|
||||
mtime + marime, vezi `rag/sync.py`), ca sa nu reface embeddings degeaba.
|
||||
|
||||
Iar cand chiar reindexeaza, **refoloseste vectorii chunk-urilor nemodificate** din
|
||||
indexul precedent (`rag/indexer.py:_vectori_existenti`). Un embedding costa ~7
|
||||
secunde pe CPU: fara refolosire, adaugarea unui singur document la 170 de chunk-uri
|
||||
insemna 20 de minute de reconstruit tot. Cheia e chiar textul chunk-ului — daca nu
|
||||
s-a schimbat niciun caracter, vectorul e acelasi. Logul spune de fiecare data cate
|
||||
au fost calculate si cate refolosite.
|
||||
|
||||
## Teste
|
||||
|
||||
```bash
|
||||
cd /workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge
|
||||
python3 -m pytest # preferinta de format, taierea XML, OCR si mesajele cu imagine
|
||||
# — fara retea, fara Ollama si fara tesseract
|
||||
python3 -m pytest # format, taierea XML, OCR, mesajele cu imagine, ordonarea
|
||||
# hibrida si escaladarea — fara retea, fara Ollama, fara tesseract
|
||||
```
|
||||
|
||||
## Cum se ordoneaza rezultatele (rank)
|
||||
|
||||
Cautarea are doua etape. Intai **recall**: primele `RANK_RECALL_N` chunk-uri dupa
|
||||
cosinus (semantic) si primele dupa BM25 (lexical). Apoi **fuziune**: cele doua
|
||||
clasamente se combina prin Reciprocal Rank Fusion — un chunk urcat de ambele metode
|
||||
iese primul, unul urcat doar de una ramane in cursa.
|
||||
|
||||
De ce nu doar embeddings. Masurat pe indexul viu:
|
||||
|
||||
| Intrebare | Cel mai bun cosinus |
|
||||
|---|---|
|
||||
| „Nu exista nici un CIF pentru care sa aveti drept in SPV" (in documente) | 0,816 |
|
||||
| „cum trimit declaratia D406 SAF-T" (in documente) | 0,689 |
|
||||
| „imi da eroare la imprimanta HP LaserJet" (strain) | 0,641 |
|
||||
| „cum schimb uleiul la masina" (strain) | 0,534 |
|
||||
|
||||
Marginea dintre o intrebare buna si una straina e de cateva sutimi. Ce lipseste din
|
||||
cosinus e exact ce conteaza la suport: **codurile**. `ORA-01722`, `D406`, `CIF` sunt
|
||||
tokeni exacti — cautarea lexicala ii prinde, cea semantica ii topeste in „ceva despre
|
||||
facturi". La „bilant contabil" (cosinus 0,600) si „import de date" (0,635) partea
|
||||
lexicala e singura care le tine in joc.
|
||||
|
||||
Fuziunea da mereu un clasament, si la o intrebare complet straina. De aceea decizia
|
||||
**„avem sau nu acoperire"** se ia separat, pe dovezi:
|
||||
|
||||
1. cosinus ≥ `RANK_STRONG_COSINE` → raspundem;
|
||||
2. cosinus < `RANK_WEAK_COSINE` → escaladam direct;
|
||||
3. intre ele → raspundem doar daca cel putin `RANK_MIN_RARE_RATIO` din termenii
|
||||
distinctivi ai intrebarii (coduri, cuvinte rare in corpus) apar chiar in
|
||||
chunk-urile gasite.
|
||||
|
||||
Fractiunea din pasul 3 nu e cosmetica: cu un singur termen gasit, „cum imi resetez
|
||||
parola de la Windows" trecea drept acoperita fiindca „parola" apare in documente.
|
||||
|
||||
### Recalibrarea, cand se schimba documentele
|
||||
|
||||
Pragurile sunt masurate, nu alese din burta — si se **muta** cand se schimba
|
||||
depozitul. Exista un script pentru asta:
|
||||
|
||||
```bash
|
||||
MARIA_BRIDGE_DIR=$HOME/.maria-bridge ~/.maria-bridge/venv/bin/python ops/calibrate-rank.py
|
||||
MARIA_BRIDGE_DIR=$HOME/.maria-bridge ~/.maria-bridge/venv/bin/python ops/calibrate-rank.py --sweep
|
||||
```
|
||||
|
||||
Primul ruleaza setul de cazuri cu pragurile din `env` si arata ce ar gresi; al doilea
|
||||
matura o grila si listeaza combinatiile cu cele mai putine greseli. Embeddings-urile
|
||||
intrebarilor raman in cache (`~/.maria-bridge/calibrare-cache.json`) — pe CPU costa
|
||||
secunde fiecare. Setul de cazuri e in capul scriptului: **adauga acolo intrebarile
|
||||
reale la care Maria a gresit**, e singurul mod in care calibrarea ramane onesta.
|
||||
|
||||
## Cand nu stie raspunsul: escaladarea la suport
|
||||
|
||||
Un raspuns generic e mai rau decat niciun raspuns — utilizatorul pleaca cu impresia
|
||||
ca a primit ajutor. Cand `rank.assess` spune ca nu avem acoperire, Maria **nu mai
|
||||
intreaba modelul deloc**: raspunde ca trimite intrebarea la suport, si chiar o
|
||||
trimite.
|
||||
|
||||
Ce pleaca: cine a intrebat, textul (sau textul citit din captura), motivul deciziei
|
||||
si scorurile. Daca a fost o captura de ecran, **imaginea insasi se retrimite** la
|
||||
suport, cu rezumatul ca legenda — de aceea captura se sterge abia dupa ce mesajul e
|
||||
complet tratat, nu imediat dupa OCR.
|
||||
|
||||
Destinatia e `SUPPORT_JID` din `env`: `<numar>@s.whatsapp.net` pentru o persoana sau
|
||||
`<id>@g.us` pentru un grup. **Nesetat = nimeni nu e anuntat**, dar escaladarea tot se
|
||||
inregistreaza in `~/.maria-bridge/escalations/` si apare in dashboard, sectiunea
|
||||
„Trimise la suport". Jurnalul se scrie intotdeauna, si cand notificarea esueaza:
|
||||
altfel exact intrebarile fara raspuns ar disparea fara urma.
|
||||
|
||||
## Ce a citit Maria dintr-o captura
|
||||
|
||||
Textul OCR se logheaza **integral** in `~/.maria-bridge/logs/rag.log`, cu prefix
|
||||
`|`, urmat de interogarea folosita la cautare si de sursele alese cu scorurile lor:
|
||||
|
||||
```
|
||||
[consumer] OCR (703 caractere):
|
||||
| Eroare la salvarea facturii:
|
||||
| ORA-01722: invalid number
|
||||
[consumer] caut dupa: 'Eroare la salvarea facturii:\nORA-01722: invalid number'
|
||||
[consumer] rank: efactura_knowledge.md(0.771), d406_saft_knowledge.xml(0.62) | cosinus 0.771 >= 0.66
|
||||
```
|
||||
|
||||
Fara asta un raspuns gresit nu se poate explica: nu se stie daca a citit prost
|
||||
captura sau a cautat prost in documente. Aceleasi linii se vad in dashboard, la
|
||||
logurile serviciului `rag`.
|
||||
|
||||
## Context conversational
|
||||
|
||||
`rag/consumer.py` nu retine memorie intre mesaje — fiecare intrebare e o
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Dictionar de erori Oracle uzuale, pentru utilizatorii ROA.
|
||||
|
||||
ATENTIE, DOUA REGULI:
|
||||
|
||||
1. Acest fisier e SAMANTA, nu sursa. Depozitul de documente al Mariei
|
||||
(~/.maria-bridge/documents/) e o OGLINDA a dosarului din Google Drive:
|
||||
`rclone sync` sterge local ce nu exista in Drive. Ca sa ramana indexat,
|
||||
fisierul trebuie pus in `document_store` din Drive. Copia locala dispare
|
||||
la prima sincronizare.
|
||||
|
||||
2. Maria e chatbot pentru CLIENTII ROA. Rezolvarile de aici sunt scrise pentru
|
||||
un utilizator din fata aplicatiei: ce vede, ce poate incerca singur, cand sa
|
||||
sune la suport. Fara nume de servere, IP-uri, porturi, utilizatori de baza de
|
||||
date sau pasi de administrare — chiar daca eroarea are o cauza tehnica.
|
||||
|
||||
Structura: un <eroare_*> = un chunk in index (vezi rag/indexer.py).
|
||||
-->
|
||||
<oracle_erori_uzuale versiune="1.0" data_actualizare="2026-08-31" optimizat_pentru="RAG_Maria">
|
||||
|
||||
<eroare_ORA_00001>
|
||||
<keywords>ORA-00001 unique constraint violated restrictie unicitate duplicat exista deja</keywords>
|
||||
<mesaj_eroare>ORA-00001: unique constraint violated / restrictie de unicitate incalcata</mesaj_eroare>
|
||||
<ce_inseamna>Incerci sa salvezi ceva ce exista deja. Aplicatia nu accepta doua inregistrari cu aceeasi valoare intr-un camp care trebuie sa fie unic: acelasi numar de factura pe aceeasi serie, acelasi cod de produs, acelasi CUI de client.</ce_inseamna>
|
||||
<rezolvare>Verifica daca documentul nu a fost deja salvat — cauta-l in lista dupa numar sau dupa client. Daca exista, nu-l mai introduce a doua oara. Daca e un document nou, schimba valoarea duplicata (alt numar de document, alt cod). La facturi, verifica seria si urmatorul numar liber.</rezolvare>
|
||||
<cand_suni_suportul>Daca esti sigur ca inregistrarea nu exista si eroarea apare oricum, sau daca numerotarea automata a facturilor propune un numar deja folosit.</cand_suni_suportul>
|
||||
</eroare_ORA_00001>
|
||||
|
||||
<eroare_ORA_01722>
|
||||
<keywords>ORA-01722 invalid number numar invalid conversie text cifra virgula punct zecimal</keywords>
|
||||
<mesaj_eroare>ORA-01722: invalid number / numar incorect</mesaj_eroare>
|
||||
<ce_inseamna>Intr-un camp care asteapta un numar a ajuns text. Cel mai des: un spatiu in plus, o virgula in loc de punct (sau invers), o litera strecurata intre cifre, sau o valoare copiata din Excel impreuna cu formatarea ei.</ce_inseamna>
|
||||
<rezolvare>Uita-te la campurile numerice din ecranul unde ai primit eroarea: cantitate, pret, cota TVA, cod numeric. Sterge continutul si scrie-l din nou de la tastatura, fara spatii si fara separator de mii. Daca ai copiat date din Excel, lipeste-le ca text simplu si verifica-le. La import de fisiere, verifica coloana numerica din fisierul sursa.</rezolvare>
|
||||
<cand_suni_suportul>Daca eroarea apare fara ca tu sa fi introdus vreo valoare, sau la o operatiune automata (import, generare de raport).</cand_suni_suportul>
|
||||
</eroare_ORA_01722>
|
||||
|
||||
<eroare_ORA_01400>
|
||||
<keywords>ORA-01400 cannot insert NULL camp obligatoriu necompletat gol</keywords>
|
||||
<mesaj_eroare>ORA-01400: cannot insert NULL into ... / nu se poate insera NULL</mesaj_eroare>
|
||||
<ce_inseamna>Un camp obligatoriu a ramas necompletat. Aplicatia nu poate salva documentul fara el.</ce_inseamna>
|
||||
<rezolvare>Reia ecranul de la inceput si completeaza toate campurile marcate ca obligatorii. Numele campului apare de obicei chiar in textul erorii, dupa numele tabelei. Atentie la campurile care par completate dar contin doar spatii.</rezolvare>
|
||||
<cand_suni_suportul>Daca toate campurile vizibile sunt completate si eroarea persista — inseamna ca lipseste ceva ce nu se vede in ecran.</cand_suni_suportul>
|
||||
</eroare_ORA_01400>
|
||||
|
||||
<eroare_ORA_01438>
|
||||
<keywords>ORA-01438 value larger than specified precision valoare prea mare depaseste precizia</keywords>
|
||||
<mesaj_eroare>ORA-01438: value larger than specified precision allowed for this column</mesaj_eroare>
|
||||
<ce_inseamna>Numarul introdus e mai mare decat incape in campul respectiv — fie are prea multe cifre inainte de virgula, fie prea multe zecimale.</ce_inseamna>
|
||||
<rezolvare>Verifica valoarea introdusa: nu ai adaugat din greseala cifre in plus? Rotunjeste zecimalele la cate accepta campul (de regula 2 pentru sume, 3-4 pentru cantitati). Daca valoarea e corecta si chiar atat de mare, sun-o la suport — campul trebuie marit.</rezolvare>
|
||||
<cand_suni_suportul>Cand valoarea e reala si corecta, dar nu incape.</cand_suni_suportul>
|
||||
</eroare_ORA_01438>
|
||||
|
||||
<eroare_ORA_01858_01861>
|
||||
<keywords>ORA-01858 ORA-01861 a non-numeric character was found literal does not match format string data invalida format data</keywords>
|
||||
<mesaj_eroare>ORA-01858 / ORA-01861: formatul datei nu corespunde</mesaj_eroare>
|
||||
<ce_inseamna>O data calendaristica a fost scrisa intr-un format pe care aplicatia nu-l recunoaste (de exemplu 31/08/26 in loc de 31.08.2026).</ce_inseamna>
|
||||
<rezolvare>Foloseste selectorul de data (iconita de calendar) in loc sa scrii data de mana. Daca o scrii, respecta formatul afisat in celelalte campuri de data din acelasi ecran, cu anul din 4 cifre. La importuri, verifica formatul coloanei de data din fisier.</rezolvare>
|
||||
<cand_suni_suportul>Daca folosesti selectorul de calendar si eroarea apare oricum.</cand_suni_suportul>
|
||||
</eroare_ORA_01858_01861>
|
||||
|
||||
<eroare_ORA_02291>
|
||||
<keywords>ORA-02291 integrity constraint parent key not found cheie parinte inexistenta</keywords>
|
||||
<mesaj_eroare>ORA-02291: integrity constraint violated - parent key not found</mesaj_eroare>
|
||||
<ce_inseamna>Documentul se leaga de ceva ce nu exista (inca) in aplicatie: un client, un produs, o gestiune sau un cont care nu a fost creat ori a fost sters.</ce_inseamna>
|
||||
<rezolvare>Verifica ce ai selectat in campurile de legatura — client, articol, gestiune, cont contabil. Daca ai scris codul de mana, alege-l din lista in loc sa-l tastezi. Daca elementul chiar nu exista, creeaza-l intai in nomenclator si reia documentul.</rezolvare>
|
||||
<cand_suni_suportul>Daca elementul exista in nomenclator si eroarea apare oricum.</cand_suni_suportul>
|
||||
</eroare_ORA_02291>
|
||||
|
||||
<eroare_ORA_02292>
|
||||
<keywords>ORA-02292 child record found nu se poate sterge inregistrari copil dependente</keywords>
|
||||
<mesaj_eroare>ORA-02292: integrity constraint violated - child record found</mesaj_eroare>
|
||||
<ce_inseamna>Incerci sa stergi ceva ce e folosit in alta parte: un client care are facturi, un produs care apare pe documente, o gestiune cu miscari.</ce_inseamna>
|
||||
<rezolvare>Nu sterge elementul — dezactiveaza-l (marcheaza-l ca inactiv), ca sa nu mai apara in liste dar sa ramana pe documentele vechi. Stergerea e posibila doar daca nu a fost folosit niciodata.</rezolvare>
|
||||
<cand_suni_suportul>Daca trebuie neaparat sters si nu gasesti optiunea de dezactivare.</cand_suni_suportul>
|
||||
</eroare_ORA_02292>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<eroare_ORA_04068>
|
||||
<keywords>ORA-04068 existing state of packages has been discarded ORA-04061 ORA-06508</keywords>
|
||||
<mesaj_eroare>ORA-04068: existing state of packages has been discarded</mesaj_eroare>
|
||||
<ce_inseamna>Programul a fost actualizat in timp ce aplicatia ta era deschisa. Sesiunea ta lucra cu versiunea veche.</ce_inseamna>
|
||||
<rezolvare>Inchide complet aplicatia si redeschide-o. De regula eroarea nu mai apare. Nu e nevoie sa repeti operatiunea decat daca nu s-a salvat.</rezolvare>
|
||||
<cand_suni_suportul>Daca revine si dupa restartul aplicatiei.</cand_suni_suportul>
|
||||
</eroare_ORA_04068>
|
||||
|
||||
<eroare_ORA_00054>
|
||||
<keywords>ORA-00054 resource busy NOWAIT specified resursa ocupata blocat alt utilizator</keywords>
|
||||
<mesaj_eroare>ORA-00054: resource busy and acquire with NOWAIT specified</mesaj_eroare>
|
||||
<ce_inseamna>Altcineva lucreaza chiar acum pe aceleasi date si le tine blocate — sau chiar tu, intr-o alta fereastra deschisa a aplicatiei.</ce_inseamna>
|
||||
<rezolvare>Asteapta un minut si incearca din nou. Verifica daca nu ai acelasi document deschis in alta fereastra sau pe alt calculator. Intreaba colegii daca lucreaza pe acelasi document.</rezolvare>
|
||||
<cand_suni_suportul>Daca eroarea persista peste cateva minute si nimeni nu lucreaza pe acele date — probabil a ramas o sesiune blocata.</cand_suni_suportul>
|
||||
</eroare_ORA_00054>
|
||||
|
||||
<eroare_ORA_00060>
|
||||
<keywords>ORA-00060 deadlock detected while waiting for resource blocaj reciproc</keywords>
|
||||
<mesaj_eroare>ORA-00060: deadlock detected while waiting for resource</mesaj_eroare>
|
||||
<ce_inseamna>Doi utilizatori s-au blocat reciproc: fiecare asteapta ceva ce tine celalalt. Baza de date a rupt blocajul anuland una din operatiuni — cel mai probabil pe a ta.</ce_inseamna>
|
||||
<rezolvare>Verifica daca operatiunea s-a salvat. Daca nu, reia-o dupa un minut. Daca se intampla des la aceeasi operatiune, noteaza ora si ce faceati tu si colegii in acel moment.</rezolvare>
|
||||
<cand_suni_suportul>Daca se repeta zilnic sau la aceeasi operatiune.</cand_suni_suportul>
|
||||
</eroare_ORA_00060>
|
||||
|
||||
<eroare_ORA_01555>
|
||||
<keywords>ORA-01555 snapshot too old rollback segment raport lung</keywords>
|
||||
<mesaj_eroare>ORA-01555: snapshot too old</mesaj_eroare>
|
||||
<ce_inseamna>O operatiune lunga (de obicei un raport pe o perioada mare) a durat atat de mult incat datele pe care le citea s-au schimbat intre timp.</ce_inseamna>
|
||||
<rezolvare>Reia raportul pe o perioada mai scurta — luna cu luna in loc de un an intreg — sau ruleaza-l cand nu mai lucreaza nimeni in aplicatie (dimineata devreme, seara).</rezolvare>
|
||||
<cand_suni_suportul>Daca apare si la rapoarte scurte.</cand_suni_suportul>
|
||||
</eroare_ORA_01555>
|
||||
|
||||
<eroare_ORA_01652_01653>
|
||||
<keywords>ORA-01652 ORA-01653 ORA-01654 unable to extend temp segment tablespace spatiu plin</keywords>
|
||||
<mesaj_eroare>ORA-01652 / ORA-01653: unable to extend segment — spatiu insuficient</mesaj_eroare>
|
||||
<ce_inseamna>Baza de date nu mai are loc sa scrie. Nu e ceva ce ai facut tu gresit.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie. Opreste operatiunile mari (rapoarte pe perioade lungi, importuri) pana se rezolva, ca sa nu agravezi situatia.</rezolvare>
|
||||
<cand_suni_suportul>Imediat. E o eroare care blocheaza in scurt timp toti utilizatorii, nu doar pe tine.</cand_suni_suportul>
|
||||
</eroare_ORA_01652_01653>
|
||||
|
||||
<eroare_ORA_01017>
|
||||
<keywords>ORA-01017 invalid username password logon denied utilizator parola gresita</keywords>
|
||||
<mesaj_eroare>ORA-01017: invalid username/password; logon denied</mesaj_eroare>
|
||||
<ce_inseamna>Utilizatorul sau parola cu care aplicatia se conecteaza la baza de date nu sunt acceptate.</ce_inseamna>
|
||||
<rezolvare>Verifica intai ca ai scris corect parola — atentie la CapsLock si la tastatura pe alta limba. Daca aplicatia se conecta pana acum singura, fara sa-ti ceara parola, nu incerca sa o schimbi: anunta suportul.</rezolvare>
|
||||
<cand_suni_suportul>Daca parola e corecta, sau daca eroarea a aparut brusc fara sa fi schimbat nimeni nimic.</cand_suni_suportul>
|
||||
</eroare_ORA_01017>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<cand_suni_suportul>Imediat. E o operatiune de administrare.</cand_suni_suportul>
|
||||
</eroare_ORA_28001>
|
||||
|
||||
<eroare_ORA_01031>
|
||||
<keywords>ORA-01031 insufficient privileges drepturi insuficiente permisiuni</keywords>
|
||||
<mesaj_eroare>ORA-01031: insufficient privileges</mesaj_eroare>
|
||||
<ce_inseamna>Contul folosit nu are dreptul sa faca acea operatiune.</ce_inseamna>
|
||||
<rezolvare>Verifica daca esti conectat cu utilizatorul potrivit pentru ce incerci sa faci. Daca da, drepturile trebuie completate — nu e ceva ce se seteaza din aplicatie.</rezolvare>
|
||||
<cand_suni_suportul>Daca operatiunea face parte din munca ta obisnuita si mergea inainte.</cand_suni_suportul>
|
||||
</eroare_ORA_01031>
|
||||
|
||||
<eroare_ORA_12154>
|
||||
<keywords>ORA-12154 TNS could not resolve the connect identifier specified alias tns conexiune</keywords>
|
||||
<mesaj_eroare>ORA-12154: TNS:could not resolve the connect identifier specified</mesaj_eroare>
|
||||
<ce_inseamna>Calculatorul tau nu stie unde sa caute baza de date — ii lipseste sau i s-a stricat configurarea de conexiune. Apare tipic pe un calculator nou, sau dupa ce s-a reinstalat ceva.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie. Daca ai mai multe scurtaturi catre ROA, incearc-o pe cea pe care o foloseai inainte.</rezolvare>
|
||||
<cand_suni_suportul>Intotdeauna. Spune daca e un calculator nou sau daca s-a instalat/dezinstalat ceva recent.</cand_suni_suportul>
|
||||
</eroare_ORA_12154>
|
||||
|
||||
<eroare_ORA_12541>
|
||||
<keywords>ORA-12541 TNS no listener nu raspunde serverul</keywords>
|
||||
<mesaj_eroare>ORA-12541: TNS:no listener</mesaj_eroare>
|
||||
<ce_inseamna>Calculatorul stie unde e baza de date, dar acolo nu raspunde nimeni. De obicei serverul e oprit, repornit, sau in mentenanta.</ce_inseamna>
|
||||
<rezolvare>Asteapta cateva minute si incearca din nou — daca serverul tocmai reporneste, revine singur. Intreaba colegii daca si lor le da aceeasi eroare (daca da, e o problema generala, nu la calculatorul tau).</rezolvare>
|
||||
<cand_suni_suportul>Daca dureaza mai mult de cateva minute, sau daca esti singurul afectat.</cand_suni_suportul>
|
||||
</eroare_ORA_12541>
|
||||
|
||||
<eroare_ORA_12514>
|
||||
<keywords>ORA-12514 listener does not currently know of service requested serviciu indisponibil</keywords>
|
||||
<mesaj_eroare>ORA-12514: TNS:listener does not currently know of service requested</mesaj_eroare>
|
||||
<ce_inseamna>Serverul raspunde, dar baza de date pe care o cauta aplicatia nu e (inca) pornita acolo. Tipic in timpul unei reporniri: serverul e sus, baza de date inca se ridica.</ce_inseamna>
|
||||
<rezolvare>Asteapta 2-3 minute si incearca din nou.</rezolvare>
|
||||
<cand_suni_suportul>Daca dupa cateva minute eroarea persista.</cand_suni_suportul>
|
||||
</eroare_ORA_12514>
|
||||
|
||||
<eroare_ORA_12170>
|
||||
<keywords>ORA-12170 TNS connect timeout occurred conexiune expirata retea lenta</keywords>
|
||||
<mesaj_eroare>ORA-12170: TNS:Connect timeout occurred</mesaj_eroare>
|
||||
<ce_inseamna>Cererea nu a ajuns la baza de date in timpul asteptat. De obicei o problema de retea sau de VPN, nu de aplicatie.</ce_inseamna>
|
||||
<rezolvare>Verifica daca ai internet si daca VPN-ul e conectat (unde se lucreaza prin VPN). Incearca din nou. Daca lucrezi pe wireless, incearca pe cablu.</rezolvare>
|
||||
<cand_suni_suportul>Daca reteaua e in regula si eroarea revine.</cand_suni_suportul>
|
||||
</eroare_ORA_12170>
|
||||
|
||||
<eroare_ORA_12560>
|
||||
<keywords>ORA-12560 TNS protocol adapter error eroare adaptor protocol</keywords>
|
||||
<mesaj_eroare>ORA-12560: TNS:protocol adapter error</mesaj_eroare>
|
||||
<ce_inseamna>Conexiunea la baza de date nu a putut fi initiata deloc. Pe calculatorul unui utilizator inseamna de regula o configurare stricata sau un serviciu oprit.</ce_inseamna>
|
||||
<rezolvare>Restarteaza calculatorul si incearca din nou — rezolva o buna parte din cazuri.</rezolvare>
|
||||
<cand_suni_suportul>Daca persista dupa restart.</cand_suni_suportul>
|
||||
</eroare_ORA_12560>
|
||||
|
||||
<eroare_ORA_03113_03135>
|
||||
<keywords>ORA-03113 end-of-file on communication channel ORA-03135 connection lost contact conexiune pierduta</keywords>
|
||||
<mesaj_eroare>ORA-03113 / ORA-03135: conexiunea cu baza de date s-a intrerupt</mesaj_eroare>
|
||||
<ce_inseamna>Legatura s-a rupt in timpul lucrului: retea cazuta, VPN picat, calculator intrat in repaus, sau server repornit.</ce_inseamna>
|
||||
<rezolvare>Inchide aplicatia si redeschide-o. VERIFICA daca ultima operatiune s-a salvat — la o conexiune rupta, ce nu era salvat se pierde. Daca lucrezi prin VPN sau wireless, verifica-le.</rezolvare>
|
||||
<cand_suni_suportul>Daca se intampla des in aceeasi zi, sau daca apare la aceeasi operatiune de fiecare data.</cand_suni_suportul>
|
||||
</eroare_ORA_03113_03135>
|
||||
|
||||
<eroare_ORA_28040>
|
||||
<keywords>ORA-28040 no matching authentication protocol client vechi versiune incompatibila</keywords>
|
||||
<mesaj_eroare>ORA-28040: No matching authentication protocol</mesaj_eroare>
|
||||
<ce_inseamna>Programul de pe calculatorul tau e o versiune prea veche pentru baza de date la care incearca sa se conecteze. Apare tipic dupa ce baza de date a fost mutata pe o versiune noua, iar calculatorul a ramas cu instalarea veche.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie — trebuie actualizata partea instalata pe calculatorul tau.</rezolvare>
|
||||
<cand_suni_suportul>Intotdeauna. Mentioneaza daca s-a facut recent o mutare sau o actualizare a bazei de date.</cand_suni_suportul>
|
||||
</eroare_ORA_28040>
|
||||
|
||||
<eroare_ORA_12954>
|
||||
<keywords>ORA-12954 database size limit exceeded limita dimensiune baza de date Express Edition</keywords>
|
||||
<mesaj_eroare>ORA-12954: The request exceeds the maximum allowed database size</mesaj_eroare>
|
||||
<ce_inseamna>Baza de date a atins limita maxima de dimensiune pe care o permite licenta ei. Nu se mai poate scrie nimic nou pana nu se elibereaza spatiu.</ce_inseamna>
|
||||
<rezolvare>Nu se rezolva din aplicatie. Opreste importurile si operatiunile mari pana la remediere.</rezolvare>
|
||||
<cand_suni_suportul>Imediat. Blocheaza toti utilizatorii, nu doar pe tine.</cand_suni_suportul>
|
||||
</eroare_ORA_12954>
|
||||
|
||||
<eroare_ORA_01013>
|
||||
<keywords>ORA-01013 user requested cancel of current operation operatiune anulata</keywords>
|
||||
<mesaj_eroare>ORA-01013: user requested cancel of current operation</mesaj_eroare>
|
||||
<ce_inseamna>Operatiunea a fost oprita — fie ai apasat tu Anulare/Escape, fie a depasit timpul maxim de asteptare al aplicatiei.</ce_inseamna>
|
||||
<rezolvare>Daca nu ai anulat tu, inseamna ca operatiunea dura prea mult: incearca pe o perioada mai scurta sau cu mai putine date. Verifica daca ceva s-a salvat pe jumatate.</rezolvare>
|
||||
<cand_suni_suportul>Daca aceeasi operatiune mergea inainte si acum expira de fiecare data.</cand_suni_suportul>
|
||||
</eroare_ORA_01013>
|
||||
|
||||
<eroare_generala_necunoscuta>
|
||||
<keywords>eroare Oracle necunoscuta ORA cod neidentificat ce fac ce trimit la suport</keywords>
|
||||
<mesaj_eroare>Un cod ORA- care nu se afla in lista de mai sus</mesaj_eroare>
|
||||
<ce_inseamna>Erorile Oracle incep cu ORA- urmat de 5 cifre. Codul e partea care conteaza: dupa el se identifica problema exact.</ce_inseamna>
|
||||
<rezolvare>Trimite o captura de ecran cu FEREASTRA INTREAGA de eroare, nu doar prima linie — mesajele Oracle vin adesea in cascada, iar cauza reala e in ultima linie, nu in prima. Noteaza si ce faceai exact cand a aparut si daca se repeta de fiecare data.</rezolvare>
|
||||
<cand_suni_suportul>Cand codul nu e in lista, sau cand raspunsul de aici nu se potriveste cu situatia ta.</cand_suni_suportul>
|
||||
</eroare_generala_necunoscuta>
|
||||
|
||||
</oracle_erori_uzuale>
|
||||
154
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py
Executable file
154
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/calibrate-rank.py
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Calibreaza pragurile de escaladare pe indexul REAL, nu pe intuitie.
|
||||
|
||||
De ce exista: pragul care decide "avem raspunsul in documente" nu se poate alege
|
||||
din burta. Marginea dintre o intrebare buna si una straina e de cateva sutimi de
|
||||
cosinus si se MUTA cand se schimba documentele — un prag bun azi poate escalada
|
||||
maine jumatate din intrebarile legitime.
|
||||
|
||||
Cum se foloseste:
|
||||
|
||||
MARIA_BRIDGE_DIR=$HOME/.maria-bridge ~/.maria-bridge/venv/bin/python \\
|
||||
ops/calibrate-rank.py # raport pe setul de cazuri
|
||||
... ops/calibrate-rank.py --sweep # matura grila de praguri
|
||||
... ops/calibrate-rank.py --cases fisier.json
|
||||
|
||||
Embeddings-urile intrebarilor se tin in cache pe disc (`~/.maria-bridge/
|
||||
calibrare-cache.json`): pe CPU, o intrebare costa secunde, iar maturarea grilei
|
||||
le-ar recalcula de zeci de ori degeaba.
|
||||
|
||||
Setul de cazuri e deliberat mic si scris de mana. Nu e un benchmark, e o plasa de
|
||||
siguranta: daca un prag nou strica un caz care mergea, se vede imediat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "rag"))
|
||||
|
||||
import config # noqa: E402
|
||||
import consumer # noqa: E402
|
||||
import rank # noqa: E402
|
||||
|
||||
# (intrebare, are raspuns in documente?)
|
||||
CAZURI: list[tuple[str, bool]] = [
|
||||
("token efactura expirat", True),
|
||||
("Nu exista nici un CIF pentru care sa aveti drept in SPV", True),
|
||||
("cum trimit declaratia D406 SAF-T", True),
|
||||
("eroare la semnatura electronica in SPV", True),
|
||||
("nu pot incarca factura in SPV, imi da eroare de certificat", True),
|
||||
("cum inregistrez o factura de achizitie in ROA", True),
|
||||
("bilant contabil", True),
|
||||
("import de date", True),
|
||||
("care e capitala Frantei", False),
|
||||
("cat costa un bilet de avion la Paris", False),
|
||||
("imi da eroare la imprimanta HP LaserJet, nu trage hartia", False),
|
||||
("cum schimb uleiul la masina", False),
|
||||
("vremea de maine in Bucuresti", False),
|
||||
("cum imi resetez parola de la Windows", False),
|
||||
("imi da eroare 0x80070005 la instalarea unui joc pe Steam", False),
|
||||
]
|
||||
|
||||
CACHE = config.STATE_DIR / "calibrare-cache.json"
|
||||
|
||||
|
||||
def incarca_cache() -> dict[str, list[float]]:
|
||||
try:
|
||||
return json.loads(CACHE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def pregateste(cazuri: list[tuple[str, bool]]) -> tuple[consumer.Index, list[dict]]:
|
||||
index = consumer.load_index()
|
||||
if not len(index):
|
||||
sys.exit(f"index gol sau lipsa: {config.INDEX_FILE}")
|
||||
cache = incarca_cache()
|
||||
date = []
|
||||
nou = 0
|
||||
for intrebare, asteptat in cazuri:
|
||||
vec = cache.get(intrebare)
|
||||
if vec is None:
|
||||
print(f" embed: {intrebare[:50]}…", file=sys.stderr)
|
||||
vec = consumer.embed(intrebare)
|
||||
cache[intrebare] = vec
|
||||
nou += 1
|
||||
cos = [consumer.cosine(vec, e["embedding"]) for e in index.entries]
|
||||
bm = index.bm25.scores(intrebare)
|
||||
ordine = rank.rank(cos, bm)[: config.get_int("TOP_K", 3)]
|
||||
date.append({
|
||||
"intrebare": intrebare,
|
||||
"asteptat": asteptat,
|
||||
"best": max(cos),
|
||||
"top_texts": [index.texts[i] for i in ordine],
|
||||
"top_surse": [index.sources[i] for i in ordine],
|
||||
})
|
||||
if nou:
|
||||
CACHE.write_text(json.dumps(cache), encoding="utf-8")
|
||||
print(f" ({nou} embeddings noi puse in cache)", file=sys.stderr)
|
||||
return index, date
|
||||
|
||||
|
||||
def evalueaza(index: consumer.Index, date: list[dict], strong: float, weak: float,
|
||||
min_ratio: float) -> tuple[int, list[str]]:
|
||||
corecte, gresite = 0, []
|
||||
for d in date:
|
||||
ok, _ = rank.assess(
|
||||
d["intrebare"], d["best"], d["top_texts"], index.bm25,
|
||||
strong=strong, weak=weak, min_ratio=min_ratio,
|
||||
)
|
||||
if ok == d["asteptat"]:
|
||||
corecte += 1
|
||||
else:
|
||||
gresite.append(f"{'ar fi raspuns' if ok else 'ar fi escaladat'}: {d['intrebare']}")
|
||||
return corecte, gresite
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--sweep", action="store_true", help="matura grila de praguri")
|
||||
ap.add_argument("--cases", help="JSON cu [[intrebare, true/false], ...]")
|
||||
args = ap.parse_args()
|
||||
|
||||
cazuri = CAZURI
|
||||
if args.cases:
|
||||
cazuri = [(q, bool(a)) for q, a in json.loads(pathlib.Path(args.cases).read_text())]
|
||||
|
||||
index, date = pregateste(cazuri)
|
||||
print(f"index: {len(index)} chunk-uri | cazuri: {len(date)}\n")
|
||||
|
||||
if args.sweep:
|
||||
rezultate = []
|
||||
for strong in (0.62, 0.66, 0.68, 0.70, 0.72, 0.75):
|
||||
for weak in (0.54, 0.58, 0.60):
|
||||
for ratio in (0.0, 0.34, 0.5, 0.67, 1.0):
|
||||
if weak >= strong:
|
||||
continue
|
||||
corecte, _ = evalueaza(index, date, strong, weak, ratio)
|
||||
rezultate.append((corecte, strong, weak, ratio))
|
||||
rezultate.sort(key=lambda r: (-r[0], r[1]))
|
||||
print(f"{'corecte':>8} strong weak ratio")
|
||||
for corecte, strong, weak, ratio in rezultate[:15]:
|
||||
print(f"{corecte:>5}/{len(date)} {strong:.2f} {weak:.2f} {ratio:.2f}")
|
||||
return
|
||||
|
||||
strong, weak = rank.strong_cosine(), rank.weak_cosine()
|
||||
ratio = rank.min_rare_ratio()
|
||||
print(f"praguri curente: strong={strong} weak={weak} ratio={ratio}\n")
|
||||
for d in date:
|
||||
ok, motiv = rank.assess(d["intrebare"], d["best"], d["top_texts"], index.bm25)
|
||||
semn = "OK " if ok == d["asteptat"] else "GRESIT"
|
||||
stare = "ACOPERIT " if ok else "ESCALADAT"
|
||||
print(f"{semn} [{stare}] {d['intrebare'][:44]:46} {motiv}")
|
||||
corecte, gresite = evalueaza(index, date, strong, weak, ratio)
|
||||
print(f"\n{corecte}/{len(date)} corecte")
|
||||
for g in gresite:
|
||||
print(f" - {g}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,6 +21,29 @@ OCR_LANGS=ron+eng
|
||||
OCR_MAX_CHARS=1500
|
||||
OCR_TIMEOUT_S=60
|
||||
|
||||
# --- Ordonare (rank) si pragul de escaladare ---------------------------------
|
||||
# Chunk-urile se ordoneaza de doua ori — semantic (embeddings) si lexical (BM25) —
|
||||
# iar clasamentele se fuzioneaza. Vezi rag/rank.py; valorile de mai jos sunt
|
||||
# calibrate pe indexul real, nu alese din burta.
|
||||
# Peste RANK_STRONG_COSINE raspundem din documente fara alte conditii; sub
|
||||
# RANK_WEAK_COSINE escaladam direct; intre ele, raspundem doar daca termenii rari
|
||||
# din intrebare (coduri de eroare, in general) chiar apar in chunk-urile gasite.
|
||||
RANK_STRONG_COSINE=0.70
|
||||
RANK_WEAK_COSINE=0.58
|
||||
RANK_RECALL_N=12
|
||||
# Ce fractiune din termenii distinctivi ai intrebarii trebuie sa apara in
|
||||
# chunk-urile gasite, ca sa salvam un cosinus de mijloc. Un singur termen gasit
|
||||
# nu e o dovada — vezi ops/calibrate-rank.py.
|
||||
RANK_MIN_RARE_RATIO=0.5
|
||||
|
||||
# --- Escaladare la suport ----------------------------------------------------
|
||||
# Unde pleaca intrebarile la care Maria NU are raspuns in documente. Fara asta ar
|
||||
# raspunde generic, ceea ce e mai rau decat sa nu raspunda deloc.
|
||||
# JID de WhatsApp: "<numar>@s.whatsapp.net" pentru o persoana,
|
||||
# "<id>@g.us" pentru un grup. Numarul se scrie international, fara "+".
|
||||
# Gol = escaladarile se scriu doar in ~/.maria-bridge/escalations/, fara notificare.
|
||||
SUPPORT_JID=
|
||||
|
||||
# --- Backend LLM + embeddings -----------------------------------------------
|
||||
# Modelul de chat folosit pentru raspunsuri (format compatibil OpenAI
|
||||
# /v1/chat/completions). Implicit presupune un tunel/proxy local catre acelasi
|
||||
|
||||
@@ -41,6 +41,16 @@ DEFAULTS: dict[str, str] = {
|
||||
"OCR_LANGS": "ron+eng",
|
||||
"OCR_MAX_CHARS": "1500",
|
||||
"OCR_TIMEOUT_S": "60",
|
||||
# Ordonarea chunk-urilor si pragul de la care se considera ca raspunsul chiar
|
||||
# e in documente (vezi rag/rank.py pentru masuratoarea din spatele valorilor).
|
||||
"RANK_STRONG_COSINE": "0.70",
|
||||
"RANK_WEAK_COSINE": "0.58",
|
||||
"RANK_RECALL_N": "12",
|
||||
"RANK_MIN_RARE_RATIO": "0.5",
|
||||
# Unde pleaca intrebarile fara raspuns: JID de WhatsApp, ex.
|
||||
# "40712345678@s.whatsapp.net" sau "1203...@g.us" pentru un grup.
|
||||
# Gol = escaladarile se scriu doar in jurnal (~/.maria-bridge/escalations/).
|
||||
"SUPPORT_JID": "",
|
||||
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
|
||||
# "gdrive,root_folder_id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O:" (dosarul
|
||||
# document_store din Drive, vazut pe Windows ca D:\GoogleDrive\romfast\document_store).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -131,16 +131,40 @@ def embed(text: str) -> list[float]:
|
||||
return resp.json()["embedding"]
|
||||
|
||||
|
||||
def _vectori_existenti() -> dict[str, list[float]]:
|
||||
"""Embeddings-urile din indexul curent, dupa textul chunk-ului.
|
||||
|
||||
Un embedding pe CPU costa ~7 secunde. Fara asta, adaugarea unui singur
|
||||
document reface toate embeddings-urile — la 170 de chunk-uri inseamna 20 de
|
||||
minute ca sa se schimbe unul. Textul chunk-ului e cheia potrivita: daca nu s-a
|
||||
schimbat niciun caracter, vectorul e acelasi.
|
||||
|
||||
Sursa e chiar indexul precedent, nu un al doilea fisier de cache: nu are ce sa
|
||||
se desincronizeze, si daca indexul lipseste se recalculeaza tot, ca inainte.
|
||||
"""
|
||||
try:
|
||||
vechi = json.loads(config.INDEX_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return {e["text"]: e["embedding"] for e in vechi if e.get("text") and e.get("embedding")}
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
entries = []
|
||||
warnings: list[str] = []
|
||||
docs = store.documents_for_index()
|
||||
refolosite = _vectori_existenti()
|
||||
reutilizari = 0
|
||||
for doc in docs:
|
||||
text = store.read_document(doc["name"])
|
||||
if doc["name"].endswith(".xml") and chunk_xml(text) is None:
|
||||
warnings.append(f"{doc['name']}: XML invalid, indexat ca text simplu")
|
||||
for i, chunk in enumerate(chunk_document(doc["name"], text)):
|
||||
vec = embed(chunk)
|
||||
vec = refolosite.get(chunk)
|
||||
if vec is None:
|
||||
vec = embed(chunk)
|
||||
else:
|
||||
reutilizari += 1
|
||||
entries.append({"source": doc["name"], "chunk": i, "text": chunk, "embedding": vec})
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Scriere atomica: consumer-ul reciteste fisierul la 30s si ar putea prinde
|
||||
@@ -148,7 +172,9 @@ def build() -> dict:
|
||||
tmp = config.INDEX_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, config.INDEX_FILE)
|
||||
out = {"documents": len(docs), "chunks": len(entries)}
|
||||
out = {"documents": len(docs), "chunks": len(entries),
|
||||
"embeddings_refolosite": reutilizari,
|
||||
"embeddings_noi": len(entries) - reutilizari}
|
||||
if warnings:
|
||||
out["warnings"] = warnings
|
||||
return out
|
||||
@@ -164,6 +190,8 @@ if __name__ == "__main__":
|
||||
for w in result.get("warnings", []):
|
||||
print(f"[indexer] ATENTIE {w}", file=sys.stderr)
|
||||
print(
|
||||
f"[indexer] {result['documents']} documente, {result['chunks']} chunk-uri -> {config.INDEX_FILE}",
|
||||
f"[indexer] {result['documents']} documente, {result['chunks']} chunk-uri "
|
||||
f"({result['embeddings_noi']} embeddings noi, {result['embeddings_refolosite']} refolosite)"
|
||||
f" -> {config.INDEX_FILE}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
209
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py
Normal file
209
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/rank.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ordonarea chunk-urilor: embeddings + potrivire lexicala, fuzionate.
|
||||
|
||||
De ce nu doar embeddings. Masurat pe indexul viu (140 de chunk-uri), scorul
|
||||
cosinus al celui mai bun chunk a fost 0,600-0,816 la intrebari care CHIAR au
|
||||
raspuns in documente si 0,534-0,685 la intrebari straine de ROA. Intervalele SE
|
||||
SUPRAPUN, deci niciun prag pe cosinus singur nu le separa: „bilant contabil"
|
||||
(0,600, buna) sta sub „eroare 0x80070005 la instalarea unui joc pe Steam" (0,685,
|
||||
straina).
|
||||
|
||||
Ce lipseste din cosinus e exact ce conteaza la suport: **codurile**. `ORA-01722`,
|
||||
`D406`, `CIF`, `SPV` sunt tokeni exacti — o cautare lexicala ii prinde imediat, iar
|
||||
una semantica ii topeste in "ceva despre facturi". Asa ca ordonam de doua ori,
|
||||
independent, si fuzionam clasamentele (Reciprocal Rank Fusion): un chunk urcat de
|
||||
ambele metode iese primul, iar unul urcat doar de una ramane in cursa.
|
||||
|
||||
Fuziunea da mereu un clasament, si la o intrebare complet straina — de aceea
|
||||
decizia "avem sau nu acoperire in documente" se ia separat, in `assess()`, pe
|
||||
dovezi (cat de sus e cosinusul, si daca termenii rari din intrebare chiar apar in
|
||||
chunk), nu pe pozitia in clasament.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
import config
|
||||
|
||||
# Praguri implicite, calibrate pe masuratoarea din docstring. Se pot suprascrie
|
||||
# din env (vezi config.DEFAULTS) fara sa umbli in cod.
|
||||
RRF_K = 60 # constanta standard din Reciprocal Rank Fusion
|
||||
|
||||
|
||||
def strong_cosine() -> float:
|
||||
"""Peste atat, singur cosinusul e de ajuns (RANK_STRONG_COSINE)."""
|
||||
return float(config.get("RANK_STRONG_COSINE", "0.70"))
|
||||
|
||||
|
||||
def weak_cosine() -> float:
|
||||
"""Sub atat nu salvam raspunsul nici cu potrivire lexicala (RANK_WEAK_COSINE)."""
|
||||
return float(config.get("RANK_WEAK_COSINE", "0.58"))
|
||||
|
||||
|
||||
def recall_n() -> int:
|
||||
"""Cate chunk-uri intra in fuziune, din fiecare metoda (RANK_RECALL_N)."""
|
||||
return config.get_int("RANK_RECALL_N", 12)
|
||||
|
||||
|
||||
def min_rare_ratio() -> float:
|
||||
"""Ce fractiune din termenii distinctivi ai intrebarii trebuie gasita (RANK_MIN_RARE_RATIO).
|
||||
|
||||
Un singur termen gasit nu e o dovada: „cum imi resetez parola de la Windows" a
|
||||
fost salvata gresit doar fiindca „parola" apare in documente. Cerand o
|
||||
fractiune, intrebarea trebuie sa se suprapuna cu documentele, nu sa le atinga.
|
||||
"""
|
||||
return float(config.get("RANK_MIN_RARE_RATIO", "0.5"))
|
||||
|
||||
_TOKEN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
||||
|
||||
# Cuvinte prea frecvente ca sa spuna ceva despre subiect. Lista e scurta
|
||||
# deliberat: la BM25 idf-ul se ocupa oricum de restul.
|
||||
_STOP = {
|
||||
"si", "sa", "se", "la", "de", "din", "in", "pe", "cu", "pentru", "ca", "care",
|
||||
"este", "sunt", "am", "are", "nu", "un", "o", "al", "ale", "lui", "cum", "ce",
|
||||
"mi", "imi", "ma", "eu", "va", "vrea", "vreau", "poate", "pot", "the", "to",
|
||||
"a", "an", "of", "is", "it", "da", "dar", "sau", "mai", "fost", "face",
|
||||
}
|
||||
|
||||
# Coduri de eroare si formulare: valoreaza cat zece cuvinte obisnuite.
|
||||
_COD = re.compile(r"^(?:ora|pls|tns|sp2|imp|exp|ora)-\d+$|^d\d{3}$|^e\d{3,}$")
|
||||
|
||||
|
||||
def fold(text: str) -> str:
|
||||
"""Fara diacritice, litere mici. „Factură" si „factura" trebuie sa fie acelasi token."""
|
||||
norm = unicodedata.normalize("NFKD", text.lower())
|
||||
return "".join(c for c in norm if not unicodedata.combining(c))
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
return [t for t in _TOKEN.findall(fold(text)) if t not in _STOP and len(t) > 1]
|
||||
|
||||
|
||||
class Bm25:
|
||||
"""Okapi BM25 peste chunk-urile indexului.
|
||||
|
||||
Se reconstruieste la fiecare recitire a indexului (la 140 de chunk-uri costa
|
||||
milisecunde) — nu merita tinut in fisier si desincronizat de el.
|
||||
"""
|
||||
|
||||
def __init__(self, documents: list[str], k1: float = 1.5, b: float = 0.75):
|
||||
self.k1, self.b = k1, b
|
||||
self.docs = [tokenize(d) for d in documents]
|
||||
self.lens = [len(d) for d in self.docs]
|
||||
self.avg_len = (sum(self.lens) / len(self.lens)) if self.lens else 0.0
|
||||
self.freqs: list[dict[str, int]] = []
|
||||
self.df: dict[str, int] = {}
|
||||
for toks in self.docs:
|
||||
f: dict[str, int] = {}
|
||||
for t in toks:
|
||||
f[t] = f.get(t, 0) + 1
|
||||
self.freqs.append(f)
|
||||
for t in f:
|
||||
self.df[t] = self.df.get(t, 0) + 1
|
||||
self.n = len(self.docs)
|
||||
|
||||
def idf(self, term: str) -> float:
|
||||
df = self.df.get(term, 0)
|
||||
# varianta cu +0.5 (Robertson): termenii din peste jumatate din documente
|
||||
# ajung aproape de zero, ceea ce e exact ce vrem.
|
||||
return math.log(1 + (self.n - df + 0.5) / (df + 0.5))
|
||||
|
||||
def scores(self, query: str) -> list[float]:
|
||||
q = tokenize(query)
|
||||
out = [0.0] * self.n
|
||||
if not q or not self.avg_len:
|
||||
return out
|
||||
for i, f in enumerate(self.freqs):
|
||||
s = 0.0
|
||||
for t in q:
|
||||
tf = f.get(t, 0)
|
||||
if not tf:
|
||||
continue
|
||||
denom = tf + self.k1 * (1 - self.b + self.b * self.lens[i] / self.avg_len)
|
||||
s += self.idf(t) * tf * (self.k1 + 1) / denom
|
||||
out[i] = s
|
||||
return out
|
||||
|
||||
def rare_terms(self, query: str, max_df_ratio: float = 0.25) -> list[str]:
|
||||
"""Termenii din intrebare care chiar disting ceva: coduri, sau cuvinte rare in corpus."""
|
||||
out = []
|
||||
for t in dict.fromkeys(tokenize(query)):
|
||||
if _COD.match(t):
|
||||
out.append(t)
|
||||
elif len(t) >= 4 and self.df.get(t, 0) <= max(1, int(self.n * max_df_ratio)):
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def rrf(rankings: list[list[int]], k: int = RRF_K) -> dict[int, float]:
|
||||
"""Reciprocal Rank Fusion: suma de 1/(k+pozitie) peste clasamente.
|
||||
|
||||
Fuzioneaza POZITII, nu scoruri — cosinusul (0..1) si BM25 (nemarginit) nu se
|
||||
pot aduna direct fara o normalizare care ar depinde de corpus.
|
||||
"""
|
||||
fused: dict[int, float] = {}
|
||||
for ranking in rankings:
|
||||
for pos, idx in enumerate(ranking):
|
||||
fused[idx] = fused.get(idx, 0.0) + 1.0 / (k + pos + 1)
|
||||
return fused
|
||||
|
||||
|
||||
def rank(
|
||||
cosines: list[float],
|
||||
bm25_scores: list[float],
|
||||
recall: int | None = None,
|
||||
) -> list[int]:
|
||||
"""Indicii chunk-urilor, de la cel mai relevant, dupa fuziunea celor doua clasamente."""
|
||||
recall = recall_n() if recall is None else recall
|
||||
by_cos = sorted(range(len(cosines)), key=lambda i: cosines[i], reverse=True)[:recall]
|
||||
# chunk-urile cu scor BM25 zero nu au niciun token comun cu intrebarea: nu au
|
||||
# ce cauta in clasamentul lexical, doar ar dilua fuziunea cu pozitii arbitrare.
|
||||
by_bm = [i for i in sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)
|
||||
if bm25_scores[i] > 0][:recall]
|
||||
fused = rrf([by_cos, by_bm])
|
||||
return sorted(fused, key=lambda i: (fused[i], cosines[i]), reverse=True)
|
||||
|
||||
|
||||
def assess(
|
||||
query: str,
|
||||
best_cosine: float,
|
||||
top_texts: list[str],
|
||||
bm25: Bm25,
|
||||
*,
|
||||
strong: float | None = None,
|
||||
weak: float | None = None,
|
||||
min_ratio: float | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""(avem acoperire in documente?, motivul) — decizia de a raspunde sau de a escalada.
|
||||
|
||||
Trei cazuri, in ordine:
|
||||
1. cosinus mare -> raspundem, indiferent de lexical;
|
||||
2. cosinus la mijloc, dar o fractiune suficienta din termenii rari ai
|
||||
intrebarii apare chiar in chunk-uri (tipic: un cod de eroare) -> raspundem;
|
||||
3. altfel -> nu avem acoperire, intrebarea pleaca la suport.
|
||||
"""
|
||||
strong = strong_cosine() if strong is None else strong
|
||||
weak = weak_cosine() if weak is None else weak
|
||||
min_ratio = min_rare_ratio() if min_ratio is None else min_ratio
|
||||
if best_cosine >= strong:
|
||||
return True, f"cosinus {best_cosine:.3f} >= {strong}"
|
||||
if best_cosine < weak:
|
||||
return False, f"cosinus {best_cosine:.3f} < {weak}"
|
||||
rare = bm25.rare_terms(query)
|
||||
if not rare:
|
||||
return False, f"cosinus {best_cosine:.3f} la limita, iar intrebarea nu are termeni distinctivi"
|
||||
blob = fold("\n".join(top_texts))
|
||||
gasiti = [t for t in rare if t in blob]
|
||||
if gasiti and len(gasiti) / len(rare) >= min_ratio:
|
||||
return True, (
|
||||
f"cosinus {best_cosine:.3f} + {len(gasiti)}/{len(rare)} termeni distinctivi "
|
||||
f"in context: {', '.join(gasiti)}"
|
||||
)
|
||||
lipsa = [t for t in rare if t not in blob]
|
||||
return False, (
|
||||
f"cosinus {best_cosine:.3f} la limita, iar termenii distinctivi "
|
||||
f"({', '.join(lipsa[:5])}) nu apar in documente"
|
||||
)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Escaladarea la suport: jurnalul si notificarea.
|
||||
|
||||
Nu atinge reteaua — `requests.post` e inlocuit si se verifica CE s-ar fi trimis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
import consumer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def apeluri(monkeypatch):
|
||||
"""Prinde apelurile HTTP catre punte, fara sa le execute."""
|
||||
prinse = []
|
||||
|
||||
class Raspuns:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def fals_post(url, json=None, timeout=None):
|
||||
prinse.append((url, json))
|
||||
return Raspuns()
|
||||
|
||||
monkeypatch.setattr(consumer.requests, "post", fals_post)
|
||||
return prinse
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rezultat_fara_acoperire():
|
||||
return {
|
||||
"chunks": [], "covered": False, "best_cosine": 0.55,
|
||||
"reason": "cosinus 0.550 < 0.58",
|
||||
"top": [{"source": "efactura_knowledge.md", "cosine": 0.55, "bm25": 0.0}],
|
||||
}
|
||||
|
||||
|
||||
def _mesaj(**kw):
|
||||
baza = {"from": "40712345678@s.whatsapp.net", "pushName": "Ion",
|
||||
"id": "ABC123", "text": "cum schimb uleiul la masina"}
|
||||
baza.update(kw)
|
||||
return baza
|
||||
|
||||
|
||||
def test_jurnalul_se_scrie_si_fara_canal_de_suport(apeluri, rezultat_fara_acoperire, tmp_path):
|
||||
# SUPPORT_JID nesetat: nu notificam pe nimeni, dar intrebarea nu are voie sa
|
||||
# dispara fara urma — asta e chiar problema pe care escaladarea o rezolva.
|
||||
consumer.escalate(_mesaj(), "cum schimb uleiul la masina", rezultat_fara_acoperire)
|
||||
|
||||
fisiere = list((config.STATE_DIR / "escalations").glob("*.json"))
|
||||
assert len(fisiere) == 1
|
||||
inregistrare = json.loads(fisiere[0].read_text(encoding="utf-8"))
|
||||
assert inregistrare["notified"] is False
|
||||
assert inregistrare["reason"] == "cosinus 0.550 < 0.58"
|
||||
assert inregistrare["best_cosine"] == 0.55
|
||||
assert not apeluri
|
||||
|
||||
|
||||
def test_referinta_e_stabila_pentru_acelasi_mesaj():
|
||||
# Se poate cita la telefon si nu se schimba daca escaladarea se reia.
|
||||
a = consumer.reference(1788212023.0, "ABC123")
|
||||
b = consumer.reference(1788212023.0, "ABC123")
|
||||
assert a == b
|
||||
assert a.startswith("M-")
|
||||
assert consumer.reference(1788212023.0, "ALT") != a
|
||||
|
||||
|
||||
def test_referinta_ajunge_si_la_suport_si_in_jurnal(apeluri, rezultat_fara_acoperire, monkeypatch):
|
||||
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
|
||||
inreg = consumer.escalate(_mesaj(), "intrebare", rezultat_fara_acoperire)
|
||||
_, corp = apeluri[0]
|
||||
assert inreg["ref"] in corp["text"]
|
||||
pe_disc = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
|
||||
assert pe_disc["ref"] == inreg["ref"]
|
||||
|
||||
|
||||
def test_notifica_suportul_ca_text(apeluri, rezultat_fara_acoperire, monkeypatch):
|
||||
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
|
||||
consumer.escalate(_mesaj(), "cum schimb uleiul la masina", rezultat_fara_acoperire)
|
||||
|
||||
url, corp = apeluri[0]
|
||||
assert url.endswith("/send")
|
||||
assert corp["to"] == "40799999999@s.whatsapp.net"
|
||||
assert "Ion" in corp["text"]
|
||||
assert "cum schimb uleiul" in corp["text"]
|
||||
|
||||
inregistrare = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
|
||||
assert inregistrare["notified"] is True
|
||||
|
||||
|
||||
def test_captura_pleaca_la_suport_ca_imagine(apeluri, rezultat_fara_acoperire, monkeypatch, tmp_path):
|
||||
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
|
||||
captura = tmp_path / "captura.png"
|
||||
captura.write_bytes(b"png")
|
||||
msg = _mesaj(text="", media={"path": str(captura)}, ocr_text="Eroare necunoscuta XYZ")
|
||||
|
||||
consumer.escalate(msg, "intrebare", rezultat_fara_acoperire)
|
||||
|
||||
url, corp = apeluri[0]
|
||||
assert url.endswith("/send-image")
|
||||
assert corp["path"] == str(captura)
|
||||
assert "Eroare necunoscuta XYZ" in corp["caption"]
|
||||
|
||||
inregistrare = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
|
||||
assert inregistrare["had_image"] is True
|
||||
assert inregistrare["ocr_text"] == "Eroare necunoscuta XYZ"
|
||||
|
||||
|
||||
def test_notificarea_esuata_nu_pierde_jurnalul(rezultat_fara_acoperire, monkeypatch):
|
||||
monkeypatch.setitem(config._env, "SUPPORT_JID", "40799999999@s.whatsapp.net")
|
||||
|
||||
def cade(*a, **kw):
|
||||
raise RuntimeError("puntea nu raspunde")
|
||||
|
||||
monkeypatch.setattr(consumer.requests, "post", cade)
|
||||
consumer.escalate(_mesaj(), "intrebare", rezultat_fara_acoperire)
|
||||
|
||||
inregistrare = json.loads(next((config.STATE_DIR / "escalations").glob("*.json")).read_text())
|
||||
assert inregistrare["notified"] is False
|
||||
assert "puntea nu raspunde" in inregistrare["notify_error"]
|
||||
@@ -54,19 +54,42 @@ def test_legenda_intra_si_in_intrebare_si_in_cautare(imagine):
|
||||
assert cautare.splitlines()[0] == "nu merge raportul"
|
||||
|
||||
|
||||
def test_captura_se_sterge_dupa_citire(imagine):
|
||||
def test_captura_ramane_pe_disc_pana_e_tratat_mesajul(imagine):
|
||||
# Nu se sterge la OCR: mai e nevoie de ea daca intrebarea pleaca la suport,
|
||||
# unde se retrimite ca imagine.
|
||||
msg, fisier = imagine("ORA-01722: invalid number")
|
||||
consumer.prepare_query(msg)
|
||||
# capturile pot contine date de client — nu raman pe disc dupa OCR
|
||||
assert fisier.exists()
|
||||
|
||||
|
||||
def test_cleanup_media_sterge_captura(imagine):
|
||||
# capturile pot contine date de client — nu raman pe disc dupa tratarea mesajului
|
||||
msg, fisier = imagine("ORA-01722: invalid number")
|
||||
consumer.prepare_query(msg)
|
||||
consumer.cleanup_media(msg)
|
||||
assert not fisier.exists()
|
||||
|
||||
|
||||
def test_cleanup_media_tolereaza_lipsa_fisierului(imagine):
|
||||
msg, fisier = imagine("ceva")
|
||||
fisier.unlink()
|
||||
consumer.cleanup_media(msg) # nu trebuie sa arunce
|
||||
consumer.cleanup_media({"text": "fara imagine"})
|
||||
|
||||
|
||||
def test_captura_ilizibila_cere_textul_erorii(imagine):
|
||||
msg, fisier = imagine("")
|
||||
msg, _ = imagine("")
|
||||
intrebare, _, imediat = consumer.prepare_query(msg)
|
||||
assert intrebare is None
|
||||
assert imediat == consumer.NO_TEXT_IN_IMAGE
|
||||
assert not fisier.exists()
|
||||
|
||||
|
||||
def test_textul_ocr_ramane_pe_mesaj_pentru_jurnal(imagine):
|
||||
# escalate() il scrie in jurnalul de escaladari; fara el, o intrebare trimisa
|
||||
# la suport ar ajunge acolo fara continutul capturii
|
||||
msg, _ = imagine("ORA-01722: invalid number")
|
||||
consumer.prepare_query(msg)
|
||||
assert msg["ocr_text"] == "ORA-01722: invalid number"
|
||||
|
||||
|
||||
def test_ocr_esuat_dar_cu_legenda_raspunde_pe_legenda(imagine):
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Ordonarea hibrida si decizia de escaladare.
|
||||
|
||||
Fara retea: embeddings-urile sunt inlocuite cu scoruri cosinus date direct, ca sa
|
||||
se testeze fuziunea si pragurile, nu Ollama.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import rank
|
||||
|
||||
|
||||
DOCS = [
|
||||
"mesaj eroare: ORA-01722 invalid number. rezolvare: verifica formatul coloanei numerice",
|
||||
"mesaj eroare: token efactura expirat. rezolvare: reautorizeaza aplicatia in SPV",
|
||||
"cum se trimite declaratia D406 SAF-T catre ANAF din meniul Rapoarte",
|
||||
"instructiuni pentru inregistrarea unei facturi de achizitie in modulul Facturare",
|
||||
]
|
||||
|
||||
|
||||
def test_tokenize_pastreaza_codurile_intregi():
|
||||
assert "ora-01722" in rank.tokenize("Imi da ORA-01722 la salvare")
|
||||
# diacriticele nu trebuie sa rupa potrivirea
|
||||
assert rank.tokenize("factură") == rank.tokenize("factura")
|
||||
|
||||
|
||||
def test_tokenize_scoate_cuvintele_de_umplutura():
|
||||
assert rank.tokenize("cum se salveaza si de ce nu") == ["salveaza"]
|
||||
|
||||
|
||||
def test_bm25_gaseste_codul_de_eroare():
|
||||
bm = rank.Bm25(DOCS)
|
||||
scoruri = bm.scores("ORA-01722")
|
||||
assert scoruri[0] > 0
|
||||
assert max(scoruri) == scoruri[0]
|
||||
|
||||
|
||||
def test_bm25_zero_cand_nu_exista_niciun_token_comun():
|
||||
bm = rank.Bm25(DOCS)
|
||||
assert all(s == 0 for s in bm.scores("uleiul de motor al masinii"))
|
||||
|
||||
|
||||
def test_rare_terms_prinde_codurile_si_ignora_cuvintele_comune():
|
||||
bm = rank.Bm25(DOCS)
|
||||
rare = bm.rare_terms("ORA-01722 la factura")
|
||||
assert "ora-01722" in rare
|
||||
|
||||
|
||||
def test_rank_urca_chunkul_confirmat_de_ambele_metode():
|
||||
# cosinusul favorizeaza documentul 3, dar lexical se potriveste documentul 0;
|
||||
# documentul 0 e sustinut de amandoua (cosinus decent + BM25 mare) si iese primul
|
||||
cos = [0.62, 0.30, 0.31, 0.64]
|
||||
bm = [8.0, 0.0, 0.0, 0.0]
|
||||
assert rank.rank(cos, bm)[0] == 0
|
||||
|
||||
|
||||
def test_rank_fara_potrivire_lexicala_ramane_ordinea_semantica():
|
||||
cos = [0.30, 0.40, 0.80, 0.20]
|
||||
assert rank.rank(cos, [0.0, 0.0, 0.0, 0.0])[0] == 2
|
||||
|
||||
|
||||
def test_assess_cosinus_mare_raspunde():
|
||||
bm = rank.Bm25(DOCS)
|
||||
covered, motiv = rank.assess("token efactura expirat", 0.77, DOCS[1:2], bm)
|
||||
assert covered
|
||||
assert "0.770" in motiv or "0.77" in motiv
|
||||
|
||||
|
||||
def test_assess_cosinus_mic_escaladeaza():
|
||||
bm = rank.Bm25(DOCS)
|
||||
covered, motiv = rank.assess("cum schimb uleiul la masina", 0.53, DOCS[:1], bm)
|
||||
assert not covered
|
||||
assert "0.53" in motiv
|
||||
|
||||
|
||||
def test_assess_zona_de_mijloc_salvata_de_codul_de_eroare():
|
||||
# Exact cazul pentru care exista partea lexicala: cosinusul e mediocru, dar
|
||||
# codul din intrebare apare textual in chunk.
|
||||
bm = rank.Bm25(DOCS)
|
||||
covered, motiv = rank.assess("ORA-01722", 0.61, DOCS[:1], bm)
|
||||
assert covered
|
||||
assert "ora-01722" in motiv
|
||||
|
||||
|
||||
def test_assess_zona_de_mijloc_fara_termeni_in_context_escaladeaza():
|
||||
bm = rank.Bm25(DOCS)
|
||||
covered, motiv = rank.assess("ORA-99999", 0.61, DOCS[:1], bm)
|
||||
assert not covered
|
||||
assert "nu apar in documente" in motiv
|
||||
|
||||
|
||||
def test_assess_un_singur_termen_din_multi_nu_e_dovada():
|
||||
# Cazul care a scos la iveala nevoia de fractiune: „resetez parola de la Windows"
|
||||
# era salvata gresit doar fiindca „parola" apare in documente. Un termen din
|
||||
# trei nu inseamna ca intrebarea e acoperita.
|
||||
bm = rank.Bm25(DOCS + ["reautorizeaza aplicatia si schimba parola in SPV"])
|
||||
covered, motiv = rank.assess("cum resetez parola de la Windows", 0.64,
|
||||
["reautorizeaza aplicatia si schimba parola in SPV"], bm)
|
||||
assert not covered
|
||||
assert "windows" in motiv
|
||||
|
||||
|
||||
def test_assess_motivul_numeste_termenii_lipsa_nu_pe_toti():
|
||||
bm = rank.Bm25(DOCS)
|
||||
_, motiv = rank.assess("ORA-01722 la imprimanta LaserJet", 0.61, DOCS[:1], bm)
|
||||
# codul chiar apare in context, deci nu are ce cauta in lista lipsurilor
|
||||
assert "ora-01722" not in motiv
|
||||
assert "imprimanta" in motiv
|
||||
|
||||
|
||||
def test_assess_intrebare_vaga_in_zona_de_mijloc_escaladeaza():
|
||||
bm = rank.Bm25(DOCS)
|
||||
covered, motiv = rank.assess("nu merge", 0.61, DOCS[:1], bm)
|
||||
assert not covered
|
||||
assert "distinctivi" in motiv
|
||||
|
||||
|
||||
def test_pragurile_vin_din_env(monkeypatch):
|
||||
import config
|
||||
bm = rank.Bm25(DOCS)
|
||||
monkeypatch.setitem(config._env, "RANK_STRONG_COSINE", "0.50")
|
||||
covered, _ = rank.assess("orice", 0.55, DOCS[:1], bm)
|
||||
assert covered
|
||||
@@ -179,3 +179,61 @@ def test_xml_valid_nu_produce_avertismente(write, monkeypatch):
|
||||
write("bun.xml", "<root><a>x</a></root>")
|
||||
monkeypatch.setattr(indexer, "embed", lambda text: [0.0])
|
||||
assert "warnings" not in indexer.build()
|
||||
|
||||
|
||||
# --- refolosirea embeddings-urilor la reindexare -----------------------------
|
||||
|
||||
def test_vectorii_din_indexul_vechi_se_refolosesc(monkeypatch, write, tmp_path):
|
||||
"""Un document nou nu are voie sa reforteze embeddings-urile celorlalte.
|
||||
|
||||
Pe CPU un embedding costa ~7 secunde; fara refolosire, adaugarea unui
|
||||
document la un index de 170 de chunk-uri inseamna 20 de minute de asteptare.
|
||||
"""
|
||||
import json
|
||||
import config
|
||||
import indexer
|
||||
|
||||
write("primul.md", "text vechi, deja indexat")
|
||||
calculate = []
|
||||
|
||||
def fals_embed(chunk):
|
||||
calculate.append(chunk)
|
||||
return [0.5, 0.5]
|
||||
|
||||
monkeypatch.setattr(indexer, "embed", fals_embed)
|
||||
|
||||
indexer.build()
|
||||
assert calculate == ["text vechi, deja indexat"]
|
||||
|
||||
calculate.clear()
|
||||
write("al-doilea.md", "text nou")
|
||||
rezultat = indexer.build()
|
||||
|
||||
# doar chunk-ul nou s-a calculat
|
||||
assert calculate == ["text nou"]
|
||||
assert rezultat["embeddings_noi"] == 1
|
||||
assert rezultat["embeddings_refolosite"] == 1
|
||||
|
||||
index = json.loads(config.INDEX_FILE.read_text(encoding="utf-8"))
|
||||
assert {e["source"] for e in index} == {"primul.md", "al-doilea.md"}
|
||||
|
||||
|
||||
def test_textul_modificat_se_reembedeaza(monkeypatch, write):
|
||||
import indexer
|
||||
|
||||
write("doc.md", "prima varianta")
|
||||
monkeypatch.setattr(indexer, "embed", lambda chunk: [1.0])
|
||||
indexer.build()
|
||||
|
||||
calculate = []
|
||||
|
||||
def fals_embed(chunk):
|
||||
calculate.append(chunk)
|
||||
return [2.0]
|
||||
|
||||
monkeypatch.setattr(indexer, "embed", fals_embed)
|
||||
write("doc.md", "varianta modificata")
|
||||
rezultat = indexer.build()
|
||||
|
||||
assert calculate == ["varianta modificata"]
|
||||
assert rezultat["embeddings_refolosite"] == 0
|
||||
|
||||
@@ -47,8 +47,21 @@ let currentQR = null;
|
||||
let pairing = null; // { code, phone, at } — vezi PAIRING_CODE_TTL_MS
|
||||
let reconnectAttempts = 0;
|
||||
let messageQueue = [];
|
||||
// Acelasi mesaj din self-chat soseste si pe `<numar>@s.whatsapp.net` si pe
|
||||
// `<lid>@lid` — doua livrari, acelasi `key.id`. Fara filtrul asta, consumer-ul
|
||||
// raspundea de doua ori la fiecare intrebare.
|
||||
let seenIds = [];
|
||||
const SEEN_IDS_MAX = 400;
|
||||
let shuttingDown = false;
|
||||
|
||||
function alreadySeen(id) {
|
||||
if (!id) return false;
|
||||
if (seenIds.includes(id)) return true;
|
||||
seenIds.push(id);
|
||||
if (seenIds.length > SEEN_IDS_MAX) seenIds = seenIds.slice(-SEEN_IDS_MAX);
|
||||
return false;
|
||||
}
|
||||
|
||||
function activePairing() {
|
||||
if (!pairing) return null;
|
||||
if (Date.now() - pairing.at > PAIRING_CODE_TTL_MS) return null;
|
||||
@@ -226,6 +239,11 @@ async function startConnection() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (alreadySeen(msg.key.id)) {
|
||||
console.log(`[whatsapp] ignorat (duplicat ${msg.key.id}): remoteJid=${msg.key.remoteJid}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const inner = unwrap(msg.message);
|
||||
const img = imageNode(msg.message);
|
||||
let text = inner.conversation || inner.extendedTextMessage?.text || '';
|
||||
@@ -353,6 +371,32 @@ app.post('/send', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Retrimiterea unei capturi primite catre altcineva (escaladare la suport).
|
||||
// Accepta doar cai din MEDIA_DIR: puntea nu are motiv sa trimita orice fisier de
|
||||
// pe disc, iar cine ajunge la API-ul ei nu trebuie sa poata cere /etc/passwd.
|
||||
app.post('/send-image', async (req, res) => {
|
||||
const { to, path: filePath, caption } = req.body || {};
|
||||
|
||||
if (!to || !filePath) {
|
||||
return res.status(400).json({ ok: false, error: 'missing "to" or "path" in body' });
|
||||
}
|
||||
if (!connected || !sock) {
|
||||
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
|
||||
}
|
||||
const resolved = path.resolve(filePath);
|
||||
if (resolved !== path.join(MEDIA_DIR, path.basename(resolved))) {
|
||||
return res.status(400).json({ ok: false, error: 'calea trebuie sa fie un fisier din MEDIA_DIR' });
|
||||
}
|
||||
try {
|
||||
const buffer = fs.readFileSync(resolved);
|
||||
const result = await sock.sendMessage(to, { image: buffer, caption: caption || undefined });
|
||||
res.json({ ok: true, id: result.key.id });
|
||||
} catch (err) {
|
||||
console.error('[whatsapp] Send image failed:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/react', async (req, res) => {
|
||||
const { to, id, emoji, fromMe, participant } = req.body || {};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user