@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,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
|
+
});
|