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

This commit is contained in:
dctouch 2026-04-14 16:28:57 +03:00
parent 7205fdd4d0
commit f6a2c8e0a3
3 changed files with 910 additions and 2 deletions

View File

@ -2,6 +2,7 @@
"schema_version": "active_domain_contract_v1",
"status": "active",
"domain_id": "inventory_stock_supplier_provenance",
"runtime_domain": "inventory_stock",
"title": "Складские остатки, происхождение товара и связь с поставщиком",
"source_of_truth_policy": {
"purpose": "single mutable domain source for the current orchestration target",
@ -17,6 +18,10 @@
]
},
"domain_goal": "Показать остатки товаров на складе на дату, затем по выбранной позиции или по связанному срезу пройти в происхождение товара, поставщика, закупочные документы, возраст закупки и дальнейшую продажу без потери контекста.",
"default_analysis_context": {
"as_of_date": "2021-09-30",
"source": "active_domain_contract_default"
},
"business_value": [
"Пользователь должен сначала получить подтвержденный срез остатков на дату.",
"Пользователь должен затем выбрать реальную позицию из ответа и углубиться без ручного переписывания сущности.",
@ -417,6 +422,414 @@
}
]
},
"orchestration_pack": {
"pack_id": "inventory_stock_active_contract_pool",
"title": "Warehouse stock active domain pack",
"description": "Scenario pack generated from the single active warehouse domain contract.",
"analysis_context": {
"as_of_date": "2021-09-30",
"source": "active_domain_contract_pack"
},
"bindings": {
"target_date_historical": "2019-03-31",
"target_date_current": "2021-09-30",
"observed_warehouse": "Основной склад",
"observed_organization": "ООО \\Альтернатива Плюс\\",
"focus_item_current": "Диван трехместный",
"focus_item_historical": "Столешница 600*3050*26 дуб ниагара",
"focus_item_small_residue": "Четки Пост (84*117)",
"observed_supplier_candidate": "Торговый дом \\Союз",
"observed_supplier_candidate_alt": "Гамма-мебель, ООО",
"observed_customer_candidate": "Департамент капитального ремонта города Москвы"
},
"scenarios": [
{
"scenario_id": "inventory_snapshot_roots",
"title": "Root stock snapshots",
"question_ids": ["Q01", "Q02", "Q03", "Q04", "Q05"],
"node_ids": ["N01_stock_snapshot", "N02_account_41_snapshot"],
"acceptance_canon": {
"root_step_id": "step_01_stock_now",
"primary_user_path": ["step_01_stock_now", "step_02_stock_on_historical_date", "step_05_nomenclature_on_historical_date"],
"required_paraphrase_families": ["canonical", "colloquial"],
"required_carryover_invariants": ["date_scope", "warehouse_scope", "organization_scope", "answer_shape"]
},
"steps": [
{
"step_id": "step_01_stock_now",
"question_id": "Q01",
"node_id": "N01_stock_snapshot",
"node_role": "root",
"paraphrase_family": "canonical",
"title": "Current stock root",
"question": "Какие товары сейчас лежат на складе",
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_02_stock_on_historical_date",
"question_id": "Q02",
"node_id": "N01_stock_snapshot",
"node_role": "root",
"paraphrase_family": "colloquial",
"title": "Historical stock slice",
"question": "покажи остатки на складе на март 2019",
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_03_account_41_now",
"question_id": "Q03",
"node_id": "N02_account_41_snapshot",
"node_role": "root_variant",
"paraphrase_family": "canonical",
"title": "Account 41 current composition",
"question": "Из каких товаров состоит остаток по 41 счету",
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_04_account_41_on_historical_date",
"question_id": "Q04",
"node_id": "N02_account_41_snapshot",
"node_role": "root_variant",
"paraphrase_family": "canonical",
"title": "Account 41 on historical date",
"question": "Какие товары числятся на 41 счете на дату {{bindings.target_date_historical}}",
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_05_nomenclature_on_historical_date",
"question_id": "Q05",
"node_id": "N01_stock_snapshot",
"node_role": "root_variant",
"paraphrase_family": "canonical",
"title": "Nomenclature on historical date",
"question": "Какие конкретно номенклатуры формируют остаток по складу на дату {{bindings.target_date_historical}}",
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
}
]
},
{
"scenario_id": "inventory_selected_item_provenance",
"title": "Selected-item supplier provenance",
"question_ids": ["Q02", "Q06", "Q09", "Q10", "Q19", "Q20"],
"node_ids": ["N01_stock_snapshot", "N03_selected_item_supplier", "N04_selected_item_purchase_date", "N05_selected_item_purchase_documents"],
"acceptance_canon": {
"root_step_id": "step_01_snapshot_historical",
"primary_user_path": ["step_01_snapshot_historical", "step_02_selected_item_supplier_colloquial", "step_05_selected_item_documents_ui"],
"required_paraphrase_families": ["canonical", "colloquial", "ui_selected_object", "ui_selected_object_colloquial"],
"required_carryover_invariants": ["selected_object", "date_scope", "warehouse_scope", "organization_scope", "answer_shape"]
},
"steps": [
{
"step_id": "step_01_snapshot_historical",
"question_id": "Q02",
"node_id": "N01_stock_snapshot",
"node_role": "root",
"paraphrase_family": "colloquial",
"title": "Historical stock anchor",
"question": "покажи остатки на складе на март 2019",
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_02_selected_item_supplier_colloquial",
"question_id": "Q19",
"node_id": "N03_selected_item_supplier",
"node_role": "critical_child",
"paraphrase_family": "ui_selected_object_colloquial",
"title": "Selected item supplier colloquial",
"question": "По выбранному объекту \"{{bindings.focus_item_historical}}\": кто это поставил нам",
"depends_on": ["step_01_snapshot_historical"],
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "inventory_purchase_provenance_for_item",
"required_carryover_invariants": ["selected_object", "date_scope", "warehouse_scope", "organization_scope"]
},
{
"step_id": "step_03_selected_item_supplier_canonical",
"question_id": "Q06",
"node_id": "N03_selected_item_supplier",
"node_role": "critical_child",
"paraphrase_family": "canonical",
"title": "Selected item supplier canonical",
"question": "От какого поставщика куплен товар {{bindings.focus_item_historical}}",
"depends_on": ["step_01_snapshot_historical"],
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "inventory_purchase_provenance_for_item"
},
{
"step_id": "step_04_selected_item_purchase_date",
"question_id": "Q09",
"node_id": "N04_selected_item_purchase_date",
"node_role": "critical_child",
"paraphrase_family": "canonical",
"title": "Selected item purchase date",
"question": "Когда был куплен товар {{bindings.focus_item_historical}}",
"depends_on": ["step_01_snapshot_historical", "step_02_selected_item_supplier_colloquial"]
},
{
"step_id": "step_05_selected_item_documents_ui",
"question_id": "Q20",
"node_id": "N05_selected_item_purchase_documents",
"node_role": "critical_child",
"paraphrase_family": "ui_selected_object_colloquial",
"title": "Selected item purchase documents UI",
"question": "По выбранному объекту \"{{bindings.focus_item_historical}}\": по каким документам это купили",
"depends_on": ["step_01_snapshot_historical", "step_02_selected_item_supplier_colloquial"],
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "inventory_purchase_documents_for_item",
"required_carryover_invariants": ["selected_object", "date_scope"]
},
{
"step_id": "step_06_selected_item_documents_canonical",
"question_id": "Q10",
"node_id": "N05_selected_item_purchase_documents",
"node_role": "critical_child",
"paraphrase_family": "canonical",
"title": "Selected item purchase documents canonical",
"question": "По каким документам был куплен товар {{bindings.focus_item_historical}}",
"depends_on": ["step_01_snapshot_historical", "step_02_selected_item_supplier_colloquial"],
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "inventory_purchase_documents_for_item"
}
]
},
{
"scenario_id": "inventory_supplier_overlap",
"title": "Supplier overlap and supplier-scoped stock",
"question_ids": ["Q01", "Q07", "Q08", "Q11", "Q12"],
"node_ids": ["N01_stock_snapshot", "N06_supplier_overlap_now", "N07_supplier_items_on_stock", "N08_supplier_items_on_date"],
"acceptance_canon": {
"root_step_id": "step_01_snapshot_current",
"primary_user_path": ["step_01_snapshot_current", "step_02_supplier_overlap_now", "step_04_supplier_items_on_stock"],
"required_paraphrase_families": ["canonical", "colloquial"],
"required_carryover_invariants": ["date_scope", "warehouse_scope", "organization_scope", "answer_shape"]
},
"steps": [
{
"step_id": "step_01_snapshot_current",
"question_id": "Q01",
"node_id": "N01_stock_snapshot",
"node_role": "root",
"paraphrase_family": "colloquial",
"title": "Current stock anchor",
"question": "какие остатки на складе на сентябрь 2021",
"analysis_context": {
"as_of_date": "2021-09-30",
"source": "binding_target_date_current"
},
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_02_supplier_overlap_now",
"question_id": "Q07",
"node_id": "N06_supplier_overlap_now",
"node_role": "critical_child",
"paraphrase_family": "canonical",
"title": "Suppliers behind current stock",
"question": "У какого поставщика были куплены товары, которые сейчас лежат на складе {{bindings.observed_warehouse}}",
"depends_on": ["step_01_snapshot_current"]
},
{
"step_id": "step_03_supplier_residue_now",
"question_id": "Q08",
"node_id": "N06_supplier_overlap_now",
"node_role": "supporting_child",
"paraphrase_family": "canonical",
"title": "Supplier attribution of current residue",
"question": "По какому поставщику проходит текущий товарный остаток на складе {{bindings.observed_warehouse}}",
"depends_on": ["step_01_snapshot_current"]
},
{
"step_id": "step_04_supplier_items_on_stock",
"question_id": "Q11",
"node_id": "N07_supplier_items_on_stock",
"node_role": "supporting_child",
"paraphrase_family": "canonical",
"title": "Current items for observed supplier",
"question": "Какие товары от поставщика {{bindings.observed_supplier_candidate}} сейчас еще лежат на складе {{bindings.observed_warehouse}}",
"depends_on": ["step_01_snapshot_current"]
},
{
"step_id": "step_05_supplier_items_on_date",
"question_id": "Q12",
"node_id": "N08_supplier_items_on_date",
"node_role": "supporting_child",
"paraphrase_family": "canonical",
"title": "Supplier items on historical date",
"question": "Какие товары по состоянию на дату {{bindings.target_date_historical}} были куплены у поставщика {{bindings.observed_supplier_candidate}}",
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
}
}
]
},
{
"scenario_id": "inventory_aging_and_unresolved",
"title": "Stock aging and unresolved supplier linkage",
"question_ids": ["Q13", "Q14", "Q15", "Q19"],
"node_ids": ["N01_stock_snapshot", "N03_selected_item_supplier", "N09_old_purchase_aging", "N10_unresolved_supplier_link"],
"acceptance_canon": {
"root_step_id": "step_01_snapshot_current",
"primary_user_path": ["step_01_snapshot_current", "step_02_selected_item_supplier_small", "step_03_old_purchase_aging_followup"],
"required_paraphrase_families": ["canonical", "followup_date_carryover", "ui_selected_object_colloquial"],
"required_carryover_invariants": ["selected_object", "date_scope", "warehouse_scope", "organization_scope", "answer_shape", "ordering_semantics"]
},
"steps": [
{
"step_id": "step_01_snapshot_current",
"question_id": "Q01",
"node_id": "N01_stock_snapshot",
"node_role": "root",
"paraphrase_family": "colloquial",
"title": "Current stock anchor",
"question": "какие остатки на складе на сентябрь 2021",
"analysis_context": {
"as_of_date": "2021-09-30",
"source": "binding_target_date_current"
},
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_02_selected_item_supplier_small",
"question_id": "Q19",
"node_id": "N03_selected_item_supplier",
"node_role": "critical_child",
"paraphrase_family": "ui_selected_object_colloquial",
"title": "Supplier for small residual item",
"question": "По выбранному объекту \"{{bindings.focus_item_small_residue}}\": кто это поставил нам",
"depends_on": ["step_01_snapshot_current"]
},
{
"step_id": "step_03_old_purchase_aging_followup",
"question_id": "Q13",
"node_id": "N09_old_purchase_aging",
"node_role": "critical_child",
"paraphrase_family": "followup_date_carryover",
"title": "Old purchase aging on the same date",
"question": "Какие остатки по товарам на эту дату относятся к старым закупкам",
"depends_on": ["step_01_snapshot_current", "step_02_selected_item_supplier_small"],
"required_carryover_invariants": ["date_scope", "warehouse_scope", "organization_scope"],
"ordering_rule": "oldest_first"
},
{
"step_id": "step_04_very_old_stock",
"question_id": "Q15",
"node_id": "N09_old_purchase_aging",
"node_role": "supporting_child",
"paraphrase_family": "canonical",
"title": "Very old stock",
"question": "Есть ли остатки товара, которые закупались очень давно",
"depends_on": ["step_01_snapshot_current", "step_03_old_purchase_aging_followup"],
"ordering_rule": "oldest_first"
},
{
"step_id": "step_05_unresolved_supplier_link",
"question_id": "Q14",
"node_id": "N10_unresolved_supplier_link",
"node_role": "supporting_child",
"paraphrase_family": "canonical",
"title": "Unresolved supplier linkage",
"question": "Какие товары сейчас висят в остатке без понятной привязки к поставщику",
"depends_on": ["step_01_snapshot_current"]
}
]
},
{
"scenario_id": "inventory_sale_trace",
"title": "Sale trace and purchase-to-sale chain",
"question_ids": ["Q04", "Q16", "Q17", "Q18"],
"node_ids": ["N02_account_41_snapshot", "N11_selected_item_buyer", "N12_purchase_to_sale_chain", "N13_supplier_to_buyer_overlap"],
"acceptance_canon": {
"root_step_id": "step_01_account_41_historical",
"primary_user_path": ["step_01_account_41_historical", "step_02_selected_item_buyer", "step_03_purchase_to_sale_chain"],
"required_paraphrase_families": ["canonical", "ui_selected_object"],
"required_carryover_invariants": ["selected_object", "date_scope", "answer_shape"]
},
"steps": [
{
"step_id": "step_01_account_41_historical",
"question_id": "Q04",
"node_id": "N02_account_41_snapshot",
"node_role": "root_variant",
"paraphrase_family": "canonical",
"title": "Historical account 41 anchor",
"question": "Какие товары числятся на 41 счете на дату {{bindings.target_date_historical}}",
"analysis_context": {
"as_of_date": "2019-03-31",
"source": "binding_target_date_historical"
},
"expected_capability": "confirmed_inventory_on_hand_as_of_date",
"expected_result_mode": "confirmed_balance"
},
{
"step_id": "step_02_selected_item_buyer",
"question_id": "Q16",
"node_id": "N11_selected_item_buyer",
"node_role": "critical_child",
"paraphrase_family": "canonical",
"title": "Buyer for historical selected item",
"question": "Кому был продан товар {{bindings.focus_item_historical}}",
"depends_on": ["step_01_account_41_historical"]
},
{
"step_id": "step_03_purchase_to_sale_chain",
"question_id": "Q17",
"node_id": "N12_purchase_to_sale_chain",
"node_role": "critical_child",
"paraphrase_family": "canonical",
"title": "Purchase to sale document chain",
"question": "Через какие документы прошел путь товара {{bindings.focus_item_historical}}: закупка -> склад -> продажа",
"depends_on": ["step_01_account_41_historical", "step_02_selected_item_buyer"]
},
{
"step_id": "step_04_supplier_to_buyer_overlap",
"question_id": "Q18",
"node_id": "N13_supplier_to_buyer_overlap",
"node_role": "supporting_child",
"paraphrase_family": "canonical",
"title": "Supplier to buyer overlap",
"question": "Какие товары были куплены у поставщика {{bindings.observed_supplier_candidate}} и позже проданы покупателю {{bindings.observed_customer_candidate}}",
"depends_on": ["step_01_account_41_historical", "step_02_selected_item_buyer"]
}
]
}
]
},
"acceptance_contract": {
"acceptance_unit": "scenario_tree",
"do_not_accept_if": [

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))

