cognitive-modules 0.3.0__py3-none-any.whl → 0.5.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.
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,84 @@ 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
25
48
  data: dict
26
49
 
27
50
 
28
- class EnvelopeFailure(TypedDict):
29
- ok: bool # False
51
+ class EnvelopeFailureV22(TypedDict, total=False):
52
+ ok: Literal[False]
53
+ meta: EnvelopeMeta
30
54
  error: EnvelopeError
31
55
  partial_data: Optional[dict]
32
56
 
33
57
 
34
- EnvelopeResponse = Union[EnvelopeSuccess, EnvelopeFailure]
58
+ EnvelopeResponseV22 = Union[EnvelopeSuccessV22, EnvelopeFailureV22]
59
+
60
+
61
+ # Legacy types for compatibility
62
+ class EnvelopeSuccessV21(TypedDict):
63
+ ok: Literal[True]
64
+ data: dict
65
+
66
+
67
+ class EnvelopeFailureV21(TypedDict, total=False):
68
+ ok: Literal[False]
69
+ error: EnvelopeError
70
+ partial_data: Optional[dict]
71
+
72
+
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"]
35
82
 
36
83
 
84
+ def aggregate_risk(changes: list[dict]) -> RiskLevel:
85
+ """Compute max risk from list of changes."""
86
+ if not changes:
87
+ return "medium" # Default conservative
88
+
89
+ max_level = 0
90
+ for change in changes:
91
+ risk = change.get("risk", "medium")
92
+ level = RISK_LEVELS.get(risk, 2)
93
+ max_level = max(max_level, level)
94
+
95
+ return RISK_NAMES[max_level]
96
+
97
+
98
+ # =============================================================================
99
+ # Schema Validation
100
+ # =============================================================================
101
+
37
102
  def validate_data(data: dict, schema: dict, label: str = "Data") -> list[str]:
38
103
  """Validate data against schema. Returns list of errors."""
39
104
  errors = []
@@ -48,9 +113,191 @@ def validate_data(data: dict, schema: dict, label: str = "Data") -> list[str]:
48
113
  return errors
49
114
 
50
115
 
