159 lines
7.2 KiB
Python
159 lines
7.2 KiB
Python
"""Верификация атомов: self-critique + кросс-подтверждение.
|
|
|
|
Двухступенчатая верификация знания:
|
|
1. self-critique (LLM) — вердикт по новизне/противоречию против существующих.
|
|
2. кросс-подтверждение (эмбеддинги) — тот же факт из N независимых источников.
|
|
|
|
Статусы:
|
|
candidate — новый атом, не проверен
|
|
verified — подтверждён (self-critique прошёл И/ИЛИ 2+ источника)
|
|
contested — противоречит существующему знанию
|
|
stale — устарел (не актуально)
|
|
|
|
Верификация не удаляет атомы, только меняет статус/confidence.
|
|
"""
|
|
from __future__ import annotations
|
|
import json
|
|
import httpx
|
|
from . import config, db
|
|
|
|
_client = httpx.Client(timeout=600)
|
|
|
|
VERDICTS = ("new", "duplicate", "corroborates", "contradicts")
|
|
|
|
|
|
def self_critique(atom: dict, context_atoms: list[dict], model: str | None = None) -> dict:
|
|
"""LLM-вердикт по атому против существующих знаний.
|
|
|
|
Возвращает {"verdict", "confidence", "reason"}.
|
|
"""
|
|
m = model or config.LLM_MODEL
|
|
ctx = "\n".join(f"- {a.get('claim','')[:120]}" for a in context_atoms[:8])
|
|
prompt = (
|
|
f"Существующие знания:\n{ctx}\n\n"
|
|
f"Новый атом ({atom.get('type')}):\n{atom.get('claim')}\n{atom.get('content','')[:400]}\n\n"
|
|
f"Оцени атом относительно существующих знаний. Верни ТОЛЬКО JSON:\n"
|
|
f'{{"verdict": "new|duplicate|corroborates|contradicts", "confidence": 0.0-1.0, "reason": "кратко"}}\n'
|
|
f"- new: уникальное знание, которого нет в контексте\n"
|
|
f"- duplicate: то же самое, что уже есть\n"
|
|
f"- corroborates: подтверждает/уточняет существующее\n"
|
|
f"- contradicts: противоречит существующему"
|
|
)
|
|
r = _client.post(f"{config.OLLAMA_URL}/api/chat", json={
|
|
"model": m,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"stream": False,
|
|
"options": {"temperature": 0.0},
|
|
})
|
|
r.raise_for_status()
|
|
out = r.json()["message"]["content"].strip()
|
|
start, end = out.find("{"), out.rfind("}")
|
|
if start == -1 or end == -1:
|
|
return {"verdict": "new", "confidence": 0.5, "reason": "parse error"}
|
|
try:
|
|
v = json.loads(out[start:end + 1])
|
|
except json.JSONDecodeError:
|
|
return {"verdict": "new", "confidence": 0.5, "reason": "json error"}
|
|
if v.get("verdict") not in VERDICTS:
|
|
v["verdict"] = "new"
|
|
return v
|
|
|
|
|
|
def corroborate_atom(conn, atom_id: int, threshold: float = 0.90) -> int:
|
|
"""Найти близкие атомы из ДРУГИХ источников (кросс-подтверждение).
|
|
|
|
Возвращает число найденных подтверждений (не считая сам атом).
|
|
"""
|
|
from . import qdrant_store
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT id, doc_id, claim, content FROM pm_atom WHERE id = %s", (atom_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return 0
|
|
text = (row["claim"] or "") + " " + (row["content"] or "")[:800]
|
|
hits = qdrant_store.search(text, top_k=5)
|
|
count = 0
|
|
for h in hits:
|
|
if int(h["id"]) == atom_id:
|
|
continue
|
|
if h["score"] >= threshold and h.get("doc_id") != row["doc_id"]:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def verify_atom(conn, atom_id: int, *, self_critique_enabled: bool = True,
|
|
model: str | None = None) -> dict:
|
|
"""Полная верификация одного атома. Возвращает результат.
|
|
|
|
- self-critique: вердикт LLM (contradicts → contested).
|
|
- кросс-подтверждение: N близких из других источников (N>=1 → verified).
|
|
"""
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT id, type, claim, content, confidence, status, doc_id FROM pm_atom WHERE id = %s", (atom_id,))
|
|
atom = cur.fetchone()
|
|
if not atom:
|
|
return {"atom_id": atom_id, "error": "not found"}
|
|
|
|
result = {"atom_id": atom_id, "verdict": None, "corroborations": 0,
|
|
"new_status": atom["status"], "new_confidence": atom["confidence"]}
|
|
|
|
# 1) кросс-подтверждение по эмбеддингам (быстро, без LLM)
|
|
corr = corroborate_atom(conn, atom_id)
|
|
result["corroborations"] = corr
|
|
|
|
# 2) self-critique (LLM): контекст — ближайшие по смыслу (эмбеддинги), а не по id
|
|
if self_critique_enabled:
|
|
from . import qdrant_store
|
|
qtext = (atom["claim"] or "") + " " + (atom["content"] or "")[:400]
|
|
hits = qdrant_store.search(qtext, top_k=8)
|
|
context = [{"claim": h.get("claim", "")} for h in hits if int(h["id"]) != atom_id]
|
|
v = self_critique(atom, context, model=model)
|
|
result["verdict"] = v["verdict"]
|
|
result["reason"] = v.get("reason")
|
|
|
|
if v["verdict"] == "contradicts":
|
|
result["new_status"] = "contested"
|
|
elif v["verdict"] == "duplicate":
|
|
result["new_status"] = "stale" # дубликат — пометить неактуальным
|
|
elif v["verdict"] in ("corroborates", "new") or corr >= 1:
|
|
result["new_status"] = "verified"
|
|
elif corr >= 1:
|
|
result["new_status"] = "verified"
|
|
|
|
# confidence: кросс-подтверждение повышает уверенность
|
|
new_conf = min(1.0, float(atom["confidence"] or 0.5) + 0.1 * corr)
|
|
result["new_confidence"] = round(new_conf, 2)
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE pm_atom SET status = %s, confidence = %s, updated_at = now() WHERE id = %s",
|
|
(result["new_status"], result["new_confidence"], atom_id),
|
|
)
|
|
conn.commit()
|
|
return result
|
|
|
|
|
|
def verify_all(conn, *, limit: int = 50, self_critique_enabled: bool = True,
|
|
model: str | None = None) -> dict:
|
|
"""Верифицировать все candidate-атомы (порциями). Возвращает сводку."""
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT id FROM pm_atom WHERE status = 'candidate' ORDER BY id LIMIT %s", (limit,))
|
|
ids = [r["id"] for r in cur.fetchall()]
|
|
summary = {"total": len(ids), "verified": 0, "contested": 0, "stale": 0, "unchanged": 0}
|
|
for aid in ids:
|
|
try:
|
|
r = verify_atom(conn, aid, self_critique_enabled=self_critique_enabled, model=model)
|
|
except Exception as e:
|
|
print(f"[verify] atom {aid} failed: {e}")
|
|
continue
|
|
ns = r.get("new_status")
|
|
if ns == "verified":
|
|
summary["verified"] += 1
|
|
elif ns == "contested":
|
|
summary["contested"] += 1
|
|
elif ns == "stale":
|
|
summary["stale"] += 1
|
|
else:
|
|
summary["unchanged"] += 1
|
|
return summary
|