From 6594e586d3d7b09a1bd06efbb79f89191f8d0c47 Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 9 Jun 2026 20:02:04 +0800 Subject: [PATCH] feat(ai): guide planner with startup context --- .../src/ai/agent/__tests__/planner.test.ts | 44 ++++++++++++++++ .../src/ai/agent/__tests__/router.test.ts | 44 ++++++++++++++++ .../src/ai/agent/data-snapshot.ts | 50 +++++++++++++++++++ packages/node-runtime/src/ai/agent/planner.ts | 10 ++-- packages/node-runtime/src/ai/agent/router.ts | 5 +- 5 files changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts index 82a17fff..dbcfe683 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts @@ -10,6 +10,19 @@ const baseInput = { availableTools: ['search_messages', 'get_time_stats', 'get_member_stats'], } +const extendedDataSnapshot = { + version: 2 as const, + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 1000, + totalMembers: 12, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + activeMemberHints: [{ memberId: 3, displayName: 'Alice', messageCount: 300, share: 30 }], + segmentSummaries: { availableCount: 8 }, +} + describe('createAnalysisPlanner', () => { it('parses valid JSON and filters suggested tools to the available set', async () => { const planner = createAnalysisPlanner({ @@ -218,4 +231,35 @@ ${json} assert.match(capturedPrompt, /A concise user-facing analysis approach/) assert.match(capturedPrompt, /those details belong in /) }) + + it('includes extended data snapshot context and reconnaissance strategy in the planner prompt', async () => { + let capturedPrompt = '' + const planner = createAnalysisPlanner({ + complete: async (prompt) => { + capturedPrompt = prompt + return JSON.stringify({ + title: '年度话题趋势分析', + intent: 'trend', + steps: [ + { + goal: '先建立话题地图和发言规律', + suggestedTools: ['search_messages', 'get_time_stats'], + evidenceNeeded: '摘要、时间分布、成员活跃和代表消息', + }, + ], + successCriteria: ['覆盖真实最近一年'], + }) + }, + }) + + await planner({ ...baseInput, dataSnapshot: extendedDataSnapshot }) + + assert.match(capturedPrompt, /first_message_ts: 1735689600/) + assert.match(capturedPrompt, /last_message_ts: 1767225599/) + assert.match(capturedPrompt, /segment_summaries_available: 8/) + assert.match(capturedPrompt, /member_id=3/) + assert.match(capturedPrompt, /display_name=Alice/) + assert.match(capturedPrompt, /lightweight reconnaissance step/i) + assert.match(capturedPrompt, /topic\/member activity map/i) + }) }) diff --git a/packages/node-runtime/src/ai/agent/__tests__/router.test.ts b/packages/node-runtime/src/ai/agent/__tests__/router.test.ts index 39b0a075..e982e4c2 100644 --- a/packages/node-runtime/src/ai/agent/__tests__/router.test.ts +++ b/packages/node-runtime/src/ai/agent/__tests__/router.test.ts @@ -10,6 +10,19 @@ const baseInput = { availableTools: ['get_chat_overview', 'search_messages', 'get_member_stats'], } +const extendedDataSnapshot = { + version: 2 as const, + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 1000, + totalMembers: 12, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + activeMemberHints: [{ memberId: 3, displayName: 'Alice', messageCount: 300, share: 30 }], + segmentSummaries: { availableCount: 8 }, +} + describe('decideRequestRoute', () => { it('routes concept and help questions to direct response by rule', async () => { const concept = await decideRequestRoute({ @@ -111,6 +124,37 @@ describe('decideRequestRoute', () => { assert.match(decision.reason, /No local data/) }) + it('includes compact extended data snapshot context in the LLM fallback prompt', async () => { + let capturedPrompt = '' + const decider = createLlmRouteDecider({ + complete: async (prompt) => { + capturedPrompt = prompt + return '{"route":"planned_execution","confidence":0.8,"reason":"Needs trend analysis."}' + }, + }) + + await decider( + { + ...baseInput, + userMessage: '帮我看一下这个情况。', + dataSnapshot: extendedDataSnapshot, + }, + { + route: 'tool_assisted', + confidence: 0.45, + reason: 'Ambiguous request.', + source: 'rule', + } + ) + + assert.match(capturedPrompt, /total_messages: 1000/) + assert.match(capturedPrompt, /total_members: 12/) + assert.match(capturedPrompt, /first_message_ts: 1735689600/) + assert.match(capturedPrompt, /last_message_ts: 1767225599/) + assert.match(capturedPrompt, /segment_summaries_available: 8/) + assert.match(capturedPrompt, /active_member_hint_count: 1/) + }) + it('keeps the rule decision when LLM fallback output is invalid', async () => { const decider = createLlmRouteDecider({ complete: async () => '{"route":"unknown","confidence":0.9,"reason":"bad"}', diff --git a/packages/node-runtime/src/ai/agent/data-snapshot.ts b/packages/node-runtime/src/ai/agent/data-snapshot.ts index c028c373..938eca99 100644 --- a/packages/node-runtime/src/ai/agent/data-snapshot.ts +++ b/packages/node-runtime/src/ai/agent/data-snapshot.ts @@ -17,6 +17,16 @@ function roundShare(value: number): number { return Math.round(value * 10) / 10 } +function formatNullableTimestamp(timestamp: number | null | undefined): string { + return typeof timestamp === 'number' && Number.isFinite(timestamp) ? String(timestamp) : 'null' +} + +function formatSharePercent(share: number): string { + if (!Number.isFinite(share)) return '0%' + const rounded = Math.round(share * 10) / 10 + return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%` +} + export function createDataSnapshotFromOverview( overview: ChatOverviewForSnapshot | null | undefined ): DataSnapshot | undefined { @@ -44,3 +54,43 @@ export function createDataSnapshotFromOverview( }, } } + +export function formatDataSnapshotForRouter(dataSnapshot: DataSnapshot | undefined): string { + if (!dataSnapshot) return '(none)' + + return [ + `name: ${dataSnapshot.name}`, + `platform: ${dataSnapshot.platform}`, + `type: ${dataSnapshot.type}`, + `total_messages: ${dataSnapshot.totalMessages}`, + `total_members: ${dataSnapshot.totalMembers}`, + `first_message_ts: ${formatNullableTimestamp(dataSnapshot.firstMessageTs)}`, + `last_message_ts: ${formatNullableTimestamp(dataSnapshot.lastMessageTs)}`, + `segment_summaries_available: ${dataSnapshot.segmentSummaries?.availableCount ?? 0}`, + `active_member_hint_count: ${dataSnapshot.activeMemberHints?.length ?? 0}`, + ].join('\n') +} + +export function formatDataSnapshotForPlanner(dataSnapshot: DataSnapshot | undefined): string { + if (!dataSnapshot) return '(none)' + + const memberHints = dataSnapshot.activeMemberHints ?? [] + const memberHintLines = + memberHints.length > 0 + ? memberHints + .map( + (member, index) => + `${index + 1}. member_id=${member.memberId} | display_name=${member.displayName} | messages=${member.messageCount} | share=${formatSharePercent(member.share)}` + ) + .join('\n') + : '(none)' + + return `${formatDataSnapshotForRouter(dataSnapshot)} +active_member_hints: +${memberHintLines} +notes: +- member_id is a tool lookup hint; display_name may not be unique. +- Active member hints reflect historical total message volume, not recent activity. +- Use real current date for relative ranges; database bounds only describe data coverage. +- Do not plan a tool call only to rediscover min/max timestamp.` +} diff --git a/packages/node-runtime/src/ai/agent/planner.ts b/packages/node-runtime/src/ai/agent/planner.ts index bdbf2945..be27410c 100644 --- a/packages/node-runtime/src/ai/agent/planner.ts +++ b/packages/node-runtime/src/ai/agent/planner.ts @@ -12,6 +12,7 @@ import type { PlanContentBlock, PlannerInput, } from './planning-types' +import { formatDataSnapshotForPlanner } from './data-snapshot' export type PlannerCompletionResult = string | { text: string } @@ -121,9 +122,7 @@ function resultToText(result: PlannerCompletionResult): string { } function buildPlannerPrompt(input: PlannerInput): string { - const dataSnapshot = input.dataSnapshot - ? `${input.dataSnapshot.name}, ${input.dataSnapshot.totalMessages} messages, ${input.dataSnapshot.totalMembers} members` - : '(none)' + const dataSnapshot = formatDataSnapshotForPlanner(input.dataSnapshot) const availableCapabilities = input.availableCapabilities && input.availableCapabilities.length > 0 ? input.availableCapabilities @@ -162,6 +161,11 @@ Limits: - Do not reveal hidden chain-of-thought; draft and plan are user-readable summaries only. - The plan is soft guidance, not a mandatory execution script. +Planning strategy: +- For open-ended topic, community profile, influence, interaction-pattern, or multi-dimensional retrospective analysis, first plan a lightweight reconnaissance step to build a topic/member activity map before drawing conclusions. +- Prefer segment summaries, keyword frequency, time distribution, member activity, and representative message retrieval when available. +- Do not create a dedicated step whose only purpose is finding max timestamp or confirming latest message time. + Context: - locale: ${input.locale} - chatType: ${input.chatType} diff --git a/packages/node-runtime/src/ai/agent/router.ts b/packages/node-runtime/src/ai/agent/router.ts index c1013598..d789a22c 100644 --- a/packages/node-runtime/src/ai/agent/router.ts +++ b/packages/node-runtime/src/ai/agent/router.ts @@ -1,4 +1,5 @@ import type { LlmRouteDecider, RouteDecision, RouterInput } from './routing-types' +import { formatDataSnapshotForRouter } from './data-snapshot' import { completeSimple, type Api as PiApi, @@ -77,9 +78,7 @@ function parseLlmDecision(rawText: string, usage?: RouteDecision['usage']): Rout function buildLlmRouterPrompt(input: RouterInput, ruleDecision: RouteDecision): string { const toolNames = input.availableTools?.length ? input.availableTools.join(', ') : '(none)' - const dataSnapshot = input.dataSnapshot - ? `${input.dataSnapshot.name}, ${input.dataSnapshot.totalMessages} messages, ${input.dataSnapshot.totalMembers} members` - : '(none)' + const dataSnapshot = formatDataSnapshotForRouter(input.dataSnapshot) return `Classify the user's ChatLab request into exactly one route.