Контекст диалога на Mastra Memory вместо своей таблицы
- Контекст диалога хранит Mastra Memory (LibSQL, memory.db рядом с DB_PATH), тред на чат - Бюджет истории — messageHistory.maxTokens (CONTEXT_TOKEN_LIMIT, по умолчанию 12000) - ToolCallFilter: результаты инструментов не попадают в прошлые реплики, внутри ответа — только свежий шаг - Системный промпт стабилен для всего чата (кешируется провайдером); автор и упоминания — в <turn author_name> - Фото передаются через context и не сохраняются в памяти - maxSteps и maxOutputTokens (ANSWER_TOKEN_LIMIT) — в defaultOptions агента - /context и /clear работают через Memory recall/deleteThread - Удалены свои dialog_messages, dialog-repository и dialog-service Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -18,7 +17,6 @@ interface BotDeps {
|
||||
personaService: PersonaService;
|
||||
chatHistoryService: ChatHistoryService;
|
||||
chatMemberService: ChatMemberService;
|
||||
dialogService: DialogService;
|
||||
historyImportService: HistoryImportService;
|
||||
assistantService: AssistantService;
|
||||
}
|
||||
@@ -29,7 +27,6 @@ export const createBot = ({
|
||||
personaService,
|
||||
chatHistoryService,
|
||||
chatMemberService,
|
||||
dialogService,
|
||||
historyImportService,
|
||||
assistantService,
|
||||
}: BotDeps): Bot => {
|
||||
@@ -45,7 +42,7 @@ export const createBot = ({
|
||||
|
||||
admin.use(createHelpHandlers());
|
||||
admin.use(createPersonaHandlers(personaService));
|
||||
admin.use(createContextHandlers({ chatHistoryService, chatMemberService, dialogService }));
|
||||
admin.use(createContextHandlers({ chatHistoryService, chatMemberService, assistantService }));
|
||||
admin.use(createImportHandlers(historyImportService));
|
||||
admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService }));
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ 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";
|
||||
import type { DialogService } from "../../services/dialog-service.js";
|
||||
import type { AssistantService } from "../../services/assistant/assistant-service.js";
|
||||
|
||||
interface ContextHandlersDeps {
|
||||
chatHistoryService: ChatHistoryService;
|
||||
chatMemberService: ChatMemberService;
|
||||
dialogService: DialogService;
|
||||
assistantService: AssistantService;
|
||||
}
|
||||
|
||||
/** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */
|
||||
@@ -26,18 +26,18 @@ const visibilityText = async (ctx: Context): Promise<string> => {
|
||||
};
|
||||
|
||||
/** Что бот помнит о чате: контекст диалога и архив переписки. Подключать только за фильтром администратора. */
|
||||
export const createContextHandlers = ({ chatHistoryService, chatMemberService, dialogService }: ContextHandlersDeps) => {
|
||||
export const createContextHandlers = ({ chatHistoryService, chatMemberService, assistantService }: ContextHandlersDeps) => {
|
||||
const composer = new Composer();
|
||||
|
||||
composer.command("context", async (ctx) => {
|
||||
const dialog = dialogService.stats(ctx.chat.id);
|
||||
const dialogMessages = await assistantService.dialogSize(ctx.chat.id);
|
||||
const archive = chatHistoryService.stats(ctx.chat.id);
|
||||
const members = chatMemberService.list(ctx.chat.id).length;
|
||||
|
||||
const since = archive.oldest === null ? "" : `, с ${formatUtc(archive.oldest)}`;
|
||||
|
||||
const lines = [
|
||||
`Контекст диалога: ${dialog.exchanges} из ${dialog.exchangesLimit} последних вопросов и ответов. Сбросить — /clear.`,
|
||||
`Контекст диалога: ${dialogMessages} сообщений (в запрос идут последние, сколько влезает в бюджет токенов). Сбросить — /clear.`,
|
||||
`Архив чата: ${archive.count} сообщений из ${archive.limit} возможных${since}. Читаю его, когда нужно.`,
|
||||
`Знаю участников: ${members}.`,
|
||||
await visibilityText(ctx),
|
||||
@@ -49,12 +49,9 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService, d
|
||||
});
|
||||
|
||||
composer.command("clear", async (ctx) => {
|
||||
const forgotten = dialogService.clear(ctx.chat.id);
|
||||
await assistantService.clearDialog(ctx.chat.id);
|
||||
|
||||
await ctx.reply(
|
||||
`Контекст диалога сброшен (забыл ${forgotten} вопросов и ответов), начинаю с чистого листа. ` +
|
||||
"Архив переписки чата на месте.",
|
||||
);
|
||||
await ctx.reply("Контекст диалога сброшен, начинаю с чистого листа. Архив переписки чата на месте.");
|
||||
});
|
||||
|
||||
return composer;
|
||||
|
||||
@@ -77,7 +77,7 @@ export const createQuestionHandlers = ({
|
||||
repliedTo: repliedToMessage(msg),
|
||||
mentionedMembers: members,
|
||||
unknownUsernames,
|
||||
imageSources: imageRefs.map((ref) => ref.source),
|
||||
hasImages: imageRefs.length > 0,
|
||||
images,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/** Значение переменной окружения без пробелов; undefined — если не задана или пустая. */
|
||||
const envValue = (name: string): string | undefined => process.env[name]?.trim() || undefined;
|
||||
|
||||
@@ -89,6 +91,9 @@ const parseModel = (): ModelChoice => {
|
||||
/** Путь к SQLite; нужен боту и скриптам (`seed`, импорт), которым остальные переменные не нужны. */
|
||||
export const DB_PATH = envValue("DB_PATH") ?? "data/bot.db";
|
||||
|
||||
/** Хранилище Mastra Memory (контекст диалога) — отдельный файл рядом с основной базой. */
|
||||
export const MEMORY_DB_PATH = join(dirname(DB_PATH), "memory.db");
|
||||
|
||||
/** Сколько последних сообщений каждого чата хранить в базе; нужен боту и скрипту импорта. */
|
||||
export const HISTORY_LIMIT = positiveInt("HISTORY_LIMIT", 5000);
|
||||
|
||||
@@ -96,6 +101,8 @@ export const loadBotConfig = () => ({
|
||||
botToken: required("BOT_TOKEN"),
|
||||
adminIds: parseAdminIds(required("ADMIN_IDS")),
|
||||
ai: parseModel(),
|
||||
/** Сколько последних пар «вопрос — ответ» модель помнит в каждом чате. */
|
||||
dialogLimit: positiveInt("DIALOG_LIMIT", 10),
|
||||
/** Бюджет токенов на весь промпт; сверх него Mastra Memory срезает самые старые реплики диалога. */
|
||||
contextTokenLimit: positiveInt("CONTEXT_TOKEN_LIMIT", 12_000),
|
||||
/** Потолок длины ответа в токенах — длиннее Telegram всё равно режет на несколько сообщений. */
|
||||
answerTokenLimit: positiveInt("ANSWER_TOKEN_LIMIT", 1_500),
|
||||
});
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -1,16 +1,15 @@
|
||||
import { BOT_COMMANDS } from "./bot/commands.js";
|
||||
import { createBot } from "./bot/create-bot.js";
|
||||
import { DB_PATH, HISTORY_LIMIT, loadBotConfig } from "./config.js";
|
||||
import { DB_PATH, HISTORY_LIMIT, MEMORY_DB_PATH, 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 { createAssistantMemory } from "./services/assistant/memory.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";
|
||||
|
||||
@@ -33,11 +32,6 @@ const chatHistoryService = createChatHistoryService({ messageRepository, history
|
||||
|
||||
const chatMemberService = createChatMemberService({ chatMemberRepository });
|
||||
|
||||
const dialogService = createDialogService({
|
||||
dialogRepository: createDialogRepository(db),
|
||||
exchangesLimit: config.dialogLimit,
|
||||
});
|
||||
|
||||
const historyImportService = createHistoryImportService({
|
||||
messageRepository,
|
||||
chatMemberRepository,
|
||||
@@ -46,10 +40,11 @@ const historyImportService = createHistoryImportService({
|
||||
|
||||
const assistantService = createAssistantService({
|
||||
model: config.ai.model,
|
||||
memory: createAssistantMemory({ path: MEMORY_DB_PATH, maxTokens: config.contextTokenLimit }),
|
||||
imageInput: config.ai.imageInput,
|
||||
answerTokenLimit: config.answerTokenLimit,
|
||||
chatHistoryService,
|
||||
chatMemberService,
|
||||
dialogService,
|
||||
});
|
||||
|
||||
const bot = createBot({
|
||||
@@ -58,7 +53,6 @@ const bot = createBot({
|
||||
personaService,
|
||||
chatHistoryService,
|
||||
chatMemberService,
|
||||
dialogService,
|
||||
historyImportService,
|
||||
assistantService,
|
||||
});
|
||||
|
||||
@@ -35,17 +35,6 @@ 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 =>
|
||||
@@ -58,8 +47,13 @@ const migrate = (db: Db): void => {
|
||||
db.exec("CREATE INDEX IF NOT EXISTS messages_by_user ON messages (chat_id, user_id, message_id)");
|
||||
};
|
||||
|
||||
export const createDb = (path: string): Db => {
|
||||
/** Создаёт папку для файла базы; для `:memory:` ничего не делает. */
|
||||
export const ensureParentDir = (path: string): void => {
|
||||
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
||||
};
|
||||
|
||||
export const createDb = (path: string): Db => {
|
||||
ensureParentDir(path);
|
||||
|
||||
const db = new Database(path);
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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<typeof createDialogRepository>;
|
||||
@@ -1,58 +1,72 @@
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { ToolCallFilter } from "@mastra/core/processors";
|
||||
import { RequestContext } from "@mastra/core/request-context";
|
||||
import type { Memory } from "@mastra/memory";
|
||||
import { readPrompt } from "../../libs/prompts.js";
|
||||
import type { DialogTurn, Persona } from "../../types.js";
|
||||
import type { Persona } from "../../types.js";
|
||||
import type { ChatHistoryService } from "../chat-history-service.js";
|
||||
import type { ChatMemberService } from "../chat-member-service.js";
|
||||
import type { DialogService } from "../dialog-service.js";
|
||||
import { chatThreadId } from "./memory.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";
|
||||
import type { AssistantRequestContext } from "./tools/request-context.js";
|
||||
import { assistantRequestContextSchema, type AssistantRequestContext } from "./tools/request-context.js";
|
||||
|
||||
export type { Question } from "./prompts.js";
|
||||
|
||||
interface AssistantServiceDeps {
|
||||
model: string;
|
||||
/** Контекст диалога — Mastra Memory (см. memory.ts). */
|
||||
memory: Memory;
|
||||
/** Максимальная длина ответа в токенах. */
|
||||
answerTokenLimit: number;
|
||||
/** Умеет ли модель смотреть изображения; если нет — фото ей не отправляем. */
|
||||
imageInput: boolean;
|
||||
chatHistoryService: ChatHistoryService;
|
||||
chatMemberService: ChatMemberService;
|
||||
dialogService: DialogService;
|
||||
}
|
||||
|
||||
const MAX_AGENT_STEPS = 6;
|
||||
/** Хватает на «прочитать архив/участников → ответить»; каждый лишний шаг — ещё один полный промпт. */
|
||||
const MAX_AGENT_STEPS = 4;
|
||||
|
||||
/** Сообщение пользователя для модели: текст вопроса и скачанные картинки. */
|
||||
const buildUserMessage = (question: Question) => ({
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{ 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 };
|
||||
/**
|
||||
* Картинки уходят через `context`: модель видит их в этом запросе, но в память они не сохраняются —
|
||||
* иначе байты фото тащились бы в каждый следующий вопрос.
|
||||
*/
|
||||
const imagesContext = (question: Question) =>
|
||||
question.images.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })),
|
||||
},
|
||||
];
|
||||
|
||||
export const createAssistantService = ({
|
||||
model,
|
||||
memory,
|
||||
answerTokenLimit,
|
||||
imageInput,
|
||||
chatHistoryService,
|
||||
chatMemberService,
|
||||
dialogService,
|
||||
}: AssistantServiceDeps) => {
|
||||
const systemRules = readPrompt("system.md");
|
||||
|
||||
const agent = new Agent({
|
||||
id: "boltun",
|
||||
name: "Boltun",
|
||||
instructions: systemRules,
|
||||
requestContextSchema: assistantRequestContextSchema,
|
||||
// Одинаковы для всех вопросов чата — провайдер может кешировать этот префикс.
|
||||
instructions: ({ requestContext }) =>
|
||||
buildInstructions(systemRules, requestContext.get("persona"), requestContext.get("chatTitle")),
|
||||
model,
|
||||
memory,
|
||||
defaultOptions: { maxSteps: MAX_AGENT_STEPS, modelSettings: { maxOutputTokens: answerTokenLimit } },
|
||||
// Результаты инструментов (сотни сообщений архива) не должны ездить в каждый запрос:
|
||||
// первый фильтр убирает их из прошлых реплик, второй внутри ответа оставляет только самый свежий шаг.
|
||||
inputProcessors: [new ToolCallFilter(), new ToolCallFilter({ filterAfterToolSteps: 1 })],
|
||||
tools: {
|
||||
readChatMessages: createReadChatMessagesTool(chatHistoryService),
|
||||
listChatMembers: createListChatMembersTool(chatMemberService),
|
||||
@@ -61,32 +75,36 @@ export const createAssistantService = ({
|
||||
});
|
||||
|
||||
/**
|
||||
* Ответ на вопрос в образе личности. Контекст диалога (прошлые вопросы и ответы) подкладываем сами,
|
||||
* архив чата агент подгружает инструментами, когда нужно.
|
||||
* Ответ на вопрос в образе личности. Прошлые вопросы и ответы чата подтягивает Mastra Memory,
|
||||
* архив переписки агент читает инструментами, когда нужно.
|
||||
*/
|
||||
const answer = async (question: Question, persona: Persona): Promise<string> => {
|
||||
const requestContext = new RequestContext<AssistantRequestContext>();
|
||||
|
||||
requestContext.set("chatId", question.chatId);
|
||||
requestContext.set("chatTitle", question.chatTitle);
|
||||
requestContext.set("persona", persona);
|
||||
|
||||
const history = dialogService.recent(question.chatId).map(toModelMessage);
|
||||
const thread = chatThreadId(question.chatId);
|
||||
|
||||
const result = await agent.generate([...history, buildUserMessage(question)], {
|
||||
instructions: buildInstructions(systemRules, question, persona),
|
||||
const result = await agent.generate(buildUserText(question), {
|
||||
context: imagesContext(question),
|
||||
memory: { thread, resource: thread },
|
||||
requestContext,
|
||||
maxSteps: MAX_AGENT_STEPS,
|
||||
});
|
||||
|
||||
const text = result.text.trim();
|
||||
|
||||
// Картинки в контекст не кладём — только текст вопроса; пустой ответ не запоминаем.
|
||||
if (text) dialogService.remember(question.chatId, buildUserText(question), text);
|
||||
|
||||
return text;
|
||||
return result.text.trim();
|
||||
};
|
||||
|
||||
/** Сколько сообщений в контексте диалога чата: recall отдаёт `total`, не загружая весь тред. */
|
||||
const dialogSize = async (chatId: number): Promise<number> =>
|
||||
(await memory.recall({ threadId: chatThreadId(chatId), perPage: 1 })).total;
|
||||
|
||||
/** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */
|
||||
const clearDialog = (chatId: number): Promise<void> => memory.deleteThread(chatThreadId(chatId));
|
||||
|
||||
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */
|
||||
return { answer, canSeeImages: imageInput };
|
||||
return { answer, dialogSize, clearDialog, canSeeImages: imageInput };
|
||||
};
|
||||
|
||||
export type AssistantService = ReturnType<typeof createAssistantService>;
|
||||
|
||||
30
src/services/assistant/memory.ts
Normal file
30
src/services/assistant/memory.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { LibSQLStore } from "@mastra/libsql";
|
||||
import { Memory } from "@mastra/memory";
|
||||
import { ensureParentDir } from "../../libs/db.js";
|
||||
|
||||
interface AssistantMemoryOptions {
|
||||
path: string;
|
||||
/** Бюджет токенов на весь промпт: сверх него Mastra срезает самые старые реплики диалога. */
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекст диалога с ботом — Mastra Memory: тред на каждый чат, только история сообщений.
|
||||
* Архив переписки чата живёт отдельно (SQLite бота) и читается инструментами.
|
||||
*/
|
||||
export const createAssistantMemory = ({ path, maxTokens }: AssistantMemoryOptions): Memory => {
|
||||
ensureParentDir(path);
|
||||
|
||||
return new Memory({
|
||||
storage: new LibSQLStore({ id: "boltun-memory", url: `file:${path}` }),
|
||||
options: {
|
||||
messageHistory: { maxTokens },
|
||||
semanticRecall: false,
|
||||
workingMemory: { enabled: false },
|
||||
generateTitle: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Один тред на чат: вопросы разных админов и ответы бота — общий диалог (шаблон Mastra для групповых чатов). */
|
||||
export const chatThreadId = (chatId: number): string => `chat:${chatId}`;
|
||||
@@ -15,8 +15,8 @@ export interface Question {
|
||||
mentionedMembers: ChatMember[];
|
||||
/** Упомянутые ники, которых бот в чате не встречал. */
|
||||
unknownUsernames: string[];
|
||||
/** Где в вопросе есть картинки — даже если модели их не отправляем. */
|
||||
imageSources: ImageSource[];
|
||||
/** Есть ли в вопросе картинки — даже если модели их не отправляем. */
|
||||
hasImages: boolean;
|
||||
/** Скачанные картинки для модели; пусто, если модель их не видит или скачать не вышло. */
|
||||
images: ImageAttachment[];
|
||||
}
|
||||
@@ -27,45 +27,47 @@ const IMAGE_SOURCES: Record<ImageSource, string> = {
|
||||
};
|
||||
|
||||
/** Подсказка модели, откуда картинки — или что посмотреть их она не может. */
|
||||
const imagesNote = ({ imageSources, images }: Question): string | undefined => {
|
||||
if (imageSources.length === 0) return undefined;
|
||||
const imagesNote = ({ hasImages, images }: Question): string | undefined => {
|
||||
if (!hasImages) return undefined;
|
||||
|
||||
if (images.length === 0) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом.";
|
||||
|
||||
return `Приложены изображения (по порядку): ${images.map((image) => IMAGE_SOURCES[image.source]).join(", ")}.`;
|
||||
};
|
||||
|
||||
const mentionsSection = (question: Question): string | undefined => {
|
||||
const known = question.mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(question.mentionedMembers)}` : "";
|
||||
const mentionsNote = ({ mentionedMembers, unknownUsernames }: Question): string | undefined => {
|
||||
const known = mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(mentionedMembers)}` : "";
|
||||
|
||||
const unknown =
|
||||
question.unknownUsernames.length > 0
|
||||
? `Эти ники в чате не встречались: ${question.unknownUsernames.map((u) => `@${u}`).join(", ")}.`
|
||||
: "";
|
||||
unknownUsernames.length > 0 ? `Эти ники в чате не встречались: ${unknownUsernames.map((u) => `@${u}`).join(", ")}.` : "";
|
||||
|
||||
return [known, unknown].filter(Boolean).join("\n") || undefined;
|
||||
};
|
||||
|
||||
/** Системные правила бота из `prompts/system.md` + личность + контекст конкретного вопроса. */
|
||||
export const buildInstructions = (systemRules: string, question: Question, persona: Persona): string =>
|
||||
[
|
||||
`Твоя личность: ${persona.name}.\n${persona.prompt}`,
|
||||
systemRules,
|
||||
`Чат: «${question.chatTitle}». Вопрос задаёт ${question.askerName}.`,
|
||||
mentionsSection(question),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
/**
|
||||
* Системный промпт: одинаков для всех вопросов в чате (правила → личность → чат),
|
||||
* поэтому провайдер может его кешировать. Всё, что про конкретный вопрос, — в реплике пользователя.
|
||||
*/
|
||||
export const buildInstructions = (systemRules: string, persona: Persona, chatTitle: string): string =>
|
||||
[systemRules, `Твоя личность: ${persona.name}.\n${persona.prompt}`, `Чат: «${chatTitle}».`].join("\n\n");
|
||||
|
||||
export const buildPrompt = (question: Question): string => {
|
||||
const escapeAttr = (value: string): string =>
|
||||
value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
/**
|
||||
* Реплика пользователя в формате Mastra для общих тредов: `<turn author_name="…">`.
|
||||
* Тег сохраняется в памяти, поэтому и в следующих вопросах модель видит, кто что спрашивал.
|
||||
*/
|
||||
export const buildUserText = (question: Question): string => {
|
||||
const replied = question.repliedTo
|
||||
? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}`
|
||||
: undefined;
|
||||
|
||||
const asked = question.text || "(текста нет — прислали только изображение, опиши и прокомментируй его)";
|
||||
|
||||
return [replied, imagesNote(question), replied ? `Вопрос: ${asked}` : asked].filter(Boolean).join("\n\n");
|
||||
};
|
||||
const body = [replied, mentionsNote(question), imagesNote(question), replied ? `Вопрос: ${asked}` : asked]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
/** Текст реплики пользователя — с именем, потому что в чате боту пишут разные админы. */
|
||||
export const buildUserText = (question: Question): string => `${question.askerName}:\n${buildPrompt(question)}`;
|
||||
return `<turn author_name="${escapeAttr(question.askerName)}">\n${body}\n</turn>`;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Что каждый инструмент агента получает от бота: в каком чате задан вопрос. */
|
||||
export const assistantRequestContextSchema = z.object({ chatId: z.number() });
|
||||
/** Что агент и его инструменты получают от бота на каждый вопрос. */
|
||||
export const assistantRequestContextSchema = z.object({
|
||||
chatId: z.number(),
|
||||
chatTitle: z.string(),
|
||||
persona: z.object({ id: z.string(), name: z.string(), prompt: z.string() }),
|
||||
});
|
||||
|
||||
export type AssistantRequestContext = z.infer<typeof assistantRequestContextSchema>;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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<typeof createDialogService>;
|
||||
@@ -42,9 +42,3 @@ export interface ImageAttachment {
|
||||
mimeType: string;
|
||||
source: ImageSource;
|
||||
}
|
||||
|
||||
/** Реплика диалога с ботом: вопрос пользователя или ответ бота. */
|
||||
export interface DialogTurn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user