feat(maria): modelul de embedding in cheia de cache si listarea grupurilor WhatsApp

Cheia de refolosire a embeddings-urilor era doar textul chunk-ului. La schimbarea
lui EMBED_MODEL indexul ar fi ramas un amestec de vectori din doua modele, iar
cautarea ar fi dat rezultate aiurea fara nici un mesaj de eroare. Acum fiecare
intrare poarta modelul cu care a fost calculata si se refolosesc doar cele cu
modelul curent; intrarile vechi, fara camp, se recalculeaza o singura data.
Indexul de pe container a fost stampilat manual cu `nomic-embed-text` (singurul
folosit pana acum), deci nu s-au recalculat cele 169 de chunk-uri.

`GET /groups` listeaza grupurile contului cu JID, nume si numar de participanti.
JID-ul unui grup ("120363...@g.us") nu se vede nicaieri in WhatsApp, iar el e
singurul mod de a scrie SUPPORT_JID pentru un grup de suport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
This commit is contained in:
Claude Agent
2026-08-31 22:20:08 +00:00
parent 52d1e9f205
commit 44763869ce
4 changed files with 89 additions and 6 deletions

View File

@@ -60,7 +60,7 @@ embeddings.
WhatsApp (self-chat, sau numarul legat)
|
v
whatsapp/index.js (Baileys) -- API HTTP :8099 (/status /send /messages /react /qr /pair)
whatsapp/index.js (Baileys) -- API HTTP :8099 (/status /send /messages /react /qr /pair /groups)
| descarca imaginile primite in ~/.maria-bridge/media/
|
v
@@ -312,8 +312,11 @@ Iar cand chiar reindexeaza, **refoloseste vectorii chunk-urilor nemodificate** d
indexul precedent (`rag/indexer.py:_vectori_existenti`). Un embedding costa ~7
secunde pe CPU: fara refolosire, adaugarea unui singur document la 170 de chunk-uri
insemna 20 de minute de reconstruit tot. Cheia e chiar textul chunk-ului — daca nu
s-a schimbat niciun caracter, vectorul e acelasi. Logul spune de fiecare data cate
au fost calculate si cate refolosite.
s-a schimbat niciun caracter, vectorul e acelasi — dar numai pentru **acelasi
`EMBED_MODEL`**: fiecare intrare din index poarta modelul cu care a fost calculata,
iar la schimbarea modelului indexul se reface intreg. Altfel ar ramane un amestec de
vectori din doua modele, iar cautarea ar da rezultate aiurea fara nici o eroare.
Logul spune de fiecare data cate au fost calculate si cate refolosite.
## Teste
@@ -386,7 +389,15 @@ suport, cu rezumatul ca legenda — de aceea captura se sterge abia dupa ce mesa
complet tratat, nu imediat dupa OCR.
Destinatia e `SUPPORT_JID` din `env`: `<numar>@s.whatsapp.net` pentru o persoana sau
`<id>@g.us` pentru un grup. **Nesetat = nimeni nu e anuntat**, dar escaladarea tot se
`<id>@g.us` pentru un grup. **JID-ul unui grup nu se vede nicaieri in WhatsApp**; se
citeste din punte, care le listeaza pe toate cu numele lor:
```bash
curl -s localhost:8099/groups | python3 -m json.tool | grep -B1 'ROMFAST'
```
Grupul trebuie sa contina si numarul Mariei (`40723197939`) — altfel JID-ul nici nu
apare in lista, iar trimiterea ar esua. **Nesetat = nimeni nu e anuntat**, dar escaladarea tot se
inregistreaza in `~/.maria-bridge/escalations/` si apare in dashboard, sectiunea
„Trimise la suport". Jurnalul se scrie intotdeauna, si cand notificarea esueaza:
altfel exact intrebarile fara raspuns ar disparea fara urma.

View File

