crprotocol 2.0.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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Attribution Scorer — map claims to envelope facts (§7.14.3).
|
|
4
|
+
|
|
5
|
+
For each factual claim in the LLM output, scores how likely it came from
|
|
6
|
+
each envelope fact vs. parametric knowledge. Uses two signals:
|
|
7
|
+
|
|
8
|
+
1. **Semantic similarity**: When available, uses dense sentence-transformer
|
|
9
|
+
embeddings (all-MiniLM-L6-v2, 384-dim) for genuine semantic comparison.
|
|
10
|
+
Falls back to hash-projected bag-of-words when the model is unavailable.
|
|
11
|
+
2. **Lexical overlap**: Token-level Jaccard overlap — fast, complements
|
|
12
|
+
semantic similarity for surface-level attribution.
|
|
13
|
+
|
|
14
|
+
The composite score is a weighted blend:
|
|
15
|
+
composite = semantic_weight × semantic + lexical_weight × lexical
|
|
16
|
+
|
|
17
|
+
Claims with no fact scoring above ``similarity_threshold`` are flagged
|
|
18
|
+
as PARAMETRIC (likely from model training data). Claims with a top score
|
|
19
|
+
between ``mixed_threshold`` and ``similarity_threshold`` are MIXED.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import logging
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from crp.envelope.packer import PackedFact
|
|
32
|
+
|
|
33
|
+
from ._embeddings import cosine_similarity as _dense_cosine
|
|
34
|
+
from ._embeddings import encode_texts
|
|
35
|
+
from ._types import (
|
|
36
|
+
AttributionType,
|
|
37
|
+
ClaimAttribution,
|
|
38
|
+
ClaimType,
|
|
39
|
+
FactScore,
|
|
40
|
+
ProvenanceConfig,
|
|
41
|
+
)
|
|
42
|
+
from .claim_detector import DetectedClaim
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Internal: lightweight text vectorisation (mirrors crp.envelope.decomposer)
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_STOP = frozenset(
|
|
52
|
+
[
|
|
53
|
+
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to",
|
|
54
|
+
"for", "of", "is", "it", "its", "are", "was", "were", "be",
|
|
55
|
+
"been", "being", "have", "has", "had", "do", "does", "did",
|
|
56
|
+
"will", "would", "shall", "should", "may", "might", "can",
|
|
57
|
+
"could", "this", "that", "these", "those", "i", "we", "you",
|
|
58
|
+
"he", "she", "they", "me", "us", "him", "her", "them", "my",
|
|
59
|
+
"our", "your", "his", "their", "with", "from", "by", "as",
|
|
60
|
+
"not", "no",
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_EMBED_DIM = 256
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tokenize(text: str) -> list[str]:
|
|
68
|
+
"""Lower-case word tokeniser (alphanum only, stopwords removed)."""
|
|
69
|
+
return [w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in _STOP]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _bag_vector(tokens: Sequence[str], dim: int = _EMBED_DIM) -> list[float]:
|
|
73
|
+
"""Sparse bag-of-words vector with hash projection, L2-normalised."""
|
|
74
|
+
vec = [0.0] * dim
|
|
75
|
+
for tok in tokens:
|
|
76
|
+
idx = int(hashlib.sha256(tok.encode()).hexdigest(), 16) % dim
|
|
77
|
+
vec[idx] += 1.0
|
|
78
|
+
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
|
79
|
+
return [v / norm for v in vec]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
|
83
|
+
"""Cosine similarity between two equal-length vectors."""
|
|
84
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
85
|
+
norm_a = math.sqrt(sum(x * x for x in a)) or 1e-12
|
|
86
|
+
norm_b = math.sqrt(sum(x * x for x in b)) or 1e-12
|
|
87
|
+
return dot / (norm_a * norm_b)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _lexical_overlap(tokens_a: list[str], tokens_b: list[str]) -> float:
|
|
91
|
+
"""Token-level Jaccard overlap (|intersection| / |union|)."""
|
|
92
|
+
if not tokens_a or not tokens_b:
|
|
93
|
+
return 0.0
|
|
94
|
+
set_a = set(tokens_a)
|
|
95
|
+
set_b = set(tokens_b)
|
|
96
|
+
intersection = set_a & set_b
|
|
97
|
+
union = set_a | set_b
|
|
98
|
+
return len(intersection) / len(union) if union else 0.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Public API
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def score_claim_against_facts(
|
|
107
|
+
claim: DetectedClaim,
|
|
108
|
+
packed_facts: list[PackedFact],
|
|
109
|
+
*,
|
|
110
|
+
config: ProvenanceConfig | None = None,
|
|
111
|
+
_embedder_override: Any = None,
|
|
112
|
+
) -> ClaimAttribution:
|
|
113
|
+
"""Score a single claim against all envelope facts.
|
|
114
|
+
|
|
115
|
+
Attempts to use dense sentence-transformer embeddings for semantic
|
|
116
|
+
similarity. Falls back to hash-projected bag-of-words if the
|
|
117
|
+
embedding model is unavailable.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
claim: Detected claim from claim_detector.
|
|
121
|
+
packed_facts: Facts that were packed into the envelope.
|
|
122
|
+
config: Scoring configuration (thresholds, weights).
|
|
123
|
+
_embedder_override: Injected embedding model for testing.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ClaimAttribution with ranked fact scores and attribution type.
|
|
127
|
+
"""
|
|
128
|
+
cfg = config or ProvenanceConfig()
|
|
129
|
+
|
|
130
|
+
claim_tokens = _tokenize(claim.text)
|
|
131
|
+
|
|
132
|
+
# --- Try dense embeddings first ---
|
|
133
|
+
texts_to_encode = [claim.text] + [pf.text for pf in packed_facts]
|
|
134
|
+
dense_embeddings = encode_texts(texts_to_encode, _model_override=_embedder_override)
|
|
135
|
+
use_dense = dense_embeddings is not None and len(dense_embeddings) == len(texts_to_encode)
|
|
136
|
+
|
|
137
|
+
if use_dense:
|
|
138
|
+
claim_emb = dense_embeddings[0]
|
|
139
|
+
fact_embs = dense_embeddings[1:]
|
|
140
|
+
else:
|
|
141
|
+
# Fallback: hash-projected bag-of-words
|
|
142
|
+
claim_vec = _bag_vector(claim_tokens)
|
|
143
|
+
|
|
144
|
+
fact_scores: list[FactScore] = []
|
|
145
|
+
|
|
146
|
+
for i, pf in enumerate(packed_facts):
|
|
147
|
+
fact_tokens = _tokenize(pf.text)
|
|
148
|
+
|
|
149
|
+
if use_dense:
|
|
150
|
+
semantic = max(_dense_cosine(claim_emb, fact_embs[i]), 0.0)
|
|
151
|
+
else:
|
|
152
|
+
fact_vec = _bag_vector(fact_tokens)
|
|
153
|
+
semantic = max(_cosine(claim_vec, fact_vec), 0.0)
|
|
154
|
+
|
|
155
|
+
lexical = _lexical_overlap(claim_tokens, fact_tokens)
|
|
156
|
+
|
|
157
|
+
composite = (
|
|
158
|
+
cfg.semantic_weight * semantic
|
|
159
|
+
+ cfg.lexical_weight * lexical
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
fact_scores.append(FactScore(
|
|
163
|
+
fact_id=pf.fact_id,
|
|
164
|
+
fact_text_preview=pf.text[:cfg.fact_preview_length],
|
|
165
|
+
semantic_similarity=round(semantic, 4),
|
|
166
|
+
lexical_overlap=round(lexical, 4),
|
|
167
|
+
composite_score=round(composite, 4),
|
|
168
|
+
fact_source_window="", # Filled by provenance chain builder
|
|
169
|
+
fact_extraction_stage=0, # Filled by provenance chain builder
|
|
170
|
+
))
|
|
171
|
+
|
|
172
|
+
# Sort by composite score descending
|
|
173
|
+
fact_scores.sort(key=lambda fs: fs.composite_score, reverse=True)
|
|
174
|
+
|
|
175
|
+
# Determine attribution type from top score
|
|
176
|
+
top = fact_scores[0].composite_score if fact_scores else 0.0
|
|
177
|
+
|
|
178
|
+
if top >= cfg.similarity_threshold:
|
|
179
|
+
attr_type = AttributionType.CONTEXT_GROUNDED
|
|
180
|
+
elif top >= cfg.mixed_threshold:
|
|
181
|
+
attr_type = AttributionType.MIXED
|
|
182
|
+
elif top > 0.0:
|
|
183
|
+
attr_type = AttributionType.PARAMETRIC
|
|
184
|
+
else:
|
|
185
|
+
attr_type = AttributionType.UNCERTAIN
|
|
186
|
+
|
|
187
|
+
# Confidence: distance from thresholds → 0.5–0.95 range
|
|
188
|
+
if attr_type == AttributionType.CONTEXT_GROUNDED:
|
|
189
|
+
confidence = min(0.60 + (top - cfg.similarity_threshold), 0.95)
|
|
190
|
+
elif attr_type == AttributionType.MIXED:
|
|
191
|
+
confidence = 0.40 + (top - cfg.mixed_threshold) * 0.5
|
|
192
|
+
elif attr_type == AttributionType.PARAMETRIC:
|
|
193
|
+
confidence = 0.50 + (cfg.mixed_threshold - top) * 0.3
|
|
194
|
+
else:
|
|
195
|
+
confidence = 0.20
|
|
196
|
+
|
|
197
|
+
return ClaimAttribution(
|
|
198
|
+
claim_text=claim.text,
|
|
199
|
+
claim_index=claim.index,
|
|
200
|
+
claim_type=claim.claim_type,
|
|
201
|
+
attributed_facts=fact_scores[:5], # Top-5 facts per claim
|
|
202
|
+
top_score=round(top, 4),
|
|
203
|
+
attribution_type=attr_type,
|
|
204
|
+
confidence=round(min(max(confidence, 0.0), 1.0), 4),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def score_all_claims(
|
|
209
|
+
claims: list[DetectedClaim],
|
|
210
|
+
packed_facts: list[PackedFact],
|
|
211
|
+
*,
|
|
212
|
+
config: ProvenanceConfig | None = None,
|
|
213
|
+
_embedder_override: Any = None,
|
|
214
|
+
) -> list[ClaimAttribution]:
|
|
215
|
+
"""Score all claims against all envelope facts.
|
|
216
|
+
|
|
217
|
+
Only FACTUAL_CLAIM and HEDGE types are scored against facts.
|
|
218
|
+
Other types (OPINION, PROCEDURAL, CONNECTIVE) are returned with
|
|
219
|
+
empty attribution (they don't require source grounding).
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
claims: All detected claims from claim_detector.
|
|
223
|
+
packed_facts: Facts that were packed into the envelope.
|
|
224
|
+
config: Scoring configuration.
|
|
225
|
+
_embedder_override: Injected embedding model for testing.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
List of ClaimAttribution objects in claim order.
|
|
229
|
+
"""
|
|
230
|
+
cfg = config or ProvenanceConfig()
|
|
231
|
+
attributions: list[ClaimAttribution] = []
|
|
232
|
+
|
|
233
|
+
for claim in claims:
|
|
234
|
+
if claim.claim_type in (ClaimType.FACTUAL_CLAIM, ClaimType.HEDGE):
|
|
235
|
+
attr = score_claim_against_facts(
|
|
236
|
+
claim, packed_facts, config=cfg,
|
|
237
|
+
_embedder_override=_embedder_override,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
# Non-factual claims don't need source attribution
|
|
241
|
+
attr = ClaimAttribution(
|
|
242
|
+
claim_text=claim.text,
|
|
243
|
+
claim_index=claim.index,
|
|
244
|
+
claim_type=claim.claim_type,
|
|
245
|
+
attributed_facts=[],
|
|
246
|
+
top_score=0.0,
|
|
247
|
+
attribution_type=AttributionType.UNCERTAIN,
|
|
248
|
+
confidence=0.0,
|
|
249
|
+
)
|
|
250
|
+
attributions.append(attr)
|
|
251
|
+
|
|
252
|
+
return attributions
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Claim Detector — segment LLM output into attributable claims (§7.14.3).
|
|
4
|
+
|
|
5
|
+
Splits model output into individual sentences/claims and classifies each as:
|
|
6
|
+
- FACTUAL_CLAIM: verifiable factual assertion (requires attribution)
|
|
7
|
+
- OPINION: subjective view or judgment
|
|
8
|
+
- PROCEDURAL: action or instruction
|
|
9
|
+
- HEDGE: qualified/uncertain statement
|
|
10
|
+
- CONNECTIVE: structural/transitional text
|
|
11
|
+
|
|
12
|
+
Uses rule-based heuristics — no ML model required for classification, keeping
|
|
13
|
+
overhead under 5ms for typical outputs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from ._types import ClaimType
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Sentence boundary detection
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
# Regex-based sentence splitter: split on sentence-ending punctuation
|
|
29
|
+
# followed by whitespace or end-of-string, but avoid splitting on
|
|
30
|
+
# common abbreviations (e.g., Mr., Dr., U.S., etc.) and decimals.
|
|
31
|
+
_ABBREV = r"(?<![A-Z][a-z])(?<!Mr)(?<!Mrs)(?<!Ms)(?<!Dr)(?<!Prof)(?<!Jr)(?<!Sr)(?<!Inc)(?<!Ltd)(?<!Corp)(?<!vs)(?<!etc)(?<!e\.g)(?<!i\.e)"
|
|
32
|
+
_SENTENCE_SPLIT = re.compile(
|
|
33
|
+
_ABBREV + r"[.!?]+(?:\s+|$)"
|
|
34
|
+
r"|(?:\n\s*\n)" # Double newline = paragraph boundary
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Claim classification patterns
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# Hedge indicators
|
|
42
|
+
_HEDGE_PATTERNS = re.compile(
|
|
43
|
+
r"\b("
|
|
44
|
+
r"may\s|might\s|could\s|possibly|perhaps|likely|unlikely"
|
|
45
|
+
r"|it\s+is\s+possible|it\s+appears|it\s+seems"
|
|
46
|
+
r"|suggest(?:s|ing)?\s|indicat(?:e|es|ing)\s"
|
|
47
|
+
r"|approximately|roughly|about\s+\d"
|
|
48
|
+
r"|tend(?:s)?\s+to|potential(?:ly)?"
|
|
49
|
+
r")\b",
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Opinion indicators
|
|
54
|
+
_OPINION_PATTERNS = re.compile(
|
|
55
|
+
r"\b("
|
|
56
|
+
r"I\s+(?:think|believe|feel|consider|recommend|suggest)"
|
|
57
|
+
r"|in\s+my\s+(?:opinion|view|experience)"
|
|
58
|
+
r"|(?:good|bad|best|worst|excellent|poor|great|terrible)"
|
|
59
|
+
r"|(?:should|ought|must)\s"
|
|
60
|
+
r"|(?:important(?:ly)?|crucial(?:ly)?|essential(?:ly)?)"
|
|
61
|
+
r"|unfortunately|fortunately|hopefully"
|
|
62
|
+
r"|(?:prefer|favorite|ideal)"
|
|
63
|
+
r")\b",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Procedural indicators
|
|
68
|
+
_PROCEDURAL_PATTERNS = re.compile(
|
|
69
|
+
r"\b("
|
|
70
|
+
r"(?:click|press|open|close|select|choose|enter|type|run|execute|install)"
|
|
71
|
+
r"|(?:step\s+\d|first,?\s|then,?\s|next,?\s|finally,?\s)"
|
|
72
|
+
r"|(?:navigate\s+to|go\s+to|switch\s+to)"
|
|
73
|
+
r"|(?:make\s+sure|ensure\s+that|verify\s+that)"
|
|
74
|
+
r"|(?:to\s+do\s+this|follow\s+these|here's\s+how)"
|
|
75
|
+
r")\b",
|
|
76
|
+
re.IGNORECASE,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Connective indicators — very short or purely structural
|
|
80
|
+
_CONNECTIVE_PATTERNS = re.compile(
|
|
81
|
+
r"^("
|
|
82
|
+
r"(?:furthermore|moreover|additionally|however|nevertheless|therefore)"
|
|
83
|
+
r"|(?:in\s+conclusion|to\s+summarize|in\s+summary|overall)"
|
|
84
|
+
r"|(?:for\s+example|for\s+instance|such\s+as|e\.g\.|i\.e\.)"
|
|
85
|
+
r"|(?:on\s+the\s+other\s+hand|in\s+contrast|conversely)"
|
|
86
|
+
r"|(?:as\s+mentioned|as\s+noted|as\s+discussed)"
|
|
87
|
+
r")(?:\s*[,:]?\s*$)",
|
|
88
|
+
re.IGNORECASE,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Factual indicators — numbers, dates, named entities, specific claims
|
|
92
|
+
_FACTUAL_PATTERNS = re.compile(
|
|
93
|
+
r"("
|
|
94
|
+
r"\d+(?:\.\d+)?%" # Percentages
|
|
95
|
+
r"|\$\d" # Dollar amounts
|
|
96
|
+
r"|\d{4}" # Years
|
|
97
|
+
r"|(?:according\s+to|based\s+on)" # Citation-like
|
|
98
|
+
r"|(?:was|were|is|are|has|have)\s+\w+" # State assertions
|
|
99
|
+
r"|\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+" # Proper nouns (2+ words)
|
|
100
|
+
r")",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Public API
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class DetectedClaim:
|
|
111
|
+
"""A single detected claim with its classification."""
|
|
112
|
+
|
|
113
|
+
text: str = ""
|
|
114
|
+
index: int = 0 # Position in the output (0-based sentence index)
|
|
115
|
+
claim_type: ClaimType = ClaimType.CONNECTIVE
|
|
116
|
+
type_confidence: float = 0.0 # Confidence in the classification
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def split_into_sentences(text: str) -> list[str]:
|
|
120
|
+
"""Split text into sentences.
|
|
121
|
+
|
|
122
|
+
Uses regex-based sentence boundary detection with abbreviation
|
|
123
|
+
handling and paragraph boundary support.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of sentence strings, stripped of leading/trailing whitespace.
|
|
127
|
+
"""
|
|
128
|
+
if not text or not text.strip():
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
# First, split on paragraph boundaries (double newlines)
|
|
132
|
+
paragraphs = re.split(r"\n\s*\n", text.strip())
|
|
133
|
+
|
|
134
|
+
sentences: list[str] = []
|
|
135
|
+
for para in paragraphs:
|
|
136
|
+
para = para.strip()
|
|
137
|
+
if not para:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Split paragraph into sentences on terminal punctuation
|
|
141
|
+
parts = re.split(r"(?<=[.!?])\s+", para)
|
|
142
|
+
for part in parts:
|
|
143
|
+
part = part.strip()
|
|
144
|
+
if part:
|
|
145
|
+
sentences.append(part)
|
|
146
|
+
|
|
147
|
+
return sentences
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def classify_claim(text: str) -> tuple[ClaimType, float]:
|
|
151
|
+
"""Classify a single sentence/claim by type.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Tuple of (ClaimType, confidence_score).
|
|
155
|
+
confidence_score is 0.0-1.0 indicating classification confidence.
|
|
156
|
+
"""
|
|
157
|
+
stripped = text.strip()
|
|
158
|
+
|
|
159
|
+
# Very short fragments are connective
|
|
160
|
+
if len(stripped) < 15:
|
|
161
|
+
return ClaimType.CONNECTIVE, 0.90
|
|
162
|
+
|
|
163
|
+
# Check connective patterns first (structural text)
|
|
164
|
+
if _CONNECTIVE_PATTERNS.search(stripped) and len(stripped) < 80:
|
|
165
|
+
return ClaimType.CONNECTIVE, 0.85
|
|
166
|
+
|
|
167
|
+
# Check hedge patterns
|
|
168
|
+
hedge_matches = len(_HEDGE_PATTERNS.findall(stripped))
|
|
169
|
+
opinion_matches = len(_OPINION_PATTERNS.findall(stripped))
|
|
170
|
+
procedural_matches = len(_PROCEDURAL_PATTERNS.findall(stripped))
|
|
171
|
+
factual_matches = len(_FACTUAL_PATTERNS.findall(stripped))
|
|
172
|
+
|
|
173
|
+
# Score each type
|
|
174
|
+
scores = {
|
|
175
|
+
ClaimType.HEDGE: hedge_matches * 0.40,
|
|
176
|
+
ClaimType.OPINION: opinion_matches * 0.35,
|
|
177
|
+
ClaimType.PROCEDURAL: procedural_matches * 0.35,
|
|
178
|
+
ClaimType.FACTUAL_CLAIM: factual_matches * 0.30,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# If no strong signals, default based on length and structure
|
|
182
|
+
max_type = max(scores, key=scores.get) # type: ignore[arg-type]
|
|
183
|
+
max_score = scores[max_type]
|
|
184
|
+
|
|
185
|
+
if max_score < 0.20:
|
|
186
|
+
# No strong signal — sentences with assertions are likely factual
|
|
187
|
+
if re.search(r"\b(?:is|are|was|were|has|have|had)\b", stripped):
|
|
188
|
+
return ClaimType.FACTUAL_CLAIM, 0.50
|
|
189
|
+
return ClaimType.CONNECTIVE, 0.40
|
|
190
|
+
|
|
191
|
+
# Map raw score to confidence (0.5 - 0.95 range)
|
|
192
|
+
confidence = min(0.50 + max_score, 0.95)
|
|
193
|
+
return max_type, confidence
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def detect_claims(
|
|
197
|
+
text: str,
|
|
198
|
+
*,
|
|
199
|
+
min_length: int = 10,
|
|
200
|
+
max_claims: int = 50,
|
|
201
|
+
) -> list[DetectedClaim]:
|
|
202
|
+
"""Detect and classify all claims in LLM output text.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
text: Raw LLM output text.
|
|
206
|
+
min_length: Minimum character length for a claim (shorter → skipped).
|
|
207
|
+
max_claims: Maximum number of claims to return (safety limit).
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of DetectedClaim objects, ordered by position in text.
|
|
211
|
+
"""
|
|
212
|
+
sentences = split_into_sentences(text)
|
|
213
|
+
|
|
214
|
+
claims: list[DetectedClaim] = []
|
|
215
|
+
for idx, sentence in enumerate(sentences):
|
|
216
|
+
if len(sentence.strip()) < min_length:
|
|
217
|
+
continue
|
|
218
|
+
if len(claims) >= max_claims:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
claim_type, confidence = classify_claim(sentence)
|
|
222
|
+
claims.append(DetectedClaim(
|
|
223
|
+
text=sentence.strip(),
|
|
224
|
+
index=idx,
|
|
225
|
+
claim_type=claim_type,
|
|
226
|
+
type_confidence=confidence,
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
return claims
|