АРЧ АП11 - Архитектура после регресса: Архитектура: централизовать organization authority в continuity policy для route, living-chat и data-scope

This commit is contained in:
dctouch 2026-04-18 16:53:04 +03:00
parent 53e9151d5a
commit 9710d81676
16 changed files with 575 additions and 366 deletions

View File

@ -245,6 +245,12 @@ Latest continuity-authority convergence evidence after the current route pass:
- deterministic organization-fact boundary replies can now trigger from grounded continuity even when `sessionScope.selectedOrganization` and `sessionScope.activeOrganization` are both empty at runtime entry;
- the chat layer now records whether it entered with grounded continuity and which organization came from that continuity snapshot, making future saved-session review less blind;
- proactive organization offer logic is now explicitly blocked when grounded address continuity already exists, so the chat layer does not re-offer company selection on top of an already grounded business session;
- the next continuity-authority pass now centralizes `organization authority` in the shared continuity policy instead of letting route, living-chat, and data-scope reconstruct it independently:
- a single resolver now merges grounded address continuity, assistant-side living-chat scope memory, session scope, and clarification candidates into one authority object;
- route arbitration now consumes that shared authority for organization clarification continuation instead of rebuilding candidates and fallback selection locally;
- living-chat runtime now boots with the same authority object, so `knownOrganizations`, `selectedOrganization`, and `activeOrganization` no longer start from a different merge order than the route layer;
- data-scope active-organization recovery now also reads the same authority object, reducing one more history-scan seam that used to drift from route and chat;
- targeted tests now protect this precedence layer directly, including the case where assistant-side living-chat selection must outrank older grounded continuity without losing the grounded organization in the merged known set;
- the first human-answer-shaping cleanup pass is now applied to heavy profile/aggregate exact answers:
- `period_coverage_profile` and `document_type_and_account_section_profile` now start with a direct business-first lead (`Коротко: ...`) instead of service-flavored intros like `профиль собран` / `строк агрегата`;
- the top block now states the business conclusion first and leaves ranked detail blocks below, which reduces the catalog-like feel without hiding the actual data;

View File

