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
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
#!/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)
|