ЮИ - Автопрогоны: доработать иконки и hover-состояния saved sessions

This commit is contained in:
dctouch 2026-04-18 14:12:11 +03:00
parent 31cb4ccbbb
commit f58ef9ad55
32 changed files with 2214 additions and 132 deletions

View File

@ -138,6 +138,8 @@ Completed in the current working pass:
- counterparty document root wording is now recovered through unicode-safe exact signals instead of depending on mojibake-sensitive legacy phrases;
- declined Russian account wording like `по счёту 60` now restores account scope inside polarity/runtime guards instead of collapsing into `other_numeric`;
- exact address intents can now stay in the address lane even if the semantic guard overflags deep investigation without an actual investigative user request;
- selected-object inventory follow-ups can now override a stale stock root intent when the semantic contract already marks `selected_object_scope_detected`, including exact user wording like `по выбранному объекту ... где взяли это`;
- explicit capability-meta wording for `дельта по договорам` now keeps the asked capability in the user-facing answer instead of collapsing into the generic `что ты умеешь` catalog reply.
- live replay `address_truth_harness_phase7_meta_domain_mix_live_20260417_post_arch_fix_rerun2` is accepted end-to-end with `14/14` steps green, including the previously broken `step_01_counterparty_documents` and `step_04_open_items_account_60`.
Still open after this pass:
@ -211,6 +213,26 @@ Still open after the accepted phase10 replay:
- the user-facing VAT explanation block is now correct and grounded, but some long exact answers still feel heavier than the target human product tone;
- the next architecture slice should keep moving from repaired bridge authority into answer-shaping cleanup and broader saved-session replay coverage, not back into isolated wording tweaks.
Latest phase11 manual follow-up/meta-quality evidence after the current hardening loop:
- live replay `address_truth_harness_phase11_manual_followup_meta_quality_live_20260418_rerun6` is accepted end-to-end with `10/10` steps green;
- the previously broken `ты умеешь считать дельту по договорам?` branch is now protected by an explicit authority rule:
- raw capability-meta intent outranks canonical predecompose rewrites that look like address retrieval;
- stale VAT follow-up continuity no longer wins over a fresh capability/meta question in the same session;
- the previously broken short counterparty retarget `а по свк` is now clean on the real assistant path:
- the display label uses the most specific confirmed counterparty name instead of a generic group fallback or a stale carryover anchor;
- short uppercase Cyrillic acronyms like `СВК` no longer get stripped by the user-facing sanitizer as false mojibake;
- the replay acceptance rule now targets the real regression (`Контрагент: Группа Найдено ...`) instead of incorrectly rejecting valid names like `Контрагент: Группа СВК.`;
- this phase matters architecturally because it closes two different seam classes at once:
- `meta authority vs stale follow-up authority`;
- `resolved business label vs boundary sanitization noise`.
Still open after the accepted phase11 replay:
- the current phase11 path is now semantically clean, but broader manual/user session packs still need to be replayed before expansion can be called low-risk across new domains;
- answer shaping on some long exact list answers is still heavier than the target human product feel, even though the truth path and routing are now correct;
- the next architecture slice should move to wider saved-session acceptance coverage and humanized exact-answer presentation, not back to isolated prompt-level repairs.
## Ready Signal
The project can leave the current breakpoint when:

View File

@ -0,0 +1,268 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "address_truth_harness_phase11_manual_followup_meta_quality",
"domain": "address_phase11_manual_followup_meta_quality",
"title": "Phase 11 manual follow-up and meta-quality replay for orchestration recovery",
"description": "Focused AGENT replay rebuilt in clean UTF-8 from the manual saved session assistant-stage1-C_LvgkpSFm. The scenario validates the real remaining seams: human capability-meta answers, historical inventory root continuity, selected-object provenance, VAT bridge from purchase date after a verification interrupt, colloquial VAT wording, capability-meta versus stale VAT follow-up, and short counterparty retarget display integrity.",
"bindings": {},
"steps": [
{
"step_id": "step_01_capability_meta_human",
"title": "Capability meta answer stays human and business-first",
"question": "расскажи что можешь интересного",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"required_direct_answer_patterns_any": [
"(?i)могу|умею",
"(?i)ндс|документ|контрагент|долг|склад|остатк"
],
"forbidden_direct_answer_patterns": [
"(?i)read_only",
"(?i)mcp",
"(?i)snapshot",
"(?i)capability",
"(?i)assistant_state",
"(?i)open item"
],
"criticality": "critical",
"semantic_tags": [
"meta_capability",
"human_answer_quality"
]
},
{
"step_id": "step_02_inventory_historical_root",
"title": "Historical inventory root on March 2016",
"question": "что там на складе по остаткам на март 2016?",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"expected_recipe": "address_inventory_on_hand_as_of_date_v1",
"required_filters": {
"as_of_date": "2016-03-31",
"period_from": "2016-03-01",
"period_to": "2016-03-31"
},
"required_direct_answer_patterns_any": [
"31\\.03\\.2016",
"(?i)на складе|остат"
],
"criticality": "critical",
"semantic_tags": [
"inventory_root",
"historical_date_anchor"
]
},
{
"step_id": "step_03_selected_item_purchase_provenance",
"title": "Selected workstation purchase provenance",
"question": "по выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?",
"allowed_reply_types": [
"factual",
"partial_coverage"
],
"expected_intents": [
"inventory_purchase_provenance_for_item"
],
"expected_recipe": "address_inventory_purchase_provenance_for_item_v1",
"required_filters": {
"item": "Рабочая станция универсального специалиста (индивидуальное изготовление)"
},
"required_direct_answer_patterns_any": [
"(?i)закуп|поступлен|постав",
"(?i)рабочая станция"
],
"criticality": "critical",
"semantic_tags": [
"selected_object",
"inventory_provenance"
]
},
{
"step_id": "step_04_meta_verify_interrupt",
"title": "Verification interrupt remains honest and does not leak technical garbage",
"question": "у тебя написано кто контрагент: рабочая станция - это ошибка?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation"
],
"required_direct_answer_patterns_any": [
"(?i)не удается|не могу|не получается|не подтвержден",
"(?i)контрагент|рабочая станция"
],
"forbidden_direct_answer_patterns": [
"(?i)address lane",
"(?i)partial_coverage",
"(?i)capability",
"(?i)mcp"
],
"criticality": "important",
"semantic_tags": [
"meta_verify",
"continuity_interrupt"
]
},
{
"step_id": "step_05_vat_on_purchase_date_after_interrupt",
"title": "VAT bridge still works after the verification interrupt",
"question": "ндс можешь прикинуть на дату покупки рабочей станции?",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"expected_intents": [
"vat_liability_confirmed_for_tax_period"
],
"required_direct_answer_patterns_any": [
"(?i)ндс",
"(?i)2015|феврал|налогов"
],
"forbidden_direct_answer_patterns": [
"(?i)такой сценарий пока не поддерживается",
"(?i)mcp",
"(?i)address_mode",
"(?i)tool_gate_reason"
],
"criticality": "critical",
"semantic_tags": [
"bridge_inventory_to_vat",
"selected_object",
"continuity_after_interrupt"
]
},
{
"step_id": "step_06_colloquial_vat_march_2020",
"title": "Colloquial VAT wording survives predecompose",
"question": "а какой ндс мы должны сгрузить на март 2020?",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"expected_intents": [
"vat_liability_confirmed_for_tax_period",
"vat_payable_confirmed_as_of_date"
],
"required_filters": {
"period_from": "2020-03-01",
"period_to": "2020-03-31"
},
"required_direct_answer_patterns_any": [
"(?i)ндс",
"(?i)2020|март"
],
"forbidden_direct_answer_patterns": [
"(?i)недостаточно информации",
"(?i)уточните, пожалуйста",
"(?i)mcp",
"(?i)address_mode"
],
"criticality": "critical",
"semantic_tags": [
"vat_colloquial_wording",
"predecompose_guard"
]
},
{
"step_id": "step_07_vat_february_2017_live",
"title": "Confirmed VAT route stays alive on February 2017",
"question": "прикинь какой ндс нам надо заплатить на февраль 2017",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"expected_intents": [
"vat_liability_confirmed_for_tax_period"
],
"required_direct_answer_patterns_any": [
"(?i)ндс",
"(?i)2017|феврал"
],
"criticality": "important",
"semantic_tags": [
"vat_liability_live",
"route_stability"
]
},
{
"step_id": "step_08_contract_delta_capability_meta",
"title": "Contract-delta capability question must not fall back into stale VAT analytics",
"question": "ты умеешь считать дельту по договорам?",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"required_direct_answer_patterns_any": [
"(?i)могу|умею|не умею|пока не",
"(?i)договор"
],
"forbidden_direct_answer_patterns": [
"(?i)ндс к уплате",
"(?i)налоговый период",
"(?i)топ-5 заказчиков",
"(?i)чепурнов",
"(?i)группа свк"
],
"criticality": "critical",
"semantic_tags": [
"meta_capability",
"capability_over_followup"
]
},
{
"step_id": "step_09_counterparty_docs_anchor",
"title": "Counterparty document root for Чепурнов",
"question": "по Чепурнову покажи все доки",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"list_documents_by_counterparty"
],
"expected_recipe": "address_documents_by_counterparty_v1",
"required_direct_answer_patterns_any": [
"(?i)чепурнов",
"(?i)документ|поступление"
],
"criticality": "important",
"semantic_tags": [
"counterparty_root",
"documents"
]
},
{
"step_id": "step_10_short_counterparty_retarget_name",
"title": "Short follow-up keeps the real counterparty name instead of a generic group label",
"question": "а по свк",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"expected_intents": [
"list_documents_by_counterparty"
],
"required_direct_answer_patterns_any": [
"(?i)свк|группа свк",
"(?i)документ|поступление"
],
"forbidden_direct_answer_patterns": [
"(?i)контрагент: группа\\s+найдено",
"(?im)^контрагент: группа\\.?$",
"(?i)глубина live-выборки",
"(?i)mcp"
],
"criticality": "critical",
"semantic_tags": [
"counterparty_followup",
"display_name_integrity"
]
}
]
}

View File

