ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Склад: усилить follow-up оркестрацию и business-first формат ответов
This commit is contained in:
parent
97b2a9b028
commit
c020ef08e1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,9 +464,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
|||
intent === "inventory_aging_by_purchase_date") &&
|
||||
!toNonEmptyString(merged.item) &&
|
||||
previousItem) {
|
||||
if (intent !== "inventory_aging_by_purchase_date") {
|
||||
merged.item = previousItem;
|
||||
reasons.push("item_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (sameDateRequested) {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +1658,8 @@ 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(
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -216,7 +216,11 @@ function resolveNavigationAction(debug: Record<string, unknown>, hasFocusObject:
|
|||
}
|
||||
|
||||
function buildFocusObjectFromDebug(debug: Record<string, unknown>, 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,9 +590,11 @@ function mergeFollowupFilters(
|
|||
!toNonEmptyString(merged.item) &&
|
||||
previousItem
|
||||
) {
|
||||
if (intent !== "inventory_aging_by_purchase_date") {
|
||||
merged.item = previousItem;
|
||||
reasons.push("item_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (sameDateRequested) {
|
||||
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof import("../src/services/addressMcpClient")>(
|
||||
"../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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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("Документы выбытия");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -3972,6 +3974,24 @@ describe("address decompose stage follow-up carryover", () => {
|
|||
).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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue