182 lines
12 KiB
PHP
182 lines
12 KiB
PHP
<?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";
|
||
}
|