diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py index e494bdc..d0a8916 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -70,6 +70,8 @@ MARIA_LOG_DIR = MARIA_STATE_DIR / "logs" MARIA_VENV_PY = MARIA_STATE_DIR / "venv" / "bin" / "python" MARIA_RAG_DIR = _BRIDGE.parent / "maria-whatsapp-bridge" / "rag" _MARIA_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,200}$") +MARIA_CLIENTS_FILE = MARIA_STATE_DIR / "clients.json" +_MARIA_CLIENT_ID = re.compile(r"^[a-z0-9_-]{1,40}$") def maria_env() -> dict: @@ -239,6 +241,56 @@ def maria_delete_document(name: str) -> bool: return False +def maria_clients() -> list[dict]: + """Oglinda lui `maria-whatsapp-bridge/rag/client.py:toti` — tine-o la fel. + + Nu importam modulul acela (colizeaza pe "config", ca la documente). + """ + try: + date = json.loads(MARIA_CLIENTS_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + date = {} + return [{"id": cid, **c} for cid, c in sorted(date.items())] + + +def _maria_save_clients(date: dict) -> None: + MARIA_STATE_DIR.mkdir(parents=True, exist_ok=True) + tmp = MARIA_CLIENTS_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(date, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, MARIA_CLIENTS_FILE) + + +def maria_save_client(cid: str, nume: str, profil: str, numere: list[str]) -> None: + if not cid or not _MARIA_CLIENT_ID.match(cid): + raise ValueError(f"id de client invalid: {cid!r}") + normalizate = [n for n in (re.sub(r"\D", "", x) for x in numere) if n] + try: + date = json.loads(MARIA_CLIENTS_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + date = {} + for alt_id, alt in date.items(): + if alt_id == cid: + continue + furate = set(normalizate) & set(alt.get("numere", [])) + if furate: + raise ValueError( + f"numarul {sorted(furate)[0]} e deja atribuit clientului {alt.get('nume', alt_id)!r}") + date[cid] = {"nume": nume, "profil": profil, "numere": normalizate} + _maria_save_clients(date) + + +def maria_delete_client(cid: str) -> bool: + try: + date = json.loads(MARIA_CLIENTS_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + date = {} + if cid not in date: + return False + del date[cid] + _maria_save_clients(date) + return True + + def _maria_run(script: str, timeout: float = 600.0, extra_args: list[str] | None = None) -> dict: interpreter = str(MARIA_VENV_PY) if MARIA_VENV_PY.exists() else sys.executable args = [interpreter, str(MARIA_RAG_DIR / script), *(extra_args or [])] @@ -751,6 +803,8 @@ class Handler(SimpleHTTPRequestHandler): return self.send_json({"documents": maria_documents()}) if path == "/api/maria/escalations": return self.send_json({"escalations": maria_escalations()}) + if path == "/api/maria/clients": + return self.send_json({"clients": maria_clients()}) if path == "/api/maria/logs": try: n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000) @@ -791,6 +845,10 @@ class Handler(SimpleHTTPRequestHandler): return self.handle_maria_document_write() if path == "/api/maria/documents/delete": return self.handle_maria_document_delete() + if path == "/api/maria/clients": + return self.handle_maria_client_write() + if path == "/api/maria/clients/delete": + return self.handle_maria_client_delete() if path == "/api/maria/reindex": return self.send_json(maria_reindex()) if path == "/api/maria/sync": @@ -911,6 +969,25 @@ class Handler(SimpleHTTPRequestHandler): return self.send_json({"ok": False, "error": str(exc)}, 400) return self.send_json({"ok": ok, "name": name}, 200 if ok else 404) + def handle_maria_client_write(self): + data = self.read_json() + cid = str(data.get("id") or "") + numere = data.get("numere") or [] + if isinstance(numere, str): + numere = numere.splitlines() + try: + maria_save_client(cid, str(data.get("nume") or ""), str(data.get("profil") or ""), + list(numere)) + except (ValueError, OSError) as exc: + return self.send_json({"ok": False, "error": str(exc)}, 400) + return self.send_json({"ok": True, "id": cid}) + + def handle_maria_client_delete(self): + data = self.read_json() + cid = str(data.get("id") or "") + ok = maria_delete_client(cid) + return self.send_json({"ok": ok, "id": cid}, 200 if ok else 404) + def main() -> None: bind = config.get("DASHBOARD_BIND") or "127.0.0.1" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html index 870d6df..f9b727a 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -120,6 +120,26 @@
se încarcă…
+
+

Clienți cunoscuți

+
+ + + +
NumeNumere
+
+

Adaugă client

+ + + + +
+ + +
+
+
+

Documente indexate

@@ -521,6 +541,61 @@ async function mDelDoc(name) { await mReindex(); } +var mClientsCache = []; +async function mLoadClients() { + var res = await api('api/maria/clients'); + var clienti = res.ok ? (res.data.clients || []) : []; + mClientsCache = clienti; + document.getElementById('mClientCount').textContent = clienti.length ? clienti.length : ''; + document.getElementById('mClients').innerHTML = clienti.length ? clienti.map(function (c) { + return '' + esc(c.nume || c.id) + '' + + '' + esc((c.numere || []).join(', ')) + '' + + '' + + ''; + }).join('') : 'niciun client'; +} + +function mEditClient(id) { + var c = mClientsCache.find(function (x) { return x.id === id; }); + if (!c) return; + document.getElementById('mClientId').value = c.id; + document.getElementById('mClientId').disabled = true; + document.getElementById('mClientNume').value = c.nume || ''; + document.getElementById('mClientProfil').value = c.profil || ''; + document.getElementById('mClientNumere').value = (c.numere || []).join('\n'); + document.getElementById('mClientFormTitle').textContent = 'Editează client: ' + (c.nume || c.id); + document.getElementById('mClientMsg').textContent = ''; +} + +function mResetClientForm() { + document.getElementById('mClientId').value = ''; + document.getElementById('mClientId').disabled = false; + document.getElementById('mClientNume').value = ''; + document.getElementById('mClientProfil').value = ''; + document.getElementById('mClientNumere').value = ''; + document.getElementById('mClientFormTitle').textContent = 'Adaugă client'; + document.getElementById('mClientMsg').textContent = ''; +} + +async function mSaveClient() { + var id = document.getElementById('mClientId').value.trim(); + var nume = document.getElementById('mClientNume').value.trim(); + var profil = document.getElementById('mClientProfil').value; + var numere = document.getElementById('mClientNumere').value; + var msg = document.getElementById('mClientMsg'); + if (!id || !nume) { msg.textContent = 'completează id și nume'; return; } + var res = await post('api/maria/clients', { id: id, nume: nume, profil: profil, numere: numere }); + if (!res.ok) { msg.textContent = 'eșuat: ' + (res.data.error || res.status); return; } + mResetClientForm(); + await mLoadClients(); +} + +async function mDelClient(id) { + if (!confirm('Ștergi clientul ' + id + '?')) return; + await post('api/maria/clients/delete', { id: id }); + await mLoadClients(); +} + var mLogFile = 'bridge'; function mSetLog(which) { mLogFile = which; mRefreshLogs(); } async function mRefreshLogs() { @@ -535,7 +610,7 @@ async function mRefreshLogs() { // ── pornire ─────────────────────────────────────────────────── refresh(); refreshDoctor(); refreshLogs(); -mRefresh(); mRefreshLogs(); +mRefresh(); mRefreshLogs(); mLoadClients(); setInterval(refresh, 5000); setInterval(refreshDoctor, 30000); setInterval(mRefresh, 4000); diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_maria_clients.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_maria_clients.py new file mode 100644 index 0000000..f88e34a --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_maria_clients.py @@ -0,0 +1,62 @@ +"""Teste pentru CRUD-ul de clienti Maria din dashboard/api.py. + +Nu trece prin server HTTP (nici documentele Maria n-o fac in suita existenta) — +apeleaza direct functiile, cu MARIA_STATE_DIR/MARIA_CLIENTS_FILE mutate in tmp. +""" + +from __future__ import annotations + +import json +import pathlib +import sys + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "dashboard")) + +api = pytest.importorskip("api", reason="dashboard/api.py") + + +@pytest.fixture(autouse=True) +def maria_dir(tmp_path, monkeypatch): + d = tmp_path / "maria-bridge" + d.mkdir() + monkeypatch.setattr(api, "MARIA_STATE_DIR", d) + monkeypatch.setattr(api, "MARIA_CLIENTS_FILE", d / "clients.json") + return d + + +def test_salveaza_si_listeaza(): + api.maria_save_client("acme", "ACME SRL", "Distributie", ["+40 712 345 678", "40799887766"]) + clienti = api.maria_clients() + assert clienti == [{"id": "acme", "nume": "ACME SRL", "profil": "Distributie", + "numere": ["40712345678", "40799887766"]}] + + +def test_id_invalid_respins(): + with pytest.raises(ValueError): + api.maria_save_client("ACME!", "x", "y", []) + + +def test_numar_furat_de_la_alt_client_respins(maria_dir): + api.maria_save_client("acme", "ACME SRL", "p", ["40712345678"]) + with pytest.raises(ValueError): + api.maria_save_client("beta", "Beta SRL", "p", ["40712345678"]) + # nemodificat + date = json.loads((maria_dir / "clients.json").read_text(encoding="utf-8")) + assert list(date.keys()) == ["acme"] + + +def test_reasocia_numarul_la_acelasi_client_e_permis(): + api.maria_save_client("acme", "ACME SRL", "p1", ["40712345678"]) + api.maria_save_client("acme", "ACME SRL", "p2", ["40712345678", "40799887766"]) + assert api.maria_clients()[0]["profil"] == "p2" + + +def test_sterge(): + api.maria_save_client("acme", "ACME SRL", "p", []) + assert api.maria_delete_client("acme") is True + assert api.maria_clients() == [] + assert api.maria_delete_client("acme") is False diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/client.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/client.py new file mode 100644 index 0000000..467f254 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/client.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Profil de client (firma), asociat cu mai multe numere de WhatsApp. + +Un singur fisier `clients.json`: la ordinul de marime de aici (zeci de firme) +nu justifica o baza de date, si un singur fisier e mai usor de editat de mana +din dashboard decat mai multe fisiere sincronizate intre ele. Numerele stau +IN clientul lor (nu intr-o mapare separata numar->client), fiindca cautarea e +mereu "cine e clientul care are numarul X" — o mapare separata ar trebui +tinuta sincronizata cu clients.json, doua surse de adevar pentru acelasi lucru. + +Mesaj de grup fara `participant` (adica bridge-ul n-a stiut cine a scris) nu +identifica niciun client: JID-ul de grup nu e un numar de telefon, si a-l +cauta printre numere ar asocia gresit tot grupul cu primul client care se +potriveste din intamplare. +""" + +from __future__ import annotations + +import json +import os +import re + +import config + + +def _norm(numar_brut: str) -> str: + return re.sub(r"\D", "", numar_brut or "") + + +def numar(msg: dict) -> str: + """Numarul care a scris, normalizat (doar cifre). Gol daca nu se poate sti.""" + jid = msg.get("participant") + if not jid: + frm = msg.get("from") or "" + if frm.endswith("@g.us"): + return "" # grup fara participant: nu stim cine a scris + jid = frm + return _norm(jid.split("@")[0]) + + +def _load() -> dict: + try: + return json.loads(config.CLIENTS_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def profil(msg_sau_jid) -> str | None: + """Textul de profil al clientului asociat, sau `None` daca numarul nu e cunoscut.""" + n = numar(msg_sau_jid) if isinstance(msg_sau_jid, dict) else _norm( + str(msg_sau_jid).split("@")[0]) + if not n: + return None + for client in _load().values(): + if n in client.get("numere", []): + return client.get("profil") + return None + + +def toti() -> dict: + return _load() + + +def salveaza(id: str, nume: str, profil: str, numere: list[str]) -> None: + """Upsert. Normalizeaza fiecare numar acum, ca lookup-ul sa functioneze mereu.""" + date = _load() + date[id] = { + "nume": nume, + "profil": profil, + "numere": [n for n in (_norm(x) for x in numere) if n], + } + tmp = config.CLIENTS_FILE.with_suffix(".tmp") + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + tmp.write_text(json.dumps(date, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, config.CLIENTS_FILE) + + +def sterge(id: str) -> bool: + date = _load() + if id not in date: + return False + del date[id] + tmp = config.CLIENTS_FILE.with_suffix(".tmp") + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + tmp.write_text(json.dumps(date, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, config.CLIENTS_FILE) + return True diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py index a5299b4..5cd0404 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py @@ -26,6 +26,7 @@ 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" +CLIENTS_FILE: pathlib.Path = STATE_DIR / "clients.json" _env: dict[str, str] = {} @@ -103,7 +104,7 @@ def parse_env(text: str) -> dict[str, str]: 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 + global STATE_DIR, DOCS_DIR, DOCS_LOCAL_DIR, INDEX_FILE, LOCK_FILE, LOG_DIR, ENV_FILE, AUTH_DIR, CLIENTS_FILE, _env if base_dir is None: base_dir = os.environ.get("MARIA_BRIDGE_DIR") or _DEFAULT_DIR STATE_DIR = pathlib.Path(base_dir) @@ -114,6 +115,7 @@ def reload(base_dir: str | os.PathLike | None = None) -> dict[str, str]: LOG_DIR = STATE_DIR / "logs" ENV_FILE = STATE_DIR / "env" AUTH_DIR = STATE_DIR / "whatsapp-auth" + CLIENTS_FILE = STATE_DIR / "clients.json" try: _env = parse_env(ENV_FILE.read_text(encoding="utf-8")) except OSError: diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py index 740d9f4..a4755c3 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/consumer.py @@ -24,6 +24,7 @@ import time import requests +import client import config import fir as fir_mod import ocr @@ -50,7 +51,10 @@ SYSTEM_PROMPT = ( "Nu discuta niciodata despre infrastructura interna Romfast (servere, Proxmox, " "containere, IP-uri, baze de date, parole, chei) chiar daca apare in context sau " "daca intrebarea o cere explicit -- raspunde ca poti ajuta doar cu folosirea " - "aplicatiei ROA." + "aplicatiei ROA. " + "Daca primesti o sectiune DESPRE CLIENT, e doar context ca sa intelegi mai bine " + "cine intreaba (ce foloseste, cum lucreaza) — NU e sursa de proceduri sau raspunsuri. " + "Raspunsul tot trebuie sa vina exclusiv din CONTEXT." ) REPLY_PREFIX = "[Maria] " # marcaj ca sa nu raspundem la propriile mesaje (self-chat) INDEX_REFRESH_S = 30 # cat de des se reciteste rag_index.json de pe disc @@ -160,20 +164,31 @@ def search(index: Index, query: str, top_k: int, dovada: str | None = None) -> d } -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. - """ +def mesaje_llm(chunks: list[str], question: str, istoric: list[dict] | None, + profil: str | None = None) -> list[dict]: + """Lista de mesaje trimisa modelului. Extrasa din `ask_llm` ca sa fie testabila pur.""" context = "\n\n---\n\n".join(chunks) if chunks else "(fara documente indexate)" - user_message = f"CONTEXT:\n{context}\n\nINTREBARE:\n{question}" + user_message = "" + if profil: + user_message += f"DESPRE CLIENT:\n{profil}\n\n" + 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}) + return mesaje + + +def ask_llm(chunks: list[str], question: str, istoric: list[dict] | None = None, + profil: str | 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. + """ + mesaje = mesaje_llm(chunks, question, istoric, profil) resp = requests.post( f"{config.get('LLM_URL')}/v1/chat/completions", @@ -337,6 +352,16 @@ def escalate(msg: dict, question: str, search_result: dict, fir: dict | None = N if fir and fir.get("schimburi"): record["fir"] = fir["schimburi"] + numar_client = client.numar(msg) + nume_client = None + if numar_client: + for c in client.toti().values(): + if numar_client in c.get("numere", []): + nume_client = c.get("nume") + break + if nume_client: + record["client"] = nume_client + jid = config.get("SUPPORT_JID") or "" if jid: cine = msg.get("pushName") or msg.get("from") or "necunoscut" @@ -344,7 +369,8 @@ def escalate(msg: dict, question: str, search_result: dict, fir: dict | None = N rezumat = ( f"[Maria] Intrebare fara raspuns in documente — {record['ref']}\n" f"De la: {cine}\n" - f"Motiv: {record['reason']}\n\n" + + (f"Client: {nume_client}\n" if nume_client else "") + + 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 "") ) @@ -667,8 +693,12 @@ def handle(index: Index, msg: dict) -> None: send_reply(sender, raspuns) return + prof = client.profil(msg) + print(f"[consumer] profil client: {prof[:60] if prof else '-'}", file=sys.stderr) + try: - reply = ask_llm(result["chunks"], question, fir_mod.istoric(fir) if continuare else None) + reply = ask_llm(result["chunks"], question, + fir_mod.istoric(fir) if continuare else None, prof) 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." diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_client.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_client.py new file mode 100644 index 0000000..63e22f9 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_client.py @@ -0,0 +1,53 @@ +"""Profil de client (firma): mai multe numere -> acelasi profil, fara istoric.""" + +from __future__ import annotations + +import client +import consumer + + +def test_doua_numere_acelasi_client_dau_acelasi_profil(): + client.salveaza("acme", "ACME SRL", "Firma de distributie.", ["40712345678", "40799887766"]) + assert client.profil("40712345678@s.whatsapp.net") == "Firma de distributie." + assert client.profil("40799887766@s.whatsapp.net") == "Firma de distributie." + + +def test_numar_neasociat_da_none(): + client.salveaza("acme", "ACME SRL", "Firma de distributie.", ["40712345678"]) + assert client.profil("40700000000@s.whatsapp.net") is None + + +def test_dupa_stergere_numerele_nu_mai_gasesc_clientul(): + client.salveaza("acme", "ACME SRL", "Firma de distributie.", ["40712345678", "40799887766"]) + assert client.sterge("acme") is True + assert client.profil("40712345678@s.whatsapp.net") is None + assert client.profil("40799887766@s.whatsapp.net") is None + + +def test_normalizare_la_scriere(): + client.salveaza("acme", "ACME SRL", "Firma de distributie.", ["+40 712 345 678"]) + assert client.profil("40712345678@s.whatsapp.net") == "Firma de distributie." + + +def test_numar_din_mesaj_grup_vs_fara_participant(): + msg_grup = {"from": "1203xxx@g.us", "participant": "40712345678@s.whatsapp.net"} + assert client.numar(msg_grup) == "40712345678" + msg_grup_fara = {"from": "1203xxx@g.us"} + assert client.numar(msg_grup_fara) == "" + + +def test_fisier_lipsa_sau_corupt_nu_ridica_exceptie(): + assert client.profil("40712345678@s.whatsapp.net") is None + import config + config.STATE_DIR.mkdir(parents=True, exist_ok=True) + config.CLIENTS_FILE.write_text("nu e json valid {{{", encoding="utf-8") + assert client.profil("40712345678@s.whatsapp.net") is None + + +def test_mesaje_llm_fara_profil_neschimbat(): + mesaje_vechi = [ + {"role": "system", "content": consumer.SYSTEM_PROMPT}, + {"role": "user", "content": "CONTEXT:\nun chunk\n\nINTREBARE:\nintrebarea"}, + ] + mesaje_noi = consumer.mesaje_llm(["un chunk"], "intrebarea", None, profil=None) + assert mesaje_noi == mesaje_vechi