@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.
- package/agents/classify.md +1 -0
- package/dist/server.js +92 -25
- package/dist/tui.js +273 -12
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +52 -23
- package/plugins/learn.ts +96 -25
package/agents/classify.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
537
|
+
score = 3;
|
|
537
538
|
else if (value && n.includes(value))
|
|
538
|
-
|
|
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
|
-
|
|
543
|
+
score = 1;
|
|
543
544
|
}
|
|
545
|
+
if (score)
|
|
546
|
+
scored.push({ idx: i + 1, score, len: label.length });
|
|
544
547
|
}
|
|
545
|
-
|
|
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
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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 (
|
|
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 <
|
|
429
|
-
const idx =
|
|
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(
|
|
456
|
+
const correct2 = computeCorrect(eff);
|
|
440
457
|
setFeedback({
|
|
441
458
|
correct: correct2,
|
|
442
|
-
selectedIndices:
|
|
459
|
+
selectedIndices: eff
|
|
443
460
|
});
|
|
444
461
|
if (reason)
|
|
445
462
|
setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
|
|
446
|
-
tlog("QuizDialog classify done",
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
1735
|
+
const ok2 = computeOk2(eff);
|
|
1577
1736
|
setFeedback({
|
|
1578
1737
|
correct: ok2,
|
|
1579
|
-
selectedIndices:
|
|
1738
|
+
selectedIndices: eff
|
|
1580
1739
|
});
|
|
1581
1740
|
if (reason)
|
|
1582
1741
|
setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
|
|
1583
|
-
tlog("QuizBatchDialog classify done",
|
|
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.
|
|
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",
|
package/plugins/learn-tui.tsx
CHANGED
|
@@ -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 (
|
|
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 <
|
|
212
|
-
const idx =
|
|
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(
|
|
218
|
-
setFeedback({ correct, selectedIndices:
|
|
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",
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
618
|
-
setFeedback({ correct: ok2, selectedIndices:
|
|
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",
|
|
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 }
|
|
467
|
+
function heuristicClassify(note: string, options: Array<{ label: string; value?: string }>, multiSelect?: boolean): number[] {
|
|
468
468
|
const n = note.toLowerCase()
|
|
469
|
-
const
|
|
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
|
-
|
|
475
|
-
|
|
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)))
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
602
|
-
|
|
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
|