@bojackduy/opencode-learn 1.1.0 → 1.1.2
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 +64 -22
- package/dist/tui.js +35 -25
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +29 -26
- package/plugins/learn.ts +65 -22
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,38 @@ 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 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}
|
|
549
560
|
|
|
550
561
|
${question ? `Question: ${question}
|
|
551
562
|
` : ""}Options:
|
|
@@ -554,7 +565,7 @@ ${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).j
|
|
|
554
565
|
|
|
555
566
|
Learner note: "${note}"
|
|
556
567
|
|
|
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.
|
|
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.
|
|
558
569
|
|
|
559
570
|
Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`;
|
|
560
571
|
try {
|
|
@@ -582,14 +593,23 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
582
593
|
const entry = arr[j];
|
|
583
594
|
if (entry?.info?.role === "assistant") {
|
|
584
595
|
const text = (entry.parts || []).filter((p) => p.type === "text").map((p) => p.text).join(" ") || "";
|
|
596
|
+
const enforceSingle = (arr2) => {
|
|
597
|
+
if (!multiSelect && arr2.length > 1) {
|
|
598
|
+
const trimmed = [arr2[0]];
|
|
599
|
+
slog("llmClassify enforce single", arr2.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single");
|
|
600
|
+
return trimmed;
|
|
601
|
+
}
|
|
602
|
+
return arr2;
|
|
603
|
+
};
|
|
585
604
|
const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/);
|
|
586
605
|
if (objMatch) {
|
|
587
606
|
try {
|
|
588
607
|
const parsed = JSON.parse(objMatch[0]);
|
|
589
608
|
if (parsed && Array.isArray(parsed.inferred)) {
|
|
590
609
|
const nums = parsed.inferred.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
|
|
591
|
-
|
|
592
|
-
|
|
610
|
+
const fnums = enforceSingle(nums);
|
|
611
|
+
slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`);
|
|
612
|
+
return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid };
|
|
593
613
|
}
|
|
594
614
|
} catch {}
|
|
595
615
|
}
|
|
@@ -600,16 +620,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
600
620
|
if (Array.isArray(parsed)) {
|
|
601
621
|
const nums = parsed.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
|
|
602
622
|
if (nums.length) {
|
|
603
|
-
|
|
604
|
-
|
|
623
|
+
const fnums = enforceSingle(nums);
|
|
624
|
+
slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`);
|
|
625
|
+
return { inferred: fnums, sessionID: sid };
|
|
605
626
|
}
|
|
606
627
|
}
|
|
607
628
|
} catch {}
|
|
608
629
|
}
|
|
609
630
|
if (text.includes("1") || text.includes("2")) {
|
|
610
631
|
const nums = [...text.matchAll(/\b([1-9])\b/g)].map((x) => parseInt(x[1])).filter((n) => n <= options.length);
|
|
611
|
-
if (nums.length)
|
|
612
|
-
|
|
632
|
+
if (nums.length) {
|
|
633
|
+
const uniq = [...new Set(nums)];
|
|
634
|
+
const fnums = enforceSingle(uniq);
|
|
635
|
+
return { inferred: fnums, sessionID: sid };
|
|
636
|
+
}
|
|
613
637
|
}
|
|
614
638
|
}
|
|
615
639
|
}
|
|
@@ -649,35 +673,53 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
649
673
|
let inferred = [];
|
|
650
674
|
let semanticCorrect;
|
|
651
675
|
let reason;
|
|
652
|
-
const
|
|
676
|
+
const multi = !!data.multiSelect;
|
|
677
|
+
const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID, multi);
|
|
653
678
|
if (llmRes.inferred.length) {
|
|
654
679
|
inferred = llmRes.inferred;
|
|
655
680
|
semanticCorrect = llmRes.semanticCorrect;
|
|
656
681
|
reason = llmRes.reason;
|
|
657
|
-
slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`);
|
|
682
|
+
slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} multi:${multi} sid:${llmRes.sessionID || ""}`);
|
|
658
683
|
} else {
|
|
659
|
-
inferred = heuristicClassify(data.note, data.options);
|
|
684
|
+
inferred = heuristicClassify(data.note, data.options, multi);
|
|
660
685
|
if (inferred.length)
|
|
661
|
-
slog("classify heuristic hit", data.id, inferred.join(","));
|
|
686
|
+
slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi}`);
|
|
662
687
|
else
|
|
663
688
|
slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`);
|
|
664
689
|
}
|
|
690
|
+
if (!multi && inferred.length > 1) {
|
|
691
|
+
const before = inferred.join(",");
|
|
692
|
+
inferred = [inferred[0]];
|
|
693
|
+
slog("classify enforce single at watcher", data.id, `${before} -> ${inferred.join(",")}`);
|
|
694
|
+
}
|
|
665
695
|
const elapsed = Date.now() - start;
|
|
666
696
|
if (elapsed < 1200)
|
|
667
697
|
await new Promise((r) => setTimeout(r, 1200 - elapsed));
|
|
668
|
-
const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
|
|
669
698
|
if (!inferred.length && data.note) {
|
|
670
699
|
const n = data.note.toLowerCase();
|
|
671
700
|
for (const o of data.options) {
|
|
672
701
|
const v = o.value ? String(o.value).toLowerCase() : "";
|
|
673
702
|
if (v && n.includes(v) && !inferred.includes(byVal.get(o.value))) {
|
|
674
703
|
const idx = byVal.get(o.value);
|
|
675
|
-
if (idx)
|
|
704
|
+
if (idx) {
|
|
676
705
|
inferred.push(idx);
|
|
706
|
+
if (!multi)
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
677
709
|
}
|
|
678
710
|
}
|
|
711
|
+
if (!multi && inferred.length > 1) {
|
|
712
|
+
slog("classify fallback enforce single", data.id, inferred.join(","));
|
|
713
|
+
inferred = [inferred[0]];
|
|
714
|
+
}
|
|
679
715
|
}
|
|
680
|
-
|
|
716
|
+
if (!multi && inferred.length > 1) {
|
|
717
|
+
const before2 = inferred.join(",");
|
|
718
|
+
inferred = [inferred[0]];
|
|
719
|
+
slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`);
|
|
720
|
+
}
|
|
721
|
+
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)}"`);
|
|
681
723
|
const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
|
|
682
724
|
try {
|
|
683
725
|
fs.writeFileSync(respPath, JSON.stringify(out), "utf8");
|
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
|
};
|
|
@@ -424,9 +425,12 @@ function QuizDialog(props) {
|
|
|
424
425
|
return idxs.length === props.request.correctIndices.length && idxs.every((v) => correctSet.has(v)) && props.request.correctIndices.every((v) => idxs.includes(v));
|
|
425
426
|
};
|
|
426
427
|
if (inferred && inferred.length) {
|
|
428
|
+
const eff = !isMulti() && inferred.length > 1 ? [inferred[0]] : inferred;
|
|
429
|
+
if (eff.length !== inferred.length)
|
|
430
|
+
tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","));
|
|
427
431
|
const m = new Map;
|
|
428
|
-
for (let i = 0;i <
|
|
429
|
-
const idx =
|
|
432
|
+
for (let i = 0;i < eff.length; i++) {
|
|
433
|
+
const idx = eff[i];
|
|
430
434
|
const opt = options()[idx - 1];
|
|
431
435
|
if (opt)
|
|
432
436
|
m.set(`opt:${idx - 1}`, {
|
|
@@ -436,17 +440,22 @@ function QuizDialog(props) {
|
|
|
436
440
|
});
|
|
437
441
|
}
|
|
438
442
|
setSelected(m);
|
|
439
|
-
const correct2 = computeCorrect(
|
|
443
|
+
const correct2 = computeCorrect(eff);
|
|
440
444
|
setFeedback({
|
|
441
445
|
correct: correct2,
|
|
442
|
-
selectedIndices:
|
|
446
|
+
selectedIndices: eff
|
|
443
447
|
});
|
|
444
448
|
if (reason)
|
|
445
449
|
setNote((prev) => prev ? `${prev} \u2014 ${reason}` : prev);
|
|
446
|
-
tlog("QuizDialog classify done",
|
|
450
|
+
tlog("QuizDialog classify done", eff.join(","), correct2, reason || "");
|
|
447
451
|
} else if (inferredValues && inferredValues.length) {
|
|
448
452
|
const byVal = new Map(options().map((o, i) => [o.value, i + 1]));
|
|
449
|
-
|
|
453
|
+
let idxs = inferredValues.map((v) => byVal.get(v)).filter(Boolean);
|
|
454
|
+
if (!isMulti() && idxs.length > 1) {
|
|
455
|
+
const b = idxs.join(",");
|
|
456
|
+
idxs = [idxs[0]];
|
|
457
|
+
tlog("QuizDialog classifyValues enforce single", b, "->", idxs.join(","));
|
|
458
|
+
}
|
|
450
459
|
const m = new Map;
|
|
451
460
|
for (const idx of idxs) {
|
|
452
461
|
const opt = options()[idx - 1];
|
|
@@ -589,17 +598,17 @@ function QuizDialog(props) {
|
|
|
589
598
|
return;
|
|
590
599
|
}
|
|
591
600
|
if (focused() === "note") {
|
|
592
|
-
if (
|
|
601
|
+
if (lower === "tab" || seq === "\t") {
|
|
593
602
|
prevent(evt);
|
|
594
603
|
setFocused("options");
|
|
595
604
|
return;
|
|
596
605
|
}
|
|
597
|
-
if (
|
|
606
|
+
if (lower === "escape" || lower === "esc") {
|
|
598
607
|
prevent(evt);
|
|
599
608
|
setFocused("options");
|
|
600
609
|
return;
|
|
601
610
|
}
|
|
602
|
-
if (
|
|
611
|
+
if (lower === "enter" && (evt.ctrl || evt.meta)) {
|
|
603
612
|
prevent(evt);
|
|
604
613
|
setFocused("options");
|
|
605
614
|
return;
|
|
@@ -647,27 +656,27 @@ function QuizDialog(props) {
|
|
|
647
656
|
return dontKnowIdx() + 1;
|
|
648
657
|
return dontKnowIdx();
|
|
649
658
|
};
|
|
650
|
-
if (
|
|
659
|
+
if (lower === "up" || isPlainKey(evt, "k") || seq === "\x1B[A") {
|
|
651
660
|
prevent(evt);
|
|
652
661
|
setOptionIndex((i) => Math.max(0, i - 1));
|
|
653
662
|
return;
|
|
654
663
|
}
|
|
655
|
-
if (
|
|
664
|
+
if (lower === "down" || isPlainKey(evt, "j") || seq === "\x1B[B") {
|
|
656
665
|
prevent(evt);
|
|
657
666
|
setOptionIndex((i) => Math.min(maxIdx(), i + 1));
|
|
658
667
|
return;
|
|
659
668
|
}
|
|
660
|
-
if (
|
|
669
|
+
if (lower === "tab" || seq === "\t") {
|
|
661
670
|
prevent(evt);
|
|
662
671
|
setFocused("note");
|
|
663
672
|
return;
|
|
664
673
|
}
|
|
665
|
-
if (
|
|
674
|
+
if (lower === "escape" || lower === "esc") {
|
|
666
675
|
prevent(evt);
|
|
667
676
|
props.onCancel();
|
|
668
677
|
return;
|
|
669
678
|
}
|
|
670
|
-
if (
|
|
679
|
+
if (lower === "space" || seq === " ") {
|
|
671
680
|
prevent(evt);
|
|
672
681
|
const idx = optionIndex();
|
|
673
682
|
if (idx === dontKnowIdx())
|
|
@@ -692,7 +701,7 @@ function QuizDialog(props) {
|
|
|
692
701
|
}
|
|
693
702
|
return;
|
|
694
703
|
}
|
|
695
|
-
if (
|
|
704
|
+
if (lower === "enter" || seq === "\r") {
|
|
696
705
|
prevent(evt);
|
|
697
706
|
const idx = optionIndex();
|
|
698
707
|
if (idx === dontKnowIdx())
|
|
@@ -715,7 +724,7 @@ function QuizDialog(props) {
|
|
|
715
724
|
}
|
|
716
725
|
return;
|
|
717
726
|
}
|
|
718
|
-
if (seq === "ctrl+j" ||
|
|
727
|
+
if (seq === "ctrl+j" || lower === "enter" && evt.ctrl) {
|
|
719
728
|
prevent(evt);
|
|
720
729
|
submitSelect();
|
|
721
730
|
return;
|
|
@@ -1525,6 +1534,7 @@ function QuizBatchDialog(props) {
|
|
|
1525
1534
|
value: o.value,
|
|
1526
1535
|
index: i + 1
|
|
1527
1536
|
})),
|
|
1537
|
+
multiSelect: isMulti(),
|
|
1528
1538
|
timestamp: Date.now(),
|
|
1529
1539
|
sessionID: props.request.sessionID || routeSessionID
|
|
1530
1540
|
};
|
|
@@ -1683,12 +1693,12 @@ function QuizBatchDialog(props) {
|
|
|
1683
1693
|
return;
|
|
1684
1694
|
}
|
|
1685
1695
|
if (focused() === "note") {
|
|
1686
|
-
if (
|
|
1696
|
+
if (lower === "tab" || seq === "\t") {
|
|
1687
1697
|
prevent(evt);
|
|
1688
1698
|
setFocused("options");
|
|
1689
1699
|
return;
|
|
1690
1700
|
}
|
|
1691
|
-
if (
|
|
1701
|
+
if (lower === "escape" || lower === "esc") {
|
|
1692
1702
|
prevent(evt);
|
|
1693
1703
|
setFocused("options");
|
|
1694
1704
|
return;
|
|
@@ -1713,27 +1723,27 @@ function QuizBatchDialog(props) {
|
|
|
1713
1723
|
} catch {}
|
|
1714
1724
|
return;
|
|
1715
1725
|
}
|
|
1716
|
-
if (
|
|
1726
|
+
if (lower === "up" || isPlainKeyBatch(evt, "k") || seq === "\x1B[A") {
|
|
1717
1727
|
prevent(evt);
|
|
1718
1728
|
setOptionIndex((i) => Math.max(0, i - 1));
|
|
1719
1729
|
return;
|
|
1720
1730
|
}
|
|
1721
|
-
if (
|
|
1731
|
+
if (lower === "down" || isPlainKeyBatch(evt, "j") || seq === "\x1B[B") {
|
|
1722
1732
|
prevent(evt);
|
|
1723
1733
|
setOptionIndex((i) => Math.min(isMulti() ? submitIdx() : dontKnowIdx(), i + 1));
|
|
1724
1734
|
return;
|
|
1725
1735
|
}
|
|
1726
|
-
if (
|
|
1736
|
+
if (lower === "tab" || seq === "\t") {
|
|
1727
1737
|
prevent(evt);
|
|
1728
1738
|
setFocused("note");
|
|
1729
1739
|
return;
|
|
1730
1740
|
}
|
|
1731
|
-
if (
|
|
1741
|
+
if (lower === "escape" || lower === "esc") {
|
|
1732
1742
|
prevent(evt);
|
|
1733
1743
|
props.onCancel();
|
|
1734
1744
|
return;
|
|
1735
1745
|
}
|
|
1736
|
-
if (
|
|
1746
|
+
if (lower === "space" || seq === " ") {
|
|
1737
1747
|
prevent(evt);
|
|
1738
1748
|
const i = optionIndex();
|
|
1739
1749
|
if (i === dontKnowIdx()) {
|
|
@@ -1759,7 +1769,7 @@ function QuizBatchDialog(props) {
|
|
|
1759
1769
|
}
|
|
1760
1770
|
return;
|
|
1761
1771
|
}
|
|
1762
|
-
if (
|
|
1772
|
+
if (lower === "enter" || seq === "\r") {
|
|
1763
1773
|
prevent(evt);
|
|
1764
1774
|
const i = optionIndex();
|
|
1765
1775
|
if (i === dontKnowIdx()) {
|
|
@@ -1783,7 +1793,7 @@ function QuizBatchDialog(props) {
|
|
|
1783
1793
|
}
|
|
1784
1794
|
return;
|
|
1785
1795
|
}
|
|
1786
|
-
if (seq === "ctrl+j" ||
|
|
1796
|
+
if (seq === "ctrl+j" || lower === "enter" && evt.ctrl) {
|
|
1787
1797
|
prevent(evt);
|
|
1788
1798
|
submitSelect();
|
|
1789
1799
|
return;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-learn",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.2",
|
|
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
|
|
@@ -207,20 +207,23 @@ function QuizDialog(props: {
|
|
|
207
207
|
return idxs.length === props.request.correctIndices.length && idxs.every((v: number) => correctSet.has(v)) && props.request.correctIndices.every((v: number) => idxs.includes(v))
|
|
208
208
|
}
|
|
209
209
|
if (inferred && inferred.length) {
|
|
210
|
+
const eff = !isMulti() && inferred.length > 1 ? [inferred[0]!] : inferred
|
|
211
|
+
if (eff.length !== inferred.length) tlog("QuizDialog classify enforce single", inferred.join(","), "->", eff.join(","))
|
|
210
212
|
const m = new Map<string, { label: string; value: string; index: number }>()
|
|
211
|
-
for (let i = 0; i <
|
|
212
|
-
const idx =
|
|
213
|
+
for (let i = 0; i < eff.length; i++) {
|
|
214
|
+
const idx = eff[i]
|
|
213
215
|
const opt = options()[idx - 1]
|
|
214
216
|
if (opt) m.set(`opt:${idx - 1}`, { label: opt.label, value: opt.value, index: idx })
|
|
215
217
|
}
|
|
216
218
|
setSelected(m)
|
|
217
|
-
const correct = computeCorrect(
|
|
218
|
-
setFeedback({ correct, selectedIndices:
|
|
219
|
+
const correct = computeCorrect(eff)
|
|
220
|
+
setFeedback({ correct, selectedIndices: eff })
|
|
219
221
|
if (reason) setNote(prev => prev ? `${prev} — ${reason}` : prev)
|
|
220
|
-
tlog("QuizDialog classify done",
|
|
222
|
+
tlog("QuizDialog classify done", eff.join(","), correct, reason || "")
|
|
221
223
|
} else if (inferredValues && inferredValues.length) {
|
|
222
224
|
const byVal = new Map(options().map((o, i) => [o.value, i + 1]))
|
|
223
|
-
|
|
225
|
+
let idxs = inferredValues.map(v => byVal.get(v)).filter(Boolean) as number[]
|
|
226
|
+
if (!isMulti() && idxs.length > 1) { const b=idxs.join(","); idxs=[idxs[0]!]; tlog("QuizDialog classifyValues enforce single", b, "->", idxs.join(",")) }
|
|
224
227
|
const m = new Map<string, { label: string; value: string; index: number }>()
|
|
225
228
|
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
229
|
setSelected(m)
|
|
@@ -286,9 +289,9 @@ function QuizDialog(props: {
|
|
|
286
289
|
}
|
|
287
290
|
// Note focused: handle Tab/Esc/Enter to exit note, otherwise let input handle typing
|
|
288
291
|
if (focused() === "note") {
|
|
289
|
-
if (
|
|
290
|
-
if (
|
|
291
|
-
if (
|
|
292
|
+
if (lower === "tab" || seq === "\t") { prevent(evt); setFocused("options"); return }
|
|
293
|
+
if (lower === "escape" || lower === "esc") { prevent(evt); setFocused("options"); return }
|
|
294
|
+
if (lower === "enter" && (evt.ctrl || evt.meta)) { prevent(evt); setFocused("options"); return }
|
|
292
295
|
// Allow typing to go to input; don't prevent
|
|
293
296
|
return
|
|
294
297
|
}
|
|
@@ -303,11 +306,11 @@ function QuizDialog(props: {
|
|
|
303
306
|
if (note().trim() && !selected().size && !dontKnow()) return dontKnowIdx() + 1
|
|
304
307
|
return dontKnowIdx()
|
|
305
308
|
}
|
|
306
|
-
if (
|
|
307
|
-
if (
|
|
308
|
-
if (
|
|
309
|
-
if (
|
|
310
|
-
if (
|
|
309
|
+
if (lower === "up" || isPlainKey(evt,"k") || seq === "\x1b[A") { prevent(evt); setOptionIndex(i => Math.max(0, i - 1)); return }
|
|
310
|
+
if (lower === "down" || isPlainKey(evt,"j") || seq === "\x1b[B") { prevent(evt); setOptionIndex(i => Math.min(maxIdx(), i + 1)); return }
|
|
311
|
+
if (lower === "tab" || seq === "\t") { prevent(evt); setFocused("note"); return }
|
|
312
|
+
if (lower === "escape" || lower === "esc") { prevent(evt); props.onCancel(); return }
|
|
313
|
+
if (lower === "space" || seq === " ") {
|
|
311
314
|
prevent(evt)
|
|
312
315
|
const idx = optionIndex()
|
|
313
316
|
if (idx === dontKnowIdx()) handleDontKnow()
|
|
@@ -321,7 +324,7 @@ function QuizDialog(props: {
|
|
|
321
324
|
}
|
|
322
325
|
return
|
|
323
326
|
}
|
|
324
|
-
if (
|
|
327
|
+
if (lower === "enter" || seq === "\r") {
|
|
325
328
|
prevent(evt)
|
|
326
329
|
const idx = optionIndex()
|
|
327
330
|
if (idx === dontKnowIdx()) handleDontKnow()
|
|
@@ -333,7 +336,7 @@ function QuizDialog(props: {
|
|
|
333
336
|
}
|
|
334
337
|
return
|
|
335
338
|
}
|
|
336
|
-
if (seq === "ctrl+j" || (
|
|
339
|
+
if (seq === "ctrl+j" || (lower === "enter" && (evt as any).ctrl)) {
|
|
337
340
|
prevent(evt); submitSelect(); return
|
|
338
341
|
}
|
|
339
342
|
})
|
|
@@ -588,7 +591,7 @@ function QuizBatchDialog(props: {
|
|
|
588
591
|
const pDir = (globalThis as any).__learnPendingDir || ".opencode/learn-pending"
|
|
589
592
|
const cid = `${props.request.id}-${idx()}`
|
|
590
593
|
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 }
|
|
594
|
+
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
595
|
fs.writeFileSync(path.join(pDir, `classify-${cid}.json`), JSON.stringify(pendingClassify), "utf8")
|
|
593
596
|
tlog("QuizBatchDialog classify request", cid, note().trim().slice(0, 50))
|
|
594
597
|
const respPath = path.join(pDir, `classify-response-${cid}.json`)
|
|
@@ -654,16 +657,16 @@ function QuizBatchDialog(props: {
|
|
|
654
657
|
if (lower==="pageup"||seq==="\x1b[5~"){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
|
|
655
658
|
if (lower==="pagedown"||seq==="\x1b[6~"){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30)}catch{} return }
|
|
656
659
|
if(lower==="enter"||seq==="\r"||lower==="escape"||lower==="esc"){ prevent(evt); goNext() } return }
|
|
657
|
-
if(focused()==="note"){ if(
|
|
660
|
+
if(focused()==="note"){ if(lower==="tab"||seq==="\t"){prevent(evt); setFocused("options"); return} if(lower==="escape"||lower==="esc"){prevent(evt); setFocused("options"); return} return }
|
|
658
661
|
if(phase()==="select" && (isPlainKeyBatch(evt,"d")||seq==="\x04")){ prevent(evt); try{scrollRefBatch?.scrollBy(scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
659
662
|
if(phase()==="select" && (isPlainKeyBatch(evt,"u")||seq==="\x15")){ prevent(evt); try{scrollRefBatch?.scrollBy(-scrollAmountBatch()); setTimeout(updateScrollBatch,30); setTimeout(updateScrollBatch,120)}catch{} return }
|
|
660
|
-
if(
|
|
661
|
-
if(
|
|
662
|
-
if(
|
|
663
|
-
if(
|
|
664
|
-
if(
|
|
665
|
-
if(
|
|
666
|
-
if(seq==="ctrl+j" || (
|
|
663
|
+
if(lower==="up"||isPlainKeyBatch(evt,"k")||seq==="\x1b[A"){prevent(evt); setOptionIndex(i=>Math.max(0,i-1)); return}
|
|
664
|
+
if(lower==="down"||isPlainKeyBatch(evt,"j")||seq==="\x1b[B"){prevent(evt); setOptionIndex(i=>Math.min(isMulti()?submitIdx():dontKnowIdx(),i+1)); return}
|
|
665
|
+
if(lower==="tab"||seq==="\t"){prevent(evt); setFocused("note"); return}
|
|
666
|
+
if(lower==="escape"||lower==="esc"){prevent(evt); props.onCancel(); return}
|
|
667
|
+
if(lower==="space"||seq===" "){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti() && i===submitIdx()) submitSelect(); else if(isMulti()) toggle(i); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
|
|
668
|
+
if(lower==="enter"||seq==="\r"){prevent(evt); const i=optionIndex(); if(i===dontKnowIdx()){ const willBe=!dontKnow(); setDontKnow(willBe); if(willBe) setSelected(new Map()); } else if(isMulti()) submitSelect(); else { const o=cur().options[i]; if(o){setSelected(new Map([[`opt:${i}`,{label:o.label,value:o.value,index:i+1}]])); setDontKnow(false); submitSelect()} } return}
|
|
669
|
+
if(seq==="ctrl+j" || (lower==="enter" && evt.ctrl)){prevent(evt); submitSelect(); return}
|
|
667
670
|
} catch(e){ tlog("useKeyboard batch failed", String(e)) }
|
|
668
671
|
})
|
|
669
672
|
return (
|
package/plugins/learn.ts
CHANGED
|
@@ -464,31 +464,41 @@ 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 }> {
|
|
493
|
+
const modeHint = multiSelect ? "This is a MULTI-SELECT question (0..N options may be correct). You may return 0..N inferred indices." : "This is a SINGLE-SELECT question (exactly 0 or 1 inferred). You MUST return at most ONE inferred index. Never return multiple. If note is ambiguous or mentions several options, pick the SINGLE best match. Return [] if vague."
|
|
494
|
+
const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options. ${modeHint}
|
|
485
495
|
|
|
486
496
|
${question ? `Question: ${question}\n` : ""}Options:
|
|
487
497
|
${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join("\n")}
|
|
488
498
|
|
|
489
499
|
Learner note: "${note}"
|
|
490
500
|
|
|
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.
|
|
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.
|
|
492
502
|
|
|
493
503
|
Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`
|
|
494
504
|
try {
|
|
@@ -515,6 +525,14 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
515
525
|
const entry = arr[j]
|
|
516
526
|
if (entry?.info?.role === "assistant") {
|
|
517
527
|
const text = (entry.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join(" ") || ""
|
|
528
|
+
const enforceSingle = (arr:number[]) => {
|
|
529
|
+
if (!multiSelect && arr.length > 1) {
|
|
530
|
+
const trimmed = [arr[0]!]
|
|
531
|
+
slog("llmClassify enforce single", arr.join(","), "->", trimmed.join(","), multiSelect ? "multi" : "single")
|
|
532
|
+
return trimmed
|
|
533
|
+
}
|
|
534
|
+
return arr
|
|
535
|
+
}
|
|
518
536
|
// Try object JSON {"inferred":[2],"semanticCorrect":false}
|
|
519
537
|
const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/)
|
|
520
538
|
if (objMatch) {
|
|
@@ -522,8 +540,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
522
540
|
const parsed = JSON.parse(objMatch[0])
|
|
523
541
|
if (parsed && Array.isArray(parsed.inferred)) {
|
|
524
542
|
const nums = parsed.inferred.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
|
|
525
|
-
|
|
526
|
-
|
|
543
|
+
const fnums = enforceSingle(nums)
|
|
544
|
+
slog("llmClassify success object", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`, `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`)
|
|
545
|
+
return { inferred: fnums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid }
|
|
527
546
|
}
|
|
528
547
|
} catch {}
|
|
529
548
|
}
|
|
@@ -534,15 +553,20 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
534
553
|
if (Array.isArray(parsed)) {
|
|
535
554
|
const nums = parsed.filter((n: any) => typeof n === "number" && n >= 1 && n <= options.length)
|
|
536
555
|
if (nums.length) {
|
|
537
|
-
|
|
538
|
-
|
|
556
|
+
const fnums = enforceSingle(nums)
|
|
557
|
+
slog("llmClassify success array", note.slice(0, 40), nums.join(","), `->${fnums.join(",")}`)
|
|
558
|
+
return { inferred: fnums, sessionID: sid }
|
|
539
559
|
}
|
|
540
560
|
}
|
|
541
561
|
} catch {}
|
|
542
562
|
}
|
|
543
563
|
if (text.includes("1") || text.includes("2")) {
|
|
544
564
|
const nums = [...text.matchAll(/\b([1-9])\b/g)].map(x => parseInt(x[1])).filter(n => n <= options.length)
|
|
545
|
-
if (nums.length)
|
|
565
|
+
if (nums.length) {
|
|
566
|
+
const uniq = [...new Set(nums)]
|
|
567
|
+
const fnums = enforceSingle(uniq)
|
|
568
|
+
return { inferred: fnums, sessionID: sid }
|
|
569
|
+
}
|
|
546
570
|
}
|
|
547
571
|
}
|
|
548
572
|
}
|
|
@@ -572,33 +596,52 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If va
|
|
|
572
596
|
let inferred: number[] = []
|
|
573
597
|
let semanticCorrect: boolean | undefined
|
|
574
598
|
let reason: string | undefined
|
|
575
|
-
const
|
|
599
|
+
const multi = !!data.multiSelect
|
|
600
|
+
const llmRes = await llmClassify(client, directory, data.note, data.options, data.question, data.sessionID, multi)
|
|
576
601
|
if (llmRes.inferred.length) {
|
|
577
602
|
inferred = llmRes.inferred
|
|
578
603
|
semanticCorrect = llmRes.semanticCorrect
|
|
579
604
|
reason = llmRes.reason
|
|
580
|
-
slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`)
|
|
605
|
+
slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} multi:${multi} sid:${llmRes.sessionID || ""}`)
|
|
581
606
|
} else {
|
|
582
|
-
inferred = heuristicClassify(data.note, data.options)
|
|
583
|
-
if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","))
|
|
607
|
+
inferred = heuristicClassify(data.note, data.options, multi)
|
|
608
|
+
if (inferred.length) slog("classify heuristic hit", data.id, inferred.join(","), `multi:${multi}`)
|
|
584
609
|
else slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`)
|
|
585
610
|
}
|
|
611
|
+
// Enforce single-select at the watcher level too (defense in depth — prompt + llmClassify + heuristic may still return multi)
|
|
612
|
+
if (!multi && inferred.length > 1) {
|
|
613
|
+
const before = inferred.join(",")
|
|
614
|
+
inferred = [inferred[0]!]
|
|
615
|
+
slog("classify enforce single at watcher", data.id, `${before} -> ${inferred.join(",")}`)
|
|
616
|
+
}
|
|
586
617
|
// Ensure minimal classify time so UI doesn't feel instant-wrong (at least 1200ms)
|
|
587
618
|
const elapsed = Date.now() - start
|
|
588
619
|
if (elapsed < 1200) await new Promise(r => setTimeout(r, 1200 - elapsed))
|
|
589
|
-
|
|
590
|
-
// Final fallback if still empty
|
|
620
|
+
// Final fallback if still empty (respects single-select)
|
|
591
621
|
if (!inferred.length && data.note) {
|
|
592
622
|
const n = data.note.toLowerCase()
|
|
593
623
|
for (const o of data.options) {
|
|
594
624
|
const v = o.value ? String(o.value).toLowerCase() : ""
|
|
595
625
|
if (v && n.includes(v) && !inferred.includes(byVal.get(o.value) as number)) {
|
|
596
626
|
const idx = byVal.get(o.value) as number | undefined
|
|
597
|
-
if (idx)
|
|
627
|
+
if (idx) {
|
|
628
|
+
inferred.push(idx)
|
|
629
|
+
if (!multi) break
|
|
630
|
+
}
|
|
598
631
|
}
|
|
599
632
|
}
|
|
633
|
+
if (!multi && inferred.length > 1) {
|
|
634
|
+
slog("classify fallback enforce single", data.id, inferred.join(","))
|
|
635
|
+
inferred = [inferred[0]!]
|
|
636
|
+
}
|
|
600
637
|
}
|
|
601
|
-
|
|
638
|
+
if (!multi && inferred.length > 1) {
|
|
639
|
+
const before2 = inferred.join(",")
|
|
640
|
+
inferred = [inferred[0]!]
|
|
641
|
+
slog("classify final enforce single", data.id, `${before2} -> ${inferred.join(",")}`)
|
|
642
|
+
}
|
|
643
|
+
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)}"`)
|
|
602
645
|
const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: (llmRes as any)?.sessionID, note: data.note, at: Date.now() }
|
|
603
646
|
try { fs.writeFileSync(respPath, JSON.stringify(out), "utf8"); slog("classify response written", data.id, inferred.join(",")) } catch {}
|
|
604
647
|
}
|