View File

@ -1,6 +1,17 @@
from __future__ import annotations
from scripts.domain_case_loop import carry_forward_analysis_context, merge_scenario_date_scope
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.domain_case_loop import (
build_scenario_acceptance_matrix,
carry_forward_analysis_context,
load_scenario_pack,
merge_scenario_date_scope,
)
def test_carry_forward_analysis_context_preserves_followup_anchor() -> None:
@ -42,3 +53,138 @@ def test_merge_scenario_date_scope_preserves_historical_anchor_on_followup() ->
assert merged["as_of_date"] == "2020-03-31"
assert merged["source"] == "current_analysis"
def test_load_scenario_pack_accepts_active_domain_contract(tmp_path) -> None:
manifest_path = tmp_path / "active_domain_contract.json"
manifest_path.write_text(
json.dumps(
{
"schema_version": "active_domain_contract_v1",
"status": "active",
"domain_id": "inventory_stock_supplier_provenance",
"runtime_domain": "inventory_stock",
"title": "Warehouse domain",
"default_analysis_context": {"as_of_date": "2021-09-30"},
"observed_anchors": {
"warehouse": "Основной склад",
"organization": "ООО \\Альтернатива Плюс\\",
"historical_as_of_date": "2019-03-31",
"current_as_of_date_example": "2021-09-30",
"focus_item_historical": "Столешница 600*3050*26 дуб ниагара",
},
"question_pool": {
"questions": [
{"question_id": "Q01", "node_id": "N01_stock_snapshot", "text": "Q1"},
{"question_id": "Q19", "node_id": "N03_selected_item_supplier", "text": "Q19"},
]
},
"scenario_tree": {
"critical_edges": [
{
"edge_id": "E01_snapshot_to_selected_item_supplier",
"from_node": "N01_stock_snapshot",
"to_node": "N03_selected_item_supplier",
"primary_user_path": True,
}
]
},
"orchestration_pack": {
"pack_id": "inventory_active_contract_smoke",
"scenarios": [
{
"scenario_id": "inventory_selected_item_provenance",
"title": "Selected item provenance",
"question_ids": ["Q01", "Q19"],
"steps": [
{
"step_id": "step_01_snapshot",
"question_id": "Q01",
"node_id": "N01_stock_snapshot",
"question": "Какие товары сейчас лежат на складе",
},
{
"step_id": "step_02_supplier",
"question_id": "Q19",
"node_id": "N03_selected_item_supplier",
"question": "По выбранному объекту \"Столешница 600*3050*26 дуб ниагара\": кто это поставил нам",
},
],
}
],
},
},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
pack = load_scenario_pack(manifest_path)
assert pack["schema_version"] == "domain_scenario_pack_v1"
assert pack["source_schema_version"] == "active_domain_contract_v1"
assert pack["domain"] == "inventory_stock"
assert pack["bindings"]["observed_warehouse"] == "Основной склад"
assert pack["bindings"]["focus_item_historical"] == "Столешница 600*3050*26 дуб ниагара"
assert pack["scenarios"][0]["question_ids"] == ["Q01", "Q19"]
assert pack["scenarios"][0]["steps"][1]["question_id"] == "Q19"
def test_build_scenario_acceptance_matrix_marks_green_edge_when_covering_scenario_is_accepted() -> None:
pack = {
"pack_id": "inventory_active_contract_smoke",
"domain": "inventory_stock",
"source_contract": {"domain_id": "inventory_stock_supplier_provenance", "title": "Warehouse domain"},
"question_pool": {
"questions": [
{"question_id": "Q01", "node_id": "N01_stock_snapshot"},
{"question_id": "Q19", "node_id": "N03_selected_item_supplier"},
]
},
"scenario_tree": {
"critical_nodes": [
{
"node_id": "N03_selected_item_supplier",
"covers_question_ids": ["Q19"],
"required_wording_families": ["canonical", "ui_selected_object_colloquial"],
}
],
"critical_edges": [
{
"edge_id": "E01_snapshot_to_selected_item_supplier",
"from_node": "N01_stock_snapshot",
"to_node": "N03_selected_item_supplier",
"primary_user_path": True,
}
],
"primary_user_paths": [
{"path_id": "P01_snapshot_to_supplier", "nodes": ["N01_stock_snapshot", "N03_selected_item_supplier"]}
],
},
"scenarios": [
{
"scenario_id": "inventory_selected_item_provenance",
"question_ids": ["Q01", "Q19"],
"steps": [
{"step_id": "step_01_snapshot", "question_id": "Q01", "node_id": "N01_stock_snapshot"},
{"step_id": "step_02_supplier", "question_id": "Q19", "node_id": "N03_selected_item_supplier"},
],
}
],
}
scenario_results = [
{
"scenario_id": "inventory_selected_item_provenance",
"final_status": "accepted",
"session_id": "asst-demo",
"artifact_dir": "artifacts/domain_runs/demo",
}
]
matrix = build_scenario_acceptance_matrix(pack, scenario_results)
assert "E01_snapshot_to_selected_item_supplier" in matrix
assert "| E01_snapshot_to_selected_item_supplier | green |" in matrix
assert "| P01_snapshot_to_supplier | green |" in matrix