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..139f5984 --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeRouting.test.ts @@ -0,0 +1,97 @@ +/** + * Wiki Compose P5 routing predicates (#953). + * Wiki Compose P5 ルーティング述語のテスト (#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, + briefDegraded: false, + 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 is empty due to LLM degradation flag", () => { + expect(routeAfterBrief(minimalState({ briefQuestions: [], briefDegraded: true }))).toBe( + "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/__tests__/routes/composeSessionProjection.test.ts b/server/api/src/__tests__/routes/composeSessionProjection.test.ts index 9fe0c380..5af5d911 100644 --- a/server/api/src/__tests__/routes/composeSessionProjection.test.ts +++ b/server/api/src/__tests__/routes/composeSessionProjection.test.ts @@ -39,6 +39,30 @@ describe("projectComposeStateValues", () => { expect(projection.phase).toBe("research"); }); + it("projects a conflict_resolution interrupt (#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: "Mixed approval", + }, + }, + }, + ], + }); + expect(projection.phase).toBe("conflict"); + expect(projection.researchConflictSummary).toMatchObject({ rationale: "Mixed approval" }); + 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/types/sseEvents.ts b/server/api/src/agents/core/types/sseEvents.ts index 6983f45b..73bf4e78 100644 --- a/server/api/src/agents/core/types/sseEvents.ts +++ b/server/api/src/agents/core/types/sseEvents.ts @@ -179,7 +179,7 @@ export interface SseResearchBatchEvent { export interface SseComposePhaseEvent { type: "compose_phase"; /** Phase name (matches state.phase). */ - phase: "brief" | "research" | "structure" | "draft" | "completed"; + phase: "brief" | "research" | "conflict" | "structure" | "draft" | "completed"; /** Lifecycle hint within the phase. */ status: "entered" | "completed"; } 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/briefDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts index dac783ef..7b5e8df8 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts @@ -132,6 +132,7 @@ export async function briefDialogue( // `structured.invoke` returns the zod input type (pre-default), so we // accept it as-is and apply fallbacks at the projection step below. let raw: z.input; + let briefDegraded = false; try { raw = await structured.invoke([ { role: "system", content: SYSTEM_PROMPT }, @@ -142,10 +143,11 @@ export async function briefDialogue( ]); } catch { // Defensive fallback: if the LLM call fails, emit an empty Brief so the - // user can still proceed straight to research. The orchestrator must not - // become unstartable just because of a transient model error. - // LLM 失敗時は Brief 0 件で先へ進ませる安全策。 + // user can still proceed straight to research. `briefDegraded` prevents + // `routeAfterBrief` from skipping research on this path (#953). + // LLM 失敗時は Brief 0 件で先へ進ませる。`briefDegraded` で調査スキップと区別する。 raw = { questions: [] }; + briefDegraded = true; } const briefQuestions: BriefQuestion[] = raw.questions.map((q) => ({ @@ -163,6 +165,7 @@ export async function briefDialogue( return { pageSnapshot: snapshot, briefQuestions, + briefDegraded, phase: "brief:await_user", }; } 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..ed3e3a06 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/routing.ts @@ -0,0 +1,56 @@ +/** + * 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 intentionally emitted zero questions (title + * already clear) or when chat seeded a pre-approved outline. When + * `briefDegraded` is set (LLM failure fallback), always run research. + */ +export function routeAfterBrief(state: WikiComposeStateType): BriefRoute { + if (state.chatSeed?.outline?.trim()) return "skip_research"; + if (state.briefQuestions.length === 0 && !state.briefDegraded) 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..d8f604f7 100644 --- a/server/api/src/agents/graphs/wikiCompose/state.ts +++ b/server/api/src/agents/graphs/wikiCompose/state.ts @@ -122,6 +122,17 @@ export const WikiComposeState = Annotation.Root({ reducer: (prev, next) => (next === undefined ? prev : next), default: () => null, }), + /** + * True when `brief_dialogue` used the LLM error fallback (empty questions). + * Routing must not treat this as an intentional "skip research" signal (#953). + * + * `brief_dialogue` が LLM 失敗フォールバックで空質問になったとき true。 + * ルーティングで調査スキップと混同しない。 + */ + briefDegraded: Annotation({ + reducer: (_prev, next) => next, + default: () => false, + }), // ── Research mirror (matches ResearchLoopState exactly) ────────────────── /** 現在のループ回数(research subgraph が書く)。 */ @@ -174,6 +185,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..59a890c3 --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/index.ts @@ -0,0 +1,15 @@ +/** + * Wiki maintenance graph — public barrel (#953). + * Wikiメンテナンスグラフの公開バレル(#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..1c4c27b6 --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/nodes/scanStubPages.ts @@ -0,0 +1,54 @@ +/** + * `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, asc, 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}`, + ), + ), + ) + .orderBy(asc(pages.id)) + .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..218ac601 --- /dev/null +++ b/server/api/src/agents/graphs/wikiMaintenance/state.ts @@ -0,0 +1,33 @@ +/** + * `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, + }), +}); + +/** + * Materialized state shape for wiki maintenance graph execution. + * Wiki メンテナンス graph 実行時の確定 state 形状。 + */ +export type WikiMaintenanceStateType = typeof WikiMaintenanceState.State; +/** + * Partial update returned by wiki maintenance nodes. + * Wiki メンテナンス各ノードが返す部分更新。 + */ +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/composeSessionProjection.ts b/server/api/src/routes/composeSessionProjection.ts index c631c537..ce115772 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 conflict-resolution interrupt summary (#953). / P5 conflict-resolution 割り込み要約。 */ + researchConflictSummary?: unknown; outlineProposal?: unknown[]; draftedSections?: unknown[]; completedMarkdown?: string | null; @@ -33,6 +35,7 @@ function phaseFromSessionRow(phase: string, status: WikiComposeSessionStatus): s if (status === "completed") return "completed"; if (phase.startsWith("brief")) return "brief"; if (phase.startsWith("research")) return "research"; + if (phase.startsWith("conflict")) return "conflict"; if (phase.startsWith("structure")) return "structure"; if (phase.startsWith("draft")) return "draft"; return "brief"; @@ -99,6 +102,7 @@ export function projectComposeStateValues( pendingSources?: unknown[]; outline?: unknown[]; approvedSources?: unknown[]; + conflicts?: unknown; }; switch (payload.kind) { case "human_review_brief": @@ -116,6 +120,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 = "conflict"; + break; default: break; } diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index 40c48dda..dc33f167 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; } diff --git a/src/components/wikiCompose/ComposePanel.tsx b/src/components/wikiCompose/ComposePanel.tsx index 5ec7f692..6d54d522 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 { ConflictResolutionSection } from "./ConflictResolutionSection"; 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: (input?: { note?: string }) => 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} /> + {phase === "conflict" && researchConflictSummary ? ( + + ) : null} + {/* Research review: visible during research interrupt and as a read-only summary in later phases. */} - {phase === "research" || (phase !== "brief" && approvedSources.length > 0) ? ( + {phase === "research" || + (phase !== "brief" && phase !== "conflict" && approvedSources.length > 0) ? ( Promise; +} + +/** + * Conflict acknowledgment panel between Research and Structure. + * Research と Structure の間で表示する矛盾解消確認パネル。 + */ +export const ConflictResolutionSection: React.FC = ({ + conflicts, + isStreaming, + onSubmit, +}) => { + const [submitting, setSubmitting] = useState(false); + + return ( +
+
+ + Resolve conflicts +
+ + + Research conflicts + + +

{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/components/wikiCompose/PhaseStepper.tsx b/src/components/wikiCompose/PhaseStepper.tsx index 328b0443..a6ae8b7c 100644 --- a/src/components/wikiCompose/PhaseStepper.tsx +++ b/src/components/wikiCompose/PhaseStepper.tsx @@ -31,7 +31,12 @@ export interface PhaseStepperProps { /** Render the 5-step phase stepper. */ export const PhaseStepper: React.FC = ({ phase }) => { - const currentIndex = Math.max(0, PHASE_ORDER.indexOf(phase)); + // P5 conflict interrupt sits between Research and Structure on the graph, but + // the stepper keeps five labels — highlight Research while resolving conflicts. + // P5 の conflict interrupt は Research と Structure の間にあるが、 + // ステッパーは 5 ラベル維持のため conflict 中は Research をハイライトする。 + const stepPhase: ComposePhase = phase === "conflict" ? "research" : phase; + const currentIndex = Math.max(0, PHASE_ORDER.indexOf(stepPhase)); return (
    {PHASE_ORDER.map((p, i) => { diff --git a/src/hooks/useWikiComposeSession.ts b/src/hooks/useWikiComposeSession.ts index d1a1aa71..0cefb8dd 100644 --- a/src/hooks/useWikiComposeSession.ts +++ b/src/hooks/useWikiComposeSession.ts @@ -33,10 +33,15 @@ import type { OutlineSection, PageSnapshot, ResearchBatch, + ResearchConflictSummary, ResearchSource, } from "@/lib/wikiCompose/types"; -export type ComposePhase = "brief" | "research" | "structure" | "draft" | "completed"; +/** + * UI phase for Wiki Compose session progression. + * Wiki Compose セッション進行を表す UI フェーズ。 + */ +export type ComposePhase = "brief" | "research" | "conflict" | "structure" | "draft" | "completed"; /** Activity log entry surfaced in the right pane's ActivitySection. */ export interface ComposeActivity { @@ -66,6 +71,8 @@ export interface WikiComposeSessionState { pendingSources: ResearchSource[]; /** Approved sources after research resume. */ approvedSources: ResearchSource[]; + /** P5 conflict-resolution interrupt payload (#953). */ + researchConflictSummary: ResearchConflictSummary | null; /** Proposed outline from the structure interrupt. */ outlineProposal: OutlineSection[]; /** Drafted section bodies — keyed by sectionId. */ @@ -93,6 +100,7 @@ const INITIAL_STATE: WikiComposeSessionState = { latestBatch: null, pendingSources: [], approvedSources: [], + researchConflictSummary: null, outlineProposal: [], draftedSections: {}, streamingSectionId: null, @@ -145,6 +153,11 @@ 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). + * 調査の矛盾を確認し Structure へ進む(#953)。 + */ + submitConflictAck: (input?: { note?: string }) => Promise; /** Cancel the session (DELETE). */ cancel: () => Promise; } @@ -373,6 +386,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) { @@ -385,6 +401,17 @@ function hydrateFromProjection( return partial; } +/** + * Build hook state context for resume-time interrupt projection. + * resume 時の interrupt 投影用に、チェックポイント上の approvedResearch を引き継ぐ。 + */ +function interruptContextFromCheckpoint(state: Record): WikiComposeSessionState { + const approved = Array.isArray(state.approvedResearch) + ? (state.approvedResearch as ResearchSource[]) + : []; + return { ...INITIAL_STATE, approvedSources: approved }; +} + function reduceResumeOutput( output: unknown, status: ComposeSessionStatus, @@ -401,7 +428,10 @@ function reduceResumeOutput( const value = entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined; if (value && typeof value === "object" && "kind" in value) { - Object.assign(partial, reduceInterrupt(INITIAL_STATE, value as ComposeInterruptPayload)); + Object.assign( + partial, + reduceInterrupt(interruptContextFromCheckpoint(state), value as ComposeInterruptPayload), + ); } } @@ -468,8 +498,16 @@ function reduceInterrupt( approvedSources: payload.approvedSources, phase: "structure", }; + case "conflict_resolution": + return { + researchConflictSummary: payload.conflicts, + phase: "conflict", + // Keep approvals from `prev` (SSE) or checkpoint context (resume); do not + // overwrite with an empty array when `reduceResumeOutput` seeds context. + ...(prev.approvedSources.length > 0 ? { approvedSources: prev.approvedSources } : {}), + }; default: - return prev; + return {}; } } @@ -617,6 +655,7 @@ export function useWikiComposeSession( (result.status === "interrupted" || result.status === "running") && !fromResume.briefQuestions?.length && !fromResume.pendingSources?.length && + !fromResume.researchConflictSummary && !fromResume.outlineProposal?.length; if (needsStream) { await streamRun(session); @@ -625,6 +664,35 @@ export function useWikiComposeSession( [pageId, state.pendingSources, streamRun, update], ); + const submitConflictAck = useCallback( + async (input) => { + 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, ...(input?.note ? { note: input.note } : {}) }, + }); + 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.researchConflictSummary && + !fromResume.outlineProposal?.length && + !fromResume.completedMarkdown; + if (needsStream) { + await streamRun(session); + } + }, + [pageId, streamRun, update], + ); + const submitOutline = useCallback( async (input) => { const session = sessionRef.current; @@ -688,6 +756,7 @@ export function useWikiComposeSession( start, submitBrief, submitResearchApproval, + submitConflictAck, submitOutline, cancel, }; diff --git a/src/lib/wikiCompose/types.ts b/src/lib/wikiCompose/types.ts index f6d5acba..2d6649e4 100644 --- a/src/lib/wikiCompose/types.ts +++ b/src/lib/wikiCompose/types.ts @@ -118,13 +118,24 @@ export interface ComposeSession { * Checkpoint projection returned by `GET /compose-sessions/:id` for reload (#950). * チェックポイントから復元した UI 用スライス。 */ +/** + * Summary shown at the P5 `conflict_resolution` interrupt (#953). + * P5 `conflict_resolution` 割り込みで表示する要約。 + */ +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"; + phase?: "brief" | "research" | "conflict" | "structure" | "draft" | "completed"; briefQuestions?: BriefQuestion[]; pageSnapshot?: PageSnapshot; pendingSources?: ResearchSource[]; latestBatch?: ResearchBatch | null; approvedSources?: ResearchSource[]; + researchConflictSummary?: ResearchConflictSummary; outlineProposal?: OutlineSection[]; draftedSections?: DraftedSection[]; completedMarkdown?: string | null; @@ -146,6 +157,10 @@ export type ComposeInterruptPayload = kind: "human_review_outline"; outline: OutlineSection[]; approvedSources: ResearchSource[]; + } + | { + kind: "conflict_resolution"; + conflicts: ResearchConflictSummary; }; // ── SSE event union (mirrors backend `SseEvent`) ─────────────────────────── @@ -188,7 +203,7 @@ export type ComposeSseEvent = } | { type: "compose_phase"; - phase: "brief" | "research" | "structure" | "draft" | "completed"; + phase: "brief" | "research" | "conflict" | "structure" | "draft" | "completed"; status: "entered" | "completed"; } | { diff --git a/src/pages/WikiComposePage.tsx b/src/pages/WikiComposePage.tsx index e28c67dd..9a452c5b 100644 --- a/src/pages/WikiComposePage.tsx +++ b/src/pages/WikiComposePage.tsx @@ -218,10 +218,12 @@ 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} + onSubmitConflictAck={session.submitConflictAck} onSubmitOutline={session.submitOutline} /> );