feat(maria): profil de client (firma), asociat cu mai multe numere
Un client poate avea mai multi angajati care scriu pe WhatsApp de pe numere diferite; toti gasesc acum acelasi profil (rag/client.py, ~/.maria-bridge/clients.json). Profilul intra in prompt sub o sectiune DESPRE CLIENT, la fiecare mesaj, stateless ca restul Mariei — dar nu atinge cautarea/scorul din rag/rank.py, ca sa nu strice pragurile deja calibrate. Administrare din dashboard (sectiune noua "Clienti cunoscuti"), nu din chat, ca sa ramana pe canalul autentificat. 117/117 teste maria-whatsapp-bridge + teste noi pentru rutele de dashboard. Co-Authored-By: Claude Agent <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user