Files
ROMFASTSQL/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py
Claude Agent 7a1d2a2073 feat(maria): asociere prin cod de telefon si citirea capturilor cu erori
Doua lucruri cerute de utilizator, ambele pe acelasi drum: sa poti lega
puntea fara sa ai un ecran de scanat, si sa poti trimite Mariei o poza cu
eroarea in loc sa transcrii mesajul.

Asociere prin cod de 8 caractere (alternativa la QR):
- endpoint-ul /pair exista, dar nu era folosibil: fara `browser` explicit
  WhatsApp refuza codul, iar dupa introducerea lui corecta serverul cere un
  restart (515) care consuma din bugetul de reincercari si putea opri puntea.
  Acum descriptorul e Browsers.ubuntu('Chrome') si restartRequired reconecteaza
  imediat, fara sa numere.
- codul are TTL de 3 minute, iar /status il da doar cat timp e valabil —
  un cod expirat afisat in dashboard trimite omul sa tasteze degeaba.
- dashboard: camp pentru numar + buton, in acelasi card cu QR-ul.

Imagini cu erori (capturi de ecran):
- puntea descarca imaginile in ~/.maria-bridge/media/ (imageMessage sau
  document cu mimetype image/*, si prin ambalajele efemer/"vezi o data" —
  fara despachetare pareau mesaje fara continut si se aruncau tacut).
- rag/ocr.py: tesseract ron+eng. Modelul de raspuns e strict text, deci OCR
  nu e o optiune de calitate, e singura cale.
- cautarea in index merge DOAR pe liniile care arata a eroare; o fereastra
  intreaga de meniuri si totaluri dilueaza embedding-ul si scoate chunk-uri
  fara legatura. Modelul primeste captura intreaga, marcata ca text OCR.
- capturile se sterg imediat dupa citire (pot contine date de client).
- cand nu se citeste nimic si nu exista legenda, Maria cere textul erorii
  in loc sa raspunda in gol.

16 teste noi (42 in total). install.sh verifica tesseract; README documenteaza
ambele metode de asociere si drumul unei capturi.

Separat, in docs/chatboti-si-punti.md: chatul "Eu" e vazut de AMBELE punti de pe
numar. Puntea lui Echo (LXC 110) e asociata ca dispozitiv :11 al aceluiasi cont,
momentan nelegata dar pornita — daca se reasociaza, raspunde in "Eu" langa Maria.
Notat si ca serviciile lui Echo sunt unitati de UTILIZATOR: `systemctl is-active`
ca root raspunde "inactive" desi botul ruleaza.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-08-31 21:22:42 +00:00

113 lines
4.2 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Citirea textului din capturile de ecran trimise pe WhatsApp (OCR cu tesseract).
De ce OCR si nu un model vizual: modelul de raspuns al Mariei (Qwen3.5-2B pe
llama.cpp, LXC 104) e strict text — nu accepta imagini. Utilizatorii insa trimit
aproape intotdeauna o captura cu fereastra de eroare, nu textul ei. Tesseract
(CPU, ~1-2 s pe o captura de ecran) transforma captura in text, iar restul
lantului ramane neschimbat: acelasi RAG, acelasi prompt.
Textul recunoscut e ZGOMOTOS. Doua consecinte tratate aici:
- pentru CAUTAREA in index folosim doar liniile care arata a eroare
(`retrieval_query`), nu toata fereastra: un ecran intreg de meniuri si
coloane diluează embedding-ul si scoate chunk-uri fara legatura;
- pentru PROMPT trimitem textul intreg, curatat si plafonat, marcat explicit ca
provenit din OCR, ca modelul sa nu trateze greselile de recunoastere ca date
exacte.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import config
DEFAULT_LANGS = "ron+eng"
DEFAULT_MAX_CHARS = 1500
DEFAULT_TIMEOUT_S = 60
# Semne ca o linie e chiar mesajul de eroare, nu decor de interfata. Ordinea nu
# conteaza; o singura potrivire e de ajuns.
_ERROR_HINTS = re.compile(
r"(ORA-\d+|PLS-\d+|TNS-\d+|SP2-\d+|IMP-\d+|EXP-\d+"
r"|\b(?:eroare|erori|eronat)\b|\berror(?:s|ul)?\b|\bexception\b|\besuat\b|\bfailed\b|\binvalid\b"
r"|\bnu se poate\b|\bnu exista\b|\bnu a fost\b|\bcod\s*[:=]?\s*-?\d+"
r"|\bE\d{3,}\b|\bwarning\b|\batentie\b)",
re.IGNORECASE,
)
# Linii care nu aduc nimic: prea scurte, sau numai simboluri/rame de fereastra.
_JUNK_LINE = re.compile(r"^[\W_]*$")
def available() -> bool:
return shutil.which("tesseract") is not None
def clean(raw: str, max_chars: int | None = None) -> str:
"""Normalizeaza iesirea tesseract: fara linii goale, fara zgomot, plafonat."""
if max_chars is None:
max_chars = config.get_int("OCR_MAX_CHARS", DEFAULT_MAX_CHARS)
lines: list[str] = []
for raw_line in raw.splitlines():
line = re.sub(r"[ \t ]+", " ", raw_line).strip()
if not line or _JUNK_LINE.match(line):
continue
if len(line) < 2:
continue
if lines and lines[-1] == line: # tesseract repeta uneori aceeasi linie
continue
lines.append(line)
text = "\n".join(lines)
if len(text) > max_chars:
text = text[:max_chars].rstrip() + "\n[…text trunchiat]"
return text
def error_lines(text: str, limit: int = 6) -> list[str]:
"""Liniile care arata a mesaj de eroare, in ordinea din imagine."""
out: list[str] = []
for line in text.splitlines():
if _ERROR_HINTS.search(line) and line not in out:
out.append(line)
if len(out) >= limit:
break
return out
def retrieval_query(caption: str, ocr_text: str) -> str:
"""Ce se trimite la cautarea in index: legenda + liniile de eroare, sau tot.
Cand nu se distinge nicio linie de eroare, cade inapoi pe primele linii ale
capturii — mai bine o cautare aproximativa decat niciuna.
"""
parts: list[str] = []
if caption.strip():
parts.append(caption.strip())
errs = error_lines(ocr_text)
if errs:
parts.extend(errs)
else:
parts.extend(ocr_text.splitlines()[:8])
return "\n".join(p for p in parts if p.strip()).strip()
def run(image_path: str, langs: str | None = None) -> str:
"""Textul curatat dintr-o imagine. Ridica RuntimeError daca tesseract lipseste/cade."""
if not available():
raise RuntimeError("tesseract nu e instalat (apt-get install tesseract-ocr tesseract-ocr-ron)")
langs = langs or config.get("OCR_LANGS", DEFAULT_LANGS)
timeout = config.get_int("OCR_TIMEOUT_S", DEFAULT_TIMEOUT_S)
try:
proc = subprocess.run(
["tesseract", image_path, "stdout", "-l", langs],
capture_output=True, text=True, timeout=timeout, check=False,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"OCR a depasit {timeout}s") from exc
if proc.returncode != 0:
raise RuntimeError((proc.stderr or "tesseract a esuat").strip().splitlines()[-1])
return clean(proc.stdout)