practice fix — creation is graph-domain: cluster picker, nodeIds-only API (owner ruling 2026-09-29)
Owner ruling: 'problem sets always take in nodes — graph objects' —
practice is graph-domain, not chat-domain. No session-created sets.
API: POST /api/practice/sets takes {nodeIds, params, title?} ONLY —
the chatId branch, UnknownChatError, and the any-provenance filter are
deleted; the selector is nodeIds (readNodes filtered to the selection,
unknown ids drop, empty live intersection → 400 empty_selection);
title optional from the UI (the picker's cluster label), dated default
stays. chatStore leaves the pipeline deps entirely (labels resolve
store-side in the clusters service).
GET /api/graph/clusters (new): the birth-session groups the graph view
renders — graphSnapshot's clusters verbatim (same ordering, same
MIN_CLUSTER_SIZE presentation filter), mapped to {key, kind
chat|notes, label (null when the naming record is gone — honest,
never guessed), nodeIds, nodeCount}; corrupt/empty graph → empty
list. GraphClusterGroup contract in core/contracts/graph.ts (client-
safe — the dialog imports it).
practice-dialog: session picker → cluster picker (rows show label +
proposition count, notes clusters marked); Create posts the cluster's
nodeIds + label as title + params. 400 empty-selection copy reworded
for the vanished-nodes edge ('Nothing practicable is left in that
cluster — the graph changed.'); 503 deep link unchanged. Preselected
mode gone — the dialog is the only creation entry for now (the graph-
view cluster entry comes later with free-form selection).
Sidebar: session right-click 'Practice this session's propositions'
removed (item + state + dialog wiring); the /practice nav row stays.
Tests: pipeline — chatId/any-provenance tests die, nodeIds selection
pins added (unknown-id filtering, dated-default title, title hint
rides, empty/vanished → EmptySelectionError); render — cluster picker
pins (label+count rows, notes kind, unlabeled cluster muted, empty
graph line), preselected pins removed; 16 render + 13 pipeline.
Docs: architecture.md §7 API+UI paragraph rewritten (graph-domain
ruling, /api/graph/clusters, cluster picker as the only entry, no
404-unknown-chat); README creation-flow line updated.
1130/1130, tsc 0, lint 0, build 0.
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
* flight is rejected (single live grading run per set).
|
||||
*/
|
||||
|
||||
import type { ChatStore, GraphStore, PracticeStore } from "../../core/ports.ts";
|
||||
import type { GraphStore, PracticeStore } from "../../core/ports.ts";
|
||||
import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts";
|
||||
import { chainsForSelection } from "../../core/practice.ts";
|
||||
import {
|
||||
@@ -55,14 +55,6 @@ export class EmptySelectionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** chatId path: the chat record is gone (404). */
|
||||
export class UnknownChatError extends Error {
|
||||
constructor(chatId: string) {
|
||||
super(`no chat ${chatId}`);
|
||||
this.name = "UnknownChatError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Set id unknown (404). */
|
||||
export class PracticeSetNotFound extends Error {
|
||||
constructor(setId: string) {
|
||||
@@ -267,8 +259,6 @@ function dedupInput(practiceStore: PracticeStore, selected: ReadonlySet<string>)
|
||||
export interface PracticePipelineDeps extends PracticeModels {
|
||||
graphStore: GraphStore;
|
||||
practiceStore: PracticeStore;
|
||||
/** Titles for the chat-born path (and existence: unknown chat → 404). */
|
||||
chatStore: Pick<ChatStore, "getChat" | "listChats">;
|
||||
}
|
||||
|
||||
export function createPracticePipeline(deps: PracticePipelineDeps) {
|
||||
@@ -428,27 +418,17 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
|
||||
* (models/key) fire BEFORE anything is created.
|
||||
*/
|
||||
async function createProblemSet(
|
||||
selector: { chatId: string } | { nodeIds: string[] },
|
||||
nodeIds: string[],
|
||||
params: ProblemSet["params"],
|
||||
titleHint?: string,
|
||||
): Promise<{ set: ProblemSet; done: Promise<void> }> {
|
||||
agents.refuseIfStagesUnready();
|
||||
|
||||
// Resolve the selection: chatId = any-provenance membership (owner:
|
||||
// a node belongs to a session when ANY provenance point names it —
|
||||
// birth-session clustering deliberately NOT used here); nodeIds =
|
||||
// the raw list, unknown ids simply drop.
|
||||
let sourceTitle: string | undefined;
|
||||
let selected;
|
||||
if ("chatId" in selector) {
|
||||
const chat = deps.chatStore.getChat(selector.chatId);
|
||||
if (!chat) throw new UnknownChatError(selector.chatId);
|
||||
sourceTitle = chat.title;
|
||||
selected = deps.graphStore.readNodes().filter((n) => n.provenance.some((pt) => pt.chatId === selector.chatId));
|
||||
} else {
|
||||
const wanted = new Set(selector.nodeIds);
|
||||
selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
|
||||
}
|
||||
// Graph-domain selection (owner ruling 2026-09-29: problem sets
|
||||
// take NODES — the UI resolves a picked cluster to its member ids;
|
||||
// unknown ids simply drop, an empty live intersection refuses).
|
||||
const wanted = new Set(nodeIds);
|
||||
const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
|
||||
const selectedIds = new Set(selected.map((n) => n.id));
|
||||
const edges = deps
|
||||
.graphStore.readEdges()
|
||||
@@ -457,7 +437,7 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
|
||||
if (chains.length === 0) throw new EmptySelectionError();
|
||||
const propositions: PropositionSnapshot = {};
|
||||
for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement };
|
||||
const title = titleHint ?? sourceTitle ?? `Practice — ${new Date().toISOString().slice(0, 10)}`;
|
||||
const title = titleHint ?? `Practice — ${new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
const set = deps.practiceStore.createSet({
|
||||
title,
|
||||
|
||||
@@ -258,6 +258,25 @@ export interface Cluster {
|
||||
members: string[];
|
||||
}
|
||||
|
||||
/** One birth-session cluster as the practice picker consumes it (owner
|
||||
* ruling 2026-09-29: problem sets take NODES — the UI resolves a picked
|
||||
* cluster to its member ids). Same grouping the graph view renders
|
||||
* (birthSessionPartition semantics, MIN_CLUSTER_SIZE presentation
|
||||
* filter included); `label` is null when the naming record is gone —
|
||||
* honest, never guessed (the graph view renders those unlabeled). */
|
||||
export interface GraphClusterGroup {
|
||||
/** The birth session's id — a chatId or a notes source id. */
|
||||
key: string;
|
||||
/** What kind of record names the group. */
|
||||
kind: "chat" | "notes";
|
||||
/** Session title / source file path; null when the record is gone. */
|
||||
label: string | null;
|
||||
/** Member node ids, sorted (the partition's members). */
|
||||
nodeIds: string[];
|
||||
/** === nodeIds.length, denormalized for the picker row. */
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
export interface GraphSnapshot {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
|
||||
@@ -103,7 +103,6 @@ export const extraction = createExtractionPipeline({
|
||||
export const practice = createPracticePipeline({
|
||||
graphStore: stores.graphStore,
|
||||
practiceStore: stores.practiceStore,
|
||||
chatStore: stores.chatStore,
|
||||
// BYOK (owner, 2026-09-22): the practice stages resolve the chain —
|
||||
// role override → slot → NOTHING — read FRESH per stage call; an empty
|
||||
// chain flows through as undefined and the pipeline refuses the run
|
||||
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import { LEDGER_FILES, POSITIONS_FILE } from "../../config.ts";
|
||||
import type { GraphSnapshot } from "../core/contracts.ts";
|
||||
import type { GraphClusterGroup, GraphSnapshot } from "../core/contracts.ts";
|
||||
import type { GraphCostSummary } from "../core/cost-summary.ts";
|
||||
import { graphCostSummary } from "../core/cost-summary.ts";
|
||||
import { graphSnapshot } from "../core/graph.ts";
|
||||
@@ -23,6 +23,24 @@ export function graphView(): GraphSnapshot {
|
||||
return graphSnapshot(stores);
|
||||
}
|
||||
|
||||
/** GET /api/graph/clusters — the birth-session groups the graph view
|
||||
* renders, resolved to node ids for the practice picker (owner ruling
|
||||
* 2026-09-29: cluster→nodeIds resolves in the UI; the API stays
|
||||
* graph-domain). Reuses the snapshot's clustering verbatim — same
|
||||
* ordering (size desc, ties by id) and the same MIN_CLUSTER_SIZE
|
||||
* presentation filter the canvas honors. A corrupt/empty graph is an
|
||||
* empty list. */
|
||||
export function graphClusters(): GraphClusterGroup[] {
|
||||
const snap = graphSnapshot(stores);
|
||||
return snap.clusters.map((cluster) => ({
|
||||
key: cluster.id,
|
||||
kind: cluster.id in snap.sessions ? "chat" : "notes",
|
||||
label: cluster.label,
|
||||
nodeIds: cluster.members,
|
||||
nodeCount: cluster.members.length,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The graph view's cost surface (GET /api/graph/costs — the 2026-09 cost
|
||||
* pill): fold every usage channel into stage totals + recent runs. A pure
|
||||
* read over the chat records (chat-front-door runs), the notes sources
|
||||
|
||||
+17
-16
@@ -17,7 +17,6 @@ import {
|
||||
EmptySelectionError,
|
||||
PracticeSetNotFound,
|
||||
PracticeSetNotReady,
|
||||
UnknownChatError,
|
||||
} from "../agent/subagents/practice-pipeline.ts";
|
||||
import {
|
||||
ModelsNotConfiguredError,
|
||||
@@ -72,7 +71,6 @@ export function practiceErrorResponse(err: unknown): Response {
|
||||
);
|
||||
}
|
||||
if (err instanceof EmptySelectionError) return Response.json({ error: err.message, code: "empty_selection" }, { status: 400 });
|
||||
if (err instanceof UnknownChatError) return Response.json({ error: err.message, code: "unknown_chat" }, { status: 404 });
|
||||
if (err instanceof BadAnswersError) return Response.json({ error: err.message, code: "bad_answers" }, { status: 400 });
|
||||
if (err instanceof PracticeSetNotFound) return Response.json({ error: err.message, code: "not_found" }, { status: 404 });
|
||||
if (err instanceof PracticeSetNotReady) return Response.json({ error: err.message, code: "not_ready" }, { status: 409 });
|
||||
@@ -81,7 +79,10 @@ export function practiceErrorResponse(err: unknown): Response {
|
||||
}
|
||||
|
||||
/** POST /api/practice/sets — 202 {id}; the set generates in the
|
||||
* background (fire-and-forget pipeline). */
|
||||
* background (fire-and-forget pipeline). Body is {nodeIds, params,
|
||||
* title?} ONLY (owner ruling 2026-09-29: problem sets take NODES —
|
||||
* graph objects; the UI resolves a picked cluster to its member ids,
|
||||
* and may pass the cluster label as the title). */
|
||||
export async function createPracticeSetResponse(body: unknown): Promise<Response> {
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
const params = parseParams(b.params);
|
||||
@@ -91,23 +92,23 @@ export async function createPracticeSetResponse(body: unknown): Promise<Response
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const titleHint =
|
||||
typeof b.title === "string" && b.title.trim() !== "" ? b.title.trim() : undefined;
|
||||
if (
|
||||
!Array.isArray(b.nodeIds) ||
|
||||
!b.nodeIds.every((id): id is string => typeof id === "string" && ID_OK.test(id))
|
||||
) {
|
||||
return Response.json({ error: "Body must be {nodeIds, params, title?}" }, { status: 400 });
|
||||
}
|
||||
if (b.nodeIds.length === 0) {
|
||||
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
if (typeof b.chatId === "string" && b.chatId !== "") {
|
||||
if (!ID_OK.test(b.chatId)) return Response.json({ error: "Invalid chatId" }, { status: 400 });
|
||||
const { set } = await practice.createProblemSet({ chatId: b.chatId }, params);
|
||||
return Response.json({ id: set.id }, { status: 202 });
|
||||
}
|
||||
if (Array.isArray(b.nodeIds) && b.nodeIds.every((id): id is string => typeof id === "string" && ID_OK.test(id))) {
|
||||
if (b.nodeIds.length === 0) {
|
||||
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
|
||||
}
|
||||
const { set } = await practice.createProblemSet({ nodeIds: b.nodeIds }, params);
|
||||
return Response.json({ id: set.id }, { status: 202 });
|
||||
}
|
||||
const { set } = await practice.createProblemSet(b.nodeIds, params, titleHint);
|
||||
return Response.json({ id: set.id }, { status: 202 });
|
||||
} catch (err) {
|
||||
return practiceErrorResponse(err);
|
||||
}
|
||||
return Response.json({ error: 'Body must be {chatId, params} or {nodeIds, params}' }, { status: 400 });
|
||||
}
|
||||
|
||||
/** POST /api/practice/sets/[id]/answers — 202; grades land in the
|
||||
|
||||
Reference in New Issue
Block a user