@bojackduy/opencode-learn 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -556,7 +556,8 @@ var server = async ({ client, directory }) => {
556
556
  }
557
557
  async function llmClassify(client2, directory2, note, options, question, parentSessionID, multiSelect) {
558
558
  const modeHint = multiSelect ? "This is a MULTI-SELECT question (0..N options may be correct). You may return 0..N inferred indices." : "This is a SINGLE-SELECT question (exactly 0 or 1 inferred). You MUST return at most ONE inferred index. Never return multiple. If note is ambiguous or mentions several options, pick the SINGLE best match. Return [] if vague.";
559
- const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options. ${modeHint}
559
+ const idkHint = `Also detect IDK intent: if note says "I don't know / idk / too hard / too difficult / need easier / want easier / skip / give me easier/harder" or expresses wanting difficulty adjustment, set "isIDK": true (and keep inferred as [] or best guess). Otherwise isIDK false. The main teacher will use this to adapt difficulty.`;
560
+ const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options. ${modeHint} ${idkHint}
560
561
 
561
562
  ${question ? `Question: ${question}
562
563
  ` : ""}Options:
@@ -565,9 +566,9 @@ ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).j
565
566
 
566
567
  Learner note: "${note}"
567
568
 
568
- Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed) \u2014 respect single/multi mode above. 2) semanticCorrect: true if note shows valid understanding or deeper nuance even when inferred != correct key (e.g., note about rotate array variant vs standard sorted is valid nuance). 3) reason: short English reason.
569
+ Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed) \u2014 respect single/multi mode above. 2) semanticCorrect: true if note shows valid understanding or deeper nuance even when inferred != correct key (e.g., note about rotate array variant vs standard sorted is valid nuance). 3) reason: short English reason. 4) isIDK: true if note expresses IDK / wants easier/harder/skip.
569
570
 
570
- Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`;
571
+ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK":false} If vague/"I don't know", inferred:[], semanticCorrect:false, isIDK:true if IDK intent. No markdown, just JSON.`;
571
572
  try {
572
573
  const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`;
573
574
  const body = { title };
@@ -601,6 +602,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
601
602
  }
602
603
  return arr2;
603
604
  };
605
+ const noteIsIDK = (() => {
606
+ const n = note.toLowerCase();
607
+ return n.includes("idk") || n.includes("i don't know") || n.includes("i dont know") || n.includes("dont know") || n.includes("too hard") || n.includes("too difficult") || n.includes("need easier") || n.includes("want easier") || n.includes("give me easier") || n.includes("skip") || n.includes("qu\xE1 kh\xF3") || n.includes("kh\xF3 qu\xE1") || n.includes("d\u1EC5 h\u01A1n") || n.includes("d\u1EC5 h\u01A1n");
608
+ })();
604
609
  const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/);
605
610
  if (objMatch) {
606
611
  try {
@@ -608,8 +613,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
608
613
  if (parsed && Array.isArray(parsed.inferred)) {
609
614
  const nums = parsed.inferred.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
610
615
  const fnums = enforceSingle(nums);
611
- slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`);
612
- return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid };
616
+ const isIDK = !!(parsed.isIDK ?? parsed.isIdk ?? parsed.dontKnow ?? parsed.isDontKnow ?? parsed.dont_know) || noteIsIDK && fnums.length === 0;
617
+ slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} isIDK:${isIDK} reason:${parsed.reason || ""} sid:${sid}`);
618
+ return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid, isIDK };
613
619
  }
614
620
  } catch {}
615
621
  }
@@ -621,8 +627,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
621
627
  const nums = parsed.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
622
628
  if (nums.length) {
623
629
  const fnums = enforceSingle(nums);
624
- slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`);
625
- return { inferred: fnums, sessionID: sid };
630
+ const isIDK = noteIsIDK && fnums.length === 0;
631
+ slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")} isIDK:${isIDK}`);
632
+ return { inferred: fnums, sessionID: sid, isIDK };
626
633
  }
627
634
  }
628
635
  } catch {}
@@ -632,9 +639,14 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
632
639
  if (nums.length) {
633
640
  const uniq = [...new Set(nums)];
634
641
  const fnums = enforceSingle(uniq);
635
- return { inferred: fnums, sessionID: sid };
642
+ const isIDK = noteIsIDK && fnums.length === 0;
643
+ return { inferred: fnums, sessionID: sid, isIDK };
636
644
  }
637
645
  }
646
+ if (noteIsIDK) {
647
+ slog("llmClassify isIDK fallback from note", note.slice(0, 40));
648
+ return { inferred: [], sessionID: sid, isIDK: true };
649
+ }
638
650
  }
639
651
  }
640
652
  } catch {}
@@ -673,19 +685,32 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
673
685
  let inferred = [];
674
686
  let semanticCorrect;
675
687
  let reason;
688
+ let isIDK;
676
689
  const multi = !!data.multiSelect;
677
690
  const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID, multi);
691
+ isIDK = llmRes.isIDK;
692
+ if (!isIDK) {
693
+ const n = data.note.toLowerCase();
694
+ if (n.includes("idk") || n.includes("i don't know") || n.includes("i dont know") || n.includes("too hard") || n.includes("too difficult") || n.includes("need easier") || n.includes("want easier") || n.includes("qu\xE1 kh\xF3") || n.includes("kh\xF3 qu\xE1")) {
695
+ if (!llmRes.inferred.length)
696
+ isIDK = true;
697
+ }
698
+ }
678
699
  if (llmRes.inferred.length) {
679
700
  inferred = llmRes.inferred;
680
701
  semanticCorrect = llmRes.semanticCorrect;
681
702
  reason = llmRes.reason;
682
- slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} multi:${multi} sid:${llmRes.sessionID || ""}`);
703
+ isIDK = llmRes.isIDK ?? isIDK;
704
+ slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} isIDK:${isIDK} multi:${multi} sid:${llmRes.sessionID || ""}`);
683
705
  } else {
684
706
  inferred = heuristicClassify(data.note, data.options, multi);
685
707
  if (inferred.length)
686
- slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi}`);
708
+ slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi} isIDK:${isIDK}`);
687
709
  else
688
- slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`);
710
+ slog("classify no match", data.id, `"${data.note.slice(0, 40)}" isIDK:${isIDK}`);
711
+ if (!inferred.length && isIDK) {
712
+ slog("classify isIDK with no inferred", data.id);
713
+ }
689
714
  }
690
715
  if (!multi && inferred.length > 1) {
691
716
  const before = inferred.join(",");
@@ -719,8 +744,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
719
744
  slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`);
720
745
  }
721
746
  const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
722
- slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} multi:${multi} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
723
- const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
747
+ slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} isIDK:${isIDK} multi:${multi} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
748
+ const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, isIDK, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
724
749
  try {
725
750
  fs.writeFileSync(respPath, JSON.stringify(out), "utf8");
726
751
  slog("classify response written", data.id, inferred.join(","));
package/dist/tui.js CHANGED
@@ -419,12 +419,25 @@ function QuizDialog(props) {
419
419
  const inferredValues = data?.inferredValues;
420
420
  const semanticCorrect = data?.semanticCorrect;
421
421
  const reason = data?.reason;
422
+ const isIDK = !!data?.isIDK;
422
423
  const computeCorrect = (idxs) => {
423
424
  if (typeof semanticCorrect === "boolean")
424
425
  return semanticCorrect;
425
426
  return idxs.length === props.request.correctIndices.length && idxs.every((v) => correctSet.has(v)) && props.request.correctIndices.every((v) => idxs.includes(v));
426
427
  };
427
- if (inferred && inferred.length) {
428
+ if (isIDK) {
429
+ setDontKnow(true);
430
+ setSelected(new Map);
431
+ setFeedback({
432
+ correct: false,
433
+ selectedIndices: []
434
+ });
435
+ if (reason)
436
+ setNote((prev) => prev ? `${prev} \u2014 ${reason}` : reason);
437
+ else if (!note().toLowerCase().includes("idk") && !note().toLowerCase().includes("don't know"))
438
+ setNote((prev) => prev ? `${prev} \u2014 IDK: ${reason || "needs easier"}` : prev);
439
+ tlog("QuizDialog classify isIDK", reason || "");
440
+ } else if (inferred && inferred.length) {
428
441
  const eff = !isMulti() && inferred.length > 1 ? [inferred[0]] : inferred;
429
442
  if (eff.length !== inferred.length)
430
443
  tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","));
@@ -844,6 +857,42 @@ function QuizDialog(props) {
844
857
  _$setProp(_el$63, "gap", 1);
845
858
  _$setProp(_el$63, "paddingLeft", 1);
846
859
  _$setProp(_el$63, "paddingRight", 1);
860
+ _$setProp(_el$63, "onMouseOver", () => {
861
+ if (phase() !== "select")
862
+ return;
863
+ if (focused() !== "options")
864
+ setFocused("options");
865
+ if (optionIndex() !== idx)
866
+ setOptionIndex(idx);
867
+ });
868
+ _$setProp(_el$63, "onMouseMove", () => {
869
+ if (phase() !== "select")
870
+ return;
871
+ if (focused() !== "options")
872
+ setFocused("options");
873
+ if (optionIndex() !== idx)
874
+ setOptionIndex(idx);
875
+ });
876
+ _$setProp(_el$63, "onMouseUp", () => {
877
+ if (phase() !== "select")
878
+ return;
879
+ setOptionIndex(idx);
880
+ setFocused("options");
881
+ if (isMulti())
882
+ toggleOption(idx);
883
+ else {
884
+ const o = options()[idx];
885
+ if (o) {
886
+ setSelected(new Map([[`opt:${idx}`, {
887
+ label: o.label,
888
+ value: o.value,
889
+ index: idx + 1
890
+ }]]));
891
+ setDontKnow(false);
892
+ submitSelect();
893
+ }
894
+ }
895
+ });
847
896
  _$insertNode(_el$64, _el$65);
848
897
  _$setProp(_el$64, "width", 2);
849
898
  _$setProp(_el$64, "alignItems", "center");
@@ -901,6 +950,29 @@ function QuizDialog(props) {
901
950
  _$setProp(_el$13, "gap", 1);
902
951
  _$setProp(_el$13, "paddingLeft", 1);
903
952
  _$setProp(_el$13, "paddingRight", 1);
953
+ _$setProp(_el$13, "onMouseOver", () => {
954
+ if (phase() !== "select")
955
+ return;
956
+ if (focused() !== "options")
957
+ setFocused("options");
958
+ if (optionIndex() !== dontKnowIdx())
959
+ setOptionIndex(dontKnowIdx());
960
+ });
961
+ _$setProp(_el$13, "onMouseMove", () => {
962
+ if (phase() !== "select")
963
+ return;
964
+ if (focused() !== "options")
965
+ setFocused("options");
966
+ if (optionIndex() !== dontKnowIdx())
967
+ setOptionIndex(dontKnowIdx());
968
+ });
969
+ _$setProp(_el$13, "onMouseUp", () => {
970
+ if (phase() !== "select")
971
+ return;
972
+ setOptionIndex(dontKnowIdx());
973
+ setFocused("options");
974
+ handleDontKnow();
975
+ });
904
976
  _$insertNode(_el$14, _el$15);
905
977
  _$setProp(_el$14, "width", 2);
906
978
  _$setProp(_el$14, "alignItems", "center");
@@ -919,6 +991,23 @@ function QuizDialog(props) {
919
991
  _$setProp(_el$21, "flexDirection", "column");
920
992
  _$setProp(_el$21, "gap", 0);
921
993
  _$setProp(_el$21, "paddingTop", 1);
994
+ _$setProp(_el$21, "onMouseOver", () => {
995
+ if (phase() !== "select")
996
+ return;
997
+ if (focused() !== "note")
998
+ setFocused("note");
999
+ });
1000
+ _$setProp(_el$21, "onMouseMove", () => {
1001
+ if (phase() !== "select")
1002
+ return;
1003
+ if (focused() !== "note")
1004
+ setFocused("note");
1005
+ });
1006
+ _$setProp(_el$21, "onMouseUp", () => {
1007
+ if (phase() !== "select")
1008
+ return;
1009
+ setFocused("note");
1010
+ });
922
1011
  _$insertNode(_el$22, _el$23);
923
1012
  _$setProp(_el$22, "flexDirection", "row");
924
1013
  _$setProp(_el$22, "alignItems", "center");
@@ -993,6 +1082,29 @@ function QuizDialog(props) {
993
1082
  _$setProp(_el$33, "border", true);
994
1083
  _$setProp(_el$33, "paddingLeft", 2);
995
1084
  _$setProp(_el$33, "paddingRight", 2);
1085
+ _$setProp(_el$33, "onMouseOver", () => {
1086
+ if (phase() !== "select")
1087
+ return;
1088
+ if (focused() !== "options" || optionIndex() !== submitIdx()) {
1089
+ setFocused("options");
1090
+ setOptionIndex(submitIdx());
1091
+ }
1092
+ });
1093
+ _$setProp(_el$33, "onMouseMove", () => {
1094
+ if (phase() !== "select")
1095
+ return;
1096
+ if (focused() !== "options" || optionIndex() !== submitIdx()) {
1097
+ setFocused("options");
1098
+ setOptionIndex(submitIdx());
1099
+ }
1100
+ });
1101
+ _$setProp(_el$33, "onMouseUp", () => {
1102
+ if (phase() !== "select")
1103
+ return;
1104
+ setOptionIndex(submitIdx());
1105
+ setFocused("options");
1106
+ submitSelect();
1107
+ });
996
1108
  _$insert(_el$34, () => focused() === "options" && optionIndex() === submitIdx() ? "\u25B8" : " ");
997
1109
  _$insertNode(_el$35, _el$36);
998
1110
  _$setProp(_el$35, "bold", true);
@@ -1029,6 +1141,29 @@ function QuizDialog(props) {
1029
1141
  _$setProp(_el$38, "border", true);
1030
1142
  _$setProp(_el$38, "paddingLeft", 2);
1031
1143
  _$setProp(_el$38, "paddingRight", 2);
1144
+ _$setProp(_el$38, "onMouseOver", () => {
1145
+ if (phase() !== "select")
1146
+ return;
1147
+ if (focused() !== "options" || optionIndex() !== dontKnowIdx() + 1) {
1148
+ setFocused("options");
1149
+ setOptionIndex(dontKnowIdx() + 1);
1150
+ }
1151
+ });
1152
+ _$setProp(_el$38, "onMouseMove", () => {
1153
+ if (phase() !== "select")
1154
+ return;
1155
+ if (focused() !== "options" || optionIndex() !== dontKnowIdx() + 1) {
1156
+ setFocused("options");
1157
+ setOptionIndex(dontKnowIdx() + 1);
1158
+ }
1159
+ });
1160
+ _$setProp(_el$38, "onMouseUp", () => {
1161
+ if (phase() !== "select")
1162
+ return;
1163
+ setOptionIndex(dontKnowIdx() + 1);
1164
+ setFocused("options");
1165
+ submitSelect();
1166
+ });
1032
1167
  _$insertNode(_el$39, _$createTextNode(`\u21B3 Submit note \u2192 classify`));
1033
1168
  _$setProp(_el$39, "bold", true);
1034
1169
  _$effect((_p$) => {
@@ -1565,15 +1700,29 @@ function QuizBatchDialog(props) {
1565
1700
  const inferred = data?.inferredIndices;
1566
1701
  const semanticCorrect = data?.semanticCorrect;
1567
1702
  const reason = data?.reason;
1703
+ const isIDK = !!data?.isIDK;
1568
1704
  const computeOk2 = (idxs) => {
1569
1705
  if (typeof semanticCorrect === "boolean")
1570
1706
  return semanticCorrect;
1571
1707
  const correctSet2 = new Set(cur().correctIndices);
1572
1708
  return idxs.length === cur().correctIndices.length && idxs.every((v) => correctSet2.has(v)) && cur().correctIndices.every((v) => idxs.includes(v));
1573
1709
  };
1574
- if (inferred && inferred.length) {
1710
+ if (isIDK) {
1711
+ setDontKnow(true);
1712
+ setSelected(new Map);
1713
+ setFeedback({
1714
+ correct: false,
1715
+ selectedIndices: []
1716
+ });
1717
+ if (reason)
1718
+ setNote((prev) => prev ? `${prev} \u2014 ${reason}` : reason);
1719
+ tlog("QuizBatchDialog classify isIDK", reason || "");
1720
+ } else if (inferred && inferred.length) {
1721
+ const eff = !isMulti() && inferred.length > 1 ? [inferred[0]] : inferred;
1722
+ if (eff.length !== inferred.length)
1723
+ tlog("QuizBatchDialog classify enforce single", inferred.join(","), "->", eff.join(","));
1575
1724
  const mm = new Map;
1576
- for (const idx2 of inferred) {
1725
+ for (const idx2 of eff) {
1577
1726
  const opt = cur().options[idx2 - 1];
1578
1727
  if (opt)
1579
1728
  mm.set(`opt:${idx2 - 1}`, {
@@ -1583,14 +1732,14 @@ function QuizBatchDialog(props) {
1583
1732
  });
1584
1733
  }
1585
1734
  setSelected(mm);
1586
- const ok2 = computeOk2(inferred);
1735
+ const ok2 = computeOk2(eff);
1587
1736
  setFeedback({
1588
1737
  correct: ok2,
1589
- selectedIndices: inferred
1738
+ selectedIndices: eff
1590
1739
  });
1591
1740
  if (reason)
1592
1741
  setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
1593
- tlog("QuizBatchDialog classify done", inferred.join(","), ok2, reason || "");
1742
+ tlog("QuizBatchDialog classify done", eff.join(","), ok2, reason || "");
1594
1743
  } else {
1595
1744
  const ok2 = typeof semanticCorrect === "boolean" ? semanticCorrect : false;
1596
1745
  setFeedback({
@@ -1914,6 +2063,42 @@ function QuizBatchDialog(props) {
1914
2063
  _$setProp(_el$148, "alignItems", "flexStart");
1915
2064
  _$setProp(_el$148, "gap", 1);
1916
2065
  _$setProp(_el$148, "paddingLeft", 1);
2066
+ _$setProp(_el$148, "onMouseOver", () => {
2067
+ if (phase() !== "select")
2068
+ return;
2069
+ if (focused() !== "options")
2070
+ setFocused("options");
2071
+ if (optionIndex() !== id)
2072
+ setOptionIndex(id);
2073
+ });
2074
+ _$setProp(_el$148, "onMouseMove", () => {
2075
+ if (phase() !== "select")
2076
+ return;
2077
+ if (focused() !== "options")
2078
+ setFocused("options");
2079
+ if (optionIndex() !== id)
2080
+ setOptionIndex(id);
2081
+ });
2082
+ _$setProp(_el$148, "onMouseUp", () => {
2083
+ if (phase() !== "select")
2084
+ return;
2085
+ setOptionIndex(id);
2086
+ setFocused("options");
2087
+ if (isMulti())
2088
+ toggle(id);
2089
+ else {
2090
+ const o = cur().options[id];
2091
+ if (o) {
2092
+ setSelected(new Map([[`opt:${id}`, {
2093
+ label: o.label,
2094
+ value: o.value,
2095
+ index: id + 1
2096
+ }]]));
2097
+ setDontKnow(false);
2098
+ submitSelect();
2099
+ }
2100
+ }
2101
+ });
1917
2102
  _$insertNode(_el$149, _el$150);
1918
2103
  _$setProp(_el$149, "width", 2);
1919
2104
  _$insert(_el$150, () => foc() ? "\u25B8" : " ");
@@ -1957,6 +2142,32 @@ function QuizBatchDialog(props) {
1957
2142
  _$setProp(_el$110, "flexDirection", "row");
1958
2143
  _$setProp(_el$110, "gap", 1);
1959
2144
  _$setProp(_el$110, "paddingLeft", 1);
2145
+ _$setProp(_el$110, "onMouseOver", () => {
2146
+ if (phase() !== "select")
2147
+ return;
2148
+ if (focused() !== "options")
2149
+ setFocused("options");
2150
+ if (optionIndex() !== dontKnowIdx())
2151
+ setOptionIndex(dontKnowIdx());
2152
+ });
2153
+ _$setProp(_el$110, "onMouseMove", () => {
2154
+ if (phase() !== "select")
2155
+ return;
2156
+ if (focused() !== "options")
2157
+ setFocused("options");
2158
+ if (optionIndex() !== dontKnowIdx())
2159
+ setOptionIndex(dontKnowIdx());
2160
+ });
2161
+ _$setProp(_el$110, "onMouseUp", () => {
2162
+ if (phase() !== "select")
2163
+ return;
2164
+ setOptionIndex(dontKnowIdx());
2165
+ setFocused("options");
2166
+ const willBe = !dontKnow();
2167
+ setDontKnow(willBe);
2168
+ if (willBe)
2169
+ setSelected(new Map);
2170
+ });
1960
2171
  _$insertNode(_el$111, _el$112);
1961
2172
  _$setProp(_el$111, "width", 2);
1962
2173
  _$insert(_el$112, () => focused() === "options" && optionIndex() === dontKnowIdx() ? "\u25B8" : " ");
@@ -1971,6 +2182,23 @@ function QuizBatchDialog(props) {
1971
2182
  _$insertNode(_el$118, _el$121);
1972
2183
  _$setProp(_el$118, "flexDirection", "column");
1973
2184
  _$setProp(_el$118, "paddingTop", 1);
2185
+ _$setProp(_el$118, "onMouseOver", () => {
2186
+ if (phase() !== "select")
2187
+ return;
2188
+ if (focused() !== "note")
2189
+ setFocused("note");
2190
+ });
2191
+ _$setProp(_el$118, "onMouseMove", () => {
2192
+ if (phase() !== "select")
2193
+ return;
2194
+ if (focused() !== "note")
2195
+ setFocused("note");
2196
+ });
2197
+ _$setProp(_el$118, "onMouseUp", () => {
2198
+ if (phase() !== "select")
2199
+ return;
2200
+ setFocused("note");
2201
+ });
1974
2202
  _$insertNode(_el$119, _$createTextNode(`\u270E Note`));
1975
2203
  _$setProp(_el$121, "border", true);
1976
2204
  _$setProp(_el$121, "paddingLeft", 1);
@@ -2030,6 +2258,29 @@ function QuizBatchDialog(props) {
2030
2258
  _$setProp(_el$128, "border", true);
2031
2259
  _$setProp(_el$128, "paddingLeft", 2);
2032
2260
  _$setProp(_el$128, "paddingRight", 2);
2261
+ _$setProp(_el$128, "onMouseOver", () => {
2262
+ if (phase() !== "select")
2263
+ return;
2264
+ if (focused() !== "options" || optionIndex() !== submitIdx()) {
2265
+ setFocused("options");
2266
+ setOptionIndex(submitIdx());
2267
+ }
2268
+ });
2269
+ _$setProp(_el$128, "onMouseMove", () => {
2270
+ if (phase() !== "select")
2271
+ return;
2272
+ if (focused() !== "options" || optionIndex() !== submitIdx()) {
2273
+ setFocused("options");
2274
+ setOptionIndex(submitIdx());
2275
+ }
2276
+ });
2277
+ _$setProp(_el$128, "onMouseUp", () => {
2278
+ if (phase() !== "select")
2279
+ return;
2280
+ setOptionIndex(submitIdx());
2281
+ setFocused("options");
2282
+ submitSelect();
2283
+ });
2033
2284
  _$insert(_el$129, () => focused() === "options" && optionIndex() === submitIdx() ? "\u25B8" : " ");
2034
2285
  _$insertNode(_el$130, _$createTextNode(`\u21B3 Submit`));
2035
2286
  _$setProp(_el$130, "bold", true);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-learn",
4
- "version": "1.1.2",
4
+ "version": "1.2.0",
5
5
  "description": "Pi learn system for OpenCode — Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to OpenCode.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -202,11 +202,19 @@ function QuizDialog(props: {
202
202
  const inferredValues = data?.inferredValues as string[] | undefined
203
203
  const semanticCorrect = data?.semanticCorrect as boolean | undefined
204
204
  const reason = data?.reason as string | undefined
205
+ const isIDK = !!(data as any)?.isIDK
205
206
  const computeCorrect = (idxs: number[]) => {
206
207
  if (typeof semanticCorrect === "boolean") return semanticCorrect
207
208
  return idxs.length === props.request.correctIndices.length && idxs.every((v: number) => correctSet.has(v)) && props.request.correctIndices.every((v: number) => idxs.includes(v))
208
209
  }
209
- if (inferred && inferred.length) {
210
+ if (isIDK) {
211
+ setDontKnow(true)
212
+ setSelected(new Map())
213
+ setFeedback({ correct: false, selectedIndices: [] })
214
+ if (reason) setNote(prev => prev ? `${prev} — ${reason}` : reason)
215
+ else if (!note().toLowerCase().includes("idk") && !note().toLowerCase().includes("don't know")) setNote(prev => prev ? `${prev} — IDK: ${reason || "needs easier"}` : prev)
216
+ tlog("QuizDialog classify isIDK", reason || "")
217
+ } else if (inferred && inferred.length) {
210
218
  const eff = !isMulti() && inferred.length > 1 ? [inferred[0]!] : inferred
211
219
  if (eff.length !== inferred.length) tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","))
212
220
  const m = new Map<string, { label: string; value: string; index: number }>()
@@ -374,7 +382,16 @@ function QuizDialog(props: {
374
382
  const isFocused = () => focused() === "options" && optionIndex() === idx
375
383
  const isSelected = () => selected().has(`opt:${idx}`)
376
384
  return (
377
- <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={isFocused() ? theme().backgroundElement : undefined}>
385
+ <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={isFocused() ? theme().backgroundElement : undefined} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==idx) setOptionIndex(idx) }} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==idx) setOptionIndex(idx) }} onMouseUp={() => {
386
+ if (phase()!=="select") return
387
+ setOptionIndex(idx)
388
+ setFocused("options")
389
+ if (isMulti()) toggleOption(idx)
390
+ else {
391
+ const o = options()[idx]
392
+ if (o) { setSelected(new Map([[`opt:${idx}`, { label: o.label, value: o.value, index: idx+1 }]])); setDontKnow(false); submitSelect() }
393
+ }
394
+ }}>
378
395
  <box width={2} alignItems="center"><text fg={isFocused() ? theme().accent : theme().textMuted}>{isFocused() ? "▸" : " "}</text></box>
379
396
  <box width={2} alignItems="center"><text fg={isMulti() ? (isSelected() ? theme().success : theme().textMuted) : (isSelected() ? theme().accent : theme().textMuted)}>{isMulti() ? (isSelected() ? "☑" : "☐") : (isSelected() ? "⬢" : "○")}</text></box>
380
397
  <box flexGrow={1}><text fg={isSelected() ? theme().text : theme().textMuted} bold={isFocused()} wrapMode="wrap">{idx + 1}. {opt.label}</text></box>
@@ -383,13 +400,13 @@ function QuizDialog(props: {
383
400
  }}
384
401
  </For>
385
402
  <Show when={options().length > 0}><box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20, popupWidth() - 8))}</text></box></Show>
386
- <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={focused() === "options" && optionIndex() === dontKnowIdx() ? theme().backgroundElement : undefined}>
403
+ <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={focused() === "options" && optionIndex() === dontKnowIdx() ? theme().backgroundElement : undefined} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==dontKnowIdx()) setOptionIndex(dontKnowIdx()) }} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==dontKnowIdx()) setOptionIndex(dontKnowIdx()) }} onMouseUp={() => { if (phase()!=="select") return; setOptionIndex(dontKnowIdx()); setFocused("options"); handleDontKnow() }}>
387
404
  <box width={2} alignItems="center"><text fg={focused() === "options" && optionIndex() === dontKnowIdx() ? theme().accent : theme().textMuted}>{focused() === "options" && optionIndex() === dontKnowIdx() ? "▸" : " "}</text></box>
388
405
  <box width={2} alignItems="center"><text fg={dontKnow() ? theme().warning : theme().textMuted}>{dontKnow() ? "☑" : "☐"}</text></box>
389
406
  <box flexGrow={1}><text fg={dontKnow() ? theme().warning : theme().textMuted} italic wrapMode="wrap">I don't know — genuine gap, not a guess</text></box>
390
407
  </box>
391
408
 
392
- <box flexDirection="column" gap={0} paddingTop={1}>
409
+ <box flexDirection="column" gap={0} paddingTop={1} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="note") setFocused("note") }} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="note") setFocused("note") }} onMouseUp={() => { if (phase()!=="select") return; setFocused("note") }}>
393
410
  <box flexDirection="row" alignItems="center" gap={1}>
394
411
  <text fg={focused() === "note" ? theme().accent : theme().textMuted} bold={focused() === "note"}>✎ Note (optional)</text>
395
412
  <Show when={focused() === "note"}><text fg={theme().accent}>● editing</text></Show>
@@ -416,7 +433,7 @@ function QuizDialog(props: {
416
433
  </box>
417
434
  <Show when={isMulti()}>
418
435
  <box justifyContent="center" paddingTop={1}>
419
- <box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().borderSubtle)} backgroundColor={focused() === "options" && optionIndex() === submitIdx() ? theme().backgroundElement : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().background)} paddingLeft={2} paddingRight={2}>
436
+ <box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().borderSubtle)} backgroundColor={focused() === "options" && optionIndex() === submitIdx() ? theme().backgroundElement : (selected().size > 0 || dontKnow() || note().trim() ? theme().success : theme().background)} paddingLeft={2} paddingRight={2} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="options" || optionIndex()!==submitIdx()) { setFocused("options"); setOptionIndex(submitIdx()) }}} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="options" || optionIndex()!==submitIdx()) { setFocused("options"); setOptionIndex(submitIdx()) }}} onMouseUp={() => { if (phase()!=="select") return; setOptionIndex(submitIdx()); setFocused("options"); submitSelect() }}>
420
437
  <text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().background : theme().textMuted)}>{focused() === "options" && optionIndex() === submitIdx() ? "▸" : " "}</text>
