@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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/ab-router.d.ts +66 -0
- package/dist/ab-router.d.ts.map +1 -0
- package/dist/answer-types.d.ts +194 -0
- package/dist/answer-types.d.ts.map +1 -0
- package/dist/answer.d.ts +59 -0
- package/dist/answer.d.ts.map +1 -0
- package/dist/built-in-tools/calendly.d.ts +19 -0
- package/dist/built-in-tools/calendly.d.ts.map +1 -0
- package/dist/chunk.d.ts +48 -0
- package/dist/chunk.d.ts.map +1 -0
- package/dist/conversation-store.d.ts +76 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/eval.d.ts +64 -0
- package/dist/eval.d.ts.map +1 -0
- package/dist/extract-user-facts.d.ts +27 -0
- package/dist/extract-user-facts.d.ts.map +1 -0
- package/dist/fact-checker.d.ts +46 -0
- package/dist/fact-checker.d.ts.map +1 -0
- package/dist/grade-skills.d.ts +29 -0
- package/dist/grade-skills.d.ts.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62655 -0
- package/dist/ingest.d.ts +49 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/multi-query.d.ts +29 -0
- package/dist/multi-query.d.ts.map +1 -0
- package/dist/parse-pdf.d.ts +14 -0
- package/dist/parse-pdf.d.ts.map +1 -0
- package/dist/persona-shortcuts.d.ts +51 -0
- package/dist/persona-shortcuts.d.ts.map +1 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/reflect.d.ts +29 -0
- package/dist/reflect.d.ts.map +1 -0
- package/dist/reranker.d.ts +71 -0
- package/dist/reranker.d.ts.map +1 -0
- package/dist/retrieval-utils.d.ts +94 -0
- package/dist/retrieval-utils.d.ts.map +1 -0
- package/dist/retry.d.ts +53 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/rewrite-query.d.ts +30 -0
- package/dist/rewrite-query.d.ts.map +1 -0
- package/dist/sanitize.d.ts +21 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/semantic-cache.d.ts +70 -0
- package/dist/semantic-cache.d.ts.map +1 -0
- package/dist/server.d.ts +77 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/stores/memory-store.d.ts +72 -0
- package/dist/stores/memory-store.d.ts.map +1 -0
- package/dist/structured-output.d.ts +21 -0
- package/dist/structured-output.d.ts.map +1 -0
- package/dist/styles.d.ts +186 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/summarize-conversation.d.ts +31 -0
- package/dist/summarize-conversation.d.ts.map +1 -0
- package/dist/system-prompt.d.ts +11 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/text-style-rules.d.ts +133 -0
- package/dist/text-style-rules.d.ts.map +1 -0
- package/dist/tool-loop.d.ts +44 -0
- package/dist/tool-loop.d.ts.map +1 -0
- package/dist/tools.d.ts +64 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/topic-classifier.d.ts +11 -0
- package/dist/topic-classifier.d.ts.map +1 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/vision.d.ts +72 -0
- package/dist/vision.d.ts.map +1 -0
- package/package.json +76 -0
- package/src/ab-router.ts +118 -0
- package/src/answer-types.ts +191 -0
- package/src/answer.ts +696 -0
- package/src/built-in-tools/calendly.ts +32 -0
- package/src/chunk.ts +198 -0
- package/src/conversation-store.ts +138 -0
- package/src/eval.ts +127 -0
- package/src/extract-user-facts.ts +120 -0
- package/src/fact-checker.ts +171 -0
- package/src/grade-skills.ts +79 -0
- package/src/index.ts +191 -0
- package/src/ingest.ts +193 -0
- package/src/multi-query.ts +89 -0
- package/src/parse-pdf.ts +24 -0
- package/src/persona-shortcuts.ts +255 -0
- package/src/prompt.ts +190 -0
- package/src/reflect.ts +99 -0
- package/src/reranker.ts +166 -0
- package/src/retrieval-utils.ts +209 -0
- package/src/retry.ts +139 -0
- package/src/rewrite-query.ts +124 -0
- package/src/sanitize.ts +44 -0
- package/src/semantic-cache.ts +154 -0
- package/src/server.ts +164 -0
- package/src/stores/memory-store.ts +249 -0
- package/src/structured-output.ts +47 -0
- package/src/styles.ts +138 -0
- package/src/summarize-conversation.ts +88 -0
- package/src/system-prompt.ts +118 -0
- package/src/text-style-rules.ts +244 -0
- package/src/tool-loop.ts +110 -0
- package/src/tools.ts +79 -0
- package/src/topic-classifier.ts +112 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +81 -0
- package/src/vision.ts +265 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AnyRagTool } from "../tools.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Встроенный tool: "offer_booking_link".
|
|
6
|
+
*
|
|
7
|
+
* Когда лид/кандидат хочет записаться на звонок, демо или встречу — LLM
|
|
8
|
+
* вызывает этот tool, получает ссылку и вставляет её в ответ.
|
|
9
|
+
* Работает с любой платформой бронирования: Calendly, Cal.com, Tidycal, и т.д.
|
|
10
|
+
*
|
|
11
|
+
* @param schedulingUrl — публичная ссылка на страницу бронирования тенанта
|
|
12
|
+
* Пример: "https://calendly.com/your-agency/30min"
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const tool = makeBookingLinkTool("https://calendly.com/my-agency/demo");
|
|
17
|
+
* const result = await answerWithRag({ ..., tools: [tool] });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function makeBookingLinkTool(schedulingUrl: string): AnyRagTool {
|
|
21
|
+
return {
|
|
22
|
+
name: "offer_booking_link",
|
|
23
|
+
description: [
|
|
24
|
+
"Use this tool when the lead or candidate explicitly wants to book a call,",
|
|
25
|
+
"schedule a demo, arrange a meeting, or talk to a human.",
|
|
26
|
+
"Returns the booking page URL so you can share it in your reply.",
|
|
27
|
+
"Do NOT use it proactively — only when the person asks to book/schedule.",
|
|
28
|
+
].join(" "),
|
|
29
|
+
parameters: z.object({}),
|
|
30
|
+
execute: async () => ({ url: schedulingUrl }),
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/chunk.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
export interface ChunkOptions {
|
|
2
|
+
maxChars: number;
|
|
3
|
+
overlapChars: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface Chunk {
|
|
7
|
+
index: number;
|
|
8
|
+
text: string;
|
|
9
|
+
/** Rough token estimate: ~4 chars per token. */
|
|
10
|
+
tokenCount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_OPTIONS: ChunkOptions = {
|
|
14
|
+
maxChars: 1500,
|
|
15
|
+
overlapChars: 150,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Approximate token count for English/mixed text: ~4 chars per token. */
|
|
19
|
+
export function estimateTokens(text: string): number {
|
|
20
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Splits `text` into overlapping chunks bounded by `maxChars`. Tries to break
|
|
25
|
+
* on paragraph boundaries (\n\n) first, then on whitespace, before slicing
|
|
26
|
+
* mid-word. Empty/whitespace-only input yields an empty array.
|
|
27
|
+
*/
|
|
28
|
+
export function chunkText(text: string, opts: Partial<ChunkOptions> = {}): Chunk[] {
|
|
29
|
+
const { maxChars, overlapChars } = { ...DEFAULT_OPTIONS, ...opts };
|
|
30
|
+
if (overlapChars < 0 || overlapChars >= maxChars) {
|
|
31
|
+
throw new Error("overlapChars must be in [0, maxChars)");
|
|
32
|
+
}
|
|
33
|
+
const trimmed = text.trim();
|
|
34
|
+
if (!trimmed) return [];
|
|
35
|
+
|
|
36
|
+
const paragraphs = trimmed
|
|
37
|
+
.split(/\n{2,}/)
|
|
38
|
+
.map((p) => p.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
|
|
41
|
+
const segments: string[] = [];
|
|
42
|
+
let buf = "";
|
|
43
|
+
for (const p of paragraphs) {
|
|
44
|
+
if (p.length > maxChars) {
|
|
45
|
+
if (buf) {
|
|
46
|
+
segments.push(buf);
|
|
47
|
+
buf = "";
|
|
48
|
+
}
|
|
49
|
+
for (const part of splitLong(p, maxChars, overlapChars)) {
|
|
50
|
+
segments.push(part);
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!buf) {
|
|
55
|
+
buf = p;
|
|
56
|
+
} else if (buf.length + 2 + p.length <= maxChars) {
|
|
57
|
+
buf = `${buf}\n\n${p}`;
|
|
58
|
+
} else {
|
|
59
|
+
segments.push(buf);
|
|
60
|
+
buf = p;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (buf) segments.push(buf);
|
|
64
|
+
|
|
65
|
+
if (overlapChars > 0 && segments.length > 1) {
|
|
66
|
+
for (let i = 1; i < segments.length; i++) {
|
|
67
|
+
const prevTail = (segments[i - 1] ?? "").slice(-overlapChars);
|
|
68
|
+
let current = `${prevTail}${segments[i] ?? ""}`;
|
|
69
|
+
if (current.length > maxChars) {
|
|
70
|
+
current = current.slice(0, maxChars);
|
|
71
|
+
}
|
|
72
|
+
segments[i] = current;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return segments.map((text, index) => ({
|
|
77
|
+
index,
|
|
78
|
+
text,
|
|
79
|
+
tokenCount: estimateTokens(text),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SectionChunk extends Chunk {
|
|
84
|
+
/** Heading that introduces this section, or null for the document preamble. */
|
|
85
|
+
heading: string | null;
|
|
86
|
+
/** Heading level (1–6) derived from the number of `#` characters, or null. */
|
|
87
|
+
headingLevel: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Semantic chunker that splits Markdown/plain-text documents by headings
|
|
92
|
+
* (`#`, `##`, …) and paragraph breaks, keeping each heading with its content.
|
|
93
|
+
*
|
|
94
|
+
* Compared to `chunkText()`:
|
|
95
|
+
* - Never breaks a heading away from its first paragraph
|
|
96
|
+
* - Each chunk carries the heading context, so retrieval results are
|
|
97
|
+
* self-contained even without surrounding text
|
|
98
|
+
* - Falls back to `chunkText()` for sections that exceed `maxChars`
|
|
99
|
+
*
|
|
100
|
+
* Use this for structured knowledge-base documents (FAQs, wikis, product
|
|
101
|
+
* pages). Use `chunkText()` for unstructured long-form prose.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { chunkBySections } from "@chatman-media/kb";
|
|
106
|
+
*
|
|
107
|
+
* const chunks = chunkBySections(markdownString, { maxChars: 1200 });
|
|
108
|
+
* // chunks[0].heading → "Installation"
|
|
109
|
+
* // chunks[0].text → "## Installation\n\nRun `bun add …`"
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export function chunkBySections(text: string, opts: Partial<ChunkOptions> = {}): SectionChunk[] {
|
|
113
|
+
const { maxChars, overlapChars } = { ...DEFAULT_OPTIONS, ...opts };
|
|
114
|
+
const trimmed = text.trim();
|
|
115
|
+
if (!trimmed) return [];
|
|
116
|
+
|
|
117
|
+
// Split on Markdown headings — keep the heading line with its section body.
|
|
118
|
+
const lines = trimmed.split("\n");
|
|
119
|
+
|
|
120
|
+
interface RawSection {
|
|
121
|
+
heading: string | null;
|
|
122
|
+
headingLevel: number | null;
|
|
123
|
+
body: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const sections: RawSection[] = [];
|
|
127
|
+
let currentHeading: string | null = null;
|
|
128
|
+
let currentLevel: number | null = null;
|
|
129
|
+
let bodyLines: string[] = [];
|
|
130
|
+
|
|
131
|
+
const flush = () => {
|
|
132
|
+
const body = bodyLines.join("\n").trim();
|
|
133
|
+
if (body || currentHeading) {
|
|
134
|
+
sections.push({ heading: currentHeading, headingLevel: currentLevel, body });
|
|
135
|
+
}
|
|
136
|
+
bodyLines = [];
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const m = line.match(/^(#{1,6})\s+(.+)$/);
|
|
141
|
+
if (m) {
|
|
142
|
+
flush();
|
|
143
|
+
currentHeading = m[2] ?? null;
|
|
144
|
+
currentLevel = (m[1] ?? "").length;
|
|
145
|
+
bodyLines = [];
|
|
146
|
+
} else {
|
|
147
|
+
bodyLines.push(line);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
flush();
|
|
151
|
+
|
|
152
|
+
const result: SectionChunk[] = [];
|
|
153
|
+
let globalIndex = 0;
|
|
154
|
+
|
|
155
|
+
for (const section of sections) {
|
|
156
|
+
// Build full section text: prepend heading if present
|
|
157
|
+
const headingPrefix =
|
|
158
|
+
section.heading && section.headingLevel
|
|
159
|
+
? `${"#".repeat(section.headingLevel)} ${section.heading}\n\n`
|
|
160
|
+
: "";
|
|
161
|
+
const full = `${headingPrefix}${section.body}`.trim();
|
|
162
|
+
if (!full) continue;
|
|
163
|
+
|
|
164
|
+
if (full.length <= maxChars) {
|
|
165
|
+
result.push({
|
|
166
|
+
index: globalIndex++,
|
|
167
|
+
text: full,
|
|
168
|
+
tokenCount: estimateTokens(full),
|
|
169
|
+
heading: section.heading,
|
|
170
|
+
headingLevel: section.headingLevel,
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
// Section is too large — fall back to chunkText, preserve heading context
|
|
174
|
+
const subChunks = chunkText(full, { maxChars, overlapChars });
|
|
175
|
+
for (const sub of subChunks) {
|
|
176
|
+
result.push({
|
|
177
|
+
...sub,
|
|
178
|
+
index: globalIndex++,
|
|
179
|
+
heading: section.heading,
|
|
180
|
+
headingLevel: section.headingLevel,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function splitLong(text: string, maxChars: number, overlapChars: number): string[] {
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
const step = Math.max(1, maxChars - overlapChars);
|
|
192
|
+
for (let i = 0; i < text.length; i += step) {
|
|
193
|
+
const slice = text.slice(i, i + maxChars);
|
|
194
|
+
out.push(slice);
|
|
195
|
+
if (i + maxChars >= text.length) break;
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ChatMessage } from "@chatman-media/llm-router";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unified interface for persisting conversation history and summaries.
|
|
5
|
+
*
|
|
6
|
+
* Implement this for your storage backend (PostgreSQL, Redis, SQLite, …).
|
|
7
|
+
* An in-memory reference implementation is exported as `InMemoryConversationStore`.
|
|
8
|
+
*/
|
|
9
|
+
export interface IConversationStore {
|
|
10
|
+
/** Append one message to the conversation history. */
|
|
11
|
+
addMessage(conversationId: string, message: ChatMessage): Promise<void>;
|
|
12
|
+
|
|
13
|
+
/** Return messages in chronological order. Returns [] for unknown ids. */
|
|
14
|
+
getHistory(conversationId: string): Promise<ChatMessage[]>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Replace the stored history with a shorter version (e.g. after summarisation).
|
|
18
|
+
* Typically called with the last N messages after older turns are compressed.
|
|
19
|
+
*/
|
|
20
|
+
setHistory(conversationId: string, messages: ChatMessage[]): Promise<void>;
|
|
21
|
+
|
|
22
|
+
/** Store or update the running summary for the conversation. */
|
|
23
|
+
setSummary(conversationId: string, summary: string): Promise<void>;
|
|
24
|
+
|
|
25
|
+
/** Retrieve the current summary, or null if none exists. */
|
|
26
|
+
getSummary(conversationId: string): Promise<string | null>;
|
|
27
|
+
|
|
28
|
+
/** Remove all data for a conversation (GDPR deletion, session reset, …). */
|
|
29
|
+
deleteConversation(conversationId: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ConversationData {
|
|
33
|
+
history: ChatMessage[];
|
|
34
|
+
summary: string | null;
|
|
35
|
+
updatedAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Zero-dependency in-memory `IConversationStore`.
|
|
40
|
+
*
|
|
41
|
+
* Suitable for tests, prototypes, and single-process deployments.
|
|
42
|
+
* Data is lost on process restart — use a database-backed implementation
|
|
43
|
+
* for production.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import {
|
|
48
|
+
* InMemoryConversationStore,
|
|
49
|
+
* summarizeConversation,
|
|
50
|
+
* answerWithRag,
|
|
51
|
+
* } from "@chatman-media/kb";
|
|
52
|
+
*
|
|
53
|
+
* const store = new InMemoryConversationStore({ maxHistoryLength: 20, ttlMs: 2 * 60 * 60_000 });
|
|
54
|
+
*
|
|
55
|
+
* // Each turn:
|
|
56
|
+
* const history = await store.getHistory(userId);
|
|
57
|
+
* const summary = await store.getSummary(userId);
|
|
58
|
+
* const result = await answerWithRag({ question, kb, chat, embedder, history, conversationSummary: summary ?? undefined });
|
|
59
|
+
* await store.addMessage(userId, { role: "user", content: question });
|
|
60
|
+
* await store.addMessage(userId, { role: "assistant", content: result.text });
|
|
61
|
+
*
|
|
62
|
+
* // Compress when history grows long:
|
|
63
|
+
* if (history.length > 30) {
|
|
64
|
+
* const newSummary = await summarizeConversation({ history, chat });
|
|
65
|
+
* await store.setSummary(userId, newSummary);
|
|
66
|
+
* await store.setHistory(userId, history.slice(-10));
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export class InMemoryConversationStore implements IConversationStore {
|
|
71
|
+
private readonly data = new Map<string, ConversationData>();
|
|
72
|
+
private readonly maxHistoryLength: number;
|
|
73
|
+
private readonly ttlMs: number;
|
|
74
|
+
|
|
75
|
+
constructor(opts: { maxHistoryLength?: number; ttlMs?: number } = {}) {
|
|
76
|
+
this.maxHistoryLength = opts.maxHistoryLength ?? 100;
|
|
77
|
+
this.ttlMs = opts.ttlMs ?? Infinity;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async addMessage(conversationId: string, message: ChatMessage): Promise<void> {
|
|
81
|
+
const conv = this.getOrCreate(conversationId);
|
|
82
|
+
conv.history.push(message);
|
|
83
|
+
if (conv.history.length > this.maxHistoryLength) {
|
|
84
|
+
conv.history = conv.history.slice(-this.maxHistoryLength);
|
|
85
|
+
}
|
|
86
|
+
conv.updatedAt = Date.now();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getHistory(conversationId: string): Promise<ChatMessage[]> {
|
|
90
|
+
const conv = this.data.get(conversationId);
|
|
91
|
+
if (!conv || this.isExpired(conv)) return [];
|
|
92
|
+
return [...conv.history];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async setHistory(conversationId: string, messages: ChatMessage[]): Promise<void> {
|
|
96
|
+
const conv = this.getOrCreate(conversationId);
|
|
97
|
+
conv.history = [...messages];
|
|
98
|
+
conv.updatedAt = Date.now();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async setSummary(conversationId: string, summary: string): Promise<void> {
|
|
102
|
+
const conv = this.getOrCreate(conversationId);
|
|
103
|
+
conv.summary = summary;
|
|
104
|
+
conv.updatedAt = Date.now();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getSummary(conversationId: string): Promise<string | null> {
|
|
108
|
+
const conv = this.data.get(conversationId);
|
|
109
|
+
if (!conv || this.isExpired(conv)) return null;
|
|
110
|
+
return conv.summary;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async deleteConversation(conversationId: string): Promise<void> {
|
|
114
|
+
this.data.delete(conversationId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Number of active (non-expired) conversations. */
|
|
118
|
+
get size(): number {
|
|
119
|
+
let n = 0;
|
|
120
|
+
for (const conv of this.data.values()) {
|
|
121
|
+
if (!this.isExpired(conv)) n++;
|
|
122
|
+
}
|
|
123
|
+
return n;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private getOrCreate(id: string): ConversationData {
|
|
127
|
+
let conv = this.data.get(id);
|
|
128
|
+
if (!conv || this.isExpired(conv)) {
|
|
129
|
+
conv = { history: [], summary: null, updatedAt: Date.now() };
|
|
130
|
+
this.data.set(id, conv);
|
|
131
|
+
}
|
|
132
|
+
return conv;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private isExpired(conv: ConversationData): boolean {
|
|
136
|
+
return this.ttlMs !== Infinity && Date.now() - conv.updatedAt > this.ttlMs;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/eval.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { KbSearchHit } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One labelled query for retrieval evaluation.
|
|
5
|
+
* `relevantChunkIds` are the ground-truth chunk ids that should be retrieved.
|
|
6
|
+
*/
|
|
7
|
+
export interface EvalQuery {
|
|
8
|
+
question: string;
|
|
9
|
+
relevantChunkIds: number[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Per-query metrics returned by `evalRetrieval`. */
|
|
13
|
+
export interface QueryMetrics {
|
|
14
|
+
question: string;
|
|
15
|
+
/** Fraction of relevant chunks found in the top-k results (0–1). */
|
|
16
|
+
recallAtK: number;
|
|
17
|
+
/** Mean Reciprocal Rank — position of the first relevant hit (0–1). */
|
|
18
|
+
mrr: number;
|
|
19
|
+
/** Normalised Discounted Cumulative Gain (0–1). */
|
|
20
|
+
ndcg: number;
|
|
21
|
+
/** Chunk ids that were retrieved. */
|
|
22
|
+
retrievedIds: number[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Aggregated metrics over a full eval set. */
|
|
26
|
+
export interface EvalResult {
|
|
27
|
+
/** Arithmetic mean of recall@k across all queries. */
|
|
28
|
+
meanRecallAtK: number;
|
|
29
|
+
/** Arithmetic mean of MRR across all queries. */
|
|
30
|
+
meanMrr: number;
|
|
31
|
+
/** Arithmetic mean of NDCG across all queries. */
|
|
32
|
+
meanNdcg: number;
|
|
33
|
+
/** Per-query breakdown. */
|
|
34
|
+
queries: QueryMetrics[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Evaluate retrieval quality over a labelled query set.
|
|
39
|
+
*
|
|
40
|
+
* Runs `retrieve(query)` for each `EvalQuery`, compares the returned chunk ids
|
|
41
|
+
* against the ground-truth relevant ids, and reports recall@k, MRR, and NDCG.
|
|
42
|
+
*
|
|
43
|
+
* @param queries Labelled queries with ground-truth chunk ids.
|
|
44
|
+
* @param retrieve Function that takes a question and returns `KbSearchHit[]`.
|
|
45
|
+
* Wrap your `kb.search()` / `kb.hybridSearch()` call here.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* import { evalRetrieval } from "@chatman-media/kb";
|
|
50
|
+
*
|
|
51
|
+
* const result = await evalRetrieval(
|
|
52
|
+
* [
|
|
53
|
+
* { question: "Какая зарплата в Дубае?", relevantChunkIds: [12, 15] },
|
|
54
|
+
* { question: "Нужна ли виза?", relevantChunkIds: [7] },
|
|
55
|
+
* ],
|
|
56
|
+
* async (q) => {
|
|
57
|
+
* const [vec] = await embedder.embed([q]);
|
|
58
|
+
* return kb.hybridSearch({ embedding: vec!, query: q, k: 5 });
|
|
59
|
+
* },
|
|
60
|
+
* );
|
|
61
|
+
*
|
|
62
|
+
* console.log(`recall@5: ${result.meanRecallAtK.toFixed(3)}`);
|
|
63
|
+
* console.log(`MRR: ${result.meanMrr.toFixed(3)}`);
|
|
64
|
+
* console.log(`NDCG: ${result.meanNdcg.toFixed(3)}`);
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export async function evalRetrieval(
|
|
68
|
+
queries: EvalQuery[],
|
|
69
|
+
retrieve: (question: string) => Promise<KbSearchHit[]>,
|
|
70
|
+
): Promise<EvalResult> {
|
|
71
|
+
const queryMetrics: QueryMetrics[] = [];
|
|
72
|
+
|
|
73
|
+
for (const q of queries) {
|
|
74
|
+
const hits = await retrieve(q.question);
|
|
75
|
+
const retrievedIds = hits.map((h) => h.chunk_id);
|
|
76
|
+
const relevant = new Set(q.relevantChunkIds);
|
|
77
|
+
|
|
78
|
+
queryMetrics.push({
|
|
79
|
+
question: q.question,
|
|
80
|
+
recallAtK: recallAtK(retrievedIds, relevant),
|
|
81
|
+
mrr: mrr(retrievedIds, relevant),
|
|
82
|
+
ndcg: ndcg(retrievedIds, relevant),
|
|
83
|
+
retrievedIds,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const mean = (arr: number[]) =>
|
|
88
|
+
arr.length === 0 ? 0 : arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
meanRecallAtK: mean(queryMetrics.map((q) => q.recallAtK)),
|
|
92
|
+
meanMrr: mean(queryMetrics.map((q) => q.mrr)),
|
|
93
|
+
meanNdcg: mean(queryMetrics.map((q) => q.ndcg)),
|
|
94
|
+
queries: queryMetrics,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Metric implementations ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function recallAtK(retrieved: number[], relevant: Set<number>): number {
|
|
101
|
+
if (relevant.size === 0) return 1;
|
|
102
|
+
const found = retrieved.filter((id) => relevant.has(id)).length;
|
|
103
|
+
return found / relevant.size;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mrr(retrieved: number[], relevant: Set<number>): number {
|
|
107
|
+
for (let i = 0; i < retrieved.length; i++) {
|
|
108
|
+
if (relevant.has(retrieved[i] as number)) return 1 / (i + 1);
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ndcg(retrieved: number[], relevant: Set<number>): number {
|
|
114
|
+
const dcg = retrieved.reduce((sum, id, i) => {
|
|
115
|
+
const gain = relevant.has(id) ? 1 : 0;
|
|
116
|
+
return sum + gain / Math.log2(i + 2);
|
|
117
|
+
}, 0);
|
|
118
|
+
|
|
119
|
+
// Ideal DCG: all relevant docs at the top
|
|
120
|
+
const idealLen = Math.min(relevant.size, retrieved.length);
|
|
121
|
+
const idcg = Array.from({ length: idealLen }, (_, i) => 1 / Math.log2(i + 2)).reduce(
|
|
122
|
+
(a, b) => a + b,
|
|
123
|
+
0,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return idcg === 0 ? 0 : dcg / idcg;
|
|
127
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ChatClient, ChatMessage } from "@chatman-media/llm-router";
|
|
2
|
+
import { stripCodeFences, stripThinkBlocks } from "./sanitize.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extracts persistent facts ABOUT THE CANDIDATE (not the bot persona) from
|
|
6
|
+
* a slice of conversation. Used by the cross-session memory layer in
|
|
7
|
+
* webhook → after each turn the new messages are passed here and the result
|
|
8
|
+
* merged into `users.profile_json.memory.facts`.
|
|
9
|
+
*
|
|
10
|
+
* Facts schema is flexible — the prompt encourages the most common keys for
|
|
11
|
+
* the recruitment use-case but allows free-form keys when the candidate
|
|
12
|
+
* volunteers something unusual ("instagram", "previous_agency", …). Only
|
|
13
|
+
* volunteered facts are returned — no inference or guessing.
|
|
14
|
+
*/
|
|
15
|
+
export interface ExtractFactsInput {
|
|
16
|
+
/** Recent messages, oldest first. Should include both user and bot turns. */
|
|
17
|
+
messages: ChatMessage[];
|
|
18
|
+
chat: ChatClient;
|
|
19
|
+
/** Existing facts so the LLM doesn't re-emit already-known data. */
|
|
20
|
+
existingFacts?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SYSTEM_PROMPT = `Ты извлекаешь факты О КАНДИДАТЕ из переписки рекрутингового агентства.
|
|
24
|
+
Анализируй ТОЛЬКО реплики кандидата (role=user). Реплики бота (role=assistant) — для контекста.
|
|
25
|
+
|
|
26
|
+
Извлекай только факты, которые КАНДИДАТ САМ ЯВНО СООБЩИЛ. Никаких догадок и вывода.
|
|
27
|
+
|
|
28
|
+
Типичные ключи (используй их когда подходит):
|
|
29
|
+
- name: имя кандидата
|
|
30
|
+
- city: город/страна где живёт сейчас
|
|
31
|
+
- age: возраст (число)
|
|
32
|
+
- experience: опыт работы (что уже делал)
|
|
33
|
+
- language: какие языки знает
|
|
34
|
+
- country_target: куда хочет поехать работать
|
|
35
|
+
- intent: что ищет ("работа моделью", "поездка в Дубай", "разовый контракт")
|
|
36
|
+
- contact: telegram/instagram/email если давал
|
|
37
|
+
|
|
38
|
+
Можешь добавлять свои ключи (snake_case) если кандидат сообщил что-то ещё.
|
|
39
|
+
|
|
40
|
+
ВЕРНИ СТРОГО JSON-ОБЪЕКТ, без markdown, без \`\`\`, без комментариев.
|
|
41
|
+
Если фактов нет — верни {}.
|
|
42
|
+
Если факт уже есть в "Существующие факты" и не изменился — не повторяй его.
|
|
43
|
+
Только новые или ОБНОВЛЁННЫЕ факты.
|
|
44
|
+
|
|
45
|
+
Пример:
|
|
46
|
+
Сообщения:
|
|
47
|
+
user: привет, я Аня из новосибирска, мне 23
|
|
48
|
+
user: ищу работу в дубае, хочу заработать
|
|
49
|
+
Существующие факты: {}
|
|
50
|
+
Ответ:
|
|
51
|
+
{"name":"Аня","city":"Новосибирск","age":"23","country_target":"Дубай","intent":"работа, заработок"}`;
|
|
52
|
+
|
|
53
|
+
const MAX_RETURNED_KEYS = 20;
|
|
54
|
+
const MAX_VALUE_LEN = 200;
|
|
55
|
+
|
|
56
|
+
export async function extractUserFacts(input: ExtractFactsInput): Promise<Record<string, string>> {
|
|
57
|
+
// No new messages → nothing to extract. Skip LLM call entirely.
|
|
58
|
+
const userMessages = input.messages.filter((m) => m.role === "user");
|
|
59
|
+
if (userMessages.length === 0) return {};
|
|
60
|
+
|
|
61
|
+
const conversation = input.messages.map((m) => `${m.role}: ${m.content}`).join("\n");
|
|
62
|
+
const existingJson = JSON.stringify(input.existingFacts ?? {});
|
|
63
|
+
|
|
64
|
+
const userPrompt = `Сообщения:\n${conversation}\n\nСуществующие факты: ${existingJson}\n\nОтвет:`;
|
|
65
|
+
|
|
66
|
+
const messages: ChatMessage[] = [
|
|
67
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
68
|
+
{ role: "user", content: userPrompt },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
let raw: string;
|
|
72
|
+
try {
|
|
73
|
+
raw = await input.chat.complete(messages, { temperature: 0.1 });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error("[extract-user-facts] LLM call failed:", err);
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return parseFactsFromLlmOutput(raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parses the LLM output — strips think-tags and markdown fences, finds the
|
|
84
|
+
* JSON object, validates that values are short strings, caps total keys.
|
|
85
|
+
* Exported for unit tests.
|
|
86
|
+
*/
|
|
87
|
+
export function parseFactsFromLlmOutput(raw: string): Record<string, string> {
|
|
88
|
+
const s = stripCodeFences(stripThinkBlocks(raw)).trim();
|
|
89
|
+
|
|
90
|
+
// Find the first {...} block. The model sometimes prefixes "Ответ:" or similar.
|
|
91
|
+
const start = s.indexOf("{");
|
|
92
|
+
const end = s.lastIndexOf("}");
|
|
93
|
+
if (start === -1 || end === -1 || end < start) return {};
|
|
94
|
+
|
|
95
|
+
const candidate = s.slice(start, end + 1);
|
|
96
|
+
let parsed: unknown;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(candidate);
|
|
99
|
+
} catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result: Record<string, string> = {};
|
|
108
|
+
let count = 0;
|
|
109
|
+
for (const [key, val] of Object.entries(parsed as Record<string, unknown>)) {
|
|
110
|
+
if (count >= MAX_RETURNED_KEYS) break;
|
|
111
|
+
if (typeof key !== "string" || key.length === 0 || key.length > 40) continue;
|
|
112
|
+
if (val === null || val === undefined) continue;
|
|
113
|
+
const str = typeof val === "string" ? val : String(val);
|
|
114
|
+
const trimmed = str.trim();
|
|
115
|
+
if (!trimmed || trimmed.length > MAX_VALUE_LEN) continue;
|
|
116
|
+
result[key] = trimmed;
|
|
117
|
+
count++;
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|