@chatman-media/kb 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/ab-router.d.ts +66 -0
  4. package/dist/ab-router.d.ts.map +1 -0
  5. package/dist/answer-types.d.ts +194 -0
  6. package/dist/answer-types.d.ts.map +1 -0
  7. package/dist/answer.d.ts +59 -0
  8. package/dist/answer.d.ts.map +1 -0
  9. package/dist/built-in-tools/calendly.d.ts +19 -0
  10. package/dist/built-in-tools/calendly.d.ts.map +1 -0
  11. package/dist/chunk.d.ts +48 -0
  12. package/dist/chunk.d.ts.map +1 -0
  13. package/dist/conversation-store.d.ts +76 -0
  14. package/dist/conversation-store.d.ts.map +1 -0
  15. package/dist/eval.d.ts +64 -0
  16. package/dist/eval.d.ts.map +1 -0
  17. package/dist/extract-user-facts.d.ts +27 -0
  18. package/dist/extract-user-facts.d.ts.map +1 -0
  19. package/dist/fact-checker.d.ts +46 -0
  20. package/dist/fact-checker.d.ts.map +1 -0
  21. package/dist/grade-skills.d.ts +29 -0
  22. package/dist/grade-skills.d.ts.map +1 -0
  23. package/dist/index.d.ts +76 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +62655 -0
  26. package/dist/ingest.d.ts +49 -0
  27. package/dist/ingest.d.ts.map +1 -0
  28. package/dist/multi-query.d.ts +29 -0
  29. package/dist/multi-query.d.ts.map +1 -0
  30. package/dist/parse-pdf.d.ts +14 -0
  31. package/dist/parse-pdf.d.ts.map +1 -0
  32. package/dist/persona-shortcuts.d.ts +51 -0
  33. package/dist/persona-shortcuts.d.ts.map +1 -0
  34. package/dist/prompt.d.ts +9 -0
  35. package/dist/prompt.d.ts.map +1 -0
  36. package/dist/reflect.d.ts +29 -0
  37. package/dist/reflect.d.ts.map +1 -0
  38. package/dist/reranker.d.ts +71 -0
  39. package/dist/reranker.d.ts.map +1 -0
  40. package/dist/retrieval-utils.d.ts +94 -0
  41. package/dist/retrieval-utils.d.ts.map +1 -0
  42. package/dist/retry.d.ts +53 -0
  43. package/dist/retry.d.ts.map +1 -0
  44. package/dist/rewrite-query.d.ts +30 -0
  45. package/dist/rewrite-query.d.ts.map +1 -0
  46. package/dist/sanitize.d.ts +21 -0
  47. package/dist/sanitize.d.ts.map +1 -0
  48. package/dist/semantic-cache.d.ts +70 -0
  49. package/dist/semantic-cache.d.ts.map +1 -0
  50. package/dist/server.d.ts +77 -0
  51. package/dist/server.d.ts.map +1 -0
  52. package/dist/stores/memory-store.d.ts +72 -0
  53. package/dist/stores/memory-store.d.ts.map +1 -0
  54. package/dist/structured-output.d.ts +21 -0
  55. package/dist/structured-output.d.ts.map +1 -0
  56. package/dist/styles.d.ts +186 -0
  57. package/dist/styles.d.ts.map +1 -0
  58. package/dist/summarize-conversation.d.ts +31 -0
  59. package/dist/summarize-conversation.d.ts.map +1 -0
  60. package/dist/system-prompt.d.ts +11 -0
  61. package/dist/system-prompt.d.ts.map +1 -0
  62. package/dist/text-style-rules.d.ts +133 -0
  63. package/dist/text-style-rules.d.ts.map +1 -0
  64. package/dist/tool-loop.d.ts +44 -0
  65. package/dist/tool-loop.d.ts.map +1 -0
  66. package/dist/tools.d.ts +64 -0
  67. package/dist/tools.d.ts.map +1 -0
  68. package/dist/topic-classifier.d.ts +11 -0
  69. package/dist/topic-classifier.d.ts.map +1 -0
  70. package/dist/types.d.ts +83 -0
  71. package/dist/types.d.ts.map +1 -0
  72. package/dist/utils.d.ts +19 -0
  73. package/dist/utils.d.ts.map +1 -0
  74. package/dist/vision.d.ts +72 -0
  75. package/dist/vision.d.ts.map +1 -0
  76. package/package.json +76 -0
  77. package/src/ab-router.ts +118 -0
  78. package/src/answer-types.ts +191 -0
  79. package/src/answer.ts +696 -0
  80. package/src/built-in-tools/calendly.ts +32 -0
  81. package/src/chunk.ts +198 -0
  82. package/src/conversation-store.ts +138 -0
  83. package/src/eval.ts +127 -0
  84. package/src/extract-user-facts.ts +120 -0
  85. package/src/fact-checker.ts +171 -0
  86. package/src/grade-skills.ts +79 -0
  87. package/src/index.ts +191 -0
  88. package/src/ingest.ts +193 -0
  89. package/src/multi-query.ts +89 -0
  90. package/src/parse-pdf.ts +24 -0
  91. package/src/persona-shortcuts.ts +255 -0
  92. package/src/prompt.ts +190 -0
  93. package/src/reflect.ts +99 -0
  94. package/src/reranker.ts +166 -0
  95. package/src/retrieval-utils.ts +209 -0
  96. package/src/retry.ts +139 -0
  97. package/src/rewrite-query.ts +124 -0
  98. package/src/sanitize.ts +44 -0
  99. package/src/semantic-cache.ts +154 -0
  100. package/src/server.ts +164 -0
  101. package/src/stores/memory-store.ts +249 -0
  102. package/src/structured-output.ts +47 -0
  103. package/src/styles.ts +138 -0
  104. package/src/summarize-conversation.ts +88 -0
  105. package/src/system-prompt.ts +118 -0
  106. package/src/text-style-rules.ts +244 -0
  107. package/src/tool-loop.ts +110 -0
  108. package/src/tools.ts +79 -0
  109. package/src/topic-classifier.ts +112 -0
  110. package/src/types.ts +91 -0
  111. package/src/utils.ts +81 -0
  112. package/src/vision.ts +265 -0