421
438
  <text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().background : theme().textMuted)} bold>↳ Submit{note().trim() && !selected().size && !dontKnow() ? " note" : ""}</text>
422
439
  </box>
@@ -424,7 +441,7 @@ function QuizDialog(props: {
424
441
  </Show>
425
442
  <Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}>
426
443
  <box justifyContent="center" paddingTop={1}>
427
- <box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().success} backgroundColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().backgroundElement : theme().success} paddingLeft={2} paddingRight={2}>
444
+ <box flexDirection="row" alignItems="center" gap={1} border={true} borderColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().success} backgroundColor={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().backgroundElement : theme().success} paddingLeft={2} paddingRight={2} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="options" || optionIndex()!==dontKnowIdx()+1) { setFocused("options"); setOptionIndex(dontKnowIdx()+1) }}} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="options" || optionIndex()!==dontKnowIdx()+1) { setFocused("options"); setOptionIndex(dontKnowIdx()+1) }}} onMouseUp={() => { if (phase()!=="select") return; setOptionIndex(dontKnowIdx()+1); setFocused("options"); submitSelect() }}>
428
445
  <text fg={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().background} bold>↳ Submit note → classify</text>
429
446
  </box>
430
447
  </box>
@@ -608,19 +625,28 @@ function QuizBatchDialog(props: {
608
625
  const inferred = data?.inferredIndices as number[] | undefined
609
626
  const semanticCorrect = data?.semanticCorrect as boolean | undefined
610
627
  const reason = data?.reason as string | undefined
628
+ const isIDK = !!(data as any)?.isIDK
611
629
  const computeOk2 = (idxs: number[]) => {
612
630
  if (typeof semanticCorrect === "boolean") return semanticCorrect
613
631
  const correctSet2 = new Set(cur().correctIndices)
614
632
  return idxs.length === cur().correctIndices.length && idxs.every(v => correctSet2.has(v)) && cur().correctIndices.every(v => idxs.includes(v))
615
633
  }
616
- if (inferred && inferred.length) {
634
+ if (isIDK) {
635
+ setDontKnow(true)
636
+ setSelected(new Map())
637
+ setFeedback({ correct: false, selectedIndices: [] })
638
+ if (reason) setNote(prev => prev ? `${prev} — ${reason}` : reason)
639
+ tlog("QuizBatchDialog classify isIDK", reason || "")
640
+ } else if (inferred && inferred.length) {
641
+ const eff = !isMulti() && inferred.length > 1 ? [inferred[0]!] : inferred
642
+ if (eff.length !== inferred.length) tlog("QuizBatchDialog classify enforce single", inferred.join(","), "->", eff.join(","))
617
643
  const mm = new Map<string, any>()
618
- for (const idx of inferred) { const opt = cur().options[idx - 1]; if (opt) mm.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx }) }
644
+ for (const idx of eff) { const opt = cur().options[idx - 1]; if (opt) mm.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx }) }
619
645
  setSelected(mm)
