AI助手临床校验改为只提示不拦截,资料不全也给出解答

- TcmClinicalReportStream 新增 BLOCKING 开关(默认 false),校验命中时照常交付原文,问题码记入 warnings()
- 问答、长报告、结构化分析、报告生成的脉象/事实状态/溯源校验改为随结果返回 clinical_review_warnings
- 处方草稿丢弃多余脉象字段、重复药名保留首味,不再整张失败
- 接诊提示词补充互联网面诊仅舌象无脉象、须合理推断给出结论

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gr
2026-10-27 15:00:10 +08:00
co-authored by Claude Opus 5.5
parent f9cbd42113
commit ea40cfdfed
3 changed files with 102 additions and 29 deletions
@@ -854,6 +854,10 @@ class DiagnosisAiLogic extends BaseLogic
$result['content'] = $aggregate;
}
$formatted = self::formatAssistantResult($prepared, $result);
if ($formatted !== null && $clinicalStream !== null && $clinicalStream->warnings() !== []) {
$formatted['clinical_review_warnings'] = self::safeClinicalWarnings(array_merge(
$formatted['clinical_review_warnings'] ?? [], array_values($clinicalStream->warnings())));
}
$status = $formatted === null ? 'error' : 'ok';
return $formatted;
} finally {
@@ -1075,24 +1079,35 @@ class DiagnosisAiLogic extends BaseLogic
self::setError('AI报告流与最终内容不一致,未作为完整报告返回');
return null;
}
$warnings = $result['clinical_review_warnings'] ?? [];
$facts = TcmClinicalFactStateGuard::validate(self::clinicalLedger($prepared), $visible);
if (empty($facts['valid'])) {
self::rejectClinicalState([], $facts['issues']);
return null;
if (TcmClinicalReportStream::BLOCKING) {
self::rejectClinicalState([], $facts['issues']);
return null;
}
$warnings = array_merge($warnings, $facts['issues'] ?? []);
}
$provenance = TcmClinicalEvidenceContract::validate($single['_clinical_evidence_index'], $visible, true);
if (empty($provenance['valid'])) {
self::rejectClinicalState([], $provenance['issues']);
return null;
if (TcmClinicalReportStream::BLOCKING) {
self::rejectClinicalState([], $provenance['issues']);
return null;
}
$warnings = array_merge($warnings, $provenance['issues'] ?? []);
}
// Count exactly the original bytes delivered across both attempts.
$shape = TcmLongReportContract::validate($visible, (string) $prepared['query']);
if (!$shape['valid']) {
self::$assistantErrorMeta['report_contract'] = $shape;
self::$assistantErrorCode = 'TCM_REPORT_INCOMPLETE';
self::setError('AI长报告未满足完整性交付要求,未作为完整报告返回');
return null;
if (TcmClinicalReportStream::BLOCKING) {
self::$assistantErrorMeta['report_contract'] = $shape;
self::$assistantErrorCode = 'TCM_REPORT_INCOMPLETE';
self::setError('AI长报告未满足完整性交付要求,未作为完整报告返回');
return null;
}
$warnings[] = ['code' => 'REPORT_FINAL_CONTRACT_INVALID', 'source_id' => 'report'];
}
$result['clinical_review_warnings'] = self::safeClinicalWarnings($warnings);
$result['answer'] = $visible;
$result['report_stage_count'] = $stageCount;
$result['clinical_repair_count'] = $repairCount;
@@ -1579,6 +1594,22 @@ class DiagnosisAiLogic extends BaseLogic
self::rejectClinicalState($result, $stream->issues());
}
/** 只保留安全问题码与来源编号,不回传被校验的正文。 */
private static function safeClinicalWarnings(array $issues): array
{
$safe = [];
foreach ($issues as $issue) {
if (!is_array($issue)) continue;
$code = (string) ($issue['code'] ?? '');
$sourceId = (string) ($issue['source_id'] ?? '');
if (preg_match('/^[A-Z0-9_]+$/D', $code) !== 1
|| preg_match('/^(?:s[1-9][0-9]*|report)?$/D', $sourceId) !== 1) continue;
$safe[$code . '|' . $sourceId] = ['code' => $code, 'source_id' => $sourceId];
if (count($safe) >= 16) break;
}
return array_values($safe);
}
private static function rejectClinicalState(array $result, array $issues = []): void
{
self::$assistantErrorMeta = self::safeAssistantErrorMeta($result);
@@ -1662,10 +1693,16 @@ class DiagnosisAiLogic extends BaseLogic
self::setError($message);
return null;
}
if (!TcmConsultationPulsePolicy::validate(self::clinicalLedger($prepared), (string) ($result['content'] ?? ''))['valid']) {
self::$assistantErrorCode = 'PULSE_EXCLUDED_FROM_ASSESSMENT';
self::setError('AI返回了本流程不采集的脉象判断或补采要求,内容未通过校验');
return null;
// 校验命中只作为复核提示随回答返回,不再拦截整条回答(见 TcmClinicalReportStream::BLOCKING)。
$reviewWarnings = [];
$pulseReview = TcmConsultationPulsePolicy::validate(self::clinicalLedger($prepared), (string) ($result['content'] ?? ''));
if (!$pulseReview['valid']) {
if (TcmClinicalReportStream::BLOCKING) {
self::$assistantErrorCode = 'PULSE_EXCLUDED_FROM_ASSESSMENT';
self::setError('AI返回了本流程不采集的脉象判断或补采要求,内容未通过校验');
return null;
}
$reviewWarnings = array_merge($reviewWarnings, $pulseReview['issues'] ?? []);
}
$content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true);
if ($content === '') {
@@ -1681,8 +1718,11 @@ class DiagnosisAiLogic extends BaseLogic
if (($prepared['task'] ?? '') !== 'prescription_generate') {
$factReview = TcmClinicalFactStateGuard::validate(self::clinicalLedger($prepared), $content);
if (!$factReview['valid']) {
self::rejectClinicalState($result, $factReview['issues']);
return null;
if (TcmClinicalReportStream::BLOCKING) {
self::rejectClinicalState($result, $factReview['issues']);
return null;
}
$reviewWarnings = array_merge($reviewWarnings, $factReview['issues'] ?? []);
}
}
@@ -1710,6 +1750,7 @@ class DiagnosisAiLogic extends BaseLogic
'source_diagnosis_ids' => is_array($prepared['source_diagnosis_ids'] ?? null)
? array_values(array_map('intval', $prepared['source_diagnosis_ids']))
: [],
'clinical_review_warnings' => self::safeClinicalWarnings($reviewWarnings),
];
if (($result['transfer_manifest_final'] ?? false) === true) {
foreach (['required_sections', 'items', 'attachment_count', 'submitted_attachment_count', 'skipped_attachment_count',
@@ -1826,8 +1867,8 @@ class DiagnosisAiLogic extends BaseLogic
}
}
if (array_key_exists('pulse', $decoded) || array_key_exists('pulse_condition', $decoded)
|| !TcmConsultationPulsePolicy::validate([], $json)['valid']) return null;
// 本接诊流程不采集脉象:模型多给的脉象字段直接丢弃,不再让整张处方草稿失败。
unset($decoded['pulse'], $decoded['pulse_condition']);
$clinical = self::cleanText($decoded['clinical_diagnosis'] ?? '', self::MAX_PRESCRIPTION_TEXT, true);
$herbs = $decoded['herbs'] ?? null;
@@ -1849,7 +1890,9 @@ class DiagnosisAiLogic extends BaseLogic
? '辅方'
: '主方';
$key = mb_strtolower(preg_replace('/\s+/u', '', $name) ?? $name, 'UTF-8');
if ($name === '' || $dosage === false || $dosage <= 0 || $dosage > 10000 || isset($seen[$key])) {
// 重复药名保留首次出现的一味,不让整张草稿失败。
if (isset($seen[$key])) continue;
if ($name === '' || $dosage === false || $dosage <= 0 || $dosage > 10000) {
return null;
}
$seen[$key] = true;
@@ -2054,21 +2097,30 @@ class DiagnosisAiLogic extends BaseLogic
}
// Each text keeps its own boundaries; one field cannot grant a source
// quotation exception to a different field, even with partial data.
$reviewWarnings = [];
foreach ([$analysis['diagnosis_advice'], $analysis['treatment_advice'],
...array_column($analysis['risk_assessment'], 'label')] as $analysisText) {
if (!TcmConsultationPulsePolicy::validate($evidenceGuard['fact_state_ledger'], $analysisText)['valid']) {
$pulseReview = TcmConsultationPulsePolicy::validate($evidenceGuard['fact_state_ledger'], $analysisText);
if ($pulseReview['valid']) continue;
if (TcmClinicalReportStream::BLOCKING) {
self::setError('AI返回了本流程不采集的脉象判断或补采要求,内容未通过校验');
return null;
}
$reviewWarnings = array_merge($reviewWarnings, $pulseReview['issues'] ?? []);
}
if (!TcmClinicalFactStateGuard::validate($evidenceGuard['fact_state_ledger'],
$factReview = TcmClinicalFactStateGuard::validate($evidenceGuard['fact_state_ledger'],
implode("\n", [$analysis['diagnosis_advice'], $analysis['treatment_advice'],
implode("\n", array_column($analysis['risk_assessment'], 'label'))]))['valid']) {
self::setError('AI正文与病历的已知、阴性或未记录状态不一致,本次分析未通过校验');
return null;
implode("\n", array_column($analysis['risk_assessment'], 'label'))]));
if (!$factReview['valid']) {
if (TcmClinicalReportStream::BLOCKING) {
self::setError('AI正文与病历的已知、阴性或未记录状态不一致,本次分析未通过校验');
return null;
}
$reviewWarnings = array_merge($reviewWarnings, $factReview['issues'] ?? []);
}
return array_merge($analysis, [
'clinical_review_warnings' => self::safeClinicalWarnings($reviewWarnings),
'answer_mode' => 'clinical_analysis',
'evidence_selection_valid' => null,
'evidence_format_normalized' => false,
@@ -2190,7 +2242,8 @@ class DiagnosisAiLogic extends BaseLogic
continue;
}
if (!TcmConsultationPulsePolicy::validate($context['tcm_evidence_guard']['fact_state_ledger'], $content)['valid']) {
if (TcmClinicalReportStream::BLOCKING
&& !TcmConsultationPulsePolicy::validate($context['tcm_evidence_guard']['fact_state_ledger'], $content)['valid']) {
$failureCount++;
$results[] = array_merge($resultBase, ['status' => 'error',
'error_code' => 'PULSE_EXCLUDED_FROM_ASSESSMENT',
@@ -7,10 +7,17 @@ namespace app\adminapi\service;
/** Validate complete prose units before exposing their original bytes to clients. */
final class TcmClinicalReportStream
{
/**
* 互联网面诊只有舌象等部分资料,校验规则命中时不再截断回答:
* 原文照常交付,问题码记入 warnings() 供医师复核。设为 true 恢复拦截模式。
*/
public const BLOCKING = false;
private string $pending = '';
private string $accepted = '';
private bool $stopped = false;
private array $issues = [];
private array $warnings = [];
private \Closure $forward;
private ?\Closure $onReject;
private string $deferred = '';
@@ -69,10 +76,11 @@ final class TcmClinicalReportStream
if ($this->finalCheckpointQuery === null) return true;
$candidate = $this->accepted . $this->deferred;
$review = $this->review($candidate, true);
if (empty($review['valid'])) return $this->reject($this->deferred, $review['issues']);
if (empty($review['valid']) && !$this->reject($this->deferred, $review['issues'])) return false;
$this->finalContract = TcmLongReportContract::validate($candidate, $this->finalCheckpointQuery);
if (empty($this->finalContract['valid'])) {
return $this->reject($this->deferred, [['code' => 'REPORT_FINAL_CONTRACT_INVALID', 'source_id' => 'report']]);
if (empty($this->finalContract['valid'])
&& !$this->reject($this->deferred, [['code' => 'REPORT_FINAL_CONTRACT_INVALID', 'source_id' => 'report']])) {
return false;
}
if ($this->shouldAbort !== null && ($this->shouldAbort)()) { $this->stopped = true; return false; }
if ($this->deferred === '') return true;
@@ -86,14 +94,16 @@ final class TcmClinicalReportStream
public function rejected(): bool { return $this->issues !== []; }
public function issues(): array { return $this->issues; }
public function warnings(): array { return $this->warnings; }
public function acceptedContent(): string { return $this->accepted; }
public function finalContract(): ?array { return $this->finalContract; }
private function release(string $unit): bool
{
if ($unit === '') return true;
if (in_array(trim($unit), $this->priorAnswerUnits, true) && !$this->isOrdinaryHeadingUnit($unit)) {
return $this->reject($unit, [['code' => 'REPORT_CONTINUATION_REPEATED_SECTION', 'source_id' => 'report']]);
if (in_array(trim($unit), $this->priorAnswerUnits, true) && !$this->isOrdinaryHeadingUnit($unit)
&& !$this->reject($unit, [['code' => 'REPORT_CONTINUATION_REPEATED_SECTION', 'source_id' => 'report']])) {
return false;
}
if ($this->finalCheckpointQuery !== null && !$this->holdingFinal) {
$offset = 0;
@@ -115,7 +125,7 @@ final class TcmClinicalReportStream
}
$candidate = $this->accepted . $unit;
$review = $this->review($candidate);
if (empty($review['valid'])) return $this->reject($unit, $review['issues']);
if (empty($review['valid']) && !$this->reject($unit, $review['issues'])) return false;
if (($this->forward)($unit) === false) {
$this->stopped = true;
return false;
@@ -158,6 +168,15 @@ final class TcmClinicalReportStream
private function reject(string $unit, array $issues): bool
{
if (!self::BLOCKING) {
// 非拦截模式:只记录问题码,调用方继续按原流程交付该段原文。
foreach ($issues as $issue) {
if (!is_array($issue)) continue;
$key = ($issue['code'] ?? '') . '|' . ($issue['source_id'] ?? '');
if (!isset($this->warnings[$key]) && count($this->warnings) < 32) $this->warnings[$key] = $issue;
}
return true;
}
if ($this->holdingFinal) {
$issues[] = ['code' => 'REPORT_FINAL_SECTION_UNDELIVERED', 'source_id' => 'report'];
}
@@ -27,6 +27,7 @@ final class TcmClinicalResponsePolicy
. self::consultationInstruction()
. '完成本次选定任务并直接给出对应分析结果:病历分析、血糖趋势、用药评估、并发症风险、问诊小结、健康建议各按自身问题作答。'
. '资料不全仍分析已知内容,逐项说明依据、可支持的结论及不确定性;缺什么说明什么,不将整个回答改成资料核对、原文摘录或通用补采清单。'
. '本次为互联网面诊,望诊以舌象为主、无脉象:须结合舌象、症状、病史与检验合理推断,明确给出倾向性结论和可执行建议,并标注为推断供医师复核,不得以资料不全为由拒答。'
. '仅在任务涉及中医辨证时按实采望闻问切及舌象→八纲→气血津液及脏腑定位→证名→治法→中药方义及禁忌组织;证据不足的证名可给有依据的鉴别方向,不能当作确诊。'
. '事实与病机分开,脏腑、证名和治法的每个限定成分分别举证,一般或正常状态本身不作病理阳性。'
. '列方须有已知依据、适用条件与禁忌,不凭病名或单症套方;新增主方方向最多一个,不输出新增剂量、频次或疗程。'