@chatman-media/kb 1.4.0 → 1.6.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.
@@ -0,0 +1,178 @@
1
+ // Unit tests for the ingest pipeline (ingestFile / ingestText / ingestDirectory)
2
+ // + the pure helpers (deriveTopicFromPath, stripNonContent). The KB store and
3
+ // embedder are faked; ingestFile/ingestDirectory use real temp files on disk.
4
+
5
+ import { afterEach, describe, expect, it } from "bun:test";
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import type { EmbeddingClient } from "@chatman-media/llm-router";
10
+ import {
11
+ deriveTopicFromPath,
12
+ type IngestDeps,
13
+ ingestDirectory,
14
+ ingestFile,
15
+ ingestText,
16
+ stripNonContent,
17
+ } from "./ingest.ts";
18
+ import type { IKbStore } from "./types.ts";
19
+
20
+ interface FakeStoreState {
21
+ docs: Map<string, { id: number; content_hash: string; chunkCount: number }>;
22
+ inserted: number;
23
+ deleted: number[];
24
+ nextId: number;
25
+ }
26
+
27
+ function fakeStore(state: FakeStoreState): IKbStore {
28
+ return {
29
+ search: async () => [],
30
+ hybridSearch: async () => [],
31
+ prioritySearch: async () => [],
32
+ getDocumentBySource: async (source: string) => {
33
+ const d = state.docs.get(source);
34
+ return d ? { id: d.id, content_hash: d.content_hash } : null;
35
+ },
36
+ countChunksForDocument: async (id: number) => {
37
+ for (const d of state.docs.values()) if (d.id === id) return d.chunkCount;
38
+ return 0;
39
+ },
40
+ deleteDocument: async (id: number) => {
41
+ state.deleted.push(id);
42
+ return true;
43
+ },
44
+ upsertDocument: async (input: { source: string; contentHash: string }) => {
45
+ const id = state.nextId++;
46
+ state.docs.set(input.source, { id, content_hash: input.contentHash, chunkCount: 0 });
47
+ return { id };
48
+ },
49
+ insertChunkWithEmbedding: async () => {
50
+ state.inserted++;
51
+ },
52
+ } as unknown as IKbStore;
53
+ }
54
+
55
+ function newState(): FakeStoreState {
56
+ return { docs: new Map(), inserted: 0, deleted: [], nextId: 1 };
57
+ }
58
+
59
+ const embedder: EmbeddingClient = {
60
+ embed: async (inputs: string[]) => inputs.map(() => [0.1, 0.2, 0.3]),
61
+ dim: 3,
62
+ };
63
+
64
+ const tmpDirs: string[] = [];
65
+ function tmp(): string {
66
+ const d = mkdtempSync(join(tmpdir(), "kb-ingest-"));
67
+ tmpDirs.push(d);
68
+ return d;
69
+ }
70
+ afterEach(() => {
71
+ while (tmpDirs.length) rmSync(tmpDirs.pop() as string, { recursive: true, force: true });
72
+ });
73
+
74
+ describe("stripNonContent", () => {
75
+ it("снимает YAML-frontmatter", () => {
76
+ expect(stripNonContent("---\ntitle: x\n---\nBody here")).toBe("Body here\n");
77
+ });
78
+ it("снимает HTML-комментарии (в т.ч. вложенный остаток)", () => {
79
+ expect(stripNonContent("a<!-- c -->b")).toBe("ab\n");
80
+ });
81
+ it("схлопывает 3+ переводов строк", () => {
82
+ expect(stripNonContent("a\n\n\n\nb")).toBe("a\n\nb\n");
83
+ });
84
+ });
85
+
86
+ describe("deriveTopicFromPath", () => {
87
+ it("файл прямо в root → null", () => {
88
+ expect(deriveTopicFromPath("/r/a.md", "/r")).toBeNull();
89
+ });
90
+ it("первая поддиректория → topic", () => {
91
+ expect(deriveTopicFromPath("/r/china/jobs/a.md", "/r")).toBe("china");
92
+ });
93
+ });
94
+
95
+ describe("ingestText", () => {
96
+ it("создаёт документ и чанки, source=inline:<hash12>", async () => {
97
+ const state = newState();
98
+ const deps: IngestDeps = { kb: fakeStore(state), embedder };
99
+ const r = await ingestText({ title: "Doc", body: "some content body text" }, deps);
100
+ expect(r.created).toBe(true);
101
+ expect(r.source).toStartWith("inline:");
102
+ expect(r.chunks).toBeGreaterThan(0);
103
+ expect(state.inserted).toBe(r.chunks);
104
+ });
105
+
106
+ it("пустой title → 'untitled'; пустое тело → 0 чанков", async () => {
107
+ const state = newState();
108
+ const r = await ingestText({ title: " ", body: "" }, { kb: fakeStore(state), embedder });
109
+ expect(r.chunks).toBe(0);
110
+ expect(state.inserted).toBe(0);
111
+ });
112
+
113
+ it("повторная загрузка идентичного контента → dedup (created:false)", async () => {
114
+ const state = newState();
115
+ const deps: IngestDeps = { kb: fakeStore(state), embedder };
116
+ const first = await ingestText({ title: "D", body: "stable text payload" }, deps);
117
+ // mark existing doc as having chunks so the dedup short-circuit fires
118
+ for (const d of state.docs.values()) d.chunkCount = first.chunks;
119
+ const second = await ingestText({ title: "D", body: "stable text payload" }, deps);
120
+ expect(second.created).toBe(false);
121
+ expect(second.documentId).toBe(first.documentId);
122
+ });
123
+
124
+ it("существующий док с другим hash → удаляется и пересоздаётся", async () => {
125
+ const state = newState();
126
+ const deps: IngestDeps = { kb: fakeStore(state), embedder, topic: "t" };
127
+ const first = await ingestText({ title: "D", body: "version one" }, deps);
128
+ for (const d of state.docs.values()) d.chunkCount = first.chunks;
129
+ // same source (hash of inline depends on body) — change body keeps a NEW source,
130
+ // so to force the delete path we reuse the same body but reset stored hash.
131
+ for (const d of state.docs.values()) d.content_hash = "STALE";
132
+ const second = await ingestText({ title: "D", body: "version one" }, deps);
133
+ expect(second.created).toBe(true);
134
+ expect(state.deleted.length).toBe(1);
135
+ });
136
+ });
137
+
138
+ describe("ingestFile", () => {
139
+ it("читает .md, стрипает frontmatter, индексирует чанки", async () => {
140
+ const dir = tmp();
141
+ const file = join(dir, "doc.md");
142
+ writeFileSync(file, "---\nx: 1\n---\nHello world content here.", "utf8");
143
+ const state = newState();
144
+ const r = await ingestFile(file, { kb: fakeStore(state), embedder });
145
+ expect(r.created).toBe(true);
146
+ expect(r.source).toBe(`file://${file}`);
147
+ expect(r.chunks).toBeGreaterThan(0);
148
+ });
149
+
150
+ it("source/title override", async () => {
151
+ const dir = tmp();
152
+ const file = join(dir, "tmpname.txt");
153
+ writeFileSync(file, "body text", "utf8");
154
+ const state = newState();
155
+ const r = await ingestFile(file, {
156
+ kb: fakeStore(state),
157
+ embedder,
158
+ source: "upload:abc",
159
+ title: "Nice Title",
160
+ });
161
+ expect(r.source).toBe("upload:abc");
162
+ });
163
+ });
164
+
165
+ describe("ingestDirectory", () => {
166
+ it("обходит дерево, пропускает неподдерживаемые расширения, выводит topic из подпапки", async () => {
167
+ const root = tmp();
168
+ mkdirSync(join(root, "china"));
169
+ writeFileSync(join(root, "china", "a.md"), "alpha content", "utf8");
170
+ writeFileSync(join(root, "b.txt"), "bravo content", "utf8");
171
+ writeFileSync(join(root, "skip.json"), "{}", "utf8");
172
+ const state = newState();
173
+ const summary = await ingestDirectory(root, { kb: fakeStore(state), embedder });
174
+ expect(summary.documents).toBe(2);
175
+ expect(summary.skipped).toBe(1);
176
+ expect(summary.chunks).toBeGreaterThan(0);
177
+ });
178
+ });
@@ -19,6 +19,154 @@ const baseStyle: Style = {
19
19
  model: { id: "x", temperature: 0.5, maxTokens: 100 },
20
20
  };
