ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить selected-object follow-up по складу и включить разговорные/UI-варианты в обязательный domain-loop
This commit is contained in:
parent
9048632d3e
commit
d41819eabd
|
|
@ -37,6 +37,8 @@ Rules:
|
||||||
- Do not praise superficial wording improvements if the compute layer is still wrong.
|
- Do not praise superficial wording improvements if the compute layer is still wrong.
|
||||||
- Highlight if an answer is unusable for a manager, accountant, or operator.
|
- Highlight if an answer is unusable for a manager, accountant, or operator.
|
||||||
- If the system answered a weaker question than the user asked, say so explicitly.
|
- If the system answered a weaker question than the user asked, say so explicitly.
|
||||||
|
- Treat colloquial/slang wording, typo variants, and UI-generated selected-object follow-ups as first-class coverage, not optional polish.
|
||||||
|
- If the domain works only for one curated phrasing but breaks for realistic conversational or UI-originated follow-ups, call that out as a real defect and lower the score.
|
||||||
|
|
||||||
Quality score:
|
Quality score:
|
||||||
- Output one integer score from 0 to 100.
|
- Output one integer score from 0 to 100.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ Your mission:
|
||||||
- Find the smallest domain-only patch that moves the case toward a correct, useful, business-readable answer.
|
- Find the smallest domain-only patch that moves the case toward a correct, useful, business-readable answer.
|
||||||
- Use exact 1C/MCP-backed routes when they exist.
|
- Use exact 1C/MCP-backed routes when they exist.
|
||||||
- If exact data does not exist in the reachable contour, surface technical insufficiency instead of fabricating a result.
|
- If exact data does not exist in the reachable contour, surface technical insufficiency instead of fabricating a result.
|
||||||
|
- Close the realistic surface of the bug, not only the clean canonical wording; include colloquial and UI-generated selected-object follow-up variants when they are part of the failing flow.
|
||||||
|
|
||||||
Allowed change zones:
|
Allowed change zones:
|
||||||
- intents
|
- intents
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ Hard rules:
|
||||||
- In autonomous loop mode, do not stop only because the analyst says `needs_exact_capability` or `partial` if there is still autonomous implementation work to do.
|
- In autonomous loop mode, do not stop only because the analyst says `needs_exact_capability` or `partial` if there is still autonomous implementation work to do.
|
||||||
- Stop early when the analyst sets `requires_user_decision = true` because the next step would otherwise require guessing a missing required observation, accepting a risky architecture fork, choosing a business-critical tradeoff, or pushing through a hacky / brittle / disproportionally complex fix.
|
- Stop early when the analyst sets `requires_user_decision = true` because the next step would otherwise require guessing a missing required observation, accepting a risky architecture fork, choosing a business-critical tradeoff, or pushing through a hacky / brittle / disproportionally complex fix.
|
||||||
- Treat true runtime or 1C availability failures as `blocked`, not as a normal low-score iteration.
|
- Treat true runtime or 1C availability failures as `blocked`, not as a normal low-score iteration.
|
||||||
|
- For follow-up-heavy domains, capture and rerun at least one colloquial/slang variant and one UI-generated selected-object follow-up variant instead of validating only canonical wording.
|
||||||
|
|
||||||
Acceptance gate:
|
Acceptance gate:
|
||||||
- accepted requires analyst quality_score >= 80
|
- accepted requires analyst quality_score >= 80
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ This skill packages the standard workflow for iterating on one concrete domain c
|
||||||
- there is a gap between exact compute intent and actual fallback output;
|
- there is a gap between exact compute intent and actual fallback output;
|
||||||
- there are follow-up / continuation bugs that corrupt business context.
|
- there are follow-up / continuation bugs that corrupt business context.
|
||||||
- the user has a cascade of linked questions that should reuse one assistant session and semantic state.
|
- the user has a cascade of linked questions that should reuse one assistant session and semantic state.
|
||||||
|
- the bug appears only in colloquial/slang wording or in UI-generated follow-up phrasing such as `По выбранному объекту "...": ...`.
|
||||||
|
|
||||||
## Do not use this skill when
|
## Do not use this skill when
|
||||||
|
|
||||||
|
|
@ -77,6 +78,7 @@ In autonomous pack-loop mode:
|
||||||
- stop only on `accepted`, `blocked`, explicit `requires_user_decision = true`, or `max_iterations`;
|
- stop only on `accepted`, `blocked`, explicit `requires_user_decision = true`, or `max_iterations`;
|
||||||
- do not stop just because the analyst returns `needs_exact_capability` or `partial` if autonomous domain enablement work still remains.
|
- do not stop just because the analyst returns `needs_exact_capability` or `partial` if autonomous domain enablement work still remains.
|
||||||
- treat `quality score >= 80` as the target gate, not as permission to keep pushing through hard blockers, missing essential observations, or unsafe fixes.
|
- treat `quality score >= 80` as the target gate, not as permission to keep pushing through hard blockers, missing essential observations, or unsafe fixes.
|
||||||
|
- for follow-up-heavy domains, include conversational variants, slang/typo variants, and UI-generated selected-object follow-ups in the acceptance slice instead of validating only one canonical wording.
|
||||||
|
|
||||||
### Step 1 - Normalize the case
|
### Step 1 - Normalize the case
|
||||||
|
|
||||||
|
|
@ -182,6 +184,7 @@ Accepted requires:
|
||||||
- Keep domain fixes minimal and localized.
|
- Keep domain fixes minimal and localized.
|
||||||
- Preserve successful baseline scenarios.
|
- Preserve successful baseline scenarios.
|
||||||
- Treat follow-up continuity as a state-machine problem, not a wording problem.
|
- Treat follow-up continuity as a state-machine problem, not a wording problem.
|
||||||
|
- Do not accept a domain as hardened if only canonical phrasing works while colloquial or UI-generated follow-up phrasing still breaks the exact contour.
|
||||||
|
|
||||||
## Domain-specific framing
|
## Domain-specific framing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
|
|
||||||
## 10. Acceptance criteria for rerun
|
## 10. Acceptance criteria for rerun
|
||||||
- ...
|
- ...
|
||||||
|
- Include colloquial/slang variants and UI-generated selected-object follow-up variants when they are part of the business flow.
|
||||||
|
|
||||||
## 11. Quality score
|
## 11. Quality score
|
||||||
- integer from 0 to 100
|
- integer from 0 to 100
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[trace-completeness] trace_id=FxptYyx1YHermm schema=v1 issues=missing_parsed_normalized_json
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
[trace-completeness] trace_id=D5LlglEclPK3wI schema=v1 issues=missing_parsed_normalized_json
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,8 @@
|
||||||
|
[trace-completeness] trace_id=VDxca2tLAKyVJo schema=v1 issues=missing_parsed_normalized_json
|
||||||
|
[trace-completeness] trace_id=-nB07p30ipnMC9 schema=v1 issues=missing_parsed_normalized_json
|
||||||
|
[trace-completeness] trace_id=xyJUh372ge9qh1 schema=v1 issues=missing_parsed_normalized_json
|
||||||
|
[trace-completeness] trace_id=diT7xjvfkD7An2 schema=v1 issues=missing_parsed_normalized_json
|
||||||
|
[trace-completeness] trace_id=fV-syC1Uct-PMK schema=v1 issues=missing_parsed_normalized_json
|
||||||
|
[trace-completeness] trace_id=gNDDzj4rdhRaKA schema=v1 issues=missing_parsed_normalized_json
|
||||||
|
[trace-completeness] trace_id=-Tg-6Lsx4z0nIT schema=v1 issues=missing_parsed_normalized_json
|
||||||
|
[trace-completeness] trace_id=MJPJ-rKxWt7poz schema=v1 issues=missing_parsed_normalized_json
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
[trace-completeness] trace_id=LW8OisC5j5TROG schema=v1 issues=missing_parsed_normalized_json
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -0,0 +1,26 @@
|
||||||
|
node:events:497
|
||||||
|
throw er; // Unhandled 'error' event
|
||||||
|
^
|
||||||
|
|
||||||
|
Error: listen EADDRINUSE: address already in use :::8787
|
||||||
|
at Server.setupListenHandle [as _listen2] (node:net:1940:16)
|
||||||
|
at listenInCluster (node:net:1997:12)
|
||||||
|
at Server.listen (node:net:2102:7)
|
||||||
|
at Function.listen (X:\1C\NDC_1C\llm_normalizer\backend\node_modules\express\lib\application.js:635:24)
|
||||||
|
at Object.<anonymous> (X:\1C\NDC_1C\llm_normalizer\backend\dist\server.js:74:9)
|
||||||
|
at Module._compile (node:internal/modules/cjs/loader:1706:14)
|
||||||
|
at Object..js (node:internal/modules/cjs/loader:1839:10)
|
||||||
|
at Module.load (node:internal/modules/cjs/loader:1441:32)
|
||||||
|
at Function._load (node:internal/modules/cjs/loader:1263:12)
|
||||||
|
at TracingChannel.traceSync (node:diagnostics_channel:322:14)
|
||||||
|
Emitted 'error' event on Server instance at:
|
||||||
|
at emitErrorNT (node:net:1976:8)
|
||||||
|
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
|
||||||
|
code: 'EADDRINUSE',
|
||||||
|
errno: -4091,
|
||||||
|
syscall: 'listen',
|
||||||
|
address: '::',
|
||||||
|
port: 8787
|
||||||
|
}
|
||||||
|
|
||||||
|
Node.js v22.20.0
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"timestamp":"2026-04-14T04:26:37.615Z","level":"info","service":"llm_normalizer_backend","message":"Backend started on http://localhost:8787"}
|
|
||||||
|
|
@ -4,6 +4,10 @@ export const designConfig = {
|
||||||
mainSurfaceRgb: "25, 25, 25",
|
mainSurfaceRgb: "25, 25, 25",
|
||||||
horizontalSurfaceRgb: "30, 30, 30",
|
horizontalSurfaceRgb: "30, 30, 30",
|
||||||
focusSurfaceRgb: "35, 35, 35",
|
focusSurfaceRgb: "35, 35, 35",
|
||||||
|
assistantChipRgb: "18, 18, 18",
|
||||||
|
assistantChipHoverRgb: "44, 44, 44",
|
||||||
|
assistantChipSelectedRgb: "167, 59, 255",
|
||||||
|
assistantChipSelectedTextRgb: "240, 240, 240",
|
||||||
activeRgb: "167, 59, 255",
|
activeRgb: "167, 59, 255",
|
||||||
activeTextRgb: "240, 240, 240",
|
activeTextRgb: "240, 240, 240",
|
||||||
textMainRgb: "240, 240, 240",
|
textMainRgb: "240, 240, 240",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"schema_version": "address_route_expectations_v1",
|
"schema_version": "address_route_expectations_v1",
|
||||||
"updated_at": "2026-04-13T00:15:00.000Z",
|
"updated_at": "2026-04-14T09:30:00.000Z",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"intent": "payables_confirmed_as_of_date",
|
"intent": "payables_confirmed_as_of_date",
|
||||||
|
|
@ -26,6 +26,36 @@
|
||||||
"expected_requested_result_modes": ["confirmed_balance"],
|
"expected_requested_result_modes": ["confirmed_balance"],
|
||||||
"expected_result_modes": ["confirmed_balance"]
|
"expected_result_modes": ["confirmed_balance"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"intent": "inventory_purchase_provenance_for_item",
|
||||||
|
"expected_selected_recipes": ["address_inventory_purchase_provenance_for_item_v1"],
|
||||||
|
"expected_requested_result_modes": ["confirmed_balance"],
|
||||||
|
"expected_result_modes": ["confirmed_balance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"intent": "inventory_purchase_documents_for_item",
|
||||||
|
"expected_selected_recipes": ["address_inventory_purchase_documents_for_item_v1"],
|
||||||
|
"expected_requested_result_modes": ["confirmed_balance"],
|
||||||
|
"expected_result_modes": ["confirmed_balance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"intent": "inventory_sale_trace_for_item",
|
||||||
|
"expected_selected_recipes": ["address_inventory_sale_trace_for_item_v1"],
|
||||||
|
"expected_requested_result_modes": ["confirmed_balance"],
|
||||||
|
"expected_result_modes": ["confirmed_balance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"intent": "inventory_purchase_to_sale_chain",
|
||||||
|
"expected_selected_recipes": ["address_inventory_purchase_to_sale_chain_v1"],
|
||||||
|
"expected_requested_result_modes": ["confirmed_balance"],
|
||||||
|
"expected_result_modes": ["confirmed_balance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"intent": "inventory_aging_by_purchase_date",
|
||||||
|
"expected_selected_recipes": ["address_inventory_aging_by_purchase_date_v1"],
|
||||||
|
"expected_requested_result_modes": ["confirmed_balance"],
|
||||||
|
"expected_result_modes": ["confirmed_balance"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"intent": "vat_payable_forecast",
|
"intent": "vat_payable_forecast",
|
||||||
"expected_selected_recipes": ["address_vat_payable_forecast_v1"]
|
"expected_selected_recipes": ["address_vat_payable_forecast_v1"]
|
||||||
|
|
|
||||||
|
|
@ -138,13 +138,33 @@ function resolveCapabilityEnabled(intent) {
|
||||||
}
|
}
|
||||||
if (intent === "inventory_purchase_provenance_for_item" ||
|
if (intent === "inventory_purchase_provenance_for_item" ||
|
||||||
intent === "inventory_purchase_documents_for_item" ||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
|
||||||
intent === "inventory_sale_trace_for_item" ||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
intent === "inventory_purchase_to_sale_chain" ||
|
intent === "inventory_purchase_to_sale_chain") {
|
||||||
intent === "inventory_aging_by_purchase_date") {
|
if (intent === "inventory_purchase_to_sale_chain") {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
reason: "inventory_purchase_to_sale_chain_route_enabled"
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||||
reason: "inventory_provenance_route_not_implemented"
|
reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
|
||||||
|
? "inventory_trace_route_enabled"
|
||||||
|
: "inventory_trace_route_disabled_by_flag"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||||
|
return {
|
||||||
|
enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||||
|
reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
|
||||||
|
? "inventory_supplier_stock_overlap_route_enabled"
|
||||||
|
: "inventory_supplier_stock_overlap_route_disabled_by_flag"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_aging_by_purchase_date") {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
reason: "inventory_aging_route_enabled"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (intent === "list_payables_counterparties") {
|
if (intent === "list_payables_counterparties") {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ const COUNTERPARTY_TOKEN_NOISE = new Set([
|
||||||
"могу",
|
"могу",
|
||||||
"можем",
|
"можем",
|
||||||
"нет",
|
"нет",
|
||||||
|
"был",
|
||||||
|
"были",
|
||||||
|
"куплен",
|
||||||
|
"куплены",
|
||||||
"покажи",
|
"покажи",
|
||||||
"показать",
|
"показать",
|
||||||
"скажи",
|
"скажи",
|
||||||
|
|
@ -796,6 +800,198 @@ function extractLeadingCounterpartyTokenHeuristic(text) {
|
||||||
function hasExplicitAccountCue(text) {
|
function hasExplicitAccountCue(text) {
|
||||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||||
}
|
}
|
||||||
|
function isInventoryTraceIntent(intent) {
|
||||||
|
return (intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date");
|
||||||
|
}
|
||||||
|
function isInventoryItemAnchoredIntent(intent) {
|
||||||
|
return (intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain");
|
||||||
|
}
|
||||||
|
function usesRecipeDefaultLimit(intent) {
|
||||||
|
return (intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date");
|
||||||
|
}
|
||||||
|
function isLowQualityInventoryItemAnchorValue(rawValue) {
|
||||||
|
const value = cleanupAnchorValue(rawValue)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е");
|
||||||
|
if (!value || value.length < 3) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/^(?:товар(?:ы|а|у|ом)?|номенклатура|позиция|остаток|остатки|склад|складе|складу|поставщик|покупатель|документ|документы)$/iu.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lowQualityTokens = new Set([
|
||||||
|
"сейчас",
|
||||||
|
"лежат",
|
||||||
|
"лежит",
|
||||||
|
"лежали",
|
||||||
|
"куплен",
|
||||||
|
"куплена",
|
||||||
|
"куплены",
|
||||||
|
"продан",
|
||||||
|
"продана",
|
||||||
|
"проданы",
|
||||||
|
"документам",
|
||||||
|
"документами",
|
||||||
|
"документы",
|
||||||
|
"поставщика",
|
||||||
|
"поставщику",
|
||||||
|
"покупателю",
|
||||||
|
"остаток",
|
||||||
|
"остатки",
|
||||||
|
"склад",
|
||||||
|
"складе",
|
||||||
|
"складу"
|
||||||
|
]);
|
||||||
|
const meaningfulTokens = value
|
||||||
|
.split(/[^a-zа-я0-9]+/iu)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((token) => !lowQualityTokens.has(token));
|
||||||
|
return meaningfulTokens.length === 0;
|
||||||
|
}
|
||||||
|
function cleanupInventoryItemAnchorValue(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
function trimInventoryItemAnchorTail(rawValue) {
|
||||||
|
let value = cleanupInventoryItemAnchorValue(rawValue);
|
||||||
|
const tailPatterns = [
|
||||||
|
/\s+для\s+остатка(?:\s+на\s+складе.*)?$/iu,
|
||||||
|
/\s+из\s+текущ(?:его|их)\s+остат(?:ка|ков).*$/iu,
|
||||||
|
/\s+из\s+остат(?:ка|ков).*$/iu,
|
||||||
|
/\s+в\s+остатке.*$/iu,
|
||||||
|
/\s+на\s+складе.*$/iu,
|
||||||
|
/\s*:\s*закупк.*$/iu
|
||||||
|
];
|
||||||
|
for (const pattern of tailPatterns) {
|
||||||
|
value = value.replace(pattern, "");
|
||||||
|
}
|
||||||
|
return cleanupInventoryItemAnchorValue(value);
|
||||||
|
}
|
||||||
|
function extractSelectedObjectQuotedValue(text) {
|
||||||
|
const patterns = [
|
||||||
|
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
|
||||||
|
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*:\s*[«"]([^»"\r\n]+)[»"]/iu
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = String(text ?? "").match(pattern);
|
||||||
|
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
|
||||||
|
if (candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function extractInventoryItemFromSelectedObject(text) {
|
||||||
|
const selectedObject = extractSelectedObjectQuotedValue(text);
|
||||||
|
if (!selectedObject) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const firstLine = selectedObject
|
||||||
|
.replace(/\r\n?/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => cleanupInventoryItemAnchorValue(line))
|
||||||
|
.find(Boolean);
|
||||||
|
const withoutNumberPrefix = cleanupInventoryItemAnchorValue(String(firstLine ?? "").replace(/^\d+\.\s*/, ""));
|
||||||
|
const primarySegment = cleanupInventoryItemAnchorValue(withoutNumberPrefix.split("|")[0] ?? withoutNumberPrefix);
|
||||||
|
const candidate = cleanupInventoryItemAnchorValue(primarySegment);
|
||||||
|
if (!candidate || isLowQualityInventoryItemAnchorValue(candidate)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
function extractInventoryItemAnchor(text) {
|
||||||
|
const selectedObjectItem = extractInventoryItemFromSelectedObject(text);
|
||||||
|
if (selectedObjectItem) {
|
||||||
|
return selectedObjectItem;
|
||||||
|
}
|
||||||
|
const patterns = [
|
||||||
|
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
|
||||||
|
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = String(text ?? "").match(pattern);
|
||||||
|
const candidate = trimInventoryItemArrowSuffix(trimInventoryItemChainTail(trimInventoryItemAnchorTail(String(match?.[1] ?? ""))));
|
||||||
|
if (!candidate || isLowQualityInventoryItemAnchorValue(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function trimInventoryItemChainTail(rawValue) {
|
||||||
|
return cleanupInventoryItemAnchorValue(cleanupInventoryItemAnchorValue(rawValue)
|
||||||
|
.replace(/\s*(?:->|=>|→)\s*(?:покупател\w*|buyer\b).*$/iu, "")
|
||||||
|
.replace(/\s*(?:->|=>|→)\s*(?:поставщик\w*|supplier\b).*$/iu, ""));
|
||||||
|
}
|
||||||
|
function trimInventoryItemArrowSuffix(rawValue) {
|
||||||
|
return cleanupAnchorValue(cleanupAnchorValue(rawValue).replace(/\s*(?:->|=>|→).+$/u, ""));
|
||||||
|
}
|
||||||
|
function isTemporalWarehousePhrase(candidate) {
|
||||||
|
const normalized = cleanupAnchorValue(candidate)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е")
|
||||||
|
.trim();
|
||||||
|
return /^(?:в|на)\s+(?:январ(?:е|ь)|феврал(?:е|ь)|март(?:е)?|апрел(?:е|ь)|ма(?:й|е)|июн(?:е|ь)|июл(?:е|ь)|август(?:е)?|сентябр(?:е|ь)|октябр(?:е|ь)|ноябр(?:е|ь)|декабр(?:е|ь))(?:\s+\d{4}(?:\s+г(?:\.|ода)?)?)?$/iu.test(normalized);
|
||||||
|
}
|
||||||
|
function extractInventoryWarehouseAnchor(text) {
|
||||||
|
const patterns = [
|
||||||
|
/(?:на|по)\s+склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu,
|
||||||
|
/склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = String(text ?? "").match(pattern);
|
||||||
|
const candidate = cleanupAnchorValue(cleanupAnchorValue(String(match?.[1] ?? "")).replace(/\s+(?:организац\w*|компани\w*|котор(?:ый|ые|ых)|на\s+дату|по\s+состоянию\s+на\s+дату).*$/iu, ""));
|
||||||
|
const normalizedCandidate = candidate
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е")
|
||||||
|
.trim();
|
||||||
|
if (!candidate ||
|
||||||
|
candidate.includes("->") ||
|
||||||
|
candidate.includes("=>") ||
|
||||||
|
normalizedCandidate.startsWith("по состоянию") ||
|
||||||
|
isTemporalWarehousePhrase(candidate) ||
|
||||||
|
/^(?:сейчас|на|дату|дате|остаток|остатки)$/iu.test(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function extractInventorySupplierAnchor(text) {
|
||||||
|
const match = String(text ?? "").match(/(?:от\s+поставщика|у\s+поставщика|поставщика|поставщику)\s+([^\r\n?]+?)(?=$|[?])/iu);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const candidate = cleanupAnchorValue(cleanupAnchorValue(String(match[1])).replace(/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu, ""));
|
||||||
|
if (!candidate ||
|
||||||
|
isLowQualityCounterpartyAnchorValue(candidate) ||
|
||||||
|
/^(?:были|был|куплен|куплены|которые|который|которых|сейчас|лежат|лежит)\b/iu.test(candidate)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
function asksForInventorySupplierIdentity(text) {
|
||||||
|
return /(?:^|[\s,.;:!?])(?:у|от)\s+какого\s+поставщика\b/iu.test(String(text ?? ""));
|
||||||
|
}
|
||||||
function extractAccountTokenHeuristic(text) {
|
function extractAccountTokenHeuristic(text) {
|
||||||
const source = String(text ?? "");
|
const source = String(text ?? "");
|
||||||
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
|
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
|
||||||
|
|
@ -820,6 +1016,12 @@ function requiredFiltersByIntent(intent) {
|
||||||
if (intent === "inventory_on_hand_as_of_date") {
|
if (intent === "inventory_on_hand_as_of_date") {
|
||||||
return ["as_of_date"];
|
return ["as_of_date"];
|
||||||
}
|
}
|
||||||
|
if (intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain") {
|
||||||
|
return ["item"];
|
||||||
|
}
|
||||||
if (intent === "payables_confirmed_as_of_date") {
|
if (intent === "payables_confirmed_as_of_date") {
|
||||||
return ["as_of_date"];
|
return ["as_of_date"];
|
||||||
}
|
}
|
||||||
|
|
@ -847,6 +1049,12 @@ function requiredFiltersByIntent(intent) {
|
||||||
}
|
}
|
||||||
function usesAsOfPrimaryWindow(intent) {
|
function usesAsOfPrimaryWindow(intent) {
|
||||||
return (intent === "inventory_on_hand_as_of_date" ||
|
return (intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "open_items_by_counterparty_or_contract" ||
|
intent === "open_items_by_counterparty_or_contract" ||
|
||||||
intent === "list_open_contracts" ||
|
intent === "list_open_contracts" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
|
|
@ -869,7 +1077,7 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
const filters = {
|
const filters = {
|
||||||
sort: "period_desc"
|
sort: "period_desc"
|
||||||
};
|
};
|
||||||
if (!isManagementProfileIntent) {
|
if (!isManagementProfileIntent && !usesRecipeDefaultLimit(intent)) {
|
||||||
if (intent !== "open_contracts_confirmed_as_of_date") {
|
if (intent !== "open_contracts_confirmed_as_of_date") {
|
||||||
filters.limit = 20;
|
filters.limit = 20;
|
||||||
}
|
}
|
||||||
|
|
@ -895,11 +1103,29 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
filters.limit = Math.min(200, Math.trunc(parsed));
|
filters.limit = Math.min(200, Math.trunc(parsed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const counterpartyMatch = text.match(COUNTERPARTY_PATTERN);
|
if (isInventoryItemAnchoredIntent(intent)) {
|
||||||
if (counterpartyMatch) {
|
const itemAnchor = extractInventoryItemAnchor(text);
|
||||||
|
if (itemAnchor) {
|
||||||
|
filters.item = itemAnchor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const warehouseAnchor = extractInventoryWarehouseAnchor(text);
|
||||||
|
if (warehouseAnchor) {
|
||||||
|
filters.warehouse = warehouseAnchor;
|
||||||
|
}
|
||||||
|
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||||
|
const supplierAnchor = asksForInventorySupplierIdentity(text) ? undefined : extractInventorySupplierAnchor(text);
|
||||||
|
if (supplierAnchor) {
|
||||||
|
filters.counterparty = supplierAnchor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent);
|
||||||
|
const counterpartyMatch = allowGenericCounterpartyAnchor ? text.match(COUNTERPARTY_PATTERN) : null;
|
||||||
|
if (counterpartyMatch && !filters.counterparty) {
|
||||||
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
||||||
}
|
}
|
||||||
if (!filters.counterparty &&
|
if (!filters.counterparty &&
|
||||||
|
allowGenericCounterpartyAnchor &&
|
||||||
(intent === "list_documents_by_counterparty" ||
|
(intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_contracts_by_counterparty")) {
|
intent === "list_contracts_by_counterparty")) {
|
||||||
|
|
@ -910,6 +1136,7 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!filters.counterparty &&
|
if (!filters.counterparty &&
|
||||||
|
allowGenericCounterpartyAnchor &&
|
||||||
(intent === "list_documents_by_counterparty" ||
|
(intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_contracts_by_counterparty")) {
|
intent === "list_contracts_by_counterparty")) {
|
||||||
|
|
@ -920,6 +1147,7 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!filters.counterparty &&
|
if (!filters.counterparty &&
|
||||||
|
allowGenericCounterpartyAnchor &&
|
||||||
(intent === "list_documents_by_counterparty" ||
|
(intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_contracts_by_counterparty")) {
|
intent === "list_contracts_by_counterparty")) {
|
||||||
|
|
@ -1028,6 +1256,12 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
if ((intent === "account_balance_snapshot" ||
|
if ((intent === "account_balance_snapshot" ||
|
||||||
intent === "documents_forming_balance" ||
|
intent === "documents_forming_balance" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
intent === "vat_payable_confirmed_as_of_date") &&
|
intent === "vat_payable_confirmed_as_of_date") &&
|
||||||
|
|
@ -1062,6 +1296,10 @@ function extractAddressFilters(userMessage, intent) {
|
||||||
delete filters.contract;
|
delete filters.contract;
|
||||||
warnings.push("contract_anchor_dropped_low_quality");
|
warnings.push("contract_anchor_dropped_low_quality");
|
||||||
}
|
}
|
||||||
|
if (filters.item && isLowQualityInventoryItemAnchorValue(filters.item)) {
|
||||||
|
delete filters.item;
|
||||||
|
warnings.push("item_anchor_dropped_low_quality");
|
||||||
|
}
|
||||||
const required = requiredFiltersByIntent(intent);
|
const required = requiredFiltersByIntent(intent);
|
||||||
const missingRequiredFilters = required.filter((key) => {
|
const missingRequiredFilters = required.filter((key) => {
|
||||||
const value = filters[key];
|
const value = filters[key];
|
||||||
|
|
|
||||||
|
|
@ -1302,20 +1302,34 @@ function hasGenericAddressLookupSignal(text) {
|
||||||
function hasAccountNumberAnchor(text) {
|
function hasAccountNumberAnchor(text) {
|
||||||
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
||||||
}
|
}
|
||||||
|
function hasInventoryAccount41Anchor(text) {
|
||||||
|
return /(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)/iu.test(text);
|
||||||
|
}
|
||||||
|
function hasInventoryAsOfCue(text) {
|
||||||
|
return /(?:сейчас|текущ|на\s+дату|по\s+состоянию|срез|на\s+конец|date|as\s+of|current|now|today)/iu.test(text);
|
||||||
|
}
|
||||||
function hasInventoryOnHandSignal(text) {
|
function hasInventoryOnHandSignal(text) {
|
||||||
|
const hasColloquialStockSnapshotCue = /(?:что|ч[её])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом)(?=$|[\s,.;:!?])/iu.test(text);
|
||||||
|
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
|
||||||
const hasStockLexeme = /(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
|
const hasStockLexeme = /(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
|
||||||
if (!hasStockLexeme) {
|
if (!hasStockLexeme && !hasAccount41Anchor) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (hasInventoryProvenanceSignalV2(text) ||
|
if (hasInventoryProvenanceSignalV2(text) ||
|
||||||
hasInventoryPurchaseDocumentsSignalV2(text) ||
|
hasInventoryPurchaseDocumentsSignalV2(text) ||
|
||||||
hasInventorySaleTraceSignalV2(text)) {
|
hasInventorySaleTraceSignalV2(text) ||
|
||||||
|
hasInventoryAgingSignal(text) ||
|
||||||
|
hasInventoryPurchaseToSaleChainSignal(text)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const hasGoodsLexeme = /(?:товар(?:ы|ов|ом|а|ные)?|номенклатур|материал(?:ы|ов|а|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text);
|
const hasGoodsLexeme = /(?:товар(?:ы|ов|ом|а|ные)?|номенклатур|материал(?:ы|ов|а|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text);
|
||||||
const hasBalanceLexeme = /(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|today|now|current|as\s+of)/iu.test(text);
|
const hasBalanceLexeme = /(?:леж(?:ит|ат)|есть|числ(?:ит(?:ся|сь)|ятся)|остат(?:ок|ки)|срез|на\s+дат|по\s+состоянию|на\s+конец|today|now|current|as\s+of)/iu.test(text);
|
||||||
const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|какой|сколько|show|list|which|what)/iu.test(text);
|
const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|какой|сколько|show|list|which|what)/iu.test(text);
|
||||||
return (hasGoodsLexeme || hasBalanceLexeme) && (hasRequestCue || hasBalanceLexeme);
|
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue) &&
|
||||||
|
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue);
|
||||||
}
|
}
|
||||||
function hasInventoryProvenanceSignal(text) {
|
function hasInventoryProvenanceSignal(text) {
|
||||||
return /(?:поставщик|закупк|происхожд|откуда|когда был куплен|активная закупк|purchase provenance|purchase date|supplier provenance|stock overlap)/iu.test(text);
|
return /(?:поставщик|закупк|происхожд|откуда|когда был куплен|активная закупк|purchase provenance|purchase date|supplier provenance|stock overlap)/iu.test(text);
|
||||||
|
|
@ -1332,6 +1346,11 @@ function hasInventoryProvenanceSignalV2(text) {
|
||||||
const hasPurchaseCue = /(?:куплен(?:ы|а)?|закупк|происхождени|откуда|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+provenance|purchase\s+date)/iu.test(text);
|
const hasPurchaseCue = /(?:куплен(?:ы|а)?|закупк|происхождени|откуда|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+provenance|purchase\s+date)/iu.test(text);
|
||||||
return hasItemCue && hasSupplierCue && hasPurchaseCue;
|
return hasItemCue && hasSupplierCue && hasPurchaseCue;
|
||||||
}
|
}
|
||||||
|
function hasInventoryPurchaseDateSignal(text) {
|
||||||
|
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
||||||
|
const hasPurchaseDateCue = /(?:когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text);
|
||||||
|
return hasItemCue && hasPurchaseDateCue;
|
||||||
|
}
|
||||||
function hasInventoryPurchaseDocumentsSignalV2(text) {
|
function hasInventoryPurchaseDocumentsSignalV2(text) {
|
||||||
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
||||||
const hasPurchaseDocCue = /(?:по\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(text);
|
const hasPurchaseDocCue = /(?:по\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(text);
|
||||||
|
|
@ -1343,19 +1362,30 @@ function hasInventorySaleTraceSignalV2(text) {
|
||||||
return hasItemCue && hasTraceCue;
|
return hasItemCue && hasTraceCue;
|
||||||
}
|
}
|
||||||
function hasInventorySupplierStockOverlapSignal(text) {
|
function hasInventorySupplierStockOverlapSignal(text) {
|
||||||
|
const hasDirectSingleItemSupplierQuestion = /(?:от\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и))|от\s+кого\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и)))/iu.test(text);
|
||||||
|
if (hasDirectSingleItemSupplierQuestion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+поставщика|у\s+поставщика)/iu.test(text);
|
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+поставщика|у\s+поставщика)/iu.test(text);
|
||||||
const hasStockCue = /(?:товар|номенклатур|склад|остат(?:ок|ки)|лежат|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(text);
|
const hasStockCue = /(?:склад|остат(?:ок|ке|ков)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(text);
|
||||||
return hasSupplierCue && hasStockCue;
|
return hasSupplierCue && hasStockCue;
|
||||||
}
|
}
|
||||||
function hasInventoryAgingSignal(text) {
|
function hasInventoryAgingSignal(text) {
|
||||||
return /(?:стар(?:ые|ым|ых)\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|когда\s+куплен|возраст\s+остатк|aged?\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date|very\s+old\s+stock)/iu.test(text);
|
const hasResidueCue = /(?:остат(?:ок|ки)|в\s+остатке|среди\s+текущих\s+остатков|на\s+складе|stock\s+residue|stock\s+balance)/iu.test(text);
|
||||||
|
const hasAgingCue = /(?:стар(?:ые|ым|ых)\s+закупк|стары(?:м|х)\s+закупк(?:ам|и|ах)|относит(?:ся|ся\s+ли)?\s+.*\s+к\s+старым\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+до(?:\s+даты)?|закуплен(?:ы|а)?\s+давно|приобретен\s+давно|задолго\s+до(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test(text);
|
||||||
|
return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+до)/iu.test(text));
|
||||||
}
|
}
|
||||||
function hasInventoryPurchaseToSaleChainSignal(text) {
|
function hasInventoryPurchaseToSaleChainSignal(text) {
|
||||||
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+кого\s+куплен)/iu.test(text);
|
|
||||||
const hasBuyerCue = /(?:покупател|buyer|customer|client|кому\s+был\s+продан)/iu.test(text);
|
|
||||||
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
||||||
const hasPurchaseSaleCue = /(?:куплен(?:ы)?|закупк|позже\s+продан(?:ы)?|продан(?:ы)?|purchase|sale|цепочк[аи]\s+движен)/iu.test(text);
|
const hasChainCue = /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test(text) || text.includes("->");
|
||||||
return (hasSupplierCue && hasBuyerCue && hasItemCue && hasPurchaseSaleCue) || /(?:purchase[\s-]?to[\s-]?sale\s+chain|закупка\s*->\s*склад\s*->\s*продажа)/iu.test(text);
|
return hasItemCue && hasChainCue;
|
||||||
|
}
|
||||||
|
function hasInventorySupplierToBuyerChainSignal(text) {
|
||||||
|
const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text);
|
||||||
|
const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text);
|
||||||
|
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
||||||
|
const hasChainCue = /(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+к\s+покупател|поставщик\s+к\s+клиент|поставщик\s+к\s+товару\s+и\s+покупателю)/iu.test(text) || text.includes("->");
|
||||||
|
return hasSupplierCue && hasBuyerCue && hasItemCue && hasChainCue;
|
||||||
}
|
}
|
||||||
function resolveAddressIntent(userMessage) {
|
function resolveAddressIntent(userMessage) {
|
||||||
const text = String(userMessage ?? "").trim().toLowerCase();
|
const text = String(userMessage ?? "").trim().toLowerCase();
|
||||||
|
|
@ -1459,25 +1489,25 @@ function resolveAddressIntent(userMessage) {
|
||||||
reasons: ["documents_by_account_drilldown_signal_detected"]
|
reasons: ["documents_by_account_drilldown_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasInventoryProvenanceSignalV2(text)) {
|
if (/(?:старым\s+закупк(?:ам|и|ах)|относится\s+ли\s+.*\s+к\s+старым\s+закупк(?:ам|и|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_provenance_for_item",
|
intent: "inventory_aging_by_purchase_date",
|
||||||
confidence: "medium",
|
confidence: "high",
|
||||||
reasons: ["inventory_provenance_signal_detected"]
|
reasons: ["inventory_aging_signal_detected_strong"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasInventoryPurchaseDocumentsSignalV2(text)) {
|
if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_documents_for_item",
|
intent: "inventory_on_hand_as_of_date",
|
||||||
confidence: "medium",
|
confidence: "high",
|
||||||
reasons: ["inventory_purchase_documents_signal_detected"]
|
reasons: ["inventory_account_41_as_of_date_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasInventoryPurchaseToSaleChainSignal(text)) {
|
if (/(?:без\s+понятн(?:ой|ого)\s+привязк(?:и|а)\s+к\s+поставщик|без\s+привязк(?:и|а)\s+к\s+поставщик|unresolved\s+supplier\s+link)/iu.test(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_to_sale_chain",
|
intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||||
confidence: "medium",
|
confidence: "medium",
|
||||||
reasons: ["inventory_purchase_to_sale_chain_signal_detected"]
|
reasons: ["inventory_unresolved_provenance_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasInventorySupplierStockOverlapSignal(text)) {
|
if (hasInventorySupplierStockOverlapSignal(text)) {
|
||||||
|
|
@ -1487,6 +1517,23 @@ function resolveAddressIntent(userMessage) {
|
||||||
reasons: ["inventory_supplier_stock_overlap_signal_detected"]
|
reasons: ["inventory_supplier_stock_overlap_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (/(?:supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|поставщик\s+к\s+покупателю|поставщик\s*->\s*товар\s*->\s*покупател|документально\s+подтвержденн\w+\s+цепочк)/iu.test(text) &&
|
||||||
|
/(?:поставщик|supplier|vendor)/iu.test(text) &&
|
||||||
|
/(?:покупател|buyer|customer|client)/iu.test(text) &&
|
||||||
|
/(?:товар|номенклатур|sku|item|product)/iu.test(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
confidence: "high",
|
||||||
|
reasons: ["inventory_supplier_to_buyer_chain_signal_detected_strong"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hasInventoryPurchaseToSaleChainSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_purchase_to_sale_chain_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
if (hasInventoryAgingSignal(text)) {
|
if (hasInventoryAgingSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_aging_by_purchase_date",
|
intent: "inventory_aging_by_purchase_date",
|
||||||
|
|
@ -1494,6 +1541,27 @@ function resolveAddressIntent(userMessage) {
|
||||||
reasons: ["inventory_aging_signal_detected"]
|
reasons: ["inventory_aging_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (hasInventoryProvenanceSignalV2(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_provenance_for_item",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_provenance_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hasInventoryPurchaseDateSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_provenance_for_item",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_purchase_date_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hasInventoryPurchaseDocumentsSignalV2(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_documents_for_item",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_purchase_documents_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
if (hasInventorySaleTraceSignalV2(text)) {
|
if (hasInventorySaleTraceSignalV2(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_sale_trace_for_item",
|
intent: "inventory_sale_trace_for_item",
|
||||||
|
|
@ -1501,6 +1569,13 @@ function resolveAddressIntent(userMessage) {
|
||||||
reasons: ["inventory_sale_trace_signal_detected"]
|
reasons: ["inventory_sale_trace_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (hasInventorySupplierToBuyerChainSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_supplier_to_buyer_chain_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
if (hasInventoryOnHandSignal(text)) {
|
if (hasInventoryOnHandSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_on_hand_as_of_date",
|
intent: "inventory_on_hand_as_of_date",
|
||||||
|
|
|
||||||
|
|
@ -1058,6 +1058,22 @@ function applyAddressFilters(rows, filters) {
|
||||||
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
|
mismatchReason = "organization_anchor_not_matched_in_materialized_rows";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (filters.item && String(filters.item).trim()) {
|
||||||
|
const needle = String(filters.item);
|
||||||
|
const before = filtered.length;
|
||||||
|
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
|
||||||
|
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
|
||||||
|
mismatchReason = "item_anchor_not_matched_in_materialized_rows";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.warehouse && String(filters.warehouse).trim()) {
|
||||||
|
const needle = String(filters.warehouse);
|
||||||
|
const before = filtered.length;
|
||||||
|
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
|
||||||
|
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
|
||||||
|
mismatchReason = "warehouse_anchor_not_matched_in_materialized_rows";
|
||||||
|
}
|
||||||
|
}
|
||||||
if (filters.document_ref && String(filters.document_ref).trim()) {
|
if (filters.document_ref && String(filters.document_ref).trim()) {
|
||||||
const needle = String(filters.document_ref);
|
const needle = String(filters.document_ref);
|
||||||
const before = filtered.length;
|
const before = filtered.length;
|
||||||
|
|
@ -1124,6 +1140,10 @@ function isConfirmedBalanceIntent(intent) {
|
||||||
return (intent === "account_balance_snapshot" ||
|
return (intent === "account_balance_snapshot" ||
|
||||||
intent === "documents_forming_balance" ||
|
intent === "documents_forming_balance" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
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 === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
|
|
@ -1453,7 +1473,21 @@ function canAutoBroadenPeriodWindow(intent, filters) {
|
||||||
return (intent === "list_documents_by_counterparty" ||
|
return (intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_documents_by_contract" ||
|
intent === "list_documents_by_contract" ||
|
||||||
intent === "bank_operations_by_contract");
|
intent === "bank_operations_by_contract" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date");
|
||||||
|
}
|
||||||
|
function shouldBoostAutoBroadenedLimit(intent) {
|
||||||
|
return (intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date");
|
||||||
}
|
}
|
||||||
function invertSort(sort) {
|
function invertSort(sort) {
|
||||||
return sort === "period_asc" ? "period_desc" : "period_asc";
|
return sort === "period_asc" ? "period_desc" : "period_asc";
|
||||||
|
|
@ -1723,6 +1757,12 @@ function normalizeMissingAnchorLabel(anchor) {
|
||||||
if (anchor === "organization") {
|
if (anchor === "organization") {
|
||||||
return "организация";
|
return "организация";
|
||||||
}
|
}
|
||||||
|
if (anchor === "item") {
|
||||||
|
return "товар";
|
||||||
|
}
|
||||||
|
if (anchor === "warehouse") {
|
||||||
|
return "склад";
|
||||||
|
}
|
||||||
if (anchor === "period" || anchor === "period_from" || anchor === "period_to" || anchor === "as_of_date") {
|
if (anchor === "period" || anchor === "period_from" || anchor === "period_to" || anchor === "as_of_date") {
|
||||||
return "период/дата";
|
return "период/дата";
|
||||||
}
|
}
|
||||||
|
|
@ -1797,6 +1837,18 @@ function buildLimitedOffers(input) {
|
||||||
else if (input.intent === "inventory_on_hand_as_of_date") {
|
else if (input.intent === "inventory_on_hand_as_of_date") {
|
||||||
offers.push("показать подтвержденный срез товаров на складах на дату по остатку счета 41.01");
|
offers.push("показать подтвержденный срез товаров на складах на дату по остатку счета 41.01");
|
||||||
}
|
}
|
||||||
|
else if (input.intent === "inventory_purchase_provenance_for_item") {
|
||||||
|
offers.push("показать подтвержденные закупочные движения по товару на 41.01 с датами и документами");
|
||||||
|
}
|
||||||
|
else if (input.intent === "inventory_purchase_documents_for_item") {
|
||||||
|
offers.push("показать документы поступления по товару на 41.01");
|
||||||
|
}
|
||||||
|
else if (input.intent === "inventory_sale_trace_for_item") {
|
||||||
|
offers.push("показать подтвержденные движения выбытия товара со счета 41.01");
|
||||||
|
}
|
||||||
|
else if (input.intent === "inventory_purchase_to_sale_chain") {
|
||||||
|
offers.push("показать документальную цепочку по товару: поступление на 41.01 и последующее выбытие");
|
||||||
|
}
|
||||||
else if (input.intent === "open_contracts_confirmed_as_of_date") {
|
else if (input.intent === "open_contracts_confirmed_as_of_date") {
|
||||||
offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76");
|
offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76");
|
||||||
}
|
}
|
||||||
|
|
@ -2752,6 +2804,11 @@ class AddressQueryService {
|
||||||
const autoBroadenedFilters = { ...filters.extracted_filters };
|
const autoBroadenedFilters = { ...filters.extracted_filters };
|
||||||
delete autoBroadenedFilters.period_from;
|
delete autoBroadenedFilters.period_from;
|
||||||
delete autoBroadenedFilters.period_to;
|
delete autoBroadenedFilters.period_to;
|
||||||
|
if (shouldBoostAutoBroadenedLimit(intent.intent)) {
|
||||||
|
autoBroadenedFilters.limit = Math.max(ADDRESS_ANCHOR_RECOVERY_LIMIT, typeof autoBroadenedFilters.limit === "number" && Number.isFinite(autoBroadenedFilters.limit)
|
||||||
|
? Math.max(1, Math.trunc(autoBroadenedFilters.limit))
|
||||||
|
: 0);
|
||||||
|
}
|
||||||
const broadenedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, autoBroadenedFilters);
|
const broadenedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, autoBroadenedFilters);
|
||||||
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
||||||
const broadenedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
const broadenedPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,26 @@ const MOVEMENTS_QUERY_TEMPLATE = `
|
||||||
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
|
||||||
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
|
||||||
ИЗ
|
ИЗ
|
||||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
|
||||||
|
__WHERE_CLAUSE__
|
||||||
|
УПОРЯДОЧИТЬ ПО
|
||||||
|
Движения.Период __ORDER_DIRECTION__
|
||||||
|
`;
|
||||||
|
const INVENTORY_MOVEMENTS_QUERY_TEMPLATE = `
|
||||||
|
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||||
|
Движения.Период КАК Период,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
|
||||||
|
Движения.Сумма КАК Сумма,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
|
||||||
|
ИЗ
|
||||||
|
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
|
||||||
__WHERE_CLAUSE__
|
__WHERE_CLAUSE__
|
||||||
УПОРЯДОЧИТЬ ПО
|
УПОРЯДОЧИТЬ ПО
|
||||||
Движения.Период __ORDER_DIRECTION__
|
Движения.Период __ORDER_DIRECTION__
|
||||||
|
|
@ -686,6 +705,72 @@ const BASE_RECIPES = [
|
||||||
account_scope_mode: "strict",
|
account_scope_mode: "strict",
|
||||||
query_template: "inventory_on_hand_as_of_balance_profile"
|
query_template: "inventory_on_hand_as_of_balance_profile"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_purchase_provenance_for_item_v1",
|
||||||
|
intent: "inventory_purchase_provenance_for_item",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements for one inventory item and summarize supplier/date provenance evidence",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 400,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_purchase_provenance_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_purchase_documents_for_item_v1",
|
||||||
|
intent: "inventory_purchase_documents_for_item",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements for one inventory item and list source purchase documents",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 400,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_purchase_documents_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_supplier_stock_overlap_as_of_date_v1",
|
||||||
|
intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements and summarize supplier overlap with current or dated stock slice",
|
||||||
|
required_filters: [],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "counterparty", "limit", "sort"],
|
||||||
|
default_limit: 500,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_supplier_stock_overlap_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_sale_trace_for_item_v1",
|
||||||
|
intent: "inventory_sale_trace_for_item",
|
||||||
|
purpose: "Trace sale-side 41.01 movements for one inventory item and summarize sale evidence",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 400,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_sale_trace_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_purchase_to_sale_chain_v1",
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
purpose: "Trace both purchase and sale side 41.01 movements for one inventory item and summarize the document chain",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 600,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_purchase_to_sale_chain_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_aging_by_purchase_date_v1",
|
||||||
|
intent: "inventory_aging_by_purchase_date",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements and summarize age of stock residue by purchase dates",
|
||||||
|
required_filters: [],
|
||||||
|
optional_filters: ["item", "as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 500,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_aging_by_purchase_date_profile"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
|
recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
|
||||||
intent: "open_contracts_confirmed_as_of_date",
|
intent: "open_contracts_confirmed_as_of_date",
|
||||||
|
|
@ -981,6 +1066,19 @@ function buildAccountPrefixPredicate(fieldPath, prefixes) {
|
||||||
const clauses = normalizedPrefixes.map((prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`);
|
const clauses = normalizedPrefixes.map((prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`);
|
||||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
||||||
}
|
}
|
||||||
|
function buildInventoryMovementQuery(filters, resolvedLimit, side) {
|
||||||
|
const debitPredicate = buildAccountPrefixPredicate("Движения.СчетДт", ["41.01"]);
|
||||||
|
const creditPredicate = buildAccountPrefixPredicate("Движения.СчетКт", ["41.01"]);
|
||||||
|
const inventoryCondition = side === "dt"
|
||||||
|
? debitPredicate
|
||||||
|
: side === "kt"
|
||||||
|
? creditPredicate
|
||||||
|
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
|
||||||
|
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
|
||||||
|
.replace("__LIMIT__", String(resolvedLimit))
|
||||||
|
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition]))
|
||||||
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
|
}
|
||||||
function shouldBoostLimitForAllTimeCounterparty(filters) {
|
function shouldBoostLimitForAllTimeCounterparty(filters) {
|
||||||
const hasAnchor = (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
const hasAnchor = (typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
||||||
(typeof filters.contract === "string" && filters.contract.trim().length > 0);
|
(typeof filters.contract === "string" && filters.contract.trim().length > 0);
|
||||||
|
|
@ -1004,6 +1102,12 @@ function maxLimitForIntent(intent) {
|
||||||
intent === "vat_payable_forecast" ||
|
intent === "vat_payable_forecast" ||
|
||||||
intent === "vat_liability_confirmed_for_tax_period" ||
|
intent === "vat_liability_confirmed_for_tax_period" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "list_contracts_by_counterparty" ||
|
intent === "list_contracts_by_counterparty" ||
|
||||||
intent === "list_documents_by_counterparty" ||
|
intent === "list_documents_by_counterparty" ||
|
||||||
|
|
@ -1150,73 +1254,85 @@ function buildAddressRecipePlan(recipe, filters) {
|
||||||
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
|
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
|
||||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
})()
|
})()
|
||||||
: recipe.query_template === "contracts_by_counterparty_profile"
|
: recipe.query_template === "inventory_purchase_provenance_profile"
|
||||||
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
|
: recipe.query_template === "inventory_purchase_documents_profile"
|
||||||
? (() => {
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
|
||||||
? toDateTimeExpr(filters.as_of_date, true)
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
: null) ??
|
: recipe.query_template === "inventory_sale_trace_profile"
|
||||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
|
||||||
? toDateTimeExpr(filters.period_to, true)
|
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
|
||||||
: null) ??
|
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
|
||||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
|
||||||
? toDateTimeExpr(filters.period_from, true)
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
: null) ??
|
: recipe.query_template === "contracts_by_counterparty_profile"
|
||||||
"ТЕКУЩАЯДАТА()";
|
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||||
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
|
||||||
.replaceAll("__LIMIT__", String(resolvedLimit))
|
? (() => {
|
||||||
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
||||||
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
|
? toDateTimeExpr(filters.as_of_date, true)
|
||||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
: null) ??
|
||||||
})()
|
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
||||||
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
|
? toDateTimeExpr(filters.period_to, true)
|
||||||
? (() => {
|
: null) ??
|
||||||
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||||
? toDateTimeExpr(filters.as_of_date, true)
|
? toDateTimeExpr(filters.period_from, true)
|
||||||
: null) ??
|
: null) ??
|
||||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
"ТЕКУЩАЯДАТА()";
|
||||||
? toDateTimeExpr(filters.period_to, true)
|
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
||||||
: null) ??
|
.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
||||||
? toDateTimeExpr(filters.period_from, true)
|
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
|
||||||
: null) ??
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
"ТЕКУЩАЯДАТА()";
|
})()
|
||||||
return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
|
||||||
.replaceAll("__LIMIT__", String(resolvedLimit))
|
? (() => {
|
||||||
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
||||||
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
|
? toDateTimeExpr(filters.as_of_date, true)
|
||||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
: null) ??
|
||||||
})()
|
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
||||||
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
|
? toDateTimeExpr(filters.period_to, true)
|
||||||
? (() => {
|
: null) ??
|
||||||
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||||
? toDateTimeExpr(filters.as_of_date, true)
|
? toDateTimeExpr(filters.period_from, true)
|
||||||
: null) ??
|
: null) ??
|
||||||
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
"ТЕКУЩАЯДАТА()";
|
||||||
? toDateTimeExpr(filters.period_to, true)
|
return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
||||||
: null) ??
|
.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||||
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
||||||
? toDateTimeExpr(filters.period_from, true)
|
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
|
||||||
: null) ??
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
"ТЕКУЩАЯДАТА()";
|
})()
|
||||||
return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
|
||||||
.replaceAll("__LIMIT__", String(resolvedLimit))
|
? (() => {
|
||||||
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
|
||||||
.replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
|
? toDateTimeExpr(filters.as_of_date, true)
|
||||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
: null) ??
|
||||||
})()
|
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
|
||||||
: MOVEMENTS_QUERY_TEMPLATE
|
? toDateTimeExpr(filters.period_to, true)
|
||||||
.replace("__LIMIT__", String(resolvedLimit))
|
: null) ??
|
||||||
.replace("__WHERE_CLAUSE__", (() => {
|
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
|
||||||
const extraConditions = [];
|
? toDateTimeExpr(filters.period_from, true)
|
||||||
const accountCondition = buildMovementAccountCondition(filters);
|
: null) ??
|
||||||
if (accountCondition) {
|
"ТЕКУЩАЯДАТА()";
|
||||||
extraConditions.push(accountCondition);
|
return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
|
||||||
}
|
.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||||
return buildWhereClause(filters, "Движения.Период", extraConditions);
|
.replaceAll("__AS_OF_EXPR__", asOfExpr)
|
||||||
})())
|
.replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
|
||||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
|
})()
|
||||||
|
: MOVEMENTS_QUERY_TEMPLATE
|
||||||
|
.replace("__LIMIT__", String(resolvedLimit))
|
||||||
|
.replace("__WHERE_CLAUSE__", (() => {
|
||||||
|
const extraConditions = [];
|
||||||
|
const accountCondition = buildMovementAccountCondition(filters);
|
||||||
|
if (accountCondition) {
|
||||||
|
extraConditions.push(accountCondition);
|
||||||
|
}
|
||||||
|
return buildWhereClause(filters, "Движения.Период", extraConditions);
|
||||||
|
})())
|
||||||
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
return {
|
return {
|
||||||
recipe,
|
recipe,
|
||||||
query,
|
query,
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,130 @@ function buildInventoryOnHandAggregate(rows, asOfDate) {
|
||||||
return left.item.localeCompare(right.item, "ru");
|
return left.item.localeCompare(right.item, "ru");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function inventoryTraceDateLabel(value) {
|
||||||
|
return value ? formatDateRu(value) : "дата не указана";
|
||||||
|
}
|
||||||
|
function hasInventoryAccountPrefix(value, prefix) {
|
||||||
|
const normalized = String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.replace(",", ".");
|
||||||
|
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
|
||||||
|
}
|
||||||
|
function isInventoryPurchaseMovement(row) {
|
||||||
|
return hasInventoryAccountPrefix(row.account_dt, "41.01");
|
||||||
|
}
|
||||||
|
function isInventorySaleMovement(row) {
|
||||||
|
return hasInventoryAccountPrefix(row.account_kt, "41.01");
|
||||||
|
}
|
||||||
|
function looksLikeInventoryTraceDocumentToken(value) {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (/(?:№|contract|invoice|payment|order|накладн|акт|счет|сч[её]т|поступлен|реализац|договор)/iu.test(normalized) ||
|
||||||
|
/(?:[a-zа-яё].*\d|\d.*[a-zа-яё])/iu.test(normalized));
|
||||||
|
}
|
||||||
|
function looksLikeInventoryPartyToken(value) {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!normalized || normalized.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/(?:склад|warehouse)/iu.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (looksLikeInventoryTraceDocumentToken(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/(?:ооо|ао|пао|зао|ип|llc|ltd|inc|corp|компани|организац|департамент|комитет|министерств|служб|управлен|торговый\s+дом)/iu.test(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const letterChars = (normalized.match(/[A-Za-zА-Яа-яЁё]/g) ?? []).length;
|
||||||
|
if (letterChars < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const words = normalized.split(/\s+/u).filter(Boolean);
|
||||||
|
if (words.length >= 2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalized === normalized.toUpperCase() && normalized.length >= 4;
|
||||||
|
}
|
||||||
|
function extractInventoryCounterpartyCandidates(row) {
|
||||||
|
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
|
||||||
|
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
|
||||||
|
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
|
||||||
|
const candidates = [];
|
||||||
|
for (const token of row.analytics) {
|
||||||
|
const normalized = String(token ?? "").trim();
|
||||||
|
if (!normalized || !looksLikeInventoryPartyToken(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const comparable = normalizeEntityToken(normalized);
|
||||||
|
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(normalized);
|
||||||
|
}
|
||||||
|
return uniqueStrings(candidates);
|
||||||
|
}
|
||||||
|
function summarizeInventoryTraceRows(rows) {
|
||||||
|
const items = uniqueStrings(rows
|
||||||
|
.map((row) => extractInventoryItemName(row))
|
||||||
|
.filter((item) => Boolean(item)));
|
||||||
|
const warehouses = uniqueStrings(rows
|
||||||
|
.map((row) => extractInventoryWarehouseName(row))
|
||||||
|
.filter((item) => Boolean(item)));
|
||||||
|
const organizations = uniqueStrings(rows
|
||||||
|
.map((row) => extractInventoryOrganizationName(row))
|
||||||
|
.filter((item) => Boolean(item)));
|
||||||
|
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row)));
|
||||||
|
const documents = uniqueStrings(rows
|
||||||
|
.map((row) => String(row.registrator ?? "").trim())
|
||||||
|
.filter((item) => item.length > 0 && item !== "(без названия)"));
|
||||||
|
const periods = rows
|
||||||
|
.map((row) => String(row.period ?? "").trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
.sort((left, right) => left.localeCompare(right, "ru"));
|
||||||
|
const totalAmount = rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||||
|
return {
|
||||||
|
item: items[0] ?? null,
|
||||||
|
warehouses,
|
||||||
|
organizations,
|
||||||
|
counterparties,
|
||||||
|
documents,
|
||||||
|
firstPeriod: periods[0] ?? null,
|
||||||
|
lastPeriod: periods.length > 0 ? periods[periods.length - 1] : null,
|
||||||
|
totalAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function formatInventoryTraceRows(rows, limit = 10) {
|
||||||
|
return rows.slice(0, limit).map((row, index) => {
|
||||||
|
const parties = extractInventoryCounterpartyCandidates(row);
|
||||||
|
const warehouse = extractInventoryWarehouseName(row);
|
||||||
|
const organization = extractInventoryOrganizationName(row);
|
||||||
|
const amount = typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
|
||||||
|
const parts = [
|
||||||
|
`${index + 1}. ${row.registrator}`,
|
||||||
|
`дата: ${inventoryTraceDateLabel(row.period)}`,
|
||||||
|
`сумма: ${amount}`
|
||||||
|
];
|
||||||
|
if (warehouse) {
|
||||||
|
parts.push(`склад: ${warehouse}`);
|
||||||
|
}
|
||||||
|
if (organization) {
|
||||||
|
parts.push(`организация: ${organization}`);
|
||||||
|
}
|
||||||
|
if (parties.length > 0) {
|
||||||
|
parts.push(`контрагент: ${parties[0]}`);
|
||||||
|
}
|
||||||
|
return parts.join(" | ");
|
||||||
|
});
|
||||||
|
}
|
||||||
function liabilityCategoryLabel(category) {
|
function liabilityCategoryLabel(category) {
|
||||||
if (category === "supplier_or_contractor") {
|
if (category === "supplier_or_contractor") {
|
||||||
return "поставщики/подрядчики";
|
return "поставщики/подрядчики";
|
||||||
|
|
@ -2873,6 +2997,257 @@ function composeFactualReply(intent, rows, options = {}) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (intent === "inventory_purchase_documents_for_item") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const lines = [
|
||||||
|
`Собран подтвержденный список документов поступления по товару ${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("; ")}.`);
|
||||||
|
}
|
||||||
|
lines.push("", "Блок 3. Документы");
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push(...formatInventoryTraceRows(purchaseRows, 12));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: purchaseRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_purchase_provenance_for_item") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const lines = [
|
||||||
|
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
"- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.",
|
||||||
|
"- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Операций поступления: ${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("; ")}.`);
|
||||||
|
}
|
||||||
|
else if (purchaseRows.length > 0) {
|
||||||
|
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
|
||||||
|
}
|
||||||
|
if (summary.documents.length > 0) {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0);
|
||||||
|
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
|
||||||
|
const lines = [
|
||||||
|
`Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
`- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`,
|
||||||
|
"- Важно: без партионности этот контур показывает документально наблюдаемые supplier candidates, но не подменяет собой лот-level атрибуцию текущего остатка.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
|
||||||
|
];
|
||||||
|
if (summary.counterparties.length > 0) {
|
||||||
|
lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`);
|
||||||
|
}
|
||||||
|
else if (purchaseRows.length > 0) {
|
||||||
|
lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза.");
|
||||||
|
}
|
||||||
|
if (unresolvedRows.length > 0) {
|
||||||
|
lines.push(`- Операций без явно материализованного поставщика: ${formatNumberWithDots(unresolvedRows.length)}.`);
|
||||||
|
}
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 10));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_aging_by_purchase_date") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const firstPeriodTime = summary.firstPeriod ? Date.parse(summary.firstPeriod) : Number.NaN;
|
||||||
|
const asOfTime = Date.parse(`${asOfDate}T23:59:59.000Z`);
|
||||||
|
const ageDays = Number.isFinite(firstPeriodTime) && Number.isFinite(asOfTime) && firstPeriodTime <= asOfTime
|
||||||
|
? Math.floor((asOfTime - firstPeriodTime) / 86_400_000)
|
||||||
|
: null;
|
||||||
|
const itemLabel = summary.item ?? "выбранному складскому остатку";
|
||||||
|
const lines = [
|
||||||
|
`Собран exact-срез возраста закупочного следа по ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
"- Контур: показаны подтвержденные закупочные движения на 41.01 и их временной разброс.",
|
||||||
|
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый диапазон закупок.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
|
||||||
|
];
|
||||||
|
if (ageDays !== null) {
|
||||||
|
lines.push(`- Между самой ранней найденной закупкой и датой среза прошло ${formatNumberWithDots(ageDays)} дн.`);
|
||||||
|
}
|
||||||
|
if (summary.counterparties.length > 0) {
|
||||||
|
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
|
||||||
|
}
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_sale_trace_for_item") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(saleRows);
|
||||||
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const lines = [
|
||||||
|
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
"- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Операций выбытия: ${formatNumberWithDots(saleRows.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("; ")}.`);
|
||||||
|
}
|
||||||
|
else if (saleRows.length > 0) {
|
||||||
|
lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре.");
|
||||||
|
}
|
||||||
|
lines.push("", "Блок 3. Документы выбытия");
|
||||||
|
if (saleRows.length > 0) {
|
||||||
|
lines.push(...formatInventoryTraceRows(saleRows, 12));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: saleRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: saleRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
|
||||||
|
balance_confirmed: saleRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_purchase_to_sale_chain") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
|
||||||
|
const purchaseSummary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const saleSummary = summarizeInventoryTraceRows(saleRows);
|
||||||
|
const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен";
|
||||||
|
const lines = [
|
||||||
|
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`,
|
||||||
|
`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`
|
||||||
|
];
|
||||||
|
if (purchaseRows.length > 0 && saleRows.length > 0) {
|
||||||
|
lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие.");
|
||||||
|
}
|
||||||
|
else if (purchaseRows.length > 0) {
|
||||||
|
lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено.");
|
||||||
|
}
|
||||||
|
else if (saleRows.length > 0) {
|
||||||
|
lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку.");
|
||||||
|
}
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push("", "Блок 2. Закупка", `- Первая дата: ${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));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 && saleRows.length > 0 ? "strong" : purchaseRows.length > 0 || saleRows.length > 0 ? "medium" : "weak",
|
||||||
|
balance_confirmed: purchaseRows.length > 0 || saleRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
if (intent === "open_contracts_confirmed_as_of_date") {
|
if (intent === "open_contracts_confirmed_as_of_date") {
|
||||||
const asOfDate = resolvePayablesAsOfDate(options);
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
|
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||||
intent === "list_open_contracts" ||
|
intent === "list_open_contracts" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
intent === "vat_payable_confirmed_as_of_date") {
|
intent === "vat_payable_confirmed_as_of_date") {
|
||||||
|
|
@ -420,6 +426,19 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||||
reasons.push("as_of_date_from_followup_context");
|
reasons.push("as_of_date_from_followup_context");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!sameDateRequested &&
|
||||||
|
(intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") &&
|
||||||
|
!hasExplicitPeriodLiteral(userMessage) &&
|
||||||
|
!hasExplicitCurrentDateHint(userMessage)) {
|
||||||
|
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||||
|
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||||
|
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||||
|
merged.as_of_date = inheritedAsOfDate;
|
||||||
|
reasons.push("as_of_date_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!sameDateRequested &&
|
if (!sameDateRequested &&
|
||||||
hasFollowupSignalForConfirmed &&
|
hasFollowupSignalForConfirmed &&
|
||||||
!hasExplicitPeriodLiteral(userMessage) &&
|
!hasExplicitPeriodLiteral(userMessage) &&
|
||||||
|
|
@ -460,6 +479,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||||
intent === "documents_forming_balance" ||
|
intent === "documents_forming_balance" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
intent === "vat_payable_confirmed_as_of_date";
|
intent === "vat_payable_confirmed_as_of_date";
|
||||||
|
|
|
||||||
|
|
@ -3856,12 +3856,27 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
|
||||||
"list_documents_by_counterparty",
|
"list_documents_by_counterparty",
|
||||||
"bank_operations_by_counterparty",
|
"bank_operations_by_counterparty",
|
||||||
"list_contracts_by_counterparty",
|
"list_contracts_by_counterparty",
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_purchase_documents_for_item",
|
||||||
|
"inventory_supplier_stock_overlap_as_of_date",
|
||||||
|
"inventory_sale_trace_for_item",
|
||||||
|
"inventory_purchase_to_sale_chain",
|
||||||
|
"inventory_aging_by_purchase_date",
|
||||||
"contract_usage_overview",
|
"contract_usage_overview",
|
||||||
"contract_usage_and_value",
|
"contract_usage_and_value",
|
||||||
"vat_payable_forecast",
|
"vat_payable_forecast",
|
||||||
"vat_liability_confirmed_for_tax_period",
|
"vat_liability_confirmed_for_tax_period",
|
||||||
"vat_payable_confirmed_as_of_date"
|
"vat_payable_confirmed_as_of_date"
|
||||||
]);
|
]);
|
||||||
|
const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_purchase_documents_for_item",
|
||||||
|
"inventory_sale_trace_for_item",
|
||||||
|
"inventory_purchase_to_sale_chain"
|
||||||
|
]);
|
||||||
|
function shouldBypassStrictDeepInvestigationCueForAddressIntent(intent) {
|
||||||
|
return Boolean(intent && ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS.has(intent));
|
||||||
|
}
|
||||||
function resolveAssistantOrchestrationDecision(input) {
|
function resolveAssistantOrchestrationDecision(input) {
|
||||||
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
|
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
|
||||||
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
|
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
|
||||||
|
|
@ -3915,11 +3930,13 @@ function resolveAssistantOrchestrationDecision(input) {
|
||||||
hasStrictDeepInvestigationCue(repairedRawUserMessage) ||
|
hasStrictDeepInvestigationCue(repairedRawUserMessage) ||
|
||||||
hasStrictDeepInvestigationCue(effectiveAddressUserMessage) ||
|
hasStrictDeepInvestigationCue(effectiveAddressUserMessage) ||
|
||||||
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
|
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
|
||||||
|
const strictDeepInvestigationBypassAllowed = shouldBypassStrictDeepInvestigationCueForAddressIntent(intentResolution.intent) ||
|
||||||
|
shouldBypassStrictDeepInvestigationCueForAddressIntent(llmContractIntent);
|
||||||
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
|
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
|
||||||
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
||||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||||
openContractsAddressSignal) &&
|
openContractsAddressSignal) &&
|
||||||
!strictDeepInvestigationCueDetected;
|
(!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed);
|
||||||
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
|
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
|
||||||
hasStrongDataIntentSignal(repairedRawUserMessage) ||
|
hasStrongDataIntentSignal(repairedRawUserMessage) ||
|
||||||
hasStrongDataIntentSignal(effectiveAddressUserMessage) ||
|
hasStrongDataIntentSignal(effectiveAddressUserMessage) ||
|
||||||
|
|
@ -4065,7 +4082,7 @@ function resolveAssistantOrchestrationDecision(input) {
|
||||||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
|
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
|
||||||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
|
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
|
||||||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
|
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
|
||||||
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
|
const supportedAddressIntentDetected = (!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed) &&
|
||||||
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
||||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||||
openContractsAddressSignal);
|
openContractsAddressSignal);
|
||||||
|
|
@ -4217,6 +4234,7 @@ function resolveAssistantOrchestrationDecision(input) {
|
||||||
semantic_reason_codes: semanticReasonCodes,
|
semantic_reason_codes: semanticReasonCodes,
|
||||||
semantic_route_arbitration: {
|
semantic_route_arbitration: {
|
||||||
supported_address_intent_detected: supportedAddressIntentDetected,
|
supported_address_intent_detected: supportedAddressIntentDetected,
|
||||||
|
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
||||||
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
||||||
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
||||||
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
||||||
|
|
|
||||||
|
|
@ -165,14 +165,34 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
|
||||||
if (
|
if (
|
||||||
intent === "inventory_purchase_provenance_for_item" ||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
intent === "inventory_purchase_documents_for_item" ||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
|
||||||
intent === "inventory_sale_trace_for_item" ||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
intent === "inventory_purchase_to_sale_chain" ||
|
intent === "inventory_purchase_to_sale_chain"
|
||||||
intent === "inventory_aging_by_purchase_date"
|
|
||||||
) {
|
) {
|
||||||
|
if (intent === "inventory_purchase_to_sale_chain") {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
reason: "inventory_purchase_to_sale_chain_route_enabled"
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||||
reason: "inventory_provenance_route_not_implemented"
|
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
|
||||||
|
? "inventory_trace_route_enabled"
|
||||||
|
: "inventory_trace_route_disabled_by_flag"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||||
|
return {
|
||||||
|
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
|
||||||
|
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
|
||||||
|
? "inventory_supplier_stock_overlap_route_enabled"
|
||||||
|
: "inventory_supplier_stock_overlap_route_disabled_by_flag"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (intent === "inventory_aging_by_purchase_date") {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
reason: "inventory_aging_route_enabled"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (intent === "list_payables_counterparties") {
|
if (intent === "list_payables_counterparties") {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AddressFilterExtraction, AddressFilterSet, AddressIntent } from "../types/addressQuery";
|
import type { AddressFilterExtraction, AddressFilterSet, AddressIntent } from "../types/addressQuery";
|
||||||
import iconv from "iconv-lite";
|
import iconv from "iconv-lite";
|
||||||
|
|
||||||
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
|
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
|
||||||
|
|
@ -72,6 +72,10 @@ const COUNTERPARTY_TOKEN_NOISE = new Set([
|
||||||
"могу",
|
"могу",
|
||||||
"можем",
|
"можем",
|
||||||
"нет",
|
"нет",
|
||||||
|
"был",
|
||||||
|
"были",
|
||||||
|
"куплен",
|
||||||
|
"куплены",
|
||||||
"покажи",
|
"покажи",
|
||||||
"показать",
|
"показать",
|
||||||
"скажи",
|
"скажи",
|
||||||
|
|
@ -906,6 +910,243 @@ function hasExplicitAccountCue(text: string): boolean {
|
||||||
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
return /(?:сч[её]т|счет|account|acct)/iu.test(String(text ?? ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInventoryTraceIntent(intent: AddressIntent): boolean {
|
||||||
|
return (
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInventoryItemAnchoredIntent(intent: AddressIntent): boolean {
|
||||||
|
return (
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesRecipeDefaultLimit(intent: AddressIntent): boolean {
|
||||||
|
return (
|
||||||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
|
||||||
|
const value = cleanupAnchorValue(rawValue)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е");
|
||||||
|
if (!value || value.length < 3) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/^(?:товар(?:ы|а|у|ом)?|номенклатура|позиция|остаток|остатки|склад|складе|складу|поставщик|покупатель|документ|документы)$/iu.test(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const lowQualityTokens = new Set([
|
||||||
|
"сейчас",
|
||||||
|
"лежат",
|
||||||
|
"лежит",
|
||||||
|
"лежали",
|
||||||
|
"куплен",
|
||||||
|
"куплена",
|
||||||
|
"куплены",
|
||||||
|
"продан",
|
||||||
|
"продана",
|
||||||
|
"проданы",
|
||||||
|
"документам",
|
||||||
|
"документами",
|
||||||
|
"документы",
|
||||||
|
"поставщика",
|
||||||
|
"поставщику",
|
||||||
|
"покупателю",
|
||||||
|
"остаток",
|
||||||
|
"остатки",
|
||||||
|
"склад",
|
||||||
|
"складе",
|
||||||
|
"складу"
|
||||||
|
]);
|
||||||
|
const meaningfulTokens = value
|
||||||
|
.split(/[^a-zа-я0-9]+/iu)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((token) => !lowQualityTokens.has(token));
|
||||||
|
return meaningfulTokens.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupInventoryItemAnchorValue(value: string): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/^['"«»“”„`’‘]+|['"«»“”„`’‘]+$/gu, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimInventoryItemAnchorTail(rawValue: string): string {
|
||||||
|
let value = cleanupInventoryItemAnchorValue(rawValue);
|
||||||
|
const tailPatterns = [
|
||||||
|
/\s+для\s+остатка(?:\s+на\s+складе.*)?$/iu,
|
||||||
|
/\s+из\s+текущ(?:его|их)\s+остат(?:ка|ков).*$/iu,
|
||||||
|
/\s+из\s+остат(?:ка|ков).*$/iu,
|
||||||
|
/\s+в\s+остатке.*$/iu,
|
||||||
|
/\s+на\s+складе.*$/iu,
|
||||||
|
/\s*:\s*закупк.*$/iu
|
||||||
|
];
|
||||||
|
for (const pattern of tailPatterns) {
|
||||||
|
value = value.replace(pattern, "");
|
||||||
|
}
|
||||||
|
return cleanupInventoryItemAnchorValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSelectedObjectQuotedValue(text: string): string | undefined {
|
||||||
|
const patterns = [
|
||||||
|
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
|
||||||
|
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*:\s*[«"]([^»"\r\n]+)[»"]/iu
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = String(text ?? "").match(pattern);
|
||||||
|
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
|
||||||
|
if (candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInventoryItemFromSelectedObject(text: string): string | undefined {
|
||||||
|
const selectedObject = extractSelectedObjectQuotedValue(text);
|
||||||
|
if (!selectedObject) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const firstLine = selectedObject
|
||||||
|
.replace(/\r\n?/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => cleanupInventoryItemAnchorValue(line))
|
||||||
|
.find(Boolean);
|
||||||
|
const withoutNumberPrefix = cleanupInventoryItemAnchorValue(String(firstLine ?? "").replace(/^\d+\.\s*/, ""));
|
||||||
|
const primarySegment = cleanupInventoryItemAnchorValue(withoutNumberPrefix.split("|")[0] ?? withoutNumberPrefix);
|
||||||
|
const candidate = cleanupInventoryItemAnchorValue(primarySegment);
|
||||||
|
if (!candidate || isLowQualityInventoryItemAnchorValue(candidate)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInventoryItemAnchor(text: string): string | undefined {
|
||||||
|
const selectedObjectItem = extractInventoryItemFromSelectedObject(text);
|
||||||
|
if (selectedObjectItem) {
|
||||||
|
return selectedObjectItem;
|
||||||
|
}
|
||||||
|
const patterns = [
|
||||||
|
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
|
||||||
|
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = String(text ?? "").match(pattern);
|
||||||
|
const candidate = trimInventoryItemArrowSuffix(trimInventoryItemChainTail(trimInventoryItemAnchorTail(String(match?.[1] ?? ""))));
|
||||||
|
if (!candidate || isLowQualityInventoryItemAnchorValue(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimInventoryItemChainTail(rawValue: string): string {
|
||||||
|
return cleanupInventoryItemAnchorValue(
|
||||||
|
cleanupInventoryItemAnchorValue(rawValue)
|
||||||
|
.replace(/\s*(?:->|=>|→)\s*(?:покупател\w*|buyer\b).*$/iu, "")
|
||||||
|
.replace(/\s*(?:->|=>|→)\s*(?:поставщик\w*|supplier\b).*$/iu, "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimInventoryItemArrowSuffix(rawValue: string): string {
|
||||||
|
return cleanupAnchorValue(cleanupAnchorValue(rawValue).replace(/\s*(?:->|=>|→).+$/u, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemporalWarehousePhrase(candidate: string): boolean {
|
||||||
|
const normalized = cleanupAnchorValue(candidate)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е")
|
||||||
|
.trim();
|
||||||
|
return /^(?:в|на)\s+(?:январ(?:е|ь)|феврал(?:е|ь)|март(?:е)?|апрел(?:е|ь)|ма(?:й|е)|июн(?:е|ь)|июл(?:е|ь)|август(?:е)?|сентябр(?:е|ь)|октябр(?:е|ь)|ноябр(?:е|ь)|декабр(?:е|ь))(?:\s+\d{4}(?:\s+г(?:\.|ода)?)?)?$/iu.test(
|
||||||
|
normalized
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInventoryWarehouseAnchor(text: string): string | undefined {
|
||||||
|
const patterns = [
|
||||||
|
/(?:на|по)\s+склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu,
|
||||||
|
/склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = String(text ?? "").match(pattern);
|
||||||
|
const candidate = cleanupAnchorValue(
|
||||||
|
cleanupAnchorValue(String(match?.[1] ?? "")).replace(
|
||||||
|
/\s+(?:организац\w*|компани\w*|котор(?:ый|ые|ых)|на\s+дату|по\s+состоянию\s+на\s+дату).*$/iu,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const normalizedCandidate = candidate
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/g, "е")
|
||||||
|
.trim();
|
||||||
|
if (
|
||||||
|
!candidate ||
|
||||||
|
candidate.includes("->") ||
|
||||||
|
candidate.includes("=>") ||
|
||||||
|
normalizedCandidate.startsWith("по состоянию") ||
|
||||||
|
isTemporalWarehousePhrase(candidate) ||
|
||||||
|
/^(?:сейчас|на|дату|дате|остаток|остатки)$/iu.test(candidate)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInventorySupplierAnchor(text: string): string | undefined {
|
||||||
|
const match = String(text ?? "").match(
|
||||||
|
/(?:от\s+поставщика|у\s+поставщика|поставщика|поставщику)\s+([^\r\n?]+?)(?=$|[?])/iu
|
||||||
|
);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const candidate = cleanupAnchorValue(
|
||||||
|
cleanupAnchorValue(String(match[1])).replace(
|
||||||
|
/\s+(?:сейчас|на\s+склад(?:е|у|ом)?|на\s+дату|по\s+состоянию\s+на\s+дату|котор(?:ый|ые|ых)|куплен(?:ы|а|о)?|были|был|лежат|лежит|еще|ещё|организац\w*|компани\w*).*$/iu,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!candidate ||
|
||||||
|
isLowQualityCounterpartyAnchorValue(candidate) ||
|
||||||
|
/^(?:были|был|куплен|куплены|которые|который|которых|сейчас|лежат|лежит)\b/iu.test(candidate)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asksForInventorySupplierIdentity(text: string): boolean {
|
||||||
|
return /(?:^|[\s,.;:!?])(?:у|от)\s+какого\s+поставщика\b/iu.test(String(text ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
function extractAccountTokenHeuristic(text: string): string | undefined {
|
function extractAccountTokenHeuristic(text: string): string | undefined {
|
||||||
const source = String(text ?? "");
|
const source = String(text ?? "");
|
||||||
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
|
const dotted = source.match(/(?:^|[^\d])(\d{2}[.,]\d{1,2})(?!\d)/u);
|
||||||
|
|
@ -932,6 +1173,14 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
|
||||||
if (intent === "inventory_on_hand_as_of_date") {
|
if (intent === "inventory_on_hand_as_of_date") {
|
||||||
return ["as_of_date"];
|
return ["as_of_date"];
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain"
|
||||||
|
) {
|
||||||
|
return ["item"];
|
||||||
|
}
|
||||||
if (intent === "payables_confirmed_as_of_date") {
|
if (intent === "payables_confirmed_as_of_date") {
|
||||||
return ["as_of_date"];
|
return ["as_of_date"];
|
||||||
}
|
}
|
||||||
|
|
@ -963,6 +1212,12 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
|
||||||
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
|
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
|
||||||
return (
|
return (
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "open_items_by_counterparty_or_contract" ||
|
intent === "open_items_by_counterparty_or_contract" ||
|
||||||
intent === "list_open_contracts" ||
|
intent === "list_open_contracts" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
|
|
@ -988,7 +1243,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
const filters: AddressFilterSet = {
|
const filters: AddressFilterSet = {
|
||||||
sort: "period_desc"
|
sort: "period_desc"
|
||||||
};
|
};
|
||||||
if (!isManagementProfileIntent) {
|
if (!isManagementProfileIntent && !usesRecipeDefaultLimit(intent)) {
|
||||||
if (intent !== "open_contracts_confirmed_as_of_date") {
|
if (intent !== "open_contracts_confirmed_as_of_date") {
|
||||||
filters.limit = 20;
|
filters.limit = 20;
|
||||||
}
|
}
|
||||||
|
|
@ -1017,12 +1272,33 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const counterpartyMatch = text.match(COUNTERPARTY_PATTERN);
|
if (isInventoryItemAnchoredIntent(intent)) {
|
||||||
if (counterpartyMatch) {
|
const itemAnchor = extractInventoryItemAnchor(text);
|
||||||
|
if (itemAnchor) {
|
||||||
|
filters.item = itemAnchor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const warehouseAnchor = extractInventoryWarehouseAnchor(text);
|
||||||
|
if (warehouseAnchor) {
|
||||||
|
filters.warehouse = warehouseAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||||
|
const supplierAnchor = asksForInventorySupplierIdentity(text) ? undefined : extractInventorySupplierAnchor(text);
|
||||||
|
if (supplierAnchor) {
|
||||||
|
filters.counterparty = supplierAnchor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent);
|
||||||
|
const counterpartyMatch = allowGenericCounterpartyAnchor ? text.match(COUNTERPARTY_PATTERN) : null;
|
||||||
|
if (counterpartyMatch && !filters.counterparty) {
|
||||||
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!filters.counterparty &&
|
!filters.counterparty &&
|
||||||
|
allowGenericCounterpartyAnchor &&
|
||||||
(intent === "list_documents_by_counterparty" ||
|
(intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_contracts_by_counterparty")
|
intent === "list_contracts_by_counterparty")
|
||||||
|
|
@ -1035,6 +1311,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!filters.counterparty &&
|
!filters.counterparty &&
|
||||||
|
allowGenericCounterpartyAnchor &&
|
||||||
(intent === "list_documents_by_counterparty" ||
|
(intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_contracts_by_counterparty")
|
intent === "list_contracts_by_counterparty")
|
||||||
|
|
@ -1047,6 +1324,7 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!filters.counterparty &&
|
!filters.counterparty &&
|
||||||
|
allowGenericCounterpartyAnchor &&
|
||||||
(intent === "list_documents_by_counterparty" ||
|
(intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_contracts_by_counterparty")
|
intent === "list_contracts_by_counterparty")
|
||||||
|
|
@ -1170,6 +1448,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
(intent === "account_balance_snapshot" ||
|
(intent === "account_balance_snapshot" ||
|
||||||
intent === "documents_forming_balance" ||
|
intent === "documents_forming_balance" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
intent === "vat_payable_confirmed_as_of_date") &&
|
intent === "vat_payable_confirmed_as_of_date") &&
|
||||||
|
|
@ -1209,6 +1493,10 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
delete filters.contract;
|
delete filters.contract;
|
||||||
warnings.push("contract_anchor_dropped_low_quality");
|
warnings.push("contract_anchor_dropped_low_quality");
|
||||||
}
|
}
|
||||||
|
if (filters.item && isLowQualityInventoryItemAnchorValue(filters.item)) {
|
||||||
|
delete filters.item;
|
||||||
|
warnings.push("item_anchor_dropped_low_quality");
|
||||||
|
}
|
||||||
|
|
||||||
const required = requiredFiltersByIntent(intent);
|
const required = requiredFiltersByIntent(intent);
|
||||||
const missingRequiredFilters = required.filter((key) => {
|
const missingRequiredFilters = required.filter((key) => {
|
||||||
|
|
@ -1222,4 +1510,3 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||||
warnings
|
warnings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1542,16 +1542,32 @@ function hasAccountNumberAnchor(text: string): boolean {
|
||||||
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
return /(?:account|сч[её]т|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasInventoryAccount41Anchor(text: string): boolean {
|
||||||
|
return /(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:сч[её]т(?:а|е|у)?|счет(?:а|е|у)?)/iu.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInventoryAsOfCue(text: string): boolean {
|
||||||
|
return /(?:сейчас|текущ|на\s+дату|по\s+состоянию|срез|на\s+конец|date|as\s+of|current|now|today)/iu.test(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasInventoryOnHandSignal(text: string): boolean {
|
function hasInventoryOnHandSignal(text: string): boolean {
|
||||||
|
const hasColloquialStockSnapshotCue = /(?:что|ч[её])\s+(?:у\s+нас\s+)?на\s+склад(?:е|у|ом)(?=$|[\s,.;:!?])/iu.test(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
const hasAccount41Anchor = hasInventoryAccount41Anchor(text);
|
||||||
const hasStockLexeme =
|
const hasStockLexeme =
|
||||||
/(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
|
/(?:склад(?:е|у|ом|ы|ов)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text);
|
||||||
if (!hasStockLexeme) {
|
if (!hasStockLexeme && !hasAccount41Anchor) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
hasInventoryProvenanceSignalV2(text) ||
|
hasInventoryProvenanceSignalV2(text) ||
|
||||||
hasInventoryPurchaseDocumentsSignalV2(text) ||
|
hasInventoryPurchaseDocumentsSignalV2(text) ||
|
||||||
hasInventorySaleTraceSignalV2(text)
|
hasInventorySaleTraceSignalV2(text) ||
|
||||||
|
hasInventoryAgingSignal(text) ||
|
||||||
|
hasInventoryPurchaseToSaleChainSignal(text)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -1562,7 +1578,11 @@ function hasInventoryOnHandSignal(text: string): boolean {
|
||||||
text
|
text
|
||||||
);
|
);
|
||||||
const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|какой|сколько|show|list|which|what)/iu.test(text);
|
const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|какой|сколько|show|list|which|what)/iu.test(text);
|
||||||
return (hasGoodsLexeme || hasBalanceLexeme) && (hasRequestCue || hasBalanceLexeme);
|
if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue) &&
|
||||||
|
(hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasInventoryProvenanceSignal(text: string): boolean {
|
function hasInventoryProvenanceSignal(text: string): boolean {
|
||||||
|
|
@ -1590,6 +1610,12 @@ function hasInventoryProvenanceSignalV2(text: string): boolean {
|
||||||
return hasItemCue && hasSupplierCue && hasPurchaseCue;
|
return hasItemCue && hasSupplierCue && hasPurchaseCue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return hasItemCue && hasPurchaseDateCue;
|
||||||
|
}
|
||||||
|
|
||||||
function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean {
|
function hasInventoryPurchaseDocumentsSignalV2(text: string): boolean {
|
||||||
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
||||||
const hasPurchaseDocCue = /(?:по\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(
|
const hasPurchaseDocCue = /(?:по\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(
|
||||||
|
|
@ -1607,25 +1633,48 @@ function hasInventorySaleTraceSignalV2(text: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasInventorySupplierStockOverlapSignal(text: string): boolean {
|
function hasInventorySupplierStockOverlapSignal(text: string): boolean {
|
||||||
|
const hasDirectSingleItemSupplierQuestion =
|
||||||
|
/(?:от\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и))|от\s+кого\s+куплен\s+(?:товар|номенклатур(?:а|у|ы)|позици(?:я|ю|и)))/iu.test(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
if (hasDirectSingleItemSupplierQuestion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+поставщика|у\s+поставщика)/iu.test(text);
|
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+поставщика|у\s+поставщика)/iu.test(text);
|
||||||
const hasStockCue = /(?:товар|номенклатур|склад|остат(?:ок|ки)|лежат|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(
|
const hasStockCue = /(?:склад|остат(?:ок|ке|ков)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|на\s+дату|по\s+состоянию\s+на\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(
|
||||||
text
|
text
|
||||||
);
|
);
|
||||||
return hasSupplierCue && hasStockCue;
|
return hasSupplierCue && hasStockCue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasInventoryAgingSignal(text: string): boolean {
|
function hasInventoryAgingSignal(text: string): boolean {
|
||||||
return /(?:стар(?:ые|ым|ых)\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|когда\s+куплен|возраст\s+остатк|aged?\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date|very\s+old\s+stock)/iu.test(
|
const hasResidueCue =
|
||||||
text
|
/(?:остат(?:ок|ки)|в\s+остатке|среди\s+текущих\s+остатков|на\s+складе|stock\s+residue|stock\s+balance)/iu.test(text);
|
||||||
);
|
const hasAgingCue =
|
||||||
|
/(?:стар(?:ые|ым|ых)\s+закупк|стары(?:м|х)\s+закупк(?:ам|и|ах)|относит(?:ся|ся\s+ли)?\s+.*\s+к\s+старым\s+закупк|закупал(?:ись|ся)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+до(?:\s+даты)?|закуплен(?:ы|а)?\s+давно|приобретен\s+давно|задолго\s+до(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+до)/iu.test(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasInventoryPurchaseToSaleChainSignal(text: string): boolean {
|
function hasInventoryPurchaseToSaleChainSignal(text: string): boolean {
|
||||||
const hasSupplierCue = /(?:поставщик|supplier|vendor|от\s+кого\s+куплен)/iu.test(text);
|
|
||||||
const hasBuyerCue = /(?:покупател|buyer|customer|client|кому\s+был\s+продан)/iu.test(text);
|
|
||||||
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
||||||
const hasPurchaseSaleCue = /(?:куплен(?:ы)?|закупк|позже\s+продан(?:ы)?|продан(?:ы)?|purchase|sale|цепочк[аи]\s+движен)/iu.test(text);
|
const hasChainCue =
|
||||||
return (hasSupplierCue && hasBuyerCue && hasItemCue && hasPurchaseSaleCue) || /(?:purchase[\s-]?to[\s-]?sale\s+chain|закупка\s*->\s*склад\s*->\s*продажа)/iu.test(text);
|
/(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test(
|
||||||
|
text
|
||||||
|
) || text.includes("->");
|
||||||
|
return hasItemCue && hasChainCue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInventorySupplierToBuyerChainSignal(text: string): boolean {
|
||||||
|
const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text);
|
||||||
|
const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text);
|
||||||
|
const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text);
|
||||||
|
const hasChainCue =
|
||||||
|
/(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+к\s+покупател|поставщик\s+к\s+клиент|поставщик\s+к\s+товару\s+и\s+покупателю)/iu.test(
|
||||||
|
text
|
||||||
|
) || text.includes("->");
|
||||||
|
return hasSupplierCue && hasBuyerCue && hasItemCue && hasChainCue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
|
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
|
||||||
|
|
@ -1745,27 +1794,35 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasInventoryProvenanceSignalV2(text)) {
|
if (
|
||||||
|
/(?:старым\s+закупк(?:ам|и|ах)|относится\s+ли\s+.*\s+к\s+старым\s+закупк(?:ам|и|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test(
|
||||||
|
text
|
||||||
|
)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_provenance_for_item",
|
intent: "inventory_aging_by_purchase_date",
|
||||||
confidence: "medium",
|
confidence: "high",
|
||||||
reasons: ["inventory_provenance_signal_detected"]
|
reasons: ["inventory_aging_signal_detected_strong"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasInventoryPurchaseDocumentsSignalV2(text)) {
|
if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_documents_for_item",
|
intent: "inventory_on_hand_as_of_date",
|
||||||
confidence: "medium",
|
confidence: "high",
|
||||||
reasons: ["inventory_purchase_documents_signal_detected"]
|
reasons: ["inventory_account_41_as_of_date_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasInventoryPurchaseToSaleChainSignal(text)) {
|
if (
|
||||||
|
/(?:без\s+понятн(?:ой|ого)\s+привязк(?:и|а)\s+к\s+поставщик|без\s+привязк(?:и|а)\s+к\s+поставщик|unresolved\s+supplier\s+link)/iu.test(
|
||||||
|
text
|
||||||
|
)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_purchase_to_sale_chain",
|
intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||||
confidence: "medium",
|
confidence: "medium",
|
||||||
reasons: ["inventory_purchase_to_sale_chain_signal_detected"]
|
reasons: ["inventory_unresolved_provenance_signal_detected"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1777,6 +1834,29 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|поставщик\s+к\s+покупателю|поставщик\s*->\s*товар\s*->\s*покупател|документально\s+подтвержденн\w+\s+цепочк)/iu.test(
|
||||||
|
text
|
||||||
|
) &&
|
||||||
|
/(?:поставщик|supplier|vendor)/iu.test(text) &&
|
||||||
|
/(?:покупател|buyer|customer|client)/iu.test(text) &&
|
||||||
|
/(?:товар|номенклатур|sku|item|product)/iu.test(text)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
confidence: "high",
|
||||||
|
reasons: ["inventory_supplier_to_buyer_chain_signal_detected_strong"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasInventoryPurchaseToSaleChainSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_purchase_to_sale_chain_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (hasInventoryAgingSignal(text)) {
|
if (hasInventoryAgingSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_aging_by_purchase_date",
|
intent: "inventory_aging_by_purchase_date",
|
||||||
|
|
@ -1785,6 +1865,30 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasInventoryProvenanceSignalV2(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_provenance_for_item",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_provenance_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasInventoryPurchaseDateSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_provenance_for_item",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_purchase_date_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasInventoryPurchaseDocumentsSignalV2(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_documents_for_item",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_purchase_documents_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (hasInventorySaleTraceSignalV2(text)) {
|
if (hasInventorySaleTraceSignalV2(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_sale_trace_for_item",
|
intent: "inventory_sale_trace_for_item",
|
||||||
|
|
@ -1793,6 +1897,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasInventorySupplierToBuyerChainSignal(text)) {
|
||||||
|
return {
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
confidence: "medium",
|
||||||
|
reasons: ["inventory_supplier_to_buyer_chain_signal_detected"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (hasInventoryOnHandSignal(text)) {
|
if (hasInventoryOnHandSignal(text)) {
|
||||||
return {
|
return {
|
||||||
intent: "inventory_on_hand_as_of_date",
|
intent: "inventory_on_hand_as_of_date",
|
||||||
|
|
|
||||||
|
|
@ -1284,6 +1284,24 @@ function applyAddressFilters(rows: NormalizedAddressRow[], filters: AddressFilte
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.item && String(filters.item).trim()) {
|
||||||
|
const needle = String(filters.item);
|
||||||
|
const before = filtered.length;
|
||||||
|
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
|
||||||
|
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
|
||||||
|
mismatchReason = "item_anchor_not_matched_in_materialized_rows";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.warehouse && String(filters.warehouse).trim()) {
|
||||||
|
const needle = String(filters.warehouse);
|
||||||
|
const before = filtered.length;
|
||||||
|
filtered = filtered.filter((row) => matchesAnchorText(rowSearchableText(row), needle));
|
||||||
|
if (before > 0 && filtered.length === 0 && mismatchReason === null) {
|
||||||
|
mismatchReason = "warehouse_anchor_not_matched_in_materialized_rows";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.document_ref && String(filters.document_ref).trim()) {
|
if (filters.document_ref && String(filters.document_ref).trim()) {
|
||||||
const needle = String(filters.document_ref);
|
const needle = String(filters.document_ref);
|
||||||
const before = filtered.length;
|
const before = filtered.length;
|
||||||
|
|
@ -1367,6 +1385,10 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
|
||||||
intent === "account_balance_snapshot" ||
|
intent === "account_balance_snapshot" ||
|
||||||
intent === "documents_forming_balance" ||
|
intent === "documents_forming_balance" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
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 === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
|
|
@ -1779,7 +1801,24 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
|
||||||
intent === "list_documents_by_counterparty" ||
|
intent === "list_documents_by_counterparty" ||
|
||||||
intent === "bank_operations_by_counterparty" ||
|
intent === "bank_operations_by_counterparty" ||
|
||||||
intent === "list_documents_by_contract" ||
|
intent === "list_documents_by_contract" ||
|
||||||
intent === "bank_operations_by_contract"
|
intent === "bank_operations_by_contract" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
|
||||||
|
return (
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2133,6 +2172,12 @@ function normalizeMissingAnchorLabel(anchor: string): string {
|
||||||
if (anchor === "organization") {
|
if (anchor === "organization") {
|
||||||
return "организация";
|
return "организация";
|
||||||
}
|
}
|
||||||
|
if (anchor === "item") {
|
||||||
|
return "товар";
|
||||||
|
}
|
||||||
|
if (anchor === "warehouse") {
|
||||||
|
return "склад";
|
||||||
|
}
|
||||||
if (anchor === "period" || anchor === "period_from" || anchor === "period_to" || anchor === "as_of_date") {
|
if (anchor === "period" || anchor === "period_from" || anchor === "period_to" || anchor === "as_of_date") {
|
||||||
return "период/дата";
|
return "период/дата";
|
||||||
}
|
}
|
||||||
|
|
@ -2220,6 +2265,14 @@ function buildLimitedOffers(input: {
|
||||||
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76");
|
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76");
|
||||||
} else if (input.intent === "inventory_on_hand_as_of_date") {
|
} else if (input.intent === "inventory_on_hand_as_of_date") {
|
||||||
offers.push("показать подтвержденный срез товаров на складах на дату по остатку счета 41.01");
|
offers.push("показать подтвержденный срез товаров на складах на дату по остатку счета 41.01");
|
||||||
|
} else if (input.intent === "inventory_purchase_provenance_for_item") {
|
||||||
|
offers.push("показать подтвержденные закупочные движения по товару на 41.01 с датами и документами");
|
||||||
|
} else if (input.intent === "inventory_purchase_documents_for_item") {
|
||||||
|
offers.push("показать документы поступления по товару на 41.01");
|
||||||
|
} else if (input.intent === "inventory_sale_trace_for_item") {
|
||||||
|
offers.push("показать подтвержденные движения выбытия товара со счета 41.01");
|
||||||
|
} else if (input.intent === "inventory_purchase_to_sale_chain") {
|
||||||
|
offers.push("показать документальную цепочку по товару: поступление на 41.01 и последующее выбытие");
|
||||||
} else if (input.intent === "open_contracts_confirmed_as_of_date") {
|
} else if (input.intent === "open_contracts_confirmed_as_of_date") {
|
||||||
offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76");
|
offers.push("показать подтвержденный реестр договоров с открытыми взаиморасчетами на дату по 60/62/76");
|
||||||
} else if (input.intent === "vat_payable_confirmed_as_of_date") {
|
} else if (input.intent === "vat_payable_confirmed_as_of_date") {
|
||||||
|
|
@ -3352,6 +3405,14 @@ export class AddressQueryService {
|
||||||
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
|
const autoBroadenedFilters: AddressFilterSet = { ...filters.extracted_filters };
|
||||||
delete autoBroadenedFilters.period_from;
|
delete autoBroadenedFilters.period_from;
|
||||||
delete autoBroadenedFilters.period_to;
|
delete autoBroadenedFilters.period_to;
|
||||||
|
if (shouldBoostAutoBroadenedLimit(intent.intent)) {
|
||||||
|
autoBroadenedFilters.limit = Math.max(
|
||||||
|
ADDRESS_ANCHOR_RECOVERY_LIMIT,
|
||||||
|
typeof autoBroadenedFilters.limit === "number" && Number.isFinite(autoBroadenedFilters.limit)
|
||||||
|
? Math.max(1, Math.trunc(autoBroadenedFilters.limit))
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
}
|
||||||
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
|
const broadenedSelection = selectAddressRecipe(intent.intent, autoBroadenedFilters);
|
||||||
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
if (broadenedSelection.selected_recipe && broadenedSelection.missing_required_filters.length === 0) {
|
||||||
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
const broadenedPlan = buildAddressRecipePlan(broadenedSelection.selected_recipe, autoBroadenedFilters);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,27 @@ const MOVEMENTS_QUERY_TEMPLATE = `
|
||||||
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
|
||||||
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
|
||||||
ИЗ
|
ИЗ
|
||||||
РегистрБухгалтерии.Хозрасчетный КАК Движения
|
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
|
||||||
|
__WHERE_CLAUSE__
|
||||||
|
УПОРЯДОЧИТЬ ПО
|
||||||
|
Движения.Период __ORDER_DIRECTION__
|
||||||
|
`;
|
||||||
|
|
||||||
|
const INVENTORY_MOVEMENTS_QUERY_TEMPLATE = `
|
||||||
|
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
|
||||||
|
Движения.Период КАК Период,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
|
||||||
|
Движения.Сумма КАК Сумма,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
|
||||||
|
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
|
||||||
|
ИЗ
|
||||||
|
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
|
||||||
__WHERE_CLAUSE__
|
__WHERE_CLAUSE__
|
||||||
УПОРЯДОЧИТЬ ПО
|
УПОРЯДОЧИТЬ ПО
|
||||||
Движения.Период __ORDER_DIRECTION__
|
Движения.Период __ORDER_DIRECTION__
|
||||||
|
|
@ -707,6 +727,72 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
|
||||||
account_scope_mode: "strict",
|
account_scope_mode: "strict",
|
||||||
query_template: "inventory_on_hand_as_of_balance_profile"
|
query_template: "inventory_on_hand_as_of_balance_profile"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_purchase_provenance_for_item_v1",
|
||||||
|
intent: "inventory_purchase_provenance_for_item",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements for one inventory item and summarize supplier/date provenance evidence",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 400,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_purchase_provenance_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_purchase_documents_for_item_v1",
|
||||||
|
intent: "inventory_purchase_documents_for_item",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements for one inventory item and list source purchase documents",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 400,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_purchase_documents_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_supplier_stock_overlap_as_of_date_v1",
|
||||||
|
intent: "inventory_supplier_stock_overlap_as_of_date",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements and summarize supplier overlap with current or dated stock slice",
|
||||||
|
required_filters: [],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "counterparty", "limit", "sort"],
|
||||||
|
default_limit: 500,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_supplier_stock_overlap_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_sale_trace_for_item_v1",
|
||||||
|
intent: "inventory_sale_trace_for_item",
|
||||||
|
purpose: "Trace sale-side 41.01 movements for one inventory item and summarize sale evidence",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 400,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_sale_trace_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_purchase_to_sale_chain_v1",
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
purpose: "Trace both purchase and sale side 41.01 movements for one inventory item and summarize the document chain",
|
||||||
|
required_filters: ["item"],
|
||||||
|
optional_filters: ["as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 600,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_purchase_to_sale_chain_profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipe_id: "address_inventory_aging_by_purchase_date_v1",
|
||||||
|
intent: "inventory_aging_by_purchase_date",
|
||||||
|
purpose: "Trace purchase-side 41.01 movements and summarize age of stock residue by purchase dates",
|
||||||
|
required_filters: [],
|
||||||
|
optional_filters: ["item", "as_of_date", "period_from", "period_to", "organization", "warehouse", "limit", "sort"],
|
||||||
|
default_limit: 500,
|
||||||
|
account_scope: ["41.01"],
|
||||||
|
account_scope_mode: "strict",
|
||||||
|
query_template: "inventory_aging_by_purchase_date_profile"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
|
recipe_id: "address_open_contracts_confirmed_as_of_date_v1",
|
||||||
intent: "open_contracts_confirmed_as_of_date",
|
intent: "open_contracts_confirmed_as_of_date",
|
||||||
|
|
@ -1039,6 +1125,28 @@ function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): str
|
||||||
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInventoryMovementQuery(
|
||||||
|
filters: AddressFilterSet,
|
||||||
|
resolvedLimit: number,
|
||||||
|
side: "dt" | "kt" | "either"
|
||||||
|
): string {
|
||||||
|
const debitPredicate = buildAccountPrefixPredicate("Движения.СчетДт", ["41.01"]);
|
||||||
|
const creditPredicate = buildAccountPrefixPredicate("Движения.СчетКт", ["41.01"]);
|
||||||
|
const inventoryCondition =
|
||||||
|
side === "dt"
|
||||||
|
? debitPredicate
|
||||||
|
: side === "kt"
|
||||||
|
? creditPredicate
|
||||||
|
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
|
||||||
|
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
|
||||||
|
.replace("__LIMIT__", String(resolvedLimit))
|
||||||
|
.replace(
|
||||||
|
"__WHERE_CLAUSE__",
|
||||||
|
buildWhereClause(filters, "Движения.Период", [inventoryCondition])
|
||||||
|
)
|
||||||
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
|
}
|
||||||
|
|
||||||
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
|
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
|
||||||
const hasAnchor =
|
const hasAnchor =
|
||||||
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
|
||||||
|
|
@ -1067,6 +1175,12 @@ function maxLimitForIntent(intent: AddressIntent): number {
|
||||||
intent === "vat_payable_forecast" ||
|
intent === "vat_payable_forecast" ||
|
||||||
intent === "vat_liability_confirmed_for_tax_period" ||
|
intent === "vat_liability_confirmed_for_tax_period" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "list_contracts_by_counterparty" ||
|
intent === "list_contracts_by_counterparty" ||
|
||||||
intent === "list_documents_by_counterparty" ||
|
intent === "list_documents_by_counterparty" ||
|
||||||
|
|
@ -1259,6 +1373,18 @@ export function buildAddressRecipePlan(
|
||||||
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
|
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
|
||||||
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
|
||||||
})()
|
})()
|
||||||
|
: recipe.query_template === "inventory_purchase_provenance_profile"
|
||||||
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
|
: recipe.query_template === "inventory_purchase_documents_profile"
|
||||||
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
|
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
|
||||||
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
|
: recipe.query_template === "inventory_sale_trace_profile"
|
||||||
|
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
|
||||||
|
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
|
||||||
|
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
|
||||||
|
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
|
||||||
|
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
|
||||||
: recipe.query_template === "contracts_by_counterparty_profile"
|
: recipe.query_template === "contracts_by_counterparty_profile"
|
||||||
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
|
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
|
||||||
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
|
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
|
||||||
|
|
|
||||||
|
|
@ -910,6 +910,165 @@ function buildInventoryOnHandAggregate(rows: ComposeStageRow[], asOfDate: string
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inventoryTraceDateLabel(value: string | null): string {
|
||||||
|
return value ? formatDateRu(value) : "дата не указана";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInventoryAccountPrefix(value: string | null | undefined, prefix: string): boolean {
|
||||||
|
const normalized = String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.replace(",", ".");
|
||||||
|
return normalized === prefix || normalized.startsWith(`${prefix}.`) || normalized.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInventoryPurchaseMovement(row: ComposeStageRow): boolean {
|
||||||
|
return hasInventoryAccountPrefix(row.account_dt, "41.01");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInventorySaleMovement(row: ComposeStageRow): boolean {
|
||||||
|
return hasInventoryAccountPrefix(row.account_kt, "41.01");
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeInventoryTraceDocumentToken(value: string): boolean {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
/(?:№|contract|invoice|payment|order|накладн|акт|счет|сч[её]т|поступлен|реализац|договор)/iu.test(normalized) ||
|
||||||
|
/(?:[a-zа-яё].*\d|\d.*[a-zа-яё])/iu.test(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeInventoryPartyToken(value: string): boolean {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!normalized || normalized.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/(?:склад|warehouse)/iu.test(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (looksLikeInventoryTraceDocumentToken(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/(?:ооо|ао|пао|зао|ип|llc|ltd|inc|corp|компани|организац|департамент|комитет|министерств|служб|управлен|торговый\s+дом)/iu.test(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const letterChars = (normalized.match(/[A-Za-zА-Яа-яЁё]/g) ?? []).length;
|
||||||
|
if (letterChars < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const words = normalized.split(/\s+/u).filter(Boolean);
|
||||||
|
if (words.length >= 2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalized === normalized.toUpperCase() && normalized.length >= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInventoryCounterpartyCandidates(row: ComposeStageRow): string[] {
|
||||||
|
const itemToken = normalizeEntityToken(extractInventoryItemName(row));
|
||||||
|
const warehouseToken = normalizeEntityToken(extractInventoryWarehouseName(row));
|
||||||
|
const organizationToken = normalizeEntityToken(extractInventoryOrganizationName(row));
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const token of row.analytics) {
|
||||||
|
const normalized = String(token ?? "").trim();
|
||||||
|
if (!normalized || !looksLikeInventoryPartyToken(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const comparable = normalizeEntityToken(normalized);
|
||||||
|
if (!comparable || comparable === itemToken || comparable === warehouseToken || comparable === organizationToken) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(normalized);
|
||||||
|
}
|
||||||
|
return uniqueStrings(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryTraceSummary {
|
||||||
|
item: string | null;
|
||||||
|
warehouses: string[];
|
||||||
|
organizations: string[];
|
||||||
|
counterparties: string[];
|
||||||
|
documents: string[];
|
||||||
|
firstPeriod: string | null;
|
||||||
|
lastPeriod: string | null;
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeInventoryTraceRows(rows: ComposeStageRow[]): InventoryTraceSummary {
|
||||||
|
const items = uniqueStrings(
|
||||||
|
rows
|
||||||
|
.map((row) => extractInventoryItemName(row))
|
||||||
|
.filter((item): item is string => Boolean(item))
|
||||||
|
);
|
||||||
|
const warehouses = uniqueStrings(
|
||||||
|
rows
|
||||||
|
.map((row) => extractInventoryWarehouseName(row))
|
||||||
|
.filter((item): item is string => Boolean(item))
|
||||||
|
);
|
||||||
|
const organizations = uniqueStrings(
|
||||||
|
rows
|
||||||
|
.map((row) => extractInventoryOrganizationName(row))
|
||||||
|
.filter((item): item is string => Boolean(item))
|
||||||
|
);
|
||||||
|
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row)));
|
||||||
|
const documents = uniqueStrings(
|
||||||
|
rows
|
||||||
|
.map((row) => String(row.registrator ?? "").trim())
|
||||||
|
.filter((item) => item.length > 0 && item !== "(без названия)")
|
||||||
|
);
|
||||||
|
const periods = rows
|
||||||
|
.map((row) => String(row.period ?? "").trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
.sort((left, right) => left.localeCompare(right, "ru"));
|
||||||
|
const totalAmount = rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||||
|
return {
|
||||||
|
item: items[0] ?? null,
|
||||||
|
warehouses,
|
||||||
|
organizations,
|
||||||
|
counterparties,
|
||||||
|
documents,
|
||||||
|
firstPeriod: periods[0] ?? null,
|
||||||
|
lastPeriod: periods.length > 0 ? periods[periods.length - 1] : null,
|
||||||
|
totalAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[] {
|
||||||
|
return rows.slice(0, limit).map((row, index) => {
|
||||||
|
const parties = extractInventoryCounterpartyCandidates(row);
|
||||||
|
const warehouse = extractInventoryWarehouseName(row);
|
||||||
|
const organization = extractInventoryOrganizationName(row);
|
||||||
|
const amount =
|
||||||
|
typeof row.amount === "number" && Number.isFinite(row.amount) ? formatMoneyRub(row.amount) : "сумма не указана";
|
||||||
|
const parts = [
|
||||||
|
`${index + 1}. ${row.registrator}`,
|
||||||
|
`дата: ${inventoryTraceDateLabel(row.period)}`,
|
||||||
|
`сумма: ${amount}`
|
||||||
|
];
|
||||||
|
if (warehouse) {
|
||||||
|
parts.push(`склад: ${warehouse}`);
|
||||||
|
}
|
||||||
|
if (organization) {
|
||||||
|
parts.push(`организация: ${organization}`);
|
||||||
|
}
|
||||||
|
if (parties.length > 0) {
|
||||||
|
parts.push(`контрагент: ${parties[0]}`);
|
||||||
|
}
|
||||||
|
return parts.join(" | ");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface CounterpartyRiskAggregate {
|
interface CounterpartyRiskAggregate {
|
||||||
name: string;
|
name: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|
@ -3719,6 +3878,265 @@ export function composeFactualReply(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "inventory_purchase_documents_for_item") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const lines: string[] = [
|
||||||
|
`Собран подтвержденный список документов поступления по товару ${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("; ")}.`);
|
||||||
|
}
|
||||||
|
lines.push("", "Блок 3. Документы");
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push(...formatInventoryTraceRows(purchaseRows, 12));
|
||||||
|
} else {
|
||||||
|
lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: purchaseRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "inventory_purchase_provenance_for_item") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const lines: string[] = [
|
||||||
|
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
"- Результат: показаны подтвержденные закупочные движения на 41.01 по выбранному товару.",
|
||||||
|
"- Важно: без партионности этот контур не подменяет собой лот-level доказательство происхождения текущего остатка.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Операций поступления: ${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("; ")}.`);
|
||||||
|
} else if (purchaseRows.length > 0) {
|
||||||
|
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
|
||||||
|
}
|
||||||
|
if (summary.documents.length > 0) {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength:
|
||||||
|
purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0);
|
||||||
|
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
|
||||||
|
const lines: string[] = [
|
||||||
|
`Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
`- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`,
|
||||||
|
"- Важно: без партионности этот контур показывает документально наблюдаемые supplier candidates, но не подменяет собой лот-level атрибуцию текущего остатка.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
|
||||||
|
];
|
||||||
|
if (summary.counterparties.length > 0) {
|
||||||
|
lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`);
|
||||||
|
} else if (purchaseRows.length > 0) {
|
||||||
|
lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
|
||||||
|
} else {
|
||||||
|
lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза.");
|
||||||
|
}
|
||||||
|
if (unresolvedRows.length > 0) {
|
||||||
|
lines.push(`- Операций без явно материализованного поставщика: ${formatNumberWithDots(unresolvedRows.length)}.`);
|
||||||
|
}
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 10));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "inventory_aging_by_purchase_date") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const firstPeriodTime = summary.firstPeriod ? Date.parse(summary.firstPeriod) : Number.NaN;
|
||||||
|
const asOfTime = Date.parse(`${asOfDate}T23:59:59.000Z`);
|
||||||
|
const ageDays =
|
||||||
|
Number.isFinite(firstPeriodTime) && Number.isFinite(asOfTime) && firstPeriodTime <= asOfTime
|
||||||
|
? Math.floor((asOfTime - firstPeriodTime) / 86_400_000)
|
||||||
|
: null;
|
||||||
|
const itemLabel = summary.item ?? "выбранному складскому остатку";
|
||||||
|
const lines: string[] = [
|
||||||
|
`Собран exact-срез возраста закупочного следа по ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
"- Контур: показаны подтвержденные закупочные движения на 41.01 и их временной разброс.",
|
||||||
|
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый диапазон закупок.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
|
||||||
|
];
|
||||||
|
if (ageDays !== null) {
|
||||||
|
lines.push(`- Между самой ранней найденной закупкой и датой среза прошло ${formatNumberWithDots(ageDays)} дн.`);
|
||||||
|
}
|
||||||
|
if (summary.counterparties.length > 0) {
|
||||||
|
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
|
||||||
|
}
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
|
||||||
|
} else {
|
||||||
|
lines.push("", "Блок 3. Опорные документы", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
|
||||||
|
balance_confirmed: purchaseRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "inventory_sale_trace_for_item") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
|
||||||
|
const summary = summarizeInventoryTraceRows(saleRows);
|
||||||
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const lines: string[] = [
|
||||||
|
`Собран подтвержденный след выбытия по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
"- Результат: показаны подтвержденные движения выбытия товара со счета 41.01.",
|
||||||
|
"",
|
||||||
|
"Блок 2. Сводка",
|
||||||
|
`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
|
||||||
|
`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
|
||||||
|
`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`,
|
||||||
|
`- Операций выбытия: ${formatNumberWithDots(saleRows.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("; ")}.`);
|
||||||
|
} else if (saleRows.length > 0) {
|
||||||
|
lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре.");
|
||||||
|
}
|
||||||
|
lines.push("", "Блок 3. Документы выбытия");
|
||||||
|
if (saleRows.length > 0) {
|
||||||
|
lines.push(...formatInventoryTraceRows(saleRows, 12));
|
||||||
|
} else {
|
||||||
|
lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: saleRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: saleRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium",
|
||||||
|
balance_confirmed: saleRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "inventory_purchase_to_sale_chain") {
|
||||||
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
|
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
|
||||||
|
const saleRows = rows.filter((row) => isInventorySaleMovement(row));
|
||||||
|
const purchaseSummary = summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
const saleSummary = summarizeInventoryTraceRows(saleRows);
|
||||||
|
const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен";
|
||||||
|
const lines: string[] = [
|
||||||
|
`Собрана документальная цепочка по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
|
||||||
|
"",
|
||||||
|
"Блок 1. Статус результата",
|
||||||
|
`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`,
|
||||||
|
`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`
|
||||||
|
];
|
||||||
|
if (purchaseRows.length > 0 && saleRows.length > 0) {
|
||||||
|
lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие.");
|
||||||
|
} else if (purchaseRows.length > 0) {
|
||||||
|
lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено.");
|
||||||
|
} else if (saleRows.length > 0) {
|
||||||
|
lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена.");
|
||||||
|
} else {
|
||||||
|
lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку.");
|
||||||
|
}
|
||||||
|
if (purchaseRows.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"Блок 2. Закупка",
|
||||||
|
`- Первая дата: ${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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
|
||||||
|
text: joinLines(lines),
|
||||||
|
semantics: {
|
||||||
|
result_mode: "confirmed_balance",
|
||||||
|
evidence_strength: purchaseRows.length > 0 && saleRows.length > 0 ? "strong" : purchaseRows.length > 0 || saleRows.length > 0 ? "medium" : "weak",
|
||||||
|
balance_confirmed: purchaseRows.length > 0 || saleRows.length > 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "open_contracts_confirmed_as_of_date") {
|
if (intent === "open_contracts_confirmed_as_of_date") {
|
||||||
const asOfDate = resolvePayablesAsOfDate(options);
|
const asOfDate = resolvePayablesAsOfDate(options);
|
||||||
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
|
const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate);
|
||||||
|
|
|
||||||
|
|
@ -490,6 +490,12 @@ function mergeFollowupFilters(
|
||||||
intent === "list_open_contracts" ||
|
intent === "list_open_contracts" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
intent === "vat_payable_confirmed_as_of_date"
|
intent === "vat_payable_confirmed_as_of_date"
|
||||||
|
|
@ -524,6 +530,21 @@ function mergeFollowupFilters(
|
||||||
reasons.push("as_of_date_from_followup_context");
|
reasons.push("as_of_date_from_followup_context");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!sameDateRequested &&
|
||||||
|
(intent === "inventory_sale_trace_for_item" || intent === "inventory_purchase_to_sale_chain") &&
|
||||||
|
!hasExplicitPeriodLiteral(userMessage) &&
|
||||||
|
!hasExplicitCurrentDateHint(userMessage)
|
||||||
|
) {
|
||||||
|
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
|
||||||
|
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
|
||||||
|
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
|
||||||
|
merged.as_of_date = inheritedAsOfDate;
|
||||||
|
reasons.push("as_of_date_from_followup_context");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!sameDateRequested &&
|
!sameDateRequested &&
|
||||||
hasFollowupSignalForConfirmed &&
|
hasFollowupSignalForConfirmed &&
|
||||||
|
|
@ -572,6 +593,12 @@ function mergeFollowupFilters(
|
||||||
intent === "documents_forming_balance" ||
|
intent === "documents_forming_balance" ||
|
||||||
intent === "open_contracts_confirmed_as_of_date" ||
|
intent === "open_contracts_confirmed_as_of_date" ||
|
||||||
intent === "inventory_on_hand_as_of_date" ||
|
intent === "inventory_on_hand_as_of_date" ||
|
||||||
|
intent === "inventory_purchase_provenance_for_item" ||
|
||||||
|
intent === "inventory_purchase_documents_for_item" ||
|
||||||
|
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||||
|
intent === "inventory_sale_trace_for_item" ||
|
||||||
|
intent === "inventory_purchase_to_sale_chain" ||
|
||||||
|
intent === "inventory_aging_by_purchase_date" ||
|
||||||
intent === "payables_confirmed_as_of_date" ||
|
intent === "payables_confirmed_as_of_date" ||
|
||||||
intent === "receivables_confirmed_as_of_date" ||
|
intent === "receivables_confirmed_as_of_date" ||
|
||||||
intent === "vat_payable_confirmed_as_of_date";
|
intent === "vat_payable_confirmed_as_of_date";
|
||||||
|
|
|
||||||
|
|
@ -3814,12 +3814,27 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
|
||||||
"list_documents_by_counterparty",
|
"list_documents_by_counterparty",
|
||||||
"bank_operations_by_counterparty",
|
"bank_operations_by_counterparty",
|
||||||
"list_contracts_by_counterparty",
|
"list_contracts_by_counterparty",
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_purchase_documents_for_item",
|
||||||
|
"inventory_supplier_stock_overlap_as_of_date",
|
||||||
|
"inventory_sale_trace_for_item",
|
||||||
|
"inventory_purchase_to_sale_chain",
|
||||||
|
"inventory_aging_by_purchase_date",
|
||||||
"contract_usage_overview",
|
"contract_usage_overview",
|
||||||
"contract_usage_and_value",
|
"contract_usage_and_value",
|
||||||
"vat_payable_forecast",
|
"vat_payable_forecast",
|
||||||
"vat_liability_confirmed_for_tax_period",
|
"vat_liability_confirmed_for_tax_period",
|
||||||
"vat_payable_confirmed_as_of_date"
|
"vat_payable_confirmed_as_of_date"
|
||||||
]);
|
]);
|
||||||
|
const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_purchase_documents_for_item",
|
||||||
|
"inventory_sale_trace_for_item",
|
||||||
|
"inventory_purchase_to_sale_chain"
|
||||||
|
]);
|
||||||
|
function shouldBypassStrictDeepInvestigationCueForAddressIntent(intent) {
|
||||||
|
return Boolean(intent && ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS.has(intent));
|
||||||
|
}
|
||||||
export function resolveAssistantOrchestrationDecision(input) {
|
export function resolveAssistantOrchestrationDecision(input) {
|
||||||
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
|
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
|
||||||
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
|
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
|
||||||
|
|
@ -3873,11 +3888,13 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
hasStrictDeepInvestigationCue(repairedRawUserMessage) ||
|
hasStrictDeepInvestigationCue(repairedRawUserMessage) ||
|
||||||
hasStrictDeepInvestigationCue(effectiveAddressUserMessage) ||
|
hasStrictDeepInvestigationCue(effectiveAddressUserMessage) ||
|
||||||
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
|
hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage);
|
||||||
|
const strictDeepInvestigationBypassAllowed = shouldBypassStrictDeepInvestigationCueForAddressIntent(intentResolution.intent) ||
|
||||||
|
shouldBypassStrictDeepInvestigationCueForAddressIntent(llmContractIntent);
|
||||||
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
|
const keepAddressLaneByIntent = semanticApplyCanonicalRecommended &&
|
||||||
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
||||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||||
openContractsAddressSignal) &&
|
openContractsAddressSignal) &&
|
||||||
!strictDeepInvestigationCueDetected;
|
(!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed);
|
||||||
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
|
const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) ||
|
||||||
hasStrongDataIntentSignal(repairedRawUserMessage) ||
|
hasStrongDataIntentSignal(repairedRawUserMessage) ||
|
||||||
hasStrongDataIntentSignal(effectiveAddressUserMessage) ||
|
hasStrongDataIntentSignal(effectiveAddressUserMessage) ||
|
||||||
|
|
@ -4023,7 +4040,7 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
|
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
|
||||||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
|
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
|
||||||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
|
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
|
||||||
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
|
const supportedAddressIntentDetected = (!strictDeepInvestigationCueDetected || strictDeepInvestigationBypassAllowed) &&
|
||||||
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
|
||||||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
|
||||||
openContractsAddressSignal);
|
openContractsAddressSignal);
|
||||||
|
|
@ -4175,6 +4192,7 @@ export function resolveAssistantOrchestrationDecision(input) {
|
||||||
semantic_reason_codes: semanticReasonCodes,
|
semantic_reason_codes: semanticReasonCodes,
|
||||||
semantic_route_arbitration: {
|
semantic_route_arbitration: {
|
||||||
supported_address_intent_detected: supportedAddressIntentDetected,
|
supported_address_intent_detected: supportedAddressIntentDetected,
|
||||||
|
strict_deep_investigation_bypass_allowed: strictDeepInvestigationBypassAllowed,
|
||||||
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
semantic_deep_investigation_hint_detected: semanticDeepInvestigationHintDetected,
|
||||||
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
semantic_aggregate_shape_detected: semanticAggregateShapeDetected,
|
||||||
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
followup_semantic_override_to_deep_allowed: followupSemanticOverrideToDeepAllowed
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@ export interface AddressFilterSet {
|
||||||
counterparty?: string;
|
counterparty?: string;
|
||||||
contract?: string;
|
contract?: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
|
item?: string;
|
||||||
|
warehouse?: string;
|
||||||
document_type?: string;
|
document_type?: string;
|
||||||
document_ref?: string;
|
document_ref?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -150,7 +152,9 @@ export interface AddressRecipeDefinition {
|
||||||
| "inventory_purchase_provenance_profile"
|
| "inventory_purchase_provenance_profile"
|
||||||
| "inventory_purchase_documents_profile"
|
| "inventory_purchase_documents_profile"
|
||||||
| "inventory_supplier_stock_overlap_profile"
|
| "inventory_supplier_stock_overlap_profile"
|
||||||
| "inventory_sale_trace_profile";
|
| "inventory_sale_trace_profile"
|
||||||
|
| "inventory_purchase_to_sale_chain_profile"
|
||||||
|
| "inventory_aging_by_purchase_date_profile";
|
||||||
required_filters: Array<keyof AddressFilterSet>;
|
required_filters: Array<keyof AddressFilterSet>;
|
||||||
optional_filters: Array<keyof AddressFilterSet>;
|
optional_filters: Array<keyof AddressFilterSet>;
|
||||||
default_limit: number;
|
default_limit: number;
|
||||||
|
|
|
||||||
|
|
@ -42,28 +42,73 @@ describe("address capability policy", () => {
|
||||||
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks inventory provenance intents as blocked exact-capability gaps", () => {
|
it("enables inventory provenance intent as compute exact capability", () => {
|
||||||
const decision = resolveAddressCapabilityRouteDecision("inventory_purchase_provenance_for_item");
|
const decision = resolveAddressCapabilityRouteDecision("inventory_purchase_provenance_for_item");
|
||||||
expect(decision.capability_id).toBe("inventory_inventory_purchase_provenance_for_item");
|
expect(decision.capability_id).toBe("inventory_inventory_purchase_provenance_for_item");
|
||||||
expect(decision.capability_layer).toBe("compute");
|
expect(decision.capability_layer).toBe("compute");
|
||||||
expect(decision.capability_route_mode).toBe("exact");
|
expect(decision.capability_route_mode).toBe("exact");
|
||||||
expect(decision.capability_route_enabled).toBe(false);
|
expect(decision.capability_route_enabled).toBe(true);
|
||||||
expect(decision.capability_route_reason).toBe("inventory_provenance_route_not_implemented");
|
expect(decision.capability_route_reason).toBe("inventory_trace_route_enabled");
|
||||||
expect(isCapabilityRouteBlocked(decision)).toBe(true);
|
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks purchase-to-sale trace and aging intents as blocked exact-capability gaps", () => {
|
it("enables inventory supplier overlap intent as compute exact capability", () => {
|
||||||
|
const decision = resolveAddressCapabilityRouteDecision("inventory_supplier_stock_overlap_as_of_date");
|
||||||
|
expect(decision.capability_id).toBe("inventory_inventory_supplier_stock_overlap_as_of_date");
|
||||||
|
expect(decision.capability_layer).toBe("compute");
|
||||||
|
expect(decision.capability_route_mode).toBe("exact");
|
||||||
|
expect(decision.capability_route_enabled).toBe(true);
|
||||||
|
expect(decision.capability_route_reason).toBe("inventory_supplier_stock_overlap_route_enabled");
|
||||||
|
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables purchase-document and sale-trace intents", () => {
|
||||||
|
const purchaseDocsDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_documents_for_item");
|
||||||
|
expect(purchaseDocsDecision.capability_route_enabled).toBe(true);
|
||||||
|
expect(purchaseDocsDecision.capability_route_reason).toBe("inventory_trace_route_enabled");
|
||||||
|
|
||||||
|
const saleTraceDecision = resolveAddressCapabilityRouteDecision("inventory_sale_trace_for_item");
|
||||||
|
expect(saleTraceDecision.capability_route_enabled).toBe(true);
|
||||||
|
expect(saleTraceDecision.capability_route_reason).toBe("inventory_trace_route_enabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("keeps inventory purchase documents and sale chain on exact compute routes", () => {
|
||||||
|
const purchaseDocsDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_documents_for_item");
|
||||||
|
expect(purchaseDocsDecision.capability_id).toBe("inventory_inventory_purchase_documents_for_item");
|
||||||
|
expect(purchaseDocsDecision.capability_layer).toBe("compute");
|
||||||
|
expect(purchaseDocsDecision.capability_route_mode).toBe("exact");
|
||||||
|
|
||||||
|
const chainDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_to_sale_chain");
|
||||||
|
expect(chainDecision.capability_id).toBe("inventory_inventory_purchase_to_sale_chain");
|
||||||
|
expect(chainDecision.capability_layer).toBe("compute");
|
||||||
|
expect(chainDecision.capability_route_mode).toBe("exact");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables purchase-to-sale trace and aging by purchase date", () => {
|
||||||
const chainDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_to_sale_chain");
|
const chainDecision = resolveAddressCapabilityRouteDecision("inventory_purchase_to_sale_chain");
|
||||||
expect(chainDecision.capability_id).toBe("inventory_inventory_purchase_to_sale_chain");
|
expect(chainDecision.capability_id).toBe("inventory_inventory_purchase_to_sale_chain");
|
||||||
expect(chainDecision.capability_route_mode).toBe("exact");
|
expect(chainDecision.capability_route_mode).toBe("exact");
|
||||||
expect(chainDecision.capability_route_enabled).toBe(false);
|
expect(chainDecision.capability_route_enabled).toBe(true);
|
||||||
expect(isCapabilityRouteBlocked(chainDecision)).toBe(true);
|
expect(chainDecision.capability_route_reason).toBe("inventory_purchase_to_sale_chain_route_enabled");
|
||||||
|
expect(isCapabilityRouteBlocked(chainDecision)).toBe(false);
|
||||||
|
|
||||||
const agingDecision = resolveAddressCapabilityRouteDecision("inventory_aging_by_purchase_date");
|
const agingDecision = resolveAddressCapabilityRouteDecision("inventory_aging_by_purchase_date");
|
||||||
expect(agingDecision.capability_id).toBe("inventory_inventory_aging_by_purchase_date");
|
expect(agingDecision.capability_id).toBe("inventory_inventory_aging_by_purchase_date");
|
||||||
expect(agingDecision.capability_route_mode).toBe("exact");
|
expect(agingDecision.capability_route_mode).toBe("exact");
|
||||||
expect(agingDecision.capability_route_enabled).toBe(false);
|
expect(agingDecision.capability_route_enabled).toBe(true);
|
||||||
expect(isCapabilityRouteBlocked(agingDecision)).toBe(true);
|
expect(agingDecision.capability_route_reason).toBe("inventory_aging_route_enabled");
|
||||||
|
expect(isCapabilityRouteBlocked(agingDecision)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables aging by purchase date as an exact inventory capability", () => {
|
||||||
|
const decision = resolveAddressCapabilityRouteDecision("inventory_aging_by_purchase_date");
|
||||||
|
expect(decision.capability_id).toBe("inventory_inventory_aging_by_purchase_date");
|
||||||
|
expect(decision.capability_layer).toBe("compute");
|
||||||
|
expect(decision.capability_route_mode).toBe("exact");
|
||||||
|
expect(decision.capability_route_enabled).toBe(true);
|
||||||
|
expect(decision.capability_route_reason).toBe("inventory_aging_route_enabled");
|
||||||
|
expect(isCapabilityRouteBlocked(decision)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps document drilldown intent to navigation capability", () => {
|
it("maps document drilldown intent to navigation capability", () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
||||||
|
|
||||||
|
describe("inventory supplier-item-buyer chain filter extraction", () => {
|
||||||
|
it("trims buyer tail from item filter in documentary chain questions", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы",
|
||||||
|
"inventory_purchase_to_sale_chain"
|
||||||
|
).extracted_filters;
|
||||||
|
|
||||||
|
expect(filters.item).toContain("1000*400*2100");
|
||||||
|
expect(filters.item).not.toContain("покупатель");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
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();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inventory selected-object follow-up", () => {
|
||||||
|
it("auto-broadens dated stock follow-up window for inventory provenance", async () => {
|
||||||
|
executeAddressMcpQueryMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
fetched_rows: 1,
|
||||||
|
matched_rows: 1,
|
||||||
|
raw_rows: [
|
||||||
|
{
|
||||||
|
Period: "2021-03-09T00:00:00Z",
|
||||||
|
Registrator: "Поступление товаров и услуг 00000000001 от 09.03.2021 0:00:00",
|
||||||
|
AccountDt: "41.01",
|
||||||
|
AccountKt: "60.01",
|
||||||
|
Amount: 442075,
|
||||||
|
SubcontoDt1: "Рабочая станция универсального специалиста с угловым элементом.",
|
||||||
|
SubcontoDt3: "Основной склад",
|
||||||
|
SubcontoKt1: "АСТРА",
|
||||||
|
SubcontoKt2: "А_03/2021 от 01.03.2021г.",
|
||||||
|
Organization: "ООО \\Альтернатива Плюс\\"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rows: [],
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
fetched_rows: 2,
|
||||||
|
matched_rows: 2,
|
||||||
|
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: "ООО \\Альтернатива Плюс\\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар'
|
||||||
|
);
|
||||||
|
|
||||||
|
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.reasons).toContain("period_window_auto_broadened_to_available_data");
|
||||||
|
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
|
||||||
|
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\");
|
||||||
|
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
|
||||||
|
|
||||||
|
describe("inventory warehouse anchor extraction", () => {
|
||||||
|
it("does not treat 'по состоянию ...' as warehouse name in stock snapshot questions", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"Какие товары находятся на складе по состоянию на 15 марта 2020 года?",
|
||||||
|
"inventory_on_hand_as_of_date"
|
||||||
|
).extracted_filters;
|
||||||
|
|
||||||
|
expect(filters.as_of_date).toBe("2020-03-15");
|
||||||
|
expect(filters.warehouse).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat month phrases as warehouse name in stock snapshot questions", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"Какие товары лежат на складе на март 2019",
|
||||||
|
"inventory_on_hand_as_of_date"
|
||||||
|
).extracted_filters;
|
||||||
|
|
||||||
|
expect(filters.period_from).toBe("2019-03-01");
|
||||||
|
expect(filters.period_to).toBe("2019-03-31");
|
||||||
|
expect(filters.as_of_date).toBe("2019-03-31");
|
||||||
|
expect(filters.warehouse).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -130,6 +130,222 @@ describe("address query shape classifier", () => {
|
||||||
expect(result.mode).toBe("address_query");
|
expect(result.mode).toBe("address_query");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts item anchor for inventory provenance questions", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"От какого поставщика куплен товар Шкаф картотечный?",
|
||||||
|
"inventory_purchase_provenance_for_item"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.item).toBe("Шкаф картотечный");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cuts inventory item anchor before current-stock tail", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
|
||||||
|
"inventory_purchase_provenance_for_item"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.item).toBe("Диван трехместный");
|
||||||
|
expect(filters.warehouse).toBe("Основной склад");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
|
||||||
|
"inventory_purchase_to_sale_chain"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.item).toBe("Шкаф картотечный 1000*400*2100");
|
||||||
|
expect(filters.warehouse).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cuts inventory item anchor before purchase-doc residue tail", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад",
|
||||||
|
"inventory_purchase_documents_for_item"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.item).toBe("Диван трехместный");
|
||||||
|
expect(filters.warehouse).toBe("Основной склад");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts item anchor from selected-object inventory row for provenance follow-up", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар',
|
||||||
|
"inventory_purchase_provenance_for_item"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.item).toBe("Кромка с клеем 33 альмандин 137 м");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps full supplier anchor with comma suffix for stock-overlap questions", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад?",
|
||||||
|
"inventory_supplier_stock_overlap_as_of_date"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.counterparty).toBe("Гамма-мебель, ООО");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not capture organization wording as supplier anchor in overlap questions", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад организации ООО \\Альтернатива Плюс\\",
|
||||||
|
"inventory_supplier_stock_overlap_as_of_date"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.counterparty).toBeUndefined();
|
||||||
|
expect(filters.warehouse).toBe("Основной склад");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts item anchor for inventory aging questions", () => {
|
||||||
|
const filters = extractAddressFilters(
|
||||||
|
"Относится ли товар Шкаф картотечный 1000*400*2100 в остатке на дату 2020-03-31 к старым закупкам",
|
||||||
|
"inventory_aging_by_purchase_date"
|
||||||
|
).extracted_filters;
|
||||||
|
expect(filters.item).toBe("Шкаф картотечный 1000*400*2100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds dedicated inventory purchase-documents query plan", () => {
|
||||||
|
const selected = selectAddressRecipe("inventory_purchase_documents_for_item", {
|
||||||
|
item: "Шкаф картотечный",
|
||||||
|
as_of_date: "2020-03-31"
|
||||||
|
});
|
||||||
|
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_purchase_documents_for_item_v1");
|
||||||
|
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
|
||||||
|
item: "Шкаф картотечный",
|
||||||
|
as_of_date: "2020-03-31"
|
||||||
|
});
|
||||||
|
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
|
||||||
|
expect(plan.query).toContain("Движения.СчетДт");
|
||||||
|
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1");
|
||||||
|
expect(plan.query).toContain("41.01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds overlap recipe for supplier-linked stock slice", () => {
|
||||||
|
const selected = selectAddressRecipe("inventory_supplier_stock_overlap_as_of_date", {
|
||||||
|
counterparty: "Гамма-мебель, ООО",
|
||||||
|
warehouse: "Основной склад",
|
||||||
|
as_of_date: "2020-03-31"
|
||||||
|
});
|
||||||
|
expect(selected.selected_recipe?.recipe_id).toBe("address_inventory_supplier_stock_overlap_as_of_date_v1");
|
||||||
|
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
|
||||||
|
counterparty: "Гамма-мебель, ООО",
|
||||||
|
warehouse: "Основной склад",
|
||||||
|
as_of_date: "2020-03-31"
|
||||||
|
});
|
||||||
|
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
|
||||||
|
expect(plan.query).toContain("Движения.СчетДт");
|
||||||
|
expect(plan.query).toContain("41.01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inventory purchase documents from purchase-side 41.01 movements", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"inventory_purchase_documents_for_item",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2020-03-15T12:00:00Z",
|
||||||
|
registrator: "Поступление товаров и услуг 0001",
|
||||||
|
account_dt: "41.01",
|
||||||
|
account_kt: "60.01",
|
||||||
|
amount: 150000,
|
||||||
|
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
|
||||||
|
item: "Шкаф картотечный",
|
||||||
|
warehouse: "Основной склад",
|
||||||
|
organization: "ООО Ромашка"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
asOfDate: "2020-03-31",
|
||||||
|
useRubCurrency: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(reply.text).toContain("Шкаф картотечный");
|
||||||
|
expect(reply.text).toContain("Поступление товаров и услуг 0001");
|
||||||
|
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inventory provenance summary from purchase-side 41.01 movements", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2020-03-15T12:00:00Z",
|
||||||
|
registrator: "Поступление товаров и услуг 0001",
|
||||||
|
account_dt: "41.01",
|
||||||
|
account_kt: "60.01",
|
||||||
|
amount: 150000,
|
||||||
|
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
|
||||||
|
item: "Шкаф картотечный",
|
||||||
|
warehouse: "Основной склад",
|
||||||
|
organization: "ООО Ромашка"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
asOfDate: "2020-03-31",
|
||||||
|
useRubCurrency: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(reply.text).toContain("закупочный след");
|
||||||
|
expect(reply.text).toContain("Гамма-мебель, ООО");
|
||||||
|
expect(reply.semantics?.balance_confirmed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inventory sale trace from credit-side 41.01 movements", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"inventory_sale_trace_for_item",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2020-04-10T12:00:00Z",
|
||||||
|
registrator: "Реализация товаров и услуг 0007",
|
||||||
|
account_dt: "90.02",
|
||||||
|
account_kt: "41.01",
|
||||||
|
amount: 210000,
|
||||||
|
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Департамент капитального ремонта города Москвы"],
|
||||||
|
item: "Шкаф картотечный",
|
||||||
|
warehouse: "Основной склад",
|
||||||
|
organization: "ООО Ромашка"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
asOfDate: "2020-04-30",
|
||||||
|
useRubCurrency: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(reply.text).toContain("след выбытия");
|
||||||
|
expect(reply.text).toContain("Реализация товаров и услуг 0007");
|
||||||
|
expect(reply.text).toContain("Департамент капитального ремонта города Москвы");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders purchase-to-sale chain from both sides of 41.01", () => {
|
||||||
|
const reply = composeFactualReply(
|
||||||
|
"inventory_purchase_to_sale_chain",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
period: "2020-03-15T12:00:00Z",
|
||||||
|
registrator: "Поступление товаров и услуг 0001",
|
||||||
|
account_dt: "41.01",
|
||||||
|
account_kt: "60.01",
|
||||||
|
amount: 150000,
|
||||||
|
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Гамма-мебель, ООО"],
|
||||||
|
item: "Шкаф картотечный",
|
||||||
|
warehouse: "Основной склад",
|
||||||
|
organization: "ООО Ромашка"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: "2020-04-10T12:00:00Z",
|
||||||
|
registrator: "Реализация товаров и услуг 0007",
|
||||||
|
account_dt: "90.02",
|
||||||
|
account_kt: "41.01",
|
||||||
|
amount: 210000,
|
||||||
|
analytics: ["Шкаф картотечный", "Основной склад", "ООО Ромашка", "Департамент капитального ремонта города Москвы"],
|
||||||
|
item: "Шкаф картотечный",
|
||||||
|
warehouse: "Основной склад",
|
||||||
|
organization: "ООО Ромашка"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
asOfDate: "2020-04-30",
|
||||||
|
useRubCurrency: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(reply.text).toContain("документальная цепочка");
|
||||||
|
expect(reply.text).toContain("Поступление товаров и услуг 0001");
|
||||||
|
expect(reply.text).toContain("Реализация товаров и услуг 0007");
|
||||||
|
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("address compose stage utf8 headers", () => {
|
describe("address compose stage utf8 headers", () => {
|
||||||
|
|
@ -4090,6 +4306,39 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(plan.query).toContain("ДоговорКонтрагента");
|
expect(plan.query).toContain("ДоговорКонтрагента");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps recipe-default limits for inventory exact intents", () => {
|
||||||
|
const onHand = extractAddressFilters("Какие товары сейчас лежат на складе?", "inventory_on_hand_as_of_date");
|
||||||
|
const provenance = extractAddressFilters(
|
||||||
|
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад?",
|
||||||
|
"inventory_purchase_provenance_for_item"
|
||||||
|
);
|
||||||
|
const purchaseDocs = extractAddressFilters(
|
||||||
|
"По каким документам был куплен товар Диван трехместный для остатка на складе Основной склад?",
|
||||||
|
"inventory_purchase_documents_for_item"
|
||||||
|
);
|
||||||
|
const overlap = extractAddressFilters(
|
||||||
|
"Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад?",
|
||||||
|
"inventory_supplier_stock_overlap_as_of_date"
|
||||||
|
);
|
||||||
|
const saleTrace = extractAddressFilters("Кому был продан товар Шкаф картотечный 1000*400*2100?", "inventory_sale_trace_for_item");
|
||||||
|
const chain = extractAddressFilters(
|
||||||
|
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа?",
|
||||||
|
"inventory_purchase_to_sale_chain"
|
||||||
|
);
|
||||||
|
const aging = extractAddressFilters(
|
||||||
|
"Есть ли среди текущих остатков на складе Основной склад позиции, закупленные очень давно?",
|
||||||
|
"inventory_aging_by_purchase_date"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onHand.extracted_filters.limit).toBeUndefined();
|
||||||
|
expect(provenance.extracted_filters.limit).toBeUndefined();
|
||||||
|
expect(purchaseDocs.extracted_filters.limit).toBeUndefined();
|
||||||
|
expect(overlap.extracted_filters.limit).toBeUndefined();
|
||||||
|
expect(saleTrace.extracted_filters.limit).toBeUndefined();
|
||||||
|
expect(chain.extracted_filters.limit).toBeUndefined();
|
||||||
|
expect(aging.extracted_filters.limit).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("selects customer value recipe and keeps top-20 default", () => {
|
it("selects customer value recipe and keeps top-20 default", () => {
|
||||||
const selected = selectAddressRecipe("customer_revenue_and_payments", {});
|
const selected = selectAddressRecipe("customer_revenue_and_payments", {});
|
||||||
expect(selected.selected_recipe).toBeTruthy();
|
expect(selected.selected_recipe).toBeTruthy();
|
||||||
|
|
@ -4339,6 +4588,52 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(result.confidence).toBe("high");
|
expect(result.confidence).toBe("high");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects colloquial warehouse snapshot wording as inventory-on-hand intent", () => {
|
||||||
|
const result = resolveAddressIntent("что у нас на складе на март 2017");
|
||||||
|
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||||
|
expect(result.confidence).toBe("high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes account 41 composition wording into inventory snapshot intent", () => {
|
||||||
|
const result = resolveAddressIntent("Из каких товаров состоит остаток по 41 счету");
|
||||||
|
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes account 41 date snapshot wording into inventory snapshot intent", () => {
|
||||||
|
const result = resolveAddressIntent("Какие товары числятся на 41 счете на дату 2020-03-31");
|
||||||
|
expect(result.intent).toBe("inventory_on_hand_as_of_date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes supplier stock overlap wording into overlap intent", () => {
|
||||||
|
const result = resolveAddressIntent("Какие товары от поставщика Гамма-мебель, ООО сейчас еще лежат на складе Основной склад");
|
||||||
|
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes supplier to buyer chain wording into purchase-to-sale chain intent", () => {
|
||||||
|
const result = resolveAddressIntent(
|
||||||
|
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"
|
||||||
|
);
|
||||||
|
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes supplier-to-buyer inventory chains as exact trace intents", () => {
|
||||||
|
const result = resolveAddressIntent(
|
||||||
|
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||||
|
expect(result.confidence).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes old purchase residue questions to aging-by-purchase-date", () => {
|
||||||
|
const result = resolveAddressIntent(
|
||||||
|
"Относится ли товар Шкаф картотечный 1000*400*2100 в остатке на дату 2020-03-31 к старым закупкам"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||||
|
expect(result.confidence).toBe("high");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives as_of_date for inventory-on-hand from explicit month window", () => {
|
it("derives as_of_date for inventory-on-hand from explicit month window", () => {
|
||||||
const filters = extractAddressFilters(
|
const filters = extractAddressFilters(
|
||||||
"Какие товары лежат на складе на март 2020",
|
"Какие товары лежат на складе на март 2020",
|
||||||
|
|
@ -4400,6 +4695,13 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps direct item supplier questions in provenance intent even with current-stock tail", () => {
|
||||||
|
const result = resolveAddressIntent(
|
||||||
|
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад?"
|
||||||
|
);
|
||||||
|
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps inventory supplier overlap questions out of on-hand routing", () => {
|
it("keeps inventory supplier overlap questions out of on-hand routing", () => {
|
||||||
const result = resolveAddressIntent("Какие товары от поставщика Альфа сейчас лежат на складе?");
|
const result = resolveAddressIntent("Какие товары от поставщика Альфа сейчас лежат на складе?");
|
||||||
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||||
|
|
@ -4414,7 +4716,7 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
const result = resolveAddressIntent(
|
const result = resolveAddressIntent(
|
||||||
"Через какие документы прошел путь товара Шкаф картоотечный: закупка -> склад -> продажа?"
|
"Через какие документы прошел путь товара Шкаф картоотечный: закупка -> склад -> продажа?"
|
||||||
);
|
);
|
||||||
expect(result.intent).toBe("inventory_sale_trace_for_item");
|
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps inventory provenance wording out of inventory-on-hand routing", () => {
|
it("keeps inventory provenance wording out of inventory-on-hand routing", () => {
|
||||||
|
|
@ -4427,6 +4729,16 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps very old purchase wording out of on-hand routing", () => {
|
||||||
|
const result = resolveAddressIntent("Относится ли товар к закупленным задолго до даты остатка на складе?");
|
||||||
|
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes old stock wording with residue anchor to aging intent", () => {
|
||||||
|
const result = resolveAddressIntent("Это остаток по очень давно купленному товару?");
|
||||||
|
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||||
|
});
|
||||||
|
|
||||||
it("routes purchase-document trace wording to dedicated inventory intent", () => {
|
it("routes purchase-document trace wording to dedicated inventory intent", () => {
|
||||||
const result = resolveAddressIntent("Какими документами был куплен товар Шкаф картоотечный?");
|
const result = resolveAddressIntent("Какими документами был куплен товар Шкаф картоотечный?");
|
||||||
expect(result.intent).toBe("inventory_purchase_documents_for_item");
|
expect(result.intent).toBe("inventory_purchase_documents_for_item");
|
||||||
|
|
@ -4436,9 +4748,33 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||||
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes residue wording with explicit cut-off date into aging intent", () => {
|
||||||
|
const result = resolveAddressIntent(
|
||||||
|
"Есть ли среди текущих остатков на складе Основной склад позиции, закупленные задолго до 2020-03-31?"
|
||||||
|
);
|
||||||
|
expect(result.intent).toBe("inventory_aging_by_purchase_date");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps unresolved stock provenance wording out of open-items routing", () => {
|
it("keeps unresolved stock provenance wording out of open-items routing", () => {
|
||||||
const result = resolveAddressIntent("Какие товары сейчас висят в остатке без понятной привязки к поставщику?");
|
const result = resolveAddressIntent("Какие товары сейчас висят в остатке без понятной привязки к поставщику?");
|
||||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
expect(result.intent).toBe("inventory_supplier_stock_overlap_as_of_date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes documentary supplier-to-buyer chain wording into inventory chain intent", () => {
|
||||||
|
const result = resolveAddressIntent(
|
||||||
|
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы?"
|
||||||
|
);
|
||||||
|
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes explicit supplier-item-buyer chain wording into inventory chain intent", () => {
|
||||||
|
const result = resolveAddressIntent("supplier -> item -> buyer");
|
||||||
|
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes documented supplier-item-buyer chain wording into inventory chain intent", () => {
|
||||||
|
const result = resolveAddressIntent("Документально подтвержденная цепочка: поставщик -> товар -> покупатель");
|
||||||
|
expect(result.intent).toBe("inventory_purchase_to_sale_chain");
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,102 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps colloquial warehouse snapshot questions in exact inventory contour when llm rewrites them abstractly", async () => {
|
||||||
|
const calls: Array<{ message: string }> = [];
|
||||||
|
const addressQueryService = {
|
||||||
|
tryHandle: vi.fn(async (message: string) => {
|
||||||
|
calls.push({ message });
|
||||||
|
return buildAddressLaneResult(message);
|
||||||
|
})
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const normalizerService = {
|
||||||
|
normalize: vi.fn(async () => ({
|
||||||
|
trace_id: "norm-predecompose-inventory-colloquial",
|
||||||
|
ok: true,
|
||||||
|
normalized: {
|
||||||
|
schema_version: "normalized_query_v2_0_2",
|
||||||
|
user_message_raw: "что у нас на складе на март 2017",
|
||||||
|
message_in_scope: true,
|
||||||
|
scope_confidence: "medium",
|
||||||
|
contains_multiple_tasks: false,
|
||||||
|
fragments: [
|
||||||
|
{
|
||||||
|
fragment_id: "F1",
|
||||||
|
raw_fragment_text: "что у нас на складе на март 2017",
|
||||||
|
normalized_fragment_text: "информация о наличии товаров на складе за март 2017 года",
|
||||||
|
domain_relevance: "in_scope",
|
||||||
|
business_scope: "company_specific_accounting",
|
||||||
|
entity_hints: [],
|
||||||
|
account_hints: [],
|
||||||
|
document_hints: [],
|
||||||
|
register_hints: [],
|
||||||
|
time_scope: {
|
||||||
|
type: "explicit",
|
||||||
|
value: "2017-03",
|
||||||
|
confidence: "high"
|
||||||
|
},
|
||||||
|
flags: {
|
||||||
|
has_multi_entity_scope: false,
|
||||||
|
asks_for_chain_explanation: false,
|
||||||
|
asks_for_ranking_or_top: false,
|
||||||
|
asks_for_period_summary: false,
|
||||||
|
asks_for_rule_check: false,
|
||||||
|
asks_for_anomaly_scan: false,
|
||||||
|
asks_for_exact_object_trace: false,
|
||||||
|
asks_for_evidence: false,
|
||||||
|
mentions_period_close_context: false
|
||||||
|
},
|
||||||
|
candidate_labels: ["simple_factual"],
|
||||||
|
confidence: "medium",
|
||||||
|
execution_readiness: "executable",
|
||||||
|
clarification_reason: null,
|
||||||
|
soft_assumption_used: [],
|
||||||
|
route_status: "routed",
|
||||||
|
no_route_reason: null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
discarded_fragments: [],
|
||||||
|
global_notes: {
|
||||||
|
needs_clarification: false,
|
||||||
|
clarification_reason: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
raw_model_output: null,
|
||||||
|
validation: { passed: true, errors: [] },
|
||||||
|
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||||
|
latency_ms: 10,
|
||||||
|
prompt_version: "normalizer_v2_0_2",
|
||||||
|
schema_version: "v2_0_2",
|
||||||
|
request_count_for_case: 1
|
||||||
|
}))
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const sessions = new AssistantSessionStore();
|
||||||
|
const service = new AssistantService(
|
||||||
|
normalizerService,
|
||||||
|
sessions as any,
|
||||||
|
{} as any,
|
||||||
|
{ persistSession: vi.fn() } as any,
|
||||||
|
addressQueryService
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await service.handleMessage({
|
||||||
|
session_id: `asst-predecompose-inventory-colloquial-${Date.now()}`,
|
||||||
|
user_message: "что у нас на складе на март 2017",
|
||||||
|
llmProvider: "local",
|
||||||
|
useMock: false
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
expect(response.reply_type).toBe("factual");
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].message.toLowerCase()).toContain("складе");
|
||||||
|
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
|
||||||
|
expect(response.debug?.llm_predecompose_contract?.intent).toBe("inventory_on_hand_as_of_date");
|
||||||
|
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
|
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
|
||||||
const calls: Array<{ message: string }> = [];
|
const calls: Array<{ message: string }> = [];
|
||||||
const addressQueryService = {
|
const addressQueryService = {
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,36 @@ describe("assistant orchestration contract", () => {
|
||||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps inventory provenance and sale-trace queries in address lane", () => {
|
||||||
|
const decision = resolveAssistantOrchestrationDecision({
|
||||||
|
rawUserMessage: "От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
|
||||||
|
effectiveAddressUserMessage: "От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
|
||||||
|
followupContext: {
|
||||||
|
previous_intent: "inventory_purchase_provenance_for_item",
|
||||||
|
previous_filters: {
|
||||||
|
as_of_date: "2020-03-31"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
llmPreDecomposeMeta: {
|
||||||
|
applied: true,
|
||||||
|
llmCanonicalCandidateDetected: true,
|
||||||
|
predecomposeContract: {
|
||||||
|
mode: "address_query",
|
||||||
|
mode_confidence: "high",
|
||||||
|
intent: "inventory_purchase_provenance_for_item",
|
||||||
|
intent_confidence: "high"
|
||||||
|
}
|
||||||
|
} as any,
|
||||||
|
useMock: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decision.runAddressLane).toBe(true);
|
||||||
|
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||||
|
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
|
||||||
|
expect(decision.livingMode).toBe("address_data");
|
||||||
|
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps customer-value ranking question in address lane even when LLM semantic guard rejects canonical rewrite", () => {
|
it("keeps customer-value ranking question in address lane even when LLM semantic guard rejects canonical rewrite", () => {
|
||||||
const decision = resolveAssistantOrchestrationDecision({
|
const decision = resolveAssistantOrchestrationDecision({
|
||||||
rawUserMessage: "кто больше всего принес денег в 2020",
|
rawUserMessage: "кто больше всего принес денег в 2020",
|
||||||
|
|
@ -558,6 +588,47 @@ describe("assistant orchestration contract", () => {
|
||||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
|
||||||
|
const question =
|
||||||
|
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";
|
||||||
|
const decision = resolveAssistantOrchestrationDecision({
|
||||||
|
rawUserMessage: question,
|
||||||
|
effectiveAddressUserMessage: question,
|
||||||
|
followupContext: null,
|
||||||
|
llmPreDecomposeMeta: {
|
||||||
|
applied: true,
|
||||||
|
llmCanonicalCandidateDetected: true,
|
||||||
|
predecomposeContract: {
|
||||||
|
mode: "deep_analysis",
|
||||||
|
mode_confidence: "high",
|
||||||
|
intent: "inventory_purchase_to_sale_chain",
|
||||||
|
intent_confidence: "medium"
|
||||||
|
},
|
||||||
|
semanticExtractionContract: {
|
||||||
|
valid: true,
|
||||||
|
apply_canonical_recommended: true,
|
||||||
|
reason_codes: ["deep_investigation_signal_detected"],
|
||||||
|
guard_hints: {
|
||||||
|
deep_investigation_signal_detected: true
|
||||||
|
},
|
||||||
|
extraction: {
|
||||||
|
query_shape: "VERIFY_FACTUAL",
|
||||||
|
aggregation_profile: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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");
|
||||||
|
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
|
||||||
|
expect(decision.orchestrationContract?.semantic_route_arbitration?.supported_address_intent_detected).toBe(true);
|
||||||
|
expect(decision.orchestrationContract?.semantic_route_arbitration?.strict_deep_investigation_bypass_allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps 'a na tekushuyu datu' follow-up in address lane when previous VAT context exists", () => {
|
it("keeps 'a na tekushuyu datu' follow-up in address lane when previous VAT context exists", () => {
|
||||||
const decision = resolveAssistantOrchestrationDecision({
|
const decision = resolveAssistantOrchestrationDecision({
|
||||||
rawUserMessage: "а на текущую дату",
|
rawUserMessage: "а на текущую дату",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"suite_id": "assistant_autogen_runtime_job-X-D_QsIwS1",
|
||||||
|
"suite_version": "0.1.0",
|
||||||
|
"schema_version": "assistant_autogen_runtime_v0_1",
|
||||||
|
"scenario_count": 1,
|
||||||
|
"case_ids": [
|
||||||
|
"AUTO-001"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"case_id": "AUTO-001",
|
||||||
|
"scenario_tag": "autogen_runtime",
|
||||||
|
"question_type": "direct",
|
||||||
|
"broadness_level": "medium",
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"user_message": "Какие товары лежат на складе на март 2017"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"suite_id": "assistant_autogen_runtime_job-Ypd6PvQH49",
|
||||||
|
"suite_version": "0.1.0",
|
||||||
|
"schema_version": "assistant_autogen_runtime_v0_1",
|
||||||
|
"scenario_count": 1,
|
||||||
|
"case_ids": [
|
||||||
|
"AUTO-001"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"case_id": "AUTO-001",
|
||||||
|
"scenario_tag": "autogen_runtime",
|
||||||
|
"question_type": "direct",
|
||||||
|
"broadness_level": "medium",
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"user_message": "Какие товары лежат на складе на 15 марта 2020"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"suite_id": "assistant_autogen_runtime_job-iFZBx9sfg7",
|
||||||
|
"suite_version": "0.1.0",
|
||||||
|
"schema_version": "assistant_autogen_runtime_v0_1",
|
||||||
|
"scenario_count": 1,
|
||||||
|
"case_ids": [
|
||||||
|
"AUTO-001"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"case_id": "AUTO-001",
|
||||||
|
"scenario_tag": "autogen_runtime",
|
||||||
|
"question_type": "direct",
|
||||||
|
"broadness_level": "medium",
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"user_message": "Какие товары лежат на складе на март 2019"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"suite_id": "assistant_autogen_runtime_job-ygtwpBa5EM",
|
||||||
|
"suite_version": "0.1.0",
|
||||||
|
"schema_version": "assistant_autogen_runtime_v0_1",
|
||||||
|
"scenario_count": 1,
|
||||||
|
"case_ids": [
|
||||||
|
"AUTO-001"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"case_id": "AUTO-001",
|
||||||
|
"scenario_tag": "autogen_runtime",
|
||||||
|
"question_type": "direct",
|
||||||
|
"broadness_level": "medium",
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"user_message": "что у нас на складе на март 2017"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NDC AI Normalizer Playground</title>
|
<title>NDC AI Normalizer Playground</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CEJTyo9A.js"></script>
|
<script type="module" crossorigin src="/assets/index-B9mz4jx4.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Cx2J_KsF.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dcuz1nX5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { designConfig } from "../../../designconfig";
|
||||||
import type {
|
import type {
|
||||||
AssistantConversationItem,
|
AssistantConversationItem,
|
||||||
AssistantAnnotationRecord,
|
AssistantAnnotationRecord,
|
||||||
|
AssistantSelectionChip,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
NormalizeResultState,
|
NormalizeResultState,
|
||||||
|
|
@ -60,6 +61,25 @@ function diffPrompts(current: PromptState, previous: PromptState | null): string
|
||||||
return `Changed fields: ${changed.length}. ${changed.join(" | ")}`;
|
return `Changed fields: ${changed.length}. ${changed.join(" | ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAssistantFollowupMessage(inputValue: string, selectedChip: AssistantSelectionChip | null): string {
|
||||||
|
const trimmedInput = inputValue.trim();
|
||||||
|
if (!trimmedInput) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!selectedChip) {
|
||||||
|
return trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedInput = trimmedInput.toLowerCase();
|
||||||
|
const selectionAnchor = selectedChip.anchor_text.trim();
|
||||||
|
const normalizedSelection = selectionAnchor.toLowerCase();
|
||||||
|
if (normalizedSelection && normalizedInput.includes(normalizedSelection)) {
|
||||||
|
return trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `По выбранному объекту "${selectionAnchor}": ${trimmedInput}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [connection, setConnection] = useState<ConnectionState>(DEFAULT_CONNECTION);
|
const [connection, setConnection] = useState<ConnectionState>(DEFAULT_CONNECTION);
|
||||||
const [prompts, setPrompts] = useState<PromptState>(DEFAULT_PROMPTS);
|
const [prompts, setPrompts] = useState<PromptState>(DEFAULT_PROMPTS);
|
||||||
|
|
@ -116,6 +136,7 @@ export default function App() {
|
||||||
const [assistantSessionId, setAssistantSessionId] = useState("");
|
const [assistantSessionId, setAssistantSessionId] = useState("");
|
||||||
const [assistantConversation, setAssistantConversation] = useState<AssistantConversationItem[]>([]);
|
const [assistantConversation, setAssistantConversation] = useState<AssistantConversationItem[]>([]);
|
||||||
const [assistantInput, setAssistantInput] = useState("");
|
const [assistantInput, setAssistantInput] = useState("");
|
||||||
|
const [assistantSelectedChip, setAssistantSelectedChip] = useState<AssistantSelectionChip | null>(null);
|
||||||
const [assistantBusy, setAssistantBusy] = useState(false);
|
const [assistantBusy, setAssistantBusy] = useState(false);
|
||||||
const [assistantStatus, setAssistantStatus] = useState("");
|
const [assistantStatus, setAssistantStatus] = useState("");
|
||||||
const [assistantError, setAssistantError] = useState("");
|
const [assistantError, setAssistantError] = useState("");
|
||||||
|
|
@ -140,6 +161,10 @@ export default function App() {
|
||||||
root.style.setProperty("--rgb-surface-main", colors.mainSurfaceRgb);
|
root.style.setProperty("--rgb-surface-main", colors.mainSurfaceRgb);
|
||||||
root.style.setProperty("--rgb-surface-horizontal", colors.horizontalSurfaceRgb);
|
root.style.setProperty("--rgb-surface-horizontal", colors.horizontalSurfaceRgb);
|
||||||
root.style.setProperty("--rgb-surface-focus", colors.focusSurfaceRgb);
|
root.style.setProperty("--rgb-surface-focus", colors.focusSurfaceRgb);
|
||||||
|
root.style.setProperty("--rgb-assistant-chip", colors.assistantChipRgb);
|
||||||
|
root.style.setProperty("--rgb-assistant-chip-hover", colors.assistantChipHoverRgb);
|
||||||
|
root.style.setProperty("--rgb-assistant-chip-selected", colors.assistantChipSelectedRgb);
|
||||||
|
root.style.setProperty("--rgb-assistant-chip-selected-text", colors.assistantChipSelectedTextRgb);
|
||||||
root.style.setProperty("--rgb-active", colors.activeRgb);
|
root.style.setProperty("--rgb-active", colors.activeRgb);
|
||||||
root.style.setProperty("--rgb-active-text", colors.activeTextRgb);
|
root.style.setProperty("--rgb-active-text", colors.activeTextRgb);
|
||||||
root.style.setProperty("--rgb-text-main", colors.textMainRgb);
|
root.style.setProperty("--rgb-text-main", colors.textMainRgb);
|
||||||
|
|
@ -785,6 +810,7 @@ export default function App() {
|
||||||
setAssistantSessionId("");
|
setAssistantSessionId("");
|
||||||
setAssistantConversation([]);
|
setAssistantConversation([]);
|
||||||
setAssistantInput("");
|
setAssistantInput("");
|
||||||
|
setAssistantSelectedChip(null);
|
||||||
setAssistantStatus("");
|
setAssistantStatus("");
|
||||||
setAssistantError("");
|
setAssistantError("");
|
||||||
setAssistantAnnotations([]);
|
setAssistantAnnotations([]);
|
||||||
|
|
@ -793,7 +819,7 @@ export default function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendAssistantMessage() {
|
async function sendAssistantMessage() {
|
||||||
const userMessage = assistantInput.trim();
|
const userMessage = buildAssistantFollowupMessage(assistantInput, assistantSelectedChip);
|
||||||
if (!userMessage) {
|
if (!userMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1035,6 +1061,9 @@ export default function App() {
|
||||||
conversation={assistantConversation}
|
conversation={assistantConversation}
|
||||||
inputValue={assistantInput}
|
inputValue={assistantInput}
|
||||||
onInputChange={setAssistantInput}
|
onInputChange={setAssistantInput}
|
||||||
|
selectedContextChip={assistantSelectedChip}
|
||||||
|
onSelectContextChip={setAssistantSelectedChip}
|
||||||
|
onClearContextChip={() => setAssistantSelectedChip(null)}
|
||||||
useMock={useMock}
|
useMock={useMock}
|
||||||
onUseMockChange={setUseMock}
|
onUseMockChange={setUseMock}
|
||||||
onSend={sendAssistantMessage}
|
onSend={sendAssistantMessage}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { AssistantConversationItem } from "../state/types";
|
import type { AssistantConversationItem, AssistantSelectionChip } from "../state/types";
|
||||||
import { buildConversationExportForCopy } from "../utils/conversationExport";
|
import { buildConversationExportForCopy } from "../utils/conversationExport";
|
||||||
import { JsonView } from "./JsonView";
|
import { JsonView } from "./JsonView";
|
||||||
import { PanelFrame } from "./PanelFrame";
|
import { PanelFrame } from "./PanelFrame";
|
||||||
|
|
@ -9,6 +9,9 @@ interface AssistantPanelProps {
|
||||||
conversation: AssistantConversationItem[];
|
conversation: AssistantConversationItem[];
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
|
selectedContextChip: AssistantSelectionChip | null;
|
||||||
|
onSelectContextChip: (value: AssistantSelectionChip) => void;
|
||||||
|
onClearContextChip: () => void;
|
||||||
useMock: boolean;
|
useMock: boolean;
|
||||||
onUseMockChange: (value: boolean) => void;
|
onUseMockChange: (value: boolean) => void;
|
||||||
onSend: () => Promise<void> | void;
|
onSend: () => Promise<void> | void;
|
||||||
|
|
@ -147,17 +150,134 @@ function lineClassName(line: string): string {
|
||||||
return "assistant-msg-line";
|
return "assistant-msg-line";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAssistantMessageBody(text: string): JSX.Element[] {
|
function buildSelectionPreview(label: string, maxChars = 40): string {
|
||||||
const blocks = splitAssistantMessageBlocks(text);
|
const normalized = label.replace(/\s+/g, " ").trim();
|
||||||
|
if (normalized.length <= maxChars) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstWords = normalized.split(" ").slice(0, 3).join(" ").trim();
|
||||||
|
if (firstWords.length >= 10 && firstWords.length <= maxChars) {
|
||||||
|
return `${firstWords}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripBlockSelectionPrefix(line: string): string {
|
||||||
|
return line.replace(/\*\*(.+?)\*\*/g, "$1").replace(/^\d+\.\s*/, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSelectionAnchorText(block: string): string {
|
||||||
|
const firstLine = block
|
||||||
|
.replace(/\r\n?/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
const normalizedFirstLine = stripBlockSelectionPrefix(firstLine ?? "");
|
||||||
|
const primarySegment = normalizedFirstLine.split("|")[0]?.trim() ?? normalizedFirstLine;
|
||||||
|
return primarySegment.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectableEntityBlock(block: string): boolean {
|
||||||
|
const firstLine = block
|
||||||
|
.replace(/\r\n?/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
if (!firstLine || !/^\d+\.\s/.test(firstLine)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedFirstLine = stripBlockSelectionPrefix(firstLine);
|
||||||
|
return normalizedFirstLine.includes("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectionChipForBlock(item: AssistantConversationItem, block: string): AssistantSelectionChip | null {
|
||||||
|
const normalizedBlock = block
|
||||||
|
.replace(/\r\n?/g, "\n")
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||||
|
.split("\n")
|
||||||
|
.map((line, index) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (index === 0) {
|
||||||
|
return trimmed.replace(/^\d+\.\s*/, "");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!normalizedBlock) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchorText = extractSelectionAnchorText(block) || normalizedBlock;
|
||||||
|
return {
|
||||||
|
message_id: item.message_id,
|
||||||
|
source_text: normalizedBlock,
|
||||||
|
anchor_text: anchorText,
|
||||||
|
preview_text: buildSelectionPreview(anchorText)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssistantMessageBody(
|
||||||
|
item: AssistantConversationItem,
|
||||||
|
selectedContextChip: AssistantSelectionChip | null,
|
||||||
|
onSelectContextChip: (value: AssistantSelectionChip) => void,
|
||||||
|
onClearContextChip: () => void
|
||||||
|
): JSX.Element[] {
|
||||||
|
const blocks = splitAssistantMessageBlocks(item.text);
|
||||||
return blocks.map((block, blockIndex) => {
|
return blocks.map((block, blockIndex) => {
|
||||||
const lines = block.split("\n");
|
const lines = block.split("\n");
|
||||||
|
const isSelectable = item.role === "assistant" && isSelectableEntityBlock(block);
|
||||||
|
const selectionChip = isSelectable ? buildSelectionChipForBlock(item, block) : null;
|
||||||
|
const selected =
|
||||||
|
Boolean(selectionChip) &&
|
||||||
|
selectedContextChip?.message_id === selectionChip?.message_id &&
|
||||||
|
selectedContextChip?.source_text === selectionChip?.source_text;
|
||||||
|
|
||||||
|
const body = lines.map((line, lineIndex) => (
|
||||||
|
<p key={`line-${blockIndex}-${lineIndex}`} className={lineClassName(line)}>
|
||||||
|
{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!isSelectable || !selectionChip) {
|
||||||
|
return (
|
||||||
|
<div key={`block-${blockIndex}`} className="assistant-msg-block">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`block-${blockIndex}`} className="assistant-msg-block">
|
<div
|
||||||
{lines.map((line, lineIndex) => (
|
key={`block-${blockIndex}`}
|
||||||
<p key={`line-${blockIndex}-${lineIndex}`} className={lineClassName(line)}>
|
className={selected ? "assistant-msg-block selectable active" : "assistant-msg-block selectable"}
|
||||||
{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}
|
role="button"
|
||||||
</p>
|
tabIndex={0}
|
||||||
))}
|
onClick={() => {
|
||||||
|
if (selected) {
|
||||||
|
onClearContextChip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectContextChip(selectionChip);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (selected) {
|
||||||
|
onClearContextChip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectContextChip(selectionChip);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -168,6 +288,9 @@ export function AssistantPanel({
|
||||||
conversation,
|
conversation,
|
||||||
inputValue,
|
inputValue,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
|
selectedContextChip,
|
||||||
|
onSelectContextChip,
|
||||||
|
onClearContextChip,
|
||||||
useMock,
|
useMock,
|
||||||
onUseMockChange,
|
onUseMockChange,
|
||||||
onSend,
|
onSend,
|
||||||
|
|
@ -316,7 +439,9 @@ export function AssistantPanel({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
<div className="assistant-msg-body">{renderAssistantMessageBody(item.text)}</div>
|
<div className="assistant-msg-body">
|
||||||
|
{renderAssistantMessageBody(item, selectedContextChip, onSelectContextChip, onClearContextChip)}
|
||||||
|
</div>
|
||||||
{item.role === "assistant" && item.debug ? (
|
{item.role === "assistant" && item.debug ? (
|
||||||
<details className="assistant-debug">
|
<details className="assistant-debug">
|
||||||
<summary>Показать технический разбор</summary>
|
<summary>Показать технический разбор</summary>
|
||||||
|
|
@ -329,6 +454,23 @@ export function AssistantPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="assistant-compose">
|
<div className="assistant-compose">
|
||||||
|
{selectedContextChip ? (
|
||||||
|
<div className="assistant-compose-context">
|
||||||
|
<span className="assistant-compose-context-label">Выбранный объект</span>
|
||||||
|
<div className="assistant-compose-context-pill" title={selectedContextChip.source_text}>
|
||||||
|
<span className="assistant-compose-context-pill-text">{selectedContextChip.preview_text}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="assistant-compose-context-clear"
|
||||||
|
onClick={onClearContextChip}
|
||||||
|
aria-label="Убрать выбранный объект"
|
||||||
|
title="Убрать выбранный объект"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<label className="full-width">
|
<label className="full-width">
|
||||||
Сообщение
|
Сообщение
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -336,7 +478,11 @@ export function AssistantPanel({
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(event) => onInputChange(event.target.value)}
|
onChange={(event) => onInputChange(event.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="Введите вопрос к данным компании..."
|
placeholder={
|
||||||
|
selectedContextChip
|
||||||
|
? "Продолжите вопрос по выбранному объекту..."
|
||||||
|
: "Введите вопрос к данным компании..."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="button-row assistant-send-row">
|
<div className="button-row assistant-send-row">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { apiClient } from "../api/client";
|
||||||
import type {
|
import type {
|
||||||
AssistantConversationItem,
|
AssistantConversationItem,
|
||||||
AssistantAnnotationRecord,
|
AssistantAnnotationRecord,
|
||||||
|
AssistantSelectionChip,
|
||||||
AsyncEvalRunJob,
|
AsyncEvalRunJob,
|
||||||
AutoGenHistoryRecord,
|
AutoGenHistoryRecord,
|
||||||
AutoGenMode,
|
AutoGenMode,
|
||||||
|
|
@ -106,6 +107,25 @@ const AUTORUNS_UI_CONFIG_KEY = "ndc_autoruns_ui_config_v1";
|
||||||
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
|
const AUTORUNS_SAVE_EVENT = "ndc-autoruns-save";
|
||||||
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
|
const ASSISTANT_STAGES = ["Анализ запроса", "Получение данных", "Подготовка ответа"];
|
||||||
|
|
||||||
|
function buildAssistantLiveFollowupMessage(inputValue: string, selectedChip: AssistantSelectionChip | null): string {
|
||||||
|
const trimmedInput = inputValue.trim();
|
||||||
|
if (!trimmedInput) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!selectedChip) {
|
||||||
|
return trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedInput = trimmedInput.toLowerCase();
|
||||||
|
const selectionAnchor = selectedChip.anchor_text.trim();
|
||||||
|
const normalizedSelection = selectionAnchor.toLowerCase();
|
||||||
|
if (normalizedSelection && normalizedInput.includes(normalizedSelection)) {
|
||||||
|
return trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `По выбранному объекту "${selectionAnchor}": ${trimmedInput}`;
|
||||||
|
}
|
||||||
|
|
||||||
const AUTOGEN_PERSONALITIES: AutoGenPersonalityDefinition[] = [
|
const AUTOGEN_PERSONALITIES: AutoGenPersonalityDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
|
|
@ -496,6 +516,7 @@ export function AutoRunsHistoryPanel({
|
||||||
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
|
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
|
||||||
const [assistantLiveAnnotations, setAssistantLiveAnnotations] = useState<AssistantAnnotationRecord[]>([]);
|
const [assistantLiveAnnotations, setAssistantLiveAnnotations] = useState<AssistantAnnotationRecord[]>([]);
|
||||||
const [assistantLiveInput, setAssistantLiveInput] = useState("");
|
const [assistantLiveInput, setAssistantLiveInput] = useState("");
|
||||||
|
const [assistantLiveSelectedChip, setAssistantLiveSelectedChip] = useState<AssistantSelectionChip | null>(null);
|
||||||
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
|
const [assistantLiveUseMock, setAssistantLiveUseMock] = useState(false);
|
||||||
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
|
const [assistantLiveBusy, setAssistantLiveBusy] = useState(false);
|
||||||
const [assistantLiveStatus, setAssistantLiveStatus] = useState("");
|
const [assistantLiveStatus, setAssistantLiveStatus] = useState("");
|
||||||
|
|
@ -704,6 +725,7 @@ export function AutoRunsHistoryPanel({
|
||||||
setAssistantLiveConversation([]);
|
setAssistantLiveConversation([]);
|
||||||
setAssistantLiveAnnotations([]);
|
setAssistantLiveAnnotations([]);
|
||||||
setAssistantLiveInput("");
|
setAssistantLiveInput("");
|
||||||
|
setAssistantLiveSelectedChip(null);
|
||||||
setAssistantLiveStatus("");
|
setAssistantLiveStatus("");
|
||||||
setAssistantLiveError("");
|
setAssistantLiveError("");
|
||||||
closeAssistantLiveCommentModal({ force: true });
|
closeAssistantLiveCommentModal({ force: true });
|
||||||
|
|
@ -711,7 +733,7 @@ export function AutoRunsHistoryPanel({
|
||||||
}, [closeAssistantLiveCommentModal, log]);
|
}, [closeAssistantLiveCommentModal, log]);
|
||||||
|
|
||||||
const sendAssistantLiveMessage = useCallback(async () => {
|
const sendAssistantLiveMessage = useCallback(async () => {
|
||||||
const userMessage = assistantLiveInput.trim();
|
const userMessage = buildAssistantLiveFollowupMessage(assistantLiveInput, assistantLiveSelectedChip);
|
||||||
if (!userMessage) {
|
if (!userMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -759,6 +781,7 @@ export function AutoRunsHistoryPanel({
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
assistantLiveInput,
|
assistantLiveInput,
|
||||||
|
assistantLiveSelectedChip,
|
||||||
assistantLiveSessionId,
|
assistantLiveSessionId,
|
||||||
assistantLiveUseMock,
|
assistantLiveUseMock,
|
||||||
assistantPromptVersion,
|
assistantPromptVersion,
|
||||||
|
|
@ -2302,6 +2325,9 @@ export function AutoRunsHistoryPanel({
|
||||||
conversation={assistantLiveConversation}
|
conversation={assistantLiveConversation}
|
||||||
inputValue={assistantLiveInput}
|
inputValue={assistantLiveInput}
|
||||||
onInputChange={setAssistantLiveInput}
|
onInputChange={setAssistantLiveInput}
|
||||||
|
selectedContextChip={assistantLiveSelectedChip}
|
||||||
|
onSelectContextChip={setAssistantLiveSelectedChip}
|
||||||
|
onClearContextChip={() => setAssistantLiveSelectedChip(null)}
|
||||||
useMock={assistantLiveUseMock}
|
useMock={assistantLiveUseMock}
|
||||||
onUseMockChange={setAssistantLiveUseMock}
|
onUseMockChange={setAssistantLiveUseMock}
|
||||||
onSend={sendAssistantLiveMessage}
|
onSend={sendAssistantLiveMessage}
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,13 @@ export interface AssistantConversationItem {
|
||||||
debug: AssistantDebugState | null;
|
debug: AssistantDebugState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssistantSelectionChip {
|
||||||
|
message_id: string;
|
||||||
|
source_text: string;
|
||||||
|
anchor_text: string;
|
||||||
|
preview_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssistantMessageResultState {
|
export interface AssistantMessageResultState {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
--rgb-surface-main: 26, 26, 31;
|
--rgb-surface-main: 26, 26, 31;
|
||||||
--rgb-surface-horizontal: 32, 32, 38;
|
--rgb-surface-horizontal: 32, 32, 38;
|
||||||
--rgb-surface-focus: 40, 40, 47;
|
--rgb-surface-focus: 40, 40, 47;
|
||||||
|
--rgb-assistant-chip: 18, 18, 18;
|
||||||
|
--rgb-assistant-chip-hover: 44, 44, 44;
|
||||||
|
--rgb-assistant-chip-selected: 228, 142, 92;
|
||||||
|
--rgb-assistant-chip-selected-text: 18, 18, 18;
|
||||||
--rgb-active: 228, 142, 92;
|
--rgb-active: 228, 142, 92;
|
||||||
--rgb-active-text: 18, 18, 18;
|
--rgb-active-text: 18, 18, 18;
|
||||||
--rgb-text-main: 240, 240, 240;
|
--rgb-text-main: 240, 240, 240;
|
||||||
|
|
@ -486,6 +490,7 @@ button:disabled {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-msg.user {
|
.assistant-msg.user {
|
||||||
|
|
@ -552,17 +557,48 @@ button:disabled {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-msg-block {
|
.assistant-msg-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-msg-block.selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 0.18s ease, color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-msg-block.selectable:hover,
|
||||||
|
.assistant-msg-block.selectable:focus-visible {
|
||||||
|
background: rgba(var(--rgb-active), 0.18);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-msg-block.selectable.active {
|
||||||
|
background: rgb(var(--rgb-active));
|
||||||
|
color: rgb(var(--rgb-active-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-msg-block.selectable.active:hover,
|
||||||
|
.assistant-msg-block.selectable.active:focus-visible {
|
||||||
|
background: rgb(var(--rgb-active));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-msg-block.selectable.active .assistant-msg-line,
|
||||||
|
.assistant-msg-block.selectable.active .assistant-msg-line strong {
|
||||||
|
color: rgb(var(--rgb-active-text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-msg-line {
|
.assistant-msg-line {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-msg-line.heading {
|
.assistant-msg-line.heading {
|
||||||
|
|
@ -601,6 +637,63 @@ button:disabled {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-compose-context {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgb(var(--rgb-surface-horizontal));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-compose-context-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-compose-context-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(var(--rgb-assistant-chip-selected));
|
||||||
|
color: rgb(var(--rgb-assistant-chip-selected-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-compose-context-pill-text {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(100%, 460px);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-compose-context-clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-compose-context-clear:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-send-row {
|
.assistant-send-row {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ def _has_account_token(text: str) -> bool:
|
||||||
return bool(ACCOUNT_TOKEN_RE.search(text))
|
return bool(ACCOUNT_TOKEN_RE.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_iso_date(text: str) -> bool:
|
||||||
|
return bool(re.search(r'\b\d{4}-\d{2}-\d{2}\b', text))
|
||||||
|
|
||||||
|
|
||||||
def _aggregate_available_for_shape(
|
def _aggregate_available_for_shape(
|
||||||
*,
|
*,
|
||||||
available: set[str],
|
available: set[str],
|
||||||
|
|
@ -77,13 +81,35 @@ def classify_query_for_route(
|
||||||
question_class = str(parsed_intent.get("question_class", "")).strip().lower()
|
question_class = str(parsed_intent.get("question_class", "")).strip().lower()
|
||||||
|
|
||||||
exact_markers = [
|
exact_markers = [
|
||||||
"документ по номеру",
|
"document by number",
|
||||||
"source-of-record",
|
"source-of-record",
|
||||||
"источник",
|
"source",
|
||||||
"цепочка",
|
"chain",
|
||||||
"почему",
|
"why",
|
||||||
"subconto3",
|
"subconto3",
|
||||||
"субконто3",
|
"supplier",
|
||||||
|
"поставщик",
|
||||||
|
"buyer",
|
||||||
|
"покупатель",
|
||||||
|
"purchase",
|
||||||
|
"закуп",
|
||||||
|
"document",
|
||||||
|
"документ",
|
||||||
|
"date",
|
||||||
|
"дата",
|
||||||
|
]
|
||||||
|
inventory_markers = [
|
||||||
|
"склад",
|
||||||
|
"остаток",
|
||||||
|
"поставщик",
|
||||||
|
"закуплен",
|
||||||
|
"куплен",
|
||||||
|
"продан",
|
||||||
|
"inventory",
|
||||||
|
"stock",
|
||||||
|
"supplier",
|
||||||
|
"purchase",
|
||||||
|
"sale",
|
||||||
]
|
]
|
||||||
causal_markers = [
|
causal_markers = [
|
||||||
"свяжи",
|
"свяжи",
|
||||||
|
|
@ -109,16 +135,35 @@ def classify_query_for_route(
|
||||||
needs_exact_object_trace = _contains_any(text, exact_markers) and (
|
needs_exact_object_trace = _contains_any(text, exact_markers) and (
|
||||||
question_class in {"drilldown_explain", "simple_factual", "cross_entity"}
|
question_class in {"drilldown_explain", "simple_factual", "cross_entity"}
|
||||||
)
|
)
|
||||||
if question_class == "simple_factual" and "документ по номеру" in text:
|
if _contains_any(text, inventory_markers) and question_class in {
|
||||||
|
"drilldown_explain",
|
||||||
|
"simple_factual",
|
||||||
|
"cross_entity",
|
||||||
|
}:
|
||||||
|
needs_exact_object_trace = True
|
||||||
|
if question_class in {"drilldown_explain", "simple_factual", "cross_entity"} and (
|
||||||
|
("41" in text and _contains_any(text, ["остаток", "balance", "stock"]))
|
||||||
|
or (
|
||||||
|
_contains_any(text, ["supplier", "поставщик", "buyer", "покупатель"])
|
||||||
|
and _contains_any(text, ["purchase", "закуп", "document", "документ"])
|
||||||
|
)
|
||||||
|
):
|
||||||
|
needs_exact_object_trace = True
|
||||||
|
if question_class == "simple_factual" and "document by number" in text:
|
||||||
|
needs_exact_object_trace = True
|
||||||
|
|
||||||
|
if question_class == "unknown" and "41" in text and _has_iso_date(text):
|
||||||
needs_exact_object_trace = True
|
needs_exact_object_trace = True
|
||||||
|
|
||||||
needs_causal_chain = _contains_any(text, causal_markers) and question_class in {
|
needs_causal_chain = _contains_any(text, causal_markers) and question_class in {
|
||||||
"drilldown_explain",
|
"drilldown_explain",
|
||||||
"cross_entity",
|
"cross_entity",
|
||||||
}
|
}
|
||||||
|
if _contains_any(text, inventory_markers) and "???????" in text:
|
||||||
|
needs_causal_chain = True
|
||||||
needs_cross_entity_join = (
|
needs_cross_entity_join = (
|
||||||
question_class == "cross_entity"
|
question_class == "cross_entity"
|
||||||
or (_contains_any(text, cross_markers) and " и " in text and "->" not in text)
|
or (_contains_any(text, cross_markers) and " ? " in text and "->" not in text)
|
||||||
)
|
)
|
||||||
|
|
||||||
needs_ranking = _contains_any(text, ranking_markers) and question_class in {
|
needs_ranking = _contains_any(text, ranking_markers) and question_class in {
|
||||||
|
|
@ -132,7 +177,7 @@ def classify_query_for_route(
|
||||||
needs_full_period_aggregation = is_heavy and not is_baseline_heavy
|
needs_full_period_aggregation = is_heavy and not is_baseline_heavy
|
||||||
|
|
||||||
needs_runtime_truth = needs_exact_object_trace or _contains_any(
|
needs_runtime_truth = needs_exact_object_trace or _contains_any(
|
||||||
text, ["runtime", "source-of-record", "источник регистра"]
|
text, ["runtime", "source-of-record", "???????????????? ????????????????"]
|
||||||
)
|
)
|
||||||
freshness_sensitive = question_class in {
|
freshness_sensitive = question_class in {
|
||||||
"period_trend",
|
"period_trend",
|
||||||
|
|
@ -141,7 +186,6 @@ def classify_query_for_route(
|
||||||
}
|
}
|
||||||
ambiguous_object_scope = question_class == "ambiguous_fuzzy"
|
ambiguous_object_scope = question_class == "ambiguous_fuzzy"
|
||||||
if ambiguous_object_scope and _has_account_token(text):
|
if ambiguous_object_scope and _has_account_token(text):
|
||||||
# Ambiguous account prompts should avoid hard downcast into canonical-only answers.
|
|
||||||
needs_runtime_truth = True
|
needs_runtime_truth = True
|
||||||
|
|
||||||
available_aggregates = {
|
available_aggregates = {
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,48 @@ def merge_analysis_context(base_context: Any, override_context: Any) -> dict[str
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def carry_forward_analysis_context(
|
||||||
|
scenario_state: dict[str, Any],
|
||||||
|
analysis_context: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
carried = dict(analysis_context)
|
||||||
|
|
||||||
|
semantic_memory = scenario_state.get("semantic_memory")
|
||||||
|
if isinstance(semantic_memory, dict):
|
||||||
|
date_scope = semantic_memory.get("date_scope")
|
||||||
|
if isinstance(date_scope, dict):
|
||||||
|
carried_as_of_date = normalize_iso_date(date_scope.get("as_of_date"))
|
||||||
|
if carried_as_of_date and not carried.get("as_of_date"):
|
||||||
|
carried["as_of_date"] = carried_as_of_date
|
||||||
|
if not carried.get("source"):
|
||||||
|
carried["source"] = "scenario_state_carryover"
|
||||||
|
return carried
|
||||||
|
|
||||||
|
|
||||||
|
def merge_scenario_date_scope(
|
||||||
|
previous_date_scope: Any,
|
||||||
|
current_date_scope: Any,
|
||||||
|
*,
|
||||||
|
depends_on: list[str],
|
||||||
|
) -> Any:
|
||||||
|
previous = previous_date_scope if isinstance(previous_date_scope, dict) else None
|
||||||
|
current = current_date_scope if isinstance(current_date_scope, dict) else None
|
||||||
|
if not current:
|
||||||
|
return previous or current_date_scope
|
||||||
|
if not depends_on or not previous:
|
||||||
|
return current
|
||||||
|
|
||||||
|
previous_as_of_date = normalize_iso_date(previous.get("as_of_date"))
|
||||||
|
current_as_of_date = normalize_iso_date(current.get("as_of_date"))
|
||||||
|
if previous_as_of_date and current_as_of_date and current_as_of_date != previous_as_of_date:
|
||||||
|
merged = dict(current)
|
||||||
|
merged["as_of_date"] = previous_as_of_date
|
||||||
|
if not merged.get("source"):
|
||||||
|
merged["source"] = "scenario_state_carryover"
|
||||||
|
return merged
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
def repair_text_mojibake(value: str) -> str:
|
def repair_text_mojibake(value: str) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
return value
|
return value
|
||||||
|
|
@ -1219,6 +1261,7 @@ def execute_scenario_manifest(
|
||||||
for step_index, step in enumerate(manifest["steps"], start=1):
|
for step_index, step in enumerate(manifest["steps"], start=1):
|
||||||
step_dir = steps_dir / step["step_id"]
|
step_dir = steps_dir / step["step_id"]
|
||||||
step_analysis_context = merge_analysis_context(manifest.get("analysis_context"), step.get("analysis_context"))
|
step_analysis_context = merge_analysis_context(manifest.get("analysis_context"), step.get("analysis_context"))
|
||||||
|
step_analysis_context = carry_forward_analysis_context(scenario_state, step_analysis_context)
|
||||||
try:
|
try:
|
||||||
resolved_question = resolve_question_template(step["question_template"], scenario_state)
|
resolved_question = resolve_question_template(step["question_template"], scenario_state)
|
||||||
result = run_assistant_step(
|
result = run_assistant_step(
|
||||||
|
|
@ -1273,12 +1316,19 @@ def execute_scenario_manifest(
|
||||||
|
|
||||||
scenario_state["session_id"] = result["session_id"]
|
scenario_state["session_id"] = result["session_id"]
|
||||||
scenario_state["step_outputs"][step["step_id"]] = result["step_state"]
|
scenario_state["step_outputs"][step["step_id"]] = result["step_state"]
|
||||||
|
previous_semantic_memory = scenario_state.get("semantic_memory") or {}
|
||||||
|
previous_date_scope = previous_semantic_memory.get("date_scope") if isinstance(previous_semantic_memory, dict) else None
|
||||||
|
current_date_scope = result["step_state"].get("date_scope")
|
||||||
scenario_state["semantic_memory"] = {
|
scenario_state["semantic_memory"] = {
|
||||||
"latest_step_id": step["step_id"],
|
"latest_step_id": step["step_id"],
|
||||||
"latest_step_status": result["step_state"].get("status"),
|
"latest_step_status": result["step_state"].get("status"),
|
||||||
"active_result_set_id": result["step_state"].get("active_result_set_id"),
|
"active_result_set_id": result["step_state"].get("active_result_set_id"),
|
||||||
"last_confirmed_route": result["step_state"].get("last_confirmed_route"),
|
"last_confirmed_route": result["step_state"].get("last_confirmed_route"),
|
||||||
"date_scope": result["step_state"].get("date_scope"),
|
"date_scope": merge_scenario_date_scope(
|
||||||
|
previous_date_scope,
|
||||||
|
current_date_scope,
|
||||||
|
depends_on=step.get("depends_on") or [],
|
||||||
|
),
|
||||||
"organization_scope": result["step_state"].get("organization_scope"),
|
"organization_scope": result["step_state"].get("organization_scope"),
|
||||||
"entries": result["step_state"].get("entries"),
|
"entries": result["step_state"].get("entries"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scripts.domain_case_loop import carry_forward_analysis_context, merge_scenario_date_scope
|
||||||
|
|
||||||
|
|
||||||
|
def test_carry_forward_analysis_context_preserves_followup_anchor() -> None:
|
||||||
|
scenario_state = {
|
||||||
|
"semantic_memory": {
|
||||||
|
"date_scope": {"as_of_date": "2020-03-31"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
analysis_context = {"as_of_date": "2026-04-13", "source": "current_analysis"}
|
||||||
|
|
||||||
|
carried = carry_forward_analysis_context(scenario_state, analysis_context)
|
||||||
|
|
||||||
|
assert carried["as_of_date"] == "2026-04-13"
|
||||||
|
assert carried["source"] == "current_analysis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_carry_forward_analysis_context_fills_missing_anchor() -> None:
|
||||||
|
scenario_state = {
|
||||||
|
"semantic_memory": {
|
||||||
|
"date_scope": {"as_of_date": "2020-03-31"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
carried = carry_forward_analysis_context(scenario_state, {})
|
||||||
|
|
||||||
|
assert carried["as_of_date"] == "2020-03-31"
|
||||||
|
assert carried["source"] == "scenario_state_carryover"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_scenario_date_scope_preserves_historical_anchor_on_followup() -> None:
|
||||||
|
previous_date_scope = {"as_of_date": "2020-03-31", "source": "exact_anchor"}
|
||||||
|
current_date_scope = {"as_of_date": "2026-04-13", "source": "current_analysis"}
|
||||||
|
|
||||||
|
merged = merge_scenario_date_scope(
|
||||||
|
previous_date_scope,
|
||||||
|
current_date_scope,
|
||||||
|
depends_on=["step_01_anchor"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert merged["as_of_date"] == "2020-03-31"
|
||||||
|
assert merged["source"] == "current_analysis"
|
||||||
|
|
@ -70,3 +70,9 @@ def test_route_guard_trend_to_store_feature_risk() -> None:
|
||||||
result = choose_route(_flags(), _suff(), parsed_as_trend_or_risk=True)
|
result = choose_route(_flags(), _suff(), parsed_as_trend_or_risk=True)
|
||||||
assert result.chosen_route == "store_feature_risk"
|
assert result.chosen_route == "store_feature_risk"
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_guard_inventory_exact_trace_stays_live() -> None:
|
||||||
|
flags = _flags()
|
||||||
|
flags.needs_exact_object_trace = True
|
||||||
|
result = choose_route(flags, _suff(), parsed_as_trend_or_risk=False)
|
||||||
|
assert result.chosen_route == "live_mcp_drilldown"
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,33 @@ def test_classifier_cross_entity_causal_flags() -> None:
|
||||||
assert flags.needs_causal_chain is True
|
assert flags.needs_causal_chain is True
|
||||||
assert flags.needs_exact_object_trace is False
|
assert flags.needs_exact_object_trace is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_inventory_stock_provenance_flags() -> None:
|
||||||
|
flags = classify_query_for_route(
|
||||||
|
"От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад",
|
||||||
|
{"question_class": "simple_factual"},
|
||||||
|
_store_meta(),
|
||||||
|
)
|
||||||
|
assert flags.needs_exact_object_trace is True
|
||||||
|
assert flags.needs_runtime_truth is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_inventory_account_on_date_flags() -> None:
|
||||||
|
flags = classify_query_for_route(
|
||||||
|
"Какие товары числятся на 41 счете на дату 2020-03-31",
|
||||||
|
{"question_class": "unknown"},
|
||||||
|
_store_meta(),
|
||||||
|
)
|
||||||
|
assert flags.needs_exact_object_trace is True
|
||||||
|
assert flags.needs_runtime_truth is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_inventory_sale_chain_flags() -> None:
|
||||||
|
flags = classify_query_for_route(
|
||||||
|
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный -> покупатель",
|
||||||
|
{"question_class": "cross_entity"},
|
||||||
|
_store_meta(),
|
||||||
|
)
|
||||||
|
assert flags.needs_exact_object_trace is True
|
||||||
|
assert flags.needs_causal_chain is True
|
||||||
|
assert flags.needs_cross_entity_join is True
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue