@bojackduy/opencode-learn 1.1.1 → 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.
@@ -12,6 +12,7 @@ You map a learner's free-text note to quiz options. You are lenient for learner-
12
12
  Rules:
13
13
 
14
14
  - Only pick from given Options 1..N, no new options. Return inferred as indices that note best matches. Consider Vietnamese translations, synonyms, and "not fully" hedges.
15
+ - Respect question type from prompt: if SINGLE-SELECT (default) you MUST return at most ONE inferred index ( [] or [k] ), never multiple — if note mentions several options, pick the single best. If MULTI-SELECT you may return 0..N. The prompt will state which mode.
15
16
  - Also judge semanticCorrect: true if note demonstrates valid understanding or deeper nuance, even when inferred != correct key. For standard facts (e.g., binary search requires sorted for vanilla), a note about rotate/mountain variant is valid nuance but the standard True still stands — in that case inferred is [2] but semanticCorrect may be true if note shows insight; the popup will show both.
16
17
  - Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"short reason in English"} — no extra text, no markdown.
17
18
  - If note is vague, empty, or "I don't know", inferred=[], semanticCorrect=false.
package/dist/server.js CHANGED
@@ -525,27 +525,39 @@ var server = async ({ client, directory }) => {
525
525
  const loggedTextPartIds = new Set;
526
526
  const loggedToolCallIds = new Set;
527
527
  const messageIdToRole = new Map;
528
- function heuristicClassify(note, options) {
528
+ function heuristicClassify(note, options, multiSelect) {
529
529
  const n = note.toLowerCase();
530
- const out = [];
530
+ const scored = [];
531
531
  for (let i = 0;i < options.length; i++) {
532
532
  const o = options[i];
533
533
  const label = (o.label || "").toLowerCase();
534
534
  const value = (o.value || "").toLowerCase();
535
+ let score = 0;
535
536
  if (label && n.includes(label))
536
- out.push(i + 1);
537
+ score = 3;
537
538
  else if (value && n.includes(value))
538
- out.push(i + 1);
539
+ score = 2;
539
540
  else {
540
541
  const tokens = label.split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
541
542
  if (tokens.some((t) => n.includes(t)))
542
- out.push(i + 1);
543
+ score = 1;
543
544
  }
545
+ if (score)
546
+ scored.push({ idx: i + 1, score, len: label.length });
544
547
  }
545
- return [...new Set(out)];
548
+ scored.sort((a, b) => b.score - a.score || b.len - a.len);
549
+ const out = scored.map((s) => s.idx);
550
+ const uniq = [...new Set(out)];
551
+ if (!multiSelect && uniq.length > 1) {
552
+ slog("heuristicClassify single-select trimmed", uniq.join(","), "->", uniq[0]);
553
+ return [uniq[0]];
554
+ }
555
+ return uniq;
546
556
  }
547
- async function llmClassify(client2, directory2, note, options, question, parentSessionID) {
548
- 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.
557
+ async function llmClassify(client2, directory2, note, options, question, parentSessionID, multiSelect) {
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 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}
549
561
 
550
562
  ${question ? `Question: ${question}
551
563
  ` : ""}Options:
@@ -554,9 +566,9 @@ ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).j
554
566
 
555
567
  Learner note: "${note}"
556
568
 
557
- Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed). 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.
558
570
 
559
- 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.`;
560
572
  try {
561
573
  const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`;
562
574
  const body = { title };
@@ -582,14 +594,28 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
582
594
  const entry = arr[j];
583
595
  if (entry?.info?.role === "assistant") {
584
596
  const text = (entry.parts || []).filter((p) => p.type === "text").map((p) => p.text).join(" ") || "";
597
+ const enforceSingle = (arr2) => {
598
+ if (!multiSelect && arr2.length > 1) {
599
+ const trimmed = [arr2[0]];
600
+ slog("llmClassify enforce single", arr2.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single");
601
+ return trimmed;
602
+ }
603
+ return arr2;
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
+ })();
585
609
  const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/);
586
610
  if (objMatch) {
587
611
  try {
588
612
  const parsed = JSON.parse(objMatch[0]);
589
613
  if (parsed && Array.isArray(parsed.inferred)) {
590
614
  const nums = parsed.inferred.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
591
- slog("llmClassify success object", note.slice(0, 40), nums.join(","), `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`);
592
- return { inferred: nums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid };
615
+ const fnums = enforceSingle(nums);
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 };
593
619
  }
594
620
  } catch {}
595
621
  }
