agnes-the-ai-analyst/services/corporate_memory/contradiction.py
PavelDo e1108b6112
feat(memory): corporate memory v1+v1.5 + 0.15.0 (#72)
Adds corporate memory v1 (verification flywheel + contradiction detection + confidence scoring) and v1.5 (audience-based distribution + per-item privacy + admin curation). Server: GET /api/memory/bundle returns mandatory + ranked-approved items within a token budget; POST /api/memory/admin/mandate accepts an audience field gated against user_group_members; /api/memory/stats uses SQL aggregation. CLI: da sync writes received items to .claude/rules/km_*.md. Verification detector extracts knowledge candidates from session JSONL files. Auto-tagging via Haiku when ai: is configured. Adapted from the v9-era branch onto v13/v14 RBAC: _is_privileged_viewer + _effective_groups now query user_group_members JOIN user_groups; require_role(Role.KM_ADMIN) replaced with require_admin (km_admin collapsed into admin). Schema v15: knowledge_items context-engineering columns + knowledge_contradictions + session_extraction_state. Schema v16: verification_evidence. Cuts release v0.15.0 (also bundles #116 /me/debug page).
2026-04-29 07:16:22 +02:00

255 lines
8.7 KiB
Python

"""Contradiction detection for corporate memory knowledge items.
Architecture (see docs/ADR-corporate-memory-v1.md, Decision 4):
- Topic / content matching is performed by a single Haiku call with strict
structured output. The brittle SQL keyword pre-filter that used to live
here was removed.
- Domain remains a hard SQL conjunct (cheap, scales with corpus size).
- The same Haiku call returns the structured resolution suggestion —
contradiction detection and resolution arrive in one shot.
Public API:
detect_and_record(extractor, new_item, repo)
-> list[str] of contradiction IDs persisted to knowledge_contradictions.
find_and_judge(extractor, new_item, repo)
-> list[dict] of contradiction records (not yet persisted).
The ``check_contradiction`` and ``check_contradictions`` helpers are retained
for callers that want the low-level path (single Haiku call + persistence
disabled).
"""
import logging
from typing import Any
from connectors.llm import StructuredExtractor
from connectors.llm.exceptions import LLMError
from src.repositories.knowledge import KnowledgeRepository
from .prompts import (
BATCH_CONTRADICTION_PROMPT,
BATCH_CONTRADICTION_SCHEMA,
format_candidates_block,
)
logger = logging.getLogger(__name__)
# Hard cap on candidates per call — keeps prompt size bounded even if a
# domain accumulates a very large corpus. Above this, callers should shard.
DEFAULT_CANDIDATE_LIMIT = 100
# "merge" is the LLM-proposed action; the API resolution field uses "merged" — intentionally different terms
_VALID_ACTIONS = {"kept_a", "kept_b", "merge", "both_valid"}
_VALID_SEVERITIES = {"hard", "soft"}
def find_candidates(
repo: KnowledgeRepository,
new_item: dict,
max_candidates: int = DEFAULT_CANDIDATE_LIMIT,
) -> list[dict]:
"""Load same-domain candidates for the LLM judge.
Domain is the only SQL narrowing applied. If the new item has no domain,
candidates from all domains are returned (the LLM still judges each).
"""
item_id = new_item.get("id", "")
return repo.find_contradiction_candidates(
new_item_id=item_id,
domain=new_item.get("domain"),
limit=max_candidates,
)
def _build_resolution(judgment: dict) -> dict | None:
"""Project the flat resolution_* fields into a nested resolution dict.
Returns None when the judgment is not a contradiction or the LLM did not
populate resolution fields.
"""
action = judgment.get("resolution_action")
if not judgment.get("is_contradiction") or not action:
return None
if action not in _VALID_ACTIONS:
logger.warning("Invalid resolution_action from LLM: %r — dropping", action)
return None
resolution: dict = {
"action": action,
"justification": judgment.get("resolution_justification") or "",
}
if action == "merge":
merged = judgment.get("resolution_merged_content")
if merged:
resolution["merged_content"] = merged
return resolution
def _judgment_to_record(judgment: dict, new_item_id: str) -> dict:
"""Convert one Haiku judgment row into the contradiction record shape."""
severity = judgment.get("severity")
if severity not in _VALID_SEVERITIES:
severity = None
record: dict = {
"item_a_id": new_item_id,
"item_b_id": judgment["candidate_id"],
"explanation": judgment.get("explanation", ""),
"severity": severity,
}
resolution = _build_resolution(judgment)
if resolution is not None:
record["suggested_resolution"] = resolution
return record
def find_and_judge(
extractor: StructuredExtractor,
new_item: dict,
repo: KnowledgeRepository,
max_candidates: int = DEFAULT_CANDIDATE_LIMIT,
) -> list[dict]:
"""Topic + judgment + resolution in one Haiku call.
Loads same-domain candidates from SQL, then asks Haiku to judge each one
in a single batched structured-output call. Returns contradiction records
ready for persistence (item_a_id, item_b_id, explanation, severity,
optional suggested_resolution dict).
Empty corpus → returns immediately without an LLM call (cost guard).
Hallucinated candidate_ids returned by Haiku are dropped.
"""
candidates = find_candidates(repo, new_item, max_candidates)
if not candidates:
return []
if len(candidates) > 50:
logger.warning(
"Contradiction candidate batch is large (%d items); prompt may exceed model context window. "
"Consider domain sharding (V2 TODO).",
len(candidates),
)
prompt = BATCH_CONTRADICTION_PROMPT.format(
new_id=new_item.get("id", ""),
new_domain=new_item.get("domain") or "unspecified",
new_title=new_item.get("title", ""),
new_content=new_item.get("content", ""),
candidates_block=format_candidates_block(candidates),
)
try:
result = extractor.extract_json(
prompt=prompt,
max_tokens=4096,
json_schema=BATCH_CONTRADICTION_SCHEMA,
schema_name="batch_contradiction",
)
except LLMError as e:
logger.error("Batch contradiction judgment failed: %s", e)
return []
judgments = result.get("judgments", [])
valid_ids = {c["id"] for c in candidates}
new_item_id = new_item.get("id", "")
records: list[dict] = []
for j in judgments:
cid = j.get("candidate_id")
if cid not in valid_ids:
logger.warning(
"Dropping judgment for unknown candidate_id %r (not in input set)",
cid,
)
continue
if not j.get("is_contradiction"):
continue
records.append(_judgment_to_record(j, new_item_id))
return records
def detect_and_record(
extractor: StructuredExtractor,
new_item: dict,
repo: KnowledgeRepository,
max_candidates: int = DEFAULT_CANDIDATE_LIMIT,
) -> list[str]:
"""Run find_and_judge and persist the resulting contradictions.
Returns the list of newly created contradiction IDs.
"""
records = find_and_judge(extractor, new_item, repo, max_candidates)
contradiction_ids: list[str] = []
for r in records:
cid = repo.create_contradiction(
item_a_id=r["item_a_id"],
item_b_id=r["item_b_id"],
explanation=r["explanation"],
severity=r.get("severity"),
suggested_resolution=r.get("suggested_resolution"),
)
contradiction_ids.append(cid)
logger.info(
"Contradiction recorded: %s vs %s (severity=%s)",
r["item_a_id"], r["item_b_id"], r.get("severity"),
)
return contradiction_ids
# ---------------------------------------------------------------------------
# Legacy single-pair helpers — kept so callers that want a one-off pairwise
# judgment (e.g. tests, ad-hoc tooling) can still use them. NOT used by the
# detector pipeline.
# ---------------------------------------------------------------------------
def check_contradiction(
extractor: StructuredExtractor,
item_a: dict,
item_b: dict,
) -> dict:
"""Single-pair judgment via the batched API. Always returns a dict with
``contradicts`` (bool), ``explanation`` (str), and optionally
``severity`` and ``suggested_resolution`` (dict).
"""
prompt = BATCH_CONTRADICTION_PROMPT.format(
new_id=item_a.get("id", "new"),
new_domain=item_a.get("domain") or "unspecified",
new_title=item_a.get("title", ""),
new_content=item_a.get("content", ""),
candidates_block=format_candidates_block([item_b]),
)
try:
result = extractor.extract_json(
prompt=prompt,
max_tokens=1024,
json_schema=BATCH_CONTRADICTION_SCHEMA,
schema_name="batch_contradiction",
)
except LLMError as e:
logger.error("Pair contradiction check failed: %s", e)
return {"contradicts": False, "explanation": f"Check failed: {e}"}
judgments = result.get("judgments", [])
if not judgments:
return {"contradicts": False, "explanation": "No judgment returned"}
j = judgments[0]
out: dict = {
"contradicts": bool(j.get("is_contradiction")),
"explanation": j.get("explanation", ""),
}
severity = j.get("severity")
if severity in _VALID_SEVERITIES:
out["severity"] = severity
resolution = _build_resolution(j)
if resolution is not None:
out["suggested_resolution"] = resolution
return out
def check_contradictions(
extractor: StructuredExtractor,
new_item: dict,
repo: KnowledgeRepository,
max_candidates: int = DEFAULT_CANDIDATE_LIMIT,
) -> list[dict]:
"""Backwards-compatible alias for find_and_judge."""
return find_and_judge(extractor, new_item, repo, max_candidates)