From 4abe7bf6204826ca0411515e309c6ae35f6139fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 17 Apr 2026 09:12:32 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20extract=20createLlmRole/createToolR?= =?UTF-8?q?ole=20factory=20=E2=80=94=20shared=20sandwich=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - llm-role-factory.ts: shared LLM call skeleton (build messages → chat → parse) - createLlmRole: free-text response roles (architect) - createToolRole: tool_choice structured output roles (analyst) - analyst-llm.ts + architect-llm.ts rewritten on factory - 25 tests pass, live report verified --- packages/pulse/src/workflows/index.ts | 3 + .../pulse/src/workflows/roles/analyst-llm.ts | 93 ++++--------- .../src/workflows/roles/architect-llm.ts | 63 ++++----- .../src/workflows/roles/llm-role-factory.ts | 127 ++++++++++++++++++ 4 files changed, 184 insertions(+), 102 deletions(-) create mode 100644 packages/pulse/src/workflows/roles/llm-role-factory.ts diff --git a/packages/pulse/src/workflows/index.ts b/packages/pulse/src/workflows/index.ts index 796779d..c285443 100644 --- a/packages/pulse/src/workflows/index.ts +++ b/packages/pulse/src/workflows/index.ts @@ -14,8 +14,11 @@ export { createCursorRunner, } from './roles/agent-executor.js'; export { createAnalystRole } from './roles/analyst-llm.js'; +export type { AnalysisResult } from './roles/analyst-llm.js'; export { createArchitectRole } from './roles/architect-llm.js'; export { createCoderRole } from './roles/coder-cursor.js'; +export { createLlmRole, createToolRole } from './roles/llm-role-factory.js'; +export type { LlmRoleConfig, ToolRoleConfig } from './roles/llm-role-factory.js'; export { createRendererRole } from './roles/renderer-template.js'; export { createReviewerRole } from './roles/reviewer-cursor.js'; export { diff --git a/packages/pulse/src/workflows/roles/analyst-llm.ts b/packages/pulse/src/workflows/roles/analyst-llm.ts index 9cfbc18..bad5554 100644 --- a/packages/pulse/src/workflows/roles/analyst-llm.ts +++ b/packages/pulse/src/workflows/roles/analyst-llm.ts @@ -1,13 +1,14 @@ /** * Analyst role — LLM reads workflow timeline JSON and produces structured analysis. - * Output is fully structured JSON (via tool_choice), no free text. + * Built on createToolRole factory (shared sandwich pattern). * * 小橘 🍊 (NEKO Team) */ import type { LlmClient } from '../../llm-client.js'; import type { AnalystMeta } from '../report-workflow.js'; -import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js'; +import type { Role } from '../workflow-type.js'; +import { createToolRole } from './llm-role-factory.js'; const SYSTEM_PROMPT = `You are a workflow execution analyst. You receive a workflow timeline (JSON with events array) and produce a structured analysis. @@ -37,74 +38,50 @@ const ANALYSIS_TOOL = { }, summary: { type: 'string', - description: 'One-sentence summary of the workflow execution', + description: 'One-sentence summary of the workflow execution (in Chinese)', }, highlights: { type: 'array', items: { type: 'object', properties: { - text: { type: 'string', description: 'What went well' }, - refEventId: { - type: 'number', - description: 'Related event id from timeline', - }, + text: { type: 'string' }, + refEventId: { type: 'number' }, }, required: ['text'], }, - description: 'Things that went well', }, issues: { type: 'array', items: { type: 'object', properties: { - text: { type: 'string', description: 'Problem detected' }, - refEventId: { - type: 'number', - description: 'Related event id from timeline', - }, + text: { type: 'string' }, + refEventId: { type: 'number' }, }, required: ['text'], }, - description: 'Problems detected', }, suggestions: { type: 'array', items: { type: 'string' }, - description: 'Concrete improvement suggestions', }, roleAnalysis: { type: 'array', items: { type: 'object', properties: { - eventId: { - type: 'number', - description: 'Event id from timeline', - }, - role: { type: 'string', description: 'Role name' }, - durationMs: { type: 'number', description: 'Duration in ms' }, - verdict: { - type: 'string', - enum: ['good', 'slow', 'problematic'], - description: 'Performance verdict', - }, - comment: { type: 'string', description: 'Brief assessment' }, + eventId: { type: 'number' }, + role: { type: 'string' }, + durationMs: { type: 'number' }, + verdict: { type: 'string', enum: ['good', 'slow', 'problematic'] }, + comment: { type: 'string' }, }, required: ['eventId', 'role', 'verdict', 'comment'], }, - description: 'Per-role analysis referencing timeline event ids', }, }, - required: [ - 'score', - 'summary', - 'highlights', - 'issues', - 'suggestions', - 'roleAnalysis', - ], + required: ['score', 'summary', 'highlights', 'issues', 'suggestions', 'roleAnalysis'], }, }, }; @@ -126,45 +103,29 @@ export interface AnalysisResult { const DEFAULT_ANALYSIS: AnalysisResult = { score: 5, - summary: 'Analysis failed to parse.', + summary: '分析解析失败', highlights: [], - issues: [{ text: 'Analyst output could not be parsed' }], + issues: [{ text: '分析输出无法解析' }], suggestions: [], roleAnalysis: [], }; export function createAnalystRole(llm: LlmClient): Role { - return async (chain: WorkflowMessage[]): Promise> => { - const startMsg = chain.find((m) => m.role === '__start__'); - const timelineJson = startMsg?.content ?? '{}'; - - const resp = await llm.chat({ - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: `Workflow timeline:\n\n${timelineJson}` }, - ], - tools: [ANALYSIS_TOOL], - tool_choice: 'required', - }); - - let analysis = DEFAULT_ANALYSIS; - const toolCall = resp.tool_calls?.find( - (tc) => tc.function.name === 'extract_analysis', - ); - if (toolCall) { - try { - analysis = JSON.parse(toolCall.function.arguments) as AnalysisResult; - } catch {} - } - - // Content is the full analysis JSON (for renderer to consume) - return { + return createToolRole(llm, { + systemPrompt: SYSTEM_PROMPT, + buildUserMessage: (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + return `Workflow timeline:\n\n${startMsg?.content ?? '{}'}`; + }, + tool: ANALYSIS_TOOL, + defaultResult: DEFAULT_ANALYSIS, + toRoleResult: (analysis) => ({ content: JSON.stringify(analysis), meta: { score: analysis.score, highlights: analysis.highlights.map((h) => h.text), issues: analysis.issues.map((i) => i.text), }, - }; - }; + }), + }); } diff --git a/packages/pulse/src/workflows/roles/architect-llm.ts b/packages/pulse/src/workflows/roles/architect-llm.ts index 9f21985..4925087 100644 --- a/packages/pulse/src/workflows/roles/architect-llm.ts +++ b/packages/pulse/src/workflows/roles/architect-llm.ts @@ -1,46 +1,37 @@ /** - * Architect role — uses LLM to analyze coding tasks. - * Pure: returns { content, meta }, adapter writes events. + * Architect role — LLM analyzes coding task and suggests approach. + * Built on createLlmRole factory (shared sandwich pattern). * * 小橘 🍊 (NEKO Team) */ import type { LlmClient } from '../../llm-client.js'; import type { ArchitectMeta } from '../coding-workflow.js'; -import type { Role, RoleResult } from '../workflow-type.js'; +import type { Role } from '../workflow-type.js'; +import { createLlmRole } from './llm-role-factory.js'; export function createArchitectRole(llmClient: LlmClient): Role { - return async (chain): Promise> => { - const startMsg = chain.find((m) => m.role === '__start__'); - const title = startMsg?.meta?.title ?? 'unknown'; - const description = startMsg?.content ?? ''; - const repoDir = (startMsg?.meta?.repoDir as string) ?? ''; - - const resp = await llmClient.chat({ - messages: [ - { - role: 'system', - content: - 'You are a software architect. Analyze the task and suggest target files and approach. Reply in JSON: {"analysis": "...", "targetFiles": ["..."]}', - }, - { - role: 'user', - content: `Task: ${title}\nDescription: ${description}\nRepo: ${repoDir}`, - }, - ], - }); - - let parsed: { analysis?: string; targetFiles?: string[] }; - try { - parsed = JSON.parse(resp.content ?? '{}'); - } catch { - parsed = { analysis: resp.content ?? 'No analysis', targetFiles: [] }; - } - - const targetFiles = parsed.targetFiles ?? []; - return { - content: parsed.analysis ?? 'No analysis', - meta: { targetFiles }, - }; - }; + return createLlmRole(llmClient, { + systemPrompt: + 'You are a software architect. Analyze the task and suggest target files and approach. Reply in JSON: {"analysis": "...", "targetFiles": ["..."]}', + buildUserMessage: (chain) => { + const startMsg = chain.find((m) => m.role === '__start__'); + const title = startMsg?.meta?.title ?? 'unknown'; + const description = startMsg?.content ?? ''; + const repoDir = (startMsg?.meta?.repoDir as string) ?? ''; + return `Task: ${title}\nDescription: ${description}\nRepo: ${repoDir}`; + }, + parseResponse: (resp) => { + let parsed: { analysis?: string; targetFiles?: string[] }; + try { + parsed = JSON.parse(resp.content ?? '{}'); + } catch { + parsed = { analysis: resp.content ?? 'No analysis', targetFiles: [] }; + } + return { + content: parsed.analysis ?? 'No analysis', + meta: { targetFiles: parsed.targetFiles ?? [] }, + }; + }, + }); } diff --git a/packages/pulse/src/workflows/roles/llm-role-factory.ts b/packages/pulse/src/workflows/roles/llm-role-factory.ts new file mode 100644 index 0000000..953f8f5 --- /dev/null +++ b/packages/pulse/src/workflows/roles/llm-role-factory.ts @@ -0,0 +1,127 @@ +/** + * LLM Role Factory — shared "sandwich" pattern for all LLM-based roles. + * + * The "bread" is always the same: + * 1. Extract context from message chain + * 2. Build messages array + * 3. llm.chat({ messages, tools?, tool_choice? }) + * 4. Parse response → { content, meta } + * + * The "filling" is what differs per role: + * - buildMessages: chain → LLM messages + * - parseResponse: LLM response → { content, meta } + * - tools/tool_choice (optional) + * + * 小橘 🍊 (NEKO Team) + */ + +import type { LlmClient, LlmResponse } from '../../llm-client.js'; +import type { PulseStore } from '../../store.js'; +import type { Role, RoleResult, WorkflowMessage } from '../workflow-type.js'; + +export interface LlmRoleConfig> { + /** System prompt */ + systemPrompt: string; + + /** Build user messages from the workflow chain. Default: last message content. */ + buildUserMessage?: (chain: WorkflowMessage[]) => string; + + /** Tool definitions for structured output */ + tools?: Array<{ + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; + }>; + + /** Tool choice strategy */ + toolChoice?: 'auto' | 'required'; + + /** Parse the LLM response into role result. */ + parseResponse: (resp: LlmResponse, chain: WorkflowMessage[]) => RoleResult; +} + +/** + * Create a reusable LLM role from config. + * All LLM roles share the same call skeleton — only the filling differs. + */ +export function createLlmRole>( + llm: LlmClient, + config: LlmRoleConfig, +): Role { + return async ( + chain: WorkflowMessage[], + _topicId?: string, + _store?: PulseStore, + ): Promise> => { + const userMessage = config.buildUserMessage + ? config.buildUserMessage(chain) + : chain[chain.length - 1]?.content ?? ''; + + const resp = await llm.chat({ + messages: [ + { role: 'system', content: config.systemPrompt }, + { role: 'user', content: userMessage }, + ], + tools: config.tools, + tool_choice: config.toolChoice, + }); + + return config.parseResponse(resp, chain); + }; +} + +// ── Convenience: tool-call based role ────────────────────────── + +export interface ToolRoleConfig< + TMeta extends Record, + TToolResult = unknown, +> { + systemPrompt: string; + buildUserMessage?: (chain: WorkflowMessage[]) => string; + tool: { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; + }; + /** Default result if tool call parsing fails */ + defaultResult: TToolResult; + /** Convert parsed tool result to role result */ + toRoleResult: (parsed: TToolResult, chain: WorkflowMessage[]) => RoleResult; +} + +/** + * Create an LLM role that uses tool_choice: required for structured output. + * Handles tool_call parsing and fallback automatically. + */ +export function createToolRole< + TMeta extends Record, + TToolResult = unknown, +>( + llm: LlmClient, + config: ToolRoleConfig, +): Role { + return createLlmRole(llm, { + systemPrompt: config.systemPrompt, + buildUserMessage: config.buildUserMessage, + tools: [config.tool], + toolChoice: 'required', + parseResponse: (resp, chain) => { + const toolCall = resp.tool_calls?.find( + (tc) => tc.function.name === config.tool.function.name, + ); + let parsed = config.defaultResult; + if (toolCall) { + try { + parsed = JSON.parse(toolCall.function.arguments) as TToolResult; + } catch {} + } + return config.toRoleResult(parsed, chain); + }, + }); +}