21
21
 
22
+ describe("composeSystemPrompt — persona / voice / framework blocks", () => {
23
+ it("human persona: менеджер агентства + честное раскрытие бота (botDisclosure=true)", () => {
24
+ const p = composeSystemPrompt(
25
+ { ...baseStyle, persona: { name: "Аня", role: "human", company: "Acme" } },
26
+ "qualify",
27
+ );
28
+ expect(p).toContain("менеджер агентства Acme");
29
+ expect(p).toContain("ЧЕСТНО ответь, что ты ИИ-ассистент");
30
+ expect(p).toContain("ФРЕЙМВОРК");
31
+ });
32
+
33
+ it("human persona, botDisclosure=false → уклончивый ответ про бота", () => {
34
+ const s: Style = {
35
+ ...baseStyle,
36
+ guardrails: { ...baseStyle.guardrails, botDisclosureOnDirectQuestion: false },
37
+ };
38
+ const p = composeSystemPrompt(s, "qualify");
39
+ expect(p).toContain("отвечай уклончиво");
40
+ });
41
+
42
+ it("assistant persona: ИИ-ассистент", () => {
43
+ const p = composeSystemPrompt(
44
+ { ...baseStyle, persona: { name: "Бот", role: "assistant", company: "Acme" } },
45
+ "qualify",
46
+ );
47
+ expect(p).toContain("ИИ-ассистент агентства Acme");
48
+ });
49
+
50
+ it("persona.facts → блок личных фактов", () => {
51
+ const p = composeSystemPrompt(
52
+ { ...baseStyle, persona: { name: "Аня", role: "human", facts: { age: "25", empty: " " } } },
53
+ "qualify",
54
+ );
55
+ expect(p).toContain("ЛИЧНЫЕ ФАКТЫ");
56
+ expect(p).toContain("age: 25");
57
+ expect(p).not.toContain("empty");
58
+ });
59
+
60
+ it("voice.forbid → блок ЗАПРЕЩЕНО; язык en", () => {
61
+ const p = composeSystemPrompt(
62
+ { ...baseStyle, voice: { tone: "bold", language: "en", forbid: ["мат", "сленг"] } },
63
+ "qualify",
64
+ );
65
+ expect(p).toContain("Язык: английский");
66
+ expect(p).toContain("ЗАПРЕЩЕНО: мат; сленг");
67
+ });
68
+ });
69
+
70
+ describe("composeSystemPrompt — hooks / director hooks / skills", () => {
71
+ it("hooks → блок ХУКИ с лейблами", () => {
72
+ const p = composeSystemPrompt(
73
+ { ...baseStyle, hooks: [{ kind: "scarcity", text: "осталось 3 места" }] },
74
+ "qualify",
75
+ );
76
+ expect(p).toContain("ДЕФИЦИТ: осталось 3 места");
77
+ });
78
+
79
+ it("directorHooks → блок ХУКИ УБЕЖДЕНИЯ + triggerHint", () => {
80
+ const p = composeSystemPrompt(baseStyle, "qualify", null, {
81
+ directorHooks: [{ name: "FOMO", body: "дави на срочность", triggerHint: "колеблется" }],
82
+ });
83
+ expect(p).toContain("ХУКИ УБЕЖДЕНИЯ");
84
+ expect(p).toContain("FOMO");
85
+ expect(p).toContain("Когда: колеблется");
86
+ });
87
+
88
+ it("skills фильтруются по стадии", () => {
89
+ const p = composeSystemPrompt(baseStyle, "qualify", null, {
90
+ skills: [
91
+ { slug: "mirror", displayName: "Зеркало", promptFragment: "отражай", applicableStages: ["qualify"] },
92
+ { slug: "close", displayName: "Закрытие", promptFragment: "закрывай", applicableStages: ["close"] },
93
+ { slug: "always", displayName: "Всегда", promptFragment: "везде", applicableStages: [] },
94
+ ],
95
+ });
96
+ expect(p).toContain("ПРИЁМЫ");
97
+ expect(p).toContain("Зеркало");
98
+ expect(p).toContain("Всегда");
99
+ expect(p).not.toContain("Закрытие");
100
+ });
101
+ });
102
+
103
+ describe("composeSystemPrompt — support mode / grounding / few-shot", () => {
104
+ it("supportPhase=docs → блок поддержки, sales-блоки выкинуты", () => {
105
+ const s: Style = { ...baseStyle, hooks: [{ kind: "scarcity", text: "x" }] };
106
+ const p = composeSystemPrompt(s, "qualify", null, { supportPhase: "docs" });
107
+ expect(p).toContain("РЕЖИМ ПОДДЕРЖКИ");
108
+ expect(p).toContain("около 10 дней");
109
+ expect(p).not.toContain("ФРЕЙМВОРК");
110
+ expect(p).not.toContain("ДЕФИЦИТ");
111
+ });
112
+
113
+ it("supportPhase=submitted → текст про консульство", () => {
114
+ const p = composeSystemPrompt(baseStyle, "qualify", null, { supportPhase: "submitted" });
115
+ expect(p).toContain("подана в консульство");
116
+ });
117
+
118
+ it("groundingRequired + нет KB контекста → напоминание о grounding (human)", () => {
119
+ const s: Style = {
120
+ ...baseStyle,
121
+ persona: { name: "Аня", role: "human" },
122
+ stages: { qualify: { goal: "G", groundingRequired: true } },
123
+ };
124
+ const p = composeSystemPrompt(s, "qualify");
125
+ expect(p).toContain("GROUNDING");
126
+ expect(p).toContain("Никогда не выдумывай");
127
+ });
128
+
129
+ it("groundingRequired + есть KB контекст → KB CONTEXT присутствует", () => {
130
+ const s: Style = {
131
+ ...baseStyle,
132
+ stages: { qualify: { goal: "G", groundingRequired: true } },
133
+ };
134
+ const p = composeSystemPrompt(s, "qualify", "факт: зарплата 1500");
135
+ expect(p).toContain("KB CONTEXT");
136
+ });
137
+
138
+ it("few-shot включается по умолчанию и выключается флагом", () => {
139
+ const s: Style = {
140
+ ...baseStyle,
141
+ fewShot: [{ user: "сколько?", assistant: "уточню", stage: "qualify" }],
142
+ };
143
+ expect(composeSystemPrompt(s, "qualify")).toContain("ПРИМЕРЫ ДИАЛОГА");
144
+ expect(composeSystemPrompt(s, "qualify", null, { includeFewShot: false })).not.toContain(
145
+ "ПРИМЕРЫ ДИАЛОГА",
146
+ );
147
+ });
148
+
149
+ it("guardrails: noMinors + forbiddenTopics → жёсткие правила", () => {
150
+ const s: Style = {
151
+ ...baseStyle,
152
+ guardrails: {
153
+ noMinors: true,
154
+ botDisclosureOnDirectQuestion: true,
155
+ forbiddenTopics: ["политика"],
156
+ },
157
+ };
158
+ const p = composeSystemPrompt(s, "qualify");
159
+ expect(p).toContain("ЖЁСТКИЕ ПРАВИЛА");
160
+ expect(p).toContain("<18 лет");
161
+ expect(p).toContain("Запрещённые темы: политика");
162
+ });
163
+
164
+ it("стадия без конфига и без override → generic-блок", () => {
165
+ const p = composeSystemPrompt(baseStyle, "pitch");
166
+ expect(p).toContain("Специфических правил для этапа нет");
167
+ });
168
+ });
169
+
22
170
  describe("composeSystemPrompt — stageOverride (Phase 2 C-2)", () => {
23
171
  it("без override → goal берётся из Style", () => {
24
172
  const p = composeSystemPrompt(baseStyle, "qualify");
@@ -42,3 +190,30 @@ describe("composeSystemPrompt — stageOverride (Phase 2 C-2)", () => {
42
190
  expect(p).toContain("CLOSE_GOAL");
43
191
  });
44
192
  });
193
+
194
+ describe("composeSystemPrompt — requestContext (R4 multi-request)", () => {
195
+ it("requestContext → блок «ЗАПРОС ГОСТЯ» в промпте", () => {
196
+ const p = composeSystemPrompt(baseStyle, "qualify", null, {
197
+ requestContext: "гость сейчас ведёт запрос «Трансфер».",
198
+ });
199
+ expect(p).toContain("ЗАПРОС ГОСТЯ:");
200
+ expect(p).toContain("«Трансфер»");
201
+ });
202
+
203
+ it("без requestContext → блока нет", () => {
204
+ const p = composeSystemPrompt(baseStyle, "qualify");
205
+ expect(p).not.toContain("ЗАПРОС ГОСТЯ:");
206
+ });
207
+ });
208
+
209
+ describe("composeSystemPrompt — awaitingOperator (R5)", () => {
210
+ it("awaitingOperator → блок «ОЖИДАНИЕ ОПЕРАТОРА»", () => {
211
+ const p = composeSystemPrompt(baseStyle, "close", null, { awaitingOperator: true });
212
+ expect(p).toContain("ОЖИДАНИЕ ОПЕРАТОРА");
213
+ });
214
+
215
+ it("без флага → блока нет", () => {
216
+ const p = composeSystemPrompt(baseStyle, "close");
217
+ expect(p).not.toContain("ОЖИДАНИЕ ОПЕРАТОРА");
218
+ });
219
+ });
package/src/prompt.ts CHANGED
@@ -134,6 +134,17 @@ export function composeSystemPrompt(
134
134
  : "")
135
135
  : `ТЕКУЩИЙ ЭТАП: ${stage}. (Специфических правил для этапа нет — используй общий стиль.)`;
136
136
 
137
+ // Динамический контекст текущего запроса гостя (multi-request): тип запроса +
138
+ // сколько открыто — помогает боту не путать параллельные заявки.
139
+ const requestBlock = options.requestContext
140
+ ? `ЗАПРОС ГОСТЯ: ${options.requestContext}`
141
+ : "";
142
+
143
+ // Лид ждёт оператора (awaiting_operator): бот держит, не выдумывает цену/условия.
144
+ const operatorBlock = options.awaitingOperator
145
+ ? `ОЖИДАНИЕ ОПЕРАТОРА: на этой стадии условия/цену/решение готовит коллега-человек. НЕ выдумывай числа и детали. Скажи гостю коротко, что уточняешь и вернёшься с ответом — и жди, не дави и не закрывай сделку.`
146
+ : "";
147
+
137
148
  const minorRule = guardrails.noMinors ? "- Если prospect <18 лет — вежливо заверши диалог." : "";
138
149
  const topicsRule = guardrails.forbiddenTopics.length
139
150
  ? `- Запрещённые темы: ${guardrails.forbiddenTopics.join(", ")}.`
@@ -182,6 +193,8 @@ export function composeSystemPrompt(
182
193
  support ? "" : directorHooksBlock,
183
194
  support ? "" : skillsBlock,
184
195
  support || stageBlock,
196
+ operatorBlock,
197
+ requestBlock,
185
198
  summaryBlock,
186
199
  userFactsBlock,
187
200
  needsGroundingReminder ? kbGroundingReminder(persona.role) : "",
package/src/styles.ts CHANGED
@@ -141,4 +141,15 @@ export interface ComposeOptions {
141
141
  * block. Lets AI-built funnels carry per-stage instructions.
142
142
  */
143
143
  stageOverride?: { goal: string; guidance?: string };
144
+ /**
145
+ * Динамический контекст текущего запроса гостя (multi-request / concierge):
146
+ * какой тип запроса ведётся + сколько открыто. Инжектится блоком «ЗАПРОС
147
+ * ГОСТЯ». null/absent для линейных вертикалей.
148
+ */
149
+ requestContext?: string;
150
+ /**
151
+ * Лид стоит на стадии awaiting_operator (R5): цену/условия даёт человек-оператор.
152
+ * Бот придерживает гостя и не выдумывает детали. Блок «ОЖИДАНИЕ ОПЕРАТОРА».
153
+ */
154
+ awaitingOperator?: boolean;
144
155
  }
@@ -0,0 +1,81 @@
1
+ // Unit tests for the legacy (non-style) system-prompt builder and its block
2
+ // renderers. Pure functions — no fakes needed.
3
+
4
+ import { describe, expect, it } from "bun:test";
5
+ import {
6
+ buildSystemPrompt,
7
+ DEFAULT_PERSONA,
8
+ legacyRagSamplingTemperature,
9
+ renderSummaryBlock,
10
+ renderUserFactsBlock,
11
+ } from "./system-prompt.ts";
12
+
13
+ describe("legacyRagSamplingTemperature", () => {
14
+ it("human → 0.55, assistant → 0.38", () => {
15
+ expect(legacyRagSamplingTemperature({ name: "x", role: "human" })).toBe(0.55);
16
+ expect(legacyRagSamplingTemperature({ name: "x", role: "assistant" })).toBe(0.38);
17
+ });
18
+ it("override имеет приоритет", () => {
19
+ expect(legacyRagSamplingTemperature(DEFAULT_PERSONA, 0.9)).toBe(0.9);
20
+ });
21
+ });
22
+
23
+ describe("renderSummaryBlock", () => {
24
+ it("пусто/whitespace → ''", () => {
25
+ expect(renderSummaryBlock()).toBe("");
26
+ expect(renderSummaryBlock(" ")).toBe("");
27
+ });
28
+ it("с текстом → блок «ИЗ РАННЕЙ ПЕРЕПИСКИ»", () => {
29
+ expect(renderSummaryBlock("обсудили визу")).toContain("ИЗ РАННЕЙ ПЕРЕПИСКИ");
30
+ });
31
+ });
32
+
33
+ describe("renderUserFactsBlock", () => {
34
+ it("undefined / только пустые значения → ''", () => {
35
+ expect(renderUserFactsBlock()).toBe("");
36
+ expect(renderUserFactsBlock({ city: " " })).toBe("");
37
+ });
38
+ it("факты → bullet-список", () => {
39
+ const b = renderUserFactsBlock({ city: "Москва", age: "25" });
40
+ expect(b).toContain("- city: Москва");
41
+ expect(b).toContain("- age: 25");
42
+ });
43
+ });
44
+
45
+ describe("buildSystemPrompt", () => {
46
+ it("human persona: НИКОГДА не говори что ты ИИ + компания", () => {
47
+ const p = buildSystemPrompt({ name: "Аня", role: "human", company: "Acme" }, "CTX");
48
+ expect(p).toContain("Аня");
49
+ expect(p).toContain("Acme");
50
+ expect(p).toContain("НИКОГДА");
51
+ expect(p).toContain("ЖИВАЯ РЕЧЬ");
52
+ expect(p).toContain("CONTEXT:\nCTX");
53
+ });
54
+
55
+ it("assistant persona: ИИ-ассистент, без блока живой речи", () => {
56
+ const p = buildSystemPrompt({ name: "Бот", role: "assistant" }, "CTX");
57
+ expect(p).toContain("ИИ-ассистент");
58
+ expect(p).not.toContain("ЖИВАЯ РЕЧЬ");
59
+ });
60
+
61
+ it("persona.facts → блок ЛИЧНЫЕ ФАКТЫ (пустые отброшены)", () => {
62
+ const p = buildSystemPrompt(
63
+ { name: "Аня", role: "human", facts: { city: "Сочи", empty: " " } },
64
+ "CTX",
65
+ );
66
+ expect(p).toContain("ЛИЧНЫЕ ФАКТЫ");
67
+ expect(p).toContain("city: Сочи");
68
+ expect(p).not.toContain("empty");
69
+ });
70
+
71
+ it("userFacts + summary секции добавляются", () => {
72
+ const p = buildSystemPrompt(
73
+ { name: "x", role: "assistant" },
74
+ "CTX",
75
+ { age: "30" },
76
+ "ранее обсудили",
77
+ );
78
+ expect(p).toContain("ЗНАЕМ О КАНДИДАТЕ");
79
+ expect(p).toContain("ИЗ РАННЕЙ ПЕРЕПИСКИ");
80
+ });
81
+ });