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.
Files changed (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. 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