@ -8,6 +8,8 @@ exports.readAddressDebugScopedDate = readAddressDebugScopedDate;
exports.buildInventoryRootFrameFromAddressDebug = buildInventoryRootFrameFromAddressDebug;
exports.isGroundedAddressDebug = isGroundedAddressDebug;
exports.resolveAssistantContinuitySnapshot = resolveAssistantContinuitySnapshot;
exports.resolveAssistantOrganizationAuthority = resolveAssistantOrganizationAuthority;
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
function fallbackToNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
@ -21,6 +23,12 @@ function toRecordObject(value) {
}
return value;
}
function mergeKnownOrganizationsDefault(values) {
return (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(values);
}
function normalizeOrganizationScopeDefault(value) {
return (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(value);
}
function formatIsoDateForReply(value) {
const source = String(value ?? "").trim();
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
@ -166,3 +174,87 @@ function resolveAssistantContinuitySnapshot(input) {
hasGroundedInventoryContext: Boolean(lastGroundedInventoryAddressDebug)
};
}
function collectAssistantOrganizationSignals(sessionItems, normalizeOrganizationScopeValue) {
let lastAssistantSelectedOrganization = null;
let lastAssistantActiveOrganization = null;
const knownOrganizationsFromAssistantHistory = [];
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
const item = sessionItems[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
const debug = item.debug;
if (!lastAssistantActiveOrganization) {
lastAssistantActiveOrganization = normalizeOrganizationScopeValue(debug.assistant_active_organization);
}
if (!lastAssistantSelectedOrganization) {
lastAssistantSelectedOrganization = normalizeOrganizationScopeValue(debug.living_chat_selected_organization);
}
const directKnownOrganizations = Array.isArray(debug.assistant_known_organizations)
? debug.assistant_known_organizations
: [];
const probeOrganizations = Array.isArray(debug.living_chat_data_scope_probe_organizations)
? debug.living_chat_data_scope_probe_organizations
: [];
const organizationCandidates = Array.isArray(debug.organization_candidates) ? debug.organization_candidates : [];
const directResolvedOrganizations = [
debug.assistant_active_organization,
debug.living_chat_selected_organization,
readAddressDebugOrganization(debug, normalizeOrganizationScopeValue)
];
knownOrganizationsFromAssistantHistory.push(...directKnownOrganizations, ...probeOrganizations, ...organizationCandidates, ...directResolvedOrganizations);
}
return {
lastAssistantSelectedOrganization,
lastAssistantActiveOrganization,
knownOrganizationsFromAssistantHistory: mergeKnownOrganizationsDefault(knownOrganizationsFromAssistantHistory)
};
}
function resolveAssistantOrganizationAuthority(input) {
const normalizeOrganizationScopeValue = input.normalizeOrganizationScopeValue ?? normalizeOrganizationScopeDefault;
const mergeKnownOrganizations = input.mergeKnownOrganizations ?? mergeKnownOrganizationsDefault;
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
const continuitySnapshot = resolveAssistantContinuitySnapshot({
sessionItems,
toNonEmptyString: input.toNonEmptyString ?? normalizeOrganizationScopeValue
});
const assistantSignals = collectAssistantOrganizationSignals(sessionItems, normalizeOrganizationScopeValue);
const continuityActiveOrganization = normalizeOrganizationScopeValue(input.sessionActiveOrganization) ??
assistantSignals.lastAssistantActiveOrganization ??
normalizeOrganizationScopeValue(continuitySnapshot.activeOrganization);
const knownOrganizations = mergeKnownOrganizations([
...(Array.isArray(input.sessionKnownOrganizations) ? input.sessionKnownOrganizations : []),
...assistantSignals.knownOrganizationsFromAssistantHistory,
input.sessionSelectedOrganization,
input.sessionActiveOrganization,
continuityActiveOrganization
]);
const selectedOrganization = normalizeOrganizationScopeValue(input.sessionSelectedOrganization) ??
assistantSignals.lastAssistantSelectedOrganization;
const activeOrganization = normalizeOrganizationScopeValue(input.sessionActiveOrganization) ??
selectedOrganization ??
assistantSignals.lastAssistantActiveOrganization ??
continuityActiveOrganization ??
(knownOrganizations.length === 1 ? knownOrganizations[0] : null);
const organizationClarificationCandidates = Array.isArray(input.lastOrganizationClarificationDebug?.organization_candidates)
? mergeKnownOrganizations([
...input.lastOrganizationClarificationDebug.organization_candidates,
...knownOrganizations,
selectedOrganization,
activeOrganization,
continuityActiveOrganization
])
: [];
const organizationClarificationSelectionFromScope = selectedOrganization ?? activeOrganization;
return {
continuitySnapshot,
continuityActiveOrganization,
lastAssistantSelectedOrganization: assistantSignals.lastAssistantSelectedOrganization,
lastAssistantActiveOrganization: assistantSignals.lastAssistantActiveOrganization,
knownOrganizations,
selectedOrganization,
activeOrganization,
organizationClarificationCandidates,
organizationClarificationSelectionFromScope
};
}

View File

@ -219,36 +219,13 @@ function createAssistantDataScopePolicy(deps) {
return (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)(collected, 20);
}
function findLastAssistantActiveOrganization(items) {
const continuitySnapshot = (0, assistantContinuityPolicy_1.resolveAssistantContinuitySnapshot)({
const authority = (0, assistantContinuityPolicy_1.resolveAssistantOrganizationAuthority)({
sessionItems: items,
toNonEmptyString: assistantOrganizationMatcher_1.normalizeOrganizationScopeValue
toNonEmptyString: assistantOrganizationMatcher_1.normalizeOrganizationScopeValue,
normalizeOrganizationScopeValue: assistantOrganizationMatcher_1.normalizeOrganizationScopeValue,
mergeKnownOrganizations: assistantOrganizationMatcher_1.mergeKnownOrganizations
});
const continuityOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(continuitySnapshot.activeOrganization);
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
const item = Array.isArray(items) ? items[index] : null;
if (!item || typeof item !== "object" || item.role !== "assistant") {
continue;
}
const debug = item.debug;
if (!debug || typeof debug !== "object") {
continue;
}
const direct = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(debug.assistant_active_organization);
if (direct) {
return direct;
}
const selected = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(debug.living_chat_selected_organization);
if (selected) {
return selected;
}
if ((0, assistantContinuityPolicy_1.isGroundedAddressDebug)(debug)) {
const groundedOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)((0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug));
if (groundedOrganization) {
return groundedOrganization;
}
}
}
return continuityOrganization;
return (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(authority.activeOrganization);
}
function extractOrganizationFactsFromRows(rows) {
const names = [];

View File

@ -152,10 +152,18 @@ function buildAddressMemoryRecapReply(input) {
}
async function runAssistantLivingChatRuntime(input) {
const userMessage = String(input.userMessage ?? "");
const continuitySnapshot = (0, assistantContinuityPolicy_1.resolveAssistantContinuitySnapshot)({
const organizationAuthority = (0, assistantContinuityPolicy_1.resolveAssistantOrganizationAuthority)({
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
sessionKnownOrganizations: Array.isArray(input.sessionScope.knownOrganizations)
? input.sessionScope.knownOrganizations
: [],
sessionSelectedOrganization: input.sessionScope.selectedOrganization,
sessionActiveOrganization: input.sessionScope.activeOrganization,
toNonEmptyString: input.toNonEmptyString,
normalizeOrganizationScopeValue: input.toNonEmptyString,
mergeKnownOrganizations: input.mergeKnownOrganizations
});
const continuitySnapshot = organizationAuthority.continuitySnapshot;
const dataScopeMetaQuery = input.hasAssistantDataScopeMetaQuestionSignal(userMessage);
const capabilityMetaQuery = input.shouldHandleAsAssistantCapabilityMetaQuery(userMessage);
const destructiveSignal = input.hasDestructiveDataActionSignal(userMessage);
@ -169,13 +177,10 @@ async function runAssistantLivingChatRuntime(input) {
let livingChatGroundingGuardApplied = false;
let livingChatGroundingGuardReason = null;
let livingChatProactiveScopeOfferApplied = false;
const continuityActiveOrganization = input.toNonEmptyString(continuitySnapshot.activeOrganization);
let knownOrganizations = input.mergeKnownOrganizations([
...(Array.isArray(input.sessionScope.knownOrganizations) ? input.sessionScope.knownOrganizations : []),
continuityActiveOrganization
]);
let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization);
let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization) ?? continuityActiveOrganization;
const continuityActiveOrganization = organizationAuthority.continuityActiveOrganization;
let knownOrganizations = [...organizationAuthority.knownOrganizations];
let selectedOrganization = organizationAuthority.selectedOrganization;
let activeOrganization = organizationAuthority.activeOrganization;
const memoryRecapContext = (0, assistantMemoryRecapPolicy_1.resolveAssistantLivingChatMemoryContext)({
modeDecisionReason: input.modeDecision?.reason ?? null,
sessionItems: input.sessionItems

View File

@ -72,26 +72,25 @@ function createAssistantRoutePolicy(deps) {
const sessionOrganizationScope = input?.sessionOrganizationScope && typeof input.sessionOrganizationScope === "object"
? input.sessionOrganizationScope
: null;
const continuitySnapshot = (0, assistantContinuityPolicy_1.resolveAssistantContinuitySnapshot)({
const organizationAuthority = (0, assistantContinuityPolicy_1.resolveAssistantOrganizationAuthority)({
sessionItems,
toNonEmptyString
sessionKnownOrganizations: Array.isArray(sessionOrganizationScope?.knownOrganizations)
? sessionOrganizationScope.knownOrganizations
: [],
sessionSelectedOrganization: sessionOrganizationScope?.selectedOrganization,
sessionActiveOrganization: sessionOrganizationScope?.activeOrganization,
lastOrganizationClarificationDebug: findLastOrganizationClarificationAddressDebug(sessionItems),
toNonEmptyString,
normalizeOrganizationScopeValue,
mergeKnownOrganizations
});
const continuityActiveOrganization = normalizeOrganizationScopeValue(sessionOrganizationScope?.activeOrganization) ??
normalizeOrganizationScopeValue(continuitySnapshot.activeOrganization);
const continuitySnapshot = organizationAuthority.continuitySnapshot;
const continuityActiveOrganization = organizationAuthority.continuityActiveOrganization;
const lastGroundedAddressDebug = findLastGroundedAddressAnswerDebug(sessionItems) ??
continuitySnapshot.lastGroundedAddressDebug;
const lastOrganizationClarificationDebug = findLastOrganizationClarificationAddressDebug(sessionItems);
const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates)
? mergeKnownOrganizations([
...lastOrganizationClarificationDebug.organization_candidates,
...((Array.isArray(sessionOrganizationScope?.knownOrganizations)
? sessionOrganizationScope.knownOrganizations
: [])),
continuityActiveOrganization
])
: [];
const organizationClarificationSelectionFromScope = normalizeOrganizationScopeValue(sessionOrganizationScope?.selectedOrganization) ??
continuityActiveOrganization;
const organizationClarificationCandidates = organizationAuthority.organizationClarificationCandidates;
const organizationClarificationSelectionFromScope = organizationAuthority.organizationClarificationSelectionFromScope;
const organizationClarificationSelection = resolveOrganizationSelectionFromMessage(rawUserMessage, organizationClarificationCandidates) ??
resolveOrganizationSelectionFromMessage(repairedRawUserMessage, organizationClarificationCandidates) ??
resolveOrganizationSelectionFromMessage(effectiveAddressUserMessage, organizationClarificationCandidates) ??

View File

@ -1,3 +1,8 @@
import {
mergeKnownOrganizations as mergeKnownOrganizationsFromMatcher,
normalizeOrganizationScopeValue as normalizeOrganizationScopeValueFromMatcher
} from "./assistantOrganizationMatcher";
export interface AssistantContinuitySnapshotInput {
sessionItems?: unknown[];
toNonEmptyString?: (value: unknown) => string | null;
@ -22,6 +27,29 @@ export interface AssistantContinuitySnapshot {
hasGroundedInventoryContext: boolean;
}
export interface AssistantOrganizationAuthorityInput {
sessionItems?: unknown[];
sessionKnownOrganizations?: unknown[];
sessionSelectedOrganization?: unknown;
sessionActiveOrganization?: unknown;
lastOrganizationClarificationDebug?: Record<string, unknown> | null;
toNonEmptyString?: (value: unknown) => string | null;
normalizeOrganizationScopeValue?: (value: unknown) => string | null;
mergeKnownOrganizations?: (values: unknown[]) => string[];
}
export interface AssistantOrganizationAuthority {
continuitySnapshot: AssistantContinuitySnapshot;
continuityActiveOrganization: string | null;
lastAssistantSelectedOrganization: string | null;
lastAssistantActiveOrganization: string | null;
knownOrganizations: string[];
selectedOrganization: string | null;
activeOrganization: string | null;
organizationClarificationCandidates: string[];
organizationClarificationSelectionFromScope: string | null;
}
function fallbackToNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
@ -37,6 +65,14 @@ function toRecordObject(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function mergeKnownOrganizationsDefault(values: unknown[]): string[] {
return mergeKnownOrganizationsFromMatcher(values);
}
function normalizeOrganizationScopeDefault(value: unknown): string | null {
return normalizeOrganizationScopeValueFromMatcher(value);
}
export function formatIsoDateForReply(value: unknown): string | null {
const source = String(value ?? "").trim();
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/);
@ -228,3 +264,113 @@ export function resolveAssistantContinuitySnapshot(
hasGroundedInventoryContext: Boolean(lastGroundedInventoryAddressDebug)
};
}
function collectAssistantOrganizationSignals(
sessionItems: unknown[],
normalizeOrganizationScopeValue: (value: unknown) => string | null
): {
lastAssistantSelectedOrganization: string | null;
lastAssistantActiveOrganization: string | null;
knownOrganizationsFromAssistantHistory: string[];
} {
let lastAssistantSelectedOrganization: string | null = null;
let lastAssistantActiveOrganization: string | null = null;
const knownOrganizationsFromAssistantHistory: unknown[] = [];
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
const item = sessionItems[index] as { role?: string; debug?: Record<string, unknown> } | null;
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
const debug = item.debug;
if (!lastAssistantActiveOrganization) {
lastAssistantActiveOrganization = normalizeOrganizationScopeValue(debug.assistant_active_organization);
}
if (!lastAssistantSelectedOrganization) {
lastAssistantSelectedOrganization = normalizeOrganizationScopeValue(debug.living_chat_selected_organization);
}
const directKnownOrganizations = Array.isArray(debug.assistant_known_organizations)
? debug.assistant_known_organizations
: [];
const probeOrganizations = Array.isArray(debug.living_chat_data_scope_probe_organizations)
? debug.living_chat_data_scope_probe_organizations
: [];
const organizationCandidates = Array.isArray(debug.organization_candidates) ? debug.organization_candidates : [];
const directResolvedOrganizations = [
debug.assistant_active_organization,
debug.living_chat_selected_organization,
readAddressDebugOrganization(debug, normalizeOrganizationScopeValue)
];
knownOrganizationsFromAssistantHistory.push(
...directKnownOrganizations,
...probeOrganizations,
...organizationCandidates,
...directResolvedOrganizations
);
}
return {
lastAssistantSelectedOrganization,
lastAssistantActiveOrganization,
knownOrganizationsFromAssistantHistory: mergeKnownOrganizationsDefault(knownOrganizationsFromAssistantHistory)
};
}
export function resolveAssistantOrganizationAuthority(
input: AssistantOrganizationAuthorityInput
): AssistantOrganizationAuthority {
const normalizeOrganizationScopeValue =
input.normalizeOrganizationScopeValue ?? normalizeOrganizationScopeDefault;
const mergeKnownOrganizations = input.mergeKnownOrganizations ?? mergeKnownOrganizationsDefault;
const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : [];
const continuitySnapshot = resolveAssistantContinuitySnapshot({
sessionItems,
toNonEmptyString: input.toNonEmptyString ?? normalizeOrganizationScopeValue
});
const assistantSignals = collectAssistantOrganizationSignals(sessionItems, normalizeOrganizationScopeValue);
const continuityActiveOrganization =
normalizeOrganizationScopeValue(input.sessionActiveOrganization) ??
assistantSignals.lastAssistantActiveOrganization ??
normalizeOrganizationScopeValue(continuitySnapshot.activeOrganization);
const knownOrganizations = mergeKnownOrganizations([
...(Array.isArray(input.sessionKnownOrganizations) ? input.sessionKnownOrganizations : []),
...assistantSignals.knownOrganizationsFromAssistantHistory,
input.sessionSelectedOrganization,
input.sessionActiveOrganization,
continuityActiveOrganization
]);
const selectedOrganization =
normalizeOrganizationScopeValue(input.sessionSelectedOrganization) ??
assistantSignals.lastAssistantSelectedOrganization;
const activeOrganization =
normalizeOrganizationScopeValue(input.sessionActiveOrganization) ??
selectedOrganization ??
assistantSignals.lastAssistantActiveOrganization ??
continuityActiveOrganization ??
(knownOrganizations.length === 1 ? knownOrganizations[0] : null);
const organizationClarificationCandidates = Array.isArray(input.lastOrganizationClarificationDebug?.organization_candidates)
? mergeKnownOrganizations([
...input.lastOrganizationClarificationDebug.organization_candidates,
...knownOrganizations,
selectedOrganization,
activeOrganization,
continuityActiveOrganization
])
: [];
const organizationClarificationSelectionFromScope = selectedOrganization ?? activeOrganization;
return {
continuitySnapshot,
continuityActiveOrganization,
lastAssistantSelectedOrganization: assistantSignals.lastAssistantSelectedOrganization,
lastAssistantActiveOrganization: assistantSignals.lastAssistantActiveOrganization,
knownOrganizations,
selectedOrganization,
activeOrganization,
organizationClarificationCandidates,
organizationClarificationSelectionFromScope
};
}

View File

@ -4,9 +4,7 @@ import {
normalizeOrganizationScopeValue
} from "./assistantOrganizationMatcher";
import {
resolveAssistantContinuitySnapshot,
isGroundedAddressDebug,
readAddressDebugOrganization
resolveAssistantOrganizationAuthority
} from "./assistantContinuityPolicy";
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
@ -297,40 +295,13 @@ export function createAssistantDataScopePolicy(deps: AssistantDataScopePolicyDep
}
function findLastAssistantActiveOrganization(items: unknown[]): string | null {
const continuitySnapshot = resolveAssistantContinuitySnapshot({
const authority = resolveAssistantOrganizationAuthority({
sessionItems: items,
toNonEmptyString: normalizeOrganizationScopeValue
toNonEmptyString: normalizeOrganizationScopeValue,
normalizeOrganizationScopeValue,
mergeKnownOrganizations
});
const continuityOrganization = normalizeOrganizationScopeValue(continuitySnapshot.activeOrganization);
for (let index = (Array.isArray(items) ? items.length : 0) - 1; index >= 0; index -= 1) {
const item = Array.isArray(items) ? items[index] : null;
if (!item || typeof item !== "object" || (item as { role?: string }).role !== "assistant") {
continue;
}
const debug = (item as { debug?: Record<string, unknown> }).debug;
if (!debug || typeof debug !== "object") {
continue;
}
const direct = normalizeOrganizationScopeValue(debug.assistant_active_organization);
if (direct) {
return direct;
}
const selected = normalizeOrganizationScopeValue(debug.living_chat_selected_organization);
if (selected) {
return selected;
}
if (isGroundedAddressDebug(debug)) {
const groundedOrganization = normalizeOrganizationScopeValue(readAddressDebugOrganization(debug));
if (groundedOrganization) {
return groundedOrganization;
}
}
}
return continuityOrganization;
return normalizeOrganizationScopeValue(authority.activeOrganization);
}
function extractOrganizationFactsFromRows(rows: unknown[]): AssistantOrganizationFacts {

View File

@ -3,7 +3,7 @@ import {
buildInventoryHistoryCapabilityFollowupReply as buildInventoryHistoryCapabilityFollowupReplyFromPolicy,
resolveAssistantLivingChatMemoryContext
} from "./assistantMemoryRecapPolicy";
import { resolveAssistantContinuitySnapshot } from "./assistantContinuityPolicy";
import { resolveAssistantOrganizationAuthority } from "./assistantContinuityPolicy";
export interface AssistantLivingChatSessionScopeInput {
knownOrganizations?: unknown[];
@ -249,10 +249,18 @@ export async function runAssistantLivingChatRuntime(
input: AssistantLivingChatRuntimeInput
): Promise<AssistantLivingChatRuntimeOutput> {
const userMessage = String(input.userMessage ?? "");
const continuitySnapshot = resolveAssistantContinuitySnapshot({
const organizationAuthority = resolveAssistantOrganizationAuthority({
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
sessionKnownOrganizations: Array.isArray(input.sessionScope.knownOrganizations)
? input.sessionScope.knownOrganizations
: [],
sessionSelectedOrganization: input.sessionScope.selectedOrganization,
sessionActiveOrganization: input.sessionScope.activeOrganization,
toNonEmptyString: input.toNonEmptyString,
normalizeOrganizationScopeValue: input.toNonEmptyString,
mergeKnownOrganizations: input.mergeKnownOrganizations
});
const continuitySnapshot = organizationAuthority.continuitySnapshot;
const dataScopeMetaQuery = input.hasAssistantDataScopeMetaQuestionSignal(userMessage);
const capabilityMetaQuery = input.shouldHandleAsAssistantCapabilityMetaQuery(userMessage);
const destructiveSignal = input.hasDestructiveDataActionSignal(userMessage);
@ -267,13 +275,10 @@ export async function runAssistantLivingChatRuntime(
let livingChatGroundingGuardApplied = false;
let livingChatGroundingGuardReason: string | null = null;
let livingChatProactiveScopeOfferApplied = false;
const continuityActiveOrganization = input.toNonEmptyString(continuitySnapshot.activeOrganization);
let knownOrganizations = input.mergeKnownOrganizations([
...(Array.isArray(input.sessionScope.knownOrganizations) ? input.sessionScope.knownOrganizations : []),
continuityActiveOrganization
]);
let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization);
let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization) ?? continuityActiveOrganization;
const continuityActiveOrganization = organizationAuthority.continuityActiveOrganization;
let knownOrganizations = [...organizationAuthority.knownOrganizations];
let selectedOrganization = organizationAuthority.selectedOrganization;
let activeOrganization = organizationAuthority.activeOrganization;
const memoryRecapContext = resolveAssistantLivingChatMemoryContext({
modeDecisionReason: input.modeDecision?.reason ?? null,
sessionItems: input.sessionItems

View File

@ -1,5 +1,7 @@
// @ts-nocheck
import { resolveAssistantContinuitySnapshot } from "./assistantContinuityPolicy";
import {
resolveAssistantOrganizationAuthority
} from "./assistantContinuityPolicy";
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"period_coverage_profile",
@ -109,26 +111,25 @@ export function createAssistantRoutePolicy(deps) {
const sessionOrganizationScope = input?.sessionOrganizationScope && typeof input.sessionOrganizationScope === "object"
? input.sessionOrganizationScope
: null;
const continuitySnapshot = resolveAssistantContinuitySnapshot({
const organizationAuthority = resolveAssistantOrganizationAuthority({
sessionItems,
toNonEmptyString
sessionKnownOrganizations: Array.isArray(sessionOrganizationScope?.knownOrganizations)
? sessionOrganizationScope.knownOrganizations
: [],
sessionSelectedOrganization: sessionOrganizationScope?.selectedOrganization,
sessionActiveOrganization: sessionOrganizationScope?.activeOrganization,
lastOrganizationClarificationDebug: findLastOrganizationClarificationAddressDebug(sessionItems),
toNonEmptyString,
normalizeOrganizationScopeValue,
mergeKnownOrganizations
});
const continuityActiveOrganization = normalizeOrganizationScopeValue(sessionOrganizationScope?.activeOrganization) ??
normalizeOrganizationScopeValue(continuitySnapshot.activeOrganization);
const continuitySnapshot = organizationAuthority.continuitySnapshot;
const continuityActiveOrganization = organizationAuthority.continuityActiveOrganization;
const lastGroundedAddressDebug = findLastGroundedAddressAnswerDebug(sessionItems) ??
continuitySnapshot.lastGroundedAddressDebug;
const lastOrganizationClarificationDebug = findLastOrganizationClarificationAddressDebug(sessionItems);
const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates)
? mergeKnownOrganizations([
...lastOrganizationClarificationDebug.organization_candidates,
...((Array.isArray(sessionOrganizationScope?.knownOrganizations)
? sessionOrganizationScope.knownOrganizations
: [])),
continuityActiveOrganization
])
: [];
const organizationClarificationSelectionFromScope = normalizeOrganizationScopeValue(sessionOrganizationScope?.selectedOrganization) ??
continuityActiveOrganization;
const organizationClarificationCandidates = organizationAuthority.organizationClarificationCandidates;
const organizationClarificationSelectionFromScope = organizationAuthority.organizationClarificationSelectionFromScope;
const organizationClarificationSelection = resolveOrganizationSelectionFromMessage(rawUserMessage, organizationClarificationCandidates) ??
resolveOrganizationSelectionFromMessage(repairedRawUserMessage, organizationClarificationCandidates) ??
resolveOrganizationSelectionFromMessage(effectiveAddressUserMessage, organizationClarificationCandidates) ??

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantOrganizationAuthority } from "../src/services/assistantContinuityPolicy";
describe("assistantContinuityPolicy organization authority", () => {
it("prefers explicit assistant organization authority over older grounded continuity and merges known organizations once", () => {
const authority = resolveAssistantOrganizationAuthority({
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: { status: "grounded" },
extracted_filters: {
organization: "Org Grounded",
as_of_date: "2020-03-31"
},
detected_intent: "receivables_confirmed_as_of_date"
}
},
{
role: "assistant",
debug: {
execution_lane: "living_chat",
living_chat_selected_organization: "Org Selected",
assistant_active_organization: "Org Selected",
assistant_known_organizations: ["Org Selected", "Org Known"],
living_chat_data_scope_probe_organizations: ["Org Probe"]
}
}
],
sessionKnownOrganizations: ["Org Session"],
lastOrganizationClarificationDebug: {
organization_candidates: ["Org Candidate", "Org Selected"]
}
});
expect(authority.continuitySnapshot.activeOrganization).toBe("Org Grounded");
expect(authority.continuityActiveOrganization).toBe("Org Selected");
expect(authority.selectedOrganization).toBe("Org Selected");
expect(authority.activeOrganization).toBe("Org Selected");
expect(authority.knownOrganizations).toEqual([
"Org Session",
"Org Selected",
"Org Known",
"Org Probe",
"Org Grounded"
]);
expect(authority.organizationClarificationCandidates).toEqual([
"Org Candidate",
"Org Selected",
"Org Session",
"Org Known",
"Org Probe",
"Org Grounded"
]);
expect(authority.organizationClarificationSelectionFromScope).toBe("Org Selected");
});
});

View File

@ -0,0 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantDataScopePolicy } from "../src/services/assistantDataScopePolicy";
function createPolicy() {
return createAssistantDataScopePolicy({
activeMcpChannel: "default",
mcpProxyUrl: "http://localhost",
executeAddressMcpQuery: vi.fn(async () => ({ rows: [] })),
repairAddressMojibake: (value: unknown) => String(value ?? "")
});
}
describe("assistantDataScopePolicy", () => {
it("recovers active organization from assistant-side living chat authority when grounded answer is absent", () => {
const policy = createPolicy();
const activeOrganization = policy.findLastAssistantActiveOrganization([
{
role: "assistant",
debug: {
execution_lane: "living_chat",
living_chat_selected_organization: "Org Selected",
assistant_active_organization: "Org Selected",
assistant_known_organizations: ["Org Selected", "Org Backup"]
}
}
]);
expect(activeOrganization).toBe("Org Selected");
});
});

View File

@ -916,5 +916,59 @@
"question_text": "а по Альтернативе Плюс сколько лет активности в базе 1С?",
"answer_text": "На данный момент не удается точно определить, сколько лет Альтернатива Плюс активна в базе 1С — данные по истории деятельности контрагента в текущем сценарии недоступны.\n\nПроверено: документы, платежи и договоры по ООО «Альтернатива Плюс» за последние периоды. Результатов, подтверждающих начало активности, не обнаружено.\n\nМогу уточнить остаток по счёту или проверить наличие договоров/платежей за конкретные годы — сообщите, какой период вас интересует."
}
},
{
"annotation_id": "ann-mo4e8t0p-3n95ceh",
"run_id": "assistant-stage1-9VDVeE165e",
"case_id": "SAVED-001",
"session_id": "assistant-stage1-9VDVeE165e-SAVED-001",
"message_index": 35,
"rating": 1,
"comment": "написано что самый дохожный год 2020 8 мультов а слоджом в сл сообщении за 17 год заработали 12 смультов - хуйцня",
"manual_case_decision": "needs_dialog_policy_fix",
"annotation_author": "manual_reviewer",
"resolved": true,
"resolved_at": "2026-04-18T13:52:00.664Z",
"resolved_by": "manual_reviewer",
"created_at": "2026-04-18T13:49:36.360Z",
"updated_at": "2026-04-18T13:52:00.664Z",
"context": {
"message_id": "msg-dVgccEymIx",
"trace_id": "address-7XZM6cSlG2",
"reply_type": "factual",
"eval_target": "assistant_stage1",
"prompt_version": "address_query_runtime_v1",
"domain": null,
"query_class": null,
"question_text": "какой у нас самый доходный год",
"answer_text": "Самый доходный год по подтвержденным поступлениям: 2021 (8.560.025,00 ₽ по 13 операциям). Это денежный поток, а не чистая прибыль.\nТоп-3 лет по сумме поступлений:\nКоротко: в выборке 5 заказчиков по 20 подтвержденным платежным строкам.\nПодтвержденный денежный поток в выборке: 18.269.640,00 ₽.\n1. 2021 | сумма: 8.560.025,00 ₽ | операций: 13 | контрагентов: 1 | максимальная разовая сумма: 1.882.400,00 ₽\n2. 2022 | сумма: 6.044.715,00 ₽ | операций: 4 | контрагентов: 4 | максимальная разовая сумма: 2.254.115,00 ₽\n3. 2020 | сумма: 3.664.900,00 ₽ | операций: 3 | контрагентов: 1 | максимальная разовая сумма: 3.248.300,00 ₽"
}
},
{
"annotation_id": "ann-mo4ebs7r-4qjefdf",
"run_id": "assistant-stage1-9VDVeE165e",
"case_id": "SAVED-001",
"session_id": "assistant-stage1-9VDVeE165e-SAVED-001",
"message_index": 39,
"rating": 1,
"comment": "не сходиться - там вы райноне 30 млн за все время - надо проверить маршрут",
"manual_case_decision": "needs_routing_extension",
"annotation_author": "manual_reviewer",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"created_at": "2026-04-18T13:51:55.286Z",
"updated_at": "2026-04-18T13:51:55.286Z",
"context": {
"message_id": "msg-SAw2wG77rB",
"trace_id": "address-QszgwNAdwL",
"reply_type": "factual",
"eval_target": "assistant_stage1",
"prompt_version": "address_query_runtime_v1",
"domain": null,
"query_class": null,
"question_text": "сколько вообще денег мы заработали за все время?",
"answer_text": "За все доступное время подтверждено 18.269.640,00 ₽ входящих поступлений. Это денежный поток от клиентов, а не чистая прибыль.\n\nПодтверждение:\n- Операций в выборке: 20.\n- Контрагентов в выборке: 5.\n- Самый сильный год по поступлениям: 2021 (8.560.025,00 ₽).\n- Крупнейший контрагент по потоку: Группа СВК (12.224.925,00 ₽)."
}
}
]

File diff suppressed because one or more lines are too long

View File

@ -1,215 +0,0 @@
{
"saved_at": "2026-04-16T17:51:50.474Z",
"generation_id": "gen-mo1s0m9z-ndf56a3",
"mode": "saved_user_sessions",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"source_session_id": "asst-iIpNheKZpP",
"questions": [
"привет как дела"
],
"session": {
"session_id": "asst-iIpNheKZpP",
"updated_at": "2026-04-16T17:51:30.711Z",
"items": [
{
"message_id": "msg-R2if3yso9x",
"session_id": "asst-iIpNheKZpP",
"role": "user",
"text": "привет как дела",
"reply_type": null,
"created_at": "2026-04-16T17:51:19.402Z",
"trace_id": null,
"debug": null
},
{
"message_id": "msg-tRiU0RadHy",
"session_id": "asst-iIpNheKZpP",
"role": "assistant",
"text": "Привет! У меня всё хорошо, спасибо за вопрос. А у тебя как дела? 😊",
"reply_type": "factual_with_explanation",
"created_at": "2026-04-16T17:51:30.711Z",
"trace_id": "chat-2RCiQ9Whq9",
"debug": {
"trace_id": "chat-2RCiQ9Whq9",
"prompt_version": "living_chat_router_v1",
"schema_version": "living_chat_router_v1",
"fallback_type": "none",
"detected_mode": "chat",
"detected_mode_confidence": "high",
"execution_lane": "living_chat",
"living_router_mode": "chat",
"living_router_reason": "non_domain_query_indexed",
"living_chat_response_source": "llm_chat",
"living_chat_script_guard_applied": false,
"living_chat_script_guard_reason": null,
"living_chat_grounding_guard_applied": false,
"living_chat_grounding_guard_reason": null,
"living_chat_data_scope_probe_status": null,
"living_chat_data_scope_probe_channel": null,
"living_chat_data_scope_probe_org_count": 0,
"living_chat_data_scope_probe_organizations": [],
"living_chat_data_scope_probe_error": null,
"living_chat_selected_organization": null,
"assistant_known_organizations": [],
"assistant_active_organization": null,
"address_llm_predecompose_attempted": true,
"address_llm_predecompose_applied": false,
"address_llm_predecompose_reason": "no_usable_fragment",
"address_llm_predecompose_contract": {
"schema_version": "address_llm_predecompose_contract_v1",
"source_message": "привет как дела",
"canonical_message": "привет как дела",
"mode": "unsupported",
"mode_confidence": "low",
"query_shape": "UNKNOWN",
"query_shape_confidence": "low",
"intent": "unknown",
"intent_confidence": "low",
"entities": {
"account": null,
"counterparty": null,
"contract": null,
"document_type": null,
"document_ref": null,
"organization": null
},
"period": {
"scope": "unspecified",
"period_from": null,
"period_to": null,
"as_of_date": null,
"has_explicit_period": false
},
"semantics": {
"scope_kind": "none",
"anchor_kind": "none",
"anchor_value": null,
"date_scope_kind": "none",
"date_basis_hint": null,
"self_scope_detected": false,
"selected_object_scope_detected": false
},
"aggregation_profile": "unknown"
},
"address_semantic_extraction_contract": {
"schema_version": "address_semantic_extraction_contract_v1",
"source_message": "привет как дела",
"canonical_message": "привет как дела",
"canonical_rewrite_applied": false,
"extraction": {
"mode": "unsupported",
"mode_confidence": "low",
"query_shape": "UNKNOWN",
"query_shape_confidence": "low",
"intent": "unknown",
"intent_confidence": "low",
"aggregation_profile": "unknown"
},
"entities": {
"account": null,
"counterparty": null,
"contract": null,
"document_type": null,
"document_ref": null,
"organization": null
},
"period": {
"scope": "unspecified",
"period_from": null,
"period_to": null,
"as_of_date": null,
"has_explicit_period": false
},
"semantics": {
"scope_kind": "none",
"anchor_kind": "none",
"anchor_value": null,
"date_scope_kind": "none",
"date_basis_hint": null,
"self_scope_detected": false,
"selected_object_scope_detected": false
},
"guard_hints": {
"source_data_signal_detected": false,
"canonical_data_signal_detected": false,
"data_scope_meta_query_detected": false,
"deep_investigation_signal_detected": false,
"required_anchor_missing": false,
"unsupported_low_confidence": true,
"semantic_drift_suspected": false
},
"quality": "low",
"valid": false,
"apply_canonical_recommended": false,
"reason_codes": [
"unsupported_low_confidence_contract"
]
},
"orchestration_contract_v1": {
"schema_version": "assistant_orchestration_contract_v1",
"hard_meta_mode": "non_domain",
"address_mode": "unsupported",
"address_mode_confidence": "low",
"address_intent": "unknown",
"address_intent_confidence": "low",
"strong_data_signal_detected": false,
"data_retrieval_signal_detected": false,
"followup_context_detected": false,
"unsupported_address_intent_fallback_to_deep": false,
"final_decision": {
"run_address_lane": false,
"tool_gate_decision": "skip_address_lane",
"tool_gate_reason": "non_domain_query_indexed",
"living_mode": "chat",
"living_reason": "non_domain_query_indexed"
}
},
"tool_gate_decision": "skip_address_lane",
"tool_gate_reason": "non_domain_query_indexed",
"normalized": null,
"normalizer_output": null
}
}
],
"investigation_state": {
"schema_version": "investigation_state_v1",
"session_id": "asst-iIpNheKZpP",
"status": "idle",
"turn_index": 0,
"updated_at": "2026-04-16T17:51:19.401Z",
"question_id": null,
"question_scope_id": null,
"scope_origin": null,
"focus": {
"domain": null,
"period": null,
"primary_accounts": [],
"active_query_subject": null
},
"narrowing_status": "unknown",
"evidence_refs": [],
"open_uncertainties": [],
"last_answer_mode": null,
"followup_context": null,
"query_mode_hint": "direct_answer"
},
"address_navigation_state": {
"schema_version": "address_navigation_state_v1",
"session_id": "asst-iIpNheKZpP",
"updated_at": "2026-04-16T17:51:19.401Z",
"session_context": {
"active_result_set_id": null,
"active_focus_object": null,
"last_confirmed_route": null,
"date_scope": {
"as_of_date": null,
"period_from": null,
"period_to": null
},
"organization_scope": null
},
"result_sets": [],
"navigation_history": []
}
}
}

View File

@ -0,0 +1,114 @@
{
"suite_id": "assistant_saved_session_runtime_job-DpGiicqgIN",
"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"
},
{
"user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?"
}
]
}
]
}

View File

@ -1,10 +1,7 @@
{
"suite_id": "assistant_saved_session_gen-mo1s0m9z-ndf56a3",
"suite_id": "assistant_saved_session_runtime_job-L2a8rqCJma",
"suite_version": "0.1.0",
"schema_version": "assistant_saved_session_suite_v0_1",
"generated_at": "2026-04-16T17:51:50.471Z",
"generation_id": "gen-mo1s0m9z-ndf56a3",
"mode": "saved_user_sessions",
"schema_version": "assistant_saved_session_runtime_v0_1",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"scenario_count": 1,
"case_ids": [
@ -13,7 +10,7 @@
"cases": [
{
"case_id": "SAVED-001",
"scenario_tag": "saved_user_sessions",
"scenario_tag": "saved_user_sessions_runtime",
"title": "Ручная сессия 16.04.2026, 20:51:30",
"question_type": "direct",
"broadness_level": "medium",