213 lines
17 KiB
PHP
213 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use app\common\service\TongueScreenshotAnalysis as Analysis;
|
|
use app\common\service\TongueScreenshotImage as Image;
|
|
use think\file\UploadedFile;
|
|
|
|
$checks = 0;
|
|
$expect = static function (bool $ok, string $label) use (&$checks): void {
|
|
if (!$ok) throw new RuntimeException('FAIL: ' . $label);
|
|
++$checks;
|
|
};
|
|
$reject = static function (callable $call, string $label) use ($expect): void {
|
|
try { $call(); } catch (DomainException $e) { $expect(true, $label); return; }
|
|
$expect(false, $label);
|
|
};
|
|
$encode = static fn (array $data): string => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
$valid = ['image_quality' => 'usable', 'quality_note' => '主画面舌头可见,轮廓较清楚',
|
|
'features' => [['name' => '舌苔颜色', 'observation' => '可见舌苔偏白']], 'limitations' => [],
|
|
'tcm_diagnosis' => ['status' => 'possible_patterns', 'summary' => '仅供参考,可考虑寒湿方向,需问诊和脉诊复核,不能据此确诊',
|
|
'candidates' => [['name' => '寒湿倾向', 'basis' => ['舌苔颜色'], 'verify' => ['需询问有无畏寒,并检查脉象是否沉迟']]],
|
|
'missing_information' => ['尚缺症状问诊和脉象检查']]];
|
|
$expect(Analysis::parse($encode($valid)) === $valid, 'observable feature returned without augmentation');
|
|
$expect(Analysis::parse("<think>discard private reasoning</think>```json\n" . $encode($valid) . "\n```") === $valid, 'reasoning removed before exact JSON parsing');
|
|
$limited = $valid; $limited['image_quality'] = 'limited'; $limited['limitations'] = ['舌根被遮挡'];
|
|
$expect(Analysis::parse($encode($limited)) === $limited, 'limited quality retains only visible features');
|
|
$insufficient = ['status' => 'insufficient', 'summary' => '图片依据不足,无法提出辨证方向',
|
|
'candidates' => [], 'missing_information' => ['缺少清晰舌象、症状问诊和脉象检查']];
|
|
$absent = ['image_quality' => 'unusable', 'quality_note' => '主画面未见舌头', 'features' => [],
|
|
'limitations' => ['主画面只显示脸部'], 'tcm_diagnosis' => $insufficient];
|
|
$expect(Analysis::parse($encode($absent)) === $absent, 'no tongue has empty observations and a reason');
|
|
foreach (['quality' => 'uncertain', 'extra' => true] as $key => $value) {
|
|
$data = $valid; $data[$key] = $value;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'unknown root field ' . $key);
|
|
}
|
|
foreach (['blurred', null, 1, ['usable']] as $quality) {
|
|
$data = $valid; $data['image_quality'] = $quality;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'quality enum strictly typed');
|
|
}
|
|
foreach ([[], ['normal' => true], [['name' => '证型', 'observation' => '湿热']],
|
|
[['name' => '舌苔颜色', 'observation' => '白', 'extra' => 'ignored']],
|
|
array_fill(0, 8, $valid['features'][0]), array_fill(0, 2, $valid['features'][0])] as $features) {
|
|
$data = $valid; $data['features'] = $features;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'invalid or duplicate features rejected');
|
|
}
|
|
foreach (['舌象正常', '未见明显异常', '提示湿热证', '建议服用中药', '高血压风险', '<script>alert(1)</script>',
|
|
'查看 https://bad.example/secret', '联系 13800138000', "偏白\n泄漏", str_repeat('白', 181), 'white', ''] as $text) {
|
|
$data = $valid; $data['features'][0]['observation'] = $text;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'unsafe, clinical or oversized observation rejected');
|
|
}
|
|
$data = $absent; $data['features'] = $valid['features'];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'unusable cannot invent normal-looking features');
|
|
$data = $absent; $data['quality_note'] = '画面清晰'; $data['limitations'] = ['舌苔偏白'];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'unusable requires an actual visibility reason');
|
|
$data = $valid; $data['quality_note'] = '主画面未见舌头';
|
|
$reject(fn () => Analysis::parse($encode($data)), 'no tongue note cannot contradict visible features');
|
|
$data = $limited; $data['limitations'] = [];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'limited must explain its limits');
|
|
$data = $valid; $data['quality_note'] = str_repeat('清', 241);
|
|
$reject(fn () => Analysis::parse($encode($data)), 'quality note bounded');
|
|
$data = $valid; $data['limitations'] = array_fill(0, 7, '受光照影响');
|
|
$reject(fn () => Analysis::parse($encode($data)), 'limitation count bounded');
|
|
$data = $valid; $data['limitations'] = [str_repeat('糊', 161)];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'limitation length bounded');
|
|
foreach (['{}', '[]', 'not json', str_repeat('x', 32769), $encode($valid) . '附加报告'] as $text) {
|
|
$reject(fn () => Analysis::parse($text), 'invalid response rejected');
|
|
}
|
|
$expect(str_contains(Analysis::prompt(), '主画面') && str_contains(Analysis::prompt(), '小窗医生')
|
|
&& str_contains(Analysis::prompt(), 'features 必须为空数组'), 'prompt explicitly excludes inset and no-tongue hallucination');
|
|
$expect(str_contains(Analysis::prompt(), '仅 tcm_diagnosis 区可使用中医术语')
|
|
&& str_contains(Analysis::prompt(), '不能编造症状、脉象、既往史或患者自述')
|
|
&& str_contains(Analysis::prompt(), '不能仅凭舌图确诊'), 'prompt separates tentative TCM direction from observed facts and unavailable clinical evidence');
|
|
|
|
// The schema binds proposed patterns to named observations; text guards catch known
|
|
// unsafe forms, but tests do not claim they can verify a model\'s medical reasoning.
|
|
$data = $valid; $data['tcm_diagnosis'] = $insufficient;
|
|
$expect(Analysis::parse($encode($data)) === $data, 'visible tongue may still provide insufficient evidence for a pattern');
|
|
$data = $valid; unset($data['tcm_diagnosis']);
|
|
$reject(fn () => Analysis::parse($encode($data)), 'TCM section is mandatory');
|
|
foreach ([null, [], '寒湿'] as $diagnosis) {
|
|
$data = $valid; $data['tcm_diagnosis'] = $diagnosis;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'TCM section must be an object');
|
|
}
|
|
$data = $absent; $data['tcm_diagnosis'] = $valid['tcm_diagnosis'];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'unusable tongue cannot carry candidate patterns');
|
|
foreach ([['status' => 'certain'], ['status' => null], ['status' => 'insufficient'], ['candidates' => []],
|
|
['candidates' => array_fill(0, 4, $valid['tcm_diagnosis']['candidates'][0])],
|
|
['missing_information' => []], ['missing_information' => array_fill(0, 7, '尚缺问诊')],
|
|
['missing_information' => [str_repeat('问', 161)]], ['summary' => str_repeat('参', 241)], ['confidence' => 0.9]] as $override) {
|
|
$data = $valid; $data['tcm_diagnosis'] = array_replace($valid['tcm_diagnosis'], $override);
|
|
$reject(fn () => Analysis::parse($encode($data)), 'TCM status, required arrays, exact fields and bounds enforced');
|
|
}
|
|
foreach ([['name' => str_repeat('湿', 41)], ['basis' => []], ['basis' => ['舌体颜色']], ['basis' => ['脉象']],
|
|
['basis' => ['可见舌苔偏白']], ['basis' => ['舌苔颜色', '舌苔颜色']], ['basis' => [null]],
|
|
['verify' => []], ['verify' => array_fill(0, 5, '需问诊核实')], ['verify' => [str_repeat('需', 161)]],
|
|
['verify' => ['脉象为弦滑']], ['verify' => ['患者伴有口苦']], ['verify' => ['患者自诉乏力']],
|
|
['confidence' => 98]] as $override) {
|
|
$data = $valid; $data['tcm_diagnosis']['candidates'][0] = array_replace($data['tcm_diagnosis']['candidates'][0], $override);
|
|
$reject(fn () => Analysis::parse($encode($data)), 'candidate bounds, supported feature names and needed corroboration enforced');
|
|
}
|
|
foreach (['不能仅凭舌象确定证型,需结合问诊与脉诊', '尚未确诊,候选方向仍待核实', '不确定具体证型,需补充症状信息'] as $text) {
|
|
$data = $valid; $data['tcm_diagnosis']['summary'] = $text;
|
|
$expect(Analysis::parse($encode($data))['tcm_diagnosis']['summary'] === $text, 'explicit uncertainty is not mistaken for a definitive diagnosis');
|
|
}
|
|
foreach (['确诊为寒湿证', '不能仅凭舌象确诊,但可以确定是寒湿证', '诊断为脾虚证', '患者伴有口苦,脉象为弦滑', '既往有高血压',
|
|
'置信度百分之九十', '可能性为90%', '建议服用中药', '治疗采用清热法', '建议黄芪30克', '保证身体健康'] as $text) {
|
|
$data = $valid; $data['tcm_diagnosis']['summary'] = $text;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'known certainty, invented clinical facts and treatment forms rejected');
|
|
}
|
|
$data = $valid; $data['features'][0]['observation'] = '寒湿倾向';
|
|
$reject(fn () => Analysis::parse($encode($data)), 'TCM terms allowed only in diagnosis, not observations');
|
|
$data = $valid; $data['tcm_diagnosis']['missing_information'] = [];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'image-only response can never claim complete clinical evidence');
|
|
|
|
foreach (['未见舌根,舌尖可见', '未见舌体边缘,中央舌面可见', '未见舌尖,舌面中部可见', '未见舌苔,舌体清晰可见'] as $limited) {
|
|
$data = $valid; $data['limitations'] = [$limited];
|
|
$expect(Analysis::parse($encode($data)) === $data, 'partially unobserved anatomy is not mistaken for an absent tongue');
|
|
}
|
|
foreach (['主画面未见舌头', '看不到患者的舌体', '没有舌,只有脸部'] as $absentNote) {
|
|
$data = $valid; $data['quality_note'] = $absentNote;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'whole tongue absence still conflicts with nonempty features');
|
|
}
|
|
foreach (['目前难以确定具体证候方向,需结合问诊与脉诊', '无法仅凭一张舌图确定证型,需结合问诊与脉诊'] as $uncertain) {
|
|
$data = $valid; $data['tcm_diagnosis']['summary'] = $uncertain;
|
|
$expect(Analysis::parse($encode($data)) === $data, 'bounded uncertainty variants are accepted');
|
|
}
|
|
$data = $valid; $data['tcm_diagnosis']['summary'] = '无法仅凭一张舌图确定,但可以确定是寒湿证';
|
|
$reject(fn () => Analysis::parse($encode($data)), 'negated clause cannot bypass a following certain assertion');
|
|
$data = $valid; $data['tcm_diagnosis']['candidates'][0]['verify'] = ['口渴吗?'];
|
|
$expect(Analysis::parse($encode($data)) === $data, 'explicit question ending is valid needed verification');
|
|
$data['tcm_diagnosis']['candidates'][0]['verify'] = ['患者伴有口苦吗?'];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'question punctuation does not bypass the existing assertion guard');
|
|
|
|
// Information needed for the consultation is distinct from an asserted fact or treatment.
|
|
foreach (['缺少既往用药史', '需补充近期用药情况和药物过敏史', '尚缺患者自述症状资料', '患者自述的症状信息'] as $missing) {
|
|
$data = $valid; $data['tcm_diagnosis']['missing_information'] = [$missing];
|
|
$expect(Analysis::parse($encode($data)) === $data, 'missing information category preserved without treating it as advice or a symptom assertion');
|
|
}
|
|
foreach (['需询问既往有无相关病史', '需询问是否服用药物', '需询问近期服药情况', '睡眠和二便是否正常?', '有无排便异常?', '请问近期睡眠如何?'] as $question) {
|
|
$data = $valid; $data['tcm_diagnosis']['candidates'][0]['verify'] = [$question];
|
|
$expect(Analysis::parse($encode($data)) === $data, 'explicit question remains a question rather than a health or medication conclusion');
|
|
}
|
|
foreach (['缺少既往用药信息,建议服药', '缺少用药史,建议黄芪30克', '患者自述乏力', '患者自述症状资料,患者伴有口苦', '既往有高血压', '睡眠和二便正常', '明确诊断为寒湿证'] as $badMissing) {
|
|
$data = $valid; $data['tcm_diagnosis']['missing_information'] = [$badMissing];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'information category exception cannot hide appended advice or invented facts');
|
|
}
|
|
foreach (['需询问既往有无相关病史,既往有高血压', '是否服用药物,建议继续服药', '是否用药,建议黄芪30克', '是否正常,但可以确定是寒湿证', '是否大便正常,小便正常', '需询问近期用药情况,脉象为弦滑'] as $badQuestion) {
|
|
$data = $valid; $data['tcm_diagnosis']['candidates'][0]['verify'] = [$badQuestion];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'question exception is bounded to its clause and keeps remaining guards');
|
|
}
|
|
foreach (['未见舌头后部,舌尖可见', '未见舌体后部,舌面中央可见', '未见舌体全貌,舌面中央可见'] as $partial) {
|
|
$data = $valid; $data['limitations'] = [$partial];
|
|
$expect(Analysis::parse($encode($data)) === $data, 'unobserved posterior or complete outline does not mean no tongue is visible');
|
|
}
|
|
foreach (['不能做出确定诊断,需结合问诊和脉诊', '无法明确诊断,需结合问诊和脉诊', '不能仅凭本次截图确定证型,需结合问诊和脉诊', '不能仅凭单张舌头照片确定证型,需结合问诊和脉诊'] as $uncertain) {
|
|
$data = $valid; $data['tcm_diagnosis']['summary'] = $uncertain;
|
|
$expect(Analysis::parse($encode($data)) === $data, 'bounded negated diagnosis is allowed');
|
|
$data['tcm_diagnosis']['summary'] .= ',但可以确定是寒湿证';
|
|
$reject(fn () => Analysis::parse($encode($data)), 'negated diagnosis does not hide a following positive diagnosis');
|
|
}
|
|
foreach (['缺少患者自述症状资料', '缺少脉象资料,无法进一步辨证', '舌下未拍摄,无法判断血瘀'] as $wrongField) {
|
|
$data = $valid; $data['limitations'] = [$wrongField];
|
|
$reject(fn () => Analysis::parse($encode($data)), 'clinical missing information is still prohibited in pure imaging limitations');
|
|
}
|
|
$data = $valid; $data['tcm_diagnosis']['candidates'][0]['name'] = '正常舌象';
|
|
$reject(fn () => Analysis::parse($encode($data)), 'normal appearance cannot become a clinical candidate');
|
|
$expect(str_contains(Analysis::prompt(), 'limitations 只能写') && str_contains(Analysis::prompt(), '缺少既往用药史')
|
|
&& str_contains(Analysis::prompt(), '不要将“正常舌象”'), 'prompt matches field roles and avoids the observed repeated Qwen failures');
|
|
foreach (['图像清晰,白平衡正常,舌面细节可见', '曝光正常,舌面存在反光', '光线正常,舌根被遮挡'] as $camera) {
|
|
$data = $valid; $data['quality_note'] = $camera;
|
|
$expect(Analysis::parse($encode($data)) === $data, 'camera conditions are not mistaken for a clinical normality claim');
|
|
foreach ([',舌象正常', ',表明患者健康', ',未见明显异常', ',提示湿热证'] as $claim) {
|
|
$data['quality_note'] = $camera . $claim;
|
|
$reject(fn () => Analysis::parse($encode($data)), 'camera exception does not hide an appended clinical conclusion');
|
|
}
|
|
}
|
|
|
|
$paths = [];
|
|
$make = static function (string $format = 'jpeg', int $width = 128, int $height = 128) use (&$paths): UploadedFile {
|
|
$path = tempnam(sys_get_temp_dir(), 'tongue-synthetic-'); $paths[] = $path;
|
|
$canvas = imagecreatetruecolor($width, $height);
|
|
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 160, 100, 100));
|
|
if ($format === 'jpeg') imagejpeg($canvas, $path, 80); else imagepng($canvas, $path);
|
|
imagedestroy($canvas);
|
|
return new UploadedFile($path, 'untrusted-client-name.jpg', 'image/jpeg', UPLOAD_ERR_OK, true);
|
|
};
|
|
try {
|
|
$file = $make(); $digest = hash_file('sha256', $file->getPathname());
|
|
$image = Image::fromUpload($file);
|
|
$expect($image->sha256() === $digest && $image->path() === $file->getRealPath(), 'digest binds exact original JPEG bytes');
|
|
$image->release(); $expect(!is_file($file->getPathname()), 'temporary screenshot removed explicitly');
|
|
$reject(fn () => Image::fromUpload('https://example.test/photo.jpg'), 'URL cannot become upload');
|
|
$reject(fn () => Image::fromUpload(['path' => __FILE__]), 'arbitrary path cannot become upload');
|
|
$reject(fn () => Image::fromUpload($make('png')), 'client MIME/name cannot disguise PNG');
|
|
$reject(fn () => Image::fromUpload($make('jpeg', 32, 128)), 'too-small JPEG rejected');
|
|
$reject(fn () => Image::fromUpload($make('jpeg', 8193, 64)), 'oversized dimension rejected before decode');
|
|
$file = $make(); $source = file_get_contents($file->getPathname());
|
|
file_put_contents($file->getPathname(), substr($source, 0, -2));
|
|
$reject(fn () => Image::fromUpload($file), 'truncated JPEG rejected');
|
|
$file = $make(); $handle = fopen($file->getPathname(), 'ab'); ftruncate($handle, Image::MAX_BYTES + 1); fclose($handle);
|
|
clearstatcache(true, $file->getPathname());
|
|
$reject(fn () => Image::fromUpload($file), 'actual file size enforces 10 MiB cap');
|
|
$file = $make();
|
|
$reject(fn () => Image::fromUpload(new UploadedFile($file->getPathname(), 'photo.jpg', 'image/jpeg')), 'ordinary local file is not an HTTP upload');
|
|
$reject(fn () => Image::fromUpload(new UploadedFile($file->getPathname(), 'photo.jpg', 'image/jpeg', UPLOAD_ERR_PARTIAL, true)), 'partial upload rejected');
|
|
} finally {
|
|
foreach ($paths as $path) if (is_file($path)) unlink($path);
|
|
}
|
|
echo "Tongue screenshot image and response contract: {$checks} checks OK\n";
|