@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
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Multi-query expansion for RAG retrieval.
3
+ *
4
+ * Instead of searching with a single query, generate N semantically-equivalent
5
+ * rephrases in parallel using a fast LLM call, then search with each and merge
6
+ * the results via RRF (Reciprocal Rank Fusion). This covers synonym gaps and
7
+ * different formulations that a single embedding vector misses.
8
+ *
9
+ * Example — query "сколько стоит квартира в ЖК Марина":
10
+ * → "цена апартаментов в Marina Gate"
11
+ * → "стоимость юнитов Marina Dubai"
12
+ *
13
+ * Falls back gracefully to the original query on any LLM error.
14
+ */
15
+
16
+ import type { ChatClient, ChatMessage } from "@chatman-media/llm-router";
17
+ import { stripThinkBlocks } from "./sanitize.ts";
18
+
19
+ export interface ExpandQueriesInput {
20
+ question: string;
21
+ history?: ChatMessage[];
22
+ chat: ChatClient;
23
+ /** Number of ADDITIONAL variants to generate (not counting the original). Default: 2. */
24
+ count?: number;
25
+ }
26
+
27
+ const SYSTEM_PROMPT = `Ты генерируешь альтернативные формулировки поискового запроса для базы знаний.
28
+
29
+ Правила:
30
+ 1. Сохраняй смысл — только меняй слова/порядок/стиль, не искажай суть
31
+ 2. Используй синонимы, профессиональную лексику, другой порядок слов
32
+ 3. Каждая формулировка на ОТДЕЛЬНОЙ СТРОКЕ, без нумерации, без кавычек, без пояснений
33
+ 4. Не повторяй оригинальный запрос
34
+ 5. Если запрос слишком короткий или это смолток — верни одну строку с оригиналом
35
+
36
+ Пример:
37
+ запрос: сколько стоит квартира в ЖК Марина
38
+ ответ:
39
+ цена апартаментов в Marina Gate Dubai
40
+ стоимость юнитов в Marsa Al Arab
41
+
42
+ Пример:
43
+ запрос: условия контракта для моделей
44
+ ответ:
45
+ требования к договору для моделей агентства
46
+ правила трудового соглашения модельного бизнеса`;
47
+
48
+ /**
49
+ * Generate N alternative search queries for the given question.
50
+ * Always includes the original question as the first element.
51
+ * Returns `[question]` (single item) on any error.
52
+ */
53
+ export async function expandQueries(input: ExpandQueriesInput): Promise<string[]> {
54
+ const { question, chat, count = 2 } = input;
55
+ const original = question.trim();
56
+ if (!original) return [original];
57
+
58
+ const tail = (input.history ?? []).slice(-4);
59
+ const historySnippet = tail.length > 0 ? tail.map((m) => `${m.role}: ${m.content}`).join("\n") + "\n\n" : "";
60
+ const userPrompt = `${historySnippet}запрос: ${original}\nответ (${count} строки):`;
61
+
62
+ let raw: string;
63
+ try {
64
+ raw = await chat.complete(
65
+ [
66
+ { role: "system", content: SYSTEM_PROMPT },
67
+ { role: "user", content: userPrompt },
68
+ ],
69
+ { temperature: 0.3 },
70
+ );
71
+ } catch (err) {
72
+ console.warn("[multi-query] LLM call failed, using original only:", err);
73
+ return [original];
74
+ }
75
+
76
+ const variants = parseVariants(raw, count);
77
+ // Original always first so that its RRF rank is counted separately from variants.
78
+ return [original, ...variants];
79
+ }
80
+
81
+ /** Parse LLM output into a list of clean query strings. */
82
+ function parseVariants(raw: string, maxCount: number): string[] {
83
+ const cleaned = stripThinkBlocks(raw);
84
+ return cleaned
85
+ .split("\n")
86
+ .map((l) => l.trim().replace(/^[-•*\d.]+\s*/, "")) // strip list markers
87
+ .filter((l) => l.length > 3)
88
+ .slice(0, maxCount);
89
+ }
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { extractText, getDocumentProxy } from "unpdf";
3
+
4
+ /**
5
+ * Extract plain text from a PDF file. Pages are joined with double newlines
6
+ * so paragraph boundaries survive. Throws on corrupt/encrypted files.
7
+ */
8
+ export async function parsePdf(filePath: string): Promise<string> {
9
+ const buffer = readFileSync(filePath);
10
+ return parsePdfBuffer(new Uint8Array(buffer));
11
+ }
12
+
13
+ /**
14
+ * Extract plain text directly from a PDF buffer (Uint8Array). Use this when
15
+ * the PDF is already in memory (e.g. HTTP multipart upload) to avoid writing
16
+ * a temporary file.
17
+ *
18
+ * Throws on corrupt or encrypted PDFs.
19
+ */
20
+ export async function parsePdfBuffer(buffer: Uint8Array): Promise<string> {
21
+ const pdf = await getDocumentProxy(buffer);
22
+ const { text } = await extractText(pdf, { mergePages: false });
23
+ return (Array.isArray(text) ? text : [text]).join("\n\n").trim();
24
+ }
@@ -0,0 +1,255 @@
1
+ import type { Persona } from "./answer-types.ts";
2
+
3
+ /**
4
+ * True when the message is asking about the persona's nature (bot vs human
5
+ * vs AI). Detected separately from the name/identity smalltalk because the
6
+ * answer depends on `persona.role`, not just on `persona.name`. Returns a
7
+ * single deterministic reply via `botPresenceReply` — bypassing RAG.
8
+ *
9
+ * Why this guard exists: the RAG system prompt contains an example reply
10
+ * ("конечно нет, я ${name}, чем помочь?") meant for "ты бот?". Without this
11
+ * shortcut, the LLM parrots that template literally — including for
12
+ * "ты человек?", inverting the answer ("конечно нет" = "I'm not human").
13
+ */
14
+ export function isBotPresenceQuestion(question: string): boolean {
15
+ const trimmed = question.trim();
16
+ if (!trimmed) return false;
17
+ const q = trimmed.toLowerCase().replace(/\s+/g, " ");
18
+
19
+ // Job/offer intent → not smalltalk; let RAG handle.
20
+ const hasJobOrOfferIntent =
21
+ /(работ|ваканс|зарплат|виза|оффер|переезд|агентств|услов|\bофис\b|график|смен|жилье|жильё|рейс|кита|китай|коре|англ\b)/i.test(
22
+ question,
23
+ );
24
+ if (hasJobOrOfferIntent) return false;
25
+
26
+ // One regex covering all forms: bare ("бот?", "человек?") and "ты <noun>"
27
+ // ("ты бот", "ты chatgpt"). The leading "ты " is optional. Unicode-property
28
+ // lookarounds replace `\b` because JS `\b` is ASCII-only and silently fails
29
+ // on Cyrillic word ends — same trick as `isPersonaSmalltalkQuestion`.
30
+ const presenceNouns =
31
+ "бот|человек|робот|ии|нейросет(?:ь|ка)|нейронка|чат\\s*бот|chat\\s*bot|chat\\s*gpt|chatgpt|gpt|llm|ai";
32
+ const ruRe = new RegExp(
33
+ `(?<![\\p{L}\\p{N}])(?:ты\\s+)?(?:${presenceNouns})(?![\\p{L}\\p{N}])`,
34
+ "iu",
35
+ );
36
+ if (ruRe.test(q)) return true;
37
+ return /(?<![a-z])are\s+you\s+(?:a\s+)?(?:bot|human|ai|robot)(?![a-z])/i.test(q);
38
+ }
39
+
40
+ /**
41
+ * Deterministic reply for `isBotPresenceQuestion`. For role=human the bot
42
+ * always asserts being human regardless of the question form ("ты бот?" /
43
+ * "ты человек?" both → same human reply). For role=assistant it admits
44
+ * being an AI helper.
45
+ */
46
+ export function botPresenceReply(persona: Persona): string {
47
+ const name = persona.name?.trim() || "Менеджер";
48
+ if (persona.role === "human") {
49
+ const company = persona.company?.trim();
50
+ const tail = company ? `, в ${company}` : "";
51
+ return `Я живой человек, ${name}${tail}. Чем помочь?`;
52
+ }
53
+ return `Я ${name}, ИИ-ассистент. Чем помочь?`;
54
+ }
55
+
56
+ /**
57
+ * True when the message is only smalltalk about identity (name / who are you).
58
+ * Catches the common bare forms candidates actually type — "как зовут?",
59
+ * "имя?", "представься" — not just the textbook "как тебя зовут".
60
+ *
61
+ * Returns false when the message also has any work/offer intent — even
62
+ * "как тебя зовут есть работа в китае?" should land in RAG, not the
63
+ * smalltalk shortcut, because the candidate is asking about a job.
64
+ */
65
+ export function isPersonaSmalltalkQuestion(question: string): boolean {
66
+ const trimmed = question.trim();
67
+ if (!trimmed) return false;
68
+ const q = trimmed.toLowerCase().replace(/\s+/g, " ");
69
+
70
+ const hasJobOrOfferIntent =
71
+ /(работ|ваканс|зарплат|виза|оффер|переезд|агентств|услов|\bофис\b|график|смен|жилье|жильё|рейс|кита|китай|коре|англ\b)/i.test(
72
+ question,
73
+ );
74
+ if (hasJobOrOfferIntent) return false;
75
+
76
+ // Various phrasings of "what's your name". The bare forms ("как зовут?",
77
+ // "имя?", "ваше имя") are the ones that USED TO LEAK into RAG and produce
78
+ // an off-topic stall — the regression we just fixed.
79
+ const nameCue =
80
+ q.includes("как тебя зовут") ||
81
+ q.includes("как вас зовут") ||
82
+ q.includes("тебя как зовут") ||
83
+ q.includes("вас как зовут") ||
84
+ /^как\s+зовут\??$/.test(q) ||
85
+ /как\s+(твоё|твое|ваше)\s+имя/i.test(question) ||
86
+ /^(твоё|твое|ваше)\s+имя\??$/i.test(trimmed) ||
87
+ /^имя\??$/.test(q) ||
88
+ /как\s+звать/i.test(question) ||
89
+ /как\s+(тебя|вас)\s+называть/i.test(question);
90
+
91
+ // "представься" / "представься" / "представьтесь" — imperative forms of
92
+ // "introduce yourself". Matches both the -ся and -сь endings (singular/plural).
93
+ // Note: JS `\b` is ASCII-only and silently fails on Cyrillic word ends —
94
+ // we use a Unicode-property lookahead instead. Same trick as stage-router.ts.
95
+ const introCue =
96
+ /^представ(ь|ьте)?(ся|сь)(?!\p{L})/iu.test(trimmed) ||
97
+ /^представь(те)?\s+себя(?!\p{L})/iu.test(trimmed);
98
+
99
+ const whoCue =
100
+ /^кто\s+ты\??$/i.test(trimmed) ||
101
+ /^ты\s+кто\??$/i.test(trimmed) ||
102
+ /^кто\s+вы\??$/i.test(trimmed) ||
103
+ /^с\s+кем\s+(я\s+)?(общаюсь|разговариваю|переписываюсь)\??$/i.test(trimmed);
104
+
105
+ // English: "what's your name" / "what is your name" / "whats your name".
106
+ // The earlier `what\s+('?s\s+)?your\s+name` required whitespace BEFORE 's,
107
+ // which `what's` doesn't have — silent miss on the most common form.
108
+ const enName = /\bwhat(?:'?s|\s+is)?\s+your\s+name\b/i.test(question);
109
+ const enWho = /\bwho\s+are\s+you\b/i.test(question);
110
+
111
+ return !!(nameCue || introCue || whoCue || enName || enWho);
112
+ }
113
+
114
+ /**
115
+ * Returns a fact key ("city" | "age" | "status" | "experience") when the
116
+ * question is ONLY about that personal attribute of the persona, or `null`
117
+ * when it also contains job/offer intent (route to RAG in that case).
118
+ *
119
+ * Mirrors the `isPersonaSmalltalkQuestion` guard: same job-intent block list,
120
+ * same design — pure function, no side effects, safe to call unconditionally.
121
+ */
122
+ export function isPersonalFactQuestion(question: string): string | null {
123
+ const trimmed = question.trim();
124
+ if (!trimmed) return null;
125
+
126
+ const hasJobOrOfferIntent =
127
+ /(работ|ваканс|зарплат|виза|оффер|переезд|агентств|услов|\bофис\b|график|смен|жилье|жильё|рейс|кита|китай|коре|англ\b)/i.test(
128
+ question,
129
+ );
130
+ if (hasJobOrOfferIntent) return null;
131
+
132
+ const q = trimmed.toLowerCase().replace(/\s+/g, " ");
133
+
134
+ const cityCue =
135
+ /где\s+(ты\s+)?(живёшь|живешь)/i.test(q) ||
136
+ /откуда\s+ты/i.test(q) ||
137
+ /из\s+какого\s+города/i.test(q) ||
138
+ /в\s+каком\s+городе/i.test(q) ||
139
+ /где\s+(ты\s+)?сейчас/i.test(q) ||
140
+ /где\s+(ты\s+)?находишься/i.test(q) ||
141
+ /в\s+каком\s+месте/i.test(q);
142
+
143
+ if (cityCue) return "city";
144
+
145
+ const ageCue =
146
+ /сколько\s+(тебе\s+)?лет/i.test(q) ||
147
+ /тебе\s+сколько\s+лет/i.test(q) ||
148
+ /какой\s+(у\s+тебя\s+)?возраст/i.test(q) ||
149
+ /твой\s+возраст/i.test(q) ||
150
+ /тебе\s+сколько/i.test(q) ||
151
+ /^возраст\??$/.test(q);
152
+
153
+ if (ageCue) return "age";
154
+
155
+ const statusCue =
156
+ /ты\s+замужем/i.test(q) ||
157
+ /замужем\s+ты/i.test(q) ||
158
+ /^замужем\??$/.test(q) ||
159
+ /есть\s+(парень|муж|молодой\s+человек)/i.test(q) ||
160
+ /в\s+отношениях/i.test(q) ||
161
+ /одна\s+(живёшь|живешь)/i.test(q) ||
162
+ /^ты\s+одна\??$/.test(q) ||
163
+ /^отношения(\s+есть)?\??$/.test(q);
164
+
165
+ if (statusCue) return "status";
166
+
167
+ // Phone / contact requests. Common phrasings: "твой номер", "номер
168
+ // телефона", "дай номер", "whatsapp есть?". Also catches the "+1 …"
169
+ // / "+7 …" style request where a candidate asks where to message.
170
+ const phoneCue =
171
+ /(?<![\p{L}\p{N}])(номер\s+(твой|телефона|тел))/iu.test(q) ||
172
+ /(?<![\p{L}\p{N}])(твой|какой|есть)\s+(номер|телефон|whatsapp|вотсап|whatsap)/iu.test(q) ||
173
+ /(?<![\p{L}\p{N}])(дай|скинь|пришли)\s+(номер|телефон|whatsapp)/iu.test(q) ||
174
+ /^номер\??$/.test(q) ||
175
+ /^телефон\??$/.test(q) ||
176
+ /^whatsapp\??$/i.test(q);
177
+
178
+ if (phoneCue) return "phone";
179
+
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Builds a short deterministic reply from `persona.facts[key]`.
185
+ * Returns `null` when the fact is not configured (caller falls through to RAG).
186
+ *
187
+ * "city" / "age" values are wrapped in natural templates; "status" /
188
+ * "experience" values are returned verbatim — the operator writes the full
189
+ * natural reply for these (e.g. "Не замужем, работа всё время занимает").
190
+ */
191
+ export function personaFactReply(persona: Persona, key: string): string | null {
192
+ const val = persona.facts?.[key]?.trim();
193
+ if (!val) return null;
194
+
195
+ if (key === "city") return `Живу в ${val}.`;
196
+ if (key === "age") {
197
+ // If value already contains letters (e.g. "26 лет") return as-is, else append " лет"
198
+ return /\d/.test(val) && !/[а-яё]/i.test(val) ? `${val} лет.` : `${val}.`;
199
+ }
200
+ if (key === "phone") {
201
+ // The configured value is the raw number. Wrap with a natural
202
+ // sentence so the bot doesn't sound like a database row.
203
+ return `Мой номер: ${val}. Можно писать в WhatsApp / Telegram.`;
204
+ }
205
+ // "status" / "experience" / other — operator writes the full reply
206
+ return val;
207
+ }
208
+
209
+ /**
210
+ * Short tail phrases tacked onto the smalltalk introduction. Picked at
211
+ * random per call so a candidate asking "как тебя зовут?" twice in a row
212
+ * doesn't get a verbatim repeat — the giveaway "I'm a script" pattern.
213
+ *
214
+ * All entries use ASCII hyphens (not em-dashes); the smalltalk path
215
+ * bypasses `sanitizeLlmOutput`, so anything here ends up in the wire
216
+ * message exactly as written.
217
+ */
218
+ const HUMAN_SMALLTALK_TAILS_WITH_COMPANY: readonly string[] = [
219
+ "Что хотел узнать?",
220
+ "По чему интересно?",
221
+ "Чем помочь?",
222
+ "Что подсказать?",
223
+ "По работе что-то?",
224
+ "", // sometimes just the introduction, no tail at all
225
+ ];
226
+
227
+ const HUMAN_SMALLTALK_TAILS_NO_COMPANY: readonly string[] = [
228
+ "Чем помочь?",
229
+ "Что хотел узнать?",
230
+ "Если что по вакансиям - просто напиши.",
231
+ "",
232
+ ];
233
+
234
+ function pickTail(pool: readonly string[]): string {
235
+ const idx = Math.floor(Math.random() * pool.length);
236
+ return pool[idx] ?? "";
237
+ }
238
+
239
+ /** Short reply derived from persona - no KB required. */
240
+ export function personaSmalltalkReply(persona: Persona): string {
241
+ const name = persona.name?.trim() || "Менеджер";
242
+ const company = persona.company?.trim();
243
+ if (persona.role === "human") {
244
+ if (company) {
245
+ const tail = pickTail(HUMAN_SMALLTALK_TAILS_WITH_COMPANY);
246
+ const head = `Меня зовут ${name}, я в ${company}.`;
247
+ return tail ? `${head} ${tail}` : head;
248
+ }
249
+ const tail = pickTail(HUMAN_SMALLTALK_TAILS_NO_COMPANY);
250
+ const head = `Меня зовут ${name}.`;
251
+ return tail ? `${head} ${tail}` : head;
252
+ }
253
+ if (company) return `Я ${name}, помощник агентства ${company}.`;
254
+ return `Я ${name}.`;
255
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,190 @@
1
+ import type { ComposeOptions, FunnelStage, Hook, Style } from "./styles.ts";
2
+ import { renderSummaryBlock, renderUserFactsBlock } from "./system-prompt.ts";
3
+
4
+ const HOOK_LABELS: Record<Hook["kind"], string> = {
5
+ social_proof: "СОЦ. ДОКАЗАТЕЛЬСТВО",
6
+ scarcity: "ДЕФИЦИТ",
7
+ authority: "АВТОРИТЕТ",
8
+ liking: "СИМПАТИЯ",
9
+ reciprocity: "ВЗАИМНОСТЬ",
10
+ commitment: "ОБЯЗАТЕЛЬСТВО",
11
+ };
12
+
13
+ const FRAMEWORK_BLURB: Record<Style["framework"], string> = {
14
+ AIDA: "Двигай разговор по AIDA: Attention → Interest → Desire → Action.",
15
+ PAS: "Используй PAS: Problem → Agitate → Solve. Кратко, без воды.",
16
+ SPIN: "Веди по SPIN: Situation → Problem → Implication → Need-payoff.",
17
+ NEPQ: "NEPQ: задавай нейро-эмоциональные вопросы. Пусть prospect сам убедит себя.",
18
+ straight_line:
19
+ "Belfort Straight Line: веди prospect к 10/10 уверенности по продукту, продавцу и компании. Тон уверенный и заразительный.",
20
+ };
21
+
22
+ function kbGroundingReminder(personaRole: Style["persona"]["role"]): string {
23
+ const base = "Никогда не выдумывай цифры, суммы, сроки, условия. Если фактов нет в KB CONTEXT — ";
24
+ return personaRole === "human"
25
+ ? base +
26
+ "напиши по-человечески, что сейчас уточнишь детали (без официоза вроде «обращусь к руководству»), если этих фактов нет в контексте."
27
+ : `${base}скажи prospect, что уточнишь у руководства.`;
28
+ }
29
+
30
+ /** Calm FAQ-support guidance used in place of the sales blocks when the
31
+ * lead is past the sales stage and waiting on a downstream process. */
32
+ function supportBlock(phase: "docs" | "submitted"): string {
33
+ const common =
34
+ "РЕЖИМ ПОДДЕРЖКИ: кандидат уже одобрена, идёт оформление рабочей визы. " +
35
+ "Отвечай тепло, спокойно и по делу на её вопросы про документы, сроки и процесс. " +
36
+ "НЕ продавай, не дави, не зови на звонок, не возвращай разговор к воронке.";
37
+ const phaseLine =
38
+ phase === "docs"
39
+ ? "Сейчас собираем её документы. Если спросит — помоги заполнить визовую анкету " +
40
+ "и объясни поля. Оформление документов обычно занимает около 10 дней."
41
+ : "Заявка уже подана в консульство, ждём решения (обычно 3-4 дня). " +
42
+ "Как будут новости — оператор напишет ей сам.";
43
+ return `${common}\n${phaseLine}`;
44
+ }
45
+
46
+ /**
47
+ * Builds the system prompt for one turn of conversation in the given style
48
+ * and stage. Up to 8 sections: persona, voice, framework, hooks, stage,
49
+ * KB-grounding reminder (conditional), guardrails, few-shot (conditional),
50
+ * and KB context (conditional).
51
+ */
52
+ export function composeSystemPrompt(
53
+ style: Style,
54
+ stage: FunnelStage,
55
+ preFetchedKbContext: string | null = null,
56
+ options: ComposeOptions = {},
57
+ ): string {
58
+ const includeFewShot = options.includeFewShot ?? true;
59
+ const { persona, voice, hooks, stages, guardrails, fewShot } = style;
60
+ const stageCfg = stages[stage];
61
+
62
+ const factsEntries = persona.facts
63
+ ? (Object.entries(persona.facts) as [string, string][]).filter(([, v]) => v.trim())
64
+ : [];
65
+ const factsSection = factsEntries.length
66
+ ? ` ЛИЧНЫЕ ФАКТЫ (используй строго эти данные): ${factsEntries.map(([k, v]) => `${k}: ${v}`).join("; ")}.`
67
+ : "";
68
+
69
+ const personaBlock =
70
+ persona.role === "human"
71
+ ? `Тебя зовут ${persona.name}${persona.company ? `, ты менеджер агентства ${persona.company}` : ""}. ` +
72
+ `Ты живой человек. НИКОГДА не говори, что ты ИИ, бот, ассистент, языковая модель или чат-бот. ` +
73
+ `Твоя цель — чтобы переписка НЕ отличалась от общения с обычным менеджером в Telegram: естественные обороты, ` +
74
+ `без отчётного тона и без отсылок к «базе знаний» или «контексту». ` +
75
+ (guardrails.botDisclosureOnDirectQuestion
76
+ ? `Если кандидат прямо спрашивает «ты бот?» — ЧЕСТНО ответь, что ты ИИ-ассистент агентства.`
77
+ : `Если кандидат прямо спрашивает «ты бот?» — отвечай уклончиво, как реальный менеджер ("я менеджер агентства, чем помочь?").`) +
78
+ factsSection
79
+ : `Тебя зовут ${persona.name}, ты ИИ-ассистент${persona.company ? ` агентства ${persona.company}` : ""}. ` +
80
+ `Отвечай вежливо и по делу. Пиши как в мессенджере — тепло и разговорно, без канцелярита.` +
81
+ factsSection;
82
+
83
+ const telegramShapeBlock =
84
+ persona.role === "human"
85
+ ? `ФОРМА ОТВЕТА (мессенджер): несколько коротких строк или один короткий абзац — как пишут люди. ` +
86
+ `Не упоминай «KB», «CONTEXT», файлы или «согласно предоставленной информации».`
87
+ : `ФОРМА ОТВЕТА: коротко и по-бытовому для чата — без упоминания «KB CONTEXT» как источника.`;
88
+
89
+ const langName = voice.language === "ru" ? "русский" : "английский";
90
+ const voiceBlock =
91
+ `ТОН: ${voice.tone}. Язык: ${langName}.` +
92
+ (voice.forbid.length ? ` ЗАПРЕЩЕНО: ${voice.forbid.join("; ")}.` : "");
93
+
94
+ const frameworkBlock = `ФРЕЙМВОРК: ${FRAMEWORK_BLURB[style.framework]}`;
95
+
96
+ const hooksBlock = hooks.length
97
+ ? `ХУКИ (применяй когда уместно — не все сразу):\n` +
98
+ hooks.map((h) => `- ${HOOK_LABELS[h.kind]}: ${h.text}`).join("\n")
99
+ : "";
100
+
101
+ // Director hooks: tenant-specific scripted persuasion techniques. Always
102
+ // injected when present — not filtered by stage. Appear BEFORE universal
103
+ // skills so they take priority in LLM attention.
104
+ const directorHooksBlock =
105
+ options.directorHooks && options.directorHooks.length > 0
106
+ ? `ХУКИ УБЕЖДЕНИЯ (применяй когда уместно — не все сразу):\n` +
107
+ options.directorHooks
108
+ .map((h) => {
109
+ const triggerLine = h.triggerHint ? `Когда: ${h.triggerHint}\n` : "";
110
+ return `━━ ${h.name} ━━\n${triggerLine}${h.body}`;
111
+ })
112
+ .join("\n\n")
113
+ : "";
114
+
115
+ const skillsForStage =
116
+ options.skills?.filter(
117
+ (s) => s.applicableStages.length === 0 || s.applicableStages.includes(stage),
118
+ ) ?? [];
119
+ const skillsBlock = skillsForStage.length
120
+ ? `ПРИЁМЫ (используй уместные, не все сразу — выбирай по контексту):\n` +
121
+ skillsForStage.map((s) => `- ${s.displayName} — ${s.promptFragment}`).join("\n")
122
+ : "";
123
+
124
+ const stageBlock = stageCfg
125
+ ? `ТЕКУЩИЙ ЭТАП: ${stage.toUpperCase()}.\n` +
126
+ `ЦЕЛЬ ЭТАПА: ${stageCfg.goal}.` +
127
+ (stageCfg.guidance ? `\nКАК: ${stageCfg.guidance}` : "") +
128
+ (stageCfg.groundingRequired
129
+ ? `\nGROUNDING: на этом этапе все конкретные факты (цифры, суммы, сроки) бери ТОЛЬКО из секции KB CONTEXT ниже. Если её нет или нужного факта в ней нет — не выдумывай, скажи что уточнишь.`
130
+ : "")
131
+ : `ТЕКУЩИЙ ЭТАП: ${stage}. (Специфических правил для этапа нет — используй общий стиль.)`;
132
+
133
+ const minorRule = guardrails.noMinors ? "- Если prospect <18 лет — вежливо заверши диалог." : "";
134
+ const topicsRule = guardrails.forbiddenTopics.length
135
+ ? `- Запрещённые темы: ${guardrails.forbiddenTopics.join(", ")}.`
136
+ : "";
137
+ const brevityRule =
138
+ persona.role === "human"
139
+ ? `- Пиши как в живом чате: 2–6 коротких фраз можно, если нужно передать условия. Без markdown-заголовков. ` +
140
+ `Списком с номерами — только если человек сам просит структуру.`
141
+ : `- Пиши коротко: 1-3 предложения. Без markdown-заголовков и нумерованных списков.`;
142
+ const guardrailBlock = `ЖЁСТКИЕ ПРАВИЛА:\n${[minorRule, topicsRule, brevityRule].filter(Boolean).join("\n")}`;
143
+
144
+ const fewShotBlock =
145
+ includeFewShot && fewShot.length
146
+ ? `ПРИМЕРЫ ДИАЛОГА (стиль и регистр):\n` +
147
+ fewShot
148
+ .map(
149
+ (ex, i) =>
150
+ `[${i + 1}]${ex.stage ? ` (этап: ${ex.stage})` : ""}\n` +
151
+ ` prospect: ${ex.user}\n` +
152
+ ` ты: ${ex.assistant}`,
153
+ )
154
+ .join("\n")
155
+ : "";
156
+
157
+ const kbBlock = preFetchedKbContext
158
+ ? `KB CONTEXT (актуальные факты агентства):\n${preFetchedKbContext}`
159
+ : "";
160
+
161
+ const userFactsBlock = renderUserFactsBlock(options.userFacts);
162
+ const summaryBlock = renderSummaryBlock(options.conversationSummary);
163
+
164
+ const needsGroundingReminder = stageCfg?.groundingRequired === true && !preFetchedKbContext;
165
+
166
+ // Support mode: the lead is past the sales stage and waiting on a
167
+ // downstream process. Drop every sales block (framework / hooks / skills /
168
+ // funnel stage / few-shot) and replace them with a calm FAQ-support block.
169
+ // Persona, voice, guardrails, KB grounding + context stay intact.
170
+ const support = options.supportPhase ? supportBlock(options.supportPhase) : "";
171
+
172
+ return [
173
+ personaBlock,
174
+ telegramShapeBlock,
175
+ voiceBlock,
176
+ support ? "" : frameworkBlock,
177
+ support ? "" : hooksBlock,
178
+ support ? "" : directorHooksBlock,
179
+ support ? "" : skillsBlock,
180
+ support || stageBlock,
181
+ summaryBlock,
182
+ userFactsBlock,
183
+ needsGroundingReminder ? kbGroundingReminder(persona.role) : "",
184
+ guardrailBlock,
185
+ support ? "" : fewShotBlock,
186
+ kbBlock,
187
+ ]
188
+ .filter((s) => s.length > 0)
189
+ .join("\n\n");
190
+ }
package/src/reflect.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type { ChatClient, ChatMessage } from "@chatman-media/llm-router";
2
+ import { stripCodeFences, stripThinkBlocks } from "./sanitize.ts";
3
+
4
+ /**
5
+ * Verifies that all factual claims in `answer` are grounded in `context`
6
+ * (the KB chunks retrieved for this turn). Used as a post-generation
7
+ * hallucination guard — if the LLM invented a number/city/condition, this
8
+ * catches it before the message reaches the candidate.
9
+ *
10
+ * Returns `{ grounded: true }` when the answer is fully supported by the
11
+ * context, or `{ grounded: false, reason }` when it isn't. The webhook
12
+ * caller is responsible for deciding what to do with `grounded:false` —
13
+ * typically: drop the reply (silent → mode stays "ai") or escalate.
14
+ */
15
+ export interface ReflectInput {
16
+ question: string;
17
+ answer: string;
18
+ /** The same KB CONTEXT that was passed to the generator. */
19
+ context: string;
20
+ chat: ChatClient;
21
+ }
22
+
23
+ export interface ReflectResult {
24
+ grounded: boolean;
25
+ reason?: string;
26
+ }
27
+
28
+ const SYSTEM_PROMPT = `Ты проверяешь ответ бота на галлюцинации.
29
+ Тебе дают: ВОПРОС кандидата, КОНТЕКСТ (выдержки из базы знаний), ОТВЕТ бота.
30
+
31
+ Задача: проверить, что КАЖДЫЙ конкретный факт из ОТВЕТА (цифры, страны, города, валюты, сроки, названия услуг, условия) встречается в КОНТЕКСТЕ.
32
+
33
+ Правила:
34
+ - Общие фразы ("у нас хорошие условия", "напишу подробности"), приветствия, эмоции — НЕ требуют проверки
35
+ - Конкретные числа, страны, города, валюты, сроки, % — ДОЛЖНЫ быть в контексте
36
+ - Если ответ говорит "уточню у руководства" / "сейчас уточню" — это ОК, не требует проверки
37
+ - Личные факты бота (имя, возраст, город, статус) — НЕ требуют проверки в контексте
38
+
39
+ Верни СТРОГО JSON одной строкой, без markdown, без \`\`\`:
40
+ {"grounded": true} — если все факты подтверждены или ответ общий
41
+ {"grounded": false, "reason": "<какой именно факт не из контекста>"} — если есть выдуманное
42
+
43
+ Только JSON, ничего больше.`;
44
+
45
+ export async function verifyAnswer(input: ReflectInput): Promise<ReflectResult> {
46
+ // Trivial / empty answers don't need a verifier — they cannot hallucinate.
47
+ // This guards against pointless LLM calls on the NO_CONTEXT path and on
48
+ // the smalltalk/persona-fact short-circuits (those return without context).
49
+ const trimmed = input.answer.trim();
50
+ if (trimmed.length === 0) return { grounded: true };
51
+ if (input.context.trim().length === 0) {
52
+ // If the generator had no KB context but produced a non-empty answer,
53
+ // we can't verify anything — let it through. The KB-grounding rules in
54
+ // the system prompt already force NO_CONTEXT_MARKER on missing data.
55
+ return { grounded: true };
56
+ }
57
+
58
+ const userPrompt = `ВОПРОС: ${input.question}\n\nКОНТЕКСТ:\n${input.context}\n\nОТВЕТ: ${trimmed}\n\nJSON:`;
59
+
60
+ const messages: ChatMessage[] = [
61
+ { role: "system", content: SYSTEM_PROMPT },
62
+ { role: "user", content: userPrompt },
63
+ ];
64
+
65
+ let raw: string;
66
+ try {
67
+ raw = await input.chat.complete(messages, { temperature: 0.0 });
68
+ } catch (err) {
69
+ console.error("[reflect] LLM call failed; treating as grounded:", err);
70
+ return { grounded: true };
71
+ }
72
+
73
+ return parseReflection(raw);
74
+ }
75
+
76
+ /** Parses the verifier's JSON output. Defaults to `grounded:true` on parse
77
+ * failure — false negatives are cheap (one wasted reply), but false positives
78
+ * here would silently drop legitimate answers. Exported for unit tests. */
79
+ export function parseReflection(raw: string): ReflectResult {
80
+ const s = stripCodeFences(stripThinkBlocks(raw)).trim();
81
+ const start = s.indexOf("{");
82
+ const end = s.lastIndexOf("}");
83
+ if (start === -1 || end === -1 || end < start) return { grounded: true };
84
+
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = JSON.parse(s.slice(start, end + 1));
88
+ } catch {
89
+ return { grounded: true };
90
+ }
91
+
92
+ if (typeof parsed !== "object" || parsed === null) return { grounded: true };
93
+ const obj = parsed as Record<string, unknown>;
94
+ const grounded = obj.grounded;
95
+ if (typeof grounded !== "boolean") return { grounded: true };
96
+ if (grounded) return { grounded: true };
97
+ const reason = typeof obj.reason === "string" ? obj.reason : "unknown";
98
+ return { grounded: false, reason };
99
+ }