feat: add reviewed follow-up audio patient enrichment
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\followupaudio;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use DomainException;
|
||||
|
||||
/** Pure conservative normalization. Model text is data, never a write instruction. */
|
||||
final class FollowupAudioPolicy
|
||||
{
|
||||
public const PERIODS = ['凌晨', '早晨', '上午', '中午', '下午', '晚上', '睡前'];
|
||||
|
||||
public static function canonical(array $value): string
|
||||
{
|
||||
$sort = static function ($item) use (&$sort) {
|
||||
if (!is_array($item)) { return $item; }
|
||||
if (!array_is_list($item)) { ksort($item, SORT_STRING); }
|
||||
foreach ($item as $key => $child) { $item[$key] = $sort($child); }
|
||||
return $item;
|
||||
};
|
||||
return json_encode($sort($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public static function hash(array $value): string { return hash('sha256', self::canonical($value)); }
|
||||
|
||||
public static function strictDate($value): string
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value)) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_DATE_INVALID');
|
||||
}
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value, new DateTimeZone('Asia/Shanghai'));
|
||||
if (!$date || $date->format('Y-m-d') !== $value || $value < '1900-01-01') {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_DATE_INVALID');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function strictRecordedAt(string $value): string
|
||||
{
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value, new DateTimeZone('Asia/Shanghai'));
|
||||
if (!$date || $date->format('Y-m-d H:i:s') !== $value || $value < '1900-01-01 00:00:00' || $date->getTimestamp() > time() + 300) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_RECORDED_AT_INVALID');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function dayTimestamp(string $date): int
|
||||
{
|
||||
return (new DateTimeImmutable(self::strictDate($date) . ' 00:00:00', new DateTimeZone('Asia/Shanghai')))->getTimestamp();
|
||||
}
|
||||
|
||||
public static function strictTime($value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') { return null; }
|
||||
if (!is_string($value) || !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/D', $value)) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_TIME_INVALID');
|
||||
}
|
||||
return substr($value, 0, 5);
|
||||
}
|
||||
|
||||
public static function normalizeExtraction(array $result, string $recordedAt): array
|
||||
{
|
||||
self::strictRecordedAt($recordedAt);
|
||||
if (!is_string($result['transcript'] ?? null) || trim($result['transcript']) === '' || strlen($result['transcript']) > 2000000
|
||||
|| !is_string($result['summary'] ?? null) || strlen($result['summary']) > 60000 || !is_array($result['items'] ?? null)
|
||||
|| count($result['items']) > 500 || !is_array($result['uncertainties'] ?? [])) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_EXTRACTION_INVALID');
|
||||
}
|
||||
$uncertainties = [];
|
||||
foreach (($result['uncertainties'] ?? []) as $uncertainty) {
|
||||
if (is_string($uncertainty) && count($uncertainties) < 200) { $uncertainties[] = mb_substr($uncertainty, 0, 1000); }
|
||||
}
|
||||
$items = [];
|
||||
$seenEvents = [];
|
||||
foreach ($result['items'] as $index => $raw) {
|
||||
try {
|
||||
if (!is_array($raw) || !is_string($raw['kind'] ?? null) || !is_array($raw['values'] ?? null)) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_ITEM_INVALID');
|
||||
}
|
||||
$kind = $raw['kind'];
|
||||
$values = FollowupAudioFields::validateValues($kind, $raw['values']);
|
||||
$evidence = self::evidence($raw['evidence'] ?? []);
|
||||
$quoted = $evidence !== [];
|
||||
foreach ($evidence as $quote) {
|
||||
if (!str_contains($result['transcript'], $quote['text'])) { $quoted = false; }
|
||||
}
|
||||
$dateText = self::shortText($raw['date_text'] ?? '');
|
||||
$timeText = self::shortText($raw['time_text'] ?? '');
|
||||
$date = self::resolveDate($raw['record_date'] ?? null, $dateText, $recordedAt);
|
||||
$time = self::strictTime($raw['record_time'] ?? null);
|
||||
$period = self::period($raw['time_period'] ?? $timeText);
|
||||
// Approved default clocks are estimates, never claims of an exact spoken measurement time.
|
||||
$estimated = ($time === null && $kind !== 'diagnosis') || !empty($raw['time_estimated']);
|
||||
if ($time === null && $kind !== 'diagnosis') { $time = self::estimatedTime($period); }
|
||||
$needsReview = !empty($raw['needs_review']) || !$quoted
|
||||
|| FollowupAudioFields::requiresClinicalReview($kind, $values)
|
||||
|| self::riskyEvidenceContext($result['transcript'], $evidence)
|
||||
|| ($kind !== 'diagnosis' && ($date === null || $estimated));
|
||||
if ($date !== null && ($date > substr($recordedAt, 0, 10) || ($kind === 'diagnosis' && $date < substr($recordedAt, 0, 10)))) { $needsReview = true; }
|
||||
$item = [
|
||||
'id' => '', 'kind' => $kind, 'values' => $values, 'record_date' => $date, 'record_time' => $time,
|
||||
'time_period' => $period, 'time_estimated' => $estimated, 'time_text' => $timeText, 'date_text' => $dateText,
|
||||
'evidence' => $evidence, 'needs_review' => $needsReview, 'selected' => false, 'target_id' => null,
|
||||
'current_values' => [], 'expected_hash' => '', 'conflict' => false, 'possible_duplicate' => false,
|
||||
];
|
||||
// Equal measurements/default times are NOT enough to collapse different utterances.
|
||||
$identity = ['kind' => $kind, 'values' => $values, 'date' => $date, 'time' => $time, 'period' => $period, 'evidence' => $evidence];
|
||||
$eventHash = self::hash($identity);
|
||||
if ($evidence !== [] && isset($seenEvents[$eventHash])) { continue; }
|
||||
$seenEvents[$eventHash] = true;
|
||||
$item['id'] = 'i_' . substr(self::hash([$identity, 'index' => $index]), 0, 32);
|
||||
$items[] = $item;
|
||||
} catch (DomainException $exception) {
|
||||
// Retain transcript for a human; do not silently turn unknown/invalid values into "normal".
|
||||
$uncertainties[] = '第' . ($index + 1) . '项未进入可写字段:' . $exception->getMessage();
|
||||
}
|
||||
}
|
||||
return ['summary' => trim($result['summary']), 'transcript' => $result['transcript'],
|
||||
'uncertainties' => array_slice($uncertainties, 0, 200), 'items' => $items];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative review gate, NOT a semantic classifier. A substring proves neither
|
||||
* speaker identity nor negation/current-vs-historical meaning. Look around every
|
||||
* occurrence so a shortened quote cannot hide an adjacent question or family cue.
|
||||
*/
|
||||
private static function riskyEvidenceContext(string $transcript, array $evidence): bool
|
||||
{
|
||||
$pattern = '/(?:家属|家人|父亲|母亲|爸爸|妈妈|父母|儿子|女儿|丈夫|妻子|老伴|爱人|爷爷|奶奶|姥姥|姥爷|'
|
||||
. '哥哥|姐姐|弟弟|妹妹|兄弟|姐妹|孩子|他们|她们|他(?:的|在|测|用)|她(?:的|在|测|用)|'
|
||||
. '客服|请问|问[::]|[??]|是否|有没有|是不是|多少|吗|呢|否认|没有|没|未|不是|不|'
|
||||
. '更正|纠正|说错|口误|改成|其实|好像|可能|大概|左右|记不清|忘记|以前|曾经|过去|之前|上次|停药|停用|'
|
||||
. '\b(?:family|father|mother|wife|husband|son|daughter|no|not|never|denied|maybe|uncertain|previously|stopped|correction)\b)/iu';
|
||||
foreach ($evidence as $quote) {
|
||||
$offset = 0;
|
||||
$occurrences = 0;
|
||||
$length = mb_strlen($quote['text'], 'UTF-8');
|
||||
while (($position = mb_strpos($transcript, $quote['text'], $offset, 'UTF-8')) !== false) {
|
||||
// Excessively repeated fragments cannot establish a unique reliable context.
|
||||
if (++$occurrences > 20) { return true; }
|
||||
$start = max(0, $position - 160);
|
||||
$context = mb_substr($transcript, $start, $position - $start + $length + 160, 'UTF-8');
|
||||
if (preg_match($pattern, $context)) { return true; }
|
||||
$offset = $position + max(1, $length);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function period($value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') { return null; }
|
||||
if (!is_string($value)) { throw new DomainException('FOLLOWUP_AUDIO_PERIOD_INVALID'); }
|
||||
$aliases = ['morning' => '上午', 'noon' => '中午', 'afternoon' => '下午', 'evening' => '晚上',
|
||||
'night' => '晚上', 'bedtime' => '睡前', '早上' => '早晨', '清晨' => '早晨', '傍晚' => '晚上'];
|
||||
$value = $aliases[$value] ?? $value;
|
||||
foreach (self::PERIODS as $period) {
|
||||
if (str_contains($value, $period)) { return $period; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function estimatedTime(?string $period): ?string
|
||||
{
|
||||
return ['早晨' => '08:00', '上午' => '08:00', '中午' => '12:00', '下午' => '15:00',
|
||||
'晚上' => '20:00', '睡前' => '22:00'][$period ?? ''] ?? null;
|
||||
}
|
||||
|
||||
private static function resolveDate($explicit, string $text, string $recordedAt): ?string
|
||||
{
|
||||
// A model-supplied calendar date cannot resolve an explicitly ambiguous spoken range.
|
||||
if (preg_match('/(?:或|至|到|最近|这几天|前几天|近几天|上周|上星期|上个月|不记得|记不清|大概|左右|between|around)/iu', $text)) { return null; }
|
||||
$base = new DateTimeImmutable(substr($recordedAt, 0, 10), new DateTimeZone('Asia/Shanghai'));
|
||||
$relative = null;
|
||||
foreach (['前天' => '-2 days', '昨天' => '-1 day', '昨日' => '-1 day', '今天' => '+0 days', '今日' => '+0 days',
|
||||
'yesterday' => '-1 day', 'today' => '+0 days'] as $word => $offset) {
|
||||
if ($text === $word || str_starts_with($text, $word)) { $relative = $base->modify($offset)->format('Y-m-d'); break; }
|
||||
}
|
||||
if ($explicit !== null && $explicit !== '') {
|
||||
$date = self::strictDate($explicit);
|
||||
// A relative phrase and supplied date disagree: leave unresolved instead of trusting one silently.
|
||||
return $relative !== null && $relative !== $date ? null : $date;
|
||||
}
|
||||
if ($relative !== null) { return $relative; }
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $text)) { return self::strictDate($text); }
|
||||
if (preg_match('/^(\d{4})年(\d{1,2})月(\d{1,2})日$/Du', $text, $match)) {
|
||||
return self::strictDate(sprintf('%04d-%02d-%02d', $match[1], $match[2], $match[3]));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function shortText($value): string
|
||||
{
|
||||
if (!is_string($value) || mb_strlen($value) > 255) { throw new DomainException('FOLLOWUP_AUDIO_TEXT_INVALID'); }
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private static function evidence($raw): array
|
||||
{
|
||||
if (!is_array($raw) || count($raw) > 30) { throw new DomainException('FOLLOWUP_AUDIO_EVIDENCE_INVALID'); }
|
||||
$evidence = [];
|
||||
foreach ($raw as $entry) {
|
||||
if (!is_array($entry) || !is_string($entry['text'] ?? null) || trim($entry['text']) === '' || mb_strlen($entry['text']) > 5000) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_EVIDENCE_INVALID');
|
||||
}
|
||||
$quote = ['text' => trim($entry['text'])];
|
||||
foreach (['start_ms', 'end_ms'] as $key) {
|
||||
if (isset($entry[$key])) {
|
||||
if (!is_int($entry[$key]) || $entry[$key] < 0 || $entry[$key] > 3600000) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_EVIDENCE_TIME_INVALID');
|
||||
}
|
||||
$quote[$key] = $entry[$key];
|
||||
}
|
||||
}
|
||||
if (isset($quote['start_ms'], $quote['end_ms']) && $quote['end_ms'] < $quote['start_ms']) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_EVIDENCE_TIME_INVALID');
|
||||
}
|
||||
$evidence[] = $quote;
|
||||
}
|
||||
return $evidence;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user