Compare commits
3 Commits
88c92e316a
...
d1ac4fc417
| Author | SHA1 | Date |
|---|---|---|
|
|
d1ac4fc417 | |
|
|
c17495d1bb | |
|
|
908d011743 |
|
|
@ -47,6 +47,7 @@
|
||||||
"period_or_honest_period_clarification",
|
"period_or_honest_period_clarification",
|
||||||
"revenue_cogs_gross_profit_margin_or_honest_unknown",
|
"revenue_cogs_gross_profit_margin_or_honest_unknown",
|
||||||
"next_action_if_limited",
|
"next_action_if_limited",
|
||||||
|
"selected_object_followup_keeps_item_focus",
|
||||||
"detector_results_fail_or_review_on_real_defect"
|
"detector_results_fail_or_review_on_real_defect"
|
||||||
],
|
],
|
||||||
"must_not_have": [
|
"must_not_have": [
|
||||||
|
|
@ -136,6 +137,86 @@
|
||||||
"forbidden_answer_patterns": [
|
"forbidden_answer_patterns": [
|
||||||
"(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)"
|
"(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_id": "step_03_selected_item_supplier",
|
||||||
|
"title": "Selected item supplier follow-up",
|
||||||
|
"question": "А по этой позиции кто поставщик?",
|
||||||
|
"depends_on": [
|
||||||
|
"step_02"
|
||||||
|
],
|
||||||
|
"semantic_tags": [
|
||||||
|
"margin_profitability",
|
||||||
|
"selected_object",
|
||||||
|
"supplier_followup"
|
||||||
|
],
|
||||||
|
"expected_intents": [
|
||||||
|
"inventory_purchase_provenance_for_item"
|
||||||
|
],
|
||||||
|
"expected_capability": "inventory_inventory_purchase_provenance_for_item",
|
||||||
|
"expected_recipe": "address_inventory_purchase_provenance_for_item_v1",
|
||||||
|
"expected_result_mode": "selected_object_purchase_provenance_or_honest_boundary",
|
||||||
|
"expected_business_answer_contract": "margin_profitability_v1",
|
||||||
|
"required_answer_shape": "direct_answer_first",
|
||||||
|
"required_answer_patterns_any": [
|
||||||
|
"(?i)(поставщик|закуп|поступлен|приход|не найден|не подтвержд)"
|
||||||
|
],
|
||||||
|
"forbidden_answer_patterns": [
|
||||||
|
"(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_id": "step_04_selected_item_purchase_date",
|
||||||
|
"title": "Selected item purchase date follow-up",
|
||||||
|
"question": "Когда ее купили?",
|
||||||
|
"depends_on": [
|
||||||
|
"step_03_selected_item_supplier"
|
||||||
|
],
|
||||||
|
"semantic_tags": [
|
||||||
|
"margin_profitability",
|
||||||
|
"selected_object",
|
||||||
|
"purchase_date_followup"
|
||||||
|
],
|
||||||
|
"expected_intents": [
|
||||||
|
"inventory_purchase_provenance_for_item"
|
||||||
|
],
|
||||||
|
"expected_capability": "inventory_inventory_purchase_provenance_for_item",
|
||||||
|
"expected_recipe": "address_inventory_purchase_provenance_for_item_v1",
|
||||||
|
"expected_result_mode": "selected_object_purchase_date_or_honest_boundary",
|
||||||
|
"expected_business_answer_contract": "margin_profitability_v1",
|
||||||
|
"required_answer_shape": "direct_answer_first",
|
||||||
|
"required_answer_patterns_any": [
|
||||||
|
"(?i)(купил|закуп|поступлен|дата|первая|последняя|не найден|не подтвержд)"
|
||||||
|
],
|
||||||
|
"forbidden_answer_patterns": [
|
||||||
|
"(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_id": "step_05_selected_item_documents",
|
||||||
|
"title": "Selected item document follow-up",
|
||||||
|
"question": "Каким документом это подтверждено?",
|
||||||
|
"depends_on": [
|
||||||
|
"step_04_selected_item_purchase_date"
|
||||||
|
],
|
||||||
|
"semantic_tags": [
|
||||||
|
"margin_profitability",
|
||||||
|
"selected_object",
|
||||||
|
"purchase_document_followup"
|
||||||
|
],
|
||||||
|
"expected_intents": [
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_purchase_documents_for_item"
|
||||||
|
],
|
||||||
|
"expected_result_mode": "selected_object_purchase_documents_or_honest_boundary",
|
||||||
|
"expected_business_answer_contract": "margin_profitability_v1",
|
||||||
|
"required_answer_shape": "direct_answer_first",
|
||||||
|
"required_answer_patterns_any": [
|
||||||
|
"(?i)(документ|поступлен|регистратор|накладн|не найден|не подтвержд)"
|
||||||
|
],
|
||||||
|
"forbidden_answer_patterns": [
|
||||||
|
"(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -190,6 +271,32 @@
|
||||||
"(?i)(route_id|capability_id|runtime_|debug)"
|
"(?i)(route_id|capability_id|runtime_|debug)"
|
||||||
],
|
],
|
||||||
"notes": "Даже если пользователь сам сказал `не ОС`, runtime должен удержать товарный контур и не строить ответ вокруг амортизации."
|
"notes": "Даже если пользователь сам сказал `не ОС`, runtime должен удержать товарный контур и не строить ответ вокруг амортизации."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_id": "step_03_selected_item_after_fixed_asset_trap",
|
||||||
|
"title": "Selected item follow-up after fixed asset trap",
|
||||||
|
"question": "По этой позиции покажи закупочный документ.",
|
||||||
|
"depends_on": [
|
||||||
|
"step_02_fixed_asset_source_trap"
|
||||||
|
],
|
||||||
|
"semantic_tags": [
|
||||||
|
"margin_profitability",
|
||||||
|
"selected_object",
|
||||||
|
"fixed_asset_trap_followup"
|
||||||
|
],
|
||||||
|
"expected_intents": [
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_purchase_documents_for_item"
|
||||||
|
],
|
||||||
|
"expected_result_mode": "selected_object_purchase_documents_or_honest_boundary",
|
||||||
|
"expected_business_answer_contract": "margin_profitability_v1",
|
||||||
|
"required_answer_shape": "direct_answer_first",
|
||||||
|
"required_answer_patterns_any": [
|
||||||
|
"(?i)(документ|поступлен|закуп|поставщик|не найден|не подтвержд)"
|
||||||
|
],
|
||||||
|
"forbidden_answer_patterns": [
|
||||||
|
"(?i)(route_id|capability_id|runtime_|debug|амортизац|объект ОС)"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT = {
|
||||||
inventory_purchase_documents_for_item: "item",
|
inventory_purchase_documents_for_item: "item",
|
||||||
inventory_supplier_stock_overlap_as_of_date: "item",
|
inventory_supplier_stock_overlap_as_of_date: "item",
|
||||||
inventory_sale_trace_for_item: "item",
|
inventory_sale_trace_for_item: "item",
|
||||||
|
inventory_margin_ranking_for_nomenclature: "item",
|
||||||
inventory_profitability_for_item: "item",
|
inventory_profitability_for_item: "item",
|
||||||
inventory_purchase_to_sale_chain: "item",
|
inventory_purchase_to_sale_chain: "item",
|
||||||
inventory_aging_by_purchase_date: "item"
|
inventory_aging_by_purchase_date: "item"
|
||||||
|
|
@ -55,6 +56,7 @@ const RESULT_SET_TYPE_BY_INTENT = {
|
||||||
inventory_purchase_documents_for_item: "inventory_trace",
|
inventory_purchase_documents_for_item: "inventory_trace",
|
||||||
inventory_supplier_stock_overlap_as_of_date: "inventory_trace",
|
inventory_supplier_stock_overlap_as_of_date: "inventory_trace",
|
||||||
inventory_sale_trace_for_item: "inventory_trace",
|
inventory_sale_trace_for_item: "inventory_trace",
|
||||||
|
inventory_margin_ranking_for_nomenclature: "inventory_trace",
|
||||||
inventory_profitability_for_item: "inventory_trace",
|
inventory_profitability_for_item: "inventory_trace",
|
||||||
inventory_purchase_to_sale_chain: "inventory_trace",
|
inventory_purchase_to_sale_chain: "inventory_trace",
|
||||||
inventory_aging_by_purchase_date: "inventory_trace",
|
inventory_aging_by_purchase_date: "inventory_trace",
|
||||||
|
|
@ -166,7 +168,8 @@ function parseEntityCandidateFromLine(line) {
|
||||||
const afterNumber = String(numberedMatch[2] ?? "");
|
const afterNumber = String(numberedMatch[2] ?? "");
|
||||||
const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean);
|
const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean);
|
||||||
const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber;
|
const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber;
|
||||||
const cleaned = valueCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim();
|
const businessLabel = valueCandidate.split(/\s+[—–-]\s+(?=(?:выручка|сумма|количество|стоимость|маржа|себестоим|дата|склад))/iu)[0] ?? valueCandidate;
|
||||||
|
const cleaned = businessLabel.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim();
|
||||||
if (!cleaned || cleaned.length < 2) {
|
if (!cleaned || cleaned.length < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -387,6 +390,30 @@ function buildFocusObjectFromDebug(debug, resultSetId, createdAt) {
|
||||||
toNonEmptyString(extractedFilters.item);
|
toNonEmptyString(extractedFilters.item);
|
||||||
return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null;
|
return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null;
|
||||||
}
|
}
|
||||||
|
function buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt) {
|
||||||
|
if (resultSet.intent !== "inventory_margin_ranking_for_nomenclature") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const primaryEntity = resultSet.entity_refs[0];
|
||||||
|
if (!primaryEntity || primaryEntity.entity_type !== "item") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildFocusObject("item", primaryEntity.value, resultSet.result_set_id, createdAt);
|
||||||
|
}
|
||||||
|
function resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject) {
|
||||||
|
if (intent !== "inventory_margin_ranking_for_nomenclature") {
|
||||||
|
return debugFocusObject ?? primaryEntityFocusObject;
|
||||||
|
}
|
||||||
|
if (primaryEntityFocusObject) {
|
||||||
|
return primaryEntityFocusObject;
|
||||||
|
}
|
||||||
|
const previousFocus = state.session_context.active_focus_object;
|
||||||
|
if (previousFocus?.object_type === "item" &&
|
||||||
|
(!debugFocusObject || debugFocusObject.object_type === "organization")) {
|
||||||
|
return cloneFocusObject(previousFocus);
|
||||||
|
}
|
||||||
|
return debugFocusObject;
|
||||||
|
}
|
||||||
function capResultSets(resultSets) {
|
function capResultSets(resultSets) {
|
||||||
if (resultSets.length <= MAX_RESULT_SETS) {
|
if (resultSets.length <= MAX_RESULT_SETS) {
|
||||||
return resultSets;
|
return resultSets;
|
||||||
|
|
@ -573,7 +600,9 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) {
|
||||||
created_at: createdAt
|
created_at: createdAt
|
||||||
};
|
};
|
||||||
const previousResultSetId = state.session_context.active_result_set_id;
|
const previousResultSetId = state.session_context.active_result_set_id;
|
||||||
const focusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
|
const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
|
||||||
|
const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt);
|
||||||
|
const focusObject = resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject);
|
||||||
const comparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
|
const comparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
|
||||||
? readNavigationDiscoveryCounterparty(debug)
|
? readNavigationDiscoveryCounterparty(debug)
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -1149,9 +1149,9 @@ function extractInventoryCounterpartyCandidates(row, excludedTokens = []) {
|
||||||
excludedComparableTokens.includes(comparable)) {
|
excludedComparableTokens.includes(comparable)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidates.push(normalized);
|
candidates.push(collapseRepeatedInventoryPartyLabel(normalized));
|
||||||
}
|
}
|
||||||
const explicitCounterparty = normalizeCounterpartyDisplayLabel(row.counterparty);
|
const explicitCounterparty = collapseRepeatedInventoryPartyLabel(normalizeCounterpartyDisplayLabel(row.counterparty));
|
||||||
const explicitComparable = normalizeEntityToken(explicitCounterparty);
|
const explicitComparable = normalizeEntityToken(explicitCounterparty);
|
||||||
if (explicitCounterparty &&
|
if (explicitCounterparty &&
|
||||||
explicitComparable &&
|
explicitComparable &&
|
||||||
|
|
@ -1163,6 +1163,50 @@ function extractInventoryCounterpartyCandidates(row, excludedTokens = []) {
|
||||||
}
|
}
|
||||||
return uniqueStrings(candidates);
|
return uniqueStrings(candidates);
|
||||||
}
|
}
|
||||||
|
function collapseRepeatedInventoryPartyLabel(value) {
|
||||||
|
const compact = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||||
|
if (!compact) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
const tokens = compact.split(" ").filter(Boolean);
|
||||||
|
for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) {
|
||||||
|
const collapsedTokens = [];
|
||||||
|
let changed = false;
|
||||||
|
for (let index = 0; index < tokens.length;) {
|
||||||
|
const leftTokens = tokens.slice(index, index + size);
|
||||||
|
const rightTokens = tokens.slice(index + size, index + size * 2);
|
||||||
|
if (leftTokens.length === size &&
|
||||||
|
rightTokens.length === size &&
|
||||||
|
normalizeEntityToken(leftTokens.join(" ")) === normalizeEntityToken(rightTokens.join(" "))) {
|
||||||
|
collapsedTokens.push(...leftTokens);
|
||||||
|
index += size * 2;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
collapsedTokens.push(tokens[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
return collapseRepeatedInventoryPartyLabel(collapsedTokens.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
function uniqueCollapsedInventoryPartyLabels(values) {
|
||||||
|
const result = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const value of values) {
|
||||||
|
const collapsed = collapseRepeatedInventoryPartyLabel(value);
|
||||||
|
const comparable = normalizeEntityToken(collapsed);
|
||||||
|
if (!collapsed || !comparable || seen.has(comparable)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(comparable);
|
||||||
|
result.push(collapsed);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) {
|
function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) {
|
||||||
const items = uniqueStrings(rows
|
const items = uniqueStrings(rows
|
||||||
.map((row) => extractInventoryItemName(row))
|
.map((row) => extractInventoryItemName(row))
|
||||||
|
|
@ -1173,7 +1217,7 @@ function summarizeInventoryTraceRows(rows, excludedCounterpartyTokens = []) {
|
||||||
const organizations = uniqueStrings(rows
|
const organizations = uniqueStrings(rows
|
||||||
.map((row) => extractInventoryOrganizationName(row))
|
.map((row) => extractInventoryOrganizationName(row))
|
||||||
.filter((item) => Boolean(item)));
|
.filter((item) => Boolean(item)));
|
||||||
const counterparties = uniqueStrings(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens)));
|
const counterparties = uniqueCollapsedInventoryPartyLabels(rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens)));
|
||||||
const documents = uniqueStrings(rows
|
const documents = uniqueStrings(rows
|
||||||
.map((row) => String(row.registrator ?? "").trim())
|
.map((row) => String(row.registrator ?? "").trim())
|
||||||
.filter((item) => item.length > 0 && item !== "(без названия)"));
|
.filter((item) => item.length > 0 && item !== "(без названия)"));
|
||||||
|
|
|
||||||
|
|
@ -613,7 +613,7 @@ function hasInventorySupplierFollowupCue(text) {
|
||||||
}
|
}
|
||||||
function hasInventoryPurchaseDocumentsFollowupCue(text) {
|
function hasInventoryPurchaseDocumentsFollowupCue(text) {
|
||||||
const value = String(text ?? "");
|
const value = String(text ?? "");
|
||||||
return (/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) ||
|
return (/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) ||
|
||||||
/(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value));
|
/(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value));
|
||||||
}
|
}
|
||||||
function hasInventoryProfitabilityFollowupCue(text) {
|
function hasInventoryProfitabilityFollowupCue(text) {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,61 @@ function normalizeInventoryReplyEntityToken(value) {
|
||||||
.trim();
|
.trim();
|
||||||
return normalized || null;
|
return normalized || null;
|
||||||
}
|
}
|
||||||
|
function normalizeInventoryReplyLabelComparable(value) {
|
||||||
|
const normalized = String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/gu, "е")
|
||||||
|
.replace(/С‘/gu, "Рµ")
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
function collapseRepeatedInventoryReplyLabel(value) {
|
||||||
|
const compact = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||||
|
if (!compact) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
const tokens = compact.split(/\s+/u).filter(Boolean);
|
||||||
|
for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) {
|
||||||
|
const collapsedTokens = [];
|
||||||
|
let changed = false;
|
||||||
|
for (let index = 0; index < tokens.length;) {
|
||||||
|
const leftTokens = tokens.slice(index, index + size);
|
||||||
|
const rightTokens = tokens.slice(index + size, index + size * 2);
|
||||||
|
if (leftTokens.length === size &&
|
||||||
|
rightTokens.length === size &&
|
||||||
|
normalizeInventoryReplyLabelComparable(leftTokens.join(" ")) === normalizeInventoryReplyLabelComparable(rightTokens.join(" "))) {
|
||||||
|
collapsedTokens.push(...leftTokens);
|
||||||
|
index += size * 2;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
collapsedTokens.push(tokens[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
return collapseRepeatedInventoryReplyLabel(collapsedTokens.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
function normalizeInventoryTraceSummaryCounterparties(summary) {
|
||||||
|
const normalized = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const counterparty of summary.counterparties) {
|
||||||
|
const collapsed = collapseRepeatedInventoryReplyLabel(counterparty);
|
||||||
|
const comparable = normalizeInventoryReplyLabelComparable(collapsed);
|
||||||
|
if (!collapsed || !comparable || seen.has(comparable)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(comparable);
|
||||||
|
normalized.push(collapsed);
|
||||||
|
}
|
||||||
|
summary.counterparties = normalized;
|
||||||
|
}
|
||||||
function sumInventoryRowAmount(rows) {
|
function sumInventoryRowAmount(rows) {
|
||||||
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +167,9 @@ function asksInventoryMarginAccount41Not01(userMessage) {
|
||||||
const text = String(userMessage ?? "").toLowerCase();
|
const text = String(userMessage ?? "").toLowerCase();
|
||||||
return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text);
|
return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text);
|
||||||
}
|
}
|
||||||
|
function asksForInventoryConfirmationDocument(userMessage) {
|
||||||
|
return /(?:каким\s+документом|чем\s+подтвержд|документ(?:ом|ами)?\s+подтвержд|покажи\s+закупочн(?:ый|ые)\s+документ|покажи\s+документ)/iu.test(String(userMessage ?? ""));
|
||||||
|
}
|
||||||
function inventoryRowItemLabel(row, deps) {
|
function inventoryRowItemLabel(row, deps) {
|
||||||
return deps.summarizeInventoryTraceRows([row]).item;
|
return deps.summarizeInventoryTraceRows([row]).item;
|
||||||
}
|
}
|
||||||
|
|
@ -210,9 +268,14 @@ function composeInventoryReply(intent, rows, options, deps) {
|
||||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const itemLabel = summary.item ?? "товар не определен";
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const firstDocument = summary.documents[0] ?? null;
|
||||||
|
const confirmationDocumentFocus = asksForInventoryConfirmationDocument(options.userMessage);
|
||||||
const directAnswerLine = purchaseRows.length <= 0
|
const directAnswerLine = purchaseRows.length <= 0
|
||||||
? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.`
|
? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.`
|
||||||
|
: confirmationDocumentFocus && firstDocument
|
||||||
|
? `Документ подтверждения по позиции ${itemLabel}: ${firstDocument}.`
|
||||||
: `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`;
|
: `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`;
|
||||||
const lines = [directAnswerLine];
|
const lines = [directAnswerLine];
|
||||||
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [
|
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [
|
||||||
|
|
@ -240,6 +303,7 @@ function composeInventoryReply(intent, rows, options, deps) {
|
||||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const itemLabel = summary.item ?? "товар не определен";
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null;
|
const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null;
|
||||||
const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage);
|
const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage);
|
||||||
|
|
@ -330,6 +394,7 @@ function composeInventoryReply(intent, rows, options, deps) {
|
||||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
|
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
|
||||||
const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(options.userMessage ?? ""));
|
const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(options.userMessage ?? ""));
|
||||||
if (unresolvedSupplierQuestion) {
|
if (unresolvedSupplierQuestion) {
|
||||||
|
|
@ -406,6 +471,7 @@ function composeInventoryReply(intent, rows, options, deps) {
|
||||||
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
||||||
});
|
});
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
||||||
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
||||||
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
||||||
|
|
@ -454,6 +520,7 @@ function composeInventoryReply(intent, rows, options, deps) {
|
||||||
const requestedItemHint = String(options.itemHint ?? "").trim();
|
const requestedItemHint = String(options.itemHint ?? "").trim();
|
||||||
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
||||||
const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
||||||
const excludedCounterpartyTokens = [itemLabel];
|
const excludedCounterpartyTokens = [itemLabel];
|
||||||
const directAnswerLine = summary.counterparties.length === 1
|
const directAnswerLine = summary.counterparties.length === 1
|
||||||
|
|
@ -529,7 +596,7 @@ function composeInventoryReply(intent, rows, options, deps) {
|
||||||
"оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;",
|
"оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;",
|
||||||
"строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений."
|
"строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений."
|
||||||
]);
|
]);
|
||||||
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false));
|
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", true));
|
||||||
}
|
}
|
||||||
if (confirmedEntries.length === 0) {
|
if (confirmedEntries.length === 0) {
|
||||||
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
|
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressFocusO
|
||||||
inventory_purchase_documents_for_item: "item",
|
inventory_purchase_documents_for_item: "item",
|
||||||
inventory_supplier_stock_overlap_as_of_date: "item",
|
inventory_supplier_stock_overlap_as_of_date: "item",
|
||||||
inventory_sale_trace_for_item: "item",
|
inventory_sale_trace_for_item: "item",
|
||||||
|
inventory_margin_ranking_for_nomenclature: "item",
|
||||||
inventory_profitability_for_item: "item",
|
inventory_profitability_for_item: "item",
|
||||||
inventory_purchase_to_sale_chain: "item",
|
inventory_purchase_to_sale_chain: "item",
|
||||||
inventory_aging_by_purchase_date: "item"
|
inventory_aging_by_purchase_date: "item"
|
||||||
|
|
@ -72,6 +73,7 @@ const RESULT_SET_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressResultSetT
|
||||||
inventory_purchase_documents_for_item: "inventory_trace",
|
inventory_purchase_documents_for_item: "inventory_trace",
|
||||||
inventory_supplier_stock_overlap_as_of_date: "inventory_trace",
|
inventory_supplier_stock_overlap_as_of_date: "inventory_trace",
|
||||||
inventory_sale_trace_for_item: "inventory_trace",
|
inventory_sale_trace_for_item: "inventory_trace",
|
||||||
|
inventory_margin_ranking_for_nomenclature: "inventory_trace",
|
||||||
inventory_profitability_for_item: "inventory_trace",
|
inventory_profitability_for_item: "inventory_trace",
|
||||||
inventory_purchase_to_sale_chain: "inventory_trace",
|
inventory_purchase_to_sale_chain: "inventory_trace",
|
||||||
inventory_aging_by_purchase_date: "inventory_trace",
|
inventory_aging_by_purchase_date: "inventory_trace",
|
||||||
|
|
@ -196,7 +198,8 @@ function parseEntityCandidateFromLine(line: string): { index: number; value: str
|
||||||
const afterNumber = String(numberedMatch[2] ?? "");
|
const afterNumber = String(numberedMatch[2] ?? "");
|
||||||
const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean);
|
const pieces = afterNumber.split("|").map((item) => item.trim()).filter(Boolean);
|
||||||
const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber;
|
const valueCandidate = pieces.length > 0 ? pieces[0] : afterNumber;
|
||||||
const cleaned = valueCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim();
|
const businessLabel = valueCandidate.split(/\s+[—–-]\s+(?=(?:выручка|сумма|количество|стоимость|маржа|себестоим|дата|склад))/iu)[0] ?? valueCandidate;
|
||||||
|
const cleaned = businessLabel.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, "").trim();
|
||||||
if (!cleaned || cleaned.length < 2) {
|
if (!cleaned || cleaned.length < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -455,6 +458,43 @@ function buildFocusObjectFromDebug(debug: Record<string, unknown>, resultSetId:
|
||||||
return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null;
|
return rawValue ? buildFocusObject(canonicalType, rawValue, resultSetId, createdAt) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFocusObjectFromPrimaryEntityRef(
|
||||||
|
resultSet: AddressResultSet,
|
||||||
|
createdAt: string
|
||||||
|
): AddressFocusObject | null {
|
||||||
|
if (resultSet.intent !== "inventory_margin_ranking_for_nomenclature") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const primaryEntity = resultSet.entity_refs[0];
|
||||||
|
if (!primaryEntity || primaryEntity.entity_type !== "item") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildFocusObject("item", primaryEntity.value, resultSet.result_set_id, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFocusObjectForNavigation(
|
||||||
|
state: AddressNavigationState,
|
||||||
|
intent: AddressIntent,
|
||||||
|
resultSet: AddressResultSet,
|
||||||
|
debugFocusObject: AddressFocusObject | null,
|
||||||
|
primaryEntityFocusObject: AddressFocusObject | null
|
||||||
|
): AddressFocusObject | null {
|
||||||
|
if (intent !== "inventory_margin_ranking_for_nomenclature") {
|
||||||
|
return debugFocusObject ?? primaryEntityFocusObject;
|
||||||
|
}
|
||||||
|
if (primaryEntityFocusObject) {
|
||||||
|
return primaryEntityFocusObject;
|
||||||
|
}
|
||||||
|
const previousFocus = state.session_context.active_focus_object;
|
||||||
|
if (
|
||||||
|
previousFocus?.object_type === "item" &&
|
||||||
|
(!debugFocusObject || debugFocusObject.object_type === "organization")
|
||||||
|
) {
|
||||||
|
return cloneFocusObject(previousFocus);
|
||||||
|
}
|
||||||
|
return debugFocusObject;
|
||||||
|
}
|
||||||
|
|
||||||
function capResultSets(resultSets: AddressResultSet[]): AddressResultSet[] {
|
function capResultSets(resultSets: AddressResultSet[]): AddressResultSet[] {
|
||||||
if (resultSets.length <= MAX_RESULT_SETS) {
|
if (resultSets.length <= MAX_RESULT_SETS) {
|
||||||
return resultSets;
|
return resultSets;
|
||||||
|
|
@ -663,7 +703,15 @@ export function evolveAddressNavigationStateWithAssistantItem(
|
||||||
created_at: createdAt
|
created_at: createdAt
|
||||||
};
|
};
|
||||||
const previousResultSetId = state.session_context.active_result_set_id;
|
const previousResultSetId = state.session_context.active_result_set_id;
|
||||||
const focusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
|
const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt);
|
||||||
|
const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt);
|
||||||
|
const focusObject = resolveFocusObjectForNavigation(
|
||||||
|
state,
|
||||||
|
intent,
|
||||||
|
resultSet,
|
||||||
|
debugFocusObject,
|
||||||
|
primaryEntityFocusObject
|
||||||
|
);
|
||||||
const comparisonCounterparty =
|
const comparisonCounterparty =
|
||||||
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
|
selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true
|
||||||
? readNavigationDiscoveryCounterparty(debug)
|
? readNavigationDiscoveryCounterparty(debug)
|
||||||
|
|
|
||||||
|
|
@ -1474,9 +1474,9 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTo
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
candidates.push(normalized);
|
candidates.push(collapseRepeatedInventoryPartyLabel(normalized));
|
||||||
}
|
}
|
||||||
const explicitCounterparty = normalizeCounterpartyDisplayLabel(row.counterparty);
|
const explicitCounterparty = collapseRepeatedInventoryPartyLabel(normalizeCounterpartyDisplayLabel(row.counterparty));
|
||||||
const explicitComparable = normalizeEntityToken(explicitCounterparty);
|
const explicitComparable = normalizeEntityToken(explicitCounterparty);
|
||||||
if (
|
if (
|
||||||
explicitCounterparty &&
|
explicitCounterparty &&
|
||||||
|
|
@ -1491,6 +1491,53 @@ function extractInventoryCounterpartyCandidates(row: ComposeStageRow, excludedTo
|
||||||
return uniqueStrings(candidates);
|
return uniqueStrings(candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collapseRepeatedInventoryPartyLabel(value: string | null | undefined): string {
|
||||||
|
const compact = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||||
|
if (!compact) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
const tokens = compact.split(" ").filter(Boolean);
|
||||||
|
for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) {
|
||||||
|
const collapsedTokens: string[] = [];
|
||||||
|
let changed = false;
|
||||||
|
for (let index = 0; index < tokens.length; ) {
|
||||||
|
const leftTokens = tokens.slice(index, index + size);
|
||||||
|
const rightTokens = tokens.slice(index + size, index + size * 2);
|
||||||
|
if (
|
||||||
|
leftTokens.length === size &&
|
||||||
|
rightTokens.length === size &&
|
||||||
|
normalizeEntityToken(leftTokens.join(" ")) === normalizeEntityToken(rightTokens.join(" "))
|
||||||
|
) {
|
||||||
|
collapsedTokens.push(...leftTokens);
|
||||||
|
index += size * 2;
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
collapsedTokens.push(tokens[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
return collapseRepeatedInventoryPartyLabel(collapsedTokens.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueCollapsedInventoryPartyLabels(values: string[]): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
const collapsed = collapseRepeatedInventoryPartyLabel(value);
|
||||||
|
const comparable = normalizeEntityToken(collapsed);
|
||||||
|
if (!collapsed || !comparable || seen.has(comparable)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(comparable);
|
||||||
|
result.push(collapsed);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
interface InventoryTraceSummary {
|
interface InventoryTraceSummary {
|
||||||
item: string | null;
|
item: string | null;
|
||||||
warehouses: string[];
|
warehouses: string[];
|
||||||
|
|
@ -1518,7 +1565,7 @@ function summarizeInventoryTraceRows(rows: ComposeStageRow[], excludedCounterpar
|
||||||
.map((row) => extractInventoryOrganizationName(row))
|
.map((row) => extractInventoryOrganizationName(row))
|
||||||
.filter((item): item is string => Boolean(item))
|
.filter((item): item is string => Boolean(item))
|
||||||
);
|
);
|
||||||
const counterparties = uniqueStrings(
|
const counterparties = uniqueCollapsedInventoryPartyLabels(
|
||||||
rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))
|
rows.flatMap((row) => extractInventoryCounterpartyCandidates(row, excludedCounterpartyTokens))
|
||||||
);
|
);
|
||||||
const documents = uniqueStrings(
|
const documents = uniqueStrings(
|
||||||
|
|
|
||||||
|
|
@ -764,7 +764,7 @@ export function hasInventorySupplierFollowupCue(text: string): boolean {
|
||||||
export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
|
export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
|
||||||
const value = String(text ?? "");
|
const value = String(text ?? "");
|
||||||
return (
|
return (
|
||||||
/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
|
/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
|
||||||
value
|
value
|
||||||
) ||
|
) ||
|
||||||
/(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(
|
/(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,65 @@ function normalizeInventoryReplyEntityToken(value: string | null | undefined): s
|
||||||
return normalized || null;
|
return normalized || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInventoryReplyLabelComparable(value: string | null | undefined): string | null {
|
||||||
|
const normalized = String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ё/gu, "е")
|
||||||
|
.replace(/С‘/gu, "Рµ")
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseRepeatedInventoryReplyLabel(value: string | null | undefined): string {
|
||||||
|
const compact = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||||||
|
if (!compact) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
const tokens = compact.split(/\s+/u).filter(Boolean);
|
||||||
|
for (let size = 1; size <= Math.floor(tokens.length / 2); size += 1) {
|
||||||
|
const collapsedTokens: string[] = [];
|
||||||
|
let changed = false;
|
||||||
|
for (let index = 0; index < tokens.length; ) {
|
||||||
|
const leftTokens = tokens.slice(index, index + size);
|
||||||
|
const rightTokens = tokens.slice(index + size, index + size * 2);
|
||||||
|
if (
|
||||||
|
leftTokens.length === size &&
|
||||||
|
rightTokens.length === size &&
|
||||||
|
normalizeInventoryReplyLabelComparable(leftTokens.join(" ")) === normalizeInventoryReplyLabelComparable(rightTokens.join(" "))
|
||||||
|
) {
|
||||||
|
collapsedTokens.push(...leftTokens);
|
||||||
|
index += size * 2;
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
collapsedTokens.push(tokens[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
return collapseRepeatedInventoryReplyLabel(collapsedTokens.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInventoryTraceSummaryCounterparties(summary: InventoryTraceSummary): void {
|
||||||
|
const normalized: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const counterparty of summary.counterparties) {
|
||||||
|
const collapsed = collapseRepeatedInventoryReplyLabel(counterparty);
|
||||||
|
const comparable = normalizeInventoryReplyLabelComparable(collapsed);
|
||||||
|
if (!collapsed || !comparable || seen.has(comparable)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(comparable);
|
||||||
|
normalized.push(collapsed);
|
||||||
|
}
|
||||||
|
summary.counterparties = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
|
function sumInventoryRowAmount(rows: ComposeStageRow[]): number {
|
||||||
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
return rows.reduce((sum, row) => sum + (typeof row.amount === "number" && Number.isFinite(row.amount) ? row.amount : 0), 0);
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +263,12 @@ function asksInventoryMarginAccount41Not01(userMessage: string | null | undefine
|
||||||
return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text);
|
return /(?:\b41\b|41\s*сч|сч[её]т[ау]?\s*41)/iu.test(text) && /(?:\b01\b|не\s+ос|основн)/iu.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asksForInventoryConfirmationDocument(userMessage: string | null | undefined): boolean {
|
||||||
|
return /(?:каким\s+документом|чем\s+подтвержд|документ(?:ом|ами)?\s+подтвержд|покажи\s+закупочн(?:ый|ые)\s+документ|покажи\s+документ)/iu.test(
|
||||||
|
String(userMessage ?? "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface InventoryMarginRankingEntry {
|
interface InventoryMarginRankingEntry {
|
||||||
item: string;
|
item: string;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
|
|
@ -346,10 +411,15 @@ export function composeInventoryReply(
|
||||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const itemLabel = summary.item ?? "товар не определен";
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
|
const firstDocument = summary.documents[0] ?? null;
|
||||||
|
const confirmationDocumentFocus = asksForInventoryConfirmationDocument(options.userMessage);
|
||||||
const directAnswerLine =
|
const directAnswerLine =
|
||||||
purchaseRows.length <= 0
|
purchaseRows.length <= 0
|
||||||
? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.`
|
? `По позиции ${itemLabel} подтвержденные документы закупки в доступных данных не найдены.`
|
||||||
|
: confirmationDocumentFocus && firstDocument
|
||||||
|
? `Документ подтверждения по позиции ${itemLabel}: ${firstDocument}.`
|
||||||
: `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`;
|
: `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`;
|
||||||
const lines: string[] = [directAnswerLine];
|
const lines: string[] = [directAnswerLine];
|
||||||
appendInventoryBulletSection(lines, "Сводка:", [
|
appendInventoryBulletSection(lines, "Сводка:", [
|
||||||
|
|
@ -376,6 +446,7 @@ export function composeInventoryReply(
|
||||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const itemLabel = summary.item ?? "товар не определен";
|
const itemLabel = summary.item ?? "товар не определен";
|
||||||
const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null;
|
const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null;
|
||||||
const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage);
|
const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage);
|
||||||
|
|
@ -485,6 +556,7 @@ export function composeInventoryReply(
|
||||||
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
const asOfDate = deps.resolvePayablesAsOfDate(options);
|
||||||
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
|
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0);
|
||||||
const unresolvedSupplierQuestion =
|
const unresolvedSupplierQuestion =
|
||||||
/(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(
|
/(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(
|
||||||
|
|
@ -579,6 +651,7 @@ export function composeInventoryReply(
|
||||||
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
return Boolean(itemToken && agingItemTokens.has(itemToken));
|
||||||
});
|
});
|
||||||
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
const summary = deps.summarizeInventoryTraceRows(purchaseRows);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
|
||||||
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
|
||||||
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
const organizationLabel = agingItems.find((item) => item.organization)?.organization ?? null;
|
||||||
|
|
@ -631,6 +704,7 @@ export function composeInventoryReply(
|
||||||
const requestedItemHint = String(options.itemHint ?? "").trim();
|
const requestedItemHint = String(options.itemHint ?? "").trim();
|
||||||
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : [];
|
||||||
const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens);
|
||||||
|
normalizeInventoryTraceSummaryCounterparties(summary);
|
||||||
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
|
||||||
const excludedCounterpartyTokens = [itemLabel];
|
const excludedCounterpartyTokens = [itemLabel];
|
||||||
const directAnswerLine =
|
const directAnswerLine =
|
||||||
|
|
@ -715,7 +789,7 @@ export function composeInventoryReply(
|
||||||
"оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;",
|
"оплаты могут помочь сверить поступление денег, но сами по себе не подтверждают валовую прибыль по товарам;",
|
||||||
"строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений."
|
"строгий бухгалтерский расчет требует проводок реализации и себестоимости, а не только банковских движений."
|
||||||
]);
|
]);
|
||||||
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
|
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", true));
|
||||||
}
|
}
|
||||||
if (confirmedEntries.length === 0) {
|
if (confirmedEntries.length === 0) {
|
||||||
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
|
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
|
||||||
|
|
|
||||||
|
|
@ -419,4 +419,54 @@ describe("inventory profitability selected-object regressions", () => {
|
||||||
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
|
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
|
||||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps payment-bank margin false-source boundary factual in the full runtime", async () => {
|
||||||
|
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||||
|
fetched_rows: 2,
|
||||||
|
matched_rows: 2,
|
||||||
|
raw_rows: [
|
||||||
|
{
|
||||||
|
Period: "2020-01-10T00:00:00Z",
|
||||||
|
Registrator: "Purchase document 1",
|
||||||
|
AccountDt: "41.01",
|
||||||
|
AccountKt: "60.01",
|
||||||
|
Amount: 500,
|
||||||
|
Quantity: 10,
|
||||||
|
SubcontoDt1: "Item A",
|
||||||
|
Organization: "OOO Alternative Plus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Period: "2020-02-10T00:00:00Z",
|
||||||
|
Registrator: "Sales document 1",
|
||||||
|
AccountDt: "62.01",
|
||||||
|
AccountKt: "41.01",
|
||||||
|
Amount: 1500,
|
||||||
|
Quantity: 10,
|
||||||
|
SubcontoKt1: "Item A",
|
||||||
|
Organization: "OOO Alternative Plus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rows: [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new AddressQueryService();
|
||||||
|
const result = await service.tryHandle(
|
||||||
|
"Можно быстро понять маржинальность товаров за 2020 год по оплатам и банку?"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.handled).toBe(true);
|
||||||
|
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||||
|
expect(result?.reply_type).toBe("factual");
|
||||||
|
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
|
||||||
|
expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature");
|
||||||
|
expect(result?.debug.address_coverage_evidence_v1?.coverage_status).toBe("full");
|
||||||
|
const reply = String(result?.reply_text ?? "");
|
||||||
|
expect(reply).toContain("По оплатам и банку");
|
||||||
|
expect(reply).toContain("нельзя честно подтвердить");
|
||||||
|
expect(reply).toContain("выручка реализации");
|
||||||
|
expect(reply).toContain("себестоимостная база");
|
||||||
|
expect(reply).not.toContain("Самая маржинальная позиция");
|
||||||
|
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -612,6 +612,111 @@ describe("inventory selected-object follow-up", () => {
|
||||||
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000003");
|
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000003");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes confirmation-document follow-up over selected item and deduplicates supplier labels", async () => {
|
||||||
|
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||||
|
fetched_rows: 1,
|
||||||
|
matched_rows: 1,
|
||||||
|
raw_rows: [
|
||||||
|
{
|
||||||
|
Period: "2020-06-22T00:00:00Z",
|
||||||
|
Registrator: "Поступление товаров и услуг 00000000020 от 22.06.2020 0:00:00",
|
||||||
|
AccountDt: "41.01",
|
||||||
|
AccountKt: "60.01",
|
||||||
|
Amount: 4744,
|
||||||
|
SubcontoDt1: "Рулонные шторы. Цвет мадагаскар серый",
|
||||||
|
SubcontoDt3: "Основной склад",
|
||||||
|
SubcontoKt1: "ИП Тучкова ИП Тучкова",
|
||||||
|
Counterparty: "ИП Тучкова",
|
||||||
|
Organization: "ООО \\Альтернатива Плюс\\"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rows: [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new AddressQueryService();
|
||||||
|
const result = await service.tryHandle("Каким документом это подтверждено?", {
|
||||||
|
followupContext: {
|
||||||
|
previous_intent: "inventory_purchase_provenance_for_item",
|
||||||
|
target_intent: "inventory_purchase_provenance_for_item",
|
||||||
|
root_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
previous_filters: {
|
||||||
|
item: "Рулонные шторы. Цвет мадагаскар серый",
|
||||||
|
organization: "ООО \\Альтернатива Плюс\\",
|
||||||
|
as_of_date: "2020-12-31",
|
||||||
|
period_from: "2020-01-01",
|
||||||
|
period_to: "2020-12-31"
|
||||||
|
},
|
||||||
|
previous_anchor_type: "item",
|
||||||
|
previous_anchor_value: "Рулонные шторы. Цвет мадагаскар серый",
|
||||||
|
current_frame_kind: "inventory_drilldown"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.handled).toBe(true);
|
||||||
|
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||||
|
expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item");
|
||||||
|
expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_documents_for_item_v1");
|
||||||
|
expect(result?.debug.extracted_filters?.item).toBe("Рулонные шторы. Цвет мадагаскар серый");
|
||||||
|
expect(result?.debug.extracted_filters?.counterparty).toBeUndefined();
|
||||||
|
const reply = String(result?.reply_text ?? "");
|
||||||
|
expect(reply.split(/\r?\n/u)[0]).toContain("Документ подтверждения");
|
||||||
|
expect(reply).toContain("Поступление товаров и услуг 00000000020");
|
||||||
|
expect(reply).toContain("Поставщик: ИП Тучкова");
|
||||||
|
expect(reply).not.toContain("ИП Тучкова ИП Тучкова");
|
||||||
|
expect(reply).not.toContain("уточните контрагента");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates supplier labels in selected-item provenance answer", async () => {
|
||||||
|
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||||
|
fetched_rows: 1,
|
||||||
|
matched_rows: 1,
|
||||||
|
raw_rows: [
|
||||||
|
{
|
||||||
|
Period: "2020-06-22T00:00:00Z",
|
||||||
|
Registrator: "Поступление товаров и услуг 00000000020 от 22.06.2020 0:00:00",
|
||||||
|
AccountDt: "41.01",
|
||||||
|
AccountKt: "60.01",
|
||||||
|
Amount: 4744,
|
||||||
|
SubcontoDt1: "Рулонные шторы. Цвет мадагаскар серый",
|
||||||
|
SubcontoDt3: "Основной склад",
|
||||||
|
SubcontoKt1: "ИП Тучкова ИП Тучкова",
|
||||||
|
Counterparty: "ИП Тучкова",
|
||||||
|
Organization: "ООО \\Альтернатива Плюс\\"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rows: [],
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new AddressQueryService();
|
||||||
|
const result = await service.tryHandle("А по этой позиции кто поставщик?", {
|
||||||
|
followupContext: {
|
||||||
|
previous_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
target_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
root_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
previous_filters: {
|
||||||
|
item: "Рулонные шторы. Цвет мадагаскар серый",
|
||||||
|
organization: "ООО \\Альтернатива Плюс\\",
|
||||||
|
as_of_date: "2020-12-31",
|
||||||
|
period_from: "2020-01-01",
|
||||||
|
period_to: "2020-12-31"
|
||||||
|
},
|
||||||
|
previous_anchor_type: "item",
|
||||||
|
previous_anchor_value: "Рулонные шторы. Цвет мадагаскар серый",
|
||||||
|
current_frame_kind: "inventory_drilldown"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.handled).toBe(true);
|
||||||
|
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||||
|
const reply = String(result?.reply_text ?? "");
|
||||||
|
expect(reply).toContain("подтвержден поставщик: ИП Тучкова");
|
||||||
|
expect(reply).not.toContain("ИП Тучкова ИП Тучкова");
|
||||||
|
expect(reply).not.toContain("однозначный поставщик не подтвержден");
|
||||||
|
expect(reply).not.toContain("несколько поставщиков");
|
||||||
|
});
|
||||||
|
|
||||||
it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => {
|
it("routes buyer follow-up over the same selected item into sale trace instead of replaying provenance", async () => {
|
||||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||||
fetched_rows: 1,
|
fetched_rows: 1,
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,151 @@ describe("address navigation state", () => {
|
||||||
expect(evolved.session_context.active_focus_object?.label).toBe("Диван трехместный");
|
expect(evolved.session_context.active_focus_object?.label).toBe("Диван трехместный");
|
||||||
expect(evolved.session_context.active_focus_object?.provenance_result_set_id).toBe("rs-msg-a3");
|
expect(evolved.session_context.active_focus_object?.provenance_result_set_id).toBe("rs-msg-a3");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("captures top margin-ranking item as active focus for selected-object follow-ups", () => {
|
||||||
|
const base = createEmptyAddressNavigationState("asst-margin", "2026-04-12T10:00:00.000Z");
|
||||||
|
const assistantItem = {
|
||||||
|
message_id: "msg-margin",
|
||||||
|
session_id: "asst-margin",
|
||||||
|
role: "assistant",
|
||||||
|
text: [
|
||||||
|
"Самая маржинальная позиция за период 01.01.2020 - 31.12.2020: Рулонные шторы. Цвет мадагаскар серый — маржа 96,00%.",
|
||||||
|
"Высокая валовая маржинальность (топ по проценту маржи):",
|
||||||
|
"1. Рулонные шторы. Цвет мадагаскар серый — выручка 118.552,00 ₽, себестоимостная база 4.744,00 ₽, маржа 96,00%.",
|
||||||
|
"2. Портьерные шторы Garden kolor — выручка 458.472,00 ₽, себестоимостная база 41.480,00 ₽, маржа 90,95%."
|
||||||
|
].join("\n"),
|
||||||
|
reply_type: "factual",
|
||||||
|
created_at: "2026-04-12T10:03:00.000Z",
|
||||||
|
trace_id: "address-margin",
|
||||||
|
debug: {
|
||||||
|
detected_mode: "address_query",
|
||||||
|
detected_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1",
|
||||||
|
extracted_filters: {
|
||||||
|
period_from: "2020-01-01",
|
||||||
|
period_to: "2020-12-31",
|
||||||
|
organization: "ООО Альтернатива Плюс"
|
||||||
|
},
|
||||||
|
anchor_type: "unknown",
|
||||||
|
anchor_value_resolved: null,
|
||||||
|
anchor_value_raw: null,
|
||||||
|
dialog_continuation_contract_v2: {
|
||||||
|
decision: "new_topic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 3);
|
||||||
|
expect(evolved.result_sets[0]?.type).toBe("inventory_trace");
|
||||||
|
expect(evolved.result_sets[0]?.entity_refs[0]?.entity_type).toBe("item");
|
||||||
|
expect(evolved.result_sets[0]?.entity_refs[0]?.value).toBe("Рулонные шторы. Цвет мадагаскар серый");
|
||||||
|
expect(evolved.session_context.active_focus_object?.object_type).toBe("item");
|
||||||
|
expect(evolved.session_context.active_focus_object?.label).toBe("Рулонные шторы. Цвет мадагаскар серый");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps margin-ranking item focus when an explanatory follow-up resolves only organization anchor", () => {
|
||||||
|
const base = createEmptyAddressNavigationState("asst-margin-followup", "2026-04-12T10:00:00.000Z");
|
||||||
|
const rootItem = {
|
||||||
|
message_id: "msg-margin-root",
|
||||||
|
session_id: "asst-margin-followup",
|
||||||
|
role: "assistant",
|
||||||
|
text: [
|
||||||
|
"Самая маржинальная позиция за период 01.01.2020 - 31.12.2020: Рулонные шторы. Цвет мадагаскар серый — маржа 96,00%.",
|
||||||
|
"Высокая валовая маржинальность:",
|
||||||
|
"1. Рулонные шторы. Цвет мадагаскар серый — выручка 118.552,00 ₽, себестоимостная база 4.744,00 ₽, маржа 96,00%."
|
||||||
|
].join("\n"),
|
||||||
|
reply_type: "factual",
|
||||||
|
created_at: "2026-04-12T10:03:00.000Z",
|
||||||
|
trace_id: "address-margin-root",
|
||||||
|
debug: {
|
||||||
|
detected_mode: "address_query",
|
||||||
|
detected_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1",
|
||||||
|
extracted_filters: {
|
||||||
|
period_from: "2020-01-01",
|
||||||
|
period_to: "2020-12-31",
|
||||||
|
organization: "ООО Альтернатива Плюс"
|
||||||
|
},
|
||||||
|
anchor_type: "unknown",
|
||||||
|
dialog_continuation_contract_v2: {
|
||||||
|
decision: "new_topic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
const afterRoot = evolveAddressNavigationStateWithAssistantItem(base, rootItem, 1);
|
||||||
|
const explanationItem = {
|
||||||
|
message_id: "msg-margin-explain",
|
||||||
|
session_id: "asst-margin-followup",
|
||||||
|
role: "assistant",
|
||||||
|
text: "Маржу посчитал по реализации и себестоимостной базе организации ООО Альтернатива Плюс.",
|
||||||
|
reply_type: "factual",
|
||||||
|
created_at: "2026-04-12T10:04:00.000Z",
|
||||||
|
trace_id: "address-margin-explain",
|
||||||
|
debug: {
|
||||||
|
detected_mode: "address_query",
|
||||||
|
detected_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1",
|
||||||
|
extracted_filters: {
|
||||||
|
period_from: "2020-01-01",
|
||||||
|
period_to: "2020-12-31",
|
||||||
|
organization: "ООО Альтернатива Плюс"
|
||||||
|
},
|
||||||
|
anchor_type: "organization",
|
||||||
|
anchor_value_resolved: "ООО Альтернатива Плюс",
|
||||||
|
dialog_continuation_contract_v2: {
|
||||||
|
decision: "continue_previous",
|
||||||
|
previous_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
target_intent: "inventory_margin_ranking_for_nomenclature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const evolved = evolveAddressNavigationStateWithAssistantItem(afterRoot, explanationItem, 2);
|
||||||
|
|
||||||
|
expect(evolved.session_context.active_focus_object?.object_type).toBe("item");
|
||||||
|
expect(evolved.session_context.active_focus_object?.label).toBe("Рулонные шторы. Цвет мадагаскар серый");
|
||||||
|
expect(evolved.navigation_history[1]?.target_object_id).toBe(
|
||||||
|
"item:рулонные шторы. цвет мадагаскар серый"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers top margin-ranking item over organization anchor on new margin answers", () => {
|
||||||
|
const base = createEmptyAddressNavigationState("asst-margin-org-anchor", "2026-04-12T10:00:00.000Z");
|
||||||
|
const assistantItem = {
|
||||||
|
message_id: "msg-margin-org-anchor",
|
||||||
|
session_id: "asst-margin-org-anchor",
|
||||||
|
role: "assistant",
|
||||||
|
text: [
|
||||||
|
"Самая маржинальная позиция за период 01.01.2020 - 31.12.2020: Рулонные шторы. Цвет мадагаскар серый — маржа 96,00%.",
|
||||||
|
"Высокая валовая маржинальность:",
|
||||||
|
"1. Рулонные шторы. Цвет мадагаскар серый — выручка 118.552,00 ₽, себестоимостная база 4.744,00 ₽, маржа 96,00%."
|
||||||
|
].join("\n"),
|
||||||
|
reply_type: "factual",
|
||||||
|
created_at: "2026-04-12T10:03:00.000Z",
|
||||||
|
trace_id: "address-margin-org-anchor",
|
||||||
|
debug: {
|
||||||
|
detected_mode: "address_query",
|
||||||
|
detected_intent: "inventory_margin_ranking_for_nomenclature",
|
||||||
|
selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1",
|
||||||
|
extracted_filters: {
|
||||||
|
period_from: "2020-01-01",
|
||||||
|
period_to: "2020-12-31",
|
||||||
|
organization: "ООО Альтернатива Плюс"
|
||||||
|
},
|
||||||
|
anchor_type: "organization",
|
||||||
|
anchor_value_resolved: "ООО Альтернатива Плюс",
|
||||||
|
dialog_continuation_contract_v2: {
|
||||||
|
decision: "new_topic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 1);
|
||||||
|
|
||||||
|
expect(evolved.session_context.active_focus_object?.object_type).toBe("item");
|
||||||
|
expect(evolved.session_context.active_focus_object?.label).toBe("Рулонные шторы. Цвет мадагаскар серый");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives single organization scope from inventory answer text when filters omit organization", () => {
|
it("derives single organization scope from inventory answer text when filters omit organization", () => {
|
||||||
const base = createEmptyAddressNavigationState("asst-5", "2026-04-12T10:00:00.000Z");
|
const base = createEmptyAddressNavigationState("asst-5", "2026-04-12T10:00:00.000Z");
|
||||||
const assistantItem = {
|
const assistantItem = {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,59 @@
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
"generation_id": "gen-ag06012117-dc9b60",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"mode": "saved_user_sessions",
|
||||||
|
"title": "AGENT | margin profitability wrong-domain traps",
|
||||||
|
"count": 9,
|
||||||
|
"domain": "margin_profitability",
|
||||||
|
"questions": [
|
||||||
|
"Какая номенклатура была самой маржинальной за 2020 год?",
|
||||||
|
"Покажи топ товаров по марже за 2020 год.",
|
||||||
|
"А из чего ты это посчитал и чего не хватает для точной маржи?",
|
||||||
|
"А по этой позиции кто поставщик?",
|
||||||
|
"Когда ее купили?",
|
||||||
|
"Каким документом это подтверждено?",
|
||||||
|
"Можно быстро понять маржинальность товаров за 2020 год по оплатам и банку?",
|
||||||
|
"Посчитай маржинальность товарной номенклатуры за 2020 год, не ОС и не амортизацию.",
|
||||||
|
"По этой позиции покажи закупочный документ."
|
||||||
|
],
|
||||||
|
"generated_by": "codex_agent",
|
||||||
|
"saved_case_set_file": "assistant_autogen_saved_user_sessions_20260601211739_gen-ag06012117-dc9b60.json",
|
||||||
|
"context": {
|
||||||
|
"llm_provider": null,
|
||||||
|
"model": null,
|
||||||
|
"assistant_prompt_version": null,
|
||||||
|
"decomposition_prompt_version": null,
|
||||||
|
"prompt_fingerprint": null,
|
||||||
|
"autogen_personality_id": null,
|
||||||
|
"autogen_personality_prompt": null,
|
||||||
|
"source_session_id": null,
|
||||||
|
"saved_session_file": "assistant_saved_session_20260601211739_gen-ag06012117-dc9b60.json",
|
||||||
|
"saved_case_set_kind": "agent_semantic_scenario",
|
||||||
|
"agent_run": true,
|
||||||
|
"agent_focus": "Минимальный reliability pack для проверки, что вопросы про маржинальность номенклатуры не утекают в ОС, амортизацию, банк, оплаты или взаиморасчеты.",
|
||||||
|
"architecture_phase": "turnaround_11",
|
||||||
|
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\agent_margin_profitability_reliability_20260524.json",
|
||||||
|
"scenario_id": null,
|
||||||
|
"semantic_tags": [
|
||||||
|
"evidence",
|
||||||
|
"fixed_asset_false_source",
|
||||||
|
"fixed_asset_trap_followup",
|
||||||
|
"inventory",
|
||||||
|
"margin_profitability",
|
||||||
|
"payment_false_source",
|
||||||
|
"purchase_date_followup",
|
||||||
|
"purchase_document_followup",
|
||||||
|
"scope_guard",
|
||||||
|
"selected_object",
|
||||||
|
"supplier_followup",
|
||||||
|
"wrong_domain_trap"
|
||||||
|
],
|
||||||
|
"validation_status": "accepted_domain_case_loop_pack",
|
||||||
|
"validated_run_dir": "artifacts\\domain_runs\\agent_margin_profitability_reliability_20260601_p10_pack",
|
||||||
|
"saved_after_validated_replay": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"generation_id": "gen-ag06011942-2ed936",
|
"generation_id": "gen-ag06011942-2ed936",
|
||||||
"created_at": "2026-06-01T19:42:13+00:00",
|
"created_at": "2026-06-01T19:42:13+00:00",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
{
|
||||||
|
"saved_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"generation_id": "gen-ag06012117-dc9b60",
|
||||||
|
"mode": "saved_user_sessions",
|
||||||
|
"title": "AGENT | margin profitability wrong-domain traps",
|
||||||
|
"agent_run": true,
|
||||||
|
"questions": [
|
||||||
|
"Какая номенклатура была самой маржинальной за 2020 год?",
|
||||||
|
"Покажи топ товаров по марже за 2020 год.",
|
||||||
|
"А из чего ты это посчитал и чего не хватает для точной маржи?",
|
||||||
|
"А по этой позиции кто поставщик?",
|
||||||
|
"Когда ее купили?",
|
||||||
|
"Каким документом это подтверждено?",
|
||||||
|
"Можно быстро понять маржинальность товаров за 2020 год по оплатам и банку?",
|
||||||
|
"Посчитай маржинальность товарной номенклатуры за 2020 год, не ОС и не амортизацию.",
|
||||||
|
"По этой позиции покажи закупочный документ."
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"assistant_prompt_version": null,
|
||||||
|
"decomposition_prompt_version": null,
|
||||||
|
"prompt_fingerprint": null,
|
||||||
|
"agent_focus": "Минимальный reliability pack для проверки, что вопросы про маржинальность номенклатуры не утекают в ОС, амортизацию, банк, оплаты или взаиморасчеты.",
|
||||||
|
"architecture_phase": "turnaround_11",
|
||||||
|
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\agent_margin_profitability_reliability_20260524.json",
|
||||||
|
"scenario_id": null,
|
||||||
|
"semantic_tags": [
|
||||||
|
"evidence",
|
||||||
|
"fixed_asset_false_source",
|
||||||
|
"fixed_asset_trap_followup",
|
||||||
|
"inventory",
|
||||||
|
"margin_profitability",
|
||||||
|
"payment_false_source",
|
||||||
|
"purchase_date_followup",
|
||||||
|
"purchase_document_followup",
|
||||||
|
"scope_guard",
|
||||||
|
"selected_object",
|
||||||
|
"supplier_followup",
|
||||||
|
"wrong_domain_trap"
|
||||||
|
],
|
||||||
|
"validation_status": "accepted_domain_case_loop_pack",
|
||||||
|
"validated_run_dir": "artifacts\\domain_runs\\agent_margin_profitability_reliability_20260601_p10_pack",
|
||||||
|
"saved_after_validated_replay": true,
|
||||||
|
"save_gate": {
|
||||||
|
"schema_version": "agent_semantic_save_gate_v1",
|
||||||
|
"validation_status": "accepted_domain_case_loop_pack",
|
||||||
|
"validated_run_dir": "artifacts\\domain_runs\\agent_margin_profitability_reliability_20260601_p10_pack",
|
||||||
|
"final_status": "accepted",
|
||||||
|
"acceptance_status": "accepted",
|
||||||
|
"execution_status": "exact",
|
||||||
|
"pack_id": "agent_margin_profitability_reliability_20260601_p10_pack",
|
||||||
|
"scenario_count": 3,
|
||||||
|
"repair_target_count": 0,
|
||||||
|
"repair_target_severity_counts": {
|
||||||
|
"P0": 0,
|
||||||
|
"P1": 0,
|
||||||
|
"P2": 0
|
||||||
|
},
|
||||||
|
"effective_runtime": {
|
||||||
|
"manifest_path": "artifacts\\domain_runs\\agent_margin_profitability_reliability_20260601_p10_pack\\effective_runtime.json",
|
||||||
|
"runner": "domain_case_loop.run-pack",
|
||||||
|
"git_sha": "88c92e316abf4c457df2165ed5ab18c6fd1b0edf",
|
||||||
|
"backend_url": "http://127.0.0.1:8787",
|
||||||
|
"mcp_proxy_url": "http://127.0.0.1:6003",
|
||||||
|
"llm_provider": "local",
|
||||||
|
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
|
||||||
|
"temperature": 0.8,
|
||||||
|
"max_output_tokens": 900,
|
||||||
|
"prompt_version": "normalizer_v2_0_2",
|
||||||
|
"prompt_source": "file",
|
||||||
|
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
|
||||||
|
"prompt_registry_status": "pass"
|
||||||
|
},
|
||||||
|
"saved_after_validated_replay": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source_session_id": null,
|
||||||
|
"session": {
|
||||||
|
"session_id": null,
|
||||||
|
"mode": "agent_semantic_run",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-001",
|
||||||
|
"role": "user",
|
||||||
|
"text": "Какая номенклатура была самой маржинальной за 2020 год?",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-002",
|
||||||
|
"role": "user",
|
||||||
|
"text": "Покажи топ товаров по марже за 2020 год.",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-003",
|
||||||
|
"role": "user",
|
||||||
|
"text": "А из чего ты это посчитал и чего не хватает для точной маржи?",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-004",
|
||||||
|
"role": "user",
|
||||||
|
"text": "А по этой позиции кто поставщик?",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-005",
|
||||||
|
"role": "user",
|
||||||
|
"text": "Когда ее купили?",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-006",
|
||||||
|
"role": "user",
|
||||||
|
"text": "Каким документом это подтверждено?",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-007",
|
||||||
|
"role": "user",
|
||||||
|
"text": "Можно быстро понять маржинальность товаров за 2020 год по оплатам и банку?",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-008",
|
||||||
|
"role": "user",
|
||||||
|
"text": "Посчитай маржинальность товарной номенклатуры за 2020 год, не ОС и не амортизацию.",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message_id": "agent-user-009",
|
||||||
|
"role": "user",
|
||||||
|
"text": "По этой позиции покажи закупочный документ.",
|
||||||
|
"created_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"reply_type": null,
|
||||||
|
"trace_id": null,
|
||||||
|
"debug": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agent_run": true,
|
||||||
|
"metadata": {
|
||||||
|
"assistant_prompt_version": null,
|
||||||
|
"decomposition_prompt_version": null,
|
||||||
|
"prompt_fingerprint": null,
|
||||||
|
"agent_focus": "Минимальный reliability pack для проверки, что вопросы про маржинальность номенклатуры не утекают в ОС, амортизацию, банк, оплаты или взаиморасчеты.",
|
||||||
|
"architecture_phase": "turnaround_11",
|
||||||
|
"source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\agent_margin_profitability_reliability_20260524.json",
|
||||||
|
"scenario_id": null,
|
||||||
|
"semantic_tags": [
|
||||||
|
"evidence",
|
||||||
|
"fixed_asset_false_source",
|
||||||
|
"fixed_asset_trap_followup",
|
||||||
|
"inventory",
|
||||||
|
"margin_profitability",
|
||||||
|
"payment_false_source",
|
||||||
|
"purchase_date_followup",
|
||||||
|
"purchase_document_followup",
|
||||||
|
"scope_guard",
|
||||||
|
"selected_object",
|
||||||
|
"supplier_followup",
|
||||||
|
"wrong_domain_trap"
|
||||||
|
],
|
||||||
|
"validation_status": "accepted_domain_case_loop_pack",
|
||||||
|
"validated_run_dir": "artifacts\\domain_runs\\agent_margin_profitability_reliability_20260601_p10_pack",
|
||||||
|
"saved_after_validated_replay": true,
|
||||||
|
"save_gate": {
|
||||||
|
"schema_version": "agent_semantic_save_gate_v1",
|
||||||
|
"validation_status": "accepted_domain_case_loop_pack",
|
||||||
|
"validated_run_dir": "artifacts\\domain_runs\\agent_margin_profitability_reliability_20260601_p10_pack",
|
||||||
|
"final_status": "accepted",
|
||||||
|
"acceptance_status": "accepted",
|
||||||
|
"execution_status": "exact",
|
||||||
|
"pack_id": "agent_margin_profitability_reliability_20260601_p10_pack",
|
||||||
|
"scenario_count": 3,
|
||||||
|
"repair_target_count": 0,
|
||||||
|
"repair_target_severity_counts": {
|
||||||
|
"P0": 0,
|
||||||
|
"P1": 0,
|
||||||
|
"P2": 0
|
||||||
|
},
|
||||||
|
"effective_runtime": {
|
||||||
|
"manifest_path": "artifacts\\domain_runs\\agent_margin_profitability_reliability_20260601_p10_pack\\effective_runtime.json",
|
||||||
|
"runner": "domain_case_loop.run-pack",
|
||||||
|
"git_sha": "88c92e316abf4c457df2165ed5ab18c6fd1b0edf",
|
||||||
|
"backend_url": "http://127.0.0.1:8787",
|
||||||
|
"mcp_proxy_url": "http://127.0.0.1:6003",
|
||||||
|
"llm_provider": "local",
|
||||||
|
"llm_model": "unsloth/qwen3-30b-a3b-instruct-2507",
|
||||||
|
"temperature": 0.8,
|
||||||
|
"max_output_tokens": 900,
|
||||||
|
"prompt_version": "normalizer_v2_0_2",
|
||||||
|
"prompt_source": "file",
|
||||||
|
"prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1",
|
||||||
|
"prompt_registry_status": "pass"
|
||||||
|
},
|
||||||
|
"saved_after_validated_replay": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"suite_id": "assistant_saved_session_gen-ag06012117-dc9b60",
|
||||||
|
"suite_version": "0.1.0",
|
||||||
|
"schema_version": "assistant_saved_session_suite_v0_1",
|
||||||
|
"generated_at": "2026-06-01T21:17:39+00:00",
|
||||||
|
"generation_id": "gen-ag06012117-dc9b60",
|
||||||
|
"mode": "saved_user_sessions",
|
||||||
|
"title": "AGENT | margin profitability wrong-domain traps",
|
||||||
|
"domain": "margin_profitability",
|
||||||
|
"scenario_count": 1,
|
||||||
|
"case_ids": [
|
||||||
|
"SAVED-001"
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"case_id": "SAVED-001",
|
||||||
|
"scenario_tag": "agent_saved_user_sessions",
|
||||||
|
"title": "AGENT | margin profitability wrong-domain traps",
|
||||||
|
"question_type": "followup",
|
||||||
|
"broadness_level": "medium",
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"user_message": "Какая номенклатура была самой маржинальной за 2020 год?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "Покажи топ товаров по марже за 2020 год."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "А из чего ты это посчитал и чего не хватает для точной маржи?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "А по этой позиции кто поставщик?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "Когда ее купили?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "Каким документом это подтверждено?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "Можно быстро понять маржинальность товаров за 2020 год по оплатам и банку?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "Посчитай маржинальность товарной номенклатуры за 2020 год, не ОС и не амортизацию."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_message": "По этой позиции покажи закупочный документ."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -166,6 +166,60 @@ def read_text_or_empty(path: Path) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def assistant_text_from_turn_path(path: Path | None) -> str:
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return ""
|
||||||
|
payload = read_json_object(path)
|
||||||
|
assistant_message = payload.get("assistant_message") if isinstance(payload.get("assistant_message"), dict) else {}
|
||||||
|
text = str(assistant_message.get("text") or "").strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def current_answer_text_for_output(output: dict[str, Any]) -> str:
|
||||||
|
turn_path = output.get("turn_path")
|
||||||
|
if isinstance(turn_path, Path):
|
||||||
|
turn_answer = assistant_text_from_turn_path(turn_path)
|
||||||
|
if turn_answer:
|
||||||
|
return turn_answer
|
||||||
|
return str(output.get("text") or "")
|
||||||
|
|
||||||
|
|
||||||
|
CURRENT_TRACE_FIELD_NAMES = {
|
||||||
|
"detected_mode",
|
||||||
|
"query_shape",
|
||||||
|
"detected_intent",
|
||||||
|
"selected_recipe",
|
||||||
|
"requested_result_mode",
|
||||||
|
"result_mode",
|
||||||
|
"response_type",
|
||||||
|
"capability_id",
|
||||||
|
"capability_layer",
|
||||||
|
"capability_route_mode",
|
||||||
|
"capability_route_reason",
|
||||||
|
"execution_lane",
|
||||||
|
"fallback_type",
|
||||||
|
"match_failure_stage",
|
||||||
|
"match_failure_reason",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def current_trace_text_from_turn_path(path: Path) -> str:
|
||||||
|
payload = read_json_object(path)
|
||||||
|
debug = payload.get("technical_debug_payload") if isinstance(payload.get("technical_debug_payload"), dict) else {}
|
||||||
|
scoped: dict[str, Any] = {}
|
||||||
|
for field_name in CURRENT_TRACE_FIELD_NAMES:
|
||||||
|
if field_name in debug:
|
||||||
|
scoped[field_name] = debug.get(field_name)
|
||||||
|
for prefix in ("route_expectation_", "capability_route_"):
|
||||||
|
for field_name, value in debug.items():
|
||||||
|
if isinstance(field_name, str) and field_name.startswith(prefix):
|
||||||
|
scoped[field_name] = value
|
||||||
|
assistant_message = payload.get("assistant_message") if isinstance(payload.get("assistant_message"), dict) else {}
|
||||||
|
if assistant_message.get("reply_type") is not None:
|
||||||
|
scoped["assistant_reply_type"] = assistant_message.get("reply_type")
|
||||||
|
return json.dumps(scoped, ensure_ascii=False, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
def output_turn_path(output_path: Path) -> Path | None:
|
def output_turn_path(output_path: Path) -> Path | None:
|
||||||
name = output_path.name
|
name = output_path.name
|
||||||
if name == "output.md":
|
if name == "output.md":
|
||||||
|
|
@ -305,7 +359,7 @@ def evaluate_forbidden_regex(
|
||||||
return build_result(detector_name, detector, "skipped", "no output.md-style artifacts matched detector scope")
|
return build_result(detector_name, detector, "skipped", "no output.md-style artifacts matched detector scope")
|
||||||
evidence: list[dict[str, Any]] = []
|
evidence: list[dict[str, Any]] = []
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
text = str(output.get("text") or "")
|
text = current_answer_text_for_output(output)
|
||||||
if prefix_line_count is not None:
|
if prefix_line_count is not None:
|
||||||
lines = [line for line in text.splitlines() if line.strip()]
|
lines = [line for line in text.splitlines() if line.strip()]
|
||||||
text = "\n".join(lines[:prefix_line_count])
|
text = "\n".join(lines[:prefix_line_count])
|
||||||
|
|
@ -336,7 +390,7 @@ def evaluate_required_any(
|
||||||
missing: list[dict[str, Any]] = []
|
missing: list[dict[str, Any]] = []
|
||||||
matched: list[dict[str, Any]] = []
|
matched: list[dict[str, Any]] = []
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
text = str(output.get("text") or "")
|
text = current_answer_text_for_output(output)
|
||||||
match = next((pattern.search(text) for pattern in patterns if pattern.search(text)), None)
|
match = next((pattern.search(text) for pattern in patterns if pattern.search(text)), None)
|
||||||
if match:
|
if match:
|
||||||
matched.append({"path": output["repo_path"], "match": match.group(0)[:240]})
|
matched.append({"path": output["repo_path"], "match": match.group(0)[:240]})
|
||||||
|
|
@ -360,7 +414,7 @@ def evaluate_limited_next_action(
|
||||||
failures: list[dict[str, Any]] = []
|
failures: list[dict[str, Any]] = []
|
||||||
limited_outputs = 0
|
limited_outputs = 0
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
text = str(output.get("text") or "")
|
text = current_answer_text_for_output(output)
|
||||||
limited_match = next((pattern.search(text) for pattern in limited_patterns if pattern.search(text)), None)
|
limited_match = next((pattern.search(text) for pattern in limited_patterns if pattern.search(text)), None)
|
||||||
if not limited_match:
|
if not limited_match:
|
||||||
continue
|
continue
|
||||||
|
|
@ -388,6 +442,10 @@ def evaluate_trace_guard(
|
||||||
return build_result(detector_name, detector, "skipped", "no turn.json-style artifacts matched detector scope")
|
return build_result(detector_name, detector, "skipped", "no turn.json-style artifacts matched detector scope")
|
||||||
evidence: list[dict[str, Any]] = []
|
evidence: list[dict[str, Any]] = []
|
||||||
for turn in turns:
|
for turn in turns:
|
||||||
|
path = turn.get("path")
|
||||||
|
if isinstance(path, Path):
|
||||||
|
text = current_trace_text_from_turn_path(path).lower()
|
||||||
|
else:
|
||||||
text = str(turn.get("text") or "").lower()
|
text = str(turn.get("text") or "").lower()
|
||||||
for marker in markers:
|
for marker in markers:
|
||||||
if marker in text:
|
if marker in text:
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,9 @@ BUSINESS_EXPECTED_RESULT_MODES = {
|
||||||
"evidence_or_honest_boundary",
|
"evidence_or_honest_boundary",
|
||||||
"ranking_or_limited_accounting_answer",
|
"ranking_or_limited_accounting_answer",
|
||||||
"same_inventory_margin_context_or_clarification",
|
"same_inventory_margin_context_or_clarification",
|
||||||
|
"selected_object_purchase_provenance_or_honest_boundary",
|
||||||
|
"selected_object_purchase_date_or_honest_boundary",
|
||||||
|
"selected_object_purchase_documents_or_honest_boundary",
|
||||||
}
|
}
|
||||||
|
|
||||||
MCP_DISCOVERY_CHAIN_INTENT_ALIASES: dict[str, tuple[str, ...]] = {
|
MCP_DISCOVERY_CHAIN_INTENT_ALIASES: dict[str, tuple[str, ...]] = {
|
||||||
|
|
@ -2607,6 +2610,20 @@ def business_expected_result_mode_matches(expected_result_mode: str, step_state:
|
||||||
detected_intent == "inventory_margin_ranking_for_nomenclature"
|
detected_intent == "inventory_margin_ranking_for_nomenclature"
|
||||||
or capability_id == "inventory_inventory_margin_ranking_for_nomenclature"
|
or capability_id == "inventory_inventory_margin_ranking_for_nomenclature"
|
||||||
)
|
)
|
||||||
|
selected_object_expected_routes = {
|
||||||
|
"selected_object_purchase_provenance_or_honest_boundary": (
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_inventory_purchase_provenance_for_item",
|
||||||
|
),
|
||||||
|
"selected_object_purchase_date_or_honest_boundary": (
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_inventory_purchase_provenance_for_item",
|
||||||
|
),
|
||||||
|
"selected_object_purchase_documents_or_honest_boundary": (
|
||||||
|
"inventory_purchase_documents_for_item",
|
||||||
|
"inventory_inventory_purchase_documents_for_item",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
if expected_result_mode == "clarification_required":
|
if expected_result_mode == "clarification_required":
|
||||||
return (
|
return (
|
||||||
|
|
@ -2668,6 +2685,25 @@ def business_expected_result_mode_matches(expected_result_mode: str, step_state:
|
||||||
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
|
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if expected_result_mode in selected_object_expected_routes:
|
||||||
|
expected_intent, expected_capability = selected_object_expected_routes[expected_result_mode]
|
||||||
|
route_matches = detected_intent == expected_intent or capability_id == expected_capability
|
||||||
|
factual_or_honest_boundary = (
|
||||||
|
truth_mode == "confirmed"
|
||||||
|
or answer_shape == "confirmed_factual"
|
||||||
|
or step_state.get("balance_confirmed") is True
|
||||||
|
or truth_mode in GUARDED_INSUFFICIENCY_TRUTH_MODES
|
||||||
|
or answer_shape in GUARDED_INSUFFICIENCY_ANSWER_SHAPES
|
||||||
|
or step_state.get("balance_confirmed") is False
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
clean_business_review
|
||||||
|
and route_matches
|
||||||
|
and factual_or_honest_boundary
|
||||||
|
and bool(assistant_text)
|
||||||
|
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
|
||||||
|
)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,9 @@ BUSINESS_EXPECTED_RESULT_MODES = {
|
||||||
"evidence_or_honest_boundary",
|
"evidence_or_honest_boundary",
|
||||||
"ranking_or_limited_accounting_answer",
|
"ranking_or_limited_accounting_answer",
|
||||||
"same_inventory_margin_context_or_clarification",
|
"same_inventory_margin_context_or_clarification",
|
||||||
|
"selected_object_purchase_provenance_or_honest_boundary",
|
||||||
|
"selected_object_purchase_date_or_honest_boundary",
|
||||||
|
"selected_object_purchase_documents_or_honest_boundary",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -451,6 +454,20 @@ def business_expected_result_mode_matches(expected_result_mode: str, step_state:
|
||||||
detected_intent == "inventory_margin_ranking_for_nomenclature"
|
detected_intent == "inventory_margin_ranking_for_nomenclature"
|
||||||
or capability_id == "inventory_inventory_margin_ranking_for_nomenclature"
|
or capability_id == "inventory_inventory_margin_ranking_for_nomenclature"
|
||||||
)
|
)
|
||||||
|
selected_object_expected_routes = {
|
||||||
|
"selected_object_purchase_provenance_or_honest_boundary": (
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_inventory_purchase_provenance_for_item",
|
||||||
|
),
|
||||||
|
"selected_object_purchase_date_or_honest_boundary": (
|
||||||
|
"inventory_purchase_provenance_for_item",
|
||||||
|
"inventory_inventory_purchase_provenance_for_item",
|
||||||
|
),
|
||||||
|
"selected_object_purchase_documents_or_honest_boundary": (
|
||||||
|
"inventory_purchase_documents_for_item",
|
||||||
|
"inventory_inventory_purchase_documents_for_item",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
if expected_result_mode == "clarification_required":
|
if expected_result_mode == "clarification_required":
|
||||||
return (
|
return (
|
||||||
|
|
@ -497,6 +514,25 @@ def business_expected_result_mode_matches(expected_result_mode: str, step_state:
|
||||||
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
|
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if expected_result_mode in selected_object_expected_routes:
|
||||||
|
expected_intent, expected_capability = selected_object_expected_routes[expected_result_mode]
|
||||||
|
route_matches = detected_intent == expected_intent or capability_id == expected_capability
|
||||||
|
factual_or_honest_boundary = (
|
||||||
|
truth_mode == "confirmed"
|
||||||
|
or answer_shape == "confirmed_factual"
|
||||||
|
or step_state.get("balance_confirmed") is True
|
||||||
|
or truth_mode in dcl.GUARDED_INSUFFICIENCY_TRUTH_MODES
|
||||||
|
or answer_shape in dcl.GUARDED_INSUFFICIENCY_ANSWER_SHAPES
|
||||||
|
or step_state.get("balance_confirmed") is False
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
clean_business_review
|
||||||
|
and route_matches
|
||||||
|
and factual_or_honest_boundary
|
||||||
|
and bool(assistant_text)
|
||||||
|
and reply_type in {"partial_coverage", "factual", "factual_with_explanation"}
|
||||||
|
)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,10 +262,57 @@ def validate_domain_pack_loop_dir(loop_dir: Path) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_domain_case_loop_pack_dir(run_dir: Path) -> dict[str, Any]:
|
||||||
|
run_dir = run_dir.resolve()
|
||||||
|
effective_runtime = require_effective_runtime_manifest(run_dir)
|
||||||
|
pack_state = load_json_object(run_dir / "pack_state.json", "Validated domain-case-loop pack_state.json")
|
||||||
|
repair_targets = load_json_object(run_dir / "repair_targets.json", "Validated domain-case-loop repair_targets.json")
|
||||||
|
severity_counts = repair_targets.get("severity_counts") if isinstance(repair_targets.get("severity_counts"), dict) else {}
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
assert_status(pack_state.get("execution_status"), "exact", "pack_state.execution_status", problems)
|
||||||
|
assert_status(pack_state.get("final_status"), "accepted", "pack_state.final_status", problems)
|
||||||
|
assert_status(pack_state.get("acceptance_status"), "accepted", "pack_state.acceptance_status", problems)
|
||||||
|
assert_status(repair_targets.get("final_status"), "accepted", "repair_targets.final_status", problems)
|
||||||
|
assert_status(repair_targets.get("acceptance_status"), "accepted", "repair_targets.acceptance_status", problems)
|
||||||
|
if int(repair_targets.get("target_count") or 0) != 0:
|
||||||
|
problems.append(f"repair_targets.target_count={repair_targets.get('target_count')}")
|
||||||
|
if int(severity_counts.get("P0") or 0) != 0 or int(severity_counts.get("P1") or 0) != 0:
|
||||||
|
problems.append(
|
||||||
|
f"repair_targets.severity_counts=P0:{severity_counts.get('P0') or 0},P1:{severity_counts.get('P1') or 0}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Refusing to save AGENT autorun because the validated domain-case-loop pack is not clean: "
|
||||||
|
+ ", ".join(problems)
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario_results = pack_state.get("scenario_results") if isinstance(pack_state.get("scenario_results"), list) else []
|
||||||
|
return {
|
||||||
|
"schema_version": VALIDATED_AGENT_SAVE_SCHEMA_VERSION,
|
||||||
|
"validation_status": "accepted_domain_case_loop_pack",
|
||||||
|
"validated_run_dir": repo_relative(run_dir),
|
||||||
|
"final_status": pack_state.get("final_status"),
|
||||||
|
"acceptance_status": pack_state.get("acceptance_status"),
|
||||||
|
"execution_status": pack_state.get("execution_status"),
|
||||||
|
"pack_id": pack_state.get("pack_id"),
|
||||||
|
"scenario_count": len(scenario_results),
|
||||||
|
"repair_target_count": repair_targets.get("target_count"),
|
||||||
|
"repair_target_severity_counts": repair_targets.get("severity_counts"),
|
||||||
|
"effective_runtime": build_effective_runtime_save_summary(effective_runtime, run_dir),
|
||||||
|
"saved_after_validated_replay": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_accepted_run_dir(run_dir: Path) -> dict[str, Any]:
|
def validate_accepted_run_dir(run_dir: Path) -> dict[str, Any]:
|
||||||
run_dir = run_dir.resolve()
|
run_dir = run_dir.resolve()
|
||||||
if (run_dir / "loop_state.json").exists():
|
if (run_dir / "loop_state.json").exists():
|
||||||
return validate_domain_pack_loop_dir(run_dir)
|
return validate_domain_pack_loop_dir(run_dir)
|
||||||
|
if (run_dir / "truth_review.json").exists():
|
||||||
|
return validate_truth_harness_run_dir(run_dir)
|
||||||
|
if (run_dir / "pack_state.json").exists() and (run_dir / "repair_targets.json").exists():
|
||||||
|
return validate_domain_case_loop_pack_dir(run_dir)
|
||||||
return validate_truth_harness_run_dir(run_dir)
|
return validate_truth_harness_run_dir(run_dir)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,152 @@ class AgentDetectorRunnerTests(unittest.TestCase):
|
||||||
self.assertEqual(statuses["forbidden_margin_terms"], "fail")
|
self.assertEqual(statuses["forbidden_margin_terms"], "fail")
|
||||||
self.assertEqual(statuses["margin_domain_leak_accounting_route"], "fail")
|
self.assertEqual(statuses["margin_domain_leak_accounting_route"], "fail")
|
||||||
|
|
||||||
|
def test_forbidden_regex_uses_current_assistant_answer_from_turn_json(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
artifact_dir = root / "run"
|
||||||
|
step_dir = artifact_dir / "steps" / "s01"
|
||||||
|
write_text(
|
||||||
|
step_dir / "output.md",
|
||||||
|
"# Assistant conversation export\n\n"
|
||||||
|
"## 1. user\n"
|
||||||
|
"Can you compute margin from payment_document evidence?\n\n"
|
||||||
|
"## 2. assistant\n"
|
||||||
|
"Clean current answer.\n",
|
||||||
|
)
|
||||||
|
write_json(
|
||||||
|
step_dir / "turn.json",
|
||||||
|
{
|
||||||
|
"user_message": {"text": "Can you compute margin from payment_document evidence?"},
|
||||||
|
"assistant_message": {"text": "Clean current answer.", "reply_type": "factual"},
|
||||||
|
"technical_debug_payload": {"detected_intent": "inventory_margin_ranking_for_nomenclature"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
registry_path = root / "detector_registry.json"
|
||||||
|
issue_catalog_path = root / "issue_catalog.json"
|
||||||
|
write_json(
|
||||||
|
registry_path,
|
||||||
|
{
|
||||||
|
"schema_version": "agent_detector_registry_v1",
|
||||||
|
"detectors": {
|
||||||
|
"forbidden_margin_terms": {
|
||||||
|
"kind": "answer_text_regex_forbidden",
|
||||||
|
"automation_level": "automatic",
|
||||||
|
"description": "No wrong-domain words in the current answer.",
|
||||||
|
"issue_codes": ["margin_domain_leak_accounting_route"],
|
||||||
|
"inputs": ["output.md"],
|
||||||
|
"check": {"forbidden_patterns": ["payment_document"]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
write_json(issue_catalog_path, {"schema_version": "agent_issue_catalog_v1", "issues": {}})
|
||||||
|
|
||||||
|
results = runner.build_detector_results(
|
||||||
|
artifact_dir,
|
||||||
|
detector_names=["forbidden_margin_terms"],
|
||||||
|
registry_path=registry_path,
|
||||||
|
issue_catalog_path=issue_catalog_path,
|
||||||
|
include_default_global=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(results["summary"]["status"], "pass")
|
||||||
|
self.assertEqual(results["results"][0]["status"], "pass")
|
||||||
|
|
||||||
|
def test_trace_guard_uses_current_route_fields_not_full_turn_history(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
artifact_dir = root / "run"
|
||||||
|
step_dir = artifact_dir / "steps" / "s01"
|
||||||
|
write_json(
|
||||||
|
step_dir / "turn.json",
|
||||||
|
{
|
||||||
|
"user_message": {"text": "Ignore fixed_asset and answer selected item document."},
|
||||||
|
"assistant_message": {"text": "Inventory document answer.", "reply_type": "factual"},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_intent": "inventory_purchase_documents_for_item",
|
||||||
|
"selected_recipe": "address_inventory_purchase_documents_for_item_v1",
|
||||||
|
"capability_id": "inventory_inventory_purchase_documents_for_item",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
registry_path = root / "detector_registry.json"
|
||||||
|
issue_catalog_path = root / "issue_catalog.json"
|
||||||
|
write_json(
|
||||||
|
registry_path,
|
||||||
|
{
|
||||||
|
"schema_version": "agent_detector_registry_v1",
|
||||||
|
"detectors": {
|
||||||
|
"wrong_capability_family": {
|
||||||
|
"kind": "trace_value_guard",
|
||||||
|
"automation_level": "automatic",
|
||||||
|
"description": "No wrong current route family.",
|
||||||
|
"issue_codes": ["margin_domain_leak_accounting_route"],
|
||||||
|
"inputs": ["turn.json"],
|
||||||
|
"check": {"forbidden_trace_markers": ["fixed_asset"]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
write_json(issue_catalog_path, {"schema_version": "agent_issue_catalog_v1", "issues": {}})
|
||||||
|
|
||||||
|
results = runner.build_detector_results(
|
||||||
|
artifact_dir,
|
||||||
|
detector_names=["wrong_capability_family"],
|
||||||
|
registry_path=registry_path,
|
||||||
|
issue_catalog_path=issue_catalog_path,
|
||||||
|
include_default_global=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(results["summary"]["status"], "pass")
|
||||||
|
self.assertEqual(results["results"][0]["status"], "pass")
|
||||||
|
|
||||||
|
def test_trace_guard_fails_when_current_route_field_has_forbidden_marker(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
artifact_dir = root / "run"
|
||||||
|
step_dir = artifact_dir / "steps" / "s01"
|
||||||
|
write_json(
|
||||||
|
step_dir / "turn.json",
|
||||||
|
{
|
||||||
|
"assistant_message": {"text": "Wrong route answer.", "reply_type": "factual"},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_intent": "fixed_asset_amortization",
|
||||||
|
"selected_recipe": "address_fixed_asset_amortization_v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
registry_path = root / "detector_registry.json"
|
||||||
|
issue_catalog_path = root / "issue_catalog.json"
|
||||||
|
write_json(
|
||||||
|
registry_path,
|
||||||
|
{
|
||||||
|
"schema_version": "agent_detector_registry_v1",
|
||||||
|
"detectors": {
|
||||||
|
"wrong_capability_family": {
|
||||||
|
"kind": "trace_value_guard",
|
||||||
|
"automation_level": "automatic",
|
||||||
|
"description": "No wrong current route family.",
|
||||||
|
"issue_codes": ["margin_domain_leak_accounting_route"],
|
||||||
|
"inputs": ["turn.json"],
|
||||||
|
"check": {"forbidden_trace_markers": ["fixed_asset"]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
write_json(issue_catalog_path, {"schema_version": "agent_issue_catalog_v1", "issues": {}})
|
||||||
|
|
||||||
|
results = runner.build_detector_results(
|
||||||
|
artifact_dir,
|
||||||
|
detector_names=["wrong_capability_family"],
|
||||||
|
registry_path=registry_path,
|
||||||
|
issue_catalog_path=issue_catalog_path,
|
||||||
|
include_default_global=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(results["summary"]["status"], "fail")
|
||||||
|
self.assertEqual(results["results"][0]["status"], "fail")
|
||||||
|
|
||||||
def test_stage_review_signal_detector_fails_when_issue_code_is_present(self) -> None:
|
def test_stage_review_signal_detector_fails_when_issue_code_is_present(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
|
|
|
||||||
|
|
@ -715,6 +715,55 @@ class DomainCaseLoopStepStateTests(unittest.TestCase):
|
||||||
|
|
||||||
self.assertIn("wrong_result_mode", step_state["violated_invariants"])
|
self.assertIn("wrong_result_mode", step_state["violated_invariants"])
|
||||||
|
|
||||||
|
def test_selected_object_purchase_document_mode_accepts_confirmed_factual_route(self) -> None:
|
||||||
|
step_state = dcl.build_scenario_step_state(
|
||||||
|
scenario_id="margin_selected_object_demo",
|
||||||
|
domain="margin_profitability",
|
||||||
|
step={
|
||||||
|
"step_id": "step_05_selected_item_documents",
|
||||||
|
"title": "Selected item document",
|
||||||
|
"depends_on": ["step_04_selected_item_purchase_date"],
|
||||||
|
"question_template": "Which document confirms this selected item?",
|
||||||
|
"expected_intents": ["inventory_purchase_documents_for_item"],
|
||||||
|
"expected_capability": "inventory_inventory_purchase_documents_for_item",
|
||||||
|
"expected_recipe": "address_inventory_purchase_documents_for_item_v1",
|
||||||
|
"expected_result_mode": "selected_object_purchase_documents_or_honest_boundary",
|
||||||
|
"required_answer_shape": "direct_answer_first",
|
||||||
|
},
|
||||||
|
step_index=5,
|
||||||
|
question_resolved="Which document confirms this selected item?",
|
||||||
|
analysis_context={},
|
||||||
|
turn_artifact={
|
||||||
|
"assistant_message": {
|
||||||
|
"reply_type": "factual",
|
||||||
|
"text": "Confirmation document for the selected item: Purchase document 0001.",
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"trace_id": "trace-1",
|
||||||
|
},
|
||||||
|
"technical_debug_payload": {
|
||||||
|
"detected_mode": "address_query",
|
||||||
|
"detected_intent": "inventory_purchase_documents_for_item",
|
||||||
|
"selected_recipe": "address_inventory_purchase_documents_for_item_v1",
|
||||||
|
"capability_id": "inventory_inventory_purchase_documents_for_item",
|
||||||
|
"capability_route_mode": "exact",
|
||||||
|
"fallback_type": "none",
|
||||||
|
"mcp_call_status": "matched_non_empty",
|
||||||
|
"response_type": "FACTUAL_SUMMARY",
|
||||||
|
"result_mode": "confirmed_balance",
|
||||||
|
"truth_mode": "confirmed",
|
||||||
|
"answer_shape": "confirmed_factual",
|
||||||
|
"balance_confirmed": True,
|
||||||
|
"extracted_filters": {"item": "Selected item"},
|
||||||
|
},
|
||||||
|
"session_summary": {},
|
||||||
|
},
|
||||||
|
entries=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(step_state["execution_status"], "exact")
|
||||||
|
self.assertNotIn("wrong_result_mode", step_state["violated_invariants"])
|
||||||
|
self.assertEqual(step_state["acceptance_status"], "validated")
|
||||||
|
|
||||||
def test_exact_confirmed_document_followup_sets_runtime_factual_validation(self) -> None:
|
def test_exact_confirmed_document_followup_sets_runtime_factual_validation(self) -> None:
|
||||||
step_state = dcl.build_scenario_step_state(
|
step_state = dcl.build_scenario_step_state(
|
||||||
scenario_id="svk_pivot",
|
scenario_id="svk_pivot",
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,62 @@ class SaveAgentSemanticRunTests(unittest.TestCase):
|
||||||
self.assertEqual(metadata["effective_runtime"]["runner"], "domain_truth_harness.run-live")
|
self.assertEqual(metadata["effective_runtime"]["runner"], "domain_truth_harness.run-live")
|
||||||
self.assertEqual(metadata["effective_runtime"]["llm_model"], "test-model")
|
self.assertEqual(metadata["effective_runtime"]["llm_model"], "test-model")
|
||||||
|
|
||||||
|
def write_domain_case_loop_pack(self, run_dir: Path, *, target_count: int = 0, p1_count: int = 0) -> None:
|
||||||
|
write_json(
|
||||||
|
run_dir / "pack_state.json",
|
||||||
|
{
|
||||||
|
"pack_id": "agent_pack",
|
||||||
|
"execution_status": "exact",
|
||||||
|
"acceptance_status": "accepted",
|
||||||
|
"final_status": "accepted",
|
||||||
|
"scenario_results": [{"scenario_id": "s01", "final_status": "accepted"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
write_json(
|
||||||
|
run_dir / "repair_targets.json",
|
||||||
|
{
|
||||||
|
"target_count": target_count,
|
||||||
|
"acceptance_status": "accepted",
|
||||||
|
"final_status": "accepted",
|
||||||
|
"severity_counts": {"P0": 0, "P1": p1_count, "P2": 0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
write_json(
|
||||||
|
run_dir / runtime_manifest.EFFECTIVE_RUNTIME_FILE_NAME,
|
||||||
|
{
|
||||||
|
"schema_version": runtime_manifest.EFFECTIVE_RUNTIME_SCHEMA_VERSION,
|
||||||
|
"runner": "domain_case_loop.run-pack",
|
||||||
|
"git_sha": "test-sha",
|
||||||
|
"llm_provider": "local",
|
||||||
|
"llm_model": "test-model",
|
||||||
|
"temperature": 0.0,
|
||||||
|
"max_output_tokens": 2048,
|
||||||
|
"prompt_version": "normalizer_v2_0_2",
|
||||||
|
"prompt_source": "file",
|
||||||
|
"prompt_hash": "abc123",
|
||||||
|
"prompt_registry_status": "pass",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_domain_case_loop_pack_accepts_clean_pack(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
run_dir = Path(tmp)
|
||||||
|
self.write_domain_case_loop_pack(run_dir)
|
||||||
|
|
||||||
|
metadata = saver.validate_accepted_run_dir(run_dir)
|
||||||
|
|
||||||
|
self.assertEqual(metadata["validation_status"], "accepted_domain_case_loop_pack")
|
||||||
|
self.assertEqual(metadata["effective_runtime"]["runner"], "domain_case_loop.run-pack")
|
||||||
|
self.assertEqual(metadata["repair_target_count"], 0)
|
||||||
|
|
||||||
|
def test_validate_domain_case_loop_pack_refuses_open_repair_targets(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
run_dir = Path(tmp)
|
||||||
|
self.write_domain_case_loop_pack(run_dir, target_count=1, p1_count=1)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "not clean"):
|
||||||
|
saver.validate_accepted_run_dir(run_dir)
|
||||||
|
|
||||||
def test_extract_questions_resolves_scenario_pack_bindings(self) -> None:
|
def test_extract_questions_resolves_scenario_pack_bindings(self) -> None:
|
||||||
spec = {
|
spec = {
|
||||||
"schema_version": "domain_scenario_pack_v1",
|
"schema_version": "domain_scenario_pack_v1",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue