更新
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 仅合成资料与注入 transport,不初始化应用、不读 .env、不访问网络。
|
||||
require dirname(__DIR__) . '/app/common/service/assistantservice/AssistantServiceEngine.php';
|
||||
|
||||
use app\common\service\assistantservice\AssistantServiceEngine as Engine;
|
||||
|
||||
$settings = ['assistant_service.enabled' => 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;
|
||||
Reference in New Issue
Block a user