@chatman-media/kb 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/ab-router.d.ts +66 -0
  4. package/dist/ab-router.d.ts.map +1 -0
  5. package/dist/answer-types.d.ts +194 -0
  6. package/dist/answer-types.d.ts.map +1 -0
  7. package/dist/answer.d.ts +59 -0
  8. package/dist/answer.d.ts.map +1 -0
  9. package/dist/built-in-tools/calendly.d.ts +19 -0
  10. package/dist/built-in-tools/calendly.d.ts.map +1 -0
  11. package/dist/chunk.d.ts +48 -0
  12. package/dist/chunk.d.ts.map +1 -0
  13. package/dist/conversation-store.d.ts +76 -0
  14. package/dist/conversation-store.d.ts.map +1 -0
  15. package/dist/eval.d.ts +64 -0
  16. package/dist/eval.d.ts.map +1 -0
  17. package/dist/extract-user-facts.d.ts +27 -0
  18. package/dist/extract-user-facts.d.ts.map +1 -0
  19. package/dist/fact-checker.d.ts +46 -0
  20. package/dist/fact-checker.d.ts.map +1 -0
  21. package/dist/grade-skills.d.ts +29 -0
  22. package/dist/grade-skills.d.ts.map +1 -0
  23. package/dist/index.d.ts +76 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +62655 -0
  26. package/dist/ingest.d.ts +49 -0
  27. package/dist/ingest.d.ts.map +1 -0
  28. package/dist/multi-query.d.ts +29 -0
  29. package/dist/multi-query.d.ts.map +1 -0
  30. package/dist/parse-pdf.d.ts +14 -0
  31. package/dist/parse-pdf.d.ts.map +1 -0
  32. package/dist/persona-shortcuts.d.ts +51 -0
  33. package/dist/persona-shortcuts.d.ts.map +1 -0
  34. package/dist/prompt.d.ts +9 -0
  35. package/dist/prompt.d.ts.map +1 -0
  36. package/dist/reflect.d.ts +29 -0
  37. package/dist/reflect.d.ts.map +1 -0
  38. package/dist/reranker.d.ts +71 -0
  39. package/dist/reranker.d.ts.map +1 -0
  40. package/dist/retrieval-utils.d.ts +94 -0
  41. package/dist/retrieval-utils.d.ts.map +1 -0
  42. package/dist/retry.d.ts +53 -0
  43. package/dist/retry.d.ts.map +1 -0
  44. package/dist/rewrite-query.d.ts +30 -0
  45. package/dist/rewrite-query.d.ts.map +1 -0
  46. package/dist/sanitize.d.ts +21 -0
  47. package/dist/sanitize.d.ts.map +1 -0
  48. package/dist/semantic-cache.d.ts +70 -0
  49. package/dist/semantic-cache.d.ts.map +1 -0
  50. package/dist/server.d.ts +77 -0
  51. package/dist/server.d.ts.map +1 -0
  52. package/dist/stores/memory-store.d.ts +72 -0
  53. package/dist/stores/memory-store.d.ts.map +1 -0
  54. package/dist/structured-output.d.ts +21 -0
  55. package/dist/structured-output.d.ts.map +1 -0
  56. package/dist/styles.d.ts +186 -0
  57. package/dist/styles.d.ts.map +1 -0
  58. package/dist/summarize-conversation.d.ts +31 -0
  59. package/dist/summarize-conversation.d.ts.map +1 -0
  60. package/dist/system-prompt.d.ts +11 -0
  61. package/dist/system-prompt.d.ts.map +1 -0
  62. package/dist/text-style-rules.d.ts +133 -0
  63. package/dist/text-style-rules.d.ts.map +1 -0
  64. package/dist/tool-loop.d.ts +44 -0
  65. package/dist/tool-loop.d.ts.map +1 -0
  66. package/dist/tools.d.ts +64 -0
  67. package/dist/tools.d.ts.map +1 -0
  68. package/dist/topic-classifier.d.ts +11 -0
  69. package/dist/topic-classifier.d.ts.map +1 -0
  70. package/dist/types.d.ts +83 -0
  71. package/dist/types.d.ts.map +1 -0
  72. package/dist/utils.d.ts +19 -0
  73. package/dist/utils.d.ts.map +1 -0
  74. package/dist/vision.d.ts +72 -0
  75. package/dist/vision.d.ts.map +1 -0
  76. package/package.json +76 -0
  77. package/src/ab-router.ts +118 -0
  78. package/src/answer-types.ts +191 -0
  79. package/src/answer.ts +696 -0
  80. package/src/built-in-tools/calendly.ts +32 -0
  81. package/src/chunk.ts +198 -0
  82. package/src/conversation-store.ts +138 -0
  83. package/src/eval.ts +127 -0
  84. package/src/extract-user-facts.ts +120 -0
  85. package/src/fact-checker.ts +171 -0
  86. package/src/grade-skills.ts +79 -0
  87. package/src/index.ts +191 -0
  88. package/src/ingest.ts +193 -0
  89. package/src/multi-query.ts +89 -0
  90. package/src/parse-pdf.ts +24 -0
  91. package/src/persona-shortcuts.ts +255 -0
  92. package/src/prompt.ts +190 -0
  93. package/src/reflect.ts +99 -0
  94. package/src/reranker.ts +166 -0
  95. package/src/retrieval-utils.ts +209 -0
  96. package/src/retry.ts +139 -0
  97. package/src/rewrite-query.ts +124 -0
  98. package/src/sanitize.ts +44 -0
  99. package/src/semantic-cache.ts +154 -0
  100. package/src/server.ts +164 -0
  101. package/src/stores/memory-store.ts +249 -0
  102. package/src/structured-output.ts +47 -0
  103. package/src/styles.ts +138 -0
  104. package/src/summarize-conversation.ts +88 -0
  105. package/src/system-prompt.ts +118 -0
  106. package/src/text-style-rules.ts +244 -0
  107. package/src/tool-loop.ts +110 -0
  108. package/src/tools.ts +79 -0
  109. package/src/topic-classifier.ts +112 -0
  110. package/src/types.ts +91 -0
  111. package/src/utils.ts +81 -0
  112. package/src/vision.ts +265 -0
