ОРРКЕСТРАЦИЯ - Подключить active domain contract к warehouse orchestration и зафиксировать матрицу покрытия сценарного дерева

This commit is contained in:
2026-04-14 16:28:57 +03:00
parent 7205fdd4d0
commit f6a2c8e0a3
3 changed files with 910 additions and 2 deletions
+350 -1
View File
@@ -32,6 +32,7 @@ SCENARIO_MANIFEST_SCHEMA_VERSION = "domain_scenario_manifest_v1"
SCENARIO_STATE_SCHEMA_VERSION = "domain_scenario_state_v1"
SCENARIO_STEP_STATE_SCHEMA_VERSION = "domain_scenario_step_state_v1"
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"
@@ -238,6 +239,20 @@ def normalize_bindings(raw_bindings: Any) -> dict[str, Any]:
return {str(key): normalize_binding_value(value) for key, value in raw_bindings.items()}
def normalize_string_list(raw_values: Any) -> list[str]:
if isinstance(raw_values, str):
value = raw_values.strip()
return [value] if value else []
if not isinstance(raw_values, list):
return []
values: list[str] = []
for item in raw_values:
value = str(item or "").strip()
if value:
values.append(value)
return values
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}
@@ -788,6 +803,12 @@ def normalize_step_definition(index: int, raw_step: Any) -> dict[str, Any]:
"analysis_context": {},
"expected_capability": None,
"expected_result_mode": None,
"question_id": None,
"node_id": None,
"node_role": None,
"paraphrase_family": None,
"required_carryover_invariants": [],
"ordering_rule": None,
}
if not isinstance(raw_step, dict):
raise RuntimeError(f"Scenario step {index} must be a string or object")
@@ -811,6 +832,12 @@ def normalize_step_definition(index: int, raw_step: Any) -> dict[str, Any]:
"analysis_context": normalize_analysis_context(raw_step.get("analysis_context")),
"expected_capability": str(raw_step.get("expected_capability") 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_carryover_invariants": normalize_string_list(raw_step.get("required_carryover_invariants")),
"ordering_rule": str(raw_step.get("ordering_rule") or "").strip() or None,
}
@@ -846,6 +873,9 @@ def normalize_scenario_manifest(
"description": description,
"analysis_context": analysis_context,
"bindings": bindings,
"question_ids": normalize_string_list(raw_manifest.get("question_ids")),
"node_ids": normalize_string_list(raw_manifest.get("node_ids")),
"acceptance_canon": raw_manifest.get("acceptance_canon") if isinstance(raw_manifest.get("acceptance_canon"), dict) else {},
"steps": steps,
}
@@ -855,8 +885,89 @@ def load_scenario_manifest(file_path: Path) -> dict[str, Any]:
return normalize_scenario_manifest(raw_manifest)
def build_active_contract_bindings(raw_contract: dict[str, Any]) -> dict[str, Any]:
observed_anchors = raw_contract.get("observed_anchors")
anchors = observed_anchors if isinstance(observed_anchors, dict) else {}
bindings = {
"target_date_historical": anchors.get("historical_as_of_date"),
"target_date_current": anchors.get("current_as_of_date_example"),
"observed_warehouse": anchors.get("warehouse"),
"observed_organization": anchors.get("organization"),
"focus_item_current": anchors.get("focus_item_current"),
"focus_item_historical": anchors.get("focus_item_historical"),
"focus_item_small_residue": anchors.get("focus_item_small_residue"),
"observed_supplier_candidate": anchors.get("supplier_candidate"),
"observed_supplier_candidate_alt": anchors.get("supplier_candidate_alt"),
"observed_customer_candidate": anchors.get("buyer_candidate"),
}
bindings.update(normalize_bindings(raw_contract.get("bindings")))
return normalize_bindings(bindings)
def convert_active_domain_contract_to_pack(raw_contract: dict[str, Any]) -> dict[str, Any]:
orchestration_pack = raw_contract.get("orchestration_pack")
if not isinstance(orchestration_pack, dict):
raise RuntimeError("Active domain contract must define object `orchestration_pack`")
raw_scenarios = orchestration_pack.get("scenarios")
if not isinstance(raw_scenarios, list) or not raw_scenarios:
raise RuntimeError("Active domain contract must define non-empty `orchestration_pack.scenarios`")
runtime_domain = (
str(raw_contract.get("runtime_domain") or raw_contract.get("domain") or "").strip()
or ("inventory_stock" if str(raw_contract.get("domain_id") or "").startswith("inventory_stock") else "")
)
if not runtime_domain:
raise RuntimeError("Active domain contract must define `runtime_domain`")
domain_id = str(raw_contract.get("domain_id") or runtime_domain).strip() or runtime_domain
bindings = build_active_contract_bindings(raw_contract)
bindings.update(normalize_bindings(orchestration_pack.get("bindings")))
analysis_context = merge_analysis_context(raw_contract.get("default_analysis_context"), raw_contract.get("analysis_context"))
analysis_context = merge_analysis_context(analysis_context, orchestration_pack.get("analysis_context"))
if analysis_context and "source" not in analysis_context:
analysis_context["source"] = "active_domain_contract"
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
return {
"schema_version": SCENARIO_PACK_SCHEMA_VERSION,
"source_schema_version": ACTIVE_DOMAIN_CONTRACT_SCHEMA_VERSION,
"source_contract_id": domain_id,
"pack_id": pack_id,
"domain": runtime_domain,
"title": title,
"description": description,
"analysis_context": analysis_context,
"bindings": bindings,
"scenarios": raw_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 {}
),
"question_pool": raw_contract.get("question_pool") if isinstance(raw_contract.get("question_pool"), dict) else {},
"wording_families": raw_contract.get("wording_families") if isinstance(raw_contract.get("wording_families"), list) else [],
"known_failure_patterns_to_watch": (
raw_contract.get("known_failure_patterns_to_watch")
if isinstance(raw_contract.get("known_failure_patterns_to_watch"), list)
else []
),
"source_contract": {
"domain_id": domain_id,
"title": str(raw_contract.get("title") or domain_id).strip() or domain_id,
"status": str(raw_contract.get("status") or "active").strip() or "active",
},
}
def load_scenario_pack(file_path: Path) -> dict[str, Any]:
raw_pack = read_json_file(file_path)
schema_version = str(raw_pack.get("schema_version") or "").strip()
if schema_version == ACTIVE_DOMAIN_CONTRACT_SCHEMA_VERSION:
raw_pack = convert_active_domain_contract_to_pack(raw_pack)
domain = str(raw_pack.get("domain") or "").strip()
if not domain:
raise RuntimeError("Scenario pack must define `domain`")
@@ -881,7 +992,7 @@ def load_scenario_pack(file_path: Path) -> dict[str, Any]:
)
for index, raw_scenario in enumerate(raw_scenarios)
]
return {
normalized_pack = {
"schema_version": str(raw_pack.get("schema_version") or SCENARIO_PACK_SCHEMA_VERSION),
"pack_id": pack_id,
"domain": domain,
@@ -891,6 +1002,20 @@ def load_scenario_pack(file_path: Path) -> dict[str, Any]:
"bindings": bindings,
"scenarios": scenarios,
}
for optional_key in (
"source_schema_version",
"source_contract_id",
"scenario_tree",
"acceptance_contract",
"question_pool",
"wording_families",
"known_failure_patterns_to_watch",
"source_contract",
):
optional_value = raw_pack.get(optional_key)
if optional_value is not None:
normalized_pack[optional_key] = optional_value
return normalized_pack
def ensure_scenario_brief(scenario_dir: Path, manifest: dict[str, Any]) -> None:
@@ -1552,6 +1677,222 @@ def build_pack_final_status(pack: dict[str, Any], scenario_results: list[dict[st
)
def derive_coverage_status(statuses: list[str]) -> str:
normalized = [str(status or "").strip() for status in statuses if str(status or "").strip()]
if not normalized:
return "unmapped"
if all(status == "accepted" for status in normalized):
return "green"
if any(status == "blocked" for status in normalized):
return "blocked"
if any(status == "needs_exact_capability" for status in normalized):
return "needs_exact_capability"
return "partial"
def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: list[dict[str, Any]]) -> str:
scenario_status_map = {
str(item.get("scenario_id") or ""): str(item.get("final_status") or "unknown")
for item in scenario_results
if isinstance(item, dict)
}
scenarios = pack.get("scenarios") if isinstance(pack.get("scenarios"), list) else []
question_pool = pack.get("question_pool") if isinstance(pack.get("question_pool"), dict) else {}
raw_questions = question_pool.get("questions") if isinstance(question_pool.get("questions"), list) else []
question_index: dict[str, dict[str, Any]] = {}
for raw_question in raw_questions:
if not isinstance(raw_question, dict):
continue
question_id = str(raw_question.get("question_id") or "").strip()
if question_id:
question_index[question_id] = raw_question
scenario_questions_map: dict[str, list[str]] = {}
scenario_nodes_map: dict[str, list[str]] = {}
for scenario in scenarios:
if not isinstance(scenario, dict):
continue
scenario_id = str(scenario.get("scenario_id") or "").strip()
if not scenario_id:
continue
question_ids = normalize_string_list(scenario.get("question_ids"))
if not question_ids:
question_ids = [
str(step.get("question_id") or "").strip()
for step in scenario.get("steps", [])
if isinstance(step, dict) and str(step.get("question_id") or "").strip()
]
node_ids = normalize_string_list(scenario.get("node_ids"))
if not node_ids:
node_ids = [
str(step.get("node_id") or "").strip()
for step in scenario.get("steps", [])
if isinstance(step, dict) and str(step.get("node_id") or "").strip()
]
if not node_ids:
for question_id in question_ids:
question_meta = question_index.get(question_id) or {}
node_id = str(question_meta.get("node_id") or "").strip()
if node_id:
node_ids.append(node_id)
scenario_questions_map[scenario_id] = question_ids
scenario_nodes_map[scenario_id] = list(dict.fromkeys(node_ids))
scenario_tree = pack.get("scenario_tree") if isinstance(pack.get("scenario_tree"), dict) else {}
source_contract = pack.get("source_contract") if isinstance(pack.get("source_contract"), dict) else {}
lines = [
"# Scenario acceptance matrix",
"",
f"- pack_id: `{pack.get('pack_id') or 'n/a'}`",
f"- domain: `{pack.get('domain') or 'n/a'}`",
f"- source_contract_id: `{source_contract.get('domain_id') or pack.get('source_contract_id') or 'n/a'}`",
f"- source_contract_title: {source_contract.get('title') or pack.get('title') or 'n/a'}",
"",
"## Scenario coverage",
"",
"| scenario_id | status | question_ids | node_ids |",
"| --- | --- | --- | --- |",
]
for scenario in scenarios:
if not isinstance(scenario, dict):
continue
scenario_id = str(scenario.get("scenario_id") or "").strip()
if not scenario_id:
continue
lines.append(
"| "
+ " | ".join(
[
scenario_id,
scenario_status_map.get(scenario_id, "not_run"),
", ".join(scenario_questions_map.get(scenario_id) or []) or "-",
", ".join(scenario_nodes_map.get(scenario_id) or []) or "-",
]
)
+ " |"
)
def append_node_section(title: str, section_key: str) -> None:
raw_nodes = scenario_tree.get(section_key)
nodes = raw_nodes if isinstance(raw_nodes, list) else []
if not nodes:
return
lines.extend(
[
"",
f"## {title}",
"",
"| node_id | status | backed_by_scenarios | question_ids | required_wording_families |",
"| --- | --- | --- | --- | --- |",
]
)
for node in nodes:
if not isinstance(node, dict):
continue
node_id = str(node.get("node_id") or "").strip()
if not node_id:
continue
backed_by = sorted(
scenario_id for scenario_id, node_ids in scenario_nodes_map.items() if node_id in node_ids
)
statuses = [scenario_status_map.get(scenario_id, "not_run") for scenario_id in backed_by]
lines.append(
"| "
+ " | ".join(
[
node_id,
derive_coverage_status(statuses),
", ".join(backed_by) or "-",
", ".join(normalize_string_list(node.get("covers_question_ids"))) or "-",
", ".join(normalize_string_list(node.get("required_wording_families"))) or "-",
]
)
+ " |"
)
append_node_section("Root nodes", "root_nodes")
append_node_section("Critical nodes", "critical_nodes")
raw_edges = scenario_tree.get("critical_edges")
edges = raw_edges if isinstance(raw_edges, list) else []
if edges:
lines.extend(
[
"",
"## Critical edges",
"",
"| edge_id | status | from_node | to_node | backed_by_scenarios | primary_user_path |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for edge in edges:
if not isinstance(edge, dict):
continue
edge_id = str(edge.get("edge_id") or "").strip()
from_node = str(edge.get("from_node") or "").strip()
to_node = str(edge.get("to_node") or "").strip()
if not edge_id:
continue
backed_by = sorted(
scenario_id
for scenario_id, node_ids in scenario_nodes_map.items()
if from_node in node_ids and to_node in node_ids
)
statuses = [scenario_status_map.get(scenario_id, "not_run") for scenario_id in backed_by]
lines.append(
"| "
+ " | ".join(
[
edge_id,
derive_coverage_status(statuses),
from_node or "-",
to_node or "-",
", ".join(backed_by) or "-",
"yes" if bool(edge.get("primary_user_path")) else "no",
]
)
+ " |"
)
raw_paths = scenario_tree.get("primary_user_paths")
paths = raw_paths if isinstance(raw_paths, list) else []
if paths:
lines.extend(
[
"",
"## Primary user paths",
"",
"| path_id | status | nodes | backed_by_scenarios |",
"| --- | --- | --- | --- |",
]
)
for path in paths:
if not isinstance(path, dict):
continue
path_id = str(path.get("path_id") or "").strip()
node_ids = normalize_string_list(path.get("nodes"))
backed_by = sorted(
scenario_id
for scenario_id, scenario_node_ids in scenario_nodes_map.items()
if node_ids and all(node_id in scenario_node_ids for node_id in node_ids)
)
statuses = [scenario_status_map.get(scenario_id, "not_run") for scenario_id in backed_by]
lines.append(
"| "
+ " | ".join(
[
path_id or "-",
derive_coverage_status(statuses),
" -> ".join(node_ids) or "-",
", ".join(backed_by) or "-",
]
)
+ " |"
)
return "\n".join(lines).strip() + "\n"
def run_subprocess_command(
command: list[str],
*,
@@ -1727,7 +2068,13 @@ def build_pack_review_bundle(pack_dir: Path) -> str:
"final_status": pack_state.get("final_status"),
"scenario_results": pack_state.get("scenario_results"),
},
"pack_manifest": read_json_file(pack_dir / "pack_manifest.json") if (pack_dir / "pack_manifest.json").exists() else {},
"pack_summary": read_text_file(pack_dir / "pack_summary.md") if (pack_dir / "pack_summary.md").exists() else "",
"scenario_acceptance_matrix": (
read_text_file(pack_dir / "scenario_acceptance_matrix.md")
if (pack_dir / "scenario_acceptance_matrix.md").exists()
else ""
),
"scenarios": scenarios_bundle,
}
return dump_json(bundle)
@@ -1783,6 +2130,7 @@ def build_analyst_loop_prompt(
Required artifacts to inspect:
- `{pack_dir / 'pack_summary.md'}`
- `{pack_dir / 'pack_state.json'}`
- `{pack_dir / 'scenario_acceptance_matrix.md'}`
- all `scenario_summary.md`, `scenario_state.json`, and problematic `steps/*/step_state.json` files inside `{pack_dir / 'scenarios'}`
Goal:
@@ -1947,6 +2295,7 @@ def handle_run_pack(args: argparse.Namespace) -> int:
"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))