cognitive-modules 0.4.0__py3-none-any.whl → 0.5.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.
cognitive/runner.py CHANGED
@@ -1,11 +1,17 @@
1
1
  """
2
2
  Module Runner - Execute cognitive modules with validation.
3
- Supports v2 envelope format and legacy formats.
3
+ Supports v2.2 envelope format with Control/Data plane separation.
4
+
5
+ v2.2 Features:
6
+ - meta (Control Plane): confidence, risk, explain
7
+ - data (Data Plane): business payload + rationale
8
+ - Repair pass for schema validation failures
9
+ - Auto-wrap v2.1 payloads to v2.2 envelope
4
10
  """
5
11
 
6
12
  import json
7
13
  from pathlib import Path
8
- from typing import Optional, TypedDict, Union
14
+ from typing import Optional, TypedDict, Union, Literal
9
15
 
10
16
  import jsonschema
11
17
  import yaml
@@ -15,25 +21,112 @@ from .loader import load_module
15
21
  from .providers import call_llm
16
22
 
17
23
 
24
+ # =============================================================================
25
+ # Type Definitions (v2.2)
26
+ # =============================================================================
27
+
28
+ RiskLevel = Literal["none", "low", "medium", "high"]
29
+
30
+ class EnvelopeMeta(TypedDict, total=False):
31
+ """Control plane metadata - unified across all modules."""
32
+ confidence: float # 0-1
33
+ risk: RiskLevel
34
+ explain: str # max 280 chars
35
+ trace_id: str
36
+ model: str
37
+ latency_ms: float
38
+
39
+
18
40
  class EnvelopeError(TypedDict):
19
41
  code: str
20
42
  message: str
21
43
 
22
44
 
23
- class EnvelopeSuccess(TypedDict):
24
- ok: bool # True
45
+ class EnvelopeSuccessV22(TypedDict):
46
+ ok: Literal[True]
47
+ meta: EnvelopeMeta
48
+ data: dict
49
+
50
+
51
+ class EnvelopeFailureV22(TypedDict, total=False):
52
+ ok: Literal[False]
53
+ meta: EnvelopeMeta
54
+ error: EnvelopeError
55
+ partial_data: Optional[dict]
56
+
57
+
58
+ EnvelopeResponseV22 = Union[EnvelopeSuccessV22, EnvelopeFailureV22]
59
+
60
+
61
+ # Legacy types for compatibility
62
+ class EnvelopeSuccessV21(TypedDict):
63
+ ok: Literal[True]
25
64
  data: dict
26
65
 
27
66
 
28
- class EnvelopeFailure(TypedDict):
29
- ok: bool # False
67
+ class EnvelopeFailureV21(TypedDict, total=False):
68
+ ok: Literal[False]
30
69
  error: EnvelopeError
31
70
  partial_data: Optional[dict]
32
71
 
33
72
 
34
- EnvelopeResponse = Union[EnvelopeSuccess, EnvelopeFailure]
73
+ EnvelopeResponse = Union[EnvelopeResponseV22, EnvelopeSuccessV21, EnvelopeFailureV21]
74
+
75
+
76
+ # =============================================================================
77
+ # Risk Aggregation
78
+ # =============================================================================
79
+
80
+ RISK_LEVELS = {"none": 0, "low": 1, "medium": 2, "high": 3}
81
+ RISK_NAMES = ["none", "low", "medium", "high"]
82
+
83
+ RiskRule = Literal["max_changes_risk", "max_issues_risk", "explicit"]
35
84
 
36
85
 
