feat(maria): accepta .xml in depozit, preferat peste .md, cu chunking pe probleme

Dosarul document_store din Drive are 3 surse .xml pe care depozitul le ignora
complet, fiindca store.py accepta doar .txt/.md. La d406_saft_knowledge exista
ambele formate, iar .xml e cu trei luni mai nou (2026-01-28 vs 2025-10-15) si cu
50% mai mare (64 KB vs 41 KB) — deci indexam varianta mai saraca.

- store.py devine sursa unica pentru extensii (DOC_EXTENSIONS = .txt/.md/.xml).
  Cand acelasi nume de baza exista in mai multe formate, la indexare intra unul
  singur, cel mai bogat (.xml > .md > .txt); celalalt ramane pe disc, marcat
  `shadowed_by`. Fara asta, acelasi raspuns ar aparea de doua ori in rezultate.
  `list_documents()` arata tot (dashboard), `documents_for_index()` doar
  castigatorii (indexer).
- indexer.py taie XML-ul altfel: un chunk per element de nivel 1, adica o
  problema = un chunk, cu <mesaj_eroare> si <rezolvare> impreuna. Taierea pe
  linii goale le-ar separa si cautarea ar returna eroarea fara raspuns.
  Etichetele raman prefixe lizibile ("mesaj eroare: ..."), fara paranteze
  unghiulare care doar dilueaza embedding-ul. XML invalid nu opreste indexarea:
  cade pe taierea obisnuita, cu o linie in log. Elementele peste 4000 de
  caractere se taie mai departe pe granite de cuvant — `chunk_text` imparte doar
  pe linii goale, deci un element scris ca un paragraf lung ar fi ramas intreg
  (prins de test).
- sync.py: amprenta si `rclone --include` derivate din DOC_EXTENSIONS.
- dashboard: acelasi filtru si aceeasi preferinta (copie, fiindca nu poate
  importa `store` — coliziune de nume pe `config`), plus marcajul "umbrit de X"
  in tabelul de documente si numarul de documente chiar indexate.
- README: sectiunea Drive rescrisa pe `rclone authorize` (autorizezi pe o masina
  cu browser, muti tokenul) in loc de cont de serviciu — mai putini pasi, fara
  consola Google Cloud. Documentat si ca `sync` sterge local ce nu mai e in Drive.

tests/ nou (20 de teste, fara retea si fara Ollama): preferinta de format,
vizibilitatea in dashboard, taierea XML, entitati, comentarii, XML invalid,
elemente uriase. Suita puntii Discord: 426 pass, neafectata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
This commit is contained in:
Claude Agent
2026-08-31 18:15:03 +00:00
parent c42b95b7db
commit 1c7899753a
11 changed files with 400 additions and 61 deletions

View File

@@ -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

View File

@@ -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 '<tr><td data-label="Nume" class="mono">' + esc(d.name) + '</td>' +
var umbrit = d.shadowed_by
? ' <span class="muted" title="acelaşi document exista intr-un format mai bogat">· umbrit de ' + esc(d.shadowed_by) + '</span>'
: '';
return '<tr><td data-label="Nume" class="mono">' + esc(d.name) + umbrit + '</td>' +
'<td data-label="Mărime" class="num">' + d.size + ' B</td>' +
'<td><button class="small danger" onclick="mDelDoc(\'' + esc(d.name) + '\')">Șterge</button></td></tr>';
}).join('') : '<tr><td colspan="3" class="empty" data-label="">niciun document</td></tr>';

View File

@@ -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 <https://rclone.org/downloads/> (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=<FOLDER_ID>
rclone config create gdrive drive scope=drive.readonly token='<TOKEN_JSON>'
```
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

View File

@@ -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 `<remote>:<cale>`.
# 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 -----------------------------------------------------------------

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -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": "",
}

View File

@@ -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 `<eroare_x><mesaj_eroare>…</mesaj_eroare><rezolvare>…</rezolvare></eroare_x>`,
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)

View File

@@ -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
(`<mesaj_eroare>`, `<rezolvare>`) 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")

View File

@@ -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:

View File

@@ -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

View File

@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<erori_rag versiune="1.0">
<!-- comentariu -->
<eroare_stocuri_negative>
<keywords>stocuri negative NIR</keywords>
<mesaj_eroare>Documentul nu poate fi sters: stocuri negative pentru BANDA HARTIE.</mesaj_eroare>
<rezolvare>Optiuni firma &gt; VERIFICARESTOCNEGATIV = 0.</rezolvare>
</eroare_stocuri_negative>
<eroare_a_doua>
<mesaj_eroare>Alta eroare.</mesaj_eroare>
<rezolvare>Alta rezolvare.</rezolvare>
</eroare_a_doua>
</erori_rag>"""
# ------------------------------------------------------- preferinta de format
def test_xml_umbreste_md_cu_acelasi_nume(write):
write("d406.xml", "<r><a>x</a></r>")
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", "<r><a>b</a></r>")
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", "<r><a>x</a></r>")
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 "<mesaj_eroare>" not in chunk
def test_entitatile_sunt_decodate():
assert "&gt;" 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", "<root><neinchis>")
assert chunks == ["<root><neinchis>"]
def test_xml_fara_copii_e_tratat_ca_text():
assert indexer.chunk_document("x.xml", "<root>doar text</root>") == ["doar text"]
def test_element_urias_e_taiat_mai_departe():
mare = "<r><a>" + ("propozitie lunga. " * 500) + "</a></r>"
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)