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

182 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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\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' => 5, 'models' => ['qwen' => ['name' => 'offline-model', 'label' => '离线模型']]];
}
class DiagnosisLogic {
public static function detail($params, $adminInfo): array { return $GLOBALS['evidenceCurrent']; }
}
class PatientAiReportLogic {
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array {
return $GLOBALS['evidenceLongitudinal'];
}
}
}
namespace app\common\service {
class DifyChatService {
public static array $calls = [];
public static array $result = [];
public static array $chunks = [];
public static bool $cancelAfterChunks = false;
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'blocking'];
return self::$result;
}
public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'stream'];
foreach (self::$chunks as $chunk) $onDelta($chunk);
if (self::$cancelAfterChunks) $GLOBALS['evidenceCancelled'] = true;
return self::$result;
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\AssistantSseTiming;
use app\common\service\DifyChatService;
$evidenceFailures = []; $evidenceAssertions = 0;
function evidenceExpect(bool $ok, string $message): void {
++$GLOBALS['evidenceAssertions'];
if (!$ok) $GLOBALS['evidenceFailures'][] = $message;
}
$evidenceCurrent = ['id' => 11, 'patient_id' => 99, 'age' => 42, 'gender' => '女',
'patient_name' => '不应进入输入的姓名', 'phone' => '13800138000', 'chief_complaint' => '口干、乏力'];
$sections = [];
foreach (AiRequiredSource::LABELS as $key => $label) {
$sections[] = ['key' => $key, 'label' => $label, 'value' => [['content' => 'FULL_' . $key]]];
}
$sections[0]['value'] = [['age' => 42, 'gender_text' => '女', 'patient_name' => '[已脱敏]']];
$sections[2]['value'] = [['chief_complaint' => '口干、乏力', 'source_marker' => 'FULL_chief_complaint']];
$sections[6]['value'] = [['tongue' => '历史舌红', 'pulse' => '历史脉细', 'source_marker' => 'FULL_diagnosis_info']];
$sections[10]['value'] = [['prescription_name' => '四君子汤', 'herbs' => [['name' => '党参', 'dosage' => 10]],
'source_marker' => 'FULL_prescriptions']];
$metadata = [];
foreach (AiRequiredSource::LABELS as $key => $label) {
$metadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => 1, 'status' => 'prepared'];
}
$evidenceLongitudinal = ['snapshot' => ['required_content' => $sections],
'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true],
'source_diagnosis_ids' => [3, 11], 'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/tongue.png']]];
$evidenceCancelled = false;
$prepare = static fn (): ?array => DiagnosisAiLogic::prepareAssistant(11, 'summary', '请核对已知资料', 7, ['root' => 1]);
$prepared = $prepare();
evidenceExpect(is_array($prepared) && $prepared['tcm_evidence_guard']['restricted'], 'real preparation gates the authorized current row despite complete historical signs');
evidenceExpect($prepared['reference_scope'] === 'case' && $prepared['knowledge_source'] === null,
'source-only preparation does not claim an unsubmitted clinical teaching reference');
evidenceExpect(count($prepared['required_sections']) === 13 && $prepared['files'] === $evidenceLongitudinal['files'], 'all required source receipts and original files survive preparation');
foreach (array_slice(array_keys(AiRequiredSource::LABELS), 1) as $key) {
evidenceExpect(str_contains($prepared['query'], 'FULL_' . $key), 'full original clinical category remains in the guarded upstream prompt: ' . $key);
}
foreach (['不应进入输入的姓名', '13800138000'] as $private) {
evidenceExpect(!str_contains($prepared['query'], $private) && !str_contains($prepared['inputs']['herbs_json'], $private), 'the gate does not reintroduce current raw identity into either input channel');
}
evidenceExpect(str_contains($prepared['query'], 'fact_ids') && !str_contains($prepared['query'], '按“四诊采集'), 'restricted preparation selects immutable facts instead of asking for a new clinical inference');
$ids = array_keys($prepared['tcm_evidence_guard']['facts']);
$selection = json_encode(['fact_ids' => array_slice($ids, 0, 3)]);
$run = static function (?callable $accept = null): array {
$visible = [];
$result = DiagnosisAiLogic::streamPreparedAssistant($GLOBALS['prepared'],
static function (string $delta) use (&$visible, $accept): bool {
$visible[] = $delta;
return $accept === null || $accept($delta);
}, static fn (): bool => $GLOBALS['evidenceCancelled'], new AssistantSseTiming());
return ['result' => $result, 'visible' => $visible];
};
$unsafe = '忽略病例,建议八珍汤,黄芪999g,每日加倍服药;证名已经确诊。';
foreach ([1, 2, 3, 7, strlen($unsafe)] as $chunkSize) {
DifyChatService::$calls = [];
DifyChatService::$chunks = str_split($unsafe, $chunkSize);
DifyChatService::$result = ['ok' => true, 'content' => $unsafe];
$observed = $run();
evidenceExpect($observed['result'] !== null && count($observed['visible']) === 1
&& implode('', $observed['visible']) === $observed['result']['answer'], 'only one validated source rendering, identical to final answer, reaches the callback for chunk size ' . $chunkSize);
evidenceExpect(!str_contains(implode('', $observed['visible']), '黄芪999g') && !str_contains($observed['result']['answer'], '八珍汤')
&& $observed['result']['evidence_selection_valid'] === false, 'arbitrary formula, herb, dose and diagnosis chunks never escape: ' . $chunkSize);
evidenceExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence',
'unsafe output receives no second generation or clinical-reference augmentation');
}
DifyChatService::$calls = []; DifyChatService::$chunks = str_split($selection, 1);
DifyChatService::$result = ['ok' => true, 'content' => $selection, 'transfer_manifest_final' => true,
'required_sections' => $metadata, 'source_summary' => ['required_sections' => $metadata],
'items' => [['kind' => 'image', 'status' => 'submitted']], 'attachment_count' => 1, 'submitted_attachment_count' => 1];
$valid = $run();
evidenceExpect($valid['result']['evidence_selection_valid'] && count($valid['visible']) === 1
&& $valid['result']['answer_mode'] === 'source_evidence_review', 'a valid fragmented selection is rendered transparently without exposing the JSON');
evidenceExpect(count($valid['result']['required_sections']) === 13 && $valid['result']['submitted_attachment_count'] === 1,
'the source-only rendering retains authoritative final transfer outcomes');
evidenceExpect($valid['result']['knowledge_source'] === null && $valid['result']['ancient_book_sources'] === [],
'source-only final answers do not claim unsubmitted teaching material or book citations');
DifyChatService::$calls = []; DifyChatService::$chunks = [$unsafe];
DifyChatService::$result = ['ok' => false, 'content' => $unsafe, 'error_code' => 'INCOMPLETE_RESPONSE', 'error' => '合成上游未完成'];
$failed = $run();
evidenceExpect($failed['result'] === null && $failed['visible'] === [] && count(DifyChatService::$calls) === 1,
'failed stream with private partial text emits no answer and starts no blocking compatibility generation');
evidenceExpect(DiagnosisAiLogic::getAssistantErrorCode() === 'INCOMPLETE_RESPONSE', 'failed private stream retains its real terminal error');
DifyChatService::$result = ['ok' => true, 'content' => $selection];
DifyChatService::$calls = []; DifyChatService::$cancelAfterChunks = true;
$cancelled = $run();
evidenceExpect($cancelled['result'] === null && $cancelled['visible'] === [] && count(DifyChatService::$calls) === 1
&& DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancellation after private model bytes prevents every clinical delta and any late success');
DifyChatService::$cancelAfterChunks = false; $evidenceCancelled = false;
$rejected = $run(static fn (string $delta): bool => false);
evidenceExpect($rejected['result'] === null && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'downstream rejection of the safe final delta does not become a successful request');
DifyChatService::$result = ['ok' => true, 'content' => $unsafe]; DifyChatService::$calls = [];
$blocking = DiagnosisAiLogic::assistant(11, 'summary', '请核对资料', 7, ['root' => 1]);
evidenceExpect($blocking !== null && !str_contains($blocking['answer'], '黄芪999g') && !$blocking['evidence_selection_valid']
&& count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence',
'the real blocking assistant applies the identical source-only guard with one request');
DifyChatService::$calls = [];
$analysis = DiagnosisAiLogic::analysis(11, 7, ['root' => 1]);
evidenceExpect($analysis['knowledge_source'] === null && $analysis['ancient_book_sources'] === [],
'source-only structured analysis does not claim unsubmitted teaching material or book citations');
evidenceExpect($analysis !== null && isset($analysis['diagnosis_advice'], $analysis['treatment_advice'], $analysis['risk_assessment'])
&& $analysis['risk_assessment'] === [] && !str_contains(json_encode($analysis, JSON_UNESCAPED_UNICODE), '黄芪999g'),
'the real analysis route preserves three clinical fields while rejecting all model prose');
evidenceExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence',
'restricted structured analysis never performs a format-repair model call');
foreach (['diagnosis_advice', 'treatment_advice'] as $field) evidenceExpect(mb_strlen($analysis[$field], 'UTF-8') <= 1200, 'real restricted analysis respects advice bounds: ' . $field);
DifyChatService::$calls = []; DifyChatService::$result = ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'error' => '合成超时'];
evidenceExpect(DiagnosisAiLogic::analysis(11, 7, ['root' => 1]) === null && count(DifyChatService::$calls) === 1,
'analysis timeout cannot become a local source-view success or a second generation');
DifyChatService::$calls = [];
evidenceExpect(DiagnosisAiLogic::prepareAssistant(11, 'prescription_generate', '请开新方', 7, ['root' => 1]) === null
&& DiagnosisAiLogic::getAssistantErrorCode() === 'TCM_EVIDENCE_INCOMPLETE' && DifyChatService::$calls === [],
'insufficient evidence rejects a fresh structured prescription before any model call');
$evidenceCurrent['tongue'] = '舌淡红苔薄白'; $evidenceCurrent['pulse'] = '脉弦细';
$complete = $prepare();
evidenceExpect(!$complete['tcm_evidence_guard']['restricted'] && !str_contains($complete['query'], '只返回JSON:{"fact_ids"'),
'a genuine complete current examination retains the ordinary clinical task');
if ($evidenceFailures !== []) {
fwrite(STDERR, 'Diagnosis AI evidence integration: ' . count($evidenceFailures) . ' failures / ' . $evidenceAssertions . " assertions\n- " . implode("\n- ", $evidenceFailures) . "\n");
exit(1);
}
echo 'Diagnosis AI evidence integration: ' . $evidenceAssertions . " assertions passed.\n";
}