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
193 lines
6.9 KiB
Python
193 lines
6.9 KiB
Python
#!/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)
|