leetcode-capture-helper 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +420 -0
- package/install.js +290 -0
- package/package.json +31 -4
- package/runtime/bundle.json +42 -0
- package/runtime/companion/capture-log.js +83 -0
- package/runtime/companion/codex-backend.js +337 -0
- package/runtime/companion/companion.js +2190 -0
- package/runtime/companion/mouse-input.js +154 -0
- package/runtime/companion/package.json +4 -0
- package/runtime/companion/panel-bridge.js +273 -0
- package/runtime/companion/settings-rpc.js +228 -0
- package/runtime/companion/settings.js +464 -0
- package/runtime/companion/terminal-format.js +330 -0
- package/runtime/companion/vault-summary.js +168 -0
- package/runtime/companion/virtual-terminal.js +138 -0
- package/runtime/extension/shared/anthropic-backend.js +193 -0
- package/runtime/extension/shared/catalog.js +415 -0
- package/runtime/extension/shared/code-ledger.js +333 -0
- package/runtime/extension/shared/context-budget.js +259 -0
- package/runtime/extension/shared/context-summary.js +271 -0
- package/runtime/extension/shared/conversation.js +81 -0
- package/runtime/extension/shared/event-hub.js +126 -0
- package/runtime/extension/shared/extension-settings.js +445 -0
- package/runtime/extension/shared/helper-launcher.js +248 -0
- package/runtime/extension/shared/local-backend.js +706 -0
- package/runtime/extension/shared/local-server.js +149 -0
- package/runtime/extension/shared/package.json +4 -0
- package/runtime/extension/shared/practice.js +228 -0
- package/runtime/extension/shared/problem-index.js +4128 -0
- package/runtime/extension/shared/profiles.js +106 -0
- package/runtime/extension/shared/providers.js +58 -0
- package/runtime/extension/shared/reply-guard.js +864 -0
- package/runtime/extension/shared/roadmap.js +1337 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +316 -0
- package/runtime/extension/shared/tutor-engine.js +369 -0
- package/runtime/extension/shared/tutor-prompt.js +108 -0
- package/runtime/extension/shared/tutor-service.js +520 -0
- package/runtime/relay-server/package.json +4 -0
- package/runtime/relay-server/panel-broker.js +797 -0
- package/runtime/relay-server/server.js +241 -0
- package/runtime/vault-tool/log-session.js +230 -0
- package/runtime/vault-tool/package.json +4 -0
- package/runtime/vault-tool/vault-notes.js +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
// extension/shared/reply-guard.js - the post-check guard for the tutor's
|
|
2
|
+
// prompt rules, shared by every runtime: the extension's own engine
|
|
3
|
+
// (tutor-engine.js) and the companion (companion.js) run every tutor turn
|
|
4
|
+
// through `guardedSend`, so a reply that breaks a rule the code can check is
|
|
5
|
+
// fixed before the student sees it, the same way in both.
|
|
6
|
+
//
|
|
7
|
+
// A local model ignores some rules some of the time (a first hint that names
|
|
8
|
+
// the data structure, a "better way" answer with a whole solution, an em
|
|
9
|
+
// dash). The guard does not judge quality; it only enforces what is
|
|
10
|
+
// mechanically checkable, per kind of turn (see `classifyMessage`):
|
|
11
|
+
// - every turn: em dashes and spaced en dashes become commas, emoji go,
|
|
12
|
+
// Unicode arrows become ASCII (outside code), "repairs";
|
|
13
|
+
// - a hint or an "I'm stuck" turn is held back (the panel shows the typing
|
|
14
|
+
// dots, nothing streams) and checked whole; a broken one is re-asked
|
|
15
|
+
// once with the rule it broke, and if that fails too, the offending
|
|
16
|
+
// sentences are removed or a safe fallback stands in;
|
|
17
|
+
// - every other checked turn streams sentence by sentence, each sentence
|
|
18
|
+
// (and each code block, whole) checked before it is released; one that
|
|
19
|
+
// breaks a rule is dropped and never shown, so nothing on screen is ever
|
|
20
|
+
// replaced;
|
|
21
|
+
// - a turn the student asked to be given everything in (the full answer,
|
|
22
|
+
// code, a fix) streams word by word with only the repairs.
|
|
23
|
+
// The rejected reply never stays in the backend's history: the guard hands
|
|
24
|
+
// the final text to `replaceLastReply`.
|
|
25
|
+
//
|
|
26
|
+
// Pure JavaScript with no Node API (extension/shared.test.js checks that).
|
|
27
|
+
|
|
28
|
+
// ---- limits ------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
// A first hint is "the smallest nudge, one question"; past these it is not.
|
|
31
|
+
export const FIRST_HINT_MAX_CHARS = 450;
|
|
32
|
+
export const LATER_HINT_MAX_CHARS = 900;
|
|
33
|
+
export const STUCK_MAX_CHARS = 450;
|
|
34
|
+
// A held reply this far past its limit is stopped early (the backend aborts
|
|
35
|
+
// the request), since it will be re-asked anyway: no point paying for the
|
|
36
|
+
// rest of a runaway local generation.
|
|
37
|
+
export const EARLY_STOP_FACTOR = 1.6;
|
|
38
|
+
|
|
39
|
+
// What stands in when a held reply and its one re-ask both break a rule and
|
|
40
|
+
// nothing safe is left of either.
|
|
41
|
+
export const FALLBACK_TEXT = {
|
|
42
|
+
hint: 'Pick a small input and trace your code on it by hand, one step at a time. What do you notice about what it does at each step?',
|
|
43
|
+
laterHint: 'Go back to the step from the last hint and trace just that part of your code on a small input. What happens there?',
|
|
44
|
+
stuck: 'What have you tried so far, and which step is unclear? I can give you a small hint if you like.',
|
|
45
|
+
other: 'I could not answer that within my tutoring rules. Could you ask it another way?',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ---- turn kinds ----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const ANSWER_RE = /\b(?:full|complete|whole|entire|working)\s+(?:solution|answer|code|implementation)\b|\b(?:give|show|tell)\s+me\s+(?:the\s+)?(?:answer|solution)\b|\bjust\s+(?:give|show|tell)\s+me\b|\bsolve\s+it\s+for\s+me\b|\bi\s+give\s+up\b|\bsoluci\u00F3n\s+completa\b/i;
|
|
51
|
+
const HINT_RE = /\bhints?\b|\bnudge\b|\bclue\b|\bpista\b/i;
|
|
52
|
+
const STUCK_RE = /\b(?:i'?m|i\s+am|im)\s+(?:so\s+|really\s+|totally\s+|completely\s+)?stuck\b|\bno\s+idea\s+(?:how|where|what)\b|\bdon'?t\s+know\s+(?:how|where|what)\s+to\s+(?:start|begin|do|go)\b/i;
|
|
53
|
+
const ALTERNATIVES_RE = /\b(?:better|faster|other|another|different|alternative|more\s+efficient|cleaner|optimal)\s+(?:way|ways|approach|approaches|method|methods|solution|solutions|algorithm|algorithms)\b|\balternatives?\b|\boptimi[sz]e\b/i;
|
|
54
|
+
const CODE_REQUEST_RE = /\b(?:show|give|write|send|share|post)\s+(?:me\s+)?(?:the\s+|some\s+|working\s+)?(?:code|implementation|pseudo-?code)\b|\b(?:with|in|as)\s+(?:the\s+)?(?:code|pseudo-?code)\b|\bcode\s+(?:it|this|that)\b|\bimplement\s+(?:it|this|that)\b|\bwrite\s+(?:it|this|that)\s+(?:out|for\s+me)\b|\bpseudo-?code\b/i;
|
|
55
|
+
const FIX_REQUEST_RE = /\b(?:fix|repair|correct\s+(?:it|this|that|my)|how\s+(?:do|can|should|would)\s+i\s+(?:change|correct|make))\b/i;
|
|
56
|
+
|
|
57
|
+
// What kind of turn the student's own words ask for. `trigger` 'submit' is a
|
|
58
|
+
// Submit review; `hintsGiven` counts earlier hint turns on this problem.
|
|
59
|
+
// Returns { kind, hintNumber? } with kind one of 'submit', 'answer', 'hint',
|
|
60
|
+
// 'stuck', 'alternatives', 'chat'.
|
|
61
|
+
export function classifyMessage(text, { trigger = null, hintsGiven = 0 } = {}) {
|
|
62
|
+
if (trigger === 'submit') return { kind: 'submit' };
|
|
63
|
+
const value = typeof text === 'string' ? text : '';
|
|
64
|
+
if (ANSWER_RE.test(value)) return { kind: 'answer' };
|
|
65
|
+
if (HINT_RE.test(value)) return { kind: 'hint', hintNumber: hintsGiven + 1 };
|
|
66
|
+
const alternatives = ALTERNATIVES_RE.test(value);
|
|
67
|
+
if (alternatives) return { kind: CODE_REQUEST_RE.test(value) ? 'answer' : 'alternatives' };
|
|
68
|
+
if (CODE_REQUEST_RE.test(value) || FIX_REQUEST_RE.test(value)) return { kind: 'answer' };
|
|
69
|
+
if (STUCK_RE.test(value) && !value.includes('?')) return { kind: 'stuck' };
|
|
70
|
+
return { kind: 'chat' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- techniques ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
// Methods and data structures the tutor must not volunteer. A group counts as
|
|
76
|
+
// named once the student wrote any of its terms, its code shows one of its
|
|
77
|
+
// signals, or a reply the student already saw mentioned it.
|
|
78
|
+
const TECHNIQUES = [
|
|
79
|
+
{ id: 'hash-map', terms: /\bhash\s*(?:map|table|maps|tables|ing)\b|\bhashmaps?\b|\bdictionar(?:y|ies)\b|\bdicts?\b|\blookup\s+table\b/i, signals: /\bdict\s*\(|\bdefaultdict\b|\bCounter\s*\(|=\s*\{\s*\}|\bHashMap\b|\bunordered_map\b|\bnew\s+Map\b|\bMap\s*<|\bmap\s*\[|\bmake\s*\(\s*map\b|\bdictionary\b|\bhash\b/i },
|
|
80
|
+
{ id: 'hash-set', terms: /\bhash\s*sets?\b|\bhashsets?\b/i, signals: /\bset\s*\(|\bHashSet\b|\bunordered_set\b|\bnew\s+Set\b|\bSet\s*<|=\s*\{\s*\w[^:}]*\}/i },
|
|
81
|
+
{ id: 'heap', terms: /\b(?:min-?|max-?)?heaps?\b|\bpriority\s+queues?\b/i, signals: /\bheapq\b|\bPriorityQueue\b|\bpriority_queue\b|\bheap\b/i },
|
|
82
|
+
{ id: 'stack', terms: /\bmonotonic\s+(?:stack|queue)\b|\b(?:an?|explicit|own)\s+stack\b|\bstack-based\b|\buse\s+a\s+stack\b/i, signals: /\bstack\b/i },
|
|
83
|
+
{ id: 'queue', terms: /\b(?:a|explicit)\s+queue\b|\bdeque\b/i, signals: /\bdeque\b|\bqueue\b|\bQueue\b/i },
|
|
84
|
+
{ id: 'trie', terms: /\btries\b|\btrie\b|\bprefix\s+tree\b/i, signals: /\btrie\b/i },
|
|
85
|
+
{ id: 'union-find', terms: /\bunion[\s-]*find\b|\bdisjoint[\s-]*set\b|\bdsu\b/i, signals: /\bunion\s*\(|\bfind\s*\(|\bparent\s*\[/i },
|
|
86
|
+
{ id: 'tree-index', terms: /\bsegment\s+trees?\b|\bfenwick\b|\bbinary\s+indexed\s+trees?\b/i, signals: /\bsegment\b|\bfenwick\b|\bbit\s*\[/i },
|
|
87
|
+
{ id: 'binary-search', terms: /\bbinary\s+search\b|\bbisect\w*\b/i, signals: /\bbisect\b|\bmid\b|\blo\s*\+\s*hi\b|\bleft\s*\+\s*right\b/i },
|
|
88
|
+
{ id: 'two-pointers', terms: /\btwo[\s-]+pointers?\b/i, signals: /\b(?:left|lo|l)\b[\s\S]*\b(?:right|hi|r)\b/ },
|
|
89
|
+
{ id: 'sliding-window', terms: /\bsliding[\s-]+windows?\b/i, signals: /\bwindow\b/i },
|
|
90
|
+
{ id: 'prefix-sum', terms: /\bprefix[\s-]+sums?\b|\bcumulative\s+sums?\b|\brunning\s+sums?\b/i, signals: /\bprefix\b|\bcumsum\b|\baccumulate\b/i },
|
|
91
|
+
{ id: 'dynamic-programming', terms: /\bdynamic\s+programming\b|\bmemoi[sz]\w*\b|\bdp\s+(?:table|array)\b|\btabulation\b|\b(?:top-down|bottom-up)\b/i, signals: /\bdp\b|\bmemo\b|\blru_cache\b|\bcache\b/i },
|
|
92
|
+
{ id: 'bfs', terms: /\bbfs\b|\bbreadth[\s-]+first\b|\blevel[\s-]+order\b/i, signals: /\bdeque\b|\bqueue\b|\bpopleft\b/i },
|
|
93
|
+
{ id: 'dfs', terms: /\bdfs\b|\bdepth[\s-]+first\b/i, signals: null },
|
|
94
|
+
{ id: 'iterative', terms: /\biterative(?:ly)?\s+(?:version|approach|solution|dfs|bfs|traversal|implementation)\b|\bexplicit\s+stack\b/i, signals: null },
|
|
95
|
+
{ id: 'graph-search', terms: /\btopological\s+sort\w*\b|\bdijkstra\w*\b|\bbellman[\s-]+ford\b|\bkruskal\w*\b|\bprim'?s\b|\bkahn'?s\b/i, signals: /\bindegree\b|\bdist\b|\bheapq\b/i },
|
|
96
|
+
{ id: 'greedy', terms: /\bgreedy\b|\bgreedily\b/i, signals: null },
|
|
97
|
+
{ id: 'backtracking', terms: /\bbacktrack\w*\b/i, signals: null },
|
|
98
|
+
{ id: 'bits', terms: /\bbit\s*(?:manipulation|mask|masks|masking|wise)\b|\bbitmasks?\b|\bxor\b/i, signals: /\^|<<|>>|\bxor\b/i },
|
|
99
|
+
{ id: 'sorting', terms: /\bsort(?:ing)?\s+(?:the|it|them|your|both|first|array|list|input|numbers|values)\b|\bsorted\s+order\b|\bafter\s+sorting\b/i, signals: /\bsort\s*\(|\bsorted\s*\(|\.sort\b|\bArrays\.sort\b/i },
|
|
100
|
+
{ id: 'recursion', terms: /\brecursion\b|\brecursive(?:ly)?\b/i, signals: null },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Whether `code` calls a function it defines from inside (recursion), which
|
|
104
|
+
// makes DFS and recursion the student's own ideas.
|
|
105
|
+
function usesRecursion(code) {
|
|
106
|
+
const names = new Set();
|
|
107
|
+
for (const match of code.matchAll(/\b(?:def|function|func|fn)\s+([A-Za-z_]\w*)\s*\(/g)) names.add(match[1]);
|
|
108
|
+
for (const match of code.matchAll(/\b(?:int|void|bool|boolean|long|double|auto|string|String|List<[^>]*>|vector<[^>]*>)\s+([A-Za-z_]\w*)\s*\([^)]*\)\s*\{/g)) names.add(match[1]);
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
const calls = code.match(new RegExp(`\\b${name}\\s*\\(`, 'g')) || [];
|
|
111
|
+
if (calls.length >= 2) return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Text as the patterns read it: the non-breaking and other Unicode hyphens a
|
|
117
|
+
// model writes ("depth\u2011first") become '-', odd spaces become ' '.
|
|
118
|
+
export function matchable(text) {
|
|
119
|
+
return String(text || '').replace(/[\u2010-\u2012]/g, '-').replace(/[\u00A0\u2007\u202F]/g, ' ');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The technique groups `material` (student words, code, replies already
|
|
123
|
+
// shown) has named, as a Set of ids.
|
|
124
|
+
export function namedTechniques({ prose: rawProse = '', code = '' } = {}) {
|
|
125
|
+
const prose = matchable(rawProse);
|
|
126
|
+
const named = new Set();
|
|
127
|
+
for (const technique of TECHNIQUES) {
|
|
128
|
+
if (technique.terms.test(prose) || technique.terms.test(code)) named.add(technique.id);
|
|
129
|
+
else if (technique.signals && code && technique.signals.test(code)) named.add(technique.id);
|
|
130
|
+
}
|
|
131
|
+
if (code && usesRecursion(code)) {
|
|
132
|
+
named.add('dfs');
|
|
133
|
+
named.add('recursion');
|
|
134
|
+
}
|
|
135
|
+
// Talking about a recursive DFS makes recursion named, and the reverse.
|
|
136
|
+
if (named.has('dfs')) named.add('recursion');
|
|
137
|
+
if (named.has('recursion')) named.add('dfs');
|
|
138
|
+
if (named.has('bfs')) named.add('queue');
|
|
139
|
+
return named;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The technique terms in `text` whose group is not in `named`, as the
|
|
143
|
+
// matched words (first match per group).
|
|
144
|
+
export function unnamedTechniques(rawText, named) {
|
|
145
|
+
const text = matchable(rawText);
|
|
146
|
+
const found = [];
|
|
147
|
+
for (const technique of TECHNIQUES) {
|
|
148
|
+
if (named.has(technique.id)) continue;
|
|
149
|
+
const match = text.match(technique.terms);
|
|
150
|
+
if (match) found.push(match[0]);
|
|
151
|
+
}
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Technique groups whose code signals show up in `code` but not in `named`:
|
|
156
|
+
// a code block that brings in a structure the student never used.
|
|
157
|
+
function unnamedSignals(code, named) {
|
|
158
|
+
const found = [];
|
|
159
|
+
for (const technique of TECHNIQUES) {
|
|
160
|
+
if (named.has(technique.id) || !technique.signals) continue;
|
|
161
|
+
// Too loose to tell anything from a code block on their own.
|
|
162
|
+
if (technique.id === 'two-pointers' || technique.id === 'binary-search' || technique.id === 'union-find') continue;
|
|
163
|
+
const match = code.match(technique.signals);
|
|
164
|
+
if (match) found.push(match[0]);
|
|
165
|
+
}
|
|
166
|
+
return found;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- patterns ----------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
const ANY_COMPLEXITY_RE = /\bO\s*\(|\b(?:linear|constant|logarithmic|quadratic|polynomial)[\s-]+(?:time|space|lookups?)\b|\b(?:one|single)[\s-]+pass\b|\bin\s+one\s+go\b|\bsub-?quadratic\b/i;
|
|
172
|
+
const TARGET_COMPLEXITY_RE = /\b(?:possible|achievable|achieve|target|optimal|reduce[sd]?|down\s+to|improve[sd]?|bring\s+(?:it\s+)?down|can\s+be\s+done\s+in|could\s+be\s+done\s+in|faster|better)\b[^.!?\n]*\bO\s*\(|\bO\s*\([^)]*\)[^.!?\n]*\b(?:is\s+possible|is\s+achievable|is\s+optimal|is\s+the\s+best|would\s+be\s+(?:better|faster))\b|\b(?:in|with)\s+(?:a\s+)?(?:one|single)[\s-]+pass\b|\bconstant[\s-]+time\s+lookups?\b/i;
|
|
173
|
+
const EDGE_CASE_RE = /\bedge[\s-]+cases?\b|\bcorner[\s-]+cases?\b|\b(?:no|zero)\s+(?:two\s+numbers|pairs?|solutions?|valid\s+(?:pairs?|answers?))\b|\bempty\s+(?:list|array|input|grid|string)\b|\bif\s+(?:there\s+(?:is|are)\s+)?no\s+(?:two|pair|answer|solution)\b|\b(?:does\s+not|doesn'?t)\s+exist\b|\bduplicates?\b|\bnegative\s+numbers?\b/i;
|
|
174
|
+
const STUCK_QUESTION_RE = /\b(?:tried|try|attempted|so\s+far|unclear|confus\w*|stuck|which\s+(?:step|part)|what\s+part|walk\s+me\s+through)\b/i;
|
|
175
|
+
const IMPROVE_RE = /\b(?:improv\w*|optimi[sz]\w*|faster|more\s+efficient(?:ly)?|efficiency|speed\s+(?:it\s+)?up|better\s+(?:method|approach|way|algorithm|solution|complexity))\b/i;
|
|
176
|
+
const VERDICT_RE = /\b(?:accepted|would\s+pass|will\s+pass|should\s+pass|passes\s+all\s+(?:the\s+)?(?:tests?|test\s+cases|cases)|time\s+limit\s+exceeded|wrong\s+answer\s+verdict)\b/i;
|
|
177
|
+
const OPTIMAL_RE = /\b(?:optimal\s+(?:solution|approach|complexity)|(?:a\s+)?(?:better|more\s+efficient|faster)\s+(?:approach|solution|algorithm|method|way)\s+(?:would|is|exists|could)|you\s+could\s+(?:instead\s+)?(?:use|switch)|consider\s+using|switch(?:ing)?\s+to)\b/i;
|
|
178
|
+
const REPAIR_RE = /\b(?:to\s+fix\s+(?:this|it|that|the)|the\s+fix\s+is|fix(?:ed)?\s+(?:it|this)\s+by|change\s+(?:the|your|it|this|that|line)\b[^.!?\n]{0,80}\bto\b|should\s+(?:start|begin|instead|be\s+changed)|start\s+(?:the\s+inner\s+loop|j)\s+at|replace\b[^.!?\n]{0,60}\bwith\b|instead\s+of\b[^.!?\n]{0,60}\buse\b|you\s+(?:need|have)\s+to\s+(?:change|add|start|use)|(?:a|the)\s+corrected\s+version)\b/i;
|
|
179
|
+
// Lines that make a block program code: definitions, control flow,
|
|
180
|
+
// statements. A plain assignment of a literal (`nums = [1, 0, 2]`) or a call
|
|
181
|
+
// (`count_islands(grid)`) is an example input, not code.
|
|
182
|
+
const STRUCTURE_LINE_RE = /^\s*(?:def\s|class\s|return\b|for\s+\w+(?:\s*,\s*\w+)*\s+in\s|for\s*\(|while\s*[(\w]|if\s*\(|if\s.+:\s*$|elif\s|else\s*[:{]|else\s+if\b|function\b|public\s|private\s|static\s|void\s|#include|import\s|from\s+\w+\s+import|[{}]\s*;?\s*$|(?:const|let|var|int|long|bool|auto)\s+\w+\s*=.*;\s*$|\w+(?:\[[^\]]*\])*\s*(?:\+|-|\*|\/)=)/;
|
|
183
|
+
const CODE_LANGS = /^(?:python3?|py|java|cpp|c\+\+|c|cs|csharp|c#|js|javascript|ts|typescript|go|golang|rust|rs|kotlin|kt|swift|ruby|rb|php|scala|dart|sql|mysql)$/i;
|
|
184
|
+
const PLAN_ITEM_RE = /^\s*(?:\d+[.)]|[-*+]|step\s+\d+[:.])\s+\S/gim;
|
|
185
|
+
|
|
186
|
+
// Whether a fenced block is program code (rather than a diagram, a trace or
|
|
187
|
+
// an example input).
|
|
188
|
+
export function isCodeBlock(info, body) {
|
|
189
|
+
const lines = body.split('\n').filter((line) => line.trim());
|
|
190
|
+
const structural = lines.filter((line) => STRUCTURE_LINE_RE.test(line)).length;
|
|
191
|
+
if (CODE_LANGS.test((info || '').trim().split(/\s+/)[0] || '')) return structural >= 1;
|
|
192
|
+
return structural >= 2;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizeLine(line) {
|
|
196
|
+
return line.replace(/\s+/g, ' ').trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Whether every non-empty line of `body` is a line of the student's code
|
|
200
|
+
// (quoting their own code is always fine).
|
|
201
|
+
function quotesStudentCode(body, code) {
|
|
202
|
+
if (!code) return false;
|
|
203
|
+
const own = new Set(code.split('\n').map(normalizeLine).filter(Boolean));
|
|
204
|
+
const lines = body.split('\n').map(normalizeLine).filter(Boolean);
|
|
205
|
+
return lines.length > 0 && lines.every((line) => own.has(line));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Inline code spans that look like code and are not quoted from the
|
|
209
|
+
// student's code.
|
|
210
|
+
function foreignInlineCode(text, code) {
|
|
211
|
+
const found = [];
|
|
212
|
+
const ownCode = normalizeLine(code || '');
|
|
213
|
+
for (const match of text.matchAll(/`([^`\n]+)`/g)) {
|
|
214
|
+
const span = match[1].trim();
|
|
215
|
+
if (!/[()=[\];{}]|\bfor\b|\bwhile\b|\breturn\b/.test(span)) continue;
|
|
216
|
+
if (ownCode.includes(normalizeLine(span))) continue;
|
|
217
|
+
found.push(span);
|
|
218
|
+
}
|
|
219
|
+
return found;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- repairs -----------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
// Fixes what never needs a re-ask, in text that is not code: an em dash or a
|
|
225
|
+
// spaced en dash becomes a comma, emoji are removed, Unicode arrows become
|
|
226
|
+
// ASCII. `text` must hold no fence (the gate repairs prose units only);
|
|
227
|
+
// inline code spans are left alone.
|
|
228
|
+
export function repairProse(text) {
|
|
229
|
+
return text
|
|
230
|
+
.split(/(`[^`\n]*`)/)
|
|
231
|
+
.map((part, index) => {
|
|
232
|
+
if (index % 2 === 1) return part;
|
|
233
|
+
return part
|
|
234
|
+
.replace(/[ \t\u00a0\u202f]*\u2014[ \t\u00a0\u202f]*/g, ', ')
|
|
235
|
+
.replace(/([A-Za-z.,)'"\u2019\u201d]{2,})[ \t\u00a0\u202f]+\u2013[ \t\u00a0\u202f]+(?=[A-Za-z(\u2018\u201c])/g, '$1, ')
|
|
236
|
+
.replace(/,(\s*,)+/g, ',')
|
|
237
|
+
.replace(/,\s*([.!?])/g, '$1')
|
|
238
|
+
.replace(/(?![\u00A9\u00AE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA])\p{Extended_Pictographic}\uFE0F?[ \t]?/gu, '')
|
|
239
|
+
.replace(/\u2192|\u27F6|\u279C|\u2794/g, '->')
|
|
240
|
+
.replace(/\u2190|\u27F5/g, '<-')
|
|
241
|
+
.replace(/\u21D2|\u27F9/g, '=>')
|
|
242
|
+
.replace(/\u2194|\u27F7/g, '<->');
|
|
243
|
+
})
|
|
244
|
+
.join('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A fenced block as shown: program code is never touched; in a trace or a
|
|
248
|
+
// diagram, a Unicode arrow becomes ASCII only on a line with no box drawing
|
|
249
|
+
// or pipe, where the extra character cannot break an alignment.
|
|
250
|
+
export function repairBlock(unit) {
|
|
251
|
+
if (isCodeBlock(unit.info, unit.body)) return unit.text;
|
|
252
|
+
return unit.text
|
|
253
|
+
.split('\n')
|
|
254
|
+
.map((line) => (/[\u2500-\u257F|]/.test(line) ? line : line
|
|
255
|
+
.replace(/\u2192|\u27F6|\u279C|\u2794/g, '->')
|
|
256
|
+
.replace(/\u2190|\u27F5/g, '<-')
|
|
257
|
+
.replace(/\u21D2|\u27F9/g, '=>')))
|
|
258
|
+
.join('\n');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- policies ----------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
// What a turn of each kind may and may not contain. `mode` is how the gate
|
|
264
|
+
// releases text: 'hold' (all at the end, re-asked when broken), 'sentence'
|
|
265
|
+
// (checked unit by unit, broken units dropped) or 'word' (repairs only).
|
|
266
|
+
export function turnPolicy(kindInfo) {
|
|
267
|
+
const { kind, hintNumber = 1 } = kindInfo;
|
|
268
|
+
if (kind === 'hint') {
|
|
269
|
+
const first = hintNumber <= 1;
|
|
270
|
+
return {
|
|
271
|
+
kind,
|
|
272
|
+
hintNumber,
|
|
273
|
+
mode: 'hold',
|
|
274
|
+
maxChars: first ? FIRST_HINT_MAX_CHARS : LATER_HINT_MAX_CHARS,
|
|
275
|
+
noFence: first ? 'any' : 'code',
|
|
276
|
+
noForeignInlineCode: true,
|
|
277
|
+
noTechniques: first,
|
|
278
|
+
noComplexity: first ? 'any' : 'target',
|
|
279
|
+
noPlan: !first,
|
|
280
|
+
needsQuestion: false,
|
|
281
|
+
noImprove: first,
|
|
282
|
+
noEdgeCase: first,
|
|
283
|
+
fallback: first ? FALLBACK_TEXT.hint : FALLBACK_TEXT.laterHint,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (kind === 'stuck') {
|
|
287
|
+
return {
|
|
288
|
+
kind,
|
|
289
|
+
mode: 'hold',
|
|
290
|
+
maxChars: STUCK_MAX_CHARS,
|
|
291
|
+
noFence: 'any',
|
|
292
|
+
noForeignInlineCode: true,
|
|
293
|
+
noTechniques: true,
|
|
294
|
+
noComplexity: 'any',
|
|
295
|
+
noPlan: true,
|
|
296
|
+
needsQuestion: true,
|
|
297
|
+
noImprove: true,
|
|
298
|
+
fallback: FALLBACK_TEXT.stuck,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (kind === 'alternatives') {
|
|
302
|
+
return { kind, mode: 'sentence', dropFences: 'code', fallback: FALLBACK_TEXT.other };
|
|
303
|
+
}
|
|
304
|
+
if (kind === 'chat') {
|
|
305
|
+
return { kind, mode: 'sentence', dropFences: 'foreign', dropTechniques: true, dropTargets: true, dropRepairs: true, fallback: FALLBACK_TEXT.other };
|
|
306
|
+
}
|
|
307
|
+
if (kind === 'submit') {
|
|
308
|
+
return { kind, mode: 'sentence', dropFences: 'foreign', dropTechniques: true, dropTargets: true, dropVerdicts: true, fallback: FALLBACK_TEXT.other };
|
|
309
|
+
}
|
|
310
|
+
return { kind: 'answer', mode: 'word', fallback: FALLBACK_TEXT.other };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- checks ------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
// Splits a whole reply into fenced blocks and prose sentences, in order:
|
|
316
|
+
// [{ type: 'fence', text, info, body } | { type: 'prose', text }]. Joining
|
|
317
|
+
// every unit's text gives the reply back.
|
|
318
|
+
export function splitUnits(text) {
|
|
319
|
+
const gate = createGate({ mode: 'sentence' }, { named: new Set(), code: '' }, { collect: true });
|
|
320
|
+
gate.push(text);
|
|
321
|
+
gate.finish();
|
|
322
|
+
return gate.units;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The rule a held reply breaks, as violations [{ rule, detail }], for the
|
|
326
|
+
// whole text. `context` is { named, code }.
|
|
327
|
+
export function checkHeld(text, policy, context) {
|
|
328
|
+
const violations = [];
|
|
329
|
+
const units = splitUnits(text);
|
|
330
|
+
const prose = matchable(units.filter((unit) => unit.type === 'prose').map((unit) => unit.text).join(''));
|
|
331
|
+
for (const unit of units) {
|
|
332
|
+
if (unit.type !== 'fence') continue;
|
|
333
|
+
if (policy.noFence === 'any' || (policy.noFence === 'code' && isCodeBlock(unit.info, unit.body) && !quotesStudentCode(unit.body, context.code))) {
|
|
334
|
+
violations.push({ rule: 'fence' });
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (policy.noForeignInlineCode) {
|
|
339
|
+
const spans = foreignInlineCode(prose, context.code);
|
|
340
|
+
if (spans.length) violations.push({ rule: 'inline-code', detail: spans[0] });
|
|
341
|
+
}
|
|
342
|
+
if (policy.noTechniques) {
|
|
343
|
+
const terms = unnamedTechniques(prose, context.named);
|
|
344
|
+
if (terms.length) violations.push({ rule: 'technique', detail: terms.join(', ') });
|
|
345
|
+
}
|
|
346
|
+
if (policy.noComplexity === 'any') {
|
|
347
|
+
const match = prose.match(ANY_COMPLEXITY_RE);
|
|
348
|
+
if (match) violations.push({ rule: 'complexity', detail: match[0] });
|
|
349
|
+
} else if (policy.noComplexity === 'target') {
|
|
350
|
+
const match = prose.match(TARGET_COMPLEXITY_RE);
|
|
351
|
+
if (match) violations.push({ rule: 'complexity', detail: match[0] });
|
|
352
|
+
}
|
|
353
|
+
if (policy.noImprove) {
|
|
354
|
+
const match = prose.match(IMPROVE_RE);
|
|
355
|
+
if (match) violations.push({ rule: 'improve', detail: match[0] });
|
|
356
|
+
}
|
|
357
|
+
if (policy.noEdgeCase) {
|
|
358
|
+
const match = prose.match(EDGE_CASE_RE);
|
|
359
|
+
if (match) violations.push({ rule: 'edge-case', detail: match[0] });
|
|
360
|
+
}
|
|
361
|
+
if (policy.noPlan && (prose.match(PLAN_ITEM_RE) || []).length >= 3) violations.push({ rule: 'plan' });
|
|
362
|
+
if (policy.needsQuestion && !askedWhatTried(prose)) violations.push({ rule: 'question' });
|
|
363
|
+
if (policy.maxChars && text.trim().length > policy.maxChars) violations.push({ rule: 'length' });
|
|
364
|
+
return violations;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Whether some question in `prose` asks what the student tried or which step
|
|
368
|
+
// is unclear.
|
|
369
|
+
function askedWhatTried(prose) {
|
|
370
|
+
return prose.split(/(?<=[.!?])\s+/).some((sentence) => sentence.includes('?') && STUCK_QUESTION_RE.test(sentence));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Why a streamed unit must be dropped (a short rule name), or null.
|
|
374
|
+
function dropReason(unit, policy, context) {
|
|
375
|
+
if (unit.type === 'fence') {
|
|
376
|
+
if (!policy.dropFences) return null;
|
|
377
|
+
if (quotesStudentCode(unit.body, context.code)) return null;
|
|
378
|
+
if (policy.dropFences === 'code') return isCodeBlock(unit.info, unit.body) ? 'fence' : null;
|
|
379
|
+
// 'foreign': program code that is not a quote of theirs and either
|
|
380
|
+
// repairs it unasked or brings in a structure they never used.
|
|
381
|
+
if (!isCodeBlock(unit.info, unit.body)) return null;
|
|
382
|
+
if (policy.dropRepairs) return 'fence';
|
|
383
|
+
return unnamedSignals(unit.body, context.named).length || unnamedTechniques(unit.body, context.named).length ? 'fence' : null;
|
|
384
|
+
}
|
|
385
|
+
const text = matchable(unit.text);
|
|
386
|
+
if (policy.dropVerdicts && VERDICT_RE.test(text)) return 'verdict';
|
|
387
|
+
if (policy.dropTechniques && unnamedTechniques(text, context.named).length) return 'technique';
|
|
388
|
+
if (policy.dropTargets && (TARGET_COMPLEXITY_RE.test(text) || OPTIMAL_RE.test(text))) return 'complexity';
|
|
389
|
+
if (policy.dropRepairs && REPAIR_RE.test(text)) return 'repair';
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The re-ask: what the rejected reply broke, in the words of the rules, and
|
|
394
|
+
// what to do instead. The rejected reply is in the request right before it.
|
|
395
|
+
const RULE_TEXT = {
|
|
396
|
+
fence: 'It contains a code block. This reply must have no code.',
|
|
397
|
+
'inline-code': 'It shows code the student did not write. Use plain words about their code.',
|
|
398
|
+
technique: 'It names a method or data structure the student has not mentioned',
|
|
399
|
+
complexity: 'It mentions a target complexity or speed',
|
|
400
|
+
improve: 'It points toward improving or optimizing the code',
|
|
401
|
+
plan: 'It lays out a step-by-step plan. Give one nudge instead.',
|
|
402
|
+
question: 'It does not ask what the student tried or which step is unclear.',
|
|
403
|
+
length: 'It is too long.',
|
|
404
|
+
'edge-case': 'It is about an edge case. A first hint asks about what their code does or repeats',
|
|
405
|
+
verdict: 'It guesses a LeetCode verdict.',
|
|
406
|
+
repair: 'It gives a fix the student did not ask for.',
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
export function reaskPrompt(violations, policy) {
|
|
410
|
+
const lines = violations.map((violation) => {
|
|
411
|
+
const base = RULE_TEXT[violation.rule] || violation.rule;
|
|
412
|
+
const detail = violation.detail && ['technique', 'complexity', 'improve', 'edge-case'].includes(violation.rule)
|
|
413
|
+
? ` ("${violation.detail}").`
|
|
414
|
+
: base.endsWith('.') ? '' : '.';
|
|
415
|
+
return `- ${base}${detail}`;
|
|
416
|
+
});
|
|
417
|
+
const shape = policy.kind !== 'stuck' && policy.kind !== 'hint'
|
|
418
|
+
? "Answer the student's last message again without those parts."
|
|
419
|
+
: policy.kind === 'stuck'
|
|
420
|
+
? 'Reply with only a short question about what they tried or which step is unclear, and offer a hint. Give no hint or method now.'
|
|
421
|
+
: policy.hintNumber > 1
|
|
422
|
+
? 'Reply with one hint, one notch more concrete than the last, about their actual code, in plain words.'
|
|
423
|
+
: 'Reply with one short question about what their code does or about one property of the input. Name no method, data structure or complexity.';
|
|
424
|
+
return `Your reply above breaks the tutoring rules:\n${lines.join('\n')}\nRewrite it so it follows every rule. ${shape} Reply with the rewritten answer only.`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// What is left of a held reply once every sentence and block that breaks a
|
|
428
|
+
// rule is removed, cut to its length limit; '' when nothing safe is left.
|
|
429
|
+
export function salvage(text, policy, context) {
|
|
430
|
+
if (typeof text !== 'string' || !text.trim()) return '';
|
|
431
|
+
const kept = [];
|
|
432
|
+
let length = 0;
|
|
433
|
+
for (const unit of splitUnits(text)) {
|
|
434
|
+
if (unit.type === 'fence') continue;
|
|
435
|
+
const piece = repairProse(unit.text);
|
|
436
|
+
if (!piece.trim()) {
|
|
437
|
+
if (kept.length) kept.push(piece);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (checkHeld(piece, { ...policy, maxChars: 0, needsQuestion: false, noPlan: false }, context).length) continue;
|
|
441
|
+
if (/^\s*(?:\d+[.)]|[-*+])\s/.test(piece) && policy.noPlan) continue;
|
|
442
|
+
if (policy.maxChars && length + piece.trim().length > policy.maxChars) break;
|
|
443
|
+
kept.push(piece);
|
|
444
|
+
length += piece.trim().length;
|
|
445
|
+
}
|
|
446
|
+
const result = kept.join('').replace(/\n{3,}/g, '\n\n').trim().replace(/:$/, '.');
|
|
447
|
+
if (!result) return '';
|
|
448
|
+
if (policy.needsQuestion && !askedWhatTried(matchable(result))) return '';
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
// ---- the streaming gate --------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})([^\n`]*)\n/;
|
|
456
|
+
const FENCE_OPEN_PARTIAL_RE = /^ {0,3}(?:`{1,2}|~{1,2}|(?:`{3,}|~{3,})[^\n`]*)$/;
|
|
457
|
+
const FOLLOW_UP_RE = /^\s*(?:[-*+]\s+)?(?:this\s+way|that\s+way|now\b|then\b|with\s+(?:this|that|the)\s+(?:change|fix)|after\s+(?:this|that|the)\s+(?:change|fix)|this\s+(?:change|fix|ensures|guarantees|makes|prevents|avoids)|that\s+(?:change|fix)|either\s+way)/i;
|
|
458
|
+
const BLOCK_LINE_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|\||>)/;
|
|
459
|
+
const SPACE_RE = /[ \t\u00A0\u202F]/;
|
|
460
|
+
const DASH_RE = /[\u2013\u2014-]/;
|
|
461
|
+
|
|
462
|
+
// Takes a reply chunk by chunk and returns, from each push, the text that
|
|
463
|
+
// may be shown now:
|
|
464
|
+
// - 'word' releases up to the last safe word break (never right after a
|
|
465
|
+
// dash, so a dash repair sees both sides) and code blocks line by line;
|
|
466
|
+
// - 'sentence' releases whole checked sentences (or lines) and whole code
|
|
467
|
+
// blocks, and drops a broken one together with a lead-in ending in ':';
|
|
468
|
+
// - 'hold' releases nothing (the caller checks `raw` at the end).
|
|
469
|
+
// Code blocks are never repaired. `collect` only splits (see splitUnits).
|
|
470
|
+
function createGate(policy, context, { collect = false } = {}) {
|
|
471
|
+
const mode = collect ? 'collect' : policy.mode;
|
|
472
|
+
let all = '';
|
|
473
|
+
let pending = '';
|
|
474
|
+
let released = '';
|
|
475
|
+
let fence = null; // { marker, info, open, body, close } inside a fenced block
|
|
476
|
+
let atLineStart = true;
|
|
477
|
+
let leadIn = null; // a prose unit ending with ':', waiting for what follows
|
|
478
|
+
let afterDrop = false;
|
|
479
|
+
let lastDropped = false;
|
|
480
|
+
let carryBreak = false;
|
|
481
|
+
const units = [];
|
|
482
|
+
const dropped = [];
|
|
483
|
+
|
|
484
|
+
function emit(text) {
|
|
485
|
+
if (!text) return '';
|
|
486
|
+
let out = text;
|
|
487
|
+
if (!released) {
|
|
488
|
+
// Nothing shown yet: no leading blank lines.
|
|
489
|
+
out = out.replace(/^\s+/, '');
|
|
490
|
+
} else if (carryBreak && !released.endsWith('\n')) {
|
|
491
|
+
// A dropped unit took its line break with it: put it back.
|
|
492
|
+
out = `\n${out}`;
|
|
493
|
+
} else if (afterDrop && released.endsWith('\n') && out.startsWith('\n')) {
|
|
494
|
+
// Never more than one blank line where units were dropped.
|
|
495
|
+
out = out.replace(/^\n+/, released.endsWith('\n\n') ? '' : '\n');
|
|
496
|
+
}
|
|
497
|
+
carryBreak = false;
|
|
498
|
+
if (!out) return '';
|
|
499
|
+
afterDrop = false;
|
|
500
|
+
released += out;
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// A complete unit in 'sentence' mode: kept, dropped, or held as a lead-in.
|
|
505
|
+
function settle(unit) {
|
|
506
|
+
if (mode === 'collect') {
|
|
507
|
+
units.push(unit);
|
|
508
|
+
return '';
|
|
509
|
+
}
|
|
510
|
+
// A sentence that only follows up on a dropped part ("This way j is
|
|
511
|
+
// always larger") goes with it.
|
|
512
|
+
const reason = dropReason(unit, policy, context) || (lastDropped && unit.type === 'prose' && FOLLOW_UP_RE.test(matchable(unit.text)) ? 'follow-up' : null);
|
|
513
|
+
if (reason) {
|
|
514
|
+
lastDropped = true;
|
|
515
|
+
dropped.push({ rule: reason, text: unit.text });
|
|
516
|
+
if (unit.text.endsWith('\n')) carryBreak = true;
|
|
517
|
+
leadIn = null;
|
|
518
|
+
afterDrop = true;
|
|
519
|
+
return '';
|
|
520
|
+
}
|
|
521
|
+
const text = unit.type === 'fence' ? repairBlock(unit) : repairProse(unit.text);
|
|
522
|
+
if (leadIn !== null && unit.type === 'prose' && !text.trim()) {
|
|
523
|
+
leadIn += text;
|
|
524
|
+
return '';
|
|
525
|
+
}
|
|
526
|
+
if (text.trim()) lastDropped = false;
|
|
527
|
+
let out = '';
|
|
528
|
+
if (leadIn !== null) {
|
|
529
|
+
out += emit(leadIn);
|
|
530
|
+
leadIn = null;
|
|
531
|
+
}
|
|
532
|
+
if (unit.type === 'prose' && /:\s*$/.test(text)) {
|
|
533
|
+
leadIn = text;
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
536
|
+
return out + emit(text);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function closeFence() {
|
|
540
|
+
const unit = { type: 'fence', info: fence.info, body: fence.body.replace(/\n$/, ''), text: fence.open + fence.body + fence.close };
|
|
541
|
+
const out = mode === 'word' ? emit(fence.close) : settle(unit);
|
|
542
|
+
fence = null;
|
|
543
|
+
atLineStart = true;
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Splits complete units off the front of `pending`; `final` means no more
|
|
548
|
+
// text is coming.
|
|
549
|
+
function drain(final) {
|
|
550
|
+
let out = '';
|
|
551
|
+
while (pending) {
|
|
552
|
+
if (fence) {
|
|
553
|
+
const closing = new RegExp(`^ {0,3}${fence.marker[0] === '`' ? '`' : '~'}{${fence.marker.length},}[ \\t]*$`);
|
|
554
|
+
let closed = false;
|
|
555
|
+
for (;;) {
|
|
556
|
+
const newline = pending.indexOf('\n');
|
|
557
|
+
if (newline < 0 && !final) break;
|
|
558
|
+
const line = newline < 0 ? pending : pending.slice(0, newline);
|
|
559
|
+
const piece = newline < 0 ? pending : pending.slice(0, newline + 1);
|
|
560
|
+
pending = pending.slice(piece.length);
|
|
561
|
+
if (closing.test(line)) {
|
|
562
|
+
fence.close = piece;
|
|
563
|
+
closed = true;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
fence.body += piece;
|
|
567
|
+
if (mode === 'word') out += emit(piece);
|
|
568
|
+
if (!pending) break;
|
|
569
|
+
}
|
|
570
|
+
if (closed || (final && !pending)) {
|
|
571
|
+
out += closeFence();
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
if (atLineStart) {
|
|
577
|
+
const open = pending.match(FENCE_OPEN_RE);
|
|
578
|
+
if (open || (final && /^ {0,3}(`{3,}|~{3,})([^\n`]*)$/.test(pending))) {
|
|
579
|
+
const match = open || pending.match(/^ {0,3}(`{3,}|~{3,})([^\n`]*)$/);
|
|
580
|
+
fence = { marker: match[1], info: match[2].trim(), open: match[0], body: '', close: '' };
|
|
581
|
+
pending = pending.slice(match[0].length);
|
|
582
|
+
if (mode === 'word') out += emit(match[0]);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (!final && FENCE_OPEN_PARTIAL_RE.test(pending)) break;
|
|
586
|
+
}
|
|
587
|
+
const end = proseUnitEnd(pending, final, mode === 'word');
|
|
588
|
+
if (end <= 0) break;
|
|
589
|
+
const text = pending.slice(0, end);
|
|
590
|
+
pending = pending.slice(end);
|
|
591
|
+
atLineStart = text.endsWith('\n');
|
|
592
|
+
out += mode === 'word' ? emit(repairProse(text)) : settle({ type: 'prose', text });
|
|
593
|
+
}
|
|
594
|
+
// A block still open when the reply ends is a whole (unclosed) block.
|
|
595
|
+
if (final && fence) out += closeFence();
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
units,
|
|
601
|
+
dropped,
|
|
602
|
+
push(chunk) {
|
|
603
|
+
if (typeof chunk !== 'string' || !chunk) return '';
|
|
604
|
+
all += chunk;
|
|
605
|
+
if (mode === 'hold') return '';
|
|
606
|
+
pending += chunk;
|
|
607
|
+
return drain(false);
|
|
608
|
+
},
|
|
609
|
+
finish() {
|
|
610
|
+
if (mode === 'hold') return '';
|
|
611
|
+
let out = drain(true);
|
|
612
|
+
if (leadIn !== null) {
|
|
613
|
+
out += emit(leadIn);
|
|
614
|
+
leadIn = null;
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
},
|
|
618
|
+
// Everything pushed so far.
|
|
619
|
+
get raw() {
|
|
620
|
+
return all;
|
|
621
|
+
},
|
|
622
|
+
get released() {
|
|
623
|
+
return released;
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Where the first complete prose unit at the start of `text` ends (an index
|
|
629
|
+
// past it), or 0 when none is complete yet. A unit ends after a newline, or
|
|
630
|
+
// (sentence units) after a sentence end (. ! ? and closing marks) and the
|
|
631
|
+
// spaces after it, or (word units) before the last
|
|
632
|
+
// space that has a plain character after it and no dash before it. Inline
|
|
633
|
+
// code spans are never split.
|
|
634
|
+
function proseUnitEnd(text, final, word) {
|
|
635
|
+
let inCode = false;
|
|
636
|
+
let lastWordBreak = 0;
|
|
637
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
638
|
+
const ch = text[i];
|
|
639
|
+
if (ch === '\n') {
|
|
640
|
+
if (word) return i + 1;
|
|
641
|
+
// A model that hard-wraps its prose breaks lines mid-sentence: such a
|
|
642
|
+
// line stays in the unit with the next one, so a dropped sentence is
|
|
643
|
+
// never cut in half. Only a line that ends a sentence, or a block
|
|
644
|
+
// line (list item, heading, table row, quote), or one followed by a
|
|
645
|
+
// new block, ends the unit here.
|
|
646
|
+
const line = text.slice(text.lastIndexOf('\n', i - 1) + 1, i);
|
|
647
|
+
if (!line.trim() || BLOCK_LINE_RE.test(line) || /[.!?:]["')\]*_\u2019\u201D]*[ \t]*$/.test(line)) return i + 1;
|
|
648
|
+
const rest = text.slice(i + 1);
|
|
649
|
+
const nextEnd = rest.indexOf('\n');
|
|
650
|
+
const next = nextEnd < 0 ? rest : rest.slice(0, nextEnd);
|
|
651
|
+
if (nextEnd < 0 && next.length < 4 && !final) return 0;
|
|
652
|
+
if (!next.trim() || BLOCK_LINE_RE.test(next) || /^ {0,3}(?:`{3}|~{3})/.test(next)) return i + 1;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (ch === '`') {
|
|
656
|
+
let run = 1;
|
|
657
|
+
while (text[i + run] === '`') run += 1;
|
|
658
|
+
if (run < 3) inCode = !inCode;
|
|
659
|
+
i += run - 1;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (inCode) continue;
|
|
663
|
+
if (word) {
|
|
664
|
+
if (SPACE_RE.test(ch) && i + 1 < text.length && !/\s/.test(text[i + 1]) && !DASH_RE.test(text[i + 1])) {
|
|
665
|
+
let j = i - 1;
|
|
666
|
+
while (j >= 0 && SPACE_RE.test(text[j])) j -= 1;
|
|
667
|
+
if (j >= 0 && !DASH_RE.test(text[j])) lastWordBreak = i;
|
|
668
|
+
}
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (ch === '.' || ch === '!' || ch === '?') {
|
|
672
|
+
let j = i + 1;
|
|
673
|
+
while (j < text.length && /["')\]*_\u2019\u201D]/.test(text[j])) j += 1;
|
|
674
|
+
if (j < text.length && SPACE_RE.test(text[j])) {
|
|
675
|
+
let k = j;
|
|
676
|
+
while (k < text.length && SPACE_RE.test(text[k])) k += 1;
|
|
677
|
+
// "e.g." and the like are not sentence ends.
|
|
678
|
+
if (ch === '.' && /\b(?:e\.g|i\.e|etc|vs|approx|Dr|Mr|Ms|Mrs)$/i.test(text.slice(Math.max(0, i - 6), i))) continue;
|
|
679
|
+
// Nor is a list marker ("1. ") at the start of a line.
|
|
680
|
+
if (ch === '.' && /^[ \t]*\d+$/.test(text.slice(text.lastIndexOf('\n', i) + 1, i))) continue;
|
|
681
|
+
// Released as soon as its trailing space arrives (more spaces, or a
|
|
682
|
+
// newline, start the next unit), so a sentence never waits for the
|
|
683
|
+
// next one to begin.
|
|
684
|
+
if (k === text.length || text[k] !== '\n') return k;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (word && lastWordBreak > 0) return lastWordBreak;
|
|
689
|
+
return final ? text.length : 0;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---- the guard ----------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
const MAX_MATERIAL_CHARS = 60_000;
|
|
695
|
+
|
|
696
|
+
// One guard per tutor conversation. It remembers how many hints were given
|
|
697
|
+
// and what the student has named or already seen (their messages, their
|
|
698
|
+
// code, every reply shown), and is reset with the conversation (a problem
|
|
699
|
+
// switch, /reset).
|
|
700
|
+
export function createReplyGuard() {
|
|
701
|
+
let hintsGiven = 0;
|
|
702
|
+
let seenProse = '';
|
|
703
|
+
let seenCode = '';
|
|
704
|
+
const keep = (value) => (value.length > MAX_MATERIAL_CHARS ? value.slice(-MAX_MATERIAL_CHARS) : value);
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
// The policy and context for one turn: `text` is the student's own
|
|
708
|
+
// words (null for a Submit), `trigger` 'submit' or null, `code` their
|
|
709
|
+
// current code (or null). `done(shown)` records the finished turn
|
|
710
|
+
// (guardedSend calls it).
|
|
711
|
+
begin({ text = null, trigger = null, code = null } = {}) {
|
|
712
|
+
const words = typeof text === 'string' ? text : '';
|
|
713
|
+
const current = typeof code === 'string' ? code : '';
|
|
714
|
+
const policy = turnPolicy(classifyMessage(words, { trigger, hintsGiven }));
|
|
715
|
+
const context = {
|
|
716
|
+
named: namedTechniques({ prose: `${seenProse}\n${words}`, code: `${seenCode}\n${current}` }),
|
|
717
|
+
code: current,
|
|
718
|
+
};
|
|
719
|
+
return {
|
|
720
|
+
policy,
|
|
721
|
+
context,
|
|
722
|
+
done(shown) {
|
|
723
|
+
if (policy.kind === 'hint') hintsGiven += 1;
|
|
724
|
+
seenProse = keep(`${seenProse}\n${words}\n${typeof shown === 'string' ? shown : ''}`);
|
|
725
|
+
if (current) seenCode = keep(current);
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
},
|
|
729
|
+
reset() {
|
|
730
|
+
hintsGiven = 0;
|
|
731
|
+
seenProse = '';
|
|
732
|
+
seenCode = '';
|
|
733
|
+
},
|
|
734
|
+
get hintsGiven() {
|
|
735
|
+
return hintsGiven;
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Runs one tutor turn through the guard.
|
|
741
|
+
// - `backend.sendMessage(text, options)` is called with `onChunk` and, for
|
|
742
|
+
// a held turn, `shouldStop(textSoFar)` (a backend that supports it ends
|
|
743
|
+
// the request early and returns what it had).
|
|
744
|
+
// - `onText(piece)` receives each piece the student may see, in order; the
|
|
745
|
+
// resolved string is the whole reply as shown, always exactly the
|
|
746
|
+
// concatenation of those pieces.
|
|
747
|
+
// - A backend with `reviseLastReply(instruction, { shouldStop })` gets one
|
|
748
|
+
// re-ask for a broken held reply (and for a streamed one whose every
|
|
749
|
+
// part was dropped); one with `replaceLastReply(text)` keeps the shown
|
|
750
|
+
// text, never the rejected one, in its history.
|
|
751
|
+
// - Backend errors propagate unchanged, except that a cut-off error's
|
|
752
|
+
// `partialReply` becomes what may be shown of it.
|
|
753
|
+
// `turn` comes from guard.begin(); null sends the turn unguarded.
|
|
754
|
+
export async function guardedSend({ backend, text, options = {}, turn, onText = () => {}, log = () => {} }) {
|
|
755
|
+
if (!turn) return backend.sendMessage(text, { ...options, onChunk: onText });
|
|
756
|
+
const { policy, context } = turn;
|
|
757
|
+
const gate = createGate(policy, context);
|
|
758
|
+
let shown = '';
|
|
759
|
+
const show = (piece) => {
|
|
760
|
+
if (!piece) return;
|
|
761
|
+
shown += piece;
|
|
762
|
+
onText(piece);
|
|
763
|
+
};
|
|
764
|
+
let chunks = 0;
|
|
765
|
+
const hold = policy.mode === 'hold';
|
|
766
|
+
const shouldStop = hold ? heldStop(policy) : undefined;
|
|
767
|
+
|
|
768
|
+
let reply;
|
|
769
|
+
try {
|
|
770
|
+
reply = await backend.sendMessage(text, {
|
|
771
|
+
...options,
|
|
772
|
+
onChunk: (chunk) => {
|
|
773
|
+
chunks += 1;
|
|
774
|
+
show(gate.push(chunk));
|
|
775
|
+
},
|
|
776
|
+
...(shouldStop ? { shouldStop } : {}),
|
|
777
|
+
});
|
|
778
|
+
} catch (err) {
|
|
779
|
+
if (err && err.cutOff) {
|
|
780
|
+
if (!chunks && typeof err.partialReply === 'string') show(gate.push(err.partialReply));
|
|
781
|
+
show(gate.finish());
|
|
782
|
+
if (hold) show(salvage(gate.raw, policy, context));
|
|
783
|
+
err.partialReply = shown || undefined;
|
|
784
|
+
}
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
if (!(typeof reply === 'string' && reply.trim())) return reply;
|
|
788
|
+
// A backend that did not stream hands over the whole reply at the end.
|
|
789
|
+
if (!chunks) show(gate.push(reply));
|
|
790
|
+
show(gate.finish());
|
|
791
|
+
|
|
792
|
+
if (hold) {
|
|
793
|
+
const raw = gate.raw;
|
|
794
|
+
const violations = checkHeld(raw, policy, context);
|
|
795
|
+
if (!violations.length) {
|
|
796
|
+
show(repairHeld(raw));
|
|
797
|
+
} else {
|
|
798
|
+
log(`guard: a ${policy.kind} reply broke ${violations.map((v) => v.rule).join(', ')}; re-asking once`);
|
|
799
|
+
show(await reaskHeld({ backend, original: raw, violations, policy, context, log }));
|
|
800
|
+
}
|
|
801
|
+
} else if (gate.dropped.length) {
|
|
802
|
+
log(`guard: dropped ${gate.dropped.map((d) => d.rule).join(', ')} from a ${policy.kind} reply`);
|
|
803
|
+
if (!shown.trim()) {
|
|
804
|
+
// Every part was dropped and nothing is on screen yet, so a re-ask
|
|
805
|
+
// cannot replace anything the student saw.
|
|
806
|
+
show(await reaskStreamed({ backend, violations: gate.dropped.map((d) => ({ rule: d.rule })), policy, context, log }));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (shown !== reply && typeof backend.replaceLastReply === 'function') backend.replaceLastReply(shown);
|
|
810
|
+
turn.done(shown);
|
|
811
|
+
return shown;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// When a held reply is past saving anyway: well over its length limit, or
|
|
815
|
+
// (where no code block is allowed) a code block has started.
|
|
816
|
+
function heldStop(policy) {
|
|
817
|
+
const limit = policy.maxChars ? Math.ceil(policy.maxChars * EARLY_STOP_FACTOR) : Infinity;
|
|
818
|
+
return (soFar) => soFar.length > limit || (policy.noFence === 'any' && /(?:^|\n) {0,3}(?:```|~~~)/.test(soFar));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// A held reply's text as shown: prose repaired, code blocks as they are.
|
|
822
|
+
function repairHeld(text) {
|
|
823
|
+
return splitUnits(text)
|
|
824
|
+
.map((unit) => (unit.type === 'fence' ? repairBlock(unit) : repairProse(unit.text)))
|
|
825
|
+
.join('')
|
|
826
|
+
.trim();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function revise(backend, violations, policy, log) {
|
|
830
|
+
if (typeof backend.reviseLastReply !== 'function') return null;
|
|
831
|
+
try {
|
|
832
|
+
const revised = await backend.reviseLastReply(reaskPrompt(violations, policy), { shouldStop: heldStop(policy) });
|
|
833
|
+
return typeof revised === 'string' && revised.trim() ? revised : null;
|
|
834
|
+
} catch (err) {
|
|
835
|
+
log(`guard: the re-ask failed (${err.message})`);
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// One re-ask of a held reply; if that breaks a rule too, what is safe of the
|
|
841
|
+
// re-ask, then of the original, then the fallback.
|
|
842
|
+
async function reaskHeld({ backend, original, violations, policy, context, log }) {
|
|
843
|
+
const revised = await revise(backend, violations, policy, log);
|
|
844
|
+
if (revised) {
|
|
845
|
+
const again = checkHeld(revised, policy, context);
|
|
846
|
+
if (!again.length) return repairHeld(revised);
|
|
847
|
+
log(`guard: the re-ask broke ${again.map((v) => v.rule).join(', ')} too`);
|
|
848
|
+
const kept = salvage(revised, policy, context);
|
|
849
|
+
if (kept) return kept;
|
|
850
|
+
}
|
|
851
|
+
return salvage(original, policy, context) || policy.fallback || FALLBACK_TEXT.other;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// One re-ask of a streamed reply that had nothing left to show, put through
|
|
855
|
+
// the same sentence checks; then the fallback.
|
|
856
|
+
async function reaskStreamed({ backend, violations, policy, context, log }) {
|
|
857
|
+
const revised = await revise(backend, violations, policy, log);
|
|
858
|
+
if (revised) {
|
|
859
|
+
const gate = createGate(policy, context);
|
|
860
|
+
const text = (gate.push(revised) + gate.finish()).trim();
|
|
861
|
+
if (text) return text;
|
|
862
|
+
}
|
|
863
|
+
return policy.fallback || FALLBACK_TEXT.other;
|
|
864
|
+
}
|