delete the batch practice-problems module (superseded by practice sessions)

Owner ruling 2026-10-04: the module is a sunk cost, retrievable from
history if ever revisited. Removed: core practice contracts + pipeline
helpers, the PracticeStore port + adapter + data dir path, server
service + API routes (sets, clusters picker, practice aborts), the
practice pages + dialog/exam/list UI, chain-table's Generate handoff,
sidebar nav, activity-dock practice rows, and the partitioner/
problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5).

Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain,
chainsForSelection, formatChainLine — the chain table and graph
emission still consume them.
This commit is contained in:
amosblomqvist
2026-10-04 21:34:24 +03:00
parent a2c1287415
commit 814c2ad412
50 changed files with 125 additions and 5767 deletions
-17
View File
@@ -3,7 +3,6 @@ import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type Tea
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { createPracticePipeline } from "../agent/subagents/practice-pipeline.ts";
import type { ModelRole } from "../../config.ts";
import {
deliverJobResult,
@@ -104,22 +103,6 @@ export const extraction = createExtractionPipeline({
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
});
/** The app's ONE practice pipeline instance — the API routes reach it
* through the server barrel / service, same discipline as extraction. */
export const practice = createPracticePipeline({
graphStore: stores.graphStore,
practiceStore: stores.practiceStore,
// 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
// with the typed ModelsNotConfiguredError. Direct test values still win.
engineFor: (role) =>
resolveEngine(role),
// Key-presence gate: same check extraction gets — a stage engine whose
// provider key is gone refuses BEFORE any set is created.
providerKeyMissing: (provider, tier) =>
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
});
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
// delivers its steers through THIS composition root (one event: the steer
+1 -1
View File
@@ -2,7 +2,7 @@
* THE NOTIFICATION CHANNEL (push-architecture slice 1): GET /api/events is
* an SSE response over the core event bus — one connection per open
* window, held open until the view goes away, fed by every emitter
* (turn lifecycle, job/chat stores, extraction/practice registries).
* (turn lifecycle, job/chat stores, extraction registry).
*
* NOTIFY → FETCH (the architecture's whole trick): frames carry only the
* event type + a tiny payload; consumers respond by fetching truth
+1 -24
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import { LEDGER_FILES, POSITIONS_FILE } from "../../config.ts";
import type { GraphClusterGroup, GraphSnapshot } from "../core/contracts.ts";
import type { 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";
@@ -22,29 +22,6 @@ export function graphView(): GraphSnapshot {
return graphSnapshot(stores);
}
/** GET /api/graph/clusters — the birth-session groups, resolved to node
* ids for the practice picker (owner ruling 2026-09-29: cluster→nodeIds
* resolves in the UI; the API stays graph-domain). EVERY group is
* listed regardless of size (owner ruling, same evening:
* MIN_CLUSTER_SIZE is a hull-DRAWING rule — practice availability must
* not inherit a visual threshold), with the partition's own ordering
* (size desc, ties by id asc). The label join mirrors the snapshot's:
* chat title / notes file path, null when the naming record is gone —
* honest, never guessed. A corrupt/empty graph is an empty list. */
export function graphClusters(): GraphClusterGroup[] {
const snap = graphSnapshot(stores);
// PROPOSITIONS ONLY: snap.nodes is the canvas image (imp nodes ride
// it); the picker offers propositions — an arg_ id in nodeIds would
// silently vanish inside chainsForSelection and lie about nodeCount.
return birthSessionPartition(snap.propositions).map((group) => ({
key: group.id,
kind: group.id in snap.sessions ? "chat" : "notes",
label: snap.sessions[group.id] ?? snap.fileSources[group.id] ?? null,
nodeIds: group.members,
nodeCount: group.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
-1
View File
@@ -7,7 +7,6 @@
export * from "./chat-handler.ts";
export * from "./events-stream.ts";
export * from "./chats.ts";
export * from "./practice.ts";
export * from "./graph.ts";
export * from "./commit-positions.ts";
export * from "./jobs.ts";
-28
View File
@@ -20,7 +20,6 @@
*/
import { extractionStatuses, type ExtractionPhase } from "../agent/subagents/extraction.ts";
import { practiceStatuses, type PracticePhase } from "../agent/subagents/practice-pipeline.ts";
import { chatStore } from "../store/chats.ts";
import { graphRevision } from "./graph.ts";
import { catalogContextLimits } from "./model-catalog.ts";
@@ -28,7 +27,6 @@ import { listJobs } from "./jobs.ts";
import type { JobRecord } from "../core/contracts.ts";
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
export { practiceStatuses, type PracticePhase, type RunningPractice } from "../agent/subagents/practice-pipeline.ts";
export { chatStore } from "../store/chats.ts";
/** A running sub-agent job, joined with its chat's title for the dock.
@@ -56,21 +54,6 @@ export interface ActivityExtraction {
startedAt: number;
}
/** A live practice ROW in the dock's feed — per-problem, matching the
* extraction per-chunk pattern (owner ruling, 2026-09-29). id is the
* ROW KEY (`${setId}:partition` | `${setId}:gen:${pid}` |
* `${setId}:grade:${pid}`) — the dock's abort target; problemIndex/
* problemTotal ride when the row belongs to a fan-out. */
export interface ActivityPractice {
id: string;
kind: "generation" | "grading";
title: string;
phase: PracticePhase;
startedAt: number;
problemIndex?: number;
problemTotal?: number;
}
/** The dock's whole world in one payload: every running process, every view.
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
* the blob disappears when the process ends. Cost math is NOT done here:
@@ -85,7 +68,6 @@ export interface ActivityPractice {
export function activityFeed(): {
jobs: ActivityJob[];
extractions: ActivityExtraction[];
practice: ActivityPractice[];
graphRevision: number;
} {
const chats = chatStore.listChats();
@@ -115,21 +97,11 @@ export function activityFeed(): {
phase: run.phase,
startedAt: run.startedAt,
}));
const practice: ActivityPractice[] = practiceStatuses().map((row) => ({
id: row.key,
kind: row.kind,
title: row.label,
phase: row.phase,
startedAt: row.startedAt,
problemIndex: row.problemIndex,
problemTotal: row.problemTotal,
}));
// oldest-first on last activity: the stable blob order (ISO strings sort chronologically)
jobs.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
return {
jobs,
extractions: extractions.sort((a, b) => a.startedAt - b.startedAt),
practice: practice.sort((a, b) => a.startedAt - b.startedAt),
graphRevision: graphRevision(),
};
}
-152
View File
@@ -1,152 +0,0 @@
/**
* THE PRACTICE SERVICE (slice 4, 2026-09-29): the thin, route-facing
* surface over the practice pipeline — request parsing/validation, typed
* error → response mapping. Routes call THIS, never the pipeline
* internals (the chats/graph service discipline).
*
* The pipeline instance is the chat-handler composition root's export
* (`practice`, next to `extraction`): engines resolve fresh per stage
* from BYOK settings, and refusals map to the 503 envelope family.
*/
import { practice } from "./chat-handler.ts";
import { stores } from "./stores.ts";
import type { PracticeParams, ProblemSet } from "../core/contracts.ts";
import {
BadAnswersError,
EmptySelectionError,
PracticeSetNotFound,
PracticeSetNotReady,
} from "../agent/subagents/practice-pipeline.ts";
import {
ModelsNotConfiguredError,
ProviderKeyMissingError,
PROVIDER_KEY_MISSING_CODE,
} from "../core/settings.ts";
// app/api uses base-URL charset ids (the activity/abort precedent).
const ID_OK = /^[A-Za-z0-9_-]+$/;
/** GET /api/practice/sets — the list, createdAt desc (store's order). */
export function listPracticeSets(): ProblemSet[] {
return stores.practiceStore.listSets();
}
/** GET /api/practice/sets/[id] — one set, or null (→ 404). */
export function getPracticeSet(id: string): ProblemSet | null {
return stores.practiceStore.getSet(id);
}
/** Strict params: three finite numbers in [0, 1]. */
function parseParams(raw: unknown): PracticeParams | null {
if (typeof raw !== "object" || raw === null) return null;
const p = raw as Record<string, unknown>;
// Named levels ONLY (owner, 2026-09-29): exact unions, nothing between —
// a rung is a rung; there is no float to interpolate.
if (p.ladderRung !== 0 && p.ladderRung !== 1 && p.ladderRung !== 2 && p.ladderRung !== 3 && p.ladderRung !== 4) return null;
if (p.cueDistance !== 0 && p.cueDistance !== 1 && p.cueDistance !== 2) return null;
if (p.presentation !== "theory" && p.presentation !== "application") return null;
return {
ladderRung: p.ladderRung,
cueDistance: p.cueDistance,
presentation: p.presentation,
};
}
/** The typed pipeline errors → HTTP (refusals → the 503 envelope family,
* exactly like the import route's extraction mapping). */
export function practiceErrorResponse(err: unknown): Response {
if (err instanceof ModelsNotConfiguredError) {
return Response.json(
{ error: err.envelope.error, code: "models_not_configured", missing: err.envelope.missing },
{ status: 503 },
);
}
if (err instanceof ProviderKeyMissingError) {
return Response.json(
{
error: err.envelope.error,
code: PROVIDER_KEY_MISSING_CODE,
provider: err.envelope.provider,
slots: err.envelope.slots,
},
{ status: 503 },
);
}
if (err instanceof EmptySelectionError) return Response.json({ error: err.message, code: "empty_selection" }, { status: 400 });
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 });
console.error("[practice] unexpected:", err);
return Response.json({ error: "Internal error" }, { status: 500 });
}
/** The learner-note cap (creation dialog; the textarea's own maxLength
* matches — server-side is the law). */
const NOTE_MAX = 2000;
/** POST /api/practice/sets — 202 {id}; the set generates in the
* background (fire-and-forget pipeline). Body is {nodeIds, params,
* title?, note?} 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; note is the learner's
* free-form creation input, optional). */
export async function createPracticeSetResponse(body: unknown): Promise<Response> {
const b = (body ?? {}) as Record<string, unknown>;
const params = parseParams(b.params);
if (!params) {
return Response.json(
{ error: "params must carry ladderRung (0-4), cueDistance (0-2), presentation (theory|application) as exact levels" },
{ status: 400 },
);
}
const titleHint =
typeof b.title === "string" && b.title.trim() !== "" ? b.title.trim() : undefined;
let note: string | undefined;
if (b.note !== undefined) {
if (typeof b.note !== "string") {
return Response.json({ error: "note must be a string" }, { status: 400 });
}
const trimmed = b.note.trim();
if (trimmed.length > NOTE_MAX) {
return Response.json({ error: `note is over ${NOTE_MAX} characters` }, { status: 400 });
}
note = trimmed || undefined; // whitespace-only = no note
}
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?, note?}" }, { status: 400 });
}
if (b.nodeIds.length === 0) {
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
}
try {
const { set } = await practice.createProblemSet(b.nodeIds, params, titleHint, note);
return Response.json({ id: set.id }, { status: 202 });
} catch (err) {
return practiceErrorResponse(err);
}
}
/** POST /api/practice/sets/[id]/answers — 202; grades land in the
* background. Body: {answers: [{problemId, text}]}. */
export async function submitPracticeAnswersResponse(setId: string, body: unknown): Promise<Response> {
if (!ID_OK.test(setId)) return Response.json({ error: "Invalid set id" }, { status: 400 });
const b = (body ?? {}) as Record<string, unknown>;
const raw = b.answers;
if (!Array.isArray(raw) || raw.length === 0 || !raw.every((a): a is { problemId: string; text: string } =>
typeof a === "object" && a !== null &&
typeof (a as Record<string, unknown>).problemId === "string" &&
typeof (a as Record<string, unknown>).text === "string",
)) {
return Response.json({ error: 'Body must be {answers: [{problemId: string, text: string}]}' }, { status: 400 });
}
try {
const { set } = await practice.submitAnswers(setId, raw);
return Response.json({ id: set.id }, { status: 202 });
} catch (err) {
return practiceErrorResponse(err);
}
}
-2
View File
@@ -9,7 +9,6 @@
import { chatStore } from "../store/chats.ts";
import { graphStore } from "../store/graph.ts";
import { jobStore } from "../store/jobs.ts";
import { practiceStore } from "../store/practice.ts";
import { settingsStore } from "../store/settings.ts";
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
@@ -17,7 +16,6 @@ export const stores = {
chatStore,
graphStore,
jobStore,
practiceStore,
settingsStore,
};