Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion evals/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
import { buildMapFromAI, createEmptyBoard, newNote } from "../src/model/boards";
import { contextForNote, leanForGeneration } from "../src/model/context";
import { answerProbe, applyExpand, applyRefine, chainPrompt, newProbe, nextStep, parsePoints, probeOutcome, refinePrompt, localGrade } from "../src/model/probe";
import { analogyPrompt, parseAnalogy } from "../src/model/stuck";
import { parseWrap, wrapPrompt } from "../src/model/wrap";
import {
firstLevelPrompt,
Expand Down
59 changes: 3 additions & 56 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,8 @@ import { applyGate } from "./model/gate";
import { learnerProfile, setLearnerProfile } from "./model/profile-context";
import { profileText } from "./shared/profile";
import { answerProbe, applyExpand, applyFrontier, applyRefine, bounds as probeBounds, cacheChain, cachedChain, chainKey, chainPrompt, failRecheck, gradePrompt, localGrade, newProbe, nextStep as probeStep, parseGrade, parsePoints, probeLearner, refinePrompt, restartProbe, leaveConfirmation, skipProbe } from "./model/probe";
import { afterChildSealed, analogyPrompt, isStuck, parseAnalogy, putOff, splitNote } from "./model/stuck";
import { parseWrap, wrapPrompt } from "./model/wrap";
import { ProbePanel } from "./components/ProbePanel";
import { StuckExits } from "./components/StuckExits";
import { WrapPanel } from "./components/WrapPanel";
import { explainPoints, localMark, markPrompt, parseMark, testPoints, type Mark } from "./model/mark";
import { questionNode } from "./shared/canvas";
Expand Down Expand Up @@ -352,8 +350,6 @@ export default function App() {
hintSignature?: string;
/** enrich: the new cards it decorates. */
newIds?: string[];
/** expand from a stuck stop (往下拆): the new cards are its split children. */
split?: boolean;
/** A picked ghost hint: select the card it becomes. */
selectNew?: boolean;
/** 重做下层 from a chat proposal: the old cards below the anchor are archived when new ones land. */
Expand Down Expand Up @@ -887,10 +883,6 @@ export default function App() {
const born = applied.added[0];
setTimeout(() => setSelection({ kind: "node", id: born }), 0);
}
if (meta.split && applied.added.length) {
const born = new Set(applied.added);
next = { ...next, nodes: next.nodes.map((n) => (born.has(n.id) ? { ...n, meta: { ...n.meta, split: true } } : n)) };
}
if (mode === "map" && !target.route?.length && anchorId === questionNode(target)?.id) {
const route = defaultRoute(next);
// 摸底 done: stations below the frontier get the light A seal.
Expand Down Expand Up @@ -1056,7 +1048,7 @@ export default function App() {
[edit, openDrawer],
);
const sendAI = useCallback(
async (request: AIRequest, anchor?: string, extra?: { selectNew?: boolean; split?: boolean; reExpand?: ReExpandJob }) => {
async (request: AIRequest, anchor?: string, extra?: { selectNew?: boolean; reExpand?: ReExpandJob }) => {
requestMeta.current.set(request.requestId, { request, anchor, ...extra });
setPending((p) => ({
...p,
Expand Down Expand Up @@ -1179,7 +1171,7 @@ export default function App() {
[settings.configured, edit],
);
const expand = useCallback(
(id: string, angle?: DigAngle, promptOverride?: string, extra?: { selectNew?: boolean; split?: boolean; reExpand?: ReExpandJob }): boolean => {
(id: string, angle?: DigAngle, promptOverride?: string, extra?: { selectNew?: boolean; reExpand?: ReExpandJob }): boolean => {
if (locked()) return false;
if (
[...requestMeta.current.values()].some(
Expand Down Expand Up @@ -1968,38 +1960,6 @@ export default function App() {
}));
if (mark?.pass) void assessStop(id);
};
const [stuckBusy, setStuckBusy] = useState<{ id: string; kind: "split" | "analogy" } | null>(null);
/** 往下拆: one gated expansion into exactly two smaller children under the stuck card. */
const splitStuck = useCallback(
(id: string) => {
const b = boardRef.current;
const n = b?.nodes.find((x) => x.id === id);
if (!b || !n) return;
if (b.nodes.some((c) => c.parentId === id && c.meta?.split)) return;
expand(id, undefined, expandPrompt(b, id) + splitNote(n), { split: true });
},
[expand],
);
const analogyStuck = useCallback(
async (id: string) => {
const n = boardRef.current?.nodes.find((x) => x.id === id);
if (!n) return;
setStuckBusy({ id, kind: "analogy" });
const prompt = analogyPrompt(n);
try {
const reply = await askQuietRef.current("probe", prompt, `analogy-${id}`);
if (import.meta.env.DEV) console.info(`[stuck] analogy ~${roughTokens(prompt, reply)} tokens`);
const analogy = parseAnalogy(reply);
if (analogy) patchNode(id, { analogy, beat: "learn", fails: 0 });
else showToast("这次没想出类比,换个办法试试?");
} catch (e) {
showToast(errorText(e));
} finally {
setStuckBusy(null);
}
},
[patchNode, showToast],
);
/** 合起来讲: one short call says which stations the answer reached. */
const submitWrap = useCallback(
async (text: string): Promise<boolean> => {
Expand Down Expand Up @@ -2135,7 +2095,6 @@ export default function App() {
}
if (!("card" in next)) return;
const id = next.card.id;
if (next.card.meta?.later) patchNode(id, { meta: { ...next.card.meta, later: false } });
if (!next.card.beat && next.card.status !== "explained") patchNode(id, { beat: "guess" });
setSelection({ kind: "node", id });
addNote({ kind: "node", id }, { cards: false });
Expand Down Expand Up @@ -2195,7 +2154,6 @@ export default function App() {
const stop = routeOf(b).find((n) => n.id === p.stopId);
if (!stop || stop.status === "explained") return setProposal(noteId, messageId, { state: "expired" });
setProposal(noteId, messageId, { state: "applied" });
if (stop.meta?.later) patchNode(stop.id, { meta: { ...stop.meta, later: false } });
if (!stop.beat) patchNode(stop.id, { beat: "guess" });
setSelection({ kind: "node", id: stop.id });
// A stop that only has a title gets its content through the usual expand flow.
Expand Down Expand Up @@ -3497,7 +3455,7 @@ export default function App() {
},
onPatch: (patch: Partial<ConceptNode>) => {
if (patch.seal === "test") {
edit((b) => afterChildSealed({ ...b, nodes: b.nodes.map((x) => (x.id === card.id ? { ...x, ...patch } : x)) }, card.id));
patchNode(card.id, patch);
void assessStop(card.id);
return;
}
Expand All @@ -3507,17 +3465,6 @@ export default function App() {
edit((b) => failRecheck(b, card.id, nextId ?? undefined));
showToast("这一站排在下一步之后,再学一遍。");
},
stuck: isStuck(card) ? (
<StuckExits
busy={stuckBusy?.id === card.id ? stuckBusy.kind : mapBusy ? "split" : undefined}
onSplit={() => splitStuck(card.id)}
onAnalogy={() => void analogyStuck(card.id)}
onLater={() => {
edit((b) => putOff(b, card.id));
closeDrawer();
}}
/>
) : undefined,
onMark: (kind: "explain" | "test", answer: string, choice: number | null) =>
markCard(card.id, kind, answer, choice),
};
Expand Down
11 changes: 1 addition & 10 deletions src/components/StopBeats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export interface StudyProps {
onPatch: (patch: Partial<ConceptNode>) => void;
/** Mark a 讲 or 测 answer; null when marking failed (AI unavailable). */
onMark: (kind: "explain" | "test", answer: string, choice: number | null) => Promise<Mark | null>;
/** Two wrong 测 answers: the stuck exits (phase 2 fills these in). */
stuck?: React.ReactNode;
/** A light (摸底) seal failed its recheck: drop it and queue the stop after the current one. */
onRecheckFail?: () => void;
}
Expand Down Expand Up @@ -83,7 +81,7 @@ export function StopBeats({ card, study }: { card: ConceptNode; study: StudyProp
return;
}
const fails = (card.fails ?? 0) + 1;
setFeedback({ ok: false, text: fails >= 2 ? "没关系,这说明这里藏着更小的概念。" : `还没对:${mark.missing || "再想想"}。再试一次?` });
setFeedback({ ok: false, text: `还没对:${mark.missing || "再想想"}。再试一次?` });
study.onPatch({ fails });
};
if (card.weight && study.weightActions) return <section className="stop" aria-label="这一站"><CardProse body={card.body} cardId={card.id} /><CardSourceLine card={card} /><WeightFooter card={card} actions={study.weightActions} /></section>;
Expand Down Expand Up @@ -133,12 +131,6 @@ export function StopBeats({ card, study }: { card: ConceptNode; study: StudyProp
{beat === "learn" && (
<div className="beat-pane">
{card.guess && <p className="beat-note">你猜的是:{card.guess}</p>}
{card.analogy && (
<p className="beat-analogy">
<span className="lab">换个类比</span>
{card.analogy}
</p>
)}
<div className="beat-acts">
<button className="btn primary" onClick={() => go({ beat: "explain" })}>
读完了,我来讲讲
Expand Down Expand Up @@ -213,7 +205,6 @@ export function StopBeats({ card, study }: { card: ConceptNode; study: StudyProp
}}
/>
{feedback && <p className="beat-feedback" role="status">{feedback.text}</p>}
{(card.fails ?? 0) >= 2 && study.stuck}
<div className="beat-acts">
<button
className="btn primary"
Expand Down
34 changes: 0 additions & 34 deletions src/components/StuckExits.tsx

This file was deleted.

30 changes: 4 additions & 26 deletions src/model/next-step.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* The one next step (spec §4.2), as a pure function. Rules, first hit wins:
* 1 a stop with its four beats half done → continue that beat;
* 3 children split off a stuck stop are unlearned → learn them first;
* 4 the first unsealed stop in route order;
* 6 every stop sealed → 合起来讲 (then "wrapped").
* Before all of them: an unfinished 摸底 continues. Stops put off with 稍后 (meta.later) go last.
* Before all of them: an unfinished 摸底 continues.
* (Rule 2, spaced review, is out of scope; rule 5 is offered only when a stop missed a point.)
*/
import type { Beat, Board, ConceptNode } from "../shared/types";
Expand All @@ -14,7 +13,6 @@ const sealed = (card: ConceptNode) => mastered(card) || card.weight === "backgro

export type NextStep =
| { kind: "continue"; card: ConceptNode; station?: number; beat: Beat; why: string }
| { kind: "child"; card: ConceptNode; parent: ConceptNode; station?: number; why: string }
| { kind: "station"; card: ConceptNode; station: number; why: string }
| { kind: "deeper"; card: ConceptNode; station: number; why: string }
| { kind: "wrap"; why: string }
Expand Down Expand Up @@ -57,42 +55,22 @@ export function nextStep(board: Board, skip: ReadonlySet<string> = new Set()): N
}
return undefined;
};
const unlearnedSplit = (n: ConceptNode) =>
board.nodes.some((c) => c.parentId === n.id && !sealed(c));
// 1. A stop in progress (not one waiting for its split children, nor one put off for later).
// 1. A stop in progress.
const inProgress = board.nodes.find(
(n) =>
n.beat && n.beat !== "done" && !sealed(n) && !skip.has(n.id) && !n.meta?.later &&
!((n.fails ?? 0) >= 2 && unlearnedSplit(n)),
n.beat && n.beat !== "done" && !sealed(n) && !skip.has(n.id),
);
if (inProgress)
return {
kind: "continue",
card: inProgress,
station: stationFor(inProgress),
beat: inProgress.beat!,
why: board.nodes.some((c) => c.parentId === inProgress.id && c.meta?.split)
? "拆出来的小问题学完了,回到这一站重新测。"
: "这一站做到一半,接着把它走完。",
why: "这一站做到一半,接着把它走完。",
};
// 3. Children split off a stuck stop.
for (const parent of board.nodes.filter((n) => (n.fails ?? 0) >= 2 && !sealed(n))) {
const child = board.nodes
.filter((n) => n.parentId === parent.id && !sealed(n) && !skip.has(n.id))
.sort((a, b) => a.order - b.order)[0];
if (child)
return {
kind: "child",
card: child,
parent,
station: stationFor(parent),
why: `「${parent.title}」卡住了,先学拆出来的小问题,学完回去重新测。`,
};
}
// 4. The first unsealed stop.
const open = route.filter((n) => !sealed(n));
const pick =
open.find((n) => !skip.has(n.id) && !n.meta?.later) ??
open.find((n) => !skip.has(n.id)) ??
open[0];
if (pick) {
Expand Down
57 changes: 0 additions & 57 deletions src/model/stuck.ts

This file was deleted.

8 changes: 4 additions & 4 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ export interface ConceptNode {
check?: CardCheck;
/** Where the learner is in this stop's four beats (先猜 → 学 → 讲 → 测). */
beat?: Beat;
/** Wrong 测 answers so far (two open the stuck exits). */
/** Wrong 测 answers so far. */
fails?: number;
/** The learner's own 先猜 and 讲 text. */
guess?: string;
explain?: string;
/** How the A seal was earned: by passing 测, or inferred from 摸底. probe-revoked: a 摸底 guess the learner later missed; never re-sealed by re-probing. probe-reviewed: a 已会 card re-checked after 摸底 (mastered, any weight). */
seal?: "test" | "probe" | "probe-revoked" | "probe-reviewed";
/** 换个类比: a second explanation the learner asked for when stuck (shown in 学). */
/** Legacy (removed stuck exits): kept so old boards still parse; unused. */
analogy?: string;
/** Internal data, never rendered or exported. */
meta?: CardMeta;
Expand Down Expand Up @@ -112,9 +112,9 @@ export interface CardMeta {
missed?: string;
/** The depth-ladder level this station teaches (摸底 maps the frontier onto it). */
level?: number;
/** Split from a stuck stop (往下拆): learning it sends the learner back to the parent's 测. */
/** Legacy (removed stuck exits): kept so old boards still parse; unused. */
split?: boolean;
/** 稍后: the learner put this stuck stop off; it goes to the end of the queue. */
/** Legacy (removed stuck exits): kept so old boards still parse; unused. */
later?: boolean;
}
/** The depth ladder: what → why → how → edge → apply; compare sits beside it. */
Expand Down
Loading
Loading