Trei probleme legate, gasite testand cu capturi reale. 1. RASPUNSURI GENERICE. Cand raspunsul nu era in documente, modelul primea oricum top-3 chunk-uri si compunea ceva plauzibil — utilizatorul pleca cu impresia ca a primit ajutor. Acum, daca nu avem acoperire, modelul nu mai e intrebat deloc: intrebarea pleaca la suport, cu referinta (M-260831-A1B2) si confirmare onesta — "am trimis" doar daca notificarea chiar a plecat, altfel "am inregistrat, dar nu am putut trimite". Jurnalul se scrie intotdeauna, in ~/.maria-bridge/escalations/, si se vede in dashboard. Daca a fost o captura, imaginea insasi se retrimite la suport (endpoint nou /send-image, limitat la MEDIA_DIR). 2. ORDONARE (rank.py). Un prag pe cosinus nu putea decide "avem raspunsul?": masurat pe indexul viu, intrebarile bune dau 0,600-0,816 si cele straine 0,534-0,685 — intervale care SE SUPRAPUN. Lipseau codurile: ORA-01722, D406, CIF sunt tokeni exacti pe care cautarea semantica ii topeste. Acum se ordoneaza de doua ori — embeddings si BM25 — si se fuzioneaza clasamentele (RRF), iar decizia de acoperire se ia pe dovezi: cosinus mare, sau cosinus de mijloc cu o fractiune din termenii distinctivi gasita chiar in chunk-uri. Fractiunea conteaza: cu un singur termen, "cum imi resetez parola de la Windows" trecea drept acoperita fiindca "parola" apare in documente. Pragurile sunt masurate, nu alese: ops/calibrate-rank.py ruleaza un set de cazuri pe indexul real si matura grila. 15/15 la strong=0.70 weak=0.58 ratio=0.5. 3. DICTIONAR DE ERORI ORACLE. 29 de erori uzuale, scrise pentru utilizatorul din fata aplicatiei: ce inseamna, ce poate incerca singur, cand sa sune. Fara nume de servere, IP-uri sau pasi de administrare — Maria e chatbot pentru clienti. Fisierul din git e SAMANTA: depozitul e oglinda Drive-ului, deci trebuie pus in document_store ca sa nu dispara la sincronizare. Pe drum, doua lucruri gasite in log: - fiecare mesaj din self-chat sosea de DOUA ori, pe @s.whatsapp.net si pe @lid, deci Maria raspundea de doua ori. Dedup pe key.id in punte. - reindexarea reface toate embeddings-urile (~7 s fiecare pe CPU): adaugarea unui document la 170 de chunk-uri insemna 20 de minute. Acum refoloseste vectorii chunk-urilor nemodificate din indexul precedent, fara vreun fisier nou de stare. Textul OCR se logheaza acum INTEGRAL, cu interogarea de cautare si sursele alese cu scorurile lor — fara asta un raspuns gresit nu se poate explica: nu stiai daca a citit prost captura sau a cautat prost in documente. 6 fisiere de test noi/extinse, 68 pass (de la 42). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
198 lines
6.7 KiB
Python
198 lines
6.7 KiB
Python
#!/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`).
|
|
|
|
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 os
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import requests
|
|
|
|
import config
|
|
import store
|
|
|
|
|
|
def chunk_text(text: str) -> list[str]:
|
|
# imparte pe linii goale in paragrafe, uneste bucatile mici cu urmatoarea
|
|
raw_parts = re.split(r"\n\s*\n", text.strip())
|
|
chunks: list[str] = []
|
|
buffer = ""
|
|
for part in raw_parts:
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
buffer = f"{buffer}\n\n{part}" if buffer else part
|
|
if len(buffer) >= 200:
|
|
chunks.append(buffer)
|
|
buffer = ""
|
|
if buffer:
|
|
chunks.append(buffer)
|
|
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",
|
|
json={"model": config.get("EMBED_MODEL"), "prompt": text},
|
|
timeout=60,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()["embedding"]
|
|
|
|
|
|
def _vectori_existenti() -> dict[str, list[float]]:
|
|
"""Embeddings-urile din indexul curent, dupa textul chunk-ului.
|
|
|
|
Un embedding pe CPU costa ~7 secunde. Fara asta, adaugarea unui singur
|
|
document reface toate embeddings-urile — la 170 de chunk-uri inseamna 20 de
|
|
minute ca sa se schimbe unul. Textul chunk-ului e cheia potrivita: daca nu s-a
|
|
schimbat niciun caracter, vectorul e acelasi.
|
|
|
|
Sursa e chiar indexul precedent, nu un al doilea fisier de cache: nu are ce sa
|
|
se desincronizeze, si daca indexul lipseste se recalculeaza tot, ca inainte.
|
|
"""
|
|
try:
|
|
vechi = json.loads(config.INDEX_FILE.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return {e["text"]: e["embedding"] for e in vechi if e.get("text") and e.get("embedding")}
|
|
|
|
|
|
def build() -> dict:
|
|
entries = []
|
|
warnings: list[str] = []
|
|
docs = store.documents_for_index()
|
|
refolosite = _vectori_existenti()
|
|
reutilizari = 0
|
|
for doc in docs:
|
|
text = store.read_document(doc["name"])
|
|
if doc["name"].endswith(".xml") and chunk_xml(text) is None:
|
|
warnings.append(f"{doc['name']}: XML invalid, indexat ca text simplu")
|
|
for i, chunk in enumerate(chunk_document(doc["name"], text)):
|
|
vec = refolosite.get(chunk)
|
|
if vec is None:
|
|
vec = embed(chunk)
|
|
else:
|
|
reutilizari += 1
|
|
entries.append({"source": doc["name"], "chunk": i, "text": chunk, "embedding": vec})
|
|
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
# Scriere atomica: consumer-ul reciteste fisierul la 30s si ar putea prinde
|
|
# un JSON pe jumatate scris daca am scrie direct peste el.
|
|
tmp = config.INDEX_FILE.with_suffix(".json.tmp")
|
|
tmp.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8")
|
|
os.replace(tmp, config.INDEX_FILE)
|
|
out = {"documents": len(docs), "chunks": len(entries),
|
|
"embeddings_refolosite": reutilizari,
|
|
"embeddings_noi": len(entries) - reutilizari}
|
|
if warnings:
|
|
out["warnings"] = warnings
|
|
return out
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
with config.exclusive():
|
|
result = build()
|
|
except config.Busy as exc:
|
|
print(f"[indexer] {exc}, ies fara sa fac nimic", file=sys.stderr)
|
|
raise SystemExit(0)
|
|
for w in result.get("warnings", []):
|
|
print(f"[indexer] ATENTIE {w}", file=sys.stderr)
|
|
print(
|
|
f"[indexer] {result['documents']} documente, {result['chunks']} chunk-uri "
|
|
f"({result['embeddings_noi']} embeddings noi, {result['embeddings_refolosite']} refolosite)"
|
|
f" -> {config.INDEX_FILE}",
|
|
file=sys.stderr,
|
|
)
|