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,173 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Stage 1 — Regex extraction (~1ms, MUST).
4
+
5
+ Extracts structured entities: IPs, CVEs, URLs, emails, JSON blocks,
6
+ error codes, versions, ports, and cryptographic hashes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ from crp.extraction.types import Fact
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Pattern registry
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @dataclass(frozen=True)
21
+ class RegexPattern:
22
+ """A single extraction pattern."""
23
+
24
+ name: str
25
+ pattern: re.Pattern[str]
26
+ category: str
27
+ confidence: float = 0.95
28
+
29
+
30
+ # Compiled patterns — order matters for priority in case of overlapping spans.
31
+ _BUILTIN_PATTERNS: list[RegexPattern] = [
32
+ RegexPattern(
33
+ name="ipv4_address",
34
+ pattern=re.compile(
35
+ r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}"
36
+ r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"
37
+ ),
38
+ category="network_entity",
39
+ ),
40
+ RegexPattern(
41
+ name="ipv6_address",
42
+ pattern=re.compile(
43
+ r"\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b"
44
+ r"|"
45
+ r"\b(?:[0-9a-fA-F]{1,4}:){1,7}:\b"
46
+ r"|"
47
+ r"\b::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\b"
48
+ ),
49
+ category="network_entity",
50
+ ),
51
+ RegexPattern(
52
+ name="cve_id",
53
+ pattern=re.compile(r"\bCVE-\d{4}-\d{4,7}\b"),
54
+ category="vulnerability",
55
+ ),
56
+ RegexPattern(
57
+ name="url",
58
+ pattern=re.compile(
59
+ r"https?://[^\s\"'<>\])}]+",
60
+ re.ASCII,
61
+ ),
62
+ category="resource",
63
+ ),
64
+ RegexPattern(
65
+ name="email",
66
+ pattern=re.compile(
67
+ r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b"
68
+ ),
69
+ category="contact",
70
+ ),
71
+ RegexPattern(
72
+ name="json_block",
73
+ pattern=re.compile(r"\{[^{}]{10,}\}", re.DOTALL),
74
+ category="structured_data",
75
+ ),
76
+ RegexPattern(
77
+ name="error_code",
78
+ pattern=re.compile(
79
+ r"\b(?:ERR|ERROR|WARN|FATAL|CRITICAL|E|W)[-_]?\d{3,6}\b",
80
+ re.IGNORECASE,
81
+ ),
82
+ category="error_code",
83
+ ),
84
+ RegexPattern(
85
+ name="semver",
86
+ pattern=re.compile(
87
+ r"\bv?\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.]+)?\b"
88
+ ),
89
+ category="version",
90
+ ),
91
+ RegexPattern(
92
+ name="port",
93
+ pattern=re.compile(
94
+ r"\b(?:port\s+|:)(\d{1,5})\b", re.IGNORECASE
95
+ ),
96
+ category="network_endpoint",
97
+ ),
98
+ RegexPattern(
99
+ name="hash_sha256",
100
+ pattern=re.compile(r"\b[0-9a-fA-F]{64}\b"),
101
+ category="identifier",
102
+ ),
103
+ RegexPattern(
104
+ name="hash_md5",
105
+ pattern=re.compile(r"\b[0-9a-fA-F]{32}\b"),
106
+ category="identifier",
107
+ ),
108
+ ]
109
+
110
+
111
+ class RegexExtractor:
112
+ """Stage 1 — fast regex extraction with extensible pattern registry."""
113
+
114
+ def __init__(self) -> None:
115
+ self._patterns: list[RegexPattern] = list(_BUILTIN_PATTERNS)
116
+ self._custom_patterns: list[RegexPattern] = []
117
+
118
+ # -- Public API ---------------------------------------------------------
119
+
120
+ def register_pattern(
121
+ self,
122
+ name: str,
123
+ pattern: str | re.Pattern[str],
124
+ category: str,
125
+ confidence: float = 0.90,
126
+ ) -> None:
127
+ """Add a user-defined extraction pattern."""
128
+ compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
129
+ rp = RegexPattern(name=name, pattern=compiled, category=category, confidence=confidence)
130
+ self._custom_patterns.append(rp)
131
+
132
+ def extract(
133
+ self,
134
+ text: str,
135
+ source_window_id: str = "",
136
+ ) -> list[Fact]:
137
+ """Extract all regex-matched facts from *text*.
138
+
139
+ Returns de-duplicated facts ordered by position in text.
140
+ """
141
+ seen_spans: set[tuple[int, int]] = set()
142
+ facts: list[tuple[int, Fact]] = [] # (start_pos, Fact)
143
+
144
+ for rp in self._all_patterns:
145
+ for m in rp.pattern.finditer(text):
146
+ span = (m.start(), m.end())
147
+ if span in seen_spans:
148
+ continue
149
+ seen_spans.add(span)
150
+ matched = m.group(0)
151
+ facts.append((
152
+ m.start(),
153
+ Fact(
154
+ text=matched,
155
+ category=rp.category,
156
+ source_window_id=source_window_id,
157
+ confidence=rp.confidence,
158
+ extraction_stage=1,
159
+ metadata={"pattern": rp.name, "span": list(span)},
160
+ ),
161
+ ))
162
+
163
+ # Stable sort by position
164
+ facts.sort(key=lambda t: t[0])
165
+ return [f for _, f in facts]
166
+
167
+ @property
168
+ def _all_patterns(self) -> list[RegexPattern]:
169
+ return self._patterns + self._custom_patterns
170
+
171
+ @property
172
+ def pattern_count(self) -> int:
173
+ return len(self._all_patterns)
@@ -0,0 +1,244 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Stage 2 — Statistical NLP extraction (~5ms, MUST).
4
+
5
+ Pure-Python: TextRank key-sentence extraction, noun-phrase heuristics,
6
+ section-header detection, list-item extraction, and numerical-value extraction.
7
+ No ML model dependencies.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import re
14
+
15
+ from crp.extraction.types import Fact
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Sentence splitting
19
+ # ---------------------------------------------------------------------------
20
+
21
+ _SENT_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z])")
22
+ _WORD_SPLIT = re.compile(r"\w+", re.UNICODE)
23
+
24
+ # Simple stop-word set (top ~100 English)
25
+ _STOP_WORDS: frozenset[str] = frozenset(
26
+ ["a", "about", "above", "after", "again", "against", "all", "am", "an", "and", "any", "are", "aren't", "as", "at", "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "can't", "cannot", "could", "couldn't", "did", "didn't", "do", "does", "doesn't", "doing", "don't", "down", "during", "each", "few", "for", "from", "further", "get", "got", "had", "hadn't", "has", "hasn't", "have", "haven't", "having", "he", "her", "here", "hers", "herself", "him", "himself", "his", "how", "i", "if", "in", "into", "is", "isn't", "it", "its", "itself", "just", "me", "more", "most", "mustn't", "my", "myself", "no", "nor", "not", "of", "off", "on", "once", "only", "or", "other", "our", "ours", "ourselves", "out", "over", "own", "same", "she", "should", "shouldn't", "so", "some", "such", "than", "that", "the", "their", "theirs", "them", "themselves", "then", "there", "these", "they", "this", "those", "through", "to", "too", "under", "until", "up", "very", "was", "wasn't", "we", "were", "weren't", "what", "when", "where", "which", "while", "who", "whom", "why", "will", "with", "won't", "would", "wouldn't", "you", "your", "yours", "yourself", "yourselves"]
27
+ )
28
+
29
+
30
+ def _sentences(text: str) -> list[str]:
31
+ """Split text into sentences."""
32
+ raw = _SENT_SPLIT.split(text.strip())
33
+ return [s.strip() for s in raw if len(s.strip()) > 5]
34
+
35
+
36
+ def _words(text: str) -> list[str]:
37
+ return _WORD_SPLIT.findall(text.lower())
38
+
39
+
40
+ def _content_words(text: str) -> list[str]:
41
+ return [w for w in _words(text) if w not in _STOP_WORDS and len(w) > 2]
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # TextRank (simplified, no external deps)
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _sentence_similarity(s1_words: list[str], s2_words: list[str]) -> float:
49
+ """Jaccard-like overlap between content-word sets."""
50
+ if not s1_words or not s2_words:
51
+ return 0.0
52
+ set1, set2 = set(s1_words), set(s2_words)
53
+ overlap = len(set1 & set2)
54
+ return overlap / (math.log(len(set1) + 1) + math.log(len(set2) + 1) + 1e-9)
55
+
56
+
57
+ def textrank_sentences(
58
+ sentences: list[str],
59
+ top_k: int = 5,
60
+ damping: float = 0.85,
61
+ iterations: int = 30,
62
+ convergence: float = 1e-4,
63
+ ) -> list[tuple[int, float]]:
64
+ """Return indices + scores of top-K sentences via TextRank.
65
+
66
+ Steps:
67
+ 1. Build sentence similarity graph.
68
+ 2. Run PageRank-style iteration.
69
+ 3. Return top-K by score (original order preserved).
70
+ """
71
+ n = len(sentences)
72
+ if n == 0:
73
+ return []
74
+ if n <= top_k:
75
+ return [(i, 1.0) for i in range(n)]
76
+
77
+ # Tokenise once
78
+ tokenised = [_content_words(s) for s in sentences]
79
+
80
+ # Build adjacency weights
81
+ weights: list[list[float]] = [[0.0] * n for _ in range(n)]
82
+ for i in range(n):
83
+ for j in range(i + 1, n):
84
+ sim = _sentence_similarity(tokenised[i], tokenised[j])
85
+ weights[i][j] = sim
86
+ weights[j][i] = sim
87
+
88
+ # Row sums for normalisation
89
+ row_sums = [sum(row) for row in weights]
90
+
91
+ # PageRank iteration
92
+ scores = [1.0 / n] * n
93
+ for _ in range(iterations):
94
+ new_scores = [0.0] * n
95
+ max_delta = 0.0
96
+ for i in range(n):
97
+ rank_sum = 0.0
98
+ for j in range(n):
99
+ if row_sums[j] > 0:
100
+ rank_sum += weights[j][i] / row_sums[j] * scores[j]
101
+ new_scores[i] = (1 - damping) / n + damping * rank_sum
102
+ max_delta = max(max_delta, abs(new_scores[i] - scores[i]))
103
+ scores = new_scores
104
+ if max_delta < convergence:
105
+ break
106
+
107
+ # Top-K indices sorted by score descending, then stable-sort by original position
108
+ ranked = sorted(range(n), key=lambda i: scores[i], reverse=True)[:top_k]
109
+ ranked.sort() # Restore document order
110
+ return [(i, scores[i]) for i in ranked]
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Noun-phrase heuristic (regex, no POS tagger)
115
+ # ---------------------------------------------------------------------------
116
+
117
+ _NP_PATTERN = re.compile(
118
+ r"\b(?:[A-Z][a-z]+(?:\s+(?:of|the|and|for|in|on|with|to)\s+)?){1,4}"
119
+ r"[A-Z][a-z]+\b"
120
+ )
121
+
122
+
123
+ def _extract_noun_phrases(text: str) -> list[str]:
124
+ """Extract capitalised multi-word noun phrases."""
125
+ return list({m.group(0) for m in _NP_PATTERN.finditer(text)})
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Section headers
130
+ # ---------------------------------------------------------------------------
131
+
132
+ _MARKDOWN_HEADER = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
133
+ _UNDERLINE_HEADER = re.compile(r"^(.+)\n[=\-]{3,}$", re.MULTILINE)
134
+
135
+
136
+ def _extract_headers(text: str) -> list[str]:
137
+ headers: list[str] = []
138
+ for m in _MARKDOWN_HEADER.finditer(text):
139
+ headers.append(m.group(2).strip())
140
+ for m in _UNDERLINE_HEADER.finditer(text):
141
+ headers.append(m.group(1).strip())
142
+ return headers
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # List items
147
+ # ---------------------------------------------------------------------------
148
+
149
+ _LIST_ITEM = re.compile(
150
+ r"^[ \t]*(?:[-*+]|\d+[.)])\s+(.+)$", re.MULTILINE
151
+ )
152
+
153
+
154
+ def _extract_list_items(text: str) -> list[str]:
155
+ return [m.group(1).strip() for m in _LIST_ITEM.finditer(text)]
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Numerical values with context
160
+ # ---------------------------------------------------------------------------
161
+
162
+ _NUMBER_CONTEXT = re.compile(
163
+ r"(\b\d+(?:\.\d+)?)\s*(%|ms|s|sec|min|hr|hours?|days?|bytes?|[KMGT]B"
164
+ r"|tokens?|requests?|errors?|users?|GB|MB|KB)\b",
165
+ re.IGNORECASE,
166
+ )
167
+
168
+
169
+ def _extract_numerical(text: str) -> list[str]:
170
+ """Extract numbers with trailing unit context."""
171
+ return [f"{m.group(1)} {m.group(2)}" for m in _NUMBER_CONTEXT.finditer(text)]
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Public API
176
+ # ---------------------------------------------------------------------------
177
+
178
+ class StatisticalExtractor:
179
+ """Stage 2 — statistical NLP extraction (no ML models)."""
180
+
181
+ def __init__(self, top_k_sentences: int = 5) -> None:
182
+ self._top_k = top_k_sentences
183
+
184
+ def extract(
185
+ self,
186
+ text: str,
187
+ source_window_id: str = "",
188
+ ) -> list[Fact]:
189
+ """Extract key sentences, noun phrases, headers, list items, numbers."""
190
+ facts: list[Fact] = []
191
+
192
+ # --- Key sentences (TextRank) ---
193
+ sentences = _sentences(text)
194
+ for idx, score in textrank_sentences(sentences, self._top_k):
195
+ facts.append(Fact(
196
+ text=sentences[idx],
197
+ category="key_sentence",
198
+ source_window_id=source_window_id,
199
+ confidence=min(0.85, 0.75 + score),
200
+ extraction_stage=2,
201
+ metadata={"textrank_score": round(score, 4), "sentence_index": idx},
202
+ ))
203
+
204
+ # --- Noun phrases ---
205
+ for np_text in _extract_noun_phrases(text):
206
+ facts.append(Fact(
207
+ text=np_text,
208
+ category="noun_phrase",
209
+ source_window_id=source_window_id,
210
+ confidence=0.75,
211
+ extraction_stage=2,
212
+ ))
213
+
214
+ # --- Section headers ---
215
+ for header in _extract_headers(text):
216
+ facts.append(Fact(
217
+ text=header,
218
+ category="section_header",
219
+ source_window_id=source_window_id,
220
+ confidence=0.85,
221
+ extraction_stage=2,
222
+ ))
223
+
224
+ # --- List items ---
225
+ for item in _extract_list_items(text):
226
+ facts.append(Fact(
227
+ text=item,
228
+ category="list_item",
229
+ source_window_id=source_window_id,
230
+ confidence=0.80,
231
+ extraction_stage=2,
232
+ ))
233
+
234
+ # --- Numerical values ---
235
+ for num in _extract_numerical(text):
236
+ facts.append(Fact(
237
+ text=num,
238
+ category="numerical_value",
239
+ source_window_id=source_window_id,
240
+ confidence=0.80,
241
+ extraction_stage=2,
242
+ ))
243
+
244
+ return facts
@@ -0,0 +1,210 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Stage 3 — GLiNER zero-shot NER extraction (SHOULD, ~50ms, lazy model load).
4
+
5
+ Trigger: Stage 2 yield < self-calibrated baseline.
6
+ Model: GLiNER (~200MB), loaded lazily, unloaded after 20 idle windows.
7
+ Graceful fallback: if model unavailable, returns empty and logs warning.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ from crp.extraction.types import Fact
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # GLiNER model protocol (for dependency inversion)
22
+ # ---------------------------------------------------------------------------
23
+
24
+ @runtime_checkable
25
+ class GLiNERModel(Protocol):
26
+ """Minimal interface a GLiNER-compatible model must satisfy."""
27
+
28
+ def predict_entities(
29
+ self, text: str, labels: list[str], threshold: float = 0.5
30
+ ) -> list[dict[str, Any]]:
31
+ """Return list of dicts with keys: text, label, score, start, end."""
32
+ ...
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Label derivation from task context
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def derive_labels_from_noun_phrases(noun_phrases: list[str], max_labels: int = 15) -> list[str]:
40
+ """Convert Stage 2 noun phrases into zero-shot NER labels.
41
+
42
+ Strips determiners and lowercases. De-duplicates and caps at *max_labels*.
43
+ """
44
+ labels: list[str] = []
45
+ seen: set[str] = set()
46
+ for np_text in noun_phrases:
47
+ # Simple normalisation
48
+ label = np_text.strip().lower()
49
+ for prefix in ("the ", "a ", "an "):
50
+ if label.startswith(prefix):
51
+ label = label[len(prefix):]
52
+ if label and label not in seen:
53
+ seen.add(label)
54
+ labels.append(label)
55
+ if len(labels) >= max_labels:
56
+ break
57
+ return labels
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Stage 3 Extractor
62
+ # ---------------------------------------------------------------------------
63
+
64
+ class GLiNERExtractor:
65
+ """Stage 3 — zero-shot NER via GLiNER (lazy, optional).
66
+
67
+ The model is loaded on first call and unloaded after *idle_limit* windows
68
+ without a call. If ``gliner`` is not installed, all calls return ``[]``.
69
+ """
70
+
71
+ def __init__(self, idle_limit: int = 20) -> None:
72
+ self._model: GLiNERModel | None = None
73
+ self._idle_limit = idle_limit
74
+ self._windows_since_use: int = 0
75
+ self._available: bool | None = None # None = not yet probed
76
+
77
+ # -- Lifecycle ----------------------------------------------------------
78
+
79
+ @staticmethod
80
+ def _is_model_cached() -> bool:
81
+ """Check if the GLiNER model is already cached locally.
82
+
83
+ Checks the HuggingFace hub cache directory for the model files.
84
+ Returns True if cached, False if a download would be required.
85
+ """
86
+ try:
87
+ from pathlib import Path
88
+ # HuggingFace hub default cache location
89
+ hf_home = os.environ.get("HF_HOME", os.environ.get(
90
+ "HUGGINGFACE_HUB_CACHE",
91
+ str(Path.home() / ".cache" / "huggingface" / "hub"),
92
+ ))
93
+ cache_dir = Path(hf_home) / "models--urchade--gliner_base"
94
+ if cache_dir.is_dir():
95
+ # Check that snapshots exist (actual model files)
96
+ snapshots = cache_dir / "snapshots"
97
+ if snapshots.is_dir() and any(snapshots.iterdir()):
98
+ return True
99
+ except Exception: # noqa: BLE001
100
+ pass
101
+ return False
102
+
103
+ def _ensure_model(self) -> GLiNERModel | None:
104
+ """Lazy-load GLiNER. Returns None if unavailable.
105
+
106
+ If the model is not cached locally and we appear to be offline,
107
+ skips the download attempt rather than hanging on a network call.
108
+ """
109
+ if self._available is False:
110
+ return None
111
+ if self._model is not None:
112
+ return self._model
113
+
114
+ # Check cache before attempting download — if not cached and
115
+ # offline, avoid a long timeout or network failure.
116
+ if not self._is_model_cached():
117
+ # Quick connectivity probe: can we reach huggingface.co?
118
+ import socket
119
+ try:
120
+ socket.create_connection(("huggingface.co", 443), timeout=3)
121
+ except OSError:
122
+ self._available = False
123
+ logger.warning(
124
+ "GLiNER model not cached and network unreachable — "
125
+ "Stage 3 disabled. Pre-download with: "
126
+ 'python -c "from gliner import GLiNER; '
127
+ "GLiNER.from_pretrained('urchade/gliner_base')\""
128
+ )
129
+ return None
130
+
131
+ try:
132
+ from gliner import GLiNER # type: ignore[import-untyped]
133
+
134
+ self._model = GLiNER.from_pretrained("urchade/gliner_base") # type: ignore[assignment]
135
+ self._available = True
136
+ logger.info("GLiNER model loaded successfully")
137
+ return self._model
138
+ except Exception:
139
+ self._available = False
140
+ logger.warning("GLiNER not available — Stage 3 will be skipped")
141
+ return None
142
+
143
+ def unload(self) -> None:
144
+ """Release model from memory."""
145
+ self._model = None
146
+ logger.info("GLiNER model unloaded (idle)")
147
+
148
+ def tick_idle(self) -> None:
149
+ """Called once per window. Unloads model after idle_limit."""
150
+ if self._model is not None:
151
+ self._windows_since_use += 1
152
+ if self._windows_since_use >= self._idle_limit:
153
+ self.unload()
154
+
155
+ @property
156
+ def is_available(self) -> bool:
157
+ if self._available is None:
158
+ self._ensure_model()
159
+ return self._available is True
160
+
161
+ # -- Extraction ---------------------------------------------------------
162
+
163
+ def extract(
164
+ self,
165
+ text: str,
166
+ labels: list[str] | None = None,
167
+ source_window_id: str = "",
168
+ threshold: float = 0.5,
169
+ ) -> list[Fact]:
170
+ """Run zero-shot NER over *text* using *labels*.
171
+
172
+ If no labels provided, uses a small set of generic security/tech labels.
173
+ Returns ``[]`` if model is unavailable — never raises.
174
+ """
175
+ model = self._ensure_model()
176
+ if model is None:
177
+ return []
178
+
179
+ self._windows_since_use = 0 # Reset idle counter
180
+
181
+ if not labels:
182
+ labels = [
183
+ "vulnerability", "server", "endpoint", "credential",
184
+ "configuration", "service", "network", "error",
185
+ "tool", "technique", "software", "organization",
186
+ ]
187
+
188
+ try:
189
+ entities = model.predict_entities(text, labels, threshold=threshold)
190
+ except Exception:
191
+ logger.exception("GLiNER prediction failed")
192
+ return []
193
+
194
+ facts: list[Fact] = []
195
+ for ent in entities:
196
+ confidence = float(ent.get("score", 0.65))
197
+ facts.append(Fact(
198
+ text=str(ent.get("text", "")),
199
+ category=str(ent.get("label", "entity")),
200
+ source_window_id=source_window_id,
201
+ confidence=min(0.85, max(0.65, confidence)),
202
+ extraction_stage=3,
203
+ metadata={
204
+ "label": ent.get("label"),
205
+ "gliner_score": round(confidence, 4),
206
+ "span": [ent.get("start", 0), ent.get("end", 0)],
207
+ },
208
+ ))
209
+
210
+ return facts