From 30bab439205c190dde919fcf39103f9858b1d86d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 25 May 2026 04:50:11 +0000 Subject: [PATCH 1/2] feat(api): connect ingest-planner graph to shared research loop (#952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register graph id ingest-planner with prepare_ingest → P1 research nodes → plan_ingest - Reuse research nodes, shouldRefine, and ZediChatModel (no duplicate tools) - Add POST /api/ingest/graph/run and /graph/resume with TSDoc vs /api/ingest/plan - Extract shouldRefine for shared conditional routing - Vitest: ingest graph wiring and research HITL resume path Co-authored-by: Akimasa Sugai --- .../graphs/ingest/ingestPlannerGraph.test.ts | 211 ++++++++++++++++ .../api/src/agents/core/composeModelConfig.ts | 3 +- server/api/src/agents/graphs/ingest/index.ts | 17 ++ .../graphs/ingest/ingestPlannerGraph.ts | 79 ++++++ .../src/agents/graphs/ingest/nodes/index.ts | 2 + .../agents/graphs/ingest/nodes/planIngest.ts | 74 ++++++ .../graphs/ingest/nodes/prepareIngest.ts | 71 ++++++ server/api/src/agents/graphs/ingest/state.ts | 103 ++++++++ server/api/src/agents/graphs/ingest/types.ts | 13 + server/api/src/agents/index.ts | 8 + .../agents/subgraphs/research/nodes/index.ts | 2 +- .../subgraphs/research/researchGraph.ts | 31 +-- .../agents/subgraphs/research/shouldRefine.ts | 18 ++ server/api/src/app.ts | 3 + server/api/src/routes/ingest.ts | 228 +++++++++++++++++- 15 files changed, 834 insertions(+), 29 deletions(-) create mode 100644 server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts create mode 100644 server/api/src/agents/graphs/ingest/index.ts create mode 100644 server/api/src/agents/graphs/ingest/ingestPlannerGraph.ts create mode 100644 server/api/src/agents/graphs/ingest/nodes/index.ts create mode 100644 server/api/src/agents/graphs/ingest/nodes/planIngest.ts create mode 100644 server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts create mode 100644 server/api/src/agents/graphs/ingest/state.ts create mode 100644 server/api/src/agents/graphs/ingest/types.ts create mode 100644 server/api/src/agents/subgraphs/research/shouldRefine.ts diff --git a/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts b/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts new file mode 100644 index 00000000..d4a7a265 --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts @@ -0,0 +1,211 @@ +/** + * Ingest planner graph (#952) — research subgraph wiring + routing tests. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + prepareIngest, + planIngest, + planQueries, + webSearch, + wikiSearch, + fetchArticles, + evaluateSufficiency, + refineQueries, + compileBatch, +} = vi.hoisted(() => ({ + prepareIngest: vi.fn(), + planIngest: vi.fn(), + planQueries: vi.fn(), + webSearch: vi.fn(), + wikiSearch: vi.fn(), + fetchArticles: vi.fn(), + evaluateSufficiency: vi.fn(), + refineQueries: vi.fn(), + compileBatch: vi.fn(), +})); + +vi.mock("../../../../agents/graphs/ingest/nodes/index.js", async () => { + const real = await vi.importActual< + typeof import("../../../../agents/graphs/ingest/nodes/index.js") + >("../../../../agents/graphs/ingest/nodes/index.js"); + return { ...real, prepareIngest, planIngest }; +}); + +vi.mock("../../../../agents/subgraphs/research/nodes/index.js", async () => { + const real = await vi.importActual< + typeof import("../../../../agents/subgraphs/research/nodes/index.js") + >("../../../../agents/subgraphs/research/nodes/index.js"); + return { + ...real, + planQueries, + webSearch, + wikiSearch, + fetchArticles, + evaluateSufficiency, + refineQueries, + compileBatch, + }; +}); + +import { GraphRunner } from "../../../../agents/runner/graphRunner.js"; +import { __resetRegistryForTests } from "../../../../agents/registry/graphRegistry.js"; +import { + INGEST_PLANNER_GRAPH_ID, + registerIngestPlannerGraph, +} from "../../../../agents/graphs/ingest/index.js"; +import type { GraphContext } from "../../../../agents/core/types/graphContext.js"; +import type { Database } from "../../../../types/index.js"; +import { MemorySaver } from "@langchain/langgraph"; + +function fakeContext(threadId: string): GraphContext { + return { + threadId, + sessionId: threadId, + userId: "user-1", + pageId: "", + graphId: INGEST_PLANNER_GRAPH_ID, + backend: "zedi_managed", + tier: "free", + db: {} as Database, + feature: "ingest_graph:test", + userEmail: null, + }; +} + +const articleInput = { + title: "Test Article", + url: "https://example.com/a", + excerpt: "Body text about testing.", +}; + +const candidatesInput = [{ id: "page-1", title: "Existing", excerpt: "Old content" }]; + +function defaultMocks() { + prepareIngest.mockImplementation(async () => ({ + article: articleInput, + candidates: candidatesInput, + phase: "ingest:prepare", + })); + + planQueries.mockImplementation(async () => ({ + queries: [{ id: "q1", query: "topic", channels: ["web"] }], + maxIterations: 3, + iteration: 0, + phase: "research:plan", + })); + webSearch.mockImplementation(async () => ({ + pendingSources: [{ id: "src:1", kind: "web", title: "Hit", url: "https://hit/" }], + })); + wikiSearch.mockImplementation(async () => ({ pendingSources: [] })); + fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); + evaluateSufficiency.mockImplementation(async (state: { iteration: number }) => ({ + lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + iteration: state.iteration + 1, + phase: "research:evaluated", + })); + compileBatch.mockImplementation( + async (state: { + iteration: number; + queries: unknown[]; + pendingSources: unknown[]; + lastEvaluation: unknown; + }) => ({ + batches: [ + { + id: "batch-1", + iteration: state.iteration, + queries: state.queries, + sources: state.pendingSources, + evaluation: state.lastEvaluation, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + exitReason: "score_threshold", + phase: "research:compile", + }), + ); + + planIngest.mockImplementation(async () => ({ + ingestPlan: { + action: "merge", + reason: "Same topic", + targetPageId: "page-1", + }, + phase: "ingest:planned", + })); +} + +describe("ingestPlannerGraph — research subgraph connection", () => { + beforeEach(() => { + __resetRegistryForTests(); + registerIngestPlannerGraph(); + prepareIngest.mockReset(); + planIngest.mockReset(); + planQueries.mockReset(); + webSearch.mockReset(); + wikiSearch.mockReset(); + fetchArticles.mockReset(); + evaluateSufficiency.mockReset(); + refineQueries.mockReset(); + compileBatch.mockReset(); + defaultMocks(); + }); + + afterEach(() => { + __resetRegistryForTests(); + }); + + it("runs prepare_ingest then research nodes before halting at human_review_research", async () => { + const runner = new GraphRunner(); + const result = await runner.invoke( + { + graphId: INGEST_PLANNER_GRAPH_ID, + context: fakeContext("thread-ingest-1"), + checkpointer: new MemorySaver(), + recursionLimit: 60, + }, + { + kind: "input", + value: { article: articleInput, candidates: candidatesInput }, + }, + ); + + expect(result.status).toBe("interrupted"); + expect(prepareIngest).toHaveBeenCalledTimes(1); + expect(planQueries).toHaveBeenCalledTimes(1); + expect(compileBatch).toHaveBeenCalledTimes(1); + expect(planIngest).not.toHaveBeenCalled(); + }); + + it("reaches plan_ingest after research HITL resume", async () => { + const checkpointer = new MemorySaver(); + const runner = new GraphRunner(); + const ctx = fakeContext("thread-ingest-2"); + + await runner.invoke( + { + graphId: INGEST_PLANNER_GRAPH_ID, + context: ctx, + checkpointer, + recursionLimit: 60, + }, + { kind: "input", value: { article: articleInput, candidates: candidatesInput } }, + ); + + const resumed = await runner.resume( + { + graphId: INGEST_PLANNER_GRAPH_ID, + context: ctx, + checkpointer, + recursionLimit: 60, + }, + { approvedSourceIds: ["src:1"] }, + ); + + expect(resumed.status).toBe("completed"); + expect(planIngest).toHaveBeenCalledTimes(1); + const output = resumed.output as { ingestPlan?: { action?: string } }; + expect(output.ingestPlan?.action).toBe("merge"); + }); +}); diff --git a/server/api/src/agents/core/composeModelConfig.ts b/server/api/src/agents/core/composeModelConfig.ts index a3740721..4cf9447c 100644 --- a/server/api/src/agents/core/composeModelConfig.ts +++ b/server/api/src/agents/core/composeModelConfig.ts @@ -5,6 +5,7 @@ import { WIKI_COMPOSE_GRAPH_ID } from "../graphs/wikiCompose/index.js"; import { getOrchestratorModelId } from "../subgraphs/research/nodes/planQueries.js"; import { RESEARCH_GRAPH_ID } from "../subgraphs/research/index.js"; +import { INGEST_PLANNER_GRAPH_ID } from "../graphs/ingest/index.js"; const DRAFT_MODEL_ENV = "WIKI_COMPOSE_DRAFT_MODEL_ID"; const DRAFT_MODEL_FALLBACK = "claude-3-5-sonnet"; @@ -23,7 +24,7 @@ export function getComposeModelIdsForGraph(graphId: string): string[] { const draft = getDraftModelId(); return orchestrator === draft ? [orchestrator] : [orchestrator, draft]; } - if (graphId === RESEARCH_GRAPH_ID) { + if (graphId === RESEARCH_GRAPH_ID || graphId === INGEST_PLANNER_GRAPH_ID) { return [getOrchestratorModelId()]; } return [getOrchestratorModelId()]; diff --git a/server/api/src/agents/graphs/ingest/index.ts b/server/api/src/agents/graphs/ingest/index.ts new file mode 100644 index 00000000..dfafbbfc --- /dev/null +++ b/server/api/src/agents/graphs/ingest/index.ts @@ -0,0 +1,17 @@ +export { + INGEST_PLANNER_GRAPH_ID, + INGEST_PLANNER_GRAPH_VERSION, + registerIngestPlannerGraph, +} from "./ingestPlannerGraph.js"; +export { + IngestPlannerState, + type IngestPlannerStateType, + type IngestPlannerStateUpdate, +} from "./state.js"; +export type { + IngestAction, + IngestPlan, + IngestConflict, + CandidatePage, + IngestArticleSummary, +} from "./types.js"; diff --git a/server/api/src/agents/graphs/ingest/ingestPlannerGraph.ts b/server/api/src/agents/graphs/ingest/ingestPlannerGraph.ts new file mode 100644 index 00000000..0d736e32 --- /dev/null +++ b/server/api/src/agents/graphs/ingest/ingestPlannerGraph.ts @@ -0,0 +1,79 @@ +/** + * Wiki Compose P4 — `ingestPlannerGraph` (issue #952). + * + * 記事クリップ ingest フロー。`prepare_ingest` の後に P1 調査ループ + * (`researchLoopSubgraph` と同じノード / tools / `shouldRefine`)を組み込み、 + * `human_review_research` のあと `plan_ingest` で merge / create / skip を決定する。 + * + * Ingest planner graph: seeds clip context, runs the shared research loop + * (same nodes/tools as Compose), then emits an ingest plan via ZediChatModel. + */ +import { END, START, StateGraph } from "@langchain/langgraph"; +import { registerGraph, type GraphFactory } from "../../registry/graphRegistry.js"; +import { shouldRefine } from "../../subgraphs/research/researchGraph.js"; +import { + planQueries, + webSearch, + wikiSearch, + fetchArticles, + evaluateSufficiency, + refineQueries, + compileBatch, + humanReviewResearch, +} from "../../subgraphs/research/nodes/index.js"; +import { IngestPlannerState } from "./state.js"; +import { prepareIngest, planIngest } from "./nodes/index.js"; + +/** Registered graph id. */ +export const INGEST_PLANNER_GRAPH_ID = "ingest-planner" as const; +/** Registered graph version. */ +export const INGEST_PLANNER_GRAPH_VERSION = "1.0.0"; + +const factory: GraphFactory = ({ checkpointer }) => { + const builder = new StateGraph(IngestPlannerState) + .addNode("prepare_ingest", prepareIngest) + .addNode("plan_ingest", planIngest) + .addEdge(START, "prepare_ingest") + // Research loop (same wiring as `researchLoopSubgraph` / `wireResearchLoopSubgraph`). + .addNode("plan_queries", planQueries) + .addNode("web_search", webSearch) + .addNode("wiki_search", wikiSearch) + .addNode("fetch_articles", fetchArticles) + .addNode("evaluate_sufficiency", evaluateSufficiency) + .addNode("refine_queries", refineQueries) + .addNode("compile_batch", compileBatch) + .addNode("human_review_research", humanReviewResearch) + .addEdge("prepare_ingest", "plan_queries") + .addEdge("plan_queries", "web_search") + .addEdge("plan_queries", "wiki_search") + .addEdge("web_search", "fetch_articles") + .addEdge("wiki_search", "fetch_articles") + .addEdge("fetch_articles", "evaluate_sufficiency") + .addConditionalEdges("evaluate_sufficiency", shouldRefine, { + refine: "refine_queries", + compile: "compile_batch", + }) + .addEdge("refine_queries", "web_search") + .addEdge("refine_queries", "wiki_search") + .addEdge("compile_batch", "human_review_research") + .addEdge("human_review_research", "plan_ingest") + .addEdge("plan_ingest", END); + + return checkpointer ? builder.compile({ checkpointer }) : builder.compile(); +}; + +/** + * Register the ingest planner graph. Idempotent; call from `app.ts` bootstrap. + */ +export function registerIngestPlannerGraph(): void { + registerGraph({ + id: INGEST_PLANNER_GRAPH_ID, + version: INGEST_PLANNER_GRAPH_VERSION, + phase: "ingest", + description: + "Wiki Compose P4: ingest clip planner. Runs the P1 research loop (shared nodes/tools) " + + "then plans merge/create/skip via ZediChatModel. Interrupt at human_review_research; " + + "resume payload matches wiki-compose-research. Coexists with POST /api/ingest/plan (#595).", + factory, + }); +} diff --git a/server/api/src/agents/graphs/ingest/nodes/index.ts b/server/api/src/agents/graphs/ingest/nodes/index.ts new file mode 100644 index 00000000..7e2cde52 --- /dev/null +++ b/server/api/src/agents/graphs/ingest/nodes/index.ts @@ -0,0 +1,2 @@ +export { prepareIngest } from "./prepareIngest.js"; +export { planIngest } from "./planIngest.js"; diff --git a/server/api/src/agents/graphs/ingest/nodes/planIngest.ts b/server/api/src/agents/graphs/ingest/nodes/planIngest.ts new file mode 100644 index 00000000..8e5636a3 --- /dev/null +++ b/server/api/src/agents/graphs/ingest/nodes/planIngest.ts @@ -0,0 +1,74 @@ +/** + * `plan_ingest` — structured ingest plan after the shared research loop (#952). + * + * 調査ループ完了後、クリップ記事と候補ページから merge / create / skip を決める。 + * LLM 呼び出しは `createZediChatModel` 経由(`ingestPlanner.ts` のプロンプトを再利用)。 + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { z } from "zod"; +import { createZediChatModel } from "../../../core/llm/modelFactory.js"; +import { getOrchestratorModelId } from "../../../subgraphs/research/nodes/planQueries.js"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import { + buildIngestPlannerPrompt, + parseIngestPlanResponse, +} from "../../../../services/ingestPlanner.js"; +import type { IngestPlannerStateType, IngestPlannerStateUpdate } from "../state.js"; + +const ingestPlanSchema = z.object({ + action: z.enum(["merge", "create", "skip"]), + reason: z.string().min(1), + targetPageId: z.string().optional(), + title: z.string().optional(), + summary: z.string().optional(), + conflicts: z + .array( + z.object({ + claim: z.string().min(1), + existing: z.string().min(1), + note: z.string().optional(), + }), + ) + .optional(), +}); + +/** + * Produce {@link IngestPlan} via ZediChatModel after research completes. + */ +export async function planIngest( + state: IngestPlannerStateType, + config: LangGraphRunnableConfig, +): Promise { + const ctx = getGraphContext(config); + if (!state.article) { + throw new Error("plan_ingest: article is missing from state"); + } + + const messages = buildIngestPlannerPrompt({ + article: state.article, + candidates: state.candidates, + userSchema: state.userSchema ?? undefined, + }); + + const model = await createZediChatModel({ + modelId: getOrchestratorModelId(), + userId: ctx.userId, + tier: ctx.tier, + db: ctx.db, + feature: `${ctx.feature}:plan_ingest`, + backend: ctx.backend, + temperature: 0.2, + maxTokens: 1024, + }); + + const structured = model.withStructuredOutput(ingestPlanSchema, { name: "plan_ingest" }); + const raw = await structured.invoke(messages.map((m) => ({ role: m.role, content: m.content }))); + + const validCandidateIds = new Set(state.candidates.map((c) => c.id)); + const ingestPlan = parseIngestPlanResponse(JSON.stringify(raw), { validCandidateIds }); + + return { + ingestPlan, + phase: "ingest:planned", + }; +} diff --git a/server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts b/server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts new file mode 100644 index 00000000..cdfadfff --- /dev/null +++ b/server/api/src/agents/graphs/ingest/nodes/prepareIngest.ts @@ -0,0 +1,71 @@ +/** + * `prepare_ingest` — seeds article / candidates and messages for the research loop. + * + * `POST /api/ingest/graph/run` の input を state に投影し、続く + * `researchLoopSubgraph`(共有ノード配線)が参照する `messages` を組み立てる。 + */ +import { HumanMessage } from "@langchain/core/messages"; +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import type { IngestPlannerStateType, IngestPlannerStateUpdate } from "../state.js"; + +function clampMaxIterations(raw: number): number { + if (!Number.isFinite(raw)) return 3; + const truncated = Math.trunc(raw); + return Math.min(Math.max(truncated, 1), 5); +} + +/** + * Project graph run input into ingest + research seed state. + * + * LangGraph merges `POST /run` input keys that match state annotations (`article`, + * `candidates`, `userSchema`, `maxIterations`) before this node runs. + */ +export async function prepareIngest( + state: IngestPlannerStateType, + config: LangGraphRunnableConfig, +): Promise { + const ctx = getGraphContext(config); + + const article = state.article; + if (!article?.title?.trim() || !article.url?.trim()) { + throw new Error("prepare_ingest: article { title, url, excerpt } is required"); + } + + const candidates = state.candidates; + const userSchema = state.userSchema; + const maxIterations = clampMaxIterations(state.maxIterations); + + const candidateBlock = + candidates.length === 0 + ? "(no candidates)" + : candidates + .map( + (c, i) => + `[${i + 1}] id=${c.id}\n title: ${c.title}\n excerpt: ${c.excerpt.slice(0, 400)}`, + ) + .join("\n\n"); + + const brief = [ + "[Ingest clip]", + `title: ${article.title}`, + `url: ${article.url}`, + "", + "excerpt:", + article.excerpt.slice(0, 4000), + "", + "## CANDIDATES", + candidateBlock, + ].join("\n"); + + return { + article, + candidates, + userSchema, + maxIterations, + userId: ctx.userId, + pageId: ctx.pageId, + phase: "ingest:prepare", + messages: [new HumanMessage(brief)], + }; +} diff --git a/server/api/src/agents/graphs/ingest/state.ts b/server/api/src/agents/graphs/ingest/state.ts new file mode 100644 index 00000000..f812e8a1 --- /dev/null +++ b/server/api/src/agents/graphs/ingest/state.ts @@ -0,0 +1,103 @@ +/** + * `IngestPlannerState` — LangGraph state for the Ingest planner graph (#952). + * + * `ResearchLoopState` の channel 群を superset として保持し、 + * `wireResearchLoopSubgraph` で P1 調査ループを組み込む。記事クリップ用の + * `article` / `candidates` / `ingestPlan` を追加する。 + * + * Extends research-loop channels so {@link wireResearchLoopSubgraph} can share + * nodes with Compose. Adds ingest-specific fields for clip planning. + */ +import { Annotation } from "@langchain/langgraph"; +import { BaseState } from "../../core/state/baseState.js"; +import type { + AdditionalResearchRequest, + Evaluation, + ExitReason, + PlannedQuery, + ResearchBatch, + Source, +} from "../../subgraphs/research/types.js"; +import type { CandidatePage, IngestArticleSummary, IngestPlan } from "./types.js"; + +function mergeSourcesById(prev: Source[], next: Source[] | undefined): Source[] { + if (!next || next.length === 0) return prev; + const order: string[] = []; + const map = new Map(); + for (const s of prev) { + if (!map.has(s.id)) order.push(s.id); + map.set(s.id, s); + } + for (const s of next) { + if (!map.has(s.id)) order.push(s.id); + map.set(s.id, s); + } + return order.map((id) => map.get(id) as Source); +} + +export const IngestPlannerState = Annotation.Root({ + ...BaseState.spec, + + // ── Ingest clip input ───────────────────────────────────────────────────── + article: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), + candidates: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + userSchema: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), + ingestPlan: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), + + // ── Research mirror (matches ResearchLoopState) ─────────────────────────── + iteration: Annotation({ + reducer: (_prev, next) => next, + default: () => 0, + }), + maxIterations: Annotation({ + reducer: (prev, next) => next ?? prev, + default: () => 3, + }), + queries: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + pendingSources: Annotation({ + reducer: mergeSourcesById, + default: () => [], + }), + lastEvaluation: Annotation({ + reducer: (_prev, next) => next, + default: () => null, + }), + exitReason: Annotation({ + reducer: (_prev, next) => next, + default: () => null, + }), + batches: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : [...prev, ...next]), + default: () => [], + }), + approvedResearch: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + rejectedResearch: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + additionalRequest: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), +}); + +export type IngestPlannerStateType = typeof IngestPlannerState.State; +export type IngestPlannerStateUpdate = typeof IngestPlannerState.Update; diff --git a/server/api/src/agents/graphs/ingest/types.ts b/server/api/src/agents/graphs/ingest/types.ts new file mode 100644 index 00000000..a6bbd3da --- /dev/null +++ b/server/api/src/agents/graphs/ingest/types.ts @@ -0,0 +1,13 @@ +/** + * Ingest planner graph types (issue #952). + * + * `ingestPlanner.ts` サービス型の graph 用エイリアス。サービス層を正とし、 + * graph state は同じ shape を参照する。 + */ +export type { + IngestAction, + IngestPlan, + IngestConflict, + CandidatePage, + IngestArticleSummary, +} from "../../../services/ingestPlanner.js"; diff --git a/server/api/src/agents/index.ts b/server/api/src/agents/index.ts index 3f469ecd..7288de97 100644 --- a/server/api/src/agents/index.ts +++ b/server/api/src/agents/index.ts @@ -89,6 +89,14 @@ export { type ResearchResumeParsed, type HumanReviewInterruptPayload, } from "./subgraphs/research/index.js"; +export { + INGEST_PLANNER_GRAPH_ID, + INGEST_PLANNER_GRAPH_VERSION, + registerIngestPlannerGraph, + IngestPlannerState, + type IngestPlannerStateType, + type IngestPlannerStateUpdate, +} from "./graphs/ingest/index.js"; export { WIKI_COMPOSE_GRAPH_ID, WIKI_COMPOSE_GRAPH_VERSION, diff --git a/server/api/src/agents/subgraphs/research/nodes/index.ts b/server/api/src/agents/subgraphs/research/nodes/index.ts index 7c836956..bb85dc15 100644 --- a/server/api/src/agents/subgraphs/research/nodes/index.ts +++ b/server/api/src/agents/subgraphs/research/nodes/index.ts @@ -13,4 +13,4 @@ export { evaluateSufficiency } from "./evaluateSufficiency.js"; export { refineQueries } from "./refineQueries.js"; export { compileBatch } from "./compileBatch.js"; export { humanReviewResearch } from "./humanReviewResearch.js"; -export { shouldRefine } from "../researchGraph.js"; +export { shouldRefine } from "../shouldRefine.js"; diff --git a/server/api/src/agents/subgraphs/research/researchGraph.ts b/server/api/src/agents/subgraphs/research/researchGraph.ts index 15b7ef55..bfb91d73 100644 --- a/server/api/src/agents/subgraphs/research/researchGraph.ts +++ b/server/api/src/agents/subgraphs/research/researchGraph.ts @@ -14,7 +14,7 @@ * node which projects it into `approvedResearch` / `rejectedResearch`. */ import { END, START, StateGraph } from "@langchain/langgraph"; -import { ResearchLoopState, type ResearchLoopStateType } from "./state.js"; +import { ResearchLoopState } from "./state.js"; import { registerGraph, type GraphFactory } from "../../registry/graphRegistry.js"; import { planQueries, @@ -27,32 +27,16 @@ import { humanReviewResearch, } from "./nodes/index.js"; +import { shouldRefine } from "./shouldRefine.js"; + +export { shouldRefine }; + /** Registered graph id. */ export const RESEARCH_GRAPH_ID = "wiki-compose-research" as const; /** Registered graph version. Bump when behaviour changes meaningfully. */ export const RESEARCH_GRAPH_VERSION = "1.0.0"; -/** - * 終了条件判定。`evaluate_sufficiency` の直後に呼ばれる。 - * - * Conditional edge predicate: - * - `score >= 0.75` → `"compile"` (exit loop) - * - `iteration >= maxIterations` → `"compile"` (hard cap) - * - otherwise → `"refine"` (loop back) - * - * Pure function; unit-tested directly in `researchGraph.conditional.test.ts`. - */ -export function shouldRefine(state: ResearchLoopStateType): "refine" | "compile" { - const score = state.lastEvaluation?.score; - if (typeof score === "number" && score >= 0.75) return "compile"; - if (state.iteration >= state.maxIterations) return "compile"; - return "refine"; -} - const factory: GraphFactory = ({ checkpointer }) => { - // LangGraph's `StateGraph` chaining is heavily typed; we let the inference - // flow naturally instead of pinning intermediate types, mirroring the stub - // graph (`registry/stubGraph.ts`). const builder = new StateGraph(ResearchLoopState) .addNode("plan_queries", planQueries) .addNode("web_search", webSearch) @@ -63,10 +47,8 @@ const factory: GraphFactory = ({ checkpointer }) => { .addNode("compile_batch", compileBatch) .addNode("human_review_research", humanReviewResearch) .addEdge(START, "plan_queries") - // Parallel fan-out from plan_queries. .addEdge("plan_queries", "web_search") .addEdge("plan_queries", "wiki_search") - // Implicit join on fetch_articles (both fan-out branches feed into it). .addEdge("web_search", "fetch_articles") .addEdge("wiki_search", "fetch_articles") .addEdge("fetch_articles", "evaluate_sufficiency") @@ -74,14 +56,11 @@ const factory: GraphFactory = ({ checkpointer }) => { refine: "refine_queries", compile: "compile_batch", }) - // refine_queries kicks the next iteration (loop back via web_search). .addEdge("refine_queries", "web_search") .addEdge("refine_queries", "wiki_search") .addEdge("compile_batch", "human_review_research") .addEdge("human_review_research", END); - // `checkpointer === false` is honoured by LangGraph as "no persistence" (the - // test path), `BaseCheckpointSaver` enables resume in production. return checkpointer ? builder.compile({ checkpointer }) : builder.compile(); }; diff --git a/server/api/src/agents/subgraphs/research/shouldRefine.ts b/server/api/src/agents/subgraphs/research/shouldRefine.ts new file mode 100644 index 00000000..1368b1ee --- /dev/null +++ b/server/api/src/agents/subgraphs/research/shouldRefine.ts @@ -0,0 +1,18 @@ +/** + * Research loop exit predicate (`evaluate_sufficiency` → refine | compile). + */ +import type { ResearchLoopStateType } from "./state.js"; + +/** + * 終了条件判定。`evaluate_sufficiency` の直後に呼ばれる。 + * + * - `score >= 0.75` → `"compile"` + * - `iteration >= maxIterations` → `"compile"` + * - otherwise → `"refine"` + */ +export function shouldRefine(state: ResearchLoopStateType): "refine" | "compile" { + const score = state.lastEvaluation?.score; + if (typeof score === "number" && score >= 0.75) return "compile"; + if (state.iteration >= state.maxIterations) return "compile"; + return "refine"; +} diff --git a/server/api/src/app.ts b/server/api/src/app.ts index 676abc00..22952b73 100644 --- a/server/api/src/app.ts +++ b/server/api/src/app.ts @@ -48,6 +48,7 @@ import userAiCredentialRoutes from "./routes/userAiCredentials.js"; import { registerStubGraph } from "./agents/registry/stubGraph.js"; import { registerResearchLoopGraph } from "./agents/subgraphs/research/index.js"; import { registerWikiComposeGraph } from "./agents/graphs/wikiCompose/index.js"; +import { registerIngestPlannerGraph } from "./agents/graphs/ingest/index.js"; /** * Creates and configures the Hono API app (routes, CORS, etc.). @@ -58,12 +59,14 @@ export function createApp(): Hono { // - `wiki-compose-stub` — P0 smoke test (#948) // - `wiki-compose-research` — P1 自律調査ループ (#949) // - `wiki-compose` — P2 全体オーケストレータ (#950) + // - `ingest-planner` — P4 ingest + shared research loop (#952) // // Register all Wiki Compose graphs. Calls are idempotent across hot // reloads (registry uses `Map#set` so the latest registration wins). registerStubGraph(); registerResearchLoopGraph(); registerWikiComposeGraph(); + registerIngestPlannerGraph(); const app = new Hono(); const wildcard = isWildcardCors(); diff --git a/server/api/src/routes/ingest.ts b/server/api/src/routes/ingest.ts index e90c3838..b5bddf0e 100644 --- a/server/api/src/routes/ingest.ts +++ b/server/api/src/routes/ingest.ts @@ -1,16 +1,23 @@ /** - * /api/ingest — LLM Wiki ingest flow (P1, otomatty/zedi#595). + * /api/ingest — LLM Wiki ingest flow (P1 #595, graph P4 #952). * * POST /api/ingest/plan — dry-run: given a URL, propose how the article should * be merged / created / skipped in the user's existing Wiki. Does NOT write * to the database. The corresponding apply endpoint is tracked as a follow-up * and will reuse the plan shape returned here. * + * POST /api/ingest/graph/run — invoke graph `ingest-planner` (#952): shared + * research loop + structured ingest plan via ZediChatModel. See route TSDoc below. + * + * POST /api/ingest/graph/resume — resume an interrupted `ingest-planner` run + * (HITL at `human_review_research`) using the same `threadId`. + * * LLM Wiki の ingest フロー。プラン生成までの dry-run エンドポイント。 * DB への書き込みは行わず、プレビュー用のプラン JSON を返す。 * apply(実適用)エンドポイントは後続 PR で追加する。 */ import { Hono } from "hono"; +import { randomUUID } from "node:crypto"; import { HTTPException } from "hono/http-exception"; import { sql } from "drizzle-orm"; import { authRequired } from "../middleware/auth.js"; @@ -34,9 +41,18 @@ import { pages } from "../schema/pages.js"; import { pageContents } from "../schema/pageContents.js"; import { recordActivity } from "../services/activityLogService.js"; import type { AppEnv, AIProviderType } from "../types/index.js"; +import { GraphRunner } from "../agents/runner/graphRunner.js"; +import { INGEST_PLANNER_GRAPH_ID } from "../agents/graphs/ingest/index.js"; +import type { IngestArticleSummary } from "../services/ingestPlanner.js"; +import { assertSupportedComposeBackend } from "../agents/core/llm/modelFactory.js"; +import { assertComposeBackendReady } from "../agents/core/composeBackendValidation.js"; +import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js"; +import type { ExecutionBackend } from "../agents/core/types/executionBackend.js"; const app = new Hono(); +const INGEST_GRAPH_RECURSION_LIMIT = 60; + /** * リクエストボディ。 * Request body for POST /api/ingest/plan. @@ -271,6 +287,216 @@ app.post("/plan", authRequired, rateLimit(), async (c) => { }); }); +/** + * Request body for `POST /api/ingest/graph/run` (#952). + */ +interface IngestGraphRunBody { + /** Optional stable thread id for checkpoint resume (defaults to new UUID). */ + threadId?: string; + backend?: ExecutionBackend; + article?: IngestArticleSummary; + candidates?: CandidatePage[]; + userSchema?: string; + maxIterations?: number; +} + +/** + * Request body for `POST /api/ingest/graph/resume` (#952). + */ +interface IngestGraphResumeBody { + threadId: string; + backend?: ExecutionBackend; + resume: unknown; +} + +/** + * POST /api/ingest/graph/run — LangGraph `ingest-planner` execution (#952). + * + * **Integration with `POST /api/ingest/plan` (#595)** + * + * - `/plan` remains the URL-first production path: server-side article extraction, + * candidate SQL search, and `callProvider` via `ingestPlanner.ts` (no research loop). + * - `/graph/run` expects the caller to supply `article` + `candidates` (typically the + * same shapes `/plan` returns) and runs graph id {@link INGEST_PLANNER_GRAPH_ID}: + * `prepare_ingest` → shared P1 research nodes → `plan_ingest` (ZediChatModel). + * - Both endpoints return the same {@link IngestPlan} JSON shape on success. + * - Apply persistence stays on `POST /api/ingest/apply` for either path. + * + * **Resume** + * + * When the graph halts at `human_review_research`, the response includes `threadId`. + * Call `POST /api/ingest/graph/resume` with the research resume payload + * (`{ approvedSourceIds, rejectedSourceIds?, note? }`, same as compose research). + */ +app.post("/graph/run", authRequired, rateLimit(), async (c) => { + const userId = c.get("userId"); + const userEmail = c.get("userEmail") ?? null; + const db = c.get("db"); + + let body: IngestGraphRunBody; + try { + body = await c.req.json(); + } catch { + throw new HTTPException(400, { message: "Invalid JSON body" }); + } + + if (!body.article || typeof body.article.title !== "string" || !body.article.url?.trim()) { + throw new HTTPException(400, { message: "article { title, url, excerpt } is required" }); + } + + const candidates = Array.isArray(body.candidates) ? body.candidates : []; + const threadId = + typeof body.threadId === "string" && body.threadId.trim() ? body.threadId.trim() : randomUUID(); + + let backend: ExecutionBackend; + try { + backend = assertSupportedComposeBackend(body.backend ?? "zedi_managed"); + } catch (err) { + const msg = err instanceof Error ? err.message : "unsupported backend"; + throw new HTTPException(400, { message: msg }); + } + + const tier = await getUserTier(userId, db); + await assertComposeBackendReady({ + backend, + graphId: INGEST_PLANNER_GRAPH_ID, + userId, + tier, + db, + }); + + const checkpointer = await resolveCheckpointerForRun(); + const runner = new GraphRunner(); + const result = await runner.invoke( + { + graphId: INGEST_PLANNER_GRAPH_ID, + checkpointer, + recursionLimit: INGEST_GRAPH_RECURSION_LIMIT, + context: { + threadId, + sessionId: threadId, + userId, + userEmail, + pageId: "", + graphId: INGEST_PLANNER_GRAPH_ID, + backend, + tier, + db, + feature: "ingest_graph:run", + }, + }, + { + kind: "input", + value: { + article: body.article, + candidates, + userSchema: body.userSchema ?? null, + maxIterations: body.maxIterations, + }, + }, + ); + + if (result.status === "failed") { + throw new HTTPException(500, { message: result.error ?? "Graph run failed" }); + } + + const output = result.output as + | { + ingestPlan?: unknown; + __interrupt__?: unknown[]; + } + | undefined; + + return c.json({ + status: result.status, + threadId, + graphId: INGEST_PLANNER_GRAPH_ID, + plan: output?.ingestPlan ?? null, + output, + }); +}); + +/** + * POST /api/ingest/graph/resume — resume `ingest-planner` after research HITL (#952). + */ +app.post("/graph/resume", authRequired, rateLimit(), async (c) => { + const userId = c.get("userId"); + const userEmail = c.get("userEmail") ?? null; + const db = c.get("db"); + + let body: IngestGraphResumeBody; + try { + body = await c.req.json(); + } catch { + throw new HTTPException(400, { message: "Invalid JSON body" }); + } + + if (typeof body.threadId !== "string" || !body.threadId.trim()) { + throw new HTTPException(400, { message: "threadId is required" }); + } + const threadId = body.threadId.trim(); + + let backend: ExecutionBackend; + try { + backend = assertSupportedComposeBackend(body.backend ?? "zedi_managed"); + } catch (err) { + const msg = err instanceof Error ? err.message : "unsupported backend"; + throw new HTTPException(400, { message: msg }); + } + + const tier = await getUserTier(userId, db); + await assertComposeBackendReady({ + backend, + graphId: INGEST_PLANNER_GRAPH_ID, + userId, + tier, + db, + }); + + const checkpointer = await resolveCheckpointerForRun(); + if (checkpointer === false) { + throw new HTTPException(503, { + message: "Graph resume requires DATABASE_URL checkpointing", + }); + } + + const runner = new GraphRunner(); + const result = await runner.resume( + { + graphId: INGEST_PLANNER_GRAPH_ID, + checkpointer, + recursionLimit: INGEST_GRAPH_RECURSION_LIMIT, + context: { + threadId, + sessionId: threadId, + userId, + userEmail, + pageId: "", + graphId: INGEST_PLANNER_GRAPH_ID, + backend, + tier, + db, + feature: "ingest_graph:resume", + }, + }, + body.resume, + ); + + if (result.status === "failed") { + throw new HTTPException(500, { message: result.error ?? "Graph resume failed" }); + } + + const output = result.output as { ingestPlan?: unknown } | undefined; + + return c.json({ + status: result.status, + threadId, + graphId: INGEST_PLANNER_GRAPH_ID, + plan: output?.ingestPlan ?? null, + output, + }); +}); + /** * Request body for POST /api/ingest/apply. * Ingest プラン適用リクエストボディ。 From e983d774cdf9c2e44db129692eaafa9a2756b47e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 25 May 2026 05:01:46 +0000 Subject: [PATCH 2/2] fix(api): apply approved research in ingest plan and guard thread ownership plan_ingest ignored HITL-approved sources, so graph runs produced the same plan as /api/ingest/plan despite the research loop. Append approved research to the planner prompt and reject cross-user checkpoint threadId reuse. Co-authored-by: akimasa.sugai --- .../agents/graphs/ingest/planIngest.test.ts | 37 +++++++++++++ .../agents/graphs/ingest/nodes/planIngest.ts | 53 +++++++++++++++++-- server/api/src/routes/ingest.ts | 43 +++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 server/api/src/__tests__/agents/graphs/ingest/planIngest.test.ts diff --git a/server/api/src/__tests__/agents/graphs/ingest/planIngest.test.ts b/server/api/src/__tests__/agents/graphs/ingest/planIngest.test.ts new file mode 100644 index 00000000..9a05b689 --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/ingest/planIngest.test.ts @@ -0,0 +1,37 @@ +/** + * `plan_ingest` prompt helpers (#952). + */ +import { describe, expect, it } from "vitest"; +import { appendApprovedResearchToPlannerMessages } from "../../../../agents/graphs/ingest/nodes/planIngest.js"; +import { buildIngestPlannerPrompt } from "../../../../services/ingestPlanner.js"; + +const article = { + title: "Test", + url: "https://example.com/a", + excerpt: "Body", +}; + +describe("appendApprovedResearchToPlannerMessages", () => { + it("appends approved source titles and excerpts to the user message", () => { + const base = buildIngestPlannerPrompt({ article, candidates: [] }); + const enriched = appendApprovedResearchToPlannerMessages(base, [ + { + id: "src:1", + kind: "fetched", + title: "Background article", + excerpt: "Important context for merge decision.", + }, + ]); + + const user = enriched.at(-1); + expect(user?.role).toBe("user"); + expect(user?.content).toContain("## APPROVED RESEARCH"); + expect(user?.content).toContain("Background article"); + expect(user?.content).toContain("Important context"); + }); + + it("returns messages unchanged when no approved sources", () => { + const base = buildIngestPlannerPrompt({ article, candidates: [] }); + expect(appendApprovedResearchToPlannerMessages(base, [])).toEqual(base); + }); +}); diff --git a/server/api/src/agents/graphs/ingest/nodes/planIngest.ts b/server/api/src/agents/graphs/ingest/nodes/planIngest.ts index 8e5636a3..21b90137 100644 --- a/server/api/src/agents/graphs/ingest/nodes/planIngest.ts +++ b/server/api/src/agents/graphs/ingest/nodes/planIngest.ts @@ -13,8 +13,48 @@ import { buildIngestPlannerPrompt, parseIngestPlanResponse, } from "../../../../services/ingestPlanner.js"; +import type { Source } from "../../../subgraphs/research/types.js"; +import type { AIMessage } from "../../../../types/index.js"; import type { IngestPlannerStateType, IngestPlannerStateUpdate } from "../state.js"; +const APPROVED_RESEARCH_MAX_SOURCES = 20; +const APPROVED_RESEARCH_EXCERPT_MAX = 800; + +/** + * Append HITL-approved research sources to the ingest planner user message (#952). + * Exported for unit tests. + */ +export function appendApprovedResearchToPlannerMessages( + messages: AIMessage[], + approved: Source[], +): AIMessage[] { + if (approved.length === 0) return messages; + const block = approved + .slice(0, APPROVED_RESEARCH_MAX_SOURCES) + .map((s, i) => { + const tag = s.kind === "fetched" ? "FETCHED" : s.kind === "wiki" ? "WIKI" : "WEB"; + const preview = (s.excerpt ?? s.snippet ?? "").slice(0, APPROVED_RESEARCH_EXCERPT_MAX); + return `[${i + 1}] (${tag}) ${s.title}\n${preview || "(no preview)"}`; + }) + .join("\n\n"); + const last = messages.at(-1); + if (!last || last.role !== "user") return messages; + return [ + ...messages.slice(0, -1), + { + ...last, + content: [ + last.content, + "", + "## APPROVED RESEARCH", + "Use these sources when deciding merge / create / skip and when recording conflicts.", + "", + block, + ].join("\n"), + }, + ]; +} + const ingestPlanSchema = z.object({ action: z.enum(["merge", "create", "skip"]), reason: z.string().min(1), @@ -44,11 +84,14 @@ export async function planIngest( throw new Error("plan_ingest: article is missing from state"); } - const messages = buildIngestPlannerPrompt({ - article: state.article, - candidates: state.candidates, - userSchema: state.userSchema ?? undefined, - }); + const messages = appendApprovedResearchToPlannerMessages( + buildIngestPlannerPrompt({ + article: state.article, + candidates: state.candidates, + userSchema: state.userSchema ?? undefined, + }), + state.approvedResearch, + ); const model = await createZediChatModel({ modelId: getOrchestratorModelId(), diff --git a/server/api/src/routes/ingest.ts b/server/api/src/routes/ingest.ts index b5bddf0e..2e6ef2cf 100644 --- a/server/api/src/routes/ingest.ts +++ b/server/api/src/routes/ingest.ts @@ -47,12 +47,51 @@ import type { IngestArticleSummary } from "../services/ingestPlanner.js"; import { assertSupportedComposeBackend } from "../agents/core/llm/modelFactory.js"; import { assertComposeBackendReady } from "../agents/core/composeBackendValidation.js"; import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js"; +import { getRegisteredGraph } from "../agents/registry/graphRegistry.js"; +import type { BaseCheckpointSaver } from "@langchain/langgraph"; import type { ExecutionBackend } from "../agents/core/types/executionBackend.js"; const app = new Hono(); const INGEST_GRAPH_RECURSION_LIMIT = 60; +/** + * Read `userId` stored in an ingest-planner checkpoint, if any. + */ +async function readIngestCheckpointUserId( + threadId: string, + checkpointer: BaseCheckpointSaver, +): Promise { + const registered = getRegisteredGraph(ING_PLANNER_GRAPH_ID); + if (!registered) return null; + const graph = registered.factory({ checkpointer }) as { + getState?: (config: unknown) => Promise<{ values?: Record } | undefined>; + }; + if (typeof graph.getState !== "function") return null; + try { + const snap = await graph.getState({ configurable: { thread_id: threadId } }); + const owner = snap?.values?.userId; + return typeof owner === "string" && owner.length > 0 ? owner : null; + } catch { + return null; + } +} + +/** + * Reject cross-user access when reusing a `threadId` tied to another user's checkpoint. + */ +async function assertIngestThreadAccessible( + threadId: string, + userId: string, + checkpointer: BaseCheckpointSaver | false, +): Promise { + if (checkpointer === false) return; + const owner = await readIngestCheckpointUserId(threadId, checkpointer); + if (owner !== null && owner !== userId) { + throw new HTTPException(403, { message: "threadId is not accessible" }); + } +} + /** * リクエストボディ。 * Request body for POST /api/ingest/plan. @@ -366,6 +405,8 @@ app.post("/graph/run", authRequired, rateLimit(), async (c) => { }); const checkpointer = await resolveCheckpointerForRun(); + await assertIngestThreadAccessible(threadId, userId, checkpointer); + const runner = new GraphRunner(); const result = await runner.invoke( { @@ -460,6 +501,8 @@ app.post("/graph/resume", authRequired, rateLimit(), async (c) => { }); } + await assertIngestThreadAccessible(threadId, userId, checkpointer); + const runner = new GraphRunner(); const result = await runner.resume( {