@danypops/papyrus 0.59.0 → 0.59.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/package.json +4 -4
- package/src/cli/discuss-command.ts +36 -0
- package/src/constants.ts +20 -1
- package/src/db.ts +39 -0
- package/src/discussion/discussion-round-store.ts +7 -0
- package/src/discussion/discussion-service.ts +87 -10
- package/src/discussion/discussion.ts +99 -2
- package/src/discussion/sqlite-discussion-round-store.ts +51 -3
- package/src/handlers/discuss.ts +39 -6
- package/src/index.ts +7 -1
- package/src/modules/discuss.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.59.
|
|
3
|
+
"version": "0.59.2",
|
|
4
4
|
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
},
|
|
58
58
|
"files": ["src", "README.md"],
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@danypops/vehicle-client": "^0.
|
|
61
|
-
"@danypops/vehicle-core": "^0.
|
|
62
|
-
"@danypops/vehicle-server": "^0.
|
|
60
|
+
"@danypops/vehicle-client": "^0.10.1",
|
|
61
|
+
"@danypops/vehicle-core": "^0.17.1",
|
|
62
|
+
"@danypops/vehicle-server": "^0.24.5",
|
|
63
63
|
"@stricli/core": "^1.3.0"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -38,6 +38,8 @@ const openCommand = buildCommand({
|
|
|
38
38
|
optionsJson?: string[];
|
|
39
39
|
optionsMode?: string;
|
|
40
40
|
optionDescriptionsJson?: string[];
|
|
41
|
+
correctOptionsJson?: string[];
|
|
42
|
+
explanation?: string;
|
|
41
43
|
},
|
|
42
44
|
) {
|
|
43
45
|
await call.call(this, "discuss.open", {
|
|
@@ -50,6 +52,8 @@ const openCommand = buildCommand({
|
|
|
50
52
|
options: flags.optionsJson,
|
|
51
53
|
options_mode: flags.optionsMode,
|
|
52
54
|
option_descriptions: flags.optionDescriptionsJson,
|
|
55
|
+
correct_options: flags.correctOptionsJson,
|
|
56
|
+
explanation: flags.explanation,
|
|
53
57
|
});
|
|
54
58
|
},
|
|
55
59
|
parameters: {
|
|
@@ -81,6 +85,20 @@ const openCommand = buildCommand({
|
|
|
81
85
|
placeholder: "json",
|
|
82
86
|
optional: true,
|
|
83
87
|
},
|
|
88
|
+
correctOptionsJson: {
|
|
89
|
+
brief: "JSON string array of correct options (one or more, verbatim from options) -- makes this a graded quiz",
|
|
90
|
+
kind: "parsed",
|
|
91
|
+
parse: parseStringArray,
|
|
92
|
+
placeholder: "json",
|
|
93
|
+
optional: true,
|
|
94
|
+
},
|
|
95
|
+
explanation: {
|
|
96
|
+
brief: "Quiz explanation, required alongside correctOptionsJson, always shown after grading",
|
|
97
|
+
kind: "parsed",
|
|
98
|
+
parse: String,
|
|
99
|
+
placeholder: "text",
|
|
100
|
+
optional: true,
|
|
101
|
+
},
|
|
84
102
|
},
|
|
85
103
|
},
|
|
86
104
|
docs: { brief: "Open a new discussion" },
|
|
@@ -96,6 +114,8 @@ const replyCommand = buildCommand({
|
|
|
96
114
|
optionsJson?: string[];
|
|
97
115
|
optionsMode?: string;
|
|
98
116
|
optionDescriptionsJson?: string[];
|
|
117
|
+
correctOptionsJson?: string[];
|
|
118
|
+
explanation?: string;
|
|
99
119
|
},
|
|
100
120
|
id: string,
|
|
101
121
|
) {
|
|
@@ -107,6 +127,8 @@ const replyCommand = buildCommand({
|
|
|
107
127
|
options: flags.optionsJson,
|
|
108
128
|
options_mode: flags.optionsMode,
|
|
109
129
|
option_descriptions: flags.optionDescriptionsJson,
|
|
130
|
+
correct_options: flags.correctOptionsJson,
|
|
131
|
+
explanation: flags.explanation,
|
|
110
132
|
});
|
|
111
133
|
},
|
|
112
134
|
parameters: {
|
|
@@ -135,6 +157,20 @@ const replyCommand = buildCommand({
|
|
|
135
157
|
placeholder: "json",
|
|
136
158
|
optional: true,
|
|
137
159
|
},
|
|
160
|
+
correctOptionsJson: {
|
|
161
|
+
brief: "JSON string array of correct options (one or more, verbatim from options) -- makes this a graded quiz",
|
|
162
|
+
kind: "parsed",
|
|
163
|
+
parse: parseStringArray,
|
|
164
|
+
placeholder: "json",
|
|
165
|
+
optional: true,
|
|
166
|
+
},
|
|
167
|
+
explanation: {
|
|
168
|
+
brief: "Quiz explanation, required alongside correctOptionsJson, always shown after grading",
|
|
169
|
+
kind: "parsed",
|
|
170
|
+
parse: String,
|
|
171
|
+
placeholder: "text",
|
|
172
|
+
optional: true,
|
|
173
|
+
},
|
|
138
174
|
},
|
|
139
175
|
positional: { kind: "tuple", parameters: [{ brief: "Discussion id", parse: String, placeholder: "id" }] },
|
|
140
176
|
},
|
package/src/constants.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
17
17
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
18
18
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
19
19
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
20
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
20
|
+
export const SQLITE_SCHEMA_VERSION = 30;
|
|
21
21
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
22
22
|
|
|
23
23
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
@@ -243,6 +243,25 @@ export const DISCUSSION_OPTION_MAX_LENGTH = 200;
|
|
|
243
243
|
export const DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH = 240;
|
|
244
244
|
// Below this, a binary yes/no choice is often self-evident and a description would just pad it.
|
|
245
245
|
export const DISCUSSION_OPTION_DESCRIPTION_REQUIRED_FROM_COUNT = 3;
|
|
246
|
+
/**
|
|
247
|
+
* A posed choice becomes a graded quiz/assessment once correct_options + explanation are given
|
|
248
|
+
* alongside options/optionsMode. correct_options is one or more entries drawn verbatim from
|
|
249
|
+
* options (exact text match, the same identity scheme `selected` already uses -- never an index
|
|
250
|
+
* or the display-only A/B/C/... label). A "single" quiz must have exactly one correct option
|
|
251
|
+
* (the participant can only pick one); a "multi" quiz allows several, graded correct iff the
|
|
252
|
+
* selected set exactly equals the correct set (no partial credit). explanation is mandatory --
|
|
253
|
+
* always shown after grading, especially when wrong, so a wrong answer still teaches something.
|
|
254
|
+
*/
|
|
255
|
+
export const DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS = 2_000;
|
|
256
|
+
/**
|
|
257
|
+
* Display-only labels (A, B, C, ...) assigned to a quiz's options by position, computed on the
|
|
258
|
+
* fly wherever a quiz is rendered -- never persisted, never part of a quiz's addressing scheme.
|
|
259
|
+
* Single-letter labels only support up to 26 options; DISCUSSION_OPTIONS_MAX_COUNT (10) already
|
|
260
|
+
* sits comfortably under that ceiling, so there is deliberately no AA/AB double-letter overflow
|
|
261
|
+
* scheme -- raising DISCUSSION_OPTIONS_MAX_COUNT past 26 would need one added at the same time.
|
|
262
|
+
*/
|
|
263
|
+
export const DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT = 26;
|
|
264
|
+
|
|
246
265
|
/** Bounds for the generic graph projection protocol (external bounded contexts). */
|
|
247
266
|
export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
|
|
248
267
|
export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
|
package/src/db.ts
CHANGED
|
@@ -307,6 +307,16 @@ CREATE TABLE IF NOT EXISTS discussion_rounds (
|
|
|
307
307
|
options_mode TEXT,
|
|
308
308
|
selected TEXT,
|
|
309
309
|
option_descriptions TEXT,
|
|
310
|
+
-- Quiz: quiz marks a posing round (safe, public); quiz_correct_options/quiz_explanation are the
|
|
311
|
+
-- hidden answer (never selected by any general-purpose query, only resolvePendingQuizAnswer's
|
|
312
|
+
-- own dedicated one); quiz_result_* is the graded outcome on the answering round (public, safe
|
|
313
|
+
-- once submission has already happened). See domain/discussion.ts.
|
|
314
|
+
quiz INTEGER,
|
|
315
|
+
quiz_correct_options TEXT,
|
|
316
|
+
quiz_explanation TEXT,
|
|
317
|
+
quiz_result_correct INTEGER,
|
|
318
|
+
quiz_result_correct_options TEXT,
|
|
319
|
+
quiz_result_explanation TEXT,
|
|
310
320
|
UNIQUE (discussion_id, round_number)
|
|
311
321
|
);
|
|
312
322
|
CREATE INDEX IF NOT EXISTS discussion_rounds_discussion_idx ON discussion_rounds(discussion_id, round_number, id);
|
|
@@ -1008,6 +1018,35 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
|
|
|
1008
1018
|
`);
|
|
1009
1019
|
},
|
|
1010
1020
|
},
|
|
1021
|
+
{
|
|
1022
|
+
version: 30,
|
|
1023
|
+
name: "discuss-quiz",
|
|
1024
|
+
// See domain/discussion.ts. Nullable, purely additive columns, same pattern as version 16/19's
|
|
1025
|
+
// own discuss-options/discuss-option-descriptions migrations -- a round with no quiz (the
|
|
1026
|
+
// overwhelming majority) simply stores NULL in all six. quiz_correct_options/quiz_explanation
|
|
1027
|
+
// are the hidden answer (a posing round) -- deliberately never selected by discussion_rounds'
|
|
1028
|
+
// general-purpose read queries, only by SQLiteDiscussionRoundStore's own dedicated
|
|
1029
|
+
// resolvePendingQuizAnswer. quiz_result_* is the graded outcome (an answering round, safe to
|
|
1030
|
+
// read generally since it only exists after submission). Guarded per-column (SQLite has no
|
|
1031
|
+
// `ADD COLUMN IF NOT EXISTS`), the same already-bootstrapped-fixture concern version 9's comment
|
|
1032
|
+
// covers.
|
|
1033
|
+
up: (db) => {
|
|
1034
|
+
const existing = new Set(
|
|
1035
|
+
(db.prepare("PRAGMA table_info(discussion_rounds)").all() as Array<{ name: string }>).map((row) => row.name),
|
|
1036
|
+
);
|
|
1037
|
+
const columns = [
|
|
1038
|
+
["quiz", "INTEGER"],
|
|
1039
|
+
["quiz_correct_options", "TEXT"],
|
|
1040
|
+
["quiz_explanation", "TEXT"],
|
|
1041
|
+
["quiz_result_correct", "INTEGER"],
|
|
1042
|
+
["quiz_result_correct_options", "TEXT"],
|
|
1043
|
+
["quiz_result_explanation", "TEXT"],
|
|
1044
|
+
] as const;
|
|
1045
|
+
for (const [column, type] of columns) {
|
|
1046
|
+
if (!existing.has(column)) db.exec(`ALTER TABLE discussion_rounds ADD COLUMN ${column} ${type}`);
|
|
1047
|
+
}
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1011
1050
|
];
|
|
1012
1051
|
|
|
1013
1052
|
/**
|
|
@@ -5,4 +5,11 @@ export interface DiscussionRoundStore {
|
|
|
5
5
|
append(round: AppendDiscussionRound, occurredAt: string): DiscussionRound;
|
|
6
6
|
list(query: DiscussionRoundQuery): DiscussionRound[];
|
|
7
7
|
count(discussionId: string): number;
|
|
8
|
+
/**
|
|
9
|
+
* Reads back a quiz's hidden correct-answer + explanation, for grading only -- the one deliberate
|
|
10
|
+
* hole in "never expose the answer", scoped to discussion-service.ts's reply() alone. Returns
|
|
11
|
+
* undefined when that round never posed a quiz (defensive; discussion-service.ts only calls this
|
|
12
|
+
* when extra.discussion.pendingIsQuiz is already true, so this should always resolve in practice).
|
|
13
|
+
*/
|
|
14
|
+
resolvePendingQuizAnswer(discussionId: string, roundNumber: number): { correctOptions: string[]; explanation: string } | undefined;
|
|
8
15
|
}
|
|
@@ -12,13 +12,16 @@ import {
|
|
|
12
12
|
DISCUSSION_SUBTYPE,
|
|
13
13
|
type DiscussionExtra,
|
|
14
14
|
type DiscussionOptionsMode,
|
|
15
|
+
type DiscussionQuizResult,
|
|
15
16
|
type DiscussionRound,
|
|
17
|
+
gradeQuizAnswer,
|
|
16
18
|
isDiscussionArtifact,
|
|
17
19
|
readDiscussionExtra,
|
|
18
20
|
validateDeferReason,
|
|
19
21
|
validateDiscussionActor,
|
|
20
22
|
validateDiscussionContent,
|
|
21
23
|
validateDiscussionOptions,
|
|
24
|
+
validateDiscussionQuiz,
|
|
22
25
|
validateSelectedOptions,
|
|
23
26
|
validateSettlement,
|
|
24
27
|
} from "./discussion.ts";
|
|
@@ -38,17 +41,25 @@ export interface OpenDiscussionInput {
|
|
|
38
41
|
optionsMode?: DiscussionOptionsMode;
|
|
39
42
|
/** Index-aligned with options -- see domain/discussion.ts's pendingOptionDescriptions. */
|
|
40
43
|
optionDescriptions?: string[];
|
|
44
|
+
/** Turns the posed choice into a graded quiz -- both required together, alongside options/optionsMode.
|
|
45
|
+
* See domain/discussion.ts's validateDiscussionQuiz. */
|
|
46
|
+
quizCorrectOptions?: string[];
|
|
47
|
+
quizExplanation?: string;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
export interface ReplyInput {
|
|
44
51
|
actor: string;
|
|
45
52
|
content: string;
|
|
46
|
-
/** Answers the Discussion's currently pending posed choice, if any; validated against it.
|
|
53
|
+
/** Answers the Discussion's currently pending posed choice, if any; validated against it. If the
|
|
54
|
+
* pending choice was a quiz, this is also graded automatically -- see the round's own quizResult. */
|
|
47
55
|
selected?: string[];
|
|
48
56
|
/** Poses a new choice on this same round, replacing whatever was previously pending. */
|
|
49
57
|
options?: string[];
|
|
50
58
|
optionsMode?: DiscussionOptionsMode;
|
|
51
59
|
optionDescriptions?: string[];
|
|
60
|
+
/** Turns the newly-posed choice into a graded quiz -- both required together. */
|
|
61
|
+
quizCorrectOptions?: string[];
|
|
62
|
+
quizExplanation?: string;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
export interface DiscussionAndRounds {
|
|
@@ -72,20 +83,50 @@ export class Discussions {
|
|
|
72
83
|
return readDiscussionExtra(discussion.extra);
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Validates a freshly-posed choice; undefined when nothing is posed at all (neither options nor
|
|
88
|
+
* quiz fields given), since both/neither is the only valid shape. correct_options/explanation
|
|
89
|
+
* turn the choice into a graded quiz -- both required together, and only once options/optionsMode
|
|
90
|
+
* are also given.
|
|
91
|
+
*/
|
|
76
92
|
private validatePosedOptions(
|
|
77
93
|
options: string[] | undefined,
|
|
78
94
|
optionsMode: DiscussionOptionsMode | undefined,
|
|
79
95
|
optionDescriptions: string[] | undefined,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
quizCorrectOptions: string[] | undefined,
|
|
97
|
+
quizExplanation: string | undefined,
|
|
98
|
+
):
|
|
99
|
+
| {
|
|
100
|
+
options: string[];
|
|
101
|
+
mode: DiscussionOptionsMode;
|
|
102
|
+
optionDescriptions?: string[];
|
|
103
|
+
quiz?: { correctOptions: string[]; explanation: string };
|
|
104
|
+
}
|
|
105
|
+
| undefined {
|
|
106
|
+
if (options === undefined && optionsMode === undefined) {
|
|
107
|
+
if (quizCorrectOptions !== undefined || quizExplanation !== undefined) {
|
|
108
|
+
throw new Error("correct_options/explanation require options and options_mode to also be posed");
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const posed = validateDiscussionOptions(options ?? [], optionsMode ?? "", optionDescriptions);
|
|
113
|
+
if (quizCorrectOptions === undefined && quizExplanation === undefined) return posed;
|
|
114
|
+
if (quizCorrectOptions === undefined || quizExplanation === undefined) {
|
|
115
|
+
throw new Error("correct_options and explanation must both be given to pose a quiz, or neither");
|
|
116
|
+
}
|
|
117
|
+
return { ...posed, quiz: validateDiscussionQuiz(posed.options, posed.mode, quizCorrectOptions, quizExplanation) };
|
|
83
118
|
}
|
|
84
119
|
|
|
85
120
|
open(input: OpenDiscussionInput, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
86
121
|
const actor = validateDiscussionActor(input.actor);
|
|
87
122
|
const content = validateDiscussionContent(input.content);
|
|
88
|
-
const posed = this.validatePosedOptions(
|
|
123
|
+
const posed = this.validatePosedOptions(
|
|
124
|
+
input.options,
|
|
125
|
+
input.optionsMode,
|
|
126
|
+
input.optionDescriptions,
|
|
127
|
+
input.quizCorrectOptions,
|
|
128
|
+
input.quizExplanation,
|
|
129
|
+
);
|
|
89
130
|
return this.artifacts.atomic(() => {
|
|
90
131
|
const discussion = this.artifacts.create(
|
|
91
132
|
{
|
|
@@ -104,6 +145,7 @@ export class Discussions {
|
|
|
104
145
|
pendingOptions: posed.options,
|
|
105
146
|
pendingOptionsMode: posed.mode,
|
|
106
147
|
...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
|
|
148
|
+
...(posed.quiz ? { pendingIsQuiz: true, pendingQuizRoundNumber: 1 } : {}),
|
|
107
149
|
}
|
|
108
150
|
: {}),
|
|
109
151
|
},
|
|
@@ -122,6 +164,9 @@ export class Discussions {
|
|
|
122
164
|
options: posed.options,
|
|
123
165
|
optionsMode: posed.mode,
|
|
124
166
|
...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
|
|
167
|
+
...(posed.quiz
|
|
168
|
+
? { quiz: true, quizCorrectOptions: posed.quiz.correctOptions, quizExplanation: posed.quiz.explanation }
|
|
169
|
+
: {}),
|
|
125
170
|
}
|
|
126
171
|
: {}),
|
|
127
172
|
},
|
|
@@ -135,7 +180,13 @@ export class Discussions {
|
|
|
135
180
|
reply(discussionId: string, input: ReplyInput, context?: ArtifactEventContext): DiscussionAndRounds {
|
|
136
181
|
const validActor = validateDiscussionActor(input.actor);
|
|
137
182
|
const validContent = validateDiscussionContent(input.content);
|
|
138
|
-
const posed = this.validatePosedOptions(
|
|
183
|
+
const posed = this.validatePosedOptions(
|
|
184
|
+
input.options,
|
|
185
|
+
input.optionsMode,
|
|
186
|
+
input.optionDescriptions,
|
|
187
|
+
input.quizCorrectOptions,
|
|
188
|
+
input.quizExplanation,
|
|
189
|
+
);
|
|
139
190
|
return this.artifacts.atomic(() => {
|
|
140
191
|
const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
|
|
141
192
|
const state = this.extra(discussion);
|
|
@@ -144,6 +195,24 @@ export class Discussions {
|
|
|
144
195
|
throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
|
|
145
196
|
const selected =
|
|
146
197
|
input.selected !== undefined ? validateSelectedOptions(input.selected, state.pendingOptions, state.pendingOptionsMode) : undefined;
|
|
198
|
+
// Grading happens right here, inside this same transaction: the hidden answer is read via
|
|
199
|
+
// resolvePendingQuizAnswer (the one deliberate hole in "never expose the quiz's answer",
|
|
200
|
+
// scoped to exactly this call) and immediately folded into a safe, already-graded quizResult --
|
|
201
|
+
// nothing upstream of this method ever sees the raw correct-options set for an unanswered quiz.
|
|
202
|
+
const quizResult: DiscussionQuizResult | undefined =
|
|
203
|
+
selected && state.pendingIsQuiz && state.pendingQuizRoundNumber !== undefined
|
|
204
|
+
? (() => {
|
|
205
|
+
const hidden = this.rounds.resolvePendingQuizAnswer(discussionId, state.pendingQuizRoundNumber as number);
|
|
206
|
+
if (!hidden) {
|
|
207
|
+
throw new DiscussionError(`discussion "${discussionId}" has no recorded quiz answer for its pending round`);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
correct: gradeQuizAnswer(selected, hidden.correctOptions),
|
|
211
|
+
correctOptions: hidden.correctOptions,
|
|
212
|
+
explanation: hidden.explanation,
|
|
213
|
+
};
|
|
214
|
+
})()
|
|
215
|
+
: undefined;
|
|
147
216
|
const nextRound = state.roundCount + 1;
|
|
148
217
|
const round = this.rounds.append(
|
|
149
218
|
{
|
|
@@ -156,21 +225,28 @@ export class Discussions {
|
|
|
156
225
|
options: posed.options,
|
|
157
226
|
optionsMode: posed.mode,
|
|
158
227
|
...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
|
|
228
|
+
...(posed.quiz
|
|
229
|
+
? { quiz: true, quizCorrectOptions: posed.quiz.correctOptions, quizExplanation: posed.quiz.explanation }
|
|
230
|
+
: {}),
|
|
159
231
|
}
|
|
160
232
|
: {}),
|
|
161
233
|
...(selected ? { selected } : {}),
|
|
234
|
+
...(quizResult ? { quizResult } : {}),
|
|
162
235
|
},
|
|
163
236
|
new Date().toISOString(),
|
|
164
237
|
);
|
|
165
238
|
// Whenever this round answers the pending choice OR poses a new one, the base must drop ALL
|
|
166
|
-
//
|
|
239
|
+
// pending* fields first -- otherwise a re-pose that omits descriptions this time would
|
|
167
240
|
// leave a stale pendingOptionDescriptions array (sized for the OLD options) spread through
|
|
168
|
-
// unchanged, no longer aligned 1:1 with the new pendingOptions
|
|
169
|
-
//
|
|
241
|
+
// unchanged, no longer aligned 1:1 with the new pendingOptions (and likewise for a stale
|
|
242
|
+
// pendingIsQuiz/pendingQuizRoundNumber pointing at a round that's no longer pending). Only a
|
|
243
|
+
// plain reply that neither answers nor re-poses leaves the existing pending state untouched.
|
|
170
244
|
const {
|
|
171
245
|
pendingOptions: _clearedOptions,
|
|
172
246
|
pendingOptionsMode: _clearedMode,
|
|
173
247
|
pendingOptionDescriptions: _clearedDescriptions,
|
|
248
|
+
pendingIsQuiz: _clearedIsQuiz,
|
|
249
|
+
pendingQuizRoundNumber: _clearedQuizRoundNumber,
|
|
174
250
|
...withoutPending
|
|
175
251
|
} = state;
|
|
176
252
|
const nextState = {
|
|
@@ -181,6 +257,7 @@ export class Discussions {
|
|
|
181
257
|
pendingOptions: posed.options,
|
|
182
258
|
pendingOptionsMode: posed.mode,
|
|
183
259
|
...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
|
|
260
|
+
...(posed.quiz ? { pendingIsQuiz: true, pendingQuizRoundNumber: nextRound } : {}),
|
|
184
261
|
}
|
|
185
262
|
: {}),
|
|
186
263
|
};
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
DISCUSSION_OPTION_MAX_LENGTH,
|
|
26
26
|
DISCUSSION_OPTIONS_MAX_COUNT,
|
|
27
27
|
DISCUSSION_OPTIONS_MIN_COUNT,
|
|
28
|
+
DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS,
|
|
29
|
+
DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT,
|
|
28
30
|
DISCUSSION_ROUND_CONTENT_MAX_CHARACTERS,
|
|
29
31
|
DISCUSSION_SETTLEMENT_MAX_CHARACTERS,
|
|
30
32
|
} from "../constants.ts";
|
|
@@ -55,9 +57,25 @@ export interface DiscussionExtra {
|
|
|
55
57
|
* "no description for this one". Purely descriptive metadata: selection/validation only ever
|
|
56
58
|
* matches against pendingOptions itself, never against this array. */
|
|
57
59
|
pendingOptionDescriptions?: string[];
|
|
60
|
+
/** True when the currently pending posed choice is a graded quiz -- safe to expose (announces
|
|
61
|
+
* "this will be graded", never the answer itself, which never lives in extra.discussion at all;
|
|
62
|
+
* see AppendDiscussionRound's own comment for where the real answer is kept instead). */
|
|
63
|
+
pendingIsQuiz?: boolean;
|
|
64
|
+
/** The round number that posed the currently pending quiz -- lets reply() look up its hidden
|
|
65
|
+
* answer in O(1) via DiscussionRoundStore.resolvePendingQuizAnswer, without scanning history. */
|
|
66
|
+
pendingQuizRoundNumber?: number;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* One append-only round of a Discussion -- opening statement is round 1. options/optionsMode/selected
|
|
71
|
+
* are the historical record of what was posed/picked in this specific round (extra.discussion.pendingOptions
|
|
72
|
+
* is the separate, mutable "what's unanswered right now" cache).
|
|
73
|
+
*
|
|
74
|
+
* quiz/quizResult are the two quiz-safe fields: `quiz: true` marks a posing round as graded (never
|
|
75
|
+
* the correct answer itself -- that never appears on this type, by construction, see
|
|
76
|
+
* AppendDiscussionRound); `quizResult` appears only on the round that actually answered a pending
|
|
77
|
+
* quiz, once grading has already happened -- safe to reveal at that point since submission is done.
|
|
78
|
+
*/
|
|
61
79
|
export interface DiscussionRound {
|
|
62
80
|
id: number;
|
|
63
81
|
discussionId: string;
|
|
@@ -70,6 +88,19 @@ export interface DiscussionRound {
|
|
|
70
88
|
/** Index-aligned with options -- see DiscussionExtra.pendingOptionDescriptions. */
|
|
71
89
|
optionDescriptions?: string[];
|
|
72
90
|
selected?: string[];
|
|
91
|
+
/** True when this round posed a graded quiz alongside options/optionsMode. */
|
|
92
|
+
quiz?: boolean;
|
|
93
|
+
/** Present only on the round that answered a pending quiz -- the graded outcome, safe to show
|
|
94
|
+
* because the participant has already submitted by the time this exists. */
|
|
95
|
+
quizResult?: DiscussionQuizResult;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The graded outcome of one quiz submission, attached to the answering round. */
|
|
99
|
+
export interface DiscussionQuizResult {
|
|
100
|
+
correct: boolean;
|
|
101
|
+
/** The full correct set, drawn verbatim from the options that were offered. */
|
|
102
|
+
correctOptions: string[];
|
|
103
|
+
explanation: string;
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
export interface AppendDiscussionRound {
|
|
@@ -81,6 +112,20 @@ export interface AppendDiscussionRound {
|
|
|
81
112
|
optionsMode?: DiscussionOptionsMode;
|
|
82
113
|
optionDescriptions?: string[];
|
|
83
114
|
selected?: string[];
|
|
115
|
+
/** Marks this round as posing a graded quiz -- paired with quizCorrectOptions/quizExplanation below. */
|
|
116
|
+
quiz?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Write-only: the quiz's actual answer, persisted for grading but deliberately absent from
|
|
119
|
+
* DiscussionRound (the read-side type) -- a store implementation must never select these columns
|
|
120
|
+
* into anything it returns from list()/append()'s own return value. Only
|
|
121
|
+
* DiscussionRoundStore.resolvePendingQuizAnswer may ever read them back, from within the same
|
|
122
|
+
* reply() transaction that performs grading.
|
|
123
|
+
*/
|
|
124
|
+
quizCorrectOptions?: string[];
|
|
125
|
+
quizExplanation?: string;
|
|
126
|
+
/** The already-graded outcome to persist on an answering round -- computed by discussion-service.ts
|
|
127
|
+
* before this reaches the store, never derived by the store itself. */
|
|
128
|
+
quizResult?: DiscussionQuizResult;
|
|
84
129
|
}
|
|
85
130
|
|
|
86
131
|
export interface DiscussionRoundQuery {
|
|
@@ -150,7 +195,48 @@ export function validateDiscussionOptions(
|
|
|
150
195
|
};
|
|
151
196
|
}
|
|
152
197
|
|
|
153
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Validates a quiz's correct-answer + explanation against the options it's layered onto (already
|
|
200
|
+
* validated by validateDiscussionOptions). correctOptions is one or more entries drawn verbatim
|
|
201
|
+
* from options -- exact text match, the same identity scheme `selected` uses. A "single" quiz
|
|
202
|
+
* (the participant can only pick one) must have exactly one correct option; a "multi" quiz may
|
|
203
|
+
* have several. explanation is mandatory -- always shown after grading, never optional.
|
|
204
|
+
*/
|
|
205
|
+
export function validateDiscussionQuiz(
|
|
206
|
+
options: string[],
|
|
207
|
+
mode: DiscussionOptionsMode,
|
|
208
|
+
correctOptions: string[],
|
|
209
|
+
explanation: string,
|
|
210
|
+
): { correctOptions: string[]; explanation: string } {
|
|
211
|
+
if (correctOptions.length === 0) throw new Error("quiz correct_options must not be empty");
|
|
212
|
+
if (new Set(correctOptions).size !== correctOptions.length) throw new Error("quiz correct_options must not repeat an entry");
|
|
213
|
+
const unknown = correctOptions.filter((entry) => !options.includes(entry));
|
|
214
|
+
if (unknown.length > 0) throw new Error(`quiz correct_options must be among the offered options: ${unknown.join(", ")}`);
|
|
215
|
+
if (mode === "single" && correctOptions.length !== 1) {
|
|
216
|
+
throw new Error('a "single" quiz must have exactly one correct_option');
|
|
217
|
+
}
|
|
218
|
+
const validExplanation = boundedString(explanation, "explanation", DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS);
|
|
219
|
+
return { correctOptions: [...correctOptions], explanation: validExplanation };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Correct iff the participant's selection exactly matches the quiz's correct set -- no partial credit. */
|
|
223
|
+
export function gradeQuizAnswer(selected: string[], correctOptions: string[]): boolean {
|
|
224
|
+
return selected.length === correctOptions.length && selected.every((entry) => correctOptions.includes(entry));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Display-only label for a quiz option by its position (A, B, C, ...) -- never persisted, never
|
|
229
|
+
* part of a quiz's addressing scheme (options/correct_options/selected all match by exact text).
|
|
230
|
+
* Single letters only: see DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT's own comment for why that's
|
|
231
|
+
* always enough given the enforced DISCUSSION_OPTIONS_MAX_COUNT.
|
|
232
|
+
*/
|
|
233
|
+
export function quizOptionLabel(index: number): string {
|
|
234
|
+
if (index < 0 || index >= DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT) {
|
|
235
|
+
throw new Error(`quiz option index ${index} has no single-letter label (max ${DISCUSSION_QUIZ_OPTION_LABEL_MAX_COUNT} options)`);
|
|
236
|
+
}
|
|
237
|
+
return String.fromCharCode(65 + index);
|
|
238
|
+
}
|
|
239
|
+
|
|
154
240
|
export function validateSelectedOptions(
|
|
155
241
|
selected: string[],
|
|
156
242
|
pendingOptions: string[] | undefined,
|
|
@@ -202,6 +288,15 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
|
|
|
202
288
|
throw new Error("invalid Discussion pendingOptionDescriptions: must align 1:1 with pendingOptions");
|
|
203
289
|
}
|
|
204
290
|
}
|
|
291
|
+
const pendingIsQuiz = record.pendingIsQuiz;
|
|
292
|
+
if (pendingIsQuiz !== undefined && typeof pendingIsQuiz !== "boolean") throw new Error("invalid Discussion pendingIsQuiz");
|
|
293
|
+
const pendingQuizRoundNumber = record.pendingQuizRoundNumber;
|
|
294
|
+
if (
|
|
295
|
+
pendingQuizRoundNumber !== undefined &&
|
|
296
|
+
(typeof pendingQuizRoundNumber !== "number" || !Number.isInteger(pendingQuizRoundNumber) || pendingQuizRoundNumber < 1)
|
|
297
|
+
) {
|
|
298
|
+
throw new Error("invalid Discussion pendingQuizRoundNumber");
|
|
299
|
+
}
|
|
205
300
|
return {
|
|
206
301
|
state: state as DiscussionState,
|
|
207
302
|
roundCount,
|
|
@@ -211,5 +306,7 @@ export function readDiscussionExtra(extra: Record<string, unknown>): DiscussionE
|
|
|
211
306
|
...(pendingOptions !== undefined ? { pendingOptions: pendingOptions as string[] } : {}),
|
|
212
307
|
...(pendingOptionsMode !== undefined ? { pendingOptionsMode: pendingOptionsMode as DiscussionOptionsMode } : {}),
|
|
213
308
|
...(pendingOptionDescriptions !== undefined ? { pendingOptionDescriptions: pendingOptionDescriptions as string[] } : {}),
|
|
309
|
+
...(pendingIsQuiz !== undefined ? { pendingIsQuiz } : {}),
|
|
310
|
+
...(pendingQuizRoundNumber !== undefined ? { pendingQuizRoundNumber } : {}),
|
|
214
311
|
};
|
|
215
312
|
}
|
|
@@ -3,6 +3,7 @@ import type { Db } from "../db.ts";
|
|
|
3
3
|
import {
|
|
4
4
|
type AppendDiscussionRound,
|
|
5
5
|
type DiscussionOptionsMode,
|
|
6
|
+
type DiscussionQuizResult,
|
|
6
7
|
type DiscussionRound,
|
|
7
8
|
type DiscussionRoundQuery,
|
|
8
9
|
validateDiscussionActor,
|
|
@@ -11,6 +12,10 @@ import {
|
|
|
11
12
|
} from "../discussion/discussion.ts";
|
|
12
13
|
import type { DiscussionRoundStore } from "../discussion/discussion-round-store.ts";
|
|
13
14
|
|
|
15
|
+
// Deliberately excludes quiz_correct_options/quiz_explanation: those are the hidden columns only
|
|
16
|
+
// resolvePendingQuizAnswer's own dedicated, narrower query ever selects. This row type -- and the
|
|
17
|
+
// general-purpose SELECT below that populates it -- structurally cannot leak a quiz's answer,
|
|
18
|
+
// because the shape flowing through mapRow() never carries it in the first place.
|
|
14
19
|
interface DiscussionRoundRow {
|
|
15
20
|
id: number;
|
|
16
21
|
discussion_id: string;
|
|
@@ -22,9 +27,21 @@ interface DiscussionRoundRow {
|
|
|
22
27
|
options_mode: string | null;
|
|
23
28
|
selected: string | null;
|
|
24
29
|
option_descriptions: string | null;
|
|
30
|
+
quiz: number | null;
|
|
31
|
+
quiz_result_correct: number | null;
|
|
32
|
+
quiz_result_correct_options: string | null;
|
|
33
|
+
quiz_result_explanation: string | null;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
function mapRow(row: DiscussionRoundRow): DiscussionRound {
|
|
37
|
+
const quizResult: DiscussionQuizResult | undefined =
|
|
38
|
+
row.quiz_result_correct !== null
|
|
39
|
+
? {
|
|
40
|
+
correct: row.quiz_result_correct === 1,
|
|
41
|
+
correctOptions: JSON.parse(row.quiz_result_correct_options ?? "[]") as string[],
|
|
42
|
+
explanation: row.quiz_result_explanation ?? "",
|
|
43
|
+
}
|
|
44
|
+
: undefined;
|
|
28
45
|
return {
|
|
29
46
|
id: row.id,
|
|
30
47
|
discussionId: row.discussion_id,
|
|
@@ -36,6 +53,8 @@ function mapRow(row: DiscussionRoundRow): DiscussionRound {
|
|
|
36
53
|
...(row.options_mode !== null ? { optionsMode: row.options_mode as DiscussionOptionsMode } : {}),
|
|
37
54
|
...(row.selected !== null ? { selected: JSON.parse(row.selected) as string[] } : {}),
|
|
38
55
|
...(row.option_descriptions !== null ? { optionDescriptions: JSON.parse(row.option_descriptions) as string[] } : {}),
|
|
56
|
+
...(row.quiz === 1 ? { quiz: true } : {}),
|
|
57
|
+
...(quizResult ? { quizResult } : {}),
|
|
39
58
|
};
|
|
40
59
|
}
|
|
41
60
|
|
|
@@ -52,10 +71,18 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
52
71
|
round.options !== undefined || round.optionsMode !== undefined
|
|
53
72
|
? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "", round.optionDescriptions)
|
|
54
73
|
: undefined;
|
|
74
|
+
// quizCorrectOptions/quizExplanation are NOT re-validated here -- discussion-service.ts already
|
|
75
|
+
// ran validateDiscussionQuiz before this reaches the store, same trust boundary as `selected`
|
|
76
|
+
// above. quizResult (the already-graded outcome) is likewise computed upstream, never here.
|
|
55
77
|
const result = this.db
|
|
56
78
|
.prepare(`
|
|
57
|
-
INSERT INTO discussion_rounds (
|
|
58
|
-
|
|
79
|
+
INSERT INTO discussion_rounds (
|
|
80
|
+
discussion_id, round_number, actor, content, occurred_at, event_schema_version,
|
|
81
|
+
options, options_mode, selected, option_descriptions,
|
|
82
|
+
quiz, quiz_correct_options, quiz_explanation,
|
|
83
|
+
quiz_result_correct, quiz_result_correct_options, quiz_result_explanation
|
|
84
|
+
)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
59
86
|
`)
|
|
60
87
|
.run(
|
|
61
88
|
round.discussionId,
|
|
@@ -67,6 +94,12 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
67
94
|
posed ? posed.mode : null,
|
|
68
95
|
round.selected !== undefined ? JSON.stringify(round.selected) : null,
|
|
69
96
|
posed?.optionDescriptions ? JSON.stringify(posed.optionDescriptions) : null,
|
|
97
|
+
round.quiz ? 1 : null,
|
|
98
|
+
round.quizCorrectOptions !== undefined ? JSON.stringify(round.quizCorrectOptions) : null,
|
|
99
|
+
round.quizExplanation ?? null,
|
|
100
|
+
round.quizResult !== undefined ? (round.quizResult.correct ? 1 : 0) : null,
|
|
101
|
+
round.quizResult !== undefined ? JSON.stringify(round.quizResult.correctOptions) : null,
|
|
102
|
+
round.quizResult?.explanation ?? null,
|
|
70
103
|
);
|
|
71
104
|
return {
|
|
72
105
|
id: Number(result.lastInsertRowid),
|
|
@@ -78,6 +111,10 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
78
111
|
...(posed ? { options: posed.options, optionsMode: posed.mode } : {}),
|
|
79
112
|
...(posed?.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
|
|
80
113
|
...(round.selected !== undefined ? { selected: [...round.selected] } : {}),
|
|
114
|
+
...(round.quiz ? { quiz: true as const } : {}),
|
|
115
|
+
...(round.quizResult !== undefined
|
|
116
|
+
? { quizResult: { ...round.quizResult, correctOptions: [...round.quizResult.correctOptions] } }
|
|
117
|
+
: {}),
|
|
81
118
|
};
|
|
82
119
|
}
|
|
83
120
|
|
|
@@ -85,7 +122,8 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
85
122
|
const limit = Math.min(DISCUSSION_ROUNDS_MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DISCUSSION_ROUNDS_DEFAULT_LIMIT)));
|
|
86
123
|
const rows = this.db
|
|
87
124
|
.prepare(`
|
|
88
|
-
SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected, option_descriptions
|
|
125
|
+
SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected, option_descriptions,
|
|
126
|
+
quiz, quiz_result_correct, quiz_result_correct_options, quiz_result_explanation
|
|
89
127
|
FROM discussion_rounds
|
|
90
128
|
WHERE discussion_id = ? AND round_number > ?
|
|
91
129
|
ORDER BY round_number ASC
|
|
@@ -98,4 +136,14 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
|
|
|
98
136
|
count(discussionId: string): number {
|
|
99
137
|
return (this.db.prepare("SELECT COUNT(*) AS c FROM discussion_rounds WHERE discussion_id = ?").get(discussionId) as { c: number }).c;
|
|
100
138
|
}
|
|
139
|
+
|
|
140
|
+
// The one place quiz_correct_options/quiz_explanation are ever read back -- a narrow, dedicated
|
|
141
|
+
// query naming exactly those two hidden columns, deliberately separate from list()'s own SELECT.
|
|
142
|
+
resolvePendingQuizAnswer(discussionId: string, roundNumber: number): { correctOptions: string[]; explanation: string } | undefined {
|
|
143
|
+
const row = this.db
|
|
144
|
+
.prepare("SELECT quiz_correct_options, quiz_explanation FROM discussion_rounds WHERE discussion_id = ? AND round_number = ?")
|
|
145
|
+
.get(discussionId, roundNumber) as { quiz_correct_options: string | null; quiz_explanation: string | null } | undefined;
|
|
146
|
+
if (!row || row.quiz_correct_options === null || row.quiz_explanation === null) return undefined;
|
|
147
|
+
return { correctOptions: JSON.parse(row.quiz_correct_options) as string[], explanation: row.quiz_explanation };
|
|
148
|
+
}
|
|
101
149
|
}
|
package/src/handlers/discuss.ts
CHANGED
|
@@ -17,8 +17,13 @@ import type { VehicleContentBlock } from "@danypops/vehicle-core";
|
|
|
17
17
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
18
18
|
import type { Artifact } from "../artifact/artifact.ts";
|
|
19
19
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
20
|
-
import {
|
|
21
|
-
|
|
20
|
+
import {
|
|
21
|
+
DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH,
|
|
22
|
+
DISCUSSION_OPTION_MAX_LENGTH,
|
|
23
|
+
DISCUSSION_OPTIONS_MAX_COUNT,
|
|
24
|
+
DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS,
|
|
25
|
+
} from "../constants.ts";
|
|
26
|
+
import { DISCUSSION_SUBTYPE, type DiscussionRound, quizOptionLabel } from "../discussion/discussion.ts";
|
|
22
27
|
import type { DiscussionAndRounds, Discussions } from "../discussion/discussion-service.ts";
|
|
23
28
|
import { discussOperations } from "../modules/discuss.ts";
|
|
24
29
|
import {
|
|
@@ -43,6 +48,19 @@ function roundsTranscript(rounds: readonly DiscussionRound[]): string {
|
|
|
43
48
|
return rounds.map((round) => ` [round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n") || " (no rounds)";
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
/** Lettered rendering of a quiz round's options (A, B, C, ...) -- never shown for a plain, non-quiz
|
|
52
|
+
* posed choice, which keeps its existing comma-joined presentation unchanged (backward compatible). */
|
|
53
|
+
function formatQuizOptions(options: readonly string[]): string {
|
|
54
|
+
return options.map((option, index) => `${quizOptionLabel(index)}. ${option}`).join(" ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Appended to reply's own content block once a submission answered a pending quiz -- always states
|
|
58
|
+
* correct/incorrect and always includes the explanation (never optional, especially when wrong). */
|
|
59
|
+
function formatQuizVerdict(result: { correct: boolean; correctOptions: string[]; explanation: string }): string {
|
|
60
|
+
const verdict = result.correct ? "✅ Correct!" : `❌ Incorrect -- correct answer(s): ${result.correctOptions.join(", ")}.`;
|
|
61
|
+
return `${verdict} ${result.explanation}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
/**
|
|
47
65
|
* Resolves a discussion's id from either an explicit id or its exact title.
|
|
48
66
|
* Discussions.list() has no project-scoping concept at all (unlike Tasks),
|
|
@@ -129,6 +147,12 @@ const optionsUnionSchema = { type: "array" } as const;
|
|
|
129
147
|
*/
|
|
130
148
|
const OPTION_BOUNDS_TEXT = `Each option is at most ${DISCUSSION_OPTION_MAX_LENGTH} characters (up to ${DISCUSSION_OPTIONS_MAX_COUNT} total); each description is at most ${DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH} characters.`;
|
|
131
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Quiz support layered onto the same options/options_mode a plain posed choice already uses.
|
|
152
|
+
* Interpolates the real, enforced explanation bound the same way OPTION_BOUNDS_TEXT does for options.
|
|
153
|
+
*/
|
|
154
|
+
const QUIZ_BOUNDS_TEXT = `To make it a graded quiz/knowledge assessment instead of a plain posed choice, also pass correct_options (one or more entries drawn verbatim from options -- exact text, not an index or display letter) + explanation (REQUIRED, at most ${DISCUSSION_QUIZ_EXPLANATION_MAX_CHARACTERS} characters, always shown after grading -- especially when wrong). A "single" quiz needs exactly one correct option; "multi" allows several, graded correct iff the reply's selected set exactly matches (no partial credit). Options display as lettered choices (A, B, C, ...) once a quiz is posed.`;
|
|
155
|
+
|
|
132
156
|
export function registerDiscussVehicleOperations(registry: VehicleRegistry, discussions: Discussions, artifacts: ArtifactStore): void {
|
|
133
157
|
const moduleOperations = new Map(discussOperations(discussions).map((op) => [op.name, op]));
|
|
134
158
|
const call = <Output>(name: string, input: Record<string, unknown>): Output => moduleOperations.get(name)!.execute(input) as Output;
|
|
@@ -163,7 +187,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
|
|
|
163
187
|
|
|
164
188
|
define(
|
|
165
189
|
"open",
|
|
166
|
-
`Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). ${OPTION_BOUNDS_TEXT} Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
|
|
190
|
+
`Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). ${OPTION_BOUNDS_TEXT} ${QUIZ_BOUNDS_TEXT} Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
|
|
167
191
|
"local-write",
|
|
168
192
|
{
|
|
169
193
|
title: stringProp,
|
|
@@ -176,6 +200,8 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
|
|
|
176
200
|
options: optionsUnionSchema,
|
|
177
201
|
options_mode: { type: "string", enum: ["single", "multi"] },
|
|
178
202
|
option_descriptions: arrayProp,
|
|
203
|
+
correct_options: arrayProp,
|
|
204
|
+
explanation: stringProp,
|
|
179
205
|
live: boolProp,
|
|
180
206
|
source: stringProp,
|
|
181
207
|
session_id: stringProp,
|
|
@@ -189,13 +215,15 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
|
|
|
189
215
|
},
|
|
190
216
|
(raw) => {
|
|
191
217
|
const result = raw as DiscussionAndRounds;
|
|
192
|
-
|
|
218
|
+
const posed = result.rounds[0];
|
|
219
|
+
const quizNote = posed?.quiz && posed.options ? `\n${formatQuizOptions(posed.options)}` : "";
|
|
220
|
+
return { ...result, content: [contentBlock(`Opened discussion ${artifactLine(result.discussion)}${quizNote}`)] };
|
|
193
221
|
},
|
|
194
222
|
);
|
|
195
223
|
|
|
196
224
|
define(
|
|
197
225
|
"reply",
|
|
198
|
-
`Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via \`selected\` (validated against it),
|
|
226
|
+
`Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via \`selected\` (validated against it) -- if that choice was a quiz, the answer is graded automatically and the verdict + explanation come back in this same call. Or poses a new choice via options/options_mode. ${OPTION_BOUNDS_TEXT} ${QUIZ_BOUNDS_TEXT} Prefer \`name\` over \`id\`. Pass live:true to get a human's answer synchronously in this same call, via the pending choice's picker if one was posed, otherwise a freeform question -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
|
|
199
227
|
"local-write",
|
|
200
228
|
{
|
|
201
229
|
id: stringProp,
|
|
@@ -206,6 +234,8 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
|
|
|
206
234
|
options: optionsUnionSchema,
|
|
207
235
|
options_mode: { type: "string", enum: ["single", "multi"] },
|
|
208
236
|
option_descriptions: arrayProp,
|
|
237
|
+
correct_options: arrayProp,
|
|
238
|
+
explanation: stringProp,
|
|
209
239
|
live: boolProp,
|
|
210
240
|
source: stringProp,
|
|
211
241
|
session_id: stringProp,
|
|
@@ -218,9 +248,12 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
|
|
|
218
248
|
},
|
|
219
249
|
(raw) => {
|
|
220
250
|
const result = raw as DiscussionAndRounds;
|
|
251
|
+
const answered = result.rounds[0];
|
|
252
|
+
const posed = answered?.quiz && answered.options ? `\n${formatQuizOptions(answered.options)}` : "";
|
|
253
|
+
const verdict = answered?.quizResult ? `\n${formatQuizVerdict(answered.quizResult)}` : "";
|
|
221
254
|
return {
|
|
222
255
|
...result,
|
|
223
|
-
content: [contentBlock(`Round ${
|
|
256
|
+
content: [contentBlock(`Round ${answered?.roundNumber} added to "${result.discussion.title}"${posed}${verdict}`)],
|
|
224
257
|
};
|
|
225
258
|
},
|
|
226
259
|
);
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,13 @@ export {
|
|
|
17
17
|
type VehicleClientTarget,
|
|
18
18
|
} from "./client.ts";
|
|
19
19
|
export * from "./constants.ts";
|
|
20
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
DISCUSSION_SUBTYPE,
|
|
22
|
+
type DiscussionQuizResult,
|
|
23
|
+
type DiscussionRound,
|
|
24
|
+
quizOptionLabel,
|
|
25
|
+
readDiscussionExtra,
|
|
26
|
+
} from "./discussion/discussion.ts";
|
|
21
27
|
export type { DiscussionAndRounds } from "./discussion/discussion-service.ts";
|
|
22
28
|
export type { DisplayGraph, DisplayGraphEdge, DisplayGraphNode, RenderedGraph } from "./display-graph/display-graph.ts";
|
|
23
29
|
export type { GraphRenderer } from "./display-graph/graph-renderer.ts";
|
package/src/modules/discuss.ts
CHANGED
|
@@ -76,6 +76,8 @@ export function discussOperations(discussions: Discussions): OperationDefinition
|
|
|
76
76
|
options: optionalStringArray(input, "options"),
|
|
77
77
|
optionsMode: optionsMode(input),
|
|
78
78
|
optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
|
|
79
|
+
quizCorrectOptions: optionalStringArray(input, "correct_options") ?? optionalStringArray(input, "correctOptions"),
|
|
80
|
+
quizExplanation: optionalString(input, "explanation"),
|
|
79
81
|
},
|
|
80
82
|
eventContext(input),
|
|
81
83
|
),
|
|
@@ -90,6 +92,8 @@ export function discussOperations(discussions: Discussions): OperationDefinition
|
|
|
90
92
|
options: optionalStringArray(input, "options"),
|
|
91
93
|
optionsMode: optionsMode(input),
|
|
92
94
|
optionDescriptions: optionalStringArray(input, "option_descriptions") ?? optionalStringArray(input, "optionDescriptions"),
|
|
95
|
+
quizCorrectOptions: optionalStringArray(input, "correct_options") ?? optionalStringArray(input, "correctOptions"),
|
|
96
|
+
quizExplanation: optionalString(input, "explanation"),
|
|
93
97
|
},
|
|
94
98
|
eventContext(input),
|
|
95
99
|
),
|