contextir 1.0.1__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.
contextir/gateway.py ADDED
@@ -0,0 +1,757 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import re
7
+ import sys
8
+ import time
9
+ from collections import Counter
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Literal
13
+
14
+ from contextir.sir_runtime import PresidioPrivacyScrubber, PrivacyScrubber, SIRRuntime, SIRV1Packet, detect_constraints, guess_intent, load_runtime
15
+ from contextir.sir_sources import normalize
16
+
17
+
18
+ Mode = Literal["auto", "raw", "hybrid", "semantic"]
19
+
20
+
21
+ @dataclass
22
+ class ContextBundle:
23
+ """Private compilation result. Vault and source text never enter the public contract."""
24
+
25
+ contract: dict[str, Any]
26
+ vault: dict[str, str]
27
+ sources: dict[str, str]
28
+
29
+
30
+ @dataclass
31
+ class ContractCheck:
32
+ event_recall: float
33
+ constraint_recall: float
34
+ entity_recall: float
35
+ lost_events: list[str]
36
+ lost_constraints: list[str]
37
+ lost_entities: list[str]
38
+ needs_source: bool
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class TaskProfile:
43
+ kind: Literal["operational", "retrieval", "exhaustive"]
44
+ query_refs: tuple[str, ...] = ()
45
+ evidence_refs: tuple[str, ...] = ()
46
+ retrieval_confidence: float = 0.0
47
+
48
+
49
+ class ContextIR:
50
+ """Adaptive context compiler with an optional lexical SIR enrichment layer."""
51
+
52
+ def __init__(self, runtime: SIRRuntime | None = None, raw_threshold: int = 240, privacy: Any | None = None):
53
+ self.runtime = runtime
54
+ self.scrubber = privacy or (runtime.scrubber if runtime else PrivacyScrubber())
55
+ self.raw_threshold = raw_threshold
56
+
57
+ def compile(
58
+ self,
59
+ text: str,
60
+ source_lang: str = "ru",
61
+ target_lang: str = "ru",
62
+ packet_id: str = "context",
63
+ mode: Mode = "auto",
64
+ ) -> dict[str, Any]:
65
+ return self.compile_private(text, source_lang, target_lang, packet_id, mode).contract
66
+
67
+ def compile_private(
68
+ self,
69
+ text: str,
70
+ source_lang: str = "ru",
71
+ target_lang: str = "ru",
72
+ packet_id: str = "context",
73
+ mode: Mode = "auto",
74
+ ) -> ContextBundle:
75
+ if not text.strip():
76
+ raise ValueError("text must not be empty")
77
+ if mode not in {"auto", "raw", "hybrid", "semantic"}:
78
+ raise ValueError(f"unsupported mode: {mode}")
79
+
80
+ started = time.perf_counter()
81
+ scrubbed = self.scrubber.scrub(text, language=source_lang)
82
+ segments = split_source(scrubbed.scrubbed_text)
83
+ sources = {segment_id: value for segment_id, value in segments}
84
+ task_profile = analyze_task(scrubbed.scrubbed_text, segments)
85
+ normalized_tokens = set(normalize(scrubbed.scrubbed_text).split())
86
+ intent = asdict(guess_intent(scrubbed.scrubbed_text, normalized_tokens=normalized_tokens))
87
+ constraints = detect_constraints(scrubbed.scrubbed_text, normalized_tokens=normalized_tokens)
88
+ if scrubbed.protected_spans:
89
+ constraints.insert(0, {"type": "privacy", "value": "keep_placeholders_private"})
90
+
91
+ retrieval_mode = choose_mode(
92
+ mode,
93
+ len(text),
94
+ task_profile.retrieval_confidence,
95
+ segments,
96
+ self.raw_threshold,
97
+ task_profile,
98
+ )
99
+ if task_profile.kind == "retrieval" and retrieval_mode != "raw":
100
+ events: list[dict[str, Any]] = []
101
+ entities = extract_entities(segments, scrubbed.protected_spans, include_numbers=False)
102
+ confidence = task_profile.retrieval_confidence
103
+ selected_mode = retrieval_mode
104
+ else:
105
+ events = extract_events(segments, source_lang)
106
+ entities = extract_entities(segments, scrubbed.protected_spans)
107
+ confidence = semantic_confidence(events, segments)
108
+ selected_mode = choose_mode(mode, len(text), confidence, segments, self.raw_threshold, task_profile)
109
+ included_refs = select_source_refs(selected_mode, segments, task_profile)
110
+ if task_profile.kind == "retrieval" and selected_mode != "raw":
111
+ included_text = " ".join(sources[ref] for ref in included_refs)
112
+ entities = [item for item in entities if item["value"] in included_text]
113
+ concepts = self._compact_concepts(scrubbed.scrubbed_text, source_lang, packet_id, selected_mode)
114
+ public_spans = [{"placeholder": span.placeholder, "kind": span.kind} for span in scrubbed.protected_spans]
115
+ if task_profile.kind == "retrieval" and selected_mode != "raw":
116
+ public_spans = [item for item in public_spans if item["placeholder"] in included_text]
117
+
118
+ contract: dict[str, Any] = {
119
+ "version": "contextir.v2",
120
+ "id": packet_id,
121
+ "language": {"source": source_lang, "target": target_lang},
122
+ "mode": selected_mode,
123
+ "intent": {"label": intent["label"], "confidence": intent["confidence"]},
124
+ "entities": entities,
125
+ "events": events,
126
+ "constraints": constraints,
127
+ "concepts": concepts,
128
+ "privacy": {"protected": public_spans},
129
+ "source": {
130
+ "refs": list(sources),
131
+ "included": [{"ref": ref, "text": sources[ref]} for ref in included_refs],
132
+ },
133
+ "uncertainty": {
134
+ "semantic_confidence": confidence,
135
+ "requires_source": selected_mode != "semantic",
136
+ },
137
+ }
138
+ compact_chars = len(json.dumps(contract, ensure_ascii=False, separators=(",", ":")))
139
+ prompt_chars = len(self.render_prompt(contract))
140
+ contract["stats"] = {
141
+ "source_chars": len(text),
142
+ "contract_chars": compact_chars,
143
+ "prompt_chars": prompt_chars,
144
+ "prompt_ratio": round(prompt_chars / max(len(text), 1), 4),
145
+ "source_segments": len(segments),
146
+ "included_segments": len(included_refs),
147
+ "latency_ms": round((time.perf_counter() - started) * 1000, 3),
148
+ }
149
+ return ContextBundle(contract=contract, vault=scrubbed.vault, sources=sources)
150
+
151
+ def render_prompt(self, contract: dict[str, Any]) -> str:
152
+ if contract.get("version") != "contextir.v2":
153
+ return json.dumps(contract, ensure_ascii=False, separators=(",", ":"))
154
+ if contract.get("mode") == "raw":
155
+ return " ".join(str(item["text"]) for item in contract["source"]["included"])
156
+ if contract.get("mode") == "hybrid" and contract["source"]["included"]:
157
+ included_refs = {item["ref"] for item in contract["source"]["included"]}
158
+ events_covered = all(event["source_ref"] in included_refs for event in contract.get("events", []))
159
+ if events_covered:
160
+ return "\n\n".join(str(item["text"]) for item in contract["source"]["included"])
161
+ language = contract["language"]
162
+ lines = [
163
+ f"CTXIR/2 mode={contract['mode']} src={language['source']} out={language['target']} intent={contract['intent']['label']}",
164
+ ]
165
+ protected = contract["privacy"]["protected"]
166
+ if protected:
167
+ lines.append("PRIV=" + ",".join(f"{item['placeholder']}:{item['kind']}" for item in protected))
168
+ if contract["constraints"]:
169
+ lines.append("RULE=" + ";".join(f"{item['type']}:{item['value']}" for item in contract["constraints"]))
170
+ if contract["entities"]:
171
+ lines.append("ENT=" + ";".join(f"{item['id']}:{item['type']}={item['value']}" for item in contract["entities"]))
172
+ for event in contract["events"]:
173
+ flags = []
174
+ if event["polarity"] == "negative":
175
+ flags.append("NOT")
176
+ if event["modality"] != "none":
177
+ flags.append(event["modality"].upper())
178
+ if event.get("condition"):
179
+ flags.append("IF")
180
+ args = ",".join(event["arguments"])
181
+ prefix = "+".join(flags) + ":" if flags else ""
182
+ repeats = f"*{event['count']}" if event.get("count", 1) > 1 else ""
183
+ lines.append(f"EV {event['source_ref']}{repeats} {prefix}{event['predicate']}({args})")
184
+ if contract["concepts"]:
185
+ lines.append("TOPIC=" + ",".join(item["label"] for item in contract["concepts"]))
186
+ for item in contract["source"]["included"]:
187
+ lines.append(f"SRC {item['ref']}={item['text']}")
188
+ lines.append("Answer naturally. Preserve RULE, NOT, numbers, and placeholders.")
189
+ return "\n".join(lines)
190
+
191
+ def decompile(self, contract: dict[str, Any], target_lang: str | None = None, include_anchors: bool = False) -> str:
192
+ if contract.get("version") == "contextir.v2":
193
+ included = contract.get("source", {}).get("included", [])
194
+ if included:
195
+ return " ".join(str(item["text"]) for item in included)
196
+ rendered = []
197
+ for event in contract.get("events", []):
198
+ prefix = "not " if event.get("polarity") == "negative" else ""
199
+ rendered.append(f"{prefix}{event.get('predicate', 'state')} {' '.join(event.get('arguments', []))}".strip())
200
+ return ". ".join(rendered)
201
+ return decompile_v1(contract, target_lang, include_anchors)
202
+
203
+ def restore(self, text: str, bundle: ContextBundle, allowed: set[str] | None = None) -> str:
204
+ """Restore only placeholders issued by this compilation and explicitly allowed."""
205
+
206
+ allowlist = set(bundle.vault) if allowed is None else set(bundle.vault) & allowed
207
+ restored = text
208
+ for placeholder in sorted(allowlist, key=len, reverse=True):
209
+ restored = restored.replace(placeholder, bundle.vault[placeholder])
210
+ return restored
211
+
212
+ def compare(self, expected: dict[str, Any], observed: dict[str, Any], threshold: float = 0.9) -> ContractCheck:
213
+ expected_events = {event_signature(item) for item in expected.get("events", [])}
214
+ observed_events = {event_signature(item) for item in observed.get("events", [])}
215
+ expected_constraints = {constraint_signature(item) for item in expected.get("constraints", [])}
216
+ observed_constraints = {constraint_signature(item) for item in observed.get("constraints", [])}
217
+ expected_entities = {entity_signature(item) for item in expected.get("entities", []) if item.get("type") == "number"}
218
+ observed_entities = {entity_signature(item) for item in observed.get("entities", []) if item.get("type") == "number"}
219
+ event_recall = overlap_recall(expected_events, observed_events)
220
+ constraint_recall = overlap_recall(expected_constraints, observed_constraints)
221
+ entity_recall = overlap_recall(expected_entities, observed_entities)
222
+ return ContractCheck(
223
+ event_recall=event_recall,
224
+ constraint_recall=constraint_recall,
225
+ entity_recall=entity_recall,
226
+ lost_events=sorted(expected_events - observed_events),
227
+ lost_constraints=sorted(expected_constraints - observed_constraints),
228
+ lost_entities=sorted(expected_entities - observed_entities),
229
+ needs_source=min(event_recall, constraint_recall, entity_recall) < threshold,
230
+ )
231
+
232
+ def compile_legacy(
233
+ self,
234
+ text: str,
235
+ source_lang: str = "ru",
236
+ target_lang: str = "ru",
237
+ packet_id: str = "contract",
238
+ ) -> dict[str, Any]:
239
+ if not self.runtime:
240
+ raise RuntimeError("legacy compilation requires lexical=True")
241
+ packet, _vault = self.runtime.compile_request(text, source_lang, target_lang, packet_id)
242
+ return packet_to_v1_contract(packet)
243
+
244
+ def _compact_concepts(self, text: str, source_lang: str, packet_id: str, mode: str) -> list[dict[str, Any]]:
245
+ if not self.runtime or mode == "raw":
246
+ return []
247
+ packet = self.runtime.compiler.compile_text(text, source_lang=source_lang, text_id=packet_id, top_k_per_segment=2)
248
+ out = []
249
+ seen: set[str] = set()
250
+ for segment in packet.segments:
251
+ for hit in segment.concepts:
252
+ if hit.concept_id in seen or hit.score < 0.9:
253
+ continue
254
+ seen.add(hit.concept_id)
255
+ label = hit.ru if source_lang == "ru" else hit.en
256
+ out.append({"id": hit.concept_id, "label": label or hit.en or hit.ru, "score": hit.score})
257
+ if len(out) == 8:
258
+ return out
259
+ return out
260
+
261
+
262
+ SIRKernel = ContextIR
263
+
264
+
265
+ ACTION_PATTERNS = [
266
+ ("send", r"\b(send|forward|dispatch|отправ\w*|переда\w*)\b"),
267
+ ("cancel", r"\b(cancel|revoke|отмен\w*)\b"),
268
+ ("redact", r"\b(redact|mask|hide|скры\w*|маскир\w*)\b"),
269
+ ("translate", r"\b(translat\w*|перев\w*)\b"),
270
+ ("compress", r"\b(compress\w*|сжим\w*|компресс\w*)\b"),
271
+ ("preserve", r"\b(preserv\w*|retain\w*|сохран\w*)\b"),
272
+ ("verify", r"\b(check|verify|validat\w*|провер\w*)\b"),
273
+ ("build", r"\b(build|create|implement|созда\w*|постро\w*|реализ\w*)\b"),
274
+ ("answer", r"\b(answer|respond|ответ\w*)\b"),
275
+ ("pay", r"\b(pay|payment|оплат\w*|плат[её]ж\w*)\b"),
276
+ ("call", r"\b(call|phone|звон\w*)\b"),
277
+ ("use", r"\b(use|using|использ\w*)\b"),
278
+ ]
279
+ ACTION_RE = re.compile(
280
+ "|".join(f"(?P<a{index}>{pattern})" for index, (_name, pattern) in enumerate(ACTION_PATTERNS)),
281
+ re.IGNORECASE,
282
+ )
283
+ POLARITY_RE = re.compile(r"\b(no|not|never|не|нельзя|никогда)\b")
284
+ REQUIREMENT_RE = re.compile(r"\b(must|should|need|долж\w*|нужно|надо)\b")
285
+ PROHIBITION_RE = re.compile(r"\b(must not|do not|don't|нельзя|не должен\w*)\b")
286
+ CONDITION_RE = re.compile(r"\b(if|unless|when|если|когда)\b")
287
+ SOURCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+|[\n]+")
288
+ SALIENT_TOKEN_RE = re.compile(r"PII_[A-Z_]+_\d+|[\wё-]+", re.IGNORECASE)
289
+ NUMBER_RE = re.compile(r"(?<!\w)\d+(?:[.,:]\d+)*(?!\w)")
290
+
291
+ STOPWORDS = {
292
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is", "it", "of", "on", "or", "that", "the", "this", "to", "we", "with", "you",
293
+ "а", "без", "бы", "в", "для", "до", "и", "из", "или", "как", "на", "но", "о", "по", "с", "то", "у", "это", "я", "мы", "он", "она", "они",
294
+ "не", "not", "if", "если", "must", "should", "должен", "должна", "нужно", "надо",
295
+ }
296
+
297
+
298
+ def split_source(text: str) -> list[tuple[str, str]]:
299
+ parts = [part.strip() for part in SOURCE_SPLIT_RE.split(text) if part.strip()]
300
+ return [(f"s{index}", part) for index, part in enumerate(parts, 1)]
301
+
302
+
303
+ def extract_events(segments: list[tuple[str, str]], source_lang: str) -> list[dict[str, Any]]:
304
+ events: list[dict[str, Any]] = []
305
+ by_signature: dict[tuple[Any, ...], dict[str, Any]] = {}
306
+ for source_ref, text in segments:
307
+ norm = text.lower()
308
+ action_indexes = {int(match.lastgroup[1:]) for match in ACTION_RE.finditer(norm) if match.lastgroup}
309
+ predicate = ACTION_PATTERNS[min(action_indexes)][0] if action_indexes else "state"
310
+ polarity = "negative" if POLARITY_RE.search(norm) else "positive"
311
+ modality = "requirement" if REQUIREMENT_RE.search(norm) else "none"
312
+ if PROHIBITION_RE.search(norm):
313
+ modality = "prohibition"
314
+ condition = "if" if CONDITION_RE.search(norm) else ""
315
+ arguments = salient_terms(text, predicate)
316
+ confidence = 0.82 if predicate != "state" else 0.48
317
+ if polarity == "negative" or condition:
318
+ confidence = max(confidence, 0.72)
319
+ signature = (predicate, tuple(arguments), polarity, modality, condition)
320
+ existing = by_signature.get(signature)
321
+ if existing:
322
+ existing["count"] += 1
323
+ continue
324
+ event = {
325
+ "id": f"e{len(events) + 1}",
326
+ "predicate": predicate,
327
+ "arguments": arguments,
328
+ "polarity": polarity,
329
+ "modality": modality,
330
+ "condition": condition,
331
+ "source_ref": source_ref,
332
+ "confidence": round(confidence, 2),
333
+ "count": 1,
334
+ }
335
+ events.append(event)
336
+ by_signature[signature] = event
337
+ return events
338
+
339
+
340
+ def salient_terms(text: str, predicate: str, limit: int = 5) -> list[str]:
341
+ tokens = SALIENT_TOKEN_RE.findall(text.lower())
342
+ out = []
343
+ for token in tokens:
344
+ if token in STOPWORDS or len(token) < 3 or token.startswith(predicate):
345
+ continue
346
+ if token not in out:
347
+ out.append(token)
348
+ if len(out) == limit:
349
+ break
350
+ return out
351
+
352
+
353
+ def extract_entities(
354
+ segments: list[tuple[str, str]],
355
+ protected_spans: list[Any],
356
+ include_numbers: bool = True,
357
+ ) -> list[dict[str, str]]:
358
+ entities = []
359
+ for span in protected_spans:
360
+ entities.append({"id": f"p{len(entities) + 1}", "type": span.kind, "value": span.placeholder})
361
+ if not include_numbers:
362
+ return entities
363
+ seen = {item["value"] for item in entities}
364
+ for source_ref, text in segments:
365
+ for value in NUMBER_RE.findall(text):
366
+ if value not in seen:
367
+ entities.append({"id": f"n{len(entities) + 1}", "type": "number", "value": value, "source_ref": source_ref})
368
+ seen.add(value)
369
+ return entities
370
+
371
+
372
+ def semantic_confidence(events: list[dict[str, Any]], segments: list[tuple[str, str]]) -> float:
373
+ if not segments:
374
+ return 0.0
375
+ weighted = sum(float(event["confidence"]) * int(event.get("count", 1)) for event in events)
376
+ return round(weighted / len(segments), 4)
377
+
378
+
379
+ def choose_mode(
380
+ requested: Mode,
381
+ chars: int,
382
+ confidence: float,
383
+ segments: list[tuple[str, str]],
384
+ raw_threshold: int,
385
+ task_profile: TaskProfile | None = None,
386
+ ) -> str:
387
+ if requested != "auto":
388
+ return requested
389
+ if chars <= raw_threshold:
390
+ return "raw"
391
+ if task_profile and task_profile.kind == "exhaustive":
392
+ return "raw"
393
+ if task_profile and task_profile.kind == "retrieval":
394
+ has_evidence = task_profile.evidence_refs and task_profile.retrieval_confidence >= 0.12
395
+ return "hybrid" if has_evidence else "raw"
396
+ critical = sum(1 for _ref, text in segments if is_critical_source(text))
397
+ if confidence < 0.65 or critical:
398
+ return "hybrid"
399
+ return "semantic"
400
+
401
+
402
+ def select_source_refs(
403
+ mode: str,
404
+ segments: list[tuple[str, str]],
405
+ task_profile: TaskProfile | None = None,
406
+ ) -> list[str]:
407
+ if mode == "raw":
408
+ return [ref for ref, _text in segments]
409
+ if mode == "semantic":
410
+ return []
411
+ if task_profile and task_profile.kind == "retrieval":
412
+ selected = set(task_profile.query_refs) | set(task_profile.evidence_refs)
413
+ selected.update(paragraph_owner_refs(segments, task_profile.evidence_refs))
414
+ selected.update(ref for ref, _text in segments[:2])
415
+ selected.update(ref for ref, _text in segments[-2:])
416
+ edge_segments = segments[:4] + segments[-6:]
417
+ selected.update(
418
+ ref
419
+ for ref, text in edge_segments
420
+ if re.search(r"\b(answer|enter|format|output|respond)\b", text, re.IGNORECASE)
421
+ )
422
+ return [ref for ref, _text in segments if ref in selected]
423
+ selected = []
424
+ seen_source = set()
425
+ for ref, text in segments:
426
+ if not is_critical_source(text):
427
+ continue
428
+ signature = " ".join(text.lower().split())
429
+ if signature in seen_source:
430
+ continue
431
+ seen_source.add(signature)
432
+ selected.append(ref)
433
+ if len(selected) == 6:
434
+ break
435
+ if not selected and segments:
436
+ selected.append(segments[0][0])
437
+ selected_set = set(selected)
438
+ selected_set.update(ref for ref, _text in segments[-2:])
439
+ return [ref for ref, _text in segments if ref in selected_set][:8]
440
+
441
+
442
+ def paragraph_owner_refs(segments: list[tuple[str, str]], evidence_refs: tuple[str, ...]) -> set[str]:
443
+ owner_by_ref: dict[str, str] = {}
444
+ current_owner = ""
445
+ for ref, text in segments:
446
+ if re.match(r"Paragraph\s+\d+\s*:", text, re.IGNORECASE):
447
+ current_owner = ref
448
+ owner_by_ref[ref] = current_owner
449
+ return {owner_by_ref[ref] for ref in evidence_refs if owner_by_ref.get(ref)}
450
+
451
+
452
+ EXHAUSTIVE_PATTERNS = [
453
+ r"how many unique paragraphs",
454
+ r"count (?:the )?(?:number|total|occurrences)",
455
+ r"after removing duplicates",
456
+ r"сколько (?:уникальн|различн|всего)",
457
+ r"подсчита\w+ (?:количество|число)",
458
+ ]
459
+
460
+ RETRIEVAL_MARKERS = [
461
+ "based on the above text",
462
+ "based on the following text",
463
+ "which paragraph",
464
+ "abstract is from",
465
+ "read the following text and answer",
466
+ "ответьте на вопрос по тексту",
467
+ "на основе приведенного текста",
468
+ ]
469
+
470
+ RETRIEVAL_STOPWORDS = STOPWORDS | {
471
+ "above", "abstract", "answer", "based", "briefly", "context", "determine", "following",
472
+ "give", "only", "output", "paragraph", "paragraphs", "please", "question", "read",
473
+ "records", "text", "which",
474
+ "ответ", "вопрос", "текст", "только", "прочитайте", "основе",
475
+ }
476
+
477
+
478
+ def analyze_task(text: str, segments: list[tuple[str, str]], evidence_limit: int = 6) -> TaskProfile:
479
+ normalized = text.lower()
480
+ if any(re.search(pattern, normalized, re.IGNORECASE) for pattern in EXHAUSTIVE_PATTERNS):
481
+ return TaskProfile(kind="exhaustive")
482
+ if len(segments) < 6:
483
+ return TaskProfile(kind="operational")
484
+
485
+ query = extract_query(text, segments)
486
+ if not query:
487
+ return TaskProfile(kind="operational")
488
+ query_terms = content_terms(query)
489
+ if not query_terms:
490
+ return TaskProfile(kind="operational")
491
+ normalized_query = " ".join(query.lower().split())
492
+ query_refs = tuple(ref for ref, segment in segments if segment_belongs_to_query(segment, normalized_query))
493
+ evidence, confidence = retrieve_evidence(segments, query_terms, set(query_refs), evidence_limit)
494
+ explicit_retrieval = any(marker in normalized for marker in RETRIEVAL_MARKERS)
495
+ if not explicit_retrieval and confidence < 0.12:
496
+ return TaskProfile(kind="operational")
497
+ return TaskProfile(
498
+ kind="retrieval",
499
+ query_refs=query_refs,
500
+ evidence_refs=tuple(evidence),
501
+ retrieval_confidence=confidence,
502
+ )
503
+
504
+
505
+ def extract_query(text: str, segments: list[tuple[str, str]]) -> str:
506
+ patterns = [
507
+ r"Question:\s*(.+?)(?:\s*Answer:|$)",
508
+ r"The following is an abstract\.\s*(.+?)(?:\s*Please enter|\s*The answer is:|$)",
509
+ ]
510
+ for pattern in patterns:
511
+ matches = list(re.finditer(pattern, text, re.IGNORECASE | re.DOTALL))
512
+ if matches:
513
+ return matches[-1].group(1).strip()
514
+ questions = [segment for _ref, segment in segments if "?" in segment]
515
+ if questions:
516
+ return " ".join(questions[-3:])
517
+ for _ref, segment in segments[:4]:
518
+ if re.search(r"\b(answer|respond)\b", segment, re.IGNORECASE):
519
+ return segment
520
+ return ""
521
+
522
+
523
+ def segment_belongs_to_query(segment: str, normalized_query: str) -> bool:
524
+ segment_norm = " ".join(segment.lower().split())
525
+ return len(segment_norm) >= 8 and segment_norm in normalized_query
526
+
527
+
528
+ def content_terms(text: str) -> set[str]:
529
+ return {
530
+ token
531
+ for token in re.findall(r"[\wё-]+", text.lower(), flags=re.UNICODE)
532
+ if len(token) >= 3 and token not in RETRIEVAL_STOPWORDS and not token.isdigit()
533
+ }
534
+
535
+
536
+ def retrieve_evidence(
537
+ segments: list[tuple[str, str]],
538
+ query_terms: set[str],
539
+ excluded_refs: set[str],
540
+ limit: int,
541
+ ) -> tuple[list[str], float]:
542
+ document_frequency: Counter[str] = Counter()
543
+ terms_by_ref: dict[str, set[str]] = {}
544
+ for ref, segment in segments:
545
+ terms = content_terms(segment)
546
+ terms_by_ref[ref] = terms
547
+ document_frequency.update(terms)
548
+
549
+ total = max(len(segments), 1)
550
+ scored = []
551
+ for index, (ref, _segment) in enumerate(segments):
552
+ if ref in excluded_refs or index < 2 or index >= len(segments) - 2:
553
+ continue
554
+ overlap = query_terms & terms_by_ref[ref]
555
+ if not overlap:
556
+ continue
557
+ score = sum(math.log((total + 1) / (document_frequency[term] + 1)) + 1 for term in overlap)
558
+ score *= 1 + len(overlap) / max(len(query_terms), 1)
559
+ scored.append((score, ref, len(overlap) / max(len(query_terms), 1)))
560
+ scored.sort(key=lambda item: (-item[0], int(item[1][1:])))
561
+ selected = [ref for _score, ref, _coverage in scored[:limit]]
562
+ confidence = max((coverage for _score, _ref, coverage in scored), default=0.0)
563
+ return selected, round(confidence, 4)
564
+
565
+
566
+ def is_critical_source(text: str) -> bool:
567
+ return bool(
568
+ re.search(r"\b(no|not|never|must|should|if|unless|не|нельзя|долж\w*|если|когда)\b", text, re.IGNORECASE)
569
+ or re.search(r"\d|PII_[A-Z_]+_\d+|[`\"']", text)
570
+ )
571
+
572
+
573
+ def event_signature(event: dict[str, Any]) -> str:
574
+ return "|".join(
575
+ [
576
+ str(event.get("predicate", "")),
577
+ str(event.get("polarity", "positive")),
578
+ str(event.get("modality", "none")),
579
+ str(event.get("condition", "")),
580
+ ]
581
+ )
582
+
583
+
584
+ def constraint_signature(item: dict[str, Any]) -> str:
585
+ return f"{item.get('type', '')}|{item.get('value', '')}"
586
+
587
+
588
+ def entity_signature(item: dict[str, Any]) -> str:
589
+ return f"{item.get('type', '')}|{item.get('value', '')}"
590
+
591
+
592
+ def overlap_recall(expected: set[str], observed: set[str]) -> float:
593
+ if not expected:
594
+ return 1.0
595
+ return round(len(expected & observed) / len(expected), 4)
596
+
597
+
598
+ def decompile_v1(contract: dict[str, Any], target_lang: str | None, include_anchors: bool) -> str:
599
+ lang = target_lang or str(contract.get("target_lang") or contract.get("source_lang") or "en")
600
+ segments: dict[int, list[str]] = {}
601
+ concepts = contract.get("concepts", [])
602
+ for item in concepts:
603
+ if not isinstance(item, dict):
604
+ continue
605
+ segment = int(item.get("segment", 0) or 0)
606
+ surface = item.get("surface", {})
607
+ term = str(surface.get(lang) or surface.get("en") or surface.get("ru") or item.get("id", "")) if isinstance(surface, dict) else ""
608
+ if term and term not in segments.setdefault(segment, []):
609
+ segments[segment].append(term)
610
+ text = ". ".join(", ".join(terms[:5]) for _segment, terms in sorted(segments.items()) if terms)
611
+ placeholders = [span.get("placeholder", "") for span in contract.get("protected_spans", []) if isinstance(span, dict)]
612
+ if placeholders:
613
+ suffix = "Protected placeholders: " + ", ".join(placeholders)
614
+ text = f"{text}. {suffix}" if text else suffix
615
+ if include_anchors:
616
+ anchors = " ".join(item["id"] for item in concepts if isinstance(item, dict) and item.get("id"))
617
+ if anchors:
618
+ text = f"{text}\nSIR anchors: {anchors}" if text else f"SIR anchors: {anchors}"
619
+ return text
620
+
621
+
622
+ def packet_to_v1_contract(packet: SIRV1Packet) -> dict[str, Any]:
623
+ data = asdict(packet)
624
+ return {
625
+ "version": data["version"],
626
+ "packet_id": data["packet_id"],
627
+ "source_lang": data["source_lang"],
628
+ "target_lang": data["target_lang"],
629
+ "intent": data["intent"],
630
+ "text": {"scrubbed": data["scrubbed_text"]},
631
+ "concepts": data["concepts"],
632
+ "relations": data["relations"],
633
+ "constraints": data["constraints"],
634
+ "protected_spans": [{"placeholder": span["placeholder"], "kind": span["kind"]} for span in data["protected_spans"]],
635
+ "uncertainty": data["uncertainty"],
636
+ "stats": data["stats"],
637
+ }
638
+
639
+
640
+ packet_to_contract = packet_to_v1_contract
641
+
642
+
643
+ def load_contextir(records: Path | None = None, lexical: bool = False, privacy: str = "regex") -> ContextIR:
644
+ scrubber = PresidioPrivacyScrubber() if privacy == "presidio" else PrivacyScrubber()
645
+ if lexical and records is None:
646
+ default_records = Path(__file__).resolve().parents[1] / "data" / "concepts" / "concept_records.jsonl"
647
+ if not default_records.exists():
648
+ raise RuntimeError(
649
+ "lexical enrichment data is not bundled in the package; "
650
+ "pass records=Path(...) or run from the source checkout"
651
+ )
652
+ records = default_records
653
+ return ContextIR(load_runtime(records) if lexical else None, privacy=scrubber)
654
+
655
+
656
+ def load_kernel(records: Path | None = None) -> ContextIR:
657
+ """Compatibility loader for research scripts that require lexical concepts."""
658
+
659
+ return load_contextir(records, lexical=True)
660
+
661
+
662
+ def read_contract(path: str) -> dict[str, Any]:
663
+ if path == "-":
664
+ import sys
665
+
666
+ return json.loads(sys.stdin.read())
667
+ return json.loads(Path(path).read_text(encoding="utf-8"))
668
+
669
+
670
+ def main() -> None:
671
+ parser = argparse.ArgumentParser(
672
+ prog="contextir",
673
+ description="Compile context, invoke a model, or render ContextIR for an LLM.",
674
+ )
675
+ sub = parser.add_subparsers(dest="cmd", required=True)
676
+ compile_p = sub.add_parser("compile", help="Compile text into ContextIR v2.")
677
+ compile_p.add_argument("--text", required=True)
678
+ compile_p.add_argument("--source-lang", default="ru")
679
+ compile_p.add_argument("--target-lang", default="ru")
680
+ compile_p.add_argument("--packet-id", default="context")
681
+ compile_p.add_argument("--mode", choices=["auto", "raw", "hybrid", "semantic"], default="auto")
682
+ compile_p.add_argument("--lexical", action="store_true", help="Load optional WordNet concept enrichment.")
683
+ compile_p.add_argument("--privacy", choices=["regex", "presidio"], default="regex")
684
+ compile_p.add_argument("--out", default="-")
685
+ render_p = sub.add_parser("render", help="Render a compact model prompt from a contract.")
686
+ render_p.add_argument("--contract", required=True, help="Path to JSON contract, or '-' for stdin.")
687
+ render_p.add_argument("--out", default="-")
688
+ run_p = sub.add_parser("run", help="Compile context and invoke a model endpoint.")
689
+ run_p.add_argument("--text", required=True, help="Input text, or '-' for stdin.")
690
+ run_p.add_argument("--backend", choices=["ollama", "openai"], default="ollama")
691
+ run_p.add_argument("--model", required=True)
692
+ run_p.add_argument("--base-url", default="")
693
+ run_p.add_argument("--api-key-env", default="OPENAI_API_KEY")
694
+ run_p.add_argument("--source-lang", default="en")
695
+ run_p.add_argument("--target-lang", default="en")
696
+ run_p.add_argument("--risk", choices=["low", "standard", "high"], default="standard")
697
+ run_p.add_argument("--task", choices=["reasoning", "transform"], default="reasoning")
698
+ run_p.add_argument("--timeout", type=float, default=180)
699
+ run_p.add_argument("--context-length", type=int, default=32768)
700
+ run_p.add_argument("--max-output-tokens", type=int, default=256)
701
+ run_p.add_argument("--json", action="store_true", help="Emit answer and payload-free trace as JSON.")
702
+ args = parser.parse_args()
703
+
704
+ if args.cmd == "run":
705
+ import os
706
+
707
+ from contextir.clients import OllamaClient, OpenAICompatibleClient
708
+ from contextir.pipeline import ContextPipeline
709
+
710
+ text = sys.stdin.read() if args.text == "-" else args.text
711
+ if args.backend == "ollama":
712
+ client = OllamaClient(
713
+ args.model,
714
+ base_url=args.base_url or "http://127.0.0.1:11434",
715
+ timeout=args.timeout,
716
+ context_length=args.context_length,
717
+ max_output_tokens=args.max_output_tokens,
718
+ )
719
+ else:
720
+ client = OpenAICompatibleClient(
721
+ args.model,
722
+ base_url=args.base_url or "http://127.0.0.1:1234/v1",
723
+ api_key=os.environ.get(args.api_key_env, ""),
724
+ timeout=args.timeout,
725
+ max_output_tokens=args.max_output_tokens,
726
+ )
727
+ result = ContextPipeline(invoke=client).run(
728
+ text,
729
+ source_lang=args.source_lang,
730
+ target_lang=args.target_lang,
731
+ risk=args.risk,
732
+ task=args.task,
733
+ )
734
+ if args.json:
735
+ print(json.dumps({"answer": result.answer, "trace": result.public_trace()}, ensure_ascii=False))
736
+ else:
737
+ print(result.answer)
738
+ if not result.accepted:
739
+ raise SystemExit(2)
740
+ return
741
+
742
+ gateway = load_contextir(lexical=getattr(args, "lexical", False), privacy=getattr(args, "privacy", "regex"))
743
+ if args.cmd == "compile":
744
+ contract = gateway.compile(args.text, args.source_lang, args.target_lang, args.packet_id, args.mode)
745
+ payload = json.dumps(contract, ensure_ascii=False, indent=2) + "\n"
746
+ else:
747
+ payload = gateway.render_prompt(read_contract(args.contract)) + "\n"
748
+ if args.out == "-":
749
+ print(payload, end="")
750
+ else:
751
+ out = Path(args.out)
752
+ out.parent.mkdir(parents=True, exist_ok=True)
753
+ out.write_text(payload, encoding="utf-8")
754
+
755
+
756
+ if __name__ == "__main__":
757
+ main()