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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -q
|
||||
@@ -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": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 > 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 ">" 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)
|
||||
Reference in New Issue
Block a user