rootmemory 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. rootmemory/__init__.py +84 -0
  2. rootmemory/api/__init__.py +25 -0
  3. rootmemory/api/agents.py +38 -0
  4. rootmemory/api/beliefs.py +66 -0
  5. rootmemory/api/claims.py +87 -0
  6. rootmemory/api/deps.py +21 -0
  7. rootmemory/api/errors.py +36 -0
  8. rootmemory/api/graph.py +32 -0
  9. rootmemory/api/invalidation.py +47 -0
  10. rootmemory/api/observations.py +46 -0
  11. rootmemory/api/promotion.py +37 -0
  12. rootmemory/api/provenance.py +88 -0
  13. rootmemory/api/security.py +53 -0
  14. rootmemory/cli.py +96 -0
  15. rootmemory/client.py +219 -0
  16. rootmemory/config.py +64 -0
  17. rootmemory/db/__init__.py +0 -0
  18. rootmemory/db/base.py +42 -0
  19. rootmemory/db/migrations/env.py +59 -0
  20. rootmemory/db/migrations/script.py.mako +26 -0
  21. rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
  22. rootmemory/db/session.py +138 -0
  23. rootmemory/errors.py +27 -0
  24. rootmemory/integrations/__init__.py +16 -0
  25. rootmemory/integrations/async_client.py +268 -0
  26. rootmemory/integrations/langchain_tools.py +237 -0
  27. rootmemory/integrations/langgraph_memory.py +179 -0
  28. rootmemory/integrations/langgraph_store.py +402 -0
  29. rootmemory/logging_config.py +30 -0
  30. rootmemory/main.py +133 -0
  31. rootmemory/models/__init__.py +22 -0
  32. rootmemory/models/agent.py +30 -0
  33. rootmemory/models/audit.py +51 -0
  34. rootmemory/models/belief.py +39 -0
  35. rootmemory/models/claim.py +52 -0
  36. rootmemory/models/decision.py +38 -0
  37. rootmemory/models/edge.py +43 -0
  38. rootmemory/models/enums.py +104 -0
  39. rootmemory/models/node_ref.py +42 -0
  40. rootmemory/models/observation.py +51 -0
  41. rootmemory/py.typed +0 -0
  42. rootmemory/repositories/__init__.py +19 -0
  43. rootmemory/repositories/agent_repo.py +44 -0
  44. rootmemory/repositories/audit_repo.py +43 -0
  45. rootmemory/repositories/belief_repo.py +63 -0
  46. rootmemory/repositories/claim_repo.py +102 -0
  47. rootmemory/repositories/decision_repo.py +76 -0
  48. rootmemory/repositories/edge_repo.py +77 -0
  49. rootmemory/repositories/observation_repo.py +76 -0
  50. rootmemory/schemas/__init__.py +52 -0
  51. rootmemory/schemas/agent.py +26 -0
  52. rootmemory/schemas/belief.py +49 -0
  53. rootmemory/schemas/claim.py +54 -0
  54. rootmemory/schemas/common.py +27 -0
  55. rootmemory/schemas/observation.py +69 -0
  56. rootmemory/schemas/promotion.py +50 -0
  57. rootmemory/schemas/provenance.py +51 -0
  58. rootmemory/services/__init__.py +35 -0
  59. rootmemory/services/belief_service.py +161 -0
  60. rootmemory/services/contradiction_service.py +148 -0
  61. rootmemory/services/decision_service.py +65 -0
  62. rootmemory/services/graph_service.py +316 -0
  63. rootmemory/services/independence_service.py +159 -0
  64. rootmemory/services/invalidation_service.py +164 -0
  65. rootmemory/services/normalization_service.py +234 -0
  66. rootmemory/services/promotion_service.py +318 -0
  67. rootmemory/services/provenance_service.py +282 -0
  68. rootmemory/services/scoring_service.py +53 -0
  69. rootmemory/static/index.html +866 -0
  70. rootmemory-0.1.0.dist-info/METADATA +266 -0
  71. rootmemory-0.1.0.dist-info/RECORD +75 -0
  72. rootmemory-0.1.0.dist-info/WHEEL +5 -0
  73. rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
  74. rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
  75. rootmemory-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,159 @@
