diff --git a/.codex/agents/domain_analyst.toml b/.codex/agents/domain_analyst.toml index 6465a6b..ee25402 100644 --- a/.codex/agents/domain_analyst.toml +++ b/.codex/agents/domain_analyst.toml @@ -61,10 +61,19 @@ Rules: - Distinguish `object_memory_gap`, `field_mapping_gap`, `business_utility_gap`, and `domain_anchor_gap` from pure route gaps. - Distinguish `followup_action_resolution_gap` and `bundle_reuse_gap` from both `object_memory_gap` and pure route gaps. - Check field truth explicitly: supplier must not be mislabeled as organization, buyer must not be mislabeled as organization, and document-side fields must not be presented as business truth without evidence. +- Check temporal honesty explicitly: if the exact requested window is empty and the answer relies on nearest out-of-window evidence, call that out separately and lower the score. +- Check action-first follow-up quality explicitly: `кто`, `когда`, `каким документом`, and `покажи документы` over a selected object must answer that action first, not replay a generic provenance trace. +- Check sale-side micro-actions explicitly as well: `кому продали`, `кто купил`, and `через какие документы прошел путь товара` over a selected object must stay on buyer / sale-trace / chain answers, not drift back into purchase provenance. +- Check answer layering explicitly: direct answer first, proof second, service notes last. +- Call out system scaffolding in the top block as a defect: `status`, `what was considered`, row counts, contour jargon, and similar headings do not belong above the user-facing answer. +- Call out numbered block scaffolding such as `Блок 1`, `Блок 2`, `Блок 3` in narrow business follow-ups as a business-utility defect unless the user explicitly asked for a structured report. +- Call out when a short micro-action like `когда` or `каким документом` replays a full multi-block provenance packet instead of giving a compact direct answer plus one short proof block. +- Call out when `когда` or another narrow follow-up should have been derived from an existing `answer_object` / `provenance_bundle` instead of recomputing the whole trace. - Under the scenario-tree section, explicitly name the root node, critical child nodes, critical edges, and the primary user path. - Under the acceptance matrix, list at least the critical nodes/edges and mark each one by wording family: `canonical`, `colloquial`, `ui_selected_object`. - Under the state continuity section, explicitly say whether the scenario behaved as if it had a stable `focus_object` and reusable bundles such as `provenance_bundle` or `sale_trace_bundle`. - Distinguish these defect classes explicitly when relevant: `semantic_understanding_gap`, `edge_carryover_gap`, `object_memory_gap`, `followup_action_resolution_gap`, `bundle_reuse_gap`, `field_mapping_gap`, `answer_shape_mismatch`, `ordering_semantics_mismatch`, `runtime_capability_gap`, `business_utility_gap`, `loop_coverage_gap`, `domain_anchor_gap`. +- Use `temporal_honesty_gap` explicitly when the answer blurs requested-window evidence with nearest available out-of-window evidence. - If the root node works but the primary user path is broken at the first selected-object drilldown, treat that as a real failure of domain hardening. - If the runtime nearly supports the path but the loop never validated the realistic wording family, call it `loop_coverage_gap`, not product success. - If short pronoun follow-ups like `по ней`, `по этой позиции`, `эта`, `ее` are product-relevant, evaluate them as first-class coverage rather than as optional polish. diff --git a/.codex/agents/orchestrator.toml b/.codex/agents/orchestrator.toml index 6f8e316..bc296a3 100644 --- a/.codex/agents/orchestrator.toml +++ b/.codex/agents/orchestrator.toml @@ -59,6 +59,14 @@ Hard rules: - Distinguish `runtime_capability_gap` from `loop_coverage_gap`; do not confuse “not validated in the loop” with “product already works”. - When the analyst says the main gap is object-centric dialog state, prefer the smallest state-layer fix over prompt inflation or broad intent rewrites. +- Require the analyst to judge whether selected-object follow-ups are action-first rather than trace-first. +- Require the analyst to judge whether the answer uses a clean layered format: direct answer, then proof, then service notes. +- Treat stable `answer_object` state as a mandatory audit target for follow-up-heavy domains. +- Distinguish temporal honesty defects from pure route defects; nearest available evidence outside the requested window must not be silently merged into an exact-window answer. +- For narrow selected-object micro-actions such as `кто`, `когда`, `каким документом`, or `покажи документы`, require the analyst to judge compactness explicitly: direct answer first, minimal proof next, no generic multi-block trace packet. +- Treat sale-side selected-object micro-actions such as `кому продали` and `через какие документы прошел путь товара` as first-class critical edges, not as secondary drilldowns after purchase provenance. +- Treat numbered top-level scaffolding such as `Блок 1/2/3` on narrow business follow-ups as a business-presentation defect unless the run explicitly targets a structured report format. + Acceptance gate: - accepted requires analyst quality_score >= 80 - accepted requires zero unresolved P0 defects diff --git a/.codex/skills/domain-case-loop/SKILL.md b/.codex/skills/domain-case-loop/SKILL.md index b0e0dd0..2113a13 100644 --- a/.codex/skills/domain-case-loop/SKILL.md +++ b/.codex/skills/domain-case-loop/SKILL.md @@ -216,6 +216,15 @@ Accepted requires: - Treat object-centric dialog state as part of correctness: short follow-ups like `по ней`, `по этой позиции`, `когда купили ее`, `покажи документы по этой позиции` must resolve against the active selected item before broader routing guesses. - Treat reusable supplier/date/document bundles as part of correctness: adjacent follow-ups over the same item should reuse a resolved provenance bundle when available. +- Treat action-first follow-up behavior as part of correctness: when the user asks `кто`, `когда`, `каким документом`, or `покажи документы` over a selected object, the answer must begin with that action's result rather than with a generic trace narrative. +- Treat answer layering as part of correctness: user-facing answer first, proof second, service or methodological notes last. +- Treat stable `answer_object` state as part of correctness: once supplier/date/document facts are already resolved, adjacent narrow follow-ups should derive from that bundle instead of replaying a full search. +- Treat narrow selected-object micro-actions as compact answers by default: `кто`, `когда`, `каким документом`, `покажи документы`, `сумма`, `все закупки` should return the requested fact first and should not open with a generic multi-block trace packet. +- Treat temporal honesty as part of correctness: if the exact requested window has no evidence and the runtime auto-broadens to nearest available rows, the answer must separate the exact-window outcome from the out-of-window evidence. +- Treat supplier/buyer field truth as part of correctness: do not surface `organization` as `supplier` or `buyer` without proven mapping. +- Do not accept top-of-answer system scaffolding such as `status`, `what was considered`, `row counts`, or `exact contour` above the user-facing answer on business-critical turns. +- Do not accept numbered block scaffolding such as `Блок 1/2/3` in narrow business follow-ups unless the user explicitly asked for a structured report. + ## Domain-specific framing For this repository: diff --git a/.codex/skills/domain-case-loop/references/business_first_analyst_rubric.md b/.codex/skills/domain-case-loop/references/business_first_analyst_rubric.md index 7608d2e..ac4c12c 100644 --- a/.codex/skills/domain-case-loop/references/business_first_analyst_rubric.md +++ b/.codex/skills/domain-case-loop/references/business_first_analyst_rubric.md @@ -6,17 +6,19 @@ The analyst must not stop at route/debug correctness. The analyst must judge whe ## Core principle -The analyst evaluates five layers at once: +The analyst evaluates these layers at once: - user intent; - scenario tree and state continuity; - object-centric dialog continuity; +- action resolution on top of the current business object; +- compact micro-action answers on top of the current business object; - business usefulness of the answer; -- evidence and field truthfulness; +- evidence, temporal honesty, and field truthfulness; - root cause and smallest defensible fix direction. ## Required analyst questions -For every critical turn or critical edge, answer these questions explicitly: +For every critical turn or critical edge, answer these questions explicitly. 1. What did the user really ask? - State the business meaning in one short sentence. @@ -25,14 +27,14 @@ For every critical turn or critical edge, answer these questions explicitly: 2. What should the first line of the answer have been? - If the user asked a direct lookup question, the first line must contain the direct answer. - Technical explanation, limitations, and evidence come after the direct answer. +- Follow-up answers over a selected object must be action-first, not trace-first. 3. What object and scope had to survive from previous turns? - selected item / selected contract / selected counterparty; - originating date or period; - warehouse or organization scope when still relevant; -- reusable resolved bundle, for example provenance trace or sale trace. - stable focus object, for example `focus_object` for a selected inventory item; -- reusable resolved bundle, for example `provenance_bundle` or `sale_trace_bundle`. +- reusable resolved bundle, for example `provenance_bundle`, `sale_trace_bundle`, or `answer_object`. 4. Did the answer stay on the same business object? - item question -> item answer; @@ -42,27 +44,50 @@ For every critical turn or critical edge, answer these questions explicitly: If the system silently switched to raw documents, movements, or another lower-level object, call it an answer-shape defect. -6. Did the runtime resolve the correct follow-up action on the same object? +5. Did the runtime resolve the correct follow-up action on the same object? - `кто это поставил` should stay on item -> supplier provenance; -- `когда купили ее` should stay on item -> purchase date; +- `когда` / `когда купили ее` should stay on item -> purchase date; - `покажи документы по этой позиции` should stay on item -> purchase documents; -- `покажи все закупки по ней` should stay on item -> receipts / provenance documents. +- `покажи все закупки по ней` should stay on item -> receipts / provenance documents; +- `каким документом купили` should stay on item -> source purchase document. +- `кому в итоге мы продали этот товар` should stay on item -> buyer / sale trace; +- `через какие документы товар прошел до продажи` should stay on item -> purchase-to-sale chain. If the selected item stayed known but the action was reinterpreted as a different drilldown such as `documents_by_counterparty`, call that a `followup_action_resolution_gap`. +Treat this as an action-router failure over an already resolved object route, not as a generic wording miss. -5. Are the surfaced fields truthful and correctly labeled? +6. Was the answer derived from the right state object? +- If the product already resolved supplier/date/document facts, the next narrow follow-up should reuse `answer_object` / `provenance_bundle` rather than replaying the whole trace. +- `когда` after a resolved provenance answer should normally be a derived lookup, not a fresh broad search. + +7. Are the surfaced fields truthful and correctly labeled? - do not confuse supplier with organization; - do not confuse buyer with organization; - do not present a document-side technical field as a business truth unless that mapping is proven. +8. Was temporal behavior honest? +- If the exact requested window has no rows and the runtime auto-broadens to nearest evidence, the answer must say so clearly. +- The direct answer must distinguish `no exact evidence in requested window` from `nearest available evidence outside the window`. +- Hidden or blurry out-of-window broadening is a defect. + +9. Was the answer layered correctly? +- Layer 1: user-facing direct answer only; +- Layer 2: proof / evidence / details; +- Layer 3: service or methodological notes only if still useful. + +If the answer opens with system headings, trace narration, or engineering hedging, lower business usefulness. + ## Business usefulness rules An answer is not accepted as business-useful when any of these are true: - the direct answer is not placed first; - the answer opens with technical hedging instead of the user-facing result; -- a weaker question is answered than the one the user asked; +- the answer is trace-first instead of action-first on a selected-object follow-up; - the answer requires the user to reconstruct the conclusion from low-level evidence; -- the answer uses ambiguous field labels for business-critical entities. +- the answer uses ambiguous field labels for business-critical entities; +- the answer puts system scaffolding such as `status`, `what was considered`, `row counts`, `exact contour`, or similar noise above the business answer. +- the answer opens with numbered scaffolding such as `Блок 1`, `Блок 2`, `Блок 3` instead of a clean business answer and lightweight separation. +- a root snapshot opens with service counters instead of a concise business summary and then the top relevant positions. ## State continuity rules @@ -72,8 +97,9 @@ The analyst must verify: - selected object continuity; - date/period continuity; - reusable evidence continuity; -- pronoun resolution continuity. -- follow-up action resolution continuity on the active business object. +- pronoun resolution continuity; +- follow-up action resolution continuity on the active business object; +- stable `focus_object` and stable `answer_object`. Important pronoun examples: - `эту позицию` @@ -84,11 +110,12 @@ Important pronoun examples: If the previous turn already resolved a concrete object, the next turn must reuse it instead of asking for the anchor again. -Short follow-up examples that should first resolve against the active object: +Short follow-up examples that should first resolve against the active object or answer bundle: - `по этой позиции` - `покажи документы по ней` - `когда купили ее` - `это тот же поставщик?` +- `когда` ## Reusable answer-object cache @@ -100,13 +127,15 @@ Examples: - `current_provenance_trace` - `current_sale_trace` - `focus_object` +- `answer_object` - `provenance_bundle` -- `first_purchase_date` +- `purchase_date` - `supplier_if_known` - `source_document_if_known` +- `provenance_docs` -If the runtime recomputes everything from scratch and loses the already resolved object, call that out as a state-layer defect. -If the runtime retains the object but fails to reuse a resolved supplier/date/document bundle for the next adjacent lookup, call that out as a `bundle_reuse_gap`. +If the runtime recomputes everything from scratch and loses the already resolved object, call that a state-layer defect. +If the runtime retains the object but fails to reuse a resolved supplier/date/document bundle for the next adjacent lookup, call that a `bundle_reuse_gap`. ## Root-cause layers @@ -118,6 +147,7 @@ Use one or more of these root-cause layers explicitly: - `followup_action_resolution_gap` - `bundle_reuse_gap` - `field_mapping_gap` +- `temporal_honesty_gap` - `answer_shape_mismatch` - `ordering_semantics_mismatch` - `business_utility_gap` @@ -137,20 +167,25 @@ The analyst verdict should expose at least: - `state_continuity_score` - `answer_shape_score` - `evidence_clarity_score` -- `root_cause_layers` -- `broken_edge_ids` -- `violated_invariants` - `focus_object_continuity_ok` - `bundle_reuse_ok` - `followup_action_resolution_ok` +- `temporal_honesty_ok` +- `field_truth_ok` +- `answer_layering_ok` - `recommended_state_objects` +- `root_cause_layers` +- `broken_edge_ids` +- `violated_invariants` ## Inventory-specific reminders For inventory follow-up chains, verify all of these: - the selected item remains the current focus object after the user clicks a result; - provenance questions answer supplier/date/document first, not only raw movement rows; -- `когда купили` can reuse the already resolved provenance bundle; +- `когда купили` can reuse the already resolved provenance bundle and should not replay a full provenance packet; +- narrow micro-actions like `кто`, `когда`, `каким документом`, `покажи документы` should answer compactly first and should not open with a generic multi-block summary packet; - `покажи документы по этой позиции` stays in item-level purchase documents instead of falling into counterparty documents; - supplier and organization are not mixed up in the surfaced answer; +- if the exact requested stock date has no purchase evidence and the runtime shows nearest earlier evidence, the answer says this honestly and keeps the distinction clear; - `на эту дату` keeps the original stock date unless the user explicitly changed it. diff --git a/AGENTS.md b/AGENTS.md index 43ec7c3..e0e5f6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Rules: - Treat temporal carryover, selected-object carryover, answer-shape match, and ordering semantics as first-class acceptance invariants rather than optional polish. - Treat direct-answer-first behavior, business usefulness, selected-object memory, and field truthfulness as first-class analyst criteria rather than optional presentation polish. - Treat stable `focus_object`, reusable bundles such as `provenance_bundle`, and pronoun-style follow-up resolution (`по ней`, `по этой позиции`) as first-class analyst criteria in follow-up-heavy domains. +- Treat action-first selected-object follow-ups, layered answer shape, stable `answer_object`, and temporal honesty about out-of-window evidence as first-class analyst criteria rather than optional polish. - If a case falls outside the current routed contour because the route/intent/capability is not wired yet, treat it as domain enablement work for this project, not as automatic out-of-scope rejection. - For new unmarked domains, `needs_exact_capability` means "bootstrap or extend the contour" rather than "close the case as unsupported". - A case can be marked `accepted` only when analyst verdict is at least `80/100`, no unresolved `P0` remains, and the rerun does not mask heuristic output as confirmed. diff --git a/docs/orchestration/active_domain_contract.json b/docs/orchestration/active_domain_contract.json index e948fbc..0022727 100644 --- a/docs/orchestration/active_domain_contract.json +++ b/docs/orchestration/active_domain_contract.json @@ -41,9 +41,9 @@ "buyer_candidate": "Департамент капитального ремонта города Москвы" }, "question_pool": { - "total_questions": 21, + "total_questions": 26, "core_questions_total": 17, - "followup_checkpoints_total": 4, + "followup_checkpoints_total": 9, "questions": [ { "question_id": "Q01", @@ -78,7 +78,7 @@ "layer": "root_snapshot", "node_id": "N02_account_41_snapshot", "role": "root_variant", - "wording_family": "canonical", + "wording_family": "colloquial", "semantic_goal": "получить датированный item-level состав остатка по 41.01" }, { @@ -126,6 +126,15 @@ "wording_family": "canonical", "semantic_goal": "получить дату первой/наблюдаемой закупки выбранного товара" }, + { + "question_id": "Q26", + "text": "когда примерно мы купили?", + "layer": "selected_item_provenance", + "node_id": "N04_selected_item_purchase_date", + "role": "followup_checkpoint", + "wording_family": "short_action_followup", + "semantic_goal": "получить компактный direct answer о дате закупки через reuse already resolved answer object, а не повторять полный provenance packet" + }, { "question_id": "Q10", "text": "По каким документам был куплен товар ...", @@ -233,6 +242,42 @@ "role": "critical_child", "wording_family": "pronoun_followup", "semantic_goal": "проверить короткий местоименный follow-up по активному товару без съезда в counterparty drilldown" + }, + { + "question_id": "Q22", + "text": "По выбранному объекту \"...\": от какого поставщика куплен товар", + "layer": "selected_item_provenance", + "node_id": "N03_selected_item_supplier", + "role": "critical_child", + "wording_family": "ui_selected_object", + "semantic_goal": "проверить selected-object follow-up в exact provenance contour без разговорной supplier-лексики" + }, + { + "question_id": "Q23", + "text": "кто это поставил нам", + "layer": "selected_item_provenance", + "node_id": "N03_selected_item_supplier", + "role": "critical_child", + "wording_family": "pronoun_followup", + "semantic_goal": "проверить короткий supplier follow-up по уже активному selected object без повторного item anchor" + }, + { + "question_id": "Q24", + "text": "По выбранному объекту \"...\": кому был продан товар", + "layer": "sale_trace", + "node_id": "N11_selected_item_buyer", + "role": "critical_child", + "wording_family": "ui_selected_object", + "semantic_goal": "проверить selected-object follow-up в exact buyer trace contour" + }, + { + "question_id": "Q25", + "text": "По выбранному объекту \"...\": через какие документы прошел путь товара: закупка -> склад -> продажа", + "layer": "sale_trace", + "node_id": "N12_purchase_to_sale_chain", + "role": "critical_child", + "wording_family": "ui_selected_object", + "semantic_goal": "проверить selected-object follow-up в exact purchase-to-sale chain contour" } ] }, @@ -267,114 +312,249 @@ { "node_id": "N01_stock_snapshot", "title": "Складской snapshot на дату", - "covers_question_ids": ["Q01", "Q02", "Q05"], - "expected_intents": ["inventory_on_hand_as_of_date"], + "covers_question_ids": [ + "Q01", + "Q02", + "Q05" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], "expected_answer_shape": "item_list_with_quantity_cost_warehouse_organization", - "required_wording_families": ["canonical", "colloquial"], - "children": ["N03_selected_item_supplier", "N06_supplier_overlap_now", "N09_old_purchase_aging"] + "required_wording_families": [ + "canonical", + "colloquial" + ], + "children": [ + "N03_selected_item_supplier", + "N06_supplier_overlap_now", + "N09_old_purchase_aging" + ] }, { "node_id": "N02_account_41_snapshot", "title": "Состав остатка по 41.01 на дату", - "covers_question_ids": ["Q03", "Q04"], - "expected_intents": ["inventory_on_hand_as_of_date"], + "covers_question_ids": [ + "Q03", + "Q04" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], "expected_answer_shape": "item_list_with_account_41_scope", - "required_wording_families": ["canonical", "colloquial"], - "children": ["N03_selected_item_supplier", "N11_selected_item_buyer"] + "required_wording_families": [ + "canonical", + "colloquial" + ], + "children": [ + "N03_selected_item_supplier", + "N11_selected_item_buyer" + ] } ], "critical_nodes": [ { "node_id": "N03_selected_item_supplier", "title": "Поставщик выбранного товара", - "covers_question_ids": ["Q06", "Q19"], - "expected_intents": ["inventory_purchase_provenance_for_item"], + "covers_question_ids": [ + "Q06", + "Q19", + "Q22", + "Q23" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], "expected_answer_shape": "direct_supplier_answer_first_then_evidence", - "required_wording_families": ["canonical", "colloquial", "ui_selected_object", "ui_selected_object_colloquial", "pronoun_followup"], - "required_carryover_invariants": ["selected_object", "focus_object", "date_scope", "warehouse_scope", "organization_scope", "reusable_bundle"], - "children": ["N04_selected_item_purchase_date", "N05_selected_item_purchase_documents", "N09_old_purchase_aging"] + "required_wording_families": [ + "canonical", + "colloquial", + "ui_selected_object", + "ui_selected_object_colloquial", + "pronoun_followup" + ], + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "warehouse_scope", + "organization_scope", + "reusable_bundle" + ], + "children": [ + "N04_selected_item_purchase_date", + "N05_selected_item_purchase_documents", + "N09_old_purchase_aging" + ] }, { "node_id": "N04_selected_item_purchase_date", "title": "Дата закупки выбранного товара", - "covers_question_ids": ["Q09"], - "expected_intents": ["inventory_purchase_provenance_for_item"], + "covers_question_ids": [ + "Q09", + "Q26" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], "expected_answer_shape": "direct_date_answer_first_then_evidence", - "required_wording_families": ["canonical", "ui_selected_object"], - "required_carryover_invariants": ["selected_object", "date_scope"] + "required_wording_families": [ + "canonical", + "ui_selected_object", + "short_action_followup" + ], + "required_carryover_invariants": [ + "selected_object", + "date_scope", + "reusable_bundle", + "followup_action_resolution" + ] }, { "node_id": "N05_selected_item_purchase_documents", "title": "Закупочные документы выбранного товара", - "covers_question_ids": ["Q10", "Q20", "Q21"], - "expected_intents": ["inventory_purchase_documents_for_item"], + "covers_question_ids": [ + "Q10", + "Q20", + "Q21" + ], + "expected_intents": [ + "inventory_purchase_documents_for_item" + ], "expected_answer_shape": "document_list_for_selected_item", - "required_wording_families": ["canonical", "ui_selected_object", "ui_selected_object_colloquial", "pronoun_followup"], - "required_carryover_invariants": ["selected_object", "focus_object", "date_scope", "warehouse_scope", "reusable_bundle", "followup_action_resolution"] + "required_wording_families": [ + "canonical", + "ui_selected_object", + "ui_selected_object_colloquial", + "pronoun_followup" + ], + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "warehouse_scope", + "reusable_bundle", + "followup_action_resolution" + ] }, { "node_id": "N09_old_purchase_aging", "title": "Старые закупки по остаткам на выбранную дату", - "covers_question_ids": ["Q13", "Q15"], - "expected_intents": ["inventory_aging_by_purchase_date"], + "covers_question_ids": [ + "Q13", + "Q15" + ], + "expected_intents": [ + "inventory_aging_by_purchase_date" + ], "expected_answer_shape": "item_level_oldest_first_stock_aging_list", - "required_wording_families": ["canonical", "followup_date_carryover"], - "required_carryover_invariants": ["date_scope", "warehouse_scope", "organization_scope"], + "required_wording_families": [ + "canonical", + "followup_date_carryover" + ], + "required_carryover_invariants": [ + "date_scope", + "warehouse_scope", + "organization_scope" + ], "ordering_rule": "oldest_first" }, { "node_id": "N11_selected_item_buyer", "title": "Покупатель выбранного товара", - "covers_question_ids": ["Q16"], - "expected_intents": ["inventory_sale_trace_for_item"], + "covers_question_ids": [ + "Q16", + "Q24" + ], + "expected_intents": [ + "inventory_sale_trace_for_item" + ], "expected_answer_shape": "direct_buyer_answer_first_then_evidence", - "required_wording_families": ["canonical", "ui_selected_object"], - "required_carryover_invariants": ["selected_object", "date_scope"] + "required_wording_families": [ + "canonical", + "ui_selected_object" + ], + "required_carryover_invariants": [ + "selected_object", + "date_scope" + ] }, { "node_id": "N12_purchase_to_sale_chain", "title": "Документальная цепочка purchase -> stock -> sale", - "covers_question_ids": ["Q17"], - "expected_intents": ["inventory_purchase_to_sale_chain"], + "covers_question_ids": [ + "Q17", + "Q25" + ], + "expected_intents": [ + "inventory_purchase_to_sale_chain" + ], "expected_answer_shape": "ordered_chain_with_purchase_stock_sale_documents", - "required_wording_families": ["canonical", "ui_selected_object"], - "required_carryover_invariants": ["selected_object", "date_scope"] + "required_wording_families": [ + "canonical", + "ui_selected_object" + ], + "required_carryover_invariants": [ + "selected_object", + "date_scope" + ] } ], "supporting_nodes": [ { "node_id": "N06_supplier_overlap_now", "title": "Supplier overlap по текущему stock slice", - "covers_question_ids": ["Q07", "Q08"], - "expected_intents": ["inventory_supplier_stock_overlap_as_of_date"], + "covers_question_ids": [ + "Q07", + "Q08" + ], + "expected_intents": [ + "inventory_supplier_stock_overlap_as_of_date" + ], "expected_answer_shape": "supplier_to_stock_slice_summary" }, { "node_id": "N07_supplier_items_on_stock", "title": "Какие товары поставщика лежат на складе сейчас", - "covers_question_ids": ["Q11"], - "expected_intents": ["inventory_supplier_stock_overlap_as_of_date"], + "covers_question_ids": [ + "Q11" + ], + "expected_intents": [ + "inventory_supplier_stock_overlap_as_of_date" + ], "expected_answer_shape": "item_list_filtered_by_supplier" }, { "node_id": "N08_supplier_items_on_date", "title": "Какие товары поставщика были в остатке на дату", - "covers_question_ids": ["Q12"], - "expected_intents": ["inventory_supplier_stock_overlap_as_of_date"], + "covers_question_ids": [ + "Q12" + ], + "expected_intents": [ + "inventory_supplier_stock_overlap_as_of_date" + ], "expected_answer_shape": "dated_item_list_filtered_by_supplier" }, { "node_id": "N10_unresolved_supplier_link", "title": "Остатки без понятной supplier attribution", - "covers_question_ids": ["Q14"], - "expected_intents": ["inventory_supplier_stock_overlap_as_of_date"], + "covers_question_ids": [ + "Q14" + ], + "expected_intents": [ + "inventory_supplier_stock_overlap_as_of_date" + ], "expected_answer_shape": "item_list_with_unresolved_supplier_linkage" }, { "node_id": "N13_supplier_to_buyer_overlap", "title": "Supplier -> item -> buyer overlap", - "covers_question_ids": ["Q18"], - "expected_intents": ["inventory_purchase_to_sale_chain"], + "covers_question_ids": [ + "Q18" + ], + "expected_intents": [ + "inventory_purchase_to_sale_chain" + ], "expected_answer_shape": "confirmed_or_honestly_limited_supplier_item_buyer_overlap" } ], @@ -385,7 +565,12 @@ "to_node": "N03_selected_item_supplier", "transition_type": "selected_object_drilldown", "primary_user_path": true, - "required_carryover_invariants": ["selected_object", "date_scope", "warehouse_scope", "organization_scope"], + "required_carryover_invariants": [ + "selected_object", + "date_scope", + "warehouse_scope", + "organization_scope" + ], "failure_means": "домен не принят, даже если root snapshot уже exact" }, { @@ -394,7 +579,13 @@ "to_node": "N05_selected_item_purchase_documents", "transition_type": "selected_object_deeper_trace", "primary_user_path": true, - "required_carryover_invariants": ["selected_object", "focus_object", "date_scope", "reusable_bundle", "followup_action_resolution"], + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "reusable_bundle", + "followup_action_resolution" + ], "failure_means": "сломано углубление из поставщика в документы закупки" }, { @@ -403,7 +594,11 @@ "to_node": "N09_old_purchase_aging", "transition_type": "date_sensitive_followup", "primary_user_path": true, - "required_carryover_invariants": ["date_scope", "warehouse_scope", "organization_scope"], + "required_carryover_invariants": [ + "date_scope", + "warehouse_scope", + "organization_scope" + ], "ordering_rule": "oldest_first", "failure_means": "сломано понимание `на эту дату` или неверная answer shape по старым закупкам" }, @@ -412,26 +607,48 @@ "from_node": "N02_account_41_snapshot", "to_node": "N11_selected_item_buyer", "transition_type": "historical_selected_object_drilldown", - "primary_user_path": false, - "required_carryover_invariants": ["selected_object", "date_scope"], + "primary_user_path": true, + "required_carryover_invariants": [ + "selected_object", + "date_scope" + ], "failure_means": "sale-trace ветка незакрыта" } ], "primary_user_paths": [ { "path_id": "P01_snapshot_to_supplier", - "nodes": ["N01_stock_snapshot", "N03_selected_item_supplier"], + "nodes": [ + "N01_stock_snapshot", + "N03_selected_item_supplier" + ], "description": "Самый типичный живой путь: получить список остатков, выбрать позицию, спросить кто поставил." }, { "path_id": "P02_snapshot_to_supplier_to_documents", - "nodes": ["N01_stock_snapshot", "N03_selected_item_supplier", "N05_selected_item_purchase_documents"], + "nodes": [ + "N01_stock_snapshot", + "N03_selected_item_supplier", + "N05_selected_item_purchase_documents" + ], "description": "После поставщика пользователь углубляется в закупочные документы." }, { "path_id": "P03_snapshot_to_aging_on_same_date", - "nodes": ["N01_stock_snapshot", "N09_old_purchase_aging"], + "nodes": [ + "N01_stock_snapshot", + "N09_old_purchase_aging" + ], "description": "Пользователь спрашивает про старые закупки на ту же дату, что и исходный snapshot." + }, + { + "path_id": "P04_account_41_to_sale_trace", + "nodes": [ + "N02_account_41_snapshot", + "N11_selected_item_buyer", + "N12_purchase_to_sale_chain" + ], + "description": "Пользователь берет исторический остаток по 41 счету, выбирает позицию и углубляется в buyer/sale trace." } ] }, @@ -459,13 +676,34 @@ { "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"], + "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"] + "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": [ { @@ -510,7 +748,7 @@ "question_id": "Q04", "node_id": "N02_account_41_snapshot", "node_role": "root_variant", - "paraphrase_family": "canonical", + "paraphrase_family": "colloquial", "title": "Account 41 on historical date", "question": "Какие товары числятся на 41 счете на дату {{bindings.target_date_historical}}", "analysis_context": { @@ -527,7 +765,10 @@ "node_role": "root_variant", "paraphrase_family": "canonical", "title": "Nomenclature on historical date", - "question": "Какие конкретно номенклатуры формируют остаток по складу на дату {{bindings.target_date_historical}}", + "question": "Какие конкретно номенклатуры формируют остаток по складу {{bindings.observed_warehouse}} на дату {{bindings.target_date_historical}}", + "depends_on": [ + "step_02_stock_on_historical_date" + ], "analysis_context": { "as_of_date": "2019-03-31", "source": "binding_target_date_historical" @@ -540,13 +781,48 @@ { "scenario_id": "inventory_selected_item_provenance", "title": "Selected-item supplier provenance", - "question_ids": ["Q02", "Q06", "Q09", "Q10", "Q19", "Q20", "Q21"], - "node_ids": ["N01_stock_snapshot", "N03_selected_item_supplier", "N04_selected_item_purchase_date", "N05_selected_item_purchase_documents"], + "question_ids": [ + "Q02", + "Q06", + "Q09", + "Q26", + "Q10", + "Q19", + "Q20", + "Q21", + "Q22", + "Q23" + ], + "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_06_selected_item_documents_pronoun"], - "required_paraphrase_families": ["canonical", "colloquial", "ui_selected_object", "ui_selected_object_colloquial", "pronoun_followup"], - "required_carryover_invariants": ["selected_object", "focus_object", "date_scope", "warehouse_scope", "organization_scope", "answer_shape", "reusable_bundle", "followup_action_resolution"] + "primary_user_path": [ + "step_01_snapshot_historical", + "step_02_selected_item_supplier_ui", + "step_08_selected_item_documents_pronoun" + ], + "required_paraphrase_families": [ + "canonical", + "colloquial", + "ui_selected_object", + "ui_selected_object_colloquial", + "pronoun_followup" + ], + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "warehouse_scope", + "organization_scope", + "answer_shape", + "reusable_bundle", + "followup_action_resolution" + ] }, "steps": [ { @@ -565,30 +841,87 @@ "expected_result_mode": "confirmed_balance" }, { - "step_id": "step_02_selected_item_supplier_colloquial", + "step_id": "step_02_selected_item_supplier_ui", + "question_id": "Q22", + "node_id": "N03_selected_item_supplier", + "node_role": "critical_child", + "paraphrase_family": "ui_selected_object", + "title": "Selected item supplier UI", + "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", + "focus_object", + "date_scope", + "warehouse_scope", + "organization_scope" + ] + }, + { + "step_id": "step_03_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"], + "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"] + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "warehouse_scope", + "organization_scope" + ] }, { - "step_id": "step_03_selected_item_supplier_canonical", + "step_id": "step_04_selected_item_supplier_pronoun", + "question_id": "Q23", + "node_id": "N03_selected_item_supplier", + "node_role": "critical_child", + "paraphrase_family": "pronoun_followup", + "title": "Selected item supplier pronoun follow-up", + "question": "кто это поставил нам", + "depends_on": [ + "step_02_selected_item_supplier_ui" + ], + "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", + "focus_object", + "date_scope", + "reusable_bundle" + ] + }, + { + "step_id": "step_05_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"], + "depends_on": [ + "step_01_snapshot_historical" + ], "analysis_context": { "as_of_date": "2019-03-31", "source": "binding_target_date_historical" @@ -596,56 +929,104 @@ "expected_capability": "inventory_purchase_provenance_for_item" }, { - "step_id": "step_04_selected_item_purchase_date", + "step_id": "step_06_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"] + "depends_on": [ + "step_01_snapshot_historical", + "step_02_selected_item_supplier_ui" + ] }, { - "step_id": "step_05_selected_item_documents_ui", + "step_id": "step_06b_selected_item_purchase_date_short", + "question_id": "Q26", + "node_id": "N04_selected_item_purchase_date", + "node_role": "critical_child", + "paraphrase_family": "short_action_followup", + "title": "Selected item purchase date short follow-up", + "question": "когда", + "depends_on": [ + "step_02_selected_item_supplier_ui" + ], + "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", + "focus_object", + "date_scope", + "reusable_bundle", + "followup_action_resolution" + ] + }, + { + "step_id": "step_07_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"], + "depends_on": [ + "step_01_snapshot_historical", + "step_02_selected_item_supplier_ui" + ], "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", "focus_object", "date_scope", "reusable_bundle", "followup_action_resolution"] + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "reusable_bundle", + "followup_action_resolution" + ] }, { - "step_id": "step_06_selected_item_documents_pronoun", + "step_id": "step_08_selected_item_documents_pronoun", "question_id": "Q21", "node_id": "N05_selected_item_purchase_documents", "node_role": "critical_child", "paraphrase_family": "pronoun_followup", "title": "Selected item purchase documents pronoun follow-up", "question": "покажи документы по этой позиции", - "depends_on": ["step_01_snapshot_historical", "step_02_selected_item_supplier_colloquial"], + "depends_on": [ + "step_01_snapshot_historical", + "step_02_selected_item_supplier_ui" + ], "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", "focus_object", "date_scope", "reusable_bundle", "followup_action_resolution"] + "required_carryover_invariants": [ + "selected_object", + "focus_object", + "date_scope", + "reusable_bundle", + "followup_action_resolution" + ] }, { - "step_id": "step_07_selected_item_documents_canonical", + "step_id": "step_09_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"], + "depends_on": [ + "step_01_snapshot_historical", + "step_02_selected_item_supplier_ui" + ], "analysis_context": { "as_of_date": "2019-03-31", "source": "binding_target_date_historical" @@ -657,13 +1038,36 @@ { "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"], + "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"] + "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": [ { @@ -689,7 +1093,9 @@ "paraphrase_family": "canonical", "title": "Suppliers behind current stock", "question": "У какого поставщика были куплены товары, которые сейчас лежат на складе {{bindings.observed_warehouse}}", - "depends_on": ["step_01_snapshot_current"] + "depends_on": [ + "step_01_snapshot_current" + ] }, { "step_id": "step_03_supplier_residue_now", @@ -699,7 +1105,9 @@ "paraphrase_family": "canonical", "title": "Supplier attribution of current residue", "question": "По какому поставщику проходит текущий товарный остаток на складе {{bindings.observed_warehouse}}", - "depends_on": ["step_01_snapshot_current"] + "depends_on": [ + "step_01_snapshot_current" + ] }, { "step_id": "step_04_supplier_items_on_stock", @@ -709,7 +1117,9 @@ "paraphrase_family": "canonical", "title": "Current items for observed supplier", "question": "Какие товары от поставщика {{bindings.observed_supplier_candidate}} сейчас еще лежат на складе {{bindings.observed_warehouse}}", - "depends_on": ["step_01_snapshot_current"] + "depends_on": [ + "step_01_snapshot_current" + ] }, { "step_id": "step_05_supplier_items_on_date", @@ -729,13 +1139,38 @@ { "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"], + "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"] + "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": [ { @@ -761,7 +1196,9 @@ "paraphrase_family": "ui_selected_object_colloquial", "title": "Supplier for small residual item", "question": "По выбранному объекту \"{{bindings.focus_item_small_residue}}\": кто это поставил нам", - "depends_on": ["step_01_snapshot_current"] + "depends_on": [ + "step_01_snapshot_current" + ] }, { "step_id": "step_03_old_purchase_aging_followup", @@ -771,8 +1208,15 @@ "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"], + "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" }, { @@ -783,7 +1227,10 @@ "paraphrase_family": "canonical", "title": "Very old stock", "question": "Есть ли остатки товара, которые закупались очень давно", - "depends_on": ["step_01_snapshot_current", "step_03_old_purchase_aging_followup"], + "depends_on": [ + "step_01_snapshot_current", + "step_03_old_purchase_aging_followup" + ], "ordering_rule": "oldest_first" }, { @@ -794,20 +1241,46 @@ "paraphrase_family": "canonical", "title": "Unresolved supplier linkage", "question": "Какие товары сейчас висят в остатке без понятной привязки к поставщику", - "depends_on": ["step_01_snapshot_current"] + "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"], + "question_ids": [ + "Q04", + "Q16", + "Q17", + "Q18", + "Q24", + "Q25" + ], + "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"] + "primary_user_path": [ + "step_01_account_41_historical", + "step_02_selected_item_buyer_ui", + "step_04_purchase_to_sale_chain_ui" + ], + "required_paraphrase_families": [ + "canonical", + "colloquial", + "ui_selected_object" + ], + "required_carryover_invariants": [ + "selected_object", + "date_scope", + "answer_shape" + ] }, "bindings": { "target_date_historical": "2020-03-31", @@ -821,7 +1294,7 @@ "question_id": "Q04", "node_id": "N02_account_41_snapshot", "node_role": "root_variant", - "paraphrase_family": "canonical", + "paraphrase_family": "colloquial", "title": "Historical account 41 anchor", "question": "Какие товары числятся на 41 счете на дату {{bindings.target_date_historical}}", "analysis_context": { @@ -832,34 +1305,67 @@ "expected_result_mode": "confirmed_balance" }, { - "step_id": "step_02_selected_item_buyer", + "step_id": "step_02_selected_item_buyer_ui", + "question_id": "Q24", + "node_id": "N11_selected_item_buyer", + "node_role": "critical_child", + "paraphrase_family": "ui_selected_object", + "title": "Buyer for historical selected item UI", + "question": "По выбранному объекту \"{{bindings.focus_item_historical}}\": кому был продан товар", + "depends_on": [ + "step_01_account_41_historical" + ] + }, + { + "step_id": "step_03_selected_item_buyer_canonical", "question_id": "Q16", "node_id": "N11_selected_item_buyer", "node_role": "critical_child", "paraphrase_family": "canonical", - "title": "Buyer for historical selected item", + "title": "Buyer for historical selected item canonical", "question": "Кому был продан товар {{bindings.focus_item_historical}}", - "depends_on": ["step_01_account_41_historical"] + "depends_on": [ + "step_01_account_41_historical" + ] }, { - "step_id": "step_03_purchase_to_sale_chain", + "step_id": "step_04_purchase_to_sale_chain_ui", + "question_id": "Q25", + "node_id": "N12_purchase_to_sale_chain", + "node_role": "critical_child", + "paraphrase_family": "ui_selected_object", + "title": "Purchase to sale document chain UI", + "question": "По выбранному объекту \"{{bindings.focus_item_historical}}\": через какие документы прошел путь товара: закупка -> склад -> продажа", + "depends_on": [ + "step_01_account_41_historical", + "step_02_selected_item_buyer_ui" + ] + }, + { + "step_id": "step_05_purchase_to_sale_chain_canonical", "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"] + "depends_on": [ + "step_01_account_41_historical", + "step_02_selected_item_buyer_ui" + ] }, { - "step_id": "step_04_supplier_to_buyer_overlap", + "step_id": "step_06_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.focus_item_historical}} -> покупатель {{bindings.observed_customer_candidate}}", - "depends_on": ["step_01_account_41_historical", "step_02_selected_item_buyer"] + "depends_on": [ + "step_01_account_41_historical", + "step_05_purchase_to_sale_chain_canonical" + ] } ] } @@ -867,26 +1373,74 @@ }, "agent_audit_expectations": { "direct_answer_first": true, + "action_first_followup_required": true, + "answer_layering_required": true, + "compact_micro_action_answers_required": true, "business_utility_required": true, "state_continuity_required": true, "selected_object_memory_required": true, "focus_object_required": true, + "answer_object_required": true, "pronoun_followup_resolution_required": true, "followup_action_resolution_required": true, "bundle_reuse_required": true, + "temporal_honesty_required": true, + "field_truth_required": true, "field_truth_checks": [ "supplier_vs_organization", "buyer_vs_organization" ], + "forbidden_top_level_noise": [ + "status headings", + "what was considered headings", + "row counts before the business answer", + "trace-first narration", + "numbered block headings", + "exact contour jargon", + "supplier is not materialized as a separate field" + ], + "required_answer_layers": [ + "direct_answer", + "evidence", + "service_notes" + ], + "required_followup_micro_actions": [ + "who", + "when", + "document_list", + "source_document", + "amount", + "all_receipts", + "buyer", + "sale_documents", + "sale_chain" + ], "required_state_objects": [ "focus_object", + "answer_object", "provenance_bundle" ], "reusable_answer_object_expectations": [ "current_item", "current_as_of_date", + "answer_object", "current_provenance_trace", "current_sale_trace" + ], + "answer_object_fields": [ + "supplier_if_known", + "purchase_date_if_known", + "purchase_doc_if_known", + "purchase_amount_if_known", + "provenance_docs", + "buyer_if_known", + "sale_date_if_known", + "sale_doc_if_known", + "sale_docs" + ], + "temporal_honesty_rule": [ + "distinguish requested window from nearest available evidence", + "do not merge out-of-window evidence into the direct answer silently" ] }, "acceptance_contract": { @@ -897,7 +1451,11 @@ "работает только quoted selected-object wording, но ломается короткий местоименный follow-up по активной позиции", "теряется date_scope на follow-up с `на эту дату` или `на ту дату`", "ответ меняет business object, например вместо item-level ответа отдаёт dump документов", - "нарушается ordering semantics, например `старые закупки` идут не oldest-first" + "нарушается ordering semantics, например `старые закупки` идут не oldest-first", + "the answer is trace-first instead of action-first on a selected-object follow-up", + "the top block contains system scaffolding instead of the direct business answer", + "the answer silently uses nearest out-of-window evidence without saying so", + "supplier or buyer is surfaced through a mislabeled organization field" ], "required_green_for_acceptance": [ "root nodes", @@ -912,9 +1470,12 @@ "edge_carryover_gap", "followup_action_resolution_gap", "bundle_reuse_gap", + "field_mapping_gap", + "temporal_honesty_gap", "answer_shape_mismatch", "ordering_semantics_mismatch", "runtime_capability_gap", + "business_utility_gap", "loop_coverage_gap" ], "minimum_score_rule": "accepted only if analyst score >= 80 and no unresolved P0 remains" @@ -954,6 +1515,41 @@ "pattern_id": "F07_provenance_bundle_not_reused", "symptom": "supplier/date/document lookup was already resolved for the selected item but adjacent follow-up recomputes broadly or loses the reusable bundle", "defect_class": "bundle_reuse_gap" + }, + { + "pattern_id": "F08_short_when_replays_full_trace", + "symptom": "short follow-up such as `когда` replays the full provenance packet instead of returning the compact purchase date first", + "defect_class": "bundle_reuse_gap" + }, + { + "pattern_id": "F09_temporal_auto_broaden_not_honest", + "symptom": "the answer quietly broadens from the requested date window to nearest available evidence without separating those two facts", + "defect_class": "temporal_honesty_gap" + }, + { + "pattern_id": "F10_supplier_field_truth_blur", + "symptom": "the answer names a supplier but the surfaced business field is actually organization or another unlabeled technical field", + "defect_class": "field_mapping_gap" + }, + { + "pattern_id": "F11_trace_first_top_block_noise", + "symptom": "the answer starts with system scaffolding, trace narration, or contour jargon instead of the direct business answer", + "defect_class": "business_utility_gap" + }, + { + "pattern_id": "F12_numbered_block_scaffolding", + "symptom": "a narrow business follow-up opens with `Блок 1/2/3` scaffolding instead of a compact direct answer", + "defect_class": "business_utility_gap" + }, + { + "pattern_id": "F13_selected_item_sale_followup_misroute", + "symptom": "selected-item follow-up such as `кому в итоге мы продали этот товар` drifts back into purchase provenance and answers about supplier instead of buyer", + "defect_class": "followup_action_resolution_gap" + }, + { + "pattern_id": "F14_root_snapshot_service_heavy_top", + "symptom": "root stock snapshot opens with service scaffolding and counters instead of a concise business summary plus top positions", + "defect_class": "business_utility_gap" } ], "legacy_references": [ diff --git a/docs/orchestration/schemas/domain_loop_analyst_verdict.schema.json b/docs/orchestration/schemas/domain_loop_analyst_verdict.schema.json index 7bbf060..88216e3 100644 --- a/docs/orchestration/schemas/domain_loop_analyst_verdict.schema.json +++ b/docs/orchestration/schemas/domain_loop_analyst_verdict.schema.json @@ -19,6 +19,9 @@ "focus_object_continuity_ok", "bundle_reuse_ok", "followup_action_resolution_ok", + "temporal_honesty_ok", + "field_truth_ok", + "answer_layering_ok", "recommended_state_objects", "loop_decision", "requires_user_decision", @@ -91,6 +94,15 @@ "followup_action_resolution_ok": { "type": "boolean" }, + "temporal_honesty_ok": { + "type": "boolean" + }, + "field_truth_ok": { + "type": "boolean" + }, + "answer_layering_ok": { + "type": "boolean" + }, "recommended_state_objects": { "type": "array", "items": { @@ -143,6 +155,7 @@ "followup_action_resolution_gap", "bundle_reuse_gap", "field_mapping_gap", + "temporal_honesty_gap", "answer_shape_mismatch", "ordering_semantics_mismatch", "business_utility_gap", @@ -194,6 +207,7 @@ "followup_action_resolution_gap", "bundle_reuse_gap", "field_mapping_gap", + "temporal_honesty_gap", "answer_shape_mismatch", "ordering_semantics_mismatch", "business_utility_gap", diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index ad640cc..02d4638 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1359,7 +1359,7 @@ function hasInventoryProvenanceSignalV2(text) { } function hasInventoryPurchaseDateSignal(text) { const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasPurchaseDateCue = /(?:когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text); + const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text); return hasItemCue && hasPurchaseDateCue; } function hasInventoryPurchaseDocumentsSignalV2(text) { @@ -1369,7 +1369,7 @@ function hasInventoryPurchaseDocumentsSignalV2(text) { } function hasInventorySaleTraceSignalV2(text) { const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasTraceCue = /(?:кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); + const hasTraceCue = /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); return hasItemCue && hasTraceCue; } function hasInventorySupplierStockOverlapSignal(text) { diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index 05f3f3c..f9e62b3 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -189,7 +189,10 @@ function resolveNavigationAction(debug, hasFocusObject) { return hasFocusObject ? "drilldown" : "open"; } function buildFocusObjectFromDebug(debug, resultSetId, createdAt) { - const rawValue = toNonEmptyString(debug.anchor_value_resolved) ?? toNonEmptyString(debug.anchor_value_raw); + const extractedFilters = toObject(debug.extracted_filters) ?? {}; + const rawValue = toNonEmptyString(debug.anchor_value_resolved) ?? + toNonEmptyString(debug.anchor_value_raw) ?? + toNonEmptyString(extractedFilters.item); if (!rawValue) { return null; } diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index bdc00c0..6f8fb20 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -238,6 +238,16 @@ function normalizeQuestionText(value) { .replace(/\s+/g, " ") .trim(); } +function hasInventoryPurchaseDateActionFocus(userMessage) { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + if (/^(?:когда|когда\?|дата\s+закупки|purchase\s+date)\??$/iu.test(text)) { + return true; + } + return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?(?:купили|был\s+куплен|куплен|это\s+купили|эту\s+позицию\s+купили|ее\s+купили)|дата\s+закупки|purchase\s+date)/iu.test(text); +} function normalizeIsoDateOnly(value) { const parsed = parseIsoDateToken(value); if (!parsed) { @@ -3059,28 +3069,12 @@ function composeFactualReply(intent, rows, options = {}) { const uniqueWarehouses = uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)); const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); - const lines = [ - `Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: подтвержденный список товарных остатков на дату.", - "", - "Блок 2. Что учтено", - `- Дата среза: ${formatDateRu(asOfDate)}.`, - "- Контур: остатки по счету 41.01 «Товары на складах».", - "- Базовая единица детализации: одна строка = товар, склад и организация на дату.", - "", - "Блок 3. Сводка", - `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, - `- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`, - `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, - `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, - `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`, - `- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`, - "", - "Блок 4. Подтвержденные позиции" - ]; + const directAnswerLine = positions.length > 0 + ? `На ${formatDateRu(asOfDate)} на складе подтверждено ${formatNumberWithDots(positions.length)} позиций с остатком на ${formatMoneyRub(totalAmount)}.` + : `На ${formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; + const lines = [directAnswerLine]; if (positions.length > 0) { + lines.push("", "Позиции:"); lines.push(...positions.slice(0, 20).map((item, index) => { const warehouseLabel = item.warehouse ?? "склад не определен"; const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; @@ -3090,7 +3084,11 @@ function composeFactualReply(intent, rows, options = {}) { })); } else { - lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + } + lines.push("", "Подтверждение:", `- Дата среза: ${formatDateRu(asOfDate)}.`, "- Контур: остатки по счету 41.01 «Товары на складах».", `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`); + if (rows.length !== positions.length) { + lines.push(`- Строк в подтвержденной выборке: ${formatNumberWithDots(rows.length)}.`); } return { responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", @@ -3107,28 +3105,20 @@ function composeFactualReply(intent, rows, options = {}) { const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); const summary = summarizeInventoryTraceRows(purchaseRows); const itemLabel = summary.item ?? "товар не определен"; - const directAnswerLine = summary.counterparties.length === 1 - ? `По товару ${itemLabel} документы поступления связаны с поставщиком: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По товару ${itemLabel} документы поступления ведут к нескольким поставщикам: ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По товару ${itemLabel} найдены документы поступления, но поставщик не материализован отдельным полем в текущем exact-контуре.`; - const lines = [ - directAnswerLine, - `Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: подтвержденные движения поступления товара на 41.01 по доступным бухгалтерским проводкам.", - "", - "Блок 2. Что учтено", - `- Дата верхней границы: ${formatDateRu(asOfDate)}.`, - "- Контур: движения, где товар поступает на счет 41.01.", - `- Документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`, - `- Операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.` - ]; - if (summary.counterparties.length > 0) { - lines.push(`- Найденные контрагенты в закупочных движениях: ${summary.counterparties.slice(0, 3).join("; ")}.`); + const directAnswerLine = purchaseRows.length <= 0 + ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` + : `По позиции ${itemLabel} найдено ${formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${formatDateRu(asOfDate)}.`; + const lines = [directAnswerLine]; + lines.push("", "Подтверждение:"); + lines.push(`- Дата верхней границы: ${formatDateRu(asOfDate)}.`); + lines.push(`- Операций поступления в выборке: ${formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик: ${summary.counterparties[0]}.`); } - lines.push("", "Блок 3. Документы"); + else if (summary.counterparties.length > 1) { + lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + lines.push("", "Документы:"); if (purchaseRows.length > 0) { lines.push(...formatInventoryTraceRows(purchaseRows, 12)); } @@ -3150,25 +3140,55 @@ function composeFactualReply(intent, rows, options = {}) { const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); const summary = summarizeInventoryTraceRows(purchaseRows); const itemLabel = summary.item ?? "товар не определен"; + const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); + if (purchaseDateActionFocus) { + const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); + const lastPurchaseDate = inventoryTraceDateLabel(summary.lastPeriod); + const directAnswerLine = purchaseRows.length <= 0 || !summary.firstPeriod + ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` + : summary.firstPeriod === summary.lastPeriod + ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` + : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; + const lines = [directAnswerLine]; + if (purchaseRows.length > 0) { + lines.push("", "Подтверждение:"); + lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`); + if (summary.firstPeriod !== summary.lastPeriod) { + lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`); + } + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`); + } + else if (summary.counterparties.length > 1) { + lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + if (summary.documents.length > 0) { + lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); + } + if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) { + lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`); + } + } + return { + responseType: "FACTUAL_SUMMARY", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: purchaseRows.length > 0 ? "strong" : "medium", + balance_confirmed: purchaseRows.length > 0 + } + }; + } const directAnswerLine = summary.counterparties.length === 1 ? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.` : summary.counterparties.length > 1 ? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.` : `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`; - const lines = [ - directAnswerLine, - `Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.", - "- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.", - "", - "Блок 2. Сводка", - `- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`, - `- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`, - `- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.` - ]; + const lines = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); if (summary.counterparties.length === 1) { lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`); } @@ -3179,7 +3199,10 @@ function composeFactualReply(intent, rows, options = {}) { lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); } if (summary.documents.length > 0) { - lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8)); + lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); + } + if (purchaseRows.length > 0) { + lines.push("", "Сервисно:", "- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка."); } return { responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", @@ -3304,19 +3327,11 @@ function composeFactualReply(intent, rows, options = {}) { : summary.counterparties.length > 1 ? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` : `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`; - const lines = [ - directAnswerLine, - `Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.", - "", - "Блок 2. Сводка", - `- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`, - `- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`, - `- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.` - ]; + const lines = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`); if (summary.counterparties.length === 1) { lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); } @@ -3326,7 +3341,7 @@ function composeFactualReply(intent, rows, options = {}) { else if (saleRows.length > 0) { lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); } - lines.push("", "Блок 3. Документы выбытия"); + lines.push("", "Документы выбытия:"); if (saleRows.length > 0) { lines.push(...formatInventoryTraceRows(saleRows, 12)); } @@ -3353,14 +3368,9 @@ function composeFactualReply(intent, rows, options = {}) { const directAnswerLine = purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; - const lines = [ - directAnswerLine, - `Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - `- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`, - `- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.` - ]; + const lines = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`); + lines.push(`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`); if (purchaseRows.length > 0 && saleRows.length > 0) { lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); } @@ -3374,10 +3384,10 @@ function composeFactualReply(intent, rows, options = {}) { lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку."); } if (purchaseRows.length > 0) { - lines.push("", "Блок 2. Закупка", `- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, ...formatInventoryTraceRows(purchaseRows, 6)); + lines.push("", "Закупка:", `- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, ...formatInventoryTraceRows(purchaseRows, 6)); } if (saleRows.length > 0) { - lines.push("", "Блок 3. Выбытие", `- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, ...formatInventoryTraceRows(saleRows, 6)); + lines.push("", "Продажа:", `- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, ...formatInventoryTraceRows(saleRows, 6)); } return { responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 1b7415f..e6f192f 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -262,6 +262,22 @@ function hasInventorySupplierFollowupCue(text) { function hasInventoryPurchaseDocumentsFollowupCue(text) { return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(String(text ?? "")); } +function hasInventoryPurchaseDateFollowupCue(text) { + return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(String(text ?? "")); +} +function hasBareInventoryPurchaseDateFollowupCue(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3; +} +function hasInventorySaleFollowupCue(text) { + return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|кто\s+купил|buyer|покупател)/iu.test(String(text ?? "")); +} +function hasInventoryPurchaseToSaleChainFollowupCue(text) { + return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(String(text ?? "")); +} function hasAddressFollowupContextSignal(text) { const normalized = String(text ?? "").trim(); if (!normalized) { @@ -448,8 +464,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { intent === "inventory_aging_by_purchase_date") && !toNonEmptyString(merged.item) && previousItem) { - merged.item = previousItem; - reasons.push("item_from_followup_context"); + if (intent !== "inventory_aging_by_purchase_date") { + merged.item = previousItem; + reasons.push("item_from_followup_context"); + } } if (sameDateRequested) { const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; @@ -459,7 +477,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } } if (!sameDateRequested && - (intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") && + (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date") && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage)) { const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; @@ -471,6 +493,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { reasons.push("as_of_date_from_followup_context"); } } + if (intent === "inventory_aging_by_purchase_date") { + const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test(String(userMessage ?? "")); + if (toNonEmptyString(merged.item) && !explicitItemMention) { + delete merged.item; + reasons.push("item_cleared_for_stock_slice_aging"); + } + } if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage) && @@ -667,6 +696,52 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo }; } } + if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || + detectedIntent.intent === "inventory_purchase_provenance_for_item" || + detectedIntent.intent === previousIntent || + detectedIntent.intent === "inventory_on_hand_as_of_date") { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + } + if (inventorySelectedObjectFollowup && hasInventorySaleFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || + detectedIntent.intent === "inventory_purchase_provenance_for_item" || + detectedIntent.intent === "inventory_on_hand_as_of_date" || + detectedIntent.intent === previousIntent) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + } + if (inventorySelectedObjectFollowup && hasInventoryPurchaseToSaleChainFollowupCue(normalizedMessage)) { + if (detectedIntent.intent === "unknown" || + detectedIntent.intent === "inventory_sale_trace_for_item" || + detectedIntent.intent === "inventory_on_hand_as_of_date" || + detectedIntent.intent === previousIntent) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + } + if (previousIsInventoryFamily && + hasFollowupSignal && + hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) && + (detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } if (hasPreviousContract) { if (detectedIntent.intent === "list_contracts_by_counterparty") { if (hasBankSignal(normalizedMessage)) { diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index ab61525..1742578 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2163,6 +2163,9 @@ function readAddressFilterString(addressDebug, key) { } return toNonEmptyString(filters[key]); } +function readAddressInventoryItemFilter(addressDebug) { + return readAddressFilterString(addressDebug, "item"); +} function isAddressLaneDebugPayload(debug) { if (!debug || typeof debug !== "object") { return false; @@ -2516,6 +2519,7 @@ function buildAddressFollowupOffer(addressDebug) { const anchorType = toNonEmptyString(addressDebug.anchor_type); const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ?? toNonEmptyString(addressDebug.anchor_value_raw) ?? + readAddressInventoryItemFilter(addressDebug) ?? readAddressFilterString(addressDebug, "counterparty") ?? readAddressFilterString(addressDebug, "contract") ?? readAddressFilterString(addressDebug, "account"); @@ -2689,6 +2693,26 @@ function hasShortDebtMirrorFollowupSignal(userMessage) { /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) || /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample)); } +function isInventorySelectedObjectIntent(intent) { + return intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain"; +} +function hasShortInventoryObjectFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText].filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY); + if (minTokens > 8) { + return false; + } + return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) || + /^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample)); +} function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); if (!normalized || countTokens(normalized) > 10) { @@ -2719,14 +2743,18 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent); + const inventoryShortFollowupPrimary = isInventorySelectedObjectIntent(sourceIntentHint) && hasShortInventoryObjectFollowupSignal(userMessage); + const inventoryShortFollowupAlternate = isInventorySelectedObjectIntent(sourceIntentHint) && toNonEmptyString(alternateMessage) + ? hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? "")) + : false; const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null; const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage) ? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) : null; const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; - const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary); + const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary; const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) - ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) + ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate : false; const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage) @@ -2766,6 +2794,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes let previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type); let previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ?? toNonEmptyString(previousAddressDebug.anchor_value_raw) ?? + readAddressFilterString(previousAddressDebug, "item") ?? readAddressFilterString(previousAddressDebug, "counterparty") ?? readAddressFilterString(previousAddressDebug, "account") ?? readAddressFilterString(previousAddressDebug, "contract"); @@ -3966,6 +3995,12 @@ function resolveAssistantOrchestrationDecision(input) { hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) || hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) || hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage); + const protectedInventoryShortFollowup = Boolean(followupContext && + isInventorySelectedObjectIntent(toNonEmptyString(followupContext.previous_intent)) && + (hasShortInventoryObjectFollowupSignal(rawUserMessage) || + hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) || + hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) || + hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage))); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && @@ -3975,7 +4010,8 @@ function resolveAssistantOrchestrationDecision(input) { intentResolution.intent === "unknown"); const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && - (llmFirstUnsupportedCandidate || llmContractMode === null)); + (llmFirstUnsupportedCandidate || llmContractMode === null) && + !protectedInventoryShortFollowup); const hardMetaMode = dataScopeMetaQuery ? "data_scope" : capabilityMetaQuery && !dataRetrievalSignal diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index d658398..ebe5a0f 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1642,7 +1642,9 @@ function hasInventoryProvenanceSignalV2(text: string): boolean { function hasInventoryPurchaseDateSignal(text: string): boolean { const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasPurchaseDateCue = /(?:когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text); + const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test( + text + ); return hasItemCue && hasPurchaseDateCue; } @@ -1656,9 +1658,10 @@ function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean { function hasInventorySaleTraceSignalV2(text: string): boolean { const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasTraceCue = /(?:кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( - text - ); + const hasTraceCue = + /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( + text + ); return hasItemCue && hasTraceCue; } diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index 4b5fc0f..5db9346 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -216,7 +216,11 @@ function resolveNavigationAction(debug: Record, hasFocusObject: } function buildFocusObjectFromDebug(debug: Record, resultSetId: string, createdAt: string): AddressFocusObject | null { - const rawValue = toNonEmptyString(debug.anchor_value_resolved) ?? toNonEmptyString(debug.anchor_value_raw); + const extractedFilters = toObject(debug.extracted_filters) ?? {}; + const rawValue = + toNonEmptyString(debug.anchor_value_resolved) ?? + toNonEmptyString(debug.anchor_value_raw) ?? + toNonEmptyString(extractedFilters.item); if (!rawValue) { return null; } diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index bde3807..467da97 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -369,6 +369,19 @@ function normalizeQuestionText(value: string | null | undefined): string { .trim(); } +function hasInventoryPurchaseDateActionFocus(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + if (/^(?:когда|когда\?|дата\s+закупки|purchase\s+date)\??$/iu.test(text)) { + return true; + } + return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?(?:купили|был\s+куплен|куплен|это\s+купили|эту\s+позицию\s+купили|ее\s+купили)|дата\s+закупки|purchase\s+date)/iu.test( + text + ); +} + function normalizeIsoDateOnly(value: string | null | undefined): string | null { const parsed = parseIsoDateToken(value); if (!parsed) { @@ -3967,30 +3980,14 @@ export function composeFactualReply( ); const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); - - const lines: string[] = [ - `Собран подтвержденный срез товаров на складах на ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: подтвержденный список товарных остатков на дату.", - "", - "Блок 2. Что учтено", - `- Дата среза: ${formatDateRu(asOfDate)}.`, - "- Контур: остатки по счету 41.01 «Товары на складах».", - "- Базовая единица детализации: одна строка = товар, склад и организация на дату.", - "", - "Блок 3. Сводка", - `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, - `- Позиции с ненулевым остатком: ${formatNumberWithDots(positions.length)}.`, - `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, - `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, - `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`, - `- Суммарная стоимость: ${formatMoneyRub(totalAmount)}.`, - "", - "Блок 4. Подтвержденные позиции" - ]; + const directAnswerLine = + positions.length > 0 + ? `На ${formatDateRu(asOfDate)} на складе подтверждено ${formatNumberWithDots(positions.length)} позиций с остатком на ${formatMoneyRub(totalAmount)}.` + : `На ${formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; + const lines: string[] = [directAnswerLine]; if (positions.length > 0) { + lines.push("", "Позиции:"); lines.push( ...positions.slice(0, 20).map((item, index) => { const warehouseLabel = item.warehouse ?? "склад не определен"; @@ -4001,7 +3998,20 @@ export function composeFactualReply( }) ); } else { - lines.push("- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + } + + lines.push( + "", + "Подтверждение:", + `- Дата среза: ${formatDateRu(asOfDate)}.`, + "- Контур: остатки по счету 41.01 «Товары на складах».", + `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, + `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, + `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.` + ); + if (rows.length !== positions.length) { + lines.push(`- Строк в подтвержденной выборке: ${formatNumberWithDots(rows.length)}.`); } return { @@ -4021,28 +4031,19 @@ export function composeFactualReply( const summary = summarizeInventoryTraceRows(purchaseRows); const itemLabel = summary.item ?? "товар не определен"; const directAnswerLine = - summary.counterparties.length === 1 - ? `По товару ${itemLabel} документы поступления связаны с поставщиком: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По товару ${itemLabel} документы поступления ведут к нескольким поставщикам: ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По товару ${itemLabel} найдены документы поступления, но поставщик не материализован отдельным полем в текущем exact-контуре.`; - const lines: string[] = [ - directAnswerLine, - `Собран подтвержденный список документов поступления по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: подтвержденные движения поступления товара на 41.01 по доступным бухгалтерским проводкам.", - "", - "Блок 2. Что учтено", - `- Дата верхней границы: ${formatDateRu(asOfDate)}.`, - "- Контур: движения, где товар поступает на счет 41.01.", - `- Документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`, - `- Операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.` - ]; - if (summary.counterparties.length > 0) { - lines.push(`- Найденные контрагенты в закупочных движениях: ${summary.counterparties.slice(0, 3).join("; ")}.`); + purchaseRows.length <= 0 + ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` + : `По позиции ${itemLabel} найдено ${formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${formatDateRu(asOfDate)}.`; + const lines: string[] = [directAnswerLine]; + lines.push("", "Подтверждение:"); + lines.push(`- Дата верхней границы: ${formatDateRu(asOfDate)}.`); + lines.push(`- Операций поступления в выборке: ${formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик: ${summary.counterparties[0]}.`); + } else if (summary.counterparties.length > 1) { + lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); } - lines.push("", "Блок 3. Документы"); + lines.push("", "Документы:"); if (purchaseRows.length > 0) { lines.push(...formatInventoryTraceRows(purchaseRows, 12)); } else { @@ -4064,26 +4065,56 @@ export function composeFactualReply( const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); const summary = summarizeInventoryTraceRows(purchaseRows); const itemLabel = summary.item ?? "товар не определен"; + const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); + if (purchaseDateActionFocus) { + const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); + const lastPurchaseDate = inventoryTraceDateLabel(summary.lastPeriod); + const directAnswerLine = + purchaseRows.length <= 0 || !summary.firstPeriod + ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` + : summary.firstPeriod === summary.lastPeriod + ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` + : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; + const lines: string[] = [directAnswerLine]; + if (purchaseRows.length > 0) { + lines.push("", "Подтверждение:"); + lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`); + if (summary.firstPeriod !== summary.lastPeriod) { + lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`); + } + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`); + } else if (summary.counterparties.length > 1) { + lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + if (summary.documents.length > 0) { + lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); + } + if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) { + lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`); + } + } + return { + responseType: "FACTUAL_SUMMARY", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: purchaseRows.length > 0 ? "strong" : "medium", + balance_confirmed: purchaseRows.length > 0 + } + }; + } const directAnswerLine = summary.counterparties.length === 1 ? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.` : summary.counterparties.length > 1 ? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.` : `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`; - const lines: string[] = [ - directAnswerLine, - `Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.", - "- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.", - "", - "Блок 2. Сводка", - `- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`, - `- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`, - `- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.` - ]; + const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); if (summary.counterparties.length === 1) { lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`); } else if (summary.counterparties.length > 1) { @@ -4092,7 +4123,14 @@ export function composeFactualReply( lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); } if (summary.documents.length > 0) { - lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8)); + lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); + } + if (purchaseRows.length > 0) { + lines.push( + "", + "Сервисно:", + "- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка." + ); } return { responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", @@ -4221,19 +4259,11 @@ export function composeFactualReply( : summary.counterparties.length > 1 ? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` : `По товару ${itemLabel} покупатель в текущем exact-контуре не материализован.`; - const lines: string[] = [ - directAnswerLine, - `Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.", - "", - "Блок 2. Сводка", - `- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`, - `- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`, - `- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.` - ]; + const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`); if (summary.counterparties.length === 1) { lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); } else if (summary.counterparties.length > 1) { @@ -4241,7 +4271,7 @@ export function composeFactualReply( } else if (saleRows.length > 0) { lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); } - lines.push("", "Блок 3. Документы выбытия"); + lines.push("", "Документы выбытия:"); if (saleRows.length > 0) { lines.push(...formatInventoryTraceRows(saleRows, 12)); } else { @@ -4269,14 +4299,9 @@ export function composeFactualReply( purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; - const lines: string[] = [ - directAnswerLine, - `Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - `- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`, - `- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.` - ]; + const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`); + lines.push(`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`); if (purchaseRows.length > 0 && saleRows.length > 0) { lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); } else if (purchaseRows.length > 0) { @@ -4289,7 +4314,7 @@ export function composeFactualReply( if (purchaseRows.length > 0) { lines.push( "", - "Блок 2. Закупка", + "Закупка:", `- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, ...formatInventoryTraceRows(purchaseRows, 6) @@ -4298,7 +4323,7 @@ export function composeFactualReply( if (saleRows.length > 0) { lines.push( "", - "Блок 3. Выбытие", + "Продажа:", `- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, ...formatInventoryTraceRows(saleRows, 6) diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 144a404..4e63f51 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -334,6 +334,32 @@ function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean { ); } +function hasInventoryPurchaseDateFollowupCue(text: string): boolean { + return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test( + String(text ?? "") + ); +} + +function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3; +} + +function hasInventorySaleFollowupCue(text: string): boolean { + return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|кто\s+купил|buyer|покупател)/iu.test( + String(text ?? "") + ); +} + +function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolean { + return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test( + String(text ?? "") + ); +} + export function hasAddressFollowupContextSignal(text: string): boolean { const normalized = String(text ?? "").trim(); if (!normalized) { @@ -564,8 +590,10 @@ function mergeFollowupFilters( !toNonEmptyString(merged.item) && previousItem ) { - merged.item = previousItem; - reasons.push("item_from_followup_context"); + if (intent !== "inventory_aging_by_purchase_date") { + merged.item = previousItem; + reasons.push("item_from_followup_context"); + } } if (sameDateRequested) { const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; @@ -576,7 +604,11 @@ function mergeFollowupFilters( } if ( !sameDateRequested && - (intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") && + (intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain" || + intent === "inventory_aging_by_purchase_date") && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage) ) { @@ -589,6 +621,15 @@ function mergeFollowupFilters( reasons.push("as_of_date_from_followup_context"); } } + if (intent === "inventory_aging_by_purchase_date") { + const explicitItemMention = /(?:^|[\s,.;:!?()\-\u2014])(?:товар(?:у|а|ом)?|позици(?:и|я|ю)|item|row|line)(?=$|[\s,.;:!?()\-\u2014])/iu.test( + String(userMessage ?? "") + ); + if (toNonEmptyString(merged.item) && !explicitItemMention) { + delete merged.item; + reasons.push("item_cleared_for_stock_slice_aging"); + } + } if ( !sameDateRequested && hasFollowupSignalForConfirmed && @@ -826,6 +867,64 @@ function deriveIntentWithFollowupContext( } } + if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { + if ( + detectedIntent.intent === "unknown" || + detectedIntent.intent === "inventory_purchase_provenance_for_item" || + detectedIntent.intent === previousIntent || + detectedIntent.intent === "inventory_on_hand_as_of_date" + ) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + } + + if (inventorySelectedObjectFollowup && hasInventorySaleFollowupCue(normalizedMessage)) { + if ( + detectedIntent.intent === "unknown" || + detectedIntent.intent === "inventory_purchase_provenance_for_item" || + detectedIntent.intent === "inventory_on_hand_as_of_date" || + detectedIntent.intent === previousIntent + ) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + } + + if (inventorySelectedObjectFollowup && hasInventoryPurchaseToSaleChainFollowupCue(normalizedMessage)) { + if ( + detectedIntent.intent === "unknown" || + detectedIntent.intent === "inventory_sale_trace_for_item" || + detectedIntent.intent === "inventory_on_hand_as_of_date" || + detectedIntent.intent === previousIntent + ) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + } + + if ( + previousIsInventoryFamily && + hasFollowupSignal && + hasBareInventoryPurchaseDateFollowupCue(normalizedMessage) && + (detectedIntent.intent === "unknown" || detectedIntent.intent === previousIntent) + ) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_followup_context"] + }; + } + if (hasPreviousContract) { if (detectedIntent.intent === "list_contracts_by_counterparty") { if (hasBankSignal(normalizedMessage)) { diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index e835648..c01e2d5 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2120,6 +2120,9 @@ function readAddressFilterString(addressDebug, key) { } return toNonEmptyString(filters[key]); } +function readAddressInventoryItemFilter(addressDebug) { + return readAddressFilterString(addressDebug, "item"); +} function isAddressLaneDebugPayload(debug) { if (!debug || typeof debug !== "object") { return false; @@ -2473,6 +2476,7 @@ function buildAddressFollowupOffer(addressDebug) { const anchorType = toNonEmptyString(addressDebug.anchor_type); const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ?? toNonEmptyString(addressDebug.anchor_value_raw) ?? + readAddressInventoryItemFilter(addressDebug) ?? readAddressFilterString(addressDebug, "counterparty") ?? readAddressFilterString(addressDebug, "contract") ?? readAddressFilterString(addressDebug, "account"); @@ -2646,6 +2650,26 @@ function hasShortDebtMirrorFollowupSignal(userMessage) { /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) || /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample)); } +function isInventorySelectedObjectIntent(intent) { + return intent === "inventory_purchase_provenance_for_item" || + intent === "inventory_purchase_documents_for_item" || + intent === "inventory_sale_trace_for_item" || + intent === "inventory_purchase_to_sale_chain"; +} +function hasShortInventoryObjectFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText].filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY); + if (minTokens > 8) { + return false; + } + return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) || + /^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample)); +} function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); if (!normalized || countTokens(normalized) > 10) { @@ -2676,14 +2700,18 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent); + const inventoryShortFollowupPrimary = isInventorySelectedObjectIntent(sourceIntentHint) && hasShortInventoryObjectFollowupSignal(userMessage); + const inventoryShortFollowupAlternate = isInventorySelectedObjectIntent(sourceIntentHint) && toNonEmptyString(alternateMessage) + ? hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? "")) + : false; const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null; const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage) ? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) : null; const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; - const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary); + const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary; const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) - ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) + ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate : false; const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage) @@ -2723,6 +2751,7 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes let previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type); let previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ?? toNonEmptyString(previousAddressDebug.anchor_value_raw) ?? + readAddressFilterString(previousAddressDebug, "item") ?? readAddressFilterString(previousAddressDebug, "counterparty") ?? readAddressFilterString(previousAddressDebug, "account") ?? readAddressFilterString(previousAddressDebug, "contract"); @@ -3924,6 +3953,12 @@ export function resolveAssistantOrchestrationDecision(input) { hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) || hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) || hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage); + const protectedInventoryShortFollowup = Boolean(followupContext && + isInventorySelectedObjectIntent(toNonEmptyString(followupContext.previous_intent)) && + (hasShortInventoryObjectFollowupSignal(rawUserMessage) || + hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) || + hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) || + hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage))); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && @@ -3933,7 +3968,8 @@ export function resolveAssistantOrchestrationDecision(input) { intentResolution.intent === "unknown"); const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && - (llmFirstUnsupportedCandidate || llmContractMode === null)); + (llmFirstUnsupportedCandidate || llmContractMode === null) && + !protectedInventoryShortFollowup); const hardMetaMode = dataScopeMetaQuery ? "data_scope" : capabilityMetaQuery && !dataRetrievalSignal diff --git a/llm_normalizer/backend/tests/addressInventoryPurchaseDateFollowup.test.ts b/llm_normalizer/backend/tests/addressInventoryPurchaseDateFollowup.test.ts new file mode 100644 index 0000000..3fbf299 --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventoryPurchaseDateFollowup.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { executeAddressMcpQueryMock } = vi.hoisted(() => ({ + executeAddressMcpQueryMock: vi.fn() +})); + +vi.mock("../src/services/addressMcpClient", async () => { + const actual = await vi.importActual( + "../src/services/addressMcpClient" + ); + return { + ...actual, + executeAddressMcpQuery: executeAddressMcpQueryMock + }; +}); + +import { AddressQueryService } from "../src/services/addressQueryService"; + +afterEach(() => { + executeAddressMcpQueryMock.mockReset(); +}); + +describe("inventory purchase-date selected-object follow-up", () => { + it("routes short 'когда' follow-up to purchase provenance and reuses the active item", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-02-11T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 165.83, + SubcontoDt1: "Кромка с клеем 33 альмяндин 137 м", + SubcontoDt3: "Основной склад", + SubcontoKt1: "Торговый дом \\Союз МСК\\", + SubcontoKt2: "Договор поставки № 12 от 01.02.2020", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("когда", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item: "Кромка с клеем 33 альмяндин 137 м", + warehouse: "Основной склад", + as_of_date: "2020-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_SUMMARY"); + expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмяндин 137 м"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); + const replyLines = String(result?.reply_text ?? "").split("\n"); + expect(replyLines[0]).toContain("11.02.2020"); + expect(replyLines[0]).toContain("Кромка"); + expect(String(result?.reply_text ?? "")).not.toContain("Блок 1"); + expect(String(result?.reply_text ?? "")).toContain("Подтверждение"); + expect(result?.debug.reasons).toContain("address_followup_context_applied"); + }); + + it("routes bare 'когда' follow-up to purchase provenance with the carried active item", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2019-02-12T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 3724.17, + SubcontoDt1: "Столешница 600*3050*26 дуб ниагара", + SubcontoDt3: "Основной склад", + SubcontoKt1: "Торговый дом \\Союз МСК\\", + SubcontoKt2: "Договор поставки № 12 от 01.02.2019", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("когда", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item: "Столешница 600*3050*26 дуб ниагара", + warehouse: "Основной склад", + as_of_date: "2019-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("12.02.2019"); + expect(String(result?.reply_text ?? "")).not.toContain("Блок 1"); + }); + + it("routes 'когда примерно мы купили' follow-up to compact purchase-date answer with the carried item", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-02-11T00:00:00Z", + Registrator: "Поступление товаров и услуг 00000000077 от 11.02.2020 0:00:00", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 833.33, + SubcontoDt1: "Четки Пост (84*117)", + SubcontoDt3: "Основной склад", + SubcontoKt1: "Торговый дом \\Союз МСК\\", + SubcontoKt2: "Договор поставки № 12 от 01.02.2020", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("когда примерно мы купили?", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item: "Четки Пост (84*117)", + warehouse: "Основной склад", + as_of_date: "2020-03-31" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("11.02.2020"); + expect(String(result?.reply_text ?? "")).toContain("Подтверждение"); + expect(String(result?.reply_text ?? "")).not.toContain("Блок 1"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index 432ed42..982e968 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -206,4 +206,51 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31"); expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077"); }); + + it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2020-04-12T00:00:00Z", + Registrator: "Реализация товаров и услуг 00000000112 от 12.04.2020 0:00:00", + AccountDt: "90.02", + AccountKt: "41.01", + Amount: 833.33, + SubcontoKt1: "Четки Пост (84*117)", + SubcontoKt3: "Основной склад", + SubcontoDt1: "ИП Покупатель", + Organization: "ООО \\Альтернатива Плюс\\" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("кому в итоге мы продали этот товар?", { + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31", + item: "Четки Пост (84*117)", + warehouse: "Основной склад" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1"); + expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)"); + expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ИП Покупатель"); + expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); + }); }); diff --git a/llm_normalizer/backend/tests/addressNavigationState.test.ts b/llm_normalizer/backend/tests/addressNavigationState.test.ts index 1175203..1ddc004 100644 --- a/llm_normalizer/backend/tests/addressNavigationState.test.ts +++ b/llm_normalizer/backend/tests/addressNavigationState.test.ts @@ -111,4 +111,37 @@ describe("address navigation state", () => { expect(evolved.session_context.date_scope.period_from).toBe("2020-01-01"); expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31"); }); + + it("captures item focus from inventory answers when no anchor is materialized", () => { + const base = createEmptyAddressNavigationState("asst-4", "2026-04-12T10:00:00.000Z"); + const assistantItem = { + message_id: "msg-a3", + session_id: "asst-4", + role: "assistant", + text: "Собран подтвержденный закупочный след по товару Диван трехместный до 14.04.2026.\n\n1. Авансовый отчет 00000000004 от 24.08.2018 12:00:04 | дата: 24.08.2018 | сумма: 34.490,00 ₽ | склад: Основной склад", + reply_type: "factual", + created_at: "2026-04-12T10:03:00.000Z", + trace_id: "address-789", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_purchase_provenance_for_item", + selected_recipe: "address_inventory_purchase_provenance_for_item_v1", + extracted_filters: { + item: "Диван трехместный", + warehouse: "Основной склад", + as_of_date: "2026-04-14" + }, + anchor_type: "unknown", + anchor_value_resolved: null, + anchor_value_raw: null, + dialog_continuation_contract_v2: { + decision: "new_topic" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 3); + expect(evolved.session_context.active_focus_object?.label).toBe("Диван трехместный"); + expect(evolved.session_context.active_focus_object?.provenance_result_set_id).toBe("rs-msg-a3"); + }); }); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 9d5efad..d6471f9 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -293,7 +293,7 @@ describe("address query shape classifier", () => { useRubCurrency: true } ); - expect(reply.text.split("\n")[0]).toContain("поставщиком"); + expect(reply.text.split("\n")[0]).toContain("документов закупки"); expect(reply.text).toContain("Шкаф картотечный"); expect(reply.text).toContain("Поступление товаров и услуг 0001"); expect(reply.semantics?.result_mode).toBe("confirmed_balance"); @@ -321,7 +321,8 @@ describe("address query shape classifier", () => { } ); expect(reply.text.split("\n")[0]).toContain("поставщиком"); - expect(reply.text).toContain("закупочный след"); + expect(reply.text).toContain("Подтверждение"); + expect(reply.text).not.toContain("Блок 1"); expect(reply.text).toContain("Гамма-мебель, ООО"); expect(reply.semantics?.balance_confirmed).toBe(true); }); @@ -348,7 +349,8 @@ describe("address query shape classifier", () => { } ); expect(reply.text.split("\n")[0]).toContain("покупатель"); - expect(reply.text).toContain("след выбытия"); + expect(reply.text).toContain("Документы выбытия"); + expect(reply.text).not.toContain("Блок 1"); expect(reply.text).toContain("Реализация товаров и услуг 0007"); expect(reply.text).toContain("Департамент капитального ремонта города Москвы"); }); @@ -385,7 +387,7 @@ describe("address query shape classifier", () => { useRubCurrency: true } ); - expect(reply.text).toContain("документальная цепочка"); + expect(reply.text.split("\n")[0]).toContain("цепочка поставки и продажи"); expect(reply.text).toContain("Поступление товаров и услуг 0001"); expect(reply.text).toContain("Реализация товаров и услуг 0007"); expect(reply.semantics?.result_mode).toBe("confirmed_balance"); @@ -3968,10 +3970,28 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.filters.extracted_filters.as_of_date).toBe("2019-03-31"); expect( result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") || - result?.intent.reasons.includes("inventory_selected_object_purchase_documents_signal_detected") + result?.intent.reasons.includes("inventory_selected_object_purchase_documents_signal_detected") ).toBe(true); }); + it("promotes conversational buyer follow-up into inventory sale trace with inherited date context", () => { + const result = runAddressDecomposeStage("кому в итоге мы продали этот товар?", { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31", + item: "Четки Пост (84*117)" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }); + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_sale_trace_for_item"); + expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31"); + }); + it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => { const result = runAddressDecomposeStage("выведи всех заков за все время", null); expect(result).not.toBeNull(); @@ -4791,8 +4811,9 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { ); expect(reply.responseType).toBe("FACTUAL_LIST"); - expect(reply.text).toContain("Собран подтвержденный срез товаров на складах"); + expect(reply.text.split("\n")[0]).toContain("На 31.03.2020 на складе подтверждено"); expect(reply.text).toContain("Контур: остатки по счету 41.01"); + expect(reply.text).not.toContain("Блок 1"); expect(reply.text).toContain("Шкаф картотечный"); expect(reply.text).toContain("Основной склад"); expect(reply.semantics?.result_mode).toBe("confirmed_balance"); @@ -4828,6 +4849,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(result.intent).toBe("inventory_purchase_to_sale_chain"); }); + it("routes conversational buyer wording to inventory sale trace intent", () => { + const result = resolveAddressIntent("Кому в итоге мы продали товар Шкаф картоотечный?"); + expect(result.intent).toBe("inventory_sale_trace_for_item"); + }); + it("keeps inventory provenance wording out of inventory-on-hand routing", () => { const result = resolveAddressIntent("От кого куплен товар Шкаф картоотечный и когда был куплен?"); expect(result.intent).toBe("inventory_purchase_provenance_for_item"); diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index b95632f..b3bbcce 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -278,6 +278,101 @@ describe("assistant address follow-up carryover", () => { expect(normalizerService.normalize).not.toHaveBeenCalled(); }); + it("treats bare 'когда' as a selected-item inventory follow-up for the active provenance object", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const followupMessage = "когда"; + const provenanceResult = { + handled: true, + reply_text: "Товар Столешница 600*3050*26 дуб ниагара по доступным закупочным движениям связан с поставщиком: Торговый дом \\Союз\\.", + reply_type: "factual", + response_type: "FACTUAL_SUMMARY", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_purchase_provenance_for_item", + detected_intent_confidence: "high", + extracted_filters: { + item: "Столешница 600*3050*26 дуб ниагара", + warehouse: "Основной склад", + as_of_date: "2019-03-31" + }, + missing_required_filters: [], + selected_recipe: "address_inventory_purchase_provenance_for_item_v1", + anchor_type: "unknown", + anchor_value_raw: null, + anchor_value_resolved: null, + reasons: ["address_action_detected", "address_entity_detected"], + dialog_continuation_contract_v2: { + decision: "continue_previous" + } + } + } as any; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === followupMessage && !options?.followupContext) { + return null; + } + if (message === followupMessage && options?.followupContext) { + return { + ...provenanceResult, + reply_text: "Позиция Столешница 600*3050*26 дуб ниагара куплена 12.02.2019.\n\nПодтверждение:\n- Первый подтверждающий документ: Поступление товаров и услуг 00000000003 от 12.02.2019.", + debug: { + ...provenanceResult.debug, + reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] + } + }; + } + return provenanceResult; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-followup-when-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-inventory-provenance-seed", + session_id: sessionId, + role: "assistant", + text: provenanceResult.reply_text, + reply_type: provenanceResult.reply_type, + created_at: "2026-04-14T18:00:00.000Z", + trace_id: "address-seed", + debug: provenanceResult.debug + } as any); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(followupMessage); + expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item"); + expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара"); + expect(calls[0].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад"); + expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2019-03-31"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => { const calls: Array<{ message: string; options?: any }> = []; const firstMessage = "покажи документы по свк за 2020"; diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index b8bd432..3df5e86 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -259,7 +259,7 @@ describe("assistant orchestration contract", () => { expect(decision.runAddressLane).toBe(true); expect(decision.toolGateDecision).toBe("run_address_lane"); - expect(decision.toolGateReason).toBe("address_mode_classifier_detected"); + expect(["address_mode_classifier_detected", "address_intent_resolver_detected"]).toContain(String(decision.toolGateReason)); expect(decision.livingMode).toBe("address_data"); expect(decision.livingReason).toBe("address_lane_triggered"); }); @@ -289,7 +289,44 @@ describe("assistant orchestration contract", () => { expect(decision.runAddressLane).toBe(true); expect(decision.toolGateDecision).toBe("run_address_lane"); - expect(decision.toolGateReason).toBe("address_mode_classifier_detected"); + expect(["address_mode_classifier_detected", "address_intent_resolver_detected"]).toContain(String(decision.toolGateReason)); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + }); + + it("keeps short inventory follow-up 'когда' in address lane when a selected-item provenance context exists", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "когда", + effectiveAddressUserMessage: "когда", + followupContext: { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + item: "Столешница 600*3050*26 дуб ниагара", + warehouse: "Основной склад", + as_of_date: "2019-03-31" + } + }, + llmPreDecomposeMeta: { + applied: false, + reason: "normalized_fragment_rejected_semantic_guard", + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + }, + semanticExtractionContract: { + valid: false, + apply_canonical_recommended: false, + reason_codes: ["unsupported_low_confidence_contract"] + } + } as any, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); expect(decision.livingMode).toBe("address_data"); expect(decision.livingReason).toBe("address_lane_triggered"); }); diff --git a/scripts/domain_case_loop.py b/scripts/domain_case_loop.py index 744ac3f..6e4049c 100644 --- a/scripts/domain_case_loop.py +++ b/scripts/domain_case_loop.py @@ -1827,8 +1827,8 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis "", f"## {title}", "", - "| node_id | status | backed_by_scenarios | question_ids | required_wording_families |", - "| --- | --- | --- | --- | --- |", + "| node_id | status | backed_by_scenarios | question_ids | required_wording_families | observed_wording_families | missing_wording_families |", + "| --- | --- | --- | --- | --- | --- | --- |", ] ) for node in nodes: @@ -1858,6 +1858,8 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis ", ".join(backed_by) or "-", ", ".join(normalize_string_list(node.get("covers_question_ids"))) or "-", ", ".join(required_wording_families) or "-", + ", ".join(observed_wording_families) or "-", + ", ".join(missing_wording_families) or "-", ] ) + " |" @@ -1874,8 +1876,8 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis "", "## Critical edges", "", - "| edge_id | status | from_node | to_node | backed_by_scenarios | primary_user_path |", - "| --- | --- | --- | --- | --- | --- |", + "| edge_id | status | from_node | to_node | backed_by_scenarios | primary_user_path | observed_wording_families | missing_wording_families |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", ] ) for edge in edges: @@ -1918,6 +1920,8 @@ def build_scenario_acceptance_matrix(pack: dict[str, Any], scenario_results: lis to_node or "-", ", ".join(backed_by) or "-", "yes" if bool(edge.get("primary_user_path")) else "no", + ", ".join(observed_wording_families) or "-", + ", ".join(missing_wording_families) or "-", ] ) + " |" @@ -2222,13 +2226,14 @@ 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; + - evaluate object-centric dialog continuity: stable `focus_object`, stable `answer_object`, reusable bundles such as `provenance_bundle`, and correct action resolution for pronoun-style follow-ups; + - evaluate action-first follow-up behavior, answer layering, compactness of narrow micro-actions, and temporal honesty when the runtime broadens beyond the requested date window; - determine whether the gate `quality_score >= {target_score}` is reached; - if not, provide the smallest high-value fix targets for the coder. Rules: - `accepted` is allowed only if quality_score >= {target_score}, unresolved_p0_count = 0, and regression_detected = false; - - `accepted` also requires `direct_answer_ok = true` and `business_usefulness_ok = true`; + - `accepted` also requires `direct_answer_ok = true`, `business_usefulness_ok = true`, `temporal_honesty_ok = true`, and `field_truth_ok = true`; - `partial` means the pack is usable but exactness, routing, or coverage is still insufficient; - `needs_exact_capability` means the primary blocker is a missing exact route or capability, but the loop should still continue autonomously unless a user decision is required; - `continue` means there is a clear next patch cycle; @@ -2241,11 +2246,13 @@ 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`, `focus_object_continuity_ok`, `bundle_reuse_ok`, `followup_action_resolution_ok`, `recommended_state_objects`, `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`, `temporal_honesty_ok`, `field_truth_ok`, `answer_layering_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 a narrow business follow-up opens with numbered scaffolding such as `Блок 1/2/3` or a full generic trace packet instead of a compact direct answer, lower business usefulness explicitly rather than treating it as harmless formatting; - if the surfaced business field looks mislabeled, for example supplier vs organization, classify that as `field_mapping_gap`; + - if the answer blurs exact-window evidence with nearest available out-of-window evidence, classify that as `temporal_honesty_gap`; - if the answer is technically grounded but still weak for a manager/accountant/operator, classify that as `business_utility_gap`. Use this UTF-8 evidence bundle as the source of truth for artifact contents. Do not treat shell rendering artifacts as file corruption if the embedded bundle is readable. @@ -2292,8 +2299,8 @@ 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, 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`; + - prioritize state continuity, selected-object persistence, stable `focus_object`, stable `answer_object`, reusable `provenance_bundle` / `sale_trace_bundle`, action-first answer behavior, compact micro-action answers, answer layering, temporal honesty, 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`, `temporal_honesty_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: @@ -2318,6 +2325,9 @@ def evaluate_analyst_gate( regression_detected = bool(verdict.get("regression_detected")) direct_answer_ok = bool(verdict.get("direct_answer_ok", True)) business_usefulness_ok = bool(verdict.get("business_usefulness_ok", True)) + temporal_honesty_ok = bool(verdict.get("temporal_honesty_ok", True)) + field_truth_ok = bool(verdict.get("field_truth_ok", True)) + answer_layering_ok = bool(verdict.get("answer_layering_ok", True)) loop_decision = str(verdict.get("loop_decision") or "").strip() or "continue" requires_user_decision = bool(verdict.get("requires_user_decision")) user_decision_type = str(verdict.get("user_decision_type") or "").strip() or "none" @@ -2329,6 +2339,9 @@ def evaluate_analyst_gate( and not regression_detected and direct_answer_ok and business_usefulness_ok + and temporal_honesty_ok + and field_truth_ok + and answer_layering_ok and loop_decision == "accepted" ) return accepted, loop_decision, requires_user_decision, user_decision_type, user_decision_prompt diff --git a/tests/test_domain_case_loop.py b/tests/test_domain_case_loop.py index 2b0fb6c..85e5a0a 100644 --- a/tests/test_domain_case_loop.py +++ b/tests/test_domain_case_loop.py @@ -10,6 +10,7 @@ from scripts.domain_case_loop import ( build_scenario_acceptance_matrix, carry_forward_analysis_context, derive_pack_final_status, + evaluate_analyst_gate, load_scenario_pack, merge_scenario_date_scope, ) @@ -247,6 +248,8 @@ def test_build_scenario_acceptance_matrix_marks_partial_when_wording_family_is_m matrix = build_scenario_acceptance_matrix(pack, scenario_results) assert "| N03_selected_item_supplier | partial |" in matrix + assert "missing_wording_families" in matrix + assert "ui_selected_object_colloquial" in matrix def test_derive_pack_final_status_downgrades_accepted_when_matrix_contains_partial_coverage() -> None: @@ -292,3 +295,31 @@ def test_derive_pack_final_status_downgrades_accepted_when_matrix_contains_parti ] assert derive_pack_final_status(pack, scenario_results) == "partial" + + +def test_evaluate_analyst_gate_requires_temporal_honesty_field_truth_and_layering() -> None: + verdict = { + "quality_score": 91, + "unresolved_p0_count": 0, + "regression_detected": False, + "direct_answer_ok": True, + "business_usefulness_ok": True, + "temporal_honesty_ok": False, + "field_truth_ok": True, + "answer_layering_ok": True, + "loop_decision": "accepted", + "requires_user_decision": False, + "user_decision_type": "none", + "user_decision_prompt": None, + } + + accepted, loop_decision, requires_user_decision, user_decision_type, user_decision_prompt = evaluate_analyst_gate( + verdict, + target_score=80, + ) + + assert accepted is False + assert loop_decision == "accepted" + assert requires_user_decision is False + assert user_decision_type == "none" + assert user_decision_prompt is None