diff --git a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php index a242f5ac9..bee419998 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php @@ -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', diff --git a/server/app/adminapi/service/TcmClinicalReportStream.php b/server/app/adminapi/service/TcmClinicalReportStream.php index ff6d68d38..8814f3a2c 100644 --- a/server/app/adminapi/service/TcmClinicalReportStream.php +++ b/server/app/adminapi/service/TcmClinicalReportStream.php @@ -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']; } diff --git a/server/app/common/service/TcmClinicalResponsePolicy.php b/server/app/common/service/TcmClinicalResponsePolicy.php index 59cf75076..eab36417f 100644 --- a/server/app/common/service/TcmClinicalResponsePolicy.php +++ b/server/app/common/service/TcmClinicalResponsePolicy.php @@ -27,6 +27,7 @@ final class TcmClinicalResponsePolicy . self::consultationInstruction() . '完成本次选定任务并直接给出对应分析结果:病历分析、血糖趋势、用药评估、并发症风险、问诊小结、健康建议各按自身问题作答。' . '资料不全仍分析已知内容,逐项说明依据、可支持的结论及不确定性;缺什么说明什么,不将整个回答改成资料核对、原文摘录或通用补采清单。' + . '本次为互联网面诊,望诊以舌象为主、无脉象:须结合舌象、症状、病史与检验合理推断,明确给出倾向性结论和可执行建议,并标注为推断供医师复核,不得以资料不全为由拒答。' . '仅在任务涉及中医辨证时按实采望闻问切及舌象→八纲→气血津液及脏腑定位→证名→治法→中药方义及禁忌组织;证据不足的证名可给有依据的鉴别方向,不能当作确诊。' . '事实与病机分开,脏腑、证名和治法的每个限定成分分别举证,一般或正常状态本身不作病理阳性。' . '列方须有已知依据、适用条件与禁忌,不凭病名或单症套方;新增主方方向最多一个,不输出新增剂量、频次或疗程。'