@bojackduy/opencode-learn 1.1.2 → 1.2.1
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/LICENSE +28 -24
- package/README.md +2 -2
- package/dist/server.js +38 -13
- package/dist/tui.js +257 -6
- package/package.json +3 -3
- package/plugins/learn-tui.tsx +41 -15
- package/plugins/learn.ts +42 -14
package/LICENSE
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 19 November 2007
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2026 Duy Trinh
|
|
5
|
+
|
|
6
|
+
This program is free software: you can redistribute it and/or modify
|
|
7
|
+
it under the terms of the GNU Affero General Public License as published
|
|
8
|
+
by the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
(at your option) any later version.
|
|
10
|
+
|
|
11
|
+
This program is distributed in the hope that it will be useful,
|
|
12
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
GNU Affero General Public License for more details.
|
|
15
|
+
|
|
16
|
+
You should have received a copy of the GNU Affero General Public License
|
|
17
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
|
+
|
|
19
|
+
For the complete AGPL-3.0 license text, see:
|
|
20
|
+
https://www.gnu.org/licenses/agpl-3.0.txt
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
This project is a port of third-party software with its own licenses:
|
|
24
|
+
|
|
25
|
+
- pi (Copyright (c) Mario Zechner — https://github.com/mariozechner/pi) — MIT License
|
|
26
|
+
- learn (Copyright (c) Amos Blomqvist — https://github.com/amosblomqvist/learn) — MIT License
|
|
27
|
+
|
|
25
28
|
Video: How I Use AI to Learn Things — https://www.youtube.com/watch?v=kzcI5F4tGiU
|
|
29
|
+
See their respective repositories for full MIT license texts.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://opencode.ai)
|
|
8
8
|
[](https://github.com/anomalyco/opentui)
|
|
9
|
-
[](LICENSE)
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
@@ -148,7 +148,7 @@ See `~/Code/opencode-loopd/{package.json,scripts/install-node.mjs,.github/workfl
|
|
|
148
148
|
|
|
149
149
|
## License
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
AGPL-3.0-or-later — see [LICENSE](./LICENSE). Original `learn` is personal, shared as-is by Amos. Original `pi` remains MIT.
|
|
152
152
|
|
|
153
153
|
## Credits
|
|
154
154
|
|
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
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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(
|
|
1735
|
+
const ok2 = computeOk2(eff);
|
|
1587
1736
|
setFeedback({
|
|
1588
1737
|
correct: ok2,
|
|
1589
|
-
selectedIndices:
|
|
1738
|
+
selectedIndices: eff
|
|
1590
1739
|
});
|
|
1591
1740
|
if (reason)
|
|
1592
1741
|
setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
|
|
1593
|
-
tlog("QuizBatchDialog classify done",
|
|
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,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-learn",
|
|
4
|
-
"version": "1.1
|
|
5
|
-
"description": "Pi learn system for OpenCode
|
|
4
|
+
"version": "1.2.1",
|
|
5
|
+
"description": "Pi learn system for OpenCode \u2014 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
|
-
"license": "
|
|
7
|
+
"license": "AGPL-3.0-or-later",
|
|
8
8
|
"private": false,
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
|
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(
|
|
621
|
-
setFeedback({ correct: ok2, selectedIndices:
|
|
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",
|
|
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
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|