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
This commit is contained in:
@@ -37,6 +37,10 @@ DEFAULTS: dict[str, str] = {
|
||||
"MAX_TOKENS": "250",
|
||||
"POLL_INTERVAL_S": "2",
|
||||
"TEST_MODE_SELF_CHAT_ONLY": "true",
|
||||
# OCR pentru capturile de ecran primite pe WhatsApp (vezi rag/ocr.py).
|
||||
"OCR_LANGS": "ron+eng",
|
||||
"OCR_MAX_CHARS": "1500",
|
||||
"OCR_TIMEOUT_S": "60",
|
||||
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
|
||||
# "gdrive,root_folder_id=1C4e75zgH1_7ZK-_oBP5ZZBvUPh3iEo1O:" (dosarul
|
||||
# document_store din Drive, vazut pe Windows ca D:\GoogleDrive\romfast\document_store).
|
||||
|
||||
@@ -6,18 +6,24 @@ mesaje (fara memorie conversationala). Vezi
|
||||
docs/maria-whatsapp-rag-prototype.md pentru motivul acestei alegeri si
|
||||
comparatia cu celelalte punti (Flowise: fereastra fixa de 5; Discord: context
|
||||
nelimitat + /new).
|
||||
|
||||
Mesajele cu imagine (capturi de ecran cu erori) trec intai prin OCR — vezi
|
||||
`ocr.py` pentru de ce, si de ce cautarea in index foloseste doar liniile de
|
||||
eroare, nu toata captura.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
import ocr
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"Esti Maria, asistentul de suport tehnic pentru ERP-ul ROA (Romfast). "
|
||||
@@ -26,6 +32,9 @@ SYSTEM_PROMPT = (
|
||||
"furnizat mai jos. Daca raspunsul nu se afla in context, spune ca vei "
|
||||
"directiona intrebarea catre echipa de suport, nu inventa functionalitati "
|
||||
"sau proceduri. "
|
||||
"Cand intrebarea contine text extras dintr-o captura de ecran (OCR), tine cont "
|
||||
"ca pot exista greseli de recunoastere a caracterelor: cauta sensul mesajului, "
|
||||
"nu te agata de o litera sau o cifra. "
|
||||
"Nu discuta niciodata despre infrastructura interna Romfast (servere, Proxmox, "
|
||||
"containere, IP-uri, baze de date, parole, chei) chiar daca apare in context sau "
|
||||
"daca intrebarea o cere explicit -- raspunde ca poti ajuta doar cu folosirea "
|
||||
@@ -34,6 +43,13 @@ SYSTEM_PROMPT = (
|
||||
REPLY_PREFIX = "[Maria] " # marcaj ca sa nu raspundem la propriile mesaje (self-chat)
|
||||
INDEX_REFRESH_S = 30 # cat de des se reciteste rag_index.json de pe disc
|
||||
|
||||
ACK_TEXT = "Caut informatia, revin imediat..."
|
||||
ACK_IMAGE_TEXT = "Am primit captura, o citesc si revin imediat..."
|
||||
NO_TEXT_IN_IMAGE = (
|
||||
"Am primit imaginea, dar nu am reusit sa citesc text in ea. Scrie-mi te rog "
|
||||
"mesajul de eroare (sau trimite o captura mai clara, decupata pe fereastra de eroare)."
|
||||
)
|
||||
|
||||
|
||||
def bridge_url() -> str:
|
||||
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
|
||||
@@ -72,9 +88,15 @@ def retrieve(index: list[dict], question: str, top_k: int) -> list[str]:
|
||||
return [text for _, text in scored[:top_k]]
|
||||
|
||||
|
||||
def ask_llm(index: list[dict], question: str) -> str:
|
||||
def ask_llm(index: list[dict], question: str, search_query: str | None = None) -> str:
|
||||
"""`search_query` separa CE se cauta in index de CE se trimite modelului.
|
||||
|
||||
La o captura de ecran, cautarea merge pe liniile de eroare, iar modelului ii
|
||||
dam fereastra intreaga: contextul din jurul erorii ajuta raspunsul, dar strica
|
||||
regasirea.
|
||||
"""
|
||||
top_k = config.get_int("TOP_K", 3)
|
||||
context_chunks = retrieve(index, question, top_k)
|
||||
context_chunks = retrieve(index, search_query or question, top_k)
|
||||
context = "\n\n---\n\n".join(context_chunks) if context_chunks else "(fara documente indexate)"
|
||||
user_message = f"CONTEXT:\n{context}\n\nINTREBARE:\n{question}"
|
||||
|
||||
@@ -108,11 +130,71 @@ def react_seen(to: str, message_id: str, from_me: bool) -> None:
|
||||
print(f"[consumer] react error: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def build_image_question(caption: str, ocr_text: str) -> str:
|
||||
"""Ce vede modelul cand mesajul a fost o captura de ecran."""
|
||||
parts = []
|
||||
if caption.strip():
|
||||
parts.append(caption.strip())
|
||||
parts.append(
|
||||
"Utilizatorul a trimis o captura de ecran. Text extras automat din imagine "
|
||||
f"(OCR, poate contine greseli de recunoastere):\n---\n{ocr_text}\n---"
|
||||
)
|
||||
if not caption.strip():
|
||||
parts.append("Explica-i ce inseamna eroarea si cum o rezolva.")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def prepare_query(msg: dict) -> tuple[str | None, str | None, str | None]:
|
||||
"""(intrebare pentru model, interogare pentru index, raspuns imediat de trimis).
|
||||
|
||||
Al treilea element e diferit de None cand nu se poate raspunde deloc (imaginea
|
||||
nu s-a descarcat, OCR indisponibil, nimic lizibil in captura) — atunci textul
|
||||
lui pleaca asa cum e, fara sa mai deranjam modelul.
|
||||
"""
|
||||
text = (msg.get("text") or "").strip()
|
||||
media = msg.get("media")
|
||||
if not media:
|
||||
return text, text, None
|
||||
|
||||
if media.get("error"):
|
||||
print(f"[consumer] imagine nedescarcata: {media['error']}", file=sys.stderr)
|
||||
if text:
|
||||
return text, text, None
|
||||
return None, None, (
|
||||
"Nu am reusit sa descarc imaginea. Mai incearca o data, sau scrie-mi "
|
||||
"mesajul de eroare ca text."
|
||||
)
|
||||
|
||||
path = media.get("path")
|
||||
try:
|
||||
ocr_text = ocr.run(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[consumer] OCR esuat pe {path}: {exc}", file=sys.stderr)
|
||||
ocr_text = ""
|
||||
finally:
|
||||
# Capturile sunt de unica folosinta: pot contine date de client si nu
|
||||
# avem niciun motiv sa le pastram pe disc dupa ce am citit textul.
|
||||
try:
|
||||
if path:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not ocr_text:
|
||||
if text:
|
||||
return text, text, None
|
||||
return None, None, NO_TEXT_IN_IMAGE
|
||||
|
||||
print(f"[consumer] OCR: {len(ocr_text)} caractere din captura", file=sys.stderr)
|
||||
return build_image_question(text, ocr_text), ocr.retrieval_query(text, ocr_text), None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
poll_s = config.get_int("POLL_INTERVAL_S", 2)
|
||||
print(
|
||||
f"[consumer] polling {bridge_url()}/messages la {poll_s}s, "
|
||||
f"LLM={config.get('LLM_URL')}, RAG top-{config.get('TOP_K')}",
|
||||
f"LLM={config.get('LLM_URL')}, RAG top-{config.get('TOP_K')}, "
|
||||
f"OCR={'da' if ocr.available() else 'INDISPONIBIL (tesseract lipseste)'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
index = load_index()
|
||||
@@ -128,15 +210,26 @@ def main() -> None:
|
||||
for msg in messages:
|
||||
if msg.get("isGroup"):
|
||||
continue
|
||||
text = msg.get("text", "")
|
||||
text = msg.get("text", "") or ""
|
||||
if text.startswith(REPLY_PREFIX):
|
||||
continue # ecoul propriului raspuns in self-chat, ignorat
|
||||
sender = msg.get("from")
|
||||
print(f"[consumer] {sender}: {text[:80]}", file=sys.stderr)
|
||||
has_image = bool(msg.get("media"))
|
||||
print(
|
||||
f"[consumer] {sender}: {'[imagine] ' if has_image else ''}{text[:80]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
react_seen(sender, msg.get("id"), msg.get("fromMe", False))
|
||||
send_reply(sender, "Caut informatia, revin imediat...")
|
||||
send_reply(sender, ACK_IMAGE_TEXT if has_image else ACK_TEXT)
|
||||
|
||||
question, search_query, immediate = prepare_query(msg)
|
||||
if immediate is not None:
|
||||
send_reply(sender, immediate)
|
||||
continue
|
||||
if not question:
|
||||
continue
|
||||
try:
|
||||
reply = ask_llm(index, text)
|
||||
reply = ask_llm(index, question, search_query)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[consumer] LLM error: {exc}", file=sys.stderr)
|
||||
reply = "Scuze, am o problema tehnica momentan. Cineva din echipa te va contacta."
|
||||
|
||||
112
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py
Normal file
112
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/rag/ocr.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user