Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

446 lines
12 KiB
TypeScript

/**
* Book Engine progress model
* ==========================
*
* A pure reducer that turns the raw `BookWsEvent` stream coming out of the
* BookEngine into a cleaned-up `BookProgress` snapshot. The `BookProgressTimeline`
* component renders this snapshot — the reducer keeps the UI presentational.
*
* Stage order matches the actual pipeline:
*
* ideation → exploration → synthesis (with critique sub-rounds) →
* overview (engine-injected) → compilation (per-page block stream)
*/
import type { BookWsEvent } from "@/lib/book-api";
export type StageId =
| "ideation"
| "exploration"
| "synthesis"
| "critique"
| "overview"
| "compilation";
export type StageState = "pending" | "running" | "completed" | "error";
export interface StageView {
id: StageId;
label: string;
description: string;
state: StageState;
detail?: string;
startedAt?: number;
endedAt?: number;
}
export interface BookProgress {
bookId: string | null;
stages: Record<StageId, StageView>;
ordered: StageId[];
// Aggregated counters
exploration: {
queryCount: number;
chunkCount: number;
candidateConcepts: number;
summary: string;
};
synthesis: {
rounds: number;
chapterCount: number;
conceptNodes: number;
conceptEdges: number;
lastVerdict: string;
};
critique: {
rounds: number;
issues: number;
};
compilation: {
pagesPlanned: number;
pagesReady: number;
blocksReady: number;
blocksError: number;
};
// Latest progress message — useful as a single-line live caption.
message: string;
updatedAt: number;
}
export const STAGE_ORDER: StageId[] = [
"ideation",
"exploration",
"synthesis",
"critique",
"overview",
"compilation",
];
const STAGE_LABELS: Record<StageId, { label: string; description: string }> = {
ideation: {
label: "Ideation",
description: "Drafting the proposal from your inputs.",
},
exploration: {
label: "Source sweep",
description: "Parallel multi-query retrieval across your KBs.",
},
synthesis: {
label: "Synthesis",
description: "Spine + concept graph (draft → revise).",
},
critique: {
label: "Critique",
description: "Self-review rounds tightening the spine.",
},
overview: {
label: "Overview chapter",
description: "Auto-built table of contents + concept map.",
},
compilation: {
label: "Compilation",
description: "Per-page block planning + generation.",
},
};
export function emptyBookProgress(): BookProgress {
const stages = Object.fromEntries(
STAGE_ORDER.map((id) => [
id,
{
id,
label: STAGE_LABELS[id].label,
description: STAGE_LABELS[id].description,
state: "pending" as StageState,
},
]),
) as Record<StageId, StageView>;
return {
bookId: null,
stages,
ordered: STAGE_ORDER,
exploration: {
queryCount: 0,
chunkCount: 0,
candidateConcepts: 0,
summary: "",
},
synthesis: {
rounds: 0,
chapterCount: 0,
conceptNodes: 0,
conceptEdges: 0,
lastVerdict: "",
},
critique: { rounds: 0, issues: 0 },
compilation: {
pagesPlanned: 0,
pagesReady: 0,
blocksReady: 0,
blocksError: 0,
},
message: "",
updatedAt: 0,
};
}
function patchStage(
state: BookProgress,
id: StageId,
patch: Partial<StageView>,
): BookProgress {
const next = { ...state.stages[id], ...patch };
return {
...state,
stages: { ...state.stages, [id]: next },
};
}
function startStage(state: BookProgress, id: StageId): BookProgress {
// Mark earlier stages completed if they're still pending/running so the
// timeline always reads left-to-right cleanly even when we miss an event.
let next = state;
for (const sid of STAGE_ORDER) {
if (sid === id) break;
const s = next.stages[sid];
if (s.state === "pending" || s.state === "running") {
next = patchStage(next, sid, { state: "completed", endedAt: Date.now() });
}
}
return patchStage(next, id, {
state: "running",
startedAt: next.stages[id].startedAt ?? Date.now(),
});
}
function completeStage(state: BookProgress, id: StageId): BookProgress {
return patchStage(state, id, { state: "completed", endedAt: Date.now() });
}
function asNumber(value: unknown): number {
if (typeof value === "number") return value;
if (Array.isArray(value)) return value.length;
if (value && typeof value === "object") return Object.keys(value).length;
return 0;
}
function asString(value: unknown): string {
return value == null ? "" : String(value);
}
/** Reducer: ingest a single WS event and return the next snapshot. */
export function reduceBookEvent(
state: BookProgress,
event: BookWsEvent,
): BookProgress {
const meta = (event.metadata as Record<string, unknown> | undefined) || {};
const stage = String((event as { stage?: string }).stage || "");
const rawKind = String(
(event.content as string) || (meta.kind as string) || "",
);
const eventType = String(event.type || "");
// Track book id once.
let next: BookProgress = { ...state, updatedAt: Date.now() };
const bookIdFromMeta = asString(meta.book_id);
if (bookIdFromMeta && next.bookId == null) {
next = { ...next, bookId: bookIdFromMeta };
}
// Pick up STAGE_BEGIN / STAGE_END from generic stream events.
if (eventType === "stage_begin" && stage) {
if ((STAGE_ORDER as string[]).includes(stage)) {
next = startStage(next, stage as StageId);
}
}
if (eventType === "stage_end" && stage) {
if ((STAGE_ORDER as string[]).includes(stage)) {
next = completeStage(next, stage as StageId);
}
}
// Generic progress message for the live caption.
if (eventType === "progress" && typeof event.content === "string") {
next = { ...next, message: event.content as string };
}
// Book-specific kinds.
switch (rawKind) {
case "proposal_ready": {
next = startStage(next, "ideation");
next = completeStage(next, "ideation");
next = { ...next, message: "Proposal ready" };
break;
}
case "exploration_ready": {
const queries = asNumber(meta.queries);
const coverage = meta.coverage as Record<string, unknown> | undefined;
const chunkCount = coverage
? Object.values(coverage).reduce<number>(
(acc, v) => acc + asNumber(v),
0,
)
: 0;
next = startStage(next, "exploration");
next = completeStage(next, "exploration");
next = {
...next,
exploration: {
queryCount: queries,
chunkCount,
candidateConcepts: asNumber(meta.candidate_concepts),
summary: asString(meta.summary).slice(0, 220),
},
message: `Source sweep done — ${queries} queries, ${chunkCount} chunks`,
};
next = patchStage(next, "exploration", {
detail: `${queries} queries · ${chunkCount} chunks`,
});
break;
}
case "spine_round": {
const round = asString(meta.round);
const isCritique = round.startsWith("critique");
const targetStage: StageId = isCritique ? "critique" : "synthesis";
next = startStage(next, targetStage);
const issueCount = asNumber(meta.issue_count);
const chapterCount = asNumber(meta.chapter_count);
const verdict = asString(meta.verdict);
if (isCritique) {
next = {
...next,
critique: {
rounds: next.critique.rounds + 1,
issues: issueCount,
},
};
next = patchStage(next, "critique", {
detail: `${next.critique.rounds} round${
next.critique.rounds === 1 ? "" : "s"
} · ${issueCount} issue${issueCount === 1 ? "" : "s"}`,
});
} else {
next = {
...next,
synthesis: {
...next.synthesis,
rounds: next.synthesis.rounds + 1,
chapterCount: chapterCount || next.synthesis.chapterCount,
lastVerdict: verdict,
},
};
next = patchStage(next, "synthesis", {
detail: `${next.synthesis.rounds} round${
next.synthesis.rounds === 1 ? "" : "s"
} · ${next.synthesis.chapterCount || 0} chapter${
next.synthesis.chapterCount === 1 ? "" : "s"
}`,
});
}
next = {
...next,
message: `${round}${verdict ? ` · ${verdict}` : ""}`,
};
break;
}
case "spine_ready": {
const chapterCount = asNumber(meta.chapter_count);
const nodes = asNumber(meta.concept_node_count);
const edges = asNumber(meta.concept_edge_count);
// Both synthesis + critique are done when the spine is ready.
next = startStage(next, "synthesis");
next = completeStage(next, "synthesis");
if (next.critique.rounds > 0) {
next = completeStage(next, "critique");
} else {
next = patchStage(next, "critique", { state: "completed" });
}
next = {
...next,
synthesis: {
...next.synthesis,
chapterCount,
conceptNodes: nodes,
conceptEdges: edges,
},
message: `Spine ready — ${chapterCount} chapters · ${nodes} concepts`,
};
next = patchStage(next, "synthesis", {
detail: `${chapterCount} chapters · ${nodes} concepts`,
});
break;
}
case "page_planning": {
next = startStage(next, "compilation");
next = {
...next,
compilation: {
...next.compilation,
pagesPlanned: next.compilation.pagesPlanned + 1,
},
};
break;
}
case "page_planned":
next = startStage(next, "compilation");
break;
case "block_ready": {
next = {
...next,
compilation: {
...next.compilation,
blocksReady: next.compilation.blocksReady + 1,
},
};
break;
}
case "block_error": {
next = {
...next,
compilation: {
...next.compilation,
blocksError: next.compilation.blocksError + 1,
},
};
break;
}
case "page_compiled":
case "page_ready": {
next = startStage(next, "compilation");
next = {
...next,
compilation: {
...next.compilation,
pagesReady: next.compilation.pagesReady + 1,
},
};
next = patchStage(next, "compilation", {
detail: `${next.compilation.pagesReady} page${
next.compilation.pagesReady === 1 ? "" : "s"
} · ${next.compilation.blocksReady} blocks`,
});
// Treat the overview as completed once the very first page is ready —
// the engine pre-materialises the overview before any normal page
// compilation.
if (next.stages.overview.state !== "completed") {
next = patchStage(next, "overview", {
state: "completed",
endedAt: Date.now(),
});
}
break;
}
case "overview_ready": {
next = startStage(next, "overview");
next = completeStage(next, "overview");
break;
}
case "compilation_complete": {
next = completeStage(next, "compilation");
next = { ...next, message: "Book compilation complete" };
break;
}
default:
break;
}
// Stream-level error → mark the *currently running* stage as errored.
if (eventType === "error") {
const running = STAGE_ORDER.find(
(id) => next.stages[id].state === "running",
);
if (running) {
next = patchStage(next, running, {
state: "error",
detail: asString(event.content) || "error",
});
}
}
return next;
}
/** True when at least one stage is no longer pending — useful for hiding the
* timeline on a freshly-loaded existing book where no events arrive. */
export function progressHasActivity(progress: BookProgress): boolean {
return STAGE_ORDER.some((id) => progress.stages[id].state !== "pending");
}
/** True when every stage is either completed or errored (or skipped). */
export function progressIsTerminal(progress: BookProgress): boolean {
return STAGE_ORDER.every((id) => {
const s = progress.stages[id].state;
return s === "completed" || s === "error" || s === "pending";
});
}
/** True when generation is fully done — every stage has completed or
* errored. Implies activity (compilation must have advanced past pending). */
export function progressIsComplete(progress: BookProgress): boolean {
if (!progressHasActivity(progress)) return false;
return STAGE_ORDER.every((id) => {
const s = progress.stages[id].state;
return s === "completed" || s === "error";
});
}