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

279 lines
20 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);
/** Offline integration: all patient, authorization and transport boundaries are 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 $GLOBALS['completePermissions']; }
}
}
namespace app\adminapi\logic\firstvisit {
class MyPatientLogic {
public static function canAccessDiagnosis($id, $adminId, $adminInfo): bool {
return $GLOBALS['completeScopeAllowed'];
}
}
}
namespace app\adminapi\logic\tcm {
function config(string $key): array {
return ['timeout' => 90, 'models' => [
'qwen' => ['name' => 'offline-qwen', 'label' => '离线千问'],
'openai' => ['name' => 'offline-openai', 'label' => '离线OpenAI'],
]];
}
class DiagnosisLogic {
public static function detail($params, $adminInfo): array { return $GLOBALS['completeCurrent']; }
}
class PatientAiReportLogic {
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array {
++$GLOBALS['completeAggregationCalls'];
return $GLOBALS['completeLongitudinal'];
}
public static function compactSourceForPrompt($profile, $text, $options = []): array {
throw new \RuntimeException('A below-budget fixture must never compact or call a model.');
}
}
}
namespace app\common\service {
class DifyChatService {
public static array $calls = [];
public static array $responses = [];
public static array $chunks = [];
public static bool $insideStream = false;
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'blocking'];
if (self::$responses === []) throw new \RuntimeException('Unexpected extra upstream call.');
return array_shift(self::$responses);
}
public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'stream'];
self::$insideStream = true;
try {
foreach (self::$chunks as $chunk) {
if ($onDelta($chunk) === false) return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
}
} finally {
self::$insideStream = false;
}
if (self::$responses === []) throw new \RuntimeException('Unexpected extra upstream call.');
return array_shift(self::$responses);
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiRequiredSource;
use app\common\service\DifyChatService;
$completeAssertions = 0;
function completeExpect(bool $ok, string $message): void {
++$GLOBALS['completeAssertions'];
if (!$ok) throw new \RuntimeException($message);
}
function completeResetTransport(array $responses): void {
DifyChatService::$calls = [];
DifyChatService::$responses = $responses;
DifyChatService::$chunks = [];
}
function completeExpectFactFidelity(string $query, string $route): void {
// Prompt delivery contract only; these assertions cannot certify model compliance.
$rules = [
'source polarity, severity and chronology' => ['“明确有”“明确无”“未记录/未采集”', '否定、程度、日期与本次/历史身份'],
'unrecorded pulse is unknown' => ['心率或舌像', '未记涩、数不能补写“不涩、不数”'],
'formula indications cannot create patient facts' => ['常见方证表现不得变成本例已有症状', '本例已记录的短证据', '保留反证'],
'one coherent primary direction' => ['主方向', '不把互相冲突的治法/方剂并列为同时适用'],
'medication actions require current use' => ['当前确实在用的依据', '早已停用的药物不得再安排'],
'normal tests do not prove zero risk' => ['未记录异常不等于检查正常', '局部或单次正常检查不等于风险为零'],
'unsupported claims require removal or explicit uncertainty' => ['找不到原文支持', '删除或改为明确待验证条件'],
'patient negatives retain literal source boundaries' => ['四诊已记录阴性', '不另写“本例无A、B、C”', '不得写“已排除/肯定不是”'],
'a primary formula requires traceable case evidence' => ['新增主方方向最多一个', '每个支持点逐字短引本例原文', '不展开备用方名或加味药清单'],
];
foreach ($rules as $rule => $fragments) {
completeExpect(count(array_filter($fragments, static fn (string $fragment): bool => str_contains($query, $fragment))) === count($fragments),
'Fact-fidelity instructions reach ' . $route . ': ' . $rule);
}
}
$completePermissions = ['tcm.diagnosis/aiassistant', 'tcm.diagnosis/aianalysis'];
$completeScopeAllowed = true;
$completeAggregationCalls = 0;
$completeCurrent = ['id' => 11, 'patient_id' => 99, 'age' => 42, 'gender' => '女',
'diagnosis_date' => '2026-10-23',
'chief_complaint' => '合成病例:乏力、食少持续两周,活动后较明显,休息后稍缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,起止和活动关系按本次访谈记录。',
'tongue' => '本次舌淡红,苔薄白', 'pulse' => '本次脉弦细',
'patient_name' => '不应传输的原始姓名', 'phone' => '13800138000'];
$completeSections = []; $completeMetadata = [];
foreach (AiRequiredSource::LABELS as $key => $label) {
$completeSections[] = ['key' => $key, 'label' => $label,
'value' => [['source_marker' => 'COMPLETE_' . $key]]];
$completeMetadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present',
'count' => 1, 'status' => 'prepared'];
}
foreach ($completeSections as &$section) {
if ($section['key'] === 'diagnosis_info') {
$section['value'][0] += ['diagnosis_date' => '2026-10-23',
'tongue' => $completeCurrent['tongue'], 'pulse' => $completeCurrent['pulse']];
$section['value'][] = ['diagnosis_date' => '2020-01-01', 'tongue' => '历史舌红', 'pulse' => '历史脉滑'];
}
if ($section['key'] === 'prescriptions') {
$section['value'][0] += ['prescription_date' => '2020-01-01', 'prescription_name' => '历史四君子汤',
'herbs' => [['name' => '历史党参', 'dosage' => 10]]];
}
}
unset($section);
$completeLongitudinal = ['snapshot' => ['required_content' => $completeSections],
'source_summary' => ['required_sections' => $completeMetadata, 'snapshot_complete' => true],
'source_diagnosis_ids' => [3, 11],
'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/not-fetched.png']]];
$reflection = new \ReflectionClass(DiagnosisAiLogic::class);
foreach (['MAX_REPORT_LENGTH' => 12000, 'MAX_PROMPT_SOURCE_BYTES' => 120000,
'MAX_ANALYSIS_RESPONSE_BYTES' => 32768, 'MAX_ANALYSIS_ADVICE_LENGTH' => 1200,
'MAX_ANALYSIS_RISK_ITEMS' => 8, 'MAX_ANALYSIS_RISK_LABEL_LENGTH' => 120] as $name => $bound) {
completeExpect($reflection->getConstant($name) === $bound, 'Established budget remains unchanged: ' . $name);
}
completeExpect($reflection->getConstant('MODEL_KEYS') === ['qwen', 'openai'], 'Both existing profile keys remain available.');
completeExpect(str_ends_with($reflection->getConstant('ASSISTANT_PROMPT_VERSION'), '-v43')
&& str_ends_with($reflection->getConstant('ANALYSIS_PROMPT_VERSION'), '-v43'), 'Both changed clinical prompts invalidate prior provenance/cache keys.');
// Capture a real preparation; a source-only replacement here would lose the clinical prompt.
$prepared = DiagnosisAiLogic::prepareAssistant(11, 'summary', '请依据本次完整四诊给出完整辨证报告并区分历史资料', 7, []);
completeExpect($prepared !== null && !$prepared['tcm_evidence_guard']['restricted'], 'Complete CURRENT examination keeps the clinical path.');
completeExpect($prepared['reference_scope'] === 'case' && $prepared['knowledge_source'] === null,
'Ordinary assistant preparation selects case references without claiming a teaching source.');
completeExpect($prepared['tcm_evidence_guard']['current_tongue'] === $completeCurrent['tongue']
&& $prepared['tcm_evidence_guard']['current_pulse'] === $completeCurrent['pulse'], 'History does not overwrite current examination identity.');
completeExpect(str_contains($prepared['query'], '四诊采集') && str_contains($prepared['query'], '中药方药')
&& !str_contains($prepared['query'], '只返回JSON:{"fact_ids"'), 'Ordinary clinical instructions survive preparation.');
completeExpectFactFidelity($prepared['query'], 'ordinary assistant preparation');
foreach (array_keys(AiRequiredSource::LABELS) as $key) {
completeExpect(str_contains($prepared['query'], 'COMPLETE_' . $key), 'No required source category is replaced by the guard index: ' . $key);
}
foreach (['本次舌淡红', '本次脉弦细', '历史舌红', '历史脉滑', '2020-01-01', '历史四君子汤', '历史党参'] as $fact) {
completeExpect(str_contains($prepared['query'], $fact), 'Complete clinical prompt preserves provenance-relevant source text: ' . $fact);
}
completeExpect(str_contains($prepared['query'], '不冒充本次新增建议'), 'Historical prescriptions retain an explicit non-new-advice instruction.');
completeExpect($prepared['files'] === $completeLongitudinal['files'] && count($prepared['required_sections']) === 13
&& $prepared['source_form'] === 'full_context' && $prepared['upstream_timeout'] === 90,
'Attachment/manifest/full-context/timeout envelope survives normal preparation.');
$serialized = json_encode([$prepared['query'], $prepared['inputs']], JSON_UNESCAPED_UNICODE);
completeExpect(!str_contains($serialized, '不应传输的原始姓名') && !str_contains($serialized, '13800138000'), 'Current raw identity is not inserted in either upstream channel.');
// Transport fixture only: this intentionally does not certify the clinical content of a real model.
// These literal quotations match current s1/s2/s3. The generic material
// after section one exercises bytes/length only, never clinical quality.
$report = "一、四诊\n\n望诊:未记录。\n\n闻诊:未记录。\n\n"
. "问诊:2026-10-23“合成病例:乏力、食少持续两周,活动后较明显,休息后稍缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,起止和活动关系按本次访谈记录。”(s1)。\n\n"
. "切诊:其他触诊未记录,脉象见独立脉象项。\n\n"
. "舌象:2026-10-23“本次舌淡红,苔薄白”(s2)。\n\n脉象:2026-10-23“本次脉弦细”(s3)。\n\n"
. "二、八纲\n\n各对结论区分已知记录、假设与待核实依据。此处仅演示离线工程传输段落,不形成任何临床结论。\n\n"
. "三、气血津液与脏腑定位\n\n此处仅演示离线工程传输段落,真实定位须由医师核对原始证据及不确定性。\n\n"
. "四、证名\n\n此处仅演示离线工程传输段落,证名需由医师按实际病例证据复核。\n\n"
. "五、治法\n\n此处仅演示离线工程传输段落,治法需与经复核的证名和实际病例证据对应。\n\n"
. "六、中药方药\n\n此处仅演示离线工程传输段落,既往资料和本次资料的时点由来源保留,不能据工程文字执行治疗。\n";
foreach (range(1, 24) as $number) {
$report .= '合成长报告传输段' . $number . ':保留本段来源时点、既往记录与不确定性;本测试只检查原文传输,没有模型推理或临床处方结论。' . "\n";
}
$report = str_replace("\n", "\n\n", $report);
completeExpect(mb_strlen($report, 'UTF-8') > 1200 && mb_strlen($report, 'UTF-8') < 12000, 'Fixture exercises a long assistant answer beyond structured-analysis field size.');
completeResetTransport([['ok' => true, 'content' => $report]]);
DifyChatService::$chunks = mb_str_split($report, 79, 'UTF-8');
$visible = []; $visibleDuringStream = [];
$streamed = DiagnosisAiLogic::streamPreparedAssistant($prepared,
static function (string $delta) use (&$visible, &$visibleDuringStream): bool {
$visible[] = $delta;
$visibleDuringStream[] = DifyChatService::$insideStream;
return true;
});
completeExpect($streamed !== null && $streamed['answer_mode'] === 'clinical_analysis'
&& $streamed['evidence_selection_valid'] === null, 'Complete-evidence streaming is reported as clinical analysis.');
completeExpect(count($visible) > 1 && !in_array(false, $visibleDuringStream, true), 'Ordinary clinical deltas are delivered during generation, before the final result.');
completeExpect(implode('', $visible) === $report && $streamed['answer'] === trim($report), 'Long model text is preserved without source-view substitution or missing tail.');
completeExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical'
&& DifyChatService::$calls[0]['options']['reference_scope'] === 'case'
&& DifyChatService::$calls[0]['options']['required_files'] === true, 'One clinical stream uses the existing profile, case scope and required-source contract.');
completeExpect($streamed['knowledge_source'] === null && $streamed['ancient_book_sources'] === [],
'Ordinary streamed answers do not falsely attribute unsubmitted reference material.');
completeResetTransport([['ok' => true, 'content' => $report]]);
$blocking = DiagnosisAiLogic::assistant(11, 'summary', '请给出完整辨证报告', 7, []);
completeExpect($blocking !== null && $blocking['answer'] === trim($report) && $blocking['knowledge_source'] === null
&& $blocking['ancient_book_sources'] === [] && count(DifyChatService::$calls) === 1
&& DifyChatService::$calls[0]['options']['reference_scope'] === 'case'
&& DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical'
&& DifyChatService::$calls[0]['files'] === $completeLongitudinal['files'],
'Blocking assistant retains the case scope, complete report and original attachments.');
completeResetTransport([['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE'], ['ok' => true, 'content' => $report]]);
$compatibility = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (string $text): bool => true,
null, null, ['reference_scope' => 'full']);
completeExpect($compatibility !== null && count(DifyChatService::$calls) === 2
&& array_column(DifyChatService::$calls, 'transport') === ['stream', 'blocking']
&& array_column(array_column(DifyChatService::$calls, 'options'), 'reference_scope') === ['case', 'case']
&& array_column(array_column(DifyChatService::$calls, 'options'), 'knowledge_mode') === ['clinical', 'clinical'],
'Both sides of the pre-delta compatibility retry retain the server-selected case scope.');
$draftPrepared = DiagnosisAiLogic::prepareAssistant(11, 'prescription_generate', '请生成处方草稿', 7, ['root' => 1]);
completeExpect($draftPrepared !== null && $draftPrepared['reference_scope'] === 'full'
&& is_array($draftPrepared['knowledge_source']), 'Structured prescription preparation retains its full reference scope.');
$clinical = ['diagnosis_advice' => '四诊:本次舌淡红苔薄白、脉弦细。八纲:逐项说明依据与待核实项。气血津液与脏腑:按源事实讨论。证名:待医生复核。',
'risk_assessment' => [['label' => '合成风险待核实', 'level' => 'low']],
'treatment_advice' => '治法与证名对应。中药方药:历史处方仅据实复核,非本次新增建议。必要复核:补充不确定性。'];
foreach (['qwen', 'openai'] as $profile) {
completeResetTransport([['ok' => true, 'content' => json_encode($clinical, JSON_UNESCAPED_UNICODE)]]);
$analysis = DiagnosisAiLogic::analysis(11, 7, [], $profile);
completeExpect($analysis !== null && array_intersect_key($analysis, $clinical) === $clinical,
'Real analysis parser preserves all three clinical fields rather than substituting a source-only review: ' . $profile);
completeExpect($analysis['answer_mode'] === 'clinical_analysis' && $analysis['model_key'] === $profile
&& $analysis['evidence_selection_valid'] === null, 'Profile/mode remains the independently selected clinical route: ' . $profile);
completeExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['profile'] === $profile
&& DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical'
&& DifyChatService::$calls[0]['options']['reference_scope'] === 'case', 'Each profile uses its own existing clinical transport with case references: ' . $profile);
completeExpect($analysis['knowledge_source'] === null && $analysis['ancient_book_sources'] === [],
'Case analysis does not claim unsubmitted teaching references: ' . $profile);
completeExpect(str_contains(DifyChatService::$calls[0]['query'], '只输出一个 JSON 对象')
&& !str_contains(DifyChatService::$calls[0]['query'], '只返回JSON:{"fact_ids"'), 'Ordinary analysis keeps the JSON prompt: ' . $profile);
completeExpectFactFidelity(DifyChatService::$calls[0]['query'], $profile . ' structured analysis');
}
completeResetTransport([['ok' => true, 'content' => '合成格式错误'],
['ok' => true, 'content' => json_encode($clinical, JSON_UNESCAPED_UNICODE)]]);
$repaired = DiagnosisAiLogic::analysis(11, 7, [], 'qwen');
completeExpect($repaired !== null && array_intersect_key($repaired, $clinical) === $clinical
&& $repaired['answer_mode'] === 'clinical_analysis', 'A format failure followed by valid JSON stays an ordinary clinical result.');
completeExpect(count(DifyChatService::$calls) === 2
&& array_column(DifyChatService::$calls, 'profile') === ['qwen', 'qwen']
&& array_column(array_column(DifyChatService::$calls, 'options'), 'reference_scope') === ['case', 'case']
&& str_contains(DifyChatService::$calls[1]['query'], '格式校验提醒'), 'Format repair stays on the same existing profile with one bounded retry.');
completeExpectFactFidelity(DifyChatService::$calls[1]['query'], 'same-profile JSON format repair');
completeResetTransport([['ok' => true, 'content' => 'bad1'], ['ok' => true, 'content' => 'bad2']]);
completeExpect(DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && count(DifyChatService::$calls) === 2,
'Repeated invalid clinical JSON fails after the existing two attempts instead of rendering a local successful review.');
$completeScopeAllowed = false;
completeResetTransport([]); $aggregationsBeforeDenial = $completeAggregationCalls;
completeExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, []) === null
&& DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && DifyChatService::$calls === []
&& $completeAggregationCalls === $aggregationsBeforeDenial, 'Denied diagnosis scope blocks aggregation and both clinical transports.');
$completeScopeAllowed = true; $completePermissions = [];
completeExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, []) === null
&& DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && DifyChatService::$calls === []
&& $completeAggregationCalls === $aggregationsBeforeDenial, 'Missing action permission blocks aggregation and clinical transports.');
echo 'Diagnosis AI complete-evidence clinical path: ' . $completeAssertions . " assertions passed (offline stubs only).\n";
}