АРЧ АП11 - Архитектура после регресса: Архитектура: сохранить свежую дату при inventory root restore и очистить data-scope ответы от грязных лейблов

This commit is contained in:
dctouch 2026-04-18 09:12:39 +03:00
parent a5ea9adf53
commit 0431595542
24 changed files with 1030 additions and 58 deletions

View File

@ -145,6 +145,39 @@ Still open after this pass:
- mixed continuity is now strong enough for the current phase7 gate, but it still needs broader saved-session proof before domain expansion can be treated as low-risk;
- the next architecture pass should move from one repaired mixed replay to a wider saved-session set and multi-domain acceptance pack;
- remaining work should focus on keeping the unified continuity authority stable under new real user paths, not on wording-only polish or isolated route greens.
- company authority is still not proactive enough at root inventory entry in multi-company sessions without an already grounded active organization;
- the next stabilization slice should prefer system-level company authority handling over repeated local clarification templates when the session has enough business context.
Completed in the current follow-up pass:
- direct company activity-age wording like `а по Альтернативе Плюс сколько лет активности в базе 1С?` is now protected by a unicode-safe exact signal instead of depending on mojibake-sensitive legacy lifecycle phrases;
- capability meta answers now explain supported business groups through human examples instead of leaking internal operation ids like `vat_period_snapshot`, `inventory_on_hand_as_of_date`, `explain_boundary`, or `suggest_safe_next_step`;
- the next proof target after unit/build checks is the live phase5 replay, because it exercises both the restored activity-age path and the capability-meta interrupt in one shared session.
Latest live replay evidence after that proof run:
- the capability meta interrupt is now business-first and no longer leaks internal operation ids in the top block;
- the same replay exposed a stricter continuity defect that the top-level review initially missed: organization identity can drift in session state as a damaged live label like `ООО \\Альтернати"а Плюс\\`;
- when that happens, the runtime keeps both `organization` and a stale `counterparty` anchor, does not emit `counterparty_cleared_for_selected_organization_activity`, and falls into `counterparty_anchor_not_matched_in_materialized_rows`;
- this is a system-level organization-identity robustness gap between data-scope probing, continuity memory, and exact-route truth gating, not a wording-only prompt defect;
- the current stabilization slice therefore includes hardening organization identity matching itself and rerunning the same live pack until step-level human answers and review verdicts align.
Latest phase8 runtime authority evidence after the manual mixed replay hardening:
- live replay `address_truth_harness_phase8_manual_runtime_authority_mix_live_20260417_rerun1` proved that the activity-age route was restored, but also exposed a hidden false-green: `step_11_inventory_same_date_after_receivables` silently reused stale inventory-root date `2021-03-31` instead of the freshest receivables date `2020-03-31`;
- the first fix in `assistantService` was not sufficient on its own, because `decomposeStage` still rebuilt `inventory_root` follow-up context by overwriting `previous_filters` from `root_filters` wholesale;
- the architectural correction was to preserve `root` authority for organization / warehouse while preserving the freshest temporal scope (`as_of_date`, `period_from`, `period_to`) from the immediately previous grounded step;
- this was locked by direct regressions in `assistantTransitionPolicy.test.ts` and `addressInventoryRootFrameRegression.test.ts`, plus a live rerun against the same manual replay spec;
- live replay `address_truth_harness_phase8_manual_runtime_authority_mix_live_20260417_rerun4` is now accepted end-to-end with `14/14` steps green, including:
- `step_07_capability_meta` with business-first human wording;
- `step_11_inventory_same_date_after_receivables` on the correct date `31.03.2020`;
- `step_14_company_activity_age` with restored factual lifecycle answer;
- cleaned user-facing company labels in the data-scope meta reply (`ООО Альтернатива Плюс`, `ООО Лайсвуд`, `РАЙМ`) instead of damaged raw probe labels.
Still open after the accepted phase8 replay:
- proactive organization authority at the very beginning of a new multi-company bookkeeping session is still weaker than the target product feel; the current system now clarifies honestly and cleanly, but it does not yet always pre-offer company selection early in the conversational flow;
- some user-facing inventory/counterparty labels inside business answers still deserve final presentation cleanup, but these are now post-stabilization quality refinements rather than continuity-authority blockers.
## Ready Signal

View File