116
+ # =============================================================================
117
+ # Repair Pass (v2.2)
118
+ # =============================================================================
119
+
120
+ def repair_envelope(
121
+ data: dict,
122
+ meta_schema: Optional[dict] = None,
123
+ max_explain_length: int = 280
124
+ ) -> dict:
125
+ """
126
+ Attempt to repair envelope format issues without changing semantics.
127
+
128
+ Repairs:
129
+ - Missing meta fields (fill with conservative defaults)
130
+ - Truncate explain if too long
131
+ - Normalize risk enum values
132
+ """
133
+ repaired = dict(data)
134
+
135
+ # Ensure meta exists
136
+ if "meta" not in repaired:
137
+ repaired["meta"] = {}
138
+
139
+ meta = repaired["meta"]
140
+ data_payload = repaired.get("data", {})
141
+
142
+ # Repair confidence
143
+ if "confidence" not in meta:
144
+ # Try to extract from data (v2.1 compatibility)
145
+ meta["confidence"] = data_payload.get("confidence", 0.5)
146
+
147
+ # Ensure confidence is in valid range
148
+ if isinstance(meta.get("confidence"), (int, float)):
149
+ meta["confidence"] = max(0.0, min(1.0, float(meta["confidence"])))
150
+
151
+ # Repair risk
152
+ if "risk" not in meta:
153
+ # Aggregate from changes if available
154
+ changes = data_payload.get("changes", [])
155
+ meta["risk"] = aggregate_risk(changes)
156
+
157
+ # Normalize risk value
158
+ risk = str(meta.get("risk", "medium")).lower()
159
+ if risk not in RISK_LEVELS:
160
+ meta["risk"] = "medium"
161
+ else:
162
+ meta["risk"] = risk
163
+
164
+ # Repair explain
165
+ if "explain" not in meta:
166
+ # Try to extract from rationale
167
+ rationale = data_payload.get("rationale", "")
168
+ if rationale:
169
+ meta["explain"] = rationale[:max_explain_length]
170
+ else:
171
+ meta["explain"] = "No explanation provided"
172
+
173
+ # Truncate explain if too long
174
+ if len(meta.get("explain", "")) > max_explain_length:
175
+ meta["explain"] = meta["explain"][:max_explain_length - 3] + "..."
176
+
177
+ return repaired
178
+
179
+
180
+ def repair_error_envelope(
181
+ data: dict,
182
+ max_explain_length: int = 280
183
+ ) -> dict:
184
+ """Repair error envelope format."""
185
+ repaired = dict(data)
186
+
187
+ # Ensure meta exists for errors
188
+ if "meta" not in repaired:
189
+ repaired["meta"] = {}
190
+
191
+ meta = repaired["meta"]
192
+
193
+ # Set default meta for errors
194
+ if "confidence" not in meta:
195
+ meta["confidence"] = 0.0
196
+ if "risk" not in meta:
197
+ meta["risk"] = "high"
198
+ if "explain" not in meta:
199
+ error = repaired.get("error", {})
200
+ meta["explain"] = error.get("message", "An error occurred")[:max_explain_length]
201
+
202
+ return repaired
203
+
204
+
205
+ # =============================================================================
206
+ # Envelope Detection & Conversion
207
+ # =============================================================================
208
+
209
+ def is_envelope_response(data: dict) -> bool:
210
+ """Check if response is in envelope format (v2.1 or v2.2)."""
211
+ return isinstance(data.get("ok"), bool)
212
+
213
+
214
+ def is_v22_envelope(data: dict) -> bool:
215
+ """Check if response is in v2.2 envelope format (has meta)."""
216
+ return is_envelope_response(data) and "meta" in data
217
+
218
+
219
+ def wrap_v21_to_v22(v21_response: dict) -> EnvelopeResponseV22:
220
+ """
221
+ Convert v2.1 envelope to v2.2 envelope.
222
+ Adds meta field by extracting/computing from data.
223
+ """
224
+ if is_v22_envelope(v21_response):
225
+ return v21_response # Already v2.2
226
+
227
+ if v21_response.get("ok") is True:
228
+ data = v21_response.get("data", {})
229
+
230
+ # Extract or compute meta fields
231
+ confidence = data.get("confidence", 0.5)
232
+ rationale = data.get("rationale", "")
233
+ changes = data.get("changes", [])
234
+
235
+ return {
236
+ "ok": True,
237
+ "meta": {
238
+ "confidence": confidence,
239
+ "risk": aggregate_risk(changes),
240
+ "explain": rationale[:280] if rationale else "No explanation provided"
241
+ },
242
+ "data": data
243
+ }
244
+ else:
245
+ error = v21_response.get("error", {"code": "UNKNOWN", "message": "Unknown error"})
246
+
247
+ return {
248
+ "ok": False,
249
+ "meta": {
250
+ "confidence": 0.0,
251
+ "risk": "high",
252
+ "explain": error.get("message", "An error occurred")[:280]
253
+ },
254
+ "error": error,
255
+ "partial_data": v21_response.get("partial_data")
256
+ }
257
+
258
+
259
+ def convert_legacy_to_envelope(data: dict, is_error: bool = False) -> EnvelopeResponseV22:
260
+ """Convert legacy format (no envelope) to v2.2 envelope."""
261
+ if is_error or "error" in data:
262
+ error = data.get("error", {})
263
+ error_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
264
+
265
+ return {
266
+ "ok": False,
267
+ "meta": {
268
+ "confidence": 0.0,
269
+ "risk": "high",
270
+ "explain": error_msg[:280]
271
+ },
272
+ "error": {
273
+ "code": error.get("code", "UNKNOWN") if isinstance(error, dict) else "UNKNOWN",
274
+ "message": error_msg
275
+ },
276
+ "partial_data": None
277
+ }
278
+ else:
279
+ # Legacy success response - data is the payload itself
280
+ confidence = data.get("confidence", 0.5)
281
+ rationale = data.get("rationale", "")
282
+ changes = data.get("changes", [])
283
+
284
+ return {
285
+ "ok": True,
286
+ "meta": {
287
+ "confidence": confidence,
288
+ "risk": aggregate_risk(changes),
289
+ "explain": rationale[:280] if rationale else "No explanation provided"
290
+ },
291
+ "data": data
292
+ }
293
+
294
+
295
+ # =============================================================================
296
+ # Prompt Building
297
+ # =============================================================================
298
+
51
299
  def substitute_arguments(text: str, input_data: dict) -> str:
