Doua greseli din grupul "Maria Test", 2026-09-01, pe care 27409fb le-a atenuat dar
nu le-a rezolvat.
1. La o reclamatie vaga Maria cauta, in loc sa intrebe. "Buna . Am si eu o factura
pe luna august cu eroare in spv la trimitere AUTO SULE" nu spune CE eroare e —
n-are ce cauta in documente si n-are ce trimite la suport, fiindca programatorul
ar pune exact aceeasi intrebare. Gardul (triaj.prea_vag) exista, dar cerea text
sub 12 cuvinte; mesajul are 14. Gresea si invers: "nu pot incarca factura in SPV,
imi da eroare de certificat" are 9 cuvinte si ARE raspuns in documente, si era
oprita degeaba. Lungimea nu masoara cat de precis e mesajul.
-> gardul nu mai numara cuvinte si se aplica DUPA cautare, doar cand nu exista
acoperire. O singura data pe fir: daca nici intrebat omul nu spune mai mult,
mesajul pleaca la suport.
2. Textul si captura, trimise una dupa alta, erau tratate ca doua probleme fara
legatura. `este_continuare` rupea firul la ORICE imagine — dar cazul frecvent e
tocmai omul care scrie problema si trimite captura imediat dupa, sau care
raspunde la "trimite-mi o captura". Textul se pierdea, captura se cauta doar pe
OCR-ul ei, se deschideau doua fire si se puteau deschide doua escaladari pentru
aceeasi problema.
-> o captura in primele FIR_IMAGINE_MIN (5) minute continua firul: textul citit
din ea intra in ancora (cu tot cu coduri), cautarea se face pe mesaj +
captura, iar pe o escaladare deschisa pleaca la aceeasi referinta — cu
imaginea, nu doar cu textul citit din ea (_notifica_suport ia si media).
Si, ca urmare a lui (2): RANK_STRONG_COSINE dispare. Interogarea pe un fir e ancora
plus mesajul nou, deci cosinusul urca la fiecare replica fara sa apara vreo dovada
noua — aceeasi captura da 0,778 singura si 0,836 cu mesajul de dinainte, adica peste
0,80 pus ieri. Orice prag fix de sus e trecut de o discutie destul de lunga. Acoperirea
ramane pe dovada lexicala (termenii distinctivi ai intrebarii chiar in chunk-uri),
care e stabila la lungime. Niciun caz cu raspuns in documente nu avea nevoie de
scurtatura.
ops/calibrate-rank.py: 26/26, cu interogarea combinata adaugata la set. Teste: 102 pass.
Verificat end-to-end pe scenariul real (mesaj, apoi captura la 10 secunde): intrebare
de detalii, apoi o singura escaladare care poarta si textul si captura.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
570 lines
29 KiB
Markdown
570 lines
29 KiB
Markdown
# Punte WhatsApp + RAG pentru Maria (LXC 171)
|
|
|
|
> **Care chatbot e care?** Exista trei lucruri numite „Maria", doi boti pe Discord si
|
|
> doua punti WhatsApp legate la acelasi numar. Inainte de a depana, citeste
|
|
> [`docs/chatboti-si-punti.md`](../../../docs/chatboti-si-punti.md) — spune care instanta raspunde de fapt.
|
|
|
|
Bot de suport ROA pe WhatsApp: puntea WhatsApp (Baileys) primeste mesajele,
|
|
consumer-ul RAG raspunde folosind DOAR informatiile dintr-un depozit de
|
|
documente indexat cu embeddings (Ollama). Controlul (start/stop/restart,
|
|
documente, reindexare, sincronizare Google Drive) se face din **dashboard-ul
|
|
puntii Discord** (`../discord-bridge/dashboard/`), sectiunea "Maria — WhatsApp
|
|
+ RAG" — un singur panou comun pentru ambele punti de pe acest container, nu
|
|
un dashboard separat per serviciu.
|
|
|
|
Continua prototipul descris in
|
|
`claude-agent/docs/maria-whatsapp-rag-prototype.md` (construit initial in
|
|
`/tmp/maria-bridge/`) — aici e mutat in git, ca serviciu persistent.
|
|
|
|
Nu confunda cu:
|
|
- **Maria pe Flowise** (`vfp_roaauto/COMUN/utile/chatbot/`) — chatbot web separat.
|
|
- **Echo / `echo-whatsapp-bridge.service`** (LXC 110 moltbot) — alt bot, alta punte.
|
|
- **Punte Discord -> Claude Code** (`discord-bridge/`, acelasi container) — alt
|
|
proiect, alt scop (comanda Claude Code de pe Discord, nu suport RAG).
|
|
|
|
## ATENTIE: a existat o a doua instalare, pe LXC 104
|
|
|
|
Prototipul din care a plecat acest proiect a fost lasat sa ruleze pe **LXC 104
|
|
(Flowise)**, in `/root/maria-whatsapp-bridge/` — in afara git-ului, cu propriul
|
|
`consumer.py`, propriul `rag_index.json` si propriul `auth/` de WhatsApp.
|
|
|
|
Pana pe 2026-08-31, **aceea era Maria care raspundea de fapt pe WhatsApp**, iar cea
|
|
din git (LXC 171) nu fusese niciodata asociata. In acea zi rolul a trecut aici:
|
|
LXC 171 a fost asociat la acelasi numar, iar pe 104 serviciile
|
|
`maria-whatsapp-bridge` si `maria-whatsapp-consumer` au fost oprite si dezactivate.
|
|
`llama-qwen35.service` (LLM-ul de raspuns) si `flowise.service` raman pornite acolo. Indexul prototipului avea 24 de
|
|
chunk-uri dintr-un singur fisier (`roaauto_instructiuni.txt`), deci raspundea
|
|
inventand la orice intrebare din afara acelui subiect — inclusiv e-Factura.
|
|
|
|
Confuzia a costat timp de diagnostic: pe LXC 171 totul parea rupt (niciun LLM pe
|
|
8091, WhatsApp neasociat, `rag.log` fara activitate) desi utilizatorul primea
|
|
raspunsuri. Explicatia era ca ne uitam la containerul gresit.
|
|
|
|
**Daca depanezi Maria, verifica intai pe ce container ruleaza instanta conectata:**
|
|
|
|
```bash
|
|
# aici (LXC 171)
|
|
curl -s http://127.0.0.1:8099/status
|
|
# pe LXC 104, daca mai exista
|
|
ssh root@10.0.20.201 "pct exec 104 -- curl -s http://127.0.0.1:8099/status"
|
|
```
|
|
|
|
LLM-ul de raspuns (`llama.cpp` cu `Qwen3.5-2B-Q4_K_M`, port 8091) ruleaza tot pe
|
|
LXC 104 si e folosit prin retea: `LLM_URL=http://10.0.20.161:8091`. Pe LXC 171 nu
|
|
exista niciun model de chat — Ollama de aici are doar `nomic-embed-text`, pentru
|
|
embeddings.
|
|
|
|
## Arhitectura
|
|
|
|
```
|
|
WhatsApp (self-chat, sau numarul legat)
|
|
|
|
|
v
|
|
whatsapp/index.js (Baileys) -- API HTTP :8099 (/status /send /messages /react /qr /pair /groups)
|
|
| descarca imaginile primite in ~/.maria-bridge/media/
|
|
|
|
|
v
|
|
rag/consumer.py -- polling la /messages, RAG stateless (FARA memorie intre mesaje)
|
|
| vezi docs/maria-whatsapp-rag-prototype.md pentru motiv
|
|
v
|
|
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
|
|
|
|
../discord-bridge/dashboard/api.py -- panou comun; sectiunea Maria controleaza
|
|
maria-whatsapp.service + maria-rag.service, gestioneaza
|
|
documentele si declanseaza sincronizare/reindexare
|
|
```
|
|
|
|
Servicii `systemctl --user` (vezi `ops/`):
|
|
|
|
| Unitate | Ce face |
|
|
|---|---|
|
|
| `maria-whatsapp.service` | Puntea Baileys (Node), port 8099 |
|
|
| `maria-rag.service` | Consumer RAG (Python), polling + raspunsuri |
|
|
| `maria-sync.service` + `.timer` | Sincronizare Drive + reindexare, la 10 min |
|
|
|
|
## Instalare
|
|
|
|
```bash
|
|
cd maria-whatsapp-bridge
|
|
./ops/install.sh # creeaza ~/.maria-bridge/, venv, npm install, symlink-uri unit
|
|
```
|
|
|
|
Completeaza manual `~/.maria-bridge/env` (copiat din `ops/env.example` la prima
|
|
rulare): cel putin `LLM_URL` (backend-ul de chat) si, daca vrei sincronizare
|
|
automata cu Drive, `DRIVE_REMOTE`.
|
|
|
|
`OLLAMA_URL` (implicit `http://127.0.0.1:11434`, folosit pentru embeddings la
|
|
indexare) presupune un Ollama instalat local pe container — nu exista alt
|
|
Ollama documentat in infrastructura. Instalare (facuta deja pe LXC 171,
|
|
2026-08-31):
|
|
```bash
|
|
sudo apt-get install -y zstd # dependinta a instalatorului Ollama
|
|
curl -fsSL https://ollama.com/install.sh | sh
|
|
ollama pull nomic-embed-text # ~274 MB, CPU-only pe acest container
|
|
```
|
|
|
|
Pentru citirea capturilor de ecran primite (vezi „Imagini cu erori" mai jos):
|
|
```bash
|
|
sudo apt-get install -y tesseract-ocr tesseract-ocr-ron
|
|
```
|
|
|
|
Bridge-ul WhatsApp NU porneste automat la instalare — cere asocierea cu telefonul
|
|
(actiune manuala, o singura data):
|
|
|
|
```bash
|
|
./ops/install.sh --start
|
|
# apoi deschide dashboard-ul puntii Discord, sectiunea "Maria — WhatsApp + RAG"
|
|
# -> cardul "Conectare WhatsApp", si alege una din cele doua metode (mai jos)
|
|
```
|
|
|
|
## Asocierea cu telefonul: QR sau cod
|
|
|
|
Ambele duc la acelasi rezultat — puntea devine un *dispozitiv conectat* al contului.
|
|
Alegerea e practica, nu tehnica:
|
|
|
|
| | Cand se foloseste |
|
|
|---|---|
|
|
| **Cod QR** | Ai dashboard-ul deschis pe un ecran pe care telefonul il poate fotografia. |
|
|
| **Cod de asociere** (8 caractere) | Esti pe telefon, sau ecranul cu QR-ul e la distanta: scrii numarul in dashboard, primesti un cod si il tastezi pe telefon. |
|
|
|
|
Pentru codul de asociere, in dashboard: scrie numarul **in format international,
|
|
fara `+` si fara `00`** (ex. `40723197939`), apasa „Cere cod de asociere", apoi pe
|
|
telefon: WhatsApp -> Dispozitive conectate -> Conecteaza un dispozitiv ->
|
|
**Conecteaza cu numar de telefon** -> tastezi codul.
|
|
|
|
Codul e valabil **~3 minute**; dupa ce expira, dashboard-ul il marcheaza ca expirat
|
|
si trebuie cerut altul. Dupa introducerea corecta, WhatsApp inchide conexiunea cu
|
|
codul `restartRequired` (515) — puntea se reconecteaza singura, imediat; nu e o
|
|
eroare si nu consuma din bugetul de reincercari.
|
|
|
|
Din linia de comanda, aceleasi lucruri:
|
|
```bash
|
|
curl -s -X POST -H 'Content-Type: application/json' \
|
|
-d '{"phone":"40723197939"}' http://127.0.0.1:8099/pair
|
|
curl -s http://127.0.0.1:8099/status | python3 -m json.tool # cod + secunde ramase
|
|
```
|
|
|
|
Codul se poate cere doar cat timp sesiunea NU e inregistrata. Daca puntea raspunde
|
|
„sesiunea e deja inregistrata", opreste-o, sterge `~/.maria-bridge/whatsapp-auth/`
|
|
si porneste-o din nou — dar atentie, asta desface asocierea existenta.
|
|
|
|
## Imagini cu erori
|
|
|
|
Utilizatorii trimit aproape intotdeauna o captura cu fereastra de eroare, nu textul
|
|
ei. Puntea descarca imaginea in `~/.maria-bridge/media/`, iar consumer-ul o trece
|
|
prin **tesseract** (`ron+eng`) inainte de RAG. Modelul de raspuns (Qwen3.5-2B pe
|
|
LXC 104) e strict text, deci OCR-ul e singura cale — nu e o optiune de calitate.
|
|
|
|
Doua detalii care nu se vad din cod la prima citire:
|
|
|
|
- **Cautarea in index nu foloseste toata captura.** Un ecran intreg de meniuri,
|
|
coloane si totaluri dilueaza embedding-ul si scoate chunk-uri fara legatura. Se
|
|
cauta doar dupa liniile care arata a eroare (`ORA-…`, „eroare", „nu exista", …);
|
|
modelul primeste totusi fereastra intreaga, marcata explicit ca text OCR, ca sa nu
|
|
trateze greselile de recunoastere ca date exacte. Vezi `rag/ocr.py`.
|
|
- **Capturile se sterg imediat dupa citire.** Pot contine date de client si nu exista
|
|
niciun motiv sa ramana pe disc. Ce ramane dupa un restart in mijlocul procesarii se
|
|
curata la pornirea puntii (dupa 24h).
|
|
|
|
Legenda imaginii, daca exista, conteaza: intra si in intrebare si in cautare. Daca
|
|
OCR-ul nu gaseste nimic lizibil si nu exista legenda, Maria cere textul erorii in
|
|
loc sa inventeze un raspuns.
|
|
|
|
Limite: `MAX_MEDIA_MB` (implicit 8) pentru imaginea bruta, `OCR_MAX_CHARS`
|
|
(implicit 1500) pentru textul trimis modelului, `OCR_TIMEOUT_S` (60).
|
|
Videoclipurile, audio si documentele non-imagine sunt in continuare ignorate.
|
|
|
|
## Dashboard (comun cu puntea Discord)
|
|
|
|
Nu exista un dashboard separat pentru Maria. Controlul se face din dashboard-ul
|
|
puntii Discord — vezi `../discord-bridge/README.md` pentru URL si autentificare
|
|
(`DASHBOARD_TOKEN` din `~/.claude-discord/env`, tunel SSH sau Tailscale la
|
|
`/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`)
|
|
|
|
## Depozitul de documente
|
|
|
|
Fisiere `.txt`/`.md`/`.xml` (lista exacta: `store.DOC_EXTENSIONS`), in **doua
|
|
directoare care formeaza un singur spatiu de nume**:
|
|
|
|
| Director | Ce e acolo |
|
|
|---|---|
|
|
| `~/.maria-bridge/documents/` | **oglinda Google Drive.** `rclone sync` sterge de aici tot ce nu exista in Drive — nu pune nimic de mana. |
|
|
| `~/.maria-bridge/documents-local/` | documente care **nu** vin din Drive: dictionare proprii, ce se adauga din dashboard. Sincronizarea nu se atinge de ele. |
|
|
|
|
Cand acelasi nume apare in ambele, castiga cel din Drive (e sursa comuna a echipei;
|
|
copia locala ar putea fi o versiune veche uitata acolo). In dashboard, documentele
|
|
locale sunt marcate „local".
|
|
|
|
Directorul local exista fiindca altfel **tot ce se adauga din dashboard traia pana
|
|
la urmatorul tur de sincronizare** — maximum 10 minute. Acum adaugarea din dashboard
|
|
scrie direct acolo.
|
|
|
|
## Sincronizare cu Google Drive
|
|
|
|
Sursa: dosarul `document_store` din Drive-ul contului `mmarius28@gmail.com`
|
|
(pe Windows apare ca `D:\GoogleDrive\romfast\document_store`, prin Google Drive
|
|
Desktop). ID-ul dosarului: `1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O`.
|
|
|
|
Containerul e headless (fara browser pentru OAuth), deci autorizarea se face pe o
|
|
masina cu browser si se muta aici ca token — **fara cont de serviciu si fara consola
|
|
Google Cloud**. rclone e deja instalat pe LXC 171 (`rclone v1.60.1`).
|
|
|
|
Pasi (o singura data):
|
|
|
|
1. **Pe Windows**, ia `rclone.exe` de la <https://rclone.org/downloads/> (arhiva
|
|
portabila, nu cere instalare) si ruleaza in acel dosar:
|
|
```
|
|
rclone.exe authorize "drive" --drive-scope=drive.readonly
|
|
```
|
|
Se deschide browserul; autentifica-te cu `mmarius28@gmail.com` si accepta.
|
|
In consola apare un token JSON intre `--->` si `<---`. Copiaza-l intreg.
|
|
2. **Pe container**, da tokenul scriptului de configurare:
|
|
```
|
|
ops/setup-drive.sh '<TOKEN_JSON>'
|
|
```
|
|
Face restul singur: creeaza remote-ul `gdrive`, verifica accesul la dosar,
|
|
scrie `DRIVE_REMOTE` in `~/.maria-bridge/env` (cu backup) si ruleaza prima
|
|
sincronizare cu reindexare. E idempotent — se poate rula din nou oricand.
|
|
|
|
Tinta scrisa in env e `gdrive,root_folder_id=<ID>:` — sintaxa „connection
|
|
string" a rclone, care fixeaza dosarul dupa ID, fara sa depinda de structura
|
|
de nume din My Drive.
|
|
3. Verifica in dashboard: sectiunea Maria trebuie sa arate „Drive: …" in loc de
|
|
„Sincronizare Drive dezactivata".
|
|
|
|
Tokenul se reimprospateaza singur (refresh token) cat timp aplicatia ramane
|
|
autorizata in contul Google. Daca expira, dashboard-ul arata sincronizarea ca
|
|
esuata — reia pasii 1-2.
|
|
|
|
### Ce se sincronizeaza
|
|
|
|
`rclone sync` aduce doar `*.txt`, `*.md` si `*.xml` (vezi `store.DOC_EXTENSIONS`).
|
|
Restul din dosar — chatflow-uri Flowise `.json`, scripturi `.ps1`, `.docx` — sunt
|
|
ignorate deliberat: nu sunt cunostinte de suport.
|
|
|
|
**`sync` sterge local ce nu mai exista in Drive**, deci `documents/` e o oglinda a
|
|
dosarului din Drive, nu o colectie care creste. De aceea exista `documents-local/`:
|
|
ce e adaugat de mana sau din dashboard sta acolo si nu e atins de sincronizare.
|
|
|
|
### 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:
|
|
|
|
- **Fisierul din git nu ajunge singur in index.** Copia care ruleaza acum sta in
|
|
`~/.maria-bridge/documents-local/`, in afara oglinzii Drive. Asa supravietuieste
|
|
sincronizarilor, dar **exista doar pe acest container**. Locul lui pe termen lung
|
|
e `document_store` din Drive (pe Windows: `D:\GoogleDrive\romfast\document_store`),
|
|
unde il vede si restul echipei si de unde se poate edita fara acces la server.
|
|
Cand ajunge in Drive, sterge copia locala — altfel raman doua versiuni si castiga
|
|
tacut cea din Drive.
|
|
- **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
|
|
preferinta: `.xml` > `.md` > `.txt`, in `rag/store.py`). Sursele `.xml` sunt
|
|
structurate pe probleme si de regula mai noi decat exporturile `.md` — la
|
|
`d406_saft_knowledge`, `.xml` era cu trei luni mai nou si cu 50% mai mare.
|
|
Fisierul umbrit ramane pe disc si apare in dashboard marcat „umbrit de …", ca sa
|
|
se vada de ce nu e indexat; daca ar fi indexate ambele, acelasi raspuns ar aparea
|
|
de doua ori in rezultatele RAG.
|
|
|
|
### Cum se taie XML-ul in chunk-uri
|
|
|
|
Un chunk per element de nivel 1 — adica **o problema = un chunk**, cu mesajul de
|
|
eroare si rezolvarea impreuna. Taierea pe linii goale (cea folosita la `.md`) le-ar
|
|
separa, iar cautarea ar gasi eroarea si ar returna un chunk fara raspuns.
|
|
Etichetele raman ca prefixe lizibile (`mesaj eroare: …`, `rezolvare: …`), fara
|
|
paranteze unghiulare. Un XML care nu se poate parsa nu opreste indexarea: cade pe
|
|
taierea obisnuita, cu o linie in log. Vezi `rag/indexer.py` si
|
|
`tests/test_store_si_chunking.py`.
|
|
|
|
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 — dar numai pentru **acelasi
|
|
`EMBED_MODEL`**: fiecare intrare din index poarta modelul cu care a fost calculata,
|
|
iar la schimbarea modelului indexul se reface intreg. Altfel ar ramane un amestec de
|
|
vectori din doua modele, iar cautarea ar da rezultate aiurea fara nici o eroare.
|
|
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 # 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:
|
|
|
|
0. **intrebarea contine un cod de eroare** (`ORA-…`, `PLS-…`, `D406`) → decide
|
|
doar prezenta lui in chunk-uri, indiferent de cosinus;
|
|
1. cosinus < `RANK_WEAK_COSINE` → escaladam direct;
|
|
2. peste → 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 2 nu e cosmetica: cu un singur termen gasit, „cum imi resetez
|
|
parola de la Windows" trecea drept acoperita fiindca „parola" apare in documente.
|
|
|
|
**Nu exista prag „cosinus destul de mare ca sa nu mai verificam".** A existat
|
|
(`RANK_STRONG_COSINE`, 0,70) si a lasat sa treaca doua raspunsuri inventate
|
|
(2026-09-01, grupul „Maria Test"): „am o factura pe luna august cu eroare in SPV"
|
|
(0,768 — nu spune ce eroare e) si o captura cu `errorMessage="CUI cumparator
|
|
incorect"` (0,778 — eroarea nu exista in documente). Toate chunk-urile eFactura
|
|
seamana intre ele, deci pe 0,7x cosinusul nu mai distinge „e in documente" de „e
|
|
despre eFactura".
|
|
|
|
L-am scos, nu urcat, fiindca **cosinusul creste cu lungimea interogarii**: pe un fir
|
|
de discutie se cauta dupa ancora plus mesajul nou, deci scorul urca la fiecare
|
|
replica fara sa apara vreo dovada in plus. Aceeasi captura: 0,778 singura, 0,836
|
|
impreuna cu mesajul de dinainte. Orice prag fix de sus e trecut de o discutie destul
|
|
de lunga. `ops/calibrate-rank.py`: 26/26, si niciunul dintre cazurile cu raspuns in
|
|
documente nu avea nevoie de scurtatura — toate trec pe dovada lexicala.
|
|
|
|
Modelul primeste `temperature=0` (`LLM_TEMPERATURE`). Fara ea, llama.cpp raspunde
|
|
cu 0,8 — creativitate exact acolo unde vrem sa se rezume la context.
|
|
|
|
Pasul 0 e mai tare decat cosinusul pentru ca **doua erori Oracle diferite se scriu
|
|
aproape la fel**. O captura cu `ORA-06550 / PLS-00906` a primit cosinus 0,736 pe
|
|
chunk-ul despre `ORA-12541: TNS no listener` — peste pragul „sigur", deci Maria a
|
|
raspuns increzatoare cu alta eroare, fara sa escaladeze. Un cod e un identificator
|
|
exact: un document care nu-l pomeneste nu raspunde la el, oricat de bine ar semana
|
|
textul din jur. Codurile dau si un **al treilea clasament** (`rank.by_codes`), langa
|
|
cosinus si BM25 — la o captura de ecran, OCR-ul aduce zeci de tokeni de zgomot
|
|
(numele butoanelor din fereastra), iar BM25 singur ineca tocmai codul.
|
|
|
|
Codul se cauta oriunde in text, nu ca token intreg: OCR-ul lipeste `[Oracle][ODBC]`
|
|
de cod si scoate `OraJORA-06550`.
|
|
|
|
### Triaj: cine primeste raspuns, cine primeste intrebari
|
|
|
|
Nu orice mesaj e o intrebare, si nu orice raspuns e util. `rag/triaj.py` ia trei
|
|
decizii inaintea modelului:
|
|
|
|
**Mesaj prea vag.** „Am o eroare" nu spune nimic. Maria cere operatiunea, ecranul si
|
|
textul erorii (sau o captura) — nu ghiceste si nu deranjeaza suportul cu atat, fiindca
|
|
programatorul ar pune exact aceeasi intrebare.
|
|
|
|
Decizia se ia **dupa cautare, si numai cand nu exista acoperire in documente**. Inainte
|
|
se lua inaintea cautarii, dupa lungime: text scurt (sub 12 cuvinte), fara captura si
|
|
fara cod, cu cuvinte de acuza („nu merge", „imi da"). Gardul gresea in ambele sensuri
|
|
— „Buna . Am si eu o factura pe luna august cu eroare in spv la trimitere AUTO SULE"
|
|
are 14 cuvinte si tot nu zice CE eroare e (a trecut, si a primit un raspuns inventat),
|
|
iar „nu pot incarca factura in SPV, imi da eroare de certificat" are 9 si e in
|
|
documente (era oprita degeaba). Lungimea nu masoara cat de precis e mesajul;
|
|
acoperirea in documente da. Se cere o singura data pe fir: daca nici dupa ce a fost
|
|
intrebat omul nu spune mai mult, mesajul pleaca la suport.
|
|
|
|
**Erori care oricum ajung la programatori.** Dictionarul de erori Oracle are pe fiecare
|
|
intrare campul `cand suni suportul`. Noua incep cu „Intotdeauna" sau „Imediat" — nu se
|
|
rezolva din aplicatie. Pentru astea raspunsul se compune **direct din campurile
|
|
dictionarului, fara model**, iar mesajul (si captura) pleaca automat la suport, cu
|
|
referinta.
|
|
|
|
De ce fara model: avand in context chiar intrarea care spune „nu se rezolva din
|
|
aplicatie", modelul a raspuns unui contabil sa *„verifice schema de date din aplicatia
|
|
ROA sau sa foloseasca procedura alternativa `MI_pack_parteneri_old`"* — o procedura pe
|
|
care a inventat-o. Textul din dictionar e deja scris pentru clienti; parafrazarea lui
|
|
nu adauga nimic si poate strica tot. Pentru restul erorilor, unde utilizatorul chiar
|
|
are ce incerca, raspunde modelul — cu interdictii explicite de vocabular in
|
|
`SYSTEM_PROMPT` (procedura, schema, tabela, PL/SQL, compilare…).
|
|
|
|
**Cat e de urgent.** Aceeasi eroare poate bloca omul complet sau poate fi ocolita pana
|
|
maine, si nu se deduce din text. Maria intreaba, iar raspunsul lui se ataseaza
|
|
escaladarii deschise (`completari` in jurnal, plus un mesaj la suport), nu deschide
|
|
alta referinta. Fereastra e de 30 de minute (`consumer.PENDING_TTL_S`), tinuta in
|
|
`~/.maria-bridge/escalations/pending.json`; un mesaj cu captura sau cu alt cod de
|
|
eroare e tratat ca intrebare noua, nu ca raspuns la intrebarea de urgenta.
|
|
|
|
### Firul de discutie
|
|
|
|
Pana la `rag/fir.py`, fiecare mesaj pornea de la zero. Dupa o escaladare pentru
|
|
ORA-06550, masurat pe indexul viu:
|
|
|
|
| Ce scrie omul | Ce raspundea Maria |
|
|
|---|---|
|
|
| „da, ma blocheaza complet" | cum se completeaza un ordin de plata la Trezorerie (0,642) |
|
|
| „eram la salvarea unei facturi" | procedura de corectie a unei eFacturi (0,742) |
|
|
| „si acum ce fac?" | observatii despre eFacturi primite gresit |
|
|
|
|
Toate „acoperite", cu cosinus peste prag: patru cuvinte fara context chiar seamana cu
|
|
ceva din documente. Firul tine ancora (textul erorii care l-a deschis), codurile ei,
|
|
referinta escaladarii si ultimele 6 schimburi, in `~/.maria-bridge/conversations/`.
|
|
|
|
La o continuare: cautarea se face pe **ancora + mesajul nou**, modelul primeste
|
|
istoricul, mesajul se adauga la escaladarea deschisa (`completari`, de cate ori e
|
|
nevoie), iar „mesaj prea vag" nu se mai aplica — detaliile au fost deja cerute.
|
|
|
|
**Ce rupe firul:** un cod de eroare pe care firul nu-l are, sau o captura venita la
|
|
mai mult de `FIR_IMAGINE_MIN` (5 min) dupa ultimul mesaj. Schimbarea subiectului in
|
|
cuvinte NU rupe firul: e prea usor de confundat cu o continuare, iar greseala aia
|
|
produce exact tabelul de mai sus. In rest, firul expira dupa `FIR_TTL_MIN` (120 min).
|
|
|
|
Orice captura a rupt firul pana pe 2026-09-01, si era gresit exact in cazul cel mai
|
|
frecvent: **omul scrie problema si trimite captura imediat dupa** (sau Maria tocmai
|
|
i-a cerut-o). WhatsApp le livreaza ca doua mesaje, dar e un singur lucru. Tratate
|
|
separat, textul se pierdea si captura se cauta doar pe OCR-ul ei; se deschideau doua
|
|
fire, se puteau deschide doua escaladari pentru aceeasi problema, iar suportul primea
|
|
doua mesaje fara legatura. Acum captura din fereastra de 5 minute continua firul:
|
|
textul citit din ea intra in ancora (cu tot cu codurile lui), cautarea se face pe
|
|
mesaj + captura, iar daca firul are deja o escaladare deschisa captura pleaca la
|
|
aceeasi referinta — cu imaginea, nu doar cu textul citit din ea.
|
|
|
|
### Completarile: trei feluri, trei raspunsuri
|
|
|
|
Cat timp firul are o escaladare deschisa, mesajele urmatoare sunt completari la ea.
|
|
Decizia se ia **inaintea cautarii si a confirmarii** „caut informatia": raspunsul vine
|
|
instant, deci un „revin imediat" ar fi o promisiune inutila, iar embedding-ul (~1-2s)
|
|
s-ar calcula degeaba — asta se si intampla inainte, la fiecare „tot nimic".
|
|
|
|
| Ce scrie omul | Ce face Maria |
|
|
|---|---|
|
|
| „este foarte urgent", „ma blocheaza" | marcheaza escaladarea `urgenta`, trimite la suport un mesaj cu **URGENT** in cap |
|
|
| „cat mai dureaza?", „nu m-a contactat nimeni" | spune de cat timp e trimisa problema si reaminteste echipei — **cel mult o data la 15 minute** (`REAMINTIRE_MIN`) |
|
|
| orice alt detaliu | confirmare scurta, cu formularea rotita |
|
|
|
|
Peste 30 de minute fara raspuns, Maria n-o mai da cu „echipa vede detaliul cand preia
|
|
problema": spune ca nu i-a raspuns nimeni si indruma spre telefon (`SUPPORT_PHONE`,
|
|
daca e completat). Omul stie oricum de cat timp asteapta — o formula l-ar enerva.
|
|
|
|
### Cand preia un om
|
|
|
|
Daca cineva din echipa scrie in discutie, Maria tace `FIR_TACERE_MIN` minute (60), ca
|
|
sa nu vorbeasca peste el. Automat **doar in grupuri cu cel putin
|
|
`FIR_PRELUARE_MIN_PARTICIPANTI` (2) participanti**: in self-chat si in grupul de test
|
|
tot ce se scrie e `fromMe`, deci regula ar amuti-o la primul mesaj. Oriunde merg si
|
|
comenzile explicite: „Maria, stop" / „preiau eu" si „Maria, continua".
|
|
|
|
### Testarea intr-un grup
|
|
|
|
`ALLOWED_GROUP_JIDS` (in `env`) listeaza grupurile in care Maria raspunde chiar si cu
|
|
`TEST_MODE_SELF_CHAT_ONLY=true`. Filtrul e si in punte, si in consumer. Azi:
|
|
`120363409761730101@g.us` — grupul „Maria Test".
|
|
|
|
> **Nu pune aici `echo-test` (`120363424350922235@g.us`).** E canalul WhatsApp al lui
|
|
> Echo (LXC 110), iar puntea lui nu filtreaza `fromMe` in grupuri
|
|
> (`if (msg.key.fromMe && !isGroup) continue`). Cei doi boti si-ar raspunde unul
|
|
> altuia la nesfarsit, cu Claude pe API la Echo. Vezi `docs/chatboti-si-punti.md`.
|
|
|
|
### 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. **JID-ul unui grup nu se vede nicaieri in WhatsApp**; se
|
|
citeste din punte, care le listeaza pe toate cu numele lor:
|
|
|
|
```bash
|
|
curl -s localhost:8099/groups | python3 -m json.tool | grep -B1 'ROMFAST'
|
|
```
|
|
|
|
Grupul trebuie sa contina si numarul Mariei (`40723197939`) — altfel JID-ul nici nu
|
|
apare in lista, iar trimiterea ar esua. **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
|
|
interogare RAG independenta (system prompt + top-K chunk-uri + intrebare).
|
|
Vezi `claude-agent/docs/maria-whatsapp-rag-prototype.md` pentru motiv si
|
|
comparatie cu celelalte punti (Discord: context nelimitat + `/new`; Maria pe
|
|
Flowise: fereastra fixa de 5 schimburi).
|