refactor: extract createLlmRole/createToolRole factory — shared sandwich pattern
CI / test (push) Has been cancelled

- 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
This commit is contained in:
2026-04-17 09:12:32 +00:00
parent a040819a09
commit 4abe7bf620
4 changed files with 184 additions and 102 deletions
+3
View File
@@ -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 {
@@ -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<AnalystMeta> {
return async (chain: WorkflowMessage[]): Promise<RoleResult<AnalystMeta>> => {
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<AnalystMeta, AnalysisResult>(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),
},
};
};
}),
});
}
@@ -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<ArchitectMeta> {
return async (chain): Promise<RoleResult<ArchitectMeta>> => {
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<ArchitectMeta>(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 ?? [] },
};
},
});
}
@@ -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<TMeta extends Record<string, unknown>> {
/** 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<string, unknown>;
};
}>;
/** Tool choice strategy */
toolChoice?: 'auto' | 'required';
/** Parse the LLM response into role result. */
parseResponse: (resp: LlmResponse, chain: WorkflowMessage[]) => RoleResult<TMeta>;
}
/**
* Create a reusable LLM role from config.
* All LLM roles share the same call skeleton — only the filling differs.
*/
export function createLlmRole<TMeta extends Record<string, unknown>>(
llm: LlmClient,
config: LlmRoleConfig<TMeta>,
): Role<TMeta> {
return async (
chain: WorkflowMessage[],
_topicId?: string,
_store?: PulseStore,
): Promise<RoleResult<TMeta>> => {
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<string, unknown>,
TToolResult = unknown,
> {
systemPrompt: string;
buildUserMessage?: (chain: WorkflowMessage[]) => string;
tool: {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
/** Default result if tool call parsing fails */
defaultResult: TToolResult;
/** Convert parsed tool result to role result */
toRoleResult: (parsed: TToolResult, chain: WorkflowMessage[]) => RoleResult<TMeta>;
}
/**
* 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<string, unknown>,
TToolResult = unknown,
>(
llm: LlmClient,
config: ToolRoleConfig<TMeta, TToolResult>,
): Role<TMeta> {
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);
},
});
}