diff --git a/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts b/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts index 3a2b429a..beaaf721 100644 --- a/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts @@ -82,3 +82,82 @@ describe('buildSystemPrompt role layering', () => { expect(prompt).toContain(SAFETY_MARKER); }); }); + +/** + * Unit tests for the "current page" context injected by buildSystemPrompt. When + * the client supplies an openedPage with a non-blank id, a CONTEXT line names + * the page (title or "Untitled") and its pageId so the agent can resolve "this + * page". When no usable id is present, nothing is added. The line always sits + * inside the safety sandwich, before the trailing SAFETY copy. + */ +describe('buildSystemPrompt current-page context', () => { + const workspace = { name: 'Acme' } as unknown as Workspace; + const SAFETY_MARKER = 'Operating rules (always in effect)'; + + it('includes the page title and pageId when both are present', () => { + const prompt = buildSystemPrompt({ + workspace, + openedPage: { id: 'pg-123', title: 'Audio Tract' }, + }); + expect(prompt).toContain('currently viewing the page'); + expect(prompt).toContain('pageId: pg-123'); + expect(prompt).toContain('"Audio Tract"'); + }); + + it('falls back to "Untitled" when the title is missing', () => { + const prompt = buildSystemPrompt({ + workspace, + openedPage: { id: 'pg-123' }, + }); + expect(prompt).toContain('pageId: pg-123'); + expect(prompt).toContain('"Untitled"'); + }); + + it('falls back to "Untitled" when the title is only whitespace', () => { + const prompt = buildSystemPrompt({ + workspace, + openedPage: { id: 'pg-123', title: ' ' }, + }); + expect(prompt).toContain('pageId: pg-123'); + expect(prompt).toContain('"Untitled"'); + }); + + it('adds no page context when openedPage is null', () => { + const prompt = buildSystemPrompt({ workspace, openedPage: null }); + expect(prompt).not.toContain('currently viewing the page'); + expect(prompt).not.toContain('pageId:'); + }); + + it('adds no page context when openedPage is omitted', () => { + const prompt = buildSystemPrompt({ workspace }); + expect(prompt).not.toContain('currently viewing the page'); + expect(prompt).not.toContain('pageId:'); + }); + + it('adds no page context when openedPage has no id', () => { + const prompt = buildSystemPrompt({ workspace, openedPage: { title: 'x' } }); + expect(prompt).not.toContain('currently viewing the page'); + expect(prompt).not.toContain('pageId:'); + }); + + it('adds no page context when the id is only whitespace', () => { + const prompt = buildSystemPrompt({ + workspace, + openedPage: { id: ' ' }, + }); + expect(prompt).not.toContain('currently viewing the page'); + expect(prompt).not.toContain('pageId:'); + }); + + it('places the page context inside the safety sandwich (before the closing SAFETY)', () => { + const prompt = buildSystemPrompt({ + workspace, + openedPage: { id: 'pg-123', title: 'Audio Tract' }, + }); + const pageIdx = prompt.indexOf('currently viewing the page'); + const firstSafety = prompt.indexOf(SAFETY_MARKER); + const lastSafety = prompt.lastIndexOf(SAFETY_MARKER); + expect(pageIdx).toBeGreaterThan(firstSafety); + expect(pageIdx).toBeLessThan(lastSafety); + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index 038e2544..33b4b86a 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -12,6 +12,7 @@ import { loadDocmostMcp, type DocmostClientLike, } from './docmost-client.loader'; +import { resolveCurrentPageResult } from './current-page.util'; /** * Per-user, per-request adapter that exposes Docmost READ operations to the @@ -222,14 +223,7 @@ export class AiChatToolsService { 'or null if the user is not currently on a page. Call this first whenever ' + 'the user refers to the current page without giving an explicit id.', inputSchema: z.object({}), - execute: async () => { - if (!openedPage?.id) { - return { page: null }; - } - return { - page: { id: openedPage.id, title: openedPage.title ?? '' }, - }; - }, + execute: async () => resolveCurrentPageResult(openedPage), }), getPage: tool({ diff --git a/apps/server/src/core/ai-chat/tools/current-page.util.spec.ts b/apps/server/src/core/ai-chat/tools/current-page.util.spec.ts new file mode 100644 index 00000000..d0649773 --- /dev/null +++ b/apps/server/src/core/ai-chat/tools/current-page.util.spec.ts @@ -0,0 +1,43 @@ +import { resolveCurrentPageResult } from './current-page.util'; + +/** + * Unit tests for resolveCurrentPageResult (pure function). Mirrors the + * getCurrentPage tool's contract: { page: null } when no page is open (no id), + * otherwise { page: { id, title } } with title defaulting to ''. + */ +describe('resolveCurrentPageResult', () => { + it('returns { page: null } when openedPage is undefined', () => { + expect(resolveCurrentPageResult(undefined)).toEqual({ page: null }); + }); + + it('returns { page: null } when openedPage is null', () => { + expect(resolveCurrentPageResult(null)).toEqual({ page: null }); + }); + + it('returns { page: null } when openedPage has no id', () => { + expect(resolveCurrentPageResult({})).toEqual({ page: null }); + expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null }); + }); + + it('returns { page: null } when id is an empty string', () => { + expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null }); + }); + + it('returns the page id and title when both are present', () => { + expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({ + page: { id: 'p1', title: 'Hello' }, + }); + }); + + it('defaults title to "" when it is missing', () => { + expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({ + page: { id: 'p1', title: '' }, + }); + }); + + it('keeps an explicit empty-string title as ""', () => { + expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({ + page: { id: 'p1', title: '' }, + }); + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/current-page.util.ts b/apps/server/src/core/ai-chat/tools/current-page.util.ts new file mode 100644 index 00000000..0ced2492 --- /dev/null +++ b/apps/server/src/core/ai-chat/tools/current-page.util.ts @@ -0,0 +1,21 @@ +export interface CurrentPageInput { + id?: string; + title?: string; +} + +export interface CurrentPageResult { + page: { id: string; title: string } | null; +} + +// Resolve the "current page" tool result from the client-supplied open-page +// context. Returns { page: null } when no page is open (no id), otherwise the +// page id + title (title defaults to '' when absent). Mirrors the getCurrentPage +// tool's contract so it can be unit-tested without the ESM Docmost client. +export function resolveCurrentPageResult( + openedPage?: CurrentPageInput | null, +): CurrentPageResult { + if (!openedPage?.id) { + return { page: null }; + } + return { page: { id: openedPage.id, title: openedPage.title ?? '' } }; +} diff --git a/docs/backlog/ai-chat-current-page-fragile.md b/docs/backlog/ai-chat-current-page-fragile.md deleted file mode 100644 index cd5feff2..00000000 --- a/docs/backlog/ai-chat-current-page-fragile.md +++ /dev/null @@ -1,129 +0,0 @@ -# Хрупкая передача «текущей страницы» в AI-агента - -Контекст: агент не понимает «эта/текущая страница». В разговоре через -CLIProxyAPI он отвечает «я не вижу текущую страницу» и просит уточнить -id/название. Пользователь сообщает: **без CLIProxyAPI (прямой эндпоинт) -работает**. То есть проблема воспроизводится на прокси-пути, но сама -механика передачи страницы хрупкая по двум независимым причинам (см. ниже), -поэтому фиксируем в беклоге целиком. - -## Как сейчас инжектится текущая страница (цепочка) - -Страница передаётся **только текстом в системный промпт** — отдельной -строкой. Это единственная точка, где агент узнаёт pageId «этой страницы». -Нет ни инструмента «get current page», ни поля в user-сообщении. - -1. Клиент вычисляет `openPage` из роута: - `apps/client/src/features/ai-chat/components/ai-chat-window.tsx:124-131` - — `const { pageSlug } = useParams();` → - `usePageQuery({ pageId: extractPageSlugId(pageSlug) })` → - `openPage = openPageData ? { id, title } : null`. Передаётся в `ChatThread` - (`:391`). -2. Транспорт кладёт `openPage` в тело запроса: - `apps/client/src/features/ai-chat/components/chat-thread.tsx:107-127` - (`prepareSendMessagesRequest`, поле на `:121`), POST `/api/ai-chat/stream`. -3. Контроллер читает тело СЫРЫМ (намеренно без DTO, чтобы глобальный - `ValidationPipe { whitelist: true }` не выкинул незадекларированное поле): - `apps/server/src/core/ai-chat/ai-chat.controller.ts:103-135` - (`const body = (req.body ?? {}) as AiChatStreamBody;`). -4. Сервис прокидывает `body.openPage` → `openedPage`: - `apps/server/src/core/ai-chat/ai-chat.service.ts:146-149` - (тип поля — `:32`, `openPage?: { id?; title? } | null`). -5. `buildSystemPrompt` дописывает строку контекста в системный промпт: - `apps/server/src/core/ai-chat/ai-chat.prompt.ts:94-101` - — `The user is currently viewing the page "