AI助手临床校验改为只提示不拦截,资料不全也给出解答
- TcmClinicalReportStream 新增 BLOCKING 开关(默认 false),校验命中时照常交付原文,问题码记入 warnings() - 问答、长报告、结构化分析、报告生成的脉象/事实状态/溯源校验改为随结果返回 clinical_review_warnings - 处方草稿丢弃多余脉象字段、重复药名保留首味,不再整张失败 - 接诊提示词补充互联网面诊仅舌象无脉象、须合理推断给出结论 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
. '完成本次选定任务并直接给出对应分析结果:病历分析、血糖趋势、用药评估、并发症风险、问诊小结、健康建议各按自身问题作答。'
|
||||
. '资料不全仍分析已知内容,逐项说明依据、可支持的结论及不确定性;缺什么说明什么,不将整个回答改成资料核对、原文摘录或通用补采清单。'
|
||||
. '本次为互联网面诊,望诊以舌象为主、无脉象:须结合舌象、症状、病史与检验合理推断,明确给出倾向性结论和可执行建议,并标注为推断供医师复核,不得以资料不全为由拒答。'
|
||||
. '仅在任务涉及中医辨证时按实采望闻问切及舌象→八纲→气血津液及脏腑定位→证名→治法→中药方义及禁忌组织;证据不足的证名可给有依据的鉴别方向,不能当作确诊。'
|
||||
. '事实与病机分开,脏腑、证名和治法的每个限定成分分别举证,一般或正常状态本身不作病理阳性。'
|
||||
. '列方须有已知依据、适用条件与禁忌,不凭病名或单症套方;新增主方方向最多一个,不输出新增剂量、频次或疗程。'
|
||||
|
||||
Reference in New Issue
Block a user