refactor: extract createLlmRole/createToolRole factory — shared sandwich pattern
CI / test (push) Has been cancelled
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:
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user