86
+ def aggregate_risk_from_list(items: list[dict]) -> RiskLevel:
87
+ """Compute max risk from list of items with risk field."""
88
+ if not items:
89
+ return "medium" # Default conservative
90
+
91
+ max_level = 0
92
+ for item in items:
93
+ risk = item.get("risk", "medium")
94
+ level = RISK_LEVELS.get(risk, 2)
95
+ max_level = max(max_level, level)
96
+
97
+ return RISK_NAMES[max_level]
98
+
99
+
100
+ def aggregate_risk(
101
+ data: dict,
102
+ risk_rule: RiskRule = "max_changes_risk"
103
+ ) -> RiskLevel:
104
+ """
105
+ Compute aggregated risk based on risk_rule.
106
+
107
+ Rules:
108
+ - max_changes_risk: max(data.changes[*].risk) - default
109
+ - max_issues_risk: max(data.issues[*].risk) - for review modules
110
+ - explicit: return "medium", module should set risk explicitly
111
+ """
112
+ if risk_rule == "max_changes_risk":
113
+ changes = data.get("changes", [])
114
+ return aggregate_risk_from_list(changes)
115
+ elif risk_rule == "max_issues_risk":
116
+ issues = data.get("issues", [])
117
+ return aggregate_risk_from_list(issues)
118
+ elif risk_rule == "explicit":
119
+ return "medium" # Module should override
120
+ else:
121
+ # Fallback to changes
122
+ changes = data.get("changes", [])
123
+ return aggregate_risk_from_list(changes)
124
+
125
+
126
+ # =============================================================================
127
+ # Schema Validation
128
+ # =============================================================================
129
+
37
130
  def validate_data(data: dict, schema: dict, label: str = "Data") -> list[str]:
38
131
  """Validate data against schema. Returns list of errors."""
39
132
  errors = []
@@ -48,9 +141,193 @@ def validate_data(data: dict, schema: dict, label: str = "Data") -> list[str]:
48
141
  return errors
49
142
 
50
143
 
144
+ # =============================================================================
145
+ # Repair Pass (v2.2)
146
+ # =============================================================================
147
+
148
+ def repair_envelope(
149
+ data: dict,
150
+ meta_schema: Optional[dict] = None,
151
+ max_explain_length: int = 280,
152
+ risk_rule: RiskRule = "max_changes_risk"
153
+ ) -> dict:
154
+ """
155
+ Attempt to repair envelope format issues without changing semantics.
156
+
157
+ Repairs (lossless only):
158
+ - Missing meta fields (fill with conservative defaults)
159
+ - Truncate explain if too long
160
+ - Trim whitespace from string fields
161
+
162
+ Does NOT repair:
163
+ - Invalid enum values (treated as validation failure)
164
+ """
165
+ repaired = dict(data)
166
+
167
+ # Ensure meta exists
168
+ if "meta" not in repaired:
169
+ repaired["meta"] = {}
170
+
171
+ meta = repaired["meta"]
172
+ data_payload = repaired.get("data", {})
173
+
174
+ # Repair confidence
175
+ if "confidence" not in meta:
176
+ # Try to extract from data (v2.1 compatibility)
177
+ meta["confidence"] = data_payload.get("confidence", 0.5)
178
+
179
+ # Ensure confidence is in valid range
180
+ if isinstance(meta.get("confidence"), (int, float)):
181
+ meta["confidence"] = max(0.0, min(1.0, float(meta["confidence"])))
182
+
183
+ # Repair risk - use configurable aggregation rule
184
+ if "risk" not in meta:
185
+ meta["risk"] = aggregate_risk(data_payload, risk_rule)
186
+
187
+ # Trim whitespace from risk (lossless), but do NOT invent new values
188
+ if isinstance(meta.get("risk"), str):
189
+ meta["risk"] = meta["risk"].strip().lower()
190
+ # If invalid after trim, leave as-is (validation will catch it)
191
+
192
+ # Repair explain
193
+ if "explain" not in meta:
194
+ # Try to extract from rationale
195
+ rationale = data_payload.get("rationale", "")
196
+ if rationale:
197
+ meta["explain"] = str(rationale)[:max_explain_length]
198
+ else:
199
+ meta["explain"] = "No explanation provided"
200
+
201
+ # Trim whitespace from explain (lossless)
202
+ if isinstance(meta.get("explain"), str):
203
+ meta["explain"] = meta["explain"].strip()
204
+
205
+ # Truncate explain if too long
206
+ if len(meta.get("explain", "")) > max_explain_length:
207
+ meta["explain"] = meta["explain"][:max_explain_length - 3] + "..."
208
+
209
+ return repaired
210
+
211
+
212
+ def repair_error_envelope(
213
+ data: dict,
214
+ max_explain_length: int = 280
215
+ ) -> dict:
216
+ """Repair error envelope format."""
217
+ repaired = dict(data)
218
+
219
+ # Ensure meta exists for errors
220
+ if "meta" not in repaired:
221
+ repaired["meta"] = {}
222
+
223
+ meta = repaired["meta"]
224
+
225
+ # Set default meta for errors
226
+ if "confidence" not in meta:
227
+ meta["confidence"] = 0.0
228
+ if "risk" not in meta:
229
+ meta["risk"] = "high"
230
+ if "explain" not in meta:
231
+ error = repaired.get("error", {})
232
+ meta["explain"] = error.get("message", "An error occurred")[:max_explain_length]
233
+
234
+ return repaired
235
+
236
+
237
+ # =============================================================================
238
+ # Envelope Detection & Conversion
239
+ # =============================================================================
240
+
241
+ def is_envelope_response(data: dict) -> bool:
242
+ """Check if response is in envelope format (v2.1 or v2.2)."""
243
+ return isinstance(data.get("ok"), bool)
244
+
245
+
246
+ def is_v22_envelope(data: dict) -> bool:
247
+ """Check if response is in v2.2 envelope format (has meta)."""
248
+ return is_envelope_response(data) and "meta" in data
249
+
250
+
251
+ def wrap_v21_to_v22(v21_response: dict) -> EnvelopeResponseV22:
252
+ """
253
+ Convert v2.1 envelope to v2.2 envelope.
254
+ Adds meta field by extracting/computing from data.
255
+ """
256
+ if is_v22_envelope(v21_response):
257
+ return v21_response # Already v2.2
258
+
259
+ if v21_response.get("ok") is True:
260
+ data = v21_response.get("data", {})
261
+
262
+ # Extract or compute meta fields
263
+ confidence = data.get("confidence", 0.5)
264
+ rationale = data.get("rationale", "")
265
+
266
+ return {
267
+ "ok": True,
268
+ "meta": {
269
+ "confidence": confidence,
270
+ "risk": aggregate_risk(data), # Uses default max_changes_risk
271
+ "explain": rationale[:280] if rationale else "No explanation provided"
272
+ },
273
+ "data": data
274
+ }
275
+ else:
276
+ error = v21_response.get("error", {"code": "UNKNOWN", "message": "Unknown error"})
277
+
278
+ return {
279
+ "ok": False,
280
+ "meta": {
281
+ "confidence": 0.0,
282
+ "risk": "high",
283
+ "explain": error.get("message", "An error occurred")[:280]
284
+ },
285
+ "error": error,
286
+ "partial_data": v21_response.get("partial_data")
287
+ }
288
+
289
+
290
+ def convert_legacy_to_envelope(data: dict, is_error: bool = False) -> EnvelopeResponseV22:
291
+ """Convert legacy format (no envelope) to v2.2 envelope."""
292
+ if is_error or "error" in data:
293
+ error = data.get("error", {})
294
+ error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
295
+
296
+ return {
297
+ "ok": False,
298
+ "meta": {
299
+ "confidence": 0.0,
300
+ "risk": "high",
301
+ "explain": error_msg[:280]
302
+ },
303
+ "error": {
304
+ "code": error.get("code", "UNKNOWN") if isinstance(error, dict) else "UNKNOWN",
305
+ "message": error_msg
306
+ },
307
+ "partial_data": None
308
+ }
309
+ else:
310
+ # Legacy success response - data is the payload itself
311
+ confidence = data.get("confidence", 0.5)
312
+ rationale = data.get("rationale", "")
313
+
314
+ return {
315
+ "ok": True,
316
+ "meta": {
317
+ "confidence": confidence,
318
+ "risk": aggregate_risk(data), # Uses default max_changes_risk
319
+ "explain": rationale[:280] if rationale else "No explanation provided"
320
+ },
321
+ "data": data
322
+ }
323
+
324
+
325
+ # =============================================================================
326
+ # Prompt Building
327
+ # =============================================================================
328
+
51
329
  def substitute_arguments(text: str, input_data: dict) -> str:
52
330
  """Substitute $ARGUMENTS and $N placeholders in text."""
53
- # Get arguments
54
331
  args_value = input_data.get("$ARGUMENTS", input_data.get("query", input_data.get("code", "")))
55
332
 
56
333
  # Replace $ARGUMENTS
@@ -66,7 +343,7 @@ def substitute_arguments(text: str, input_data: dict) -> str:
66
343
  return text
67
344
 
68
345
 
69
- def build_prompt(module: dict, input_data: dict, use_envelope: bool = False) -> str:
346
+ def build_prompt(module: dict, input_data: dict, use_envelope: bool = False, use_v22: bool = False) -> str:
70
347
  """Build the complete prompt for the LLM."""
71
348
  # Substitute $ARGUMENTS in prompt
