Stage 2 завершён: problem-first ответы и follow-up continuity - ассистент переведён от entity-heavy логики к problem-first ответам с problem-unit слоем, удержанием контекста в follow-up и очисткой пользовательского ответа от сырых технических ссылок.

This commit is contained in:
2026-03-26 14:53:52 +03:00
parent ece1abed76
commit 96353cfd48
2474 changed files with 21678 additions and 3292445 deletions
@@ -19,6 +19,8 @@ import {
FEATURE_ASSISTANT_CONTRACTS_V11,
FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1,
FEATURE_ASSISTANT_INVESTIGATION_STATE_V1,
FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1,
FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1,
FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1
} from "../config";
import { logJson } from "../utils/log";
@@ -807,22 +809,28 @@ function hasAccountingSignal(text: string): boolean {
if (/(?:^|[\s,;:])\d{2}(?:\.\d{2})?(?=$|[\s,.;:])/i.test(lower)) {
return true;
}
return /(проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|ндс|амортиз|рбп|ос|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(
return /(РїСЂРѕРІРѕРґРє|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|РЅРґСЃ|амортиз|СЂР±Рї|РѕСЃ|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|проводк|документ|реализац|поступлен|взаиморасчет|сальдо|остатк|счет|счёт|ндс|амортиз|рбп|контрагент|поставщик|покупател|оплат|банк|выписк|склад|товар|материал|закрыти|период|postavshchik|kontragent|schet|schetu|period|counterparty|supplier|invoice|posting|ledger|account|anomaly|risk)/i.test(
lower
);
}
function hasFollowupMarker(text: string): boolean {
const compact = compactWhitespace(text.toLowerCase());
return /^(и|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|plus|also|dobav|utochn|prodolzh)/i.test(compact);
return /^(Рё|Р° еще|Р° ещё|еще|ещё|добав|уточн|продолж|также|и|а если|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|plus|also|dobav|utochn|prodolzh)/i.test(
compact
);
}
function hasReferentialPointer(text: string): boolean {
return /(по этому|по тому|это же|этой|этим|тому|same thing|that one|po etomu|po tomu)/i.test(text.toLowerCase());
return /(РїРѕ этому|РїРѕ тому|это Р¶Рµ|этой|этим|тому|по этому|по тому|это же|этой|этим|этому|из этого|в этом|тот же|same thing|that one|po etomu|po tomu)/i.test(
text.toLowerCase()
);
}
function hasSmallTalkSignal(text: string): boolean {
return /(привет|как дела|спасибо|thanks|thank you|hello|hi)\b/i.test(text.toLowerCase());
return /(привет|как дела|спасибо|привет|как дела|спасибо|благодарю|thanks|thank you|hello|hi)\b/i.test(
text.toLowerCase()
);
}
function countTokens(text: string): number {
@@ -835,6 +843,44 @@ function hasPeriodLiteral(text: string): boolean {
return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text);
}
function extractNormalizedPeriodLiteral(text: string): string | null {
const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/);
if (monthly) {
return `${monthly[1]}-${monthly[2]}`;
}
const yearly = text.match(/\b(20\d{2})\b/);
if (yearly) {
return yearly[1];
}
return null;
}
function hasStrongFollowupAnchors(
userMessage: string,
state: NonNullable<AssistantSessionState["investigation_state"]>
): boolean {
const explicitPeriod = extractNormalizedPeriodLiteral(userMessage);
if (explicitPeriod && state.focus.period && explicitPeriod !== state.focus.period) {
const periodLooksLikeFollowupRefinement = hasFollowupMarker(userMessage) || hasReferentialPointer(userMessage);
if (!periodLooksLikeFollowupRefinement) {
return true;
}
}
const explicitAccounts = extractAccountTokens(userMessage);
if (explicitAccounts.length > 0) {
const knownAccounts = new Set(state.focus.primary_accounts.map((item) => item.trim()));
if (knownAccounts.size === 0) {
return true;
}
if (explicitAccounts.some((item) => !knownAccounts.has(item))) {
return true;
}
}
return false;
}
function routeFromInvestigationState(state: NonNullable<AssistantSessionState["investigation_state"]>): RouteHint | null {
const rawDomain = compactWhitespace(state.focus.domain ?? "");
if (!rawDomain) {
@@ -890,7 +936,17 @@ function buildFollowupStateBinding(input: {
const referentialPointer = hasReferentialPointer(userMessage);
const shortPrompt = countTokens(userMessage) <= 10;
const smallTalkSignal = hasSmallTalkSignal(userMessage);
const shouldBind = !smallTalkSignal && (followupMarker || referentialPointer || (!strongSignal && shortPrompt));
const problemState = input.investigationState.problem_unit_state;
const problemContinuityAvailable =
FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 &&
Boolean(problemState) &&
((problemState?.active_problem_units.length ?? 0) > 0 || (problemState?.focus_problem_types.length ?? 0) > 0);
const strongNewAnchorDetected = hasStrongFollowupAnchors(userMessage, input.investigationState);
const periodRefinementFollowup = hasPeriodLiteral(userMessage) && problemContinuityAvailable;
const shouldBind =
!smallTalkSignal &&
!strongNewAnchorDetected &&
(followupMarker || referentialPointer || periodRefinementFollowup || (!strongSignal && shortPrompt));
if (!shouldBind) {
return {
@@ -903,6 +959,7 @@ function buildFollowupStateBinding(input: {
const context: NormalizeRequestPayload["context"] = {
...(input.payloadContext ?? {})
};
const hasExplicitExpectedRoute = Boolean(input.payloadContext?.expected_route);
const expectedRouteFromState = !context?.expected_route ? routeFromInvestigationState(input.investigationState) : null;
const periodHintFromState = !context?.period_hint ? input.investigationState.focus.period : null;
@@ -915,6 +972,9 @@ function buildFollowupStateBinding(input: {
const subject = withCappedLength(compactWhitespace(input.investigationState.focus.active_query_subject ?? ""), FOLLOWUP_SUBJECT_MAX);
const businessContextPatch: string[] = ["followup_state_binding_v1"];
let problemContinuityApplied = false;
let problemContinuitySkippedReason: string | null = null;
if (input.investigationState.focus.period) {
businessContextPatch.push("active_period");
}
@@ -924,6 +984,20 @@ function buildFollowupStateBinding(input: {
if (input.investigationState.focus.primary_accounts.length > 0) {
businessContextPatch.push(`focus_accounts:${input.investigationState.focus.primary_accounts.join(",")}`);
}
if (problemContinuityAvailable) {
if (hasExplicitExpectedRoute) {
problemContinuitySkippedReason = "explicit_expected_route";
} else {
const focusTypes = (problemState?.focus_problem_types ?? []).slice(0, 3);
const activeCount = problemState?.active_problem_units.length ?? 0;
businessContextPatch.push("problem_unit_continuity_v1");
if (focusTypes.length > 0) {
businessContextPatch.push(`problem_focus_types:${focusTypes.join(",")}`);
}
businessContextPatch.push(`problem_active_count:${activeCount}`);
problemContinuityApplied = true;
}
}
const mergedBusinessContext = mergeBusinessContext(context?.business_context, businessContextPatch);
if (mergedBusinessContext) {
@@ -940,6 +1014,9 @@ function buildFollowupStateBinding(input: {
if (periodHintFromState && !hasPeriodLiteral(userMessage)) {
appendParts.push(`Период фокуса: ${periodHintFromState}`);
}
if (problemContinuityApplied && (problemState?.focus_problem_types.length ?? 0) > 0) {
appendParts.push(`Problem focus types: ${(problemState?.focus_problem_types ?? []).slice(0, 3).join(", ")}`);
}
const appendBlock = withCappedLength(compactWhitespace(appendParts.join("; ")), FOLLOWUP_QUESTION_APPEND_MAX);
normalizedQuestion = `${userMessage}\n${appendBlock}`.trim();
}
@@ -961,7 +1038,11 @@ function buildFollowupStateBinding(input: {
period_hint_from_state: Boolean(periodHintFromState),
expected_route_from_state: Boolean(expectedRouteFromState),
business_context_from_state: Boolean(mergedBusinessContext),
question_augmented: shouldAugmentQuestion
question_augmented: shouldAugmentQuestion,
problem_continuity_available: problemContinuityAvailable,
problem_continuity_applied: problemContinuityApplied,
problem_continuity_skipped_reason: problemContinuityApplied ? null : problemContinuitySkippedReason,
strong_new_anchor_detected: strongNewAnchorDetected
}
}
};
@@ -1121,7 +1202,8 @@ export class AssistantService {
requirements: coverageEvaluation.requirements,
coverageReport: coverageEvaluation.coverage,
groundingCheck,
enableAnswerPolicyV11: FEATURE_ASSISTANT_ANSWER_POLICY_V11
enableAnswerPolicyV11: FEATURE_ASSISTANT_ANSWER_POLICY_V11,
enableProblemCentricAnswerV1: FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1
});
const answerStructureV11 = FEATURE_ASSISTANT_CONTRACTS_V11
@@ -1175,6 +1257,14 @@ export class AssistantService {
answer_grounding_check: groundingCheck,
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
problem_units_used_count: composition.problem_units_used_count ?? 0,
problem_answer_mode: composition.problem_answer_mode ?? "stage1_policy_v11",
...(Array.isArray(composition.problem_unit_ids_used) && composition.problem_unit_ids_used.length > 0
? {
problem_unit_ids_used: composition.problem_unit_ids_used
}
: {}),
answer_structure_v11: answerStructureV11,
investigation_state_snapshot: investigationStateSnapshot,
normalized: normalized.normalized
@@ -1234,6 +1324,14 @@ export class AssistantService {
clarification_target: coverageEvaluation.coverage.clarification_needed_for,
dropped_intent_segments: extractDiscardedIntentSegments(normalized.normalized),
...(followupBinding.usage ? { followup_state_usage: followupBinding.usage } : {}),
problem_centric_answer_applied: composition.problem_centric_answer_applied ?? false,
problem_units_used_count: composition.problem_units_used_count ?? 0,
problem_answer_mode: composition.problem_answer_mode ?? "stage1_policy_v11",
...(Array.isArray(composition.problem_unit_ids_used) && composition.problem_unit_ids_used.length > 0
? {
problem_unit_ids_used: composition.problem_unit_ids_used
}
: {}),
answer_structure_v11: answerStructureV11,
investigation_state_snapshot: investigationStateSnapshot,
fallback_type: composition.fallback_type,