620
- const ok2 = computeOk2(inferred)
621
- setFeedback({ correct: ok2, selectedIndices: inferred })
646
+ const ok2 = computeOk2(eff)
647
+ setFeedback({ correct: ok2, selectedIndices: eff })
622
648
  if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
623
- tlog("QuizBatchDialog classify done", inferred.join(","), ok2, reason || "")
649
+ tlog("QuizBatchDialog classify done", eff.join(","), ok2, reason || "")
624
650
  } else {
625
651
  const ok2 = typeof semanticCorrect === "boolean" ? semanticCorrect : false
626
652
  setFeedback({ correct: ok2, selectedIndices: [] })
@@ -688,12 +714,12 @@ function QuizBatchDialog(props: {
688
714
  <Show when={cur().details}><markdown syntaxStyle={syntax()} content={decodeQuizText(cur().details)} fg={theme().textMuted} bg={theme().backgroundPanel} /></Show>
689
715
  <Show when={phase()==="select"}>
690
716
  <box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
691
- <For each={cur().options}>{(opt:any,i:any)=>{const id=i(); const foc=()=>focused()==="options"&&optionIndex()===id; const sel=()=>selected().has(`opt:${id}`); return <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} backgroundColor={foc()?theme().backgroundElement:undefined}><box width={2}><text fg={foc()?theme().accent:theme().textMuted}>{foc()?"▸":" "}</text></box><box width={2}><text fg={isMulti()?(sel()?theme().success:theme().textMuted):(sel()?theme().accent:theme().textMuted)}>{isMulti()?(sel()?"☑":"☐"):(sel()?"⬢":"○")}</text></box><box flexGrow={1}><text fg={sel()?theme().text:theme().textMuted} bold={foc()} wrapMode="wrap">{id+1}. {opt.label}</text></box></box>}}</For>
717
+ <For each={cur().options}>{(opt:any,i:any)=>{const id=i(); const foc=()=>focused()==="options"&&optionIndex()===id; const sel=()=>selected().has(`opt:${id}`); return <box flexDirection="row" alignItems="flexStart" gap={1} paddingLeft={1} backgroundColor={foc()?theme().backgroundElement:undefined} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==id) setOptionIndex(id) }} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==id) setOptionIndex(id) }} onMouseUp={() => { if (phase()!=="select") return; setOptionIndex(id); setFocused("options"); if (isMulti()) toggle(id); else { const o=cur().options[id]; if(o){ setSelected(new Map([[`opt:${id}`,{label:o.label,value:o.value,index:id+1}]])); setDontKnow(false); submitSelect() } } }}><box width={2}><text fg={foc()?theme().accent:theme().textMuted}>{foc()?"▸":" "}</text></box><box width={2}><text fg={isMulti()?(sel()?theme().success:theme().textMuted):(sel()?theme().accent:theme().textMuted)}>{isMulti()?(sel()?"☑":"☐"):(sel()?"⬢":"○")}</text></box><box flexGrow={1}><text fg={sel()?theme().text:theme().textMuted} bold={foc()} wrapMode="wrap">{id+1}. {opt.label}</text></box></box>}}</For>
692
718
  <box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
