@bojackduy/opencode-learn 0.1.5 → 1.0.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 +17 -0
- package/dist/server.js +220 -27
- package/dist/tui.js +1394 -490
- package/package.json +1 -1
- package/plugins/learn-tui.tsx +344 -34
- package/plugins/learn.ts +197 -25
- package/scripts/install.mjs +3 -3
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: classify
|
|
3
|
+
description: Map learner free-text note (Vietnamese or English) to closest quiz option(s) and judge semantic correctness. Returns strict JSON only.
|
|
4
|
+
thinking: low
|
|
5
|
+
system-prompt: append
|
|
6
|
+
auto-exit: true
|
|
7
|
+
mode: subagent
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
You map a learner's free-text note to quiz options. You are lenient for learner-easy but auditable.
|
|
11
|
+
|
|
12
|
+
Rules:
|
|
13
|
+
|
|
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
|
+
- 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
|
+
- Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"short reason in English"} — no extra text, no markdown.
|
|
17
|
+
- If note is vague, empty, or "I don't know", inferred=[], semanticCorrect=false.
|
package/dist/server.js
CHANGED
|
@@ -147,6 +147,18 @@ function resolveCorrect(correctAnswer, options) {
|
|
|
147
147
|
}
|
|
148
148
|
return { indices: Array.from(new Set(indices)).sort((a, b) => a - b) };
|
|
149
149
|
}
|
|
150
|
+
function decodeQuizText(s) {
|
|
151
|
+
if (!s || typeof s !== "string")
|
|
152
|
+
return s;
|
|
153
|
+
if (!s.includes("\\"))
|
|
154
|
+
return s;
|
|
155
|
+
let out = s.replace(/\\r\\n/g, `
|
|
156
|
+
`).replace(/\\n/g, `
|
|
157
|
+
`).replace(/\\r/g, "\r").replace(/\\t/g, "\t");
|
|
158
|
+
out = out.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
|
159
|
+
out = out.replace(/\\\\/g, "\\");
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
150
162
|
var mdLogFile = null;
|
|
151
163
|
var mdLogWriteLock = Promise.resolve();
|
|
152
164
|
function withMdLock(fn) {
|
|
@@ -513,10 +525,183 @@ var server = async ({ client, directory }) => {
|
|
|
513
525
|
const loggedTextPartIds = new Set;
|
|
514
526
|
const loggedToolCallIds = new Set;
|
|
515
527
|
const messageIdToRole = new Map;
|
|
528
|
+
function heuristicClassify(note, options) {
|
|
529
|
+
const n = note.toLowerCase();
|
|
530
|
+
const out = [];
|
|
531
|
+
for (let i = 0;i < options.length; i++) {
|
|
532
|
+
const o = options[i];
|
|
533
|
+
const label = (o.label || "").toLowerCase();
|
|
534
|
+
const value = (o.value || "").toLowerCase();
|
|
535
|
+
if (label && n.includes(label))
|
|
536
|
+
out.push(i + 1);
|
|
537
|
+
else if (value && n.includes(value))
|
|
538
|
+
out.push(i + 1);
|
|
539
|
+
else {
|
|
540
|
+
const tokens = label.split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
541
|
+
if (tokens.some((t) => n.includes(t)))
|
|
542
|
+
out.push(i + 1);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return [...new Set(out)];
|
|
546
|
+
}
|
|
547
|
+
async function llmClassify(client2, directory2, note, options, question, parentSessionID) {
|
|
548
|
+
const prompt = `Map learner's free-text note (may be Vietnamese or English) to closest option(s) and judge semantic correctness. Only pick from given Options, no new options.
|
|
549
|
+
|
|
550
|
+
${question ? `Question: ${question}
|
|
551
|
+
` : ""}Options:
|
|
552
|
+
${options.map((o, i) => `${i + 1}. ${o.label} (value: ${o.value || o.label})`).join(`
|
|
553
|
+
`)}
|
|
554
|
+
|
|
555
|
+
Learner note: "${note}"
|
|
556
|
+
|
|
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.
|
|
558
|
+
|
|
559
|
+
Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"..."} If vague/"I don't know", inferred:[], semanticCorrect:false. No markdown, just JSON.`;
|
|
560
|
+
try {
|
|
561
|
+
const title = `classify: ${question ? question.slice(0, 30) : note.slice(0, 20)}`;
|
|
562
|
+
const body = { title };
|
|
563
|
+
if (parentSessionID)
|
|
564
|
+
body.parentID = parentSessionID;
|
|
565
|
+
const created = await client2.session.create({ body, query: { directory: directory2 } });
|
|
566
|
+
const sid = created?.data?.id || created?.id || created?.data?.sessionID;
|
|
567
|
+
if (!sid)
|
|
568
|
+
throw new Error("no sid");
|
|
569
|
+
const createdSession = created?.data || created;
|
|
570
|
+
slog("classify subagent created", sid, `requestedParent:${parentSessionID || "none"}`, `actualParent:${createdSession?.parentID || "none"}`, note.slice(0, 40));
|
|
571
|
+
if (parentSessionID && createdSession?.parentID !== parentSessionID) {
|
|
572
|
+
throw new Error(`classifier parent mismatch: expected ${parentSessionID}, got ${createdSession?.parentID || "none"}`);
|
|
573
|
+
}
|
|
574
|
+
await client2.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text", text: prompt }], agent: "classify" } });
|
|
575
|
+
for (let i = 0;i < 24; i++) {
|
|
576
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
577
|
+
try {
|
|
578
|
+
const msgs = await client2.session.messages({ path: { id: sid } });
|
|
579
|
+
const data = msgs?.data || msgs;
|
|
580
|
+
const arr = Array.isArray(data) ? data : [];
|
|
581
|
+
for (let j = arr.length - 1;j >= 0; j--) {
|
|
582
|
+
const entry = arr[j];
|
|
583
|
+
if (entry?.info?.role === "assistant") {
|
|
584
|
+
const text = (entry.parts || []).filter((p) => p.type === "text").map((p) => p.text).join(" ") || "";
|
|
585
|
+
const objMatch = text.match(/\{[^}]*"inferred"[^}]*\}/);
|
|
586
|
+
if (objMatch) {
|
|
587
|
+
try {
|
|
588
|
+
const parsed = JSON.parse(objMatch[0]);
|
|
589
|
+
if (parsed && Array.isArray(parsed.inferred)) {
|
|
590
|
+
const nums = parsed.inferred.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
|
|
591
|
+
slog("llmClassify success object", note.slice(0, 40), nums.join(","), `semantic:${parsed.semanticCorrect} reason:${parsed.reason || ""} sid:${sid}`);
|
|
592
|
+
return { inferred: nums, semanticCorrect: !!parsed.semanticCorrect, reason: parsed.reason, sessionID: sid };
|
|
593
|
+
}
|
|
594
|
+
} catch {}
|
|
595
|
+
}
|
|
596
|
+
const m = text.match(/\[[\s\d,]*\]/);
|
|
597
|
+
if (m) {
|
|
598
|
+
try {
|
|
599
|
+
const parsed = JSON.parse(m[0]);
|
|
600
|
+
if (Array.isArray(parsed)) {
|
|
601
|
+
const nums = parsed.filter((n) => typeof n === "number" && n >= 1 && n <= options.length);
|
|
602
|
+
if (nums.length) {
|
|
603
|
+
slog("llmClassify success array", note.slice(0, 40), nums.join(","));
|
|
604
|
+
return { inferred: nums, sessionID: sid };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch {}
|
|
608
|
+
}
|
|
609
|
+
if (text.includes("1") || text.includes("2")) {
|
|
610
|
+
const nums = [...text.matchAll(/\b([1-9])\b/g)].map((x) => parseInt(x[1])).filter((n) => n <= options.length);
|
|
611
|
+
if (nums.length)
|
|
612
|
+
return { inferred: [...new Set(nums)], sessionID: sid };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
} catch {}
|
|
617
|
+
}
|
|
618
|
+
slog("llmClassify timeout", note.slice(0, 40));
|
|
619
|
+
} catch (e) {
|
|
620
|
+
slog("llmClassify failed", String(e).slice(0, 200));
|
|
621
|
+
}
|
|
622
|
+
return { inferred: [] };
|
|
623
|
+
}
|
|
624
|
+
function startClassifyWatcher(client2, directory2) {
|
|
625
|
+
const dir = pendingDir(directory2);
|
|
626
|
+
try {
|
|
627
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
628
|
+
} catch {}
|
|
629
|
+
const processClassify = async (filename) => {
|
|
630
|
+
if (!filename.startsWith("classify-") || filename.startsWith("classify-response-"))
|
|
631
|
+
return;
|
|
632
|
+
const fp = path.join(dir, filename);
|
|
633
|
+
if (!fs.existsSync(fp))
|
|
634
|
+
return;
|
|
635
|
+
const respPath = path.join(dir, filename.replace("classify-", "classify-response-"));
|
|
636
|
+
if (fs.existsSync(respPath))
|
|
637
|
+
return;
|
|
638
|
+
let data;
|
|
639
|
+
try {
|
|
640
|
+
data = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
641
|
+
} catch {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (data?.type !== "classify" || !data?.note || !Array.isArray(data?.options))
|
|
645
|
+
return;
|
|
646
|
+
slog("classify watcher processing", data.id, data.note.slice(0, 80));
|
|
647
|
+
const byVal = new Map(data.options.map((o, i) => [o.value, i + 1]));
|
|
648
|
+
const start = Date.now();
|
|
649
|
+
let inferred = [];
|
|
650
|
+
let semanticCorrect;
|
|
651
|
+
let reason;
|
|
652
|
+
const llmRes = await llmClassify(client2, directory2, data.note, data.options, data.question, data.sessionID);
|
|
653
|
+
if (llmRes.inferred.length) {
|
|
654
|
+
inferred = llmRes.inferred;
|
|
655
|
+
semanticCorrect = llmRes.semanticCorrect;
|
|
656
|
+
reason = llmRes.reason;
|
|
657
|
+
slog("classify llm hit", data.id, llmRes.inferred.join(","), `semantic:${semanticCorrect} sid:${llmRes.sessionID || ""}`);
|
|
658
|
+
} else {
|
|
659
|
+
inferred = heuristicClassify(data.note, data.options);
|
|
660
|
+
if (inferred.length)
|
|
661
|
+
slog("classify heuristic hit", data.id, inferred.join(","));
|
|
662
|
+
else
|
|
663
|
+
slog("classify no match", data.id, `"${data.note.slice(0, 40)}"`);
|
|
664
|
+
}
|
|
665
|
+
const elapsed = Date.now() - start;
|
|
666
|
+
if (elapsed < 1200)
|
|
667
|
+
await new Promise((r) => setTimeout(r, 1200 - elapsed));
|
|
668
|
+
const inferredValues = inferred.map((i) => data.options[i - 1]?.value).filter(Boolean);
|
|
669
|
+
if (!inferred.length && data.note) {
|
|
670
|
+
const n = data.note.toLowerCase();
|
|
671
|
+
for (const o of data.options) {
|
|
672
|
+
const v = o.value ? String(o.value).toLowerCase() : "";
|
|
673
|
+
if (v && n.includes(v) && !inferred.includes(byVal.get(o.value))) {
|
|
674
|
+
const idx = byVal.get(o.value);
|
|
675
|
+
if (idx)
|
|
676
|
+
inferred.push(idx);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
slog("classify inferred", data.id, inferred.join(",") || "(none)", `semantic:${semanticCorrect} reason:${reason || ""} sid:${llmRes?.sessionID || ""} note:"${data.note.slice(0, 60)}"`);
|
|
681
|
+
const out = { id: data.id, inferredIndices: inferred, inferredValues, semanticCorrect, reason, classifySessionID: llmRes?.sessionID, note: data.note, at: Date.now() };
|
|
682
|
+
try {
|
|
683
|
+
fs.writeFileSync(respPath, JSON.stringify(out), "utf8");
|
|
684
|
+
slog("classify response written", data.id, inferred.join(","));
|
|
685
|
+
} catch {}
|
|
686
|
+
};
|
|
687
|
+
try {
|
|
688
|
+
for (const f of fs.readdirSync(dir).filter((f2) => f2.startsWith("classify-") && !f2.startsWith("classify-response-"))) {
|
|
689
|
+
processClassify(f);
|
|
690
|
+
}
|
|
691
|
+
} catch {}
|
|
692
|
+
try {
|
|
693
|
+
const w = fs.watch(dir, (_e, filename) => {
|
|
694
|
+
if (filename)
|
|
695
|
+
processClassify(filename);
|
|
696
|
+
});
|
|
697
|
+
w.on("error", () => {});
|
|
698
|
+
} catch {}
|
|
699
|
+
}
|
|
700
|
+
startClassifyWatcher(client, directory);
|
|
516
701
|
try {
|
|
517
702
|
const dir = pendingDir(directory);
|
|
518
703
|
if (fs.existsSync(dir)) {
|
|
519
|
-
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith("."))) {
|
|
704
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json") && !x.startsWith("response-") && !x.startsWith(".") && !x.startsWith("classify"))) {
|
|
520
705
|
try {
|
|
521
706
|
const j = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
522
707
|
if (j?.id && j?.sessionID) {
|
|
@@ -574,7 +759,7 @@ Explanation: ${j.explanation}${note}`;
|
|
|
574
759
|
config: async (output) => {
|
|
575
760
|
const agents = output.agent ?? {};
|
|
576
761
|
let mutated = false;
|
|
577
|
-
for (const name of ["researcher", "mermaid-maker", "svg-maker"]) {
|
|
762
|
+
for (const name of ["researcher", "mermaid-maker", "svg-maker", "classify"]) {
|
|
578
763
|
if (!agents[name]) {
|
|
579
764
|
agents[name] = { mode: "subagent", description: `${name} subagent (from learn plugin)`, permission: { "*": "allow" } };
|
|
580
765
|
mutated = true;
|
|
@@ -718,9 +903,13 @@ Explanation: ${j.explanation}${note}`;
|
|
|
718
903
|
shuffle: tool.schema.boolean().optional().describe("Default true: shuffle before display. False only if order matters.")
|
|
719
904
|
},
|
|
720
905
|
async execute(args, ctx) {
|
|
906
|
+
const qFixed = decodeQuizText(args.question) ?? args.question;
|
|
907
|
+
const dFixed = decodeQuizText(args.details);
|
|
908
|
+
const eFixed = decodeQuizText(args.explanation) ?? args.explanation;
|
|
909
|
+
const optsDecoded = args.options?.map((o) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description }));
|
|
721
910
|
let options;
|
|
722
911
|
try {
|
|
723
|
-
options = normalizeQuizOptions(
|
|
912
|
+
options = normalizeQuizOptions(optsDecoded);
|
|
724
913
|
} catch (e) {
|
|
725
914
|
return `quiz error: ${e.message}`;
|
|
726
915
|
}
|
|
@@ -744,11 +933,11 @@ Explanation: ${j.explanation}${note}`;
|
|
|
744
933
|
const payload = {
|
|
745
934
|
id,
|
|
746
935
|
type: "quiz",
|
|
747
|
-
question:
|
|
748
|
-
details:
|
|
936
|
+
question: qFixed,
|
|
937
|
+
details: dFixed,
|
|
749
938
|
options: options.map((o, i) => ({ label: o.label, value: o.value, description: o.description, index: i + 1 })),
|
|
750
939
|
correctIndices,
|
|
751
|
-
explanation:
|
|
940
|
+
explanation: eFixed,
|
|
752
941
|
multiSelect: !!args.multiSelect,
|
|
753
942
|
sessionID: ctx.sessionID,
|
|
754
943
|
timestamp: Date.now()
|
|
@@ -760,7 +949,7 @@ Explanation: ${j.explanation}${note}`;
|
|
|
760
949
|
slog("quiz write failed", String(e));
|
|
761
950
|
}
|
|
762
951
|
try {
|
|
763
|
-
await ctx.metadata?.({ title: `Quiz: ${
|
|
952
|
+
await ctx.metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { pendingId: id } });
|
|
764
953
|
} catch {}
|
|
765
954
|
watchAndInject(client, directory, id, ctx.sessionID, (r) => {
|
|
766
955
|
const dk = !!r?.dontKnow;
|
|
@@ -776,21 +965,21 @@ Note: ${r.note}` : "";
|
|
|
776
965
|
answers: r?.answers || [],
|
|
777
966
|
correct: ok,
|
|
778
967
|
correctIndices,
|
|
779
|
-
explanation:
|
|
968
|
+
explanation: eFixed,
|
|
780
969
|
dontKnow: dk,
|
|
781
970
|
note: r?.note
|
|
782
971
|
};
|
|
783
972
|
withMdLock(() => appendToMdLog(answerCalloutQuiz(details)));
|
|
784
973
|
}
|
|
785
|
-
return dk ? `[quiz answered] "${
|
|
974
|
+
return dk ? `[quiz answered] "${qFixed}" -> I don't know (genuine gap).
|
|
786
975
|
Correct: ${correctStr}
|
|
787
|
-
Explanation: ${
|
|
976
|
+
Explanation: ${eFixed}${note}` : `[quiz answered] "${qFixed}" -> ${sel} = ${ok ? "CORRECT" : "INCORRECT"}.
|
|
788
977
|
Correct: ${correctStr}
|
|
789
|
-
Explanation: ${
|
|
978
|
+
Explanation: ${eFixed}${note}`;
|
|
790
979
|
});
|
|
791
980
|
if (mdLogFile) {
|
|
792
981
|
try {
|
|
793
|
-
await withMdLock(() => appendToMdLog(questionCallout("Quiz",
|
|
982
|
+
await withMdLock(() => appendToMdLog(questionCallout("Quiz", qFixed, dFixed?.trim() || undefined, options.map((o) => ({ label: o.label })))));
|
|
794
983
|
} catch {}
|
|
795
984
|
}
|
|
796
985
|
if (tuiAlive) {
|
|
@@ -825,9 +1014,9 @@ ${args.multiSelect ? "Select all correct (comma-separated numbers, e.g. 1,3) or
|
|
|
825
1014
|
if (trimmed === "0" || trimmed.toLowerCase() === "i don't know") {
|
|
826
1015
|
const msg = `User selected "I don't know" \u2014 genuine gap, not a guess.
|
|
827
1016
|
Correct: ${correctStr}
|
|
828
|
-
Explanation: ${
|
|
1017
|
+
Explanation: ${eFixed}`;
|
|
829
1018
|
if (mdLogFile)
|
|
830
|
-
await withMdLock(() => appendToMdLog(callout("question", "Quiz \u2014 I don't know", [
|
|
1019
|
+
await withMdLock(() => appendToMdLog(callout("question", "Quiz \u2014 I don't know", [qFixed, trimmed, `Correct: ${correctStr}`, eFixed])));
|
|
831
1020
|
return msg;
|
|
832
1021
|
}
|
|
833
1022
|
const nums = trimmed.split(/[,\s]+/).map((s) => parseInt(s, 10)).filter((n) => !isNaN(n) && n >= 1 && n <= options.length);
|
|
@@ -839,30 +1028,30 @@ Explanation: ${args.explanation}`;
|
|
|
839
1028
|
const result = `User answered ${verdict}.
|
|
840
1029
|
Selected: ${selectedStr}
|
|
841
1030
|
Correct: ${correctStr}
|
|
842
|
-
Explanation: ${
|
|
843
|
-
ctx.metadata?.({ title: correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", metadata: { correct, correctIndices, explanation:
|
|
1031
|
+
Explanation: ${eFixed}`;
|
|
1032
|
+
ctx.metadata?.({ title: correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", metadata: { correct, correctIndices, explanation: eFixed } });
|
|
844
1033
|
if (mdLogFile)
|
|
845
|
-
await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", [`Q: ${
|
|
1034
|
+
await withMdLock(() => appendToMdLog(callout(correct ? "success" : "failure", correct ? "Quiz \u2014 correct \u2713" : "Quiz \u2014 incorrect \u2717", [`Q: ${qFixed}`, `Selected: ${selectedStr}`, `Correct: ${correctStr}`, eFixed])));
|
|
846
1035
|
return result;
|
|
847
1036
|
}
|
|
848
1037
|
const instruction = [
|
|
849
1038
|
`[quiz ready \u2014 awaiting user answer via \`question\` tool]`,
|
|
850
|
-
`Question: ${
|
|
851
|
-
|
|
1039
|
+
`Question: ${qFixed}`,
|
|
1040
|
+
dFixed ? `Details: ${dFixed}` : null,
|
|
852
1041
|
`Options (display order, already shuffled):`,
|
|
853
1042
|
...options.map((o, i) => `${i + 1}. ${o.label}${o.description ? ` \u2014 ${o.description}` : ""} (value="${o.value}")`),
|
|
854
1043
|
`Correct indices: ${correctIndices.join(", ")} (Correct values: ${correctStr})`,
|
|
855
|
-
`Explanation (reveal AFTER answer): ${
|
|
1044
|
+
`Explanation (reveal AFTER answer): ${eFixed}`,
|
|
856
1045
|
`Mode: ${args.multiSelect ? "multi-select (exact set)" : "single-select"}`,
|
|
857
1046
|
``,
|
|
858
1047
|
`INSTRUCTION FOR LLM: Call the built-in \`question\` tool with:`,
|
|
859
1048
|
` header: "Quiz"`,
|
|
860
|
-
` question: "${
|
|
1049
|
+
` question: "${qFixed.replace(/"/g, "\\\"")}"`,
|
|
861
1050
|
` options: [${options.map((o) => `{label:"${o.label.replace(/"/g, "\\\"")}", description:"${(o.description ?? "").replace(/"/g, "\\\"")}"}`).join(", ")}]`,
|
|
862
1051
|
`Then compare the user's selected labels to correct indices [${correctIndices.join(", ")}]. Grade as ${args.multiSelect ? "exact-set match" : "single match"}, show \u2713/\u2717, reveal Correct: ${correctStr}, and Explanation. An 'I don't know' maps to dontKnow (genuine gap).`
|
|
863
1052
|
].filter(Boolean).join(`
|
|
864
1053
|
`);
|
|
865
|
-
ctx.metadata?.({ title: `Quiz: ${
|
|
1054
|
+
ctx.metadata?.({ title: `Quiz: ${qFixed.slice(0, 40)}`, metadata: { correctIndices, explanation: eFixed, options: options.map((o, i) => ({ index: i + 1, label: o.label })) } });
|
|
866
1055
|
return instruction;
|
|
867
1056
|
}
|
|
868
1057
|
}),
|
|
@@ -890,23 +1079,27 @@ Explanation: ${args.explanation}`;
|
|
|
890
1079
|
slog("quiz_batch isAlive", isAlive);
|
|
891
1080
|
const normalized = [];
|
|
892
1081
|
for (const q of args.quizzes) {
|
|
1082
|
+
const qFixed = decodeQuizText(q.question) ?? q.question;
|
|
1083
|
+
const dFixed = decodeQuizText(q.details);
|
|
1084
|
+
const eFixed = decodeQuizText(q.explanation) ?? q.explanation;
|
|
1085
|
+
const optsDecoded = q.options?.map((o) => ({ ...o, label: decodeQuizText(o.label) ?? o.label, description: o.description ? decodeQuizText(o.description) : o.description }));
|
|
893
1086
|
let opts;
|
|
894
1087
|
try {
|
|
895
|
-
opts = normalizeQuizOptions(
|
|
1088
|
+
opts = normalizeQuizOptions(optsDecoded);
|
|
896
1089
|
} catch (e) {
|
|
897
1090
|
slog("quiz_batch normalize error", e.message);
|
|
898
|
-
return `quiz_batch error: ${e.message} in "${
|
|
1091
|
+
return `quiz_batch error: ${e.message} in "${qFixed}"`;
|
|
899
1092
|
}
|
|
900
1093
|
if (q.shuffle !== false)
|
|
901
1094
|
opts = shuffleOptions(opts);
|
|
902
1095
|
const { indices, error } = resolveCorrect(q.correctAnswer, opts);
|
|
903
1096
|
if (error) {
|
|
904
1097
|
slog("quiz_batch resolveCorrect error", error);
|
|
905
|
-
return `quiz_batch error: ${error} in "${
|
|
1098
|
+
return `quiz_batch error: ${error} in "${qFixed}"`;
|
|
906
1099
|
}
|
|
907
1100
|
if (opts.length < 2)
|
|
908
|
-
return `quiz_batch error: need 2+ options in "${
|
|
909
|
-
normalized.push({ question:
|
|
1101
|
+
return `quiz_batch error: need 2+ options in "${qFixed}"`;
|
|
1102
|
+
normalized.push({ question: qFixed, details: dFixed, options: opts, correctIndices: indices, explanation: eFixed, multiSelect: !!q.multiSelect });
|
|
910
1103
|
}
|
|
911
1104
|
slog("quiz_batch normalized", normalized.length);
|
|
912
1105
|
try {
|