feat(ai): add analysis planner core

This commit is contained in:
digua committed 2026-06-10 00:15:25 +08:00
1 parent 8ca3f0be8b
commit 645f0c6789
6 files changed
+313

No files matched your search

@@ -0,0 +1,67 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { createAnalysisPlanner } from '../planner'
const baseInput = {
userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化,并举出证据。',
chatType: 'group' as const,
locale: 'zh-CN',
availableTools: ['search_messages', 'get_time_stats', 'get_member_stats'],
}
describe('createAnalysisPlanner', () => {
it('parses valid JSON and filters suggested tools to the available set', async () => {
const planner = createAnalysisPlanner({
complete: async () =>
JSON.stringify({
title: '年度话题趋势分析',
intent: 'trend',
steps: [
{
goal: '按季度检索代表性话题',
suggestedTools: ['search_messages', 'missing_tool'],
evidenceNeeded: '每个季度的代表消息',
},
],
successCriteria: ['覆盖全年', '引用证据'],
}),
})
const plan = await planner(baseInput)
assert.equal(plan?.route, 'planned_execution')
assert.equal(plan?.title, '年度话题趋势分析')
assert.deepEqual(plan?.steps[0]?.suggestedTools, ['search_messages'])
})
it('limits steps and success criteria to five items', async () => {
const planner = createAnalysisPlanner({
complete: async () =>
JSON.stringify({
title: '复杂分析',
intent: 'mixed',
steps: Array.from({ length: 7 }, (_, index) => ({
goal: `step ${index + 1}`,
suggestedTools: ['search_messages'],
evidenceNeeded: `evidence ${index + 1}`,
})),
successCriteria: ['a', 'b', 'c', 'd', 'e', 'f'],
}),
})
const plan = await planner(baseInput)
assert.equal(plan?.steps.length, 5)
assert.equal(plan?.successCriteria.length, 5)
})
it('returns null for invalid planner JSON', async () => {
const planner = createAnalysisPlanner({
complete: async () => '{"title": "broken", "intent": "unknown", "steps": []}',
})
const plan = await planner(baseInput)
assert.equal(plan, null)
})
})
@@ -3,3 +3,12 @@ export { runAgentCore } from './core'
export { DEFAULT_MAX_TOOL_ROUNDS } from './constants'
export { createLlmRouteDecider, decideRequestRoute } from './router'
export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './routing-types'
export { createAnalysisPlanner } from './planner'
export type {
AnalysisPlanIntent,
AnalysisPlanner,
AnalysisPlanStep,
AnalysisPlanSummary,
PlannerInput,
PlanContentBlock,
} from './planning-types'
@@ -0,0 +1,181 @@
import {
completeSimple,
type Api as PiApi,
type Model as PiModel,
type TextContent as PiTextContent,
} from '@earendil-works/pi-ai'
import type { AnalysisPlanIntent, AnalysisPlanSummary, AnalysisPlanner, PlannerInput } from './planning-types'
export type PlannerCompletionResult = string | { text: string }
export interface CreateAnalysisPlannerOptions {
piModel?: PiModel<PiApi>
apiKey?: string
complete?: (prompt: string, signal?: AbortSignal) => Promise<PlannerCompletionResult> | PlannerCompletionResult
maxTokens?: number
temperature?: number
}
const PLAN_INTENTS: readonly AnalysisPlanIntent[] = [
'summary',
'trend',
'relationship',
'search',
'comparison',
'mixed',
]
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isIntent(value: unknown): value is AnalysisPlanIntent {
return typeof value === 'string' && PLAN_INTENTS.includes(value as AnalysisPlanIntent)
}
function normalizeString(value: unknown): string | null {
if (typeof value !== 'string') return null
const text = value.trim()
return text.length > 0 ? text : null
}
function extractJsonObject(text: string): string {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenced?.[1]) return fenced[1].trim()
const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start >= 0 && end > start) return text.slice(start, end + 1)
return text.trim()
}
function parsePlanJson(rawText: string, availableTools: Set<string>): AnalysisPlanSummary | null {
try {
const parsed = JSON.parse(extractJsonObject(rawText)) as unknown
if (!isRecord(parsed)) return null
const title = normalizeString(parsed.title)
if (!title || !isIntent(parsed.intent) || !Array.isArray(parsed.steps)) return null
// 中文注释:Planner 输出来自 LLM,必须在进入 Agent guidance 前做结构约束;
// 这里裁剪长度并过滤不存在的工具,避免把幻觉工具名注入后续执行上下文。
const steps = parsed.steps
.filter(isRecord)
.map((step) => {
const goal = normalizeString(step.goal)
const evidenceNeeded = normalizeString(step.evidenceNeeded)
if (!goal || !evidenceNeeded) return null
const suggestedTools = Array.isArray(step.suggestedTools)
? step.suggestedTools.filter((tool): tool is string => typeof tool === 'string' && availableTools.has(tool))
: []
return { goal, suggestedTools: Array.from(new Set(suggestedTools)), evidenceNeeded }
})
.filter((step): step is NonNullable<typeof step> => step !== null)
.slice(0, 5)
if (steps.length === 0) return null
const successCriteria = Array.isArray(parsed.successCriteria)
? parsed.successCriteria
.map(normalizeString)
.filter((item): item is string => item !== null)
.slice(0, 5)
: []
if (successCriteria.length === 0) return null
return {
version: 1,
title,
route: 'planned_execution',
intent: parsed.intent,
steps,
successCriteria,
}
} catch {
return null
}
}
function resultToText(result: PlannerCompletionResult): string {
return typeof result === 'string' ? result : result.text
}
function buildPlannerPrompt(input: PlannerInput): string {
const dataSnapshot = input.dataSnapshot
? `${input.dataSnapshot.name}, ${input.dataSnapshot.totalMessages} messages, ${input.dataSnapshot.totalMembers} members`
: '(none)'
return `Create a concise user-visible analysis plan for a ChatLab planned_execution request.
Return strict JSON only:
{
"title": "short title",
"intent": "summary|trend|relationship|search|comparison|mixed",
"steps": [
{"goal": "what to investigate", "suggestedTools": ["tool_name"], "evidenceNeeded": "what evidence is needed"}
],
"successCriteria": ["criterion"]
}
Limits:
- Max 5 steps.
- Max 5 successCriteria.
- suggestedTools must come only from availableTools.
- Do not reveal hidden chain-of-thought; write a user-readable plan summary.
- The plan is soft guidance, not a mandatory execution script.
Context:
- locale: ${input.locale}
- chatType: ${input.chatType}
- availableTools: ${input.availableTools.join(', ') || '(none)'}
- dataSnapshot: ${dataSnapshot}
- assistantSummary: ${input.assistantSummary ?? '(none)'}
- skillSummary: ${input.skillSummary ?? '(none)'}
- recentIntentSummary: ${input.recentIntentSummary ?? '(none)'}
User request:
${input.userMessage}`
}
export function createAnalysisPlanner(options: CreateAnalysisPlannerOptions): AnalysisPlanner {
return async (input, signal) => {
try {
const prompt = buildPlannerPrompt(input)
const rawResult = options.complete
? await options.complete(prompt, signal)
: await completeWithPiAi(prompt, options, signal)
return parsePlanJson(resultToText(rawResult), new Set(input.availableTools))
} catch {
return null
}
}
}
async function completeWithPiAi(
prompt: string,
options: CreateAnalysisPlannerOptions,
signal?: AbortSignal
): Promise<PlannerCompletionResult> {
if (!options.piModel || !options.apiKey) {
throw new Error('Planner requires piModel and apiKey')
}
const result = await completeSimple(
options.piModel,
{
systemPrompt: 'You are a strict JSON planner for ChatLab analysis. Return JSON only.',
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
},
{
apiKey: options.apiKey,
maxTokens: options.maxTokens ?? 700,
temperature: options.temperature ?? 0.2,
signal,
}
)
return {
text: result.content
.filter((item): item is PiTextContent => item.type === 'text')
.map((item) => item.text)
.join(''),
}
}
@@ -0,0 +1,38 @@
import type { DataSnapshot } from './prompt-builder'
export type AnalysisPlanIntent = 'summary' | 'trend' | 'relationship' | 'search' | 'comparison' | 'mixed'
export interface AnalysisPlanStep {
goal: string
suggestedTools: string[]
evidenceNeeded: string
}
export interface AnalysisPlanSummary {
version: 1
title: string
route: 'planned_execution'
intent: AnalysisPlanIntent
steps: AnalysisPlanStep[]
successCriteria: string[]
}
export interface PlanContentBlock {
type: 'plan'
version: 1
status: 'created' | 'executing' | 'done' | 'skipped'
plan: AnalysisPlanSummary
}
export interface PlannerInput {
userMessage: string
chatType: 'group' | 'private'
locale: string
dataSnapshot?: DataSnapshot
availableTools: string[]
assistantSummary?: string
skillSummary?: string
recentIntentSummary?: string
}
export type AnalysisPlanner = (input: PlannerInput, signal?: AbortSignal) => Promise<AnalysisPlanSummary | null>
+9
View File
@@ -99,6 +99,15 @@ export type { PreprocessingPipelineOptions, PreprocessingPipelineResult } from '
export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './agent'
export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './agent'
export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './agent'
export { createAnalysisPlanner } from './agent'
export type {
AnalysisPlanIntent,
AnalysisPlanner,
AnalysisPlanStep,
AnalysisPlanSummary,
PlannerInput,
PlanContentBlock,
} from './agent'
// Agent Event Handler
export { AgentEventHandler, estimateTokensFromText } from './agent/event-handler'
+9
View File
@@ -205,6 +205,15 @@ export type { AIChat, AIMessage, AIMessageRole, ContentBlock, TokenUsageData, AI
export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './ai'
export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './ai'
export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './ai'
export { createAnalysisPlanner } from './ai'
export type {
AnalysisPlanIntent,
AnalysisPlanner,
AnalysisPlanStep,
AnalysisPlanSummary,
PlannerInput,
PlanContentBlock,
} from './ai'
// Summary generation
export {