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:
amosblomqvist
2026-09-29 19:26:37 +03:00
parent 451caba1b1
commit f39e1b4294
13 changed files with 205 additions and 199 deletions
+8 -28
View File
@@ -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,