Files
Claude Agent f46410eb58 feat(maria): ALLOW_SELF_CHAT — Maria raspunde doar in grupul de test, nu si in self-chat
`TEST_MODE_SELF_CHAT_ONLY` insemna „self-chat SI grupurile permise"; nu exista
niciun fel de a spune „doar grupurile". `ALLOW_SELF_CHAT=false` (nou, implicit
`true`) scoate chatul cu sine insusi din filtrul puntii.

De ce: `SUPPORT_JID` e tot numarul propriu, deci escaladarile, reamintirile si
raspunsurile suportului cadeau in acelasi chat cu intrebarile — Maria isi citea
propriul canal de suport ca pe o discutie cu un client.

Pus pe `false` in `~/.maria-bridge/env`, puntea repornita: raspunde acum doar in
`120363409761730101@g.us` („Maria Test"). Se vede in `/status` (`allowSelfChat`)
si in linia de conectare din log.

Trimiterea catre suport nu e afectata — filtrul e doar pe mesajele primite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-09-01 21:12:25 +00:00

32 KiB

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 — 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:

# 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

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):

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):

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):

./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:

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

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:

  1. intrebarea contine un cod de eroare (ORA-…, PLS-…, D406) → decide doar prezenta lui in chunk-uri, indiferent de cosinus;
  2. cosinus < RANK_WEAK_COSINE → escaladam direct;
  3. 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.

Termenii se compara pe primele 5 litere, nu pe cuvantul intreg (codurile fac exceptie: ORA-01722 si ORA-01720 incep la fel). Documentatia si clientul nu folosesc aceleasi forme — documentul scrie „token" si „tokenuri", omul scrie „tokenul"; documentul „Generare", omul „generez". Comparate intregi, exact cuvintele care conteaza nu se gaseau: pe setul de calibrare, trei continuari legitime escaladau desi chunk-ul cu raspunsul era chiar in context.

Pe un fir de discutie, interogarea are doua roluri diferite. Regasirea se face pe ancora plus mesajul nou — ancora chiar ajuta, „da, ma blocheaza" singur nu gaseste nimic. Acoperirea se judeca insa pe mesajul nou (dovada, in rank.assess): altfel raspunde la ce a intrebat omul acum SI la formulele si numele proprii din primul mesaj („buna", „august", „AUTO SULEA" nu apar in niciun document), iar raportul scade la fiecare replica — firul lung escaladeaza degeaba. Cand mesajul nou n-are termeni distinctivi proprii, se cade inapoi pe interogarea intreaga, ca inainte.

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: 31/31, 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".

ALLOW_SELF_CHAT=false scoate si chatul cu tine insuti, deci ramane doar grupul. Asa e configurat acum (2026-09-01). Motivul: SUPPORT_JID e tot numarul propriu, deci escaladarile, reamintirile si raspunsurile suportului cadeau in acelasi chat cu intrebarile — Maria isi citea propriul canal de suport ca pe o discutie cu un client. Cele doua comutatoare sunt independente:

TEST_MODE_SELF_CHAT_ONLY ALLOW_SELF_CHAT Unde raspunde Maria
true true self-chat + grupurile din ALLOWED_GROUP_JIDS
true false doar grupurile din ALLOWED_GROUP_JIDS (azi)
false true oricine ii scrie pe numarul legat
false false oricine, minus self-chat

Filtrul e in punte (whatsapp/index.js), inainte de coada de mesaje: ce se opreste acolo nu ajunge niciodata la consumer. Se vede in /status (allowSelfChat) si in linia de conectare din logs/whatsapp.log.

Cat timp SUPPORT_JID e numarul propriu, trimiterea catre suport merge in continuare (filtrul e doar pe mesajele primite), dar ce raspunzi tu acolo nu mai e citit — deci nici completarile, nici raspuns_om nu se inregistreaza din self-chat. Cand suportul devine un grup sau alt numar, dispare si limita asta.

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:

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:

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.

Si ce a raspuns omul

Cat tine preluarea, fiecare mesaj al omului din echipa se adauga la fisierul escaladarii (completari, cu "fel": "raspuns_om" si cine a scris). Inainte, replica lui traia doar in firul din conversations/, care se sterge dupa FIR_TTL_MIN — jurnalul pastra deci numai intrebarile fara raspuns, si niciun raspuns. Or perechea eroare + rezolvare e chiar materia prima pentru documentele RAG: o eroare ca „CUI cumparator incorect" ajunge in document_store doar daca o scrie cineva acolo, iar cel mai ieftin loc de unde poate fi scrisa e ce a raspuns deja omul o data.

De unde se citesc, pentru o trecere in revista:

jq -r 'select(.completari) | .ref + "\n  ? " + (.text // .ocr_text // "")[0:120]
       + "\n" + ([.completari[] | select(.fel == "raspuns_om")
       | "  > " + .text] | join("\n"))' ~/.maria-bridge/escalations/*-M-*.json

Atentie la limita: preluarea se detecteaza 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 acolo raspunsurile nu se inregistreaza automat — doar in grupurile reale de client.

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).