From b8f09227b0ed67dd110eb675abe6b215c21adfe7 Mon Sep 17 00:00:00 2001 From: yunogasai Date: Thu, 24 Sep 2026 20:42:14 +0200 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=BE=D1=82=20?= =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D0=B2=D0=B0=20=D1=87=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Новая таблица dialog_messages: последние DIALOG_LIMIT пар «вопрос — ответ», подкладываются в каждый запрос - /clear сбрасывает только контекст диалога, архив (включая импорт) не трогает; без подтверждения - /context показывает контекст и архив по отдельности Co-Authored-By: Claude Opus 5.5 --- .env.example | 5 ++- CLAUDE.md | 1 + README.md | 8 +++- prompts/system.md | 4 ++ src/bot/commands.ts | 10 +++-- src/bot/create-bot.ts | 5 ++- src/bot/handlers/context-handlers.ts | 46 +++++++-------------- src/config.ts | 2 + src/index.ts | 9 ++++ src/libs/db.ts | 11 +++++ src/repositories/dialog-repository.ts | 41 ++++++++++++++++++ src/repositories/message-repository.ts | 6 +-- src/services/assistant/assistant-service.ts | 31 +++++++++++--- src/services/assistant/prompts.ts | 3 ++ src/services/chat-history-service.ts | 5 +-- src/services/dialog-service.ts | 27 ++++++++++++ src/types.ts | 6 +++ 17 files changed, 167 insertions(+), 53 deletions(-) create mode 100644 src/repositories/dialog-repository.ts create mode 100644 src/services/dialog-service.ts diff --git a/.env.example b/.env.example index 60bfe86..17307e3 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ OPENROUTER_API_KEY= # Необязательно: умеет ли модель смотреть фото. Для своей AI_MODEL по умолчанию false — включи, если модель видит картинки # AI_IMAGE_INPUT=true -# Путь к SQLite-базе и сколько сообщений на чат хранить +# Путь к SQLite-базе и сколько сообщений на чат хранить в архиве DB_PATH=data/bot.db HISTORY_LIMIT=5000 + +# Сколько последних пар «вопрос — ответ» бот помнит в контексте диалога +DIALOG_LIMIT=10 diff --git a/CLAUDE.md b/CLAUDE.md index 80be4b3..5af3b46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ src/ prompts/ — тексты промптов: system.md (правила бота) и personas/.md ``` +- Память разделена: архив чата (`messages`, читается инструментами) и контекст диалога (`dialog_messages`, подкладывается в каждый запрос). `/clear` чистит только контекст. - Промпты храним только в `prompts/*.md`, не в коде. - Личности не редактируются из чата: источник правды — код и `prompts/`, в БД их кладёт `seed`. - Изменения схемы БД — через `migrate` в `libs/db.ts` (у пользователей уже есть базы). diff --git a/README.md b/README.md index 63856b1..b81c17f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,10 @@ Telegram-бот для групповых чатов с AI-личностями. - **Контекст из чата**: бот сохраняет сообщения чата в SQLite (последние `HISTORY_LIMIT` на чат). Попросите «посмотри последние 100 сообщений и перескажи» — агент сам вызовет инструмент `read_chat_messages` и загрузит переписку в контекст. Если задать `/ask` ответом на чьё-то сообщение, оно тоже попадёт в контекст. - **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени. - **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет. -- **Память чата**: `/context` — сколько сообщений и участников бот помнит, ID чата и видит ли он всю переписку; `/clear` — забыть сохранённую переписку (с подтверждением, имена участников остаются); `/import` — загрузить старую переписку из экспорта (см. ниже). +- **Память — две части**: + - *Контекст диалога* — последние `DIALOG_LIMIT` вопросов к боту и его ответов в этом чате. Подкладывается в каждый запрос, поэтому можно уточнять «а подробнее?». `/clear` сбрасывает только его. + - *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает. + - `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже). - **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D). - `/personas` — список и выбор кнопками - `/persona ` — выбрать @@ -63,7 +66,8 @@ pnpm dev \* нужен ключ того провайдера, чья модель выбрана; бот проверяет это при старте. | `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) | -| `HISTORY_LIMIT` | нет | сколько сообщений на чат хранить, по умолчанию 5000 | +| `HISTORY_LIMIT` | нет | сколько сообщений на чат хранить в архиве, по умолчанию 5000 | +| `DIALOG_LIMIT` | нет | сколько последних пар «вопрос — ответ» бот помнит в контексте, по умолчанию 10 | ## Деплой (Dokploy) diff --git a/prompts/system.md b/prompts/system.md index eedcc93..3ba7203 100644 --- a/prompts/system.md +++ b/prompts/system.md @@ -3,6 +3,10 @@ Будь кратким, если не просят подробностей. Обращайся к людям по имени, а не по нику. Если говоришь о другом участнике — тоже называй его по имени. +Память: +- Предыдущие вопросы к тебе и твои ответы в этом чате уже есть выше в диалоге — это твой контекст. +- Остальная переписка чата лежит в архиве — читай её инструментами, когда нужно. + Инструменты: - read_chat_messages — последние сообщения чата. Когда просят посмотреть/прочитать/пересказать переписку или вопрос касается того, что обсуждали. - list_chat_members — участники чата с именами и никами. Когда человека называют по имени или прозвищу и нужно понять, кто это. diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 803f43a..2e7dccc 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -2,8 +2,8 @@ export const BOT_COMMANDS = [ { command: "ask", description: "Задать вопрос боту" }, { command: "personas", description: "Список личностей и выбор" }, { command: "persona", description: "Выбрать личность: /persona " }, - { command: "context", description: "Что бот помнит о чате и видит ли переписку" }, - { command: "clear", description: "Очистить сохранённую переписку чата" }, + { command: "context", description: "Что бот помнит: контекст диалога и архив чата" }, + { command: "clear", description: "Сбросить контекст диалога (архив остаётся)" }, { command: "import", description: "Загрузить старую переписку из экспорта Telegram" }, { command: "help", description: "Справка" }, ]; @@ -22,8 +22,10 @@ export const helpText = (username: string) => `Я отвечаю только а Старые сообщения, написанные до моего добавления, Telegram мне не отдаёт — но их можно загрузить из экспорта. Память: -• /context — что я помню о чате и вижу ли всю переписку -• /clear — забыть сохранённую переписку +• Помню последние вопросы ко мне и свои ответы — можно уточнять «а подробнее?». +• Всю переписку чата храню отдельно, в архиве, и читаю её, когда нужно. +• /context — что сейчас в контексте и в архиве, вижу ли я всю переписку +• /clear — сбросить контекст диалога (архив не трогаю) • /import — загрузить старую переписку из экспорта Telegram Desktop (JSON) Личности: diff --git a/src/bot/create-bot.ts b/src/bot/create-bot.ts index 90de65f..285dcab 100644 --- a/src/bot/create-bot.ts +++ b/src/bot/create-bot.ts @@ -2,6 +2,7 @@ import { Bot, type Context } from "grammy"; import type { AssistantService } from "../services/assistant/assistant-service.js"; import type { ChatHistoryService } from "../services/chat-history-service.js"; import type { ChatMemberService } from "../services/chat-member-service.js"; +import type { DialogService } from "../services/dialog-service.js"; import type { HistoryImportService } from "../services/history-import-service.js"; import type { PersonaService } from "../services/persona-service.js"; import { createContextHandlers } from "./handlers/context-handlers.js"; @@ -17,6 +18,7 @@ interface BotDeps { personaService: PersonaService; chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; + dialogService: DialogService; historyImportService: HistoryImportService; assistantService: AssistantService; } @@ -27,6 +29,7 @@ export const createBot = ({ personaService, chatHistoryService, chatMemberService, + dialogService, historyImportService, assistantService, }: BotDeps): Bot => { @@ -42,7 +45,7 @@ export const createBot = ({ admin.use(createHelpHandlers()); admin.use(createPersonaHandlers(personaService)); - admin.use(createContextHandlers({ chatHistoryService, chatMemberService })); + admin.use(createContextHandlers({ chatHistoryService, chatMemberService, dialogService })); admin.use(createImportHandlers(historyImportService)); admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService })); diff --git a/src/bot/handlers/context-handlers.ts b/src/bot/handlers/context-handlers.ts index c6d31aa..e6ca404 100644 --- a/src/bot/handlers/context-handlers.ts +++ b/src/bot/handlers/context-handlers.ts @@ -1,15 +1,13 @@ -import { Composer, InlineKeyboard, type Context } from "grammy"; +import { Composer, type Context } from "grammy"; import { formatUtc } from "../../libs/time.js"; import type { ChatHistoryService } from "../../services/chat-history-service.js"; import type { ChatMemberService } from "../../services/chat-member-service.js"; - -const CLEAR_CONFIRM = "clear:yes"; - -const CLEAR_CANCEL = "clear:no"; +import type { DialogService } from "../../services/dialog-service.js"; interface ContextHandlersDeps { chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; + dialogService: DialogService; } /** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */ @@ -27,48 +25,36 @@ const visibilityText = async (ctx: Context): Promise => { ); }; -/** Что бот помнит о чате и очистка этой памяти. Подключать только за фильтром администратора. */ -export const createContextHandlers = ({ chatHistoryService, chatMemberService }: ContextHandlersDeps) => { +/** Что бот помнит о чате: контекст диалога и архив переписки. Подключать только за фильтром администратора. */ +export const createContextHandlers = ({ chatHistoryService, chatMemberService, dialogService }: ContextHandlersDeps) => { const composer = new Composer(); composer.command("context", async (ctx) => { - const { count, oldest, limit } = chatHistoryService.stats(ctx.chat.id); + const dialog = dialogService.stats(ctx.chat.id); + const archive = chatHistoryService.stats(ctx.chat.id); const members = chatMemberService.list(ctx.chat.id).length; - const since = oldest === null ? "" : `, с ${formatUtc(oldest)}`; + const since = archive.oldest === null ? "" : `, с ${formatUtc(archive.oldest)}`; const lines = [ - `Помню сообщений: ${count} из ${limit} возможных${since}.`, + `Контекст диалога: ${dialog.exchanges} из ${dialog.exchangesLimit} последних вопросов и ответов. Сбросить — /clear.`, + `Архив чата: ${archive.count} сообщений из ${archive.limit} возможных${since}. Читаю его, когда нужно.`, `Знаю участников: ${members}.`, await visibilityText(ctx), `ID чата: ${ctx.chat.id}`, - "Старые сообщения, написанные до моего добавления, Telegram боту не отдаёт — загрузи экспорт через /import.", - "Очистить переписку: /clear", + "Сообщения до моего добавления Telegram не отдаёт — загрузи экспорт через /import.", ]; await ctx.reply(lines.join("\n")); }); composer.command("clear", async (ctx) => { - const { count } = chatHistoryService.stats(ctx.chat.id); + const forgotten = dialogService.clear(ctx.chat.id); - const keyboard = new InlineKeyboard().text("🗑 Да, очистить", CLEAR_CONFIRM).text("Отмена", CLEAR_CANCEL); - - await ctx.reply(`Забыть сохранённую переписку этого чата (${count} сообщений)? Имена участников останутся.`, { - reply_markup: keyboard, - }); - }); - - composer.callbackQuery(CLEAR_CONFIRM, async (ctx) => { - const removed = ctx.chat ? chatHistoryService.clear(ctx.chat.id) : 0; - - await ctx.answerCallbackQuery({ text: "Контекст очищен" }); - await ctx.editMessageText(`Готово: забыл ${removed} сообщений. Начинаю с чистого листа.`).catch(() => {}); - }); - - composer.callbackQuery(CLEAR_CANCEL, async (ctx) => { - await ctx.answerCallbackQuery({ text: "Отменено" }); - await ctx.editMessageText("Очистка отменена.").catch(() => {}); + await ctx.reply( + `Контекст диалога сброшен (забыл ${forgotten} вопросов и ответов), начинаю с чистого листа. ` + + "Архив переписки чата на месте.", + ); }); return composer; diff --git a/src/config.ts b/src/config.ts index 2f9197f..2ac8123 100644 --- a/src/config.ts +++ b/src/config.ts @@ -96,4 +96,6 @@ export const loadBotConfig = () => ({ botToken: required("BOT_TOKEN"), adminIds: parseAdminIds(required("ADMIN_IDS")), ai: parseModel(), + /** Сколько последних пар «вопрос — ответ» модель помнит в каждом чате. */ + dialogLimit: positiveInt("DIALOG_LIMIT", 10), }); diff --git a/src/index.ts b/src/index.ts index 557aab1..e903570 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,11 +4,13 @@ import { DB_PATH, HISTORY_LIMIT, loadBotConfig } from "./config.js"; import { createDb } from "./libs/db.js"; import { createChatMemberRepository } from "./repositories/chat-member-repository.js"; import { createChatSettingsRepository } from "./repositories/chat-settings-repository.js"; +import { createDialogRepository } from "./repositories/dialog-repository.js"; import { createMessageRepository } from "./repositories/message-repository.js"; import { createPersonaRepository } from "./repositories/persona-repository.js"; import { createAssistantService } from "./services/assistant/assistant-service.js"; import { createChatHistoryService } from "./services/chat-history-service.js"; import { createChatMemberService } from "./services/chat-member-service.js"; +import { createDialogService } from "./services/dialog-service.js"; import { createHistoryImportService } from "./services/history-import-service.js"; import { createPersonaService } from "./services/persona-service.js"; @@ -31,6 +33,11 @@ const chatHistoryService = createChatHistoryService({ messageRepository, history const chatMemberService = createChatMemberService({ chatMemberRepository }); +const dialogService = createDialogService({ + dialogRepository: createDialogRepository(db), + exchangesLimit: config.dialogLimit, +}); + const historyImportService = createHistoryImportService({ messageRepository, chatMemberRepository, @@ -42,6 +49,7 @@ const assistantService = createAssistantService({ imageInput: config.ai.imageInput, chatHistoryService, chatMemberService, + dialogService, }); const bot = createBot({ @@ -50,6 +58,7 @@ const bot = createBot({ personaService, chatHistoryService, chatMemberService, + dialogService, historyImportService, assistantService, }); diff --git a/src/libs/db.ts b/src/libs/db.ts index 9f440f1..82d3243 100644 --- a/src/libs/db.ts +++ b/src/libs/db.ts @@ -35,6 +35,17 @@ const SCHEMA = ` last_name TEXT, PRIMARY KEY (chat_id, user_id) ); + + -- Контекст диалога с ботом (вопросы и ответы) — отдельно от архива чата, /clear чистит только его. + CREATE TABLE IF NOT EXISTS dialog_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), + content TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS dialog_by_chat ON dialog_messages (chat_id, id); `; const hasColumn = (db: Db, table: string, column: string): boolean => diff --git a/src/repositories/dialog-repository.ts b/src/repositories/dialog-repository.ts new file mode 100644 index 0000000..abc5b6b --- /dev/null +++ b/src/repositories/dialog-repository.ts @@ -0,0 +1,41 @@ +import type { Db } from "../libs/db.js"; +import type { DialogTurn } from "../types.js"; + +export const createDialogRepository = (db: Db) => { + const statements = { + insert: db.prepare("INSERT INTO dialog_messages (chat_id, role, content, created_at) VALUES (?, ?, ?, ?)"), + pruneChat: db.prepare(` + DELETE FROM dialog_messages WHERE chat_id = ? AND id <= ( + SELECT id FROM dialog_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ? + ) + `), + latest: db.prepare("SELECT role, content FROM dialog_messages WHERE chat_id = ? ORDER BY id DESC LIMIT ?"), + countByChat: db.prepare("SELECT COUNT(*) AS count FROM dialog_messages WHERE chat_id = ?"), + deleteByChat: db.prepare("DELETE FROM dialog_messages WHERE chat_id = ?"), + }; + + /** + * Записывает вопрос и ответ парой и оставляет в чате не больше `keep` последних реплик. + * Пары пишутся атомарно, поэтому при чётном `keep` контекст всегда начинается с вопроса. + */ + const appendExchangeAndPrune = db.transaction((chatId: number, question: string, answer: string, keep: number) => { + const now = Math.floor(Date.now() / 1000); + + statements.insert.run(chatId, "user", question, now); + statements.insert.run(chatId, "assistant", answer, now); + statements.pruneChat.run(chatId, chatId, keep); + }); + + /** Последние `limit` реплик диалога в хронологическом порядке. */ + const latest = (chatId: number, limit: number): DialogTurn[] => + (statements.latest.all(chatId, limit) as DialogTurn[]).reverse(); + + const countByChat = (chatId: number): number => (statements.countByChat.get(chatId) as { count: number }).count; + + /** Забыть диалог чата; возвращает, сколько реплик удалено. */ + const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes; + + return { appendExchangeAndPrune, latest, countByChat, deleteByChat }; +}; + +export type DialogRepository = ReturnType; diff --git a/src/repositories/message-repository.ts b/src/repositories/message-repository.ts index f30c2b0..be2e524 100644 --- a/src/repositories/message-repository.ts +++ b/src/repositories/message-repository.ts @@ -44,7 +44,6 @@ export const createMessageRepository = (db: Db) => { ), findById: db.prepare("SELECT * FROM messages WHERE chat_id = ? AND message_id = ?"), statsByChat: db.prepare("SELECT COUNT(*) AS count, MIN(date) AS oldest FROM messages WHERE chat_id = ?"), - deleteByChat: db.prepare("DELETE FROM messages WHERE chat_id = ?"), }; const toChronological = (rows: unknown[]): ChatMessage[] => (rows as MessageRow[]).reverse().map(toMessage); @@ -85,10 +84,7 @@ export const createMessageRepository = (db: Db) => { const statsByChat = (chatId: number): { count: number; oldest: number | null } => statements.statsByChat.get(chatId) as { count: number; oldest: number | null }; - /** Удаляет всю сохранённую историю чата; возвращает, сколько сообщений удалено. */ - const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes; - - return { upsertAndPrune, insertManyAndPrune, latest, latestByUser, findById, statsByChat, deleteByChat }; + return { upsertAndPrune, insertManyAndPrune, latest, latestByUser, findById, statsByChat }; }; export type MessageRepository = ReturnType; diff --git a/src/services/assistant/assistant-service.ts b/src/services/assistant/assistant-service.ts index 3ef8e45..1b183d9 100644 --- a/src/services/assistant/assistant-service.ts +++ b/src/services/assistant/assistant-service.ts @@ -1,10 +1,11 @@ import { Agent } from "@mastra/core/agent"; import { RequestContext } from "@mastra/core/request-context"; import { readPrompt } from "../../libs/prompts.js"; -import type { Persona } from "../../types.js"; +import type { DialogTurn, Persona } from "../../types.js"; import type { ChatHistoryService } from "../chat-history-service.js"; import type { ChatMemberService } from "../chat-member-service.js"; -import { buildInstructions, buildPrompt, type Question } from "./prompts.js"; +import type { DialogService } from "../dialog-service.js"; +import { buildInstructions, buildUserText, type Question } from "./prompts.js"; import { createListChatMembersTool } from "./tools/list-chat-members-tool.js"; import { createReadChatMessagesTool } from "./tools/read-chat-messages-tool.js"; import { createReadMemberMessagesTool } from "./tools/read-member-messages-tool.js"; @@ -18,6 +19,7 @@ interface AssistantServiceDeps { imageInput: boolean; chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; + dialogService: DialogService; } const MAX_AGENT_STEPS = 6; @@ -26,16 +28,23 @@ const MAX_AGENT_STEPS = 6; const buildUserMessage = (question: Question) => ({ role: "user" as const, content: [ - { type: "text" as const, text: buildPrompt(question) }, + { type: "text" as const, text: buildUserText(question) }, ...question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })), ], }); +/** Реплика прошлого диалога в формате сообщений Mastra (роль должна быть литералом). */ +const toModelMessage = (turn: DialogTurn) => + turn.role === "user" + ? { role: "user" as const, content: turn.content } + : { role: "assistant" as const, content: turn.content }; + export const createAssistantService = ({ model, imageInput, chatHistoryService, chatMemberService, + dialogService, }: AssistantServiceDeps) => { const systemRules = readPrompt("system.md"); @@ -51,19 +60,29 @@ export const createAssistantService = ({ }, }); - /** Ответ на вопрос в образе личности; агент сам решает, какую историю чата подгрузить. */ + /** + * Ответ на вопрос в образе личности. Контекст диалога (прошлые вопросы и ответы) подкладываем сами, + * архив чата агент подгружает инструментами, когда нужно. + */ const answer = async (question: Question, persona: Persona): Promise => { const requestContext = new RequestContext(); requestContext.set("chatId", question.chatId); - const result = await agent.generate([buildUserMessage(question)], { + const history = dialogService.recent(question.chatId).map(toModelMessage); + + const result = await agent.generate([...history, buildUserMessage(question)], { instructions: buildInstructions(systemRules, question, persona), requestContext, maxSteps: MAX_AGENT_STEPS, }); - return result.text.trim(); + const text = result.text.trim(); + + // Картинки в контекст не кладём — только текст вопроса; пустой ответ не запоминаем. + if (text) dialogService.remember(question.chatId, buildUserText(question), text); + + return text; }; /** Боту стоит скачивать фото, только если модель умеет их смотреть. */ diff --git a/src/services/assistant/prompts.ts b/src/services/assistant/prompts.ts index a4970af..dff95e8 100644 --- a/src/services/assistant/prompts.ts +++ b/src/services/assistant/prompts.ts @@ -66,3 +66,6 @@ export const buildPrompt = (question: Question): string => { return [replied, imagesNote(question), replied ? `Вопрос: ${asked}` : asked].filter(Boolean).join("\n\n"); }; + +/** Текст реплики пользователя — с именем, потому что в чате боту пишут разные админы. */ +export const buildUserText = (question: Question): string => `${question.askerName}:\n${buildPrompt(question)}`; diff --git a/src/services/chat-history-service.ts b/src/services/chat-history-service.ts index 6c34f7b..91abee0 100644 --- a/src/services/chat-history-service.ts +++ b/src/services/chat-history-service.ts @@ -21,10 +21,7 @@ export const createChatHistoryService = ({ messageRepository, historyLimit }: Ch const stats = (chatId: number) => ({ ...messageRepository.statsByChat(chatId), limit: historyLimit }); - /** Забыть переписку чата: модель больше не увидит сообщения, сохранённые до этого момента. */ - const clear = (chatId: number): number => messageRepository.deleteByChat(chatId); - - return { record, latest, latestByUser, find, stats, clear }; + return { record, latest, latestByUser, find, stats }; }; export type ChatHistoryService = ReturnType; diff --git a/src/services/dialog-service.ts b/src/services/dialog-service.ts new file mode 100644 index 0000000..a863078 --- /dev/null +++ b/src/services/dialog-service.ts @@ -0,0 +1,27 @@ +import type { DialogRepository } from "../repositories/dialog-repository.js"; +import type { DialogTurn } from "../types.js"; + +interface DialogServiceDeps { + dialogRepository: DialogRepository; + /** Сколько последних пар «вопрос — ответ» модель видит в каждом запросе. */ + exchangesLimit: number; +} + +/** Контекст диалога с ботом — то, что модель «помнит» между вопросами. Архив чата живёт отдельно. */ +export const createDialogService = ({ dialogRepository, exchangesLimit }: DialogServiceDeps) => { + const turnsLimit = exchangesLimit * 2; + + const recent = (chatId: number): DialogTurn[] => dialogRepository.latest(chatId, turnsLimit); + + const remember = (chatId: number, question: string, answer: string): void => + dialogRepository.appendExchangeAndPrune(chatId, question, answer, turnsLimit); + + const stats = (chatId: number) => ({ exchanges: Math.floor(dialogRepository.countByChat(chatId) / 2), exchangesLimit }); + + /** Начать диалог с чистого листа; архив сообщений чата не трогаем. Возвращает, сколько пар забыто. */ + const clear = (chatId: number): number => Math.floor(dialogRepository.deleteByChat(chatId) / 2); + + return { recent, remember, stats, clear }; +}; + +export type DialogService = ReturnType; diff --git a/src/types.ts b/src/types.ts index 7ef1a01..87e6cd1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,3 +42,9 @@ export interface ImageAttachment { mimeType: string; source: ImageSource; } + +/** Реплика диалога с ботом: вопрос пользователя или ответ бота. */ +export interface DialogTurn { + role: "user" | "assistant"; + content: string; +}