72
349
  prompt = substitute_arguments(module["prompt"], input_data)
@@ -82,13 +359,23 @@ def build_prompt(module: dict, input_data: dict, use_envelope: bool = False) ->
82
359
  ]
83
360
 
84
361
  if use_envelope:
85
- parts.extend([
86
- "\n## Response Format (Envelope)\n",
87
- "You MUST wrap your response in the envelope format:\n",
88
- "- Success: { \"ok\": true, \"data\": { ...your output... } }\n",
89
- "- Error: { \"ok\": false, \"error\": { \"code\": \"ERROR_CODE\", \"message\": \"...\" } }\n",
90
- "Return ONLY valid JSON.\n",
91
- ])
362
+ if use_v22:
363
+ parts.extend([
364
+ "\n## Response Format (Envelope v2.2)\n",
365
+ "You MUST wrap your response in the v2.2 envelope format with separate meta and data:\n",
366
+ "- Success: { \"ok\": true, \"meta\": { \"confidence\": 0.9, \"risk\": \"low\", \"explain\": \"short summary\" }, \"data\": { ...payload... } }\n",
367
+ "- Error: { \"ok\": false, \"meta\": { \"confidence\": 0.0, \"risk\": \"high\", \"explain\": \"error summary\" }, \"error\": { \"code\": \"ERROR_CODE\", \"message\": \"...\" } }\n",
368
+ "Note: meta.explain must be ≤280 characters. data.rationale can be longer for detailed reasoning.\n",
369
+ "Return ONLY valid JSON.\n",
370
+ ])
371
+ else:
372
+ parts.extend([
373
+ "\n## Response Format (Envelope)\n",
374
+ "You MUST wrap your response in the envelope format:\n",
375
+ "- Success: { \"ok\": true, \"data\": { ...your output... } }\n",
376
+ "- Error: { \"ok\": false, \"error\": { \"code\": \"ERROR_CODE\", \"message\": \"...\" } }\n",
377
+ "Return ONLY valid JSON.\n",
378
+ ])
92
379
  else:
