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,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
|