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("discard private reasoning```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 (['舌象正常', '未见明显异常', '提示湿热证', '建议服用中药', '高血压风险', '',
'查看 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";