Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { formatResearchForIngest } from "../../../../agents/graphs/ingest/nodes/formatResearchForIngest.js";
import type { IngestPlannerStateType } from "../../../../agents/graphs/ingest/state.js";

function baseState(): IngestPlannerStateType {
return {
messages: [],
phase: "ingest:prepare",
pageId: "",
userId: "u1",
article: { title: "T", url: "https://a/", excerpt: "body" },
candidates: [],
userSchema: null,
ingestPlan: null,
iteration: 1,
maxIterations: 3,
queries: [],
pendingSources: [],
lastEvaluation: null,
exitReason: null,
batches: [
{
id: "b1",
iteration: 1,
queries: [],
sources: [],
evaluation: { score: 0.9, rationale: "ok", missingAspects: [] },
createdAt: "2026-01-01T00:00:00.000Z",
},
],
approvedResearch: [
{
id: "src:1",
kind: "fetched",
title: "Research hit",
url: "https://hit/",
excerpt: "Details from web",
},
],
rejectedResearch: [],
additionalRequest: null,
};
}

describe("formatResearchForIngest", () => {
it("includes approved sources and evaluation in the prompt block", () => {
const block = formatResearchForIngest(baseState());
expect(block).toContain("APPROVED RESEARCH SOURCES");
expect(block).toContain("src:1");
expect(block).toContain("Research hit");
expect(block).toContain("RESEARCH EVALUATION");
expect(block).toContain("score: 0.9");
});

it("returns empty string when no research output exists", () => {
const state = baseState();
state.approvedResearch = [];
state.batches = [];
expect(formatResearchForIngest(state)).toBe("");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* Ingest planner graph (#952) — research subgraph wiring + routing tests.
* ingest プランナーグラフ — 調査 subgraph 配線とルーティングのテスト。
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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");
});
});
11 changes: 11 additions & 0 deletions server/api/src/__tests__/services/ingestPlanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
extractJsonFromResponse,
IngestPlanParseError,
parseIngestPlanResponse,
parseIngestPlanValue,
planIngest,
type CallProviderAdapter,
type CandidatePage,
Expand Down Expand Up @@ -57,6 +58,16 @@ describe("extractJsonFromResponse", () => {
});
});

describe("parseIngestPlanValue", () => {
it("validates structured objects without JSON round-trip", () => {
const plan = parseIngestPlanValue({
action: "skip",
reason: "no value",
});
expect(plan.action).toBe("skip");
});
});

describe("parseIngestPlanResponse", () => {
const validIds = new Set(sampleCandidates.map((c) => c.id));

Expand Down
3 changes: 2 additions & 1 deletion server/api/src/agents/core/composeModelConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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()];
Expand Down
17 changes: 17 additions & 0 deletions server/api/src/agents/graphs/ingest/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading