['type' => 'string', 'enum' => [$kind]], 'values' => ['type' => 'object', 'properties' => $properties, 'additionalProperties' => false, 'minProperties' => 1], 'record_date' => ['type' => ['string', 'null'], 'pattern' => '^\\d{4}-\\d{2}-\\d{2}$'], 'record_time' => ['type' => ['string', 'null'], 'pattern' => '^(?:[01]\\d|2[0-3]):[0-5]\\d$'], 'date_text' => ['type' => 'string'], 'time_text' => ['type' => 'string'], 'time_period' => ['type' => ['string', 'null'], 'enum' => array_merge(FollowupAudioPolicy::PERIODS, [null])], 'time_estimated' => ['type' => 'boolean'], 'needs_review' => ['type' => 'boolean'], 'evidence_ids' => ['type' => 'array', 'minItems' => 1, 'maxItems' => FollowupAudioTranscriptPrompt::MAX_CITATIONS, 'items' => ['type' => 'string', 'enum' => $ids]], ]; $branches[] = ['type' => 'object', 'properties' => $fields, 'required' => array_keys($fields), 'additionalProperties' => false]; } if ($branches === []) { throw new FollowupAudioException('CONFIG_INVALID'); } $top = ['schema_version' => ['type' => 'string', 'enum' => [self::VERSION]], 'summary' => ['type' => 'string', 'maxLength' => 60000], 'uncertainties' => ['type' => 'array', 'maxItems' => 200, 'items' => ['type' => 'string', 'maxLength' => 10000]], 'items' => ['type' => 'array', 'maxItems' => 500, 'items' => ['anyOf' => $branches]]]; // vLLM rejects uniqueItems; duplicate IDs and option values remain rejected by server validators. // strict=true requires every declared values property to be required on OpenAI. That would fabricate missing facts. // We deliberately keep sparse optional values; no automatic fallback when a provider rejects this explicit mode. return ['type' => 'json_schema', 'json_schema' => ['name' => 'followup_audio_transcript_v2', 'strict' => false, 'schema' => ['type' => 'object', 'properties' => $top, 'required' => array_keys($top), 'additionalProperties' => false]]]; } private static function field(array $field): ?array { if (!is_string($field['key'] ?? null) || !preg_match('/^[a-z][a-z0-9_]*$/D', $field['key'])) { throw new FollowupAudioException('CONFIG_INVALID'); } $type = $field['type'] ?? ''; if ($type === 'number') { // The current catalog omits bounds/integer hints; preserve the established integer destinations explicitly. $integer = !empty($field['integer']) || in_array($field['key'], ['age', 'height', 'systolic_pressure', 'diastolic_pressure', 'duration'], true); $schema = ['type' => $integer ? 'integer' : 'number']; if (isset($field['min'])) { $schema['minimum'] = $field['min']; } if (isset($field['max'])) { $schema['maximum'] = $field['max']; } return $schema; } if (in_array($type, ['select', 'multiselect'], true)) { $values = []; foreach ($field['options'] ?? [] as $option) { $value = $option['value'] ?? null; if (!is_int($value) && !is_string($value)) { throw new FollowupAudioException('CONFIG_INVALID'); } if (!in_array($value, $values, true)) { $values[] = $value; } } if ($values === []) { return null; } // No valid dictionary choice exists: omit this optional field entirely. $enum = ['enum' => $values]; return $type === 'select' ? $enum : ['type' => 'array', 'items' => $enum, 'minItems' => 1, 'maxItems' => 100]; } if ($type === 'date') { return ['type' => 'string', 'pattern' => '^\\d{4}-\\d{2}-\\d{2}$']; } if ($type !== 'text') { throw new FollowupAudioException('CONFIG_INVALID'); } return ['type' => 'string', 'minLength' => 1, 'maxLength' => (int) ($field['max'] ?? 10000)]; } /** V2 never repairs numeric strings, guessed choices, foreign kind names, or empty/synthetic fields. */ public static function validateValues(string $kind, array $values, array $catalog): void { if (!in_array($kind, self::KINDS, true) || !isset($catalog[$kind]) || $values === [] || array_is_list($values)) { self::invalid(); } $fields = array_column($catalog[$kind], null, 'key'); foreach ($values as $key => $value) { if (!isset($fields[$key]) || ($schema = self::field($fields[$key])) === null) { self::invalid(); } if (isset($schema['enum'])) { if (!in_array($value, $schema['enum'], true)) { self::invalid(); } continue; } $type = $schema['type']; if ($type === 'number' || $type === 'integer') { if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || ($type === 'integer' && floor((float) $value) !== (float) $value)) { self::invalid(); } } elseif ($type === 'array') { if (!is_array($value) || !array_is_list($value) || $value === [] || count($value) > 100) { self::invalid(); } $seen = []; foreach ($value as $choice) { if (!in_array($choice, $schema['items']['enum'], true) || in_array($choice, $seen, true)) { self::invalid(); } $seen[] = $choice; } } elseif (!is_string($value) || trim($value) === '') { self::invalid(); } } } private static function invalid(): void { throw new FollowupAudioException('UPSTREAM_SCHEMA_INVALID'); } }