113 lines
7.0 KiB
PHP
113 lines
7.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\followupaudio;
|
|
|
|
/** Sparse per-kind output contract. Server validation remains authoritative after constrained generation. */
|
|
final class FollowupAudioExtractionSchema
|
|
{
|
|
public const VERSION = 'followup-audio-transcript-v2';
|
|
public const DIALECT = 'vllm-json-schema-no-unique-items-v1';
|
|
public const KINDS = ['diagnosis', 'blood', 'diet', 'exercise', 'tracking_note'];
|
|
|
|
public static function responseFormat(array $catalog, array $citations): array
|
|
{
|
|
$ids = [];
|
|
foreach ($citations as $citation) {
|
|
if (!is_array($citation) || !is_string($citation['id'] ?? null) || !preg_match('/^c_[a-f0-9]{16,64}$/D', $citation['id'])
|
|
|| !is_string($citation['segment_id'] ?? null) || !is_string($citation['text'] ?? null)
|
|
|| trim($citation['text']) === '' || in_array($citation['id'], $ids, true)) {
|
|
throw new FollowupAudioException('CONFIG_INVALID');
|
|
}
|
|
$ids[] = $citation['id'];
|
|
}
|
|
if ($ids === []) { throw new FollowupAudioException('ASR_TRANSCRIPT_EMPTY'); }
|
|
$branches = [];
|
|
foreach (self::KINDS as $kind) {
|
|
if (!isset($catalog[$kind])) { continue; }
|
|
$properties = [];
|
|
foreach ($catalog[$kind] as $field) {
|
|
$schema = self::field($field);
|
|
if ($schema !== null) { $properties[$field['key']] = $schema; }
|
|
}
|
|
if ($properties === []) { continue; }
|
|
$fields = [
|
|
'kind' => ['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'); }
|
|
}
|