156 lines
11 KiB
PHP
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);
|
|
}
|