@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
@@ -0,0 +1,110 @@
1
+ import type { AnswerTelemetry } from "./answer-types.ts";
2
+ import type { ChatClient, ChatCompletionOpts, ChatMessage } from "@chatman-media/llm-router";
3
+ import type { AnyRagTool } from "./tools.ts";
4
+ import { toolToOpenAIFunction } from "./tools.ts";
5
+
6
+ /** Default maximum number of agentic tool-calling cycles. */
7
+ export const DEFAULT_MAX_TOOL_CYCLES = 4;
8
+
9
+ /** A single tool execution recorded during an agentic tool loop. */
10
+ export interface ToolCallRecord {
11
+ name: string;
12
+ args: Record<string, unknown>;
13
+ result: unknown;
14
+ /** True when the tool was unknown or `execute()` threw — `result` holds `{ error }`. */
15
+ error?: boolean;
16
+ /** Zero-based index of the loop cycle this call belongs to. */
17
+ cycle: number;
18
+ }
19
+
20
+ export interface ToolLoopResult {
21
+ /** Final assistant text when the model stopped calling tools, else null. */
22
+ content: string | null;
23
+ /** Every tool call executed across all cycles, in order. */
24
+ toolCalls: ToolCallRecord[];
25
+ /** True when the loop stopped because `maxCycles` was hit while the model still wanted tools. */
26
+ exhausted: boolean;
27
+ }
28
+
29
+ /**
30
+ * Runs an agentic tool-calling loop. Mutates `messages` in place, appending an
31
+ * assistant message (carrying all tool calls) and one `tool` message per call
32
+ * for every cycle. Returns the final model text (when produced) and the full
33
+ * tool-call trace.
34
+ *
35
+ * Unknown tools and thrown `execute()` errors are fed back to the model as the
36
+ * tool result so it can recover — the loop never throws on tool failure.
37
+ *
38
+ * The caller must guarantee `chat.completeWithTools` exists and `tools` is non-empty.
39
+ */
40
+ export async function runToolLoop(opts: {
41
+ chat: ChatClient;
42
+ messages: ChatMessage[];
43
+ tools: AnyRagTool[];
44
+ llmOpts: ChatCompletionOpts;
45
+ maxCycles: number;
46
+ }): Promise<ToolLoopResult> {
47
+ const { chat, messages, tools, llmOpts, maxCycles } = opts;
48
+ const completeWithTools = chat.completeWithTools;
49
+ if (!completeWithTools) throw new Error("runToolLoop: chat.completeWithTools is required");
50
+ const toolDefs = tools.map(toolToOpenAIFunction);
51
+ const toolCalls: ToolCallRecord[] = [];
52
+
53
+ for (let cycle = 0; cycle < maxCycles; cycle++) {
54
+ const res = await completeWithTools.call(chat, messages, toolDefs, llmOpts);
55
+
56
+ if (res.toolCalls.length === 0) {
57
+ return { content: res.content, toolCalls, exhausted: false };
58
+ }
59
+
60
+ messages.push({
61
+ role: "assistant",
62
+ content: null,
63
+ tool_calls: res.toolCalls.map((tc) => ({
64
+ id: tc.id,
65
+ type: "function",
66
+ function: { name: tc.name, arguments: JSON.stringify(tc.args) },
67
+ })),
68
+ });
69
+
70
+ const settled = await Promise.allSettled(
71
+ res.toolCalls.map((tc) => {
72
+ const tool = tools.find((t) => t.name === tc.name);
73
+ if (!tool) return Promise.reject(new Error(`unknown tool: ${tc.name}`));
74
+ return tool.execute(tc.args);
75
+ }),
76
+ );
77
+
78
+ for (let i = 0; i < res.toolCalls.length; i++) {
79
+ const tc = res.toolCalls[i] as (typeof res.toolCalls)[number];
80
+ const outcome = settled[i] as PromiseSettledResult<unknown>;
81
+ let payload: unknown;
82
+ let isError = false;
83
+ if (outcome.status === "fulfilled") {
84
+ payload = outcome.value;
85
+ } else {
86
+ isError = true;
87
+ const message =
88
+ outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
89
+ payload = { error: message };
90
+ console.warn(`[tool-loop] tool "${tc.name}" failed: ${message}`);
91
+ }
92
+ toolCalls.push({ name: tc.name, args: tc.args, result: payload, error: isError, cycle });
93
+ messages.push({ role: "tool", content: JSON.stringify(payload), tool_call_id: tc.id });
94
+ }
95
+ }
96
+
97
+ return { content: null, toolCalls, exhausted: true };
98
+ }
99
+
100
+ /** Builds the tool-related telemetry fields from a tool-call trace. */
101
+ export function buildToolTelemetry(
102
+ records: ToolCallRecord[],
103
+ ): Pick<AnswerTelemetry, "toolCall" | "toolCalls"> {
104
+ const first = records[0];
105
+ if (!first) return {};
106
+ return {
107
+ toolCall: { name: first.name, result: first.result },
108
+ toolCalls: records,
109
+ };
110
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * A tool the LLM can call during `answerWithRag`.
5
+ *
6
+ * Pass one or more tools via `AnswerInput.tools`. When the model decides to
7
+ * use a tool, the library executes `execute()` automatically and feeds the
8
+ * result back to the model for a final answer (single-cycle — one tool call
9
+ * per request).
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { answerWithRag, type RagTool } from "@chatman-media/kb";
14
+ * import { z } from "zod";
15
+ *
16
+ * const slotsTool: RagTool = {
17
+ * name: "getAvailableSlots",
18
+ * description: "Returns available booking slots for a given date (YYYY-MM-DD).",
19
+ * parameters: z.object({ date: z.string() }),
20
+ * execute: async ({ date }) => fetchSlotsFromCRM(date),
21
+ * };
22
+ *
23
+ * const result = await answerWithRag({ question, kb, chat, embedder, tools: [slotsTool] });
24
+ * // result.telemetry.toolCall — name + result if a tool was invoked
25
+ * ```
26
+ */
27
+ export interface RagTool<TParams extends z.ZodTypeAny = z.ZodTypeAny> {
28
+ name: string;
29
+ description: string;
30
+ /** Zod schema for the tool's input parameters. Used to build the JSON Schema sent to the model. */
31
+ parameters: TParams;
32
+ execute: (args: z.infer<TParams>) => Promise<unknown>;
33
+ }
34
+
35
+ // biome-ignore lint/suspicious/noExplicitAny: intentional open type for mixed tool arrays
36
+ export type AnyRagTool = RagTool<any>;
37
+
38
+ /** Converts a `RagTool` to the OpenAI function-calling format. */
39
+ export function toolToOpenAIFunction(tool: AnyRagTool): OpenAIToolDefinition {
40
+ return {
41
+ type: "function",
42
+ function: {
43
+ name: tool.name,
44
+ description: tool.description,
45
+ parameters: z.toJSONSchema(tool.parameters) as Record<string, unknown>,
46
+ },
47
+ };
48
+ }
49
+
50
+ // ── Internal types used by ChatClient ────────────────────────────────────────
51
+
52
+ export interface OpenAIToolDefinition {
53
+ type: "function";
54
+ function: {
55
+ name: string;
56
+ description: string;
57
+ parameters: Record<string, unknown>;
58
+ };
59
+ }
60
+
61
+ export interface ToolCallRequest {
62
+ id: string;
63
+ type: "function";
64
+ function: { name: string; arguments: string };
65
+ }
66
+
67
+ export interface ToolCallResult {
68
+ /** Tool call id returned by the model. */
69
+ id: string;
70
+ name: string;
71
+ args: Record<string, unknown>;
72
+ }
73
+
74
+ export interface CompleteWithToolsResult {
75
+ /** Model text when no tool was called. */
76
+ content: string | null;
77
+ /** Parsed tool calls when the model chose to call a tool. */
78
+ toolCalls: ToolCallResult[];
79
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Deterministic topic classifier for inbound questions in the recruitment
3
+ * domain. Returns a topic slug (matching the values you tag KB docs with at
4
+ * ingest time) or `null` when the question doesn't fall cleanly into one
5
+ * bucket. No LLM call — pure regex over Russian + English with Unicode-
6
+ * property word boundaries (JS `\b` is ASCII-only, fails silently on
7
+ * Cyrillic; same trick used in stage-router.ts and rewrite-query.ts).
8
+ *
9
+ * Designed to be CONSERVATIVE: when in doubt, return null. The webhook
10
+ * falls back to global retrieval on null, so a missed classification just
11
+ * loses precision — not recall. Matching multiple topics also returns null
12
+ * (mixed-intent question shouldn't be force-routed).
13
+ *
14
+ * Add new topics by appending to TOPIC_PATTERNS. The slug must match the
15
+ * `topic` value used by your ingest pipeline (typically a directory name
16
+ * under `kb/curated/<slug>/`).
17
+ */
18
+ // Cyrillic-aware leading word-boundary lookbehind. Trailing word-boundary
19
+ // lookahead is intentionally OMITTED — patterns are STEMS that should match
20
+ // any inflected form ("зарплат" matches "зарплата" / "зарплате" / etc).
21
+ // False positives from inside other words are blocked by the leading
22
+ // lookbehind alone (e.g. "девиз" doesn't match the "виз" stem because the
23
+ // "е" before "виз" is \p{L}).
24
+ const NW = `(?<![\\p{L}\\p{N}])`;
25
+
26
+ const TOPIC_PATTERNS: Array<{ topic: string; pattern: RegExp }> = [
27
+ {
28
+ topic: "visa",
29
+ // Виза, visa, оформление документов на въезд.
30
+ pattern: new RegExp(`${NW}(виз|visa|загранпаспорт|invitation|приглашен)`, "iu"),
31
+ },
32
+ {
33
+ topic: "payment",
34
+ // Деньги: зарплата, оплата, ставка, комиссия, юани/евро/доллары.
35
+ // "плат" is intentionally excluded as a bare stem — it's too generic
36
+ // (matches "плата" / "платье" / "поплатишься"). Inflected verb forms
37
+ // ("платят", "платить") are caught by the explicit list.
38
+ pattern: new RegExp(
39
+ `${NW}(зарплат|оплат|плат[ияеу]|ставк|комисси|юан|доллар|евро|рубл|salary|payment|rate)`,
40
+ "iu",
41
+ ),
42
+ },
43
+ {
44
+ topic: "schedule",
45
+ // График, смены, часы, выходные.
46
+ pattern: new RegExp(`${NW}(график|смен|часов|выходн|расписан|schedule|shift)`, "iu"),
47
+ },
48
+ {
49
+ topic: "housing",
50
+ // Жильё, проживание, общежитие, квартира. Both Russian verb stems
51
+ // "жил-" (past) and "жит-" (infinitive) are needed.
52
+ pattern: new RegExp(`${NW}(жил|жит[ьея]|проживан|общежит|квартир|комнат|hous|accommod)`, "iu"),
53
+ },
54
+ {
55
+ topic: "locations",
56
+ // Конкретные локации в этом домене (Дубай, Стамбул, Китай, Корея).
57
+ pattern: new RegExp(
58
+ `${NW}(дуба|стамбул|кита|коре|шаохин|вэньчжоу|йиу|dubai|istanbul|china|korea)`,
59
+ "iu",
60
+ ),
61
+ },
62
+ {
63
+ topic: "vacancy",
64
+ // Прямые вопросы про офферы / "что у вас сейчас", виды клубов (KTV).
65
+ // NOT triggered by location words alone — those go to "locations".
66
+ pattern: new RegExp(
67
+ `${NW}(ваканс|оффер|какие\\s+(есть|у\\s+вас)|что\\s+у\\s+вас\\s+(есть|сеичас|сейчас)|чем\\s+(можете|можно)|ktv|караоке\\s+хостес|хостес)`,
68
+ "iu",
69
+ ),
70
+ },
71
+ {
72
+ topic: "requirements",
73
+ // Требования к кандидату: рост, вес, возраст, опыт, фото/портфолио.
74
+ pattern: new RegExp(
75
+ `${NW}(рост|вес\\b|возраст|сколько\\s+лет|портфолио|фотосет|какие\\s+треб|нужно\\s+ли\\s+знать|опыт\\s+(работ|в))`,
76
+ "iu",
77
+ ),
78
+ },
79
+ {
80
+ topic: "application",
81
+ // Анкета, форма подачи / форма для, заявка, подать.
82
+ pattern: new RegExp(`${NW}(анкет|форм[аыу]\\s+(подач|для)|заявк|подать|application)`, "iu"),
83
+ },
84
+ ];
85
+
86
+ /**
87
+ * Returns a single topic slug when exactly ONE topic pattern matches, or
88
+ * `null` when zero or multiple match. Multi-match is treated as ambiguous —
89
+ * forcing one topic would silently drop docs from the other.
90
+ */
91
+ export function classifyTopic(question: string): string | null {
92
+ if (!question?.trim()) return null;
93
+ const matches: string[] = [];
94
+ for (const { topic, pattern } of TOPIC_PATTERNS) {
95
+ if (pattern.test(question)) matches.push(topic);
96
+ }
97
+ if (matches.length === 1) return matches[0] ?? null;
98
+ return null;
99
+ }
100
+
101
+ /** Exposed for tests + admin debugging — lets callers see ALL matches. */
102
+ export function classifyTopicAll(question: string): string[] {
103
+ if (!question?.trim()) return [];
104
+ const matches: string[] = [];
105
+ for (const { topic, pattern } of TOPIC_PATTERNS) {
106
+ if (pattern.test(question)) matches.push(topic);
107
+ }
108
+ return matches;
109
+ }
110
+
111
+ /** All defined topic slugs. Useful for ingest CLI validation and admin UI. */
112
+ export const KNOWN_TOPICS: readonly string[] = TOPIC_PATTERNS.map((p) => p.topic);
package/src/types.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Storage interfaces for the RAG engine.
3
+ *
4
+ * The package defines these interfaces; consumers provide implementations.
5
+ * The reference implementation (`PgKbStore`) ships with the lead-engine platform
6
+ * project and wraps the PostgreSQL + pgvector repos.
7
+ */
8
+
9
+ export interface KbSearchHit {
10
+ chunk_id: number;
11
+ /** Cosine distance (vector search) or negated BM25 rank (FTS). Lower = closer. */
12
+ distance: number;
13
+ text: string;
14
+ document_id: number;
15
+ source: string;
16
+ title: string;
17
+ }
18
+
19
+ /**
20
+ * Minimal contract the RAG engine needs from whatever storage backend you
21
+ * plug in. Split into two logical groups:
22
+ *
23
+ * - **Search** — called by `answerWithRag` at query time (read-only).
24
+ * - **Ingest** — called by `ingestFile` / `ingestText` / `ingestDirectory`
25
+ * when indexing documents (read+write).
26
+ */
27
+ export interface IKbStore {
28
+ // ── Search ──────────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Pure vector search (cosine distance via pgvector or equivalent).
32
+ * Returns up to `k` hits, optionally filtered by `topic`.
33
+ */
34
+ search(embedding: number[], k: number, topic?: string | null): Promise<KbSearchHit[]>;
35
+
36
+ /**
37
+ * Hybrid retrieval: fuse vector + BM25 results via Reciprocal Rank Fusion.
38
+ * Falls back to vector-only when the FTS index returns nothing.
39
+ */
40
+ hybridSearch(input: {
41
+ embedding: number[];
42
+ query: string;
43
+ k?: number;
44
+ topic?: string | null;
45
+ }): Promise<KbSearchHit[]>;
46
+
47
+ /**
48
+ * Books-priority search: tries the "books" topic first, falls back to
49
+ * global search when no books-tagged chunks match. Used when `booksPriority`
50
+ * is set on `AnswerInput`.
51
+ */
52
+ prioritySearch(input: {
53
+ embedding: number[];
54
+ query: string;
55
+ k?: number;
56
+ vectorOnly?: boolean;
57
+ }): Promise<KbSearchHit[]>;
58
+
59
+ // ── Ingest ──────────────────────────────────────────────────────────────────
60
+
61
+ /** Look up an existing document by its source URI. */
62
+ getDocumentBySource(source: string): Promise<{ id: number; content_hash: string } | null>;
63
+
64
+ /** Count indexed chunks for a document (used for dedup short-circuit). */
65
+ countChunksForDocument(documentId: number): Promise<number>;
66
+
67
+ /** Delete a document and all its chunks (called before re-indexing). */
68
+ deleteDocument(id: number): Promise<boolean>;
69
+
70
+ /** Upsert a document record; returns the canonical row id. */
71
+ upsertDocument(input: {
72
+ source: string;
73
+ title: string;
74
+ contentHash: string;
75
+ topic?: string | null;
76
+ }): Promise<{ id: number }>;
77
+
78
+ /** Insert one chunk with its embedding vector. */
79
+ insertChunkWithEmbedding(input: {
80
+ documentId: number;
81
+ chunkIndex: number;
82
+ text: string;
83
+ tokenCount: number;
84
+ embedding: number[];
85
+ }): Promise<void>;
86
+ }
87
+
88
+ /** Optional write-only interface for logging unanswered questions. */
89
+ export interface IKbSuggestionsStore {
90
+ log(question: string, conversationId: string): Promise<void>;
91
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { KbSearchHit } from "./types.ts";
2
+
3
+ /**
4
+ * Reciprocal Rank Fusion — fuses two ranked result lists into one.
5
+ *
6
+ * Standard formula: score(d) = Σ 1/(k + rank(d))
7
+ * where k=60 is the smoothing constant (Cormack et al., 2009).
8
+ *
9
+ * Returns hits sorted by descending fused score. The `distance` field is
10
+ * remapped to `1 - score` so callers get a consistent "lower = better"
11
+ * interpretation regardless of whether the underlying search was vector or BM25.
12
+ *
13
+ * @param vectorHits Results from vector (cosine) search, ranked by ascending distance.
14
+ * @param bm25Hits Results from BM25 search, ranked by descending BM25 score.
15
+ * @param k Number of top results to return.
16
+ * @param rrfK RRF smoothing constant (default 60).
17
+ */
18
+ export function reciprocalRankFusion(
19
+ vectorHits: KbSearchHit[],
20
+ bm25Hits: KbSearchHit[],
21
+ k: number,
22
+ rrfK = 60,
23
+ ): KbSearchHit[] {
24
+ if (bm25Hits.length === 0) return vectorHits.slice(0, k);
25
+ if (vectorHits.length === 0) return bm25Hits.slice(0, k);
26
+
27
+ const scores = new Map<number, { hit: KbSearchHit; score: number }>();
28
+
29
+ const addRanked = (hits: KbSearchHit[]) => {
30
+ hits.forEach((h, i) => {
31
+ const rank = i + 1;
32
+ const inc = 1 / (rrfK + rank);
33
+ const prev = scores.get(h.chunk_id);
34
+ if (prev) {
35
+ prev.score += inc;
36
+ } else {
37
+ scores.set(h.chunk_id, { hit: h, score: inc });
38
+ }
39
+ });
40
+ };
41
+
42
+ addRanked(vectorHits);
43
+ addRanked(bm25Hits);
44
+
45
+ return Array.from(scores.values())
46
+ .sort((a, b) => b.score - a.score)
47
+ .slice(0, k)
48
+ .map(({ hit, score }) => ({ ...hit, distance: 1 - score }));
49
+ }
50
+
51
+ /**
52
+ * Sanitizes a raw user query into a valid PostgreSQL `tsquery` string for
53
+ * Russian full-text search. Uses prefix-OR semantics (`term:* OR term:*`)
54
+ * so partial words and morphological variants match without a stemming dict.
55
+ *
56
+ * Strips tsquery operator characters to prevent injection:
57
+ * `"`, `'`, `(`, `)`, `*`, `:`, `.`, `\\`, `^`, `&`, `|`, `!`
58
+ * and boolean keywords `AND`, `OR`, `NOT`, `NEAR`.
59
+ *
60
+ * Returns an empty string when the query contains no usable tokens (caller
61
+ * should skip the FTS query entirely in that case).
62
+ *
63
+ * @example
64
+ * sanitizeFtsQuery("виза оформляется")
65
+ * // → "виза:* | оформляется:*"
66
+ *
67
+ * sanitizeFtsQuery('OR "injection"')
68
+ * // → "injection:*"
69
+ */
70
+ const FTS_KEYWORDS = new Set(["and", "or", "not", "near"]);
71
+
72
+ export function sanitizeFtsQuery(raw: string): string {
73
+ if (!raw) return "";
74
+ const stripped = raw.replace(/["'()*:.\\^&|!]/g, " ");
75
+ const tokens = stripped
76
+ .split(/\s+/)
77
+ .map((t) => t.trim())
78
+ .filter((t) => t.length >= 2 && !FTS_KEYWORDS.has(t.toLowerCase()));
79
+ if (tokens.length === 0) return "";
80
+ return tokens.map((t) => `${t}:*`).join(" | ");
81
+ }