@@ -600,16 +626,26 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
600
626
  if (Array.isArray(parsed)) {
601
627
  const nums = parsed.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
602
628
  if (nums.length) {
603
- slog("llmClassify success array", note.slice(0, 40), nums.join(","));
604
- return { inferred: nums, sessionID: sid };
629
+ const fnums = enforceSingle(nums);
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 };
605
633
  }
606
634
  }
607
635
  } catch {}
608
636
  }
609
637
  if (text.includes("1") || text.includes("2")) {
610
638
  const nums = [...text.matchAll(/\b([1-9])\b/g)].map((x) => parseInt(x[1])).filter((n) => n <= options.length);
611
- if (nums.length)
612
- return { inferred: [...new Set(nums)], sessionID: sid };
639
+ if (nums.length) {
640
+ const uniq = [...new Set(nums)];
641
+ const fnums = enforceSingle(uniq);
642
+ const isIDK = noteIsIDK && fnums.length === 0;
643
+ return { inferred: fnums, sessionID: sid, isIDK };
644
+ }
645
+ }
646
+ if (noteIsIDK) {
647
+ slog("llmClassify isIDK fallback from note", note.slice(0, 40));
648
+ return { inferred: [], sessionID: sid, isIDK: true };
613
649
  }
614
650
  }
615
651
  }
@@ -649,36 +685,67 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
649
685
  let inferred = [];
650
686
  let semanticCorrect;
651
687
  let reason;
652
- const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID);
688
+ let isIDK;
689
+ const multi = !!data.multiSelect;
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
+ }
653
699
  if (llmRes.inferred.length) {
654
700
  inferred = llmRes.inferred;
655
701
  semanticCorrect = llmRes.semanticCorrect;
656
702
  reason = llmRes.reason;
657
- slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} 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 || ""}`);
658
705
  } else {
659
- inferred = heuristicClassify(data.note, data.options);
706
+ inferred = heuristicClassify(data.note, data.options, multi);
660
707
  if (inferred.length)
661
- slog("classify heuristic hit", data.id, inferred.join(","));
708
+ slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi} isIDK:${isIDK}`);
662
709
  else
663
- 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
+ }
714
+ }
715
+ if (!multi && inferred.length > 1) {
716
+ const before = inferred.join(",");
717
+ inferred = [inferred[0]];
718
+ slog("classify enforce single at watcher", data.id, `${before} -> ${inferred.join(",")}`);
664
719
  }
665
720
  const elapsed = Date.now() - start;
666
721
  if (elapsed < 1200)
667
722
  await new Promise((r) => setTimeout(r, 1200 - elapsed));
