Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 | /** * Worksheet Parsing Schemas * * These Zod schemas define the structure of LLM responses when parsing * abacus workbook pages. The .describe() annotations are critical - * they are automatically extracted and included in the LLM prompt. */ import { z } from 'zod' /** * Bounding box in normalized coordinates (0-1) * Represents a rectangular region on the worksheet image */ export const BoundingBoxSchema = z .object({ x: z .number() .min(0) .max(1) .describe( 'Left edge of the box as a fraction of image width (0 = left edge, 1 = right edge)' ), y: z .number() .min(0) .max(1) .describe( 'Top edge of the box as a fraction of image height (0 = top edge, 1 = bottom edge)' ), width: z.number().min(0).max(1).describe('Width of the box as a fraction of image width'), height: z.number().min(0).max(1).describe('Height of the box as a fraction of image height'), }) .describe('Rectangular region on the worksheet image, in normalized 0-1 coordinates') export type BoundingBox = z.infer<typeof BoundingBoxSchema> /** * Problem format detected in the worksheet */ export const ProblemFormatSchema = z .enum(['vertical', 'linear']) .describe( 'Format of the problem: "vertical" for stacked column addition/subtraction with answer box below, ' + '"linear" for horizontal format like "a + b - c = ___"' ) export type ProblemFormat = z.infer<typeof ProblemFormatSchema> /** * Single term in a problem (number with operation) */ export const ProblemTermSchema = z .number() .int() .describe( 'A single term in the problem. Positive numbers represent addition, ' + 'negative numbers represent subtraction. The first term is always positive. ' + 'Example: for "45 - 17 + 8", terms are [45, -17, 8]' ) /** * A single parsed problem from the worksheet */ export const ParsedProblemSchema = z .object({ // Identification problemNumber: z .number() .int() .min(1) .describe('The problem number as printed on the worksheet (1, 2, 3, etc.)'), row: z .number() .int() .min(1) .describe('Which row of problems this belongs to (1 = top row, 2 = second row, etc.)'), column: z .number() .int() .min(1) .describe('Which column position in the row (1 = leftmost, counting right)'), // Problem content format: ProblemFormatSchema, terms: z .array(ProblemTermSchema) .min(2) .max(7) .describe( 'All terms in the problem, in order. First term is positive. ' + 'Subsequent terms are positive for addition, negative for subtraction. ' + 'Example: "45 - 17 + 8" → [45, -17, 8]' ), correctAnswer: z.number().int().describe('The mathematically correct answer to this problem'), // Student work studentAnswer: z .number() .int() .nullable() .describe( 'The answer the student wrote, if readable. Null if the answer box is empty, ' + "illegible, or you cannot confidently read the student's handwriting" ), studentAnswerConfidence: z .number() .min(0) .max(1) .describe( "Confidence in reading the student's answer (0 = not readable/empty, 1 = perfectly clear). " + 'Use 0.5-0.7 for somewhat legible, 0.8-0.9 for mostly clear, 1.0 for crystal clear' ), // Problem extraction confidence termsConfidence: z .number() .min(0) .max(1) .describe( 'Confidence in correctly reading all the problem terms (0 = very unsure, 1 = certain). ' + 'Lower confidence if digits are smudged, cropped, or partially obscured' ), // Optional notes explaining low confidence or issues notes: z .string() .nullable() .describe( 'Brief explanation if confidence is low. Examples: "Answer partially obscured by smudge", ' + '"Digit could be 7 or 1", "Answer box appears empty". Null if confidence is high.' ), // Bounding boxes for UI highlighting problemBoundingBox: BoundingBoxSchema.describe( 'Bounding box around the entire problem (including all terms and answer area)' ), answerBoundingBox: BoundingBoxSchema.nullable().describe( "Bounding box around just the student's answer area. Null if no answer area is visible" ), // User exclusion (set by corrections, not LLM) excluded: z .boolean() .default(false) .describe('True if user marked this problem to be excluded from the session'), // Review workflow state (set by user during review, not LLM) reviewStatus: z .enum(['pending', 'approved', 'corrected', 'flagged']) .default('pending') .describe( 'User review status: "pending" = not yet reviewed, "approved" = user confirmed correct, ' + '"corrected" = user made corrections, "flagged" = user marked for re-scan' ), reviewedAt: z .string() .nullable() .default(null) .describe('ISO timestamp when this problem was reviewed. Null if not yet reviewed'), }) .describe('A single arithmetic problem extracted from the worksheet') export type ParsedProblem = z.infer<typeof ParsedProblemSchema> /** * Detected worksheet format */ export const WorksheetFormatSchema = z .enum(['vertical', 'linear', 'mixed']) .describe( 'Overall format of problems on this page: ' + '"vertical" if all problems are stacked column format, ' + '"linear" if all are horizontal equation format, ' + '"mixed" if the page contains both formats' ) /** * Page metadata extracted from the worksheet */ export const PageMetadataSchema = z .object({ lessonId: z .string() .nullable() .describe( 'Lesson identifier if printed on the page (e.g., "Lesson 5", "L5", "Unit 2 Lesson 3"). ' + 'Null if no lesson identifier is visible' ), weekId: z .string() .nullable() .describe( 'Week identifier if printed on the page (e.g., "Week 4", "W4"). ' + 'Null if no week identifier is visible' ), pageNumber: z .number() .int() .nullable() .describe('Page number if printed on the page. Null if no page number is visible'), detectedFormat: WorksheetFormatSchema, totalRows: z .number() .int() .min(1) .max(6) .describe('Number of rows of problems on this page (typically 1-4)'), problemsPerRow: z .number() .int() .min(1) .max(12) .describe('Average number of problems per row (typically 8-10)'), }) .describe('Metadata about the worksheet page layout and identifiers') export type PageMetadata = z.infer<typeof PageMetadataSchema> /** * Complete worksheet parsing result */ export const WorksheetParsingResultSchema = z .object({ problems: z .array(ParsedProblemSchema) .min(1) .describe( 'All problems detected on the worksheet, in reading order (left to right, top to bottom)' ), pageMetadata: PageMetadataSchema, overallConfidence: z .number() .min(0) .max(1) .describe( 'Overall confidence in the parsing accuracy (0 = very uncertain, 1 = highly confident). ' + 'Based on image quality, problem clarity, and answer legibility' ), warnings: z .array(z.string()) .describe( 'List of issues encountered during parsing, such as: ' + '"Problem 5 terms partially obscured", ' + '"Row 2 problems may be cropped", ' + '"Student handwriting difficult to read on problems 3, 7, 12"' ), needsReview: z .boolean() .describe( 'True if any problems have low confidence or warnings that require human review ' + 'before creating a practice session' ), }) .describe('Complete result of parsing an abacus workbook page') export type WorksheetParsingResult = z.infer<typeof WorksheetParsingResultSchema> /** * User correction to a parsed problem */ export const ProblemCorrectionSchema = z .object({ problemNumber: z.number().int().min(1).describe('The problem number being corrected'), correctedTerms: z .array(ProblemTermSchema) .nullable() .optional() .describe('Corrected terms if the LLM got them wrong. Omit or null to keep original'), correctedStudentAnswer: z .number() .int() .nullable() .optional() .describe('Corrected student answer. Null means empty/not answered. Omit to keep original'), shouldExclude: z .boolean() .optional() .describe('True to exclude this problem from the session (e.g., illegible)'), shouldRestore: z.boolean().optional().describe('True to restore a previously excluded problem'), note: z.string().nullable().optional().describe('Optional note explaining the correction'), }) .describe('User correction to a single parsed problem') export type ProblemCorrection = z.infer<typeof ProblemCorrectionSchema> /** * Request to re-parse with additional context */ export const ReparseRequestSchema = z .object({ problemNumbers: z.array(z.number().int().min(1)).describe('Which problems to re-parse'), additionalContext: z .string() .describe( 'Additional instructions for the LLM, such as: ' + '"The student writes 7s with a line through them", ' + '"Problem 5 has a 3-digit answer, not 2-digit"' ), }) .describe('Request to re-parse specific problems with additional context') export type ReparseRequest = z.infer<typeof ReparseRequestSchema> /** * Review progress for resumable worksheet review workflow * * Stored per-attachment to track where the user left off in the review process. * Enables users to close the browser and resume later. */ export const ReviewProgressSchema = z .object({ status: z .enum(['not_started', 'in_progress', 'completed']) .describe('Overall review status for this worksheet'), currentIndex: z .number() .int() .min(0) .describe('Index of the next problem to review (for resuming). 0-based.'), lastReviewedAt: z .string() .nullable() .describe('ISO timestamp of last review action. Null if not started'), // Counts for quick display (avoid recalculating from problems array) autoApprovedCount: z .number() .int() .min(0) .describe('Number of problems auto-approved due to high confidence'), manuallyReviewedCount: z .number() .int() .min(0) .describe('Number of problems manually reviewed by user'), flaggedCount: z .number() .int() .min(0) .describe('Number of problems flagged as needing attention'), correctedCount: z .number() .int() .min(0) .describe('Number of problems that were corrected by user'), }) .describe('Review workflow progress state for resumable reviews') export type ReviewProgress = z.infer<typeof ReviewProgressSchema> /** * Create initial review progress for a newly parsed worksheet */ export function createInitialReviewProgress( problemCount: number, autoApprovedCount: number ): ReviewProgress { return { status: autoApprovedCount === problemCount ? 'completed' : 'not_started', currentIndex: 0, lastReviewedAt: null, autoApprovedCount, manuallyReviewedCount: 0, flaggedCount: problemCount - autoApprovedCount, // Problems that need review correctedCount: 0, } } |