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";