feat(maria): fir de discutie — continuarea nu mai e tratata ca eroare noua
Fiecare mesaj pornea de la zero. Masurat pe indexul viu, dupa o escaladare pentru
ORA-06550: "da, ma blocheaza complet" primea raspuns despre ordinul de plata la
Trezorerie (cosinus 0,642, "acoperit"), "eram la salvarea unei facturi" despre
corectia unei eFacturi (0,742), iar "am incercat si tot nu merge" cerea din nou
detaliile tocmai date. Patru cuvinte fara context seamana cu ceva din documente,
iar cautarea nu avea de unde sti ca sunt raspunsul la intrebarea Mariei.
rag/fir.py tine ancora (textul erorii), codurile ei, referinta escaladarii si
ultimele 6 schimburi, in ~/.maria-bridge/conversations/ (expira, ca si capturile).
La o continuare: cautarea e pe ancora + mesajul nou, modelul primeste istoricul,
mesajul se adauga la escaladarea deschisa de cate ori e nevoie, iar "mesaj prea
vag" nu se mai aplica. Escaladarea duce firul intreg la suport, nu un mesaj rupt
din context.
Firul se rupe doar la o captura noua sau un cod de eroare diferit — schimbarea
subiectului in cuvinte e prea usor de confundat cu o continuare.
In plus:
- Maria tace 60 min cand preia un om, dar automat doar in grupuri cu >= 2
participanti: in self-chat totul e `fromMe`. Comenzi explicite oriunde
("Maria, stop" / "Maria, continua").
- ALLOWED_GROUP_JIDS: testarea se muta in grupul "Maria Test"
(120363409761730101@g.us), ca sa nu mai poluam chatul "Eu". Filtru si in punte,
si in consumer. NU echo-test: puntea lui Echo nu filtreaza fromMe in grupuri,
deci cei doi boti ar intra in bucla.
Teste: 94 pass. Calibrare 23/23, cu doua cazuri de continuare.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
This commit is contained in:
@@ -405,6 +405,49 @@ alta referinta. Fereastra e de 30 de minute (`consumer.PENDING_TTL_S`), tinuta i
|
||||
`~/.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, necontestat:** o captura noua sau un cod de eroare diferit. Sunt
|
||||
singurele semne sigure ca omul a trecut la altceva. 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).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -55,6 +55,12 @@ CAZURI: list[tuple[str, bool]] = [
|
||||
"ORA-06550: line 1, column 7:", True),
|
||||
# cod Oracle real, dar care NU e in dictionar: trebuie sa plece la suport
|
||||
("ORA-00600 internal error code arguments", False),
|
||||
# continuari pe un fir deschis: asa arata interogarea dupa fir.interogare()
|
||||
# — ancora plus raspunsul scurt al omului. Fara ancora, „da, ma blocheaza"
|
||||
# nimerea ordinul de plata la Trezorerie, cu cosinus 0,642.
|
||||
("ORA-06550: line 1, column 7 PLS-00906 object invalid\nda, ma blocheaza complet", True),
|
||||
("ORA-12154 TNS could not resolve the connect identifier\n"
|
||||
"eram la salvarea unei facturi", True),
|
||||
("care e capitala Frantei", False),
|
||||
("cat costa un bilet de avion la Paris", False),
|
||||
("imi da eroare la imprimanta HP LaserJet, nu trage hartia", False),
|
||||
|
||||
@@ -10,6 +10,13 @@ BRIDGE_PORT=8099
|
||||
# dupa ce esti multumit de calitatea raspunsurilor — altfel Maria raspunde la
|
||||
# oricine iti scrie pe numarul legat.
|
||||
TEST_MODE_SELF_CHAT_ONLY=true
|
||||
# Grupuri in care Maria raspunde chiar si cu self-chat-only (JID-uri, separate prin
|
||||
# virgula). JID-ul unui grup se citeste din punte: curl -s localhost:8099/groups
|
||||
# ATENTIE: nu pune un grup in care raspunde si Echo (LXC 110) — puntea lui nu
|
||||
# filtreaza `fromMe` in grupuri, deci cei doi boti isi raspund unul altuia la
|
||||
# nesfarsit. Vezi docs/chatboti-si-punti.md.
|
||||
ALLOWED_GROUP_JIDS=
|
||||
|
||||
# Imaginile primite (capturi cu erori) peste aceasta marime sunt refuzate, fara
|
||||
# descarcare. O captura de ecran normala are sub 1 MB.
|
||||
MAX_MEDIA_MB=8
|
||||
|
||||
@@ -41,6 +41,15 @@ DEFAULTS: dict[str, str] = {
|
||||
"MAX_TOKENS": "250",
|
||||
"POLL_INTERVAL_S": "2",
|
||||
"TEST_MODE_SELF_CHAT_ONLY": "true",
|
||||
# Grupuri in care Maria are voie sa raspunda (JID-uri separate prin virgula).
|
||||
# Restul grupurilor sunt ignorate, si de punte, si de consumer.
|
||||
"ALLOWED_GROUP_JIDS": "",
|
||||
# Firul de discutie (vezi rag/fir.py): cat tine minte, cat tace dupa ce a
|
||||
# preluat un om, si de cati participanti e nevoie ca preluarea sa se aplice
|
||||
# automat (in self-chat si in grupul de test totul e `fromMe`).
|
||||
"FIR_TTL_MIN": "120",
|
||||
"FIR_TACERE_MIN": "60",
|
||||
"FIR_PRELUARE_MIN_PARTICIPANTI": "2",
|
||||
# OCR pentru capturile de ecran primite pe WhatsApp (vezi rag/ocr.py).
|
||||
"OCR_LANGS": "ron+eng",
|
||||
"OCR_MAX_CHARS": "1500",
|
||||
|
||||
@@ -25,6 +25,7 @@ import time
|
||||
import requests
|
||||
|
||||
import config
|
||||
import fir as fir_mod
|
||||
import ocr
|
||||
import rank
|
||||
import triaj
|
||||
@@ -75,6 +76,10 @@ ESCALATED_RECORDED = (
|
||||
)
|
||||
|
||||
|
||||
TACERE_OK = (
|
||||
'Am inteles, ma opresc. Scrie-mi "Maria, continua" cand vrei sa reiau.'
|
||||
)
|
||||
REVENIRE_OK = "Sunt aici. Cu ce te ajut?"
|
||||
FOLLOWUP_OK = (
|
||||
"Multumesc, am adaugat si asta la {ref} — echipa vede detaliul cand preia "
|
||||
"problema."
|
||||
@@ -154,17 +159,25 @@ def search(index: Index, query: str, top_k: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def ask_llm(chunks: list[str], question: str) -> str:
|
||||
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": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"messages": mesaje,
|
||||
"max_tokens": config.get_int("MAX_TOKENS", 250),
|
||||
},
|
||||
timeout=60,
|
||||
@@ -270,7 +283,7 @@ def reference(when: float, message_id: str | None) -> str:
|
||||
return f"M-{time.strftime('%y%m%d', time.localtime(when))}-{sufix}"
|
||||
|
||||
|
||||
def escalate(msg: dict, question: str, search_result: dict) -> dict:
|
||||
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`
|
||||
@@ -295,15 +308,19 @@ def escalate(msg: dict, question: str, search_result: dict) -> dict:
|
||||
"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 "")
|
||||
)
|
||||
try:
|
||||
if media.get("path") and os.path.exists(media["path"]):
|
||||
@@ -329,49 +346,6 @@ def escalate(msg: dict, question: str, search_result: dict) -> dict:
|
||||
return record
|
||||
|
||||
|
||||
PENDING_FILE_NAME = "pending.json"
|
||||
PENDING_TTL_S = 30 * 60 # cat timp un raspuns scurt mai e „completare", nu intrebare noua
|
||||
|
||||
|
||||
def _pending_path():
|
||||
return config.STATE_DIR / "escalations" / PENDING_FILE_NAME
|
||||
|
||||
|
||||
def _pending_all() -> dict:
|
||||
try:
|
||||
return json.loads(_pending_path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _pending_write(data: dict) -> None:
|
||||
try:
|
||||
_pending_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
_pending_path().write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"[consumer] nu pot scrie {PENDING_FILE_NAME}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def pending_set(sender: str, ref: str) -> None:
|
||||
"""Retine ca l-am intrebat pe om cat e de urgent, ca sa stiu unde duce raspunsul."""
|
||||
data = _pending_all()
|
||||
data[sender] = {"ref": ref, "at": time.time()}
|
||||
_pending_write(data)
|
||||
|
||||
|
||||
def pending_get(sender: str) -> dict | None:
|
||||
intrare = _pending_all().get(sender)
|
||||
if not intrare or time.time() - intrare.get("at", 0) > PENDING_TTL_S:
|
||||
return None
|
||||
return intrare
|
||||
|
||||
|
||||
def pending_clear(sender: str) -> None:
|
||||
data = _pending_all()
|
||||
if data.pop(sender, None) is not None:
|
||||
_pending_write(data)
|
||||
|
||||
|
||||
def append_followup(ref: str, msg: dict, text: str) -> bool:
|
||||
"""Adauga raspunsul omului la escaladarea deschisa si il trimite la suport.
|
||||
|
||||
@@ -408,6 +382,44 @@ def append_followup(ref: str, msg: dict, text: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
_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")
|
||||
@@ -424,22 +436,50 @@ def handle(index: Index, msg: dict) -> None:
|
||||
has_image = bool(msg.get("media"))
|
||||
text = msg.get("text", "") or ""
|
||||
print(f"[consumer] {sender}: {'[imagine] ' if has_image else ''}{text[:80]}", file=sys.stderr)
|
||||
react_seen(sender, msg.get("id"), msg.get("fromMe", False))
|
||||
|
||||
# Raspunsul la „te blocheaza sau poti continua?" merge la escaladarea deschisa.
|
||||
# Inainte de orice altceva: nu e o intrebare noua, deci nu se cauta si nu se
|
||||
# confirma cu „caut informatia".
|
||||
asteptat = pending_get(sender) if not has_image else None
|
||||
if asteptat and text.strip() and not rank.codes(text):
|
||||
if append_followup(asteptat["ref"], msg, text.strip()):
|
||||
pending_clear(sender)
|
||||
send_reply(sender, FOLLOWUP_OK.format(ref=asteptat["ref"]))
|
||||
return
|
||||
pending_clear(sender)
|
||||
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)
|
||||
|
||||
# „Am o eroare", fara sa spuna care: nu ghicim si nu deranjam suportul — intrebam.
|
||||
if triaj.prea_vag(text, has_image):
|
||||
# Doar la deschiderea firului: la o continuare, detaliile au fost deja cerute.
|
||||
if not continuare and triaj.prea_vag(text, has_image):
|
||||
print("[consumer] mesaj prea vag -> cer detalii", file=sys.stderr)
|
||||
fir = fir_mod.deschide(sender, text)
|
||||
fir_mod.adauga(fir, "client", text)
|
||||
fir_mod.adauga(fir, "maria", triaj.CERE_DETALII)
|
||||
fir_mod.salveaza(fir)
|
||||
send_reply(sender, triaj.CERE_DETALII)
|
||||
return
|
||||
|
||||
@@ -452,6 +492,15 @@ def handle(index: Index, msg: dict) -> None:
|
||||
if not question:
|
||||
return
|
||||
|
||||
if continuare:
|
||||
fir_mod.adauga(fir, "client", text)
|
||||
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))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -462,11 +511,24 @@ def handle(index: Index, msg: dict) -> None:
|
||||
surse = ", ".join(f"{t['source']}({t['cosine']})" for t in result["top"]) or "-"
|
||||
print(f"[consumer] rank: {surse} | {result['reason']}", file=sys.stderr)
|
||||
|
||||
# Firul are deja o escaladare deschisa: ce spune omul acum e completare la ea,
|
||||
# nu o problema noua. De cate ori e nevoie, nu o singura data.
|
||||
if continuare and fir.get("ref") and append_followup(fir["ref"], msg, text.strip() or "(captura)"):
|
||||
raspuns = FOLLOWUP_OK.format(ref=fir["ref"])
|
||||
fir_mod.adauga(fir, "maria", raspuns)
|
||||
fir_mod.salveaza(fir)
|
||||
send_reply(sender, raspuns)
|
||||
return
|
||||
|
||||
if not result["covered"]:
|
||||
print(f"[consumer] fara acoperire in documente -> suport ({result['reason']})", file=sys.stderr)
|
||||
record = escalate(msg, question, result)
|
||||
record = escalate(msg, question, result, fir)
|
||||
sablon = ESCALATED_SENT if record["notified"] else ESCALATED_RECORDED
|
||||
send_reply(sender, sablon.format(ref=record["ref"]))
|
||||
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
|
||||
@@ -474,18 +536,22 @@ def handle(index: Index, msg: dict) -> None:
|
||||
nivel = triaj.nivel_suport(result["chunks"][0]) if result["chunks"] else None
|
||||
if nivel:
|
||||
print(f"[consumer] eroare de nivel '{nivel}' -> raspuns fix + suport", file=sys.stderr)
|
||||
record = escalate(msg, question, result)
|
||||
send_reply(sender, triaj.raspuns_escaladat(
|
||||
result["chunks"][0], nivel, record["ref"], record["notified"],
|
||||
cu_imagine=has_image))
|
||||
pending_set(sender, record["ref"])
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -509,7 +575,7 @@ def main() -> None:
|
||||
resp = requests.get(f"{bridge_url()}/messages", timeout=10)
|
||||
resp.raise_for_status()
|
||||
for msg in resp.json().get("messages", []):
|
||||
if msg.get("isGroup"):
|
||||
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
|
||||
|
||||
160
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py
Normal file
160
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/fir.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Firul de discutie: al doilea mesaj al omului e continuarea primului.
|
||||
|
||||
Fara asta, fiecare mesaj pornea de la zero. Masurat pe indexul viu, dupa o
|
||||
escaladare pentru ORA-06550:
|
||||
|
||||
„da, ma blocheaza complet" -> raspuns despre ordinul de plata la Trezorerie
|
||||
„eram la salvarea unei facturi" -> raspuns despre corectia unei eFacturi
|
||||
„si acum ce fac?" -> raspuns despre eFacturi primite gresit
|
||||
|
||||
Toate trei „acoperite", cu cosinus peste prag: patru cuvinte fara context seamana
|
||||
cu ceva din documente, iar cautarea nu are de unde sti ca sunt raspunsul la
|
||||
intrebarea Mariei, nu o intrebare noua.
|
||||
|
||||
Ce tine firul: ancora (textul erorii care l-a deschis), codurile ei, referinta
|
||||
escaladarii deschise si ultimele schimburi. Ancora intra in interogarea de cautare
|
||||
la fiecare continuare — asa „da, ma blocheaza" se cauta impreuna cu eroarea, nu
|
||||
singur.
|
||||
|
||||
Ce rupe firul, necontestat: o captura noua sau un cod de eroare diferit. Sunt
|
||||
singurele semne sigure ca omul a trecut la altceva; restul (schimbarea subiectului
|
||||
in cuvinte) e prea usor de confundat cu o continuare, iar greseala aia produce
|
||||
exact raspunsurile de mai sus.
|
||||
|
||||
Firele contin text de client (uneori si OCR de pe capturi), deci expira si se sterg
|
||||
singure, ca si capturile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
import config
|
||||
import rank
|
||||
|
||||
MAX_SCHIMBURI = 6 # cate replici se tin minte (ale omului si ale Mariei, la un loc)
|
||||
|
||||
|
||||
def ttl_s() -> int:
|
||||
"""Cat timp de tacere inseamna ca discutia s-a incheiat (FIR_TTL_MIN)."""
|
||||
return config.get_int("FIR_TTL_MIN", 120) * 60
|
||||
|
||||
|
||||
def tacere_s() -> int:
|
||||
"""Cat sta Maria deoparte dupa ce a preluat un om (FIR_TACERE_MIN)."""
|
||||
return config.get_int("FIR_TACERE_MIN", 60) * 60
|
||||
|
||||
|
||||
def _dir():
|
||||
return config.STATE_DIR / "conversations"
|
||||
|
||||
|
||||
def _cale(jid: str):
|
||||
return _dir() / (re.sub(r"[^0-9a-zA-Z@._-]", "_", jid) + ".json")
|
||||
|
||||
|
||||
def incarca(jid: str) -> dict | None:
|
||||
"""Firul deschis cu cineva, sau `None` daca nu exista ori a expirat."""
|
||||
cale = _cale(jid)
|
||||
try:
|
||||
fir = json.loads(cale.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if time.time() - fir.get("at", 0) > ttl_s():
|
||||
cale.unlink(missing_ok=True)
|
||||
return None
|
||||
return fir
|
||||
|
||||
|
||||
def salveaza(fir: dict) -> None:
|
||||
fir["at"] = time.time()
|
||||
try:
|
||||
_dir().mkdir(parents=True, exist_ok=True)
|
||||
_cale(fir["jid"]).write_text(json.dumps(fir, ensure_ascii=False), encoding="utf-8")
|
||||
except OSError:
|
||||
pass # firul e o comoditate, nu o obligatie: fara el se comporta ca inainte
|
||||
|
||||
|
||||
def sterge(jid: str) -> None:
|
||||
_cale(jid).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def deschide(jid: str, ancora: str) -> dict:
|
||||
return {"jid": jid, "at": time.time(), "ancora": ancora.strip(),
|
||||
"coduri": rank.codes(ancora), "ref": None, "tacere_pana": 0, "schimburi": []}
|
||||
|
||||
|
||||
def adauga(fir: dict, rol: str, text: str) -> None:
|
||||
fir.setdefault("schimburi", []).append({"rol": rol, "text": text.strip()[:1500],
|
||||
"at": time.time()})
|
||||
del fir["schimburi"][:-MAX_SCHIMBURI]
|
||||
|
||||
|
||||
def este_continuare(fir: dict | None, text: str, are_imagine: bool) -> bool:
|
||||
"""Mesajul duce mai departe firul deschis, sau incepe altceva?
|
||||
|
||||
O captura noua sau un cod pe care firul nu-l are inseamna subiect nou. In rest
|
||||
— inclusiv la un mesaj lung — e continuare: omul raspunde la ce l-a intrebat
|
||||
Maria, iar ancora nu strica o cautare care oricum are destul text al ei.
|
||||
"""
|
||||
if not fir:
|
||||
return False
|
||||
if are_imagine:
|
||||
return False
|
||||
noi = rank.codes(text)
|
||||
if noi and any(c not in (fir.get("coduri") or []) for c in noi):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def interogare(fir: dict, text: str) -> str:
|
||||
"""Ce se cauta in index la o continuare: ancora firului plus mesajul nou."""
|
||||
ancora = (fir.get("ancora") or "").strip()
|
||||
if not ancora:
|
||||
return text
|
||||
return f"{ancora}\n{text.strip()}".strip()
|
||||
|
||||
|
||||
def istoric(fir: dict) -> list[dict]:
|
||||
"""Schimburile de pana acum, in formatul de chat al modelului."""
|
||||
return [{"role": "assistant" if s["rol"] == "maria" else "user", "content": s["text"]}
|
||||
for s in fir.get("schimburi", [])]
|
||||
|
||||
|
||||
def rezumat(fir: dict, maxim: int = 900) -> str:
|
||||
"""Firul, ca text, pentru mesajul catre suport."""
|
||||
etichete = {"maria": "Maria", "om": "Suport"}
|
||||
linii = [f"{etichete.get(s['rol'], 'Client')}: {s['text']}"
|
||||
for s in fir.get("schimburi", [])]
|
||||
return "\n".join(linii)[-maxim:]
|
||||
|
||||
|
||||
# --- cand preia un om -------------------------------------------------------
|
||||
|
||||
_STOP = re.compile(r"^\s*(maria[, ]+)?(stop|pauza|taci|preiau|ma ocup eu)\b")
|
||||
_START = re.compile(r"^\s*maria[, ]+(start|continua|revino|poti)\b")
|
||||
|
||||
|
||||
def comanda(text: str) -> str | None:
|
||||
"""`"stop"` / `"start"` / `None` — comenzi explicite de oprire si repornire."""
|
||||
t = rank.fold(text or "")
|
||||
if _START.match(t):
|
||||
return "start"
|
||||
if _STOP.match(t):
|
||||
return "stop"
|
||||
return None
|
||||
|
||||
|
||||
def taci(fir: dict, secunde: int | None = None) -> None:
|
||||
fir["tacere_pana"] = time.time() + (tacere_s() if secunde is None else secunde)
|
||||
|
||||
|
||||
def vorbeste(fir: dict) -> None:
|
||||
fir["tacere_pana"] = 0
|
||||
|
||||
|
||||
def tace(fir: dict | None) -> bool:
|
||||
return bool(fir) and time.time() < (fir.get("tacere_pana") or 0)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Firul de discutie: continuare vs subiect nou, ancora, tacerea la preluare."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import fir
|
||||
|
||||
|
||||
JID = "40712345678@s.whatsapp.net"
|
||||
EROARE = ("[Oracle][ODBC][Ora]ORA-06550: line 1, column 7\n"
|
||||
"PLS-00906: object VADECO PACK PARTENERI is invalid")
|
||||
|
||||
|
||||
def test_firul_retine_codurile_ancorei():
|
||||
f = fir.deschide(JID, EROARE)
|
||||
assert "ora-06550" in f["coduri"] and "pls-00906" in f["coduri"]
|
||||
|
||||
|
||||
def test_un_raspuns_scurt_e_continuare_nu_eroare_noua():
|
||||
"""Cazul masurat: „da, ma blocheaza" cautat singur nimerea ordinul de plata."""
|
||||
f = fir.deschide(JID, EROARE)
|
||||
assert fir.este_continuare(f, "da, ma blocheaza complet", False)
|
||||
assert fir.este_continuare(f, "eram la salvarea unei facturi", False)
|
||||
# ancora intra in cautare, ca sa nu se caute patru cuvinte fara context
|
||||
q = fir.interogare(f, "da, ma blocheaza complet")
|
||||
assert "ORA-06550" in q and "ma blocheaza" in q
|
||||
|
||||
|
||||
def test_captura_noua_sau_alt_cod_rup_firul():
|
||||
f = fir.deschide(JID, EROARE)
|
||||
assert not fir.este_continuare(f, "imi da acum ORA-12154", False)
|
||||
assert not fir.este_continuare(f, "si asta ce e?", True) # captura noua
|
||||
# acelasi cod nu rupe firul
|
||||
assert fir.este_continuare(f, "tot ORA-06550 imi da", False)
|
||||
|
||||
|
||||
def test_fara_fir_nu_exista_continuare():
|
||||
assert not fir.este_continuare(None, "da", False)
|
||||
|
||||
|
||||
def test_firul_expira(monkeypatch):
|
||||
f = fir.deschide(JID, EROARE)
|
||||
fir.salveaza(f)
|
||||
assert fir.incarca(JID) is not None
|
||||
|
||||
tarziu = time.time() + fir.ttl_s() + 1
|
||||
monkeypatch.setattr(fir.time, "time", lambda: tarziu)
|
||||
assert fir.incarca(JID) is None
|
||||
|
||||
|
||||
def test_istoricul_se_opreste_la_max_schimburi():
|
||||
f = fir.deschide(JID, EROARE)
|
||||
for i in range(fir.MAX_SCHIMBURI + 4):
|
||||
fir.adauga(f, "client", f"mesaj {i}")
|
||||
assert len(f["schimburi"]) == fir.MAX_SCHIMBURI
|
||||
assert f["schimburi"][-1]["text"] == f"mesaj {fir.MAX_SCHIMBURI + 3}"
|
||||
assert fir.istoric(f)[-1]["role"] == "user"
|
||||
|
||||
|
||||
def test_comenzile_de_oprire_si_revenire():
|
||||
assert fir.comanda("Maria, stop") == "stop"
|
||||
assert fir.comanda("preiau eu") == "stop"
|
||||
assert fir.comanda("Maria, continua") == "start"
|
||||
assert fir.comanda("cum trimit D406") is None
|
||||
|
||||
|
||||
def test_tacerea_expira(monkeypatch):
|
||||
f = fir.deschide(JID, EROARE)
|
||||
fir.taci(f)
|
||||
assert fir.tace(f)
|
||||
tarziu = time.time() + fir.tacere_s() + 1
|
||||
monkeypatch.setattr(fir.time, "time", lambda: tarziu)
|
||||
assert not fir.tace(f)
|
||||
|
||||
|
||||
def test_preluarea_doar_in_grupuri_cu_mai_multi_oameni(monkeypatch):
|
||||
"""In self-chat si in grupul de test tot ce se scrie e fromMe."""
|
||||
import consumer
|
||||
monkeypatch.setattr(consumer, "grup_info",
|
||||
lambda jid: {"participants": 1 if "test" in jid else 5})
|
||||
|
||||
assert not consumer.preluare_de_om({"fromMe": True, "isGroup": False, "from": JID})
|
||||
assert not consumer.preluare_de_om(
|
||||
{"fromMe": True, "isGroup": True, "from": "test@g.us", "text": "salut"})
|
||||
assert consumer.preluare_de_om(
|
||||
{"fromMe": True, "isGroup": True, "from": "client@g.us", "text": "preiau eu cazul"})
|
||||
# propriul raspuns al Mariei nu e „un om care a preluat"
|
||||
assert not consumer.preluare_de_om(
|
||||
{"fromMe": True, "isGroup": True, "from": "client@g.us",
|
||||
"text": consumer.REPLY_PREFIX + "buna"})
|
||||
@@ -74,22 +74,25 @@ def test_raspunsul_despre_urgenta_intra_in_escaladarea_deschisa(monkeypatch):
|
||||
monkeypatch.setattr(config, "get", lambda k, d=None: "" if k == "SUPPORT_JID"
|
||||
else config.DEFAULTS.get(k, d))
|
||||
_escaladare("M-TEST-1")
|
||||
consumer.pending_set("40712@s.whatsapp.net", "M-TEST-1")
|
||||
assert consumer.pending_get("40712@s.whatsapp.net")["ref"] == "M-TEST-1"
|
||||
|
||||
assert consumer.append_followup("M-TEST-1", {"id": "x"}, "ma blocheaza, nu pot factura")
|
||||
|
||||
fisier = next((config.STATE_DIR / "escalations").glob("*-M-TEST-1.json"))
|
||||
record = json.loads(fisier.read_text(encoding="utf-8"))
|
||||
assert record["completari"][0]["text"] == "ma blocheaza, nu pot factura"
|
||||
|
||||
consumer.pending_clear("40712@s.whatsapp.net")
|
||||
assert consumer.pending_get("40712@s.whatsapp.net") is None
|
||||
|
||||
|
||||
def test_completarea_expira(monkeypatch):
|
||||
def test_completarile_se_aduna_nu_se_inlocuiesc(monkeypatch):
|
||||
"""Omul raspunde de mai multe ori; prima data nu inchide firul."""
|
||||
import config
|
||||
import consumer
|
||||
consumer.pending_set("40799@s.whatsapp.net", "M-TEST-2")
|
||||
peste_o_ora = time.time() + consumer.PENDING_TTL_S + 1
|
||||
monkeypatch.setattr(consumer.time, "time", lambda: peste_o_ora)
|
||||
assert consumer.pending_get("40799@s.whatsapp.net") is None
|
||||
|
||||
monkeypatch.setattr(config, "get", lambda k, d=None: "" if k == "SUPPORT_JID"
|
||||
else config.DEFAULTS.get(k, d))
|
||||
_escaladare("M-TEST-3")
|
||||
consumer.append_followup("M-TEST-3", {"id": "a"}, "da, ma blocheaza")
|
||||
consumer.append_followup("M-TEST-3", {"id": "b"}, "eram la salvarea unei facturi")
|
||||
|
||||
fisier = next((config.STATE_DIR / "escalations").glob("*-M-TEST-3.json"))
|
||||
record = json.loads(fisier.read_text(encoding="utf-8"))
|
||||
assert [c["text"] for c in record["completari"]] == [
|
||||
"da, ma blocheaza", "eram la salvarea unei facturi"]
|
||||
|
||||
@@ -28,6 +28,12 @@ const MEDIA_DIR = process.env.MARIA_BRIDGE_DIR
|
||||
: path.join(__dirname, 'media');
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const TEST_MODE_SELF_CHAT_ONLY = (process.env.TEST_MODE_SELF_CHAT_ONLY || 'true') !== 'false';
|
||||
// Grupuri in care Maria are voie sa raspunda chiar si in modul self-chat. Testarea
|
||||
// intr-un grup dedicat scoate zgomotul din chatul „Eu" — dar numai grupurile de
|
||||
// aici trec, ca sa nu inceapa Maria sa vorbeasca in cele 197 de grupuri ale contului.
|
||||
const ALLOWED_GROUP_JIDS = new Set(
|
||||
(process.env.ALLOWED_GROUP_JIDS || '').split(/[,;]/).map((s) => s.trim()).filter(Boolean),
|
||||
);
|
||||
// WhatsApp invalideaza codul de asociere in cateva minute. Il tinem afisat doar
|
||||
// atat: un cod expirat afisat in dashboard trimite omul sa tasteze degeaba.
|
||||
const PAIRING_CODE_TTL_MS = 3 * 60 * 1000;
|
||||
@@ -230,7 +236,8 @@ async function startConnection() {
|
||||
|| (ownLid && msg.key.remoteJid === ownLid);
|
||||
// Mesajele aruncate se logheaza. Fara asta, un JID care nu se potriveste cu
|
||||
// ownJid (ex. formatul @lid) face bridge-ul sa taca fara nicio urma in log.
|
||||
if (TEST_MODE_SELF_CHAT_ONLY && !isSelfChat) {
|
||||
const isAllowedGroup = isGroup && ALLOWED_GROUP_JIDS.has(msg.key.remoteJid);
|
||||
if (TEST_MODE_SELF_CHAT_ONLY && !isSelfChat && !isAllowedGroup) {
|
||||
console.log(`[whatsapp] ignorat (nu e self-chat): remoteJid=${msg.key.remoteJid} ownJid=${ownJid} ownLid=${ownLid}`);
|
||||
continue;
|
||||
}
|
||||
@@ -292,6 +299,7 @@ app.get('/status', (_req, res) => {
|
||||
connected,
|
||||
phone: phoneNumber,
|
||||
selfChatOnly: TEST_MODE_SELF_CHAT_ONLY,
|
||||
allowedGroups: [...ALLOWED_GROUP_JIDS],
|
||||
lid: ownLid,
|
||||
qr: connected ? null : currentQR,
|
||||
pairing: connected || !p ? null : {
|
||||
|
||||
Reference in New Issue
Block a user