Files
zyt/server/tests/DiagnosisAiPulseExcludedR36bRegressionTest.php
2026-10-27 14:03:35 +08:00

156 lines
11 KiB
PHP

<?php
declare(strict_types=1);
/** Offline only: authorization, aggregation and transport all use local stubs. */
namespace think\facade {
class Log {
public static function warning(string $message, array $context = []): void {}
public static function write(string $message, string $level = ''): void {}
}
}
namespace app\common\cache {
class AdminAuthCache {
public function __construct(int $adminId) {}
public function getAdminUri(): array { return ['tcm.diagnosis/aiassistant', 'tcm.diagnosis/aianalysis', 'tcm.diagnosis/aigenerateprescription']; }
}
}
namespace app\adminapi\logic\firstvisit {
class MyPatientLogic {
public static function canAccessDiagnosis($id, $adminId, $adminInfo): bool { return true; }
}
}
namespace app\adminapi\logic\tcm {
function config(string $key): array {
return ['timeout' => 90, 'models' => ['qwen' => ['name' => 'offline-model', 'label' => '离线模型']]];
}
class DiagnosisLogic {
public static function detail($params, $adminInfo): array { return $GLOBALS['r36Current']; }
}
class PatientAiReportLogic {
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { return $GLOBALS['r36Longitudinal']; }
public static function compactSourceForPrompt(...$args): array { throw new \RuntimeException('Unexpected compaction.'); }
}
}
namespace app\common\service {
class DifyChatService {
public static array $calls = [];
public static string $reply = '';
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options');
return ['ok' => true, 'content' => self::$reply];
}
public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options');
if ($delta(self::$reply . "\n\n") === false) return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
return ['ok' => true, 'content' => self::$reply . "\n\n"];
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmClinicalFactStateGuard;
use app\adminapi\service\TcmConsultationPulsePolicy;
use app\common\service\ClinicalKnowledgeReference;
use app\common\service\DifyChatService;
$checks = [];
function r36bExpect(bool $ok, string $name): void { $GLOBALS['checks'][] = ['name' => $name, 'passed' => $ok]; }
function r36bAnalysis(string $diagnosis, string $treatment = '辨证及用药仍须医师结合实采证据复核。', array $risks = []): ?array {
DifyChatService::$reply = json_encode(['diagnosis_advice' => $diagnosis, 'treatment_advice' => $treatment,
'risk_assessment' => $risks], JSON_UNESCAPED_UNICODE);
return DiagnosisAiLogic::analysis(36, 7, []);
}
$r36Current = ['id' => 36, 'patient_id' => 99, 'age' => 42, 'diagnosis_date' => '2026-10-26',
'chief_complaint' => '口干、乏力。', 'tongue' => '舌淡红,苔薄白。', 'pulse' => '脉细。', 'pulse_condition' => ''];
$sections = []; $metadata = [];
foreach (AiRequiredSource::LABELS as $key => $label) {
$sections[] = ['key' => $key, 'label' => $label, 'value' => [['marker' => 'R36B_' . $key]]];
$metadata[] = ['key' => $key, 'label' => $label, 'count' => 1, 'availability' => 'present', 'status' => 'prepared'];
}
foreach ($sections as &$section) if ($section['key'] === 'diagnosis_info') {
$section['value'] = [$r36Current, ['id' => 1, 'diagnosis_date' => '2020-01-01', 'pulse' => '脉弦细。']];
}
unset($section);
$r36Longitudinal = ['snapshot' => ['required_content' => $sections],
'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true],
'source_diagnosis_ids' => [1, 36], 'files' => []];
$rawBefore = serialize([$r36Current, $r36Longitudinal]);
$prepared = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []);
r36bExpect(is_array($prepared) && !$prepared['tcm_evidence_guard']['restricted'], 'preparation still ignores raw pulse as completeness criterion');
$ledger = $prepared['tcm_evidence_guard']['fact_state_ledger'];
$historicalSource = array_values(array_filter($ledger['sources'], static fn (array $s): bool => $s['scope'] === 'historical'
&& $s['field'] === 'pulse' && $s['quote'] === '脉弦细。'))[0];
$currentSource = array_values(array_filter($ledger['sources'], static fn (array $s): bool => $s['path'] === 'current.pulse'))[0];
$history = "历史记录:2020-01-01“脉弦细。”({$historicalSource['id']})。";
r36bExpect(TcmClinicalFactStateGuard::validate($ledger, $history)['valid'], 'original unchanged state guard accepts exact historical source');
r36bExpect(TcmConsultationPulsePolicy::validate($ledger, $history)['valid'], 'history exception itself remains line-bound and exact');
foreach (['缺脉则暂缓选方。', '缺脉时只能等待。', '缺少脉则不能辨证。', '未采集脉故暂缓选方。', '缺脉象则暂缓选方。'] as $text) {
r36bExpect(!TcmConsultationPulsePolicy::validate($ledger, $text)['valid'], 'missing traditional pulse cannot gate assessment: ' . $text);
}
DifyChatService::$reply = '缺脉则暂缓选方。';
$blocking = DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []);
r36bExpect($blocking === null, 'actual blocking route rejects exact missing-pulse escape whole');
$visible = '';
$streaming = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $part) use (&$visible): bool { $visible .= $part; return true; });
r36bExpect($streaming === null && $visible === '', 'actual streaming route never exposes exact missing-pulse escape');
r36bExpect(r36bAnalysis('证候方向仍须医师复核。', '缺脉则暂缓选方。') === null, 'actual analysis route rejects missing-pulse gate');
foreach (['缺脉率记录则补充心率及测量时间。', '缺脉压资料则核对血压记录。', '缺脉搏数值则复核生命体征。',
'缺脉络资料则查看原检查。', '缺脉管影像则核对检查记录。', '冠状动脉与静脉检查须保留原时点。',
'pulse rate: 72 bpm; pulse pressure: 40 mmHg。'] as $text) {
r36bExpect(TcmConsultationPulsePolicy::validate($ledger, $text)['valid'], 'non-TCM vascular or vital wording is preserved: ' . $text);
}
$cardio = '冠状动脉、静脉及生命体征项目须结合原检查记录复核。';
DifyChatService::$reply = $cardio;
r36bExpect(DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []) !== null, 'actual blocking path retains cardiovascular discussion');
$visible = '';
$streaming = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $part) use (&$visible): bool { $visible .= $part; return true; });
r36bExpect($streaming !== null && trim($visible) === $cardio, 'actual stream retains cardiovascular discussion byte-for-byte');
$diagnosisHistory = r36bAnalysis($history);
r36bExpect(is_array($diagnosisHistory) && $diagnosisHistory['diagnosis_advice'] === $history,
'actual analysis preserves original exact historical quote in diagnosis field');
$treatmentHistory = r36bAnalysis('证候方向仍须医师复核。', $history);
r36bExpect(is_array($treatmentHistory) && $treatmentHistory['treatment_advice'] === $history,
'actual analysis preserves original exact historical quote in treatment field');
$paragraph = "已记录资料按原日期引用。\n\n" . $history . "\n\n证候方向仍须结合本次证据复核。";
$paragraphResult = r36bAnalysis($paragraph);
r36bExpect(is_array($paragraphResult) && $paragraphResult['diagnosis_advice'] === $paragraph,
'parsed paragraph and line boundaries survive analysis validation');
$riskResult = r36bAnalysis('证候方向仍须医师复核。', '用药依据仍须复核。', [['label' => $history, 'level' => 'low']]);
r36bExpect(is_array($riskResult) && $riskResult['risk_assessment'][0]['label'] === $history,
'risk text is checked as its own original string, without JSON escapes');
$forged = str_replace('2020-01-01', '2026-10-26', $history);
$currentRow = "历史记录:2026-10-26“脉细。”({$currentSource['id']})。";
foreach ([$forged, $currentRow, $history . '因此本次脉细。',
str_replace('脉弦细。', '脉弦细,不涩。', $history),
'{"history":"' . $history . '"}', '原文引用:' . $history] as $bad) {
r36bExpect(r36bAnalysis($bad) === null, 'analysis rejects forged/current/extended/wrapped historical use: ' . $bad);
}
r36bExpect(r36bAnalysis('证候方向仍须医师复核。', $forged) === null, 'forged history is rejected in treatment field independently');
r36bExpect(r36bAnalysis('证候方向仍须医师复核。', '用药依据仍须复核。', [['label' => $currentRow, 'level' => 'low']]) === null,
'current-row historical misuse is rejected in risk field independently');
r36bExpect(r36bAnalysis('历史记录:2020-01-01“', '脉弦细。”(' . $historicalSource['id'] . ')。') === null,
'separate fields cannot compose a historical exception across field boundaries');
r36bExpect(r36bAnalysis($history, '脉细支持本次选方。') === null, 'one valid historical field cannot exempt another active pulse field');
r36bExpect(!TcmClinicalFactStateGuard::validate($ledger, '舌红绛,苔黄腻。')['valid'], 'unchanged source guard still rejects invented tongue features');
r36bExpect(str_contains(ClinicalKnowledgeReference::augmentCaseQuery('其他产品'), '舌象、脉象各单列'), 'other-product default reference scope stays unchanged');
r36bExpect(count($prepared['required_sections']) === 13 && str_contains($prepared['query'], '脉弦细。')
&& str_contains($prepared['inputs']['herbs_json'], '脉弦细。'), 'thirteen-source context retains original historical pulse in both channels');
r36bExpect(serialize([$r36Current, $r36Longitudinal]) === $rawBefore, 'all endpoint checks leave current and historical fixture bytes unchanged');
$r36Current['tongue'] = '';
$incomplete = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []);
r36bExpect(is_array($incomplete) && $incomplete['tcm_evidence_guard']['restricted']
&& $incomplete['tcm_evidence_guard']['missing'] === ['舌象'], 'independent current tongue requirement remains enforced');
$failures = array_values(array_filter($checks, static fn (array $c): bool => !$c['passed']));
echo json_encode(['suite' => 'DiagnosisAiPulseExcludedR36bRegressionTest', 'checks' => count($checks),
'passed' => count($checks) - count($failures), 'failed' => count($failures), 'failures' => $failures],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
exit($failures === [] ? 0 : 1);
}