52
300
  """Substitute $ARGUMENTS and $N placeholders in text."""
53
- # Get arguments
54
301
  args_value = input_data.get("$ARGUMENTS", input_data.get("query", input_data.get("code", "")))
55
302
 
56
303
  # Replace $ARGUMENTS
@@ -66,7 +313,7 @@ def substitute_arguments(text: str, input_data: dict) -> str:
66
313
  return text
67
314
 
68
315
 
69
- def build_prompt(module: dict, input_data: dict, use_envelope: bool = False) -> str:
316
+ def build_prompt(module: dict, input_data: dict, use_envelope: bool = False, use_v22: bool = False) -> str:
70
317
  """Build the complete prompt for the LLM."""
71
318
  # Substitute $ARGUMENTS in prompt
72
319
  prompt = substitute_arguments(module["prompt"], input_data)
@@ -82,13 +329,23 @@ def build_prompt(module: dict, input_data: dict, use_envelope: bool = False) ->
82
329
  ]
83
330
 
84
331
  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
- ])
332
+ if use_v22:
333
+ parts.extend([
334
+ "\n## Response Format (Envelope v2.2)\n",
335
+ "You MUST wrap your response in the v2.2 envelope format with separate meta and data:\n",
336
+ "- Success: { \"ok\": true, \"meta\": { \"confidence\": 0.9, \"risk\": \"low\", \"explain\": \"short summary\" }, \"data\": { ...payload... } }\n",
337
+ "- Error: { \"ok\": false, \"meta\": { \"confidence\": 0.0, \"risk\": \"high\", \"explain\": \"error summary\" }, \"error\": { \"code\": \"ERROR_CODE\", \"message\": \"...\" } }\n",
338
+ "Note: meta.explain must be ≤280 characters. data.rationale can be longer for detailed reasoning.\n",
339
+ "Return ONLY valid JSON.\n",
340
+ ])
341
+ else:
342
+ parts.extend([
343
+ "\n## Response Format (Envelope)\n",
344
+ "You MUST wrap your response in the envelope format:\n",
345
+ "- Success: { \"ok\": true, \"data\": { ...your output... } }\n",
346
+ "- Error: { \"ok\": false, \"error\": { \"code\": \"ERROR_CODE\", \"message\": \"...\" } }\n",
347
+ "Return ONLY valid JSON.\n",
348
+ ])
92
349
  else:
