@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.
- package/dist/answer-types.d.ts +10 -0
- package/dist/answer-types.d.ts.map +1 -1
- package/dist/answer.d.ts.map +1 -1
- package/dist/answer.test.d.ts +2 -0
- package/dist/answer.test.d.ts.map +1 -0
- package/dist/index.js +26 -3
- package/dist/ingest.test.d.ts +2 -0
- package/dist/ingest.test.d.ts.map +1 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/styles.d.ts +11 -0
- package/dist/styles.d.ts.map +1 -1
- package/dist/system-prompt.test.d.ts +2 -0
- package/dist/system-prompt.test.d.ts.map +1 -0
- package/dist/vision.test.d.ts +2 -0
- package/dist/vision.test.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/answer-types.ts +10 -0
- package/src/answer.test.ts +380 -0
- package/src/answer.ts +22 -2
- package/src/ingest.test.ts +178 -0
- package/src/prompt.test.ts +175 -0
- package/src/prompt.ts +13 -0
- package/src/styles.ts +11 -0
- package/src/system-prompt.test.ts +81 -0
- package/src/vision.test.ts +199 -0
|
@@ -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
|
+
});
|
package/src/prompt.test.ts
CHANGED
|
@@ -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
|
+
});
|