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
164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
"""Configuratie comuna a puntii WhatsApp+RAG pentru Maria (LXC 171 claude-agent).
|
|
|
|
Citeste ~/.maria-bridge/env (KEY=value, tolerant la comentarii si ghilimele).
|
|
Mirrors deliberat conventiile din discord-bridge/config.py, ca sa fie un singur
|
|
model de citit pentru cine intretine ambele punti pe acest container.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import fcntl
|
|
import os
|
|
import pathlib
|
|
|
|
# Directorul de baza poate fi mutat in teste prin MARIA_BRIDGE_DIR.
|
|
_DEFAULT_DIR = pathlib.Path.home() / ".maria-bridge"
|
|
|
|
STATE_DIR: pathlib.Path = pathlib.Path(os.environ.get("MARIA_BRIDGE_DIR") or _DEFAULT_DIR)
|
|
DOCS_DIR: pathlib.Path = STATE_DIR / "documents"
|
|
# Documente care NU vin din Drive. `rclone sync` sterge din DOCS_DIR tot ce nu
|
|
# exista in Drive, deci orice fisier pus acolo de mana dispare la urmatorul tur.
|
|
# Ce sta aici e in afara oglinzii si supravietuieste.
|
|
DOCS_LOCAL_DIR: pathlib.Path = STATE_DIR / "documents-local"
|
|
INDEX_FILE: pathlib.Path = STATE_DIR / "rag_index.json"
|
|
LOCK_FILE: pathlib.Path = STATE_DIR / ".rag.lock"
|
|
LOG_DIR: pathlib.Path = STATE_DIR / "logs"
|
|
ENV_FILE: pathlib.Path = STATE_DIR / "env"
|
|
AUTH_DIR: pathlib.Path = STATE_DIR / "whatsapp-auth"
|
|
|
|
_env: dict[str, str] = {}
|
|
|
|
# Valori implicite. LLM_URL/OLLAMA_URL presupun tunel/proxy local catre backend-ul
|
|
# real (vezi docs/maria-whatsapp-rag-prototype.md) — de completat in env dupa caz.
|
|
DEFAULTS: dict[str, str] = {
|
|
"BRIDGE_HOST": "127.0.0.1",
|
|
"BRIDGE_PORT": "8099",
|
|
"LLM_URL": "http://127.0.0.1:8091",
|
|
"OLLAMA_URL": "http://127.0.0.1:11434",
|
|
"EMBED_MODEL": "nomic-embed-text",
|
|
"TOP_K": "3",
|
|
"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",
|
|
"OCR_TIMEOUT_S": "60",
|
|
# Ordonarea chunk-urilor si pragul de la care se considera ca raspunsul chiar
|
|
# e in documente (vezi rag/rank.py pentru masuratoarea din spatele valorilor).
|
|
"RANK_STRONG_COSINE": "0.70",
|
|
"RANK_WEAK_COSINE": "0.58",
|
|
"RANK_RECALL_N": "12",
|
|
"RANK_MIN_RARE_RATIO": "0.5",
|
|
# Unde pleaca intrebarile fara raspuns: JID de WhatsApp, ex.
|
|
# "40712345678@s.whatsapp.net" sau "1203...@g.us" pentru un grup.
|
|
# Gol = escaladarile se scriu doar in jurnal (~/.maria-bridge/escalations/).
|
|
"SUPPORT_JID": "",
|
|
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
|
|
# "gdrive,root_folder_id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O:" (dosarul
|
|
# document_store din Drive, vazut pe Windows ca D:\GoogleDrive\romfast\document_store).
|
|
# Gol = sincronizare dezactivata, doar upload manual din dashboard.
|
|
"DRIVE_REMOTE": "",
|
|
}
|
|
|
|
|
|
def parse_env(text: str) -> dict[str, str]:
|
|
"""Parseaza un fisier de tip KEY=value. Nu arunca niciodata."""
|
|
out: dict[str, str] = {}
|
|
for raw in text.splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("export "):
|
|
line = line[len("export "):].strip()
|
|
if "=" not in line:
|
|
continue
|
|
key, _, val = line.partition("=")
|
|
key = key.strip()
|
|
if not key:
|
|
continue
|
|
val = val.strip()
|
|
if val[:1] not in ("'", '"'):
|
|
cut = val.find(" #")
|
|
if cut >= 0:
|
|
val = val[:cut].rstrip()
|
|
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
|
val = val[1:-1]
|
|
out[key] = val
|
|
return out
|
|
|
|
|
|
def reload(base_dir: str | os.PathLike | None = None) -> dict[str, str]:
|
|
"""Recalculeaza caile si reciteste env-ul. Returneaza dictionarul incarcat."""
|
|
global STATE_DIR, DOCS_DIR, DOCS_LOCAL_DIR, INDEX_FILE, LOCK_FILE, LOG_DIR, ENV_FILE, AUTH_DIR, _env
|
|
if base_dir is None:
|
|
base_dir = os.environ.get("MARIA_BRIDGE_DIR") or _DEFAULT_DIR
|
|
STATE_DIR = pathlib.Path(base_dir)
|
|
DOCS_DIR = STATE_DIR / "documents"
|
|
DOCS_LOCAL_DIR = STATE_DIR / "documents-local"
|
|
INDEX_FILE = STATE_DIR / "rag_index.json"
|
|
LOCK_FILE = STATE_DIR / ".rag.lock"
|
|
LOG_DIR = STATE_DIR / "logs"
|
|
ENV_FILE = STATE_DIR / "env"
|
|
AUTH_DIR = STATE_DIR / "whatsapp-auth"
|
|
try:
|
|
_env = parse_env(ENV_FILE.read_text(encoding="utf-8"))
|
|
except OSError:
|
|
_env = {}
|
|
return _env
|
|
|
|
|
|
def get(key: str, default: str | None = None) -> str | None:
|
|
if key in _env:
|
|
return _env[key]
|
|
if key in DEFAULTS:
|
|
return DEFAULTS[key]
|
|
return default
|
|
|
|
|
|
def get_int(key: str, default: int) -> int:
|
|
try:
|
|
return int(get(key, str(default)) or default)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
reload()
|
|
|
|
|
|
class Busy(RuntimeError):
|
|
"""Alta reindexare e deja in curs."""
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def exclusive():
|
|
"""Lacat intre PROCESE pentru reindexare (timer vs dashboard vs rulare manuala).
|
|
|
|
Reindexarea dureaza minute (embeddings pe CPU). Fara lacat, `maria-sync.timer`
|
|
poate porni peste o sincronizare manuala si ambele scriu acelasi rag_index.json.
|
|
Nu asteptam: a doua rulare se anuleaza curat, fiindca oricum ar reface acelasi
|
|
lucru imediat dupa.
|
|
"""
|
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
fh = open(LOCK_FILE, "w", encoding="utf-8")
|
|
try:
|
|
try:
|
|
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
raise Busy("o reindexare e deja in curs")
|
|
fh.write(str(os.getpid()))
|
|
fh.flush()
|
|
yield
|
|
finally:
|
|
fh.close()
|