@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,171 @@
1
+ import type { ChatClient, ChatMessage } from "@chatman-media/llm-router";
2
+ import { stripCodeFences, stripThinkBlocks } from "./sanitize.ts";
3
+
4
+ /**
5
+ * Unified fact-checker — replaces the separate reflect.ts + vacancy-guard.ts
6
+ * pipeline with a single LLM call that checks both:
7
+ *
8
+ * 1. KB grounding: every concrete fact in the answer is supported by the
9
+ * retrieved KB context (no hallucinations).
10
+ *
11
+ * 2. Vacancy accuracy: any salary/city/condition data matches the
12
+ * authoritative АКТУАЛЬНЫЕ ВАКАНСИИ block from the DB table.
13
+ * ВАКАНСИИ always win over KB chunks when they conflict — an old
14
+ * chat log with a stale salary is not a valid grounding source.
15
+ *
16
+ * Cost: 1 LLM call per turn (was 2 with separate reflect + vacancy-guard).
17
+ *
18
+ * **Fail-closed by default**: on any LLM or parse error returns
19
+ * `{ grounded: false, vacancyOk: false, reason: "checker_error: …" }` so the
20
+ * caller treats the reply as ungrounded and escalates to silence/operator
21
+ * instead of shipping a potentially-hallucinated answer. The legacy
22
+ * fail-open behaviour can be restored with `RAG_FACT_CHECKER_FAIL_OPEN=1`
23
+ * (kept for test fixtures that drive the checker with mock failures).
24
+ */
25
+
26
+ export interface FactCheckInput {
27
+ question: string;
28
+ answer: string;
29
+ /** KB chunks concatenated — same string passed to the generator. */
30
+ context: string;
31
+ chat: ChatClient;
32
+ /** Optional: rendered АКТУАЛЬНЫЕ ВАКАНСИИ block.
33
+ * When provided, vacancy facts are checked against it as the authoritative
34
+ * source. KB chunks that contradict it are treated as outdated. */
35
+ vacanciesBlock?: string;
36
+ }
37
+
38
+ export interface FactCheckResult {
39
+ /** All concrete facts in the answer are found in context (or vacanciesBlock). */
40
+ grounded: boolean;
41
+ /** All vacancy-specific facts (salary, city, conditions) match vacanciesBlock.
42
+ * Always true when vacanciesBlock was not provided. */
43
+ vacancyOk: boolean;
44
+ /** Human-readable reason when grounded=false or vacancyOk=false. */
45
+ reason?: string;
46
+ }
47
+
48
+ // ── System prompts ──────────────────────────────────────────────────────────
49
+
50
+ const SYSTEM_PROMPT_NO_VACANCIES = `Ты проверяешь ответ бота на галлюцинации.
51
+ Тебе дают: ВОПРОС кандидата, КОНТЕКСТ (выдержки из базы знаний), ОТВЕТ бота.
52
+
53
+ Задача: проверить, что КАЖДЫЙ конкретный факт из ОТВЕТА встречается в КОНТЕКСТЕ.
54
+
55
+ Правила:
56
+ - Общие фразы ("хорошие условия", "напишу подробности"), приветствия, эмоции — НЕ требуют проверки
57
+ - Конкретные числа, страны, города, валюты, сроки, % — ДОЛЖНЫ быть в контексте
58
+ - "уточню у руководства" / "уточним на созвоне" — ОК, не требует проверки
59
+ - Личные факты бота (имя, возраст, город, статус) — НЕ требуют проверки в контексте
60
+
61
+ Верни СТРОГО JSON одной строкой, без markdown, без \`\`\`:
62
+ {"grounded": true, "vacancyOk": true} — если все факты подтверждены или ответ общий
63
+ {"grounded": false, "vacancyOk": true, "reason": "<какой факт не из контекста>"} — если есть выдуманное
64
+
65
+ Только JSON, ничего больше.`;
66
+
67
+ const SYSTEM_PROMPT_WITH_VACANCIES = `Ты проверяешь ответ бота на два типа ошибок.
68
+ Тебе дают: ВОПРОС кандидата, KB-КОНТЕКСТ (выдержки из базы знаний), АКТУАЛЬНЫЕ ВАКАНСИИ (официальный список из БД), ОТВЕТ бота.
69
+
70
+ ВАЖНО: АКТУАЛЬНЫЕ ВАКАНСИИ имеют приоритет над KB-КОНТЕКСТОМ.
71
+ Если KB-чанк содержит устаревшую зарплату/условие, а в АКТУАЛЬНЫХ ВАКАНСИЯХ другое — правы ВАКАНСИИ.
72
+
73
+ Проверка 1 — KB grounding:
74
+ - Все конкретные факты в ОТВЕТЕ (числа, страны, города, валюты, сроки, %) должны быть в KB-КОНТЕКСТЕ или в АКТУАЛЬНЫХ ВАКАНСИЯХ.
75
+ - Общие фразы, приветствия, "уточним на созвоне", личные факты бота — НЕ проверяются.
76
+
77
+ Проверка 2 — Vacancy accuracy (только если ОТВЕТ содержит конкретные данные о вакансиях):
78
+ - Суммы заработка, города трудоустройства, условия жилья/перелёта, тип заведения —
79
+ должны совпадать с АКТУАЛЬНЫМИ ВАКАНСИЯМИ.
80
+ - Общие фразы ("легальный контракт", "поддержка агентства") — НЕ проверяются.
81
+
82
+ Верни СТРОГО JSON одной строкой, без markdown, без \`\`\`:
83
+ {"grounded": true, "vacancyOk": true} — всё ок
84
+ {"grounded": false, "vacancyOk": true, "reason": "..."} — выдуманный факт (не из KB и не из вакансий)
85
+ {"grounded": true, "vacancyOk": false, "reason": "..."} — данные вакансии не совпадают с официальным списком
86
+ {"grounded": false, "vacancyOk": false, "reason": "..."} — оба нарушения
87
+
88
+ Только JSON, ничего больше.`;
89
+
90
+ // ── Main function ───────────────────────────────────────────────────────────
91
+
92
+ /** Reply when the checker itself cannot run (LLM error / network).
93
+ * Default: treat the answer as ungrounded so it gets silenced. The
94
+ * RAG_FACT_CHECKER_FAIL_OPEN=1 env override restores the legacy
95
+ * "let it through" behaviour for test environments that intentionally
96
+ * inject failing LLM mocks. */
97
+ function checkerErrorResult(reason: string): FactCheckResult {
98
+ if (process.env.RAG_FACT_CHECKER_FAIL_OPEN === "1") {
99
+ return { grounded: true, vacancyOk: true };
100
+ }
101
+ return { grounded: false, vacancyOk: false, reason: `checker_error: ${reason}` };
102
+ }
103
+
104
+ export async function checkFacts(input: FactCheckInput): Promise<FactCheckResult> {
105
+ const OK: FactCheckResult = { grounded: true, vacancyOk: true };
106
+
107
+ const trimmed = input.answer.trim();
108
+ if (!trimmed) return OK;
109
+
110
+ const hasContext = input.context.trim().length > 0;
111
+ const hasVacancies = (input.vacanciesBlock ?? "").trim().length > 0;
112
+
113
+ // Nothing to check against — let it through (generator's own NO_CONTEXT
114
+ // rules are the last line of defense on truly empty turns).
115
+ if (!hasContext && !hasVacancies) return OK;
116
+
117
+ const systemPrompt = hasVacancies ? SYSTEM_PROMPT_WITH_VACANCIES : SYSTEM_PROMPT_NO_VACANCIES;
118
+
119
+ let userPrompt: string;
120
+ if (hasVacancies) {
121
+ userPrompt =
122
+ `ВОПРОС: ${input.question}\n\n` +
123
+ `KB-КОНТЕКСТ:\n${input.context || "(пусто)"}\n\n` +
124
+ `АКТУАЛЬНЫЕ ВАКАНСИИ:\n${input.vacanciesBlock}\n\n` +
125
+ `ОТВЕТ: ${trimmed}\n\nJSON:`;
126
+ } else {
127
+ userPrompt = `ВОПРОС: ${input.question}\n\nКОНТЕКСТ:\n${input.context}\n\nОТВЕТ: ${trimmed}\n\nJSON:`;
128
+ }
129
+
130
+ const messages: ChatMessage[] = [
131
+ { role: "system", content: systemPrompt },
132
+ { role: "user", content: userPrompt },
133
+ ];
134
+
135
+ let raw: string;
136
+ try {
137
+ raw = await input.chat.complete(messages, { temperature: 0.0 });
138
+ } catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ console.error("[fact-checker] LLM call failed:", err);
141
+ return checkerErrorResult(`llm_call: ${msg}`);
142
+ }
143
+
144
+ return parseFactCheckResult(raw);
145
+ }
146
+
147
+ /** Exported for unit tests. */
148
+ export function parseFactCheckResult(raw: string): FactCheckResult {
149
+ const OK: FactCheckResult = { grounded: true, vacancyOk: true };
150
+
151
+ const s = stripCodeFences(stripThinkBlocks(raw)).trim();
152
+ const start = s.indexOf("{");
153
+ const end = s.lastIndexOf("}");
154
+ if (start === -1 || end === -1 || end < start) return OK;
155
+
156
+ let parsed: unknown;
157
+ try {
158
+ parsed = JSON.parse(s.slice(start, end + 1));
159
+ } catch {
160
+ return OK;
161
+ }
162
+
163
+ if (typeof parsed !== "object" || parsed === null) return OK;
164
+ const obj = parsed as Record<string, unknown>;
165
+
166
+ const grounded = typeof obj.grounded === "boolean" ? obj.grounded : true;
167
+ const vacancyOk = typeof obj.vacancyOk === "boolean" ? obj.vacancyOk : true;
168
+ const reason = typeof obj.reason === "string" ? obj.reason : undefined;
169
+
170
+ return { grounded, vacancyOk, ...(reason ? { reason } : {}) };
171
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * After the bot has generated a reply, optionally ask a small LLM to
3
+ * inspect the (system_prompt → user → assistant) triple and report which
4
+ * of the configured skills (slugs) it actually used. Output goes into
5
+ * `messages.meta_json.telemetry.skills_used` for downstream attribution
6
+ * (Phase 2: ELO over outcomes; Phase 3: self-play).
7
+ *
8
+ * Cost: +1 LLM call per assistant turn. Gated behind RAG_SKILL_GRADING=true
9
+ * because for high-volume single-style production it's overkill — flip on
10
+ * during A/B research windows, off in steady state.
11
+ *
12
+ * Failure-soft: any error → returns empty array, never throws. The whole
13
+ * point is post-hoc analytics, not a hard dependency.
14
+ */
15
+ import type { ChatClient } from "@chatman-media/llm-router";
16
+
17
+ export interface GradeSkillsInput {
18
+ question: string;
19
+ reply: string;
20
+ /** Slugs offered to the model in the system prompt (only these are valid). */
21
+ availableSlugs: readonly string[];
22
+ chat: ChatClient;
23
+ /** Lightweight model id — falls back to the chat client's default if undefined. */
24
+ model?: string;
25
+ }
26
+
27
+ const SYSTEM = (slugs: readonly string[]) =>
28
+ `You are a sales-conversation auditor. Given a candidate's question and a salesperson's reply, identify which persuasion skills (from the allowed list below) the reply actually demonstrates.\n\nALLOWED SKILLS:\n${slugs.map((s) => `- ${s}`).join("\n")}\n\nReturn ONLY a JSON array of slugs from the allowed list. Empty array if none clearly apply. No commentary, no markdown, no explanation. Be conservative: only include a slug when the reply demonstrably USES the technique, not just touches its theme.\n\nCue patterns (when each skill is "demonstrably USED"):\n social-proof-stat: explicit number/% of past results ("70% наших девочек закрывают за 2 недели") → include.\n tactical-empathy: names candidate's emotion verbatim ("звучит, что ты переживаешь о..." / "кажется, тебе важна стабильность") → include.\n calibrated-question: open "как / что / почему" question that can't be answered yes/no ("как ты сейчас представляешь идеальный вариант?") → include.\n liking-genuine-compliment: a SPECIFIC sincere compliment to the candidate (energy / style / sharp question / good русский) — NOT generic "красавица". Even one short line counts ("вижу, ты задаёшь правильные вопросы — это редкость" / "по фото — энергия чувствуется"). → include.\n accusation-audit: salesperson VOICES the candidate's fear/objection BEFORE the candidate raises it ("наверное это звучит как развод" / "понимаю, кажется, что слишком хорошо чтобы быть правдой" / "догадываюсь, ты думаешь — а вдруг кинут") → include.\n authority-license: cites concrete legal/contractual mechanism ("договор подписывается ДО вылета" / "виза оформляется агентством на их стороне" / "официальный контракт с работодателем" / "никаких выплат в счёт работы") → include.\n scarcity-spots-left: explicit limited-slots phrasing ("3-5 мест на поток" / "разбирают за неделю" / "ближайший вылет почти набран") → include.\n reciprocity-free-info: shares useful info WITHOUT asking for commitment in the same breath ("кстати, в Сеуле сейчас сезон — поэтому ставки выше") → include.\n unity-belonging: uses "мы / наши девочки / наша команда" framing instead of impersonal "у нас" ("наши девочки в Шанхае пишут что..." / "мы тебя сопровождаем до прилёта") → include.\n commitment-microyes: stacks 2+ short confirmations before a bigger ask ("21? ок. паспорт есть? ок. готова на 3 месяца?") → include.\n mirroring: repeats candidate's last 1-3 words as a question ("не уверена?" / "слишком далеко?") → include.\n Reply just answers a factual question with no persuasion technique → return [].`;
29
+
30
+ export async function gradeSkills(input: GradeSkillsInput): Promise<string[]> {
31
+ if (input.availableSlugs.length === 0) return [];
32
+ try {
33
+ const raw = await input.chat.complete(
34
+ [
35
+ { role: "system", content: SYSTEM(input.availableSlugs) },
36
+ {
37
+ role: "user",
38
+ content: `Candidate: ${input.question}\n\nSalesperson reply: ${input.reply}\n\nReturn JSON array of skill slugs used (subset of allowed list).`,
39
+ },
40
+ ],
41
+ {
42
+ temperature: 0,
43
+ ...(input.model ? { model: input.model } : {}),
44
+ numPredict: 80,
45
+ },
46
+ );
47
+ return parseSlugList(raw, input.availableSlugs);
48
+ } catch (err) {
49
+ console.warn("[grade-skills] LLM call failed:", err);
50
+ return [];
51
+ }
52
+ }
53
+
54
+ /** Tolerant parser — accepts a bare JSON array, code-fenced JSON, or
55
+ * comma-separated text. Filters to allowed slugs. Exported for tests. */
56
+ export function parseSlugList(raw: string, allowed: readonly string[]): string[] {
57
+ if (!raw) return [];
58
+ const allowSet = new Set(allowed);
59
+ // Try JSON first.
60
+ const stripped = raw
61
+ .replace(/^```(?:json)?\s*/i, "")
62
+ .replace(/\s*```\s*$/i, "")
63
+ .trim();
64
+ try {
65
+ const parsed = JSON.parse(stripped);
66
+ if (Array.isArray(parsed)) {
67
+ return parsed
68
+ .filter((s): s is string => typeof s === "string")
69
+ .map((s) => s.trim())
70
+ .filter((s) => allowSet.has(s));
71
+ }
72
+ } catch {
73
+ /* fall through to plain-text split */
74
+ }
75
+ return stripped
76
+ .split(/[,\n]+/)
77
+ .map((s) => s.trim().replace(/^["'-]+|["'-]+$/g, ""))
78
+ .filter((s) => allowSet.has(s));
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @chatman-media/kb — production-grade RAG engine for conversational bots.
3
+ *
4
+ * Quick start:
5
+ *
6
+ * ```ts
7
+ * import { answerWithRag, OpenAIChatClient, OpenAIEmbeddingClient } from "@chatman-media/kb";
8
+ *
9
+ * const chat = new OpenAIChatClient({ apiKey, baseUrl, model: "gpt-4o-mini" });
10
+ * const embedder = new OpenAIEmbeddingClient({ apiKey, baseUrl, model: "text-embedding-3-small", dim: 1536 });
11
+ *
12
+ * const result = await answerWithRag({ question, kb: myKbStore, chat, embedder });
13
+ * console.log(result.text);
14
+ * ```
15
+ *
16
+ * See README.md for full usage and `IKbStore` implementation guide.
17
+ */
18
+
19
+ // ── A/B style router ──────────────────────────────────────────────────────────
20
+ export type { ABRouterOptions, ABVariant } from "./ab-router.ts";
21
+ export { ABRouter } from "./ab-router.ts";
22
+ // ── Core answer pipeline ─────────────────────────────────────────────────────
23
+ export {
24
+ answerWithRag,
25
+ answerWithRagStream,
26
+ generateSoftFallback,
27
+ retrieveHits,
28
+ type RetrievalResult,
29
+ } from "./answer.ts";
30
+ export type { AnswerInput, AnswerResult, AnswerTelemetry, Persona } from "./answer-types.ts";
31
+ export { NO_CONTEXT_MARKER } from "./answer-types.ts";
32
+ // ── LLM clients (re-exports из @chatman-media/llm-router для backwards-compat) ─
33
+ // Новый код должен импортировать chat/embed/providers напрямую из llm-router;
34
+ // эти re-exports будут удалены в будущем PR после полной миграции consumers.
35
+ export type {
36
+ ChatClient,
37
+ ChatCompletionOpts,
38
+ ChatMessage,
39
+ ChatRole,
40
+ } from "@chatman-media/llm-router";
41
+ export { ChatApiError, OpenAIChatClient } from "@chatman-media/llm-router";
42
+ export type { Chunk, ChunkOptions, SectionChunk } from "./chunk.ts";
43
+ // ── Chunking ──────────────────────────────────────────────────────────────────
44
+ export { chunkBySections, chunkText, estimateTokens } from "./chunk.ts";
45
+ // ── Conversation store ────────────────────────────────────────────────────────
46
+ export type { IConversationStore } from "./conversation-store.ts";
47
+ export { InMemoryConversationStore } from "./conversation-store.ts";
48
+ export type { EmbeddingClient } from "@chatman-media/llm-router";
49
+ export {
50
+ EmbeddingApiError,
51
+ NullEmbeddingClient,
52
+ OpenAIEmbeddingClient,
53
+ } from "@chatman-media/llm-router";
54
+ // ── Retrieval evaluation ──────────────────────────────────────────────────────
55
+ export type { EvalQuery, EvalResult, QueryMetrics } from "./eval.ts";
56
+ export { evalRetrieval } from "./eval.ts";
57
+ export type { ExtractFactsInput } from "./extract-user-facts.ts";
58
+ // ── Memory & conversation management ─────────────────────────────────────────
59
+ export { extractUserFacts, parseFactsFromLlmOutput } from "./extract-user-facts.ts";
60
+ export type { FactCheckInput, FactCheckResult } from "./fact-checker.ts";
61
+ // ── Hallucination guard ───────────────────────────────────────────────────────
62
+ export { checkFacts, parseFactCheckResult } from "./fact-checker.ts";
63
+ export type { GradeSkillsInput } from "./grade-skills.ts";
64
+ // ── Skill grading (post-hoc analytics) ───────────────────────────────────────
65
+ export { gradeSkills } from "./grade-skills.ts";
66
+ export type { IngestDeps, IngestDirectorySummary, IngestFileResult } from "./ingest.ts";
67
+ // ── Ingest pipeline ───────────────────────────────────────────────────────────
68
+ export {
69
+ deriveTopicFromPath,
70
+ ingestDirectory,
71
+ ingestFile,
72
+ ingestText,
73
+ stripNonContent,
74
+ } from "./ingest.ts";
75
+ // ── PDF parsing ───────────────────────────────────────────────────────────────
76
+ export { parsePdf, parsePdfBuffer } from "./parse-pdf.ts";
77
+ // ── Persona shortcuts ─────────────────────────────────────────────────────────
78
+ export {
79
+ botPresenceReply,
80
+ isBotPresenceQuestion,
81
+ isPersonalFactQuestion,
82
+ isPersonaSmalltalkQuestion,
83
+ personaFactReply,
84
+ personaSmalltalkReply,
85
+ } from "./persona-shortcuts.ts";
86
+ // ── Sales-style prompt engine ────────────────────────────────────────────────
87
+ export { composeSystemPrompt } from "./prompt.ts";
88
+ // ── Provider implementations (re-exports из llm-router) ──────────────────────
89
+ export type {
90
+ OllamaChatOptions,
91
+ OllamaEmbeddingOptions,
92
+ OpenRouterChatOptions,
93
+ } from "@chatman-media/llm-router";
94
+ export {
95
+ OllamaChatClient,
96
+ OllamaEmbeddingClient,
97
+ OpenRouterChatClient,
98
+ } from "@chatman-media/llm-router";
99
+ export type { ReflectInput, ReflectResult } from "./reflect.ts";
100
+ export { parseReflection, verifyAnswer } from "./reflect.ts";
101
+ // ── Reranker ──────────────────────────────────────────────────────────────────
102
+ export type { CohereRerankerOptions, JinaRerankerOptions, Reranker } from "./reranker.ts";
103
+ export { CohereReranker, JinaReranker } from "./reranker.ts";
104
+ // ── Retry / resilience ────────────────────────────────────────────────────────
105
+ export type { RetryOptions } from "./retry.ts";
106
+ export { withRetryChatClient, withRetryEmbeddingClient } from "./retry.ts";
107
+ export type { RewriteQueryInput } from "./rewrite-query.ts";
108
+ // ── Retrieval enhancements ────────────────────────────────────────────────────
109
+ export { questionNeedsRewrite, rewriteQuery, sanitizeRewritten } from "./rewrite-query.ts";
110
+ // ── Output sanitization ───────────────────────────────────────────────────────
111
+ export { sanitizeLlmOutput } from "./sanitize.ts";
112
+ // ── Semantic cache ────────────────────────────────────────────────────────────
113
+ export type { SemanticCacheOptions } from "./semantic-cache.ts";
114
+ export { SemanticCache } from "./semantic-cache.ts";
115
+ // ── SSE server ────────────────────────────────────────────────────────────────
116
+ export type { RagRequestBody, RagServerOptions } from "./server.ts";
117
+ export { createRagServer } from "./server.ts";
118
+ export { InMemoryKbStore } from "./stores/memory-store.ts";
119
+ // ── Structured output ─────────────────────────────────────────────────────────
120
+ export { parseStructuredOutput, zodToJsonSchema } from "./structured-output.ts";
121
+ export type {
122
+ ComposeOptions,
123
+ DirectorHookForPrompt,
124
+ FunnelStage,
125
+ Hook,
126
+ HookKind,
127
+ SalesFramework,
128
+ SkillForPrompt,
129
+ StageConfig,
130
+ Style,
131
+ StylePersona,
132
+ } from "./styles.ts";
133
+ export {
134
+ FUNNEL_STAGES,
135
+ HookSchema,
136
+ PersonaSchema,
137
+ SALES_FRAMEWORKS,
138
+ StyleSchema,
139
+ } from "./styles.ts";
140
+ export type { SummarizeInput } from "./summarize-conversation.ts";
141
+ export { cleanSummary, summarizeConversation } from "./summarize-conversation.ts";
142
+ // ── Legacy / simple prompt builder ───────────────────────────────────────────
143
+ export {
144
+ buildSystemPrompt,
145
+ DEFAULT_PERSONA,
146
+ legacyRagSamplingTemperature,
147
+ renderSummaryBlock,
148
+ renderUserFactsBlock,
149
+ } from "./system-prompt.ts";
150
+ export type { TextStyleRule } from "./text-style-rules.ts";
151
+ export {
152
+ applyStyleRules,
153
+ capitalizeFirstLetter,
154
+ DEFAULT_STYLE_RULES,
155
+ replaceEllipsis,
156
+ replaceEmDash,
157
+ stripAILeadIns,
158
+ stripMarkdownBold,
159
+ } from "./text-style-rules.ts";
160
+ // ── Tool calling ──────────────────────────────────────────────────────────────
161
+ export type { AnyRagTool, CompleteWithToolsResult, RagTool } from "./tools.ts";
162
+ // ── Built-in tools (ready-made RagTool factories) ────────────────────────────
163
+ export { makeBookingLinkTool } from "./built-in-tools/calendly.ts";
164
+ // ── Agentic tool-calling loop (multi-cycle, used internally by answerWithRag) ─
165
+ export {
166
+ buildToolTelemetry,
167
+ DEFAULT_MAX_TOOL_CYCLES,
168
+ runToolLoop,
169
+ type ToolCallRecord,
170
+ type ToolLoopResult,
171
+ } from "./tool-loop.ts";
172
+ // ── Topic routing ─────────────────────────────────────────────────────────────
173
+ export { classifyTopic, classifyTopicAll, KNOWN_TOPICS } from "./topic-classifier.ts";
174
+ // ── Storage interfaces & implementations ─────────────────────────────────────
175
+ export type { IKbStore, IKbSuggestionsStore, KbSearchHit } from "./types.ts";
176
+ // ── Utilities ─────────────────────────────────────────────────────────────────
177
+ export { reciprocalRankFusion, sanitizeFtsQuery } from "./utils.ts";
178
+ // ── Vision / photo classification ────────────────────────────────────────────
179
+ export type {
180
+ ClassifyPhotoOptions,
181
+ PassportIdentity,
182
+ PhotoClass,
183
+ VisionProvider,
184
+ } from "./vision.ts";
185
+ export {
186
+ classifyPhoto,
187
+ extractPassportIdentity,
188
+ PHOTO_CLASSES,
189
+ parsePassportJson,
190
+ parsePhotoClass,
191
+ } from "./vision.ts";
package/src/ingest.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
4
+ import { type ChunkOptions, chunkText } from "./chunk.ts";
5
+ import type { EmbeddingClient } from "@chatman-media/llm-router";
6
+ import { parsePdf } from "./parse-pdf.ts";
7
+ import type { IKbStore } from "./types.ts";
8
+
9
+ const SUPPORTED_EXTS = new Set([".md", ".txt", ".pdf"]);
10
+
11
+ export interface IngestDeps {
12
+ kb: IKbStore;
13
+ embedder: EmbeddingClient;
14
+ chunk?: Partial<ChunkOptions>;
15
+ topic?: string | null;
16
+ /** Overrides the default `file://<abs>` document source. Callers that
17
+ * ingest from a throwaway temp path (e.g. an admin upload route) pass a
18
+ * stable, content-addressed source here so re-uploads dedupe. */
19
+ source?: string;
20
+ /** Overrides the default `basename(abs)` document title — useful when the
21
+ * on-disk filename is a temp name rather than something operator-facing. */
22
+ title?: string;
23
+ }
24
+
25
+ export interface IngestFileResult {
26
+ source: string;
27
+ documentId: number;
28
+ chunks: number;
29
+ created: boolean;
30
+ }
31
+
32
+ export async function ingestFile(path: string, deps: IngestDeps): Promise<IngestFileResult> {
33
+ const abs = resolve(path);
34
+ const ext = extname(abs).toLowerCase();
35
+ const raw = ext === ".pdf" ? await parsePdf(abs) : readFileSync(abs, "utf8");
36
+ const hash = createHash("sha256").update(raw).digest("hex");
37
+ const source = deps.source ?? `file://${abs}`;
38
+ const title = deps.title ?? basename(abs);
39
+
40
+ const existing = await deps.kb.getDocumentBySource(source);
41
+
42
+ if (existing && existing.content_hash === hash) {
43
+ const chunks = await deps.kb.countChunksForDocument(existing.id);
44
+ if (chunks > 0) {
45
+ return { source, documentId: existing.id, chunks, created: false };
46
+ }
47
+ }
48
+
49
+ if (existing) {
50
+ await deps.kb.deleteDocument(existing.id);
51
+ }
52
+
53
+ const doc = await deps.kb.upsertDocument({
54
+ source,
55
+ title,
56
+ contentHash: hash,
57
+ ...(deps.topic !== undefined ? { topic: deps.topic } : {}),
58
+ });
59
+
60
+ const preparedText = ext === ".pdf" ? raw : stripNonContent(raw);
61
+ const chunks = chunkText(preparedText, deps.chunk);
62
+ if (chunks.length === 0) {
63
+ return { source, documentId: doc.id, chunks: 0, created: true };
64
+ }
65
+
66
+ const vectors = await deps.embedder.embed(chunks.map((c) => c.text));
67
+ for (const [i, chunk] of chunks.entries()) {
68
+ const vec = vectors[i];
69
+ if (!vec) throw new Error(`embedder returned no vector for chunk ${i}`);
70
+ await deps.kb.insertChunkWithEmbedding({
71
+ documentId: doc.id,
72
+ chunkIndex: chunk.index,
73
+ text: chunk.text,
74
+ tokenCount: chunk.tokenCount,
75
+ embedding: vec,
76
+ });
77
+ }
78
+ return { source, documentId: doc.id, chunks: chunks.length, created: true };
79
+ }
80
+
81
+ /**
82
+ * Ingest a raw text document directly (no filesystem). Used by admin-UI
83
+ * paste path. Source is synthesised as `inline:<sha256[:12]>` so subsequent
84
+ * uploads of identical content dedupe via the `source` UNIQUE lookup.
85
+ */
86
+ export async function ingestText(
87
+ input: { title: string; body: string },
88
+ deps: IngestDeps,
89
+ ): Promise<IngestFileResult> {
90
+ const raw = input.body;
91
+ const hash = createHash("sha256").update(raw).digest("hex");
92
+ const source = `inline:${hash.slice(0, 12)}`;
93
+ const title = input.title.trim() || "untitled";
94
+
95
+ const existing = await deps.kb.getDocumentBySource(source);
96
+
97
+ if (existing && existing.content_hash === hash) {
98
+ const chunks = await deps.kb.countChunksForDocument(existing.id);
99
+ if (chunks > 0) {
100
+ return { source, documentId: existing.id, chunks, created: false };
101
+ }
102
+ }
103
+
104
+ if (existing) {
105
+ await deps.kb.deleteDocument(existing.id);
106
+ }
107
+
108
+ const doc = await deps.kb.upsertDocument({
109
+ source,
110
+ title,
111
+ contentHash: hash,
112
+ ...(deps.topic !== undefined ? { topic: deps.topic } : {}),
113
+ });
114
+
115
+ const chunks = chunkText(stripNonContent(raw), deps.chunk);
116
+ if (chunks.length === 0) {
117
+ return { source, documentId: doc.id, chunks: 0, created: true };
118
+ }
119
+
120
+ const vectors = await deps.embedder.embed(chunks.map((c) => c.text));
121
+ for (const [i, chunk] of chunks.entries()) {
122
+ const vec = vectors[i];
123
+ if (!vec) throw new Error(`embedder returned no vector for chunk ${i}`);
124
+ await deps.kb.insertChunkWithEmbedding({
125
+ documentId: doc.id,
126
+ chunkIndex: chunk.index,
127
+ text: chunk.text,
128
+ tokenCount: chunk.tokenCount,
129
+ embedding: vec,
130
+ });
131
+ }
132
+ return { source, documentId: doc.id, chunks: chunks.length, created: true };
133
+ }
134
+
135
+ export interface IngestDirectorySummary {
136
+ documents: number;
137
+ chunks: number;
138
+ skipped: number;
139
+ }
140
+
141
+ export async function ingestDirectory(
142
+ dir: string,
143
+ deps: IngestDeps,
144
+ ): Promise<IngestDirectorySummary> {
145
+ const summary: IngestDirectorySummary = { documents: 0, chunks: 0, skipped: 0 };
146
+ const root = resolve(dir);
147
+ for (const file of walk(root)) {
148
+ if (!SUPPORTED_EXTS.has(extname(file).toLowerCase())) {
149
+ summary.skipped++;
150
+ continue;
151
+ }
152
+ const fileDeps: IngestDeps =
153
+ deps.topic !== undefined ? deps : { ...deps, topic: deriveTopicFromPath(file, root) };
154
+ const r = await ingestFile(file, fileDeps);
155
+ summary.documents++;
156
+ summary.chunks += r.chunks;
157
+ }
158
+ return summary;
159
+ }
160
+
161
+ /**
162
+ * Returns the immediate sub-directory name of `file` relative to `root`,
163
+ * or null when the file sits directly under `root`. Exported for unit tests.
164
+ */
165
+ export function deriveTopicFromPath(file: string, root: string): string | null {
166
+ const rel = relative(root, dirname(file));
167
+ if (!rel || rel === "." || rel.startsWith("..")) return null;
168
+ const first = rel.split(/[/\\]/)[0];
169
+ return first || null;
170
+ }
171
+
172
+ function* walk(dir: string): Generator<string> {
173
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
174
+ const full = join(dir, entry.name);
175
+ if (entry.isDirectory()) {
176
+ yield* walk(full);
177
+ } else if (entry.isFile()) {
178
+ yield full;
179
+ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Removes YAML frontmatter and HTML comments from Markdown — noise that
185
+ * pollutes embeddings. The visible body is preserved as-is.
186
+ */
187
+ export function stripNonContent(raw: string): string {
188
+ let s = raw;
189
+ s = s.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
190
+ s = s.replace(/<!--[\s\S]*?-->/g, "");
191
+ s = s.replace(/\n{3,}/g, "\n\n").trim();
192
+ return `${s}\n`;
193
+ }