693
- <box flexDirection="row" gap={1} paddingLeft={1} backgroundColor={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().backgroundElement:undefined}><box width={2}><text fg={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===dontKnowIdx()?"▸":" "}</text></box><box width={2}><text fg={dontKnow()?theme().warning:theme().textMuted}>{dontKnow()?"☑":"☐"}</text></box><box flexGrow={1}><text fg={dontKnow()?theme().warning:theme().textMuted} italic>I don't know</text></box></box>
694
- <box flexDirection="column" paddingTop={1}><text fg={focused()==="note"?theme().accent:theme().textMuted}>✎ Note</text><box border={true} borderColor={focused()==="note"?theme().accent:theme().borderSubtle} backgroundColor={theme().backgroundElement} paddingLeft={1} paddingRight={1}><Show when={focused()==="note"} fallback={<text fg={theme().textMuted}>{note()||"Tab to edit · share what you were thinking"}</text>}><input ref={(el:any)=>noteEl=el} value={note()} onInput={(v:any)=>setNote(typeof v==="string"?v:v?.target?.value??"")} onSubmit={()=>{ if (!selected().size && !dontKnow() && note().trim()) submitSelect(); else setFocused("options") }} placeholder="note (Enter to submit note → classify)" /></Show></box></box>
719
+ <box flexDirection="row" gap={1} paddingLeft={1} backgroundColor={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().backgroundElement:undefined} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==dontKnowIdx()) setOptionIndex(dontKnowIdx()) }} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="options") setFocused("options"); if (optionIndex()!==dontKnowIdx()) setOptionIndex(dontKnowIdx()) }} onMouseUp={() => { if (phase()!=="select") return; setOptionIndex(dontKnowIdx()); setFocused("options"); const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); }}><box width={2}><text fg={focused()==="options"&&optionIndex()===dontKnowIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===dontKnowIdx()?"▸":" "}</text></box><box width={2}><text fg={dontKnow()?theme().warning:theme().textMuted}>{dontKnow()?"☑":"☐"}</text></box><box flexGrow={1}><text fg={dontKnow()?theme().warning:theme().textMuted} italic>I don't know</text></box></box>
720
+ <box flexDirection="column" paddingTop={1} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="note") setFocused("note") }} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="note") setFocused("note") }} onMouseUp={() => { if (phase()!=="select") return; setFocused("note") }}><text fg={focused()==="note"?theme().accent:theme().textMuted}>✎ Note</text><box border={true} borderColor={focused()==="note"?theme().accent:theme().borderSubtle} backgroundColor={theme().backgroundElement} paddingLeft={1} paddingRight={1}><Show when={focused()==="note"} fallback={<text fg={theme().textMuted}>{note()||"Tab to edit · share what you were thinking"}</text>}><input ref={(el:any)=>noteEl=el} value={note()} onInput={(v:any)=>setNote(typeof v==="string"?v:v?.target?.value??"")} onSubmit={()=>{ if (!selected().size && !dontKnow() && note().trim()) submitSelect(); else setFocused("options") }} placeholder="note (Enter to submit note → classify)" /></Show></box></box>
695
721
  <box flexDirection="row" justifyContent="space-between" paddingTop={1}><text fg={theme().textMuted}>{isMulti() ? `${selected().size} selected` : note().trim() && !selected().size ? "note → classify" : ""}</text><text fg={theme().textMuted}>{idx()+1}/{props.request.quizzes.length}</text></box>
