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/envelope/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Envelope construction — 6-phase algorithm, scoring, packing, formatting."""
|
|
4
|
+
|
|
5
|
+
from .builder import EnvelopeResult, EnvelopeState, compute_envelope_budget, construct
|
|
6
|
+
from .decomposer import DecompositionResult, decompose_task_aspects
|
|
7
|
+
from .formatter import EnvelopeSection, format_envelope
|
|
8
|
+
from .packer import PackedFact, PackingResult, estimate_tokens, pack_facts
|
|
9
|
+
from .reranker import CrossEncoderCache, rerank
|
|
10
|
+
from .scoring import ScoredFact, ScoringConfig, score_facts
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CrossEncoderCache",
|
|
14
|
+
"DecompositionResult",
|
|
15
|
+
"EnvelopeResult",
|
|
16
|
+
"EnvelopeSection",
|
|
17
|
+
"EnvelopeState",
|
|
18
|
+
"PackedFact",
|
|
19
|
+
"PackingResult",
|
|
20
|
+
"ScoredFact",
|
|
21
|
+
"ScoringConfig",
|
|
22
|
+
"compute_envelope_budget",
|
|
23
|
+
"construct",
|
|
24
|
+
"decompose_task_aspects",
|
|
25
|
+
"estimate_tokens",
|
|
26
|
+
"format_envelope",
|
|
27
|
+
"pack_facts",
|
|
28
|
+
"rerank",
|
|
29
|
+
"score_facts",
|
|
30
|
+
]
|
crp/envelope/builder.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Envelope builder — top-level 6-phase construction orchestrator (§3.2).
|
|
4
|
+
|
|
5
|
+
``construct(task_intent, budget, state)`` returns the final envelope text.
|
|
6
|
+
|
|
7
|
+
6-Phase algorithm:
|
|
8
|
+
Phase 1: Multi-aspect task decomposition → decomposer.py
|
|
9
|
+
Phase 2: Bi-encoder scoring → scoring.py
|
|
10
|
+
Phase 3: Cross-encoder reranking → reranker.py
|
|
11
|
+
Phase 4: Graph-aware packing → packer.py
|
|
12
|
+
Phase 5: Bookend strategy → packer.py
|
|
13
|
+
Phase 6: CKF retrieval gate → this module
|
|
14
|
+
|
|
15
|
+
Envelope budget formula (02_CORE §2.1):
|
|
16
|
+
E_max = C − S − T − G
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("crp.envelope.builder")
|
|
27
|
+
|
|
28
|
+
from crp.core.task_intent import TaskIntent
|
|
29
|
+
from crp.extraction.types import Fact, FactGraph
|
|
30
|
+
|
|
31
|
+
from .decomposer import DecompositionResult, decompose_task_aspects
|
|
32
|
+
from .formatter import format_envelope
|
|
33
|
+
from .packer import PackingResult, estimate_tokens, pack_facts
|
|
34
|
+
from .reranker import CrossEncoderCache, rerank
|
|
35
|
+
from .scoring import ScoringConfig, score_facts
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Envelope state — holds facts, graph, and per-session metadata
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
CKF_GATE_TOKENS = 120 # min remaining tokens before pulling from CKF
|
|
42
|
+
CKF_RESERVE_RATIO = 0.15 # fraction of fact_budget reserved for CKF when retriever is available
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class EnvelopeState:
|
|
47
|
+
"""Session-scoped state passed to the builder."""
|
|
48
|
+
|
|
49
|
+
# Fact store (warm tier)
|
|
50
|
+
facts: list[Fact] = field(default_factory=list)
|
|
51
|
+
graph: FactGraph = field(default_factory=FactGraph)
|
|
52
|
+
|
|
53
|
+
# Metadata for scoring
|
|
54
|
+
current_window_index: int = 0
|
|
55
|
+
seen_counts: dict[str, int] = field(default_factory=dict)
|
|
56
|
+
fact_window_indices: dict[str, int] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
# Critical state sections (pre-populated by state management)
|
|
59
|
+
sections: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
# CKF callback — if registered, called when budget allows
|
|
62
|
+
ckf_retriever: Callable[[str, int], list[Fact]] | None = None
|
|
63
|
+
|
|
64
|
+
# Cross-encoder cache (shared across windows)
|
|
65
|
+
ce_cache: CrossEncoderCache = field(default_factory=CrossEncoderCache)
|
|
66
|
+
|
|
67
|
+
# Scoring config override
|
|
68
|
+
scoring_config: ScoringConfig | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Build result
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class EnvelopeResult:
|
|
78
|
+
"""Output of ``construct()``."""
|
|
79
|
+
|
|
80
|
+
envelope_text: str = ""
|
|
81
|
+
envelope_tokens: int = 0
|
|
82
|
+
budget_tokens: int = 0
|
|
83
|
+
saturation: float = 0.0 # envelope_tokens / budget_tokens
|
|
84
|
+
facts_included: int = 0
|
|
85
|
+
facts_available: int = 0
|
|
86
|
+
bookend_count: int = 0
|
|
87
|
+
compressed_count: int = 0
|
|
88
|
+
ckf_facts_added: int = 0
|
|
89
|
+
latency_ms: float = 0.0
|
|
90
|
+
decomposition: DecompositionResult | None = None
|
|
91
|
+
packing: PackingResult | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Envelope budget computation
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_envelope_budget(
|
|
100
|
+
context_window: int,
|
|
101
|
+
system_tokens: int,
|
|
102
|
+
task_tokens: int,
|
|
103
|
+
generation_reserve: int | None = None,
|
|
104
|
+
max_output_tokens: int | None = None,
|
|
105
|
+
) -> int:
|
|
106
|
+
"""E_max = C − S − T − G.
|
|
107
|
+
|
|
108
|
+
Generation reserve precedence:
|
|
109
|
+
1. User-specified max_output_tokens
|
|
110
|
+
2. Explicit generation_reserve
|
|
111
|
+
3. Default: min(C // 4, 16384)
|
|
112
|
+
"""
|
|
113
|
+
if max_output_tokens is not None:
|
|
114
|
+
g = max_output_tokens
|
|
115
|
+
elif generation_reserve is not None:
|
|
116
|
+
g = generation_reserve
|
|
117
|
+
else:
|
|
118
|
+
g = min(context_window // 4, 16384)
|
|
119
|
+
return max(0, context_window - system_tokens - task_tokens - g)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Main entry point
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def construct(
|
|
128
|
+
task_intent: TaskIntent,
|
|
129
|
+
budget_tokens: int,
|
|
130
|
+
state: EnvelopeState,
|
|
131
|
+
*,
|
|
132
|
+
count_tokens: Callable[[str], int] | None = None,
|
|
133
|
+
chars_per_token: float = 3.3,
|
|
134
|
+
) -> EnvelopeResult:
|
|
135
|
+
"""Build an envelope for *task_intent* within *budget_tokens*.
|
|
136
|
+
|
|
137
|
+
This is the top-level orchestrator implementing the 6-phase algorithm.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
task_intent : TaskIntent
|
|
142
|
+
The task to assemble context for.
|
|
143
|
+
budget_tokens : int
|
|
144
|
+
Maximum envelope tokens (E_max from budget formula).
|
|
145
|
+
state : EnvelopeState
|
|
146
|
+
Session-scoped state containing facts, graph, sections, etc.
|
|
147
|
+
count_tokens : callable | None
|
|
148
|
+
Model tokenizer function ``(str) → int``. Falls back to estimator.
|
|
149
|
+
chars_per_token : float
|
|
150
|
+
Calibrated chars/token ratio for fallback estimation.
|
|
151
|
+
"""
|
|
152
|
+
t0 = time.perf_counter()
|
|
153
|
+
|
|
154
|
+
def _count(text: str) -> int:
|
|
155
|
+
if count_tokens is not None:
|
|
156
|
+
return count_tokens(text)
|
|
157
|
+
return estimate_tokens(text, chars_per_token)
|
|
158
|
+
|
|
159
|
+
result = EnvelopeResult(
|
|
160
|
+
budget_tokens=budget_tokens,
|
|
161
|
+
facts_available=len(state.facts),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if budget_tokens <= 0:
|
|
165
|
+
result.latency_ms = (time.perf_counter() - t0) * 1000
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
# -----------------------------------------------------------------------
|
|
169
|
+
# Phase 1: Multi-aspect task decomposition
|
|
170
|
+
# -----------------------------------------------------------------------
|
|
171
|
+
decomposition = decompose_task_aspects(task_intent)
|
|
172
|
+
result.decomposition = decomposition
|
|
173
|
+
|
|
174
|
+
# -----------------------------------------------------------------------
|
|
175
|
+
# Phase 2: Bi-encoder scoring
|
|
176
|
+
# -----------------------------------------------------------------------
|
|
177
|
+
scored = score_facts(
|
|
178
|
+
state.facts,
|
|
179
|
+
decomposition,
|
|
180
|
+
state.graph,
|
|
181
|
+
current_window_index=state.current_window_index,
|
|
182
|
+
seen_counts=state.seen_counts,
|
|
183
|
+
fact_window_indices=state.fact_window_indices,
|
|
184
|
+
config=state.scoring_config,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# -----------------------------------------------------------------------
|
|
188
|
+
# Phase 3: Cross-encoder reranking
|
|
189
|
+
# -----------------------------------------------------------------------
|
|
190
|
+
scored = rerank(
|
|
191
|
+
scored,
|
|
192
|
+
task_intent,
|
|
193
|
+
cache=state.ce_cache,
|
|
194
|
+
current_window=state.current_window_index,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# -----------------------------------------------------------------------
|
|
198
|
+
# Compute section overhead (tier 1-2 sections that always appear)
|
|
199
|
+
# -----------------------------------------------------------------------
|
|
200
|
+
section_text_parts: dict[str, str] = {}
|
|
201
|
+
section_overhead = 0
|
|
202
|
+
for name, content in state.sections.items():
|
|
203
|
+
if content and content.strip():
|
|
204
|
+
header = f"[{name.upper()}]\n{content.strip()}"
|
|
205
|
+
section_overhead += _count(header) + 2 # +2 for "\n\n"
|
|
206
|
+
section_text_parts[name] = content.strip()
|
|
207
|
+
|
|
208
|
+
# Budget remaining for facts
|
|
209
|
+
fact_budget = max(0, budget_tokens - section_overhead)
|
|
210
|
+
|
|
211
|
+
# Reserve budget for CKF when retriever is available (§audit fix G1)
|
|
212
|
+
ckf_reserve = 0
|
|
213
|
+
if state.ckf_retriever is not None and fact_budget > CKF_GATE_TOKENS * 2:
|
|
214
|
+
ckf_reserve = max(CKF_GATE_TOKENS, int(fact_budget * CKF_RESERVE_RATIO))
|
|
215
|
+
warm_budget = fact_budget - ckf_reserve
|
|
216
|
+
|
|
217
|
+
# -----------------------------------------------------------------------
|
|
218
|
+
# Phase 4+5: Graph-aware packing + bookend strategy
|
|
219
|
+
# -----------------------------------------------------------------------
|
|
220
|
+
packing = pack_facts(
|
|
221
|
+
scored,
|
|
222
|
+
state.graph,
|
|
223
|
+
warm_budget,
|
|
224
|
+
count_tokens=count_tokens,
|
|
225
|
+
chars_per_token=chars_per_token,
|
|
226
|
+
)
|
|
227
|
+
result.packing = packing
|
|
228
|
+
result.facts_included = packing.facts_packed
|
|
229
|
+
result.bookend_count = packing.bookend_count
|
|
230
|
+
result.compressed_count = packing.compressed_count
|
|
231
|
+
|
|
232
|
+
# -----------------------------------------------------------------------
|
|
233
|
+
# Phase 6: CKF retrieval gate
|
|
234
|
+
# -----------------------------------------------------------------------
|
|
235
|
+
# CKF gets reserved budget + any unused warm store budget
|
|
236
|
+
remaining_for_ckf = ckf_reserve + (warm_budget - packing.total_tokens)
|
|
237
|
+
if (
|
|
238
|
+
remaining_for_ckf > CKF_GATE_TOKENS
|
|
239
|
+
and state.ckf_retriever is not None
|
|
240
|
+
and decomposition.aspects
|
|
241
|
+
):
|
|
242
|
+
query = " ".join(decomposition.aspects[:3])
|
|
243
|
+
try:
|
|
244
|
+
ckf_facts = state.ckf_retriever(query, remaining_for_ckf)
|
|
245
|
+
if ckf_facts:
|
|
246
|
+
# Score and pack CKF facts into remaining budget
|
|
247
|
+
ckf_scored = score_facts(
|
|
248
|
+
ckf_facts,
|
|
249
|
+
decomposition,
|
|
250
|
+
state.graph,
|
|
251
|
+
current_window_index=state.current_window_index,
|
|
252
|
+
config=state.scoring_config,
|
|
253
|
+
)
|
|
254
|
+
ckf_packed = pack_facts(
|
|
255
|
+
ckf_scored,
|
|
256
|
+
state.graph,
|
|
257
|
+
remaining_for_ckf,
|
|
258
|
+
count_tokens=count_tokens,
|
|
259
|
+
chars_per_token=chars_per_token,
|
|
260
|
+
)
|
|
261
|
+
# Merge CKF results
|
|
262
|
+
packing.packed_facts.extend(ckf_packed.packed_facts)
|
|
263
|
+
packing.total_tokens += ckf_packed.total_tokens
|
|
264
|
+
result.ckf_facts_added = ckf_packed.facts_packed
|
|
265
|
+
except Exception: # noqa: BLE001
|
|
266
|
+
logger.warning("CKF lookup failed during envelope build", exc_info=True)
|
|
267
|
+
|
|
268
|
+
# -----------------------------------------------------------------------
|
|
269
|
+
# Serialization
|
|
270
|
+
# -----------------------------------------------------------------------
|
|
271
|
+
envelope_text = format_envelope(section_text_parts, packing.packed_facts)
|
|
272
|
+
|
|
273
|
+
result.envelope_text = envelope_text
|
|
274
|
+
result.envelope_tokens = _count(envelope_text)
|
|
275
|
+
|
|
276
|
+
# Clamp envelope to budget if it overflows (§audit M9)
|
|
277
|
+
if budget_tokens > 0 and result.envelope_tokens > budget_tokens:
|
|
278
|
+
# Truncate envelope text to fit within budget
|
|
279
|
+
target_chars = int(budget_tokens * chars_per_token)
|
|
280
|
+
if len(envelope_text) > target_chars:
|
|
281
|
+
result.envelope_text = envelope_text[:target_chars]
|
|
282
|
+
result.envelope_tokens = _count(result.envelope_text)
|
|
283
|
+
|
|
284
|
+
if budget_tokens > 0:
|
|
285
|
+
result.saturation = min(1.0, result.envelope_tokens / budget_tokens)
|
|
286
|
+
result.latency_ms = (time.perf_counter() - t0) * 1000
|
|
287
|
+
|
|
288
|
+
return result
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Phase 1 — Multi-aspect task decomposition (§3.2).
|
|
4
|
+
|
|
5
|
+
Decomposes TaskIntent into explicit + implicit aspects, each with an embedding
|
|
6
|
+
vector. Falls back to lightweight word-overlap vectors when sentence-transformers
|
|
7
|
+
is unavailable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from crp.core.task_intent import TaskIntent
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Embedding abstraction — lightweight fallback when ML deps absent
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
# Sentinel that indicates sentence-transformers is available
|
|
25
|
+
_ST_AVAILABLE: bool = False
|
|
26
|
+
_EMBED_MODEL: object | None = None
|
|
27
|
+
_EMBED_DIM: int = 384 # all-MiniLM-L6-v2 output dim
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _try_load_sentence_transformer() -> bool:
|
|
31
|
+
"""Attempt lazy load of sentence-transformers model. Returns True on success."""
|
|
32
|
+
global _ST_AVAILABLE, _EMBED_MODEL # noqa: PLW0603
|
|
33
|
+
if _ST_AVAILABLE:
|
|
34
|
+
return True
|
|
35
|
+
try:
|
|
36
|
+
from sentence_transformers import SentenceTransformer # type: ignore[import-untyped]
|
|
37
|
+
|
|
38
|
+
_EMBED_MODEL = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
|
|
39
|
+
_ST_AVAILABLE = True
|
|
40
|
+
except Exception: # noqa: BLE001
|
|
41
|
+
pass
|
|
42
|
+
return _ST_AVAILABLE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_embedding_fn() -> "Callable[[str], list[float]] | None":
|
|
46
|
+
"""Return a text→embedding function using the loaded model, or None.
|
|
47
|
+
|
|
48
|
+
This is the canonical embedding function for the CRP pipeline.
|
|
49
|
+
Used by: set_embedding_function(), gap_analysis(), CKF semantic mode.
|
|
50
|
+
"""
|
|
51
|
+
if not _try_load_sentence_transformer() or _EMBED_MODEL is None:
|
|
52
|
+
return None
|
|
53
|
+
model = _EMBED_MODEL
|
|
54
|
+
|
|
55
|
+
def _embed(text: str) -> list[float]:
|
|
56
|
+
return model.encode([text], show_progress_bar=False).tolist()[0] # type: ignore[attr-defined]
|
|
57
|
+
|
|
58
|
+
return _embed
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# -- lightweight fallback vectors (word-overlap, normalised) -----------------
|
|
62
|
+
|
|
63
|
+
_STOP = frozenset(
|
|
64
|
+
["a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "is", "it", "its", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "shall", "should", "may", "might", "can", "could", "this", "that", "these", "those", "i", "we", "you", "he", "she", "they", "me", "us", "him", "her", "them", "my", "our", "your", "his", "its", "their", "with", "from", "by", "as", "not", "no"]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tokenize(text: str) -> list[str]:
|
|
69
|
+
"""Lower-case word tokeniser (alphanum only)."""
|
|
70
|
+
return [w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in _STOP]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _bag_vector(tokens: Sequence[str], vocab: dict[str, int], dim: int) -> list[float]:
|
|
74
|
+
"""Sparse bag-of-words vector projected to *dim* via hashing."""
|
|
75
|
+
vec = [0.0] * dim
|
|
76
|
+
for tok in tokens:
|
|
77
|
+
idx = vocab.get(tok)
|
|
78
|
+
if idx is None:
|
|
79
|
+
idx = int(hashlib.sha256(tok.encode()).hexdigest(), 16) % dim
|
|
80
|
+
vec[idx] += 1.0
|
|
81
|
+
# L2 normalise
|
|
82
|
+
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
|
83
|
+
return [v / norm for v in vec]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_vocab(all_tokens: list[str]) -> dict[str, int]:
|
|
87
|
+
"""Deterministic vocabulary mapping (ordered by first occurrence)."""
|
|
88
|
+
vocab: dict[str, int] = {}
|
|
89
|
+
for tok in all_tokens:
|
|
90
|
+
if tok not in vocab:
|
|
91
|
+
vocab[tok] = len(vocab) % _EMBED_DIM
|
|
92
|
+
return vocab
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Public result dataclass
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class DecompositionResult:
|
|
101
|
+
"""Output of ``decompose_task_aspects``."""
|
|
102
|
+
|
|
103
|
+
aspects: list[str] = field(default_factory=list)
|
|
104
|
+
aspect_embeddings: list[list[float]] = field(default_factory=list)
|
|
105
|
+
full_embedding: list[float] = field(default_factory=list)
|
|
106
|
+
used_ml_model: bool = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Noun-phrase extraction (lightweight, no ML)
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
# Simple regex pattern for noun-phrase-like chunks: sequences of adjectives/nouns
|
|
114
|
+
_NP_PATTERN = re.compile(
|
|
115
|
+
r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b" # Capitalised phrases
|
|
116
|
+
r"|"
|
|
117
|
+
r"\b([a-z]+(?:[-_][a-z]+)+)\b" # Hyphenated/underscored compound terms
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_noun_phrases(text: str) -> list[str]:
|
|
122
|
+
"""Extract candidate noun phrases from *text*."""
|
|
123
|
+
phrases: list[str] = []
|
|
124
|
+
for m in _NP_PATTERN.finditer(text):
|
|
125
|
+
phrase = (m.group(1) or m.group(2) or "").strip()
|
|
126
|
+
if phrase and len(phrase) > 2:
|
|
127
|
+
phrases.append(phrase)
|
|
128
|
+
# Also grab quoted strings as explicit aspects
|
|
129
|
+
for m in re.finditer(r'"([^"]{3,80})"', text):
|
|
130
|
+
phrases.append(m.group(1))
|
|
131
|
+
# Deduplicate preserving order
|
|
132
|
+
seen: set[str] = set()
|
|
133
|
+
result: list[str] = []
|
|
134
|
+
for p in phrases:
|
|
135
|
+
key = p.lower()
|
|
136
|
+
if key not in seen:
|
|
137
|
+
seen.add(key)
|
|
138
|
+
result.append(p)
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Implicit aspect expansion (word-based when no embeddings)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _expand_aspects_implicit(aspects: list[str], full_text: str) -> list[str]:
|
|
147
|
+
"""Add implicit aspects derived from the full text that aren't in *aspects*.
|
|
148
|
+
|
|
149
|
+
When ML embeddings are unavailable, uses word co-occurrence: finds bigrams
|
|
150
|
+
from the full text that share a word with an existing aspect.
|
|
151
|
+
"""
|
|
152
|
+
if not aspects:
|
|
153
|
+
# Fallback: use first N significant tokens as aspects
|
|
154
|
+
tokens = _tokenize(full_text)
|
|
155
|
+
# Pick unique tokens in order, up to 5
|
|
156
|
+
seen: set[str] = set()
|
|
157
|
+
result: list[str] = []
|
|
158
|
+
for t in tokens:
|
|
159
|
+
if t not in seen and len(t) > 2:
|
|
160
|
+
seen.add(t)
|
|
161
|
+
result.append(t)
|
|
162
|
+
if len(result) >= 5:
|
|
163
|
+
break
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
aspect_words = set()
|
|
167
|
+
for a in aspects:
|
|
168
|
+
aspect_words.update(_tokenize(a))
|
|
169
|
+
|
|
170
|
+
words = _tokenize(full_text)
|
|
171
|
+
new_aspects: list[str] = []
|
|
172
|
+
seen_lower = {a.lower() for a in aspects}
|
|
173
|
+
for i in range(len(words) - 1):
|
|
174
|
+
bigram = f"{words[i]} {words[i + 1]}"
|
|
175
|
+
if bigram.lower() not in seen_lower:
|
|
176
|
+
# At least one word overlaps with existing aspects
|
|
177
|
+
if words[i] in aspect_words or words[i + 1] in aspect_words:
|
|
178
|
+
seen_lower.add(bigram.lower())
|
|
179
|
+
new_aspects.append(bigram)
|
|
180
|
+
if len(new_aspects) >= 3: # cap expansion
|
|
181
|
+
break
|
|
182
|
+
return aspects + new_aspects
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Main entry point
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def decompose_task_aspects(task_intent: TaskIntent) -> DecompositionResult:
|
|
190
|
+
"""Decompose *task_intent* into aspects with embedding vectors.
|
|
191
|
+
|
|
192
|
+
Algorithm (§3.2 Phase 1):
|
|
193
|
+
1. Concatenate system_prompt + task_input
|
|
194
|
+
2. Extract noun phrases as explicit aspects
|
|
195
|
+
3. Expand with implicit aspects via dependency analysis
|
|
196
|
+
4. Compute embedding for each aspect + full text
|
|
197
|
+
"""
|
|
198
|
+
system = task_intent.system_prompt or ""
|
|
199
|
+
task = task_intent.task_input or ""
|
|
200
|
+
full_text = f"{system} {task}".strip()
|
|
201
|
+
|
|
202
|
+
if not full_text:
|
|
203
|
+
return DecompositionResult()
|
|
204
|
+
|
|
205
|
+
# Step 1-2: extract explicit aspects from noun phrases
|
|
206
|
+
explicit = _extract_noun_phrases(full_text)
|
|
207
|
+
|
|
208
|
+
# Step 3: implicit expansion
|
|
209
|
+
expanded = _expand_aspects_implicit(explicit, full_text)
|
|
210
|
+
if not expanded:
|
|
211
|
+
expanded = [full_text[:200]] # absolute fallback
|
|
212
|
+
|
|
213
|
+
# Step 4: compute embeddings
|
|
214
|
+
use_ml = _try_load_sentence_transformer()
|
|
215
|
+
|
|
216
|
+
if use_ml and _EMBED_MODEL is not None:
|
|
217
|
+
model = _EMBED_MODEL
|
|
218
|
+
texts_to_embed = expanded + [full_text]
|
|
219
|
+
embeddings = model.encode(texts_to_embed, show_progress_bar=False).tolist() # type: ignore[attr-defined]
|
|
220
|
+
aspect_embs = embeddings[:-1]
|
|
221
|
+
full_emb = embeddings[-1]
|
|
222
|
+
else:
|
|
223
|
+
# Fallback: bag-of-words vectors
|
|
224
|
+
all_tokens = _tokenize(full_text)
|
|
225
|
+
for a in expanded:
|
|
226
|
+
all_tokens.extend(_tokenize(a))
|
|
227
|
+
vocab = _build_vocab(all_tokens)
|
|
228
|
+
aspect_embs = [_bag_vector(_tokenize(a), vocab, _EMBED_DIM) for a in expanded]
|
|
229
|
+
full_emb = _bag_vector(_tokenize(full_text), vocab, _EMBED_DIM)
|
|
230
|
+
|
|
231
|
+
return DecompositionResult(
|
|
232
|
+
aspects=expanded,
|
|
233
|
+
aspect_embeddings=aspect_embs,
|
|
234
|
+
full_embedding=full_emb,
|
|
235
|
+
used_ml_model=use_ml,
|
|
236
|
+
)
|