refactor(workflows): rename knowledge-extraction to extract-knowledge
Align WorkflowDefinition name, nerve.yaml, role prompts, and lockfile path with extract-knowledge. Refs #285 Made-with: Cursor
This commit is contained in:
parent
7432f80d61
commit
dc1e96d8f3
@ -41,6 +41,6 @@ workflows:
|
|||||||
solve-issue:
|
solve-issue:
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
overflow: queue
|
overflow: queue
|
||||||
knowledge-extraction:
|
extract-knowledge:
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
overflow: queue
|
overflow: queue
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@ -183,7 +183,7 @@ importers:
|
|||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
workflows/knowledge-extraction:
|
workflows/extract-knowledge:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@uncaged/nerve-adapter-cursor':
|
'@uncaged/nerve-adapter-cursor':
|
||||||
specifier: link:../../../repos/nerve/packages/adapter-cursor
|
specifier: link:../../../repos/nerve/packages/adapter-cursor
|
||||||
|
|||||||
33
workflows/extract-knowledge/build.ts
Normal file
33
workflows/extract-knowledge/build.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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";
|
||||||
|
import { createAnswererRole } from "./roles/answerer.js";
|
||||||
|
import { createExplorerRole } from "./roles/explorer.js";
|
||||||
|
import { createQuestionerRole } from "./roles/questioner.js";
|
||||||
|
|
||||||
|
export type CreateKnowledgeExtractionDeps = {
|
||||||
|
defaultAdapter: AgentFn;
|
||||||
|
adapters?: Partial<Record<keyof WorkflowMeta, AgentFn>>;
|
||||||
|
extract: LlmExtractorConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createKnowledgeExtractionWorkflow({
|
||||||
|
defaultAdapter,
|
||||||
|
adapters,
|
||||||
|
extract,
|
||||||
|
}: CreateKnowledgeExtractionDeps): WorkflowDefinition<WorkflowMeta> {
|
||||||
|
const a = (role: keyof WorkflowMeta) => adapters?.[role] ?? defaultAdapter;
|
||||||
|
const llmAdapter = createLlmAdapter(extract.provider);
|
||||||
|
return {
|
||||||
|
name: "extract-knowledge",
|
||||||
|
roles: {
|
||||||
|
questioner: createQuestionerRole(adapters?.questioner ?? llmAdapter, { extract }),
|
||||||
|
answerer: createAnswererRole(adapters?.answerer ?? llmAdapter, { extract }),
|
||||||
|
explorer: createExplorerRole(a("explorer"), { extract }),
|
||||||
|
},
|
||||||
|
moderator,
|
||||||
|
};
|
||||||
|
}
|
||||||
93
workflows/extract-knowledge/roles/explorer.ts
Normal file
93
workflows/extract-knowledge/roles/explorer.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
|
||||||
|
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||||
|
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { resolveWorkdir } from "../lib/workdir.js";
|
||||||
|
|
||||||
|
import type { AnswererMeta } from "./answerer.js";
|
||||||
|
import type { QuestionerMeta } from "./questioner.js";
|
||||||
|
|
||||||
|
export const explorerMetaSchema = z.object({
|
||||||
|
patches: z.array(
|
||||||
|
z.object({
|
||||||
|
card: z.string(),
|
||||||
|
section: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
new_cards: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExplorerMeta = z.infer<typeof explorerMetaSchema>;
|
||||||
|
|
||||||
|
export type CreateExplorerRoleDeps = {
|
||||||
|
extract: LlmExtractorConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function lastMeta<M>(messages: WorkflowMessage[], role: string): M | undefined {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].role === role) {
|
||||||
|
return messages[i].meta as M;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explorerPrompt(ctx: ThreadContext): string {
|
||||||
|
const messages = ctx.steps as unknown as WorkflowMessage[];
|
||||||
|
const threadId = ctx.start.meta.threadId;
|
||||||
|
const qm = lastMeta<QuestionerMeta>(messages, "questioner");
|
||||||
|
const am = lastMeta<AnswererMeta>(messages, "answerer");
|
||||||
|
const cwd = resolveWorkdir(ctx.start);
|
||||||
|
|
||||||
|
const unanswered =
|
||||||
|
am?.results.filter((r) => !r.found).map((r) => r.id) ?? [];
|
||||||
|
|
||||||
|
return `You are the **explorer** in an extract-knowledge workflow.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Thread: \`nerve thread ${threadId}\`
|
||||||
|
- Working directory (repo root for paths): ${cwd}
|
||||||
|
- Current knowledge card (questioner): ${qm?.card ?? "(unknown)"}
|
||||||
|
|
||||||
|
## Unanswered question ids
|
||||||
|
|
||||||
|
${JSON.stringify(unanswered)}
|
||||||
|
|
||||||
|
Use the prior answerer results in the thread to map ids to full question text when you read messages above.
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
For each unanswered question, **read the codebase** as needed, then either:
|
||||||
|
|
||||||
|
- Add a new markdown file under \`.knowledge/\`, or
|
||||||
|
- Patch an existing card (prefer updating the card listed above when appropriate).
|
||||||
|
|
||||||
|
After any write or patch to \`.knowledge\`, run:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
nerve knowledge sync
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
from this repo root (${cwd}), and fix failures until sync succeeds.
|
||||||
|
|
||||||
|
## Output meta
|
||||||
|
|
||||||
|
Report \`patches\` as { card, section } entries for cards you edited (section is a short heading or path hint).
|
||||||
|
Report \`new_cards\` as repo-relative paths for brand-new files you created (e.g. \`.knowledge/new-topic.md\`).
|
||||||
|
|
||||||
|
Do not claim work you did not perform.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExplorerRole(
|
||||||
|
adapter: AgentFn,
|
||||||
|
{ extract }: CreateExplorerRoleDeps,
|
||||||
|
): Role<ExplorerMeta> {
|
||||||
|
return createRole(
|
||||||
|
adapter,
|
||||||
|
async (ctx: ThreadContext) => explorerPrompt(ctx),
|
||||||
|
explorerMetaSchema,
|
||||||
|
extract,
|
||||||
|
);
|
||||||
|
}
|
||||||
108
workflows/extract-knowledge/roles/questioner.ts
Normal file
108
workflows/extract-knowledge/roles/questioner.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { AgentFn, Role, ThreadContext, WorkflowMessage } from "@uncaged/nerve-core";
|
||||||
|
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||||
|
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { resolveQueueForQuestioner } from "../lib/knowledge-queue.js";
|
||||||
|
import { resolveWorkdir } from "../lib/workdir.js";
|
||||||
|
|
||||||
|
const questionerExtractSchema = z.object({
|
||||||
|
questions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
question: z.string(),
|
||||||
|
domain: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.length(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type QuestionerMeta = {
|
||||||
|
/** Empty when no .knowledge cards and no work to do. */
|
||||||
|
card: string;
|
||||||
|
questions: { id: string; question: string; domain: string }[];
|
||||||
|
remaining_queue: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateQuestionerRoleDeps = {
|
||||||
|
extract: LlmExtractorConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function questionerSystem(): string {
|
||||||
|
return `You are the **questioner** in an extract-knowledge workflow.
|
||||||
|
|
||||||
|
Read the given markdown knowledge card. Propose exactly **five** technical questions that are **not** already answered or covered by that card.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Questions must be concrete and technical.
|
||||||
|
- Each question needs a stable string id (e.g. q1, q2, q3, q4, q5), a short domain label (e.g. routing, storage), and the question text.
|
||||||
|
- Do not assume access to other files or tools — reason only from the card content shown.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionerUser(card: string, cardBody: string, remainingHint: string[]): string {
|
||||||
|
return `Current card path: ${card}
|
||||||
|
|
||||||
|
Remaining queue after this card (paths, may be empty): ${JSON.stringify(remainingHint)}
|
||||||
|
|
||||||
|
--- Card content ---
|
||||||
|
|
||||||
|
${cardBody}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function questionerPrompt(ctx: ThreadContext): Promise<string> {
|
||||||
|
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<QuestionerMeta> {
|
||||||
|
const inner = createRole(adapter, questionerPrompt, questionerExtractSchema, extract);
|
||||||
|
|
||||||
|
return async (ctx: ThreadContext) => {
|
||||||
|
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) {
|
||||||
|
return {
|
||||||
|
content:
|
||||||
|
"questioner: no `.knowledge` markdown files found and no seed path in the trigger prompt; queue is empty.",
|
||||||
|
meta: {
|
||||||
|
card: "",
|
||||||
|
questions: [],
|
||||||
|
remaining_queue: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = queue[0]!;
|
||||||
|
const remaining_queue = queue.slice(1);
|
||||||
|
const r = await inner(ctx);
|
||||||
|
return {
|
||||||
|
content: r.content,
|
||||||
|
meta: {
|
||||||
|
card,
|
||||||
|
questions: r.meta.questions,
|
||||||
|
remaining_queue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user