Semantic Gate: закрепить бизнес-аудит и executive summary

This commit is contained in:
2026-05-05 18:21:05 +03:00
parent 98afdd39c4
commit 9be3fb29b7
17 changed files with 604 additions and 54 deletions
@@ -14,6 +14,14 @@ function hasPriorAssistantTurn(items) {
function buildDeterministicSmalltalkLeadReply() {
return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e.";
}
function hasConversationExecutiveSummarySignal(value) {
const normalized = String(value ?? "")
.toLowerCase()
.replace(/\u0451/gu, "\u0435")
.replace(/\s+/g, " ")
.trim();
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(normalized);
}
function asRecord(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
@@ -169,15 +177,25 @@ async function runAssistantLivingChatRuntime(input) {
livingChatSource = "deterministic_inventory_history_capability_contract";
}
else if (contextualMemoryRecapFollowup) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({
organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
const scopedOrganization = selectedOrganization ?? activeOrganization ?? continuityActiveOrganization ?? null;
const executiveSummaryFollowup = hasConversationExecutiveSummarySignal(userMessage);
chatText = executiveSummaryFollowup
? (0, assistantMemoryRecapPolicy_1.buildConversationExecutiveSummaryReply)({
organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
})
: (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({
organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_memory_recap_contract";
livingChatSource = executiveSummaryFollowup
? "deterministic_conversation_executive_summary_contract"
: "deterministic_memory_recap_contract";
}
else if (contextualAnswerInspectionFollowup) {
chatText = (0, assistantMemoryRecapPolicy_1.buildSelectedObjectAnswerInspectionReply)({
@@ -119,6 +119,9 @@ function createAssistantLivingModePolicy(deps) {
}
return true;
}
function hasConversationExecutiveSummarySignal(sample) {
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample);
}
function hasConversationMemoryRecallFollowupSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
@@ -130,7 +133,8 @@ function createAssistantLivingModePolicy(deps) {
}
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample));
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample));
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample) ||
hasConversationExecutiveSummarySignal(sample));
if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) {
return false;
}
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.buildInventoryHistoryCapabilityFollowupReply = buildInventoryHistoryCapabilityFollowupReply;
exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply;
exports.buildBroadBusinessEvaluationReply = buildBroadBusinessEvaluationReply;
exports.buildConversationExecutiveSummaryReply = buildConversationExecutiveSummaryReply;
exports.buildSelectedObjectAnswerInspectionReply = buildSelectedObjectAnswerInspectionReply;
exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext;
exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy;
@@ -168,7 +169,7 @@ function hasSignalAcrossSamples(samples, detector) {
return samples.some((sample) => detector(sample));
}
function hasExplicitRecapPromptSignal(samples) {
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(sample));
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample));
}
function buildInventoryHistoryCapabilityFollowupReply(input) {
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
@@ -196,12 +197,45 @@ function normalizeRecapIdentity(value) {
.replace(/[«»"'`]/g, "")
.replace(/\s+/g, " ");
}
function isLowQualityRecapCounterparty(value) {
const normalized = normalizeRecapIdentity(value);
if (!normalized) {
return false;
}
const stopwordOnlyCounterparty = new Set([
"без",
"в",
"во",
"для",
"до",
"за",
"из",
"к",
"ко",
"на",
"от",
"по",
"с",
"со",
"у"
]);
if (stopwordOnlyCounterparty.has(normalized)) {
return true;
}
if (/^(?:и\s+)?(?:кто|что|где|какой|какие)\b/iu.test(normalized) ||
/(?:главн|основн|крупн|поставщик|клиент|контрагент|покупател|документ|движени|операци)/iu.test(normalized) &&
!/(?<!\p{L})(?:ооо|ип|ао|пао|зао|llc|inc|corp)(?!\p{L})/iu.test(normalized)) {
return true;
}
return /(?:\s+в|\s+по|\s+для)$/iu.test(normalized);
}
function buildRecapFactLine(input) {
const detectedIntent = String(input.debug?.detected_intent ?? "");
const scopedDate = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.debug).scopedDate;
const counterparty = isLowQualityRecapCounterparty(input.counterparty) ? null : input.counterparty;
const discoveryFact = buildDiscoveryRecapFactLine({
debug: input.debug,
counterparty: input.counterparty,
counterparty,
organization: input.organization,
scopedDate
});
@@ -209,7 +243,7 @@ function buildRecapFactLine(input) {
return discoveryFact;
}
const itemPart = input.item ? `по позиции «${input.item}»` : null;
const counterpartyPart = input.counterparty ? `по контрагенту «${input.counterparty}»` : null;
const counterpartyPart = counterparty ? `по контрагенту «${counterparty}»` : null;
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
const datePart = scopedDate ? ` на ${scopedDate}` : "";
if (detectedIntent === "inventory_on_hand_as_of_date") {
@@ -516,7 +550,7 @@ function buildBroadBusinessEvaluationReply(input) {
: "- Прибыль, маржа и качество операционки пока не доказаны: нужны расходы, себестоимость и задолженность."
];
return [
`Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
`Коротко: по уже подтвержденным срезам 1С${organizationPart} бизнес выглядит операционно живым; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
"Что уже видно:",
...recapFacts.map((fact) => `- ${ensureSentence(fact)}`),
...(businessEvidence.confirmedLines.length > 0
@@ -533,6 +567,69 @@ function buildBroadBusinessEvaluationReply(input) {
"Если хочешь, я быстро доберу основу для такой оценки: денежный поток, дебиторка/кредиторка, НДС или ключевые контрагенты."
].join(" ");
}
function buildConversationExecutiveSummaryReply(input) {
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
const organization = input.organization ?? contextFacts.organization;
const organizationPart = organization ? ` по компании «${organization}»` : "";
const recapFacts = collectRecentRecapFacts({
sessionItems: input.sessionItems,
item: null,
organization,
toNonEmptyString: input.toNonEmptyString,
limit: 8
});
const businessEvidence = collectBusinessEvaluationEvidence({
sessionItems: input.sessionItems,
organization,
toNonEmptyString: input.toNonEmptyString
});
const confirmedLines = [];
for (const fact of recapFacts) {
pushBusinessLine(confirmedLines, ensureSentence(fact));
}
for (const fact of businessEvidence.confirmedLines) {
pushBusinessLine(confirmedLines, ensureSentence(fact));
}
const proxyLines = [];
for (const line of businessEvidence.interpretationLines) {
pushBusinessLine(proxyLines, ensureSentence(line));
}
if (businessEvidence.hasNet) {
pushBusinessLine(proxyLines, "Нетто по деньгам можно использовать как cash-flow proxy, но это не бухгалтерская прибыль и не маржа.");
}
if (businessEvidence.hasRanking) {
pushBusinessLine(proxyLines, "Крупнейшие клиенты/контрагенты видны как операционный сигнал концентрации, но это не полноценный CRM-аудит.");
}
const missingLines = [
"Чистая прибыль, финрезультат и маржа не доказаны без отдельной проверки себестоимости, расходов и закрытия периода.",
"Просрочка, качество долга и due-date aging не доказаны без сроков оплаты и отдельного долгового контура.",
"Ликвидность склада, резервы, списания и устаревание нельзя считать подтвержденными без специальных складских доказательств.",
"Vendor-risk, качество закупок и юридическая надежность контрагентов остаются вне подтвержденного контура."
];
const manualLines = [
"Сверить ОСВ/финрезультат и управленческие расходы за ключевые годы.",
"Отдельно проверить старые открытые расчеты, крупные долги и документы закрытия.",
"Посмотреть концентрацию клиентов и поставщиков не только по сумме, но и по доле в периоде.",
"Сверить НДС, склад и договоры там, где ответ опирался на proxy, а не на прямой учетный факт."
];
const confirmedSection = confirmedLines.length > 0
? confirmedLines.slice(0, 10).map((line) => `- ${line}`)
: ["- Есть grounded-контекст диалога, но для строгого executive summary не хватает подтвержденных метрик в текущем окне."];
const proxySection = proxyLines.length > 0
? proxyLines.slice(0, 6).map((line) => `- ${line}`)
: ["- Proxy-выводы пока слабые: можно говорить только о направлении проверки, не о зрелой оценке бизнеса."];
return [
`Executive summary${organizationPart}: по диалогу уже можно собрать рабочую карту подтвержденного, proxy и ручного контроля, но не стоит выдавать это за полный аудит компании.`,
"Подтверждено по данным/ответам 1С:",
...confirmedSection,
"Proxy и осторожная аналитика:",
...proxySection,
"Где не хватило доказательств:",
...missingLines.map((line) => `- ${line}`),
"Что директору смотреть руками в первую очередь:",
...manualLines.map((line) => `- ${line}`)
].join("\n");
}
function buildSelectedObjectAnswerInspectionReply(input) {
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
const itemLabel = contextFacts.item ?? "эта позиция";
@@ -578,6 +578,13 @@ function createAssistantRoutePolicy(deps) {
hasOrganizationFactFollowupSignal(repairedRawUserMessage, sessionItems ?? []) ||
hasOrganizationFactFollowupSignal(effectiveAddressUserMessage, sessionItems ?? []) ||
hasOrganizationFactFollowupSignal(repairedEffectiveAddressUserMessage, sessionItems ?? []);
const broadBusinessMeaningBoundary = Boolean(assistantTurnMeaning?.unsupported_but_understood_family === "broad_business_evaluation" &&
assistantTurnMeaning?.stale_replay_forbidden === true &&
!turnMeaningIntentCandidate &&
!dataScopeMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
const hardMetaMode = resolveHardMetaMode({
dataScopeMetaQuery,
capabilityMetaQuery,
@@ -613,6 +620,41 @@ function createAssistantRoutePolicy(deps) {
};
}
if (hardMetaMode === "capability") {
if (broadBusinessMeaningBoundary) {
return {
runAddressLane: false,
toolGateDecision: "skip_address_lane",
toolGateReason: "unsupported_current_turn_meaning_boundary",
livingMode: "chat",
livingReason: "unsupported_current_turn_meaning_boundary",
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: null,
provider_execution: providerExecution,
assistant_turn_meaning: assistantTurnMeaning,
address_mode: resolvedModeDetection.mode,
address_mode_confidence: resolvedModeDetection.confidence,
address_intent: resolvedIntentResolution.intent,
address_intent_confidence: resolvedIntentResolution.confidence,
strong_data_signal_detected: strongDataSignal,
data_retrieval_signal_detected: dataRetrievalSignal,
followup_context_detected: Boolean(followupContext),
unsupported_current_turn_meaning_boundary: true,
unsupported_current_turn_family: assistantTurnMeaning.unsupported_but_understood_family,
unsupported_address_intent_fallback_to_deep: false,
final_decision: {
run_address_lane: false,
tool_gate_decision: "skip_address_lane",
tool_gate_reason: "unsupported_current_turn_meaning_boundary",
living_mode: "chat",
living_reason: "unsupported_current_turn_meaning_boundary"
},
reason_codes: [
"business_overview_meaning_overrides_capability_meta_noise"
]
}
};
}
if (contextualHistoricalCapabilityFollowupDetected) {
return {
runAddressLane: false,
@@ -699,7 +741,7 @@ function createAssistantRoutePolicy(deps) {
}
};
}
const unsupportedCurrentTurnMeaningBoundary = Boolean(assistantTurnMeaning?.unsupported_but_understood_family &&
const unsupportedCurrentTurnMeaningBoundary = Boolean((broadBusinessMeaningBoundary || assistantTurnMeaning?.unsupported_but_understood_family) &&
assistantTurnMeaning?.stale_replay_forbidden === true &&
!turnMeaningIntentCandidate &&
!aggregateBusinessAnalyticsSignal &&
@@ -1,6 +1,7 @@
import {
buildAddressMemoryRecapReply as buildAddressMemoryRecapReplyFromPolicy,
buildBroadBusinessEvaluationReply as buildBroadBusinessEvaluationReplyFromPolicy,
buildConversationExecutiveSummaryReply as buildConversationExecutiveSummaryReplyFromPolicy,
buildSelectedObjectAnswerInspectionReply as buildSelectedObjectAnswerInspectionReplyFromPolicy,
buildInventoryHistoryCapabilityFollowupReply as buildInventoryHistoryCapabilityFollowupReplyFromPolicy,
resolveAssistantLivingChatMemoryContext
@@ -81,6 +82,17 @@ function buildDeterministicSmalltalkLeadReply(): string {
return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e.";
}
function hasConversationExecutiveSummarySignal(value: unknown): boolean {
const normalized = String(value ?? "")
.toLowerCase()
.replace(/\u0451/gu, "\u0435")
.replace(/\s+/g, " ")
.trim();
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(
normalized
);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
@@ -246,15 +258,25 @@ export async function runAssistantLivingChatRuntime(
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_inventory_history_capability_contract";
} else if (contextualMemoryRecapFollowup) {
const scopedOrganization = selectedOrganization ?? activeOrganization ?? null;
chatText = buildAddressMemoryRecapReplyFromPolicy({
organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
const scopedOrganization = selectedOrganization ?? activeOrganization ?? continuityActiveOrganization ?? null;
const executiveSummaryFollowup = hasConversationExecutiveSummarySignal(userMessage);
chatText = executiveSummaryFollowup
? buildConversationExecutiveSummaryReplyFromPolicy({
organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
})
: buildAddressMemoryRecapReplyFromPolicy({
organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
activeOrganization = scopedOrganization ?? activeOrganization;
livingChatSource = "deterministic_memory_recap_contract";
livingChatSource = executiveSummaryFollowup
? "deterministic_conversation_executive_summary_contract"
: "deterministic_memory_recap_contract";
} else if (contextualAnswerInspectionFollowup) {
chatText = buildSelectedObjectAnswerInspectionReplyFromPolicy({
addressDebug: lastAnswerInspectionAddressDebug,
@@ -185,6 +185,10 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
return true;
}
function hasConversationExecutiveSummarySignal(sample) {
return /(?:executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample);
}
function hasConversationMemoryRecallFollowupSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
@@ -196,7 +200,8 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD
}
const hasMemoryCue = samples.some((sample) => /(?:помни(?:шь|те|м)?|remember|recall)/iu.test(sample));
const hasDiscussionCue = samples.some((sample) => /(?:обсуждал[аи]?|говорил[аи]?|смотрел[аи]?|разбирал[аи]?|спрашивал[аи]?)/iu.test(sample));
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample));
const hasExplicitRecapPrompt = samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|напомни\s+что\s+мы|what\s+we\s+already\s+(?:discussed|figured\s+out))/iu.test(sample) ||
hasConversationExecutiveSummarySignal(sample));
if (!(hasExplicitRecapPrompt || (hasMemoryCue && hasDiscussionCue))) {
return false;
}
@@ -230,7 +230,7 @@ function hasSignalAcrossSamples(
function hasExplicitRecapPromptSignal(samples: string[]): boolean {
return samples.some((sample) =>
/(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы)/iu.test(
/(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(
sample
)
);
@@ -268,6 +268,41 @@ function normalizeRecapIdentity(value: unknown): string {
.replace(/\s+/g, " ");
}
function isLowQualityRecapCounterparty(value: string | null): boolean {
const normalized = normalizeRecapIdentity(value);
if (!normalized) {
return false;
}
const stopwordOnlyCounterparty = new Set([
"без",
"в",
"во",
"для",
"до",
"за",
"из",
"к",
"ко",
"на",
"от",
"по",
"с",
"со",
"у"
]);
if (stopwordOnlyCounterparty.has(normalized)) {
return true;
}
if (
/^(?:и\s+)?(?:кто|что|где|какой|какие)\b/iu.test(normalized) ||
/(?:главн|основн|крупн|поставщик|клиент|контрагент|покупател|документ|движени|операци)/iu.test(normalized) &&
!/(?<!\p{L})(?:ооо|ип|ао|пао|зао|llc|inc|corp)(?!\p{L})/iu.test(normalized)
) {
return true;
}
return /(?:\s+в|\s+по|\s+для)$/iu.test(normalized);
}
function buildRecapFactLine(input: {
debug: Record<string, unknown> | null;
item: string | null;
@@ -276,9 +311,10 @@ function buildRecapFactLine(input: {
}): string | null {
const detectedIntent = String(input.debug?.detected_intent ?? "");
const scopedDate = resolveAddressDebugContextFacts(input.debug).scopedDate;
const counterparty = isLowQualityRecapCounterparty(input.counterparty) ? null : input.counterparty;
const discoveryFact = buildDiscoveryRecapFactLine({
debug: input.debug,
counterparty: input.counterparty,
counterparty,
organization: input.organization,
scopedDate
});
@@ -286,7 +322,7 @@ function buildRecapFactLine(input: {
return discoveryFact;
}
const itemPart = input.item ? `по позиции «${input.item}»` : null;
const counterpartyPart = input.counterparty ? `по контрагенту «${input.counterparty}»` : null;
const counterpartyPart = counterparty ? `по контрагенту «${counterparty}»` : null;
const organizationPart = input.organization ? `по компании «${input.organization}»` : null;
const datePart = scopedDate ? ` на ${scopedDate}` : "";
if (detectedIntent === "inventory_on_hand_as_of_date") {
@@ -673,7 +709,7 @@ export function buildBroadBusinessEvaluationReply(input: {
: "- Прибыль, маржа и качество операционки пока не доказаны: нужны расходы, себестоимость и задолженность."
];
return [
`Коротко: по уже подтвержденным срезам 1С${organizationPart} компания выглядит операционно живой; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
`Коротко: по уже подтвержденным срезам 1С${organizationPart} бизнес выглядит операционно живым; это предварительная оценка бизнеса, а для взрослого вывода еще нужны прибыль, маржа и долги.`,
"Что уже видно:",
...recapFacts.map((fact) => `- ${ensureSentence(fact)}`),
...(businessEvidence.confirmedLines.length > 0
@@ -692,6 +728,84 @@ export function buildBroadBusinessEvaluationReply(input: {
].join(" ");
}
export function buildConversationExecutiveSummaryReply(input: {
organization: string | null;
addressDebug: Record<string, unknown> | null;
sessionItems?: unknown[];
toNonEmptyString: (value: unknown) => string | null;
}): string {
const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
const organization = input.organization ?? contextFacts.organization;
const organizationPart = organization ? ` по компании «${organization}»` : "";
const recapFacts = collectRecentRecapFacts({
sessionItems: input.sessionItems,
item: null,
organization,
toNonEmptyString: input.toNonEmptyString,
limit: 8
});
const businessEvidence = collectBusinessEvaluationEvidence({
sessionItems: input.sessionItems,
organization,
toNonEmptyString: input.toNonEmptyString
});
const confirmedLines: string[] = [];
for (const fact of recapFacts) {
pushBusinessLine(confirmedLines, ensureSentence(fact));
}
for (const fact of businessEvidence.confirmedLines) {
pushBusinessLine(confirmedLines, ensureSentence(fact));
}
const proxyLines: string[] = [];
for (const line of businessEvidence.interpretationLines) {
pushBusinessLine(proxyLines, ensureSentence(line));
}
if (businessEvidence.hasNet) {
pushBusinessLine(
proxyLines,
"Нетто по деньгам можно использовать как cash-flow proxy, но это не бухгалтерская прибыль и не маржа."
);
}
if (businessEvidence.hasRanking) {
pushBusinessLine(
proxyLines,
"Крупнейшие клиенты/контрагенты видны как операционный сигнал концентрации, но это не полноценный CRM-аудит."
);
}
const missingLines = [
"Чистая прибыль, финрезультат и маржа не доказаны без отдельной проверки себестоимости, расходов и закрытия периода.",
"Просрочка, качество долга и due-date aging не доказаны без сроков оплаты и отдельного долгового контура.",
"Ликвидность склада, резервы, списания и устаревание нельзя считать подтвержденными без специальных складских доказательств.",
"Vendor-risk, качество закупок и юридическая надежность контрагентов остаются вне подтвержденного контура."
];
const manualLines = [
"Сверить ОСВ/финрезультат и управленческие расходы за ключевые годы.",
"Отдельно проверить старые открытые расчеты, крупные долги и документы закрытия.",
"Посмотреть концентрацию клиентов и поставщиков не только по сумме, но и по доле в периоде.",
"Сверить НДС, склад и договоры там, где ответ опирался на proxy, а не на прямой учетный факт."
];
const confirmedSection =
confirmedLines.length > 0
? confirmedLines.slice(0, 10).map((line) => `- ${line}`)
: ["- Есть grounded-контекст диалога, но для строгого executive summary не хватает подтвержденных метрик в текущем окне."];
const proxySection =
proxyLines.length > 0
? proxyLines.slice(0, 6).map((line) => `- ${line}`)
: ["- Proxy-выводы пока слабые: можно говорить только о направлении проверки, не о зрелой оценке бизнеса."];
return [
`Executive summary${organizationPart}: по диалогу уже можно собрать рабочую карту подтвержденного, proxy и ручного контроля, но не стоит выдавать это за полный аудит компании.`,
"Подтверждено по данным/ответам 1С:",
...confirmedSection,
"Proxy и осторожная аналитика:",
...proxySection,
"Где не хватило доказательств:",
...missingLines.map((line) => `- ${line}`),
"Что директору смотреть руками в первую очередь:",
...manualLines.map((line) => `- ${line}`)
].join("\n");
}
export function buildSelectedObjectAnswerInspectionReply(input: {
addressDebug: Record<string, unknown> | null;
toNonEmptyString: (value: unknown) => string | null;
@@ -662,6 +662,14 @@ export function createAssistantRoutePolicy(deps) {
hasOrganizationFactFollowupSignal(repairedRawUserMessage, sessionItems ?? []) ||
hasOrganizationFactFollowupSignal(effectiveAddressUserMessage, sessionItems ?? []) ||
hasOrganizationFactFollowupSignal(repairedEffectiveAddressUserMessage, sessionItems ?? []);
const broadBusinessMeaningBoundary = Boolean(
assistantTurnMeaning?.unsupported_but_understood_family === "broad_business_evaluation" &&
assistantTurnMeaning?.stale_replay_forbidden === true &&
!turnMeaningIntentCandidate &&
!dataScopeMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
const hardMetaMode = resolveHardMetaMode({
dataScopeMetaQuery,
capabilityMetaQuery,
@@ -697,6 +705,41 @@ export function createAssistantRoutePolicy(deps) {
};
}
if (hardMetaMode === "capability") {
if (broadBusinessMeaningBoundary) {
return {
runAddressLane: false,
toolGateDecision: "skip_address_lane",
toolGateReason: "unsupported_current_turn_meaning_boundary",
livingMode: "chat",
livingReason: "unsupported_current_turn_meaning_boundary",
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
hard_meta_mode: null,
provider_execution: providerExecution,
assistant_turn_meaning: assistantTurnMeaning,
address_mode: resolvedModeDetection.mode,
address_mode_confidence: resolvedModeDetection.confidence,
address_intent: resolvedIntentResolution.intent,
address_intent_confidence: resolvedIntentResolution.confidence,
strong_data_signal_detected: strongDataSignal,
data_retrieval_signal_detected: dataRetrievalSignal,
followup_context_detected: Boolean(followupContext),
unsupported_current_turn_meaning_boundary: true,
unsupported_current_turn_family: assistantTurnMeaning.unsupported_but_understood_family,
unsupported_address_intent_fallback_to_deep: false,
final_decision: {
run_address_lane: false,
tool_gate_decision: "skip_address_lane",
tool_gate_reason: "unsupported_current_turn_meaning_boundary",
living_mode: "chat",
living_reason: "unsupported_current_turn_meaning_boundary"
},
reason_codes: [
"business_overview_meaning_overrides_capability_meta_noise"
]
}
};
}
if (contextualHistoricalCapabilityFollowupDetected) {
return {
runAddressLane: false,
@@ -784,7 +827,7 @@ export function createAssistantRoutePolicy(deps) {
};
}
const unsupportedCurrentTurnMeaningBoundary = Boolean(
assistantTurnMeaning?.unsupported_but_understood_family &&
(broadBusinessMeaningBoundary || assistantTurnMeaning?.unsupported_but_understood_family) &&
assistantTurnMeaning?.stale_replay_forbidden === true &&
!turnMeaningIntentCandidate &&
!aggregateBusinessAnalyticsSignal &&
@@ -426,6 +426,20 @@ describe("assistant living chat runtime adapter", () => {
userMessage: "а ты помнишь мы зеркало обсуждали?",
modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" },
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
organization: "ООО Альтернатива Плюс",
counterparty: "для"
}
}
},
{
role: "assistant",
debug: {
@@ -504,6 +518,81 @@ describe("assistant living chat runtime adapter", () => {
expect(executeLlmChat).not.toHaveBeenCalled();
});
it("builds executive summary from memory instead of running generic address lookup", async () => {
const executeLlmChat = vi.fn(async () => "raw-llm");
const input = buildRuntimeInput({
userMessage:
"Финально собери executive summary по всему диалогу: где подтверждено, где proxy и что смотреть руками.",
modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" },
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: {
status: "grounded"
},
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
organization: "ООО Альтернатива Плюс",
counterparty: "для"
}
}
},
{
role: "assistant",
debug: {
execution_lane: "living_chat",
mcp_discovery_response_applied: true,
assistant_mcp_discovery_entry_point_v1: {
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
entry_status: "bridge_executed",
turn_input: {
turn_meaning_ref: {
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
}
},
bridge: {
bridge_status: "answer_draft_ready",
business_fact_answer_allowed: true,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
},
pilot: {
pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1",
derived_bidirectional_value_flow: {
period_scope: "2020",
net_amount_human_ru: "3 865 501,50 руб.",
incoming_customer_revenue: {
total_amount_human_ru: "47 628 853,03 руб."
},
outgoing_supplier_payout: {
total_amount_human_ru: "43 763 351,53 руб."
}
}
}
}
}
}
}
],
executeLlmChat
});
const output = await runAssistantLivingChatRuntime(input);
expect(output.handled).toBe(true);
expect(output.chatText).toContain("Executive summary");
expect(output.chatText).toContain("Подтверждено");
expect(output.chatText).toContain("Proxy");
expect(output.chatText).toContain("3 865 501,50");
expect(output.chatText).toContain("директору смотреть руками");
expect(output.chatText).not.toContain("«для»");
expect(output.debug?.living_chat_response_source).toBe("deterministic_conversation_executive_summary_contract");
expect(executeLlmChat).not.toHaveBeenCalled();
});
it("uses continuity-backed active organization for organization-fact boundary even when session scope is empty", async () => {
const executeLlmChat = vi.fn(async () => "raw-llm");
const input = buildRuntimeInput({
@@ -50,6 +50,16 @@ describe("assistantLivingModePolicy", () => {
expect(policy.hasConversationMemoryRecallFollowupSignal("а что мы уже выяснили по этой позиции?")).toBe(true);
});
it("detects final executive summary wording as memory signal", () => {
const policy = buildPolicy();
expect(
policy.hasConversationMemoryRecallFollowupSignal(
"Финально собери executive summary по всему диалогу: где подтверждено, где proxy и что смотреть руками"
)
).toBe(true);
});
it("detects answer inspection wording for previous answer correction", () => {
const policy = buildPolicy();
@@ -104,6 +104,42 @@ describe("assistantMemoryRecapPolicy", () => {
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
});
it("keeps final executive summary in memory lane even with strong data wording", () => {
const executivePolicy = createAssistantMemoryRecapPolicy({
hasHistoricalCapabilityFollowupSignal: () => false,
hasConversationMemoryRecallFollowupSignal: (text: unknown) => /executive summary/i.test(String(text ?? "")),
isGroundedInventoryContextDebug: () => false
});
const signals = executivePolicy.resolveRouteMemorySignals({
rawUserMessage:
"Финально собери executive summary по всему диалогу: где подтверждено, где proxy и где не хватило доказательств.",
repairedRawUserMessage: "",
effectiveAddressUserMessage: "",
repairedEffectiveAddressUserMessage: "",
dataScopeMetaQuery: false,
capabilityMetaQuery: false,
dataRetrievalSignal: false,
strongDataSignal: true,
aggregateBusinessAnalyticsSignal: false,
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: { status: "grounded" },
detected_intent: "receivables_confirmed_as_of_date",
extracted_filters: {
organization: "ООО Альтернатива Плюс"
}
}
}
]
});
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
});
it("does not trigger recap from ungrounded address history", () => {
const signals = policy.resolveRouteMemorySignals({
rawUserMessage: "а ты помнишь что мы обсуждали?",
@@ -683,6 +683,45 @@ describe("assistantRoutePolicy", () => {
expect(decision.orchestrationContract?.unsupported_current_turn_family).toBe("broad_business_evaluation");
});
it("lets broad business-audit meaning override noisy capability wording", () => {
const policy = buildPolicy({
resolveMetaSignalSet: () => ({
dataScopeMetaQuery: false,
capabilityMetaQuery: true,
metaAnswerFollowupSignal: false,
answerInspectionFollowupSignal: false
}),
resolveAssistantTurnMeaning: () => ({
schema_version: "assistant_turn_meaning_v1",
asked_domain_family: "business_summary",
asked_action_family: "broad_evaluation",
explicit_intent_candidate: null,
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true,
reason_codes: ["broad_business_evaluation_current_turn_signal"]
})
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage:
"Собери это как нормальный бизнес-аудит: что уже можно сказать уверенно, что proxy и что директору проверить руками.",
effectiveAddressUserMessage:
"Собери это как нормальный бизнес-аудит: что уже можно сказать уверенно, что proxy и что директору проверить руками.",
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("unsupported_current_turn_meaning_boundary");
expect(decision.livingReason).toBe("unsupported_current_turn_meaning_boundary");
expect(decision.orchestrationContract?.unsupported_current_turn_family).toBe("broad_business_evaluation");
expect(decision.orchestrationContract?.hard_meta_mode).toBeNull();
expect((decision.orchestrationContract as Record<string, unknown>)?.reason_codes).toContain(
"business_overview_meaning_overrides_capability_meta_noise"
);
});
it("recovers an address route from current-turn meaning when L0 resolver is noisy", () => {
const policy = buildPolicy({
resolveAddressToolGateDecision: undefined,