mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): add analysis planner core
This commit is contained in:
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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user