АДРЕСНЫЙ РЕЖИМ -Step-4: Batch-2 lifecycle закрыт gate 36/36, nightly 102/102 + 25/25 PASS
This commit is contained in:
@@ -268,6 +268,14 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
|
||||
"регулярные поставщики",
|
||||
"эпизодические поставщики",
|
||||
"давно не использовались поставщики",
|
||||
"всех заков",
|
||||
"кто был активен",
|
||||
"потом отвалился",
|
||||
"ровно один раз",
|
||||
"и пропал",
|
||||
"самые старые по сотрудничеству",
|
||||
"разбей поставщиков на регуляр и разовые",
|
||||
"кто новые в этом году",
|
||||
"active customers",
|
||||
"customer activity list",
|
||||
"counterparty lifecycle"
|
||||
@@ -605,13 +613,13 @@ function hasCounterpartyPopulationAndRolesSignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasLifecycleSegmentationSignal(text: string): boolean {
|
||||
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|только\s+один\s+раз|однораз|дольше\s+всех|долгожив|регулярн|эпизодич|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(
|
||||
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
if (hasDocumentSignal(text) || hasBankOperationSignal(text)) {
|
||||
if ((hasDocumentSignal(text) || hasBankOperationSignal(text)) && !hasLifecycleSegmentationSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) {
|
||||
@@ -633,6 +641,10 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
);
|
||||
const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
|
||||
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text);
|
||||
const hasImplicitCounterpartyQuestion =
|
||||
/(?:кто\s+с\s+нами|кто\s+у\s+нас|всех?\s+зак(?:ов|а|и)?|все\s+заки|кто\s+нов(?:ые|ых|ый)\b|кто\s+был\s+активен|самые\s+старые\s+по\s+сотрудничеству)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasListWithWindow = hasCounterpartyLexeme && hasListVerb && hasTimeWindowLexeme;
|
||||
if (hasListWithWindow) {
|
||||
return true;
|
||||
@@ -643,6 +655,12 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean {
|
||||
if (hasCounterpartyLexeme && hasLifecycleSegmentationSignal(text)) {
|
||||
return true;
|
||||
}
|
||||
if (hasImplicitCounterpartyQuestion && (hasLifecycleSegmentationSignal(text) || hasTimeWindowLexeme || hasActivityLexeme)) {
|
||||
return true;
|
||||
}
|
||||
if (!hasCounterpartyLexeme && hasListVerb && hasLifecycleSegmentationSignal(text) && /\bкто\b/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ function hasSameDateHint(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasExplicitPeriodLiteral(text: string): boolean {
|
||||
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? ""));
|
||||
}
|
||||
|
||||
function hasOpenItemsHint(text: string): boolean {
|
||||
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
|
||||
}
|
||||
@@ -393,6 +397,20 @@ function mergeFollowupFilters(
|
||||
return { filters: merged, reasons };
|
||||
}
|
||||
|
||||
if (
|
||||
intent === "counterparty_activity_lifecycle" &&
|
||||
hasAddressFollowupContextSignal(userMessage) &&
|
||||
!hasExplicitPeriodLiteral(userMessage)
|
||||
) {
|
||||
const currentPeriodFrom = toNonEmptyString(merged.period_from);
|
||||
const currentPeriodTo = toNonEmptyString(merged.period_to);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
if (!currentPeriodFrom && currentPeriodTo === todayIso) {
|
||||
delete merged.period_to;
|
||||
reasons.push("period_to_cleared_for_lifecycle_followup");
|
||||
}
|
||||
}
|
||||
|
||||
const currentHasPeriod = hasExplicitPeriodWindow(merged);
|
||||
const previousHasPeriod = hasExplicitPeriodWindow(previous);
|
||||
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
|
||||
@@ -543,9 +561,20 @@ export function runAddressDecomposeStage(
|
||||
followupContext: AddressFollowupContext | null
|
||||
): AddressDecomposeStageResult | null {
|
||||
const detectedMode = detectAddressQuestionMode(userMessage);
|
||||
const shape = classifyAddressQueryShape(userMessage);
|
||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||
return null;
|
||||
}
|
||||
const detectedIntent = resolveAddressIntent(userMessage);
|
||||
const mode =
|
||||
detectedMode.mode === "address_query"
|
||||
? detectedMode
|
||||
: detectedIntent.intent !== "unknown"
|
||||
? {
|
||||
mode: "address_query" as const,
|
||||
confidence: "medium" as const,
|
||||
reasons: [...detectedMode.reasons, "address_mode_from_resolved_intent"]
|
||||
}
|
||||
: followupContext && hasAddressFollowupContextSignal(userMessage)
|
||||
? {
|
||||
mode: "address_query" as const,
|
||||
@@ -556,13 +585,6 @@ export function runAddressDecomposeStage(
|
||||
if (mode.mode !== "address_query") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shape = classifyAddressQueryShape(userMessage);
|
||||
if (shape.shape === "EXPLAIN_OR_REASON") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detectedIntent = resolveAddressIntent(userMessage);
|
||||
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
|
||||
const extractedFilters = extractAddressFilters(userMessage, intent.intent);
|
||||
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);
|
||||
|
||||
@@ -1391,10 +1391,10 @@ function hasAccountingSignal(text) {
|
||||
}
|
||||
function hasFollowupMarker(text) {
|
||||
const compact = compactWhitespace(text.toLowerCase());
|
||||
return /^(и|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|а если|а теперь|теперь|plus|also|dobav|utochn|prodolzh|then|now)/i.test(compact);
|
||||
return /^(и|а\s+кто|а еще|а ещё|еще|ещё|добав|уточн|продолж|также|а если|а теперь|теперь|plus|also|dobav|utochn|prodolzh|then|now)/i.test(compact);
|
||||
}
|
||||
function hasReferentialPointer(text) {
|
||||
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) {
|
||||
return /(привет|как дела|спасибо|благодарю|thanks|thank you|hello|hi)\b/i.test(text.toLowerCase());
|
||||
@@ -2374,6 +2374,30 @@ function readAddressFilterString(addressDebug, key) {
|
||||
}
|
||||
return toNonEmptyString(filters[key]);
|
||||
}
|
||||
function isAddressLaneDebugPayload(debug) {
|
||||
if (!debug || typeof debug !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (debug.detected_mode === "address_query") {
|
||||
return true;
|
||||
}
|
||||
if (typeof debug.selected_recipe === "string" && debug.selected_recipe.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (typeof debug.mcp_call_status === "string" && debug.mcp_call_status.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (typeof debug.anchor_type === "string" && debug.anchor_type.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (debug.extracted_filters && typeof debug.extracted_filters === "object") {
|
||||
const keys = Object.keys(debug.extracted_filters);
|
||||
if (keys.length > 0 && typeof debug.detected_intent === "string" && debug.detected_intent.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function findLastAddressAssistantDebug(items) {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
@@ -2381,7 +2405,7 @@ function findLastAddressAssistantDebug(items) {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1") {
|
||||
if (isAddressLaneDebugPayload(debug)) {
|
||||
return debug;
|
||||
}
|
||||
}
|
||||
@@ -2394,7 +2418,7 @@ function findRecentAddressFilterValue(items, key) {
|
||||
continue;
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (!(debug.detected_mode === "address_query" || debug.prompt_version === "address_query_runtime_v1")) {
|
||||
if (!isAddressLaneDebugPayload(debug)) {
|
||||
continue;
|
||||
}
|
||||
const directFilterValue = readAddressFilterString(debug, key);
|
||||
@@ -2438,6 +2462,13 @@ function hasAddressFollowupContextSignal(userMessage) {
|
||||
if (shortFollowup && /(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
if (shortFollowup &&
|
||||
/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
|
||||
return true;
|
||||
}
|
||||
if (shortFollowup &&
|
||||
/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) &&
|
||||
!/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) {
|
||||
|
||||
@@ -1403,6 +1403,41 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps supplier lifecycle segmentation with operations wording in lifecycle intent", () => {
|
||||
const result = resolveAddressIntent("Раздели поставщиков на регулярных и эпизодических по частоте операций.");
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps stale supplier operations wording in lifecycle intent", () => {
|
||||
const result = resolveAddressIntent("Какие поставщики давно не использовались в операционной активности?");
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps slang all-customers-all-time wording in lifecycle intent", () => {
|
||||
const result = resolveAddressIntent("выведи всех заков за все время");
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps churn wording with year in lifecycle intent", () => {
|
||||
const result = resolveAddressIntent("кто был активен в 2020 и потом отвалился");
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps one-time-plus-churn wording in lifecycle intent", () => {
|
||||
const result = resolveAddressIntent("кто с нами был ровно один раз и пропал");
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps oldest-collaboration slang wording in lifecycle intent", () => {
|
||||
const result = resolveAddressIntent("самые старые по сотрудничеству кто");
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps regular-vs-one-off supplier slang in lifecycle intent (not population)", () => {
|
||||
const result = resolveAddressIntent("разбей поставщиков на регуляр и разовые");
|
||||
expect(result.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("resolves contract usage overview intent", () => {
|
||||
const result = resolveAddressIntent("Сколько всего договоров заведено и сколько из них реально использовались?");
|
||||
expect(result.intent).toBe("contract_usage_overview");
|
||||
@@ -2219,6 +2254,22 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||
});
|
||||
|
||||
describe("address decompose stage follow-up carryover", () => {
|
||||
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
|
||||
const result = runAddressDecomposeStage("выведи всех заков за все время", null);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mode.mode).toBe("address_query");
|
||||
expect(result?.intent.intent).toBe("counterparty_activity_lifecycle");
|
||||
});
|
||||
|
||||
it("keeps churn wording with year in address lane via resolved intent fallback", () => {
|
||||
const result = runAddressDecomposeStage("кто был активен в 2020 и потом отвалился", null);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mode.mode).toBe("address_query");
|
||||
expect(result?.intent.intent).toBe("counterparty_activity_lifecycle");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31");
|
||||
});
|
||||
|
||||
it("uses short bare year in follow-up period switch", () => {
|
||||
const result = runAddressDecomposeStage("теперь за 21", {
|
||||
previous_intent: "counterparty_activity_lifecycle",
|
||||
@@ -2235,6 +2286,24 @@ describe("address decompose stage follow-up carryover", () => {
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2021-12-31");
|
||||
});
|
||||
|
||||
it("keeps lifecycle follow-up phrasing with referential pointer and inherits period", () => {
|
||||
const result = runAddressDecomposeStage("А кто из них новые?", {
|
||||
previous_intent: "counterparty_activity_lifecycle",
|
||||
previous_filters: {
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mode.mode).toBe("address_query");
|
||||
expect(result?.intent.intent).toBe("counterparty_activity_lifecycle");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31");
|
||||
expect(result?.baseReasons).toContain("address_followup_context_applied");
|
||||
});
|
||||
|
||||
it("keeps short period follow-up in address lane and preserves previous counterparty anchor", () => {
|
||||
const result = runAddressDecomposeStage("а теперь только за май 2020", {
|
||||
previous_intent: "list_documents_by_counterparty",
|
||||
|
||||
@@ -192,4 +192,91 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses last real address context after intermediate clarification fallback", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const lifecycleFollowupMessage = "А кто из них новые?";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === "что там не так?") {
|
||||
return null;
|
||||
}
|
||||
if (message === lifecycleFollowupMessage && !options?.followupContext) {
|
||||
return null;
|
||||
}
|
||||
if (message === lifecycleFollowupMessage && options?.followupContext) {
|
||||
return buildAddressLaneResult({
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
detected_intent: "counterparty_activity_lifecycle",
|
||||
extracted_filters: {
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
reasons: ["address_action_detected", "address_followup_context_applied"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return buildAddressLaneResult();
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async (payload: any) => ({
|
||||
assistant_reply: payload?.userQuestion === "что там не так?" ? "Нужно уточнение по фокусу." : "unexpected_normalizer_call",
|
||||
reply_type: payload?.userQuestion === "что там не так?" ? "clarification_required" : "partial_coverage",
|
||||
debug: {
|
||||
prompt_version: "address_query_runtime_v1",
|
||||
detected_mode: null,
|
||||
detected_intent: null
|
||||
}
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const sessionId = `asst-address-followup-clar-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: "покажи документы по свк за 2020",
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("factual");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: "что там не так?",
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("clarification_required");
|
||||
|
||||
const third = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: lifecycleFollowupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(third.ok).toBe(true);
|
||||
expect(third.reply_type).toBe("factual");
|
||||
expect(third.debug?.detected_mode).toBe("address_query");
|
||||
expect(third.debug?.detected_intent).toBe("counterparty_activity_lifecycle");
|
||||
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
const contextualCall = calls.find((item) => item.message === lifecycleFollowupMessage && Boolean(item.options?.followupContext));
|
||||
expect(contextualCall).toBeTruthy();
|
||||
expect(contextualCall?.options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||||
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(String(contextualCall?.options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||||
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user