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
crp/continuation/flow.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Information flow monitor — Δfacts/Δtokens rolling measurement (§4.3)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class FlowSample:
|
|
12
|
+
"""Single measurement point in the flow monitor."""
|
|
13
|
+
|
|
14
|
+
window_id: str
|
|
15
|
+
facts_produced: int
|
|
16
|
+
tokens_consumed: int
|
|
17
|
+
timestamp: float = 0.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FlowMetrics:
|
|
22
|
+
"""Current information flow metrics."""
|
|
23
|
+
|
|
24
|
+
current_rate: float # facts per 1000 tokens
|
|
25
|
+
rolling_average: float # rolling average over last N windows
|
|
26
|
+
trend: float # positive = increasing, negative = decreasing
|
|
27
|
+
sample_count: int
|
|
28
|
+
is_alive: bool # True if flow > 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InformationFlowMonitor:
|
|
32
|
+
"""Measures Δfacts/Δtokens rolling rate across windows (§4.3).
|
|
33
|
+
|
|
34
|
+
Tracks how much new information the LLM is producing per token.
|
|
35
|
+
When flow drops to zero, the model has stopped producing new facts.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, rolling_window: int = 5) -> None:
|
|
39
|
+
self._samples: list[FlowSample] = []
|
|
40
|
+
self._rolling_window = max(1, rolling_window)
|
|
41
|
+
|
|
42
|
+
def record(self, window_id: str, facts_produced: int, tokens_consumed: int, timestamp: float = 0.0) -> None:
|
|
43
|
+
"""Record a new flow sample after a window completes."""
|
|
44
|
+
self._samples.append(FlowSample(
|
|
45
|
+
window_id=window_id,
|
|
46
|
+
facts_produced=facts_produced,
|
|
47
|
+
tokens_consumed=max(1, tokens_consumed),
|
|
48
|
+
timestamp=timestamp,
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
def current_rate(self) -> float:
|
|
52
|
+
"""Facts per 1000 tokens for the most recent window."""
|
|
53
|
+
if not self._samples:
|
|
54
|
+
return 0.0
|
|
55
|
+
s = self._samples[-1]
|
|
56
|
+
return (s.facts_produced / s.tokens_consumed) * 1000.0
|
|
57
|
+
|
|
58
|
+
def rolling_average(self) -> float:
|
|
59
|
+
"""Rolling average rate over the last N windows."""
|
|
60
|
+
if not self._samples:
|
|
61
|
+
return 0.0
|
|
62
|
+
recent = self._samples[-self._rolling_window:]
|
|
63
|
+
total_facts = sum(s.facts_produced for s in recent)
|
|
64
|
+
total_tokens = sum(s.tokens_consumed for s in recent)
|
|
65
|
+
if total_tokens == 0:
|
|
66
|
+
return 0.0
|
|
67
|
+
return (total_facts / total_tokens) * 1000.0
|
|
68
|
+
|
|
69
|
+
def trend(self) -> float:
|
|
70
|
+
"""Rate of change: positive = flow increasing, negative = decreasing.
|
|
71
|
+
|
|
72
|
+
Computed as linear slope over rolling window.
|
|
73
|
+
"""
|
|
74
|
+
recent = self._samples[-self._rolling_window:]
|
|
75
|
+
if len(recent) < 2:
|
|
76
|
+
return 0.0
|
|
77
|
+
|
|
78
|
+
rates = [(s.facts_produced / max(1, s.tokens_consumed)) * 1000.0 for s in recent]
|
|
79
|
+
n = len(rates)
|
|
80
|
+
x_mean = (n - 1) / 2.0
|
|
81
|
+
y_mean = sum(rates) / n
|
|
82
|
+
|
|
83
|
+
num = sum((i - x_mean) * (r - y_mean) for i, r in enumerate(rates))
|
|
84
|
+
den = sum((i - x_mean) ** 2 for i in range(n))
|
|
85
|
+
if den == 0.0:
|
|
86
|
+
return 0.0
|
|
87
|
+
return num / den
|
|
88
|
+
|
|
89
|
+
def is_alive(self) -> bool:
|
|
90
|
+
"""True if information flow is still positive."""
|
|
91
|
+
return self.current_rate() > 0.0
|
|
92
|
+
|
|
93
|
+
def metrics(self) -> FlowMetrics:
|
|
94
|
+
"""Get current flow metrics snapshot."""
|
|
95
|
+
return FlowMetrics(
|
|
96
|
+
current_rate=self.current_rate(),
|
|
97
|
+
rolling_average=self.rolling_average(),
|
|
98
|
+
trend=self.trend(),
|
|
99
|
+
sample_count=len(self._samples),
|
|
100
|
+
is_alive=self.is_alive(),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def sample_count(self) -> int:
|
|
105
|
+
return len(self._samples)
|
|
106
|
+
|
|
107
|
+
def reset(self) -> None:
|
|
108
|
+
"""Clear all samples."""
|
|
109
|
+
self._samples.clear()
|
crp/continuation/gap.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Gap analysis — L1/L2/L3 requirement extraction and fulfillment scoring (§3.5)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("crp.continuation.gap")
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from crp.extraction.types import Fact
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Requirement:
|
|
22
|
+
"""A single task requirement at a specific analysis level."""
|
|
23
|
+
|
|
24
|
+
text: str
|
|
25
|
+
level: int # 1=structural, 2=semantic, 3=LLM-assisted
|
|
26
|
+
category: str = ""
|
|
27
|
+
weight: float = 1.0
|
|
28
|
+
fulfilled: bool = False
|
|
29
|
+
fulfillment_score: float = 0.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class GapResult:
|
|
34
|
+
"""Result of gap analysis between requirements and output facts."""
|
|
35
|
+
|
|
36
|
+
requirements: list[Requirement]
|
|
37
|
+
gap_score: float # 0.0 = all fulfilled, 1.0 = nothing fulfilled
|
|
38
|
+
fulfilled_count: int
|
|
39
|
+
total_count: int
|
|
40
|
+
unfulfilled: list[Requirement]
|
|
41
|
+
details: dict[str, object] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_complete(self) -> bool:
|
|
45
|
+
return self.gap_score <= 0.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── L1: Structural requirement extraction ──────────────────────────
|
|
49
|
+
|
|
50
|
+
_STRUCTURAL_PATTERNS: list[tuple[str, str]] = [
|
|
51
|
+
(r"\b(\d+)\s+(?:\w+\s+){0,2}(?:items?|points?|steps?|sections?|parts?)\b", "enumerated_items"),
|
|
52
|
+
(r"\b(?:list|enumerate|outline)\b", "list_structure"),
|
|
53
|
+
(r"\b(?:compare|contrast|versus|vs\.?)\b", "comparison"),
|
|
54
|
+
(r"\b(?:table|matrix|grid)\b", "tabular"),
|
|
55
|
+
(r"\b(?:code|implement|function|class|module)\b", "code_output"),
|
|
56
|
+
(r"\b(?:explain|describe|elaborate)\b", "explanation"),
|
|
57
|
+
(r"\b(?:summarize|summary|brief|concise)\b", "summary"),
|
|
58
|
+
(r"\b(?:analyze|analysis|evaluate)\b", "analysis"),
|
|
59
|
+
(r"\b(?:example|demonstrate|illustrate)\b", "example"),
|
|
60
|
+
(r"\b(?:pros?\s+(?:and|&)\s+cons?|advantages?\s+(?:and|&)\s+disadvantages?)\b", "pro_con"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_l1_structural(task_text: str) -> list[Requirement]:
|
|
65
|
+
"""L1: regex-based structural requirement extraction.
|
|
66
|
+
|
|
67
|
+
When the task requests N numbered sections/items AND those items are
|
|
68
|
+
individually listed (e.g. ``1. Foo — description\\n2. Bar — ...``),
|
|
69
|
+
each item becomes its own requirement so gap analysis can track
|
|
70
|
+
per-section fulfillment rather than a single coarse "N sections" req.
|
|
71
|
+
"""
|
|
72
|
+
reqs: list[Requirement] = []
|
|
73
|
+
seen_categories: set[str] = set()
|
|
74
|
+
|
|
75
|
+
for pattern, category in _STRUCTURAL_PATTERNS:
|
|
76
|
+
match = re.search(pattern, task_text, re.IGNORECASE)
|
|
77
|
+
if match and category not in seen_categories:
|
|
78
|
+
seen_categories.add(category)
|
|
79
|
+
|
|
80
|
+
# If this is an enumerated-items match (e.g. "30 sections"),
|
|
81
|
+
# try to expand into per-item requirements by parsing the
|
|
82
|
+
# numbered list that typically follows in the task text.
|
|
83
|
+
if category == "enumerated_items":
|
|
84
|
+
expanded = _expand_enumerated_items(task_text, match)
|
|
85
|
+
if expanded:
|
|
86
|
+
reqs.extend(expanded)
|
|
87
|
+
continue # skip the coarse "N sections" requirement
|
|
88
|
+
|
|
89
|
+
reqs.append(Requirement(
|
|
90
|
+
text=match.group(0),
|
|
91
|
+
level=1,
|
|
92
|
+
category=category,
|
|
93
|
+
weight=1.0,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
return reqs
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _expand_enumerated_items(task_text: str, enum_match: re.Match) -> list[Requirement]:
|
|
100
|
+
"""Expand an 'N sections/items/steps' match into per-item requirements.
|
|
101
|
+
|
|
102
|
+
Looks for a numbered list in the task text (``1. Foo\\n2. Bar\\n...``)
|
|
103
|
+
and creates one Requirement per item with the item's title/description.
|
|
104
|
+
Returns empty list if no numbered list is found, letting the caller
|
|
105
|
+
fall back to the coarse requirement.
|
|
106
|
+
"""
|
|
107
|
+
# Parse numbered list items: "N. Title" or "N. Title — description"
|
|
108
|
+
items: list[tuple[int, str]] = []
|
|
109
|
+
for m in re.finditer(
|
|
110
|
+
r"(?:^|\n)\s*(\d{1,3})[.)]\s+(.+?)(?=\n\s*\d{1,3}[.)]\s|\n\n|\Z)",
|
|
111
|
+
task_text,
|
|
112
|
+
re.DOTALL,
|
|
113
|
+
):
|
|
114
|
+
num = int(m.group(1))
|
|
115
|
+
# Take the first line as the item title (strip sub-lines)
|
|
116
|
+
title = m.group(2).split("\n")[0].strip()
|
|
117
|
+
if title:
|
|
118
|
+
items.append((num, title))
|
|
119
|
+
|
|
120
|
+
if len(items) < 3:
|
|
121
|
+
return [] # Not enough items to justify expansion
|
|
122
|
+
|
|
123
|
+
reqs: list[Requirement] = []
|
|
124
|
+
for num, title in items:
|
|
125
|
+
# Extract just the section name (before any " — " description)
|
|
126
|
+
section_name = title.split(" — ")[0].split(" - ")[0].strip()
|
|
127
|
+
reqs.append(Requirement(
|
|
128
|
+
text=f"Section {num}: {section_name}",
|
|
129
|
+
level=1,
|
|
130
|
+
category=f"section_{num}",
|
|
131
|
+
weight=1.0,
|
|
132
|
+
))
|
|
133
|
+
return reqs
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── L2: Semantic requirement extraction ────────────────────────────
|
|
137
|
+
|
|
138
|
+
_SEMANTIC_MARKERS: list[tuple[str, str, float]] = [
|
|
139
|
+
(r"\b(?:must|shall|required?|need)\b", "mandatory", 1.5),
|
|
140
|
+
(r"\b(?:should|recommend|prefer)\b", "recommended", 1.0),
|
|
141
|
+
(r"\b(?:may|optional|consider)\b", "optional", 0.5),
|
|
142
|
+
(r"\b(?:don't|do not|avoid|never)\b", "constraint", 1.2),
|
|
143
|
+
(r"\b(?:include|contain|cover|address)\b", "inclusion", 1.0),
|
|
144
|
+
(r"\b(?:first|then|finally|next|after)\b", "sequential", 0.8),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _extract_l2_semantic(task_text: str) -> list[Requirement]:
|
|
149
|
+
"""L2: semantic marker extraction for intent decomposition."""
|
|
150
|
+
reqs: list[Requirement] = []
|
|
151
|
+
sentences = re.split(r"[.!?]\s+", task_text)
|
|
152
|
+
|
|
153
|
+
for sentence in sentences:
|
|
154
|
+
sentence = sentence.strip()
|
|
155
|
+
if not sentence:
|
|
156
|
+
continue
|
|
157
|
+
for pattern, category, weight in _SEMANTIC_MARKERS:
|
|
158
|
+
if re.search(pattern, sentence, re.IGNORECASE):
|
|
159
|
+
reqs.append(Requirement(
|
|
160
|
+
text=sentence,
|
|
161
|
+
level=2,
|
|
162
|
+
category=category,
|
|
163
|
+
weight=weight,
|
|
164
|
+
))
|
|
165
|
+
break # one marker per sentence
|
|
166
|
+
|
|
167
|
+
return reqs
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Requirement extraction (public) ────────────────────────────────
|
|
171
|
+
|
|
172
|
+
_requirement_cache: dict[str, list[Requirement]] = {}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def extract_task_requirements(
|
|
176
|
+
task_intent: str,
|
|
177
|
+
l3_extractor: Callable[[str], list[Requirement]] | None = None,
|
|
178
|
+
) -> list[Requirement]:
|
|
179
|
+
"""Extract requirements at L1 (structural) and L2 (semantic) levels.
|
|
180
|
+
|
|
181
|
+
L3 (LLM-assisted) can be provided via *l3_extractor* callback (§5B.1).
|
|
182
|
+
Results are cached by content hash (singleton pattern per §3.5).
|
|
183
|
+
"""
|
|
184
|
+
# V11 fix: Use deterministic MD5 hash instead of Python's hash()
|
|
185
|
+
# which varies across interpreter runs (PYTHONHASHSEED).
|
|
186
|
+
cache_key = hashlib.md5(task_intent.encode()).hexdigest()
|
|
187
|
+
if cache_key in _requirement_cache:
|
|
188
|
+
return _requirement_cache[cache_key]
|
|
189
|
+
|
|
190
|
+
l1 = _extract_l1_structural(task_intent)
|
|
191
|
+
l2 = _extract_l2_semantic(task_intent)
|
|
192
|
+
|
|
193
|
+
# Deduplicate: L1 takes priority for same category
|
|
194
|
+
l1_categories = {r.category for r in l1}
|
|
195
|
+
combined = l1 + [r for r in l2 if r.category not in l1_categories]
|
|
196
|
+
|
|
197
|
+
# L3: LLM-assisted extraction when provider available (§5B.1)
|
|
198
|
+
if l3_extractor is not None:
|
|
199
|
+
try:
|
|
200
|
+
l3 = l3_extractor(task_intent)
|
|
201
|
+
existing_texts = {r.text.lower() for r in combined}
|
|
202
|
+
for r in l3:
|
|
203
|
+
if r.text.lower() not in existing_texts:
|
|
204
|
+
combined.append(r)
|
|
205
|
+
existing_texts.add(r.text.lower())
|
|
206
|
+
except Exception: # noqa: BLE001
|
|
207
|
+
logger.warning("L3 requirement extraction failed", exc_info=True)
|
|
208
|
+
|
|
209
|
+
if not combined:
|
|
210
|
+
combined = [Requirement(
|
|
211
|
+
text=task_intent[:200],
|
|
212
|
+
level=1,
|
|
213
|
+
category="general",
|
|
214
|
+
weight=1.0,
|
|
215
|
+
)]
|
|
216
|
+
|
|
217
|
+
_requirement_cache[cache_key] = combined
|
|
218
|
+
return combined
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def clear_requirement_cache() -> None:
|
|
222
|
+
"""Clear the requirement cache (for testing)."""
|
|
223
|
+
_requirement_cache.clear()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── Adaptive requirement discovery (GAP H) ────────────────────────
|
|
227
|
+
|
|
228
|
+
def discover_adaptive_requirements(
|
|
229
|
+
existing_reqs: list[Requirement],
|
|
230
|
+
document_headings: list[str] | None = None,
|
|
231
|
+
) -> list[Requirement]:
|
|
232
|
+
"""Discover new requirements from document headings not yet covered.
|
|
233
|
+
|
|
234
|
+
As continuation windows produce new sections, the DocumentMap tracks
|
|
235
|
+
headings. This function creates L2 requirements for sections that
|
|
236
|
+
appeared in the output but were not anticipated by the original task
|
|
237
|
+
analysis, ensuring the gap score tracks actual document completeness.
|
|
238
|
+
"""
|
|
239
|
+
if not document_headings:
|
|
240
|
+
return existing_reqs
|
|
241
|
+
|
|
242
|
+
existing_lower = {r.text.lower() for r in existing_reqs}
|
|
243
|
+
existing_sections = set()
|
|
244
|
+
for r in existing_reqs:
|
|
245
|
+
m = re.match(r"section\s+(\d+)", r.text, re.IGNORECASE)
|
|
246
|
+
if m:
|
|
247
|
+
existing_sections.add(m.group(1))
|
|
248
|
+
|
|
249
|
+
new_reqs: list[Requirement] = []
|
|
250
|
+
for heading in document_headings:
|
|
251
|
+
heading_stripped = heading.strip().lstrip("#").strip()
|
|
252
|
+
if not heading_stripped:
|
|
253
|
+
continue
|
|
254
|
+
# Extract section number if present (e.g. "2. Background")
|
|
255
|
+
m = re.match(r"(\d+)\.\s*(.*)", heading_stripped)
|
|
256
|
+
if m:
|
|
257
|
+
sec_num, sec_title = m.group(1), m.group(2).strip()
|
|
258
|
+
if sec_num in existing_sections:
|
|
259
|
+
continue
|
|
260
|
+
req_text = f"Section {sec_num}: {sec_title}" if sec_title else f"Section {sec_num}"
|
|
261
|
+
else:
|
|
262
|
+
req_text = f"Cover: {heading_stripped}"
|
|
263
|
+
|
|
264
|
+
if req_text.lower() in existing_lower:
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
new_reqs.append(Requirement(
|
|
268
|
+
text=req_text,
|
|
269
|
+
level=2,
|
|
270
|
+
category="adaptive_discovery",
|
|
271
|
+
weight=0.8, # slightly lower weight than original requirements
|
|
272
|
+
fulfilled=True, # already fulfilled since they came from output
|
|
273
|
+
fulfillment_score=1.0,
|
|
274
|
+
))
|
|
275
|
+
existing_lower.add(req_text.lower())
|
|
276
|
+
|
|
277
|
+
return existing_reqs + new_reqs
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── Fulfillment scoring ───────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
283
|
+
"""Compute cosine similarity between two vectors."""
|
|
284
|
+
if len(a) != len(b) or not a:
|
|
285
|
+
return 0.0
|
|
286
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
287
|
+
norm_a = sum(x * x for x in a) ** 0.5
|
|
288
|
+
norm_b = sum(x * x for x in b) ** 0.5
|
|
289
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
290
|
+
return 0.0
|
|
291
|
+
return dot / (norm_a * norm_b)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _text_overlap(req_text: str, fact_text: str) -> float:
|
|
295
|
+
"""Word overlap ratio as fallback similarity.
|
|
296
|
+
|
|
297
|
+
For section-level requirements (``Section N: Title``), also checks
|
|
298
|
+
whether the fact references the section number or title keywords,
|
|
299
|
+
enabling per-section fulfillment tracking.
|
|
300
|
+
"""
|
|
301
|
+
req_lower = req_text.lower()
|
|
302
|
+
fact_lower = fact_text.lower()
|
|
303
|
+
|
|
304
|
+
# Fast path: section-level requirement matching
|
|
305
|
+
sec_match = re.match(r"section\s+(\d+):\s*(.+)", req_lower)
|
|
306
|
+
if sec_match:
|
|
307
|
+
sec_num = sec_match.group(1)
|
|
308
|
+
sec_title = sec_match.group(2).strip()
|
|
309
|
+
|
|
310
|
+
# Check if fact references this section number in a heading
|
|
311
|
+
if re.search(rf"(?:^|\n)\s*#{{1,3}}\s*{sec_num}\.", fact_lower):
|
|
312
|
+
return 1.0
|
|
313
|
+
|
|
314
|
+
# Check if fact contains the section title keywords
|
|
315
|
+
title_words = {w for w in sec_title.split() if len(w) > 3}
|
|
316
|
+
if title_words:
|
|
317
|
+
fact_words = set(fact_lower.split())
|
|
318
|
+
title_overlap = len(title_words & fact_words) / len(title_words)
|
|
319
|
+
if title_overlap >= 0.5:
|
|
320
|
+
return max(0.7, title_overlap)
|
|
321
|
+
|
|
322
|
+
# General word overlap
|
|
323
|
+
req_words = set(req_lower.split())
|
|
324
|
+
fact_words = set(fact_lower.split())
|
|
325
|
+
if not req_words:
|
|
326
|
+
return 0.0
|
|
327
|
+
overlap = req_words & fact_words
|
|
328
|
+
return len(overlap) / len(req_words)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
FULFILLMENT_THRESHOLD = 0.65
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def gap_analysis(
|
|
335
|
+
task_intent: str,
|
|
336
|
+
output_facts: list[Fact],
|
|
337
|
+
requirements: list[Requirement] | None = None,
|
|
338
|
+
embedding_fn: Callable[[str], list[float]] | None = None,
|
|
339
|
+
document_headings: list[str] | None = None,
|
|
340
|
+
) -> GapResult:
|
|
341
|
+
"""Compute gap between task requirements and produced facts (§3.5).
|
|
342
|
+
|
|
343
|
+
For each requirement, find best-matching fact. Uses cosine similarity
|
|
344
|
+
when *embedding_fn* is provided (§5B.3), otherwise falls back to word
|
|
345
|
+
overlap. Threshold: 0.65.
|
|
346
|
+
|
|
347
|
+
When *document_headings* is provided (from the DocumentMap), section-
|
|
348
|
+
level requirements are also matched against actual headings produced,
|
|
349
|
+
enabling accurate per-section tracking.
|
|
350
|
+
"""
|
|
351
|
+
if requirements is None:
|
|
352
|
+
requirements = extract_task_requirements(task_intent)
|
|
353
|
+
|
|
354
|
+
# GAP H: Adaptive requirement discovery from document headings
|
|
355
|
+
requirements = discover_adaptive_requirements(requirements, document_headings)
|
|
356
|
+
|
|
357
|
+
reqs = [Requirement(
|
|
358
|
+
text=r.text, level=r.level, category=r.category,
|
|
359
|
+
weight=r.weight, fulfilled=r.fulfilled,
|
|
360
|
+
fulfillment_score=r.fulfillment_score,
|
|
361
|
+
) for r in requirements]
|
|
362
|
+
|
|
363
|
+
fact_texts = [f.text for f in output_facts]
|
|
364
|
+
|
|
365
|
+
# If document headings are available, add them as pseudo-facts
|
|
366
|
+
# so section-level requirements can be matched against actual headings
|
|
367
|
+
if document_headings:
|
|
368
|
+
fact_texts = fact_texts + document_headings
|
|
369
|
+
|
|
370
|
+
# Pre-compute embeddings if embedding_fn available (§5B.3)
|
|
371
|
+
req_embeddings: list[list[float] | None] = []
|
|
372
|
+
fact_embeddings: list[list[float] | None] = []
|
|
373
|
+
if embedding_fn is not None:
|
|
374
|
+
try:
|
|
375
|
+
req_embeddings = [embedding_fn(r.text) for r in reqs]
|
|
376
|
+
fact_embeddings = [embedding_fn(ft) for ft in fact_texts]
|
|
377
|
+
except Exception: # noqa: BLE001
|
|
378
|
+
logger.warning("Embedding computation failed, falling back to text overlap", exc_info=True)
|
|
379
|
+
req_embeddings = []
|
|
380
|
+
fact_embeddings = []
|
|
381
|
+
|
|
382
|
+
use_embeddings = bool(req_embeddings and fact_embeddings)
|
|
383
|
+
|
|
384
|
+
for i, req in enumerate(reqs):
|
|
385
|
+
best_score = 0.0
|
|
386
|
+
for j, ft in enumerate(fact_texts):
|
|
387
|
+
# Always compute text_overlap (has section-level fast paths)
|
|
388
|
+
text_score = _text_overlap(req.text, ft)
|
|
389
|
+
if use_embeddings:
|
|
390
|
+
re_emb = req_embeddings[i]
|
|
391
|
+
fe_emb = fact_embeddings[j]
|
|
392
|
+
if re_emb and fe_emb:
|
|
393
|
+
cos_score = _cosine_similarity(re_emb, fe_emb)
|
|
394
|
+
# Use max of both: text_overlap catches section-number matches,
|
|
395
|
+
# cosine catches semantic similarity with different vocabulary
|
|
396
|
+
score = max(text_score, cos_score)
|
|
397
|
+
else:
|
|
398
|
+
score = text_score
|
|
399
|
+
else:
|
|
400
|
+
score = text_score
|
|
401
|
+
best_score = max(best_score, score)
|
|
402
|
+
|
|
403
|
+
req.fulfillment_score = best_score
|
|
404
|
+
req.fulfilled = best_score >= FULFILLMENT_THRESHOLD
|
|
405
|
+
|
|
406
|
+
fulfilled = [r for r in reqs if r.fulfilled]
|
|
407
|
+
unfulfilled = [r for r in reqs if not r.fulfilled]
|
|
408
|
+
|
|
409
|
+
total_weight = sum(r.weight for r in reqs) or 1.0
|
|
410
|
+
fulfilled_weight = sum(r.weight for r in fulfilled)
|
|
411
|
+
gap_score = 1.0 - (fulfilled_weight / total_weight)
|
|
412
|
+
|
|
413
|
+
return GapResult(
|
|
414
|
+
requirements=reqs,
|
|
415
|
+
gap_score=max(0.0, min(1.0, gap_score)),
|
|
416
|
+
fulfilled_count=len(fulfilled),
|
|
417
|
+
total_count=len(reqs),
|
|
418
|
+
unfulfilled=unfulfilled,
|
|
419
|
+
)
|