@@ -141,12 +141,22 @@ def _vectori_existenti() -> dict[str, list[float]]:
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.
Se refolosesc doar intrarile scrise cu modelul curent. Doua modele dau vectori
diferiti (adesea si de alta lungime) pentru acelasi text, iar un index amestecat
ar strica ordonarea fara nici un mesaj de eroare. Intrarile vechi, dinainte de
campul `model`, se trateaza ca necunoscute: se recalculeaza o singura data.
"""
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")}
model = config.get("EMBED_MODEL")
return {
e["text"]: e["embedding"]
for e in vechi
if e.get("text") and e.get("embedding") and e.get("model") == model
}
def build() -> dict:
@@ -154,6 +164,7 @@ def build() -> dict:
warnings: list[str] = []
docs = store.documents_for_index()
refolosite = _vectori_existenti()
model = config.get("EMBED_MODEL")
reutilizari = 0
for doc in docs:
text = store.read_document(doc["name"])
@@ -165,7 +176,8 @@ def build() -> dict:
vec = embed(chunk)
else:
reutilizari += 1
entries.append({"source": doc["name"], "chunk": i, "text": chunk, "embedding": vec})
entries.append({"source": doc["name"], "chunk": i, "text": chunk,
"embedding": vec, "model": model})
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.

View File

@@ -291,3 +291,44 @@ def test_stergerea_gaseste_documentul_in_ambele_directoare(write):
assert store.delete_document("local.md") is True
assert store.delete_document("drive.md") is True
assert store.delete_document("inexistent.md") is False
def test_schimbarea_modelului_reface_embeddings(monkeypatch, write):
"""Alt EMBED_MODEL = alti vectori; refolosirea lor ar amesteca tacut doua modele."""
import config
import indexer
write("doc.md", "acelasi text, alt model")
monkeypatch.setattr(indexer, "embed", lambda chunk: [1.0])
indexer.build()
calculate = []
monkeypatch.setattr(indexer, "embed", lambda chunk: calculate.append(chunk) or [2.0])
monkeypatch.setattr(config, "get", lambda k, d=None: "alt-model"
if k == "EMBED_MODEL" else config.DEFAULTS.get(k, d))
rezultat = indexer.build()
assert calculate == ["acelasi text, alt model"]
assert rezultat["embeddings_refolosite"] == 0
def test_indexul_fara_camp_model_se_recalculeaza_o_data(monkeypatch, write):
"""Intrarile scrise inainte de campul `model` nu se pot atribui unui model."""
import json
import config
import indexer
write("doc.md", "text vechi")
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
config.INDEX_FILE.write_text(json.dumps(
[{"source": "doc.md", "chunk": 0, "text": "text vechi", "embedding": [9.0]}]),
encoding="utf-8")
calculate = []
monkeypatch.setattr(indexer, "embed", lambda chunk: calculate.append(chunk) or [1.0])
assert indexer.build()["embeddings_refolosite"] == 0
assert calculate == ["text vechi"]
calculate.clear()
assert indexer.build()["embeddings_refolosite"] == 1
assert calculate == []

View File

@@ -418,6 +418,25 @@ app.post('/react', async (req, res) => {
}
});
// Grupurile din care face parte contul, cu JID-ul lor. WhatsApp nu arata nicaieri
// JID-ul unui grup ("120363...@g.us"), iar el e singurul mod de a-l scrie in
// SUPPORT_JID: de aici se citeste, o data, cand se leaga escaladarile de un grup.
app.get('/groups', async (_req, res) => {
if (!connected || !sock) {
return res.status(503).json({ ok: false, error: 'not connected to WhatsApp' });
}
try {
const all = await sock.groupFetchAllParticipating();
const groups = Object.values(all)
.map((g) => ({ jid: g.id, subject: g.subject, participants: g.participants?.length || 0 }))
.sort((a, b) => (a.subject || '').localeCompare(b.subject || ''));
res.json({ ok: true, groups });
} catch (err) {
console.error('[whatsapp] Group fetch failed:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
app.get('/messages', (_req, res) => {
const messages = messageQueue.splice(0);
res.json({ messages });