93
350
  parts.extend([
94
351
  "\n## Instructions\n",
@@ -99,6 +356,10 @@ def build_prompt(module: dict, input_data: dict, use_envelope: bool = False) ->
99
356
  return "".join(parts)
100
357
 
101
358
 
359
+ # =============================================================================
360
+ # LLM Response Parsing
361
+ # =============================================================================
362
+
102
363
  def parse_llm_response(response: str) -> dict:
103
364
  """Parse LLM response, handling potential markdown code blocks."""
104
365
  text = response.strip()
@@ -117,44 +378,9 @@ def parse_llm_response(response: str) -> dict:
117
378
  return json.loads(text)
118
379
 
119
380
 
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
-
381
+ # =============================================================================
382
+ # Main Runner
383
+ # =============================================================================
158
384
 
159
385
  def run_module(
160
386
  name_or_path: str,
@@ -163,10 +389,12 @@ def run_module(
163
389
  validate_output: bool = True,
164
390
  model: Optional[str] = None,
165
391
  use_envelope: Optional[bool] = None,
166
- ) -> EnvelopeResponse:
392
+ use_v22: Optional[bool] = None,
393
+ enable_repair: bool = True,
394
+ ) -> EnvelopeResponseV22:
167
395
  """
168
396
  Run a cognitive module with the given input.
169
- Returns envelope format response.
397
+ Returns v2.2 envelope format response.
170
398
 
171
399
  Args:
172
400
  name_or_path: Module name or path to module directory
@@ -175,10 +403,15 @@ def run_module(
175
403
  validate_output: Whether to validate output against schema
176
404
  model: Optional model override
177
405
  use_envelope: Force envelope format (auto-detect if None)
406
+ use_v22: Force v2.2 envelope format (auto-detect if None)
407
+ enable_repair: Enable repair pass for validation failures
178
408
 
179
409
  Returns:
180
- EnvelopeResponse with ok=True/False and data/error
410
+ EnvelopeResponseV22 with ok=True/False, meta, and data/error
181
411
  """
412
+ import time
413
+ start_time = time.time()
414
+
182
415
  # Find module path
183
416
  path = Path(name_or_path)
184
417
  if path.exists() and path.is_dir():
@@ -188,6 +421,11 @@ def run_module(
188
421
  if not module_path:
189
422
  return {
190
423
  "ok": False,
424
+ "meta": {
425
+ "confidence": 1.0,
426
+ "risk": "high",
427
+ "explain": f"Module '{name_or_path}' not found."
428
+ },
191
429
  "error": {"code": "MODULE_NOT_FOUND", "message": f"Module not found: {name_or_path}"},
192
430
  "partial_data": None
193
431
  }
@@ -195,57 +433,114 @@ def run_module(
195
433
  # Load module (auto-detects format)
196
434
  module = load_module(module_path)
197
435
 
198
- # Determine if we should use envelope format
436
+ # Determine envelope version
437
+ compat = module.get("compat", {})
438
+ is_v22_module = module.get("tier") is not None or "meta_schema" in module
439
+
199
440
  should_use_envelope = use_envelope
200
441
  if should_use_envelope is None:
201
- # Auto-detect: use envelope for v2 format or if output.envelope is True
202
442
  output_contract = module.get("output_contract", {})
203
443
  should_use_envelope = (
204
444
  module.get("format") == "v2" or
205
445
  output_contract.get("envelope", False)
206
446
  )
207
447
 
448
+ should_use_v22 = use_v22
449
+ if should_use_v22 is None:
450
+ should_use_v22 = is_v22_module or compat.get("runtime_auto_wrap", False)
451
+
208
452
  # Validate input
209
- if validate_input and module["input_schema"]:
453
+ if validate_input and module.get("input_schema"):
210
454
  errors = validate_data(input_data, module["input_schema"], "Input")
211
455
  if errors:
212
456
  return {
213
457
  "ok": False,
458
+ "meta": {
459
+ "confidence": 1.0,
460
+ "risk": "none",
461
+ "explain": "Input validation failed."
462
+ },
214
463
  "error": {"code": "INVALID_INPUT", "message": str(errors)},
215
464
  "partial_data": None
216
465
  }
217
466
 
218
467
  # Build prompt and call LLM
219
- full_prompt = build_prompt(module, input_data, use_envelope=should_use_envelope)
468
+ full_prompt = build_prompt(
469
+ module,
470
+ input_data,
471
+ use_envelope=should_use_envelope,
472
+ use_v22=should_use_v22
473
+ )
220
474
  response = call_llm(full_prompt, model=model)
221
475
 
476
+ # Calculate latency
477
+ latency_ms = (time.time() - start_time) * 1000
478
+
222
479
  # Parse response
223
480
  try:
224
481
  output_data = parse_llm_response(response)
225
482
  except json.JSONDecodeError as e:
226
483
  return {
227
484
  "ok": False,
485
+ "meta": {
486
+ "confidence": 0.0,
487
+ "risk": "high",
488
+ "explain": "Failed to parse LLM response as JSON."
489
+ },
228
490
  "error": {"code": "PARSE_ERROR", "message": f"Failed to parse JSON: {e}"},
229
491
  "partial_data": None
230
492
  }
231
493
 
232
- # Handle envelope format
233
- if is_envelope_response(output_data):
234
- result = parse_envelope_response(output_data)
494
+ # Convert to v2.2 envelope
495
+ if is_v22_envelope(output_data):
496
+ result = output_data
497
+ elif is_envelope_response(output_data):
498
+ # v2.1 envelope -> v2.2
499
+ result = wrap_v21_to_v22(output_data)
235
500
  else:
236
- # Convert legacy format to envelope
237
- result = convert_to_envelope(output_data)
501
+ # Legacy format -> v2.2
502
+ result = convert_legacy_to_envelope(output_data)
238
503
 
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
- }
504
+ # Add latency to meta
505
+ if "meta" in result:
506
+ result["meta"]["latency_ms"] = latency_ms
507
+ if model:
508
+ result["meta"]["model"] = model
509
+
510
+ # Validate and potentially repair
511
+ if result.get("ok") and validate_output:
512
+ # Get data schema (support both "data" and "output" aliases)
513
+ data_schema = module.get("data_schema") or module.get("output_schema")
514
+ meta_schema = module.get("meta_schema")
515
+
516
+ if data_schema:
517
+ data_to_validate = result.get("data", {})
518
+ errors = validate_data(data_to_validate, data_schema, "Data")
519
+
520
+ if errors and enable_repair:
521
+ # Attempt repair pass
522
+ result = repair_envelope(result, meta_schema)
523
+
524
+ # Re-validate after repair
525
+ errors = validate_data(result.get("data", {}), data_schema, "Data")
526
+
527
+ if errors:
528
+ return {
529
+ "ok": False,
530
+ "meta": {
531
+ "confidence": 0.0,
532
+ "risk": "high",
533
+ "explain": "Schema validation failed after repair attempt."
534
+ },
535
+ "error": {"code": "SCHEMA_VALIDATION_FAILED", "message": str(errors)},
536
+ "partial_data": result.get("data")
537
+ }
538
+
539
+ # Validate meta if schema exists
540
+ if meta_schema:
541
+ meta_errors = validate_data(result.get("meta", {}), meta_schema, "Meta")
542
+ if meta_errors and enable_repair:
543
+ result = repair_envelope(result, meta_schema)
249
544
 
250
545
  return result
251
546
 
@@ -267,10 +562,44 @@ def run_module_legacy(
267
562
  validate_input=validate_input,
268
563
  validate_output=validate_output,
269
564
  model=model,
270
- use_envelope=False
565
+ use_envelope=False,
566
+ use_v22=False
271
567
  )
272
568
 
273
- if result["ok"]:
274
- return result["data"]
569
+ if result.get("ok"):
570
+ return result.get("data", {})
275
571
  else:
276
- raise ValueError(f"{result['error']['code']}: {result['error']['message']}")
572
+ error = result.get("error", {})
573
+ raise ValueError(f"{error.get('code', 'UNKNOWN')}: {error.get('message', 'Unknown error')}")
574
+
575
+
576
+ # =============================================================================
577
+ # Convenience Functions
578
+ # =============================================================================
579
+
580
+ def extract_meta(result: EnvelopeResponseV22) -> EnvelopeMeta:
581
+ """Extract meta from v2.2 envelope for routing/logging."""
582
+ return result.get("meta", {
583
+ "confidence": 0.5,
584
+ "risk": "medium",
585
+ "explain": "No meta available"
586
+ })
587
+
588
+
589
+ def should_escalate(result: EnvelopeResponseV22, confidence_threshold: float = 0.7) -> bool:
590
+ """Determine if result should be escalated to human review based on meta."""
591
+ meta = extract_meta(result)
592
+
593
+ # Escalate if low confidence
594
+ if meta.get("confidence", 0) < confidence_threshold:
595
+ return True
596
+
597
+ # Escalate if high risk
598
+ if meta.get("risk") == "high":
599
+ return True
600
+
601
+ # Escalate if error
602
+ if not result.get("ok"):
603
+ return True
604
+
605
+ return False