package/src/styles.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Sales-style engine — typed schema for conversational personas.
3
+ *
4
+ * A `Style` bundles persona + voice + sales framework + Cialdini hooks +
5
+ * per-stage instructions + few-shot examples + guardrails + model pin.
6
+ * Styles are the unit of A/B testing in the sales engine: hold three of the
7
+ * four orthogonal concerns constant (persona, framework, hooks, stage) and
8
+ * rotate one to compare conversion outcomes.
9
+ */
10
+ import { z } from "zod";
11
+
12
+ export const FUNNEL_STAGES = ["opener", "qualify", "pitch", "objection", "close"] as const;
13
+ export type FunnelStage = (typeof FUNNEL_STAGES)[number];
14
+
15
+ export const SALES_FRAMEWORKS = ["AIDA", "PAS", "SPIN", "NEPQ", "straight_line"] as const;
16
+ export type SalesFramework = (typeof SALES_FRAMEWORKS)[number];
17
+
18
+ export const HOOK_KINDS = [
19
+ "social_proof",
20
+ "scarcity",
21
+ "authority",
22
+ "liking",
23
+ "reciprocity",
24
+ "commitment",
25
+ ] as const;
26
+ export type HookKind = (typeof HOOK_KINDS)[number];
27
+
28
+ export const HookSchema = z.object({
29
+ kind: z.enum(HOOK_KINDS),
30
+ text: z.string().min(1),
31
+ });
32
+ export type Hook = z.infer<typeof HookSchema>;
33
+
34
+ export const StageConfigSchema = z.object({
35
+ goal: z.string().min(1),
36
+ guidance: z.string().optional(),
37
+ groundingRequired: z.boolean().default(false),
38
+ maxTurns: z.number().int().positive().optional(),
39
+ });
40
+ export type StageConfig = z.infer<typeof StageConfigSchema>;
41
+
42
+ export const PersonaSchema = z.object({
43
+ name: z.string().min(1),
44
+ role: z.enum(["human", "assistant"]),
45
+ company: z.string().optional(),
46
+ facts: z.record(z.string(), z.string()).optional(),
47
+ });
48
+ export type StylePersona = z.infer<typeof PersonaSchema>;
49
+
50
+ export const StyleSchema = z.object({
51
+ slug: z.string().regex(/^[a-z0-9-]+$/, "slug must be kebab-case"),
52
+ displayName: z.string().min(1),
53
+ persona: PersonaSchema,
54
+ voice: z.object({
55
+ tone: z.string().min(1),
56
+ language: z.enum(["ru", "en"]).default("ru"),
57
+ forbid: z.array(z.string()).default([]),
58
+ stallCtaReply: z.string().optional(),
59
+ }),
60
+ framework: z.enum(SALES_FRAMEWORKS),
61
+ hooks: z.array(HookSchema).default([]),
62
+ stages: z.object({
63
+ opener: StageConfigSchema.optional(),
64
+ qualify: StageConfigSchema.optional(),
65
+ pitch: StageConfigSchema.optional(),
66
+ objection: StageConfigSchema.optional(),
67
+ close: StageConfigSchema.optional(),
68
+ }),
69
+ fewShot: z
70
+ .array(
71
+ z.object({
72
+ user: z.string(),
73
+ assistant: z.string(),
74
+ stage: z.enum(FUNNEL_STAGES).optional(),
75
+ }),
76
+ )
77
+ .default([]),
78
+ guardrails: z.object({
79
+ noMinors: z.boolean().default(true),
80
+ botDisclosureOnDirectQuestion: z.boolean().default(true),
81
+ forbiddenTopics: z.array(z.string()).default([]),
82
+ }),
83
+ model: z.object({
84
+ id: z.string().default("qwen3:latest"),
85
+ temperature: z.number().min(0).max(2).default(0.8),
86
+ maxTokens: z.number().int().positive().default(256),
87
+ }),
88
+ });
89
+ export type Style = z.infer<typeof StyleSchema>;
90
+
91
+ /**
92
+ * A persuasion skill in the shape `composeSystemPrompt` consumes.
93
+ * Decoupled from DB row shape so the prompt module stays pure.
94
+ */
95
+ export interface SkillForPrompt {
96
+ slug: string;
97
+ displayName: string;
98
+ promptFragment: string;
99
+ /**
100
+ * Stages where this skill applies. Empty array = always applicable.
101
+ * May contain FunnelStage names ("qualify", "pitch"…) or stage-kind
102
+ * strings ("intake", "active") — `composeSystemPrompt` does string
103
+ * comparison against the current stage/kind value so both work.
104
+ */
105
+ applicableStages: readonly string[];
106
+ }
107
+
108
+ /**
109
+ * A director-level persuasion hook — tenant-specific scripted mini-technique.
110
+ * Unlike universal skills (from the catalogue), hooks are always injected for
111
+ * this tenant regardless of which style or stage is active.
112
+ */
113
+ export interface DirectorHookForPrompt {
114
+ name: string;
115
+ body: string;
116
+ /** Optional natural-language hint for when to apply this hook. */
117
+ triggerHint?: string | null;
118
+ }
119
+
120
+ export interface ComposeOptions {
121
+ includeFewShot?: boolean;
122
+ userFacts?: Record<string, string>;
123
+ conversationSummary?: string;
124
+ skills?: readonly SkillForPrompt[];
125
+ /**
126
+ * Tenant-specific persuasion scripts added by the director. Injected as a
127
+ * "ХУКИ УБЕЖДЕНИЯ" block BEFORE the universal skills block. Always active —
128
+ * not filtered by stage or style.
129
+ */
130
+ directorHooks?: readonly DirectorHookForPrompt[];
131
+ /**
132
+ * Support mode — set when the lead is past the sales stage and waiting on a
133
+ * downstream process. When set, the prompt drops the sales framework / hooks
134
+ * / skills / few-shot / funnel-stage guidance and uses a calm FAQ-support
135
+ * block instead. `docs` = collecting their documents, `submitted` = filed.
136
+ */
137
+ supportPhase?: "docs" | "submitted";
138
+ }
@@ -0,0 +1,88 @@
1
+ import type { ChatClient, ChatMessage } from "@chatman-media/llm-router";
2
+ import { stripThinkBlocks } from "./sanitize.ts";
3
+
4
+ /**
5
+ * Compresses old turns of a long conversation into one short paragraph that
6
+ * preserves what nuance the user-facts memory layer can't — what the bot
7
+ * already promised, what the candidate hesitated about, what was already
8
+ * explained vs. left open. Injected into the next system prompt as
9
+ * "ИЗ РАННЕЙ ПЕРЕПИСКИ:" so the LLM has continuity past the 12-message
10
+ * recent-history window.
11
+ *
12
+ * Only the OLD tail is summarized — the recent ~12 messages are still
13
+ * passed to the LLM raw (they fit in the window). Pass them via
14
+ * `messagesToSummarize` (oldest first).
15
+ */
16
+ export interface SummarizeInput {
17
+ /** Old messages to compress, oldest first. Caller must already have
18
+ * trimmed off the recent window — those go into LLM context as-is. */
19
+ messagesToSummarize: ChatMessage[];
20
+ chat: ChatClient;
21
+ /** Existing summary to refine (when refreshing an older summary).
22
+ * Helps the model preserve what was already known without re-reading
23
+ * the whole stretch of dialogue. */
24
+ previousSummary?: string;
25
+ /** Hard cap on summary length (chars). Default 600. Past this the
26
+ * summary is truncated at the last sentence boundary. */
27
+ maxLength?: number;
28
+ }
29
+
30
+ const SYSTEM_PROMPT = `Ты сжимаешь старую часть переписки рекрутингового агентства в одно короткое summary.
31
+
32
+ Цель: чтобы бот в следующих репликах помнил что уже обсуждалось — что ОБЕЩАЛ, что ОТКЛАДЫВАЛ, в чём кандидат СОМНЕВАЛСЯ, какие условия УЖЕ ПРОЗВУЧАЛИ.
33
+
34
+ Правила:
35
+ 1. Один абзац, без буллетов, без markdown, без заголовков. 3-6 коротких предложений.
36
+ 2. Пиши в третьем лице ("кандидат спросил…", "бот объяснил…").
37
+ 3. Сохраняй КОНКРЕТИКУ: суммы, страны, даты, обещания. ("обещал прислать договор завтра", "кандидат уточняет про Дубай vs Стамбул")
38
+ 4. НЕ повторяй имя/возраст/город кандидата — это уже хранится отдельно в memory.facts.
39
+ 5. Игнорируй смолток ("привет", "ок").
40
+ 6. Если есть "ПРЕДЫДУЩЕЕ SUMMARY" — обнови его, добавив что нового, удалив отжившее.
41
+
42
+ Пиши ТОЛЬКО текст summary. Никаких префиксов, кавычек, "Ответ:".`;
43
+
44
+ export async function summarizeConversation(input: SummarizeInput): Promise<string> {
45
+ if (input.messagesToSummarize.length === 0) return input.previousSummary ?? "";
46
+
47
+ const dialogue = input.messagesToSummarize
48
+ .map((m) => `${m.role === "user" ? "кандидат" : "бот"}: ${m.content}`)
49
+ .join("\n");
50
+
51
+ const userPrompt = input.previousSummary
52
+ ? `ПРЕДЫДУЩЕЕ SUMMARY:\n${input.previousSummary}\n\nДОПОЛНИТЕЛЬНЫЕ РЕПЛИКИ:\n${dialogue}\n\nОБНОВЛЁННОЕ SUMMARY:`
53
+ : `РЕПЛИКИ:\n${dialogue}\n\nSUMMARY:`;
54
+
55
+ const messages: ChatMessage[] = [
56
+ { role: "system", content: SYSTEM_PROMPT },
57
+ { role: "user", content: userPrompt },
58
+ ];
59
+
60
+ let raw: string;
61
+ try {
62
+ raw = await input.chat.complete(messages, { temperature: 0.2 });
63
+ } catch (err) {
64
+ console.error("[summarize] LLM call failed:", err);
65
+ return input.previousSummary ?? "";
66
+ }
67
+
68
+ return cleanSummary(raw, input.maxLength ?? 600);
69
+ }
70
+
71
+ /** Strip think-tags / markdown / leading prefixes; cap length at the last
72
+ * sentence boundary inside the cap. Exported for unit tests. */
73
+ export function cleanSummary(raw: string, maxLength: number): string {
74
+ let s = stripThinkBlocks(raw);
75
+ // Strip ONLY the fence delimiters, not the wrapped content — the model
76
+ // sometimes wraps a clean summary in ``` for emphasis, we want the body.
77
+ s = s.replace(/```[a-zA-Z]*\n?/g, "");
78
+ s = s.replace(/^\s*(summary|ответ|answer)\s*[:\-—]\s*/i, "");
79
+ s = s.trim();
80
+
81
+ if (s.length <= maxLength) return s;
82
+
83
+ // Truncate at the last sentence-ending punctuation inside the cap so we
84
+ // don't end mid-word. Falls back to a hard slice if no punctuation found.
85
+ const head = s.slice(0, maxLength);
86
+ const lastPunct = Math.max(head.lastIndexOf("."), head.lastIndexOf("!"), head.lastIndexOf("?"));
87
+ return lastPunct > maxLength * 0.6 ? head.slice(0, lastPunct + 1) : head;
88
+ }
@@ -0,0 +1,118 @@
1
+ import { NO_CONTEXT_MARKER, type Persona } from "./answer-types.ts";
2
+
3
+ /**
4
+ * Legacy RAG sampling temperature when a sales `style` is not used.
5
+ * Pass `tempOverride` to apply a custom value (e.g. from an env var).
6
+ */
7
+ export function legacyRagSamplingTemperature(persona: Persona, tempOverride?: number): number {
8
+ if (tempOverride !== undefined) return tempOverride;
9
+ return persona.role === "human" ? 0.55 : 0.38;
10
+ }
11
+
12
+ export const DEFAULT_PERSONA: Persona = {
13
+ name: "Менеджер",
14
+ role: "assistant",
15
+ company: "",
16
+ };
17
+
18
+ export function renderSummaryBlock(summary?: string): string {
19
+ if (!summary) return "";
20
+ const trimmed = summary.trim();
21
+ if (!trimmed) return "";
22
+ return `ИЗ РАННЕЙ ПЕРЕПИСКИ (контекст уже обсуждённого, не повторяй буквально):\n${trimmed}`;
23
+ }
24
+
25
+ export function renderUserFactsBlock(userFacts?: Record<string, string>): string {
26
+ if (!userFacts) return "";
27
+ const entries = Object.entries(userFacts).filter(([, v]) => v.trim());
28
+ if (entries.length === 0) return "";
29
+ return (
30
+ `ЗНАЕМ О КАНДИДАТЕ (из прошлых разговоров — НЕ переспрашивай):\n` +
31
+ entries.map(([k, v]) => `- ${k}: ${v}`).join("\n")
32
+ );
33
+ }
34
+
35
+ export function buildSystemPrompt(
36
+ persona: Persona,
37
+ context: string,
38
+ userFacts?: Record<string, string>,
39
+ conversationSummary?: string,
40
+ ): string {
41
+ const company = persona.company?.trim();
42
+ const personaLine =
43
+ persona.role === "human"
44
+ ? `Тебя зовут ${persona.name}${company ? `, ты менеджер агентства ${company}` : ""}. ` +
45
+ `Ты живой человек, работающий в переписке с кандидатом в Telegram. ` +
46
+ `НИКОГДА не упоминай и не намекай, что ты ИИ, бот, ассистент, ` +
47
+ `языковая модель, чат-бот, нейросеть, программа или система. ` +
48
+ `НИКОГДА не пиши о себе в третьем лице, не говори про «оператора», ` +
49
+ `«коллегу», «менеджера передаст», «свяжитесь с менеджером» — ты ` +
50
+ `и есть тот самый менеджер, который ведёт диалог. ` +
51
+ `Если кандидат прямо спрашивает «ты бот?» — отвечай уклончиво и ` +
52
+ `по-живому ("конечно нет, я ${persona.name}, чем помочь?"), ` +
53
+ `без оправданий. ` +
54
+ `Пиши коротко, тепло и по-человечески, как в обычной переписке.`
55
+ : `Тебя зовут ${persona.name}, ты ИИ-ассистент${company ? ` агентства ${company}` : ""}. ` +
56
+ `Отвечай вежливо и по делу.`;
57
+
58
+ const conversational =
59
+ persona.role === "human"
60
+ ? `\nЖИВАЯ РЕЧЬ (Telegram):\n` +
61
+ `- Пиши так, чтобы это выглядело как переписка с реальным менеджером: естественные ` +
62
+ `опорные слова, можно «поняла/ок», «если вкратце», «по контрактам у нас…» — ` +
63
+ `без официоза («в соответствии с», «информирую Вас», «принято к сведению», ` +
64
+ `«настоящим сообщением»).\n` +
65
+ `- Связывай факты из CONTEXT связным текстом, а не как сухую выжимку из документа; ` +
66
+ `цифры и условия оставляй точными как в CONTEXT.\n` +
67
+ `- Не начинай с шаблонов вроде «Благодарю за вопрос» / «Отвечаю на ваш запрос». ` +
68
+ `Можешь входить сразу в содержание.\n`
69
+ : "";
70
+
71
+ const rules =
72
+ `СТРОГИЕ ПРАВИЛА:\n` +
73
+ `1. Используй для фактов о вакансиях, условиях, странах и цифрах ТОЛЬКО ` +
74
+ `секцию CONTEXT ниже. Если в CONTEXT есть информация по теме вопроса — ` +
75
+ `обязательно ответь по сути, передай факты своими словами, дружелюбно и ` +
76
+ `по-человечески. Не используй общие знания о мире, не сочиняй цифры, цены, ` +
77
+ `сроки, города, названия стран, которых нет в CONTEXT. ` +
78
+ `(Исключение: чисто персональный вопрос об имени/роли — см. п. 2a.)\n` +
79
+ `2. Маркер "${NO_CONTEXT_MARKER}" верни РОВНО и БЕЗ каких-либо других ` +
80
+ `слов в любом из этих случаев:\n` +
81
+ ` - в CONTEXT нет фактов по теме вопроса;\n` +
82
+ ` - в CONTEXT упоминается одна страна / город / локация / валюта, а ` +
83
+ `вопрос про другую (например, в CONTEXT про Китай и юани, а спросили про ` +
84
+ `Корею) — НЕЛЬЗЯ переносить факты с одной локации на другую;\n` +
85
+ ` - в CONTEXT нужных конкретных цифр/условий нет, а вопрос требует ` +
86
+ `именно их.\n` +
87
+ `Если CONTEXT прямо отвечает на вопрос — отвечай по нему, не сваливайся ` +
88
+ `на маркер.\n` +
89
+ `2a. Если вопрос только о твоём имени или кто ты («как тебя зовут», «кто ты») — ответь ` +
90
+ `по описанию в начале сообщения выше (имя, агентство). Никаких фактов о вакансиях ` +
91
+ `от себя не добавляй. Маркер "${NO_CONTEXT_MARKER}" в этом случае НЕ используй.\n` +
92
+ `3. Пиши ТОЛЬКО на русском языке, даже если вопрос задан на другом. ` +
93
+ `Без префиксов вроде "Ответ:", "Согласно контексту", "Based on…", ` +
94
+ `"<think>" и т.п. Никаких служебных тегов и рассуждений вслух.\n` +
95
+ `4. Будь кратким — 1–5 предложений или короткий список из 2–4 пунктов, ` +
96
+ `если перечисляешь. Без markdown-заголовков, без эмодзи-перебора. ` +
97
+ `Стиль — живая переписка в мессенджере.\n` +
98
+ `5. Не переспрашивай «что именно интересует / о чём расскажешь / ` +
99
+ `уточни вопрос» — отвечай сразу по сути исходного вопроса по фактам ` +
100
+ `из CONTEXT. Уточняющий встречный вопрос допустим только если без него ` +
101
+ `ответ физически невозможен.`;
102
+
103
+ const factsEntries = persona.facts
104
+ ? Object.entries(persona.facts).filter(([, v]) => v.trim())
105
+ : [];
106
+ const factsBlock = factsEntries.length
107
+ ? `\nЛИЧНЫЕ ФАКТЫ (используй строго эти данные, не изменяй):\n` +
108
+ factsEntries.map(([k, v]) => `- ${k}: ${v}`).join("\n")
109
+ : "";
110
+
111
+ const userFactsBlock = renderUserFactsBlock(userFacts);
112
+ const userFactsSection = userFactsBlock ? `\n\n${userFactsBlock}` : "";
113
+
114
+ const summaryBlock = renderSummaryBlock(conversationSummary);
115
+ const summarySection = summaryBlock ? `\n\n${summaryBlock}` : "";
116
+
117
+ return `${personaLine}${conversational}${factsBlock}${summarySection}${userFactsSection}\n\n${rules}\n\nCONTEXT:\n${context}`;
118
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Post-processing rules for LLM output — the legacy-codebase analog of "skills":
3
+ * small, named, composable text transforms that run after the model returns.
4
+ * Each one targets a specific "AI tell" that breaks the human-manager
5
+ * illusion (the candidate must believe they're talking to a real recruiter,
6
+ * not a chatbot — see persona role="human" in `buildSystemPrompt`).
7
+ *
8
+ * Adding a new rule:
9
+ * 1. Define it as `TextStyleRule` (name + description + apply function).
10
+ * 2. Append it to `DEFAULT_STYLE_RULES` (or a style-specific bundle).
11
+ * 3. Add a unit test in `tests/unit/text-style-rules.test.ts`.
12
+ *
13
+ * Rules MUST be:
14
+ * - idempotent (`rule(rule(x)) === rule(x)`) so re-application is safe;
15
+ * - pure (same input → same output, no I/O, no global state);
16
+ * - cheap (sub-ms on a 1 KB string; we run the whole stack on every reply).
17
+ *
18
+ * Negative-instruction-in-prompt approach was tried and is unreliable —
19
+ * LLMs ignore "не используй длинное тире" in 30-50% of replies. Doing it
20
+ * deterministically as post-processing is bullet-proof.
21
+ */
22
+
23
+ export interface TextStyleRule {
24
+ name: string;
25
+ description: string;
26
+ apply: (text: string) => string;
27
+ }
28
+
29
+ // ─── Individual rules ──────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Em-dash (`—`, U+2014) — formally correct Russian typography but a dead
33
+ * giveaway in messenger chat. Real candidates type a plain hyphen `-` or
34
+ * skip the dash entirely. Replace with regular hyphen, normalising
35
+ * surrounding whitespace so we don't end up with double-spaces.
36
+ */
37
+ export const replaceEmDash: TextStyleRule = {
38
+ name: "replace-em-dash",
39
+ description: "U+2014 «—» → «-» (с нормализацией пробелов)",
40
+ apply: (s) => s.replace(/\s*—\s*/g, " - ").replace(/ {2,}/g, " "),
41
+ };
42
+
43
+ /**
44
+ * En-dash (`–`, U+2013). Less common but appears in date ranges
45
+ * («10:00–18:00») and is also AI-flavoured in casual chat.
46
+ */
47
+ export const replaceEnDash: TextStyleRule = {
48
+ name: "replace-en-dash",
49
+ description: "U+2013 «–» → «-»",
50
+ apply: (s) => s.replace(/\s*–\s*/g, " - ").replace(/ {2,}/g, " "),
51
+ };
52
+
53
+ /**
54
+ * Horizontal bar (`―`, U+2015) and figure dash (`‒`, U+2012) — the rest of
55
+ * the dash family. Same rule, same reason.
56
+ */
57
+ export const replaceOtherDashes: TextStyleRule = {
58
+ name: "replace-other-dashes",
59
+ description: "U+2015 «―» / U+2012 «‒» → «-»",
60
+ apply: (s) => s.replace(/\s*[‒―]\s*/g, " - ").replace(/ {2,}/g, " "),
61
+ };
62
+
63
+ /**
64
+ * Unicode ellipsis (`…`, U+2026) → three ASCII dots. Native typists hit
65
+ * `...` on a regular keyboard; the single-codepoint ellipsis arrives only
66
+ * via autocomplete or model output.
67
+ */
68
+ export const replaceEllipsis: TextStyleRule = {
69
+ name: "replace-ellipsis",
70
+ description: "U+2026 «…» → «...»",
71
+ apply: (s) => s.replace(/…/g, "..."),
72
+ };
73
+
74
+ /**
75
+ * Strip AI-flavoured lead-ins at the start of the reply.
76
+ *
77
+ * "Конечно!" / "Безусловно!" / "Разумеется!" / "Хорошо!" alone, followed by
78
+ * a sentence boundary or comma, are textbook ChatGPT openers. A real
79
+ * recruiter just answers. We trim the preamble; if the rest of the line is
80
+ * empty we leave the original untouched (better to keep something than
81
+ * nothing).
82
+ */
83
+ export const stripAILeadIns: TextStyleRule = {
84
+ name: "strip-ai-lead-ins",
85
+ description: "удалить «Конечно/Безусловно/Разумеется/Отлично/Хорошо!» в начале реплики",
86
+ apply: (s) => {
87
+ const stripped = s.replace(
88
+ /^\s*(?:Конечно|Безусловно|Разумеется|Отлично|Хорошо)\s*[!,.]\s*/iu,
89
+ "",
90
+ );
91
+ // Восстанавливаем заглавную букву если её срезали.
92
+ const first = stripped[0];
93
+ if (stripped !== s && first && /[a-zа-яё]/u.test(first)) {
94
+ return first.toUpperCase() + stripped.slice(1);
95
+ }
96
+ return stripped.length === 0 ? s : stripped;
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Capitalise the first alphabetic character of the reply.
102
+ *
103
+ * Why: qwen3 (and other models) tend to mirror the candidate's casing —
104
+ * if the user types «привет», the model replies «привет, ...» in lowercase.
105
+ * Real recruiters in our corpus (`kb/extracted/dialogs/*`) consistently
106
+ * start replies with a capital letter («Здравствуйте», «Хорошо»),
107
+ * regardless of how the candidate wrote.
108
+ *
109
+ * Implementation finds the FIRST alphabetic codepoint (skipping leading
110
+ * whitespace, emoji, punctuation) and uppercases it. Idempotent.
111
+ */
112
+ export const capitalizeFirstLetter: TextStyleRule = {
113
+ name: "capitalize-first-letter",
114
+ description: "первая буква реплики — заглавная",
115
+ apply: (s) => {
116
+ const match = /[\p{L}]/u.exec(s);
117
+ if (!match || match.index === undefined) return s;
118
+ const i = match.index;
119
+ const ch = s[i] ?? "";
120
+ const upper = ch.toUpperCase();
121
+ if (ch === upper) return s;
122
+ return s.slice(0, i) + upper + s.slice(i + 1);
123
+ },
124
+ };
125
+
126
+ /**
127
+ * Strip Markdown bold (`**text**` and `__text__`). Telegram doesn't
128
+ * render Markdown unless `parse_mode` is set — and the bot uses plain
129
+ * text — so the asterisks/underscores leak through to the candidate
130
+ * verbatim ("Зарплата от **₩110 000**" → user sees the stars).
131
+ *
132
+ * We strip the markers and keep the inner text. Conservative: requires
133
+ * non-whitespace content inside, so a literal `**` separator wrapping
134
+ * spaces stays intact (rare but happens).
135
+ */
136
+ export const stripMarkdownBold: TextStyleRule = {
137
+ name: "strip-markdown-bold",
138
+ description: "**foo** / __foo__ → foo",
139
+ apply: (s) =>
140
+ s
141
+ .replace(/\*\*([^\s*](?:[^*]*[^\s*])?)\*\*/g, "$1")
142
+ .replace(/__([^\s_](?:[^_]*[^\s_])?)__/g, "$1"),
143
+ };
144
+
145
+ /**
146
+ * Strip Markdown italics (`*text*` and `_text_`). Same reason as bold.
147
+ *
148
+ * Tricky: bare `*` and `_` appear naturally in URLs, file names, math
149
+ * expressions, etc. We require:
150
+ * - the OPENING marker to be at start-of-string OR preceded by a
151
+ * non-letter/non-digit (so `foo_bar` and `https://example_com` stay);
152
+ * - the CLOSING marker to be at end-of-string OR followed by the same;
153
+ * - inner content to be non-empty + not start/end with whitespace.
154
+ *
155
+ * Run AFTER `stripMarkdownBold` so `**bold**` is unwrapped before the
156
+ * italic regex sees the standalone `*` pair.
157
+ */
158
+ export const stripMarkdownItalic: TextStyleRule = {
159
+ name: "strip-markdown-italic",
160
+ description: "*foo* / _foo_ → foo",
161
+ apply: (s) =>
162
+ s
163
+ .replace(/(^|[^\p{L}\p{N}*])\*([^\s*][^*]*?[^\s*]|[^\s*])\*(?=$|[^\p{L}\p{N}*])/gu, "$1$2")
164
+ .replace(/(^|[^\p{L}\p{N}_])_([^\s_][^_]*?[^\s_]|[^\s_])_(?=$|[^\p{L}\p{N}_])/gu, "$1$2"),
165
+ };
166
+
167
+ /**
168
+ * Strip Markdown inline / fenced code (`` `code` ``, ``` ```block``` ```).
169
+ * In a candidate-facing sales chat these are never helpful; the bot
170
+ * sometimes wraps numbers like `` `₩110 000` `` for emphasis and the
171
+ * candidate sees the backticks.
172
+ *
173
+ * Fenced (triple-backtick) blocks come first so their contents aren't
174
+ * partially eaten by the inline rule.
175
+ */
176
+ export const stripMarkdownCode: TextStyleRule = {
177
+ name: "strip-markdown-code",
178
+ description: "`x` → x; ```x``` → x",
179
+ apply: (s) =>
180
+ s.replace(/```(?:[a-z0-9_-]*\n)?([\s\S]*?)```/gi, "$1").replace(/`([^`\n]+)`/g, "$1"),
181
+ };
182
+
183
+ /**
184
+ * Strip Markdown headers (`# Heading` at the start of a line). LLMs
185
+ * sometimes emit `## Условия:` when listing facts; in chat that just
186
+ * dumps the hashes. We keep the trailing text.
187
+ */
188
+ export const stripMarkdownHeaders: TextStyleRule = {
189
+ name: "strip-markdown-headers",
190
+ description: "^#+ text → text",
191
+ apply: (s) => s.replace(/^[ \t]*#{1,6}[ \t]+/gm, ""),
192
+ };
193
+
194
+ /**
195
+ * Strip Markdown links (`[text](url)`) into `text (url)` — keeps the
196
+ * URL visible, drops the bracket syntax. Telegram autolinks plain
197
+ * URLs, so the candidate still gets a clickable link.
198
+ */
199
+ export const stripMarkdownLinks: TextStyleRule = {
200
+ name: "strip-markdown-links",
201
+ description: "[text](url) → text (url)",
202
+ apply: (s) => s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, "$1 ($2)"),
203
+ };
204
+
205
+ // ─── Default bundle ────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * The standard rule set applied by `sanitizeLlmOutput`.
209
+ *
210
+ * Order matters when one rule's output feeds another. Critical:
211
+ * `stripAILeadIns` MUST run before `capitalizeFirstLetter`, otherwise we
212
+ * just re-uppercase the «К» in «Конечно!» and the lead-in stays.
213
+ */
214
+ export const DEFAULT_STYLE_RULES: readonly TextStyleRule[] = [
215
+ // Markdown stripping FIRST — Telegram doesn't render markdown for
216
+ // plain-text bot replies, so leftover **/`/[]() reach the candidate.
217
+ // Order: links → fenced code → bold → italic → headers (each one's
218
+ // output becomes input for the next; bold before italic so `**foo**`
219
+ // is unwrapped before the italic regex sees a `*` pair).
220
+ stripMarkdownLinks,
221
+ stripMarkdownCode,
222
+ stripMarkdownBold,
223
+ stripMarkdownItalic,
224
+ stripMarkdownHeaders,
225
+ // Typography normalisation.
226
+ replaceEmDash,
227
+ replaceEnDash,
228
+ replaceOtherDashes,
229
+ replaceEllipsis,
230
+ // Conversational tone fixes.
231
+ stripAILeadIns, // strip first
232
+ capitalizeFirstLetter, // then ensure remaining first char is capital
233
+ ];
234
+
235
+ /**
236
+ * Apply a sequence of style rules in order. Returns the input unchanged
237
+ * when `rules` is empty.
238
+ */
239
+ export function applyStyleRules(
240
+ text: string,
241
+ rules: readonly TextStyleRule[] = DEFAULT_STYLE_RULES,
242
+ ): string {
243
+ return rules.reduce((acc, rule) => rule.apply(acc), text);
244
+ }