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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* `composeSessionProjection` のユニットテスト (#950)。
* Unit tests for `composeSessionProjection`.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { describe, expect, it } from "vitest";
import { projectComposeStateValues } from "../../routes/composeSessionProjection.js";

describe("projectComposeStateValues", () => {
it("projects a Brief interrupt from __interrupt__", () => {
const projection = projectComposeStateValues({
__interrupt__: [
{
value: {
kind: "human_review_brief",
questions: [{ id: "q1", question: "Scope?", required: false, options: [] }],
pageSnapshot: { pageId: "p1", title: "T", body: "", hasContent: false },
},
},
],
});
expect(projection.phase).toBe("brief");
expect(projection.briefQuestions).toHaveLength(1);
expect(projection.pageSnapshot).toMatchObject({ title: "T" });
});

it("keeps interrupt-derived phase when row phase is also present", () => {
const projection = projectComposeStateValues({
phase: "brief:await_user",
__interrupt__: [
{
value: {
kind: "human_review_research",
batch: null,
pendingSources: [],
},
},
],
});
expect(projection.phase).toBe("research");
});

it("projects completion markdown from checkpoint values", () => {
const projection = projectComposeStateValues({
phase: "completed",
completion: {
markdown: "## A\n\nBody",
sections: [
{
sectionId: "sec-1",
heading: "A",
body: "Body",
citedSourceIds: [],
completedAt: "2026-01-01T00:00:00.000Z",
},
],
},
});
expect(projection.completedMarkdown).toBe("## A\n\nBody");
expect(projection.draftedSections).toHaveLength(1);
});
});
27 changes: 25 additions & 2 deletions server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ const SYSTEM_PROMPT =
"make the article unwritable. Most questions should be optional.\n" +
"Respond as JSON only.";

function buildUserPrompt(title: string, body: string): string {
function buildUserPrompt(
title: string,
body: string,
chatSeed?: { outline: string; conversationText: string; userSchema?: string } | null,
): string {
const parts: string[] = [`[Page title]`, title || "(no title yet)"];
if (body.trim()) {
parts.push(
Expand All @@ -83,6 +87,22 @@ function buildUserPrompt(title: string, body: string): string {
} else {
parts.push("", "(Page body is empty.)");
}
if (chatSeed?.outline?.trim()) {
parts.push("", "[User-approved outline from chat]", chatSeed.outline.trim().slice(0, 2000));
}
if (chatSeed?.conversationText?.trim()) {
parts.push(
"",
"[Chat transcript excerpt]",
chatSeed.conversationText.trim().slice(0, 4000),
chatSeed.conversationText.length > 4000
? `\n(…truncated; total ${chatSeed.conversationText.length} chars)`
: "",
);
}
if (chatSeed?.userSchema?.trim()) {
parts.push("", "[User wiki schema]", chatSeed.userSchema.trim().slice(0, 1500));
}
return parts.join("\n");
}

Expand Down Expand Up @@ -120,7 +140,10 @@ export async function briefDialogue(
try {
raw = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: buildUserPrompt(snapshot.title, snapshot.body) },
{
role: "user",
content: buildUserPrompt(snapshot.title, snapshot.body, state.chatSeed),
},
]);
} catch {
// Defensive fallback: if the LLM call fails, emit an empty Brief so the
Expand Down
12 changes: 12 additions & 0 deletions server/api/src/agents/graphs/wikiCompose/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
ApprovedOutline,
BriefQuestion,
BriefResult,
ComposeChatSeed,
ComposeCompletion,
DraftedSection,
OutlineSection,
Expand Down Expand Up @@ -95,6 +96,17 @@ export const WikiComposeState = Annotation.Root({
...BaseState.spec,

// ── Brief phase ───────────────────────────────────────────────────────────
/**
* チャット由来 seed(outline + 会話)。AI Chat / Promote to Wiki からの
* 初回 `POST /run` input でセットする (#950)。
*
* Chat → Compose seed (outline + conversation). Set on the first `POST /run`
* input when the user arrives from AI Chat / Promote to Wiki (#950).
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
chatSeed: Annotation<ComposeChatSeed | null>({
reducer: (prev, next) => (next === undefined ? prev : next),
default: () => null,
}),
/** Page snapshot loaded once at session start. */
pageSnapshot: Annotation<PageSnapshot | null>({
reducer: (prev, next) => (next === undefined ? prev : next),
Expand Down
14 changes: 14 additions & 0 deletions server/api/src/agents/graphs/wikiCompose/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ import type { Source } from "../../subgraphs/research/types.js";
/** Re-exported here so orchestrator nodes can import everything from one barrel. */
export type { Source };

/**
* チャットから Compose に入ったときの任意 seed (#950)。
* 初回 `POST /run` の input とセッション行 `metadata` に載せる。
*
* Optional chat context seeded when entering Compose from AI Chat (#950).
* Passed on the first graph `input` and stored on the session row metadata.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export interface ComposeChatSeed {
outline: string;
conversationText: string;
userSchema?: string;
conversationId?: string;
}

/**
* Brief フェーズで Orchestrator が生成する 1 つの構造化質問。
*
Expand Down
180 changes: 180 additions & 0 deletions server/api/src/routes/composeSessionProjection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Project LangGraph checkpoint state into Compose UI slices (#950).
*
* `GET /compose-sessions/:id` が interrupted / completed 行を再開するとき、
* チェックポイントから Brief 質問・アウトライン等を復元する。
*
* Maps persisted graph state (including `__interrupt__`) into a JSON shape the
* frontend hook can merge without replaying `POST /run`.
*/
import { GRAPH_CONTEXT_CONFIG_KEY } from "../agents/core/types/graphContext.js";
import type { GraphContext } from "../agents/core/types/graphContext.js";
import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js";
import { getRegisteredGraph } from "../agents/registry/graphRegistry.js";
import type { WikiComposeSessionStatus } from "../schema/wikiComposeSessions.js";

/**
* `GET /compose-sessions/:id` が返す UI projection。
* Wire projection returned by `GET /compose-sessions/:id`.
*/
export interface ComposeSessionUiProjection {
phase?: string;
briefQuestions?: unknown[];
pageSnapshot?: unknown;
pendingSources?: unknown[];
latestBatch?: unknown;
approvedSources?: unknown[];
outlineProposal?: unknown[];
draftedSections?: unknown[];
completedMarkdown?: string | null;
}

function phaseFromSessionRow(phase: string, status: WikiComposeSessionStatus): string {
if (status === "completed") return "completed";
if (phase.startsWith("brief")) return "brief";
if (phase.startsWith("research")) return "research";
if (phase.startsWith("structure")) return "structure";
if (phase.startsWith("draft")) return "draft";
return "brief";
}

/**
* Build UI projection from a LangGraph state snapshot (values + interrupts).
*/
export function projectComposeStateValues(
state: Record<string, unknown>,
): ComposeSessionUiProjection {
const projection: ComposeSessionUiProjection = {};

if (Array.isArray(state.briefQuestions)) {
projection.briefQuestions = state.briefQuestions;
}
if (state.pageSnapshot && typeof state.pageSnapshot === "object") {
projection.pageSnapshot = state.pageSnapshot;
}
if (Array.isArray(state.pendingSources)) {
projection.pendingSources = state.pendingSources;
}
if (Array.isArray(state.batches) && state.batches.length > 0) {
projection.latestBatch = state.batches[state.batches.length - 1];
}
if (Array.isArray(state.approvedResearch)) {
projection.approvedSources = state.approvedResearch;
}
if (Array.isArray(state.outlineProposal) && state.outlineProposal.length > 0) {
projection.outlineProposal = state.outlineProposal;
} else {
const approved = state.approvedOutline as { sections?: unknown[] } | undefined;
if (approved?.sections?.length) {
projection.outlineProposal = approved.sections;
}
}

if (Array.isArray(state.draftedSections) && state.draftedSections.length > 0) {
projection.draftedSections = state.draftedSections;
}

const completion = state.completion;
if (completion && typeof completion === "object") {
const c = completion as { markdown?: string; sections?: unknown[] };
if (typeof c.markdown === "string") {
projection.completedMarkdown = c.markdown;
}
if (Array.isArray(c.sections)) {
projection.draftedSections = c.sections;
}
}

const interrupts = state.__interrupt__;
if (Array.isArray(interrupts) && interrupts.length > 0) {
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 payload = value as {
kind: string;
questions?: unknown[];
pageSnapshot?: unknown;
batch?: unknown;
pendingSources?: unknown[];
outline?: unknown[];
approvedSources?: unknown[];
};
switch (payload.kind) {
case "human_review_brief":
if (payload.questions) projection.briefQuestions = payload.questions;
if (payload.pageSnapshot) projection.pageSnapshot = payload.pageSnapshot;
projection.phase = "brief";
break;
case "human_review_research":
if (payload.batch) projection.latestBatch = payload.batch;
if (payload.pendingSources) projection.pendingSources = payload.pendingSources;
projection.phase = "research";
break;
case "human_review_outline":
if (payload.outline) projection.outlineProposal = payload.outline;
if (payload.approvedSources) projection.approvedSources = payload.approvedSources;
projection.phase = "structure";
break;
default:
break;
}
}
}

// Interrupt-derived phase wins; row `phase` is only a fallback.
// interrupt 由来の phase を優先し、行の phase はフォールバックのみ。
if (typeof state.phase === "string" && projection.phase === undefined) {
projection.phase = phaseFromSessionRow(state.phase, "interrupted");
}

return projection;
}

/**
* チェックポイントから UI projection を読み込む。利用不可時は `null`。
* Load checkpoint projection for a compose session row, or `null` when unavailable.
*/
export async function loadComposeSessionProjection(input: {
sessionId: string;
pageId: string;
graphId: string;
status: WikiComposeSessionStatus;
phase: string;
context: GraphContext;
}): Promise<ComposeSessionUiProjection | null> {
if (input.status !== "interrupted" && input.status !== "completed" && input.status !== "failed") {
return null;
}

const checkpointer = await resolveCheckpointerForRun();
if (checkpointer === false) return null;

const registered = getRegisteredGraph(input.graphId);
if (!registered) return null;

const graph = registered.factory({ checkpointer }) as {
getState?: (config: unknown) => Promise<{ values?: Record<string, unknown> } | undefined>;
};
if (typeof graph.getState !== "function") return null;

const config = {
configurable: {
thread_id: input.sessionId,
[GRAPH_CONTEXT_CONFIG_KEY]: input.context,
},
};

try {
const snap = await graph.getState(config);
const values = snap?.values;
if (!values || typeof values !== "object") return null;
const projection = projectComposeStateValues(values);
if (!projection.phase) {
projection.phase = phaseFromSessionRow(input.phase, input.status);
}
return projection;
} catch {
return null;
}
}
36 changes: 35 additions & 1 deletion server/api/src/routes/composeSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { RESEARCH_GRAPH_ID } from "../agents/subgraphs/research/index.js";
import { WIKI_COMPOSE_GRAPH_ID } from "../agents/graphs/wikiCompose/index.js";
import type { AppEnv } from "../types/index.js";
import { persistOutcomeIfStillRunning } from "./composeSessionPersistence.js";
import { loadComposeSessionProjection } from "./composeSessionProjection.js";

/**
* Translate the documented `body.input.kind === "additional_research"` shape
Expand Down Expand Up @@ -210,7 +211,40 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => {
.limit(1);
if (!row) throw new HTTPException(404, { message: "Session not found" });

return c.json({ session: row });
const tier = await getUserTier(userId, db);

// Stale / unsupported backend rows must still be readable; skip projection
// instead of turning GET into a 500 (CodeRabbit P1 on reload path).
// 古い backend 行でもセッション行は返し、projection だけ省略する。
let projection = null;
try {
const backend = assertSupportedBackendP0(row.backend);
projection = await loadComposeSessionProjection({
sessionId: row.id,
pageId: row.pageId,
graphId: row.graphId,
status: row.status,
phase: row.phase,
context: {
threadId: row.id,
sessionId: row.id,
userId,
userEmail: c.get("userEmail") ?? null,
pageId: row.pageId,
graphId: row.graphId,
backend,
tier,
db,
feature: `wiki_compose:${row.graphId}`,
},
});
} catch (err) {
if (!(err instanceof UnsupportedBackendError)) {
throw err;
}
}

return c.json({ session: row, projection });
});

// ── POST /:id/run — SSE run ─────────────────────────────────────────────────
Expand Down
Loading
Loading