@ -79,6 +79,24 @@
{
"step_id": "step_09_company_activity_age",
"title": "Organization age should be answered through reachable activity evidence or honest boundedness",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"expected_intents": [
"counterparty_activity_lifecycle"
],
"expected_recipe": "address_counterparty_activity_lifecycle_v1",
"required_direct_answer_patterns_any": [
"(?i)по активности",
"(?i)первая подтвержденная активность|не удается точно определить"
],
"forbidden_direct_answer_patterns": [
"(?i)не найден контрагент",
"(?i)уточните точное наименование организации"
],
"criticality": "critical",
"question": "а по Альтернативе Плюс сколько лет активности в базе 1С?",
"semantic_tags": [
"organization_activity_age",
@ -88,6 +106,23 @@
{
"step_id": "step_10_capability_meta_interrupt",
"title": "Capability meta interrupt does not destroy prior context",
"allowed_reply_types": [
"factual_with_explanation",
"factual"
],
"required_direct_answer_patterns_any": [
"(?i)1СЃ",
"(?i)ндс|контрагент|остатк|склад"
],
"forbidden_direct_answer_patterns": [
"(?i)vat_period_snapshot",
"(?i)inventory_on_hand_as_of_date",
"(?i)explain_boundary",
"(?i)suggest_safe_next_step",
"(?i)read_only",
"(?i)mcp"
],
"criticality": "warning",
"question": "что ты умеешь?",
"semantic_tags": [
"meta_capability"

View File

@ -0,0 +1,337 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "address_truth_harness_phase8_manual_runtime_authority_mix",
"domain": "address_phase8_manual_runtime_authority_mix",
"title": "Phase 8 manual runtime authority replay for company continuity, activity age, and human meta answers",
"description": "Mixed AGENT replay based on the latest manual session. The pack validates company authority, counterparty -> inventory transition behavior, selected-object continuity, organization activity age, capability meta cleanliness, same-date cross-domain pivot, and account 60 tails in one live session.",
"bindings": {},
"steps": [
{
"step_id": "step_01_smalltalk",
"title": "Casual opening stays human",
"question": "привет, как дела?",
"required_answer_patterns_any": [
"(?i)привет|дела|помочь|норм"
],
"forbidden_answer_patterns": [
"(?i)tool_gate_reason",
"(?i)address_mode",
"(?i)living_reason",
"(?i)snapshot_items"
],
"criticality": "info",
"semantic_tags": [
"meta_smalltalk"
]
},
{
"step_id": "step_02_data_scope_meta",
"title": "Data-scope meta stays deterministic and non-technical",
"question": "по какой компании мы сейчас работаем?",
"required_answer_patterns_any": [
"(?i)компан|организац|контур",
"(?i)работ"
],
"forbidden_answer_patterns": [
"(?i)tool_gate_reason",
"(?i)hard_meta_mode",
"(?i)living_reason",
"(?i)mcp",
"(?i)read_only"
],
"criticality": "warning",
"semantic_tags": [
"meta_scope"
]
},
{
"step_id": "step_03_counterparty_documents",
"title": "Counterparty documents use the legal name contour",
"question": "покажи все документы по чепурнову",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"list_documents_by_counterparty"
],
"required_direct_answer_patterns_any": [
"(?i)чепурнов",
"(?i)документ|отгруз|оплат|счет|акт"
],
"criticality": "critical",
"semantic_tags": [
"counterparty_documents"
]
},
{
"step_id": "step_04_counterparty_shipments",
"title": "Counterparty shipment fallback stays human and business-useful",
"question": "что нам отгружал чепурнов, какой товар или услугу?",
"allowed_reply_types": [
"factual",
"partial_coverage"
],
"expected_intents": [
"list_documents_by_counterparty"
],
"required_direct_answer_patterns_any": [
"(?i)чепурнов",
"(?i)товар|услуг|отгруз|документ|оплат"
],
"forbidden_direct_answer_patterns": [
"(?i)^сейчас не дам прямой адресный ответ",
"(?i)^в текущем адресном контуре этот запрос лучше не закрывать"
],
"criticality": "critical",
"semantic_tags": [
"counterparty_shipment_fallback"
]
},
{
"step_id": "step_05_inventory_root_after_counterparty",
"title": "Inventory root after counterparty branch remains human and non-technical",
"question": "какие остатки на складе на март 2021",
"allowed_reply_types": [
"factual",
"clarification_required",
"partial_coverage"
],
"required_answer_patterns_any": [
"(?i)март 2021|31\\.03\\.2021|организац|компан"
],
"forbidden_answer_patterns": [
"(?i)tool_gate_reason",
"(?i)address_mode",
"(?i)mcp",
"(?i)read_only",
"(?i)snapshot_items"
],
"criticality": "warning",
"semantic_tags": [
"inventory_root",
"company_authority_probe"
]
},
{
"step_id": "step_06_selected_item_supplier",
"title": "Selected-object supplier follow-up stays action-first",
"question": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?",
"allowed_reply_types": [
"factual",
"clarification_required"
],
"expected_intents": [
"inventory_purchase_provenance_for_item"
],
"required_direct_answer_patterns_any": [
"(?i)столешница 600\\*3050\\*26 альмандин",
"(?i)поставщик|поставил|куплен|союз|торговый дом|уточните организац"
],
"forbidden_direct_answer_patterns": [
"(?i)^сейчас не дам прямой адресный ответ"
],
"criticality": "critical",
"semantic_tags": [
"selected_object",
"selected_object_supplier"
]
},
{
"step_id": "step_07_capability_meta",
"title": "Capability meta answer is business-first and free from technical garbage",
"question": "что ты умеешь?",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"required_direct_answer_patterns_any": [
"(?i)могу|умею",
"(?i)документ|остатк|контрагент|ндс|склад|долг"
],
"forbidden_direct_answer_patterns": [
"(?i)vat_period_snapshot",
"(?i)inventory_on_hand_as_of_date",
"(?i)explain_boundary",
"(?i)suggest_safe_next_step",
"(?i)read_only",
"(?i)mcp",
"(?i)snapshot_items",
"(?i)assessed state",
"(?i)open item"
],
"criticality": "critical",
"semantic_tags": [
"meta_capability"
]
},
{
"step_id": "step_08_selected_item_documents",
"title": "Selected-object documents stay in the same contour after meta interrupt",
"question": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции",
"allowed_reply_types": [
"factual",
"clarification_required"
],
"expected_intents": [
"inventory_purchase_documents_for_item"
],
"required_direct_answer_patterns_any": [
"(?i)столешница 600\\*3050\\*26 альмандин|по этой позиции",
"(?i)документ|уточните организац"
],
"criticality": "critical",
"semantic_tags": [
"selected_object",
"selected_object_documents"
]
},
{
"step_id": "step_09_memory_recap",
"title": "Memory recap does not invent grounded facts",
"question": "а ты помнишь, что мы по этой позиции уже выяснили?",
"required_answer_patterns_any": [
"(?i)помню|по позиции|столешница"
],
"forbidden_answer_patterns": [
"(?i)^сейчас не дам прямой адресный ответ"
],
"criticality": "warning",
"semantic_tags": [
"meta_memory"
]
},
{
"step_id": "step_10_receivables_march_2020",
"title": "Receivables root establishes March 2020 carryover",
"question": "кто нам должен на март 2020",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"receivables_confirmed_as_of_date"
],
"required_filters": {
"as_of_date": "2020-03-31",
"period_from": "2020-03-01",
"period_to": "2020-03-31"
},
"required_direct_answer_patterns_any": [
"(?i)дебитор",
"31\\.03\\.2020"
],
"criticality": "critical",
"semantic_tags": [
"settlements_receivables"
]
},
{
"step_id": "step_11_inventory_same_date_after_receivables",
"title": "Inventory same-date pivot reuses March 2020 without re-clarification",
"question": "остатки по складу на эту же дату",
"allowed_reply_types": [
"factual"
],
"expected_intents": [
"inventory_on_hand_as_of_date"
],
"required_filters": {
"as_of_date": "{{step_10_receivables_march_2020.filters.as_of_date}}",
"period_from": "{{step_10_receivables_march_2020.filters.period_from}}",
"period_to": "{{step_10_receivables_march_2020.filters.period_to}}"
},
"required_direct_answer_patterns_all": [
"(?i)на складе",
"31\\.03\\.2020"
],
"forbidden_direct_answer_patterns": [
"(?i)уточните организац",
"(?i)по какой компании"
],
"required_filter_within_previous_step_period": {
"as_of_date": "step_10_receivables_march_2020"
},
"criticality": "critical",
"semantic_tags": [
"inventory_root",
"same_date_pivot"
]
},
{
"step_id": "step_12_historical_inventory_capability",
"title": "Historical inventory capability follow-up stays human",
"question": "а исторические остатки ты можешь дать?",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"required_answer_patterns_any": [
"(?i)историческ|история",
"(?i)могу|умею"
],
"forbidden_answer_patterns": [
"(?i)tool_gate_reason",
"(?i)hard_meta_mode",
"(?i)mcp",
"(?i)read_only"
],
"criticality": "warning",
"semantic_tags": [
"meta_historical_capability",
"inventory_root"
]
},
{
"step_id": "step_13_open_items_account_60",
"title": "Account 60 tails stay exact after the mixed session",
"question": "хвосты покажи по счету 60 на август 2022",
"allowed_reply_types": [
"factual",
"factual_with_explanation"
],
"expected_intents": [
"open_items_by_counterparty_or_contract"
],
"required_filters": {
"account": "60",
"period_from": "2022-08-01",
"period_to": "2022-08-31",
"as_of_date": "2022-08-31"
},
"required_direct_answer_patterns_any": [
"(?i)счету 60|счёту 60",
"(?i)хвост"
],
"criticality": "critical",
"semantic_tags": [
"settlements_account_60"
]
},
{
"step_id": "step_14_company_activity_age",
"title": "Organization activity age is answered through activity evidence or honest boundedness",
"question": "а по Альтернативе Плюс сколько лет активности в базе 1С?",
"allowed_reply_types": [
"factual",
"factual_with_explanation",
"partial_coverage"
],
"expected_intents": [
"counterparty_activity_lifecycle"
],
"expected_recipe": "address_counterparty_activity_lifecycle_v1",
"required_direct_answer_patterns_any": [
"(?i)активност",
"(?i)первая подтвержденная|не удается точно определить|лет"
],
"forbidden_direct_answer_patterns": [
"(?i)не найден контрагент",
"(?i)уточните точное наименование организации"
],
"criticality": "critical",
"semantic_tags": [
"organization_activity_age",
"company_selected"
]
}
]
}

View File

@ -60,6 +60,19 @@ function hasUnicodeLikelyCounterpartyAfterBy(text) {
]);
return !stopWords.has(token);
}
function hasUnicodeCounterpartyActivityLifecycleSignal(text) {
const normalized = String(text ?? "").toLowerCase();
if (!normalized) {
return false;
}
const hasActivityAgeCue = /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0432\s+\u0431\u0430\u0437\u0435|\u0432\u043e\u0437\u0440\u0430\u0441\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u043f\u0435\u0440\u0432(?:\u0430\u044f|\u044b\u0439|\u043e\u0435)\s+(?:\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u043f\u043b\u0430\u0442\u0435\u0436|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0435|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442)|\u043f\u043e\u0441\u043b\u0435\u0434\u043d(?:\u044f\u044f|\u0438\u0439|\u0435\u0435)\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u0441\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u0433\u043e\u0434\u0430\s+\u0430\u043a\u0442\u0438\u0432)/iu.test(normalized);
if (!hasActivityAgeCue) {
return false;
}
const hasOneCLexeme = /(?:\u0432\s+\u0431\u0430\u0437\u0435\s+1[\u0441c]|\u0432\s+1[\u0441c]\s+\u0431\u0430\u0437\u0435|\u0438\u0437\s+1[\u0441c])/iu.test(normalized);
const hasBusinessAnchor = /(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0448\u0435\u0439\s+\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438|\u043d\u0430\u0448\u0435\u0439\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438|\u043e\u043e\u043e|\u0430\u043e|\u0437\u0430\u043e|\u0438\u043f)/iu.test(normalized);
return hasOneCLexeme || hasBusinessAnchor || hasUnicodeLikelyCounterpartyAfterBy(normalized);
}
function resolveCounterpartyAddressIntent(text, deps) {
if (hasUnicodeOpenItemsAccountSignal(text)) {
return {
@ -107,6 +120,13 @@ function resolveCounterpartyAddressIntent(text, deps) {
reasons: ["counterparty_item_flow_signal_detected"]
};
}
if (hasUnicodeCounterpartyActivityLifecycleSignal(text)) {
return {
intent: "counterparty_activity_lifecycle",
confidence: "high",
reasons: ["counterparty_activity_lifecycle_signal_detected"]
};
}
if (deps.hasOpenContractsListSignal(text)) {
return {
intent: "open_contracts_confirmed_as_of_date",

View File

@ -1401,9 +1401,7 @@ function stripOrganizationLegalForm(value) {
.trim();
}
function sameOrganizationEntityReference(left, right) {
const leftNorm = stripOrganizationLegalForm(left);
const rightNorm = stripOrganizationLegalForm(right);
return Boolean(leftNorm && rightNorm && leftNorm === rightNorm);
return (0, assistantOrganizationMatcher_1.organizationsLikelySameEntity)(left, right);
}
function applyPreExecutionOrganizationScopeGrounding(input) {
const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(input.activeOrganization ?? null);

View File

@ -287,10 +287,36 @@ function buildInventoryRootFollowupContext(followupContext) {
if (!followupContext || !followupContext.root_intent || !followupContext.root_filters) {
return followupContext;
}
const rootFilters = followupContext.root_filters && typeof followupContext.root_filters === "object"
? { ...followupContext.root_filters }
: {};
const previousFilters = followupContext.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: {};
const previousAsOfDate = toNonEmptyString(previousFilters.as_of_date);
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
const previousOrganization = toNonEmptyString(previousFilters.organization);
const previousWarehouse = toNonEmptyString(previousFilters.warehouse);
if (previousAsOfDate) {
rootFilters.as_of_date = previousAsOfDate;
}
if (previousPeriodFrom) {
rootFilters.period_from = previousPeriodFrom;
}
if (previousPeriodTo) {
rootFilters.period_to = previousPeriodTo;
}
if (!toNonEmptyString(rootFilters.organization) && previousOrganization) {
rootFilters.organization = previousOrganization;
}
if (!toNonEmptyString(rootFilters.warehouse) && previousWarehouse) {
rootFilters.warehouse = previousWarehouse;
}
return {
...followupContext,
previous_intent: followupContext.root_intent,
previous_filters: { ...followupContext.root_filters },
previous_filters: rootFilters,
previous_anchor_type: followupContext.root_anchor_type ?? followupContext.previous_anchor_type,
previous_anchor_value: followupContext.root_anchor_value ?? followupContext.previous_anchor_value,
current_frame_kind: "inventory_root"

View File

@ -22,8 +22,9 @@ function createAssistantBoundaryPolicy(deps) {
function buildAssistantDataScopeContractReply(scopeProbe = null) {
const organizations = Array.isArray(scopeProbe?.organizations)
? scopeProbe.organizations
.map((item) => String(item ?? "").trim())
.map((item) => normalizeSelectedOrganization(item, deps.normalizeOrganizationScopeValue))
.filter((item) => item.length > 0)
.filter((item, index, array) => array.indexOf(item) === index)
: [];
if (organizations.length === 1) {
return [

View File

@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeOrganizationScopeValue = normalizeOrganizationScopeValue;
exports.normalizeOrganizationScopeSearchText = normalizeOrganizationScopeSearchText;
exports.scoreOrganizationMentionInMessage = scoreOrganizationMentionInMessage;
exports.organizationsLikelySameEntity = organizationsLikelySameEntity;
exports.mergeKnownOrganizations = mergeKnownOrganizations;
exports.resolveOrganizationSelectionFromMessage = resolveOrganizationSelectionFromMessage;
const ORGANIZATION_SCOPE_STOPWORDS = new Set([
@ -53,7 +54,9 @@ const ORGANIZATION_SCOPE_STOPWORDS = new Set([
]);
function normalizeScopeLabel(value) {
return String(value ?? "")
.replace(/\\/g, " ")
.replace(/[“”«»]/g, '"')
.replace(/([\p{L}])"(?=[\p{L}])/gu, "$1в")
.replace(/\s+/g, " ")
.trim();
}
@ -104,6 +107,52 @@ function organizationTokenVariants(token) {
}
return Array.from(variants);
}
function isSingleInsertionOrDeletionAway(left, right) {
const longer = left.length >= right.length ? left : right;
const shorter = left.length >= right.length ? right : left;
if (longer.length - shorter.length !== 1) {
return false;
}
let longIndex = 0;
let shortIndex = 0;
let mismatchUsed = false;
while (longIndex < longer.length && shortIndex < shorter.length) {
if (longer[longIndex] === shorter[shortIndex]) {
longIndex += 1;
shortIndex += 1;
continue;
}
if (mismatchUsed) {
return false;
}
mismatchUsed = true;
longIndex += 1;
}
return true;
}
function organizationTokensLookEquivalent(left, right) {
if (!left || !right) {
return false;
}
if (left === right) {
return true;
}
if (left.length >= 5 && right.length >= 5 && (left.startsWith(right) || right.startsWith(left))) {
return true;
}
const leftCompact = left.replace(/\s+/g, "");
const rightCompact = right.replace(/\s+/g, "");
if (!leftCompact || !rightCompact) {
return false;
}
if (leftCompact === rightCompact) {
return true;
}
if (leftCompact.length >= 6 && rightCompact.length >= 6 && isSingleInsertionOrDeletionAway(leftCompact, rightCompact)) {
return true;
}
return false;
}
function scoreOrganizationMentionInMessage(message, organization) {
const messageNorm = normalizeOrganizationScopeSearchText(message);
const organizationNorm = normalizeOrganizationScopeSearchText(organization);
@ -163,20 +212,62 @@ function scoreOrganizationMentionInMessage(message, organization) {
}
return score;
}
function organizationsLikelySameEntity(left, right) {
const leftNorm = normalizeOrganizationScopeSearchText(left);
const rightNorm = normalizeOrganizationScopeSearchText(right);
if (!leftNorm || !rightNorm) {
return false;
}
if (leftNorm === rightNorm) {
return true;
}
const leftTokens = tokenizeOrganizationScope(leftNorm);
const rightTokens = tokenizeOrganizationScope(rightNorm);
if (leftTokens.length === 0 || rightTokens.length === 0) {
return false;
}
const leftCompact = leftTokens.join("");
const rightCompact = rightTokens.join("");
if (leftCompact && rightCompact) {
if (leftCompact === rightCompact) {
return true;
}
if (leftCompact.length >= 8 &&
rightCompact.length >= 8 &&
isSingleInsertionOrDeletionAway(leftCompact, rightCompact)) {
return true;
}
}
const leftCovered = leftTokens.every((leftToken) => rightTokens.some((rightToken) => organizationTokensLookEquivalent(leftToken, rightToken)));
if (!leftCovered) {
return false;
}
const rightCovered = rightTokens.every((rightToken) => leftTokens.some((leftToken) => organizationTokensLookEquivalent(leftToken, rightToken)));
return rightCovered;
}
function mergeKnownOrganizations(values, limit = 50) {
const dedup = new Map();
const dedup = [];
for (const raw of Array.isArray(values) ? values : []) {
const normalized = normalizeOrganizationScopeValue(raw);
if (!normalized) {
continue;
}
const key = normalizeOrganizationScopeSearchText(normalized);
if (!key || dedup.has(key)) {
if (!key) {
continue;
}
dedup.set(key, normalized);
const existingIndex = dedup.findIndex((item) => organizationsLikelySameEntity(item, normalized));
if (existingIndex >= 0) {
const existing = dedup[existingIndex];
const existingKey = normalizeOrganizationScopeSearchText(existing);
if (key.length > existingKey.length || normalized.length > existing.length) {
dedup[existingIndex] = normalized;
}
continue;
}
dedup.push(normalized);
}
return Array.from(dedup.values()).slice(0, limit);
return dedup.slice(0, limit);
}
function resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations) {
const known = mergeKnownOrganizations(Array.isArray(knownOrganizations) ? knownOrganizations : []);

View File

@ -41,6 +41,7 @@ exports.evaluateCoverageForTests = evaluateCoverageForTests;
exports.extractSubjectTokensForTests = extractSubjectTokensForTests;
exports.resolveAssistantOrchestrationDecision = resolveAssistantOrchestrationDecision;
exports.resolveSessionOrganizationScopeContextForTests = resolveSessionOrganizationScopeContextForTests;
exports.buildRootScopedCarryoverFiltersForTests = buildRootScopedCarryoverFiltersForTests;
exports.extractOrganizationFactsFromRowsForTests = extractOrganizationFactsFromRowsForTests;
exports.resolveOrganizationNamesByRefsForTests = resolveOrganizationNamesByRefsForTests;
exports.resolveLivingAssistantModeDecision = resolveLivingAssistantModeDecision;
@ -2751,13 +2752,13 @@ function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMes
function buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame) {
const candidateFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object"
? inventoryRootFrame.filters
: previousFilters;
: {};
const nextFilters = {};
const organization = toNonEmptyString(candidateFilters?.organization) ?? toNonEmptyString(previousFilters?.organization);
const warehouse = toNonEmptyString(candidateFilters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse);
const asOfDate = toNonEmptyString(candidateFilters?.as_of_date) ?? toNonEmptyString(previousFilters?.as_of_date);
const periodFrom = toNonEmptyString(candidateFilters?.period_from) ?? toNonEmptyString(previousFilters?.period_from);
const periodTo = toNonEmptyString(candidateFilters?.period_to) ?? toNonEmptyString(previousFilters?.period_to);
const asOfDate = toNonEmptyString(previousFilters?.as_of_date) ?? toNonEmptyString(candidateFilters?.as_of_date);
const periodFrom = toNonEmptyString(previousFilters?.period_from) ?? toNonEmptyString(candidateFilters?.period_from);
const periodTo = toNonEmptyString(previousFilters?.period_to) ?? toNonEmptyString(candidateFilters?.period_to);
if (organization) {
nextFilters.organization = organization;
}
@ -4557,6 +4558,9 @@ function mergeFollowupContextWithOrganizationScope(followupContext, organization
function resolveSessionOrganizationScopeContextForTests(userMessage, items, addressNavigationState = null) {
return resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState);
}
function buildRootScopedCarryoverFiltersForTests(previousFilters, inventoryRootFrame) {
return buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame);
}
function normalizeGuidValue(value) {
const source = normalizeScopeLabel(value);
if (!source) {

View File

@ -16,14 +16,14 @@ const FALLBACK_REGISTRY = {
{
group_code: "vat",
group_title: "НДС",
description: "Срезы и расчеты НДС на базе данных 1С.",
description: "Срезы и расчёты НДС на базе данных 1С",
risk_level: "high",
maturity_status: "partial",
supported_operations: ["vat_period_snapshot", "vat_payable_forecast"],
unsupported_operations: ["submit_tax_declaration"],
required_entities: ["period", "organization"],
optional_entities: ["counterparty"],
typical_queries: ["Сколько НДС к уплате за период?"],
typical_queries: ["Сколько НДС к уплате за период?", "Покажи срез НДС на дату"],
related_routes: [],
safe_alternatives: ["Показать движения по 68/19 за период"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
@ -31,22 +31,71 @@ const FALLBACK_REGISTRY = {
{
group_code: "counterparties",
group_title: "Контрагенты",
description: "Документы, операции, договоры и срезы по контрагентам.",
description: "Документы, операции, договоры и активность по контрагентам",
risk_level: "medium",
maturity_status: "production_ready",
supported_operations: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
unsupported_operations: ["edit_counterparty_card"],
required_entities: ["counterparty_scope_or_contract"],
optional_entities: ["period", "organization"],
typical_queries: ["Покажи документы по контрагенту"],
typical_queries: ["Покажи документы по контрагенту", "Какие операции были по банку с контрагентом?"],
related_routes: [],
safe_alternatives: ["Уточнить ИНН/наименование контрагента"],
safe_alternatives: ["Уточнить ИНН или наименование контрагента"],
one_c_hints: ["Справочник.Контрагенты"]
},
{
group_code: "settlements",
group_title: "Долги и расчёты",
description: "Сальдо, хвосты, незакрытые авансы и аналитика по расчётам",
risk_level: "high",
maturity_status: "production_ready",
supported_operations: ["receivables_confirmed_as_of_date", "open_items_by_counterparty_or_contract"],
unsupported_operations: ["close_period"],
required_entities: ["period_or_date"],
optional_entities: ["organization", "account", "counterparty"],
typical_queries: ["Кто нам должен на дату?", "Хвосты покажи по счёту 60 за период"],
related_routes: [],
safe_alternatives: ["Уточнить период, счёт или организацию"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
},
{
group_code: "cash",
group_title: "Деньги",
description: "Остатки и движение по денежным счетам и кассе",
risk_level: "medium",
maturity_status: "production_ready",
supported_operations: ["account_balance_snapshot", "bank_operations_by_counterparty"],
unsupported_operations: ["post_bank_statement"],
required_entities: ["date_or_period"],
optional_entities: ["organization", "account", "counterparty"],
typical_queries: ["Какой остаток по счёту 51 на дату?", "Покажи движение денег за месяц"],
related_routes: [],
safe_alternatives: ["Уточнить счёт или период"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
},
{
group_code: "inventory",
group_title: "Склад и товары",
description: "Подтверждённые остатки, происхождение и документы по товарным позициям",
risk_level: "medium",
maturity_status: "production_ready",
supported_operations: [
"inventory_on_hand_as_of_date",
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item"
],
unsupported_operations: ["write_off_inventory"],
required_entities: ["date_or_period"],
optional_entities: ["organization", "warehouse", "item"],
typical_queries: ["Какие товары сейчас лежат на складе?", "Кто поставил эту позицию?"],
related_routes: [],
safe_alternatives: ["Уточнить организацию, дату или позицию"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
},
{
group_code: "boundaries",
group_title: "Ограничения",
description: "Операции, которые ассистент не выполняет.",
description: "Операции, которые ассистент не выполняет в этом рантайме",
risk_level: "high",
maturity_status: "production_ready",
supported_operations: ["explain_boundary", "suggest_safe_next_step"],
@ -55,7 +104,7 @@ const FALLBACK_REGISTRY = {
optional_entities: [],
typical_queries: ["Можешь настроить 1С?"],
related_routes: [],
safe_alternatives: ["Сформировать план диагностики для 1С/ИТ-админа"],
safe_alternatives: ["Сформировать безопасный план диагностики для 1С или ИТ-админа"],
one_c_hints: []
}
]
@ -150,16 +199,20 @@ function loadCapabilitiesRegistry() {
}
function buildCapabilityContractReplyFromRegistry() {
const registry = loadCapabilitiesRegistry();
const topGroups = registry.groups.slice(0, 6);
const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6);
const groupLines = topGroups.map((group, index) => {
const ops = group.supported_operations.slice(0, 3).join(", ");
return `${index + 1}. ${group.group_title}: ${group.description}${ops ? ` (например: ${ops})` : ""}.`;
const examples = group.typical_queries
.slice(0, 2)
.map((query) => query.trim())
.filter((query) => query.length > 0)
.join("; ");
return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`;
});
return [
"Я ассистент по анализу данных 1С в режиме чтения.",
"Что умею по группам:",
"Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.",
"По основным группам:",
...groupLines,
"Если хотите, раскрою любую группу точечно и дам готовую формулировку запроса.",
"Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.",
"Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере."
].join("\n");
}

View File

@ -108,6 +108,32 @@ function hasUnicodeLikelyCounterpartyAfterBy(text: string): boolean {
return !stopWords.has(token);
}
function hasUnicodeCounterpartyActivityLifecycleSignal(text: string): boolean {
const normalized = String(text ?? "").toLowerCase();
if (!normalized) {
return false;
}
const hasActivityAgeCue =
/(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0432\s+\u0431\u0430\u0437\u0435|\u0432\u043e\u0437\u0440\u0430\u0441\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u043f\u0435\u0440\u0432(?:\u0430\u044f|\u044b\u0439|\u043e\u0435)\s+(?:\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u043f\u043b\u0430\u0442\u0435\u0436|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0435|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442)|\u043f\u043e\u0441\u043b\u0435\u0434\u043d(?:\u044f\u044f|\u0438\u0439|\u0435\u0435)\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u0441\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u0433\u043e\u0434\u0430\s+\u0430\u043a\u0442\u0438\u0432)/iu.test(
normalized
);
if (!hasActivityAgeCue) {
return false;
}
const hasOneCLexeme =
/(?:\u0432\s+\u0431\u0430\u0437\u0435\s+1[\u0441c]|\u0432\s+1[\u0441c]\s+\u0431\u0430\u0437\u0435|\u0438\u0437\s+1[\u0441c])/iu.test(
normalized
);
const hasBusinessAnchor =
/(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0448\u0435\u0439\s+\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438|\u043d\u0430\u0448\u0435\u0439\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438|\u043e\u043e\u043e|\u0430\u043e|\u0437\u0430\u043e|\u0438\u043f)/iu.test(
normalized
);
return hasOneCLexeme || hasBusinessAnchor || hasUnicodeLikelyCounterpartyAfterBy(normalized);
}
export function resolveCounterpartyAddressIntent(
text: string,
deps: CounterpartyIntentDeps
@ -170,6 +196,14 @@ export function resolveCounterpartyAddressIntent(
};
}
if (hasUnicodeCounterpartyActivityLifecycleSignal(text)) {
return {
intent: "counterparty_activity_lifecycle",
confidence: "high",
reasons: ["counterparty_activity_lifecycle_signal_detected"]
};
}
if (deps.hasOpenContractsListSignal(text)) {
return {
intent: "open_contracts_confirmed_as_of_date",

View File

@ -60,6 +60,7 @@ import {
mergeKnownOrganizations,
normalizeOrganizationScopeSearchText,
normalizeOrganizationScopeValue,
organizationsLikelySameEntity,
resolveOrganizationSelectionFromMessage
} from "./assistantOrganizationMatcher";
import {
@ -1735,9 +1736,7 @@ function stripOrganizationLegalForm(value: string | null | undefined): string {
}
function sameOrganizationEntityReference(left: string | null | undefined, right: string | null | undefined): boolean {
const leftNorm = stripOrganizationLegalForm(left);
const rightNorm = stripOrganizationLegalForm(right);
return Boolean(leftNorm && rightNorm && leftNorm === rightNorm);
return organizationsLikelySameEntity(left, right);
}
function applyPreExecutionOrganizationScopeGrounding(input: {

View File

@ -395,10 +395,38 @@ function buildInventoryRootFollowupContext(
if (!followupContext || !followupContext.root_intent || !followupContext.root_filters) {
return followupContext;
}
const rootFilters =
followupContext.root_filters && typeof followupContext.root_filters === "object"
? { ...followupContext.root_filters }
: {};
const previousFilters =
followupContext.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: {};
const previousAsOfDate = toNonEmptyString(previousFilters.as_of_date);
const previousPeriodFrom = toNonEmptyString(previousFilters.period_from);
const previousPeriodTo = toNonEmptyString(previousFilters.period_to);
const previousOrganization = toNonEmptyString(previousFilters.organization);
const previousWarehouse = toNonEmptyString(previousFilters.warehouse);
if (previousAsOfDate) {
rootFilters.as_of_date = previousAsOfDate;
}
if (previousPeriodFrom) {
rootFilters.period_from = previousPeriodFrom;
}
if (previousPeriodTo) {
rootFilters.period_to = previousPeriodTo;
}
if (!toNonEmptyString(rootFilters.organization) && previousOrganization) {
rootFilters.organization = previousOrganization;
}
if (!toNonEmptyString(rootFilters.warehouse) && previousWarehouse) {
rootFilters.warehouse = previousWarehouse;
}
return {
...followupContext,
previous_intent: followupContext.root_intent,
previous_filters: { ...followupContext.root_filters },
previous_filters: rootFilters,
previous_anchor_type: followupContext.root_anchor_type ?? followupContext.previous_anchor_type,
previous_anchor_value: followupContext.root_anchor_value ?? followupContext.previous_anchor_value,
current_frame_kind: "inventory_root"

View File

@ -56,8 +56,9 @@ export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps)
function buildAssistantDataScopeContractReply(scopeProbe: Record<string, unknown> | null = null): string {
const organizations = Array.isArray(scopeProbe?.organizations)
? scopeProbe.organizations
.map((item) => String(item ?? "").trim())
.map((item) => normalizeSelectedOrganization(item, deps.normalizeOrganizationScopeValue))
.filter((item) => item.length > 0)
.filter((item, index, array) => array.indexOf(item) === index)
: [];
if (organizations.length === 1) {

View File

@ -47,7 +47,9 @@ const ORGANIZATION_SCOPE_STOPWORDS = new Set([
function normalizeScopeLabel(value: unknown): string {
return String(value ?? "")
.replace(/\\/g, " ")
.replace(/[“”«»]/g, '"')
.replace(/([\p{L}])"(?=[\p{L}])/gu, "$1в")
.replace(/\s+/g, " ")
.trim();
}
@ -109,6 +111,54 @@ function organizationTokenVariants(token: string): string[] {
return Array.from(variants);
}
function isSingleInsertionOrDeletionAway(left: string, right: string): boolean {
const longer = left.length >= right.length ? left : right;
const shorter = left.length >= right.length ? right : left;
if (longer.length - shorter.length !== 1) {
return false;
}
let longIndex = 0;
let shortIndex = 0;
let mismatchUsed = false;
while (longIndex < longer.length && shortIndex < shorter.length) {
if (longer[longIndex] === shorter[shortIndex]) {
longIndex += 1;
shortIndex += 1;
continue;
}
if (mismatchUsed) {
return false;
}
mismatchUsed = true;
longIndex += 1;
}
return true;
}
function organizationTokensLookEquivalent(left: string, right: string): boolean {
if (!left || !right) {
return false;
}
if (left === right) {
return true;
}
if (left.length >= 5 && right.length >= 5 && (left.startsWith(right) || right.startsWith(left))) {
return true;
}
const leftCompact = left.replace(/\s+/g, "");
const rightCompact = right.replace(/\s+/g, "");
if (!leftCompact || !rightCompact) {
return false;
}
if (leftCompact === rightCompact) {
return true;
}
if (leftCompact.length >= 6 && rightCompact.length >= 6 && isSingleInsertionOrDeletionAway(leftCompact, rightCompact)) {
return true;
}
return false;
}
export function scoreOrganizationMentionInMessage(message: unknown, organization: unknown): number {
const messageNorm = normalizeOrganizationScopeSearchText(message);
const organizationNorm = normalizeOrganizationScopeSearchText(organization);
@ -170,20 +220,73 @@ export function scoreOrganizationMentionInMessage(message: unknown, organization
return score;
}
export function organizationsLikelySameEntity(left: unknown, right: unknown): boolean {
const leftNorm = normalizeOrganizationScopeSearchText(left);
const rightNorm = normalizeOrganizationScopeSearchText(right);
if (!leftNorm || !rightNorm) {
return false;
}
if (leftNorm === rightNorm) {
return true;
}
const leftTokens = tokenizeOrganizationScope(leftNorm);
const rightTokens = tokenizeOrganizationScope(rightNorm);
if (leftTokens.length === 0 || rightTokens.length === 0) {
return false;
}
const leftCompact = leftTokens.join("");
const rightCompact = rightTokens.join("");
if (leftCompact && rightCompact) {
if (leftCompact === rightCompact) {
return true;
}
if (
leftCompact.length >= 8 &&
rightCompact.length >= 8 &&
isSingleInsertionOrDeletionAway(leftCompact, rightCompact)
) {
return true;
}
}
const leftCovered = leftTokens.every((leftToken) =>
rightTokens.some((rightToken) => organizationTokensLookEquivalent(leftToken, rightToken))
);
if (!leftCovered) {
return false;
}
const rightCovered = rightTokens.every((rightToken) =>
leftTokens.some((leftToken) => organizationTokensLookEquivalent(leftToken, rightToken))
);
return rightCovered;
}
export function mergeKnownOrganizations(values: unknown[], limit = 50): string[] {
const dedup = new Map<string, string>();
const dedup: string[] = [];
for (const raw of Array.isArray(values) ? values : []) {
const normalized = normalizeOrganizationScopeValue(raw);
if (!normalized) {
continue;
}
const key = normalizeOrganizationScopeSearchText(normalized);
if (!key || dedup.has(key)) {
if (!key) {
continue;
}
dedup.set(key, normalized);
const existingIndex = dedup.findIndex((item) => organizationsLikelySameEntity(item, normalized));
if (existingIndex >= 0) {
const existing = dedup[existingIndex];
const existingKey = normalizeOrganizationScopeSearchText(existing);
if (key.length > existingKey.length || normalized.length > existing.length) {
dedup[existingIndex] = normalized;
}
continue;
}
dedup.push(normalized);
}
return Array.from(dedup.values()).slice(0, limit);
return dedup.slice(0, limit);
}
export function resolveOrganizationSelectionFromMessage(

View File

@ -2707,13 +2707,13 @@ function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMes
function buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame) {
const candidateFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object"
? inventoryRootFrame.filters
: previousFilters;
: {};
const nextFilters = {};
const organization = toNonEmptyString(candidateFilters?.organization) ?? toNonEmptyString(previousFilters?.organization);
const warehouse = toNonEmptyString(candidateFilters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse);
const asOfDate = toNonEmptyString(candidateFilters?.as_of_date) ?? toNonEmptyString(previousFilters?.as_of_date);
const periodFrom = toNonEmptyString(candidateFilters?.period_from) ?? toNonEmptyString(previousFilters?.period_from);
const periodTo = toNonEmptyString(candidateFilters?.period_to) ?? toNonEmptyString(previousFilters?.period_to);
const asOfDate = toNonEmptyString(previousFilters?.as_of_date) ?? toNonEmptyString(candidateFilters?.as_of_date);
const periodFrom = toNonEmptyString(previousFilters?.period_from) ?? toNonEmptyString(candidateFilters?.period_from);
const periodTo = toNonEmptyString(previousFilters?.period_to) ?? toNonEmptyString(candidateFilters?.period_to);
if (organization) {
nextFilters.organization = organization;
}
@ -4514,6 +4514,9 @@ function mergeFollowupContextWithOrganizationScope(followupContext, organization
export function resolveSessionOrganizationScopeContextForTests(userMessage, items, addressNavigationState = null) {
return resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState);
}
export function buildRootScopedCarryoverFiltersForTests(previousFilters, inventoryRootFrame) {
return buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame);
}
function normalizeGuidValue(value) {
const source = normalizeScopeLabel(value);
if (!source) {

View File

@ -34,14 +34,14 @@ const FALLBACK_REGISTRY: CapabilityRegistry = {
{
group_code: "vat",
group_title: "НДС",
description: "Срезы и расчеты НДС на базе данных 1С.",
description: "Срезы и расчёты НДС на базе данных 1С",
risk_level: "high",
maturity_status: "partial",
supported_operations: ["vat_period_snapshot", "vat_payable_forecast"],
unsupported_operations: ["submit_tax_declaration"],
required_entities: ["period", "organization"],
optional_entities: ["counterparty"],
typical_queries: ["Сколько НДС к уплате за период?"],
typical_queries: ["Сколько НДС к уплате за период?", "Покажи срез НДС на дату"],
related_routes: [],
safe_alternatives: ["Показать движения по 68/19 за период"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
@ -49,22 +49,71 @@ const FALLBACK_REGISTRY: CapabilityRegistry = {
{
group_code: "counterparties",
group_title: "Контрагенты",
description: "Документы, операции, договоры и срезы по контрагентам.",
description: "Документы, операции, договоры и активность по контрагентам",
risk_level: "medium",
maturity_status: "production_ready",
supported_operations: ["list_documents_by_counterparty", "list_contracts_by_counterparty"],
unsupported_operations: ["edit_counterparty_card"],
required_entities: ["counterparty_scope_or_contract"],
optional_entities: ["period", "organization"],
typical_queries: ["Покажи документы по контрагенту"],
typical_queries: ["Покажи документы по контрагенту", "Какие операции были по банку с контрагентом?"],
related_routes: [],
safe_alternatives: ["Уточнить ИНН/наименование контрагента"],
safe_alternatives: ["Уточнить ИНН или наименование контрагента"],
one_c_hints: ["Справочник.Контрагенты"]
},
{
group_code: "settlements",
group_title: "Долги и расчёты",
description: "Сальдо, хвосты, незакрытые авансы и аналитика по расчётам",
risk_level: "high",
maturity_status: "production_ready",
supported_operations: ["receivables_confirmed_as_of_date", "open_items_by_counterparty_or_contract"],
unsupported_operations: ["close_period"],
required_entities: ["period_or_date"],
optional_entities: ["organization", "account", "counterparty"],
typical_queries: ["Кто нам должен на дату?", "Хвосты покажи по счёту 60 за период"],
related_routes: [],
safe_alternatives: ["Уточнить период, счёт или организацию"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
},
{
group_code: "cash",
group_title: "Деньги",
description: "Остатки и движение по денежным счетам и кассе",
risk_level: "medium",
maturity_status: "production_ready",
supported_operations: ["account_balance_snapshot", "bank_operations_by_counterparty"],
unsupported_operations: ["post_bank_statement"],
required_entities: ["date_or_period"],
optional_entities: ["organization", "account", "counterparty"],
typical_queries: ["Какой остаток по счёту 51 на дату?", "Покажи движение денег за месяц"],
related_routes: [],
safe_alternatives: ["Уточнить счёт или период"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
},
{
group_code: "inventory",
group_title: "Склад и товары",
description: "Подтверждённые остатки, происхождение и документы по товарным позициям",
risk_level: "medium",
maturity_status: "production_ready",
supported_operations: [
"inventory_on_hand_as_of_date",
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item"
],
unsupported_operations: ["write_off_inventory"],
required_entities: ["date_or_period"],
optional_entities: ["organization", "warehouse", "item"],
typical_queries: ["Какие товары сейчас лежат на складе?", "Кто поставил эту позицию?"],
related_routes: [],
safe_alternatives: ["Уточнить организацию, дату или позицию"],
one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"]
},
{
group_code: "boundaries",
group_title: "Ограничения",
description: "Операции, которые ассистент не выполняет.",
description: "Операции, которые ассистент не выполняет в этом рантайме",
risk_level: "high",
maturity_status: "production_ready",
supported_operations: ["explain_boundary", "suggest_safe_next_step"],
@ -73,7 +122,7 @@ const FALLBACK_REGISTRY: CapabilityRegistry = {
optional_entities: [],
typical_queries: ["Можешь настроить 1С?"],
related_routes: [],
safe_alternatives: ["Сформировать план диагностики для 1С/ИТ-админа"],
safe_alternatives: ["Сформировать безопасный план диагностики для 1С или ИТ-админа"],
one_c_hints: []
}
]
@ -171,17 +220,21 @@ export function loadCapabilitiesRegistry(): CapabilityRegistry {
export function buildCapabilityContractReplyFromRegistry(): string {
const registry = loadCapabilitiesRegistry();
const topGroups = registry.groups.slice(0, 6);
const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6);
const groupLines = topGroups.map((group, index) => {
const ops = group.supported_operations.slice(0, 3).join(", ");
return `${index + 1}. ${group.group_title}: ${group.description}${ops ? ` (например: ${ops})` : ""}.`;
const examples = group.typical_queries
.slice(0, 2)
.map((query) => query.trim())
.filter((query) => query.length > 0)
.join("; ");
return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`;
});
return [
"Я ассистент по анализу данных 1С в режиме чтения.",
"Что умею по группам:",
"Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.",
"По основным группам:",
...groupLines,
"Если хотите, раскрою любую группу точечно и дам готовую формулировку запроса.",
"Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.",
"Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере."
].join("\n");
}

View File

@ -52,4 +52,20 @@ describe("address counterparty utf8 regression", () => {
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("classifies direct company activity-age wording with a colloquial organization anchor", () => {
const result = resolveCounterpartyAddressIntent(
"а по Альтернативе Плюс сколько лет активности в базе 1С?",
utf8Deps
);
expect(result?.intent).toBe("counterparty_activity_lifecycle");
expect(result?.reasons).toContain("counterparty_activity_lifecycle_signal_detected");
});
it("keeps the main resolver in the supported contour for direct company activity-age wording", () => {
const result = resolveAddressIntent("а по Альтернативе Плюс сколько лет активности в базе 1С?");
expect(result.intent).toBe("counterparty_activity_lifecycle");
});
});

View File

@ -205,4 +205,37 @@ describe("inventory root frame regressions", () => {
expect(result?.filters.extracted_filters.period_to).toBe("2019-07-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-07-31");
});
it("keeps the freshest previous date when inventory root restore follows a receivables step", () => {
const result = runAddressDecomposeStage("остатки по складу на эту же дату", {
previous_intent: "receivables_confirmed_as_of_date",
target_intent: "inventory_on_hand_as_of_date",
previous_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"',
root_intent: "inventory_on_hand_as_of_date",
root_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31"
},
root_anchor_type: "organization",
root_anchor_value: 'ООО "Альтернатива Плюс"',
root_context_only: true,
current_frame_kind: "inventory_root"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.intent.reasons).toContain("intent_restored_to_inventory_root_frame");
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
});
});

View File

@ -3746,6 +3746,29 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.mcp_call_status).not.toBe("skipped");
});
it("keeps colloquial follow-up activity-age wording in the lifecycle aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("а по Альтернативе Плюс сколько лет активности в базе 1С?", {
activeOrganization: 'ООО "Альтернатива Плюс"'
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
});
it("keeps colloquial follow-up activity-age wording grounded to the selected organization", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("а по Альтернативе Плюс сколько лет активности в базе 1С?", {
activeOrganization: 'ООО "Альтернатива Плюс"'
});
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1");
expect(result?.debug.extracted_filters.counterparty).toBeUndefined();
expect(result?.debug.mcp_call_status).not.toBe("materialized_but_not_anchor_matched");
});
it("routes debt-longevity wording into receivables lane with factual reply", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(

View File

@ -8,7 +8,10 @@ function createPolicy() {
if (value === null || value === undefined) {
return null;
}
const text = String(value ?? "").trim();
const text = String(value ?? "")
.replace(/\\/g, "")
.replace(/([А-Яа-яA-Za-z])"([А-Яа-яA-Za-z])/gu, "$1в$2")
.trim();
return text.length > 0 ? text : null;
},
toNonEmptyString: (value: unknown) => {
@ -38,6 +41,21 @@ describe("assistantBoundaryPolicy", () => {
expect(reply.toLowerCase()).not.toContain("read-only");
});
it("normalizes noisy organization labels in data-scope reply", () => {
const policy = createPolicy();
const reply = policy.buildAssistantDataScopeContractReply({
status: "resolved",
channel: "default",
organizations: ['ООО \\Альтернати"а Плюс\\', 'ООО \\Лайс"уд\\']
});
expect(reply).toContain('ООО Альтернатива Плюс');
expect(reply).toContain('ООО Лайсвуд');
expect(reply).not.toContain('\\"');
expect(reply).not.toContain("\\");
});
it("strips unexpected CJK fragments from live chat reply", () => {
const policy = createPolicy();

View File

@ -409,8 +409,13 @@ 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()).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");
expect(String(response.assistant_reply)).not.toContain("suggest_safe_next_step");
expect(String(response.assistant_reply)).not.toContain("explain_boundary");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);

View File

@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
mergeKnownOrganizations,
normalizeOrganizationScopeSearchText,
normalizeOrganizationScopeValue,
organizationsLikelySameEntity,
resolveOrganizationSelectionFromMessage,
scoreOrganizationMentionInMessage
} from "../src/services/assistantOrganizationMatcher";
@ -17,8 +19,13 @@ describe("assistant organization matcher", () => {
).toEqual(['ООО "Альтернатива Плюс"', "ООО Лайсвуд"]);
});
it("repairs noisy display labels before exposing them to the user", () => {
expect(normalizeOrganizationScopeValue('ООО \\Альтернати"а Плюс\\')).toBe("ООО Альтернатива Плюс");
expect(normalizeOrganizationScopeValue('ООО \\Лайс"уд\\')).toBe("ООО Лайсвуд");
});
it("matches incomplete or reordered organization mention against live candidates", () => {
const resolved = resolveOrganizationSelectionFromMessage("дай что сегодня на складе в конторе ссыт кот", [
const resolved = resolveOrganizationSelectionFromMessage("дай что сегодня на складе в конторе кот ссыт", [
"ООО КОТ ССЫТ ВО ДВОРЕ",
"ООО Альтернатива Плюс"
]);
@ -34,4 +41,12 @@ describe("assistant organization matcher", () => {
expect(score).toBeGreaterThanOrEqual(90);
});
it("treats minor live label corruption as the same organization entity", () => {
expect(organizationsLikelySameEntity("Альтернатива Плюс", 'ООО "Альтернати"а Плюс"')).toBe(true);
});
it("does not merge different organizations with only one shared token", () => {
expect(organizationsLikelySameEntity('ООО "Альтернатива Плюс"', 'ООО "Альтернатива Минус"')).toBe(false);
});
});

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createAssistantTransitionPolicy } from "../src/services/assistantTransitionPolicy";
import { buildRootScopedCarryoverFiltersForTests } from "../src/services/assistantService";
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
@ -76,8 +77,20 @@ function buildPolicy(overrides: Record<string, unknown> = {}) {
isInventoryRootFrameIntent: (intent: unknown) => String(intent ?? "") === "inventory_on_hand_as_of_date",
findRecentAddressFilterValue: () => null,
hasForeignAccountingPivotOverInventoryMessage: () => false,
buildRootScopedCarryoverFilters: (_previousFilters: Record<string, unknown>, inventoryRootFrame: Record<string, unknown>) => ({
...(inventoryRootFrame?.filters ?? {})
buildRootScopedCarryoverFilters: (
previousFilters: Record<string, unknown>,
inventoryRootFrame: Record<string, unknown>
) => ({
organization:
toNonEmptyString(inventoryRootFrame?.filters?.organization) ?? toNonEmptyString(previousFilters?.organization),
warehouse:
toNonEmptyString(inventoryRootFrame?.filters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse),
as_of_date:
toNonEmptyString(previousFilters?.as_of_date) ?? toNonEmptyString(inventoryRootFrame?.filters?.as_of_date),
period_from:
toNonEmptyString(previousFilters?.period_from) ?? toNonEmptyString(inventoryRootFrame?.filters?.period_from),
period_to:
toNonEmptyString(previousFilters?.period_to) ?? toNonEmptyString(inventoryRootFrame?.filters?.period_to)
}),
inferDisplayedEntityTypeFromIntent: () => "item",
extractDisplayedAddressEntityCandidates: () => [],
@ -109,7 +122,7 @@ describe("assistantTransitionPolicy", () => {
expect(carryover?.followupContext?.root_context_only).toBe(true);
expect(carryover?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date");
expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(carryover?.followupContext?.previous_filters).toEqual({
expect(carryover?.followupContext?.previous_filters).toMatchObject({
as_of_date: "2020-03-31",
organization: 'ООО "Альтернатива Плюс"'
});
@ -131,7 +144,7 @@ describe("assistantTransitionPolicy", () => {
expect(carryover?.followupSelectionMode).toBe("carry_root_context");
expect(carryover?.followupContext?.root_context_only).toBe(true);
expect(carryover?.followupContext?.previous_intent).toBeUndefined();
expect(carryover?.followupContext?.previous_filters).toEqual({
expect(carryover?.followupContext?.previous_filters).toMatchObject({
as_of_date: "2020-03-31",
organization: 'ООО "Альтернатива Плюс"'
});
@ -169,6 +182,7 @@ describe("assistantTransitionPolicy", () => {
expect(contract.anchor_type).toBe("item");
expect(contract.anchor_value).toBe("Рабочая станция");
});
it("prefers carryover target intent over llm contract drift in continuation contract", () => {
const policy = buildPolicy();
@ -257,6 +271,7 @@ describe("assistantTransitionPolicy", () => {
expect(carryover?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(carryover?.followupSelectionMode).toBe("carry_previous_intent");
});
it("keeps root-scoped carryover for foreign accounting pivot over inventory drilldown", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
@ -308,4 +323,32 @@ describe("assistantTransitionPolicy", () => {
period_to: "2021-03-31"
});
});
it("prefers the freshest previous date scope over a stale inventory root frame during same-date pivot", () => {
const filters = buildRootScopedCarryoverFiltersForTests(
{
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31"
},
{
filters: {
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31"
}
}
);
expect(filters).toEqual({
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2020-03-31",
period_from: "2020-03-01",
period_to: "2020-03-31"
});
});
});