Починить восстановление кириллицы в автопрогонах

This commit is contained in:
2026-05-10 08:38:52 +03:00
parent 9b02083493
commit 3be06b5f93
18 changed files with 618 additions and 74 deletions
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { repairAddressMojibakeText } from "../src/services/addressTextRepair";
describe("address text mojibake repair", () => {
it("preserves C1 bytes when repairing old autorun Russian text", () => {
const damagedTitle = String.fromCharCode(
0x420, 0x2018, 0x420, 0x45b, 0x420, 0x203a, 0x420, 0xac, 0x420, 0x401, 0x420, 0x45b, 0x420,
0x2122, 0x20, 0x420, 0x45b, 0x420, 0x2018, 0x420, 0xa9, 0x420, 0x98, 0x420, 0x2122, 0x20,
0x420, 0xa0, 0x421, 0x453, 0x421, 0x2021, 0x420, 0x405, 0x420, 0xb0, 0x421, 0x40f, 0x20,
0x421, 0x403, 0x420, 0xb5, 0x421, 0x403, 0x421, 0x403, 0x420, 0x451, 0x421, 0x40f, 0x20,
0x31, 0x36, 0x2e, 0x30, 0x34, 0x2e, 0x32, 0x30, 0x32, 0x36, 0x2c, 0x20, 0x32, 0x31,
0x3a, 0x32, 0x36, 0x3a, 0x30, 0x36
);
const damagedAlternative = String.fromCharCode(
0x420, 0x452, 0x420, 0x203a, 0x420, 0xac, 0x420, 0x45e, 0x420, 0x2022, 0x420, 0xa0, 0x420,
0x45c, 0x420, 0x452, 0x420, 0x45e, 0x420, 0x98, 0x420, 0x2019, 0x420, 0x452
);
expect(repairAddressMojibakeText(damagedTitle)).toBe(
"\u0411\u041e\u041b\u042c\u0428\u041e\u0419 \u041e\u0411\u0429\u0418\u0419 \u0420\u0443\u0447\u043d\u0430\u044f \u0441\u0435\u0441\u0441\u0438\u044f 16.04.2026, 21:26:06"
);
expect(repairAddressMojibakeText(damagedAlternative)).toBe(
"\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\u0418\u0412\u0410"
);
});
it("repairs already lossy known replacement fragments", () => {
expect(repairAddressMojibakeText("\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\uFFFD?\u0412\u0410")).toBe(
"\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\u0418\u0412\u0410"
);
expect(repairAddressMojibakeText("\u0411\u041e\u041b\u042c\u0428\u041e\u0419 \u041e\u0411\u0429\uFFFD\u0419")).toBe(
"\u0411\u041e\u041b\u042c\u0428\u041e\u0419 \u041e\u0411\u0429\u0418\u0419"
);
});
});
@@ -2276,6 +2276,101 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("continues the original inventory query after replacement-damaged organization clarification", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "кайф - что там на складе по остаткам?";
const secondMessage = "АЛЬТЕРНАТ\uFFFD?ВА";
const repairedSecondMessage = "\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\u0418\u0412\u0410";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return buildAddressLimitedLaneResult("missing_anchor", {
reply_text: [
"Нужно уточнить организацию, чтобы не смешивать компании в одном ответе.",
"Сейчас в доступном контуре вижу такие организации:",
"- ООО Альтернатива Плюс",
"- ООО Лайсвуд",
"- РАЙМ"
].join("\n"),
debug: {
...buildAddressLimitedLaneResult("missing_anchor").debug,
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2026-04-15"
},
selected_recipe: null,
organization_candidates: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
reasons: ["organization_clarification_required", "multiple_known_organizations_detected"]
}
});
}
if (
(message === secondMessage || message === repairedSecondMessage) &&
options?.followupContext &&
options?.activeOrganization === "ООО Альтернатива Плюс"
) {
return buildAddressLaneResult({
reply_text: "На 15.04.2026 по ООО Альтернатива Плюс подтвержден складской остаток по всем складам.",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
as_of_date: "2026-04-15",
organization: "ООО Альтернатива Плюс"
},
reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} 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-org-clarification-damaged-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("partial_coverage");
const second = await service.handleMessage({
session_id: sessionId,
user_message: secondMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(repairedSecondMessage);
expect(calls[1].options?.activeOrganization).toBe("ООО Альтернатива Плюс");
expect(calls[1].options?.knownOrganizations).toEqual(["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"]);
expect(calls[1].options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[1].options?.followupContext?.previous_filters?.organization).toBe("ООО Альтернатива Плюс");
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps historical inventory date follow-up alive after company clarification and a capability answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи остатки по складу";
@@ -42,6 +42,26 @@ describe("assistant organization matcher", () => {
expect(score).toBeGreaterThanOrEqual(90);
});
it("matches replacement-damaged organization clarification when the live candidate is unique", () => {
const resolved = resolveOrganizationSelectionFromMessage("АЛЬТЕРНАТ\uFFFD?ВА", [
"ООО Альтернатива Плюс",
"ООО Лайсвуд",
"РАЙМ"
]);
expect(resolved).toBe("ООО Альтернатива Плюс");
});
it("keeps replacement-damaged organization clarification ambiguous when candidates share the token", () => {
const resolved = resolveOrganizationSelectionFromMessage("АЛЬТЕРНАТ\uFFFD?ВА", [
"ООО Альтернатива Плюс",
"ООО Альтернатива Минус",
"ООО Лайсвуд"
]);
expect(resolved).toBeNull();
});
it("treats minor live label corruption as the same organization entity", () => {
expect(organizationsLikelySameEntity("Альтернатива Плюс", 'ООО "Альтернати"а Плюс"')).toBe(true);
});
@@ -31,4 +31,26 @@ describe("autoruns question extraction", () => {
expect(parsed[0]).toMatch(/поставщик/i);
expect(parsed[0]).toMatch(/коротко/i);
});
it("repairs old autorun C1-control mojibake before exposing cards and questions", () => {
const damagedTitle = String.fromCharCode(
0x420, 0x2018, 0x420, 0x45b, 0x420, 0x203a, 0x420, 0xac, 0x420, 0x401, 0x420, 0x45b, 0x420,
0x2122, 0x20, 0x420, 0x45b, 0x420, 0x2018, 0x420, 0xa9, 0x420, 0x98, 0x420, 0x2122, 0x20,
0x420, 0xa0, 0x421, 0x453, 0x421, 0x2021, 0x420, 0x405, 0x420, 0xb0, 0x421, 0x40f, 0x20,
0x421, 0x403, 0x420, 0xb5, 0x421, 0x403, 0x421, 0x403, 0x420, 0x451, 0x421, 0x40f, 0x20,
0x31, 0x36, 0x2e, 0x30, 0x34, 0x2e, 0x32, 0x30, 0x32, 0x36, 0x2c, 0x20, 0x32, 0x31,
0x3a, 0x32, 0x36, 0x3a, 0x30, 0x36
);
const damagedAlternative = String.fromCharCode(
0x420, 0x452, 0x420, 0x203a, 0x420, 0xac, 0x420, 0x45e, 0x420, 0x2022, 0x420, 0xa0, 0x420,
0x45c, 0x420, 0x452, 0x420, 0x45e, 0x420, 0x98, 0x420, 0x2019, 0x420, 0x452
);
expect(__autoRunsQuestionTestUtils.repairAutogenMojibake(damagedTitle)).toBe(
"\u0411\u041e\u041b\u042c\u0428\u041e\u0419 \u041e\u0411\u0429\u0418\u0419 \u0420\u0443\u0447\u043d\u0430\u044f \u0441\u0435\u0441\u0441\u0438\u044f 16.04.2026, 21:26:06"
);
expect(__autoRunsQuestionTestUtils.repairAutogenMojibake(damagedAlternative)).toBe(
"\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\u0418\u0412\u0410"
);
});
});
@@ -33,4 +33,37 @@ describe("eval runtime question splitting", () => {
expect(parsed[0]).toMatch(/поставщик/i);
expect(parsed[0]).toMatch(/коротко/i);
});
it("repairs mojibake questions before runtime job materialization", () => {
const parsed = __evalRouteTestUtils.normalizeRuntimeQuestions([
"кайф - что там на складе по остаткам?"
]);
expect(parsed).toEqual(["кайф - что там на складе по остаткам?"]);
});
it("repairs damaged clarification as one scenario turn when splitting is disabled", () => {
const parsed = __evalRouteTestUtils.normalizeRuntimeQuestions(
["\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\uFFFD?\u0412\u0410"],
{
dedupe: false,
splitCandidates: false
}
);
expect(parsed).toEqual(["\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\u0418\u0412\u0410"]);
});
it("repairs C1-control autorun clarification before runtime job materialization", () => {
const damagedAlternative = String.fromCharCode(
0x420, 0x452, 0x420, 0x203a, 0x420, 0xac, 0x420, 0x45e, 0x420, 0x2022, 0x420, 0xa0, 0x420,
0x45c, 0x420, 0x452, 0x420, 0x45e, 0x420, 0x98, 0x420, 0x2019, 0x420, 0x452
);
const parsed = __evalRouteTestUtils.normalizeRuntimeQuestions([damagedAlternative], {
dedupe: false,
splitCandidates: false
});
expect(parsed).toEqual(["\u0410\u041b\u042c\u0422\u0415\u0420\u041d\u0410\u0422\u0418\u0412\u0410"]);
});
});