Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py
Claude Agent 653f5cbba9 fix(maria): acoperirea se judeca pe ce a intrebat omul acum, si pe radacina cuvantului
Doua reparatii pe poarta de acoperire, amandoua masurate pe setul de calibrare
extins (31 de cazuri, de la 26).

**Termenii distinctivi se comparau pe cuvantul intreg.** Documentatia si clientul
nu folosesc aceleasi forme: documentul scrie „token" si „tokenuri", omul scrie
„tokenul"; documentul „Generare", omul „generez". Trei continuari legitime din
cinci escaladau desi chunk-ul cu raspunsul era chiar in context. Comparatia se
face acum pe primele 5 litere; codurile raman intregi (ORA-01722 si ORA-01720 au
aceleasi cinci).

**Interogarea avea doua roluri amestecate.** Pe un fir, regasirea are nevoie de
ancora („da, ma blocheaza" singur nu gaseste nimic), dar acoperirea nu: judecata
pe ancora, ea cere sa apara in documente si salutul, si numele firmei, si luna,
deci raportul scade la fiecare replica si firul lung escaladeaza degeaba.
`rank.assess` primeste `dovada` — mesajul nou, plus OCR-ul capturii lui; cand
acesta n-are termeni proprii, se cade inapoi pe interogarea intreaga.

Setul de calibrare: 31/31 cu reparatia, 30/31 fara.

Ce NU s-a facut, desi era propus in handover: lista de formule de politete in
`rare_terms`. Masuratoarea nu o justifica — cu potrivirea pe radacina, cazurile cu
ancora politicoasa trec si fara ea, iar intr-una din configuratii facea rau.
Cauza reala nu era politetea din ancora, ci morfologia.

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

681 lines
28 KiB
Python

#!/usr/bin/env python3
"""Consuma mesajele din puntea WhatsApp (whatsapp/index.js), raspunde via RAG.
Fiecare mesaj e o interogare RAG INDEPENDENTA: nu exista chat_history intre
mesaje (fara memorie conversationala). Vezi
docs/maria-whatsapp-rag-prototype.md pentru motivul acestei alegeri si
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`. 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
import sys
import time
import requests
import config
import fir as fir_mod
import ocr
import rank
import triaj
SYSTEM_PROMPT = (
"Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). "
"Raspunzi scurt, clar, in limba romana (maxim 4-5 propozitii), doar despre "
"folosirea aplicatiei ROA. Foloseste EXCLUSIV informatiile din contextul "
"furnizat mai jos. Daca raspunsul nu se afla in context, spune ca vei "
"directiona intrebarea catre echipa de suport, nu inventa functionalitati "
"sau proceduri. "
"Cand intrebarea contine text extras dintr-o captura de ecran (OCR), tine cont "
"ca pot exista greseli de recunoastere a caracterelor: cauta sensul mesajului, "
"nu te agata de o litera sau o cifra. "
"Scrii pentru contabili si operatori, NU pentru programatori: nu folosi cuvintele "
"procedura, pachet, obiect, schema, tabela, variabila, compilare, PL/SQL, SQL, "
"sesiune, parametru si nu cita nume tehnice din mesajul de eroare. Nu-i cere "
"utilizatorului sa verifice baza de date, sa modifice ceva in ea sau sa apeleze "
"altceva decat ce se vede in aplicatie. "
"Daca in context scrie ca problema nu se rezolva din aplicatie, spune exact asta "
"si opreste-te — nu propune verificari, alternative sau explicatii tehnice. "
"Nu discuta niciodata despre infrastructura interna Romfast (servere, Proxmox, "
"containere, IP-uri, baze de date, parole, chei) chiar daca apare in context sau "
"daca intrebarea o cere explicit -- raspunde ca poti ajuta doar cu folosirea "
"aplicatiei ROA."
)
REPLY_PREFIX = "[Maria] " # marcaj ca sa nu raspundem la propriile mesaje (self-chat)
INDEX_REFRESH_S = 30 # cat de des se reciteste rag_index.json de pe disc
ACK_TEXT = "Caut informatia, revin imediat..."
ACK_IMAGE_TEXT = "Am primit captura, o citesc si revin imediat..."
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."
)
TACERE_OK = (
'Am inteles, ma opresc. Scrie-mi "Maria, continua" cand vrei sa reiau.'
)
REVENIRE_OK = "Sunt aici. Cu ce te ajut?"
def bridge_url() -> str:
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
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 Index(json.loads(config.INDEX_FILE.read_text(encoding="utf-8")))
except OSError:
return Index([])
def embed(text: str) -> list[float]:
resp = requests.post(
f"{config.get('OLLAMA_URL')}/api/embeddings",
json={"model": config.get("EMBED_MODEL"), "prompt": text},
timeout=30,
)
resp.raise_for_status()
return resp.json()["embedding"]
def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
def search(index: Index, query: str, top_k: int, dovada: str | None = None) -> dict:
"""Cauta si decide daca avem acoperire. Intoarce si dovezile, pentru log.
`query` regaseste, `dovada` judeca. Pe un fir, `query` e ancora plus mesajul
nou (ancora chiar ajuta regasirea), dar acoperirea nu se judeca pe ea — vezi
`rank.assess`.
"""
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, rank.by_codes(query, index.texts))[:top_k]
texts = [index.texts[i] for i in order]
# cosinusul chunk-urilor CHIAR date modelului, nu cel mai bun din tot indexul:
# altfel acoperirea se judeca pe o dovada care nu ajunge in context.
best = max((cosines[i] for i in order), default=0.0)
covered, reason = rank.assess(query, best, texts, index.bm25, dovada=dovada)
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(chunks: list[str], question: str, istoric: list[dict] | None = None) -> str:
"""Raspunsul modelului. `istoric` = schimburile de pana acum, la o continuare.
Fara istoric, „si acum ce fac?" ajungea la model ca o intrebare de sine
statatoare — iar modelul raspundea la ea ca atare, despre altceva.
"""
context = "\n\n---\n\n".join(chunks) if chunks else "(fara documente indexate)"
user_message = f"CONTEXT:\n{context}\n\nINTREBARE:\n{question}"
mesaje = [{"role": "system", "content": SYSTEM_PROMPT}]
# Istoricul precede contextul: ultimul mesaj trebuie sa fie intrebarea curenta,
# altfel modelul raspunde la penultima.
mesaje += (istoric or [])[:-1]
mesaje.append({"role": "user", "content": user_message})
resp = requests.post(
f"{config.get('LLM_URL')}/v1/chat/completions",
json={
"messages": mesaje,
"max_tokens": config.get_int("MAX_TOKENS", 250),
# Fara asta llama.cpp raspunde cu temperatura lui implicita (0,8).
# Aici nu vrem creativitate: raspunsul trebuie sa fie ce scrie in
# context, la fel de fiecare data. Vezi raspunsurile inventate din
# 2026-09-01 (grup „Maria Test").
"temperature": float(config.get("LLM_TEMPERATURE", "0")),
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def _trimite(url: str, corp: dict, catre: str, timeout: int) -> str:
"""Trimite prin punte. Intoarce "" daca a plecat, altfel motivul esecului.
`requests` NU ridica exceptie la 4xx/5xx, iar puntea raspunde 503 cand nu e
conectata la WhatsApp si 500 cand `sendMessage` cade. Fara verificarea
codului, escaladarea se inregistra ca „notificata" si Maria promitea „te
contacteaza cineva" pentru un mesaj care nu plecase nicaieri — exact
promisiunea pe care ESCALATED_RECORDED exista ca sa n-o facem.
"""
try:
resp = requests.post(url, json=corp, timeout=timeout)
except Exception as exc: # noqa: BLE001
print(f"[consumer] trimitere esuata catre {catre}: {exc}", file=sys.stderr)
return str(exc)
if resp.status_code >= 400:
motiv = f"HTTP {resp.status_code}: {resp.text[:150]}"
print(f"[consumer] trimitere esuata catre {catre}: {motiv}", file=sys.stderr)
return motiv
return ""
def send_reply(to: str, text: str) -> str:
return _trimite(f"{bridge_url()}/send", {"to": to, "text": REPLY_PREFIX + text}, to, 15)
def send_image(to: str, path: str, caption: str) -> str:
return _trimite(f"{bridge_url()}/send-image",
{"to": to, "path": path, "caption": caption}, to, 60)
def react_seen(to: str, message_id: str, from_me: bool) -> None:
try:
requests.post(
f"{bridge_url()}/react",
json={"to": to, "id": message_id, "emoji": "\U0001F440", "fromMe": from_me},
timeout=10,
)
except Exception as exc: # noqa: BLE001
print(f"[consumer] react error: {exc}", file=sys.stderr)
def build_image_question(caption: str, ocr_text: str) -> str:
"""Ce vede modelul cand mesajul a fost o captura de ecran."""
parts = []
if caption.strip():
parts.append(caption.strip())
parts.append(
"Utilizatorul a trimis o captura de ecran. Text extras automat din imagine "
f"(OCR, poate contine greseli de recunoastere):\n---\n{ocr_text}\n---"
)
if not caption.strip():
parts.append("Explica-i ce inseamna eroarea si cum o rezolva.")
return "\n\n".join(parts)
def prepare_query(msg: dict) -> tuple[str | None, str | None, str | None]:
"""(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")
if not media:
return text, text, None
if media.get("error"):
print(f"[consumer] imagine nedescarcata: {media['error']}", file=sys.stderr)
if text:
return text, text, None
return None, None, (
"Nu am reusit sa descarc imaginea. Mai incearca o data, sau scrie-mi "
"mesajul de eroare ca text."
)
try:
ocr_text = ocr.run(media.get("path"))
except Exception as exc: # noqa: BLE001
print(f"[consumer] OCR esuat pe {media.get('path')}: {exc}", file=sys.stderr)
ocr_text = ""
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
# 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)
# „din captura", nu „caut dupa": pe un fir, interogarea finala e alta (ancora
# plus asta) si se logheaza separat — doua linii „caut dupa" induceau in eroare.
print(f"[consumer] din captura, retin: {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, fir: dict | None = None) -> 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,
}
if fir and fir.get("schimburi"):
record["fir"] = fir["schimburi"]
jid = config.get("SUPPORT_JID") or ""
if jid:
cine = msg.get("pushName") or msg.get("from") or "necunoscut"
discutie = fir_mod.rezumat(fir) if fir else ""
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]}"
+ (f"\n\n--- discutia de pana acum ---\n{discutie}" if discutie else "")
)
if media.get("path") and os.path.exists(media["path"]):
eroare = send_image(jid, media["path"], rezumat[:1000])
else:
eroare = _trimite(f"{bridge_url()}/send", {"to": jid, "text": rezumat}, jid, 20)
record["notified"] = not eroare
if eroare:
record["notify_error"] = eroare
print(f"[consumer] escaladare: notificarea a esuat: {eroare}", 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
REAMINTIRE_MIN = 15 # cat de rar poate Maria sa reaminteasca aceeasi problema
def _fisier_escaladare(ref: str):
d = config.STATE_DIR / "escalations"
if not d.exists():
return None
return next(iter(sorted(d.glob(f"*-{ref}.json"))), None)
def _notifica_suport(text: str, imagine: str | None = None) -> bool:
"""Mesaj catre suport; cu `imagine`, chiar captura primita, nu doar textul citit."""
jid = config.get("SUPPORT_JID") or ""
if not jid:
return False
if imagine and os.path.exists(imagine):
return not send_image(jid, imagine, text[:1000])
return not _trimite(f"{bridge_url()}/send", {"to": jid, "text": text}, jid, 20)
def completeaza(ref: str, msg: dict, text: str) -> str | None:
"""Adauga mesajul la escaladarea deschisa si intoarce ce i se raspunde omului.
`None` daca escaladarea nu mai exista (atunci mesajul se trateaza normal).
Trei feluri de completare, cu trei raspunsuri diferite: un semnal de urgenta
schimba escaladarea si anunta echipa; o intrebare de stare primeste ce stim
(de cat timp asteapta, daca am reamintit); un detaliu primeste o confirmare
scurta, rotita, ca sa nu sune a robot la a treia oara.
"""
fisier = _fisier_escaladare(ref)
if fisier is None:
return None
try:
record = json.loads(fisier.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
fel = triaj.fel_completare(text)
completari = record.setdefault("completari", [])
completari.append({"at": time.time(), "text": text, "fel": fel,
"message_id": msg.get("id")})
cine = msg.get("pushName") or msg.get("from") or "necunoscut"
minute = int((time.time() - record.get("at", time.time())) / 60)
if fel == "urgenta":
record["urgenta"] = True
trimis = _notifica_suport(
f"[Maria] URGENT — {ref} (de la {cine})\n{text[:800]}")
record["ultima_reamintire"] = time.time()
raspuns = triaj.URGENTA_MARCATA.format(ref=ref)
elif fel == "stare":
# Reamintirea e limitata: la fiecare „tot nimic" nu suna telefonul echipei.
de_reamintit = time.time() - record.get("ultima_reamintire", record.get("at", 0)) \
> REAMINTIRE_MIN * 60
reamintit = False
if de_reamintit:
reamintit = _notifica_suport(
f"[Maria] {ref}: clientul intreaba de {minute} minute daca s-a "
f"rezolvat.\n{text[:400]}")
if reamintit:
record["ultima_reamintire"] = time.time()
intrebari = sum(1 for c in completari if c.get("fel") == "stare")
raspuns = triaj.raspuns_stare(ref, minute, reamintit,
config.get("SUPPORT_PHONE", ""),
a_cata=intrebari - 1)
else:
_notifica_suport(f"[Maria] Completare la {ref} (de la {cine}):\n{text[:800]}",
imagine=(msg.get("media") or {}).get("path"))
raspuns = triaj.confirmare(ref, len(completari) - 1)
try:
fisier.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as exc:
print(f"[consumer] nu pot actualiza {fisier.name}: {exc}", file=sys.stderr)
print(f"[consumer] completare '{fel}' la {ref}: {text[:60]!r}", file=sys.stderr)
return raspuns
_GRUPURI_CACHE: dict = {"at": 0.0, "date": {}}
GRUPURI_TTL_S = 600
def grup_info(jid: str) -> dict:
"""Cate persoane sunt in grup. Raspunsul se tine 10 minute in memorie."""
if time.time() - _GRUPURI_CACHE["at"] > GRUPURI_TTL_S:
try:
resp = requests.get(f"{bridge_url()}/groups", timeout=15)
resp.raise_for_status()
_GRUPURI_CACHE["date"] = {g["jid"]: g for g in resp.json().get("groups", [])}
_GRUPURI_CACHE["at"] = time.time()
except Exception as exc: # noqa: BLE001
print(f"[consumer] nu pot citi grupurile: {exc}", file=sys.stderr)
return _GRUPURI_CACHE["date"].get(jid, {})
def preluare_de_om(msg: dict) -> bool:
"""Un om din echipa a scris in discutie, deci Maria se retrage.
Doar in grupuri cu mai multi oameni. In self-chat si in grupul de test (un
singur participant) TOT ce se scrie e `fromMe`: acolo regula ar face Maria sa
amuteasca la primul mesaj, deci ramane doar comanda explicita.
"""
if not msg.get("fromMe") or not msg.get("isGroup"):
return False
if (msg.get("text") or "").startswith(REPLY_PREFIX):
return False # propriul raspuns, nu un om
minim = config.get_int("FIR_PRELUARE_MIN_PARTICIPANTI", 2)
return grup_info(msg.get("from") or "").get("participants", 0) >= minim
def grupuri_permise() -> set[str]:
"""JID-urile de grup in care Maria are voie sa raspunda (ALLOWED_GROUP_JIDS)."""
brut = config.get("ALLOWED_GROUP_JIDS", "") or ""
return {j.strip() for j in brut.replace(";", ",").split(",") if j.strip()}
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)
fir = fir_mod.incarca(sender)
# Comenzi explicite. Merg oriunde, si acolo unde regula automata de mai jos nu
# se aplica (self-chat, grup de test cu un singur om).
cmd = fir_mod.comanda(text)
if cmd and not has_image:
fir = fir or fir_mod.deschide(sender, "")
if cmd == "stop":
fir_mod.taci(fir)
fir_mod.salveaza(fir)
print(f"[consumer] {sender}: tac la cerere", file=sys.stderr)
send_reply(sender, TACERE_OK)
else:
fir_mod.vorbeste(fir)
fir_mod.salveaza(fir)
send_reply(sender, REVENIRE_OK)
return
# Un om din echipa a intrat in discutie: Maria nu vorbeste peste el.
if preluare_de_om(msg):
fir = fir or fir_mod.deschide(sender, "")
fir_mod.adauga(fir, "om", text)
fir_mod.taci(fir)
fir_mod.salveaza(fir)
print(f"[consumer] {sender}: a preluat un om, tac {fir_mod.tacere_s() // 60} min",
file=sys.stderr)
return
if fir_mod.tace(fir):
print(f"[consumer] {sender}: fir preluat de om, nu raspund", file=sys.stderr)
return
react_seen(sender, msg.get("id"), msg.get("fromMe", False))
continuare = fir_mod.este_continuare(fir, text, has_image)
# Firul are o escaladare deschisa: mesajul e completare la ea, nu intrebare
# noua. Inaintea confirmarii si a cautarii — raspunsul vine instant, deci un
# „caut informatia, revin imediat" ar fi o promisiune inutila, iar embedding-ul
# s-ar calcula degeaba.
if continuare and fir.get("ref") and text.strip() and not has_image:
raspuns = completeaza(fir["ref"], msg, text.strip())
if raspuns:
fir_mod.adauga(fir, "client", text)
fir_mod.adauga(fir, "maria", raspuns)
fir_mod.salveaza(fir)
send_reply(sender, raspuns)
return
fir["ref"] = None # escaladarea nu mai exista; tratam mesajul normal
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
dovada = None
if continuare:
fir_mod.adauga(fir, "client", text or "[captura de ecran]")
fir_mod.extinde_ancora(fir, msg.get("ocr_text") or "")
# Captura pe o escaladare deschisa e chiar detaliul care lipsea, nu o
# problema noua: pleaca la aceeasi referinta, cu tot cu imagine.
if fir.get("ref") and has_image:
raspuns = completeaza(fir["ref"], msg, msg.get("ocr_text") or text)
if raspuns:
fir_mod.adauga(fir, "maria", raspuns)
fir_mod.salveaza(fir)
send_reply(sender, raspuns)
return
fir["ref"] = None # escaladarea nu mai exista; tratam mesajul normal
# ce a intrebat omul ACUM (mesajul nou, sau textul retinut din captura lui):
# acoperirea se judeca pe asta, nu pe ancora care ramane in interogare.
dovada = search_query
search_query = fir_mod.interogare(fir, search_query)
print(f"[consumer] continuare pe firul deschis; caut dupa {search_query[:90]!r}",
file=sys.stderr)
else:
fir = fir_mod.deschide(sender, msg.get("ocr_text") or text)
fir_mod.adauga(fir, "client", text or "[captura de ecran]")
try:
result = search(index, search_query, config.get_int("TOP_K", 3), dovada)
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"]:
# „Am o eroare la o factura", fara sa spuna care: nu avem ce cauta si nici
# ce trimite la suport — programatorul ar pune exact aceeasi intrebare.
# O singura data pe fir: daca nici dupa ce a fost intrebat nu spune mai
# mult, mesajul pleaca la om, care poate insista altfel.
if triaj.prea_vag(text, has_image) and not fir.get("detalii_cerute"):
fir["detalii_cerute"] = True
fir_mod.adauga(fir, "maria", triaj.CERE_DETALII)
fir_mod.salveaza(fir)
print("[consumer] mesaj prea vag -> cer detalii", file=sys.stderr)
send_reply(sender, triaj.CERE_DETALII)
return
print(f"[consumer] fara acoperire in documente -> suport ({result['reason']})", file=sys.stderr)
record = escalate(msg, question, result, fir)
sablon = ESCALATED_SENT if record["notified"] else ESCALATED_RECORDED
raspuns = sablon.format(ref=record["ref"])
fir["ref"] = record["ref"]
fir_mod.adauga(fir, "maria", raspuns)
fir_mod.salveaza(fir)
send_reply(sender, raspuns)
return
# Eroare care nu se rezolva din aplicatie: raspunsul se compune din dictionar
# si pleaca automat la programatori. Vezi triaj.py pentru de ce nu prin model.
nivel = triaj.nivel_suport(result["chunks"][0]) if result["chunks"] else None
if nivel:
print(f"[consumer] eroare de nivel '{nivel}' -> raspuns fix + suport", file=sys.stderr)
record = escalate(msg, question, result, fir)
raspuns = triaj.raspuns_escaladat(
result["chunks"][0], nivel, record["ref"], record["notified"], cu_imagine=has_image)
fir["ref"] = record["ref"]
fir_mod.adauga(fir, "maria", raspuns)
fir_mod.salveaza(fir)
send_reply(sender, raspuns)
return
try:
reply = ask_llm(result["chunks"], question, fir_mod.istoric(fir) if continuare else None)
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."
fir_mod.adauga(fir, "maria", reply)
fir_mod.salveaza(fir)
send_reply(sender, reply)
print(f"[consumer] -> raspuns catre {sender}", file=sys.stderr)
def main() -> None:
poll_s = config.get_int("POLL_INTERVAL_S", 2)
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"suport={config.get('SUPPORT_JID') or 'NESETAT (doar jurnal)'}",
file=sys.stderr,
)
index = load_index()
last_index_check = time.time()
while True:
try:
if time.time() - last_index_check > INDEX_REFRESH_S:
index = load_index()
last_index_check = time.time()
resp = requests.get(f"{bridge_url()}/messages", timeout=10)
resp.raise_for_status()
for msg in resp.json().get("messages", []):
if msg.get("isGroup") and msg.get("from") not in grupuri_permise():
continue
if (msg.get("text") or "").startswith(REPLY_PREFIX):
continue # ecoul propriului raspuns in self-chat, ignorat
try:
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)
if __name__ == "__main__":
main()