696
- <Show when={isMulti()}><box justifyContent="center" paddingTop={1}><box flexDirection="row" gap={1} border={true} borderColor={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().borderSubtle} backgroundColor={focused()==="options"&&optionIndex()===submitIdx()?theme().backgroundElement:theme().background} paddingLeft={2} paddingRight={2}><text fg={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===submitIdx()?"▸":" "}</text><text bold>↳ Submit</text></box></box></Show>
722
+ <Show when={isMulti()}><box justifyContent="center" paddingTop={1}><box flexDirection="row" gap={1} border={true} borderColor={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().borderSubtle} backgroundColor={focused()==="options"&&optionIndex()===submitIdx()?theme().backgroundElement:theme().background} paddingLeft={2} paddingRight={2} onMouseOver={() => { if (phase()!=="select") return; if (focused()!=="options" || optionIndex()!==submitIdx()) { setFocused("options"); setOptionIndex(submitIdx()) }}} onMouseMove={() => { if (phase()!=="select") return; if (focused()!=="options" || optionIndex()!==submitIdx()) { setFocused("options"); setOptionIndex(submitIdx()) }}} onMouseUp={() => { if (phase()!=="select") return; setOptionIndex(submitIdx()); setFocused("options"); submitSelect() }}><text fg={focused()==="options"&&optionIndex()===submitIdx()?theme().accent:theme().textMuted}>{focused()==="options"&&optionIndex()===submitIdx()?"▸":" "}</text><text bold>↳ Submit</text></box></box></Show>
697
723
  </box>
