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,157 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Voice profile — tone, terminology, style extraction from first window (§04 §3.5.1)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class VoiceProfile:
|
|
14
|
+
"""Captured voice characteristics from the first generation window."""
|
|
15
|
+
|
|
16
|
+
tone: str # e.g. "formal", "casual", "technical", "mixed"
|
|
17
|
+
avg_sentence_length: float
|
|
18
|
+
vocabulary_level: str # "basic", "intermediate", "advanced", "technical"
|
|
19
|
+
person: str # "first", "second", "third", "mixed"
|
|
20
|
+
active_voice_ratio: float # 0.0–1.0
|
|
21
|
+
key_terminology: list[str] # domain terms used
|
|
22
|
+
style_markers: dict[str, object] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, object]:
|
|
25
|
+
return {
|
|
26
|
+
"tone": self.tone,
|
|
27
|
+
"avg_sentence_length": self.avg_sentence_length,
|
|
28
|
+
"vocabulary_level": self.vocabulary_level,
|
|
29
|
+
"person": self.person,
|
|
30
|
+
"active_voice_ratio": self.active_voice_ratio,
|
|
31
|
+
"key_terminology": self.key_terminology,
|
|
32
|
+
"style_markers": self.style_markers,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict[str, object]) -> VoiceProfile:
|
|
37
|
+
return cls(
|
|
38
|
+
tone=str(data.get("tone", "neutral")),
|
|
39
|
+
avg_sentence_length=float(data.get("avg_sentence_length", 0)), # type: ignore[arg-type]
|
|
40
|
+
vocabulary_level=str(data.get("vocabulary_level", "intermediate")),
|
|
41
|
+
person=str(data.get("person", "third")),
|
|
42
|
+
active_voice_ratio=float(data.get("active_voice_ratio", 0.5)), # type: ignore[arg-type]
|
|
43
|
+
key_terminology=list(data.get("key_terminology", [])), # type: ignore[call-overload]
|
|
44
|
+
style_markers=dict(data.get("style_markers", {})), # type: ignore[call-overload]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Extraction helpers ───────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
_FORMAL_MARKERS = {
|
|
51
|
+
"therefore", "consequently", "furthermore", "moreover", "nevertheless",
|
|
52
|
+
"notwithstanding", "henceforth", "whereby", "wherein", "herein",
|
|
53
|
+
"accordingly", "albeit", "hitherto", "therein",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_CASUAL_MARKERS = {
|
|
57
|
+
"okay", "ok", "cool", "awesome", "basically", "literally",
|
|
58
|
+
"gonna", "wanna", "kinda", "sorta", "yeah", "nope", "stuff",
|
|
59
|
+
"thing", "actually", "pretty much", "like",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_TECHNICAL_TERMS_PATTERN = re.compile(
|
|
63
|
+
r"\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b" # CamelCase
|
|
64
|
+
r"|\b[a-z]+_[a-z]+\b" # snake_case
|
|
65
|
+
r"|\b[A-Z]{2,}\b" # ACRONYMS
|
|
66
|
+
r"|\b\w+(?:tion|ment|ness|ity|ism|ize|ify)\b" # nominalizations
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_PASSIVE_PATTERN = re.compile(
|
|
70
|
+
r"\b(?:is|are|was|were|be|been|being)\s+(?:\w+\s+)*?\w+(?:ed|en)\b",
|
|
71
|
+
re.IGNORECASE,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_FIRST_PERSON = re.compile(r"\b(?:I|me|my|mine|we|us|our|ours)\b", re.IGNORECASE)
|
|
75
|
+
_SECOND_PERSON = re.compile(r"\b(?:you|your|yours)\b", re.IGNORECASE)
|
|
76
|
+
_THIRD_PERSON = re.compile(r"\b(?:he|she|it|they|him|her|them|his|its|their)\b", re.IGNORECASE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_voice_profile(text: str) -> VoiceProfile:
|
|
80
|
+
"""Extract a voice profile from the first window's output (§04 §3.5.1)."""
|
|
81
|
+
words = text.lower().split()
|
|
82
|
+
word_count = max(1, len(words))
|
|
83
|
+
word_set = set(words)
|
|
84
|
+
|
|
85
|
+
sentences = [s.strip() for s in re.split(r"[.!?]+\s*", text) if s.strip()]
|
|
86
|
+
sentence_count = max(1, len(sentences))
|
|
87
|
+
|
|
88
|
+
# Tone classification
|
|
89
|
+
formal_count = len(word_set & _FORMAL_MARKERS)
|
|
90
|
+
casual_count = len(word_set & _CASUAL_MARKERS)
|
|
91
|
+
tech_matches = _TECHNICAL_TERMS_PATTERN.findall(text)
|
|
92
|
+
|
|
93
|
+
if len(tech_matches) > word_count * 0.05:
|
|
94
|
+
tone = "technical"
|
|
95
|
+
elif formal_count > casual_count:
|
|
96
|
+
tone = "formal"
|
|
97
|
+
elif casual_count > formal_count:
|
|
98
|
+
tone = "casual"
|
|
99
|
+
else:
|
|
100
|
+
tone = "neutral"
|
|
101
|
+
|
|
102
|
+
# Average sentence length
|
|
103
|
+
avg_sentence_length = word_count / sentence_count
|
|
104
|
+
|
|
105
|
+
# Vocabulary level
|
|
106
|
+
unique_ratio = len(word_set) / word_count
|
|
107
|
+
avg_word_len = sum(len(w) for w in words) / word_count
|
|
108
|
+
if avg_word_len > 7 and unique_ratio > 0.6:
|
|
109
|
+
vocabulary_level = "advanced"
|
|
110
|
+
elif avg_word_len > 5.5 or unique_ratio > 0.5:
|
|
111
|
+
vocabulary_level = "intermediate"
|
|
112
|
+
elif len(tech_matches) > 5:
|
|
113
|
+
vocabulary_level = "technical"
|
|
114
|
+
else:
|
|
115
|
+
vocabulary_level = "basic"
|
|
116
|
+
|
|
117
|
+
# Person detection
|
|
118
|
+
first = len(_FIRST_PERSON.findall(text))
|
|
119
|
+
second = len(_SECOND_PERSON.findall(text))
|
|
120
|
+
third = len(_THIRD_PERSON.findall(text))
|
|
121
|
+
total_person = first + second + third
|
|
122
|
+
if total_person == 0:
|
|
123
|
+
person = "third"
|
|
124
|
+
elif first > second and first > third:
|
|
125
|
+
person = "first"
|
|
126
|
+
elif second > first and second > third:
|
|
127
|
+
person = "second"
|
|
128
|
+
elif first > 0 and second > 0:
|
|
129
|
+
person = "mixed"
|
|
130
|
+
else:
|
|
131
|
+
person = "third"
|
|
132
|
+
|
|
133
|
+
# Active voice ratio
|
|
134
|
+
passive_count = len(_PASSIVE_PATTERN.findall(text))
|
|
135
|
+
active_ratio = 1.0 - (passive_count / sentence_count) if sentence_count > 0 else 0.5
|
|
136
|
+
active_ratio = max(0.0, min(1.0, active_ratio))
|
|
137
|
+
|
|
138
|
+
# Key terminology (most frequent technical-looking terms)
|
|
139
|
+
term_counter: Counter[str] = Counter()
|
|
140
|
+
for match in tech_matches:
|
|
141
|
+
term_counter[match] += 1
|
|
142
|
+
key_terms = [t for t, _ in term_counter.most_common(20)]
|
|
143
|
+
|
|
144
|
+
return VoiceProfile(
|
|
145
|
+
tone=tone,
|
|
146
|
+
avg_sentence_length=round(avg_sentence_length, 1),
|
|
147
|
+
vocabulary_level=vocabulary_level,
|
|
148
|
+
person=person,
|
|
149
|
+
active_voice_ratio=round(active_ratio, 2),
|
|
150
|
+
key_terminology=key_terms,
|
|
151
|
+
style_markers={
|
|
152
|
+
"formal_markers": formal_count,
|
|
153
|
+
"casual_markers": casual_count,
|
|
154
|
+
"tech_terms": len(tech_matches),
|
|
155
|
+
"unique_word_ratio": round(unique_ratio, 3),
|
|
156
|
+
},
|
|
157
|
+
)
|
crp/core/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Core protocol — orchestrator, task intent, windows, sessions, config, errors."""
|
|
4
|
+
|
|
5
|
+
from crp.core.batch import BatchResult, dispatch_batch, ingest_batch
|
|
6
|
+
from crp.core.config import ConfigurationResolver, CRPConfig
|
|
7
|
+
from crp.core.errors import (
|
|
8
|
+
BudgetExhaustedError,
|
|
9
|
+
CRPError,
|
|
10
|
+
ProviderError,
|
|
11
|
+
SessionClosedError,
|
|
12
|
+
SessionExpiredError,
|
|
13
|
+
)
|
|
14
|
+
from crp.core.idempotency import CachedResult, RequestDeduplicator, SessionLock
|
|
15
|
+
from crp.core.orchestrator import (
|
|
16
|
+
CRPOrchestrator,
|
|
17
|
+
ExtractionResult,
|
|
18
|
+
StreamEvent,
|
|
19
|
+
)
|
|
20
|
+
from crp.core.session import (
|
|
21
|
+
CostEstimate,
|
|
22
|
+
EnvelopePreview,
|
|
23
|
+
QualityReport,
|
|
24
|
+
RemainingBudget,
|
|
25
|
+
SecurityFlags,
|
|
26
|
+
SessionHandle,
|
|
27
|
+
SessionStatus,
|
|
28
|
+
)
|
|
29
|
+
from crp.core.task_intent import TaskIntent
|
|
30
|
+
from crp.core.window import WindowDAG, WindowMetrics, WindowNode, WindowState
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Orchestrator
|
|
34
|
+
"CRPOrchestrator",
|
|
35
|
+
"ExtractionResult",
|
|
36
|
+
"StreamEvent",
|
|
37
|
+
# Config
|
|
38
|
+
"ConfigurationResolver",
|
|
39
|
+
"CRPConfig",
|
|
40
|
+
# Session
|
|
41
|
+
"SessionHandle",
|
|
42
|
+
"SessionStatus",
|
|
43
|
+
"CostEstimate",
|
|
44
|
+
"EnvelopePreview",
|
|
45
|
+
"QualityReport",
|
|
46
|
+
"RemainingBudget",
|
|
47
|
+
"SecurityFlags",
|
|
48
|
+
# Task
|
|
49
|
+
"TaskIntent",
|
|
50
|
+
# Window
|
|
51
|
+
"WindowDAG",
|
|
52
|
+
"WindowMetrics",
|
|
53
|
+
"WindowNode",
|
|
54
|
+
"WindowState",
|
|
55
|
+
# Errors
|
|
56
|
+
"CRPError",
|
|
57
|
+
"BudgetExhaustedError",
|
|
58
|
+
"ProviderError",
|
|
59
|
+
"SessionClosedError",
|
|
60
|
+
"SessionExpiredError",
|
|
61
|
+
# Batch
|
|
62
|
+
"BatchResult",
|
|
63
|
+
"dispatch_batch",
|
|
64
|
+
"ingest_batch",
|
|
65
|
+
# Idempotency
|
|
66
|
+
"CachedResult",
|
|
67
|
+
"RequestDeduplicator",
|
|
68
|
+
"SessionLock",
|
|
69
|
+
]
|
crp/core/batch.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Batch dispatch and ingest operations (§6.6).
|
|
4
|
+
|
|
5
|
+
dispatch_batch: Fan-out multiple intents in parallel.
|
|
6
|
+
ingest_batch: Batch ingestion with boundary reconciliation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BatchResult:
|
|
18
|
+
"""Result for one item in a batch operation."""
|
|
19
|
+
|
|
20
|
+
index: int = 0
|
|
21
|
+
output: str = ""
|
|
22
|
+
success: bool = True
|
|
23
|
+
error: str | None = None
|
|
24
|
+
facts_extracted: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dispatch_batch(
|
|
28
|
+
intents: list[dict[str, str]],
|
|
29
|
+
dispatch_fn: Callable[[str, str], tuple[str, Any]] | None = None,
|
|
30
|
+
) -> list[BatchResult]:
|
|
31
|
+
"""Batch dispatch with sequential execution (parallel fan-out ready).
|
|
32
|
+
|
|
33
|
+
Each intent should have keys: "system_prompt", "task_input".
|
|
34
|
+
"""
|
|
35
|
+
results: list[BatchResult] = []
|
|
36
|
+
for i, intent in enumerate(intents):
|
|
37
|
+
sys_prompt = intent.get("system_prompt", "")
|
|
38
|
+
task_input = intent.get("task_input", "")
|
|
39
|
+
if dispatch_fn:
|
|
40
|
+
try:
|
|
41
|
+
output, report = dispatch_fn(sys_prompt, task_input)
|
|
42
|
+
results.append(BatchResult(index=i, output=output))
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
results.append(BatchResult(
|
|
45
|
+
index=i, success=False, error=str(exc),
|
|
46
|
+
))
|
|
47
|
+
else:
|
|
48
|
+
results.append(BatchResult(
|
|
49
|
+
index=i, output="[no dispatch_fn]",
|
|
50
|
+
))
|
|
51
|
+
return results
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ingest_batch(
|
|
55
|
+
texts: list[str],
|
|
56
|
+
extract_fn: Callable[[str, str], list[Any]] | None = None,
|
|
57
|
+
task_intent: str = "",
|
|
58
|
+
) -> list[BatchResult]:
|
|
59
|
+
"""Batch ingestion with per-item extraction.
|
|
60
|
+
|
|
61
|
+
Returns extraction results per text item.
|
|
62
|
+
"""
|
|
63
|
+
results: list[BatchResult] = []
|
|
64
|
+
for i, text in enumerate(texts):
|
|
65
|
+
if extract_fn:
|
|
66
|
+
try:
|
|
67
|
+
facts = extract_fn(text, task_intent)
|
|
68
|
+
results.append(BatchResult(
|
|
69
|
+
index=i, facts_extracted=len(facts),
|
|
70
|
+
))
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
results.append(BatchResult(
|
|
73
|
+
index=i, success=False, error=str(exc),
|
|
74
|
+
))
|
|
75
|
+
else:
|
|
76
|
+
results.append(BatchResult(index=i, facts_extracted=0))
|
|
77
|
+
return results
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Circuit breaker for LLM provider calls (§audit H4).
|
|
4
|
+
|
|
5
|
+
Implements the CLOSED → OPEN → HALF-OPEN pattern to prevent
|
|
6
|
+
cascading failures when a provider is unavailable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("crp.core.circuit_breaker")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CircuitState(Enum):
|
|
21
|
+
CLOSED = "closed" # Normal operation
|
|
22
|
+
OPEN = "open" # Failing — reject calls immediately
|
|
23
|
+
HALF_OPEN = "half_open" # Testing recovery — allow one probe call
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CircuitBreakerConfig:
|
|
28
|
+
"""Configuration for the circuit breaker."""
|
|
29
|
+
failure_threshold: int = 5 # consecutive failures to trip OPEN
|
|
30
|
+
recovery_timeout: float = 30.0 # seconds before OPEN → HALF-OPEN
|
|
31
|
+
success_threshold: int = 2 # successes in HALF-OPEN to close
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CircuitBreaker:
|
|
35
|
+
"""Thread-safe circuit breaker for LLM providers."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: CircuitBreakerConfig | None = None) -> None:
|
|
38
|
+
self._config = config or CircuitBreakerConfig()
|
|
39
|
+
self._state = CircuitState.CLOSED
|
|
40
|
+
self._failure_count = 0
|
|
41
|
+
self._success_count = 0
|
|
42
|
+
self._last_failure_time: float = 0.0
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._half_open_probing = False # Only allow one probe (§audit2 CB-M10)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def state(self) -> CircuitState:
|
|
48
|
+
"""Current circuit state (may transition OPEN → HALF-OPEN on read)."""
|
|
49
|
+
with self._lock:
|
|
50
|
+
if self._state == CircuitState.OPEN:
|
|
51
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
52
|
+
if elapsed >= self._config.recovery_timeout:
|
|
53
|
+
self._state = CircuitState.HALF_OPEN
|
|
54
|
+
self._success_count = 0
|
|
55
|
+
self._half_open_probing = False
|
|
56
|
+
logger.info("Circuit breaker → HALF-OPEN (recovery timeout elapsed)")
|
|
57
|
+
return self._state
|
|
58
|
+
|
|
59
|
+
def allow_request(self) -> bool:
|
|
60
|
+
"""Check if a request should be allowed through."""
|
|
61
|
+
with self._lock:
|
|
62
|
+
current = self._state
|
|
63
|
+
if current == CircuitState.OPEN:
|
|
64
|
+
elapsed = time.monotonic() - self._last_failure_time
|
|
65
|
+
if elapsed >= self._config.recovery_timeout:
|
|
66
|
+
self._state = CircuitState.HALF_OPEN
|
|
67
|
+
self._success_count = 0
|
|
68
|
+
self._half_open_probing = False
|
|
69
|
+
current = CircuitState.HALF_OPEN
|
|
70
|
+
if current == CircuitState.CLOSED:
|
|
71
|
+
return True
|
|
72
|
+
if current == CircuitState.HALF_OPEN:
|
|
73
|
+
if self._half_open_probing:
|
|
74
|
+
return False # Only one probe at a time (§audit2 CB-M10)
|
|
75
|
+
self._half_open_probing = True
|
|
76
|
+
return True
|
|
77
|
+
return False # OPEN — reject
|
|
78
|
+
|
|
79
|
+
def record_success(self) -> None:
|
|
80
|
+
"""Record a successful call."""
|
|
81
|
+
with self._lock:
|
|
82
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
83
|
+
self._success_count += 1
|
|
84
|
+
self._half_open_probing = False
|
|
85
|
+
if self._success_count >= self._config.success_threshold:
|
|
86
|
+
self._state = CircuitState.CLOSED
|
|
87
|
+
self._failure_count = 0
|
|
88
|
+
logger.info("Circuit breaker → CLOSED (recovery confirmed)")
|
|
89
|
+
else:
|
|
90
|
+
self._failure_count = 0
|
|
91
|
+
|
|
92
|
+
def record_failure(self) -> None:
|
|
93
|
+
"""Record a failed call."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._failure_count += 1
|
|
96
|
+
self._last_failure_time = time.monotonic()
|
|
97
|
+
|
|
98
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
99
|
+
# Single failure in half-open immediately trips back to open
|
|
100
|
+
self._state = CircuitState.OPEN
|
|
101
|
+
self._half_open_probing = False
|
|
102
|
+
logger.warning("Circuit breaker → OPEN (half-open probe failed)")
|
|
103
|
+
elif self._failure_count >= self._config.failure_threshold:
|
|
104
|
+
self._state = CircuitState.OPEN
|
|
105
|
+
logger.warning(
|
|
106
|
+
"Circuit breaker → OPEN after %d consecutive failures",
|
|
107
|
+
self._failure_count,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def reset(self) -> None:
|
|
111
|
+
"""Reset to closed state."""
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._state = CircuitState.CLOSED
|
|
114
|
+
self._failure_count = 0
|
|
115
|
+
self._success_count = 0
|
|
116
|
+
self._half_open_probing = False
|