'医生原话', '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('/(?\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); } }