|
| 1 | +import { isDeepStrictEqual } from 'node:util' |
1 | 2 | import { z } from 'zod' |
| 3 | +import type { ToolResponseContext } from '@/tools/types' |
2 | 4 | import type { |
| 5 | + TypeSafeAnswer, |
3 | 6 | TypeSafeBaseParams, |
4 | 7 | TypeSafeEntry, |
5 | 8 | TypeSafeQuestion, |
@@ -51,6 +54,7 @@ const questionsSchema = z |
51 | 54 | .refine((value) => Object.keys(value).length > 0, { |
52 | 55 | message: 'Questions must contain at least one named question', |
53 | 56 | }) |
| 57 | +const requestQuestionsSchema = z.object({ questions: questionsSchema }) |
54 | 58 | const probabilitySchema = z.number().min(0).max(1) |
55 | 59 | const probabilitiesSchema = z |
56 | 60 | .record(z.string(), probabilitySchema) |
@@ -156,23 +160,72 @@ export function buildTypeSafeRequest( |
156 | 160 | return { state: normalizeTypeSafeEntry(params.state, 'state'), model, questions } |
157 | 161 | } |
158 | 162 |
|
159 | | -/** Reads typed answers and verifies that every requested question has an answer of the same type. */ |
| 163 | +function hasExactKeys(value: Record<string, unknown>, keys: string[]): boolean { |
| 164 | + return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)) |
| 165 | +} |
| 166 | + |
| 167 | +function validateAnswerCriteria( |
| 168 | + name: string, |
| 169 | + question: TypeSafeQuestion, |
| 170 | + answer: TypeSafeAnswer |
| 171 | +): void { |
| 172 | + if (question.type === 'choice' && answer.type === 'choice') { |
| 173 | + if (!Object.hasOwn(question.criteria, answer.choice)) { |
| 174 | + throw new Error(`TypeSafe response choice is not a requested option for question "${name}"`) |
| 175 | + } |
| 176 | + if (!hasExactKeys(answer.probabilities, Object.keys(question.criteria))) { |
| 177 | + throw new Error( |
| 178 | + `TypeSafe response probabilities do not match the options for question "${name}"` |
| 179 | + ) |
| 180 | + } |
| 181 | + } |
| 182 | + if (question.type === 'score' && answer.type === 'score') { |
| 183 | + const levels = question.criteria.map((_, index) => String(index)) |
| 184 | + if (answer.score > question.criteria.length - 1) { |
| 185 | + throw new Error(`TypeSafe response score exceeds the rubric for question "${name}"`) |
| 186 | + } |
| 187 | + if (!hasExactKeys(answer.probabilities, levels)) { |
| 188 | + throw new Error( |
| 189 | + `TypeSafe response probabilities do not match the rubric for question "${name}"` |
| 190 | + ) |
| 191 | + } |
| 192 | + if ( |
| 193 | + !hasExactKeys(answer.legend, levels) || |
| 194 | + levels.some( |
| 195 | + (level, index) => !isDeepStrictEqual(answer.legend[level], question.criteria[index]) |
| 196 | + ) |
| 197 | + ) { |
| 198 | + throw new Error(`TypeSafe response legend does not match the rubric for question "${name}"`) |
| 199 | + } |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +/** Validates typed answers against the actual projected request, including its criteria. */ |
160 | 204 | export async function readTypeSafeResponse( |
161 | 205 | response: Response, |
162 | | - expectedQuestions?: Record<string, { type: TypeSafeQuestion['type'] }> |
| 206 | + expectedQuestions?: TypeSafeQuestions, |
| 207 | + context?: ToolResponseContext |
163 | 208 | ): Promise<TypeSafeResult> { |
164 | 209 | const data = validate(resultSchema, await response.json(), 'response') |
165 | | - if (expectedQuestions) { |
166 | | - for (const [name, question] of Object.entries(expectedQuestions)) { |
167 | | - if (!Object.hasOwn(data.answers, name) || data.answers[name].type !== question.type) { |
168 | | - throw new Error( |
169 | | - `TypeSafe response is missing the ${question.type} answer for question "${name}"` |
170 | | - ) |
171 | | - } |
172 | | - } |
173 | | - if (Object.keys(data.answers).length !== Object.keys(expectedQuestions).length) { |
174 | | - throw new Error('TypeSafe response question IDs do not match the request') |
| 210 | + const questions = |
| 211 | + context?.requestBody === undefined |
| 212 | + ? expectedQuestions |
| 213 | + : validate( |
| 214 | + requestQuestionsSchema, |
| 215 | + parseTypeSafeJson(context.requestBody, 'request body'), |
| 216 | + 'request body' |
| 217 | + ).questions |
| 218 | + if (!questions) throw new Error('TypeSafe response validation requires the request questions') |
| 219 | + for (const [name, question] of Object.entries(questions)) { |
| 220 | + if (!Object.hasOwn(data.answers, name) || data.answers[name].type !== question.type) { |
| 221 | + throw new Error( |
| 222 | + `TypeSafe response is missing the ${question.type} answer for question "${name}"` |
| 223 | + ) |
175 | 224 | } |
| 225 | + validateAnswerCriteria(name, question, data.answers[name]) |
| 226 | + } |
| 227 | + if (Object.keys(data.answers).length !== Object.keys(questions).length) { |
| 228 | + throw new Error('TypeSafe response question IDs do not match the request') |
176 | 229 | } |
177 | 230 | return data |
178 | 231 | } |
0 commit comments