Files
zyt/server/app/adminapi/service/LiveConsultationResult.php
2026-10-30 13:41:35 +08:00

302 lines
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\service;
/** Schema/citation gate. This is not a medical or general semantic correctness proof. */
final class LiveConsultationResult
{
public const MAX_BYTES = 65536;
private const GROUPS = ['summary', 'questions', 'checks', 'review'];
private const LABELS = ['doctor' => '医生原话', 'patient' => '患者原话', 'unknown' => '未知说话人原话'];
/** Reject malformed structure/invented references; render summary facts only from immutable sources. */
public static function parse(string $content, array $snapshot, string $task, array $knownChart = [], ?string &$failure = null): ?array
{
$failure = null;
if (strlen($content) > self::MAX_BYTES) return self::reject('LENGTH_LIMIT', $failure);
$content = trim($content);
// Accept only an exact, complete JSON code-fence envelope, never surrounding prose/wrappers.
if (preg_match('/\A```(?:json)?[ \t]*\r?\n([\s\S]*)\r?\n```[ \t]*\z/i', $content, $match) === 1)
$content = trim($match[1]);
$scan = self::scanGroups($content);
if ($scan === null || !$scan['complete']) return self::reject('JSON_INVALID', $failure);
return self::validate($scan['groups'], $snapshot, $task, $knownChart, $failure);
}
/** Complete, delimited items only; absent groups stay empty until a later validated update. */
public static function progress(string $content, array $snapshot, string $task, array $knownChart = []): ?array
{
if (strlen($content) > self::MAX_BYTES) return null;
$content = ltrim($content);
$fenced = preg_match('/\A```(?:json)?[ \t]*\r?\n/i', $content, $match) === 1;
if ($fenced) {
$content = substr($content, strlen($match[0]));
$content = preg_replace('/\r?\n```[ \t\r\n]*\z/', '', $content) ?? $content;
}
$scan = self::scanGroups($content);
if ($scan === null || ($scan['completed_groups'] === 0 && !array_filter($scan['groups']))) return null;
// The same full schema, role, citation and safety gate protects partial delivery.
$failure = null;
$result = self::validate(array_replace(array_fill_keys(self::GROUPS, []), $scan['groups']),
$snapshot, $task, $knownChart, $failure);
return $result === null ? null : ['completed_groups' => $scan['completed_groups'],
'displayed_items' => array_sum(array_map('count', $result)), 'assistance' => $result];
}
/** Bounded JSON lexer. Reject duplicate/unknown top-level keys instead of json_decode's last-key wins. */
private static function scanGroups(string $content): ?array
{
$length = strlen($content);
$index = 0;
$space = static function () use ($content, $length, &$index): void {
while ($index < $length && str_contains(" \t\r\n", $content[$index])) $index++;
};
$space();
if ($index >= $length || $content[$index++] !== '{') return null;
$groups = [];
$completedGroups = 0;
$pending = static function () use (&$groups, &$completedGroups): array {
return ['groups' => $groups, 'completed_groups' => $completedGroups, 'complete' => false];
};
$afterComma = false;
while (true) {
$space();
if ($index >= $length) return $pending();
if ($content[$index] === '}') {
if ($afterComma) return null;
$index++; $space();
return $index === $length ? ['groups' => $groups, 'completed_groups' => $completedGroups, 'complete' => true] : null;
}
// Plain contract keys only: no alternate spelling, escaping or nested wrapper.
if (preg_match('/\G"(summary|questions|checks|review)"[ \t\r\n]*:[ \t\r\n]*/A', $content, $key, 0, $index) !== 1) {
$tail = substr($content, $index);
// Incomplete key/colon may await another chunk; it cannot yield any new group.
foreach (self::GROUPS as $group) {
if (str_starts_with('"' . $group . '"', rtrim($tail))
|| preg_match('/\A"' . $group . '"[ \t\r\n]*:?[ \t\r\n]*\z/', $tail) === 1)
return $pending();
}
return null;
}
$group = $key[1];
if (array_key_exists($group, $groups)) return null;
$index += strlen($key[0]);
if ($index >= $length) return $pending();
if ($content[$index] !== '[') return null;
$groups[$group] = [];
$index++;
$itemAfterComma = false;
while (true) {
$space();
if ($index >= $length) return $pending();
if ($content[$index] === ']') {
if ($itemAfterComma) return null;
$index++; $completedGroups++;
break;
}
if ($content[$index] !== '{') return null;
$start = $index;
$stack = []; $quoted = false; $escaped = false;
for (; $index < $length; $index++) {
$character = $content[$index];
if ($quoted) {
if ($escaped) $escaped = false;
elseif ($character === '\\') $escaped = true;
elseif ($character === '"') $quoted = false;
continue;
}
if ($character === '"') { $quoted = true; continue; }
if ($character === '[' || $character === '{') $stack[] = $character;
if (count($stack) > 32) return null;
if ($character === ']' || $character === '}') {
if (array_pop($stack) !== ($character === ']' ? '[' : '{')) return null;
if ($stack === []) break;
}
}
if ($index >= $length) return $pending();
try { $item = json_decode(substr($content, $start, $index - $start + 1), true, 32, JSON_THROW_ON_ERROR); }
catch (\Throwable $e) { return null; }
$index++; $space();
// A closing object alone is insufficient: wait for its legal array delimiter.
if ($index >= $length) return $pending();
if (!in_array($content[$index], [',', ']'], true)) return null;
$groups[$group][] = $item;
$itemAfterComma = $content[$index] === ',';
if ($itemAfterComma) $index++;
}
$space(); $afterComma = false;
if ($index >= $length) return $pending();
if ($content[$index] === ',') { $index++; $afterComma = true; continue; }
if ($content[$index] !== '}') return null;
}
}
private static function reject(string $reason, ?string &$failure): ?array
{
$failure = $reason;
return null;
}
private static function validate(array $data, array $snapshot, string $task, array $knownChart, ?string &$failure): ?array
{
if (!self::keys($data, self::GROUPS, self::GROUPS)) return self::reject('SCHEMA_INVALID', $failure);
$sources = array_column($snapshot['sources'] ?? [], null, 'id');
$output = [];
foreach (self::GROUPS as $group) {
if (!is_array($data[$group]) || !array_is_list($data[$group]) || count($data[$group]) > 6)
return self::reject('SCHEMA_INVALID', $failure);
if ($group === 'review' && $task !== 'live_review' && $data[$group] !== []) return self::reject('SCHEMA_INVALID', $failure);
$output[$group] = [];
foreach ($data[$group] as $item) {
if (!is_array($item) || !self::keys($item, ['text', 'reason', 'evidence_ids'], ['text', 'evidence_ids'])
|| !is_string($item['text']) || trim($item['text']) === ''
|| mb_strlen($item['text']) > ($group === 'summary' ? 4000 : 600)
|| (isset($item['reason']) && (!is_string($item['reason']) || mb_strlen($item['reason']) > 600))
|| (array_key_exists('reason', $item) && $item['reason'] === null)
|| !is_array($item['evidence_ids']) || !array_is_list($item['evidence_ids'])
|| count($item['evidence_ids']) < 1 || count($item['evidence_ids']) > 8) return self::reject('SCHEMA_INVALID', $failure);
$ids = [];
foreach ($item['evidence_ids'] as $id) {
if (!is_string($id) || !isset($sources[$id]) || isset($ids[$id])) return self::reject('CITATION_INVALID', $failure);
$ids[$id] = true;
}
$text = $item['text'];
if ($group === 'summary') {
if (count($ids) !== 1) return self::reject('CITATION_INVALID', $failure);
$source = $sources[$item['evidence_ids'][0]];
if (self::unsafeSourceQuote($text . "\n" . ($item['reason'] ?? ''))
|| self::unsafeSourceQuote($source['text'])) return self::reject('SAFETY_REJECTED', $failure);
// Model summary prose/rationale never reaches the user. Reconstruct the entire
// quote from its validated ID, preserving denials, history and third-person scope.
if (($source['role'] ?? '') !== 'patient' || preg_match('/[??]\s*$/u', $source['text']) === 1
|| mb_strlen($source['text']) > 4000) continue;
$text = $source['text'];
unset($item['reason']);
$text = (self::LABELS[$source['role']] ?? self::LABELS['unknown']) . ':' . $text;
} else {
if (self::unsafeGeneratedText($text . "\n" . ($item['reason'] ?? ''))) return self::reject('SAFETY_REJECTED', $failure);
$roles = array_unique(array_map(static fn (string $id): string => $sources[$id]['role'], $item['evidence_ids']));
if (!in_array('patient', $roles, true)) {
// Source identity is immutable. Never relabel a doctor's question as a patient fact.
if (self::unsupportedPatientPremise($text)) continue;
$item['reason'] = in_array('unknown', $roles, true)
? '引用含说话人未确认的发言,仅供核实,不代表患者已确认。'
: '依据医生本次发言提出核实问题,不代表患者已确认。';
}
if ($group === 'questions') {
$text = trim($text);
if (preg_match('/[??]/u', $text) !== 1) {
$candidate = preg_replace('/[。.!!\s]+$/u', '', $text) ?? $text;
if (preg_match('/(?:[吗呢么]$|^(?:是否|有无|有没有|请问|何时|怎么|什么|如何|哪些|哪里|什么时候))/u', $candidate) === 1)
$text = $candidate . '?';
}
if (preg_match('/^[^??]+[??]$/u', $text) !== 1) continue;
}
if ($group === 'checks' && preg_match('/^(核实|确认)/u', $text) !== 1) continue;
if (in_array($group, ['questions', 'checks'], true)
&& self::reasksKnownDemographic($text, $item['evidence_ids'], $sources, $knownChart)) continue;
}
$output[$group][] = ['text' => $text, 'evidence_ids' => $item['evidence_ids']]
+ (isset($item['reason']) && $item['reason'] !== '' ? ['reason' => $item['reason']] : []);
}
// Validate every supplied item before limiting presentation; hidden items cannot bypass safety.
if ($group !== 'summary') $output[$group] = array_slice($output[$group], 0, 3);
}
return $output;
}
/** Narrow guard for explicit factual premises when no cited source belongs to the patient. */
private static function unsupportedPatientPremise(string $text): bool
{
if (preg_match('/(?:患者|[您你])(?:本人)?(?:自述|表示|称|说|提到|否认)|患者(?:已|未|没有|一直|存在|出现|有)/u', $text) === 1)
return true;
if (preg_match('/是否|有无|有没有/u', $text) !== 1
&& preg_match('/(?:已|未|没有|从未|一直).{0,24}(?:多久|多长时间|何时|什么时候)|(?:为何|为什么).{0,16}(?:未|没有|一直)/u', $text) === 1)
return true;
$clauses = preg_split('/[,,。;;::]/u', $text) ?: [];
foreach ($clauses as $clause) {
// A question about a state is not an assertion of that state. Chart references
// are background, never patient confirmation; their full text stays in the prompt.
if (preg_match('/[??]|是否|有无|有没有|(?:病历|历史|既往)(?:中)?(?:记录|记载)/u', $clause) === 1) continue;
if (preg_match('/(?:已(?:经)?|未(?:曾)?|没有|从未|一直|仍然|不再|否认)/u', $clause) === 1) return true;
}
return false;
}
/** Narrow demographic check: only a cited patient's explicit conflicting current value permits rechecking. */
private static function reasksKnownDemographic(string $text, array $ids, array $sources, array $knownChart): bool
{
// Current chart demographics do not answer onset age, historical age or a relative's age.
if (preg_match('/(?:发病|起病|患病|确诊|当时|那时|首次|第一次|开始出现|初次出现|绝经|停经|父亲|母亲|父母|孩子|儿子|女儿|家人|家属|配偶|丈夫|妻子)/u', $text) === 1)
return false;
$demographics = (string) ($knownChart['demographics'] ?? '');
if (preg_match('/(?:年龄|年纪|岁数|几岁|多少岁|[您你](?:今年|现在)?多大(?:了)?[??])/u', $text) === 1
&& preg_match('/(?<!\d)(\d{1,3})岁/u', $demographics, $age) === 1) {
foreach ($ids as $id) {
$source = $sources[$id];
if ($source['role'] === 'patient' && preg_match('/^\s*(?:我(?:现在|今年)?|本人(?:现在|今年)?|今年)\s*(\d{1,3})\s*岁/u', $source['text'], $stated) === 1
&& (int) $stated[1] !== (int) $age[1]) return false;
}
return true;
}
if (preg_match('/(?:性别|男(?:的|性)?还是女|女(?:的|性)?还是男)/u', $text) === 1
&& preg_match('/[男女]/u', $demographics, $gender) === 1) {
foreach ($ids as $id) {
$source = $sources[$id];
if ($source['role'] === 'patient' && preg_match('/(?:我|本人)是(男性|女性|男的|女的)/u', $source['text'], $stated) === 1
&& mb_substr($stated[1], 0, 1) !== $gender[0]) return false;
}
return true;
}
return false;
}
private static function keys(array $data, array $allowed, array $required): bool
{
return array_diff(array_keys($data), $allowed) === [] && array_diff($required, array_keys($data)) === [];
}
private static function unsafeGeneratedText(string $text): bool
{
// Conservative boundary for this deliberately limited information-organizing task.
return self::unsafeSourceQuote($text) || preg_match('/'
. '(建议|应当|应该|需要|立即|请|开始|停止|增加|减少|改为).{0,12}(服用|停药|换药|用药|加药|给药|注射)|'
. '(确诊|诊断为|辨证为|证型为|开具处方|医嘱|指南.{0,16}(推荐|要求|规定|条款)|风险[为::]?(高|中|低))|'
. '(保证|确保|包).{0,6}(治好|治愈|疗效)|(?:下单|成交|购买疗程|加购|套餐|限时优惠)|'
. '\b\d+(?:\.\d+)?\s*(?:mg|mcg|毫克|微克|克\/日)\b/iu', $text) === 1;
}
private static function unsafeSourceQuote(string $text): bool
{
return preg_match('/[<>\x00-\x08\x0b\x0c\x0e-\x1f]|忽略.{0,12}(指令|规则)|系统提示|system\s*prompt|ignore\s+(previous|all)/iu', $text) === 1;
}
/** Response references are a projection, not a new snapshot/revision or the UI subtitle window. */
public static function citedContext(array $snapshot, array $result): array
{
$ids = [];
foreach (self::GROUPS as $group) foreach ($result[$group] as $item)
foreach ($item['evidence_ids'] as $id) $ids[$id] = true;
$snapshot['sources'] = array_values(array_filter($snapshot['sources'],
static fn (array $source): bool => isset($ids[$source['id']])));
return $snapshot;
}
/** Safe textual fallback for clients that do not yet render structured cards. */
public static function answer(array $result, array $snapshot): string
{
$lines = ['本次通话资料草稿,供医生核实。'];
if ($snapshot['transcription_status'] === 'partial') $lines[] = '转写仅部分保存,以下不能代表完整通话。';
elseif ($snapshot['transcription_status'] === 'running') $lines[] = '仅依据截至当前已保存的完成分段。';
foreach (['questions' => '建议追问', 'summary' => '关键原话', 'checks' => '待核实', 'review' => '问诊复盘'] as $key => $label) {
if ($result[$key] === []) continue;
$lines[] = $label . ':';
foreach ($result[$key] as $item) $lines[] = '• ' . $item['text']
. (isset($item['reason']) ? '(' . $item['reason'] . ')' : '');
}
return implode("\n", $lines);
}
}