From f6a2c8e0a3bdd125faadd7cb64db00519c32a0e3 Mon Sep 17 00:00:00 2001 From: dctouch Date: Tue, 14 Apr 2026 16:28:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=A0=D0=A0=D0=9A=D0=95=D0=A1=D0=A2?= =?UTF-8?q?=D0=A0=D0=90=D0=A6=D0=98=D0=AF=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20active=20domain=20contra?= =?UTF-8?q?ct=20=D0=BA=20warehouse=20orchestration=20=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D1=83=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D1=81=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=D1=80=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../orchestration/active_domain_contract.json | 413 ++++++++++++++++++ scripts/domain_case_loop.py | 351 ++++++++++++++- tests/test_domain_case_loop.py | 148 ++++++- 3 files changed, 910 insertions(+), 2 deletions(-) diff --git a/docs/orchestration/active_domain_contract.json b/docs/orchestration/active_domain_contract.json index 5b0af55..9f4979a 100644 --- a/docs/orchestration/active_domain_contract.json +++ b/docs/orchestration/active_domain_contract.json @@ -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": [ diff --git a/scripts/domain_case_loop.py b/scripts/domain_case_loop.py index fc62600..a2e75f5 100644 --- a/scripts/domain_case_loop.py +++ b/scripts/domain_case_loop.py @@ -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)) diff --git a/tests/test_domain_case_loop.py b/tests/test_domain_case_loop.py index 9ab6828..07a7088 100644 --- a/tests/test_domain_case_loop.py +++ b/tests/test_domain_case_loop.py @@ -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