#!/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. Ancora ajuta REGASIREA, nu acoperirea. Decizia „raspund sau escaladez" se ia pe mesajul nou (`rank.assess(..., dovada=...)`), fiindca ancora poarta si salutul, si numele firmei, si luna — cuvinte care nu apar in niciun document si care trageau raportul de termeni distinctivi in jos la fiecare replica. 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 imagine_fereastra_s() -> int: """Cat de repede dupa un mesaj o captura mai e „acelasi lucru" (FIR_IMAGINE_MIN).""" return config.get_int("FIR_IMAGINE_MIN", 5) * 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? 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. Captura de ecran a fost la inceput semn sigur de subiect nou. E fals exact in cazul cel mai frecvent: omul scrie „am o factura cu eroare la trimitere" si trimite imediat captura, sau Maria tocmai i-a cerut-o. WhatsApp le livreaza ca doua mesaje, dar e un singur lucru — iar tratate separat, textul se pierde si captura se cauta pe OCR-ul ei singur (2026-09-01, grupul „Maria Test"). Departajarea o face timpul: o captura in primele `FIR_IMAGINE_MIN` minute continua firul, una venita mult mai tarziu deschide altul. """ if not fir: return False if are_imagine: return time.time() - fir.get("at", 0) <= imagine_fereastra_s() noi = rank.codes(text) if noi and any(c not in (fir.get("coduri") or []) for c in noi): return False return True def extinde_ancora(fir: dict, text: str, maxim: int = 2000) -> None: """Captura sosita pe firul deschis aduce chiar textul erorii — intra in ancora. Fara asta, ancora ramane reclamatia vaga care a deschis firul („am o eroare la o factura"), iar continuarile de dupa captura se cauta tot dupa ea. """ text = (text or "").strip() if not text: return ancora = (fir.get("ancora") or "").strip() fir["ancora"] = f"{ancora}\n{text}".strip()[-maxim:] fir["coduri"] = list(dict.fromkeys((fir.get("coduri") or []) + rank.codes(text))) 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)