93
380
  parts.extend([
94
381
  "\n## Instructions\n",
@@ -99,6 +386,10 @@ def build_prompt(module: dict, input_data: dict, use_envelope: bool = False) ->
99
386
  return "".join(parts)
100
387
 
101
388
 
389
+ # =============================================================================
390
+ # LLM Response Parsing
391
+ # =============================================================================
392
+
102
393
  def parse_llm_response(response: str) -> dict:
103
394
  """Parse LLM response, handling potential markdown code blocks."""
104
395
  text = response.strip()
@@ -117,44 +408,9 @@ def parse_llm_response(response: str) -> dict:
117
408
  return json.loads(text)
118
409
 
119
410
 
120
- def is_envelope_response(data: dict) -> bool:
121
- """Check if response is in envelope format."""
122
- return isinstance(data.get("ok"), bool)
123
-
124
-
125
- def parse_envelope_response(data: dict) -> EnvelopeResponse:
126
- """Parse and normalize envelope response."""
127
- if data.get("ok") is True:
128
- return {
129
- "ok": True,
130
- "data": data.get("data", {})
131
- }
132
- else:
133
- return {
134
- "ok": False,
135
- "error": data.get("error", {"code": "UNKNOWN", "message": "Unknown error"}),
136
- "partial_data": data.get("partial_data")
137
- }
138
-
139
-
140
- def convert_to_envelope(data: dict, is_error: bool = False) -> EnvelopeResponse:
141
- """Convert legacy format to envelope format."""
142
- if is_error or "error" in data:
143
- error = data.get("error", {})
144
- return {
145
- "ok": False,
146
- "error": {
147
- "code": error.get("code", "UNKNOWN"),
148
- "message": error.get("message", str(error))
149
- },
150
- "partial_data": None
151
- }
152
- else:
153
- return {
154
- "ok": True,
155
- "data": data
156
- }
157
-
411
+ # =============================================================================
412
+ # Main Runner
413
+ # =============================================================================
158
414
 
159
415
  def run_module(
160
416
  name_or_path: str,
@@ -163,10 +419,12 @@ def run_module(
163
419
  validate_output: bool = True,
164
420
  model: Optional[str] = None,
165
421
  use_envelope: Optional[bool] = None,
166
- ) -> EnvelopeResponse:
422
+ use_v22: Optional[bool] = None,
423
+ enable_repair: bool = True,
424
+ ) -> EnvelopeResponseV22:
167
425
  """
168
426
  Run a cognitive module with the given input.
169
- Returns envelope format response.
427
+ Returns v2.2 envelope format response.
170
428
 
171
429
  Args:
172
430
  name_or_path: Module name or path to module directory
@@ -175,10 +433,15 @@ def run_module(
175
433
  validate_output: Whether to validate output against schema
176
434
  model: Optional model override
177
435
  use_envelope: Force envelope format (auto-detect if None)
436
+ use_v22: Force v2.2 envelope format (auto-detect if None)
437
+ enable_repair: Enable repair pass for validation failures
178
438
 
179
439
  Returns:
180
- EnvelopeResponse with ok=True/False and data/error
440
+ EnvelopeResponseV22 with ok=True/False, meta, and data/error
181
441
  """
442
+ import time
443
+ start_time = time.time()
444
+
182
445
  # Find module path
183
446
  path = Path(name_or_path)
184
447
  if path.exists() and path.is_dir():
@@ -188,6 +451,11 @@ def run_module(
188
451
  if not module_path:
189
452
  return {
190
453
  "ok": False,
454
+ "meta": {
455
+ "confidence": 1.0,
456
+ "risk": "high",
457
+ "explain": f"Module '{name_or_path}' not found."
458
+ },
191
459
  "error": {"code": "MODULE_NOT_FOUND", "message": f"Module not found: {name_or_path}"},
192
460
  "partial_data": None
193
461
  }
@@ -195,57 +463,118 @@ def run_module(
195
463
  # Load module (auto-detects format)
196
464
  module = load_module(module_path)
197
465
 
198
- # Determine if we should use envelope format
466
+ # Determine envelope version
467
+ compat = module.get("compat", {})
468
+ is_v22_module = module.get("tier") is not None or "meta_schema" in module
469
+
199
470
  should_use_envelope = use_envelope
200
471
  if should_use_envelope is None:
201
- # Auto-detect: use envelope for v2 format or if output.envelope is True
202
472
  output_contract = module.get("output_contract", {})
203
473
  should_use_envelope = (
204
474
  module.get("format") == "v2" or
205
475
  output_contract.get("envelope", False)
206
476
  )
207
477
 
478
+ should_use_v22 = use_v22
479
+ if should_use_v22 is None:
480
+ should_use_v22 = is_v22_module or compat.get("runtime_auto_wrap", False)
481
+
208
482
  # Validate input
209
- if validate_input and module["input_schema"]:
483
+ if validate_input and module.get("input_schema"):
210
484
  errors = validate_data(input_data, module["input_schema"], "Input")
211
485
  if errors:
212
486
  return {
213
487
  "ok": False,
488
+ "meta": {
489
+ "confidence": 1.0,
490
+ "risk": "none",
491
+ "explain": "Input validation failed."
492
+ },
214
493
  "error": {"code": "INVALID_INPUT", "message": str(errors)},
215
494
  "partial_data": None
216
495
  }
217
496
 
218
497
  # Build prompt and call LLM
219
- full_prompt = build_prompt(module, input_data, use_envelope=should_use_envelope)
498
+ full_prompt = build_prompt(
499
+ module,
500
+ input_data,
501
+ use_envelope=should_use_envelope,
502
+ use_v22=should_use_v22
503
+ )
220
504
  response = call_llm(full_prompt, model=model)
221
505
 
506
+ # Calculate latency
507
+ latency_ms = (time.time() - start_time) * 1000
508
+
222
509
  # Parse response
223
510
  try:
224
511
  output_data = parse_llm_response(response)
225
512
  except json.JSONDecodeError as e:
226
513
  return {
227
514
  "ok": False,
515
+ "meta": {
516
+ "confidence": 0.0,
517
+ "risk": "high",
518
+ "explain": "Failed to parse LLM response as JSON."
519
+ },
228
520
  "error": {"code": "PARSE_ERROR", "message": f"Failed to parse JSON: {e}"},
229
521
  "partial_data": None
230
522
  }
231
523
 
232
- # Handle envelope format
233
- if is_envelope_response(output_data):
234
- result = parse_envelope_response(output_data)
524
+ # Convert to v2.2 envelope
525
+ if is_v22_envelope(output_data):
526
+ result = output_data
527
+ elif is_envelope_response(output_data):
528
+ # v2.1 envelope -> v2.2
529
+ result = wrap_v21_to_v22(output_data)
235
530
  else:
236
- # Convert legacy format to envelope
237
- result = convert_to_envelope(output_data)
531
+ # Legacy format -> v2.2
532
+ result = convert_legacy_to_envelope(output_data)
238
533
 
239
- # Validate output (only for success responses)
240
- if result["ok"] and validate_output and module["output_schema"]:
241
- data_to_validate = result.get("data", {})
242
- errors = validate_data(data_to_validate, module["output_schema"], "Output")
243
- if errors:
244
- return {
245
- "ok": False,
246
- "error": {"code": "OUTPUT_VALIDATION_ERROR", "message": str(errors)},
247
- "partial_data": data_to_validate
248
- }
534
+ # Add latency to meta
535
+ if "meta" in result:
536
+ result["meta"]["latency_ms"] = latency_ms
537
+ if model:
538
+ result["meta"]["model"] = model
539
+
540
+ # Validate and potentially repair
541
+ if result.get("ok") and validate_output:
542
+ # Get data schema (support both "data" and "output" aliases)
543
+ data_schema = module.get("data_schema") or module.get("output_schema")
544
+ meta_schema = module.get("meta_schema")
545
+
546
+ # Get risk_rule from module.yaml meta config
547
+ meta_config = module.get("metadata", {}).get("meta", {})
548
+ risk_rule = meta_config.get("risk_rule", "max_changes_risk")
549
+
550
+ if data_schema:
551
+ data_to_validate = result.get("data", {})
552
+ errors = validate_data(data_to_validate, data_schema, "Data")
553
+
554
+ if errors and enable_repair:
555
+ # Attempt repair pass
556
+ result = repair_envelope(result, meta_schema, risk_rule=risk_rule)
557
+
558
+ # Re-validate after repair
559
+ errors = validate_data(result.get("data", {}), data_schema, "Data")
560
+
561
+ if errors:
562
+ return {
563
+ "ok": False,
564
+ "meta": {
565
+ "confidence": 0.0,
566
+ "risk": "high",
567
+ "explain": "Schema validation failed after repair attempt."
568
+ },
569
+ "error": {"code": "SCHEMA_VALIDATION_FAILED", "message": str(errors)},
570
+ "partial_data": result.get("data")
571
+ }
572
+
573
+ # Validate meta if schema exists
574
+ if meta_schema:
575
+ meta_errors = validate_data(result.get("meta", {}), meta_schema, "Meta")
576
+ if meta_errors and enable_repair:
577
+ result = repair_envelope(result, meta_schema, risk_rule=risk_rule)
249
578
 
250
579
  return result
251
580
 
@@ -267,10 +596,44 @@ def run_module_legacy(
267
596
  validate_input=validate_input,
268
597
  validate_output=validate_output,
269
598
  model=model,
270
- use_envelope=False
599
+ use_envelope=False,
600
+ use_v22=False
271
601
  )
272
602
 
273
- if result["ok"]:
274
- return result["data"]
603
+ if result.get("ok"):
604
+ return result.get("data", {})
275
605
  else:
276
- raise ValueError(f"{result['error']['code']}: {result['error']['message']}")
606
+ error = result.get("error", {})
607
+ raise ValueError(f"{error.get('code', 'UNKNOWN')}: {error.get('message', 'Unknown error')}")
608
+
609
+
610
+ # =============================================================================
611
+ # Convenience Functions
612
+ # =============================================================================
613
+
614
+ def extract_meta(result: EnvelopeResponseV22) -> EnvelopeMeta:
615
+ """Extract meta from v2.2 envelope for routing/logging."""
616
+ return result.get("meta", {
617
+ "confidence": 0.5,
618
+ "risk": "medium",
619
+ "explain": "No meta available"
620
+ })
621
+
622
+
623
+ def should_escalate(result: EnvelopeResponseV22, confidence_threshold: float = 0.7) -> bool:
624
+ """Determine if result should be escalated to human review based on meta."""
625
+ meta = extract_meta(result)
626
+
627
+ # Escalate if low confidence
628
+ if meta.get("confidence", 0) < confidence_threshold:
629
+ return True
630
+
631
+ # Escalate if high risk
632
+ if meta.get("risk") == "high":
633
+ return True
634
+
635
+ # Escalate if error
636
+ if not result.get("ok"):
637
+ return True
638
+
639
+ return False