698
724
  </Show>
699
725
  <Show when={(phase() as any)==="classifying"}>
package/plugins/learn.ts CHANGED
@@ -489,18 +489,19 @@ const server: Plugin = async ({ client, directory }) => {
489
489
  }
490
490
  return uniq
491
491
  }
492
- async function llmClassify(client: any, directory: string, note: string, options: Array<{ label: string; value?: string }>, question?: string, parentSessionID?: string, multiSelect?: boolean): Promise<{ inferred: number[]; semanticCorrect?: boolean; reason?: string; sessionID?: string }> {
492
+ async function llmClassify(client: any, directory: string, note: string, options: Array<{ label: string; value?: string }>, question?: string, parentSessionID?: string, multiSelect?: boolean): Promise<{ inferred: number[]; semanticCorrect?: boolean; reason?: string; sessionID?: string; isIDK?: boolean }> {
493
493
  const modeHint = multiSelect ? "This is a MULTI-SELECT question (0..N options may be correct). You may return 0..N inferred indices." : "This is a SINGLE-SELECT question (exactly 0 or 1 inferred). You MUST return at most ONE inferred index. Never return multiple. If note is ambiguous or mentions several options, pick the SINGLE best match. Return [] if vague."
494
- const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options. ${modeHint}
494
+ const idkHint = `Also detect IDK intent: if note says "I don't know / idk / too hard / too difficult / need easier / want easier / skip / give me easier/harder" or expresses wanting difficulty adjustment, set "isIDK": true (and keep inferred as [] or best guess). Otherwise isIDK false. The main teacher will use this to adapt difficulty.`
495
+ const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options. ${modeHint} ${idkHint}
495
496
 
496
497
  ${question ? `Question: ${question}\n` : ""}Options:
497
498
  ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join("\n")}
498
499
 
499
500
  Learner note: "${note}"
500
501
 
501
- Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed) — respect single/multi mode above. 2) semanticCorrect: true if note shows valid understanding or deeper nuance even when inferred != correct key (e.g., note about rotate array variant vs standard sorted is valid nuance). 3) reason: short English reason.
502
+ Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed) — respect single/multi mode above. 2) semanticCorrect: true if note shows valid understanding or deeper nuance even when inferred != correct key (e.g., note about rotate array variant vs standard sorted is valid nuance). 3) reason: short English reason. 4) isIDK: true if note expresses IDK / wants easier/harder/skip.
502
503
 