package/src/vision.ts ADDED
@@ -0,0 +1,265 @@
1
+ import type { FetchLike } from "@chatman-media/llm-router";
2
+
3
+ /** AI provider for the vision model — both expose an OpenAI-compatible API. */
4
+ export type VisionProvider = "openrouter" | "openai";
5
+
6
+ /**
7
+ * Photo classification via a vision-capable model.
8
+ *
9
+ * The recruiting funnel collects two distinct kinds of photo from a
10
+ * candidate — full-body shots and a photo of her international passport
11
+ * (загранпаспорт) — plus assorted regular photos. The bot needs to tell
12
+ * them apart so the lead intake counters are accurate (see
13
+ * `src/leads/intake.ts`), instead of the old "total photos >= 7" guess.
14
+ *
15
+ * Works with either OpenRouter or OpenAI (set via `provider`). Both expose
16
+ * an OpenAI-compatible `/chat/completions` endpoint; vision input is the
17
+ * standard `image_url` content part with a data URL. The OpenRouter-only
18
+ * `reasoning` request param is sent only for that provider.
19
+ */
20
+
21
+ export const PHOTO_CLASSES = ["passport", "full_body", "portrait", "other"] as const;
22
+ export type PhotoClass = (typeof PHOTO_CLASSES)[number];
23
+
24
+ const SYSTEM_PROMPT = `Ты классифицируешь фотографию из переписки рекрутингового агентства.
25
+
26
+ Отнеси изображение РОВНО к одной из категорий и верни ТОЛЬКО одно слово:
27
+
28
+ - passport — фотография или скан страницы паспорта/загранпаспорта (видны поля документа, фото-страница, машиночитаемая зона).
29
+ - full_body — человек снят в полный рост (видно всю фигуру от головы до ног или почти всю).
30
+ - portrait — обычное фото человека: лицо, по пояс, селфи, не в полный рост.
31
+ - other — всё остальное (пейзаж, предмет, скриншот, документ, который не паспорт, и т.п.).
32
+
33
+ Ответь СТРОГО одним словом из списка: passport, full_body, portrait, other.
34
+ Без знаков препинания, без пояснений.`;
35
+
36
+ export interface ClassifyPhotoOptions {
37
+ /** Raw image bytes (as downloaded from Telegram). */
38
+ bytes: ArrayBuffer;
39
+ /** MIME type, e.g. "image/jpeg". Falls back to image/jpeg when empty. */
40
+ mimeType?: string;
41
+ /** Vision-capable model id (OpenRouter slug or OpenAI model name). */
42
+ model: string;
43
+ apiKey: string;
44
+ /** AI provider. Default: "openrouter". */
45
+ provider?: VisionProvider;
46
+ /** Default: https://openrouter.ai/api/v1 */
47
+ baseUrl?: string;
48
+ /** Per-request timeout in ms. Default 30_000. */
49
+ timeoutMs?: number;
50
+ fetch?: FetchLike;
51
+ }
52
+
53
+ interface VisionResponse {
54
+ choices?: Array<{ message?: { content?: string }; finish_reason?: string }>;
55
+ error?: { message?: string };
56
+ }
57
+
58
+ const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
59
+ const DEFAULT_TIMEOUT_MS = 30_000;
60
+
61
+ /** Maps a free-form model reply onto a `PhotoClass`, defaulting to `other`. */
62
+ export function parsePhotoClass(raw: string): PhotoClass {
63
+ const word = raw.toLowerCase().match(/passport|full_body|portrait|other/);
64
+ if (word) return word[0] as PhotoClass;
65
+ return "other";
66
+ }
67
+
68
+ /**
69
+ * Downloads nothing — caller passes raw bytes. Returns the classified
70
+ * category. Throws on transport / API errors so the caller can decide
71
+ * to leave the photo unclassified and retry on the next turn.
72
+ */
73
+ export async function classifyPhoto(opts: ClassifyPhotoOptions): Promise<PhotoClass> {
74
+ if (!opts.apiKey || opts.apiKey.trim().length === 0) {
75
+ throw new Error("classifyPhoto: apiKey required");
76
+ }
77
+ const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
78
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
79
+ const mime = opts.mimeType?.trim() ? opts.mimeType : "image/jpeg";
80
+ const base64 = Buffer.from(opts.bytes).toString("base64");
81
+
82
+ const provider = opts.provider ?? "openrouter";
83
+
84
+ const body = {
85
+ model: opts.model,
86
+ stream: false,
87
+ temperature: 0,
88
+ max_tokens: 512,
89
+ // Reasoning models (Gemini 2.5 Flash et al.) spend output tokens on
90
+ // internal thinking — a tiny cap there returns EMPTY content. Disable
91
+ // reasoning and leave generous headroom; the answer itself is one word.
92
+ // `reasoning` is OpenRouter-specific — OpenAI rejects unknown params.
93
+ ...(provider === "openrouter" ? { reasoning: { enabled: false } } : {}),
94
+ messages: [
95
+ { role: "system", content: SYSTEM_PROMPT },
96
+ {
97
+ role: "user",
98
+ content: [
99
+ { type: "text", text: "Категория этого изображения?" },
100
+ { type: "image_url", image_url: { url: `data:${mime};base64,${base64}` } },
101
+ ],
102
+ },
103
+ ],
104
+ };
105
+
106
+ const res = await fetchImpl(`${baseUrl}/chat/completions`, {
107
+ method: "POST",
108
+ headers: {
109
+ "content-type": "application/json",
110
+ authorization: `Bearer ${opts.apiKey}`,
111
+ },
112
+ body: JSON.stringify(body),
113
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
114
+ });
115
+
116
+ let payload: VisionResponse;
117
+ try {
118
+ payload = (await res.json()) as VisionResponse;
119
+ } catch {
120
+ throw new Error(`classifyPhoto: non-JSON response (HTTP ${res.status})`);
121
+ }
122
+ if (!res.ok || payload.error) {
123
+ throw new Error(
124
+ `classifyPhoto: vision API error (HTTP ${res.status}): ${payload.error?.message ?? "unknown"}`,
125
+ );
126
+ }
127
+ const choice = payload.choices?.[0];
128
+ const content = choice?.message?.content;
129
+ if (!content) {
130
+ throw new Error(`classifyPhoto: empty content (finish_reason=${choice?.finish_reason ?? "?"})`);
131
+ }
132
+ return parsePhotoClass(content);
133
+ }
134
+
135
+ /**
136
+ * Identity fields read off a candidate's international passport
137
+ * (загранпаспорт). Latin spelling — the form is what the visa anketa
138
+ * needs and what the MRZ carries unambiguously. Every field optional:
139
+ * a blurry scan may yield only some, or none.
140
+ */
141
+ export interface PassportIdentity {
142
+ /** Surname, Latin, as printed in the passport / MRZ. */
143
+ family_name?: string;
144
+ /** Given name(s), Latin. */
145
+ given_name?: string;
146
+ passport_number?: string;
147
+ /** Expiration date, formatted dd.mm.yyyy to match the intake field. */
148
+ passport_expiry?: string;
149
+ }
150
+
151
+ const PASSPORT_PROMPT = `Ты извлекаешь данные из фотографии загранпаспорта.
152
+
153
+ На изображении — страница с данными заграничного паспорта. Внизу страницы
154
+ есть машиночитаемая зона (MRZ) — две строки из букв, цифр и символов «<».
155
+
156
+ Извлеки и верни СТРОГО JSON-объект с полями (все опциональные):
157
+ - family_name: фамилия ЛАТИНИЦЕЙ, как напечатано в паспорте / в MRZ
158
+ - given_name: имя ЛАТИНИЦЕЙ, как напечатано в паспорте / в MRZ
159
+ - passport_number: номер паспорта
160
+ - passport_expiry: дата окончания срока действия в формате дд.мм.гггг
161
+
162
+ Приоритет источника — MRZ (две нижние строки). Если MRZ нечитаема —
163
+ бери печатные латинские поля. Не транслитерируй и не угадывай сам:
164
+ бери ровно то, что видно.
165
+
166
+ ВЕРНИ ТОЛЬКО JSON, без markdown, без \`\`\`, без пояснений.
167
+ Если поле не читается — НЕ включай его в JSON. Если не видно ничего —
168
+ верни {}.`;
169
+
170
+ /**
171
+ * Strips markdown / think-tags and parses the model's passport JSON.
172
+ * Validates value types and length. Exported for unit tests.
173
+ */
174
+ export function parsePassportJson(raw: string): PassportIdentity {
175
+ let s = raw.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, "");
176
+ s = s.replace(/```(?:json)?/gi, "").trim();
177
+ const start = s.indexOf("{");
178
+ const end = s.lastIndexOf("}");
179
+ if (start === -1 || end === -1 || end < start) return {};
180
+ let parsed: unknown;
181
+ try {
182
+ parsed = JSON.parse(s.slice(start, end + 1));
183
+ } catch {
184
+ return {};
185
+ }
186
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
187
+ return {};
188
+ }
189
+ const obj = parsed as Record<string, unknown>;
190
+ const out: PassportIdentity = {};
191
+ for (const key of ["family_name", "given_name", "passport_number", "passport_expiry"] as const) {
192
+ const val = obj[key];
193
+ if (typeof val === "string" && val.trim() && val.trim().length <= 100) {
194
+ out[key] = val.trim();
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * Reads identity fields off a passport photo via the same vision model
202
+ * used for classification. Caller should only invoke this for photos
203
+ * already classified as `passport`. Throws on transport / API errors so
204
+ * the caller can leave the photo for retry; a successful-but-empty model
205
+ * reply returns `{}` (a valid "nothing readable" result).
206
+ */
207
+ export async function extractPassportIdentity(
208
+ opts: ClassifyPhotoOptions,
209
+ ): Promise<PassportIdentity> {
210
+ if (!opts.apiKey || opts.apiKey.trim().length === 0) {
211
+ throw new Error("extractPassportIdentity: apiKey required");
212
+ }
213
+ const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
214
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
215
+ const mime = opts.mimeType?.trim() ? opts.mimeType : "image/jpeg";
216
+ const base64 = Buffer.from(opts.bytes).toString("base64");
217
+
218
+ const body = {
219
+ model: opts.model,
220
+ stream: false,
221
+ temperature: 0,
222
+ reasoning: { enabled: false },
223
+ max_tokens: 512,
224
+ messages: [
225
+ { role: "system", content: PASSPORT_PROMPT },
226
+ {
227
+ role: "user",
228
+ content: [
229
+ { type: "text", text: "Извлеки данные из этого загранпаспорта." },
230
+ { type: "image_url", image_url: { url: `data:${mime};base64,${base64}` } },
231
+ ],
232
+ },
233
+ ],
234
+ };
235
+
236
+ const res = await fetchImpl(`${baseUrl}/chat/completions`, {
237
+ method: "POST",
238
+ headers: {
239
+ "content-type": "application/json",
240
+ authorization: `Bearer ${opts.apiKey}`,
241
+ },
242
+ body: JSON.stringify(body),
243
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
244
+ });
245
+
246
+ let payload: VisionResponse;
247
+ try {
248
+ payload = (await res.json()) as VisionResponse;
249
+ } catch {
250
+ throw new Error(`extractPassportIdentity: non-JSON response (HTTP ${res.status})`);
251
+ }
252
+ if (!res.ok || payload.error) {
253
+ throw new Error(
254
+ `extractPassportIdentity: OpenRouter error (HTTP ${res.status}): ${payload.error?.message ?? "unknown"}`,
255
+ );
256
+ }
257
+ const choice = payload.choices?.[0];
258
+ const content = choice?.message?.content;
259
+ if (!content) {
260
+ throw new Error(
261
+ `extractPassportIdentity: empty content (finish_reason=${choice?.finish_reason ?? "?"})`,
262
+ );
263
+ }
264
+ return parsePassportJson(content);
265
+ }