@chatman-media/kb 1.5.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.
@@ -1 +1 @@
1
- {"version":3,"file":"answer.d.ts","sourceRoot":"","sources":["../src/answer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,YAAY,EAGjB,KAAK,OAAO,EACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AA8BzE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,iBAAiB,EACjB,KAAK,OAAO,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,0BAA0B,EAC1B,gBAAgB,EAChB,qBAAqB,GACtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EACL,iBAAiB,EACjB,4BAA4B,EAC5B,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAI5B,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,oEAAoE;IACpE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,CAAC,CAgG/E;AA8OD;;;;;;;;GAQG;AACH,wBAAuB,mBAAmB,CAAC,KAAK,EAAE,WAAW,GAAG,aAAa,CAAC,MAAM,CAAC,CA6HpF;AAED,wBAAsB,aAAa,CAAC,CAAC,SAAS,CAAC,CAAC,UAAU,EACxD,KAAK,EAAE,WAAW,GAAG;IAAE,YAAY,EAAE,CAAC,CAAA;CAAE,GACvC,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACrC,wBAAsB,aAAa,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;AAiG/E;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,UAAU,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB,GAAG,OAAO,CAAC,MAAM,CAAC,CA8BlB"}
1
+ {"version":3,"file":"answer.d.ts","sourceRoot":"","sources":["../src/answer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,YAAY,EAGjB,KAAK,OAAO,EACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAmCzE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,iBAAiB,EACjB,KAAK,OAAO,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,0BAA0B,EAC1B,gBAAgB,EAChB,qBAAqB,GACtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EACL,iBAAiB,EACjB,4BAA4B,EAC5B,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAa5B,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,oEAAoE;IACpE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,CAAC,CAgG/E;AAgPD;;;;;;;;GAQG;AACH,wBAAuB,mBAAmB,CAAC,KAAK,EAAE,WAAW,GAAG,aAAa,CAAC,MAAM,CAAC,CA6HpF;AAED,wBAAsB,aAAa,CAAC,CAAC,SAAS,CAAC,CAAC,UAAU,EACxD,KAAK,EAAE,WAAW,GAAG;IAAE,YAAY,EAAE,CAAC,CAAA;CAAE,GACvC,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACrC,wBAAsB,aAAa,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;AAiG/E;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,UAAU,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB,GAAG,OAAO,CAAC,MAAM,CAAC,CA8BlB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=answer.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"answer.test.d.ts","sourceRoot":"","sources":["../src/answer.test.ts"],"names":[],"mappings":""}
package/dist/index.js CHANGED
@@ -59803,6 +59803,17 @@ function classifyTopicAll(question) {
59803
59803
  var KNOWN_TOPICS = TOPIC_PATTERNS.map((p) => p.topic);
59804
59804
 
59805
59805
  // src/answer.ts
59806
+ function toolCallsToGroundingContext(records) {
59807
+ if (!records || records.length === 0)
59808
+ return "";
59809
+ const lines = records.map((record2, index) => {
59810
+ const result = typeof record2.result === "string" ? record2.result : JSON.stringify(record2.result);
59811
+ return `[#tool-${index + 1}] ${record2.name}: ${result ?? ""}`;
59812
+ });
59813
+ return `TOOL RESULTS:
59814
+ ${lines.join(`
59815
+ `)}`;
59816
+ }
59806
59817
  async function retrieveHits(input) {
59807
59818
  const topK = input.topK ?? 5;
59808
59819
  const candidateK = input.reranker ? topK * 3 : topK;
@@ -60001,10 +60012,14 @@ ${kbContextStr}` : vacBlock : kbContextStr;
60001
60012
  const groundingExempt = input.stage !== undefined && GROUNDING_EXEMPT_STAGES.has(input.stage);
60002
60013
  const runFactCheck = (input.reflect || runVacancyCheck) && text !== NO_CONTEXT_MARKER && text.trim().length > 0 && !(groundingExempt && !runVacancyCheck);
60003
60014
  if (runFactCheck) {
60015
+ const toolContext = toolCallsToGroundingContext(multiCycleToolCalls);
60016
+ const groundingContext = toolContext ? [context, toolContext].filter(Boolean).join(`
60017
+
60018
+ `) : context;
60004
60019
  const verdict = await checkFacts({
60005
60020
  question: input.question,
60006
60021
  answer: text,
60007
- context,
60022
+ context: groundingContext,
60008
60023
  chat: input.chat,
60009
60024
  ...runVacancyCheck ? { vacanciesBlock: vacBlock } : {}
60010
60025
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ingest.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ingest.test.d.ts","sourceRoot":"","sources":["../src/ingest.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=system-prompt.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"system-prompt.test.d.ts","sourceRoot":"","sources":["../src/system-prompt.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=vision.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vision.test.d.ts","sourceRoot":"","sources":["../src/vision.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatman-media/kb",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Tenant-scoped Knowledge Base: hybrid retrieval (pgvector + BM25), ingest, answer pipeline, persona/skill composition. LLM I/O живёт в @chatman-media/llm-router.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,380 @@
1
+ // Unit tests for the RAG answer pipeline (answer.ts): retrieveHits,
2
+ // answerWithRag (+ its answerFromHits branches), answerWithRagStream and
3
+ // generateSoftFallback. The KB store, embedder and chat client are faked so
4
+ // every branch (shortcuts, no-context, structured output, tool loop, fact
5
+ // check drops, retrieval failure, streaming) runs without network or DB.
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import type { ChatClient, ChatMessage, EmbeddingClient } from "@chatman-media/llm-router";
9
+ import { z } from "zod";
10
+ import {
11
+ answerWithRag,
12
+ answerWithRagStream,
13
+ generateSoftFallback,
14
+ NO_CONTEXT_MARKER,
15
+ retrieveHits,
16
+ } from "./answer.ts";
17
+ import type { AnswerInput } from "./answer-types.ts";
18
+ import type { KbSearchHit } from "./types.ts";
19
+
20
+ const hit = (id: number, over: Partial<KbSearchHit> = {}): KbSearchHit => ({
21
+ chunk_id: id,
22
+ distance: 0.1,
23
+ text: `chunk ${id}`,
24
+ document_id: 1,
25
+ source: "src",
26
+ title: `title ${id}`,
27
+ ...over,
28
+ });
29
+
30
+ const embedder: EmbeddingClient = {
31
+ embed: async (inputs: string[]) => inputs.map(() => [1, 0, 0]),
32
+ dim: 3,
33
+ };
34
+
35
+ function fakeKb(
36
+ over: Partial<Record<keyof FakeKbCalls, unknown>> = {},
37
+ hits: KbSearchHit[] = [hit(1)],
38
+ ) {
39
+ const calls: FakeKbCalls = { search: 0, hybrid: 0, priority: 0 };
40
+ const store = {
41
+ search: async (_e: number[], _k: number, topic?: string | null) => {
42
+ calls.search++;
43
+ calls.lastTopic = topic ?? null;
44
+ return (over.search as KbSearchHit[] | undefined) ?? hits;
45
+ },
46
+ hybridSearch: async () => {
47
+ calls.hybrid++;
48
+ return (over.hybrid as KbSearchHit[] | undefined) ?? hits;
49
+ },
50
+ prioritySearch: async () => {
51
+ calls.priority++;
52
+ return (over.priority as KbSearchHit[] | undefined) ?? hits;
53
+ },
54
+ } as unknown as AnswerInput["kb"];
55
+ return { store, calls };
56
+ }
57
+ interface FakeKbCalls {
58
+ search: number;
59
+ hybrid: number;
60
+ priority: number;
61
+ lastTopic?: string | null;
62
+ }
63
+
64
+ /** Chat client whose complete() returns a fixed string and records messages. */
65
+ function fakeChat(
66
+ reply: string | ((m: ChatMessage[]) => string),
67
+ extra: Partial<ChatClient> = {},
68
+ ): ChatClient & { lastMessages?: ChatMessage[] } {
69
+ const c: ChatClient & { lastMessages?: ChatMessage[] } = {
70
+ complete: async (messages: ChatMessage[]) => {
71
+ c.lastMessages = messages;
72
+ return typeof reply === "function" ? reply(messages) : reply;
73
+ },
74
+ ...extra,
75
+ };
76
+ return c;
77
+ }
78
+
79
+ const baseInput = (over: Partial<AnswerInput> = {}): AnswerInput => {
80
+ const { store } = fakeKb();
81
+ return {
82
+ question: "Расскажи про вакансию в Китае и условия", // job intent → not a shortcut
83
+ kb: store,
84
+ embedder,
85
+ chat: fakeChat("ответ модели"),
86
+ ...over,
87
+ };
88
+ };
89
+
90
+ describe("retrieveHits", () => {
91
+ it("single-query vector search, без topic", async () => {
92
+ const { store, calls } = fakeKb();
93
+ const r = await retrieveHits(baseInput({ kb: store }));
94
+ expect(r.hits).toHaveLength(1);
95
+ expect(calls.search).toBe(1);
96
+ expect(r.searchQuery).toContain("вакансию");
97
+ });
98
+
99
+ it("hybridSearch path", async () => {
100
+ const { store, calls } = fakeKb();
101
+ await retrieveHits(baseInput({ kb: store, hybridSearch: true }));
102
+ expect(calls.hybrid).toBe(1);
103
+ expect(calls.search).toBe(0);
104
+ });
105
+
106
+ it("booksPriority path", async () => {
107
+ const { store, calls } = fakeKb();
108
+ const r = await retrieveHits(baseInput({ kb: store, booksPriority: true }));
109
+ expect(calls.priority).toBe(1);
110
+ expect(r.usedTopic).toBeNull();
111
+ });
112
+
113
+ it("topicRouting: пустой результат по topic → fallback на global (usedTopic=null)", async () => {
114
+ let first = true;
115
+ const store = {
116
+ search: async () => {
117
+ if (first) {
118
+ first = false;
119
+ return [];
120
+ }
121
+ return [hit(2)];
122
+ },
123
+ hybridSearch: async () => [],
124
+ prioritySearch: async () => [],
125
+ } as unknown as AnswerInput["kb"];
126
+ const r = await retrieveHits(
127
+ baseInput({ kb: store, topicRouting: true, question: "виза china" }),
128
+ );
129
+ // fell back to global → got the hit, usedTopic reset to null
130
+ expect(r.usedTopic).toBeNull();
131
+ });
132
+
133
+ it("maxDistance фильтрует далёкие хиты (non-hybrid)", async () => {
134
+ const { store } = fakeKb({}, [hit(1, { distance: 0.1 }), hit(2, { distance: 0.9 })]);
135
+ const r = await retrieveHits(baseInput({ kb: store, maxDistance: 0.5 }));
136
+ expect(r.hits.map((h) => h.chunk_id)).toEqual([1]);
137
+ });
138
+
139
+ it("multiQuery → expandQueries + RRF merge", async () => {
140
+ const { store, calls } = fakeKb({}, [hit(1), hit(2)]);
141
+ // expandQueries uses chat to generate variants; a chat returning lines works,
142
+ // but multiQuery merges per-query result lists regardless of expansion output.
143
+ const r = await retrieveHits(
144
+ baseInput({ kb: store, multiQuery: true, chat: fakeChat("вариант1\nвариант2") }),
145
+ );
146
+ expect(r.queries.length).toBeGreaterThanOrEqual(1);
147
+ expect(calls.search).toBeGreaterThanOrEqual(1);
148
+ });
149
+
150
+ it("embedder без вектора → throw", async () => {
151
+ const empty: EmbeddingClient = { embed: async () => [], dim: 3 };
152
+ await expect(retrieveHits(baseInput({ embedder: empty }))).rejects.toThrow(/no vector/);
153
+ });
154
+ });
155
+
156
+ describe("answerWithRag — persona shortcuts", () => {
157
+ it("smalltalk «кто ты?» → path=smalltalk, без LLM", async () => {
158
+ const chat = fakeChat(() => {
159
+ throw new Error("should not call LLM");
160
+ });
161
+ const r = await answerWithRag(baseInput({ question: "кто ты?", chat }));
162
+ expect(r.telemetry.path).toBe("smalltalk");
163
+ expect(r.hits).toHaveLength(0);
164
+ });
165
+
166
+ it("bot-presence «ты бот?» → path=smalltalk", async () => {
167
+ const r = await answerWithRag(baseInput({ question: "ты бот?" }));
168
+ expect(r.telemetry.path).toBe("smalltalk");
169
+ });
170
+
171
+ it("personal-fact «где ты живёшь?» с persona.facts → path=persona_fact", async () => {
172
+ const r = await answerWithRag(
173
+ baseInput({
174
+ question: "где ты живёшь?",
175
+ persona: { name: "Аня", role: "human", facts: { city: "Москва" } },
176
+ }),
177
+ );
178
+ expect(r.telemetry.path).toBe("persona_fact");
179
+ expect(r.text).toContain("Москва");
180
+ });
181
+ });
182
+
183
+ describe("answerWithRag — main paths", () => {
184
+ it("happy path: hits + LLM → path=ok, usedChunkIds", async () => {
185
+ const onTel: unknown[] = [];
186
+ const r = await answerWithRag(baseInput({ onTelemetry: (t) => onTel.push(t) }));
187
+ expect(r.telemetry.path).toBe("ok");
188
+ expect(r.text.toLowerCase()).toBe("ответ модели");
189
+ expect(r.usedChunkIds).toEqual([1]);
190
+ expect(onTel).toHaveLength(1);
191
+ });
192
+
193
+ it("нет hits, нет style/vac/tools → NO_CONTEXT_MARKER", async () => {
194
+ const { store } = fakeKb({ search: [] });
195
+ const r = await answerWithRag(baseInput({ kb: store }));
196
+ expect(r.text).toBe(NO_CONTEXT_MARKER);
197
+ expect(r.telemetry.path).toBe("no_context");
198
+ });
199
+
200
+ it("retrieval падает → отвечаем без базы (graceful)", async () => {
201
+ const store = {
202
+ search: async () => {
203
+ throw new Error("vector down");
204
+ },
205
+ hybridSearch: async () => [],
206
+ prioritySearch: async () => [],
207
+ } as unknown as AnswerInput["kb"];
208
+ // no style → with zero hits answerFromHits returns no_context (no throw)
209
+ const r = await answerWithRag(baseInput({ kb: store }));
210
+ expect(r.text).toBe(NO_CONTEXT_MARKER);
211
+ });
212
+
213
+ it("structured output через completeStructured", async () => {
214
+ const schema = z.object({ ok: z.boolean() });
215
+ const chat = fakeChat("unused", {
216
+ completeStructured: async () => '{"ok":true}',
217
+ });
218
+ const r = await answerWithRag({ ...baseInput({ chat }), outputSchema: schema });
219
+ expect(r.output).toEqual({ ok: true });
220
+ expect(r.telemetry.path).toBe("ok");
221
+ });
222
+
223
+ it("structured output fallback (нет completeStructured) → inject + complete temp0", async () => {
224
+ const schema = z.object({ n: z.number() });
225
+ const chat = fakeChat('{"n":7}');
226
+ const r = await answerWithRag({ ...baseInput({ chat }), outputSchema: schema });
227
+ expect(r.output).toEqual({ n: 7 });
228
+ });
229
+
230
+ it("fact-check (reflect): ungrounded → дроп в NO_CONTEXT_MARKER, path=ungrounded", async () => {
231
+ const chat = fakeChat((m) => {
232
+ // checkFacts uses the last system prompt asking for JSON verdict
233
+ const sys = m[0]?.content ?? "";
234
+ return sys.includes("grounded")
235
+ ? '{"grounded":false,"vacancyOk":true,"reason":"выдумал"}'
236
+ : "сырой ответ";
237
+ });
238
+ const r = await answerWithRag(baseInput({ chat, reflect: true, stage: "pitch" }));
239
+ expect(r.telemetry.path).toBe("ungrounded");
240
+ expect(r.text).toBe(NO_CONTEXT_MARKER);
241
+ });
242
+
243
+ it("vacancyGuard: mismatched vacancy → дроп", async () => {
244
+ const chat = fakeChat((m) => {
245
+ const sys = m[0]?.content ?? "";
246
+ return sys.includes("grounded")
247
+ ? '{"grounded":true,"vacancyOk":false,"reason":"цифры"}'
248
+ : "ответ с вакансией";
249
+ });
250
+ const r = await answerWithRag(
251
+ baseInput({ chat, vacanciesBlock: "Вакансия 2000$", stage: "pitch" }),
252
+ );
253
+ expect(r.telemetry.path).toBe("ungrounded");
254
+ });
255
+
256
+ it("grounding-exempt стадия (qualify) без vacancy → fact-check скипается", async () => {
257
+ let factCheckCalled = false;
258
+ const chat = fakeChat((m) => {
259
+ if ((m[0]?.content ?? "").includes("grounded")) {
260
+ factCheckCalled = true;
261
+ return '{"grounded":false,"vacancyOk":true}';
262
+ }
263
+ return "вопрос кандидату";
264
+ });
265
+ const r = await answerWithRag(baseInput({ chat, reflect: true, stage: "qualify" }));
266
+ expect(factCheckCalled).toBe(false);
267
+ expect(r.telemetry.path).toBe("ok");
268
+ });
269
+
270
+ it("tool-loop: модель отвечает без вызова tools → early-return ok", async () => {
271
+ const chat = fakeChat("unused", {
272
+ completeWithTools: async () => ({ content: "финальный ответ", toolCalls: [] }),
273
+ }) as ChatClient;
274
+ const tool = {
275
+ name: "quote",
276
+ description: "d",
277
+ parameters: z.object({}),
278
+ execute: async () => "x",
279
+ };
280
+ const r = await answerWithRag(
281
+ baseInput({ chat, tools: [tool] as unknown as AnswerInput["tools"] }),
282
+ );
283
+ expect(r.text.toLowerCase()).toBe("финальный ответ");
284
+ expect(r.telemetry.path).toBe("ok");
285
+ });
286
+
287
+ it("fact-check sees tool results as grounding context", async () => {
288
+ let calls = 0;
289
+ let checkerPrompt = "";
290
+ const chat = fakeChat("unused", {
291
+ completeWithTools: async () => {
292
+ calls++;
293
+ if (calls === 1) {
294
+ return {
295
+ content: null,
296
+ toolCalls: [{ id: "q1", name: "quote", args: { asset: "USDT", amount: 335 } }],
297
+ };
298
+ }
299
+ return { content: "Курс 31.5, получите 10553 THB.", toolCalls: [] };
300
+ },
301
+ complete: async (messages: ChatMessage[]) => {
302
+ if ((messages[0]?.content ?? "").includes("grounded")) {
303
+ checkerPrompt = messages[1]?.content ?? "";
304
+ return '{"grounded":true,"vacancyOk":true}';
305
+ }
306
+ return "Курс 31.5, получите 10553 THB.";
307
+ },
308
+ }) as ChatClient;
309
+ const tool = {
310
+ name: "quote",
311
+ description: "d",
312
+ parameters: z.object({ asset: z.string(), amount: z.number() }),
313
+ execute: async () => ({ rate: 31.5, amountToThb: 10553 }),
314
+ };
315
+ const r = await answerWithRag(
316
+ baseInput({ chat, reflect: true, tools: [tool] as unknown as AnswerInput["tools"] }),
317
+ );
318
+ expect(r.telemetry.path).toBe("ok");
319
+ expect(r.text).toContain("10553 THB");
320
+ expect(checkerPrompt).toContain("TOOL RESULTS");
321
+ expect(checkerPrompt).toContain("quote");
322
+ expect(checkerPrompt).toContain("10553");
323
+ });
324
+ });
325
+
326
+ describe("answerWithRagStream", () => {
327
+ async function collect(it: AsyncIterable<string>): Promise<string> {
328
+ let out = "";
329
+ for await (const t of it) out += t;
330
+ return out;
331
+ }
332
+
333
+ it("smalltalk shortcut → yield + telemetry", async () => {
334
+ const tel: { path?: string }[] = [];
335
+ const out = await collect(
336
+ answerWithRagStream(baseInput({ question: "кто ты?", onTelemetry: (t) => tel.push(t) })),
337
+ );
338
+ expect(out.length).toBeGreaterThan(0);
339
+ expect(tel[0]?.path).toBe("smalltalk");
340
+ });
341
+
342
+ it("no-context → yield NO_CONTEXT_MARKER", async () => {
343
+ const { store } = fakeKb({ search: [] });
344
+ const out = await collect(answerWithRagStream(baseInput({ kb: store })));
345
+ expect(out).toBe(NO_CONTEXT_MARKER);
346
+ });
347
+
348
+ it("stream() доступен → токены отдаются по одному", async () => {
349
+ const chat = fakeChat("unused", {
350
+ // eslint-disable-next-line require-yield
351
+ stream: async function* () {
352
+ yield "при";
353
+ yield "вет";
354
+ },
355
+ }) as ChatClient;
356
+ const out = await collect(answerWithRagStream(baseInput({ chat })));
357
+ expect(out).toBe("привет");
358
+ });
359
+
360
+ it("нет stream() → fallback на complete()", async () => {
361
+ const out = await collect(answerWithRagStream(baseInput({ chat: fakeChat("полный ответ") })));
362
+ // complete() fallback runs sanitizeLlmOutput (capitalises the first letter)
363
+ expect(out.toLowerCase()).toBe("полный ответ");
364
+ });
365
+ });
366
+
367
+ describe("generateSoftFallback", () => {
368
+ it("строит persona-промпт и санитизирует ответ", async () => {
369
+ const chat = fakeChat("Уточню и вернусь!");
370
+ const r = await generateSoftFallback({
371
+ question: "сколько платят?",
372
+ chat,
373
+ persona: { name: "Аня", role: "human", company: "Acme" },
374
+ });
375
+ expect(r).toBe("Уточню и вернусь!");
376
+ const sys = chat.lastMessages?.[0]?.content ?? "";
377
+ expect(sys).toContain("Аня");
378
+ expect(sys).toContain("Acme");
379
+ });
380
+ });
package/src/answer.ts CHANGED
@@ -33,7 +33,12 @@ import {
33
33
  legacyRagSamplingTemperature,
34
34
  } from "./system-prompt.ts";
35
35
  import { applyStyleRules } from "./text-style-rules.ts";
36
- import { buildToolTelemetry, DEFAULT_MAX_TOOL_CYCLES, runToolLoop } from "./tool-loop.ts";
36
+ import {
37
+ buildToolTelemetry,
38
+ DEFAULT_MAX_TOOL_CYCLES,
39
+ runToolLoop,
40
+ type ToolCallRecord,
41
+ } from "./tool-loop.ts";
37
42
  import type { AnyRagTool } from "./tools.ts";
38
43
  import { classifyTopic } from "./topic-classifier.ts";
39
44
  import type { KbSearchHit } from "./types.ts";
@@ -62,6 +67,15 @@ export {
62
67
  renderUserFactsBlock,
63
68
  } from "./system-prompt.ts";
64
69
 
70
+ function toolCallsToGroundingContext(records: ToolCallRecord[] | undefined): string {
71
+ if (!records || records.length === 0) return "";
72
+ const lines = records.map((record, index) => {
73
+ const result = typeof record.result === "string" ? record.result : JSON.stringify(record.result);
74
+ return `[#tool-${index + 1}] ${record.name}: ${result ?? ""}`;
75
+ });
76
+ return `TOOL RESULTS:\n${lines.join("\n")}`;
77
+ }
78
+
65
79
  // ── Shared retrieval ─────────────────────────────────────────────────────────
66
80
 
67
81
  export interface RetrievalResult {
@@ -368,10 +382,12 @@ async function answerFromHits(opts: {
368
382
  !(groundingExempt && !runVacancyCheck);
369
383
 
370
384
  if (runFactCheck) {
385
+ const toolContext = toolCallsToGroundingContext(multiCycleToolCalls);
386
+ const groundingContext = toolContext ? [context, toolContext].filter(Boolean).join("\n\n") : context;
371
387
  const verdict = await checkFacts({
372
388
  question: input.question,
373
389
  answer: text,
374
- context,
390
+ context: groundingContext,
375
391
  chat: input.chat,
376
392
  ...(runVacancyCheck ? { vacanciesBlock: vacBlock } : {}),
377
393
  });
@@ -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");
@@ -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
+ });
@@ -0,0 +1,199 @@
1
+ // Unit tests for the vision helpers: photo classification + passport identity
2
+ // extraction. Both hit an OpenAI-compatible /chat/completions endpoint, so the
3
+ // transport is exercised with a mock `fetch` (request shape, response parsing,
4
+ // error branches). Pure parsers (`parsePhotoClass`, `parsePassportJson`) are
5
+ // tested directly.
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import type { FetchLike } from "@chatman-media/llm-router";
9
+ import {
10
+ type ClassifyPhotoOptions,
11
+ classifyPhoto,
12
+ extractPassportIdentity,
13
+ parsePassportJson,
14
+ parsePhotoClass,
15
+ } from "./vision.ts";
16
+
17
+ const bytes = new TextEncoder().encode("fake-image").buffer as ArrayBuffer;
18
+
19
+ interface MockCall {
20
+ url: string;
21
+ init: RequestInit;
22
+ }
23
+
24
+ /** Mock fetch that records the call and returns a JSON response. */
25
+ function mockFetch(opts: {
26
+ status?: number;
27
+ body?: unknown;
28
+ nonJson?: boolean;
29
+ calls?: MockCall[];
30
+ }): FetchLike {
31
+ const status = opts.status ?? 200;
32
+ return (async (url: string, init: RequestInit) => {
33
+ opts.calls?.push({ url, init });
34
+ return {
35
+ ok: status >= 200 && status < 300,
36
+ status,
37
+ json: async () => {
38
+ if (opts.nonJson) throw new Error("invalid json");
39
+ return opts.body ?? {};
40
+ },
41
+ } as Response;
42
+ }) as unknown as FetchLike;
43
+ }
44
+
45
+ function reply(content: string | undefined, finish = "stop") {
46
+ return { choices: [{ message: { content }, finish_reason: finish }] };
47
+ }
48
+
49
+ const base: Omit<ClassifyPhotoOptions, "fetch"> = {
50
+ bytes,
51
+ model: "vision-model",
52
+ apiKey: "sk-test",
53
+ };
54
+
55
+ describe("parsePhotoClass", () => {
56
+ it("распознаёт каждую категорию из свободного текста", () => {
57
+ expect(parsePhotoClass("passport")).toBe("passport");
58
+ expect(parsePhotoClass("это full_body фото")).toBe("full_body");
59
+ expect(parsePhotoClass("PORTRAIT.")).toBe("portrait");
60
+ expect(parsePhotoClass("other")).toBe("other");
61
+ });
62
+ it("неизвестный ответ → other", () => {
63
+ expect(parsePhotoClass("не знаю")).toBe("other");
64
+ expect(parsePhotoClass("")).toBe("other");
65
+ });
66
+ });
67
+
68
+ describe("classifyPhoto", () => {
69
+ it("требует apiKey", async () => {
70
+ await expect(classifyPhoto({ ...base, apiKey: " ", fetch: mockFetch({}) })).rejects.toThrow(
71
+ /apiKey required/,
72
+ );
73
+ });
74
+
75
+ it("openrouter (default): шлёт reasoning:{enabled:false}, data-url, парсит ответ", async () => {
76
+ const calls: MockCall[] = [];
77
+ const cls = await classifyPhoto({
78
+ ...base,
79
+ fetch: mockFetch({ body: reply("full_body"), calls }),
80
+ });
81
+ expect(cls).toBe("full_body");
82
+ expect(calls).toHaveLength(1);
83
+ expect(calls[0]?.url).toBe("https://openrouter.ai/api/v1/chat/completions");
84
+ const body = JSON.parse(calls[0]?.init.body as string);
85
+ expect(body.reasoning).toEqual({ enabled: false });
86
+ expect(body.model).toBe("vision-model");
87
+ const imgPart = body.messages[1].content.find((p: { type: string }) => p.type === "image_url");
88
+ expect(imgPart.image_url.url).toStartWith("data:image/jpeg;base64,");
89
+ expect((calls[0]?.init.headers as Record<string, string>).authorization).toBe("Bearer sk-test");
90
+ });
91
+
92
+ it("openai provider: НЕ шлёт reasoning", async () => {
93
+ const calls: MockCall[] = [];
94
+ await classifyPhoto({
95
+ ...base,
96
+ provider: "openai",
97
+ fetch: mockFetch({ body: reply("portrait"), calls }),
98
+ });
99
+ const body = JSON.parse(calls[0]?.init.body as string);
100
+ expect(body.reasoning).toBeUndefined();
101
+ });
102
+
103
+ it("custom baseUrl c хвостовыми слэшами нормализуется + mimeType", async () => {
104
+ const calls: MockCall[] = [];
105
+ await classifyPhoto({
106
+ ...base,
107
+ baseUrl: "https://api.example.com/v9///",
108
+ mimeType: "image/png",
109
+ fetch: mockFetch({ body: reply("other"), calls }),
110
+ });
111
+ expect(calls[0]?.url).toBe("https://api.example.com/v9/chat/completions");
112
+ const body = JSON.parse(calls[0]?.init.body as string);
113
+ const imgPart = body.messages[1].content.find((p: { type: string }) => p.type === "image_url");
114
+ expect(imgPart.image_url.url).toStartWith("data:image/png;base64,");
115
+ });
116
+
117
+ it("HTTP-ошибка → throw с кодом", async () => {
118
+ await expect(
119
+ classifyPhoto({
120
+ ...base,
121
+ fetch: mockFetch({ status: 500, body: { error: { message: "boom" } } }),
122
+ }),
123
+ ).rejects.toThrow(/vision API error \(HTTP 500\): boom/);
124
+ });
125
+
126
+ it("payload.error при ok → throw", async () => {
127
+ await expect(
128
+ classifyPhoto({ ...base, fetch: mockFetch({ body: { error: { message: "rate" } } }) }),
129
+ ).rejects.toThrow(/rate/);
130
+ });
131
+
132
+ it("не-JSON ответ → throw", async () => {
133
+ await expect(
134
+ classifyPhoto({ ...base, fetch: mockFetch({ status: 502, nonJson: true }) }),
135
+ ).rejects.toThrow(/non-JSON response \(HTTP 502\)/);
136
+ });
137
+
138
+ it("пустой content → throw с finish_reason", async () => {
139
+ await expect(
140
+ classifyPhoto({ ...base, fetch: mockFetch({ body: reply(undefined, "length") }) }),
141
+ ).rejects.toThrow(/empty content \(finish_reason=length\)/);
142
+ });
143
+ });
144
+
145
+ describe("parsePassportJson", () => {
146
+ it("чистый JSON → trim + забор только известных полей", () => {
147
+ expect(parsePassportJson('{"family_name":" IVANOV ","given_name":"ANNA","extra":"x"}')).toEqual(
148
+ { family_name: "IVANOV", given_name: "ANNA" },
149
+ );
150
+ });
151
+ it("снимает think-теги и code-fence", () => {
152
+ expect(
153
+ parsePassportJson('<think>hmm</think>```json\n{"passport_number":"12 34"}\n```'),
154
+ ).toEqual({ passport_number: "12 34" });
155
+ });
156
+ it("нет фигурных скобок → {}", () => {
157
+ expect(parsePassportJson("ничего")).toEqual({});
158
+ });
159
+ it("битый JSON → {}", () => {
160
+ expect(parsePassportJson("{ broken")).toEqual({});
161
+ });
162
+ it("массив / не-объект → {}", () => {
163
+ expect(parsePassportJson("[1,2,3]")).toEqual({});
164
+ });
165
+ it("пустые / слишком длинные значения отбрасываются", () => {
166
+ const long = "x".repeat(101);
167
+ expect(parsePassportJson(`{"family_name":" ","given_name":"${long}"}`)).toEqual({});
168
+ });
169
+ });
170
+
171
+ describe("extractPassportIdentity", () => {
172
+ it("требует apiKey", async () => {
173
+ await expect(
174
+ extractPassportIdentity({ ...base, apiKey: "", fetch: mockFetch({}) }),
175
+ ).rejects.toThrow(/apiKey required/);
176
+ });
177
+ it("парсит поля из ответа модели", async () => {
178
+ const r = await extractPassportIdentity({
179
+ ...base,
180
+ fetch: mockFetch({ body: reply('{"family_name":"PETROV","passport_expiry":"01.02.2030"}') }),
181
+ });
182
+ expect(r).toEqual({ family_name: "PETROV", passport_expiry: "01.02.2030" });
183
+ });
184
+ it("HTTP-ошибка → throw", async () => {
185
+ await expect(
186
+ extractPassportIdentity({ ...base, fetch: mockFetch({ status: 403, body: {} }) }),
187
+ ).rejects.toThrow(/OpenRouter error \(HTTP 403\)/);
188
+ });
189
+ it("не-JSON → throw", async () => {
190
+ await expect(
191
+ extractPassportIdentity({ ...base, fetch: mockFetch({ status: 500, nonJson: true }) }),
192
+ ).rejects.toThrow(/non-JSON response/);
193
+ });
194
+ it("пустой content → throw", async () => {
195
+ await expect(
196
+ extractPassportIdentity({ ...base, fetch: mockFetch({ body: reply(undefined) }) }),
197
+ ).rejects.toThrow(/empty content/);
198
+ });
199
+ });