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

307 lines
24 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 public-endpoint integration. No network, database, provider or patient data. */
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 $id) {}
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' => 'r37-local-qwen', 'label' => 'R37 local Qwen'],
'openai' => ['name' => 'r37-local-openai', 'label' => 'R37 local OpenAI'],
]];
}
class DiagnosisLogic {
public static function detail($params, $adminInfo): array { return $GLOBALS['r37Current']; }
}
class PatientAiReportLogic {
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array {
return $GLOBALS['r37Context'];
}
public static function compactSourceForPrompt(...$args): array {
throw new \RuntimeException('Unexpected compaction; finite source is below the existing budget.');
}
}
}
namespace app\common\service {
class DifyChatService {
public static array $calls = [];
public static array $responses = [];
public static int $totalCalls = 0;
public static bool $streaming = false;
private static function take(): array {
if (self::$responses === []) throw new \RuntimeException('Unexpected extra local transport invocation.');
return array_shift(self::$responses);
}
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
++self::$totalCalls;
self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options') + ['transport' => 'chat'];
return self::take();
}
public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array {
++self::$totalCalls;
self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options') + ['transport' => 'stream'];
$result = self::take();
if (empty($result['ok'])) return $result;
self::$streaming = true;
try {
foreach (mb_str_split($result['content'], 19, 'UTF-8') as $chunk) {
if (($abort !== null && $abort()) || $delta($chunk) === false) {
return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
}
}
} finally { self::$streaming = false; }
return $result;
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmEvidenceGuard;
use app\adminapi\service\TcmClinicalFactStateGuard;
use app\common\service\DifyChatService;
$checks = [];
function r37Check(bool $passed, string $name): void { $GLOBALS['checks'][] = compact('passed', 'name'); }
function r37Reset(array $responses): void {
DifyChatService::$calls = [];
DifyChatService::$responses = $responses;
}
function r37Fixture(string $variant): void {
$GLOBALS['r37Current'] = [
'id' => 37, 'patient_id' => 999, 'diagnosis_date' => '2026-10-26', 'age' => 46,
'chief_complaint' => $variant === 'tongue_missing' ? '口干两周。' : '未记录',
'tongue' => $variant === 'symptoms_missing' ? '舌淡红,苔薄白。' : '未记录',
'pulse' => '脉细', 'allergy_history' => '不详',
'past_history' => '2020年外院糖尿病诊断记录。',
'current_medications' => '二甲双胍500mg,每日2次。',
'fasting_blood_sugar' => 7.9, 'systolic_pressure' => 132, 'diastolic_pressure' => 82,
];
$sections = []; $metadata = [];
foreach (AiRequiredSource::LABELS as $key => $label) {
$value = [['marker' => 'R37_FULL_SOURCE_' . $key]];
if ($key === 'diagnosis_info') $value[] = $GLOBALS['r37Current'];
if ($key === 'prescriptions') $value[] = ['prescription_date' => '2020-02-01',
'prescription_name' => '历史用药记录', 'usage' => '格列美脲已停用。'];
if ($key === 'daily_tracking') $value[] = ['record_date' => '2026-10-25',
'fasting_blood_sugar' => 8.3, 'note' => '晚餐主食较多,晚饭后步行20分钟。'];
if ($key === 'chat') $value[] = ['msg_time' => '2020-02-01', 'text' => '历史自述口渴,非本次采集。'];
$sections[] = compact('key', 'label', 'value');
$metadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => count($value), 'status' => 'prepared'];
}
$GLOBALS['r37Context'] = [
'snapshot' => ['required_content' => $sections],
'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true],
'source_diagnosis_ids' => [12, 37],
'files' => [['type' => 'image', 'url' => '/offline-r37/attachment.png']],
];
}
// Independent literal answers are local transport fixtures, not generated
// medical advice. They exercise distinct useful analyses and byte retention.
$answers = [
'case_analysis' => "主要问题与病程变化\n2026-10-25记录空腹血糖8.3 mmol/L,2026-10-26记录7.9 mmol/L。两次点位可供本次复核,不能据此评价整个病程或确诊并发症。\n\n诊断支持与局限\n2020年外院糖尿病诊断只保留历史身份;结合近期血糖和治疗记录复核当前控制情况,其他病因或辨证方向仍需相应证据。",
'glucose_trend' => "可比较记录\n2026-10-25空腹血糖8.3 mmol/L,2026-10-26为7.9 mmol/L,两次相差0.4 mmol/L;这只是两个记录点,不能代表长期达标或总体稳定。\n\n关联与监测\n日常记录有晚餐主食较多及饭后步行信息,尚不能确立与次日数值的因果;需核实采样条件和记录时间,再结合更多同类数据评价变化。",
'medication_review' => "当前与历史用药\n2026-10-26当前用药记录为“二甲双胍500mg,每日2次。”;历史格列美脲已停用,不对旧药安排再次停用。\n\n安全复核\n现有记录可核对药名和用法;肾功能与过敏史的资料尚不完整,不能据此认定适用性或安全性,需由医师结合相应检查和实际服用情况复核,不擅自调药。",
'complication_risk' => "风险依据与范围\n历史糖尿病诊断及近期空腹血糖记录提示需要持续评价相关风险;目前提供的血糖点位和单次血压不能确诊、排除或分级肾脏、眼底等并发症。\n\n筛查与优先级\n肾功能、尿白蛋白及眼底检查的评价范围尚未明确,需核实既有结果与日期后决定后续筛查;资料未提供不代表检查正常或风险为零。",
'summary' => "问诊核心结论\n本次可交接的重点为近期空腹血糖记录及当前用药复核:2026-10-25为8.3 mmol/L,2026-10-26为7.9 mmol/L。\n\n治疗和后续重点\n记录二甲双胍500mg每日2次,历史格列美脲已停用;复核服用情况、检查时点与资料缺口,保持历史记录和本次信息的区分,不把有限点位写成长程疗效结论。",
'lifestyle_advice' => "与本例相关的优先建议\n日常记录提到“晚餐主食较多,晚饭后步行20分钟。”,可先记录餐量、进餐时间和同类血糖点位,帮助复核饮食与血糖的关联;目前不能断定该次餐量造成次日血糖变化。\n\n可行步骤与限制\n保留可持续的活动记录,结合实际耐受和治疗安排由医师细化饮食、活动目标;年龄及少量血糖记录不足以确定个体化热量或运动强度。",
];
$tasks = array_keys($answers);
$original = new \ReflectionClass(DiagnosisAiLogic::class);
r37Check($original->getConstant('MODEL_KEYS') === ['qwen', 'openai'], 'Original profile whitelist is retained.');
foreach (['PROMPT_VERSION', 'ASSISTANT_PROMPT_VERSION', 'ANALYSIS_PROMPT_VERSION'] as $version) {
r37Check(str_ends_with($original->getConstant($version), '-r37'), $version . ' invalidates R36 provenance/cache.');
}
foreach (['MAX_REPORT_LENGTH' => 12000, 'MAX_PROMPT_SOURCE_BYTES' => 120000, 'MAX_ANALYSIS_ADVICE_LENGTH' => 1200] as $key => $value) {
r37Check($original->getConstant($key) === $value, 'Original finite budget retained: ' . $key);
}
r37Check(count(array_unique(array_map('sha1', $answers))) === 6, 'Six independently written task results are distinct.');
$invocations = 0;
foreach (['both_missing', 'tongue_missing', 'symptoms_missing'] as $variant) {
r37Fixture($variant);
$rawBefore = serialize([$r37Current, $r37Context]);
foreach ($answers as $task => $answer) {
$question = 'R37医师原问题:请完成' . $task . ',结合现有记录说明依据及局限。';
$prepared = DiagnosisAiLogic::prepareAssistant(37, $task, $question, 7, []);
r37Check(is_array($prepared), $variant . '/' . $task . ': partial evidence prepares successfully.');
if ($prepared === null) continue;
$guard = $prepared['tcm_evidence_guard'];
r37Check($guard['restricted'] && $guard['analysis_allowed'] && !in_array('脉象', $guard['missing'], true),
$variant . '/' . $task . ': completeness marker never includes excluded pulse or disables analysis.');
r37Check(str_contains($prepared['query'], $question) && str_contains($prepared['query'], '输出安排:')
&& !str_contains($prepared['query'], '只能从下方事实索引选id') && !str_contains($prepared['query'], '只输出fact_ids'),
$variant . '/' . $task . ': original question and task contract replace old source-selection protocol.');
$coverage = true;
foreach (array_keys(AiRequiredSource::LABELS) as $key) {
$coverage = $coverage && str_contains($prepared['query'], 'R37_FULL_SOURCE_' . $key)
&& str_contains($prepared['inputs']['herbs_json'], 'R37_FULL_SOURCE_' . $key);
}
r37Check($coverage && count($prepared['required_sections']) === 13
&& $prepared['files'] === $r37Context['files'], $variant . '/' . $task . ': all 13 raw source categories and attachments retained.');
$missing = implode('、', $guard['missing']);
$modelText = $answer . "\n\n本次资料限制\n本次尚缺可核验的" . $missing . ';依赖这些资料的辨证只作待核实方向,其余记录仍可供相应任务分析。需执业医师复核。';
foreach (['chat', 'stream'] as $mode) {
r37Reset([['ok' => true, 'content' => $modelText]]);
$visible = ''; $duringTransport = [];
$result = $mode === 'chat'
? DiagnosisAiLogic::assistant(37, $task, $question, 7, [])
: DiagnosisAiLogic::streamPreparedAssistant($prepared,
static function (string $text) use (&$visible, &$duringTransport): bool {
$visible .= $text; $duringTransport[] = DifyChatService::$streaming; return true;
}, null, null, ['knowledge_mode' => 'evidence']);
$invocations += count(DifyChatService::$calls);
$call = DifyChatService::$calls[0] ?? [];
$prefix = $variant . '/' . $task . '/' . $mode;
r37Check(count(DifyChatService::$calls) === 1 && DifyChatService::$responses === [], $prefix . ': public endpoint invokes the real local transport boundary exactly once.');
r37Check(is_array($result) && $result['answer'] === $modelText, $prefix . ': original useful model answer is returned without any local replacement.');
r37Check(($result['answer_mode'] ?? '') === 'clinical_analysis'
&& ($result['clinical_fact_state_version'] ?? '') === TcmClinicalFactStateGuard::VERSION
&& ($result['evidence_limitations'] ?? []) === $guard['missing'], $prefix . ': model analysis and limitations are reported honestly.');
r37Check(($call['profile'] ?? '') === 'qwen' && ($result['model_name'] ?? '') === 'r37-local-qwen'
&& ($call['options']['knowledge_mode'] ?? '') === 'clinical'
&& ($call['options']['reference_scope'] ?? '') === 'consultation', $prefix . ': original Qwen route and clinical consultation scope retained.');
r37Check(($call['query'] ?? '') === $prepared['query'] && ($call['inputs'] ?? []) === $prepared['inputs']
&& ($call['files'] ?? []) === $prepared['files'] && ($call['options']['required_files'] ?? false), $prefix . ': prompt, inputs, original files and required-source flag reach transport.');
r37Check(($call['options']['timeout_ms'] ?? 0) > 0 && $call['options']['timeout_ms'] <= 90000
&& ($call['options']['request_deadline_ns'] ?? 0) > 0, $prefix . ': existing deadline and finite model budget retained.');
if ($mode === 'stream') r37Check($visible === $modelText && in_array(true, $duringTransport, true),
$prefix . ': validated original model paragraphs arrive while generation is running.');
}
}
r37Check(serialize([$r37Current, $r37Context]) === $rawBefore, $variant . ': full source snapshot is never mutated.');
}
r37Fixture('both_missing');
$structured = ['diagnosis_advice' => '近期记录有两次空腹血糖点位,可供当前控制情况复核;历史诊断保留日期,症状病史与舌象资料尚待核实,不能据此确定证候。',
'risk_assessment' => [['label' => '既有病史和近期血糖记录需要持续风险评价;并发症检查资料尚不完整。', 'level' => 'medium']],
'treatment_advice' => '复核实际用药、血糖测量条件及已有肾功能等检查日期,再决定监测与随访;当前不形成新增方药或调药结论。'];
foreach (['qwen', 'openai'] as $profile) {
r37Reset([['ok' => true, 'content' => json_encode($structured, JSON_UNESCAPED_UNICODE)]]);
$result = DiagnosisAiLogic::analysis(37, 7, [], $profile);
$invocations += count(DifyChatService::$calls);
r37Check(is_array($result) && array_intersect_key($result, $structured) === $structured, $profile . ': structured analysis retains actual parsed model findings under partial evidence.');
r37Check(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['profile'] === $profile
&& DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical'
&& ($result['model_key'] ?? '') === $profile && ($result['answer_mode'] ?? '') === 'clinical_analysis',
$profile . ': selected original channel performs structured clinical analysis.');
}
foreach (['请补采脉象。', '本次舌红,苔黄腻。', '本次无过敏史。'] as $unsafe) {
$invalid = $structured;
$invalid['treatment_advice'] = $unsafe;
r37Reset([['ok' => true, 'content' => json_encode($invalid, JSON_UNESCAPED_UNICODE)]]);
r37Check(DiagnosisAiLogic::analysis(37, 7, [], 'openai') === null && count(DifyChatService::$calls) === 1,
'Partial structured analysis rejects unsafe model field without local substitution: ' . $unsafe);
}
r37Reset([['ok' => true, 'content' => '{"fact_ids":["f1"]}'], ['ok' => true, 'content' => '{"fact_ids":["f1"]}']]);
r37Check(DiagnosisAiLogic::analysis(37, 7, [], 'openai') === null && count(DifyChatService::$calls) === 2,
'Structured obsolete protocol gets only the existing finite format repair, then an honest error.');
// Independent missing-data cases still request task-specific model analysis.
$r37Current['fasting_blood_sugar'] = ''; $r37Current['current_medications'] = '未记录';
foreach ($r37Context['snapshot']['required_content'] as &$section) {
if ($section['key'] === 'diagnosis_info') $section['value'] = [$r37Current];
if (in_array($section['key'], ['daily_tracking', 'prescriptions'], true)) $section['value'] = [];
}
unset($section);
foreach (['glucose_trend' => '当前没有可比较的血糖数值,不能计算趋势;可依据既往诊断记录建立同一时段的测量表,补充空腹或餐后身份及测量条件后再评价。',
'medication_review' => '当前用药资料未记录,不能判断具体药物相互作用或剂量;可结合历史诊断核实药名、实际服用状态及监测结果,再作相应安全评价。'] as $task => $answer) {
r37Reset([['ok' => true, 'content' => $answer]]);
$result = DiagnosisAiLogic::assistant(37, $task, '按本例资料解释局限及能做的分析', 7, []);
$invocations += count(DifyChatService::$calls);
r37Check(count(DifyChatService::$calls) === 1 && ($result['answer'] ?? '') === $answer,
$task . ': absent task measurements produce an actual model explanation, not a bypass or local checklist.');
}
r37Fixture('both_missing');
foreach (['请补采脉象。', '本次舌红,苔黄腻。', '本次无过敏史。'] as $unsafe) {
r37Reset([['ok' => true, 'content' => $unsafe]]);
r37Check(DiagnosisAiLogic::assistant(37, 'case_analysis', '', 7, []) === null
&& count(DifyChatService::$calls) === 1, 'Partial evidence still rejects forbidden or fabricated fact: ' . $unsafe);
$prepared = DiagnosisAiLogic::prepareAssistant(37, 'case_analysis', '', 7, []);
r37Reset([['ok' => true, 'content' => $unsafe . "\n\n"]]);
$visible = '';
$result = DiagnosisAiLogic::streamPreparedAssistant($prepared,
static function (string $text) use (&$visible): bool { $visible .= $text; return true; });
r37Check($result === null && $visible === '', 'Partial stream rejects unsafe model paragraph before delivery: ' . $unsafe);
}
r37Reset([['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'error' => 'Local stub timed out.']]);
r37Check(DiagnosisAiLogic::assistant(37, 'summary', '', 7, []) === null
&& DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT'
&& count(DifyChatService::$calls) === 1, 'Model timeout is an honest error, with no fabricated source report.');
$prepared = DiagnosisAiLogic::prepareAssistant(37, 'summary', '', 7, []);
foreach (['UPSTREAM_TIMEOUT' => 1, 'UPSTREAM_REJECTED' => 2] as $code => $attempts) {
r37Reset(array_fill(0, $attempts, ['ok' => false, 'error_code' => $code, 'error' => 'Local stub unavailable.']));
$visible = '';
$result = DiagnosisAiLogic::streamPreparedAssistant($prepared,
static function (string $text) use (&$visible): bool { $visible .= $text; return true; });
r37Check($result === null && $visible === '' && count(DifyChatService::$calls) === $attempts
&& DiagnosisAiLogic::getAssistantErrorCode() === $code,
$code . ': stream keeps bounded existing retry policy and reports failure without a replacement answer.');
}
r37Reset([['ok' => true, 'content' => '{"fact_ids":["f1"]}']]);
r37Check(DiagnosisAiLogic::assistant(37, 'summary', '', 7, []) === null
&& DiagnosisAiLogic::getAssistantErrorCode() === 'TASK_ANALYSIS_MISSING', 'Obsolete source-ID-only model answer is rejected rather than expanded locally.');
foreach (['{"fact_ids":["f1"]}', "```json\n{\"fact_ids\":[\"f1\"]}\n```\n\n"] as $obsolete) {
$prepared = DiagnosisAiLogic::prepareAssistant(37, 'summary', '', 7, []);
r37Reset([['ok' => true, 'content' => $obsolete]]);
$visible = '';
$result = DiagnosisAiLogic::streamPreparedAssistant($prepared,
static function (string $text) use (&$visible): bool { $visible .= $text; return true; });
r37Check($result === null && $visible === '' && DiagnosisAiLogic::getAssistantErrorCode() === 'TASK_ANALYSIS_MISSING',
'Stream rejects obsolete source selection before delivery, without reporting doctor cancellation.');
}
$prepared = DiagnosisAiLogic::prepareAssistant(37, 'summary', '', 7, []);
r37Reset([]);
r37Check(DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, static fn (): bool => true) === null
&& DifyChatService::$calls === [], 'Cancellation before submission prevents all upstream calls.');
$prepared['request_deadline_ns'] = hrtime(true) - 1;
r37Check(DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true) === null
&& DifyChatService::$calls === [] && DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT', 'Expired budget prevents all upstream calls.');
r37Check(DiagnosisAiLogic::prepareAssistant(37, 'prescription_generate', '', 7, []) === null
&& DiagnosisAiLogic::getAssistantErrorCode() === 'TCM_EVIDENCE_INCOMPLETE' && DifyChatService::$calls === [],
'Separate prescription draft gate is explicit and never disguised as a model response.');
r37Reset([]);
$legacyQuestion = '请根据当前病历总结患者病情、证候与风险。';
$authorizedTask = null;
$legacy = DiagnosisAiLogic::prepareAssistant(37, 'tcm_pattern', $legacyQuestion, 7, [], null,
['on_authorized' => static function (array $meta) use (&$authorizedTask): bool {
$authorizedTask = $meta['task']; return true;
}]);
r37Check(is_array($legacy) && $legacy['task'] === 'summary' && $authorizedTask === 'summary',
'Released summary shortcut uses summary in both authorization metadata and prepared task.');
r37Check(is_array($legacy) && str_contains($legacy['query'], $legacyQuestion)
&& str_contains($legacy['query'], '问诊核心结论→病程和关键记录'),
'Legacy summary keeps the exact question and receives the summary output contract.');
$explicit = DiagnosisAiLogic::prepareAssistant(37, 'tcm_pattern', $legacyQuestion . '请生成完整中医辨证报告。', 7, []);
r37Check(is_array($explicit) && $explicit['task'] === 'tcm_pattern' && DifyChatService::$calls === [],
'Distinct explicit TCM request is not remapped and preparation makes no model call.');
$failed = array_values(array_filter($checks, static fn (array $check): bool => !$check['passed']));
echo json_encode(['suite' => 'DiagnosisAiTaskSpecificPartialR37IntegrationTest', 'checks' => count($checks),
'passed' => count($checks) - count($failed), 'failed' => count($failed), 'core_transport_calls' => $invocations,
'local_transport_calls' => DifyChatService::$totalCalls,
'failures' => $failed], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
exit($failed === [] ? 0 : 1);
}