1
+ """IndependenceService: how many *independent* sources really back a set of beliefs.
2
+
3
+ This is the service that answers the question the whole project exists for:
4
+
5
+ agreeing agents != independent evidence sources
6
+ supporting beliefs != independent evidence sources
7
+
8
+ Both numbers are always computed and always reported (spec section 64).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from collections.abc import Iterable, Sequence
15
+ from dataclasses import dataclass, field
16
+
17
+ from sqlalchemy.orm import Session
18
+
19
+ from rootmemory.models.belief import Belief
20
+ from rootmemory.models.enums import EpistemicStatus
21
+ from rootmemory.models.node_ref import NodeRef
22
+ from rootmemory.models.observation import Observation
23
+ from rootmemory.repositories.observation_repo import ObservationRepository
24
+ from rootmemory.services.provenance_service import DEFAULT_ROOT_STATUSES, ProvenanceService
25
+ from rootmemory.services.scoring_service import (
26
+ aggregate_confidence_by_root,
27
+ naive_average_confidence,
28
+ )
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class IndependenceReport:
33
+ """Independence analysis for one set of beliefs."""
34
+
35
+ belief_ids: list[uuid.UUID] = field(default_factory=list)
36
+ agreeing_agent_ids: set[uuid.UUID] = field(default_factory=set)
37
+ #: independence key -> the beliefs that rest on that source
38
+ beliefs_by_source: dict[str, list[uuid.UUID]] = field(default_factory=dict)
39
+ #: independence key -> the observations grouped under it
40
+ observations_by_source: dict[str, list[uuid.UUID]] = field(default_factory=dict)
41
+ #: beliefs whose ancestry contains no currently valid observation
42
+ unsupported_belief_ids: list[uuid.UUID] = field(default_factory=list)
43
+ aggregate_confidence: float = 0.0
44
+ naive_confidence: float = 0.0
45
+
46
+ @property
47
+ def belief_count(self) -> int:
48
+ """How many beliefs assert this - the number a naive system counts."""
49
+ return len(self.belief_ids)
50
+
51
+ @property
52
+ def agreeing_agent_count(self) -> int:
53
+ """How many distinct agents assert this."""
54
+ return len(self.agreeing_agent_ids)
55
+
56
+ @property
57
+ def independent_source_count(self) -> int:
58
+ """How many genuinely independent sources back this."""
59
+ return len(self.beliefs_by_source)
60
+
61
+ @property
62
+ def root_observation_ids(self) -> list[uuid.UUID]:
63
+ flat: list[uuid.UUID] = []
64
+ for observation_ids in self.observations_by_source.values():
65
+ flat.extend(observation_ids)
66
+ return flat
67
+
68
+ @property
69
+ def shares_single_source(self) -> bool:
70
+ """True when several beliefs all descend from one source - the failure
71
+ mode this system exists to catch."""
72
+ return self.belief_count > 1 and self.independent_source_count == 1
73
+
74
+ def as_dict(self) -> dict[str, object]:
75
+ return {
76
+ "supporting_belief_count": self.belief_count,
77
+ "agreeing_agent_count": self.agreeing_agent_count,
78
+ "independent_source_count": self.independent_source_count,
79
+ "independent_sources": sorted(self.beliefs_by_source),
80
+ "root_observation_ids": [str(o) for o in self.root_observation_ids],
81
+ "unsupported_belief_ids": [str(b) for b in self.unsupported_belief_ids],
82
+ "aggregate_confidence": self.aggregate_confidence,
83
+ "naive_average_confidence": self.naive_confidence,
84
+ }
85
+
86
+
87
+ class IndependenceService:
88
+ """Groups beliefs by the independent source they ultimately rest on."""
89
+
90
+ def __init__(self, session: Session) -> None:
91
+ self.session = session
92
+ self.provenance = ProvenanceService(session)
93
+ self.observations = ObservationRepository(session)
94
+
95
+ def analyze(
96
+ self,
97
+ beliefs: Sequence[Belief],
98
+ allowed_statuses: Iterable[str] = DEFAULT_ROOT_STATUSES,
99
+ ignore_retracted: bool = True,
100
+ ) -> IndependenceReport:
101
+ """Build the independence report for a set of beliefs.
102
+
103
+ Retracted beliefs are ignored by default: an agent that has withdrawn a
104
+ belief is no longer asserting it (spec section 21).
105
+ """
106
+ considered = [
107
+ b
108
+ for b in beliefs
109
+ if not (ignore_retracted and b.epistemic_status == EpistemicStatus.RETRACTED)
110
+ ]
111
+
112
+ report = IndependenceReport(
113
+ belief_ids=[b.id for b in considered],
114
+ agreeing_agent_ids={b.agent_id for b in considered},
115
+ )
116
+ if not considered:
117
+ return report
118
+
119
+ roots_by_belief = {
120
+ belief.id: self.provenance.find_evidence_roots(
121
+ NodeRef.belief(belief.id), allowed_statuses
122
+ ).roots
123
+ for belief in considered
124
+ }
125
+
126
+ all_root_ids = {root for roots in roots_by_belief.values() for root in roots}
127
+ observations: dict[uuid.UUID, Observation] = {
128
+ o.id: o for o in self.observations.get_many(all_root_ids)
129
+ }
130
+
131
+ for belief in considered:
132
+ roots = roots_by_belief[belief.id]
133
+ if not roots:
134
+ report.unsupported_belief_ids.append(belief.id)
135
+ continue
136
+ for root_id in roots:
137
+ observation = observations.get(root_id)
138
+ if observation is None:
139
+ continue
140
+ # Observations copied from a common origin share a family id and
141
+ # therefore collapse into ONE independent source (spec section 20).
142
+ key = observation.independence_key
143
+ report.beliefs_by_source.setdefault(key, [])
144
+ if belief.id not in report.beliefs_by_source[key]:
145
+ report.beliefs_by_source[key].append(belief.id)
146
+ report.observations_by_source.setdefault(key, [])
147
+ if root_id not in report.observations_by_source[key]:
148
+ report.observations_by_source[key].append(root_id)
149
+
150
+ confidence_by_belief = {b.id: b.confidence for b in considered}
151
+ report.aggregate_confidence = aggregate_confidence_by_root(
152
+ report.beliefs_by_source, confidence_by_belief
153
+ )
154
+ report.naive_confidence = naive_average_confidence([b.confidence for b in considered])
155
+ return report
156
+
157
+ def independent_evidence_count(self, beliefs: Sequence[Belief]) -> int:
158
+ """Convenience wrapper: just the independent source count."""
159
+ return self.analyze(beliefs).independent_source_count
@@ -0,0 +1,164 @@
1
+ """InvalidationService: cascade repair when a source turns out to be wrong.
2
+
3
+ Observations are never edited or deleted. When one is retracted, its validity
4
+ flips and this service walks forward through everything that was built on it:
5
+
6
+ observation -> beliefs -> claims -> decisions
7
+
8
+ Beliefs that still rest on other valid evidence are downgraded to uncertain;
9
+ beliefs left with nothing are retracted. Affected claims are re-run through the
10
+ promotion gate, and decisions that depended on them are flagged for review.
11
+ The whole traversal is written to a RepairReport (spec sections 24-26).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+
19
+ from sqlalchemy.orm import Session
20
+
21
+ from rootmemory.errors import NodeNotFoundError
22
+ from rootmemory.models.audit import RepairReport
23
+ from rootmemory.models.enums import (
24
+ DecisionStatus,
25
+ EpistemicStatus,
26
+ NodeType,
27
+ ValidityStatus,
28
+ Visibility,
29
+ )
30
+ from rootmemory.models.node_ref import NodeRef
31
+ from rootmemory.repositories.audit_repo import AuditRepository
32
+ from rootmemory.repositories.belief_repo import BeliefRepository
33
+ from rootmemory.repositories.claim_repo import ClaimRepository
34
+ from rootmemory.repositories.decision_repo import DecisionRepository
35
+ from rootmemory.repositories.observation_repo import ObservationRepository
36
+ from rootmemory.services.promotion_service import PromotionPolicy, PromotionService
37
+ from rootmemory.services.provenance_service import ProvenanceService
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class RepairSummary:
42
+ """Human/JSON friendly view of one repair pass."""
43
+
44
+ report_id: uuid.UUID
45
+ trigger_node_id: uuid.UUID
46
+ trigger_reason: str
47
+ affected_beliefs: list[uuid.UUID] = field(default_factory=list)
48
+ affected_claims: list[uuid.UUID] = field(default_factory=list)
49
+ affected_decisions: list[uuid.UUID] = field(default_factory=list)
50
+ details: dict = field(default_factory=dict)
51
+
52
+ def as_dict(self) -> dict[str, object]:
53
+ return {
54
+ "repair_report_id": str(self.report_id),
55
+ "trigger_node_id": str(self.trigger_node_id),
56
+ "trigger_reason": self.trigger_reason,
57
+ "affected_beliefs": [str(b) for b in self.affected_beliefs],
58
+ "affected_claims": [str(c) for c in self.affected_claims],
59
+ "affected_decisions": [str(d) for d in self.affected_decisions],
60
+ "details": self.details,
61
+ }
62
+
63
+
64
+ class InvalidationService:
65
+ def __init__(self, session: Session, policy: PromotionPolicy | None = None) -> None:
66
+ self.session = session
67
+ self.observations = ObservationRepository(session)
68
+ self.beliefs = BeliefRepository(session)
69
+ self.claims = ClaimRepository(session)
70
+ self.decisions = DecisionRepository(session)
71
+ self.audits = AuditRepository(session)
72
+ self.provenance = ProvenanceService(session)
73
+ self.promotion = PromotionService(session, policy)
74
+
75
+ def invalidate_observation(
76
+ self,
77
+ observation_id: uuid.UUID,
78
+ reason: str = "",
79
+ status: ValidityStatus = ValidityStatus.INVALID,
80
+ ) -> RepairSummary:
81
+ """Retract a source and repair everything downstream of it."""
82
+ observation = self.observations.get(observation_id)
83
+ if observation is None:
84
+ raise NodeNotFoundError(f"observation {observation_id} does not exist")
85
+
86
+ self.observations.set_validity(observation, status, reason)
87
+
88
+ cascade = self.provenance.cascade_descendants(NodeRef.observation(observation_id))
89
+ details: dict[str, dict] = {"beliefs": {}, "claims": {}, "decisions": {}}
90
+
91
+ # 1. beliefs -------------------------------------------------------
92
+ for belief in self.beliefs.get_many(cascade.beliefs):
93
+ roots = self.provenance.find_evidence_roots(NodeRef.belief(belief.id))
94
+ previous = belief.epistemic_status
95
+ if not roots.roots:
96
+ belief.epistemic_status = str(EpistemicStatus.RETRACTED)
97
+ belief.visibility = str(Visibility.PRIVATE)
98
+ elif belief.epistemic_status != EpistemicStatus.RETRACTED:
99
+ belief.epistemic_status = str(EpistemicStatus.UNCERTAIN)
100
+ details["beliefs"][str(belief.id)] = {
101
+ "previous_status": str(previous),
102
+ "status": str(belief.epistemic_status),
103
+ "remaining_independent_roots": len(roots.roots),
104
+ }
105
+ self.session.flush()
106
+
107
+ # 2. claims --------------------------------------------------------
108
+ for claim_id in cascade.claims:
109
+ claim = self.claims.get(claim_id)
110
+ if claim is None:
111
+ continue
112
+ previous_status = str(claim.status)
113
+ evaluation = self.promotion.recompute(claim_id)
114
+ details["claims"][str(claim_id)] = {
115
+ "previous_status": previous_status,
116
+ "status": str(claim.status),
117
+ "independent_support_count": evaluation.independent_support_count,
118
+ "aggregate_confidence": evaluation.aggregate_confidence,
119
+ "decision": "promote" if evaluation.promoted else "reject",
120
+ }
121
+
122
+ # 3. decisions -----------------------------------------------------
123
+ decision_ids = list(cascade.decisions)
124
+ for dependent in self.decisions.decisions_for_claims(cascade.claims):
125
+ if dependent.id not in decision_ids:
126
+ decision_ids.append(dependent.id)
127
+
128
+ for decision_id in decision_ids:
129
+ affected = self.decisions.get(decision_id)
130
+ if affected is None:
131
+ continue
132
+ previous_status = str(affected.status)
133
+ if affected.status == DecisionStatus.ACTIVE:
134
+ self.decisions.set_status(affected, DecisionStatus.NEEDS_REVIEW)
135
+ details["decisions"][str(decision_id)] = {
136
+ "previous_status": previous_status,
137
+ "status": str(affected.status),
138
+ }
139
+
140
+ report = self.audits.record_repair(
141
+ trigger_node_id=observation_id,
142
+ trigger_node_type=str(NodeType.OBSERVATION),
143
+ trigger_reason=reason or "source retracted",
144
+ affected_beliefs=[str(b) for b in cascade.beliefs],
145
+ affected_claims=[str(c) for c in cascade.claims],
146
+ affected_decisions=[str(d) for d in decision_ids],
147
+ details=details,
148
+ )
149
+
150
+ return RepairSummary(
151
+ report_id=report.id,
152
+ trigger_node_id=observation_id,
153
+ trigger_reason=report.trigger_reason,
154
+ affected_beliefs=list(cascade.beliefs),
155
+ affected_claims=list(cascade.claims),
156
+ affected_decisions=decision_ids,
157
+ details=details,
158
+ )
159
+
160
+ def get_report(self, report_id: uuid.UUID) -> RepairReport:
161
+ report = self.audits.get_repair(report_id)
162
+ if report is None:
163
+ raise NodeNotFoundError(f"repair report {report_id} does not exist")
164
+ return report
@@ -0,0 +1,234 @@
1
+ """Claim normalization: deciding which proposition a belief is about.
2
+
3
+ This is the gap between the demo and real text. "The deadline is Oct 15",
4
+ "delivery is scheduled for October 15" and "Oct 15 is confirmed" are the same
5
+ proposition, but unless something maps them onto one ``claim_key`` they never
6
+ meet, every claim looks single-source, and the gate silently rejects everything.
7
+
8
+ Three strategies, chosen by ``CLAIM_NORMALIZATION``:
9
+
10
+ ``manual`` the caller always supplies the key (V1 behaviour, the default)
11
+ ``deterministic`` derive a slug from the text - no model, fully reproducible
12
+ ``llm`` ask a model to match an existing claim, falling back to the
13
+ deterministic slug when it declines or is unavailable
14
+
15
+ **The boundary that matters.** A normalizer only decides *which claim a belief
16
+ is filed under*. It never touches provenance, independence counting, confidence
17
+ aggregation or the promotion gate - those stay deterministic (spec section 47).
18
+ The worst a bad normalizer can do is file a belief under the wrong claim, which
19
+ is visible and repairable. It can never make one source look like two.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ from collections.abc import Sequence
27
+ from typing import Protocol
28
+
29
+ from rootmemory.config import Settings, get_settings
30
+ from rootmemory.logging_config import get_logger
31
+
32
+ logger = get_logger("rootmemory.normalization")
33
+
34
+ #: Words carrying no propositional content, dropped when building a slug.
35
+ #:
36
+ #: Negation words are deliberately NOT here. "the deadline is not October 15"
37
+ #: and "the deadline is October 15" are opposite claims, and dropping "not"
38
+ #: would collapse them onto one.
39
+ STOPWORDS = frozenset(
40
+ """a an the is are was were be been being of to in on at for from by with
41
+ that this these those it its as and or but if then than so such may might
42
+ will would can could should must have has had do does did we they
43
+ he she you i our their his her your my""".split()
44
+ )
45
+
46
+ MAX_SLUG_WORDS = 6
47
+ MAX_SLUG_LENGTH = 120
48
+
49
+
50
+ class ClaimNormalizer(Protocol):
51
+ """Maps belief text onto a claim key."""
52
+
53
+ def normalize(self, text: str, existing_keys: Sequence[str] = ()) -> str:
54
+ """Return the claim key this text belongs under."""
55
+ ...
56
+
57
+
58
+ def slugify(text: str) -> str:
59
+ """A stable, readable key derived from the text alone.
60
+
61
+ Deterministic and dependency-free, so the same sentence always produces the
62
+ same key on every machine and in every process.
63
+ """
64
+ words = re.findall(r"[a-z0-9]+", text.lower())
65
+ meaningful = [w for w in words if w not in STOPWORDS] or words
66
+ return "_".join(meaningful[:MAX_SLUG_WORDS])[:MAX_SLUG_LENGTH] or "unnamed_claim"
67
+
68
+
69
+ class ManualNormalizer:
70
+ """No normalization: the caller is responsible for the key."""
71
+
72
+ def normalize(self, text: str, existing_keys: Sequence[str] = ()) -> str:
73
+ return slugify(text)
74
+
75
+
76
+ #: Prefixes that flip a word's meaning. "stable" and "unstable" share almost
77
+ #: all their letters and all their neighbouring words, so plain overlap scoring
78
+ #: happily merges a proposition with its own negation.
79
+ NEGATION_PREFIXES = ("un", "in", "im", "il", "ir", "non", "dis", "anti")
80
+
81
+ #: Standalone words that flip a statement's polarity.
82
+ NEGATION_WORDS = frozenset({"not", "no", "never", "without", "denies", "denied", "false"})
83
+
84
+
85
+ def contradicts(left: set[str], right: set[str]) -> bool:
86
+ """True when two word sets look like opposite claims rather than the same one.
87
+
88
+ Two checks: a bare negation word present on one side only, and a word on one
89
+ side that is a negated form of a word on the other ("stable"/"unstable").
90
+ """
91
+ if bool(left & NEGATION_WORDS) != bool(right & NEGATION_WORDS):
92
+ return True
93
+
94
+ for a, b in ((left, right), (right, left)):
95
+ extra = a - b
96
+ # "non compliant" tokenizes to a bare prefix beside the word it negates.
97
+ if extra & set(NEGATION_PREFIXES) and (a - extra - {"non"}) & b:
98
+ return True
99
+ for word in extra:
100
+ for prefix in NEGATION_PREFIXES:
101
+ if word.startswith(prefix) and word[len(prefix) :] in b:
102
+ return True
103
+ return False
104
+
105
+
106
+ class DeterministicNormalizer:
107
+ """Slug-based matching with a cheap overlap check against known claims.
108
+
109
+ Not clever, but honest and free: two sentences sharing most of their
110
+ meaningful words land on the same claim - unless one is the negation of the
111
+ other, which overlap alone cannot see.
112
+
113
+ Its weakness is recall, not safety: it merges rephrasings only when the
114
+ wording is close, so varied phrasing starts separate claims. That is the
115
+ failure an LLM normalizer is for.
116
+ """
117
+
118
+ #: Share of meaningful words that must overlap to reuse an existing key.
119
+ similarity_threshold = 0.6
120
+
121
+ def normalize(self, text: str, existing_keys: Sequence[str] = ()) -> str:
122
+ candidate = slugify(text)
123
+ candidate_words = set(candidate.split("_"))
124
+ if not candidate_words:
125
+ return candidate
126
+
127
+ best_key, best_score = candidate, 0.0
128
+ for key in existing_keys:
129
+ key_words = set(key.split("_"))
130
+ if not key_words:
131
+ continue
132
+ # Never merge a claim with its own negation: filing a contradicting
133
+ # belief as support would corrupt both sides of the claim.
134
+ if contradicts(candidate_words, key_words):
135
+ continue
136
+ overlap = len(candidate_words & key_words) / len(candidate_words | key_words)
137
+ if overlap > best_score:
138
+ best_key, best_score = key, overlap
139
+
140
+ return best_key if best_score >= self.similarity_threshold else candidate
141
+
142
+
143
+ class LLMClaimNormalizer:
144
+ """Ask a model which existing claim a belief is about.
145
+
146
+ The model is given the existing keys and may only choose one of them or
147
+ decline. It never invents the key itself: a decline falls through to the
148
+ deterministic slug, so a hallucinated or malformed answer cannot create
149
+ junk claims.
150
+ """
151
+
152
+ system_prompt = (
153
+ "You map a statement onto the proposition it asserts.\n"
154
+ "You are given existing claim keys. If the statement asserts the SAME "
155
+ "proposition as one of them, return that key exactly.\n"
156
+ "Two statements match only if one being true makes the other true. "
157
+ "Statements about the same topic that assert different things do NOT match "
158
+ "-- 'the supplier is stable' and 'the supplier is unstable' are different "
159
+ "propositions.\n"
160
+ 'Reply with JSON only: {"key": "<existing key>"} or {"key": null}.'
161
+ )
162
+
163
+ def __init__(
164
+ self,
165
+ model: str,
166
+ api_key: str,
167
+ base_url: str | None = None,
168
+ fallback: ClaimNormalizer | None = None,
169
+ ) -> None:
170
+ self.model = model
171
+ self.api_key = api_key
172
+ self.base_url = base_url
173
+ self.fallback = fallback or DeterministicNormalizer()
174
+
175
+ def normalize(self, text: str, existing_keys: Sequence[str] = ()) -> str:
176
+ if not existing_keys:
177
+ return self.fallback.normalize(text, existing_keys)
178
+ try:
179
+ chosen = self._ask(text, list(existing_keys))
180
+ except Exception as exc: # noqa: BLE001 - never fail a write on the model
181
+ logger.warning("normalizer_unavailable", error=str(exc))
182
+ return self.fallback.normalize(text, existing_keys)
183
+
184
+ # Only an exact match on an offered key is accepted, so a hallucinated
185
+ # or malformed answer can never create a junk claim.
186
+ if chosen is not None and chosen in existing_keys:
187
+ return chosen
188
+ return self.fallback.normalize(text, existing_keys)
189
+
190
+ def _ask(self, text: str, existing_keys: list[str]) -> str | None:
191
+ from openai import OpenAI # noqa: PLC0415 - optional dependency
192
+
193
+ client = OpenAI(api_key=self.api_key, base_url=self.base_url)
194
+ response = client.chat.completions.create(
195
+ model=self.model,
196
+ messages=[
197
+ {"role": "system", "content": self.system_prompt},
198
+ {
199
+ "role": "user",
200
+ "content": json.dumps(
201
+ {"statement": text, "existing_keys": existing_keys}
202
+ ),
203
+ },
204
+ ],
205
+ response_format={"type": "json_object"},
206
+ temperature=0,
207
+ )
208
+ payload = json.loads(response.choices[0].message.content or "{}")
209
+ key = payload.get("key")
210
+ return key if isinstance(key, str) else None
211
+
212
+
213
+ def build_normalizer(settings: Settings | None = None) -> ClaimNormalizer:
214
+ """Pick a normalizer from configuration, degrading safely."""
215
+ settings = settings or get_settings()
216
+ mode = settings.claim_normalization
217
+
218
+ if mode == "llm":
219
+ if settings.openai_api_key and settings.openai_model:
220
+ return LLMClaimNormalizer(
221
+ model=settings.openai_model,
222
+ api_key=settings.openai_api_key,
223
+ base_url=settings.openai_base_url,
224
+ )
225
+ logger.warning(
226
+ "llm_normalizer_unconfigured",
227
+ detail="CLAIM_NORMALIZATION=llm needs OPENAI_API_KEY and OPENAI_MODEL; "
228
+ "falling back to deterministic",
229
+ )
230
+ return DeterministicNormalizer()
231
+
232
+ if mode == "deterministic":
233
+ return DeterministicNormalizer()
234
+ return ManualNormalizer()