feat(ai): guide planner with startup context

This commit is contained in:
digua committed 2026-06-10 00:15:25 +08:00
1 parent 718735a748
commit 6594e586d3
5 files changed
+147 -6

No files matched your search

@@ -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 <json>/)
})
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)
})
})
@@ -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"}',
@@ -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.`
}
@@ -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}
+2 -3
View File
@@ -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.