@ -1523,8 +1523,8 @@ function resolveAddressIntent(userMessage) {
const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase();
const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text;
const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:\u043d\u0430\u043c\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e)|\u043d\u0430\u043c\s+\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0430\u043c\s+\u043d\u0443\u0436\u043d\u043e\s+\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) &&
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text);
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) &&
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\u0432\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text);
if (hasLooseVatPayableBridge) {
return {
intent: "vat_liability_confirmed_for_tax_period",

View File

@ -826,6 +826,88 @@ function normalizeCounterpartyName(value) {
.replace(/\s+/g, " ")
.trim();
}
function resolvePreferredCounterpartyReplyLabel(...values) {
const candidates = Array.from(new Set(values
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter((value) => value.length > 0)));
if (candidates.length === 0) {
return undefined;
}
const genericPattern = /^(?:группа|контрагент|компания|организация)$/iu;
candidates.sort((left, right) => {
const leftNormalized = normalizeCounterpartyName(left);
const rightNormalized = normalizeCounterpartyName(right);
const leftGeneric = genericPattern.test(leftNormalized);
const rightGeneric = genericPattern.test(rightNormalized);
if (leftGeneric !== rightGeneric) {
return leftGeneric ? 1 : -1;
}
if (leftNormalized && rightNormalized) {
if (leftNormalized.includes(rightNormalized) && leftNormalized !== rightNormalized) {
return -1;
}
if (rightNormalized.includes(leftNormalized) && leftNormalized !== rightNormalized) {
return 1;
}
}
return right.length - left.length;
});
return candidates[0] || undefined;
}
function repairCounterpartyReplyLabel(replyText, intent, extractedFilters) {
if (intent !== "list_documents_by_counterparty" && intent !== "bank_operations_by_counterparty") {
return replyText;
}
const requestedCounterparty = extractedFilters && typeof extractedFilters.counterparty === "string"
? extractedFilters.counterparty.trim()
: "";
if (!requestedCounterparty) {
return replyText;
}
const requestedNormalized = normalizeCounterpartyName(requestedCounterparty);
if (!requestedNormalized) {
return replyText;
}
let repaired = String(replyText ?? "");
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)(?=\s+Найдено\s+документов:)/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}`
: `Контрагент: ${label}`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+)([.\n])/gu, (match, label, suffix) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return match;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
const shouldReplace = labelLooksGeneric || requestedContainsLabel;
return shouldReplace ? `Контрагент: ${requestedCounterparty}${suffix}` : match;
});
repaired = repaired.replace(/Контрагент:\s*([^\n]+?)\.\.([^\n]*)(?=\n|$)/gu, (_match, left, right) => {
const leftPart = String(left ?? "").trim().replace(/[.]+$/u, "");
const rightPart = String(right ?? "").trim().replace(/^[.]+/u, "");
return rightPart.length > 0 ? `Контрагент: ${leftPart}. ${rightPart}` : `Контрагент: ${leftPart}.`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)\.\s+Найдено\s+документов:/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}. Найдено документов:`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}. Найдено документов:`
: `Контрагент: ${label}. Найдено документов:`;
});
return repaired;
}
function hasCounterpartyShipmentItemFlowSignal(userMessage) {
const text = normalizeSearchText(String(userMessage ?? ""));
if (!text) {
@ -2801,11 +2883,9 @@ class AddressQueryService {
};
const composeRuntimeOptions = (filterSet, options = {}) => composeOptionsFromFilters(filterSet, {
...options,
counterpartyHint: typeof options.counterpartyHint === "string"
? options.counterpartyHint
: anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined,
counterpartyHint: resolvePreferredCounterpartyReplyLabel(typeof options.counterpartyHint === "string" ? options.counterpartyHint : undefined, typeof filterSet.counterparty === "string" ? filterSet.counterparty : undefined, anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined),
accountHint: typeof options.accountHint === "string"
? options.accountHint
: typeof filterSet.account === "string"
@ -3368,7 +3448,7 @@ class AddressQueryService {
});
return {
handled: true,
reply_text: replyText,
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters),
reply_type: (0, composeStage_1.inferReplyType)(responseType),
response_type: responseType,
debug: debugPayload
@ -3490,7 +3570,7 @@ class AddressQueryService {
});
return {
handled: true,
reply_text: input.replyText,
reply_text: repairCounterpartyReplyLabel(input.replyText, intent.intent, (input.extractedFilters ?? filters.extracted_filters)),
reply_type: (0, composeStage_1.inferReplyType)(input.responseType),
response_type: input.responseType,
debug: debugPayload

View File

@ -11,6 +11,30 @@ function uniqueStrings(values) {
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
function normalizeCounterpartyDisplayLabel(value) {
const source = String(value ?? "").trim().replace(/[.]+$/u, "");
if (!source) {
return null;
}
return source.replace(/\s+/gu, " ");
}
function isGenericCounterpartyDisplayLabel(value) {
const normalized = normalizeCounterpartyDisplayLabel(value);
if (!normalized) {
return true;
}
return /^(?:группа|контрагент|компания|организация)$/iu.test(normalized);
}
function resolvePreferredCounterpartyDisplayLabel(requestedHint, rowLabels) {
const requested = normalizeCounterpartyDisplayLabel(requestedHint);
const resolvedFromRows = rowLabels.length === 1 ? normalizeCounterpartyDisplayLabel(rowLabels[0]) : null;
if (requested && resolvedFromRows) {
if (isGenericCounterpartyDisplayLabel(resolvedFromRows) || requested.includes(resolvedFromRows)) {
return requested;
}
}
return requested ?? resolvedFromRows;
}
function formatTopRows(rows, limit = 6) {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
@ -3266,15 +3290,10 @@ function composeFactualReplyBody(intent, rows, options = {}) {
};
}
if (intent === "list_documents_by_counterparty") {
const resolvedCounterparty = (typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
? options.counterpartyHint.trim()
: null) ??
(() => {
const counterparties = uniqueStrings(rows
.map((row) => extractCounterpartyName(row))
.filter((item) => Boolean(item)));
return counterparties.length === 1 ? counterparties[0] : null;
})();
const rowCounterparties = uniqueStrings(rows
.map((row) => extractCounterpartyName(row))
.filter((item) => Boolean(item)));
const resolvedCounterparty = resolvePreferredCounterpartyDisplayLabel(typeof options.counterpartyHint === "string" ? options.counterpartyHint : null, rowCounterparties);
const counterpartyLabel = typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
? resolvedCounterparty
: resolvedCounterparty

View File

@ -476,6 +476,9 @@ function isLikelyMojibakeToken(value) {
if (!token) {
return false;
}
if (/^[А-ЯЁ]{2,5}$/u.test(token)) {
return false;
}
if (MOJIBAKE_SINGLE_MARKER_PATTERN.test(token)) {
return true;
}

View File

@ -240,7 +240,7 @@ async function runAssistantLivingChatRuntime(input) {
livingChatSource = "deterministic_memory_recap_contract";
}
else if (capabilityMetaQuery) {
chatText = input.buildAssistantCapabilityContractReply();
chatText = input.buildAssistantCapabilityContractReply(userMessage);
livingChatSource = "deterministic_capability_contract";
}
else {

View File

@ -235,6 +235,11 @@ function createAssistantLivingModePolicy(deps) {
if (hasScopeMetaSignal) {
return true;
}
const hasExplicitDeltaCapabilityMetaSignal = /(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(raw) ||
/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(repaired);
if (hasExplicitDeltaCapabilityMetaSignal) {
return true;
}
const hasCapabilitySignal = hasAssistantCapabilityQuestionSignal(raw) ||
hasAssistantCapabilityQuestionSignal(repaired) ||
hasOperationalAdminActionRequestSignal(raw) ||

View File

@ -3694,13 +3694,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) {
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? "");
const repairedRawMessageForGate = repairAddressMojibake(rawMessageForGate);
const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedRawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedInputMessage);
const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
const rawCapabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedRawMessageForGate);
const capabilityMetaQuery = rawCapabilityMetaQuery ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedInputMessage);
const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
const rawDataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(repairedRawMessageForGate);
const dataRetrievalSignal = rawDataRetrievalSignal ||
hasDataRetrievalRequestSignal(repairedInputMessage);
if (dataScopeMetaQuery || (capabilityMetaQuery && !dataRetrievalSignal)) {
const rawCapabilityMetaOverride = rawCapabilityMetaQuery && !rawDataRetrievalSignal;
if (dataScopeMetaQuery || rawCapabilityMetaOverride || (capabilityMetaQuery && !dataRetrievalSignal)) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@ -4107,6 +4114,9 @@ function hasAssistantCapabilityQuestionSignal(text) {
if (/(?:каки[ею].*(?:фич|функц|возможност|отработан)|какого\s+рода\s+ошибк.*ты\s+мож(?:ешь|ете)|какие\s+ошибк.*ты\s+мож(?:ешь|ете))/iu.test(normalized)) {
return true;
}
if (/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(normalized)) {
return true;
}
const hasCanVerb = /(?:можешь|можете|умеешь|умеете|можно)/i.test(normalized);
const hasControlAction = /(?:настро|установ|подключ|обнов|созда|подготов|сдела|делат|дела)/i.test(normalized);
const hasAnalysisAction = /(?:найт|искать|провер|анализ|разоб|объясн|расска|подсказ|показ)/i.test(normalized);
@ -4135,8 +4145,27 @@ function shouldHandleAsAssistantCapabilityMetaQuery(text) {
function hasLivingChatSignal(text) {
return assistantLivingModePolicy.hasLivingChatSignal(text);
}
function buildAssistantCapabilityContractReply() {
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
function buildAssistantCapabilityContractReply(userMessage = "") {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()).replace(/ё/g, "е");
if (/(?:дельт|delta).*(?:договор|контракт)|(?:договор|контракт).*(?:дельт|delta)/iu.test(normalized)) {
return [
"По дельте по договорам отдельный подтвержденный маршрут в текущем контуре пока не включен.",
"То есть я не буду делать вид, что уже считаю ее точно.",
"Сейчас могу помочь с близкими вещами: показать договоры, документы, оплаты, выручку и хвосты по расчетам, чтобы подготовить основу под такой расчет.",
"Если хочешь, следующим проходом можем именно этот контур добить архитектурно."
].join("\n");
}
const registryReply = (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
const normalizedReply = String(registryReply ?? "")
.replace(/в режиме чтения/giu, "")
.replace(/read[_ -]?only/giu, "")
.replace(/По основным группам:/giu, "Основные направления:")
.replace(/Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу\./giu, "Если хотите, можно сразу задать конкретный вопрос по документам, остаткам, НДС, контрагенту или договору.")
.replace(/Что не делаю:\s*/giu, "Не делаю только административные действия: ")
.replace(/\s{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
return normalizedReply || "Могу помогать с вопросами по данным 1С: НДС, контрагенты, долги, деньги, договоры и склад.";
}
const assistantProviderExecutionPolicy = (0, assistantProviderExecutionPolicy_1.createAssistantProviderExecutionPolicy)();
const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistantLivingModePolicy)({
@ -4204,6 +4233,8 @@ const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistan
compactWhitespace,
repairAddressMojibake,
countTokens,
shouldHandleAsAssistantCapabilityMetaQuery,
hasDataRetrievalRequestSignal,
findLastAddressAssistantItem,
findLastOrganizationClarificationAddressDebug,
mergeKnownOrganizations,

View File

@ -3,6 +3,46 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy;
function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasSelectedObjectInventoryScopeSignal(text) {
const normalized = normalizeFollowupText(text);
if (!normalized) {
return false;
}
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(normalized);
}
function extractSelectedObjectLabel(text) {
const repaired = deps.repairAddressMojibake(String(text ?? ""));
const quotedMatch = repaired.match(/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu);
if (quotedMatch?.[1]) {
return deps.toNonEmptyString(quotedMatch[1]);
}
return null;
}
function inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage = null) {
const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean);
if (samples.length === 0) {
return null;
}
if (samples.some((sample) => /(?:где\s+(?:мы\s+)?взял|откуда\s+(?:мы\s+)?взял|у\s+кого\s+купил|кто\s+постав|поставщик|источник\s+поступл|от\s+кого\s+поступ)/iu.test(sample))) {
return "inventory_purchase_provenance_for_item";
}
if (samples.some((sample) => /(?:документ|накладн|счет|сч[её]т|акт|поступл|покажи\s+док|все\s+документ|все\s+операц)/iu.test(sample))) {
return "inventory_purchase_documents_for_item";
}
if (samples.some((sample) => /(?:кому\s+продал|кому\s+ушл|куда\s+продал|кто\s+купил|реализац|отгруз|продаж)/iu.test(sample))) {
return "inventory_sale_trace_for_item";
}
if (samples.some((sample) => /(?:рентабел|маржин|прибыл|наценк|выгод)/iu.test(sample))) {
return "inventory_profitability_for_item";
}
if (samples.some((sample) => /(?:цепочк|от\s+закупк.*до\s+продаж|от\s+покупк.*до\s+продаж)/iu.test(sample))) {
return "inventory_purchase_to_sale_chain";
}
return null;
}
function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) {
@ -83,6 +123,68 @@ function createAssistantTransitionPolicy(deps) {
const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs);
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
}
function isUsableFollowupSourceDebug(debug) {
if (!debug || typeof debug !== "object") {
return false;
}
const executionLane = deps.toNonEmptyString(debug.execution_lane);
const detectedIntent = deps.toNonEmptyString(debug.detected_intent);
const selectedRecipe = deps.toNonEmptyString(debug.selected_recipe);
const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object"
? debug.answer_grounding_check
: null;
const groundingStatus = deps.toNonEmptyString(answerGroundingCheck?.status);
if (groundingStatus === "grounded") {
return true;
}
if (selectedRecipe) {
return true;
}
return executionLane === "address_query" && Boolean(detectedIntent && detectedIntent !== "unknown");
}
function findRecentUsableAddressAssistantItem(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
if (isUsableFollowupSourceDebug(item.debug)) {
return item;
}
}
return null;
}
function readAddressDebugItemHint(debug) {
if (!debug || typeof debug !== "object") {
return null;
}
const extractedFilters = debug.extracted_filters && typeof debug.extracted_filters === "object" ? debug.extracted_filters : null;
return (deps.toNonEmptyString(extractedFilters?.item) ??
(deps.toNonEmptyString(debug.anchor_type) === "item"
? deps.toNonEmptyString(debug.anchor_value_resolved) ?? deps.toNonEmptyString(debug.anchor_value_raw)
: null));
}
function findRecentInventoryPurchaseProvenanceItem(items, itemHint = null) {
const normalizedItemHint = deps.toNonEmptyString(itemHint);
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
const debug = item?.debug;
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
continue;
}
if (deps.toNonEmptyString(debug.detected_intent) !== "inventory_purchase_provenance_for_item") {
continue;
}
if (!normalizedItemHint) {
return item;
}
const candidateItem = readAddressDebugItemHint(debug);
if (candidateItem && candidateItem === normalizedItemHint) {
return item;
}
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint &&
@ -222,7 +324,21 @@ function createAssistantTransitionPolicy(deps) {
return null;
}
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) {
const previousAddressItem = deps.findLastAddressAssistantItem(items);
const rawCapabilityMetaQuery = deps.shouldHandleAsAssistantCapabilityMetaQuery(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.shouldHandleAsAssistantCapabilityMetaQuery(String(alternateMessage ?? ""))
: false);
const rawDataRetrievalSignal = deps.hasDataRetrievalRequestSignal(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.hasDataRetrievalRequestSignal(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) {
return null;
}
const latestAddressItem = deps.findLastAddressAssistantItem(items);
const previousAddressItem = (latestAddressItem && isUsableFollowupSourceDebug(latestAddressItem?.debug)
? latestAddressItem
: findRecentUsableAddressAssistantItem(items)) ?? latestAddressItem;
const previousAddressDebug = previousAddressItem?.debug ?? null;
const lastOrganizationClarificationDebug = deps.findLastOrganizationClarificationAddressDebug(items);
const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates)
@ -344,6 +460,7 @@ function createAssistantTransitionPolicy(deps) {
}
const sourceIntent = deps.toNonEmptyString(previousAddressDebug.detected_intent);
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent;
const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage)
? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent
@ -467,10 +584,13 @@ function createAssistantTransitionPolicy(deps) {
? deps.hasFollowupMarker(String(alternateMessage ?? "")) ||
deps.hasReferentialPointer(String(alternateMessage ?? ""))
: false);
const hasSelectedObjectInventorySignalPrimary = /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(userMessage ?? ""));
const hasSelectedObjectInventorySignalPrimary = llmSelectedObjectScopeDetected || hasSelectedObjectInventoryScopeSignal(userMessage);
const hasSelectedObjectInventorySignalAlternate = deps.toNonEmptyString(alternateMessage)
? /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(alternateMessage ?? ""))
? hasSelectedObjectInventoryScopeSignal(String(alternateMessage ?? ""))
: false;
const selectedObjectRetargetIntent = hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate
? inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage)
: null;
let inventoryRootFrame = deps.findRecentInventoryRootFrame(items);
if (inventoryRootFrame && navigationOrganization && !deps.toNonEmptyString(inventoryRootFrame.filters?.organization)) {
inventoryRootFrame = {
@ -540,7 +660,13 @@ function createAssistantTransitionPolicy(deps) {
previousFilters.organization = organizationClarificationSelection;
}
if (inventoryPurchaseDateVatBridge) {
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState);
const purchaseBridgeItem = previousAddressItem &&
deps.toNonEmptyString(previousAddressDebug?.detected_intent) === "inventory_purchase_provenance_for_item"
? previousAddressItem
: findRecentInventoryPurchaseProvenanceItem(items, deps.toNonEmptyString(navigationFocusObjectLabel) ??
readAddressDebugItemHint(previousAddressDebug) ??
deps.toNonEmptyString(previousFilters.item)) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
@ -631,8 +757,6 @@ function createAssistantTransitionPolicy(deps) {
}
if (!rootScopedPivot &&
!deps.toNonEmptyString(previousFilters.item) &&
navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_purchase_provenance_for_item" ||
sourceIntentHint === "inventory_purchase_documents_for_item" ||
@ -642,10 +766,13 @@ function createAssistantTransitionPolicy(deps) {
sourceIntentHint === "inventory_aging_by_purchase_date" ||
hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate)) {
previousFilters.item = navigationFocusObjectLabel;
if (!previousAnchor) {
const selectedObjectLabel = (navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null) ??
extractSelectedObjectLabel(userMessage) ??
(deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null);
if (selectedObjectLabel) {
previousFilters.item = selectedObjectLabel;
previousAnchorType = "item";
previousAnchor = navigationFocusObjectLabel;
previousAnchor = selectedObjectLabel;
}
}
if (organizationClarificationSelection && !previousAnchor) {
@ -681,11 +808,16 @@ function createAssistantTransitionPolicy(deps) {
hasSelectedObjectInventorySignalAlternate));
const carryoverTargetIntent = inventoryPurchaseDateVatBridge
? "vat_liability_confirmed_for_tax_period"
: followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: explicitInventorySameDatePivot
? "inventory_on_hand_as_of_date"
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
: selectedObjectRetargetIntent &&
(explicitIntent === null ||
explicitIntent === "inventory_on_hand_as_of_date" ||
explicitIntent === sourceIntent)
? selectedObjectRetargetIntent
: followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: explicitInventorySameDatePivot
? "inventory_on_hand_as_of_date"
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
return {
followupContext: {
previous_intent: previousIntent ?? undefined,

View File

@ -1907,10 +1907,10 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
const hasLooseVatPayableBridge =
/(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:\u043d\u0430\u043c\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e)|\u043d\u0430\u043c\s+\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0430\u043c\s+\u043d\u0443\u0436\u043d\u043e\s+\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(
text
) &&
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\u0432\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(
text
);
if (hasLooseVatPayableBridge) {

View File

@ -1056,6 +1056,99 @@ function normalizeCounterpartyName(value: string): string {
.trim();
}
function resolvePreferredCounterpartyReplyLabel(...values: Array<string | null | undefined>): string | undefined {
const candidates = Array.from(
new Set(
values
.map((value) => (typeof value === "string" ? value.trim() : ""))
.filter((value) => value.length > 0)
)
);
if (candidates.length === 0) {
return undefined;
}
const genericPattern = /^(?:группа|контрагент|компания|организация)$/iu;
candidates.sort((left, right) => {
const leftNormalized = normalizeCounterpartyName(left);
const rightNormalized = normalizeCounterpartyName(right);
const leftGeneric = genericPattern.test(leftNormalized);
const rightGeneric = genericPattern.test(rightNormalized);
if (leftGeneric !== rightGeneric) {
return leftGeneric ? 1 : -1;
}
if (leftNormalized && rightNormalized) {
if (leftNormalized.includes(rightNormalized) && leftNormalized !== rightNormalized) {
return -1;
}
if (rightNormalized.includes(leftNormalized) && leftNormalized !== rightNormalized) {
return 1;
}
}
return right.length - left.length;
});
return candidates[0] || undefined;
}
function repairCounterpartyReplyLabel(
replyText: string,
intent: string,
extractedFilters: AddressFilterSet | null | undefined
): string {
if (intent !== "list_documents_by_counterparty" && intent !== "bank_operations_by_counterparty") {
return replyText;
}
const requestedCounterparty =
extractedFilters && typeof extractedFilters.counterparty === "string"
? extractedFilters.counterparty.trim()
: "";
if (!requestedCounterparty) {
return replyText;
}
const requestedNormalized = normalizeCounterpartyName(requestedCounterparty);
if (!requestedNormalized) {
return replyText;
}
let repaired = String(replyText ?? "");
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)(?=\s+Найдено\s+документов:)/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}`
: `Контрагент: ${label}`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+)([.\n])/gu, (match, label, suffix) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return match;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
const shouldReplace = labelLooksGeneric || requestedContainsLabel;
return shouldReplace ? `Контрагент: ${requestedCounterparty}${suffix}` : match;
});
repaired = repaired.replace(/Контрагент:\s*([^\n]+?)\.\.([^\n]*)(?=\n|$)/gu, (_match, left, right) => {
const leftPart = String(left ?? "").trim().replace(/[.]+$/u, "");
const rightPart = String(right ?? "").trim().replace(/^[.]+/u, "");
return rightPart.length > 0 ? `Контрагент: ${leftPart}. ${rightPart}` : `Контрагент: ${leftPart}.`;
});
repaired = repaired.replace(/Контрагент:\s*([^\n.]+?)\.\s+Найдено\s+документов:/gu, (_match, label) => {
const normalizedLabel = normalizeCounterpartyName(String(label ?? ""));
if (!normalizedLabel || normalizedLabel === requestedNormalized) {
return `Контрагент: ${label}. Найдено документов:`;
}
const labelLooksGeneric = /^(?:группа|контрагент|компания|организация)$/iu.test(String(label ?? "").trim());
const requestedContainsLabel = requestedNormalized.includes(normalizedLabel);
return labelLooksGeneric || requestedContainsLabel
? `Контрагент: ${requestedCounterparty}. Найдено документов:`
: `Контрагент: ${label}. Найдено документов:`;
});
return repaired;
}
function hasCounterpartyShipmentItemFlowSignal(userMessage: string): boolean {
const text = normalizeSearchText(String(userMessage ?? ""));
if (!text) {
@ -3497,12 +3590,13 @@ export class AddressQueryService {
) =>
composeOptionsFromFilters(filterSet, {
...options,
counterpartyHint:
typeof options.counterpartyHint === "string"
? options.counterpartyHint
: anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined,
counterpartyHint: resolvePreferredCounterpartyReplyLabel(
typeof options.counterpartyHint === "string" ? options.counterpartyHint : undefined,
typeof filterSet.counterparty === "string" ? filterSet.counterparty : undefined,
anchor?.anchor_type === "counterparty"
? anchor.anchor_value_resolved ?? anchor.anchor_value_raw ?? undefined
: undefined
),
accountHint:
typeof options.accountHint === "string"
? options.accountHint
@ -4127,7 +4221,7 @@ export class AddressQueryService {
);
return {
handled: true,
reply_text: replyText,
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters),
reply_type: inferReplyType(responseType),
response_type: responseType,
debug: debugPayload
@ -4294,7 +4388,11 @@ export class AddressQueryService {
);
return {
handled: true,
reply_text: input.replyText,
reply_text: repairCounterpartyReplyLabel(
input.replyText,
intent.intent,
(input.extractedFilters ?? filters.extracted_filters) as AddressFilterSet
),
reply_type: inferReplyType(input.responseType),
response_type: input.responseType,
debug: debugPayload

View File

@ -100,6 +100,37 @@ function uniqueStrings(values: string[]): string[] {
);
}
function normalizeCounterpartyDisplayLabel(value: string | null | undefined): string | null {
const source = String(value ?? "").trim().replace(/[.]+$/u, "");
if (!source) {
return null;
}
return source.replace(/\s+/gu, " ");
}
function isGenericCounterpartyDisplayLabel(value: string | null | undefined): boolean {
const normalized = normalizeCounterpartyDisplayLabel(value);
if (!normalized) {
return true;
}
return /^(?:группа|контрагент|компания|организация)$/iu.test(normalized);
}
function resolvePreferredCounterpartyDisplayLabel(
requestedHint: string | null | undefined,
rowLabels: string[]
): string | null {
const requested = normalizeCounterpartyDisplayLabel(requestedHint);
const resolvedFromRows =
rowLabels.length === 1 ? normalizeCounterpartyDisplayLabel(rowLabels[0]) : null;
if (requested && resolvedFromRows) {
if (isGenericCounterpartyDisplayLabel(resolvedFromRows) || requested.includes(resolvedFromRows)) {
return requested;
}
}
return requested ?? resolvedFromRows;
}
function formatTopRows(rows: ComposeStageRow[], limit = 6): string[] {
return rows.slice(0, limit).map((row, index) => {
const period = row.period ?? "дата не указана";
@ -4181,18 +4212,15 @@ function composeFactualReplyBody(
}
if (intent === "list_documents_by_counterparty") {
const resolvedCounterparty =
(typeof options.counterpartyHint === "string" && options.counterpartyHint.trim().length > 0
? options.counterpartyHint.trim()
: null) ??
(() => {
const counterparties = uniqueStrings(
rows
.map((row) => extractCounterpartyName(row))
.filter((item): item is string => Boolean(item))
);
return counterparties.length === 1 ? counterparties[0] : null;
})();
const rowCounterparties = uniqueStrings(
rows
.map((row) => extractCounterpartyName(row))
.filter((item): item is string => Boolean(item))
);
const resolvedCounterparty = resolvePreferredCounterpartyDisplayLabel(
typeof options.counterpartyHint === "string" ? options.counterpartyHint : null,
rowCounterparties
);
const counterpartyLabel =
typeof resolvedCounterparty === "string" && resolvedCounterparty.endsWith(".")
? resolvedCounterparty

View File

@ -567,6 +567,9 @@ function isLikelyMojibakeToken(value: string): boolean {
if (!token) {
return false;
}
if (/^[А-ЯЁ]{2,5}$/u.test(token)) {
return false;
}
if (MOJIBAKE_SINGLE_MARKER_PATTERN.test(token)) {
return true;
}

View File

@ -56,7 +56,7 @@ export interface AssistantLivingChatRuntimeInput {
buildAssistantOrganizationFactBoundaryReply: (organization: string | null) => string;
buildAssistantDataScopeSelectionReply: (organization: string | null) => string;
buildAssistantOperationalBoundaryReply: () => string;
buildAssistantCapabilityContractReply: () => string;
buildAssistantCapabilityContractReply: (userMessage?: string) => string;
}
export interface AssistantLivingChatRuntimeOutput {
@ -336,7 +336,7 @@ export async function runAssistantLivingChatRuntime(
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_memory_recap_contract";
} else if (capabilityMetaQuery) {
chatText = input.buildAssistantCapabilityContractReply();
chatText = input.buildAssistantCapabilityContractReply(userMessage);
livingChatSource = "deterministic_capability_contract";
} else {
chatText = await input.executeLlmChat();

View File

@ -304,6 +304,12 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
if (hasScopeMetaSignal) {
return true;
}
const hasExplicitDeltaCapabilityMetaSignal =
/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(raw) ||
/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(repaired);
if (hasExplicitDeltaCapabilityMetaSignal) {
return true;
}
const hasCapabilitySignal = hasAssistantCapabilityQuestionSignal(raw) ||
hasAssistantCapabilityQuestionSignal(repaired) ||
hasOperationalAdminActionRequestSignal(raw) ||

View File

@ -3649,13 +3649,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
function resolveAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) {
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? "");
const repairedRawMessageForGate = repairAddressMojibake(rawMessageForGate);
const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedRawMessageForGate) ||
hasAssistantDataScopeMetaQuestionSignal(repairedInputMessage);
const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
const rawCapabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawMessageForGate) ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedRawMessageForGate);
const capabilityMetaQuery = rawCapabilityMetaQuery ||
shouldHandleAsAssistantCapabilityMetaQuery(repairedInputMessage);
const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
const rawDataRetrievalSignal = hasDataRetrievalRequestSignal(rawMessageForGate) ||
hasDataRetrievalRequestSignal(repairedRawMessageForGate);
const dataRetrievalSignal = rawDataRetrievalSignal ||
hasDataRetrievalRequestSignal(repairedInputMessage);
if (dataScopeMetaQuery || (capabilityMetaQuery && !dataRetrievalSignal)) {
const rawCapabilityMetaOverride = rawCapabilityMetaQuery && !rawDataRetrievalSignal;
if (dataScopeMetaQuery || rawCapabilityMetaOverride || (capabilityMetaQuery && !dataRetrievalSignal)) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@ -4064,6 +4071,9 @@ function hasAssistantCapabilityQuestionSignal(text) {
if (/(?:каки[ею].*(?:фич|функц|возможност|отработан)|какого\s+рода\s+ошибк.*ты\s+мож(?:ешь|ете)|какие\s+ошибк.*ты\s+мож(?:ешь|ете))/iu.test(normalized)) {
return true;
}
if (/(?:мож(?:ешь|ете)|уме(?:ешь|ете)).*(?:считать|рассчитывать|посчитать).*(?:дельт|delta|маржинальн|margin|рентабельн)/iu.test(normalized)) {
return true;
}
const hasCanVerb = /(?:можешь|можете|умеешь|умеете|можно)/i.test(normalized);
const hasControlAction = /(?:настро|установ|подключ|обнов|созда|подготов|сдела|делат|дела)/i.test(normalized);
const hasAnalysisAction = /(?:найт|искать|провер|анализ|разоб|объясн|расска|подсказ|показ)/i.test(normalized);
@ -4092,8 +4102,27 @@ function shouldHandleAsAssistantCapabilityMetaQuery(text) {
function hasLivingChatSignal(text) {
return assistantLivingModePolicy.hasLivingChatSignal(text);
}
function buildAssistantCapabilityContractReply() {
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
function buildAssistantCapabilityContractReply(userMessage = "") {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()).replace(/ё/g, "е");
if (/(?:дельт|delta).*(?:договор|контракт)|(?:договор|контракт).*(?:дельт|delta)/iu.test(normalized)) {
return [
"По дельте по договорам отдельный подтвержденный маршрут в текущем контуре пока не включен.",
"То есть я не буду делать вид, что уже считаю ее точно.",
"Сейчас могу помочь с близкими вещами: показать договоры, документы, оплаты, выручку и хвосты по расчетам, чтобы подготовить основу под такой расчет.",
"Если хочешь, следующим проходом можем именно этот контур добить архитектурно."
].join("\n");
}
const registryReply = (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
const normalizedReply = String(registryReply ?? "")
.replace(/в режиме чтения/giu, "")
.replace(/read[_ -]?only/giu, "")
.replace(/По основным группам:/giu, "Основные направления:")
.replace(/Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу\./giu, "Если хотите, можно сразу задать конкретный вопрос по документам, остаткам, НДС, контрагенту или договору.")
.replace(/Что не делаю:\s*/giu, "Не делаю только административные действия: ")
.replace(/\s{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
return normalizedReply || "Могу помогать с вопросами по данным 1С: НДС, контрагенты, долги, деньги, договоры и склад.";
}
const assistantProviderExecutionPolicy = (0, assistantProviderExecutionPolicy_1.createAssistantProviderExecutionPolicy)();
const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistantLivingModePolicy)({
@ -4161,6 +4190,8 @@ const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistan
compactWhitespace,
repairAddressMojibake,
countTokens,
shouldHandleAsAssistantCapabilityMetaQuery,
hasDataRetrievalRequestSignal,
findLastAddressAssistantItem,
findLastOrganizationClarificationAddressDebug,
mergeKnownOrganizations,

View File

@ -1,6 +1,68 @@
// @ts-nocheck
export function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasSelectedObjectInventoryScopeSignal(text) {
const normalized = normalizeFollowupText(text);
if (!normalized) {
return false;
}
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(
normalized
);
}
function extractSelectedObjectLabel(text) {
const repaired = deps.repairAddressMojibake(String(text ?? ""));
const quotedMatch = repaired.match(
/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu
);
if (quotedMatch?.[1]) {
return deps.toNonEmptyString(quotedMatch[1]);
}
return null;
}
function inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage = null) {
const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean);
if (samples.length === 0) {
return null;
}
if (
samples.some((sample) =>
/(?:где\s+(?:мы\s+)?взял|откуда\s+(?:мы\s+)?взял|у\s+кого\s+купил|кто\s+постав|поставщик|источник\s+поступл|от\s+кого\s+поступ)/iu.test(
sample
)
)
) {
return "inventory_purchase_provenance_for_item";
}
if (
samples.some((sample) =>
/(?:документ|накладн|счет|сч[её]т|акт|поступл|покажи\s+док|все\s+документ|все\s+операц)/iu.test(sample)
)
) {
return "inventory_purchase_documents_for_item";
}
if (
samples.some((sample) =>
/(?:кому\s+продал|кому\s+ушл|куда\s+продал|кто\s+купил|реализац|отгруз|продаж)/iu.test(sample)
)
) {
return "inventory_sale_trace_for_item";
}
if (samples.some((sample) => /(?:рентабел|маржин|прибыл|наценк|выгод)/iu.test(sample))) {
return "inventory_profitability_for_item";
}
if (samples.some((sample) => /(?:цепочк|от\s+закупк.*до\s+продаж|от\s+покупк.*до\s+продаж)/iu.test(sample))) {
return "inventory_purchase_to_sale_chain";
}
return null;
}
function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) {
@ -90,6 +152,76 @@ export function createAssistantTransitionPolicy(deps) {
return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null;
}
function isUsableFollowupSourceDebug(debug) {
if (!debug || typeof debug !== "object") {
return false;
}
const executionLane = deps.toNonEmptyString(debug.execution_lane);
const detectedIntent = deps.toNonEmptyString(debug.detected_intent);
const selectedRecipe = deps.toNonEmptyString(debug.selected_recipe);
const answerGroundingCheck =
debug.answer_grounding_check && typeof debug.answer_grounding_check === "object"
? debug.answer_grounding_check
: null;
const groundingStatus = deps.toNonEmptyString(answerGroundingCheck?.status);
if (groundingStatus === "grounded") {
return true;
}
if (selectedRecipe) {
return true;
}
return executionLane === "address_query" && Boolean(detectedIntent && detectedIntent !== "unknown");
}
function findRecentUsableAddressAssistantItem(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
if (isUsableFollowupSourceDebug(item.debug)) {
return item;
}
}
return null;
}
function readAddressDebugItemHint(debug) {
if (!debug || typeof debug !== "object") {
return null;
}
const extractedFilters =
debug.extracted_filters && typeof debug.extracted_filters === "object" ? debug.extracted_filters : null;
return (
deps.toNonEmptyString(extractedFilters?.item) ??
(deps.toNonEmptyString(debug.anchor_type) === "item"
? deps.toNonEmptyString(debug.anchor_value_resolved) ?? deps.toNonEmptyString(debug.anchor_value_raw)
: null)
);
}
function findRecentInventoryPurchaseProvenanceItem(items, itemHint = null) {
const normalizedItemHint = deps.toNonEmptyString(itemHint);
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
const debug = item?.debug;
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
continue;
}
if (deps.toNonEmptyString(debug.detected_intent) !== "inventory_purchase_provenance_for_item") {
continue;
}
if (!normalizedItemHint) {
return item;
}
const candidateItem = readAddressDebugItemHint(debug);
if (candidateItem && candidateItem === normalizedItemHint) {
return item;
}
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
if (
sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
@ -270,7 +402,24 @@ export function createAssistantTransitionPolicy(deps) {
llmPreDecomposeMeta = null,
addressNavigationState = null
) {
const previousAddressItem = deps.findLastAddressAssistantItem(items);
const rawCapabilityMetaQuery =
deps.shouldHandleAsAssistantCapabilityMetaQuery(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.shouldHandleAsAssistantCapabilityMetaQuery(String(alternateMessage ?? ""))
: false);
const rawDataRetrievalSignal =
deps.hasDataRetrievalRequestSignal(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? deps.hasDataRetrievalRequestSignal(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) {
return null;
}
const latestAddressItem = deps.findLastAddressAssistantItem(items);
const previousAddressItem =
(latestAddressItem && isUsableFollowupSourceDebug(latestAddressItem?.debug)
? latestAddressItem
: findRecentUsableAddressAssistantItem(items)) ?? latestAddressItem;
const previousAddressDebug = previousAddressItem?.debug ?? null;
const lastOrganizationClarificationDebug = deps.findLastOrganizationClarificationAddressDebug(items);
const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates)
@ -430,6 +579,8 @@ export function createAssistantTransitionPolicy(deps) {
}
const sourceIntent = deps.toNonEmptyString(previousAddressDebug.detected_intent);
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmSelectedObjectScopeDetected =
llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent;
const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage)
? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent
@ -567,14 +718,15 @@ export function createAssistantTransitionPolicy(deps) {
? deps.hasFollowupMarker(String(alternateMessage ?? "")) ||
deps.hasReferentialPointer(String(alternateMessage ?? ""))
: false);
const hasSelectedObjectInventorySignalPrimary = /(?:РїРѕ\s+РІСбранному\s+объекССѓ|РїРѕ\s+СЌСРѕР\s+РїРѕР·РёСРёРё|РїРѕ\s+СЌСРѕРјСѓ\s+Совару|selected\s+object)/iu.test(
String(userMessage ?? "")
);
const hasSelectedObjectInventorySignalPrimary =
llmSelectedObjectScopeDetected || hasSelectedObjectInventoryScopeSignal(userMessage);
const hasSelectedObjectInventorySignalAlternate = deps.toNonEmptyString(alternateMessage)
? /(?:РїРѕ\s+РІСбранному\s+объекССѓ|РїРѕ\s+СЌСРѕР\s+РїРѕР·РёСРёРё|РїРѕ\s+СЌСРѕРјСѓ\s+Совару|selected\s+object)/iu.test(
String(alternateMessage ?? "")
)
? hasSelectedObjectInventoryScopeSignal(String(alternateMessage ?? ""))
: false;
const selectedObjectRetargetIntent =
hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate
? inferSelectedObjectInventoryFollowupIntent(userMessage, alternateMessage)
: null;
let inventoryRootFrame = deps.findRecentInventoryRootFrame(items);
if (inventoryRootFrame && navigationOrganization && !deps.toNonEmptyString(inventoryRootFrame.filters?.organization)) {
inventoryRootFrame = {
@ -649,7 +801,17 @@ export function createAssistantTransitionPolicy(deps) {
previousFilters.organization = organizationClarificationSelection;
}
if (inventoryPurchaseDateVatBridge) {
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState);
const purchaseBridgeItem =
previousAddressItem &&
deps.toNonEmptyString(previousAddressDebug?.detected_intent) === "inventory_purchase_provenance_for_item"
? previousAddressItem
: findRecentInventoryPurchaseProvenanceItem(
items,
deps.toNonEmptyString(navigationFocusObjectLabel) ??
readAddressDebugItemHint(previousAddressDebug) ??
deps.toNonEmptyString(previousFilters.item)
) ?? previousAddressItem;
const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(purchaseBridgeItem, addressNavigationState);
if (purchaseBridgeWindow) {
previousFilters.period_from = purchaseBridgeWindow.period_from;
previousFilters.period_to = purchaseBridgeWindow.period_to;
@ -761,8 +923,6 @@ export function createAssistantTransitionPolicy(deps) {
if (
!rootScopedPivot &&
!deps.toNonEmptyString(previousFilters.item) &&
navigationFocusObjectType === "item" &&
navigationFocusObjectLabel &&
(sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_purchase_provenance_for_item" ||
sourceIntentHint === "inventory_purchase_documents_for_item" ||
@ -773,10 +933,14 @@ export function createAssistantTransitionPolicy(deps) {
hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate)
) {
previousFilters.item = navigationFocusObjectLabel;
if (!previousAnchor) {
const selectedObjectLabel =
(navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null) ??
extractSelectedObjectLabel(userMessage) ??
(deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null);
if (selectedObjectLabel) {
previousFilters.item = selectedObjectLabel;
previousAnchorType = "item";
previousAnchor = navigationFocusObjectLabel;
previousAnchor = selectedObjectLabel;
}
}
if (organizationClarificationSelection && !previousAnchor) {
@ -817,6 +981,11 @@ export function createAssistantTransitionPolicy(deps) {
const carryoverTargetIntent =
inventoryPurchaseDateVatBridge
? "vat_liability_confirmed_for_tax_period"
: selectedObjectRetargetIntent &&
(explicitIntent === null ||
explicitIntent === "inventory_on_hand_as_of_date" ||
explicitIntent === sourceIntent)
? selectedObjectRetargetIntent
: followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: explicitInventorySameDatePivot

View File

@ -130,6 +130,134 @@ describe("counterparty shipment item flow and open-items routing", () => {
expect(query).not.toContain("контрагентом");
});
it("keeps resolved counterparty group name in user-facing document reply for svk wording", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Counterparty: "Группа СВК",
Registrator: "Группа СВК"
}
],
rows: [],
error: null
})
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2021-11-10T12:00:07Z",
Registrator: "Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07",
AccountDt: "0",
AccountKt: "0",
Amount: 20000,
Counterparty: "Группа СВК",
Contract: "Договор № 1-ПМ/2020 от 05.06.2020",
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("покажи документы по свк");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(String(result?.reply_text ?? "")).toContain("Контрагент: Группа СВК");
expect(String(result?.reply_text ?? "")).not.toContain("Контрагент: Группа Найдено");
});
it("prefers requested full counterparty label when document rows only expose a generic group label", () => {
const reply = composeFactualReply(
"list_documents_by_counterparty",
[
{
period: "2021-11-10T12:00:07Z",
registrator: "Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07",
account_dt: "0",
account_kt: "0",
amount: 20000,
analytics: ["Группа", "Договор № 1-ПМ/2020 от 05.06.2020"],
organization: 'ООО "Альтернатива Плюс"'
}
],
{
userMessage: "а по свк",
counterpartyHint: "Группа СВК"
}
);
expect(reply.text).toContain("Контрагент: Группа СВК. Найдено документов: 1.");
expect(reply.text).not.toContain("Контрагент: Группа. Найдено документов");
});
it("keeps current resolved counterparty label over stale follow-up anchor during short retarget", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Counterparty: "Группа СВК",
Registrator: "Группа СВК"
}
],
rows: [],
error: null
})
.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2021-11-10T12:00:07Z",
Registrator: "Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07",
AccountDt: "0",
AccountKt: "0",
Amount: 20000,
Counterparty: "Группа",
Contract: "Договор № 1-ПМ/2020 от 05.06.2020",
Organization: 'ООО "Альтернатива Плюс"'
},
{
Period: "2021-09-29T12:00:03Z",
Registrator: "Поступление на расчетный счет 00000000012 от 29.09.2021 12:00:03",
AccountDt: "0",
AccountKt: "0",
Amount: 50000,
Counterparty: "Группа",
Contract: "Договор № 1-ПМ/2020 от 05.06.2020",
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("а по свк", {
followupContext: {
previous_intent: "list_documents_by_counterparty",
previous_filters: {
counterparty: "Чепурнов П.Д."
},
previous_anchor_type: "counterparty",
previous_anchor_value: "Чепурнов П.Д."
}
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(String(result?.reply_text ?? "")).toContain("Контрагент: Группа СВК");
expect(String(result?.reply_text ?? "")).not.toContain("Контрагент: Группа Найдено");
expect(String(result?.reply_text ?? "")).not.toContain("Контрагент: Чепурнов");
});
it("explains supplier payments and return when no supply rows are found", async () => {
executeAddressMcpQueryMock.mockImplementation(async (request?: { query?: string }) => {
const query = String(request?.query ?? "");

View File

@ -9,18 +9,27 @@ import { AddressQueryService } from "../src/services/addressQueryService";
describe("vat payable confirmed as-of route", () => {
it("routes VAT payable question into exact confirmed intent", () => {
const result = resolveAddressIntent("сколько НДС к уплате на март 2020");
expect(result.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(
result.reasons.includes("vat_liability_confirmed_tax_period_signal_detected") ||
result.reasons.includes("vat_liability_colloquial_bridge_signal_detected")
).toBe(true);
});
it("treats colloquial 'сгрузить' wording as confirmed VAT payable intent", () => {
const result = resolveAddressIntent("какой НДС мы должны сгрузить на март 2020");
expect(result.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result.reasons).toContain("vat_liability_colloquial_bridge_signal_detected");
});
it("treats colloquial VAT wording with month in prepositional case as confirmed intent", () => {
const result = resolveAddressIntent("Какой НДС необходимо сгрузить в марте 2020 года?");
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result.reasons).toContain("vat_liability_colloquial_bridge_signal_detected");
});
it("keeps VAT forecast intent when explicit forecast wording is used", () => {
const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020");
const result = resolveAddressIntent("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
expect(result.intent).toBe("vat_payable_forecast");
expect(result.reasons).toContain("forecast_tax_signal_detected");
});
@ -63,8 +72,8 @@ describe("vat payable confirmed as-of route", () => {
const service = new AddressQueryService();
const result = await service.tryHandle("сколько НДС к уплате на март 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
expect(result?.debug.detected_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result?.debug.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");

View File

@ -44,5 +44,17 @@ describe("answer composer user-facing formatting", () => {
expect(sanitized).not.toContain("technical_debug_payload_json");
expect(sanitized).not.toContain("trace_id");
});
});
it("keeps short uppercase Cyrillic counterparty acronyms in user-facing text", () => {
const source = [
"Контрагент: Группа СВК. Найдено документов: 2.",
"1. 2021-11-10 | Поступление на расчетный счет.",
"2. 2021-09-29 | Поступление на расчетный счет."
].join("\n");
const sanitized = sanitizeAssistantReplyForUserFacing(source);
expect(sanitized).toContain("Группа СВК");
expect(sanitized).toContain("Найдено документов: 2.");
});
});

View File

@ -410,7 +410,7 @@ describe("assistant living chat mode", () => {
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("могу помочь");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u044e 1\u0441");
expect(String(response.assistant_reply)).not.toContain("vat_period_snapshot");
expect(String(response.assistant_reply)).not.toContain("inventory_on_hand_as_of_date");
@ -608,7 +608,7 @@ describe("assistant living chat mode", () => {
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
@ -655,13 +655,165 @@ describe("assistant living chat mode", () => {
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("режиме чтения");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("режиме чтения");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("treats delta-by-contracts wording as capability meta instead of stale revenue follow-up", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-contract-delta-capability",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const sessionId = "asst-living-chat-contract-delta-capability";
sessions.ensureSession(sessionId);
sessions.appendItem(sessionId, {
message_id: "msg-seed-revenue-answer",
session_id: sessionId,
role: "assistant",
text: "Самый доходный клиент за все время — Гамма-мебель, ООО.",
reply_type: "factual",
created_at: new Date().toISOString(),
trace_id: "address-seed-revenue-answer",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "customer_revenue_and_payments",
selected_recipe: "address_customer_revenue_and_payments_v1",
extracted_filters: {
period_mode: "all_time"
}
}
} as any);
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-contract-delta-capability-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "ты умеешь считать дельту по договорам?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
expect(String(response.assistant_reply).toLowerCase()).toContain("дельт");
expect(String(response.assistant_reply).toLowerCase()).toContain("договор");
expect(String(response.assistant_reply).toLowerCase()).not.toContain("самый доходный клиент");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("keeps delta-by-contracts wording in capability meta mode after canonical rewrite", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: true,
trace_id: "norm-contract-delta-capability-rewrite",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: {
query: "проверить возможность расчета дельты по договорам"
},
validation: { passed: true, errors: [] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const sessionId = "asst-living-chat-contract-delta-capability-rewrite";
sessions.ensureSession(sessionId);
sessions.appendItem(sessionId, {
message_id: "msg-seed-vat-answer",
session_id: sessionId,
role: "assistant",
text: "Собран подтвержденный расчет НДС к уплате за февраль 2017.",
reply_type: "factual",
created_at: new Date().toISOString(),
trace_id: "address-seed-vat-answer",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "vat_liability_confirmed_for_tax_period",
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
extracted_filters: {
organization: "ООО Альтернатива Плюс",
as_of_date: "2017-02-28",
period_from: "2017-02-01",
period_to: "2017-02-28"
}
}
} as any);
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-contract-delta-capability-rewrite-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: sessionId,
user_message: "ты умеешь считать дельту по договорам?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
expect(String(response.assistant_reply).toLowerCase()).toContain("дельт");
expect(String(response.assistant_reply).toLowerCase()).toContain("договор");
expect(String(response.assistant_reply)).not.toContain("февраль 2017");
expect(String(response.assistant_reply)).not.toContain("0,00");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("answers historical capability follow-up in current inventory context instead of generic capability contract", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({

View File

@ -15,6 +15,8 @@ function buildPolicy(overrides: Record<string, unknown> = {}) {
compactWhitespace: (value: string) => String(value ?? "").replace(/\s+/g, " ").trim(),
repairAddressMojibake: (value: string) => value,
countTokens: (value: string) => String(value ?? "").split(/\s+/).filter(Boolean).length,
shouldHandleAsAssistantCapabilityMetaQuery: () => false,
hasDataRetrievalRequestSignal: () => false,
findLastAddressAssistantItem: () => ({
text: "1. Рабочая станция",
debug: {
@ -381,6 +383,88 @@ describe("assistantTransitionPolicy", () => {
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
});
it("keeps purchase-date VAT bridge after unsupported verification interrupt", () => {
const item = "Рабочая станция универрсального специалиста";
const provenanceItem = {
role: "assistant",
text: [
`По позиции ${item} однозначный поставщик не подтвержден.`,
"Подтверждение:",
"- Первая найденная дата закупки: 05.02.2015."
].join("\n"),
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item,
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2016-03-31"
},
anchor_type: "item",
anchor_value_resolved: item
}
} as any;
const unsupportedInterrupt = {
role: "assistant",
text: "РџРѕРєР° РЅРµ РјРѕРіСѓ точно подтвердить, что именно это СС РёРјРµРµС€СЊ РІ РІРёРґСѓ.",
debug: {
execution_lane: "address_query",
detected_intent: "unknown",
answer_grounding_check: {
status: "unsupported"
}
}
} as any;
const policy = buildPolicy({
findLastAddressAssistantItem: () => unsupportedInterrupt,
hasAddressFollowupContextSignal: () => false,
findRecentInventoryRootFrame: () => null,
resolveAddressIntent: () => ({ intent: "unknown" })
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"ндс можешь прикинуть на дату покупки рабочей станции?",
[provenanceItem, unsupportedInterrupt],
null,
null,
{
session_context: {
active_focus_object: {
object_type: "item",
label: item,
provenance_result_set_id: "rs-provenance-interrupt"
},
active_result_set_id: "rs-provenance-interrupt"
},
result_sets: [
{
result_set_id: "rs-provenance-interrupt",
intent: "inventory_purchase_provenance_for_item",
entity_refs: [
{
index: 1,
entity_type: "item",
value: "Поступление товаров и услуг 00000000023 от 05.02.2015 0:00:00"
}
]
}
]
}
);
expect(carryover?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item");
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
item,
organization: 'ООО "Альтернатива Плюс"',
period_from: "2015-02-01",
period_to: "2015-02-28"
});
});
it("drops stale carryover for a fresh standalone topic from another intent family", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
@ -520,4 +604,71 @@ describe("assistantTransitionPolicy", () => {
period_to: "2020-03-31"
});
});
it("does not attach address follow-up carryover to explicit capability-meta questions", () => {
const policy = buildPolicy({
shouldHandleAsAssistantCapabilityMetaQuery: (message: unknown) =>
/дельт[ауы]?\s+по\s+договорам/iu.test(String(message ?? "")),
hasAddressFollowupContextSignal: () => true
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"ты умеешь считать дельту по договорам?",
[],
"проверить возможность расчета дельты по договорам",
{
predecomposeContract: {
mode: "address_query",
intent: "unknown"
}
},
null
);
expect(carryover).toBeNull();
});
it("retargets selected-object provenance follow-up from inventory root when semantic scope is already detected", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
text: "На 31.03.2016 на складе подтверждено 2 позиции.",
debug: {
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2016-03-31",
period_from: "2016-03-01",
period_to: "2016-03-31"
},
anchor_type: "organization",
anchor_value_resolved: 'ООО "Альтернатива Плюс"'
}
}),
hasAddressFollowupContextSignal: () => true
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где взяли это?',
[],
null,
{
predecomposeContract: {
mode: "address_query",
intent: "unknown",
semantics: {
selected_object_scope_detected: true
}
}
},
null
);
expect(carryover?.followupContext?.target_intent).toBe("inventory_purchase_provenance_for_item");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
organization: 'ООО "Альтернатива Плюс"',
item: "Рабочая станция универсального специалиста (индивидуальное изготовление)"
});
expect(carryover?.followupContext?.previous_anchor_type).toBe("item");
});
});

View File

@ -0,0 +1,111 @@
{
"suite_id": "assistant_saved_session_runtime_job-nSVMEVVWXj",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_runtime_v0_1",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "saved_user_sessions_runtime",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"question_type": "followup",
"broadness_level": "medium",
"turns": [
{
"user_message": "приветик - че как там дела"
},
{
"user_message": "расскажи что можешь интересного"
},
{
"user_message": "кайф - что там на складе по остаткам?"
},
{
"user_message": "а исторические остатки на другие даты умеешь?"
},
{
"user_message": "давай на июль 2017"
},
{
"user_message": "март 2016"
},
{
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
},
{
"user_message": "а кому продали?"
},
{
"user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?"
},
{
"user_message": "ндс можешь прикинуть на дату покупки рабочей станции?"
},
{
"user_message": "а какой ндс мы должны сгрузить на март 2020?"
},
{
"user_message": "прикинь какой ндс нам надо заплатить на февраль 2017"
},
{
"user_message": "кто у нас самый доходный клиент за все время"
},
{
"user_message": "кто нам должен денег на май 2017"
},
{
"user_message": "а какой ндс мы должны примерно заплатить за этот период?"
},
{
"user_message": "мы должны комуто денег на сегодня?"
},
{
"user_message": "а нам?"
},
{
"user_message": "какой у нас самый доходный год"
},
{
"user_message": "а за 2017 мы скок заработали?"
},
{
"user_message": "сколько вообще денег мы заработали за все время?"
},
{
"user_message": "ты умеешь считать дельту по договорам?"
},
{
"user_message": "по чепурнову покажи все доки"
},
{
"user_message": "а по свк"
},
{
"user_message": "а сейчас у нас есть что на складе?"
},
{
"user_message": "что нам отгружать чепурнов? какой товар или услугу?"
},
{
"user_message": "какие остатки на складе на сегодня"
},
{
"user_message": "остатки на март 2016"
},
{
"user_message": "хвосты покажи по счету 60 на август 2022"
},
{
"user_message": "Есть ли остатки товара, которые закупались очень давно"
},
{
"user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020"
}
]
}
]
}

View File

@ -0,0 +1,111 @@
{
"suite_id": "assistant_saved_session_runtime_job-pVqO53XVsK",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_runtime_v0_1",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"scenario_count": 1,
"case_ids": [
"SAVED-001"
],
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "saved_user_sessions_runtime",
"title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06",
"question_type": "followup",
"broadness_level": "medium",
"turns": [
{
"user_message": "приветик - че как там дела"
},
{
"user_message": "расскажи что можешь интересного"
},
{
"user_message": "кайф - что там на складе по остаткам?"
},
{
"user_message": "а исторические остатки на другие даты умеешь?"
},
{
"user_message": "давай на июль 2017"
},
{
"user_message": "март 2016"
},
{
"user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?"
},
{
"user_message": "а кому продали?"
},
{
"user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?"
},
{
"user_message": "ндс можешь прикинуть на дату покупки рабочей станции?"
},
{
"user_message": "а какой ндс мы должны сгрузить на март 2020?"
},
{
"user_message": "прикинь какой ндс нам надо заплатить на февраль 2017"
},
{
"user_message": "кто у нас самый доходный клиент за все время"
},
{
"user_message": "кто нам должен денег на май 2017"
},
{
"user_message": "а какой ндс мы должны примерно заплатить за этот период?"
},
{
"user_message": "мы должны комуто денег на сегодня?"
},
{
"user_message": "а нам?"
},
{
"user_message": "какой у нас самый доходный год"
},
{
"user_message": "а за 2017 мы скок заработали?"
},
{
"user_message": "сколько вообще денег мы заработали за все время?"
},
{
"user_message": "ты умеешь считать дельту по договорам?"
},
{
"user_message": "по чепурнову покажи все доки"
},
{
"user_message": "а по свк"
},
{
"user_message": "а сейчас у нас есть что на складе?"
},
{
"user_message": "что нам отгружать чепурнов? какой товар или услугу?"
},
{
"user_message": "какие остатки на складе на сегодня"
},
{
"user_message": "остатки на март 2016"
},
{
"user_message": "хвосты покажи по счету 60 на август 2022"
},
{
"user_message": "Есть ли остатки товара, которые закупались очень давно"
},
{
"user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020"
}
]
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NDC AI Normalizer Playground</title>
<script type="module" crossorigin src="/assets/index-3F56oUw0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DNDajOYc.css">
<script type="module" crossorigin src="/assets/index-CLCo6iY2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DkvyfMZP.css">
</head>
<body>
<div id="root"></div>

View File

@ -204,6 +204,12 @@ interface AutoRunsUiConfig {
filters?: Partial<AutoRunsFilters>;
analysisDate?: string;
autogenPersonalityPromptHeight?: number;
groupsExpanded?: {
filters?: boolean;
generationContext?: boolean;
autogen?: boolean;
savedSessions?: boolean;
};
autoGenSettings?: {
mode?: AutoGenMode;
count?: number;
@ -282,6 +288,22 @@ function formatDateTime(iso: string | null): string {
return new Date(parsed).toLocaleString("ru-RU");
}
function formatDialogStepTag(message: AutoRunDialogMessage): string | null {
const localIndex =
typeof message.case_message_index === "number"
? message.case_message_index
: typeof message.message_index === "number"
? message.message_index
: null;
if (localIndex === null || localIndex < 0) {
return null;
}
const turnNumber = Math.floor(localIndex / 2) + 1;
const turnLabel = String(turnNumber).padStart(3, "0");
const roleLabel = message.role === "assistant" ? "ответ" : "вопрос";
return `${turnLabel} ${roleLabel}`;
}
function formatAutoGenModeLabel(mode: AutoGenMode): string {
if (mode === "saved_user_sessions") {
return "Пользовательские сессии";
@ -569,6 +591,35 @@ function QuestionGripIcon() {
);
}
function CardChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
className={expanded ? "autoruns-card-chevron-svg expanded" : "autoruns-card-chevron-svg"}
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
>
<path d="M3.5 6.2 8 10.4l4.5-4.2" />
</svg>
);
}
function CardLaunchIcon() {
return (
<svg className="autoruns-card-launch-svg" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M5 3.8 12 8l-7 4.2Z" />
</svg>
);
}
function GroupChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg className={expanded ? "autoruns-group-chevron-svg expanded" : "autoruns-group-chevron-svg"} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M3.5 6.2 8 10.4l4.5-4.2" />
</svg>
);
}
export function AutoRunsHistoryPanel({
connection,
modelOptions,
@ -620,6 +671,7 @@ export function AutoRunsHistoryPanel({
const [autoGenSettings, setAutoGenSettings] = useState<AutoGenSettingsState>(DEFAULT_AUTOGEN_SETTINGS);
const [autoGenHistory, setAutoGenHistory] = useState<AutoGenHistoryRecord[]>([]);
const [selectedAutogenGenerationId, setSelectedAutogenGenerationId] = useState("");
const [expandedSavedSessionGenerationId, setExpandedSavedSessionGenerationId] = useState("");
const [editableGeneratedQuestions, setEditableGeneratedQuestions] = useState<string[]>([]);
const [generatedQuestionsBusy, setGeneratedQuestionsBusy] = useState(false);
const [editingQuestionIndex, setEditingQuestionIndex] = useState<number | null>(null);
@ -650,6 +702,10 @@ export function AutoRunsHistoryPanel({
const [limitInput, setLimitInput] = useState(String(DEFAULT_FILTERS.limit));
const [autogenCountInput, setAutogenCountInput] = useState(String(DEFAULT_AUTOGEN_SETTINGS.count));
const [autogenPersonalityPromptHeight, setAutogenPersonalityPromptHeight] = useState(160);
const [filtersGroupExpanded, setFiltersGroupExpanded] = useState(true);
const [generationContextGroupExpanded, setGenerationContextGroupExpanded] = useState(true);
const [autogenGroupExpanded, setAutogenGroupExpanded] = useState(true);
const [savedSessionsGroupExpanded, setSavedSessionsGroupExpanded] = useState(true);
const [commentModal, setCommentModal] = useState<CommentModalState>({
open: false,
caseId: "",
@ -1485,17 +1541,20 @@ export function AutoRunsHistoryPanel({
[loadAutoGenHistory, loadHistory, log, selectedCaseId, selectedRunId, stopAsyncJobPolling]
);
const runAutogenCampaign = useCallback(async () => {
const runAutogenCampaign = useCallback(async (generationOverride?: AutoGenHistoryRecord, questionsOverride?: string[]) => {
stopAsyncJobPolling();
setAutogenRunBusy(true);
setErrorText("");
try {
const generation = selectedAutogenGeneration;
const generation = generationOverride ?? selectedAutogenGeneration;
if (!generation) {
throw new Error("История автогенерации пуста. Сначала сгенерируйте пачку вопросов.");
}
const questionsForRun = editableGeneratedQuestions
const sourceQuestions =
questionsOverride ??
(selectedAutogenGeneration?.generation_id === generation.generation_id ? editableGeneratedQuestions : generation.questions);
const questionsForRun = sourceQuestions
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (questionsForRun.length === 0) {
@ -1980,6 +2039,11 @@ export function AutoRunsHistoryPanel({
setDragOverQuestionIndex(null);
}, []);
const toggleSavedSessionQuestions = useCallback((generationId: string) => {
setSelectedAutogenGenerationId(generationId);
setExpandedSavedSessionGenerationId((prev) => (prev === generationId ? "" : generationId));
}, []);
const openAutoGenDeleteModal = useCallback((item: AutoGenHistoryRecord) => {
setAutoGenDeleteModal({
open: true,
@ -2135,6 +2199,19 @@ export function AutoRunsHistoryPanel({
return () => window.clearTimeout(timer);
}, [editingQuestionIndex]);
useEffect(() => {
if (!isSavedUserSessionsMode) {
setExpandedSavedSessionGenerationId("");
return;
}
if (
expandedSavedSessionGenerationId &&
!visibleAutoGenHistory.some((item) => item.generation_id === expandedSavedSessionGenerationId)
) {
setExpandedSavedSessionGenerationId("");
}
}, [expandedSavedSessionGenerationId, isSavedUserSessionsMode, visibleAutoGenHistory]);
useEffect(() => {
setLimitInput(String(filters.limit));
}, [filters.limit]);
@ -2213,6 +2290,20 @@ export function AutoRunsHistoryPanel({
if (typeof parsed.autogenPersonalityPromptHeight === "number") {
setAutogenPersonalityPromptHeight(clampAutogenPromptHeight(parsed.autogenPersonalityPromptHeight));
}
if (parsed.groupsExpanded) {
if (typeof parsed.groupsExpanded.filters === "boolean") {
setFiltersGroupExpanded(parsed.groupsExpanded.filters);
}
if (typeof parsed.groupsExpanded.generationContext === "boolean") {
setGenerationContextGroupExpanded(parsed.groupsExpanded.generationContext);
}
if (typeof parsed.groupsExpanded.autogen === "boolean") {
setAutogenGroupExpanded(parsed.groupsExpanded.autogen);
}
if (typeof parsed.groupsExpanded.savedSessions === "boolean") {
setSavedSessionsGroupExpanded(parsed.groupsExpanded.savedSessions);
}
}
if (parsed.autoGenSettings) {
setAutoGenSettings((prev) => {
const nextPrompts: Record<string, string> = {
@ -2272,6 +2363,12 @@ export function AutoRunsHistoryPanel({
filters,
analysisDate,
autogenPersonalityPromptHeight,
groupsExpanded: {
filters: filtersGroupExpanded,
generationContext: generationContextGroupExpanded,
autogen: autogenGroupExpanded,
savedSessions: savedSessionsGroupExpanded
},
autoGenSettings: {
mode: autoGenSettings.mode,
count: autoGenSettings.count,
@ -2284,7 +2381,18 @@ export function AutoRunsHistoryPanel({
hideResolvedAnnotations
};
localStorage.setItem(AUTORUNS_UI_CONFIG_KEY, JSON.stringify(payload));
}, [analysisDate, annotationDecisionFilter, autoGenSettings, autogenPersonalityPromptHeight, filters, hideResolvedAnnotations]);
}, [
analysisDate,
annotationDecisionFilter,
autoGenSettings,
autogenGroupExpanded,
autogenPersonalityPromptHeight,
filters,
filtersGroupExpanded,
generationContextGroupExpanded,
hideResolvedAnnotations,
savedSessionsGroupExpanded
]);
useEffect(() => {
const onSave = () => {
@ -2347,7 +2455,20 @@ export function AutoRunsHistoryPanel({
<h3>Автопрогоны</h3>
</div>
<h4>Настройки выборки</h4>
<div className="autoruns-group-heading">
<h4>Настройки выборки</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setFiltersGroupExpanded((prev) => !prev)}
aria-label={filtersGroupExpanded ? "Скрыть группу настройки выборки" : "Показать группу настройки выборки"}
title={filtersGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={filtersGroupExpanded} />
</button>
</div>
{filtersGroupExpanded ? (
<>
<div className="autoruns-form-grid">
<label>
Дата с
@ -2453,8 +2574,22 @@ export function AutoRunsHistoryPanel({
Сбросить фильтры
</button>
</div>
</>
) : null}
<h4>Контур генерации</h4>
<div className="autoruns-group-heading">
<h4>Контур генерации</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setGenerationContextGroupExpanded((prev) => !prev)}
aria-label={generationContextGroupExpanded ? "Скрыть группу контура генерации" : "Показать группу контура генерации"}
title={generationContextGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={generationContextGroupExpanded} />
</button>
</div>
{generationContextGroupExpanded ? (
<div className="autoruns-meta-list">
<div>
<span>Провайдер:</span>
@ -2473,8 +2608,22 @@ export function AutoRunsHistoryPanel({
<strong>{decompositionPromptVersion}</strong>
</div>
</div>
) : null}
<h4>Автопрогоны</h4>
<div className="autoruns-group-heading">
<h4>Автопрогоны</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setAutogenGroupExpanded((prev) => !prev)}
aria-label={autogenGroupExpanded ? "Скрыть группу автопрогонов" : "Показать группу автопрогонов"}
title={autogenGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={autogenGroupExpanded} />
</button>
</div>
{autogenGroupExpanded ? (
<>
<div className="autoruns-form-grid">
<label>
Режимы
@ -2600,6 +2749,7 @@ export function AutoRunsHistoryPanel({
<button
type="button"
className="autoruns-run-launch-btn"
style={isSavedUserSessionsMode ? { display: "none" } : undefined}
disabled={autogenRunBusy || editableGeneratedQuestions.length === 0 || !selectedAutogenGeneration}
onClick={() => void runAutogenCampaign()}
>
@ -2682,7 +2832,7 @@ export function AutoRunsHistoryPanel({
</>
) : (
<>
<div className="autoruns-generated-questions">
<div className="autoruns-generated-questions" style={isSavedUserSessionsMode ? { display: "none" } : undefined}>
<div className="autoruns-generated-questions-head">
<strong>Вопросы к запуску: {editableGeneratedQuestions.length}</strong>
</div>
@ -2765,14 +2915,35 @@ export function AutoRunsHistoryPanel({
+
</button>
</div>
{isSavedUserSessionsMode ? (
<h4>Сохраненные пользовательские сессии</h4>
) : (
{!isSavedUserSessionsMode ? (
<p className="muted">Запуск выполняет `assistant_stage1` eval по выбранному кейс-сету.</p>
)}
) : null}
</>
)}
</>
) : null}
<div className="autoruns-group-heading">
<h4>{isSavedUserSessionsMode ? "Сохраненные пользовательские сессии" : "История автогенераций"}</h4>
<button
type="button"
className="autoruns-group-toggle"
onClick={() => setSavedSessionsGroupExpanded((prev) => !prev)}
aria-label={
savedSessionsGroupExpanded
? isSavedUserSessionsMode
? "Скрыть группу сохраненных пользовательских сессий"
: "Скрыть группу истории автогенераций"
: isSavedUserSessionsMode
? "Показать группу сохраненных пользовательских сессий"
: "Показать группу истории автогенераций"
}
title={savedSessionsGroupExpanded ? "Скрыть группу" : "Показать группу"}
>
<GroupChevronIcon expanded={savedSessionsGroupExpanded} />
</button>
</div>
{savedSessionsGroupExpanded ? (
<div className="autoruns-autogen-list">
{autogenHistoryBusy ? (
<p className="muted">
@ -2787,9 +2958,49 @@ export function AutoRunsHistoryPanel({
{visibleAutoGenHistory.slice(0, 30).map((item) => (
<article
key={item.generation_id}
className={selectedAutogenGenerationId === item.generation_id ? "autoruns-autogen-item selected" : "autoruns-autogen-item"}
onClick={() => setSelectedAutogenGenerationId(item.generation_id)}
className={[
"autoruns-autogen-item",
selectedAutogenGenerationId === item.generation_id ? "selected" : "",
expandedSavedSessionGenerationId === item.generation_id ? "expanded" : "",
isSavedUserSessionsMode ? "saved-session" : ""
].filter(Boolean).join(" ")}
onClick={isSavedUserSessionsMode ? undefined : () => setSelectedAutogenGenerationId(item.generation_id)}
>
{isSavedUserSessionsMode ? (
<div className="autoruns-saved-session-topbar">
<button
type="button"
className="autoruns-saved-session-icon-btn"
disabled={autogenRunBusy}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setSelectedAutogenGenerationId(item.generation_id);
void runAutogenCampaign(
item,
selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions : item.questions
);
}}
title="Запустить прогон"
aria-label={`Запустить прогон для ${formatAutoGenGenerationTitle(item)}`}
>
<CardLaunchIcon />
</button>
<button
type="button"
className="autoruns-autogen-delete-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openAutoGenDeleteModal(item);
}}
title="Удалить сохраненный набор"
aria-label={`Удалить набор ${item.generation_id}`}
>
×
</button>
</div>
) : null}
<header>
<strong>{formatAutoGenGenerationTitle(item)}</strong>
<div className="autoruns-autogen-card-actions">
@ -2834,9 +3045,146 @@ export function AutoRunsHistoryPanel({
<div className="autoruns-run-meta">
тип={isAgentSemanticGeneration(item) ? "АГЕНТНЫЙ ПРОГОН" : "АВТОПРОГОН"}
</div>
{isSavedUserSessionsMode ? (
<>
<div className="autoruns-saved-session-footer">
<button
type="button"
className="autoruns-saved-session-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleSavedSessionQuestions(item.generation_id);
}}
title={
expandedSavedSessionGenerationId === item.generation_id ? "Скрыть вопросы" : "Показать вопросы"
}
aria-label={
expandedSavedSessionGenerationId === item.generation_id ? "Скрыть вопросы" : "Показать вопросы"
}
>
<CardChevronIcon expanded={expandedSavedSessionGenerationId === item.generation_id} />
</button>
</div>
<div
className={
expandedSavedSessionGenerationId === item.generation_id
? "autoruns-saved-session-questions expanded"
: "autoruns-saved-session-questions"
}
>
<div className="autoruns-generated-questions autoruns-generated-questions-embedded">
<div className="autoruns-generated-questions-head">
<strong>
Вопросы к запуску:{" "}
{selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions.length : item.questions.length}
</strong>
</div>
{(selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions : item.questions).length === 0 ? (
<p className="muted">Список вопросов пуст.</p>
) : (
<div className="autoruns-generated-questions-list">
{(selectedAutogenGenerationId === item.generation_id ? editableGeneratedQuestions : item.questions).map(
(question, index) => (
<div
key={`${item.generation_id}-${index}-${question.slice(0, 24)}`}
className={[
"autoruns-generated-question-item",
dragOverQuestionIndex === index && selectedAutogenGenerationId === item.generation_id ? "drag-over" : "",
draggingQuestionIndex === index && selectedAutogenGenerationId === item.generation_id ? "dragging" : "",
editingQuestionIndex === index && selectedAutogenGenerationId === item.generation_id ? "editing" : ""
].filter(Boolean).join(" ")}
onDragOver={(event) =>
selectedAutogenGenerationId === item.generation_id ? handleQuestionDragOver(event, index) : undefined
}
onDrop={(event) =>
selectedAutogenGenerationId === item.generation_id ? void handleQuestionDrop(event, index) : undefined
}
>
<button
type="button"
className="autoruns-question-grip-btn"
draggable={
selectedAutogenGenerationId === item.generation_id &&
!generatedQuestionsBusy &&
editingQuestionIndex !== index
}
disabled={
selectedAutogenGenerationId !== item.generation_id ||
generatedQuestionsBusy ||
editingQuestionIndex === index
}
onDragStart={(event) => {
setSelectedAutogenGenerationId(item.generation_id);
handleQuestionDragStart(event, index);
}}
onDragEnd={handleQuestionDragEnd}
title="Перетащить вопрос"
aria-label={`Перетащить вопрос ${index + 1}`}
>
<QuestionGripIcon />
</button>
{selectedAutogenGenerationId === item.generation_id && editingQuestionIndex === index ? (
<>
<input
ref={questionEditorRef}
className="autoruns-generated-question-input"
value={editingQuestionDraft}
onChange={(event) => setEditingQuestionDraft(event.target.value)}
onBlur={handleQuestionEditorBlur}
onKeyDown={handleQuestionEditorKeyDown}
placeholder="Текст вопроса"
disabled={generatedQuestionsBusy}
/>
<button
type="button"
className="autoruns-remove-question-btn"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void handleDeleteGeneratedQuestion(index)}
title="Удалить вопрос"
aria-label={`Удалить вопрос ${index + 1}`}
disabled={generatedQuestionsBusy}
>
×
</button>
</>
) : (
<button
type="button"
className="autoruns-generated-question-text"
onDoubleClick={() => {
setSelectedAutogenGenerationId(item.generation_id);
startQuestionEdit(index);
}}
title="Двойной клик для редактирования"
>
{index + 1}. {question}
</button>
)}
</div>
)
)}
</div>
)}
<button
type="button"
className="autoruns-add-question-btn"
onClick={() => {
setSelectedAutogenGenerationId(item.generation_id);
void handleAddGeneratedQuestion();
}}
disabled={selectedAutogenGenerationId !== item.generation_id || generatedQuestionsBusy}
>
+
</button>
</div>
</div>
</>
) : null}
</article>
))}
</div>
) : null}
<details className="autoruns-prompt-details">
<summary>Копия активного промпта (только чтение)</summary>
@ -3038,6 +3386,9 @@ export function AutoRunsHistoryPanel({
<strong>{role === "assistant" ? "Система" : "Модель/вопрос"}</strong>
<div className="autoruns-msg-head-actions">
{item.case_id ? <span className="autoruns-msg-case-tag">{item.case_id}</span> : null}
{formatDialogStepTag(item) ? (
<span className="autoruns-msg-case-tag">{formatDialogStepTag(item)}</span>
) : null}
<span>{item.created_at ? formatDateTime(item.created_at) : "нет данных"}</span>
{role === "assistant" && !isLiveRunId(selectedRunId) ? (
<>

View File

@ -1029,6 +1029,53 @@ button:disabled {
color: var(--text-muted);
}
.autoruns-group-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 12px 0 8px;
}
.autoruns-group-heading h4 {
margin: 0;
}
.autoruns-group-toggle {
width: 20px;
min-width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.autoruns-group-toggle:hover {
background: rgba(var(--rgb-background), 0.28);
color: rgb(var(--rgb-text-main));
}
.autoruns-group-chevron-svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transform: rotate(-90deg);
transition: transform 0.18s ease;
}
.autoruns-group-chevron-svg.expanded {
transform: rotate(0deg);
}
.autoruns-col-header {
position: sticky;
top: -12px;
@ -1468,6 +1515,7 @@ button:disabled {
display: grid;
gap: 5px;
cursor: pointer;
overflow: hidden;
}
.autoruns-autogen-item.selected {
@ -1487,6 +1535,120 @@ button:disabled {
font-size: 0.76rem;
}
.autoruns-autogen-item.saved-session {
cursor: default;
gap: 8px;
}
.autoruns-autogen-item.saved-session header {
display: grid;
gap: 4px;
}
.autoruns-autogen-item.saved-session header strong {
font-size: 0.84rem;
}
.autoruns-autogen-item.saved-session header span {
color: var(--text-muted);
font-size: 0.74rem;
}
.autoruns-autogen-item.saved-session header .autoruns-autogen-card-actions {
display: none;
}
.autoruns-saved-session-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.autoruns-saved-session-footer {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 20px;
margin-top: 2px;
}
.autoruns-saved-session-icon-btn {
width: 20px;
min-width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.autoruns-saved-session-icon-btn:hover:not(:disabled) {
background: rgba(var(--rgb-background), 0.28);
color: rgb(var(--rgb-text-main));
}
.autoruns-autogen-item.saved-session .autoruns-saved-session-icon-btn,
.autoruns-autogen-item.saved-session .autoruns-autogen-delete-btn {
color: #fff;
}
.autoruns-autogen-item.saved-session .autoruns-saved-session-icon-btn:hover:not(:disabled),
.autoruns-autogen-item.saved-session .autoruns-autogen-delete-btn:hover:not(:disabled) {
background: rgba(var(--rgb-background), 0.28);
color: rgb(var(--rgb-active));
box-shadow: none;
}
.autoruns-autogen-item.saved-session .autoruns-autogen-delete-btn {
border-radius: 6px;
}
.autoruns-card-chevron-svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.18s ease;
}
.autoruns-card-launch-svg {
width: 14px;
height: 14px;
fill: currentColor;
stroke: none;
}
.autoruns-card-chevron-svg.expanded {
transform: rotate(180deg);
}
.autoruns-saved-session-questions {
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.24s ease, opacity 0.18s ease, margin-top 0.24s ease;
margin-top: 0;
}
.autoruns-saved-session-questions.expanded {
max-height: 520px;
opacity: 1;
margin-top: 4px;
}
.autoruns-generated-questions-embedded {
margin-top: 2px;
}
.autoruns-autogen-item p {
margin: 0;
color: var(--text-muted);