302 lines
18 KiB
PHP
302 lines
18 KiB
PHP
<?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);
|
||
}
|
||
}
|