From 7432f80d61600e4ed16c4aaf63ffcc602b378ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 30 Apr 2026 12:38:58 +0000 Subject: [PATCH] refactor(knowledge-extraction): convert questioner and answerer to createRole four-tuple - questioner: createRole(adapter, questionerPrompt, schema, extract) + queue short-circuit + meta post-processing - answerer: createRole(adapter, answererPrompt, schema, extract) + empty-questions short-circuit - build.ts: use createLlmAdapter(extract.provider) as default LLM adapter for questioner/answerer Refs uncaged/nerve#277 --- workflows/knowledge-extraction/build.ts | 6 +- .../knowledge-extraction/roles/answerer.ts | 115 +++++++++--------- .../knowledge-extraction/roles/questioner.ts | 49 ++++---- 3 files changed, 85 insertions(+), 85 deletions(-) diff --git a/workflows/knowledge-extraction/build.ts b/workflows/knowledge-extraction/build.ts index 992a64b..05dd5b6 100644 --- a/workflows/knowledge-extraction/build.ts +++ b/workflows/knowledge-extraction/build.ts @@ -1,5 +1,6 @@ import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; +import { createLlmAdapter } from "@uncaged/nerve-workflow-utils"; import { moderator } from "./moderator.js"; import type { WorkflowMeta } from "./moderator.js"; @@ -19,11 +20,12 @@ export function createKnowledgeExtractionWorkflow({ extract, }: CreateKnowledgeExtractionDeps): WorkflowDefinition { const a = (role: keyof WorkflowMeta) => adapters?.[role] ?? defaultAdapter; + const llmAdapter = createLlmAdapter(extract.provider); return { name: "knowledge-extraction", roles: { - questioner: createQuestionerRole({ extract }), - answerer: createAnswererRole({ extract }), + questioner: createQuestionerRole(adapters?.questioner ?? llmAdapter, { extract }), + answerer: createAnswererRole(adapters?.answerer ?? llmAdapter, { extract }), explorer: createExplorerRole(a("explorer"), { extract }), }, moderator, diff --git a/workflows/knowledge-extraction/roles/answerer.ts b/workflows/knowledge-extraction/roles/answerer.ts index 1efca45..ff13f4f 100644 --- a/workflows/knowledge-extraction/roles/answerer.ts +++ b/workflows/knowledge-extraction/roles/answerer.ts @@ -1,6 +1,6 @@ -import type { Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; -import { llmExtract, nerveCommandEnv, spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { createRole, nerveCommandEnv, spawnSafe } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; import { resolveWorkdir } from "../lib/workdir.js"; @@ -34,12 +34,62 @@ function lastQuestionerMeta(messages: WorkflowMessage[]): QuestionerMeta | undef return undefined; } -export function createAnswererRole(deps: CreateAnswererRoleDeps): Role { - const { extract } = deps; +export async function answererPrompt(ctx: ThreadContext): Promise { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const cwd = resolveWorkdir(ctx.start); + const qm = lastQuestionerMeta(messages); + if (!qm || qm.questions.length === 0) { + throw new Error("answerer: prompt invoked without questioner questions — wrapped role should short-circuit"); + } + + const blocks: string[] = []; + for (const q of qm.questions) { + if ((ctx.start.meta as Record).dryRun) { + blocks.push(`### ${q.id}\n[dryRun] skipped nerve knowledge query\n`); + continue; + } + const res = await spawnSafe( + "nerve", + ["knowledge", "query", q.question], + { + cwd, + env: nerveCommandEnv(), + timeoutMs: 120_000, + dryRun: false, + abortSignal: null, + }, + ); + if (res.ok) { + blocks.push(`### ${q.id} (${q.domain})\nQuestion: ${q.question}\n---\n${res.value.stdout}\n`); + } else { + const err = res.error; + const detail = + err.kind === "non_zero_exit" + ? `exit ${err.exitCode}\n${err.stderr}` + : err.kind === "timeout" + ? `timeout\n${err.stderr}` + : err.kind === "spawn_failed" + ? err.message + : "aborted"; + blocks.push(`### ${q.id}\nnerve knowledge query failed: ${detail}\n`); + } + } + + return [ + "You are the **answerer**. You MUST NOT read repository source code — only the CLI retrieval excerpts below.", + "For each question id, decide whether the knowledge base already answers it.", + "Set found=true only when the excerpt supports a confident answer; otherwise found=false.", + "Set has_unanswered=true if any question remains unanswered by the knowledge base.", + "", + ...blocks, + ].join("\n"); +} + +export function createAnswererRole(adapter: AgentFn, { extract }: CreateAnswererRoleDeps): Role { + const inner = createRole(adapter, answererPrompt, answererMetaSchema, extract); return async (ctx: ThreadContext) => { const messages = ctx.steps as unknown as WorkflowMessage[]; - const cwd = resolveWorkdir(ctx.start); const qm = lastQuestionerMeta(messages); if (!qm || qm.questions.length === 0) { return { @@ -47,59 +97,6 @@ export function createAnswererRole(deps: CreateAnswererRoleDeps): Role).dryRun) { - blocks.push(`### ${q.id}\n[dryRun] skipped nerve knowledge query\n`); - continue; - } - const res = await spawnSafe( - "nerve", - ["knowledge", "query", q.question], - { - cwd, - env: nerveCommandEnv(), - timeoutMs: 120_000, - dryRun: false, - abortSignal: null, - }, - ); - if (res.ok) { - blocks.push(`### ${q.id} (${q.domain})\nQuestion: ${q.question}\n---\n${res.value.stdout}\n`); - } else { - const err = res.error; - const detail = - err.kind === "non_zero_exit" - ? `exit ${err.exitCode}\n${err.stderr}` - : err.kind === "timeout" - ? `timeout\n${err.stderr}` - : err.kind === "spawn_failed" - ? err.message - : "aborted"; - blocks.push(`### ${q.id}\nnerve knowledge query failed: ${detail}\n`); - } - } - - const bundle = [ - "You are the **answerer**. You MUST NOT read repository source code — only the CLI retrieval excerpts below.", - "For each question id, decide whether the knowledge base already answers it.", - "Set found=true only when the excerpt supports a confident answer; otherwise found=false.", - "Set has_unanswered=true if any question remains unanswered by the knowledge base.", - "", - ...blocks, - ].join("\n"); - - const metaR = await llmExtract({ - text: bundle, - schema: answererMetaSchema, - provider: extract.provider, - dryRun: false, - }); - if (!metaR.ok) { - throw new Error(`answerer llmExtract: ${JSON.stringify(metaR.error)}`); - } - - return { content: bundle, meta: metaR.value }; + return inner(ctx); }; } diff --git a/workflows/knowledge-extraction/roles/questioner.ts b/workflows/knowledge-extraction/roles/questioner.ts index 5a09d70..9fda7b9 100644 --- a/workflows/knowledge-extraction/roles/questioner.ts +++ b/workflows/knowledge-extraction/roles/questioner.ts @@ -1,9 +1,9 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import type { Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; -import { createLlmRole } from "@uncaged/nerve-workflow-utils"; +import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; import { resolveQueueForQuestioner } from "../lib/knowledge-queue.js"; @@ -53,8 +53,29 @@ Remaining queue after this card (paths, may be empty): ${JSON.stringify(remainin ${cardBody}`; } -export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps): Role { - const { extract } = adapterExtract; +export async function questionerPrompt(ctx: ThreadContext): Promise { + const messages = ctx.steps as unknown as WorkflowMessage[]; + const cwd = resolveWorkdir(ctx.start); + const queue = await resolveQueueForQuestioner(ctx.start, messages, cwd); + if (queue.length === 0) { + throw new Error( + "questioner: prompt invoked with empty queue — wrapped role should short-circuit before LLM", + ); + } + const card = queue[0]!; + const remaining_queue = queue.slice(1); + let cardBody: string; + try { + cardBody = await readFile(join(cwd, card), "utf8"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`questioner: failed to read ${card}: ${msg}`); + } + return `${questionerSystem()}\n\n${questionerUser(card, cardBody, remaining_queue)}`; +} + +export function createQuestionerRole(adapter: AgentFn, { extract }: CreateQuestionerRoleDeps): Role { + const inner = createRole(adapter, questionerPrompt, questionerExtractSchema, extract); return async (ctx: ThreadContext) => { const messages = ctx.steps as unknown as WorkflowMessage[]; @@ -74,26 +95,6 @@ export function createQuestionerRole(adapterExtract: CreateQuestionerRoleDeps): const card = queue[0]!; const remaining_queue = queue.slice(1); - let cardBody: string; - try { - cardBody = await readFile(join(cwd, card), "utf8"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error(`questioner: failed to read ${card}: ${msg}`); - } - - const inner = createLlmRole({ - provider: extract.provider, - prompt: async () => [ - { role: "system", content: questionerSystem() }, - { role: "user", content: questionerUser(card, cardBody, remaining_queue) }, - ], - extract: { - schema: questionerExtractSchema, - provider: extract.provider, - }, - }); - const r = await inner(ctx); return { content: r.content,