diff --git a/README.md b/README.md index a2bf0f5..420f199 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source **The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection. -**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; a creation flow (sidebar → a session, or `/practice`) freezes the selection's propositions + chains into a self-contained `ProblemSet`. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. +**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. **Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. diff --git a/app/api/graph/clusters/route.ts b/app/api/graph/clusters/route.ts new file mode 100644 index 0000000..8669b13 --- /dev/null +++ b/app/api/graph/clusters/route.ts @@ -0,0 +1,11 @@ +/** + * GET /api/graph/clusters — the birth-session groups for the practice + * creation picker (owner ruling 2026-09-29: sets are created from graph + * clusters, node ids only). Thin transport; the grouping is the graph + * view's own (server/graph.ts → graphSnapshot's clusters). + */ +import { graphClusters } from "@/src/server"; + +export async function GET() { + return Response.json({ clusters: graphClusters() }, { headers: { "cache-control": "no-store" } }); +} diff --git a/components/practice-dialog.tsx b/components/practice-dialog.tsx index e5ea544..e54718d 100644 --- a/components/practice-dialog.tsx +++ b/components/practice-dialog.tsx @@ -1,28 +1,31 @@ "use client"; /** - * The problem-set creation dialog (practice slice 5, 2026-09-29): the - * three parameter dials + the session picker → POST /api/practice/sets - * → router.push to the new exam page. In the delete-session dialog's - * shape (portaled z-50, pure body exported for SSR pins). + * The problem-set creation dialog (practice slice 5, 2026-09-29; + * cluster-picker fix same evening): the three parameter dials + the + * CLUSTER picker → POST /api/practice/sets → router.push to the new + * exam page. In the delete-session dialog's shape (portaled z-50, pure + * body exported for SSR pins). * - * TWO OPEN MODES: - * - generic (the list page's "New problem set"): a session picker — - * the selection is "everything this session touched"; - * - preselected (the sidebar's "Practice this session's - * propositions"): picker hidden, the session shown locked. + * GRAPH-DOMAIN, NOT CHAT-DOMAIN (owner ruling 2026-09-29): problem sets + * take NODES. The picker lists the birth-session clusters the graph + * view renders (GET /api/graph/clusters); picking one resolves to its + * member node ids, and the cluster's label rides along as the set's + * title. This dialog is the ONLY creation entry for now — the graph-view + * cluster entry comes later with the free-form selection UI. * - * Honest failures inline: 400 empty_selection → "This session has no - * propositions yet" (verbatim per brief); 503 (models/key refusal) → - * the server's line + an "Open settings" deep link (the zero-keys - * precedent — setup owns the fix, the dialog owns the pointer). + * Honest failures inline: 400 empty_selection (the vanished-nodes edge + * — the cluster's propositions are gone) renders the honest line; + * 503 (models/key refusal) renders the server's line + an "Open + * settings" deep link (the zero-keys precedent — setup owns the fix, + * the dialog owns the pointer). */ import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { useSettingsDialog } from "@/components/settings/settings-dialog"; -import type { ChatListItem } from "@/src/core"; +import type { GraphClusterGroup } from "@/src/core"; /** The three dials — key order matches PracticeParams; anchors are the * honest display (a raw 0..1 number tells the learner nothing). */ @@ -43,10 +46,9 @@ export interface PracticeFailure { export function PracticeDialogBody({ values, onDial, - chats, - selectedId, + clusters, + selectedKey, onSelect, - lockedTitle, busy, failure, onOpenSettings, @@ -55,18 +57,16 @@ export function PracticeDialogBody({ }: { values: Record<(typeof PRACTICE_DIALS)[number]["key"], number>; onDial: (key: (typeof PRACTICE_DIALS)[number]["key"], v: number) => void; - /** Null = preselected mode (picker hidden, `lockedTitle` shown). */ - chats: ChatListItem[] | null; - selectedId: string | null; - onSelect: (id: string) => void; - lockedTitle: string | null; + clusters: GraphClusterGroup[]; + selectedKey: string | null; + onSelect: (key: string) => void; busy: boolean; failure: PracticeFailure | null; onOpenSettings: () => void; onClose: () => void; onRun: () => void; }) { - const canRun = !busy && (lockedTitle !== null || selectedId !== null); + const canRun = !busy && selectedKey !== null; return (

New problem set

- {/* The session: everything it touched (any provenance point). */} + {/* The cluster: a birth-session group of the graph, resolved to + node ids on create (graph-domain, owner 2026-09-29). */}
- {lockedTitle !== null ? ( -
- From session — - {lockedTitle} -
- ) : ( - <> - Session -
- {(chats ?? []).map((chat) => ( - - ))} - {(chats ?? []).length === 0 && ( -
No sessions yet.
- )} + Cluster +
+ {clusters.map((cluster) => ( + + ))} + {clusters.length === 0 && ( +
+ No clusters yet — a cluster appears once a session births 3+ propositions.
- - )} + )} +
{/* The three dials (SettingSlider's markup, anchors as display). */} @@ -135,7 +134,7 @@ export function PracticeDialogBody({ {failure && (
- {failure.code === "empty_selection" ? "This session has no propositions yet" : failure.error} + {failure.code === "empty_selection" ? "Nothing practicable is left in that cluster — the graph changed." : failure.error} {failure.status === 503 && (