From 5b873ac5d66d832381291ef2ec2c10d5a2568a49 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 25 May 2026 07:56:47 +0000 Subject: [PATCH 1/2] feat(api): wiki compose P5 dynamic routing and maintenance graph (#953) - Add routeAfterBrief and routeAfterResearch conditional edges to wikiComposeGraph - Add skip_research and conflict_resolution nodes with Vitest coverage - Register wiki-maintenance graph (broken links + stub page scan) - Bump wiki-compose graph version to 1.1.0 Co-authored-by: Akimasa Sugai --- .../wikiCompose/wikiComposeGraph.test.ts | 74 ++++++++++++- .../wikiCompose/wikiComposeRouting.test.ts | 89 +++++++++++++++ .../wikiMaintenanceGraph.test.ts | 101 ++++++++++++++++++ .../src/agents/graphs/wikiCompose/index.ts | 10 ++ .../wikiCompose/nodes/conflictResolution.ts | 49 +++++++++ .../agents/graphs/wikiCompose/nodes/index.ts | 2 + .../graphs/wikiCompose/nodes/skipResearch.ts | 22 ++++ .../graphs/wikiCompose/resumeSchemas.ts | 12 +++ .../src/agents/graphs/wikiCompose/routing.ts | 55 ++++++++++ .../src/agents/graphs/wikiCompose/state.ts | 11 ++ .../src/agents/graphs/wikiCompose/types.ts | 10 +- .../graphs/wikiCompose/wikiComposeGraph.ts | 87 +++++++-------- .../agents/graphs/wikiMaintenance/index.ts | 14 +++ .../graphs/wikiMaintenance/nodes/index.ts | 3 + .../wikiMaintenance/nodes/planMaintenance.ts | 21 ++++ .../wikiMaintenance/nodes/scanBrokenLinks.ts | 26 +++++ .../wikiMaintenance/nodes/scanStubPages.ts | 51 +++++++++ .../agents/graphs/wikiMaintenance/state.ts | 25 +++++ .../agents/graphs/wikiMaintenance/types.ts | 25 +++++ .../wikiMaintenance/wikiMaintenanceGraph.ts | 51 +++++++++ server/api/src/agents/index.ts | 17 +++ .../src/agents/subgraphs/research/types.ts | 7 +- server/api/src/app.ts | 3 + server/api/src/routes/composeSessions.ts | 2 + 24 files changed, 722 insertions(+), 45 deletions(-) create mode 100644 server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeRouting.test.ts create mode 100644 server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/skipResearch.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/routing.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/index.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/nodes/index.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/nodes/planMaintenance.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/nodes/scanBrokenLinks.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/nodes/scanStubPages.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/state.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/types.ts create mode 100644 server/api/src/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.ts diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts index bd2d6c0c..a242cbaa 100644 --- a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts @@ -1,5 +1,5 @@ /** - * Wiki Compose orchestrator graph (#950) — wiring + interrupt tests. + * Wiki Compose orchestrator graph (#950, #953) — wiring + interrupt tests. * * 受け入れ条件 #1 / #6 / 技術 #1: * - `wikiComposeGraph` が P1 subgraph を組み込んでいる (channels 共有で表現) @@ -285,4 +285,76 @@ describe("wikiComposeGraph — orchestrator wiring", () => { expect(finalState.completion?.markdown).toMatch(/Overview/); expect(finalState.completion?.markdown).toMatch(/Details/); }); + + it("skips research when Brief emits zero questions (P5)", async () => { + briefDialogue.mockImplementation(async () => ({ + briefQuestions: [], + pageSnapshot: { pageId: "page-1", title: "Self-evident Title", body: "", hasContent: false }, + phase: "brief:await_user", + })); + + const checkpointer = new MemorySaver(); + const runner = new GraphRunner(); + const ctx = fakeContext("thread-skip-research"); + + await runner.invoke( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { kind: "input", value: { messages: [{ role: "user", content: "title: Obvious" }] } }, + ); + + const afterBrief = await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { answers: [], appendToExisting: false }, + ); + + expect(afterBrief.status).toBe("interrupted"); + expect(planQueries).not.toHaveBeenCalled(); + expect(compileBatch).not.toHaveBeenCalled(); + expect(structureDialogue).toHaveBeenCalledTimes(1); + }); + + it("halts at conflict_resolution when many sources are rejected (P5)", async () => { + webSearch.mockImplementation(async () => ({ + pendingSources: [ + { id: "src:a", kind: "web", title: "A", url: "https://a/" }, + { id: "src:b", kind: "web", title: "B", url: "https://b/" }, + { id: "src:c", kind: "web", title: "C", url: "https://c/" }, + ], + })); + + const checkpointer = new MemorySaver(); + const runner = new GraphRunner(); + const ctx = fakeContext("thread-conflict"); + + await runner.invoke( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { kind: "input", value: { messages: [{ role: "user", content: "title: Hello" }] } }, + ); + await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { answers: [], appendToExisting: false }, + ); + + const conflictHalt = await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { + approvedSourceIds: ["src:a"], + rejectedSourceIds: ["src:b", "src:c"], + }, + ); + + expect(conflictHalt.status).toBe("interrupted"); + const interruptState = conflictHalt.output as { + __interrupt__?: Array<{ value: { kind?: string } }>; + }; + expect(interruptState.__interrupt__?.[0]?.value?.kind).toBe("conflict_resolution"); + expect(structureDialogue).not.toHaveBeenCalled(); + + const afterConflict = await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { acknowledged: true }, + ); + expect(afterConflict.status).toBe("interrupted"); + expect(structureDialogue).toHaveBeenCalledTimes(1); + }); }); diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeRouting.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeRouting.test.ts new file mode 100644 index 00000000..13f50901 --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeRouting.test.ts @@ -0,0 +1,89 @@ +/** + * Wiki Compose P5 routing predicates (#953). + */ +import { describe, expect, it } from "vitest"; +import { + routeAfterBrief, + routeAfterResearch, + shouldResolveResearchConflicts, +} from "../../../../agents/graphs/wikiCompose/routing.js"; +import type { WikiComposeStateType } from "../../../../agents/graphs/wikiCompose/state.js"; + +function minimalState(overrides: Partial = {}): WikiComposeStateType { + return { + messages: [], + phase: "init", + pageId: "page-1", + userId: "user-1", + chatSeed: null, + pageSnapshot: null, + briefQuestions: [], + brief: null, + iteration: 0, + maxIterations: 3, + queries: [], + pendingSources: [], + lastEvaluation: null, + exitReason: null, + batches: [], + approvedResearch: [], + rejectedResearch: [], + additionalRequest: null, + researchConflicts: [], + outlineProposal: [], + approvedOutline: null, + draftedSections: [], + completion: null, + ...overrides, + }; +} + +describe("routeAfterBrief", () => { + it("routes to skip_research when Brief emitted zero questions", () => { + expect(routeAfterBrief(minimalState({ briefQuestions: [] }))).toBe("skip_research"); + }); + + it("routes to skip_research when chatSeed carries a pre-approved outline", () => { + expect( + routeAfterBrief( + minimalState({ + briefQuestions: [{ id: "q1", question: "Scope?", options: [], required: false }], + chatSeed: { outline: "## Intro\n- point", conversationText: "hi" }, + }), + ), + ).toBe("skip_research"); + }); + + it("routes to research when Brief has questions and no chat outline seed", () => { + expect( + routeAfterBrief( + minimalState({ + briefQuestions: [{ id: "q1", question: "Audience?", options: [], required: true }], + chatSeed: null, + }), + ), + ).toBe("research"); + }); +}); + +describe("routeAfterResearch / shouldResolveResearchConflicts", () => { + it("detects conflict when ≥2 rejected and ≥1 approved", () => { + const state = minimalState({ + approvedResearch: [{ id: "a", kind: "web", title: "A" }], + rejectedResearch: [ + { id: "b", kind: "web", title: "B" }, + { id: "c", kind: "web", title: "C" }, + ], + }); + expect(shouldResolveResearchConflicts(state)).toBe(true); + expect(routeAfterResearch(state)).toBe("conflict_resolution"); + }); + + it("routes to structure when rejections are below threshold", () => { + const state = minimalState({ + approvedResearch: [{ id: "a", kind: "web", title: "A" }], + rejectedResearch: [{ id: "b", kind: "web", title: "B" }], + }); + expect(routeAfterResearch(state)).toBe("structure"); + }); +}); diff --git a/server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts b/server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts new file mode 100644 index 00000000..e51657c0 --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts @@ -0,0 +1,101 @@ +/** + * Wiki maintenance graph (#953) — wiring + scan node tests. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { scanBrokenLinks, scanStubPages } = vi.hoisted(() => ({ + scanBrokenLinks: vi.fn(), + scanStubPages: vi.fn(), +})); + +vi.mock("../../../../agents/graphs/wikiMaintenance/nodes/index.js", async () => { + const real = await vi.importActual< + typeof import("../../../../agents/graphs/wikiMaintenance/nodes/index.js") + >("../../../../agents/graphs/wikiMaintenance/nodes/index.js"); + return { ...real, scanBrokenLinks, scanStubPages }; +}); + +import { GraphRunner } from "../../../../agents/runner/graphRunner.js"; +import { __resetRegistryForTests } from "../../../../agents/registry/graphRegistry.js"; +import { + WIKI_MAINTENANCE_GRAPH_ID, + registerWikiMaintenanceGraph, +} from "../../../../agents/graphs/wikiMaintenance/index.js"; +import type { GraphContext } from "../../../../agents/core/types/graphContext.js"; +import type { Database } from "../../../../types/index.js"; + +function fakeContext(threadId: string): GraphContext { + return { + threadId, + sessionId: threadId, + userId: "user-1", + pageId: "page-1", + graphId: WIKI_MAINTENANCE_GRAPH_ID, + backend: "zedi_managed", + tier: "free", + db: {} as Database, + feature: "wiki_maintenance:test", + userEmail: null, + }; +} + +describe("wikiMaintenanceGraph", () => { + beforeEach(() => { + __resetRegistryForTests(); + registerWikiMaintenanceGraph(); + scanBrokenLinks.mockReset(); + scanStubPages.mockReset(); + scanBrokenLinks.mockImplementation(async () => ({ + brokenLinkFindings: [ + { + rule: "broken_link", + severity: "error", + pageIds: ["p1", "p2"], + detail: { sourceId: "p1" }, + }, + ], + phase: "maintenance:broken_links_scanned", + })); + scanStubPages.mockImplementation(async () => ({ + stubPageFindings: [ + { + rule: "stub_page", + severity: "info", + pageIds: ["p3"], + detail: { title: "Draft" }, + }, + ], + phase: "maintenance:stub_pages_scanned", + })); + }); + + afterEach(() => { + __resetRegistryForTests(); + }); + + it("runs scan → plan and completes with a maintenance plan", async () => { + const runner = new GraphRunner(); + const result = await runner.invoke( + { + graphId: WIKI_MAINTENANCE_GRAPH_ID, + context: fakeContext("maint-1"), + checkpointer: false, + recursionLimit: 20, + }, + { kind: "input", value: {} }, + ); + + expect(result.status).toBe("completed"); + expect(scanBrokenLinks).toHaveBeenCalledTimes(1); + expect(scanStubPages).toHaveBeenCalledTimes(1); + + const out = result.output as { + maintenancePlan?: { brokenLinkCount: number; stubPageCount: number; findings: unknown[] }; + phase?: string; + }; + expect(out.phase).toBe("maintenance:planned"); + expect(out.maintenancePlan?.brokenLinkCount).toBe(1); + expect(out.maintenancePlan?.stubPageCount).toBe(1); + expect(out.maintenancePlan?.findings).toHaveLength(2); + }); +}); diff --git a/server/api/src/agents/graphs/wikiCompose/index.ts b/server/api/src/agents/graphs/wikiCompose/index.ts index 108a0512..f919153e 100644 --- a/server/api/src/agents/graphs/wikiCompose/index.ts +++ b/server/api/src/agents/graphs/wikiCompose/index.ts @@ -28,10 +28,20 @@ export type { OutlineSection, PageSnapshot, WikiComposeInterruptPayload, + ResearchConflictSummary, } from "./types.js"; export { briefResumeSchema, type BriefResumeParsed, outlineResumeSchema, type OutlineResumeParsed, + conflictResumeSchema, + type ConflictResumeParsed, } from "./resumeSchemas.js"; +export { + routeAfterBrief, + routeAfterResearch, + shouldResolveResearchConflicts, + type BriefRoute, + type ResearchRoute, +} from "./routing.js"; diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts b/server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts new file mode 100644 index 00000000..36a27d2d --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts @@ -0,0 +1,49 @@ +/** + * `conflict_resolution` — HITL step when research approval left conflicting + * sources (#953). + * + * 調査承認で採用・却下が混在し矛盾が疑われるとき、Structure の前に 1 回だけ + * 中断してユーザーに確認させる。resume 後は `researchConflicts` をクリアして + * Structure へ進む。 + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { interrupt } from "@langchain/langgraph"; +import { conflictResumeSchema } from "../resumeSchemas.js"; +import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; +import type { ResearchConflictSummary, WikiComposeInterruptPayload } from "../types.js"; +import { shouldResolveResearchConflicts } from "../routing.js"; + +function buildConflictSummary(state: WikiComposeStateType): ResearchConflictSummary { + return { + approved: state.approvedResearch.map((s) => ({ id: s.id, title: s.title })), + rejected: state.rejectedResearch.map((s) => ({ id: s.id, title: s.title })), + rationale: + "Multiple sources were rejected while others were kept. Confirm you want to proceed " + + "with the approved set before generating the outline.", + }; +} + +/** + * Halts when {@link shouldResolveResearchConflicts} was true at the prior edge; + * on resume clears the conflict flag and advances to Structure. + */ +export async function conflictResolution( + state: WikiComposeStateType, + _config: LangGraphRunnableConfig, +): Promise { + if (!shouldResolveResearchConflicts(state)) { + return { phase: "conflict:skipped" }; + } + + const payload: WikiComposeInterruptPayload = { + kind: "conflict_resolution", + conflicts: buildConflictSummary(state), + }; + const resumeValue: unknown = interrupt(payload); + conflictResumeSchema.parse(resumeValue); + + return { + researchConflicts: [], + phase: "conflict:resolved", + }; +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/index.ts b/server/api/src/agents/graphs/wikiCompose/nodes/index.ts index 55ca4185..80d4a7b7 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/index.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/index.ts @@ -10,3 +10,5 @@ export { structureDialogue } from "./structureDialogue.js"; export { humanReviewOutline } from "./humanReviewOutline.js"; export { draftSections } from "./draftSections.js"; export { completed } from "./completed.js"; +export { skipResearch } from "./skipResearch.js"; +export { conflictResolution } from "./conflictResolution.js"; diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/skipResearch.ts b/server/api/src/agents/graphs/wikiCompose/nodes/skipResearch.ts new file mode 100644 index 00000000..b95433c0 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/skipResearch.ts @@ -0,0 +1,22 @@ +/** + * `skip_research` — bypasses the P1 research loop when Brief routing decides + * research adds little value (#953). + * + * Brief ルーティングで調査をスキップするときのノード。`approvedResearch` を + * 空にし、exitReason を `brief_skip` にして Structure フェーズへ進む。 + */ +import type { WikiComposeStateUpdate } from "../state.js"; + +/** + * Project a no-op research outcome so downstream Structure can run without + * an extra HITL at `human_review_research`. + */ +export async function skipResearch(): Promise { + return { + approvedResearch: [], + rejectedResearch: [], + batches: [], + exitReason: "brief_skip", + phase: "research:skipped", + }; +} diff --git a/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts b/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts index 8ac9fe12..dec5f9d4 100644 --- a/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts +++ b/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts @@ -64,3 +64,15 @@ export const outlineResumeSchema = z.object({ }); export type OutlineResumeParsed = z.infer; + +/** + * Resume payload for `conflict_resolution` (#953). + * + * User acknowledges conflicting sources and opts to continue with the approved set. + */ +export const conflictResumeSchema = z.object({ + acknowledged: z.literal(true), + note: z.string().optional(), +}); + +export type ConflictResumeParsed = z.infer; diff --git a/server/api/src/agents/graphs/wikiCompose/routing.ts b/server/api/src/agents/graphs/wikiCompose/routing.ts new file mode 100644 index 00000000..3e4525bd --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/routing.ts @@ -0,0 +1,55 @@ +/** + * Wiki Compose P5 — conditional routing predicates (#953). + * + * `wikiComposeGraph` の conditional edge が呼ぶ純関数群。LLM や DB に触れず、 + * state のみから次ノードを決めるため Vitest で単体テストしやすい。 + * + * Pure routing functions for orchestrator conditional edges. No I/O — only + * state inspection — so each branch is covered by focused unit tests. + * + * ## Non-goals (本 Issue では実装しない) + * - `media_curator` subgraph(画像スロット分岐)は outline 側のメタデータ設計後に追加。 + * - Draft 3 回失敗時の `escalate_to_orchestrator` は retry カウンタ設計が必要なため保留。 + * - pgvector による Wiki Linker 強化は別 Epic。 + * + * ## Extension points + * - `routeAfterBrief`: `chatSeed` / Brief 0 件以外のシグナル(例: 明示 `skipResearch`)を足せる。 + * - `routeAfterResearch`: `researchResumeSchema.flagConflicts` 等の明示フラグと併用可能。 + * - `routeAfterOutline`: 将来 `OutlineSection.mediaSlots` で `media_curator` へ分岐。 + */ +import type { WikiComposeStateType } from "./state.js"; + +/** Edge label after `human_review_brief`. */ +export type BriefRoute = "research" | "skip_research"; + +/** Edge label after `human_review_research`. */ +export type ResearchRoute = "structure" | "conflict_resolution"; + +/** + * Brief 完了後に調査ループへ進むか Structure へ直行するか。 + * + * Skips research when the Brief emitted zero questions (title/intent already + * clear) or when the session was seeded from chat with a pre-approved outline. + */ +export function routeAfterBrief(state: WikiComposeStateType): BriefRoute { + if (state.briefQuestions.length === 0) return "skip_research"; + if (state.chatSeed?.outline?.trim()) return "skip_research"; + return "research"; +} + +/** + * 調査 HITL 後に矛盾解消ノードへ寄せるか Structure へ進むか。 + * + * Heuristic: user approved some sources but rejected two or more — signals + * contradictory evidence worth a dedicated resolution step before outline. + */ +export function shouldResolveResearchConflicts(state: WikiComposeStateType): boolean { + return state.rejectedResearch.length >= 2 && state.approvedResearch.length >= 1; +} + +/** + * Research フェーズ完了後の分岐ラベル。 + */ +export function routeAfterResearch(state: WikiComposeStateType): ResearchRoute { + return shouldResolveResearchConflicts(state) ? "conflict_resolution" : "structure"; +} diff --git a/server/api/src/agents/graphs/wikiCompose/state.ts b/server/api/src/agents/graphs/wikiCompose/state.ts index 2cac45ce..c328ef90 100644 --- a/server/api/src/agents/graphs/wikiCompose/state.ts +++ b/server/api/src/agents/graphs/wikiCompose/state.ts @@ -174,6 +174,17 @@ export const WikiComposeState = Annotation.Root({ reducer: (prev, next) => (next === undefined ? prev : next), default: () => null, }), + /** + * P5 conflict-resolution marker. Populated before `conflict_resolution` interrupt; + * cleared on resume. Routing uses `rejectedResearch` counts; this channel is for + * future explicit conflict metadata from evaluate / resume payloads. + * + * P5 矛盾解消用マーカー。将来 evaluate や resume から明示的な矛盾リストを載せる。 + */ + researchConflicts: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), // ── Structure phase ────────────────────────────────────────────────────── /** Orchestrator が提案する初期アウトライン。 */ diff --git a/server/api/src/agents/graphs/wikiCompose/types.ts b/server/api/src/agents/graphs/wikiCompose/types.ts index 0cefd0cb..520cd4db 100644 --- a/server/api/src/agents/graphs/wikiCompose/types.ts +++ b/server/api/src/agents/graphs/wikiCompose/types.ts @@ -200,10 +200,18 @@ export interface ComposeCompletion { * 各 interrupt ノードが `interrupt(value)` で渡すペイロード。フロントは * `kind` で分岐して UI を出し分ける。 */ +/** Lightweight conflict summary for the P5 `conflict_resolution` interrupt. */ +export interface ResearchConflictSummary { + approved: Array<{ id: string; title: string }>; + rejected: Array<{ id: string; title: string }>; + rationale: string; +} + export type WikiComposeInterruptPayload = | { kind: "human_review_brief"; questions: BriefQuestion[]; pageSnapshot: PageSnapshot } | { kind: "human_review_research"; batchId: string | null; pendingSources: Source[] } - | { kind: "human_review_outline"; outline: OutlineSection[]; approvedSources: Source[] }; + | { kind: "human_review_outline"; outline: OutlineSection[]; approvedSources: Source[] } + | { kind: "conflict_resolution"; conflicts: ResearchConflictSummary }; /** * Resume payloads expected at each interrupt point. Each is validated at the diff --git a/server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts b/server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts index c23b3d51..137295ed 100644 --- a/server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts +++ b/server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts @@ -1,31 +1,36 @@ /** - * Wiki Compose P2 — `wikiComposeGraph` orchestrator (issue #950). + * Wiki Compose P2/P5 — `wikiComposeGraph` orchestrator (#950, #953). * - * Brief → Research → Structure → Draft → Completed の全体フローを担う - * LangGraph オーケストレータ。`researchLoopSubgraph` (#949 / P1) を **subgraph - * as node** として組み込み、ループ内 interrupt - * (`human_review_research`) は親グラフから見ても通常の interrupt として伝播する - * (状態は `WikiComposeState` の superset 設計により自動共有)。 + * Brief → (optional Research) → (optional Conflict resolution) → Structure → + * Draft → Completed の全体フローを担う LangGraph オーケストレータ。 * - * Top-level orchestrator. The research subgraph composes as a node so a - * single PostgresSaver thread services Brief → Research → Outline → Draft. - * Each interrupt halts the same `thread_id` and resumes through the same - * `PATCH /resume` route. + * P5 adds conditional edges: + * - After Brief: skip research when questions are empty or chat seeded an outline. + * - After Research HITL: conflict resolution when many sources were rejected. + * + * Top-level orchestrator. Research nodes are inlined so state channels are shared + * and interrupts halt the same `thread_id`. See `routing.ts` for branch predicates. * * Pipeline: * * ``` - * START - * → brief_dialogue - * → human_review_brief [interrupt #1] - * → research_subgraph (= researchLoopSubgraph) - * └── plan → search → fetch → eval → … → human_review_research [interrupt #2] - * → structure_dialogue - * → human_review_outline [interrupt #3] - * → draft_sections - * → completed - * → END + * START → brief_dialogue → human_review_brief + * ├─[research]→ plan_queries → … → human_review_research + * └─[skip_research]→ skip_research ────────────────┐ + * ↓ + * human_review_research ─┬─[structure]──────────────┤ + * └─[conflict_resolution]→ conflict_resolution + * ↓ + * structure_dialogue → human_review_outline + * → draft_sections → completed → END * ``` + * + * ## Non-goals (P5 / #953) + * - `media_curator` subgraph, draft failure escalation, session TTL GC — tracked separately. + * + * ## Extension points + * - Add `routeAfterOutline` for image-slot sections. + * - Register sibling graphs via `GraphRegistry` (`wiki-maintenance`, template compose, …). */ import { END, START, StateGraph } from "@langchain/langgraph"; import { WikiComposeState } from "./state.js"; @@ -52,31 +57,23 @@ import { humanReviewOutline, draftSections, completed, + skipResearch, + conflictResolution, } from "./nodes/index.js"; import { shouldRefine } from "../../subgraphs/research/researchGraph.js"; +import { routeAfterBrief, routeAfterResearch } from "./routing.js"; /** Registered graph id. */ export const WIKI_COMPOSE_GRAPH_ID = "wiki-compose" as const; /** Registered graph version. Bump when behaviour changes meaningfully. */ -export const WIKI_COMPOSE_GRAPH_VERSION = "1.0.0"; +export const WIKI_COMPOSE_GRAPH_VERSION = "1.1.0"; -/** - * Inlined research nodes vs separate subgraph: we inline the research nodes - * at the orchestrator level so the parent state's `iteration` / `queries` / - * `pendingSources` channels are written directly and the interrupt at - * `human_review_research` halts the parent thread_id without a translation - * layer. This is equivalent to subgraph-as-node composition since the - * orchestrator state is a strict superset of `ResearchLoopState` (see - * `state.ts`). - * - * 研究ノードは orchestrator state 上に直接配置する。state を superset 設計に - * したので state は自動共有され、interrupt は親 thread_id で halt する。 - */ const factory: GraphFactory = ({ checkpointer }: GraphFactoryInput): CompiledGraphLike => { const builder = new StateGraph(WikiComposeState) // Brief phase .addNode("brief_dialogue", briefDialogue) .addNode("human_review_brief", humanReviewBrief) + .addNode("skip_research", skipResearch) // Research phase (inlined research subgraph nodes, sharing state) .addNode("plan_queries", planQueries) .addNode("web_search", webSearch) @@ -86,6 +83,7 @@ const factory: GraphFactory = ({ checkpointer }: GraphFactoryInput): CompiledGra .addNode("refine_queries", refineQueries) .addNode("compile_batch", compileBatch) .addNode("human_review_research", humanReviewResearch) + .addNode("conflict_resolution", conflictResolution) // Structure phase .addNode("structure_dialogue", structureDialogue) .addNode("human_review_outline", humanReviewOutline) @@ -95,7 +93,11 @@ const factory: GraphFactory = ({ checkpointer }: GraphFactoryInput): CompiledGra // Edges .addEdge(START, "brief_dialogue") .addEdge("brief_dialogue", "human_review_brief") - .addEdge("human_review_brief", "plan_queries") + .addConditionalEdges("human_review_brief", routeAfterBrief, { + research: "plan_queries", + skip_research: "skip_research", + }) + .addEdge("skip_research", "structure_dialogue") // Research loop (mirrors researchLoopSubgraph wiring). .addEdge("plan_queries", "web_search") .addEdge("plan_queries", "wiki_search") @@ -109,7 +111,11 @@ const factory: GraphFactory = ({ checkpointer }: GraphFactoryInput): CompiledGra .addEdge("refine_queries", "web_search") .addEdge("refine_queries", "wiki_search") .addEdge("compile_batch", "human_review_research") - .addEdge("human_review_research", "structure_dialogue") + .addConditionalEdges("human_review_research", routeAfterResearch, { + structure: "structure_dialogue", + conflict_resolution: "conflict_resolution", + }) + .addEdge("conflict_resolution", "structure_dialogue") // Structure phase. .addEdge("structure_dialogue", "human_review_outline") .addEdge("human_review_outline", "draft_sections") @@ -122,9 +128,6 @@ const factory: GraphFactory = ({ checkpointer }: GraphFactoryInput): CompiledGra /** * Register the Wiki Compose orchestrator graph. Idempotent. - * - * `app.ts` から `registerResearchLoopGraph()` と並べて呼ぶ。再登録は registry が - * 上書きで吸収する。 */ export function registerWikiComposeGraph(): void { registerGraph({ @@ -132,10 +135,10 @@ export function registerWikiComposeGraph(): void { version: WIKI_COMPOSE_GRAPH_VERSION, phase: "orchestrator", description: - "Wiki Compose P2: full orchestrator. Brief → research → structure → draft → completed. " + - "Embeds the P1 research loop in-place via shared state (orchestrator state is a strict " + - "superset of ResearchLoopState). Three interrupt points: human_review_brief, " + - "human_review_research, human_review_outline.", + "Wiki Compose P2+P5 orchestrator. Brief → optional research → optional conflict resolution → " + + "structure → draft → completed. Conditional: skip research (empty Brief / chat outline seed), " + + "conflict resolution (≥2 rejected sources with ≥1 approved). Interrupts: brief, research, " + + "conflict (conditional), outline.", factory, }); } diff --git a/server/api/src/agents/graphs/wikiMaintenance/index.ts b/server/api/src/agents/graphs/wikiMaintenance/index.ts new file mode 100644 index 00000000..04386b4e --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/index.ts @@ -0,0 +1,14 @@ +/** + * Wiki maintenance graph — public barrel (#953). + */ +export { + WIKI_MAINTENANCE_GRAPH_ID, + WIKI_MAINTENANCE_GRAPH_VERSION, + registerWikiMaintenanceGraph, +} from "./wikiMaintenanceGraph.js"; +export { + WikiMaintenanceState, + type WikiMaintenanceStateType, + type WikiMaintenanceStateUpdate, +} from "./state.js"; +export type { MaintenanceFinding, MaintenancePlan } from "./types.js"; diff --git a/server/api/src/agents/graphs/wikiMaintenance/nodes/index.ts b/server/api/src/agents/graphs/wikiMaintenance/nodes/index.ts new file mode 100644 index 00000000..e7353ef1 --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/nodes/index.ts @@ -0,0 +1,3 @@ +export { scanBrokenLinks } from "./scanBrokenLinks.js"; +export { scanStubPages } from "./scanStubPages.js"; +export { planMaintenance } from "./planMaintenance.js"; diff --git a/server/api/src/agents/graphs/wikiMaintenance/nodes/planMaintenance.ts b/server/api/src/agents/graphs/wikiMaintenance/nodes/planMaintenance.ts new file mode 100644 index 00000000..43bc927f --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/nodes/planMaintenance.ts @@ -0,0 +1,21 @@ +/** + * `plan_maintenance` — aggregates scan results into a single plan object. + */ +import type { WikiMaintenanceStateType, WikiMaintenanceStateUpdate } from "../state.js"; +import type { MaintenancePlan } from "../types.js"; + +export async function planMaintenance( + state: WikiMaintenanceStateType, +): Promise { + const findings = [...state.brokenLinkFindings, ...state.stubPageFindings]; + const plan: MaintenancePlan = { + brokenLinkCount: state.brokenLinkFindings.length, + stubPageCount: state.stubPageFindings.length, + findings, + plannedAt: new Date().toISOString(), + }; + return { + maintenancePlan: plan, + phase: "maintenance:planned", + }; +} diff --git a/server/api/src/agents/graphs/wikiMaintenance/nodes/scanBrokenLinks.ts b/server/api/src/agents/graphs/wikiMaintenance/nodes/scanBrokenLinks.ts new file mode 100644 index 00000000..c007124e --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/nodes/scanBrokenLinks.ts @@ -0,0 +1,26 @@ +/** + * `scan_broken_links` — runs the broken-link lint rule for the session owner. + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { runBrokenLinkRule } from "../../../../services/lintEngine/rules/brokenLink.js"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import type { WikiMaintenanceStateUpdate } from "../state.js"; +import type { MaintenanceFinding } from "../types.js"; + +export async function scanBrokenLinks( + _state: unknown, + config: LangGraphRunnableConfig, +): Promise { + const ctx = getGraphContext(config); + const result = await runBrokenLinkRule(ctx.userId, ctx.db); + const brokenLinkFindings: MaintenanceFinding[] = result.findings.map((f) => ({ + rule: "broken_link", + severity: f.severity, + pageIds: f.pageIds, + detail: f.detail as Record, + })); + return { + brokenLinkFindings, + phase: "maintenance:broken_links_scanned", + }; +} diff --git a/server/api/src/agents/graphs/wikiMaintenance/nodes/scanStubPages.ts b/server/api/src/agents/graphs/wikiMaintenance/nodes/scanStubPages.ts new file mode 100644 index 00000000..dac448f9 --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/nodes/scanStubPages.ts @@ -0,0 +1,51 @@ +/** + * `scan_stub_pages` — detects pages with very little stored preview text. + * + * Full Y.Doc bodies live in Hocuspocus; `pages.content_preview` is the best + * server-side heuristic for "stub" pages without pulling every document. + */ +import { and, eq, or, isNull, sql } from "drizzle-orm"; +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { pages } from "../../../../schema/pages.js"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import type { WikiMaintenanceStateUpdate } from "../state.js"; +import type { MaintenanceFinding } from "../types.js"; + +/** Minimum trimmed preview length to treat a page as non-stub. */ +const STUB_PREVIEW_MAX_LEN = 40; + +export async function scanStubPages( + _state: unknown, + config: LangGraphRunnableConfig, +): Promise { + const ctx = getGraphContext(config); + const rows = await ctx.db + .select({ id: pages.id, title: pages.title }) + .from(pages) + .where( + and( + eq(pages.ownerId, ctx.userId), + eq(pages.isDeleted, false), + or( + isNull(pages.contentPreview), + sql`length(trim(${pages.contentPreview})) < ${STUB_PREVIEW_MAX_LEN}`, + ), + ), + ) + .limit(200); + + const stubPageFindings: MaintenanceFinding[] = rows.map((p) => ({ + rule: "stub_page", + severity: "info", + pageIds: [p.id], + detail: { + title: p.title ?? "(untitled)", + suggestion: "Page preview is empty or very short. Consider expanding or linking this stub.", + }, + })); + + return { + stubPageFindings, + phase: "maintenance:stub_pages_scanned", + }; +} diff --git a/server/api/src/agents/graphs/wikiMaintenance/state.ts b/server/api/src/agents/graphs/wikiMaintenance/state.ts new file mode 100644 index 00000000..a138ac4b --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/state.ts @@ -0,0 +1,25 @@ +/** + * `WikiMaintenanceState` — LangGraph state for wiki maintenance (#953). + */ +import { Annotation } from "@langchain/langgraph"; +import { BaseState } from "../../core/state/baseState.js"; +import type { MaintenanceFinding, MaintenancePlan } from "./types.js"; + +export const WikiMaintenanceState = Annotation.Root({ + ...BaseState.spec, + brokenLinkFindings: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + stubPageFindings: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + maintenancePlan: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), +}); + +export type WikiMaintenanceStateType = typeof WikiMaintenanceState.State; +export type WikiMaintenanceStateUpdate = typeof WikiMaintenanceState.Update; diff --git a/server/api/src/agents/graphs/wikiMaintenance/types.ts b/server/api/src/agents/graphs/wikiMaintenance/types.ts new file mode 100644 index 00000000..eb8d2e20 --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/types.ts @@ -0,0 +1,25 @@ +/** + * Value types for the Wiki maintenance graph (#953). + * + * Wiki メンテナンス graph が扱う検出結果・プランの型。LangGraph state から分離し、 + * テスト fixture が runtime を import しなくて済むようにする。 + */ + +/** One lint-style finding projected into graph state. */ +export interface MaintenanceFinding { + rule: "broken_link" | "stub_page"; + severity: "error" | "warn" | "info"; + pageIds: string[]; + detail: Record; +} + +/** + * Aggregated maintenance plan emitted at the end of the graph. + */ +export interface MaintenancePlan { + brokenLinkCount: number; + stubPageCount: number; + findings: MaintenanceFinding[]; + /** ISO timestamp when the plan was assembled. */ + plannedAt: string; +} diff --git a/server/api/src/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.ts b/server/api/src/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.ts new file mode 100644 index 00000000..8d1378ca --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.ts @@ -0,0 +1,51 @@ +/** + * Wiki Compose P5 — `wikiMaintenanceGraph` (#953). + * + * リンク切れ検出・スタブページ検出を順に走らせ、メンテナンスプランを返す。 + * Compose orchestrator とは独立した graphId で `GraphRegistry` に登録する。 + * + * Linear graph: `scan_broken_links` → `scan_stub_pages` → `plan_maintenance` → END. + * No HITL interrupts in P5 — future versions may add repair subgraphs per finding. + * + * ## Non-goals + * - Automatic link repair or page creation (human or a future repair graph). + * - Full Y.Doc body analysis (uses `content_preview` heuristic only). + * + * ## Extension points + * - Additional scan nodes (orphan, ghost_many, stale) via parallel fan-out. + * - Conditional routing when `brokenLinkCount === 0` to skip LLM planning steps. + */ +import { END, START, StateGraph } from "@langchain/langgraph"; +import { registerGraph, type GraphFactory } from "../../registry/graphRegistry.js"; +import { WikiMaintenanceState } from "./state.js"; +import { scanBrokenLinks, scanStubPages, planMaintenance } from "./nodes/index.js"; + +/** Registered graph id. */ +export const WIKI_MAINTENANCE_GRAPH_ID = "wiki-maintenance" as const; +export const WIKI_MAINTENANCE_GRAPH_VERSION = "1.0.0"; + +const factory: GraphFactory = ({ checkpointer }) => { + const builder = new StateGraph(WikiMaintenanceState) + .addNode("scan_broken_links", scanBrokenLinks) + .addNode("scan_stub_pages", scanStubPages) + .addNode("plan_maintenance", planMaintenance) + .addEdge(START, "scan_broken_links") + .addEdge("scan_broken_links", "scan_stub_pages") + .addEdge("scan_stub_pages", "plan_maintenance") + .addEdge("plan_maintenance", END); + + return checkpointer ? builder.compile({ checkpointer }) : builder.compile(); +}; + +/** Register the wiki maintenance graph. Idempotent; call from `app.ts` bootstrap. */ +export function registerWikiMaintenanceGraph(): void { + registerGraph({ + id: WIKI_MAINTENANCE_GRAPH_ID, + version: WIKI_MAINTENANCE_GRAPH_VERSION, + phase: "maintenance", + description: + "Wiki maintenance P5: scan broken links (lint rule) and stub pages (short content_preview), " + + "then emit a MaintenancePlan. No interrupts; suitable for background / admin runs.", + factory, + }); +} diff --git a/server/api/src/agents/index.ts b/server/api/src/agents/index.ts index 7288de97..01970925 100644 --- a/server/api/src/agents/index.ts +++ b/server/api/src/agents/index.ts @@ -97,6 +97,23 @@ export { type IngestPlannerStateType, type IngestPlannerStateUpdate, } from "./graphs/ingest/index.js"; +export { + WIKI_MAINTENANCE_GRAPH_ID, + WIKI_MAINTENANCE_GRAPH_VERSION, + registerWikiMaintenanceGraph, + WikiMaintenanceState, + type WikiMaintenanceStateType, + type WikiMaintenanceStateUpdate, + type MaintenanceFinding, + type MaintenancePlan, +} from "./graphs/wikiMaintenance/index.js"; +export { + routeAfterBrief, + routeAfterResearch, + shouldResolveResearchConflicts, + type BriefRoute, + type ResearchRoute, +} from "./graphs/wikiCompose/routing.js"; export { WIKI_COMPOSE_GRAPH_ID, WIKI_COMPOSE_GRAPH_VERSION, diff --git a/server/api/src/agents/subgraphs/research/types.ts b/server/api/src/agents/subgraphs/research/types.ts index 4db72508..036df588 100644 --- a/server/api/src/agents/subgraphs/research/types.ts +++ b/server/api/src/agents/subgraphs/research/types.ts @@ -124,7 +124,12 @@ export interface ResearchBatch { * * Reason the loop exited; set by `compile_batch`. */ -export type ExitReason = "score_threshold" | "max_iterations" | "manual_stop"; +export type ExitReason = + | "score_threshold" + | "max_iterations" + | "manual_stop" + /** Orchestrator skipped the research loop after Brief (#953). */ + | "brief_skip"; /** * `human_review_research` ノードが期待する resume payload の TS 型。 diff --git a/server/api/src/app.ts b/server/api/src/app.ts index 22952b73..9e15bc16 100644 --- a/server/api/src/app.ts +++ b/server/api/src/app.ts @@ -49,6 +49,7 @@ 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"; +import { registerWikiMaintenanceGraph } from "./agents/graphs/wikiMaintenance/index.js"; /** * Creates and configures the Hono API app (routes, CORS, etc.). @@ -60,6 +61,7 @@ export function createApp(): Hono { // - `wiki-compose-research` — P1 自律調査ループ (#949) // - `wiki-compose` — P2 全体オーケストレータ (#950) // - `ingest-planner` — P4 ingest + shared research loop (#952) + // - `wiki-maintenance` — P5 broken links + stub scan (#953) // // Register all Wiki Compose graphs. Calls are idempotent across hot // reloads (registry uses `Map#set` so the latest registration wins). @@ -67,6 +69,7 @@ export function createApp(): Hono { registerResearchLoopGraph(); registerWikiComposeGraph(); registerIngestPlannerGraph(); + registerWikiMaintenanceGraph(); const app = new Hono(); const wildcard = isWildcardCors(); diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index 77e5d71a..1d75f6f5 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -60,6 +60,7 @@ import { GRAPH_CONTEXT_CONFIG_KEY } from "../agents/core/types/graphContext.js"; import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js"; import { RESEARCH_GRAPH_ID } from "../agents/subgraphs/research/index.js"; import { WIKI_COMPOSE_GRAPH_ID } from "../agents/graphs/wikiCompose/index.js"; +import { WIKI_MAINTENANCE_GRAPH_ID } from "../agents/graphs/wikiMaintenance/index.js"; import type { AppEnv } from "../types/index.js"; import { persistOutcomeIfStillRunning } from "./composeSessionPersistence.js"; import { loadComposeSessionProjection } from "./composeSessionProjection.js"; @@ -109,6 +110,7 @@ function translateGraphInput(graphId: string, raw: unknown): unknown { function recursionLimitFor(graphId: string): number | undefined { if (graphId === RESEARCH_GRAPH_ID) return 60; if (graphId === WIKI_COMPOSE_GRAPH_ID) return 120; + if (graphId === WIKI_MAINTENANCE_GRAPH_ID) return 40; return undefined; } From e22509cfea48b708fbedbca111e3631b355275c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 25 May 2026 08:08:58 +0000 Subject: [PATCH 2/2] fix(api): unblock wiki-maintenance BYOK and conflict-resolution HITL - Return no compose model ids for wiki-maintenance so BYOK session create does not require orchestrator credentials for a lint-only graph. - Project conflict_resolution interrupts on GET compose-sessions reload. - Handle conflict_resolution in the compose hook and UI so research approval can pause for acknowledgement instead of leaving the session stuck. --- .../routes/composeSessionProjection.test.ts | 26 ++++++++ .../agents/core/composeModelConfig.test.ts | 12 ++++ .../api/src/agents/core/composeModelConfig.ts | 3 + .../src/routes/composeSessionProjection.ts | 10 ++++ src/components/wikiCompose/ComposePanel.tsx | 17 +++++- .../wikiCompose/ResearchConflictSection.tsx | 60 +++++++++++++++++++ src/hooks/useWikiComposeSession.test.ts | 43 +++++++++++++ src/hooks/useWikiComposeSession.ts | 60 ++++++++++++++++++- src/lib/wikiCompose/composeService.ts | 1 + src/lib/wikiCompose/types.ts | 12 ++++ src/pages/WikiComposePage.tsx | 2 + 11 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 server/api/src/agents/core/composeModelConfig.test.ts create mode 100644 src/components/wikiCompose/ResearchConflictSection.tsx diff --git a/server/api/src/__tests__/routes/composeSessionProjection.test.ts b/server/api/src/__tests__/routes/composeSessionProjection.test.ts index 9fe0c380..12a2169b 100644 --- a/server/api/src/__tests__/routes/composeSessionProjection.test.ts +++ b/server/api/src/__tests__/routes/composeSessionProjection.test.ts @@ -39,6 +39,32 @@ describe("projectComposeStateValues", () => { expect(projection.phase).toBe("research"); }); + it("projects conflict_resolution interrupt for reload (#953)", () => { + const projection = projectComposeStateValues({ + approvedResearch: [{ id: "src:a", kind: "web", title: "A" }], + __interrupt__: [ + { + value: { + kind: "conflict_resolution", + conflicts: { + approved: [{ id: "src:a", title: "A" }], + rejected: [ + { id: "src:b", title: "B" }, + { id: "src:c", title: "C" }, + ], + rationale: "Confirm approved set.", + }, + }, + }, + ], + }); + expect(projection.phase).toBe("research"); + expect(projection.researchConflictSummary).toMatchObject({ + approved: [{ id: "src:a", title: "A" }], + }); + expect(projection.approvedSources).toHaveLength(1); + }); + it("projects completion markdown from checkpoint values", () => { const projection = projectComposeStateValues({ phase: "completed", diff --git a/server/api/src/agents/core/composeModelConfig.test.ts b/server/api/src/agents/core/composeModelConfig.test.ts new file mode 100644 index 00000000..33b65561 --- /dev/null +++ b/server/api/src/agents/core/composeModelConfig.test.ts @@ -0,0 +1,12 @@ +/** + * `getComposeModelIdsForGraph` unit tests — BYOK validation inputs (#953). + */ +import { describe, expect, it } from "vitest"; +import { getComposeModelIdsForGraph } from "./composeModelConfig.js"; +import { WIKI_MAINTENANCE_GRAPH_ID } from "../graphs/wikiMaintenance/index.js"; + +describe("getComposeModelIdsForGraph", () => { + it("returns no model ids for wiki-maintenance (lint-only graph)", () => { + expect(getComposeModelIdsForGraph(WIKI_MAINTENANCE_GRAPH_ID)).toEqual([]); + }); +}); diff --git a/server/api/src/agents/core/composeModelConfig.ts b/server/api/src/agents/core/composeModelConfig.ts index 4cf9447c..e635bb1e 100644 --- a/server/api/src/agents/core/composeModelConfig.ts +++ b/server/api/src/agents/core/composeModelConfig.ts @@ -3,6 +3,7 @@ * Wiki Compose グラフが使うモデル行 ID を解決する(BYOK 検証用)。 */ import { WIKI_COMPOSE_GRAPH_ID } from "../graphs/wikiCompose/index.js"; +import { WIKI_MAINTENANCE_GRAPH_ID } from "../graphs/wikiMaintenance/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"; @@ -19,6 +20,8 @@ function getDraftModelId(): string { * `createZediChatModel` 経由で呼ばれるモデル行 ID 一覧。 */ export function getComposeModelIdsForGraph(graphId: string): string[] { + // Lint-only graph — no `createZediChatModel` calls; BYOK must not require orchestrator keys. + if (graphId === WIKI_MAINTENANCE_GRAPH_ID) return []; if (graphId === WIKI_COMPOSE_GRAPH_ID) { const orchestrator = getOrchestratorModelId(); const draft = getDraftModelId(); diff --git a/server/api/src/routes/composeSessionProjection.ts b/server/api/src/routes/composeSessionProjection.ts index c631c537..de07517a 100644 --- a/server/api/src/routes/composeSessionProjection.ts +++ b/server/api/src/routes/composeSessionProjection.ts @@ -24,6 +24,8 @@ export interface ComposeSessionUiProjection { pendingSources?: unknown[]; latestBatch?: unknown; approvedSources?: unknown[]; + /** P5 research conflict summary when halted at `conflict_resolution` (#953). */ + researchConflictSummary?: unknown; outlineProposal?: unknown[]; draftedSections?: unknown[]; completedMarkdown?: string | null; @@ -99,6 +101,7 @@ export function projectComposeStateValues( pendingSources?: unknown[]; outline?: unknown[]; approvedSources?: unknown[]; + conflicts?: unknown; }; switch (payload.kind) { case "human_review_brief": @@ -116,6 +119,13 @@ export function projectComposeStateValues( if (payload.approvedSources) projection.approvedSources = payload.approvedSources; projection.phase = "structure"; break; + case "conflict_resolution": + if (payload.conflicts) projection.researchConflictSummary = payload.conflicts; + if (Array.isArray(state.approvedResearch)) { + projection.approvedSources = state.approvedResearch; + } + projection.phase = "research"; + break; default: break; } diff --git a/src/components/wikiCompose/ComposePanel.tsx b/src/components/wikiCompose/ComposePanel.tsx index 5ec7f692..c0a29d8f 100644 --- a/src/components/wikiCompose/ComposePanel.tsx +++ b/src/components/wikiCompose/ComposePanel.tsx @@ -13,6 +13,7 @@ import React from "react"; import { PhaseStepper } from "./PhaseStepper"; import { DialogueSection } from "./DialogueSection"; import { ResearchSection } from "./ResearchSection"; +import { ResearchConflictSection } from "./ResearchConflictSection"; import { ActivitySection } from "./ActivitySection"; import type { BriefAnswer, @@ -20,6 +21,7 @@ import type { OutlineSection, PageSnapshot, ResearchBatch, + ResearchConflictSummary, ResearchSource, } from "@/lib/wikiCompose/types"; import type { ComposeActivity, ComposePhase } from "@/hooks/useWikiComposeSession"; @@ -34,6 +36,7 @@ export interface ComposePanelProps { latestBatch: ResearchBatch | null; pendingSources: ResearchSource[]; approvedSources: ResearchSource[]; + researchConflictSummary: ResearchConflictSummary | null; outlineProposal: OutlineSection[]; @@ -50,6 +53,7 @@ export interface ComposePanelProps { note?: string; }) => Promise; onSubmitOutline: (input: { sections: OutlineSection[] }) => Promise; + onSubmitConflictAck: () => Promise; } /** Right pane container. */ @@ -62,11 +66,13 @@ export const ComposePanel: React.FC = (props) => { latestBatch, pendingSources, approvedSources, + researchConflictSummary, outlineProposal, activity, onSubmitBrief, onSubmitResearchApproval, onSubmitOutline, + onSubmitConflictAck, } = props; return ( @@ -90,9 +96,18 @@ export const ComposePanel: React.FC = (props) => { onSubmitOutline={onSubmitOutline} /> + {researchConflictSummary ? ( + + ) : null} + {/* Research review: visible during research interrupt and as a read-only summary in later phases. */} - {phase === "research" || (phase !== "brief" && approvedSources.length > 0) ? ( + {!researchConflictSummary && + (phase === "research" || (phase !== "brief" && approvedSources.length > 0)) ? ( Promise; +} + +/** Conflict acknowledgement panel. */ +export const ResearchConflictSection: React.FC = ({ + conflicts, + isStreaming, + onAcknowledge, +}) => { + const [submitting, setSubmitting] = useState(false); + + return ( +
+

Review conflicting sources

+

{conflicts.rationale}

+
+
+ Approved ({conflicts.approved.length}) +
    + {conflicts.approved.map((s) => ( +
  • {s.title}
  • + ))} +
+
+
+ Rejected ({conflicts.rejected.length}) +
    + {conflicts.rejected.map((s) => ( +
  • {s.title}
  • + ))} +
+
+
+ +
+ ); +}; diff --git a/src/hooks/useWikiComposeSession.test.ts b/src/hooks/useWikiComposeSession.test.ts index 386a1e9f..1117e6f7 100644 --- a/src/hooks/useWikiComposeSession.test.ts +++ b/src/hooks/useWikiComposeSession.test.ts @@ -191,6 +191,49 @@ describe("useWikiComposeSession", () => { expect(result.current.phase).toBe("completed"); }); + it("submitResearchApproval stops at conflict_resolution without POST /run", async () => { + arrangeRun([ + { type: "started", sessionId: SESSION.id, graphId: SESSION.graphId }, + { type: "done", status: "interrupted" }, + ]); + mocks.resumeSession.mockResolvedValue({ + status: "interrupted", + output: { + __interrupt__: [ + { + value: { + kind: "conflict_resolution", + conflicts: { + approved: [{ id: "src:a", title: "A" }], + rejected: [ + { id: "src:b", title: "B" }, + { id: "src:c", title: "C" }, + ], + rationale: "Confirm approved set.", + }, + }, + }, + ], + }, + }); + + const { result } = renderHook(() => + useWikiComposeSession({ pageId: "page-1", sessionId: null }), + ); + await waitFor(() => expect(result.current.session).not.toBeNull()); + + await act(async () => { + await result.current.submitResearchApproval({ + approvedSourceIds: ["src:a"], + rejectedSourceIds: ["src:b", "src:c"], + }); + }); + + expect(mocks.runSession).toHaveBeenCalledTimes(1); + expect(result.current.researchConflictSummary?.approved).toHaveLength(1); + expect(result.current.phase).toBe("research"); + }); + it("submitBrief applies research interrupt from PATCH output without POST /run", async () => { arrangeRun([ { type: "started", sessionId: SESSION.id, graphId: SESSION.graphId }, diff --git a/src/hooks/useWikiComposeSession.ts b/src/hooks/useWikiComposeSession.ts index d1a1aa71..e40f2853 100644 --- a/src/hooks/useWikiComposeSession.ts +++ b/src/hooks/useWikiComposeSession.ts @@ -33,6 +33,7 @@ import type { OutlineSection, PageSnapshot, ResearchBatch, + ResearchConflictSummary, ResearchSource, } from "@/lib/wikiCompose/types"; @@ -66,6 +67,8 @@ export interface WikiComposeSessionState { pendingSources: ResearchSource[]; /** Approved sources after research resume. */ approvedSources: ResearchSource[]; + /** Set when the graph halts at `conflict_resolution` (#953). */ + researchConflictSummary: ResearchConflictSummary | null; /** Proposed outline from the structure interrupt. */ outlineProposal: OutlineSection[]; /** Drafted section bodies — keyed by sectionId. */ @@ -93,6 +96,7 @@ const INITIAL_STATE: WikiComposeSessionState = { latestBatch: null, pendingSources: [], approvedSources: [], + researchConflictSummary: null, outlineProposal: [], draftedSections: {}, streamingSectionId: null, @@ -145,10 +149,27 @@ export interface UseWikiComposeSessionReturn extends WikiComposeSessionState { }) => Promise; /** Submit outline approval and continue streaming. */ submitOutline: (input: { sections: OutlineSection[] }) => Promise; + /** Acknowledge research conflicts and continue to Structure (#953). */ + submitConflictAck: () => Promise; /** Cancel the session (DELETE). */ cancel: () => Promise; } +/** First interrupt kind on a LangGraph checkpoint output, if any. */ +function interruptKindFromOutput(output: unknown): string | undefined { + if (!output || typeof output !== "object") return undefined; + const interrupts = (output as { __interrupt__?: unknown }).__interrupt__; + if (!Array.isArray(interrupts) || interrupts.length === 0) return undefined; + const entry = interrupts[0]; + const value = + entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined; + if (value && typeof value === "object" && "kind" in value) { + const kind = (value as { kind?: unknown }).kind; + return typeof kind === "string" ? kind : undefined; + } + return undefined; +} + /** * Returns a unique id for an activity row. Uses crypto.randomUUID when * available (modern browsers); falls back to a coarse fallback for old @@ -373,6 +394,9 @@ function hydrateFromProjection( if (projection.latestBatch !== undefined) partial.latestBatch = projection.latestBatch; if (projection.pendingSources?.length) partial.pendingSources = projection.pendingSources; if (projection.approvedSources?.length) partial.approvedSources = projection.approvedSources; + if (projection.researchConflictSummary) { + partial.researchConflictSummary = projection.researchConflictSummary; + } if (projection.outlineProposal?.length) partial.outlineProposal = projection.outlineProposal; if (projection.completedMarkdown) partial.completedMarkdown = projection.completedMarkdown; if (projection.draftedSections?.length) { @@ -466,10 +490,18 @@ function reduceInterrupt( return { outlineProposal: payload.outline, approvedSources: payload.approvedSources, + researchConflictSummary: null, phase: "structure", }; + case "conflict_resolution": + return { + researchConflictSummary: payload.conflicts, + approvedSources: prev.approvedSources, + pendingSources: [], + phase: "research", + }; default: - return prev; + return {}; } } @@ -611,6 +643,11 @@ export function useWikiComposeSession( const approved = state.pendingSources.filter((s) => input.approvedSourceIds.includes(s.id)); update({ approvedSources: approved }); const result = await resumeSession({ pageId, sessionId: session.id, resume: input }); + if (interruptKindFromOutput(result.output) === "conflict_resolution") { + const fromResume = reduceResumeOutput(result.output, result.status); + update({ status: result.status, ...fromResume }); + return; + } const fromResume = reduceResumeOutput(result.output, result.status); update({ status: result.status, ...fromResume }); const needsStream = @@ -625,6 +662,26 @@ export function useWikiComposeSession( [pageId, state.pendingSources, streamRun, update], ); + const submitConflictAck = useCallback(async () => { + const session = sessionRef.current; + if (!session) throw new Error("Session not initialised"); + const result = await resumeSession({ + pageId, + sessionId: session.id, + resume: { acknowledged: true as const }, + }); + const fromResume = reduceResumeOutput(result.output, result.status); + update({ status: result.status, researchConflictSummary: null, ...fromResume }); + const needsStream = + (result.status === "interrupted" || result.status === "running") && + !fromResume.briefQuestions?.length && + !fromResume.pendingSources?.length && + !fromResume.outlineProposal?.length; + if (needsStream) { + await streamRun(session); + } + }, [pageId, streamRun, update]); + const submitOutline = useCallback( async (input) => { const session = sessionRef.current; @@ -689,6 +746,7 @@ export function useWikiComposeSession( submitBrief, submitResearchApproval, submitOutline, + submitConflictAck, cancel, }; } diff --git a/src/lib/wikiCompose/composeService.ts b/src/lib/wikiCompose/composeService.ts index 69dfff6d..85d41cb8 100644 --- a/src/lib/wikiCompose/composeService.ts +++ b/src/lib/wikiCompose/composeService.ts @@ -170,6 +170,7 @@ export async function runSession(input: { * - `human_review_brief` — `{ answers, appendToExisting?, researchMaxIterations? }` * - `human_review_research` — `{ approvedSourceIds, rejectedSourceIds?, note? }` * - `human_review_outline` — `{ sections }` + * - `conflict_resolution` — `{ acknowledged: true, note?: string }` * * The server returns a JSON body `{ status, output }` on resume completion (no * SSE stream). Callers must hydrate UI state from `output` (interrupt payloads diff --git a/src/lib/wikiCompose/types.ts b/src/lib/wikiCompose/types.ts index f6d5acba..1eddd039 100644 --- a/src/lib/wikiCompose/types.ts +++ b/src/lib/wikiCompose/types.ts @@ -118,6 +118,13 @@ export interface ComposeSession { * Checkpoint projection returned by `GET /compose-sessions/:id` for reload (#950). * チェックポイントから復元した UI 用スライス。 */ +/** Summary shown at the P5 `conflict_resolution` interrupt (#953). */ +export interface ResearchConflictSummary { + approved: Array<{ id: string; title: string }>; + rejected: Array<{ id: string; title: string }>; + rationale: string; +} + export interface ComposeSessionUiProjection { phase?: "brief" | "research" | "structure" | "draft" | "completed"; briefQuestions?: BriefQuestion[]; @@ -125,6 +132,7 @@ export interface ComposeSessionUiProjection { pendingSources?: ResearchSource[]; latestBatch?: ResearchBatch | null; approvedSources?: ResearchSource[]; + researchConflictSummary?: ResearchConflictSummary; outlineProposal?: OutlineSection[]; draftedSections?: DraftedSection[]; completedMarkdown?: string | null; @@ -146,6 +154,10 @@ export type ComposeInterruptPayload = kind: "human_review_outline"; outline: OutlineSection[]; approvedSources: ResearchSource[]; + } + | { + kind: "conflict_resolution"; + conflicts: ResearchConflictSummary; }; // ── SSE event union (mirrors backend `SseEvent`) ─────────────────────────── diff --git a/src/pages/WikiComposePage.tsx b/src/pages/WikiComposePage.tsx index e28c67dd..8259b311 100644 --- a/src/pages/WikiComposePage.tsx +++ b/src/pages/WikiComposePage.tsx @@ -218,11 +218,13 @@ const WikiComposePage: React.FC = () => { latestBatch={session.latestBatch} pendingSources={session.pendingSources} approvedSources={session.approvedSources} + researchConflictSummary={session.researchConflictSummary} outlineProposal={session.outlineProposal} activity={session.activity} onSubmitBrief={session.submitBrief} onSubmitResearchApproval={session.submitResearchApproval} onSubmitOutline={session.submitOutline} + onSubmitConflictAck={session.submitConflictAck} /> );