Skip to content

Commit 653aeac

Browse files
authored
fix(ai): load source context server-side (#66)
Send only persisted source IDs, load authenticated source content in the AI Edge Function, and enforce UTF-8 request budgets without silently truncating the current Ask message.
1 parent 6eb5bed commit 653aeac

14 files changed

Lines changed: 289 additions & 102 deletions

‎docs/architecture.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ Client-side environment selection is a developer convenience, not authorization.
5151

5252
1. A feature page validates input and calls a feature wrapper.
5353
2. The client reads the current Supabase session.
54-
3. It invokes `ai` or `ai-stream` with selected controls and active source context.
55-
4. The Edge Function validates authentication, input bounds, supported model and control combinations, access limits, and feature-specific rules.
54+
3. It invokes `ai` or `ai-stream` with selected controls, stable active-source IDs, and a bounded recent Ask history when needed.
55+
4. The Edge Function validates authentication, loads active personal sources by authenticated user and ID, then enforces input bounds, supported model and control combinations, access limits, and feature-specific rules.
5656
5. The backend optionally gathers research, builds prompts, calls an allowlisted model through OpenRouter, validates output, and records the provider-reported usage and charge.
5757
6. The client validates or transforms the response and updates the relevant store.
5858

@@ -68,7 +68,9 @@ Shared research, source ranking, URL validation, and prompt construction live un
6868

6969
The import endpoint validates public HTTP URLs, resolves redirect targets, blocks private networks, and enforces response timeout, content type, and size limits. Research paths bound source count and content before prompt construction.
7070

71-
Authenticated AI requests carry stable source IDs only for user-scoped assessment updates; prompt construction still receives bounded title, content, origin, and type fields. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration.
71+
Authenticated personal AI requests carry stable source IDs instead of source content. The Edge Function loads matching active rows for the authenticated user, bounds their title, content, origin, and type, then passes that context to ranking and prompt construction. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration.
72+
73+
Ordinary Ask remains stateless while a conversation is in progress. The browser sends only the newest eight messages within a UTF-8 byte budget. Pressing Save persists the chat and its messages in Supabase; Workspace threads use their separate durable server-side context path. Legacy local-only sources are not sent to the authenticated AI endpoint because they have no persisted UUID to authorize and load.
7274

7375
## Database boundary
7476

‎src/lib/ai/client.ts‎

Lines changed: 62 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,14 @@ import type { WorkspaceToolContext } from '@/lib/workspace/tool-context';
4545
export const REQUEST_TIMEOUT_MS = 200 * 1000;
4646
export const CROSSFIRE_REQUEST_TIMEOUT_MS = 75 * 1000;
4747
const MAX_CLIENT_ERROR_BODY_BYTES = 16_000;
48-
const MAX_CROSSFIRE_SOURCE_COUNT = 10;
49-
const MAX_CROSSFIRE_SOURCE_CHARS = 8_000;
50-
const MAX_CROSSFIRE_SOURCE_TOTAL_CHARS = 24_000;
48+
const MAX_CLIENT_REQUEST_BODY_BYTES = 190_000;
49+
const MAX_REQUEST_SOURCE_IDS = 10;
50+
const MAX_ASK_HISTORY_ITEMS = 8;
51+
const MAX_ASK_HISTORY_CONTENT_BYTES = 6_000;
52+
const MAX_ASK_HISTORY_TOTAL_BYTES = 48_000;
53+
const MAX_ASK_MESSAGE_BYTES = 80_000;
54+
const MAX_CUSTOM_SYSTEM_PROMPT_BYTES = 32_000;
55+
const SOURCE_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5156

5257
interface AIRequestOptions<T = unknown> {
5358
includeActiveSources?: boolean;
@@ -67,6 +72,45 @@ export function withOutputLanguage(input: Record<string, unknown>): Record<strin
6772
return { ...input, language: selectedOutputLanguage() };
6873
}
6974

75+
function utf8ByteLength(value: string): number {
76+
return new TextEncoder().encode(value).byteLength;
77+
}
78+
79+
function truncateUtf8(value: string, maxBytes: number): string {
80+
const encoded = new TextEncoder().encode(value);
81+
if (encoded.byteLength <= maxBytes) return value;
82+
const decoder = new TextDecoder('utf-8', { fatal: true });
83+
for (let end = maxBytes; end > Math.max(0, maxBytes - 4); end -= 1) {
84+
try {
85+
return decoder.decode(encoded.subarray(0, end));
86+
} catch {
87+
// Move to the prior complete UTF-8 code point.
88+
}
89+
}
90+
return '';
91+
}
92+
93+
export function boundAskMessage(message: string): string {
94+
if (!isAskMessageWithinBudget(message)) {
95+
throw new AIError('Message is too large. Shorten it before sending.', { statusCode: 413 });
96+
}
97+
return message;
98+
}
99+
100+
export function isAskMessageWithinBudget(message: string): boolean {
101+
return utf8ByteLength(message) <= MAX_ASK_MESSAGE_BYTES;
102+
}
103+
104+
export function boundAskHistory(history: { role: 'user' | 'assistant'; content: string }[]) {
105+
let remaining = MAX_ASK_HISTORY_TOTAL_BYTES;
106+
return history.slice(-MAX_ASK_HISTORY_ITEMS).reverse().flatMap((message) => {
107+
if (remaining <= 0) return [];
108+
const content = truncateUtf8(message.content, Math.min(MAX_ASK_HISTORY_CONTENT_BYTES, remaining));
109+
remaining -= utf8ByteLength(content);
110+
return content ? [{ ...message, content }] : [];
111+
}).reverse();
112+
}
113+
70114
function waitForAbortable<T>(operation: () => PromiseLike<T>, signal: AbortSignal): Promise<T> {
71115
if (signal.aborted) return Promise.reject(new DOMException('The request was canceled.', 'AbortError'));
72116
return new Promise<T>((resolve, reject) => {
@@ -76,29 +120,6 @@ function waitForAbortable<T>(operation: () => PromiseLike<T>, signal: AbortSigna
76120
});
77121
}
78122

79-
interface AIRequestSource {
80-
id?: string;
81-
title: string;
82-
content: string;
83-
origin?: string;
84-
type: string;
85-
metadata?: SourceProvenance;
86-
}
87-
88-
function boundCrossfireRequestSources(sources: AIRequestSource[]): AIRequestSource[] {
89-
let remaining = MAX_CROSSFIRE_SOURCE_TOTAL_CHARS;
90-
return sources.slice(0, MAX_CROSSFIRE_SOURCE_COUNT).flatMap((source) => {
91-
if (remaining <= 0) return [];
92-
const title = source.title.trim().slice(0, 240);
93-
const content = source.content.trim().slice(0, Math.min(MAX_CROSSFIRE_SOURCE_CHARS, remaining));
94-
const type = source.type.trim().slice(0, 64);
95-
if (!title || !content || !type) return [];
96-
remaining -= content.length;
97-
const origin = source.origin?.trim().slice(0, 240);
98-
return [{ title, content, type, ...(origin ? { origin } : {}) }];
99-
});
100-
}
101-
102123
/** A web source discovered during research, with auditable ranking metadata from the edge function. */
103124
export interface DiscoveredSource extends Omit<SourceProvenance, 'url'> {
104125
title: string;
@@ -425,21 +446,13 @@ async function callSupabaseAI<T = unknown>(
425446
const settings = useSettingsStore.getState();
426447
const session = useSessionStore.getState();
427448

428-
const selectedSources = options.workspaceContext || options.includeActiveSources === false || feature === 'sparring'
449+
const sourceIds = options.workspaceContext || options.includeActiveSources === false || feature === 'sparring'
429450
? []
430451
: session.sourceLibrary
431452
.filter((s) => s.isActive)
432-
.map((s) => ({
433-
...(s.id ? { id: s.id } : {}),
434-
title: s.title,
435-
content: s.content,
436-
...(s.origin ? { origin: s.origin } : {}),
437-
type: s.type,
438-
...(s.metadata ? { metadata: s.metadata } : {}),
439-
}));
440-
const activeSources = feature === 'crossfire'
441-
? boundCrossfireRequestSources(selectedSources)
442-
: selectedSources;
453+
.map((s) => s.id)
454+
.filter((id) => SOURCE_UUID_PATTERN.test(id))
455+
.slice(0, MAX_REQUEST_SOURCE_IDS);
443456

444457
const provider = 'openrouter';
445458
captureProductEvent('ai_request_started', {
@@ -479,7 +492,7 @@ async function callSupabaseAI<T = unknown>(
479492
provider,
480493
model: config.model,
481494
hasAccessToken: !!accessToken,
482-
activeSources: activeSources.length,
495+
activeSources: sourceIds.length,
483496
});
484497
}
485498

@@ -494,11 +507,17 @@ async function callSupabaseAI<T = unknown>(
494507
model: config.model,
495508
temperature: config.temperature,
496509
reasoningEffort: config.reasoningEffort ?? 'low',
497-
customSystemPrompt: options.workspaceContext || options.includeCustomSystemPrompt === false ? null : settings.customSystemPrompt || null,
498-
sources: activeSources,
510+
customSystemPrompt: options.workspaceContext || options.includeCustomSystemPrompt === false
511+
? null
512+
: settings.customSystemPrompt ? truncateUtf8(settings.customSystemPrompt, MAX_CUSTOM_SYSTEM_PROMPT_BYTES) : null,
513+
sourceIds,
499514
workspaceContext: options.workspaceContext,
500515
};
501516

517+
if (utf8ByteLength(JSON.stringify(requestBody)) > MAX_CLIENT_REQUEST_BODY_BYTES) {
518+
throw new AIError('Request is too large. Shorten the input and try again.', { statusCode: 413, requestId });
519+
}
520+
502521
const { data, error } = await invokeAuthenticatedFunction<Record<string, unknown>>('ai', {
503522
headers: { Authorization: `Bearer ${accessToken}` },
504523
body: requestBody,
@@ -1309,8 +1328,8 @@ export async function askChat(
13091328
options: AskChatOptions = {}
13101329
): Promise<AskOutput> {
13111330
return await callSupabaseAI<AskOutput>('ask', withOutputLanguage({
1312-
message,
1313-
history,
1331+
message: boundAskMessage(message),
1332+
history: boundAskHistory(history),
13141333
mode,
13151334
citationMode: Boolean(options.citationMode),
13161335
webSearch: Boolean(options.webSearch),

‎src/lib/ai/streaming.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client';
22
import { captureProductEvent } from '@/lib/observability';
3-
import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, mapAIError, reportAIFailure, withOutputLanguage } from './client';
3+
import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, boundAskHistory, boundAskMessage, mapAIError, reportAIFailure, withOutputLanguage } from './client';
44
import type { ProviderConfig } from './providers/types';
55
import { AskOutputSchema, type AskOutput } from './schemas/ask';
66

@@ -193,8 +193,8 @@ export async function streamAskChat(
193193
body: JSON.stringify({
194194
feature: 'ask',
195195
input: withOutputLanguage({
196-
message,
197-
history,
196+
message: boundAskMessage(message),
197+
history: boundAskHistory(history),
198198
mode,
199199
citationMode: Boolean(options.citationMode),
200200
webSearch: Boolean(options.webSearch),

‎src/locales/en.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@
324324
"timeoutDesc": "The request took too long. Please try again.",
325325
"saved": "Saved",
326326
"savedDesc": "Source added to library.",
327+
"inputTooLargeTitle": "Message is too large",
328+
"inputTooLargeDesc": "Remove some attachment text or shorten your question before sending.",
327329
"streamInterrupted": "The streamed response was interrupted before completion.",
328330
"streamInterruptedDesc": "You can ask again to get a complete answer."
329331
},

‎src/locales/es.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@
324324
"timeoutDesc": "La solicitud tardó demasiado. Inténtalo de nuevo.",
325325
"saved": "Guardado",
326326
"savedDesc": "Fuente añadida a la biblioteca.",
327+
"inputTooLargeTitle": "El mensaje es demasiado grande",
328+
"inputTooLargeDesc": "Elimina parte del texto adjunto o acorta la pregunta antes de enviarla.",
327329
"streamInterrupted": "La respuesta en streaming se interrumpió antes de completarse.",
328330
"streamInterruptedDesc": "Puedes preguntar de nuevo para obtener una respuesta completa."
329331
},

‎src/locales/hu.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@
324324
"timeoutDesc": "A kérés túl sokáig tartott. Próbáld újra.",
325325
"saved": "Mentve",
326326
"savedDesc": "Forrás hozzáadva a könyvtárhoz.",
327+
"inputTooLargeTitle": "Az üzenet túl nagy",
328+
"inputTooLargeDesc": "Küldés előtt távolíts el néhány csatolt szöveget, vagy rövidítsd le a kérdést.",
327329
"streamInterrupted": "A streamelt válasz a befejezés előtt megszakadt.",
328330
"streamInterruptedDesc": "Kérdezz újra a teljes válaszért."
329331
},

‎src/pages/AskPage.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
33
import { useSessionStore } from '@/lib/stores/session-store';
44
import { useSettingsStore } from '@/lib/stores/settings-store';
55
import { AskChat } from '@/components/features/ask/AskChat';
6-
import { askChat, isTimeoutError } from '@/lib/ai/client';
6+
import { askChat, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client';
77
import { isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming';
88
import { useSubscriptionError } from '@/hooks/use-subscription-error';
99
import { getUserFacingAIError } from '@/lib/ai/error-display';
@@ -110,7 +110,7 @@ export default function AskPage() {
110110

111111
const history = workspace.context ? [] : askMessages.map(m => ({ role: m.role, content: m.content }));
112112
const fullMessage = fileContext ? `CONTEXT:\n${fileContext}\n\nQUESTION: ${message}` : message;
113-
if (fullMessage.length > MAX_AI_INPUT_LENGTH) {
113+
if (fullMessage.length > MAX_AI_INPUT_LENGTH || !isAskMessageWithinBudget(fullMessage)) {
114114
toast({
115115
title: t('ask.inputTooLargeTitle', 'Message is too large'),
116116
description: t('ask.inputTooLargeDesc', 'Remove some attachment text or shorten your question before sending.'),

‎src/test/ai/client-branches.test.ts‎

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const {
1616
const applySourceAssessmentsMock = vi.fn();
1717
const sessionState = {
1818
sourceLibrary: [
19-
{ title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true },
20-
{ title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false },
19+
{ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true },
20+
{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false },
2121
],
2222
addSource: addSourceMock,
2323
applySourceAssessments: applySourceAssessmentsMock,
@@ -120,8 +120,8 @@ describe('AI client branch coverage', () => {
120120
beforeEach(() => {
121121
vi.clearAllMocks();
122122
sessionState.sourceLibrary = [
123-
{ title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true },
124-
{ title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false },
123+
{ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true },
124+
{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false },
125125
];
126126
getSessionMock.mockResolvedValue({
127127
data: { session: { access_token: 'token-123' } },
@@ -275,18 +275,19 @@ describe('AI client branch coverage', () => {
275275
});
276276

277277
it('sends stable source IDs and applies returned semantic assessments', async () => {
278-
sessionState.sourceLibrary[0] = { ...sessionState.sourceLibrary[0], id: 'source-active', metadata: { qualityLabel: 'strong' } } as any;
278+
const sourceId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
279+
sessionState.sourceLibrary[0] = { ...sessionState.sourceLibrary[0], id: sourceId, metadata: { qualityLabel: 'strong' } } as any;
279280
invokeMock.mockResolvedValueOnce({
280281
data: {
281282
output: validGenerateOutput,
282-
sourceAssessments: [{ key: 'source-active', id: 'source-active', relevance: 0.9, importance: 3, strength: 2, evaluatedAt: '2026-09-21T00:00:00.000Z', retained: true, usedInCase: true }],
283+
sourceAssessments: [{ key: sourceId, id: sourceId, relevance: 0.9, importance: 3, strength: 2, evaluatedAt: '2026-09-21T00:00:00.000Z', retained: true, usedInCase: true }],
283284
},
284285
error: null,
285286
});
286287

287288
await generateCase(validGenerateInput, config);
288-
expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ sources: [expect.objectContaining({ id: 'source-active' })] }) }));
289-
expect(sessionState.applySourceAssessments).toHaveBeenCalledWith([expect.objectContaining({ id: 'source-active', usedInCase: true })]);
289+
expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ sourceIds: [sourceId] }) }));
290+
expect(sessionState.applySourceAssessments).toHaveBeenCalledWith([expect.objectContaining({ id: sourceId, usedInCase: true })]);
290291
});
291292

292293
it('does not autosave a semantically irrelevant discovered source', async () => {
@@ -831,14 +832,7 @@ describe('AI client branch coverage', () => {
831832
body: expect.objectContaining({
832833
provider: 'openrouter',
833834
customSystemPrompt: 'custom-system-prompt',
834-
sources: [
835-
{
836-
title: 'Active source',
837-
content: 'Useful evidence',
838-
origin: 'https://example.com',
839-
type: 'url',
840-
},
841-
],
835+
sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
842836
}),
843837
}));
844838
});

‎src/test/ai/client.test.ts‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ const clientMocks = vi.hoisted(() => ({
1919
},
2020
session: {
2121
sourceLibrary: [
22-
{ title: 'Source 1', content: 'Content 1', origin: 'url1', type: 'web', isActive: true },
23-
{ title: 'Source 2', content: 'Content 2', origin: 'url2', type: 'web', isActive: false },
22+
{ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Source 1', content: 'Content 1', origin: 'url1', type: 'web', isActive: true },
23+
{ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Source 2', content: 'Content 2', origin: 'url2', type: 'web', isActive: false },
2424
],
2525
addSource: vi.fn(),
2626
},
@@ -121,15 +121,12 @@ describe('AI Client Integration', () => {
121121
temperature: 0.8,
122122
input: expect.objectContaining(input),
123123
customSystemPrompt: 'test-system-prompt',
124-
sources: expect.arrayContaining([
125-
expect.objectContaining({ title: 'Source 1' }) // Only active source
126-
])
124+
sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
127125
}),
128126
}));
129127

130-
// Verify sources count
131128
const callArgs = (supabaseMock.functions.invoke.mock.calls[0] as any[])[1];
132-
expect(callArgs.body.sources).toHaveLength(1);
129+
expect(callArgs.body.sources).toBeUndefined();
133130
});
134131

135132
it('sends the configured reasoning effort in the non-streaming request', async () => {
@@ -293,7 +290,7 @@ describe('AI Client Integration', () => {
293290
await askChat('Question', [], 'general', mockConfig, { workspaceContext });
294291

295292
expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({
296-
body: expect.objectContaining({ customSystemPrompt: null, sources: [], workspaceContext }),
293+
body: expect.objectContaining({ customSystemPrompt: null, sourceIds: [], workspaceContext }),
297294
}));
298295
});
299296

0 commit comments

Comments
 (0)