668
- const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
669
723
  if (!inferred.length && data.note) {
670
724
  const n = data.note.toLowerCase();
671
725
  for (const o of data.options) {
672
726
  const v = o.value ? String(o.value).toLowerCase() : "";
673
727
  if (v && n.includes(v) && !inferred.includes(byVal.get(o.value))) {
674
728
  const idx = byVal.get(o.value);
675
- if (idx)
729
+ if (idx) {
676
730
  inferred.push(idx);
731
+ if (!multi)
732
+ break;
733
+ }
677
734
  }
678
735
  }
736
+ if (!multi && inferred.length > 1) {
737
+ slog("classify fallback enforce single", data.id, inferred.join(","));
738
+ inferred = [inferred[0]];
739
+ }
740
+ }
741
+ if (!multi && inferred.length > 1) {
742
+ const before2 = inferred.join(",");
743
+ inferred = [inferred[0]];
744
+ slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`);
679
745
  }
680
- slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
681
- const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
746
+ const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
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() };
682
749
  try {
683
750
  fs.writeFileSync(respPath, JSON.stringify(out), "utf8");
684
751
  slog("classify response written", data.id, inferred.join(","));
package/dist/tui.js CHANGED
@@ -387,6 +387,7 @@ function QuizDialog(props) {
387
387
  value: o.value,
388
388
  index: i + 1
389
389
  })),
390
+ multiSelect: isMulti(),
390
391
  timestamp: Date.now(),
391
392
  sessionID: props.request.sessionID || routeSessionID
392
393
  };
@@ -418,15 +419,31 @@ function QuizDialog(props) {
418
419
  const inferredValues = data?.inferredValues;
419
420
  const semanticCorrect = data?.semanticCorrect;
420
421
  const reason = data?.reason;
422
+ const isIDK = !!data?.isIDK;
421
423
  const computeCorrect = (idxs) => {
422
424
  if (typeof semanticCorrect === "boolean")
423
425
  return semanticCorrect;
424
426
  return idxs.length === props.request.correctIndices.length && idxs.every((v) => correctSet.has(v)) && props.request.correctIndices.every((v) => idxs.includes(v));
425
427
  };
426
- 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) {
441
+ const eff = !isMulti() && inferred.length > 1 ? [inferred[0]] : inferred;
442
+ if (eff.length !== inferred.length)
443
+ tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","));
427
444
  const m = new Map;
428
- for (let i = 0;i < inferred.length; i++) {
429
- const idx = inferred[i];
445
+ for (let i = 0;i < eff.length; i++) {
446
+ const idx = eff[i];
430
447
  const opt = options()[idx - 1];
431
448
  if (opt)
432
449
  m.set(`opt:${idx - 1}`, {
@@ -436,17 +453,22 @@ function QuizDialog(props) {
436
453
  });
437
454
  }
438
455
  setSelected(m);
439
- const correct2 = computeCorrect(inferred);
456
+ const correct2 = computeCorrect(eff);
440
457
  setFeedback({
441
458
  correct: correct2,
442
- selectedIndices: inferred
459
+ selectedIndices: eff
443
460
  });
444
461
  if (reason)
445
462
  setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
446
- tlog("QuizDialog classify done", inferred.join(","), correct2, reason || "");
463
+ tlog("QuizDialog classify done", eff.join(","), correct2, reason || "");
447
464
  } else if (inferredValues && inferredValues.length) {
448
465
  const byVal = new Map(options().map((o, i) => [o.value, i + 1]));
449
- const idxs = inferredValues.map((v) => byVal.get(v)).filter(Boolean);
466
+ let idxs = inferredValues.map((v) => byVal.get(v)).filter(Boolean);
467
+ if (!isMulti() && idxs.length > 1) {
468
+ const b = idxs.join(",");
469
+ idxs = [idxs[0]];
470
+ tlog("QuizDialog classifyValues enforce single", b, "->", idxs.join(","));
471
+ }
450
472
  const m = new Map;
451
473
  for (const idx of idxs) {
452
474
  const opt = options()[idx - 1];
@@ -835,6 +857,42 @@ function QuizDialog(props) {
835
857
  _$setProp(_el$63, "gap", 1);
836
858
  _$setProp(_el$63, "paddingLeft", 1);
837
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
+ });
838
896
  _$insertNode(_el$64, _el$65);
839
897
  _$setProp(_el$64, "width", 2);
840
898
  _$setProp(_el$64, "alignItems", "center");
@@ -892,6 +950,29 @@ function QuizDialog(props) {
892
950
  _$setProp(_el$13, "gap", 1);
893
951
  _$setProp(_el$13, "paddingLeft", 1);
894
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
+ });
895
976
  _$insertNode(_el$14, _el$15);
896
977
  _$setProp(_el$14, "width", 2);
897
978
  _$setProp(_el$14, "alignItems", "center");
@@ -910,6 +991,23 @@ function QuizDialog(props) {
910
991
  _$setProp(_el$21, "flexDirection", "column");
911
992
  _$setProp(_el$21, "gap", 0);
912
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
+ });
913
1011
  _$insertNode(_el$22, _el$23);
914
1012
  _$setProp(_el$22, "flexDirection", "row");
915
1013
  _$setProp(_el$22, "alignItems", "center");
@@ -984,6 +1082,29 @@ function QuizDialog(props) {
984
1082
  _$setProp(_el$33, "border", true);
985
1083
  _$setProp(_el$33, "paddingLeft", 2);
986
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
+ });
987
1108
  _$insert(_el$34, () => focused() === "options" && optionIndex() === submitIdx() ? "\u25B8" : " ");
988
1109
  _$insertNode(_el$35, _el$36);
989
1110
  _$setProp(_el$35, "bold", true);
@@ -1020,6 +1141,29 @@ function QuizDialog(props) {
1020
1141
  _$setProp(_el$38, "border", true);
1021
1142
  _$setProp(_el$38, "paddingLeft", 2);
1022
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
+ });
1023
1167
  _$insertNode(_el$39, _$createTextNode(`\u21B3 Submit note \u2192 classify`));
1024
1168
  _$setProp(_el$39, "bold", true);
1025
1169
  _$effect((_p$) => {
@@ -1525,6 +1669,7 @@ function QuizBatchDialog(props) {
1525
1669
  value: o.value,
1526
1670
  index: i + 1
1527
1671
  })),
1672
+ multiSelect: isMulti(),
1528
1673
  timestamp: Date.now(),
1529
1674
  sessionID: props.request.sessionID || routeSessionID
1530
1675
  };
@@ -1555,15 +1700,29 @@ function QuizBatchDialog(props) {
1555
1700
  const inferred = data?.inferredIndices;
1556
1701
  const semanticCorrect = data?.semanticCorrect;
1557
1702
  const reason = data?.reason;
1703
+ const isIDK = !!data?.isIDK;
1558
1704
  const computeOk2 = (idxs) => {
1559
1705
  if (typeof semanticCorrect === "boolean")
1560
1706
  return semanticCorrect;
1561
1707
  const correctSet2 = new Set(cur().correctIndices);
1562
1708
  return idxs.length === cur().correctIndices.length && idxs.every((v) => correctSet2.has(v)) && cur().correctIndices.every((v) => idxs.includes(v));
1563
1709
  };
1564
- 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(","));
1565
1724
  const mm = new Map;
1566
- for (const idx2 of inferred) {
1725
+ for (const idx2 of eff) {
1567
1726
  const opt = cur().options[idx2 - 1];
1568
1727
  if (opt)
1569
1728
  mm.set(`opt:${idx2 - 1}`, {
@@ -1573,14 +1732,14 @@ function QuizBatchDialog(props) {
1573
1732
  });
1574
1733
  }
1575
1734
  setSelected(mm);
1576
- const ok2 = computeOk2(inferred);
1735
+ const ok2 = computeOk2(eff);
1577
1736
  setFeedback({
1578
1737
  correct: ok2,
1579
- selectedIndices: inferred
1738
+ selectedIndices: eff
1580
1739
  });
1581
1740
  if (reason)
1582
1741
  setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
1583
- tlog("QuizBatchDialog classify done", inferred.join(","), ok2, reason || "");
1742
+ tlog("QuizBatchDialog classify done", eff.join(","), ok2, reason || "");
1584
1743
  } else {
1585
1744
  const ok2 = typeof semanticCorrect === "boolean" ? semanticCorrect : false;
1586
1745
  setFeedback({
@@ -1904,6 +2063,42 @@ function QuizBatchDialog(props) {
1904
2063
  _$setProp(_el$148, "alignItems", "flexStart");
1905
2064
  _$setProp(_el$148, "gap", 1);
1906
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
+ });
1907
2102
  _$insertNode(_el$149, _el$150);
1908
2103
  _$setProp(_el$149, "width", 2);
1909
2104
  _$insert(_el$150, () => foc() ? "\u25B8" : " ");
@@ -1947,6 +2142,32 @@ function QuizBatchDialog(props) {
1947
2142
  _$setProp(_el$110, "flexDirection", "row");
1948
2143
  _$setProp(_el$110, "gap", 1);
1949
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
+ });
1950
2171
  _$insertNode(_el$111, _el$112);
1951
2172
  _$setProp(_el$111, "width", 2);
1952
2173
  _$insert(_el$112, () => focused() === "options" && optionIndex() === dontKnowIdx() ? "\u25B8" : " ");
@@ -1961,6 +2182,23 @@ function QuizBatchDialog(props) {
1961
2182
  _$insertNode(_el$118, _el$121);
1962
2183
  _$setProp(_el$118, "flexDirection", "column");
1963
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
+ });
1964
2202
  _$insertNode(_el$119, _$createTextNode(`\u270E Note`));
1965
2203
  _$setProp(_el$121, "border", true);
1966
2204
  _$setProp(_el$121, "paddingLeft", 1);
@@ -2020,6 +2258,29 @@ function QuizBatchDialog(props) {
2020
2258
  _$setProp(_el$128, "border", true);
2021
2259
  _$setProp(_el$128, "paddingLeft", 2);
2022
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
+ });
2023
2284
  _$insert(_el$129, () => focused() === "options" && optionIndex() === submitIdx() ? "\u25B8" : " ");
2024
2285
  _$insertNode(_el$130, _$createTextNode(`\u21B3 Submit`));
2025
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.1",
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",
@@ -183,7 +183,7 @@ function QuizDialog(props: {
183
183
  try {
184
184
  const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
185
185
  const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
186
- const pendingClassify = { id: props.request.id, type: "classify" as const, note: note().trim(), question: props.request.question, options: options().map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
186
+ const pendingClassify = { id: props.request.id, type: "classify" as const, note: note().trim(), question: props.request.question, options: options().map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
187
187
  fs.writeFileSync(path.join(pDir, `classify-${props.request.id}.json`), JSON.stringify(pendingClassify), "utf8")
188
188
  tlog("QuizDialog classify request", props.request.id, note().trim().slice(0, 50))
189
189
  // Poll for classify-response
@@ -202,25 +202,36 @@ 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) {
218
+ const eff = !isMulti() && inferred.length > 1 ? [inferred[0]!] : inferred
219
+ if (eff.length !== inferred.length) tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","))
210
220
  const m = new Map<string, { label: string; value: string; index: number }>()
211
- for (let i = 0; i < inferred.length; i++) {
212
- const idx = inferred[i]
221
+ for (let i = 0; i < eff.length; i++) {
222
+ const idx = eff[i]
213
223
  const opt = options()[idx - 1]
214
224
  if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx })
215
225
  }
216
226
  setSelected(m)
217
- const correct = computeCorrect(inferred)
218
- setFeedback({ correct, selectedIndices: inferred })
227
+ const correct = computeCorrect(eff)
228
+ setFeedback({ correct, selectedIndices: eff })
219
229
  if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
220
- tlog("QuizDialog classify done", inferred.join(","), correct, reason || "")
230
+ tlog("QuizDialog classify done", eff.join(","), correct, reason || "")
221
231
  } else if (inferredValues && inferredValues.length) {
222
232
  const byVal = new Map(options().map((o, i) => [o.value, i + 1]))
223
- const idxs = inferredValues.map(v => byVal.get(v)).filter(Boolean) as number[]
233
+ let idxs = inferredValues.map(v => byVal.get(v)).filter(Boolean) as number[]
234
+ if (!isMulti() && idxs.length > 1) { const b=idxs.join(","); idxs=[idxs[0]!]; tlog("QuizDialog classifyValues enforce single", b, "->", idxs.join(",")) }
224
235
  const m = new Map<string, { label: string; value: string; index: number }>()
225
236
  for (const idx of idxs) { const opt = options()[idx - 1]; if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx }) }
226
237
  setSelected(m)
@@ -371,7 +382,16 @@ function QuizDialog(props: {
371
382
  const isFocused = () => focused() === "options" && optionIndex() === idx
372
383
  const isSelected = () => selected().has(`opt:${idx}`)
373
384
  return (
374
- <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
+ }}>
375
395
  <box width={2} alignItems="center"><text fg={isFocused() ? theme().accent : theme().textMuted}>{isFocused() ? "▸" : " "}</text></box>
376
396
  <box width={2} alignItems="center"><text fg={isMulti() ? (isSelected() ? theme().success : theme().textMuted) : (isSelected() ? theme().accent : theme().textMuted)}>{isMulti() ? (isSelected() ? "☑" : "☐") : (isSelected() ? "⬢" : "○")}</text></box>
377
397
  <box flexGrow={1}><text fg={isSelected() ? theme().text : theme().textMuted} bold={isFocused()} wrapMode="wrap">{idx + 1}. {opt.label}</text></box>
@@ -380,13 +400,13 @@ function QuizDialog(props: {
380
400
  }}
381
401
  </For>
382
402
  <Show when={options().length > 0}><box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20, popupWidth() - 8))}</text></box></Show>
383
- <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() }}>
384
404
  <box width={2} alignItems="center"><text fg={focused() === "options" && optionIndex() === dontKnowIdx() ? theme().accent : theme().textMuted}>{focused() === "options" && optionIndex() === dontKnowIdx() ? "▸" : " "}</text></box>
385
405
  <box width={2} alignItems="center"><text fg={dontKnow() ? theme().warning : theme().textMuted}>{dontKnow() ? "☑" : "☐"}</text></box>
386
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>
387
407
  </box>
388
408
 
389
- <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") }}>
390
410
  <box flexDirection="row" alignItems="center" gap={1}>
391
411
  <text fg={focused() === "note" ? theme().accent : theme().textMuted} bold={focused() === "note"}>✎ Note (optional)</text>
392
412
  <Show when={focused() === "note"}><text fg={theme().accent}>● editing</text></Show>
@@ -413,7 +433,7 @@ function QuizDialog(props: {
413
433
  </box>
414
434
  <Show when={isMulti()}>
415
435
  <box justifyContent="center" paddingTop={1}>
416
- <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() }}>
417
437
  <text fg={focused() === "options" && optionIndex() === submitIdx() ? theme().accent : (selected().size > 0 || dontKnow() || note().trim() ? theme().background : theme().textMuted)}>{focused() === "options" && optionIndex() === submitIdx() ? "▸" : " "}</text>
418
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>
419
439
  </box>
@@ -421,7 +441,7 @@ function QuizDialog(props: {
421
441
  </Show>
422
442
  <Show when={!isMulti() && note().trim() && !selected().size && !dontKnow()}>
423
443
  <box justifyContent="center" paddingTop={1}>
424
- <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() }}>
425
445
  <text fg={focused() === "options" && optionIndex() === dontKnowIdx() + 1 ? theme().accent : theme().background} bold>↳ Submit note → classify</text>
426
446
  </box>
427
447
  </box>
@@ -588,7 +608,7 @@ function QuizBatchDialog(props: {
588
608
  const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
589
609
  const cid = `${props.request.id}-${idx()}`
590
610
  const routeSessionID = (props.api.route as any)?.current?.params?.sessionID
591
- const pendingClassify = { id: cid, type: "classify" as const, note: note().trim(), question: cur().question, options: cur().options.map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
611
+ const pendingClassify = { id: cid, type: "classify" as const, note: note().trim(), question: cur().question, options: cur().options.map((o: any, i: number) => ({ label: o.label, value: o.value, index: i + 1 })), multiSelect: isMulti(), timestamp: Date.now(), sessionID: props.request.sessionID || routeSessionID }
592
612
  fs.writeFileSync(path.join(pDir, `classify-${cid}.json`), JSON.stringify(pendingClassify), "utf8")
593
613
  tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50))
594
614
  const respPath = path.join(pDir, `classify-response-${cid}.json`)
@@ -605,19 +625,28 @@ function QuizBatchDialog(props: {
605
625
  const inferred = data?.inferredIndices as number[] | undefined
606
626
  const semanticCorrect = data?.semanticCorrect as boolean | undefined
607
627
  const reason = data?.reason as string | undefined
628
+ const isIDK = !!(data as any)?.isIDK
608
629
  const computeOk2 = (idxs: number[]) => {
609
630
  if (typeof semanticCorrect === "boolean") return semanticCorrect
610
631
  const correctSet2 = new Set(cur().correctIndices)
611
632
  return idxs.length === cur().correctIndices.length && idxs.every(v => correctSet2.has(v)) && cur().correctIndices.every(v => idxs.includes(v))
612
633
  }
613
- 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(","))
614
643
  const mm = new Map<string, any>()
615
- 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 }) }
616
645
  setSelected(mm)
617
- const ok2 = computeOk2(inferred)
618
- setFeedback({ correct: ok2, selectedIndices: inferred })
646
+ const ok2 = computeOk2(eff)
647
+ setFeedback({ correct: ok2, selectedIndices: eff })
619
648
  if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
620
- tlog("QuizBatchDialog classify done", inferred.join(","), ok2, reason || "")
649
+ tlog("QuizBatchDialog classify done", eff.join(","), ok2, reason || "")
621
650
  } else {
622
651
  const ok2 = typeof semanticCorrect === "boolean" ? semanticCorrect : false
623
652
  setFeedback({ correct: ok2, selectedIndices: [] })
@@ -685,12 +714,12 @@ function QuizBatchDialog(props: {
685
714
  <Show when={cur().details}><markdown syntaxStyle={syntax()} content={decodeQuizText(cur().details)} fg={theme().textMuted} bg={theme().backgroundPanel} /></Show>
686
715
  <Show when={phase()==="select"}>
687
716
  <box flexDirection="column" gap={0} padding={1} border={true} borderColor={theme().borderSubtle} backgroundColor={theme().background}>
688
- <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>
689
718
  <box height={1}><text fg={theme().borderSubtle}>{"─".repeat(Math.max(20,popupWidth()-8))}</text></box>
690
- <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>
691
- <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>
692
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>
693
- <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>
694
723
  </box>
695
724
  </Show>
696
725
  <Show when={(phase() as any)==="classifying"}>
package/plugins/learn.ts CHANGED
@@ -464,33 +464,44 @@ const server: Plugin = async ({ client, directory }) => {
464
464
  const messageIdToRole = new Map<string, string>()
465
465
 
466
466
  // ── Classify watcher: note → inferred options (learner-easy) — LLM-backed, not heuristic-only
467
- function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>): number[] {
467
+ function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>, multiSelect?: boolean): number[] {
468
468
  const n = note.toLowerCase()
469
- const out: number[] = []
469
+ const scored: Array<{ idx:number, score:number, len:number }> = []
470
470
  for (let i = 0; i < options.length; i++) {
471
471
  const o = options[i]
472
472
  const label = (o.label || "").toLowerCase()
473
473
  const value = (o.value || "").toLowerCase()
474
- if (label && n.includes(label)) out.push(i + 1)
475
- else if (value && n.includes(value)) out.push(i + 1)
474
+ let score = 0
475
+ if (label && n.includes(label)) score = 3
476
+ else if (value && n.includes(value)) score = 2
476
477
  else {
477
478
  const tokens = label.split(/[^a-z0-9]+/).filter(t => t.length >= 3)
478
- if (tokens.some(t => n.includes(t))) out.push(i + 1)
479
+ if (tokens.some(t => n.includes(t))) score = 1
479
480
  }
481
+ if (score) scored.push({ idx: i + 1, score, len: label.length })
480
482
  }
481
- return [...new Set(out)]
483
+ scored.sort((a,b)=> b.score - a.score || b.len - a.len)
484
+ const out = scored.map(s=> s.idx)
485
+ const uniq = [...new Set(out)]
486
+ if (!multiSelect && uniq.length > 1) {
487
+ slog("heuristicClassify single-select trimmed", uniq.join(","), "->", uniq[0])
488
+ return [uniq[0]!]
489
+ }
490
+ return uniq
482
491
  }
483
- async function llmClassify(client: any, directory: string, note: string, options: Array<{ label: string; value?: string }>, question?: string, parentSessionID?: string): Promise<{ inferred: number[]; semanticCorrect?: boolean; reason?: string; sessionID?: string }> {
484
- 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.
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
+ 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 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}
485
496
 
486
497
  ${question ? `Question: ${question}\n` : ""}Options:
487
498
  ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join("\n")}
488
499
 
489
500
  Learner note: "${note}"
490
501
 
491
- Task: 1) inferred: which option(s) note best matches (Vietnamese translations/synonyms allowed). 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.
492
503
 
493
- 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.`
494
505
  try {
495
506
  const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`
496
507
  const body: any = { title }
@@ -515,6 +526,18 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
515
526
  const entry = arr[j]
516
527
  if (entry?.info?.role === "assistant") {
517
528
  const text = (entry.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join(" ") || ""
529
+ const enforceSingle = (arr:number[]) => {
530
+ if (!multiSelect && arr.length > 1) {
531
+ const trimmed = [arr[0]!]
532
+ slog("llmClassify enforce single", arr.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single")
533
+ return trimmed
534
+ }
535
+ return arr
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
+ })()
518
541
  // Try object JSON {"inferred":[2],"semanticCorrect":false}
519
542
  const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/)
520
543
  if (objMatch) {
@@ -522,8 +545,10 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
522
545
  const parsed = JSON.parse(objMatch[0])
523
546
  if (parsed && Array.isArray(parsed.inferred)) {
524
547
  const nums = parsed.inferred.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
525
- slog("llmClassify success object", note.slice(0, 40), nums.join(","), `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`)
526
- return { inferred: nums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid }
548
+ const fnums = enforceSingle(nums)
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 }
527
552
  }
528
553
  } catch {}
529
554
  }
@@ -534,15 +559,27 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
534
559
  if (Array.isArray(parsed)) {
535
560
  const nums = parsed.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
536
561
  if (nums.length) {
537
- slog("llmClassify success array", note.slice(0, 40), nums.join(","))
538
- return { inferred: nums, sessionID: sid }
562
+ const fnums = enforceSingle(nums)
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 }
539
566
  }
540
567
  }
541
568
  } catch {}
542
569
  }
543
570
  if (text.includes("1") || text.includes("2")) {
544
571
  const nums = [...text.matchAll(/\b([1-9])\b/g)].map(x => parseInt(x[1])).filter(n => n <= options.length)
545
- if (nums.length) return { inferred: [...new Set(nums)], sessionID: sid }
572
+ if (nums.length) {
573
+ const uniq = [...new Set(nums)]
574
+ const fnums = enforceSingle(uniq)
575
+ const isIDK = noteIsIDK && fnums.length===0
576
+ return { inferred: fnums, sessionID: sid, isIDK }
577
+ }
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 }
546
583
  }
547
584
  }
548
585
  }
@@ -572,34 +609,68 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
572
609
  let inferred: number[] = []
573
610
  let semanticCorrect: boolean | undefined
574
611
  let reason: string | undefined
575
- const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID)
612
+ let isIDK: boolean | undefined
613
+ const multi = !!data.multiSelect
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
+ }
576
624
  if (llmRes.inferred.length) {
577
625
  inferred = llmRes.inferred
578
626
  semanticCorrect = llmRes.semanticCorrect
579
627
  reason = llmRes.reason
580
- slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} 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 || ""}`)
581
630
  } else {
582
- inferred = heuristicClassify(data.note, data.options)
583
- if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","))
584
- else slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`)
631
+ inferred = heuristicClassify(data.note, data.options, multi)
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
+ }
638
+ }
639
+ // Enforce single-select at the watcher level too (defense in depth — prompt + llmClassify + heuristic may still return multi)
640
+ if (!multi && inferred.length > 1) {
641
+ const before = inferred.join(",")
642
+ inferred = [inferred[0]!]
643
+ slog("classify enforce single at watcher", data.id, `${before} -> ${inferred.join(",")}`)
585
644
  }
586
645
  // Ensure minimal classify time so UI doesn't feel instant-wrong (at least 1200ms)
587
646
  const elapsed = Date.now() - start
588
647
  if (elapsed < 1200) await new Promise(r => setTimeout(r, 1200 - elapsed))
589
- const inferredValues = inferred.map((i: number) => data.options[i - 1]?.value).filter(Boolean) as string[]
590
- // Final fallback if still empty
648
+ // Final fallback if still empty (respects single-select)
591
649
  if (!inferred.length && data.note) {
592
650
  const n = data.note.toLowerCase()
593
651
  for (const o of data.options) {
594
652
  const v = o.value ? String(o.value).toLowerCase() : ""
595
653
  if (v && n.includes(v) && !inferred.includes(byVal.get(o.value) as number)) {
596
654
  const idx = byVal.get(o.value) as number | undefined
597
- if (idx) inferred.push(idx)
655
+ if (idx) {
656
+ inferred.push(idx)
657
+ if (!multi) break
658
+ }
598
659
  }
599
660
  }
661
+ if (!multi && inferred.length > 1) {
662
+ slog("classify fallback enforce single", data.id, inferred.join(","))
663
+ inferred = [inferred[0]!]
664
+ }
665
+ }
666
+ if (!multi && inferred.length > 1) {
667
+ const before2 = inferred.join(",")
668
+ inferred = [inferred[0]!]
669
+ slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`)
600
670
  }
601
- slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${(llmRes as any)?.sessionID || ""} note:"${data.note.slice(0, 60)}"`)
602
- const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
671
+ const inferredValues = inferred.map((i: number) => data.options[i - 1]?.value).filter(Boolean) as string[]
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() }
603
674
  try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
604
675
  }
605
676
  // Initial sweep