feat: add durable ASR-to-patient extraction and guarded review

This commit is contained in:
2026-10-08 15:08:42 +08:00
parent 8bb4bd07ae
commit 13948f5982
34 changed files with 2377 additions and 56 deletions
@@ -0,0 +1,112 @@
<?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'); }
}