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/__init__.py +1 -1
- cognitive/cli.py +173 -18
- cognitive/loader.py +191 -14
- cognitive/mcp_server.py +245 -0
- cognitive/migrate.py +624 -0
- cognitive/runner.py +443 -80
- cognitive/server.py +294 -0
- cognitive/validator.py +380 -122
- {cognitive_modules-0.4.0.dist-info → cognitive_modules-0.5.1.dist-info}/METADATA +194 -177
- cognitive_modules-0.5.1.dist-info/RECORD +18 -0
- cognitive_modules-0.4.0.dist-info/RECORD +0 -15
- {cognitive_modules-0.4.0.dist-info → cognitive_modules-0.5.1.dist-info}/WHEEL +0 -0
- {cognitive_modules-0.4.0.dist-info → cognitive_modules-0.5.1.dist-info}/entry_points.txt +0 -0
- {cognitive_modules-0.4.0.dist-info → cognitive_modules-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {cognitive_modules-0.4.0.dist-info → cognitive_modules-0.5.1.dist-info}/top_level.txt +0 -0
cognitive/runner.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Module Runner - Execute cognitive modules with validation.
|
|
3
|
-
Supports v2 envelope format
|
|
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
|
|
24
|
-
ok:
|
|
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
|
|
29
|
-
ok:
|
|
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[
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
#
|
|
233
|
-
if
|
|
234
|
-
result =
|
|
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
|
-
#
|
|
237
|
-
result =
|
|
531
|
+
# Legacy format -> v2.2
|
|
532
|
+
result = convert_legacy_to_envelope(output_data)
|
|
238
533
|
|
|
239
|
-
#
|
|
240
|
-
if
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
274
|
-
return result
|
|
603
|
+
if result.get("ok"):
|
|
604
|
+
return result.get("data", {})
|
|
275
605
|
else:
|
|
276
|
-
|
|
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
|