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

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