ОРРКЕСТРАЦИЯ - Усилить agent loop object-centric аудитом и добить pronoun follow-up по документам закупки

This commit is contained in:
2026-04-14 18:47:38 +03:00
parent cb0eb450d7
commit 97b2a9b028
19 changed files with 469 additions and 44 deletions
+98 -19
View File
@@ -171,6 +171,8 @@ def merge_analysis_context(base_context: Any, override_context: Any) -> dict[str
def carry_forward_analysis_context(
scenario_state: dict[str, Any],
analysis_context: dict[str, Any],
*,
prefer_carryover: bool = False,
) -> dict[str, Any]:
carried = dict(analysis_context)
@@ -179,10 +181,23 @@ def carry_forward_analysis_context(
date_scope = semantic_memory.get("date_scope")
if isinstance(date_scope, dict):
carried_as_of_date = normalize_iso_date(date_scope.get("as_of_date"))
if carried_as_of_date and not carried.get("as_of_date"):
if carried_as_of_date and (prefer_carryover or not carried.get("as_of_date")):
carried["as_of_date"] = carried_as_of_date
if not carried.get("source"):
carried["source"] = "scenario_state_carryover"
for key in (
"focus_object",
"selected_object_ref",
"warehouse_scope",
"organization_scope",
"provenance_bundle",
"sale_trace_bundle",
"purchase_documents_bundle",
"supplier_if_known",
"first_purchase_date",
):
if (prefer_carryover or key not in carried) and semantic_memory.get(key) is not None:
carried[key] = semantic_memory.get(key)
return carried
@@ -1386,7 +1401,11 @@ def execute_scenario_manifest(
for step_index, step in enumerate(manifest["steps"], start=1):
step_dir = steps_dir / step["step_id"]
step_analysis_context = merge_analysis_context(manifest.get("analysis_context"), step.get("analysis_context"))
step_analysis_context = carry_forward_analysis_context(scenario_state, step_analysis_context)
step_analysis_context = carry_forward_analysis_context(
scenario_state,
step_analysis_context,
prefer_carryover=bool(step.get("depends_on")),
)
try:
resolved_question = resolve_question_template(step["question_template"], scenario_state)
result = run_assistant_step(
@@ -1690,6 +1709,24 @@ def derive_coverage_status(statuses: list[str]) -> str:
return "partial"
def derive_pack_final_status(pack: dict[str, Any], scenario_results: list[dict[str, Any]]) -> str:
aggregate_statuses = [item["final_status"] for item in scenario_results]
if not aggregate_statuses:
return "blocked"
if any(status == "blocked" for status in aggregate_statuses):
return "blocked"
if any(status == "needs_exact_capability" for status in aggregate_statuses):
return "needs_exact_capability"
if any(status == "partial" for status in aggregate_statuses):
return "partial"
acceptance_matrix = build_scenario_acceptance_matrix(pack, scenario_results)
if "| partial |" in acceptance_matrix:
return "partial"
return "accepted" if len(scenario_results) == len(pack.get("scenarios") or []) else "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")
@@ -1709,6 +1746,7 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis
scenario_questions_map: dict[str, list[str]] = {}
scenario_nodes_map: dict[str, list[str]] = {}
scenario_wording_map: dict[str, list[str]] = {}
for scenario in scenarios:
if not isinstance(scenario, dict):
continue
@@ -1737,9 +1775,16 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis
node_ids.append(node_id)
scenario_questions_map[scenario_id] = question_ids
scenario_nodes_map[scenario_id] = list(dict.fromkeys(node_ids))
scenario_wording_map[scenario_id] = _scenario_observed_wording_families(scenario)
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 {}
all_nodes: list[dict[str, Any]] = []
for section_key in ("root_nodes", "critical_nodes", "supporting_nodes"):
raw_nodes = scenario_tree.get(section_key)
if isinstance(raw_nodes, list):
all_nodes.extend(node for node in raw_nodes if isinstance(node, dict))
lines = [
"# Scenario acceptance matrix",
"",
@@ -1796,15 +1841,23 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis
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]
required_wording_families = normalize_string_list(node.get("required_wording_families"))
observed_wording_families = sorted(
{family for scenario_id in backed_by for family in scenario_wording_map.get(scenario_id, [])}
)
missing_wording_families = [family for family in required_wording_families if family not in observed_wording_families]
status = derive_coverage_status(statuses)
if status == "green" and missing_wording_families:
status = "partial"
lines.append(
"| "
+ " | ".join(
[
node_id,
derive_coverage_status(statuses),
status,
", ".join(backed_by) or "-",
", ".join(normalize_string_list(node.get("covers_question_ids"))) or "-",
", ".join(normalize_string_list(node.get("required_wording_families"))) or "-",
", ".join(required_wording_families) or "-",
]
)
+ " |"
@@ -1839,12 +1892,28 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis
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]
from_required = []
to_required = []
for node in all_nodes:
node_id = str(node.get("node_id") or "").strip()
if node_id == from_node:
from_required = normalize_string_list(node.get("required_wording_families"))
elif node_id == to_node:
to_required = normalize_string_list(node.get("required_wording_families"))
observed_wording_families = sorted(
{family for scenario_id in backed_by for family in scenario_wording_map.get(scenario_id, [])}
)
edge_required_families = list(dict.fromkeys(from_required + [family for family in to_required if family not in from_required]))
missing_wording_families = [family for family in edge_required_families if family not in observed_wording_families]
status = derive_coverage_status(statuses)
if status == "green" and missing_wording_families:
status = "partial"
lines.append(
"| "
+ " | ".join(
[
edge_id,
derive_coverage_status(statuses),
status,
from_node or "-",
to_node or "-",
", ".join(backed_by) or "-",
@@ -2031,6 +2100,8 @@ def compact_step_output_for_review(step_output: Any) -> dict[str, Any]:
"selected_recipe": step_output.get("selected_recipe"),
"capability_id": step_output.get("capability_id"),
"result_mode": step_output.get("result_mode"),
"answer_shape": step_output.get("answer_shape"),
"actual_direct_answer": step_output.get("actual_direct_answer"),
"fallback_type": step_output.get("fallback_type"),
"mcp_call_status": step_output.get("mcp_call_status"),
"failure_type": step_output.get("failure_type"),
@@ -2080,6 +2151,20 @@ def build_pack_review_bundle(pack_dir: Path) -> str:
return dump_json(bundle)
def _scenario_observed_wording_families(scenario: dict[str, Any]) -> list[str]:
families: list[str] = []
steps = scenario.get("steps")
if not isinstance(steps, list):
return families
for step in steps:
if not isinstance(step, dict):
continue
family = str(step.get("paraphrase_family") or step.get("wording_family") or "").strip()
if family:
families.append(family)
return list(dict.fromkeys(families))
def build_analyst_loop_prompt(
*,
loop_dir: Path,
@@ -2137,6 +2222,7 @@ def build_analyst_loop_prompt(
Goal:
- evaluate current domain-pack correctness for business meaning, route/capability quality, evidence quality, and absence of silent heuristic masking;
- evaluate business usefulness, direct-answer-first behavior, state continuity, and field truthfulness, not only technical groundedness;
- evaluate object-centric dialog continuity: stable `focus_object`, reusable bundles such as `provenance_bundle`, and correct action resolution for pronoun-style follow-ups;
- determine whether the gate `quality_score >= {target_score}` is reached;
- if not, provide the smallest high-value fix targets for the coder.
@@ -2155,8 +2241,10 @@ def build_analyst_loop_prompt(
- if `requires_user_decision = true`, fill `user_decision_type` and `user_decision_prompt`;
- if the pack is below {target_score} but there is still safe autonomous implementation work, keep `requires_user_decision = false`;
- do not request user input merely because the score is still below {target_score}; request it only when the loop would otherwise guess, overfit, or risk architecture drift.
- return machine-readable fields for: `user_intent_summary`, `expected_direct_answer`, `actual_direct_answer`, `direct_answer_ok`, `business_usefulness_ok`, `business_utility_score`, `direct_answer_priority_score`, `state_continuity_score`, `answer_shape_score`, `evidence_clarity_score`, `root_cause_layers`, `broken_edge_ids`, `violated_invariants`;
- return machine-readable fields for: `user_intent_summary`, `expected_direct_answer`, `actual_direct_answer`, `direct_answer_ok`, `business_usefulness_ok`, `business_utility_score`, `direct_answer_priority_score`, `state_continuity_score`, `answer_shape_score`, `evidence_clarity_score`, `focus_object_continuity_ok`, `bundle_reuse_ok`, `followup_action_resolution_ok`, `recommended_state_objects`, `root_cause_layers`, `broken_edge_ids`, `violated_invariants`;
- if the product found the evidence but failed to retain the selected object, provenance bundle, or another reusable resolved object across turns, classify that as `object_memory_gap` or `edge_carryover_gap`, not as a generic route problem;
- if the product retained the item but resolved the wrong action over that item, for example `покажи документы по этой позиции` -> `documents_by_counterparty`, classify that as `followup_action_resolution_gap`;
- if the product already resolved supplier/date/document details for the active item but failed to reuse that bundle for adjacent follow-ups, classify that as `bundle_reuse_gap`;
- if the surfaced business field looks mislabeled, for example supplier vs organization, classify that as `field_mapping_gap`;
- if the answer is technically grounded but still weak for a manager/accountant/operator, classify that as `business_utility_gap`.
@@ -2204,8 +2292,9 @@ def build_coder_loop_prompt(
- do not touch unrelated files;
- preserve already successful baseline flows.
- use `root_cause_layers`, `broken_edge_ids`, `violated_invariants`, and business-utility scores from the analyst verdict to choose the smallest fix;
- prioritize state continuity, selected-object persistence, direct-answer-first behavior, and field-truth mapping when those are the blocking layers;
- do not broaden scope when the analyst says the defect is mainly `object_memory_gap`, `field_mapping_gap`, `answer_shape_mismatch`, or `business_utility_gap`.
- prioritize state continuity, selected-object persistence, stable `focus_object`, reusable `provenance_bundle` / `sale_trace_bundle`, direct-answer-first behavior, and field-truth mapping when those are the blocking layers;
- do not broaden scope when the analyst says the defect is mainly `object_memory_gap`, `followup_action_resolution_gap`, `bundle_reuse_gap`, `field_mapping_gap`, `answer_shape_mismatch`, or `business_utility_gap`;
- when the verdict points to pronoun follow-ups or item-centric drilldowns, prefer a narrow object-state or follow-up-action fix over prompt inflation.
Required outputs:
- create `{iteration_dir / 'coder_plan.md'}` with a short plan;
@@ -2291,17 +2380,7 @@ def handle_run_pack(args: argparse.Namespace) -> int:
}
)
aggregate_statuses = [item["final_status"] for item in scenario_results]
if not aggregate_statuses:
final_status = "blocked"
elif any(status == "blocked" for status in aggregate_statuses):
final_status = "blocked"
elif any(status == "needs_exact_capability" for status in aggregate_statuses):
final_status = "needs_exact_capability"
elif any(status == "partial" for status in aggregate_statuses):
final_status = "partial"
else:
final_status = "accepted" if len(scenario_results) == len(pack.get("scenarios") or []) else "partial"
final_status = derive_pack_final_status(pack, scenario_results)
pack_state = {
"schema_version": SCENARIO_PACK_SCHEMA_VERSION,