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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user