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.
- rootmemory/__init__.py +84 -0
- rootmemory/api/__init__.py +25 -0
- rootmemory/api/agents.py +38 -0
- rootmemory/api/beliefs.py +66 -0
- rootmemory/api/claims.py +87 -0
- rootmemory/api/deps.py +21 -0
- rootmemory/api/errors.py +36 -0
- rootmemory/api/graph.py +32 -0
- rootmemory/api/invalidation.py +47 -0
- rootmemory/api/observations.py +46 -0
- rootmemory/api/promotion.py +37 -0
- rootmemory/api/provenance.py +88 -0
- rootmemory/api/security.py +53 -0
- rootmemory/cli.py +96 -0
- rootmemory/client.py +219 -0
- rootmemory/config.py +64 -0
- rootmemory/db/__init__.py +0 -0
- rootmemory/db/base.py +42 -0
- rootmemory/db/migrations/env.py +59 -0
- rootmemory/db/migrations/script.py.mako +26 -0
- rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
- rootmemory/db/session.py +138 -0
- rootmemory/errors.py +27 -0
- rootmemory/integrations/__init__.py +16 -0
- rootmemory/integrations/async_client.py +268 -0
- rootmemory/integrations/langchain_tools.py +237 -0
- rootmemory/integrations/langgraph_memory.py +179 -0
- rootmemory/integrations/langgraph_store.py +402 -0
- rootmemory/logging_config.py +30 -0
- rootmemory/main.py +133 -0
- rootmemory/models/__init__.py +22 -0
- rootmemory/models/agent.py +30 -0
- rootmemory/models/audit.py +51 -0
- rootmemory/models/belief.py +39 -0
- rootmemory/models/claim.py +52 -0
- rootmemory/models/decision.py +38 -0
- rootmemory/models/edge.py +43 -0
- rootmemory/models/enums.py +104 -0
- rootmemory/models/node_ref.py +42 -0
- rootmemory/models/observation.py +51 -0
- rootmemory/py.typed +0 -0
- rootmemory/repositories/__init__.py +19 -0
- rootmemory/repositories/agent_repo.py +44 -0
- rootmemory/repositories/audit_repo.py +43 -0
- rootmemory/repositories/belief_repo.py +63 -0
- rootmemory/repositories/claim_repo.py +102 -0
- rootmemory/repositories/decision_repo.py +76 -0
- rootmemory/repositories/edge_repo.py +77 -0
- rootmemory/repositories/observation_repo.py +76 -0
- rootmemory/schemas/__init__.py +52 -0
- rootmemory/schemas/agent.py +26 -0
- rootmemory/schemas/belief.py +49 -0
- rootmemory/schemas/claim.py +54 -0
- rootmemory/schemas/common.py +27 -0
- rootmemory/schemas/observation.py +69 -0
- rootmemory/schemas/promotion.py +50 -0
- rootmemory/schemas/provenance.py +51 -0
- rootmemory/services/__init__.py +35 -0
- rootmemory/services/belief_service.py +161 -0
- rootmemory/services/contradiction_service.py +148 -0
- rootmemory/services/decision_service.py +65 -0
- rootmemory/services/graph_service.py +316 -0
- rootmemory/services/independence_service.py +159 -0
- rootmemory/services/invalidation_service.py +164 -0
- rootmemory/services/normalization_service.py +234 -0
- rootmemory/services/promotion_service.py +318 -0
- rootmemory/services/provenance_service.py +282 -0
- rootmemory/services/scoring_service.py +53 -0
- rootmemory/static/index.html +866 -0
- rootmemory-0.1.0.dist-info/METADATA +266 -0
- rootmemory-0.1.0.dist-info/RECORD +75 -0
- rootmemory-0.1.0.dist-info/WHEEL +5 -0
- rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
- rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|