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
@@ -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 &&