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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user