Skip to content

Commit dc22d8a

Browse files
committed
Fix AI request time context
1 parent 73811c7 commit dc22d8a

7 files changed

Lines changed: 381 additions & 67 deletions

File tree

server/route/v2/ai.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ async function streamToolPlannedChatResponse(
100100
limit: body?.limit,
101101
excludeIds: body?.excludeIds,
102102
history: body?.history,
103+
clientContext: body?.clientContext,
103104
enableThinking: body?.enableThinking === true,
104105
onPlanThinkingDelta: async (text) => {
105106
await writeSseEvent(stream, 'thinking', { phase: 'retrieval_planning', text });
@@ -366,12 +367,14 @@ aiRouter.post('/chat', authenticateJWT, bodyTypeCheck, async (c: HonoContext) =>
366367
limit: body?.limit,
367368
excludeIds: body?.excludeIds,
368369
history: body?.history,
370+
clientContext: body?.clientContext,
369371
})
370372
: {
371373
answer: await createDirectSiteChat({
372374
userId: user.id,
373375
message,
374376
history: body?.history,
377+
clientContext: body?.clientContext,
375378
enableThinking: body?.enableThinking === true,
376379
}),
377380
sources: [],
@@ -409,6 +412,7 @@ aiRouter.post('/agent/stream', authenticateJWT, bodyTypeCheck, async (c: HonoCon
409412
history: body?.history,
410413
state: body?.state,
411414
selectedContext: body?.selectedContext,
415+
clientContext: body?.clientContext,
412416
debug: body?.debug,
413417
limit: body?.limit,
414418
previousPlan: body?.previousPlan,
@@ -455,6 +459,7 @@ aiRouter.post('/chat/stream', authenticateJWT, bodyTypeCheck, async (c: HonoCont
455459
userId: user.id,
456460
message,
457461
history: body?.history,
462+
clientContext: body?.clientContext,
458463
enableThinking: body?.enableThinking === true,
459464
onReasoning: (text) => writeSseEvent(stream, 'thinking', { phase: 'answer', text }),
460465
onContent: (text) => writeSseEvent(stream, 'delta', { text }),

server/tests/aiRetrievalPlanner.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ describe('canonicalizeSearchRotesArgs', () => {
133133
expect(Date.parse(range?.from || '')).toBeLessThan(Date.parse(range?.to || ''));
134134
});
135135

136+
it('anchors relative dates to the client request date', () => {
137+
const range = canonicalizeTimeRange({ timeExpression: '上月' }, undefined, {
138+
nowIso: '2026-07-07T14:14:35.000Z',
139+
localDate: '2026-07-07',
140+
localDateTime: '2026-07-07T22:14:35+08:00',
141+
timeZone: 'Asia/Shanghai',
142+
utcOffsetMinutes: 480,
143+
});
144+
145+
expect(range).toMatchObject({
146+
from: '2026-06-01T00:00:00+08:00',
147+
to: '2026-06-30T23:59:59+08:00',
148+
label: '上个月',
149+
});
150+
});
151+
136152
it('prefers structured timeRange DSL over free-text fields', () => {
137153
expect(
138154
canonicalizeTimeRange({

server/utils/ai/agent/runtime.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getNativeRoteTools } from './tools';
1010
import {
1111
AgentToolCallingUnavailableError,
1212
DEFAULT_AGENT_POLICY,
13+
type RoteAgentClientContext,
1314
type RoteAgentClientState,
1415
type RoteAgentContext,
1516
type RoteAgentEmitter,
@@ -28,6 +29,42 @@ function sourceKey(source: SemanticSearchResult): string {
2829
return `${source.sourceType}:${source.sourceId}`;
2930
}
3031

32+
function asRecord(value: unknown): Record<string, unknown> {
33+
return value && typeof value === 'object' && !Array.isArray(value)
34+
? (value as Record<string, unknown>)
35+
: {};
36+
}
37+
38+
function sanitizeString(value: unknown, maxLength: number): string | undefined {
39+
if (typeof value !== 'string') return undefined;
40+
const trimmed = value.trim();
41+
return trimmed ? trimmed.slice(0, maxLength) : undefined;
42+
}
43+
44+
function sanitizeUtcOffsetMinutes(value: unknown): number | undefined {
45+
const numeric = Number(value);
46+
if (!Number.isFinite(numeric)) return undefined;
47+
const minutes = Math.trunc(numeric);
48+
return minutes >= -14 * 60 && minutes <= 14 * 60 ? minutes : undefined;
49+
}
50+
51+
function sanitizeClientContext(value: unknown): RoteAgentClientContext | null {
52+
const raw = asRecord(value);
53+
if (!Object.keys(raw).length) return null;
54+
55+
const context: RoteAgentClientContext = {
56+
nowIso: sanitizeString(raw.nowIso, 64),
57+
localDate: sanitizeString(raw.localDate, 32),
58+
localDateTime: sanitizeString(raw.localDateTime, 64),
59+
timeZone: sanitizeString(raw.timeZone, 80),
60+
utcOffsetMinutes: sanitizeUtcOffsetMinutes(raw.utcOffsetMinutes),
61+
locale: sanitizeString(raw.locale, 32),
62+
calendar: sanitizeString(raw.calendar, 32),
63+
};
64+
65+
return Object.values(context).some((item) => item !== undefined) ? context : null;
66+
}
67+
3168
class SourceCollector {
3269
private sources: SemanticSearchResult[] = [];
3370
private indexByKey = new Map<string, number>();
@@ -68,6 +105,8 @@ function sanitizeAgentState(request: RoteAgentRequest): RoteAgentClientState {
68105
previousPlan: state.previousPlan || request.previousPlan || null,
69106
seenSourceIds,
70107
selectedContext: state.selectedContext || request.selectedContext || null,
108+
clientContext:
109+
sanitizeClientContext(state.clientContext) || sanitizeClientContext(request.clientContext),
71110
stateVersion: Number.isFinite(state.stateVersion) ? state.stateVersion : 1,
72111
};
73112
}
@@ -134,13 +173,51 @@ async function logChatUsage(userId: string, model: string, usage: any): Promise<
134173
});
135174
}
136175

137-
function buildInitialMessages(request: RoteAgentRequest): ChatMessage[] {
176+
function buildRequestTimeContextMessage(state: RoteAgentClientState): ChatMessage {
177+
const clientContext = state.clientContext;
178+
const lines = [
179+
'## Current request time context',
180+
`Server now (UTC): ${new Date().toISOString()}`,
181+
];
182+
183+
if (clientContext) {
184+
if (clientContext.nowIso) lines.push(`Client now (UTC): ${clientContext.nowIso}`);
185+
if (clientContext.localDate) lines.push(`Client local date: ${clientContext.localDate}`);
186+
if (clientContext.localDateTime) {
187+
lines.push(`Client local date/time: ${clientContext.localDateTime}`);
188+
}
189+
if (clientContext.timeZone) lines.push(`Client time zone: ${clientContext.timeZone}`);
190+
if (typeof clientContext.utcOffsetMinutes === 'number') {
191+
lines.push(`Client UTC offset minutes: ${clientContext.utcOffsetMinutes}`);
192+
}
193+
if (clientContext.locale) lines.push(`Client locale: ${clientContext.locale}`);
194+
if (clientContext.calendar) lines.push(`Client calendar: ${clientContext.calendar}`);
195+
} else {
196+
lines.push(
197+
'Client time context was not supplied; use server now and Asia/Shanghai for Rote date ranges.'
198+
);
199+
}
200+
201+
lines.push(
202+
'Resolve relative date phrases such as today, yesterday, this month, last month, 最近, 本月, and 上月 using this context.',
203+
'When calling rote_search_notes for a relative date phrase, pass the phrase as timeExpression instead of inventing absolute from/to dates.',
204+
'Use from/to only when the user explicitly provides absolute dates.'
205+
);
206+
207+
return { role: 'system', content: lines.join('\n') };
208+
}
209+
210+
function buildInitialMessages(
211+
request: RoteAgentRequest,
212+
state: RoteAgentClientState
213+
): ChatMessage[] {
138214
const mode = request.mode || 'chat';
139215
const messages: ChatMessage[] = [
140216
{
141217
role: 'system',
142218
content: buildRoteAgentSystemPrompt(mode),
143219
},
220+
buildRequestTimeContextMessage(state),
144221
];
145222

146223
if (request.history?.length) {
@@ -210,7 +287,7 @@ export async function runRoteAgentStream(params: {
210287
getSources: () => sourceCollector.list(),
211288
};
212289

213-
const messages = buildInitialMessages(request);
290+
const messages = buildInitialMessages(request, state);
214291
let toolCallCount = 0;
215292
let hasFinalAnswer = false;
216293

server/utils/ai/agent/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,27 @@ export type RoteAgentClientState = {
3636
selectedSourceIds?: string[];
3737
selectedTags?: string[];
3838
} | null;
39+
clientContext?: RoteAgentClientContext | null;
3940
stateVersion?: number;
4041
};
4142

43+
export type RoteAgentClientContext = {
44+
nowIso?: string;
45+
localDate?: string;
46+
localDateTime?: string;
47+
timeZone?: string;
48+
utcOffsetMinutes?: number;
49+
locale?: string;
50+
calendar?: string;
51+
};
52+
4253
export type RoteAgentRequest = {
4354
message: string;
4455
mode?: RoteAgentMode;
4556
history?: { role: 'user' | 'assistant'; content: string }[];
4657
state?: RoteAgentClientState | null;
4758
selectedContext?: RoteAgentClientState['selectedContext'];
59+
clientContext?: RoteAgentClientContext | null;
4860
debug?: boolean;
4961
limit?: number;
5062
previousPlan?: PlannerAgentDto | null;

server/utils/ai/directChat.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,49 @@ import {
66
} from './client';
77
import { getStoredAiConfig } from '../dbMethods/ai';
88
import { logAiTokenUsage } from '../dbMethods/aiToken';
9+
import type { RetrievalTimeContext } from './retrievalPlan';
910

1011
function buildMessages(
1112
message: string,
12-
history?: Array<{ role: 'user' | 'assistant'; content: string }>
13+
history?: Array<{ role: 'user' | 'assistant'; content: string }>,
14+
clientContext?: RetrievalTimeContext | null
1315
): ChatMessage[] {
14-
return [...(Array.isArray(history) ? history.slice(-8) : []), { role: 'user', content: message }];
16+
const messages: ChatMessage[] = [
17+
{
18+
role: 'system',
19+
content: buildTimeContextMessage(clientContext),
20+
},
21+
];
22+
if (Array.isArray(history)) {
23+
messages.push(...history.slice(-8));
24+
}
25+
messages.push({ role: 'user', content: message });
26+
return messages;
27+
}
28+
29+
function buildTimeContextMessage(clientContext?: RetrievalTimeContext | null): string {
30+
const lines = [
31+
'Use the current request time context for relative date phrases.',
32+
`Server now (UTC): ${new Date().toISOString()}`,
33+
];
34+
35+
if (clientContext) {
36+
if (clientContext.nowIso) lines.push(`Client now (UTC): ${clientContext.nowIso}`);
37+
if (clientContext.localDate) lines.push(`Client local date: ${clientContext.localDate}`);
38+
if (clientContext.localDateTime) {
39+
lines.push(`Client local date/time: ${clientContext.localDateTime}`);
40+
}
41+
if (clientContext.timeZone) lines.push(`Client time zone: ${clientContext.timeZone}`);
42+
if (typeof clientContext.utcOffsetMinutes === 'number') {
43+
lines.push(`Client UTC offset minutes: ${clientContext.utcOffsetMinutes}`);
44+
}
45+
} else {
46+
lines.push(
47+
'Client time context was not supplied; use server now and Asia/Shanghai by default.'
48+
);
49+
}
50+
51+
return lines.join('\n');
1552
}
1653

1754
async function logUsage(userId: string, model: string, usage: ChatCompletionUsage) {
@@ -29,12 +66,13 @@ export async function createDirectSiteChat(params: {
2966
userId: string;
3067
message: string;
3168
history?: Array<{ role: 'user' | 'assistant'; content: string }>;
69+
clientContext?: RetrievalTimeContext | null;
3270
enableThinking?: boolean;
3371
}) {
3472
const config = await getStoredAiConfig();
3573
const result = await createChatCompletion(
3674
config.chat,
37-
buildMessages(params.message, params.history),
75+
buildMessages(params.message, params.history, params.clientContext),
3876
{
3977
enableThinking: params.enableThinking,
4078
}
@@ -47,6 +85,7 @@ export async function streamDirectSiteChat(params: {
4785
userId: string;
4886
message: string;
4987
history?: Array<{ role: 'user' | 'assistant'; content: string }>;
88+
clientContext?: RetrievalTimeContext | null;
5089
enableThinking?: boolean;
5190
onReasoning: (text: string) => Promise<void>;
5291
onContent: (text: string) => Promise<void>;
@@ -56,7 +95,7 @@ export async function streamDirectSiteChat(params: {
5695
let usage: ChatCompletionUsage | undefined;
5796
for await (const part of createChatCompletionStreamParts(
5897
config.chat,
59-
buildMessages(params.message, params.history),
98+
buildMessages(params.message, params.history, params.clientContext),
6099
{ enableThinking: params.enableThinking }
61100
)) {
62101
if (part.type === 'reasoning') await params.onReasoning(part.text);

0 commit comments

Comments
 (0)