503
- Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`
504
+ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK":false} If vague/"I don't know", inferred:[], semanticCorrect:false, isIDK:true if IDK intent. No markdown, just JSON.`
504
505
  try {
505
506
  const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`
506
507
  const body: any = { title }
@@ -533,6 +534,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
533
534
  }
534
535
  return arr
535
536
  }
537
+ const noteIsIDK = (() => {
538
+ const n = note.toLowerCase()
539
+ return n.includes("idk") || n.includes("i don't know") || n.includes("i dont know") || n.includes("dont know") || n.includes("too hard") || n.includes("too difficult") || n.includes("need easier") || n.includes("want easier") || n.includes("give me easier") || n.includes("skip") || n.includes("quá khó") || n.includes("khó quá") || n.includes("dễ hơn") || n.includes("dễ hơn")
540
+ })()
536
541
  // Try object JSON {"inferred":[2],"semanticCorrect":false}
537
542
  const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/)
538
543
  if (objMatch) {
@@ -541,8 +546,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
541
546
  if (parsed && Array.isArray(parsed.inferred)) {
542
547
  const nums = parsed.inferred.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
543
548
  const fnums = enforceSingle(nums)
544
- slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`)
545
- return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid }
549
+ const isIDK = !!(parsed.isIDK ?? parsed.isIdk ?? parsed.dontKnow ?? parsed.isDontKnow ?? parsed.dont_know) || (noteIsIDK && fnums.length===0)
550
+ slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} isIDK:${isIDK} reason:${parsed.reason || ""} sid:${sid}`)
551
+ return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid, isIDK }
546
552
  }
547
553
  } catch {}
548
554
  }
@@ -554,8 +560,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
554
560
  const nums = parsed.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
555
561
  if (nums.length) {
556
562
  const fnums = enforceSingle(nums)
557
- slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`)
558
- return { inferred: fnums, sessionID: sid }
563
+ const isIDK = noteIsIDK && fnums.length===0
564
+ slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")} isIDK:${isIDK}`)
565
+ return { inferred: fnums, sessionID: sid, isIDK }
559
566
  }
560
567
  }
561
568
  } catch {}
@@ -565,9 +572,15 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
565
572
  if (nums.length) {
566
573
  const uniq = [...new Set(nums)]
567
574
  const fnums = enforceSingle(uniq)
568
- return { inferred: fnums, sessionID: sid }
575
+ const isIDK = noteIsIDK && fnums.length===0
576
+ return { inferred: fnums, sessionID: sid, isIDK }
569
577
  }
570
578
  }
579
+ // If LLM returned no inferred but note is IDK intent, still surface isIDK
580
+ if (noteIsIDK) {
581
+ slog("llmClassify isIDK fallback from note", note.slice(0,40))
582
+ return { inferred: [], sessionID: sid, isIDK: true }
583
+ }
571
584
  }
572
585
  }
573
586
  } catch {}
@@ -596,17 +609,32 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
596
609
  let inferred: number[] = []
597
610
  let semanticCorrect: boolean | undefined
598
611
  let reason: string | undefined
612
+ let isIDK: boolean | undefined
599
613
  const multi = !!data.multiSelect
600
614
  const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID, multi)
615
+ isIDK = (llmRes as any).isIDK
616
+ // Direct IDK keyword fallback if LLM didn't flag (covers heuristic-only path)
617
+ if (!isIDK) {
618
+ const n = data.note.toLowerCase()
619
+ if (n.includes("idk") || n.includes("i don't know") || n.includes("i dont know") || n.includes("too hard") || n.includes("too difficult") || n.includes("need easier") || n.includes("want easier") || n.includes("quá khó") || n.includes("khó quá")) {
620
+ // Only treat as IDK if no inferred or inferred is empty — don't override a confident inferred
621
+ if (!llmRes.inferred.length) isIDK = true
622
+ }
623
+ }
601
624
  if (llmRes.inferred.length) {
602
625
  inferred = llmRes.inferred
603
626
  semanticCorrect = llmRes.semanticCorrect
604
627
  reason = llmRes.reason
605
- slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} multi:${multi} sid:${llmRes.sessionID || ""}`)
628
+ isIDK = (llmRes as any).isIDK ?? isIDK
629
+ slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} isIDK:${isIDK} multi:${multi} sid:${llmRes.sessionID || ""}`)
606
630
  } else {
607
631
  inferred = heuristicClassify(data.note, data.options, multi)
608
- if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi}`)
609
- else slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`)
632
+ if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi} isIDK:${isIDK}`)
633
+ else slog("classify no match", data.id, `"${data.note.slice(0, 40)}" isIDK:${isIDK}`)
634
+ // If heuristic still empty but note is IDK, keep isIDK true so TUI can show IDK
635
+ if (!inferred.length && isIDK) {
636
+ slog("classify isIDK with no inferred", data.id)
637
+ }
610
638
  }
611
639
  // Enforce single-select at the watcher level too (defense in depth — prompt + llmClassify + heuristic may still return multi)
612
640
  if (!multi && inferred.length > 1) {
@@ -641,8 +669,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
641
669
  slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`)
642
670
  }
643
671
  const inferredValues = inferred.map((i: number) => data.options[i - 1]?.value).filter(Boolean) as string[]
644
- slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} multi:${multi} sid:${(llmRes as any)?.sessionID || ""} note:"${data.note.slice(0, 60)}"`)
645
- const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
672
+ slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} isIDK:${isIDK} multi:${multi} sid:${(llmRes as any)?.sessionID || ""} note:"${data.note.slice(0, 60)}"`)
673
+ const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, isIDK, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
646
674
  try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
647
675
  }
648
676
  // Initial sweep