ОРРКЕСТРАЦИЯ - Оркестрация домена: ввести step-validator и починить исторический as_of_date в item-trace
This commit is contained in:
+434
-19
@@ -35,6 +35,31 @@ SCENARIO_PACK_SCHEMA_VERSION = "domain_scenario_pack_v1"
|
||||
ACTIVE_DOMAIN_CONTRACT_SCHEMA_VERSION = "active_domain_contract_v1"
|
||||
AUTONOMOUS_LOOP_SCHEMA_VERSION = "domain_autonomous_loop_v1"
|
||||
|
||||
TOP_LEVEL_NOISE_PATTERNS = (
|
||||
re.compile(r"^(?:status|статус(?: результата)?)\b", re.IGNORECASE),
|
||||
re.compile(r"^(?:что учтено|сводка)\b", re.IGNORECASE),
|
||||
re.compile(r"^блок\s+\d+\b", re.IGNORECASE),
|
||||
re.compile(r"^(?:подтверждение|опорные документы|сервисно)\b", re.IGNORECASE),
|
||||
)
|
||||
|
||||
DEFAULT_INVARIANT_SEVERITY: dict[str, str] = {
|
||||
"wrong_intent": "P0",
|
||||
"wrong_capability": "P0",
|
||||
"wrong_followup_action": "P0",
|
||||
"wrong_recipe": "P0",
|
||||
"wrong_result_mode": "P0",
|
||||
"wrong_as_of_date": "P0",
|
||||
"wrong_period_from": "P0",
|
||||
"wrong_period_to": "P0",
|
||||
"missing_required_filter": "P0",
|
||||
"forbidden_capability_selected": "P0",
|
||||
"forbidden_recipe_selected": "P0",
|
||||
"focus_object_missing": "P0",
|
||||
"wrong_date_scope_state": "P0",
|
||||
"direct_answer_missing": "P0",
|
||||
"top_level_noise_present": "P0",
|
||||
}
|
||||
|
||||
|
||||
def dump_json(payload: Any) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
@@ -268,6 +293,115 @@ def normalize_string_list(raw_values: Any) -> list[str]:
|
||||
return values
|
||||
|
||||
|
||||
def normalize_validation_filters(raw_filters: Any) -> dict[str, str]:
|
||||
if not isinstance(raw_filters, dict):
|
||||
return {}
|
||||
normalized: dict[str, str] = {}
|
||||
for raw_key, raw_value in raw_filters.items():
|
||||
key = str(raw_key or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
if key in {"as_of_date", "period_from", "period_to"}:
|
||||
normalized_value = normalize_iso_date(raw_value)
|
||||
if normalized_value:
|
||||
normalized[key] = normalized_value
|
||||
continue
|
||||
text_value = str(raw_value or "").strip()
|
||||
if text_value:
|
||||
normalized[key] = text_value
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_invariant_severity(raw_mapping: Any) -> dict[str, str]:
|
||||
if not isinstance(raw_mapping, dict):
|
||||
return {}
|
||||
normalized: dict[str, str] = {}
|
||||
for raw_key, raw_value in raw_mapping.items():
|
||||
key = str(raw_key or "").strip()
|
||||
value = str(raw_value or "").strip().upper()
|
||||
if not key or value not in {"P0", "P1", "WARNING"}:
|
||||
continue
|
||||
normalized[key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_identifier(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def identifiers_match(actual: Any, expected: Any) -> bool:
|
||||
actual_id = normalize_identifier(actual)
|
||||
expected_id = normalize_identifier(expected)
|
||||
if not actual_id or not expected_id:
|
||||
return False
|
||||
return actual_id == expected_id or actual_id.endswith(expected_id) or expected_id.endswith(actual_id)
|
||||
|
||||
|
||||
def identifier_in_list(actual: Any, expected_values: list[str]) -> bool:
|
||||
return any(identifiers_match(actual, expected) for expected in expected_values if expected)
|
||||
|
||||
|
||||
def first_non_empty_lines(text: str, limit: int = 3) -> list[str]:
|
||||
output: list[str] = []
|
||||
for raw_line in str(text or "").splitlines():
|
||||
cleaned = raw_line.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
output.append(cleaned)
|
||||
if len(output) >= limit:
|
||||
break
|
||||
return output
|
||||
|
||||
|
||||
def build_node_contract_index(raw_contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
scenario_tree = raw_contract.get("scenario_tree")
|
||||
if not isinstance(scenario_tree, dict):
|
||||
return {}
|
||||
node_index: dict[str, dict[str, Any]] = {}
|
||||
for section_key in ("root_nodes", "critical_nodes", "supporting_nodes"):
|
||||
raw_nodes = scenario_tree.get(section_key)
|
||||
if not isinstance(raw_nodes, list):
|
||||
continue
|
||||
for raw_node in raw_nodes:
|
||||
if not isinstance(raw_node, dict):
|
||||
continue
|
||||
node_id = str(raw_node.get("node_id") or "").strip()
|
||||
if not node_id:
|
||||
continue
|
||||
node_index[node_id] = {
|
||||
"expected_intents": normalize_string_list(raw_node.get("expected_intents")),
|
||||
"required_answer_shape": str(raw_node.get("expected_answer_shape") or "").strip() or None,
|
||||
"required_carryover_invariants": normalize_string_list(raw_node.get("required_carryover_invariants")),
|
||||
"ordering_rule": str(raw_node.get("ordering_rule") or "").strip() or None,
|
||||
}
|
||||
return node_index
|
||||
|
||||
|
||||
def enrich_step_with_node_contract(raw_step: Any, node_contract_index: dict[str, dict[str, Any]]) -> Any:
|
||||
if not isinstance(raw_step, dict):
|
||||
return raw_step
|
||||
node_id = str(raw_step.get("node_id") or "").strip()
|
||||
node_defaults = node_contract_index.get(node_id) or {}
|
||||
if not node_defaults:
|
||||
return raw_step
|
||||
enriched = dict(raw_step)
|
||||
if not enriched.get("expected_intents") and node_defaults.get("expected_intents"):
|
||||
enriched["expected_intents"] = list(node_defaults["expected_intents"])
|
||||
if not enriched.get("required_answer_shape") and node_defaults.get("required_answer_shape"):
|
||||
enriched["required_answer_shape"] = node_defaults["required_answer_shape"]
|
||||
merged_invariants = list(
|
||||
dict.fromkeys(
|
||||
normalize_string_list(node_defaults.get("required_carryover_invariants"))
|
||||
+ normalize_string_list(enriched.get("required_carryover_invariants"))
|
||||
)
|
||||
)
|
||||
if merged_invariants:
|
||||
enriched["required_carryover_invariants"] = merged_invariants
|
||||
if not enriched.get("ordering_rule") and node_defaults.get("ordering_rule"):
|
||||
enriched["ordering_rule"] = node_defaults["ordering_rule"]
|
||||
return enriched
|
||||
|
||||
|
||||
def drop_none_values(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: value for key, value in payload.items() if value is not None}
|
||||
|
||||
@@ -777,8 +911,18 @@ def build_failed_step_state(
|
||||
"depends_on": step["depends_on"],
|
||||
"question_template": step["question_template"],
|
||||
"question_resolved": question_resolved,
|
||||
"expected_intents": step.get("expected_intents") or [],
|
||||
"expected_capability": step.get("expected_capability"),
|
||||
"expected_recipe": step.get("expected_recipe"),
|
||||
"expected_result_mode": step.get("expected_result_mode"),
|
||||
"required_filters": step.get("required_filters") or {},
|
||||
"forbidden_capabilities": step.get("forbidden_capabilities") or [],
|
||||
"forbidden_recipes": step.get("forbidden_recipes") or [],
|
||||
"required_state_objects": step.get("required_state_objects") or [],
|
||||
"required_answer_shape": step.get("required_answer_shape"),
|
||||
"forbidden_answer_patterns": step.get("forbidden_answer_patterns") or [],
|
||||
"required_carryover_invariants": step.get("required_carryover_invariants") or [],
|
||||
"invariant_severity": step.get("invariant_severity") or {},
|
||||
"reply_type": "backend_error" if status == "blocked" else "unresolved_followup",
|
||||
"assistant_message_id": None,
|
||||
"trace_id": None,
|
||||
@@ -790,6 +934,11 @@ def build_failed_step_state(
|
||||
"route_expectation_status": None,
|
||||
"result_mode": None,
|
||||
"response_type": None,
|
||||
"assistant_text": "",
|
||||
"top_non_empty_lines": [],
|
||||
"actual_direct_answer": None,
|
||||
"extracted_filters": {},
|
||||
"focus_object": None,
|
||||
"fallback_type": failure_type,
|
||||
"mcp_call_status": None,
|
||||
"balance_confirmed": None,
|
||||
@@ -798,6 +947,11 @@ def build_failed_step_state(
|
||||
"date_scope": None,
|
||||
"organization_scope": None,
|
||||
"entries": [],
|
||||
"execution_status": status,
|
||||
"acceptance_status": status,
|
||||
"violated_invariants": [],
|
||||
"warnings": [],
|
||||
"hard_fail": status == "blocked",
|
||||
"status": status,
|
||||
"failure_type": failure_type,
|
||||
"error_message": error_message,
|
||||
@@ -816,13 +970,22 @@ def normalize_step_definition(index: int, raw_step: Any) -> dict[str, Any]:
|
||||
"question_template": question_template,
|
||||
"depends_on": [],
|
||||
"analysis_context": {},
|
||||
"expected_intents": [],
|
||||
"expected_capability": None,
|
||||
"expected_recipe": None,
|
||||
"expected_result_mode": None,
|
||||
"question_id": None,
|
||||
"node_id": None,
|
||||
"node_role": None,
|
||||
"paraphrase_family": None,
|
||||
"required_filters": {},
|
||||
"forbidden_capabilities": [],
|
||||
"forbidden_recipes": [],
|
||||
"required_state_objects": [],
|
||||
"required_answer_shape": None,
|
||||
"forbidden_answer_patterns": [],
|
||||
"required_carryover_invariants": [],
|
||||
"invariant_severity": {},
|
||||
"ordering_rule": None,
|
||||
}
|
||||
if not isinstance(raw_step, dict):
|
||||
@@ -845,13 +1008,24 @@ def normalize_step_definition(index: int, raw_step: Any) -> dict[str, Any]:
|
||||
"question_template": question_template,
|
||||
"depends_on": depends_on,
|
||||
"analysis_context": normalize_analysis_context(raw_step.get("analysis_context")),
|
||||
"expected_intents": normalize_string_list(raw_step.get("expected_intents") or raw_step.get("expected_intent")),
|
||||
"expected_capability": str(raw_step.get("expected_capability") or "").strip() or None,
|
||||
"expected_recipe": str(raw_step.get("expected_recipe") or raw_step.get("expected_selected_recipe") or "").strip() or None,
|
||||
"expected_result_mode": str(raw_step.get("expected_result_mode") or "").strip() or None,
|
||||
"question_id": str(raw_step.get("question_id") or "").strip() or None,
|
||||
"node_id": str(raw_step.get("node_id") or "").strip() or None,
|
||||
"node_role": str(raw_step.get("node_role") or raw_step.get("role") or "").strip() or None,
|
||||
"paraphrase_family": str(raw_step.get("paraphrase_family") or raw_step.get("wording_family") or "").strip() or None,
|
||||
"required_filters": normalize_validation_filters(raw_step.get("required_filters")),
|
||||
"forbidden_capabilities": normalize_string_list(raw_step.get("forbidden_capabilities")),
|
||||
"forbidden_recipes": normalize_string_list(raw_step.get("forbidden_recipes")),
|
||||
"required_state_objects": normalize_string_list(raw_step.get("required_state_objects")),
|
||||
"required_answer_shape": (
|
||||
str(raw_step.get("required_answer_shape") or raw_step.get("expected_answer_shape") or "").strip() or None
|
||||
),
|
||||
"forbidden_answer_patterns": normalize_string_list(raw_step.get("forbidden_answer_patterns")),
|
||||
"required_carryover_invariants": normalize_string_list(raw_step.get("required_carryover_invariants")),
|
||||
"invariant_severity": normalize_invariant_severity(raw_step.get("invariant_severity")),
|
||||
"ordering_rule": str(raw_step.get("ordering_rule") or "").strip() or None,
|
||||
}
|
||||
|
||||
@@ -936,6 +1110,7 @@ def convert_active_domain_contract_to_pack(raw_contract: dict[str, Any]) -> dict
|
||||
raise RuntimeError("Active domain contract must define `runtime_domain`")
|
||||
|
||||
domain_id = str(raw_contract.get("domain_id") or runtime_domain).strip() or runtime_domain
|
||||
node_contract_index = build_node_contract_index(raw_contract)
|
||||
bindings = build_active_contract_bindings(raw_contract)
|
||||
bindings.update(normalize_bindings(orchestration_pack.get("bindings")))
|
||||
|
||||
@@ -947,6 +1122,18 @@ def convert_active_domain_contract_to_pack(raw_contract: dict[str, Any]) -> dict
|
||||
pack_id = str(orchestration_pack.get("pack_id") or "").strip() or slugify_case_id(domain_id, None)
|
||||
title = str(orchestration_pack.get("title") or raw_contract.get("title") or domain_id).strip() or domain_id
|
||||
description = str(orchestration_pack.get("description") or raw_contract.get("domain_goal") or "").strip() or None
|
||||
enriched_scenarios: list[Any] = []
|
||||
for raw_scenario in raw_scenarios:
|
||||
if not isinstance(raw_scenario, dict):
|
||||
enriched_scenarios.append(raw_scenario)
|
||||
continue
|
||||
enriched_scenario = dict(raw_scenario)
|
||||
raw_steps = enriched_scenario.get("steps")
|
||||
if isinstance(raw_steps, list):
|
||||
enriched_scenario["steps"] = [
|
||||
enrich_step_with_node_contract(raw_step, node_contract_index) for raw_step in raw_steps
|
||||
]
|
||||
enriched_scenarios.append(enriched_scenario)
|
||||
|
||||
return {
|
||||
"schema_version": SCENARIO_PACK_SCHEMA_VERSION,
|
||||
@@ -958,7 +1145,7 @@ def convert_active_domain_contract_to_pack(raw_contract: dict[str, Any]) -> dict
|
||||
"description": description,
|
||||
"analysis_context": analysis_context,
|
||||
"bindings": bindings,
|
||||
"scenarios": raw_scenarios,
|
||||
"scenarios": enriched_scenarios,
|
||||
"scenario_tree": raw_contract.get("scenario_tree") if isinstance(raw_contract.get("scenario_tree"), dict) else {},
|
||||
"acceptance_contract": (
|
||||
raw_contract.get("acceptance_contract") if isinstance(raw_contract.get("acceptance_contract"), dict) else {}
|
||||
@@ -1135,7 +1322,7 @@ def extract_structured_entries(answer_text: str) -> list[dict[str, Any]]:
|
||||
return entries
|
||||
|
||||
|
||||
def derive_step_status(reply_type: str | None, debug_payload: dict[str, Any]) -> str:
|
||||
def derive_step_execution_status(reply_type: str | None, debug_payload: dict[str, Any]) -> str:
|
||||
if reply_type == "backend_error":
|
||||
return "blocked"
|
||||
capability_route_mode = str(debug_payload.get("capability_route_mode") or "").strip()
|
||||
@@ -1156,6 +1343,153 @@ def derive_step_status(reply_type: str | None, debug_payload: dict[str, Any]) ->
|
||||
return "needs_exact_capability"
|
||||
|
||||
|
||||
def derive_step_status(reply_type: str | None, debug_payload: dict[str, Any]) -> str:
|
||||
return derive_step_execution_status(reply_type, debug_payload)
|
||||
|
||||
|
||||
def should_require_direct_answer(step_state: dict[str, Any]) -> bool:
|
||||
required_answer_shape = str(step_state.get("required_answer_shape") or "").strip()
|
||||
if required_answer_shape:
|
||||
return True
|
||||
return str(step_state.get("node_role") or "").strip() in {"root", "critical_child"}
|
||||
|
||||
|
||||
def is_top_level_noise_line(line: str) -> bool:
|
||||
cleaned = str(line or "").strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
return any(pattern.search(cleaned) for pattern in TOP_LEVEL_NOISE_PATTERNS)
|
||||
|
||||
|
||||
def derive_invariant_severity(step_state: dict[str, Any], violation_code: str) -> str:
|
||||
overrides = step_state.get("invariant_severity")
|
||||
if isinstance(overrides, dict):
|
||||
override = str(overrides.get(violation_code) or "").strip().upper()
|
||||
if override in {"P0", "P1", "WARNING"}:
|
||||
return override
|
||||
return DEFAULT_INVARIANT_SEVERITY.get(violation_code, "P1")
|
||||
|
||||
|
||||
def acceptance_status_from_execution(execution_status: str, hard_fail: bool) -> str:
|
||||
if execution_status == "blocked":
|
||||
return "blocked"
|
||||
if execution_status == "needs_exact_capability":
|
||||
return "needs_exact_capability"
|
||||
if hard_fail:
|
||||
return "rejected"
|
||||
if execution_status == "exact":
|
||||
return "validated"
|
||||
return "rejected"
|
||||
|
||||
|
||||
def validate_step_contract(step_state: dict[str, Any]) -> dict[str, Any]:
|
||||
state = dict(step_state)
|
||||
execution_status = str(state.get("execution_status") or state.get("status") or "").strip() or "needs_exact_capability"
|
||||
actual_direct_answer = str(state.get("actual_direct_answer") or "").strip()
|
||||
top_non_empty_lines = state.get("top_non_empty_lines") if isinstance(state.get("top_non_empty_lines"), list) else []
|
||||
extracted_filters = state.get("extracted_filters") if isinstance(state.get("extracted_filters"), dict) else {}
|
||||
date_scope = state.get("date_scope") if isinstance(state.get("date_scope"), dict) else {}
|
||||
violated_invariants: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
expected_intents = normalize_string_list(state.get("expected_intents"))
|
||||
if expected_intents and not identifier_in_list(state.get("detected_intent"), expected_intents):
|
||||
violated_invariants.append("wrong_intent")
|
||||
|
||||
expected_capability = state.get("expected_capability")
|
||||
if expected_capability and not identifiers_match(state.get("capability_id"), expected_capability):
|
||||
required_state_objects = set(normalize_string_list(state.get("required_state_objects")))
|
||||
required_state_objects.update(normalize_string_list(state.get("required_carryover_invariants")))
|
||||
violation_code = "wrong_followup_action" if "focus_object" in required_state_objects else "wrong_capability"
|
||||
violated_invariants.append(violation_code)
|
||||
|
||||
expected_recipe = state.get("expected_recipe")
|
||||
if expected_recipe and not identifiers_match(state.get("selected_recipe"), expected_recipe):
|
||||
violated_invariants.append("wrong_recipe")
|
||||
|
||||
expected_result_mode = str(state.get("expected_result_mode") or "").strip()
|
||||
actual_result_mode = str(state.get("result_mode") or "").strip()
|
||||
if expected_result_mode and actual_result_mode and normalize_identifier(actual_result_mode) != normalize_identifier(expected_result_mode):
|
||||
violated_invariants.append("wrong_result_mode")
|
||||
|
||||
for forbidden_capability in normalize_string_list(state.get("forbidden_capabilities")):
|
||||
if identifiers_match(state.get("capability_id"), forbidden_capability):
|
||||
violated_invariants.append("forbidden_capability_selected")
|
||||
break
|
||||
|
||||
for forbidden_recipe in normalize_string_list(state.get("forbidden_recipes")):
|
||||
if identifiers_match(state.get("selected_recipe"), forbidden_recipe):
|
||||
violated_invariants.append("forbidden_recipe_selected")
|
||||
break
|
||||
|
||||
required_filters = normalize_validation_filters(state.get("required_filters"))
|
||||
required_as_of_date_from_context = normalize_iso_date(
|
||||
(state.get("analysis_context") or {}).get("as_of_date") if isinstance(state.get("analysis_context"), dict) else None
|
||||
)
|
||||
if required_as_of_date_from_context and "as_of_date" not in required_filters:
|
||||
required_filters["as_of_date"] = required_as_of_date_from_context
|
||||
|
||||
for filter_key, expected_value in required_filters.items():
|
||||
actual_value = ""
|
||||
if filter_key in {"as_of_date", "period_from", "period_to"}:
|
||||
actual_value = normalize_iso_date(extracted_filters.get(filter_key))
|
||||
else:
|
||||
actual_value = str(extracted_filters.get(filter_key) or "").strip()
|
||||
if not actual_value:
|
||||
violated_invariants.append("missing_required_filter")
|
||||
continue
|
||||
if actual_value != expected_value:
|
||||
if filter_key == "as_of_date":
|
||||
violated_invariants.append("wrong_as_of_date")
|
||||
elif filter_key == "period_from":
|
||||
violated_invariants.append("wrong_period_from")
|
||||
elif filter_key == "period_to":
|
||||
violated_invariants.append("wrong_period_to")
|
||||
else:
|
||||
violated_invariants.append("missing_required_filter")
|
||||
|
||||
required_state_objects = set(normalize_string_list(state.get("required_state_objects")))
|
||||
required_state_objects.update(
|
||||
item
|
||||
for item in normalize_string_list(state.get("required_carryover_invariants"))
|
||||
if item in {"focus_object"}
|
||||
)
|
||||
focus_object = state.get("focus_object") if isinstance(state.get("focus_object"), dict) else {}
|
||||
if "focus_object" in required_state_objects:
|
||||
has_focus_object = bool(str(focus_object.get("object_id") or "").strip() or str(focus_object.get("label") or "").strip())
|
||||
if not has_focus_object:
|
||||
violated_invariants.append("focus_object_missing")
|
||||
|
||||
if "date_scope" in normalize_string_list(state.get("required_carryover_invariants")) and required_filters.get("as_of_date"):
|
||||
current_date_scope = normalize_iso_date(date_scope.get("as_of_date"))
|
||||
if current_date_scope and current_date_scope != required_filters["as_of_date"]:
|
||||
violated_invariants.append("wrong_date_scope_state")
|
||||
|
||||
if should_require_direct_answer(state):
|
||||
if not actual_direct_answer or is_top_level_noise_line(actual_direct_answer):
|
||||
violated_invariants.append("direct_answer_missing")
|
||||
|
||||
first_top_line = str(top_non_empty_lines[0] if top_non_empty_lines else "").strip()
|
||||
if first_top_line and is_top_level_noise_line(first_top_line):
|
||||
violated_invariants.append("top_level_noise_present")
|
||||
|
||||
forbidden_answer_patterns = normalize_string_list(state.get("forbidden_answer_patterns"))
|
||||
if forbidden_answer_patterns and top_non_empty_lines:
|
||||
joined_top_block = "\n".join(str(line) for line in top_non_empty_lines)
|
||||
for pattern in forbidden_answer_patterns:
|
||||
if pattern and re.search(pattern, joined_top_block, flags=re.IGNORECASE):
|
||||
warnings.append(f"forbidden_answer_pattern:{pattern}")
|
||||
|
||||
unique_violations = list(dict.fromkeys(violated_invariants))
|
||||
hard_fail = any(derive_invariant_severity(state, code) == "P0" for code in unique_violations)
|
||||
state["violated_invariants"] = unique_violations
|
||||
state["warnings"] = list(dict.fromkeys(warnings))
|
||||
state["hard_fail"] = hard_fail
|
||||
state["acceptance_status"] = acceptance_status_from_execution(execution_status, hard_fail)
|
||||
state["status"] = state["acceptance_status"]
|
||||
return state
|
||||
|
||||
|
||||
def build_scenario_step_state(
|
||||
*,
|
||||
scenario_id: str,
|
||||
@@ -1167,6 +1501,9 @@ def build_scenario_step_state(
|
||||
entries: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
debug_payload = turn_artifact.get("technical_debug_payload")
|
||||
if not isinstance(debug_payload, dict):
|
||||
assistant_debug = turn_artifact.get("assistant_message", {}).get("debug") if isinstance(turn_artifact.get("assistant_message"), dict) else None
|
||||
debug_payload = assistant_debug if isinstance(assistant_debug, dict) else {}
|
||||
debug = debug_payload if isinstance(debug_payload, dict) else {}
|
||||
session_summary = turn_artifact.get("session_summary")
|
||||
summary = session_summary if isinstance(session_summary, dict) else {}
|
||||
@@ -1177,6 +1514,9 @@ def build_scenario_step_state(
|
||||
assistant_message = turn_artifact.get("assistant_message")
|
||||
assistant_item = assistant_message if isinstance(assistant_message, dict) else {}
|
||||
reply_type = assistant_item.get("reply_type")
|
||||
assistant_text = str(assistant_item.get("text") or "")
|
||||
top_non_empty = first_non_empty_lines(assistant_text, limit=3)
|
||||
analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {}
|
||||
|
||||
step_state = {
|
||||
"schema_version": SCENARIO_STEP_STATE_SCHEMA_VERSION,
|
||||
@@ -1188,8 +1528,19 @@ def build_scenario_step_state(
|
||||
"depends_on": step["depends_on"],
|
||||
"question_template": step["question_template"],
|
||||
"question_resolved": question_resolved,
|
||||
"analysis_context": analysis_context,
|
||||
"expected_intents": step.get("expected_intents") or [],
|
||||
"expected_capability": step.get("expected_capability"),
|
||||
"expected_recipe": step.get("expected_recipe"),
|
||||
"expected_result_mode": step.get("expected_result_mode"),
|
||||
"required_filters": step.get("required_filters") or {},
|
||||
"forbidden_capabilities": step.get("forbidden_capabilities") or [],
|
||||
"forbidden_recipes": step.get("forbidden_recipes") or [],
|
||||
"required_state_objects": step.get("required_state_objects") or [],
|
||||
"required_answer_shape": step.get("required_answer_shape"),
|
||||
"forbidden_answer_patterns": step.get("forbidden_answer_patterns") or [],
|
||||
"required_carryover_invariants": step.get("required_carryover_invariants") or [],
|
||||
"invariant_severity": step.get("invariant_severity") or {},
|
||||
"reply_type": reply_type,
|
||||
"assistant_message_id": assistant_item.get("message_id"),
|
||||
"trace_id": assistant_item.get("trace_id"),
|
||||
@@ -1201,6 +1552,11 @@ def build_scenario_step_state(
|
||||
"route_expectation_status": debug.get("route_expectation_status"),
|
||||
"result_mode": debug.get("result_mode"),
|
||||
"response_type": debug.get("response_type"),
|
||||
"assistant_text": assistant_text,
|
||||
"top_non_empty_lines": top_non_empty,
|
||||
"actual_direct_answer": top_non_empty[0] if top_non_empty else None,
|
||||
"extracted_filters": debug.get("extracted_filters") if isinstance(debug.get("extracted_filters"), dict) else {},
|
||||
"focus_object": context.get("active_focus_object") if isinstance(context.get("active_focus_object"), dict) else None,
|
||||
"fallback_type": debug.get("fallback_type"),
|
||||
"mcp_call_status": debug.get("mcp_call_status"),
|
||||
"balance_confirmed": debug.get("balance_confirmed"),
|
||||
@@ -1210,8 +1566,10 @@ def build_scenario_step_state(
|
||||
"organization_scope": context.get("organization_scope"),
|
||||
"entries": entries,
|
||||
}
|
||||
step_state["status"] = derive_step_status(reply_type if isinstance(reply_type, str) else None, debug)
|
||||
return step_state
|
||||
step_state["execution_status"] = derive_step_execution_status(reply_type if isinstance(reply_type, str) else None, debug)
|
||||
step_state["acceptance_status"] = step_state["execution_status"]
|
||||
step_state["status"] = step_state["acceptance_status"]
|
||||
return validate_step_contract(step_state)
|
||||
|
||||
|
||||
def save_scenario_step_bundle(
|
||||
@@ -1233,8 +1591,8 @@ def save_scenario_step_bundle(
|
||||
write_text(step_dir / "resolved_question.txt", f"{step_state['question_resolved']}\n")
|
||||
|
||||
|
||||
def derive_scenario_status(step_outputs: dict[str, dict[str, Any]]) -> str:
|
||||
statuses = [str(item.get("status") or "") for item in step_outputs.values()]
|
||||
def derive_scenario_execution_status(step_outputs: dict[str, dict[str, Any]]) -> str:
|
||||
statuses = [str(item.get("execution_status") or item.get("status") or "") for item in step_outputs.values()]
|
||||
if not statuses:
|
||||
return "blocked"
|
||||
if any(status == "blocked" for status in statuses):
|
||||
@@ -1243,10 +1601,23 @@ def derive_scenario_status(step_outputs: dict[str, dict[str, Any]]) -> str:
|
||||
return "needs_exact_capability"
|
||||
if any(status == "partial" for status in statuses):
|
||||
return "partial"
|
||||
return "accepted"
|
||||
return "exact"
|
||||
|
||||
|
||||
def build_scenario_summary(manifest: dict[str, Any], scenario_state: dict[str, Any], final_status: str) -> str:
|
||||
def derive_scenario_status(step_outputs: dict[str, dict[str, Any]]) -> str:
|
||||
statuses = [str(item.get("acceptance_status") or item.get("status") or "") for item in step_outputs.values()]
|
||||
if not statuses:
|
||||
return "blocked"
|
||||
if any(status == "blocked" for status in statuses):
|
||||
return "blocked"
|
||||
if any(status == "needs_exact_capability" for status in statuses):
|
||||
return "needs_exact_capability"
|
||||
if any(status in {"partial", "rejected"} for status in statuses):
|
||||
return "partial"
|
||||
return "accepted" if all(status == "validated" for status in statuses) else "partial"
|
||||
|
||||
|
||||
def build_scenario_summary(manifest: dict[str, Any], scenario_state: dict[str, Any], final_status: str, execution_status: str) -> str:
|
||||
lines = [
|
||||
"# Scenario summary",
|
||||
"",
|
||||
@@ -1254,6 +1625,7 @@ def build_scenario_summary(manifest: dict[str, Any], scenario_state: dict[str, A
|
||||
f"- domain: `{manifest['domain']}`",
|
||||
f"- title: {manifest['title']}",
|
||||
f"- session_id: `{scenario_state.get('session_id') or 'n/a'}`",
|
||||
f"- execution_status: `{execution_status}`",
|
||||
f"- final_status: `{final_status}`",
|
||||
"",
|
||||
"## Steps",
|
||||
@@ -1263,20 +1635,27 @@ def build_scenario_summary(manifest: dict[str, Any], scenario_state: dict[str, A
|
||||
lines.extend(
|
||||
[
|
||||
f"{index}. `{step['step_id']}` - {step['question_template']}",
|
||||
f"status: `{step_output.get('status') or 'n/a'}`",
|
||||
f"execution_status: `{step_output.get('execution_status') or 'n/a'}`",
|
||||
f"acceptance_status: `{step_output.get('acceptance_status') or step_output.get('status') or 'n/a'}`",
|
||||
f"question_resolved: {step_output.get('question_resolved') or 'n/a'}",
|
||||
f"intent: `{step_output.get('detected_intent') or 'n/a'}`",
|
||||
f"recipe: `{step_output.get('selected_recipe') or 'n/a'}`",
|
||||
f"capability: `{step_output.get('capability_id') or 'n/a'}`",
|
||||
f"result_mode: `{step_output.get('result_mode') or 'n/a'}`",
|
||||
f"result_set: `{step_output.get('active_result_set_id') or 'n/a'}`",
|
||||
f"violated_invariants: {', '.join(step_output.get('violated_invariants') or []) or 'none'}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def build_scenario_final_status(manifest: dict[str, Any], scenario_state: dict[str, Any], final_status: str) -> str:
|
||||
def build_scenario_final_status(
|
||||
manifest: dict[str, Any],
|
||||
scenario_state: dict[str, Any],
|
||||
final_status: str,
|
||||
execution_status: str,
|
||||
) -> str:
|
||||
reason = {
|
||||
"accepted": "all scenario steps executed in one assistant session with no unresolved route or capability gaps",
|
||||
"partial": "scenario captured successfully, but at least one step still needs exact capability enablement or route hardening",
|
||||
@@ -1288,6 +1667,7 @@ def build_scenario_final_status(manifest: dict[str, Any], scenario_state: dict[s
|
||||
# Final status
|
||||
|
||||
- status: `{final_status}`
|
||||
- execution_status: `{execution_status}`
|
||||
- scenario_id: `{manifest['scenario_id']}`
|
||||
- session_id: `{scenario_state.get('session_id') or 'n/a'}`
|
||||
- reason: {reason}
|
||||
@@ -1492,14 +1872,15 @@ def execute_scenario_manifest(
|
||||
f"{step['step_id']} -> {result['step_state']['status']}"
|
||||
)
|
||||
|
||||
execution_status = derive_scenario_execution_status(scenario_state["step_outputs"])
|
||||
final_status = derive_scenario_status(scenario_state["step_outputs"])
|
||||
write_text(scenario_dir / "scenario_output.md", last_export_markdown or "")
|
||||
write_text(scenario_dir / "scenario_summary.md", build_scenario_summary(manifest, scenario_state, final_status))
|
||||
write_text(scenario_dir / "final_status.md", build_scenario_final_status(manifest, scenario_state, final_status))
|
||||
write_text(scenario_dir / "scenario_summary.md", build_scenario_summary(manifest, scenario_state, final_status, execution_status))
|
||||
write_text(scenario_dir / "final_status.md", build_scenario_final_status(manifest, scenario_state, final_status, execution_status))
|
||||
if scenario_state.get("session_id"):
|
||||
write_text(scenario_dir / "session_id.txt", f"{scenario_state['session_id']}\n")
|
||||
print(f"[domain-case-loop] saved scenario artifacts to {scenario_dir}")
|
||||
print(f"[domain-case-loop] final_status={final_status}")
|
||||
print(f"[domain-case-loop] execution_status={execution_status} final_status={final_status}")
|
||||
return scenario_state, final_status
|
||||
|
||||
|
||||
@@ -1645,13 +2026,19 @@ def handle_run_scenario(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def build_pack_summary(pack: dict[str, Any], scenario_results: list[dict[str, Any]], final_status: str) -> str:
|
||||
def build_pack_summary(
|
||||
pack: dict[str, Any],
|
||||
scenario_results: list[dict[str, Any]],
|
||||
final_status: str,
|
||||
execution_status: str,
|
||||
) -> str:
|
||||
lines = [
|
||||
"# Pack summary",
|
||||
"",
|
||||
f"- pack_id: `{pack['pack_id']}`",
|
||||
f"- domain: `{pack['domain']}`",
|
||||
f"- title: {pack['title']}",
|
||||
f"- execution_status: `{execution_status}`",
|
||||
f"- final_status: `{final_status}`",
|
||||
"",
|
||||
"## Scenarios",
|
||||
@@ -1660,7 +2047,8 @@ def build_pack_summary(pack: dict[str, Any], scenario_results: list[dict[str, An
|
||||
lines.extend(
|
||||
[
|
||||
f"{index}. `{item['scenario_id']}` - {item['title']}",
|
||||
f"status: `{item['final_status']}`",
|
||||
f"execution_status: `{item.get('execution_status') or 'n/a'}`",
|
||||
f"acceptance_status: `{item['final_status']}`",
|
||||
f"session_id: `{item.get('session_id') or 'n/a'}`",
|
||||
f"artifact_dir: `{item['artifact_dir']}`",
|
||||
"",
|
||||
@@ -1669,7 +2057,12 @@ def build_pack_summary(pack: dict[str, Any], scenario_results: list[dict[str, An
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def build_pack_final_status(pack: dict[str, Any], scenario_results: list[dict[str, Any]], final_status: str) -> str:
|
||||
def build_pack_final_status(
|
||||
pack: dict[str, Any],
|
||||
scenario_results: list[dict[str, Any]],
|
||||
final_status: str,
|
||||
execution_status: str,
|
||||
) -> str:
|
||||
expected_scenarios = len(pack.get("scenarios") or [])
|
||||
executed_scenarios = len(scenario_results)
|
||||
has_missing_scenarios = executed_scenarios < expected_scenarios
|
||||
@@ -1689,6 +2082,7 @@ def build_pack_final_status(pack: dict[str, Any], scenario_results: list[dict[st
|
||||
# Final status
|
||||
|
||||
- status: `{final_status}`
|
||||
- execution_status: `{execution_status}`
|
||||
- pack_id: `{pack['pack_id']}`
|
||||
- domain: `{pack['domain']}`
|
||||
- reason: {reason}
|
||||
@@ -1709,6 +2103,19 @@ def derive_coverage_status(statuses: list[str]) -> str:
|
||||
return "partial"
|
||||
|
||||
|
||||
def derive_pack_execution_status(scenario_results: list[dict[str, Any]]) -> str:
|
||||
aggregate_statuses = [str(item.get("execution_status") or "") for item in scenario_results if isinstance(item, dict)]
|
||||
if not aggregate_statuses:
|
||||
return "blocked"
|
||||
if any(status == "blocked" for status in aggregate_statuses):
|
||||
return "blocked"
|
||||
if any(status == "needs_exact_capability" for status in aggregate_statuses):
|
||||
return "needs_exact_capability"
|
||||
if any(status == "partial" for status in aggregate_statuses):
|
||||
return "partial"
|
||||
return "exact"
|
||||
|
||||
|
||||
def derive_pack_final_status(pack: dict[str, Any], scenario_results: list[dict[str, Any]]) -> str:
|
||||
aggregate_statuses = [item["final_status"] for item in scenario_results]
|
||||
if not aggregate_statuses:
|
||||
@@ -2099,6 +2506,8 @@ def compact_step_output_for_review(step_output: Any) -> dict[str, Any]:
|
||||
entry_titles_sample.append(title)
|
||||
return {
|
||||
"status": step_output.get("status"),
|
||||
"execution_status": step_output.get("execution_status"),
|
||||
"acceptance_status": step_output.get("acceptance_status"),
|
||||
"question_resolved": step_output.get("question_resolved"),
|
||||
"detected_intent": step_output.get("detected_intent"),
|
||||
"selected_recipe": step_output.get("selected_recipe"),
|
||||
@@ -2106,6 +2515,8 @@ def compact_step_output_for_review(step_output: Any) -> dict[str, Any]:
|
||||
"result_mode": step_output.get("result_mode"),
|
||||
"answer_shape": step_output.get("answer_shape"),
|
||||
"actual_direct_answer": step_output.get("actual_direct_answer"),
|
||||
"violated_invariants": step_output.get("violated_invariants"),
|
||||
"warnings": step_output.get("warnings"),
|
||||
"fallback_type": step_output.get("fallback_type"),
|
||||
"mcp_call_status": step_output.get("mcp_call_status"),
|
||||
"failure_type": step_output.get("failure_type"),
|
||||
@@ -2140,6 +2551,7 @@ def build_pack_review_bundle(pack_dir: Path) -> str:
|
||||
"pack_id": pack_state.get("pack_id"),
|
||||
"domain": pack_state.get("domain"),
|
||||
"title": pack_state.get("title"),
|
||||
"execution_status": pack_state.get("execution_status"),
|
||||
"final_status": pack_state.get("final_status"),
|
||||
"scenario_results": pack_state.get("scenario_results"),
|
||||
},
|
||||
@@ -2387,12 +2799,14 @@ def handle_run_pack(args: argparse.Namespace) -> int:
|
||||
{
|
||||
"scenario_id": scenario_manifest["scenario_id"],
|
||||
"title": scenario_manifest["title"],
|
||||
"execution_status": derive_scenario_execution_status(scenario_state.get("step_outputs") or {}),
|
||||
"final_status": scenario_final_status,
|
||||
"session_id": scenario_state.get("session_id"),
|
||||
"artifact_dir": str(scenario_dir),
|
||||
}
|
||||
)
|
||||
|
||||
execution_status = derive_pack_execution_status(scenario_results)
|
||||
final_status = derive_pack_final_status(pack, scenario_results)
|
||||
|
||||
pack_state = {
|
||||
@@ -2403,15 +2817,16 @@ def handle_run_pack(args: argparse.Namespace) -> int:
|
||||
"analysis_context": pack.get("analysis_context") or {},
|
||||
"bindings": pack.get("bindings") or {},
|
||||
"scenario_results": scenario_results,
|
||||
"execution_status": execution_status,
|
||||
"final_status": final_status,
|
||||
"updated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
}
|
||||
write_text(pack_dir / "scenario_acceptance_matrix.md", build_scenario_acceptance_matrix(pack, scenario_results))
|
||||
write_json(pack_dir / "pack_state.json", pack_state)
|
||||
write_text(pack_dir / "pack_summary.md", build_pack_summary(pack, scenario_results, final_status))
|
||||
write_text(pack_dir / "final_status.md", build_pack_final_status(pack, scenario_results, final_status))
|
||||
write_text(pack_dir / "pack_summary.md", build_pack_summary(pack, scenario_results, final_status, execution_status))
|
||||
write_text(pack_dir / "final_status.md", build_pack_final_status(pack, scenario_results, final_status, execution_status))
|
||||
print(f"[domain-case-loop] saved pack artifacts to {pack_dir}")
|
||||
print(f"[domain-case-loop] final_status={final_status}")
|
||||
print(f"[domain-case-loop] execution_status={execution_status} final_status={final_status}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user