diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py index 5cd2a29..ddea518 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -56,6 +56,9 @@ SELF_SERVICE = "claude-discord-dashboard.service" # pe numele de modul "config" cu discord-bridge/config.py, deja importat mai # sus) — citim direct caile si folosim subprocess/HTTP, exact ca la SERVICE. MARIA_UNITS = {"bridge": "maria-whatsapp.service", "rag": "maria-rag.service"} +# Oglinda lui rag/store.py: DOC_EXTENSIONS si ordinea de preferinta (.xml cel mai bogat). +MARIA_DOC_EXTENSIONS = (".txt", ".md", ".xml") +_MARIA_FORMAT_RANK = {".xml": 0, ".md": 1, ".txt": 2} MARIA_STATE_DIR = Path(os.environ.get("MARIA_BRIDGE_DIR") or (Path.home() / ".maria-bridge")) MARIA_DOCS_DIR = MARIA_STATE_DIR / "documents" MARIA_INDEX_FILE = MARIA_STATE_DIR / "rag_index.json" @@ -113,20 +116,39 @@ def maria_sync_state() -> dict: def maria_documents() -> list[dict]: + """Oglinda lui `maria-whatsapp-bridge/rag/store.py:list_documents` — tine-le la fel. + + Nu importam modulul acela: si el, si `discord-bridge/config.py` s-ar numi `config`. + """ MARIA_DOCS_DIR.mkdir(parents=True, exist_ok=True) + files = [ + f for f in sorted(MARIA_DOCS_DIR.glob("*")) + if f.is_file() and f.suffix in MARIA_DOC_EXTENSIONS + ] + winners: dict[str, str] = {} + for f in files: + best = winners.get(f.stem) + if best is None or _MARIA_FORMAT_RANK[f.suffix] < _MARIA_FORMAT_RANK[Path(best).suffix]: + winners[f.stem] = f.name + out = [] - for f in sorted(MARIA_DOCS_DIR.glob("*")): - if f.is_file() and f.suffix in (".txt", ".md"): - st = f.stat() - out.append({"name": f.name, "size": st.st_size, "mtime": st.st_mtime}) + for f in files: + st = f.stat() + winner = winners[f.stem] + out.append({ + "name": f.name, + "size": st.st_size, + "mtime": st.st_mtime, + "shadowed_by": None if winner == f.name else winner, + }) return out def _maria_validate_name(name: str) -> str: if not name or not _MARIA_SAFE_NAME.match(name) or ".." in name or "/" in name: raise ValueError(f"nume de document invalid: {name!r}") - if not name.endswith((".txt", ".md")): - raise ValueError("doar fisiere .txt sau .md") + if not name.endswith(MARIA_DOC_EXTENSIONS): + raise ValueError("doar fisiere " + ", ".join(MARIA_DOC_EXTENSIONS)) return name diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html index a47e67a..2b68387 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -378,7 +378,6 @@ async function mRefresh() { document.getElementById('mIndexDetail').textContent = s.index.chunks + ' chunk-uri din ' + s.index.documents_indexed + ' documente indexate' + (s.index.mtime ? ' · indexat ' + new Date(s.index.mtime * 1000).toLocaleString('ro-RO') : ''); - document.getElementById('mDocCount').textContent = s.documents + ' în depozit'; var sync = s.sync || {}; document.getElementById('mSyncDetail').textContent = sync.drive_remote @@ -387,8 +386,14 @@ async function mRefresh() { var docsRes = await api('api/maria/documents'); var docs = docsRes.ok ? (docsRes.data.documents || []) : []; + var indexate = docs.filter(function (d) { return !d.shadowed_by; }).length; + document.getElementById('mDocCount').textContent = + docs.length + ' în depozit' + (indexate !== docs.length ? ' · ' + indexate + ' indexate' : ''); document.getElementById('mDocs').innerHTML = docs.length ? docs.map(function (d) { - return '' + esc(d.name) + '' + + var umbrit = d.shadowed_by + ? ' · umbrit de ' + esc(d.shadowed_by) + '' + : ''; + return '' + esc(d.name) + umbrit + '' + '' + d.size + ' B' + ''; }).join('') : 'niciun document'; diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md index 9ec2c8c..c9a24fc 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md @@ -91,57 +91,93 @@ puntii Discord — vezi `../discord-bridge/README.md` pentru URL si autentificar ## Depozitul de documente -Fisiere `.txt`/`.md` in `~/.maria-bridge/documents/`. Se pot administra: +Fisiere `.txt`/`.md`/`.xml` in `~/.maria-bridge/documents/` (lista exacta: +`store.DOC_EXTENSIONS`). Se pot administra: 1. **manual din dashboard-ul comun** (adaugare/stergere text, reindexare automata la salvare); 2. **prin sincronizare Google Drive** — vezi mai jos. ## Sincronizare cu Google Drive -Sursa: dosarul `D:\GoogleDrive\romfast\document_store` de pe Windows (Google -Drive Desktop). Containerul e headless, deci sincronizarea foloseste -**rclone cu un cont de serviciu** — nu OAuth interactiv in browser. +Sursa: dosarul `document_store` din Drive-ul contului `mmarius28@gmail.com` +(pe Windows apare ca `D:\GoogleDrive\romfast\document_store`, prin Google Drive +Desktop). ID-ul dosarului: `1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O`. + +Containerul e headless (fara browser pentru OAuth), deci autorizarea se face pe o +masina cu browser si se muta aici ca token — **fara cont de serviciu si fara consola +Google Cloud**. rclone e deja instalat pe LXC 171 (`rclone v1.60.1`). Pasi (o singura data): -1. **Instaleaza rclone** pe container: `sudo apt-get install -y rclone`. - ✅ Deja instalat pe LXC 171 (2026-08-31), `rclone v1.60.1`. Pasii 2-7 de mai - jos raman de facut — cer acces la consola Google Cloud a utilizatorului, nu - se pot automatiza din container. -2. **Creeaza un cont de serviciu Google** cu acces la Drive API (Google Cloud - Console -> IAM -> Service Accounts -> Create -> descarca cheia JSON). -3. **Partajeaza folderul** `document_store` din Google Drive cu adresa de email - a contului de serviciu (click dreapta pe folder -> Share), exact cum ai - partaja cu o persoana. Fara acest pas, contul de serviciu nu vede nimic. -4. Pune cheia JSON pe container, ex. `~/.maria-bridge/gdrive-service-account.json` - (0600). -5. Configureaza remote-ul rclone (`rclone config`, fara sesiune interactiva de - browser cu tip `service_account_file`): +1. **Pe Windows**, ia `rclone.exe` de la (arhiva + portabila, nu cere instalare) si ruleaza in acel dosar: ``` - rclone config create gdrive drive \ - scope=drive.readonly \ - service_account_file=/home/claude/.maria-bridge/gdrive-service-account.json + rclone.exe authorize "drive" --drive-scope=drive.readonly ``` -6. Gaseste ID-ul folderului `document_store` (din URL-ul Drive) si testeaza: + Se deschide browserul; autentifica-te cu `mmarius28@gmail.com` si accepta. + In consola apare un token JSON intre `--->` si `<---`. Copiaza-l intreg. +2. **Pe container**, creeaza remote-ul cu tokenul copiat: ``` - rclone lsf gdrive: --drive-root-folder-id= + rclone config create gdrive drive scope=drive.readonly token='' ``` - ID-ul curent al folderului `document_store` (cont `mmarius28@gmail.com`, - gasit prin cautare Drive): `1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O`. - Sau, mai simplu, foloseste calea prin nume daca folderul e in "My Drive" al - contului care a facut share (rclone urmareste shared-with-me cu - `--drive-shared-with-me` daca e nevoie). -7. Pune tinta gasita in `~/.maria-bridge/env`: +3. Testeaza ca vede dosarul: ``` - DRIVE_REMOTE=gdrive:romfast/document_store + rclone lsf gdrive: --drive-root-folder-id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O ``` -8. Testeaza manual: `systemctl --user start maria-sync.service` apoi - `journalctl --user -u maria-sync -n 50`, sau butonul „sincronizeaza din Drive - acum" din dashboard. +4. Pune tinta in `~/.maria-bridge/env`: + ``` + DRIVE_REMOTE=gdrive,root_folder_id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O: + ``` + (sintaxa „connection string" a rclone — fixeaza dosarul fara sa depinda de + structura de nume din My Drive.) +5. Testeaza: butonul „sincronizeaza din Drive acum" din dashboard, sau + `systemctl --user start maria-sync.service` apoi + `journalctl --user -u maria-sync -n 50`. + +Tokenul se reimprospateaza singur (refresh token) cat timp aplicatia ramane +autorizata in contul Google. Daca expira, dashboard-ul arata sincronizarea ca +esuata — reia pasii 1-2. + +### Ce se sincronizeaza + +`rclone sync` aduce doar `*.txt`, `*.md` si `*.xml` (vezi `store.DOC_EXTENSIONS`). +Restul din dosar — chatflow-uri Flowise `.json`, scripturi `.ps1`, `.docx` — sunt +ignorate deliberat: nu sunt cunostinte de suport. + +**`sync` sterge local ce nu mai exista in Drive**, deci depozitul e o oglinda a +dosarului din Drive, nu o colectie care creste. Documentele adaugate manual din +dashboard dispar la prima sincronizare daca nu exista si in Drive. + +### Acelasi document in doua formate + +Cand exista `X.xml` si `X.md`, **in index intra doar `.xml`** (ordinea de +preferinta: `.xml` > `.md` > `.txt`, in `rag/store.py`). Sursele `.xml` sunt +structurate pe probleme si de regula mai noi decat exporturile `.md` — la +`d406_saft_knowledge`, `.xml` era cu trei luni mai nou si cu 50% mai mare. +Fisierul umbrit ramane pe disc si apare in dashboard marcat „umbrit de …", ca sa +se vada de ce nu e indexat; daca ar fi indexate ambele, acelasi raspuns ar aparea +de doua ori in rezultatele RAG. + +### Cum se taie XML-ul in chunk-uri + +Un chunk per element de nivel 1 — adica **o problema = un chunk**, cu mesajul de +eroare si rezolvarea impreuna. Taierea pe linii goale (cea folosita la `.md`) le-ar +separa, iar cautarea ar gasi eroarea si ar returna un chunk fara raspuns. +Etichetele raman ca prefixe lizibile (`mesaj eroare: …`, `rezolvare: …`), fara +paranteze unghiulare. Un XML care nu se poate parsa nu opreste indexarea: cade pe +taierea obisnuita, cu o linie in log. Vezi `rag/indexer.py` si +`tests/test_store_si_chunking.py`. Dupa configurare, `maria-sync.timer` trage la fiecare 10 minute; reindexarea ruleaza DOAR daca s-a schimbat efectiv ceva in depozit (amprenta pe nume + mtime + marime, vezi `rag/sync.py`), ca sa nu reface embeddings degeaba. +## Teste + +```bash +cd /workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge +python3 -m pytest # preferinta de format + taierea XML, fara retea si fara Ollama +``` + ## Context conversational `rag/consumer.py` nu retine memorie intre mesaje — fiecare intrebare e o diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example index 4a93b7b..ca305f7 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/ops/env.example @@ -24,12 +24,13 @@ MAX_TOKENS=250 POLL_INTERVAL_S=2 # --- Depozit de documente + sincronizare Google Drive ----------------------- -# Tinta rclone pentru dosarul de documente (partajat de pe Windows, -# D:\GoogleDrive\romfast\document_store), format `:`. +# Tinta rclone pentru dosarul de documente din Drive (pe Windows apare ca +# D:\GoogleDrive\romfast\document_store). Se aduc doar *.txt, *.md, *.xml. # Gol = sincronizare dezactivata; documentele se administreaza doar manual din -# dashboard. Vezi README.md, "Sincronizare cu Google Drive", pentru configurarea -# remote-ului `rclone` (cont de serviciu, fara OAuth interactiv pe un container -# headless). +# dashboard. Vezi README.md, "Sincronizare cu Google Drive": autorizarea se face +# cu `rclone authorize` pe o masina cu browser, tokenul se muta aici — fara cont +# de serviciu. Exemplu, cu dosarul fixat pe ID: +# DRIVE_REMOTE=gdrive,root_folder_id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O: DRIVE_REMOTE= # --- control ----------------------------------------------------------------- diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/pytest.ini b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q 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 017bec8..bf69cef 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/config.py @@ -35,9 +35,9 @@ DEFAULTS: dict[str, str] = { "POLL_INTERVAL_S": "2", "TEST_MODE_SELF_CHAT_ONLY": "true", # Tinta rclone pentru sincronizarea depozitului de documente, ex: - # "gdrive:romfast/document_store" (dosarul D:\GoogleDrive\romfast\document_store - # de pe Windows, vazut prin Google Drive API). Gol = sincronizare dezactivata, - # doar upload manual din dashboard. + # "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": "", } diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py index 3370e98..392ca5b 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/indexer.py @@ -1,12 +1,21 @@ #!/usr/bin/env python3 """(Re)construieste rag_index.json din toate documentele din depozit (store.py), -cu embeddings Ollama. Rulat manual sau declansat din dashboard (`/api/reindex`).""" +cu embeddings Ollama. Rulat manual sau declansat din dashboard (`/api/reindex`). + +Doua strategii de taiere in chunk-uri: +- **.xml** — un chunk per element de nivel 1 (o problema = un chunk). Sursele sunt + structurate `……`, + iar taierea pe linii goale ar rupe mesajul de eroare de rezolvarea lui: cautarea ar + gasi eroarea si ar returna un chunk fara raspuns. +- **restul** — paragrafe unite pana la ~200 de caractere, ca inainte. +""" from __future__ import annotations import json import re import sys +import xml.etree.ElementTree as ET import requests @@ -32,6 +41,85 @@ def chunk_text(text: str) -> list[str]: return chunks +# Peste atat, un singur element XML se taie mai departe ca text obisnuit. +MAX_XML_CHUNK = 4000 + + +def _render_element(el, depth: int = 0) -> str: + """Elementul, ca text cu etichete pe post de etichete: `mesaj_eroare: ...`. + + Numele etichetelor poarta intelesul (care bucata e eroarea, care e rezolvarea), + deci raman in text — dar fara paranteze unghiulare, care doar dilueaza embedding-ul. + """ + lines = [] + own = (el.text or "").strip() + label = el.tag.replace("_", " ") + children = list(el) + if own: + lines.append(f"{label}: {own}" if children or depth else own) + elif children and depth == 0: + lines.append(f"[{label}]") + for child in children: + rendered = _render_element(child, depth + 1) + if rendered: + lines.append(rendered) + tail = (child.tail or "").strip() + if tail: + lines.append(tail) + return "\n".join(lines) + + +def _split_long(text: str, limit: int = MAX_XML_CHUNK) -> list[str]: + """Taie o bucata prea lunga pe granite de cuvant. + + `chunk_text` imparte pe linii goale, deci un element XML scris ca un singur + paragraf lung nu s-ar imparti deloc — de aici taierea asta, pe lungime. + """ + out: list[str] = [] + for part in chunk_text(text) or [text]: + while len(part) > limit: + cut = part.rfind(" ", 0, limit) + if cut <= 0: + cut = limit + out.append(part[:cut].strip()) + part = part[cut:].lstrip() + if part.strip(): + out.append(part.strip()) + return out + + +def chunk_xml(text: str) -> list[str] | None: + """Un chunk per element de nivel 1. `None` daca XML-ul nu se poate parsa.""" + try: + root = ET.fromstring(text) + except ET.ParseError: + return None + + chunks: list[str] = [] + for el in root: + rendered = _render_element(el).strip() + if not rendered: + continue + if len(rendered) > MAX_XML_CHUNK: + chunks.extend(_split_long(rendered)) + else: + chunks.append(rendered) + if not chunks: + # radacina fara copii (sau doar text): trateaz-o ca document obisnuit + whole = _render_element(root).strip() + return chunk_text(whole) if whole else [] + return chunks + + +def chunk_document(name: str, text: str) -> list[str]: + if name.endswith(".xml"): + chunks = chunk_xml(text) + if chunks is not None: + return chunks + print(f"[indexer] {name}: XML invalid, il tai ca text simplu", file=sys.stderr) + return chunk_text(text) + + def embed(text: str) -> list[float]: resp = requests.post( f"{config.get('OLLAMA_URL')}/api/embeddings", @@ -44,10 +132,10 @@ def embed(text: str) -> list[float]: def build() -> dict: entries = [] - docs = store.list_documents() + docs = store.documents_for_index() for doc in docs: text = store.read_document(doc["name"]) - for i, chunk in enumerate(chunk_text(text)): + for i, chunk in enumerate(chunk_document(doc["name"], text)): vec = embed(chunk) entries.append({"source": doc["name"], "chunk": i, "text": chunk, "embedding": vec}) config.STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/store.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/store.py index e46f5b7..5cfb2cb 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/store.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/store.py @@ -1,38 +1,75 @@ """Depozitul de documente pentru indexarea RAG a lui Maria. -Fisiere text (.txt/.md) sub STATE_DIR/documents/, un singur nivel (fara +Fisiere text (.txt/.md/.xml) sub STATE_DIR/documents/, un singur nivel (fara subdirectoare), ca numele afisat in dashboard sa fie neambiguu si sa poata servi direct ca parametru de request fara riscuri de traversare de cale. + +Cand acelasi document exista in mai multe formate (`d406_saft_knowledge.xml` si +`d406_saft_knowledge.md`), la indexare intra UNUL SINGUR, cel cu formatul cel mai +bogat: .xml > .md > .txt. Sursele .xml sunt structurate pe probleme +(``, ``) si de regula mai noi decat exporturile .md. +Celalalt ramane pe disc si se vede in dashboard, marcat ca umbrit — altfel acelasi +raspuns ar aparea de doua ori in rezultatele RAG. """ from __future__ import annotations +import pathlib import re import config _SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,200}$") +# Extensiile acceptate in depozit. Sursa unica de adevar pentru sync.py si indexer.py; +# dashboard-ul (discord-bridge/dashboard/api.py) tine o copie, fiindca nu importa +# module din acest proiect (coliziune de nume pe `config`) — tine-le sincronizate. +DOC_EXTENSIONS = (".txt", ".md", ".xml") + +# Mai mic = preferat cand acelasi nume de baza exista in mai multe formate. +_FORMAT_RANK = {".xml": 0, ".md": 1, ".txt": 2} + def validate_name(name: str) -> str: if not name or not _SAFE_NAME.match(name) or ".." in name or "/" in name: raise ValueError(f"nume de document invalid: {name!r}") - if not name.endswith((".txt", ".md")): - raise ValueError("doar fisiere .txt sau .md") + if not name.endswith(DOC_EXTENSIONS): + raise ValueError("doar fisiere " + ", ".join(DOC_EXTENSIONS)) return name def list_documents() -> list[dict]: + """Tot ce e in depozit. `shadowed_by` spune de ce un fisier nu ajunge in index.""" config.DOCS_DIR.mkdir(parents=True, exist_ok=True) + files = [ + f for f in sorted(config.DOCS_DIR.glob("*")) + if f.is_file() and f.suffix in DOC_EXTENSIONS + ] + # castigatorul per nume de baza: formatul cel mai bogat + winners: dict[str, str] = {} + for f in files: + best = winners.get(f.stem) + if best is None or _FORMAT_RANK[f.suffix] < _FORMAT_RANK[pathlib.Path(best).suffix]: + winners[f.stem] = f.name + out = [] - for f in sorted(config.DOCS_DIR.glob("*")): - if not f.is_file() or f.suffix not in (".txt", ".md"): - continue + for f in files: st = f.stat() - out.append({"name": f.name, "size": st.st_size, "mtime": st.st_mtime}) + winner = winners[f.stem] + out.append({ + "name": f.name, + "size": st.st_size, + "mtime": st.st_mtime, + "shadowed_by": None if winner == f.name else winner, + }) return out +def documents_for_index() -> list[dict]: + """Doar documentele care ajung efectiv in index (fara cele umbrite).""" + return [d for d in list_documents() if not d["shadowed_by"]] + + def read_document(name: str) -> str: name = validate_name(name) return (config.DOCS_DIR / name).read_text(encoding="utf-8") diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py index 615c533..22c0dd4 100644 --- a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/sync.py @@ -22,6 +22,7 @@ import time import config import indexer +import store STATE_FILE_NAME = ".sync_state.json" @@ -32,7 +33,7 @@ def _fingerprint() -> str: config.DOCS_DIR.mkdir(parents=True, exist_ok=True) h = hashlib.sha256() for f in sorted(config.DOCS_DIR.glob("*")): - if f.is_file() and f.suffix in (".txt", ".md"): + if f.is_file() and f.suffix in store.DOC_EXTENSIONS: st = f.stat() h.update(f"{f.name}:{st.st_mtime_ns}:{st.st_size}\n".encode()) return h.hexdigest() @@ -65,8 +66,8 @@ def pull_from_drive() -> dict: config.DOCS_DIR.mkdir(parents=True, exist_ok=True) try: r = subprocess.run( - ["rclone", "sync", remote, str(config.DOCS_DIR), - "--include", "*.txt", "--include", "*.md"], + ["rclone", "sync", remote, str(config.DOCS_DIR)] + + [arg for ext in store.DOC_EXTENSIONS for arg in ("--include", f"*{ext}")], capture_output=True, text=True, timeout=300, ) except FileNotFoundError: diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/conftest.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/conftest.py new file mode 100644 index 0000000..8b23370 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/conftest.py @@ -0,0 +1,32 @@ +"""Fixturi: depozit intr-un tmp, zero retea, zero Ollama.""" + +from __future__ import annotations + +import pathlib +import sys + +import pytest + +RAG = pathlib.Path(__file__).resolve().parent.parent / "rag" +if str(RAG) not in sys.path: + sys.path.insert(0, str(RAG)) + +import config # noqa: E402 + + +@pytest.fixture(autouse=True) +def docs_dir(tmp_path, monkeypatch): + monkeypatch.setenv("MARIA_BRIDGE_DIR", str(tmp_path)) + config.reload(tmp_path) + d = config.DOCS_DIR + d.mkdir(parents=True, exist_ok=True) + yield d + config.reload() + + +@pytest.fixture +def write(docs_dir): + def _write(name: str, text: str): + (docs_dir / name).write_text(text, encoding="utf-8") + return docs_dir / name + return _write diff --git a/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_store_si_chunking.py b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_store_si_chunking.py new file mode 100644 index 0000000..4158c37 --- /dev/null +++ b/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/tests/test_store_si_chunking.py @@ -0,0 +1,114 @@ +"""Ce intra in index (preferinta de format) si cum se taie XML-ul in chunk-uri.""" + +from __future__ import annotations + +import pytest + +import indexer +import store + +XML = """ + + + + stocuri negative NIR + Documentul nu poate fi sters: stocuri negative pentru BANDA HARTIE. + Optiuni firma > VERIFICARESTOCNEGATIV = 0. + + + Alta eroare. + Alta rezolvare. + +""" + + +# ------------------------------------------------------- preferinta de format +def test_xml_umbreste_md_cu_acelasi_nume(write): + write("d406.xml", "x") + write("d406.md", "versiune veche") + umbrite = {d["name"]: d["shadowed_by"] for d in store.list_documents()} + assert umbrite["d406.md"] == "d406.xml" + assert umbrite["d406.xml"] is None + assert [d["name"] for d in store.documents_for_index()] == ["d406.xml"] + + +def test_md_umbreste_txt(write): + write("x.md", "md") + write("x.txt", "txt") + assert [d["name"] for d in store.documents_for_index()] == ["x.md"] + + +def test_fisierele_fara_pereche_raman_toate(write): + write("bilant.md", "a") + write("roagest.xml", "b") + write("note.txt", "c") + assert len(store.documents_for_index()) == 3 + + +def test_depozitul_e_vizibil_intreg_chiar_daca_nu_tot_se_indexeaza(write): + write("d406.xml", "x") + write("d406.md", "vechi") + assert len(store.list_documents()) == 2 # dashboard le arata pe ambele + assert len(store.documents_for_index()) == 1 # indexul ia doar una + + +def test_extensiile_necunoscute_sunt_ignorate(write): + write("chatflow.json", "{}") + write("script.ps1", "echo") + assert store.list_documents() == [] + + +@pytest.mark.parametrize("name", ["a.xml", "a.md", "a.txt"]) +def test_numele_acceptate(name): + assert store.validate_name(name) == name + + +@pytest.mark.parametrize("name", ["a.json", "a.pdf", "../a.md", "a b.md"]) +def test_numele_respinse(name): + with pytest.raises(ValueError): + store.validate_name(name) + + +# ----------------------------------------------------------- chunking XML +def test_o_problema_ramane_un_singur_chunk(): + """Miezul: mesajul de eroare si rezolvarea NU trebuie separate.""" + chunks = indexer.chunk_document("x.xml", XML) + assert len(chunks) == 2 + assert "BANDA HARTIE" in chunks[0] and "VERIFICARESTOCNEGATIV" in chunks[0] + assert "Alta eroare" in chunks[1] and "Alta rezolvare" in chunks[1] + + +def test_etichetele_raman_ca_etichete_fara_paranteze(): + chunk = indexer.chunk_document("x.xml", XML)[0] + assert "mesaj eroare:" in chunk and "rezolvare:" in chunk + assert "" not in chunk + + +def test_entitatile_sunt_decodate(): + assert ">" not in indexer.chunk_document("x.xml", XML)[0] + + +def test_comentariile_nu_devin_chunk(): + assert not any("comentariu" in c for c in indexer.chunk_document("x.xml", XML)) + + +def test_xml_invalid_cade_pe_taierea_obisnuita(): + chunks = indexer.chunk_document("stricat.xml", "") + assert chunks == [""] + + +def test_xml_fara_copii_e_tratat_ca_text(): + assert indexer.chunk_document("x.xml", "doar text") == ["doar text"] + + +def test_element_urias_e_taiat_mai_departe(): + mare = "" + ("propozitie lunga. " * 500) + "" + chunks = indexer.chunk_document("x.xml", mare) + assert len(chunks) > 1 + assert all(len(c) < indexer.MAX_XML_CHUNK * 2 for c in chunks) + + +def test_md_foloseste_taierea_pe_paragrafe(): + text = "primul paragraf\n\n" + "al doilea paragraf " * 20 + assert len(indexer.chunk_document("x.md", text)) >= 1 + assert indexer.chunk_document("x.md", text) == indexer.chunk_text(text)