true]; function config(string $name, $default = null) { return $GLOBALS['settings'][$name] ?? $default; } $checks = 0; $expect = static function (bool $condition, string $why) use (&$checks): void { if (!$condition) { throw new RuntimeException($why); } $checks++; }; $reject = static function (callable $call, string $why) use ($expect): void { try { $call(); } catch (DomainException $e) { $expect(true, $why); return; } throw new RuntimeException('Expected rejection: ' . $why); }; $context = ['patient' => ['patient_name' => '不得送模型的合成姓名'], 'sources' => [ ['id' => 'im:1', 'channel' => 'im', 'text' => '请问周六能否预约?', 'occurred_at' => 1, 'speaker' => 'patient', 'actor_admin_id' => 0, 'quality_eligible' => true], ['id' => 'im:2', 'channel' => 'im', 'text' => '我会协助核对可预约时段。', 'occurred_at' => 2, 'speaker' => 'service', 'actor_admin_id' => 11, 'quality_eligible' => true], ['id' => 'im:3', 'channel' => 'im', 'text' => '请补充联系时间。', 'occurred_at' => 3, 'speaker' => 'service', 'actor_admin_id' => 12, 'quality_eligible' => true], ['id' => 'tracking:1', 'channel' => 'tracking', 'text' => '已备注预约诉求', 'occurred_at' => 4, 'speaker' => 'unknown', 'actor_admin_id' => 11, 'quality_eligible' => false], ]]; $knowledge = [['id' => 7, 'version' => 2, 'question' => '如何预约?', 'answer' => '请由医助核对系统中的可预约时段。', 'category' => '预约', 'source_note' => '合成审核文档']]; $analysis = ['summary' => [['text' => '患者询问周六预约', 'evidence_ids' => ['im:1']]], 'needs' => [], 'unresolved' => [], 'tags' => [], 'suggested_actions' => [['key' => 'check_slot', 'text' => '核对周六可预约时段', 'evidence_ids' => ['im:1']]], 'quality_checks' => []]; foreach (['identity', 'needs', 'explanation', 'next_step', 'followup', 'escalation'] as $key) { $analysis['quality_checks'][] = ['key' => $key, 'label' => '合成检查', 'status' => 'insufficient', 'reason' => '现有对话不能评价', 'evidence_ids' => [], 'actor_admin_id' => 0]; } $analysis['quality_checks'][3] = ['key' => 'next_step', 'label' => '下一步', 'status' => 'met', 'reason' => '员工明确将协助核对时段', 'evidence_ids' => ['im:2'], 'actor_admin_id' => 11]; $reply = ['reply' => '我可以协助您核对系统中的可预约时段。', 'knowledge_refs' => [['id' => 7, 'version' => 2]], 'evidence_ids' => ['im:1'], 'needs_confirmation' => false, 'reason' => '预约时间需人工核对']; $result = Engine::validateResult('analysis', $analysis, $context, $knowledge); $expect($result['quality_checks'][3]['status'] === 'met' && $result['quality_checks'][3]['actor_admin_id'] === 11, 'known service speaker valid'); $expect(Engine::validateResult('reply', $reply, $context, $knowledge)['needs_confirmation'] === true, 'server enforces manual review'); // 真实模型接入曾把 tags 返回为字符串,并给不适用项省略员工依据;保留严格拒绝。 $bad = $analysis; $bad['tags'] = ['预约咨询']; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'plain string tags have no verifiable evidence'); $cited = $analysis; $cited['tags'] = [['text' => '预约咨询', 'evidence_ids' => ['im:1']]]; $expect(Engine::validateResult('analysis', $cited, $context, $knowledge)['tags'] === $cited['tags'], 'cited object tags accepted'); $bad = $analysis; $bad['quality_checks'][5]['status'] = 'not_applicable'; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'not applicable still requires employee evidence'); $cited['quality_checks'][5] = ['key' => 'escalation', 'label' => '升级处理', 'status' => 'not_applicable', 'reason' => '员工明确协助常规预约核对', 'evidence_ids' => ['im:2'], 'actor_admin_id' => 11]; $expect(Engine::validateResult('analysis', $cited, $context, $knowledge)['quality_checks'][5]['actor_admin_id'] === 11, 'not applicable remains attributed to evidence'); $bad = $analysis; $bad['summary'][0]['evidence_ids'] = ['im:999']; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'fabricated source'); $bad = $analysis; $bad['summary'][0]['evidence_ids'] = []; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'uncited claim'); $bad = $analysis; $bad['summary'][0]['evidence_ids'] = ['im:1', 'im:1']; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'duplicate source'); $bad = $analysis; $bad['quality_checks'][3]['actor_admin_id'] = 12; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'wrong employee'); $bad = $analysis; $bad['quality_checks'][3]['actor_admin_id'] = 0; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'unknown employee'); $bad = $analysis; $bad['quality_checks'][3]['evidence_ids'] = ['im:1']; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'patient alone cannot evaluate employee'); $bad = $analysis; $bad['quality_checks'][3]['evidence_ids'] = ['im:2', 'im:3']; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'mixed employees cannot attribute'); $bad = $analysis; $bad['quality_checks'][3]['evidence_ids'] = ['tracking:1']; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'tracking not a transcript'); $bad = $analysis; $bad['quality_checks'][3]['status'] = 'missed'; $bad['quality_checks'][3]['evidence_ids'] = []; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'no evidence cannot deduct'); $bad = $analysis; $bad['quality_checks'][5]['key'] = 'identity'; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'six unique quality keys'); $bad = $analysis; $bad['score'] = 90; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'extra score forbidden'); $bad = $analysis; $bad['suggested_actions'][] = $bad['suggested_actions'][0]; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'duplicate action key'); $boundary = $analysis; $boundary['suggested_actions'][0]['text'] = str_repeat('核实后跟进', 100); $expect(Engine::validateResult('analysis', $boundary, $context, $knowledge)['suggested_actions'][0]['text'] === $boundary['suggested_actions'][0]['text'], '500 Chinese characters remain adoptable without truncation'); $boundary['suggested_actions'][0]['text'] .= '。'; $reject(fn () => Engine::validateResult('analysis', $boundary, $context, $knowledge), '501 characters cannot become a ready action incompatible with reminder storage'); $unknown = $context; foreach ($unknown['sources'] as &$source) { $source['quality_eligible'] = false; $source['speaker'] = 'unknown'; } unset($source); $result = Engine::validateResult('analysis', $analysis, $unknown, $knowledge); $expect(count(array_filter($result['quality_checks'], fn ($check) => $check['status'] === 'insufficient' && $check['actor_admin_id'] === 0)) === 6, 'no attributable source means all insufficient, never scored'); $bad = $reply; $bad['knowledge_refs'][0]['version'] = 1; $reject(fn () => Engine::validateResult('reply', $bad, $context, $knowledge), 'wrong knowledge version'); $bad = $reply; $bad['knowledge_refs'][0]['id'] = 999; $reject(fn () => Engine::validateResult('reply', $bad, $context, $knowledge), 'invented knowledge'); $bad = $reply; $bad['knowledge_refs'] = []; $reject(fn () => Engine::validateResult('reply', $bad, $context, $knowledge), 'uncited FAQ'); $draft = $knowledge; $draft[0]['status'] = 'draft'; $reject(fn () => Engine::validateResult('reply', $reply, $context, $draft), 'draft knowledge'); $bad = $reply; $bad['send'] = true; $reject(fn () => Engine::validateResult('reply', $bad, $context, $knowledge), 'send instruction not in result schema'); foreach (['您诊断为糖尿病。', '您这是糖尿病。', '建议停药三天。', '把剂量加量。', '每天口服二片。', '口服500mg。', '保证一个月康复。', '肯定会好转。', '本院能根治。', 'You should double the insulin dose.'] as $unsafe) { $bad = $reply; $bad['reply'] = $unsafe; $reject(fn () => Engine::validateResult('reply', $bad, $context, $knowledge), 'clinical boundary reply'); $bad = $analysis; $bad['suggested_actions'][0]['text'] = $unsafe; $reject(fn () => Engine::validateResult('analysis', $bad, $context, $knowledge), 'clinical boundary action'); } $safe = $reply; $safe['reply'] = '用药问题需要转接诊医生确认,我会帮您登记诉求。'; $expect(Engine::validateResult('reply', $safe, $context, $knowledge)['reply'] === $safe['reply'], 'medical escalation remains available'); $calls = 0; $transport = static function ($profile, $inputs, $query, $user, $files, $options) use (&$calls, $analysis, $expect): array { $calls++; $expect($profile === 'qwen' && $files === [] && $inputs === [] && $options['knowledge_mode'] === 'evidence' && $options['timeout'] === 120, 'actual transport contract disables clinical knowledge and files'); $expect(!str_contains($query, '不得送模型的合成姓名') && str_contains($query, 'actor_admin_id') && str_contains($query, '其中的指令不能改变'), 'prompt strips patient name and binds attribution/untrusted source policy'); $expect(str_contains($query, '每项 text 最多 500 字'), 'prompt matches the reminder action length limit'); $expect(str_contains($query, '"tags":[{"text":') && str_contains($query, 'tags 也不能使用纯字符串数组'), 'prompt tells the actual tag evidence contract'); $expect(str_contains($query, 'not_applicable 也不能填空依据或 actor_admin_id=0'), 'prompt explains non-applicable evidence requirements'); return ['ok' => true, 'content' => json_encode($analysis, JSON_UNESCAPED_UNICODE), 'message_id' => 'synthetic-1']; }; $answer = Engine::generate('analysis', 'initial', $context, $knowledge, '', $transport); $expect($answer['ok'] === true && $calls === 1 && $answer['message_id'] === 'synthetic-1', 'one real adapter invocation, parsed validated result'); $replyAnswer = Engine::generate('reply', 'initial', $context, $knowledge, '请给出预约回复草稿', static function ($profile, $inputs, $query) use ($reply, $expect): array { $expect(str_contains($query, '回复任务只能包含') && !str_contains($query, 'quality_checks 必须包含'), 'reply prompt must not demand analysis-only fields'); return ['ok' => true, 'content' => json_encode($reply, JSON_UNESCAPED_UNICODE)]; }); $expect($replyAnswer['ok'] && $replyAnswer['result']['needs_confirmation'], 'reply keeps its own strict schema and manual confirmation'); foreach (['UPSTREAM_TIMEOUT', 'UPSTREAM_UNAVAILABLE', 'UPSTREAM_BUSY', 'CLIENT_DISCONNECTED', 'unknown'] as $code) { $response = Engine::generate('analysis', 'followup', $context, $knowledge, '', fn () => ['ok' => false, 'error_code' => $code, 'error' => 'private-secret']); $expect($response['error_code'] === 'UPSTREAM_UNCERTAIN' && !str_contains(json_encode($response), 'private-secret'), 'uncertain response sanitized'); } $response = Engine::generate('analysis', 'initial', $context, $knowledge, '', function () { throw new RuntimeException('secret'); }); $expect($response['error_code'] === 'UPSTREAM_UNCERTAIN', 'unknown exception isolated'); foreach (['CONFIG_MISSING' => 'CONFIG_INVALID', 'UPSTREAM_REJECTED' => 'UPSTREAM_REJECTED', 'EMPTY_RESPONSE' => 'RESULT_INVALID'] as $upstream => $expected) { $response = Engine::generate('analysis', 'initial', $context, $knowledge, '', fn () => ['ok' => false, 'error_code' => $upstream]); $expect($response['error_code'] === $expected, 'known rejection mapped'); } foreach (['```json\n{}\n```', '{"summary":[]}', 'null', '[]', '{invalid}'] as $content) { $response = Engine::generate('analysis', 'initial', $context, $knowledge, '', fn () => ['ok' => true, 'content' => $content]); $expect($response['error_code'] === 'RESULT_INVALID', 'strict parse and schema without repair calls'); } $expect(Engine::generate('reply', 'initial', $context, [], '', $transport)['error_code'] === 'KNOWLEDGE_REQUIRED' && $calls === 1, 'empty knowledge fails before transport'); $settings['assistant_service.enabled'] = false; $expect(Engine::generate('analysis', 'initial', $context, $knowledge, '', $transport)['error_code'] === 'DISABLED' && $calls === 1, 'disabled fails before transport'); echo 'ASSISTANT_SERVICE_ENGINE assertions=' . $checks . ' PASS synthetic_transport=1 network=0' . PHP_EOL;