Merge commit '5706e9a6a51227ebf798df46fabcbabf83f91ab2' into codex/followup-audio

This commit is contained in:
2026-10-09 14:51:44 +08:00
793 changed files with 3807585 additions and 320 deletions
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace app\common\cache {
// 菜单权限数据在内存中,测试不连接缓存和业务数据库。
class AdminAuthCache
{
public static array $allUri = ['tcm.diagnosis/lists', 'tcm.prescriptionorder/lists'];
public static array $adminUri = ['tcm.diagnosis/lists'];
public function __construct(int $adminId)
{
if ($adminId !== 9) { throw new \RuntimeException('wrong permission identity'); }
}
public function getAllUri(): array { return self::$allUri; }
public function getAdminUri(): array { return self::$adminUri; }
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\http\middleware\AuthMiddleware;
$app = new think\App();
function rpaIpCheck(bool $ok, string $message): void
{
if (!$ok) { throw new RuntimeException($message); }
}
// 桌面在员工电脑登录(198.51.100.10),客服后台服务器(203.0.113.20)拿同一令牌代查患者。
$desktop = ['admin_id' => 9, 'terminal' => 7, 'root' => 0, 'login_ip' => '198.51.100.10'];
$invoke = function (array $identity, string $ip, string $controller = 'tcm.diagnosis', string $action = 'lists') use ($app) {
$request = (new think\Request())->withServer(['REMOTE_ADDR' => $ip]);
$request->setController($controller);
$request->setAction($action);
$request->adminInfo = $identity;
$request->controllerObject = new class {
public function isNotNeedLogin(): bool { return false; }
};
$app->instance('request', $request);
$result = (new AuthMiddleware())->handle($request, static fn() => ['code' => 1, 'msg' => 'passed']);
return is_array($result) ? $result : $result->getData();
};
$ipChanged = static fn(array $r): bool => $r['code'] === -1 && str_contains($r['msg'], 'ip地址发生变化');
$setTrusted = static fn(string $value) => $app->env->set('wecom_rpa.server_ips', $value);
rpaIpCheck(env('wecom_rpa.server_ips') === null, 'test process must not inherit WECOM_RPA_SERVER_IPS');
rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.20')), 'missing config keeps the login IP check');
$setTrusted('');
rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.20')), 'empty config keeps the login IP check');
// [WECOM_RPA] SERVER_IPS 在 .env 里是逗号/空白分隔的列表
$setTrusted(' 192.0.2.1,203.0.113.20 2001:db8::20 ');
rpaIpCheck(env('wecom_rpa.server_ips') === ' 192.0.2.1,203.0.113.20 2001:db8::20 ', 'section key maps to env');
rpaIpCheck($invoke($desktop, '203.0.113.20')['code'] === 1, 'desktop token from the kefu server passes');
rpaIpCheck($invoke(array_replace($desktop, ['terminal' => 9]), '203.0.113.20')['code'] === 1,
'mobile console token from the kefu server passes');
rpaIpCheck($invoke(array_replace($desktop, ['terminal' => '7']), '2001:db8::20')['code'] === 1,
'IPv6 server address and string terminal from cache pass');
rpaIpCheck($invoke($desktop, '198.51.100.10')['code'] === 1, 'desktop token from the login IP still passes');
rpaIpCheck($ipChanged($invoke($desktop, '198.51.100.99')), 'desktop token from an untrusted IP is rejected');
rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.2')), 'trusted IP must match exactly, not by prefix');
rpaIpCheck($ipChanged($invoke($desktop, '2001:db8::2')), 'trusted IPv6 must match exactly');
rpaIpCheck($ipChanged($invoke(array_replace($desktop, ['terminal' => 1]), '203.0.113.20')),
'web token from the kefu server keeps IP protection');
rpaIpCheck($ipChanged($invoke(array_replace($desktop, ['terminal' => 2]), '203.0.113.20')),
'admin mobile web token keeps IP protection');
rpaIpCheck($ipChanged($invoke(array_replace($desktop, ['terminal' => 8]), '203.0.113.20')),
'AI browser console token keeps IP protection');
rpaIpCheck($ipChanged($invoke(array_diff_key($desktop, ['terminal' => 0]), '203.0.113.20')),
'token without terminal keeps IP protection');
// 跳过的只有 IP 校验:客服后台仍只能访问该账号角色允许的接口
$denied = $invoke($desktop, '203.0.113.20', 'tcm.prescriptionOrder', 'lists');
rpaIpCheck($denied['code'] === 0 && str_contains($denied['msg'], '权限不足'), 'menu permissions still apply');
rpaIpCheck($invoke(array_replace($desktop, ['root' => 1]), '203.0.113.20', 'tcm.prescriptionOrder', 'lists')['code'] === 1,
'root bypass is unchanged');
$setTrusted('true');
rpaIpCheck(env('wecom_rpa.server_ips') === true, 'think Env converts true to bool');
rpaIpCheck($ipChanged($invoke($desktop, '203.0.113.20')), 'non-list config keeps the login IP check');
rpaIpCheck(!AuthMiddleware::isTrustedRpaServerRequest($desktop, '', ','), 'empty request IP never trusted');
rpaIpCheck(!AuthMiddleware::isTrustedRpaServerRequest($desktop, '203.0.113.20', " ,\n\t"), 'separators only');
rpaIpCheck(AuthMiddleware::isTrustedRpaServerRequest($desktop, '203.0.113.20', "192.0.2.1\n203.0.113.20"),
'newline separated list');
// 示例配置能按 .env 规则解析且默认留空;生产 IP 只写在运维配置里,不硬编码进代码
$exampleEnv = new think\Env();
$exampleEnv->load(dirname(__DIR__) . '/.env.wecom-rpa.example');
rpaIpCheck($exampleEnv->get('wecom_rpa.server_ips') === '', 'env example parses and ships an empty list');
$middlewareSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
rpaIpCheck(is_string($middlewareSource) && !str_contains($middlewareSource, '123.14.'),
'kefu server IP is not hard-coded in the middleware');
echo "AdminWecomRpaServerIpTest passed\n";
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace app\common\service {
function config(string $name): array
{
return $GLOBALS['clinicalChatConfig'];
}
function curl_init(): \stdClass
{
return new \stdClass();
}
function curl_setopt(\stdClass $handle, int $option, mixed $value): bool
{
$handle->options[$option] = $value;
return true;
}
function curl_exec(\stdClass $handle): string|bool
{
$GLOBALS['clinicalChatPayloads'][] = json_decode($handle->options[CURLOPT_POSTFIELDS], true);
if (isset($handle->options[CURLOPT_WRITEFUNCTION])) {
$write = $handle->options[CURLOPT_WRITEFUNCTION];
$write($handle, "data: {\"choices\":[{\"delta\":{\"content\":\"offline stream\"}}]}\n\n");
return true;
}
return '{"choices":[{"message":{"content":"offline reply"}}]}';
}
function curl_errno(\stdClass $handle): int { return 0; }
function curl_error(\stdClass $handle): string { return ''; }
function curl_getinfo(\stdClass $handle, int $option): int { return 200; }
function curl_close(\stdClass $handle): void {}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\AiChatService;
use app\common\service\NihaixiaClinicalSkill;
function chatSkillExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$clinicalChatConfig = [
'enable' => true,
'api_key' => 'offline-fixture',
'base_url' => 'https://ai.example.test',
'model' => 'offline-model',
];
$clinicalChatPayloads = [];
$messages = [
['role' => 'system', 'content' => '只输出 JSON'],
['role' => 'user', 'content' => '分析该患者症状'],
];
$blocking = AiChatService::chat($messages);
chatSkillExpect($blocking['ok'] === true, 'blocking chat succeeds offline');
$deltas = [];
$streaming = AiChatService::streamChat($messages, static function (string $delta) use (&$deltas): void {
$deltas[] = $delta;
});
chatSkillExpect($streaming['ok'] === true && $deltas === ['offline stream'], 'streaming chat succeeds offline');
chatSkillExpect(count($clinicalChatPayloads) === 2, 'both transports sent one request');
foreach ($clinicalChatPayloads as $payload) {
$wireMessages = $payload['messages'];
chatSkillExpect(str_contains($wireMessages[0]['content'], NihaixiaClinicalSkill::VERSION), 'wire request includes the clinical skill');
chatSkillExpect($wireMessages[1] === $messages[0] && $wireMessages[2] === $messages[1], 'original format and patient question are preserved');
}
echo "AiChat clinical skill transport contract passed.\n";
}
@@ -50,7 +50,19 @@ $analysisSource = implode('', array_slice(
));
analysisContractExpect(
substr_count($analysisSource, 'DifyChatService::chat(') === 1,
'one analysis request performs exactly one upstream chat call'
'analysis retries malformed output through the same bounded upstream call site'
);
analysisContractExpect(
str_contains($analysisSource, '$attempt < 2')
&& str_contains($analysisSource, 'buildAnalysisRepairPrompt($prompt)'),
'malformed structured output receives at most one format repair attempt'
);
$repair = $logicReflection->getMethod('buildAnalysisRepairPrompt')->invoke(null, 'ORIGINAL_CASE_PROMPT');
analysisContractExpect(
str_contains($repair, 'ORIGINAL_CASE_PROMPT')
&& str_contains($repair, '仅有 diagnosis_advice、risk_assessment、treatment_advice 三个键')
&& str_contains($repair, '资料不足时说明缺口'),
'repair keeps the original patient evidence and constrained schema'
);
foreach ([
"'diagnosis_advice'",
@@ -29,6 +29,23 @@ analysisParserExpect(is_string($json), 'fixture JSON encodes');
$parsed = $parse->invoke(null, $json);
analysisParserExpect($parsed === $validPayload, 'plain JSON parses without changing contract');
foreach (['diagnosis_ad', 'diagnosis_adive'] as $alias) {
$misspelled = $validPayload;
$misspelled[$alias] = $misspelled['diagnosis_advice'];
unset($misspelled['diagnosis_advice']);
analysisParserExpect(
$parse->invoke(null, json_encode($misspelled, JSON_UNESCAPED_UNICODE)) === $validPayload,
"observed {$alias} typo maps to canonical diagnosis field"
);
}
$ambiguous = $validPayload;
$ambiguous['diagnosis_ad'] = '另一段诊断';
analysisParserExpect(
$parse->invoke(null, json_encode($ambiguous, JSON_UNESCAPED_UNICODE)) === null,
'conflicting diagnosis fields are rejected'
);
$fenced = "说明文字\n```json\n{$json}\n```\n后续文字";
analysisParserExpect($parse->invoke(null, $fenced) === $validPayload, 'fenced JSON with surrounding text parses');
@@ -71,7 +71,7 @@ assistantStreamExpect(
$actionStart = strpos($controller, 'public function aiAssistantStream()');
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
$prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart);
$runAt = strpos($controller, '$this->runAssistantSse($prepared)', $actionStart);
$runAt = strpos($controller, '$this->runAssistantSse($prepared, $timing)', $actionStart);
$headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart);
assistantStreamExpect(
$actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt,
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantSseTiming;
function timingExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$one = new AssistantSseTiming();
$two = new AssistantSseTiming();
$first = $one->event('prepare', [
'duration_ms' => 120,
'aggregate_ms' => 75,
'compaction_ms' => 0,
'source_bytes' => 1000,
'query_bytes' => 1100,
'attachment_count' => 2,
'compaction_attempted' => false,
'status' => 'ok',
'patient_name' => '患者姓名',
'query' => '患者正文',
'url' => 'https://private.example/path',
'api_key' => 'secret',
]);
$second = $one->event('upstream_done', [
'duration_ms' => 4300,
'retry_ms' => 1200,
'retry_attempted' => true,
'delta_count' => 4,
'delta_bytes' => 24,
'status' => 'error',
'error_code' => 'UPSTREAM_TIMEOUT',
]);
timingExpect(preg_match('/^[a-f0-9]{32}$/', $first['trace_id']) === 1, 'trace id is random hex only');
timingExpect($first['trace_id'] !== $two->event('prepare')['trace_id'], 'requests use distinct trace ids');
timingExpect($first['trace_id'] === $second['trace_id'], 'all phases share one trace id');
timingExpect(
$first['aggregate_ms'] === 75 && $first['attachment_count'] === 2 && $first['compaction_attempted'] === false,
'prepare phase retains useful numeric metadata'
);
timingExpect(
$second['retry_attempted'] === true && $second['retry_ms'] === 1200
&& $second['error_code'] === 'UPSTREAM_TIMEOUT',
'upstream phase retains retry, duration and safe status'
);
$encoded = json_encode($first, JSON_UNESCAPED_UNICODE);
foreach (['患者姓名', '患者正文', 'private.example', 'secret'] as $sensitive) {
timingExpect(!str_contains($encoded, $sensitive), 'timing log excludes sensitive payload');
}
timingExpect(!isset($one->event('prepare', ['duration_ms' => '患者正文'])['duration_ms']), 'numeric fields reject text');
timingExpect(!isset($one->event('upstream_done', ['error_code' => 'KEY=secret'])['error_code']), 'error code is validated');
timingExpect(!isset($one->event('upstream_done', ['error_code' => 'API_KEY_SECRET'])['error_code']), 'unknown machine codes are not logged');
$startedAt = hrtime(true);
timingExpect(AssistantSseTiming::elapsedMs($startedAt) >= 0, 'monotonic elapsed time is non-negative');
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
$logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/DiagnosisAiLogic.php');
timingExpect(is_string($controller) && is_string($logic), 'instrumented source is readable');
timingExpect(str_contains($controller, "log('first_delta'") && str_contains($controller, "log('sse_done'"), 'SSE first delta and completion are timed');
timingExpect(str_contains($logic, "log('prepare'") && str_contains($logic, "log('upstream_done'"), 'prepare and upstream are timed');
timingExpect(str_contains($logic, 'compaction_attempted') && str_contains($logic, 'retry_attempted'), 'compression and compatibility retry are recorded');
echo "Diagnosis AI assistant timing: OK\n";
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
final class PrescriptionGenerateAuthCacheDouble
{
/** @var array<int,string> */
public static array $uris = [];
public function __construct(int $adminId = 0)
{
}
/** @return array<int,string> */
public function getAdminUri(): array
{
return self::$uris;
}
}
function prescriptionPermissionExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
prescriptionPermissionExpect(
class_alias(PrescriptionGenerateAuthCacheDouble::class, 'app\\common\\cache\\AdminAuthCache'),
'permission cache double is installed before logic autoload'
);
$reflection = new ReflectionClass(DiagnosisAiLogic::class);
prescriptionPermissionExpect(
$reflection->getConstant('PERMISSION_GENERATE_PRESCRIPTION') === 'tcm.diagnosis/aigenerateprescription',
'prescription generation has a dedicated permission'
);
PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiAssistant'];
prescriptionPermissionExpect(
DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null,
'general assistant permission alone cannot generate a prescription'
);
prescriptionPermissionExpect(
DiagnosisAiLogic::getError() === '权限不足,无法使用 AI 生成处方',
'denial names the missing prescription permission'
);
prescriptionPermissionExpect(
DiagnosisAiLogic::assistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null,
'blocking endpoint rejects the task before any upstream call'
);
prescriptionPermissionExpect(
DiagnosisAiLogic::prepareAssistant(1, 'PRESCRIPTION_GENERATE', '', 7, ['root' => 0]) === null
&& DiagnosisAiLogic::getError() === '权限不足,无法使用 AI 生成处方',
'task name normalization cannot bypass the dedicated permission'
);
prescriptionPermissionExpect(
DiagnosisAiLogic::prepareAssistant(0, 'summary', '', 7, ['root' => 0]) === null
&& DiagnosisAiLogic::getError() === '诊单ID必须大于0',
'ordinary assistant tasks continue past the prescription permission check'
);
PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiGeneratePrescription'];
prescriptionPermissionExpect(
DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null,
'prescription permission alone does not bypass the general assistant permission'
);
prescriptionPermissionExpect(
DiagnosisAiLogic::getError() === '权限不足,无法使用诊单 AI 助手',
'general assistant permission remains required'
);
$hasPermission = $reflection->getMethod('hasPermission');
prescriptionPermissionExpect(
$hasPermission->invoke(null, 7, ['root' => 0], 'tcm.diagnosis/aigenerateprescription') === true,
'new permission is read from role permissions'
);
prescriptionPermissionExpect(
$hasPermission->invoke(null, 1, ['root' => 1], 'tcm.diagnosis/aigenerateprescription') === true,
'root keeps its existing permission bypass'
);
$migration = file_get_contents(
dirname(__DIR__) . '/database/migrations/2026_10_08_diagnosis_ai_generate_prescription_permission.sql'
);
prescriptionPermissionExpect(
is_string($migration)
&& str_contains($migration, "'tcm.diagnosis/aiGeneratePrescription'")
&& str_contains($migration, "'AI生成处方'"),
'management role tree registers the dedicated checkbox'
);
prescriptionPermissionExpect(
!str_contains($migration, 'zyt_system_role_menu'),
'migration does not silently grant the new permission to existing roles'
);
echo "Diagnosis AI prescription generation permission: OK\n";
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace app\common\service {
function config(string $name): array
{
return $GLOBALS['ancientReuseConfig'];
}
function curl_init(): \stdClass
{
return new \stdClass();
}
function curl_setopt_array(\stdClass $handle, array $options): bool
{
$handle->options = $options;
$GLOBALS['ancientReuseRequests'][] = json_decode($options[CURLOPT_POSTFIELDS], true);
return true;
}
function curl_exec(\stdClass $handle): string
{
$options = $handle->options;
if (isset($options[CURLOPT_WRITEFUNCTION])) {
$options[CURLOPT_HEADERFUNCTION]($handle, "HTTP/1.1 200 OK\r\n");
$options[CURLOPT_WRITEFUNCTION]($handle,
"data: {\"event\":\"message\",\"answer\":\"offline\"}\n\n"
. "data: {\"event\":\"message_end\"}\n\n");
return '';
}
return '{"answer":"offline"}';
}
function curl_errno(\stdClass $handle): int { return 0; }
function curl_getinfo(\stdClass $handle, int $option): int { return 200; }
function curl_close(\stdClass $handle): void {}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\ClinicalKnowledgeReference;
use app\common\service\DifyChatService;
use app\common\service\TcmAncientBooksReference;
function ancientReuseExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$indexProperty = new ReflectionProperty(TcmAncientBooksReference::class, 'index');
$indexProperty->setValue(null, [
'commit' => TcmAncientBooksReference::COMMIT,
'index_version' => TcmAncientBooksReference::INDEX_VERSION,
'terms' => [
'消渴' => [[
'title' => '甲书', 'chapter' => '消渴篇', 'path' => '甲书.txt',
'line' => 42, 'excerpt' => '消渴原文。', 'score' => 15,
]],
],
'chapter_terms' => [
'痛风' => [[
'title' => '乙书', 'chapter' => '痛风篇', 'path' => '乙书.txt',
'line' => 88, 'excerpt' => '痛风原文。', 'score' => 12,
]],
],
]);
$ancientReuseConfig = [
'enable' => true, 'base_url' => 'https://ai.example.test/v1/chat-messages',
'timeout' => 30, 'models' => ['qwen' => ['name' => 'offline', 'api_key' => 'test-key']],
];
foreach (['患者糖尿病,请分析。', '患者痛风,请分析。', '患者有未收录的症状。'] as $case) {
$expectedSources = ClinicalKnowledgeReference::ancientBookSources($case);
$expectedPrompt = ClinicalKnowledgeReference::augmentQuery($case);
foreach (['blocking', 'streaming'] as $mode) {
$ancientReuseRequests = [];
if ($mode === 'blocking') {
$result = DifyChatService::chat('qwen', [], $case, 'offline-user');
} else {
$deltas = [];
$result = DifyChatService::streamChat('qwen', [], $case, 'offline-user',
static function (string $delta) use (&$deltas): void { $deltas[] = $delta; });
ancientReuseExpect($deltas === ['offline'], 'streaming forwards the upstream delta');
}
ancientReuseExpect($result['ok'] === true, "$mode request succeeds");
ancientReuseExpect(count($ancientReuseRequests) === 1, "$mode sends one request");
ancientReuseExpect($ancientReuseRequests[0]['query'] === $expectedPrompt,
"$mode prompt remains byte-for-byte identical");
ancientReuseExpect($result['ancient_book_sources'] === $expectedSources,
"$mode returns exactly the selected citations");
}
}
$indexProperty->setValue(null, null);
echo "Dify ancient-books reuse contract passed.\n";
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\NihaixiaClinicalSkill;
function skillExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$reference = NihaixiaClinicalSkill::reference();
skillExpect(str_contains($reference, NihaixiaClinicalSkill::VERSION), 'pinned skill version is present');
skillExpect(str_contains($reference, '六经主证:太阳:'), 'reference is read from the installed six-meridian table');
skillExpect(str_contains($reference, '条件线索:太阳病(脉浮 + 头项强痛 + 恶寒)'), 'clinical clues are read from the installed diagnostic formulas');
skillExpect(str_contains($reference, '辨证步骤:定表里→分阴阳→辨寒热'), 'seven-step method is read from the installed skill');
skillExpect(str_contains($reference, '未满足关键证据时不得强行归经'), 'diagnostic clues require patient evidence');
skillExpect(str_contains($reference, '不得据此建议停用现有治疗'), 'historical cases cannot override treatment safety');
skillExpect(strlen($reference) < 3000, 'reference is bounded for staged clinical prompts');
$question = '请分析当前患者的口渴和高血糖。';
$query = NihaixiaClinicalSkill::augmentQuery($question);
skillExpect(str_contains($query, $question), 'original clinical question is preserved');
skillExpect(substr_count($query, NihaixiaClinicalSkill::VERSION) === 1, 'query receives the skill once');
skillExpect(NihaixiaClinicalSkill::augmentQuery($query) === $query, 'query retries do not duplicate the skill');
$messages = [
['role' => 'system', 'content' => '只输出 JSON'],
['role' => 'user', 'content' => $question],
];
$augmented = NihaixiaClinicalSkill::augmentMessages($messages);
skillExpect(count($augmented) === 3, 'chat messages receive one skill system reference');
skillExpect(($augmented[1]['content'] ?? '') === '只输出 JSON', 'original system format is preserved');
skillExpect(($augmented[2]['content'] ?? '') === $question, 'original user question is preserved');
skillExpect(NihaixiaClinicalSkill::augmentMessages($augmented) === $augmented, 'stream retries do not duplicate the skill');
$source = NihaixiaClinicalSkill::knowledgeSource();
skillExpect($source['commit'] === '270e94e637c4249dc411e54ac6715f21247de3cb', 'metadata pins the complete vendored commit');
skillExpect($source['url'] === '' && $source['path'] === 'SKILL.md', 'metadata points to the locally installed skill without an external link');
skillExpect($source['sections'] === ['六经速查表', '六经辨证诊断公式', '七步走辨证思维模式'], 'metadata names only the extracted sections');
$savedSource = $source;
$savedSource['url'] = 'https://github.com/coinweth/nihaixia-skill/blob/' . $source['commit'] . '/SKILL.md';
unset($savedSource['path']);
$localizedSource = NihaixiaClinicalSkill::localizeKnowledgeSource($savedSource);
skillExpect($localizedSource['url'] === '' && $localizedSource['path'] === 'SKILL.md'
&& $localizedSource['commit'] === $source['commit'] && $localizedSource['sections'] === $source['sections'],
'saved skill source loses the external URL and retains its local file');
skillExpect(NihaixiaClinicalSkill::knowledgeSourceForPromptVersion('patient-longitudinal-report-v2') === null, 'legacy prompt versions do not acquire fabricated sources');
skillExpect(NihaixiaClinicalSkill::knowledgeSourceForPromptVersion('patient-longitudinal-report-nihaixia-ancient-v4') === $source, 'new ancient-book reports retain the existing skill attribution');
foreach ([
[app\adminapi\logic\tcm\DiagnosisAiLogic::class, ['report_content' => '{}']],
[app\adminapi\logic\tcm\PatientAiReportLogic::class, ['report_json' => '{}', 'source_summary_json' => '{"diagnosis_count":2}', 'source_diagnosis_ids_json' => '[11,12]', 'source_snapshot' => 'PRIVATE PATIENT INPUT']],
] as [$class, $row]) {
$format = new ReflectionMethod($class, 'formatReportRow');
$args = $class === app\adminapi\logic\tcm\DiagnosisAiLogic::class ? ['',] : [];
$legacy = $format->invoke(null, $row + ['prompt_version' => 'report-v2'], ...$args);
$currentVersion = $class === app\adminapi\logic\tcm\DiagnosisAiLogic::class
? 'patient-context-case-explain-nihaixia-v3'
: 'patient-longitudinal-report-nihaixia-v3';
$current = $format->invoke(null, $row + ['prompt_version' => $currentVersion], ...$args);
skillExpect($legacy['knowledge_source'] === null, $class . ' omits a claim for old reports');
skillExpect($current['knowledge_source'] === $source, $class . ' returns a pinned source for saved skill reports');
skillExpect(!str_contains(json_encode($current), 'PRIVATE PATIENT INPUT'), 'public report metadata does not expose the source snapshot');
if ($class === app\adminapi\logic\tcm\PatientAiReportLogic::class) {
skillExpect($current['source_summary'] === ['diagnosis_count' => 2], 'patient summary comes from the saved report');
skillExpect($current['source_diagnosis_ids'] === [11, 12], 'patient source IDs come from the saved report');
}
}
$formatDiagnosis = new ReflectionMethod(app\adminapi\logic\tcm\DiagnosisAiLogic::class, 'formatReportRow');
$currentSources = ['source_summary' => ['diagnosis_count' => 2], 'source_diagnosis_ids' => [11, 12]];
$matching = $formatDiagnosis->invoke(null, ['case_fingerprint' => 'matching', 'report_content' => '{}'], 'matching', $currentSources);
$stale = $formatDiagnosis->invoke(null, ['case_fingerprint' => 'old', 'report_content' => '{}'], 'matching', $currentSources);
skillExpect($matching['source_diagnosis_ids'] === [11, 12], 'matching report exposes current source IDs');
skillExpect($matching['source_summary'] === ['diagnosis_count' => 2], 'matching report exposes current source counts');
skillExpect($stale['source_diagnosis_ids'] === [] && $stale['source_summary'] === [], 'stale report does not borrow current patient sources');
echo "Nihaixia clinical skill contract passed.\n";
@@ -37,6 +37,7 @@ namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
use app\common\service\NihaixiaClinicalSkill;
function expectSame($expected, $actual, string $message): void
{
@@ -210,6 +211,9 @@ foreach (['dify' => 'chat-messages', 'openai' => 'chat/completions'] as $protoco
expectSame(true, $strictResult['ok'], 'strict ' . $protocol . ' accepts distinct logical attachments sharing a URL');
expectSame(1, count($upstreamTestRequests), 'strict ' . $protocol . ' submits the complete batch once');
$payload = $upstreamTestRequests[0]['payload'];
$wirePrompt = $protocol === 'dify' ? $payload['query'] : $payload['messages'][0]['content'][0]['text'];
expectSame(true, str_contains($wirePrompt, NihaixiaClinicalSkill::VERSION), 'strict ' . $protocol . ' includes the clinical skill on the wire');
expectSame(true, str_contains($wirePrompt, 'offline query'), 'strict ' . $protocol . ' preserves the clinical question');
$wireUrls = $protocol === 'dify' ? array_column($payload['files'], 'url')
: array_column(array_column(array_slice($payload['messages'][0]['content'], 1), 'image_url'), 'url');
expectSame(array_column($duplicateFiles, 'url'), $wireUrls, 'strict ' . $protocol . ' transmits every attachment in manifest order');
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\ClinicalKnowledgeReference;
use app\common\service\NihaixiaClinicalSkill;
use app\common\service\TcmAncientBooksReference;
function ancientExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$indexProperty = new ReflectionProperty(TcmAncientBooksReference::class, 'index');
$indexProperty->setValue(null, [
'commit' => TcmAncientBooksReference::COMMIT,
'index_version' => TcmAncientBooksReference::INDEX_VERSION,
'terms' => [
'消渴' => [[
'title' => '测试古籍', 'chapter' => '消渴篇',
'path' => '123-测试古籍.txt', 'line' => 42,
'excerpt' => '原文中谈到消渴的历史记载。', 'score' => 15,
]],
],
'chapter_terms' => [
'痛风' => [[
'title' => '测试古籍二', 'chapter' => '痛风',
'path' => '124-测试古籍二.txt', 'line' => 88,
'excerpt' => '痛风相关的原文。', 'score' => 12,
]],
],
]);
$query = '患者糖尿病、高血糖,请分析病症。';
$sources = TcmAncientBooksReference::sourcesForText($query);
ancientExpect(count($sources) === 1, 'matching patient symptoms select one excerpt');
ancientExpect($sources[0]['title'] === '测试古籍' && $sources[0]['chapter'] === '消渴篇', 'book and chapter retained');
ancientExpect($sources[0]['url'] === '' && $sources[0]['path'] === '123-测试古籍.txt' && $sources[0]['line'] === 42,
'local citation retains file and line without an external URL');
ancientExpect($sources[0]['excerpt'] === '原文中谈到消渴的历史记载。', 'local citation retains verifiable original text');
ancientExpect(TcmAncientBooksReference::sourcesForText('请分析耳鸣。') === [], 'unmatched symptoms have no invented citation');
ancientExpect(TcmAncientBooksReference::sourcesForText('患者出现痛风症状。')[0]['line'] === 88, 'unlisted diseases can match indexed chapter headings');
$augmented = ClinicalKnowledgeReference::augmentQuery($query);
ancientExpect(str_contains($augmented, TcmAncientBooksReference::COMMIT), 'query includes ancient-book version');
ancientExpect(str_contains($augmented, NihaixiaClinicalSkill::VERSION), 'existing clinical skill remains');
ancientExpect(str_ends_with($augmented, $query), 'original patient question remains intact');
ancientExpect(ClinicalKnowledgeReference::augmentQuery($augmented) === $augmented, 'retry does not duplicate references');
foreach ([$query, '患者出现痛风症状。', '患者有未收录的症状。', '阶段=text 患者糖尿病', '阶段=final 患者糖尿病'] as $case) {
$selected = ClinicalKnowledgeReference::ancientBookSources($case);
ancientExpect(
ClinicalKnowledgeReference::augmentQueryWithAncientBookSources($case, $selected)
=== ClinicalKnowledgeReference::augmentQuery($case),
'selected sources preserve the exact legacy prompt for each clinical case'
);
}
ancientExpect(
ClinicalKnowledgeReference::augmentQueryWithAncientBookSources('患者有未收录的症状。', [])
=== ClinicalKnowledgeReference::augmentQuery('患者有未收录的症状。'),
'an unrelated request receives no preceding patient citations'
);
$messages = [['role' => 'user', 'content' => $query]];
$wire = ClinicalKnowledgeReference::augmentMessages($messages);
ancientExpect(count($wire) === 2 && str_contains($wire[0]['content'], '测试古籍'), 'chat receives reference in system message');
ancientExpect(ClinicalKnowledgeReference::augmentMessages($wire) === $wire, 'stream retry does not duplicate reference');
$indexProperty->setValue(null, null);
echo "TCM ancient-books reference contract passed.\n";
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\logic\tcm\PrescriptionAiLogic;
use app\common\service\NihaixiaClinicalSkill;
use app\common\service\TcmAncientBooksReference;
function provenanceExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$source = [
'title' => '测试古籍', 'chapter' => '消渴篇', 'path' => '123-测试古籍.txt',
'excerpt' => '原文中谈到消渴的历史记载。',
'line' => 42, 'commit' => TcmAncientBooksReference::COMMIT,
'url' => 'https://github.com/xiaopangxia/TCM-Ancient-Books/blob/'
. TcmAncientBooksReference::COMMIT . '/123-%E6%B5%8B%E8%AF%95%E5%8F%A4%E7%B1%8D.txt#L42',
];
$localizedSource = $source;
$localizedSource['url'] = '';
$localized = TcmAncientBooksReference::localizeSources([$source]);
provenanceExpect($localized === [$localizedSource], 'saved citation drops external URL without losing original text or location');
$json = json_encode([$source], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$diagnosisFormat = new ReflectionMethod(DiagnosisAiLogic::class, 'formatReportRow');
$diagnosis = $diagnosisFormat->invoke(null, [
'report_content' => '{}',
'prompt_version' => 'patient-context-case-explain-nihaixia-ancient-v4',
'ancient_book_sources_json' => $json,
], '');
provenanceExpect($diagnosis['ancient_book_sources'] === [$localizedSource], 'diagnosis report localizes saved book and line');
$oldDiagnosis = $diagnosisFormat->invoke(null, ['report_content' => '{}', 'prompt_version' => 'legacy-v2'], '');
provenanceExpect($oldDiagnosis['ancient_book_sources'] === [], 'old diagnosis report gets no ancient citation');
$patientFormat = new ReflectionMethod(PatientAiReportLogic::class, 'formatReportRow');
$patient = $patientFormat->invoke(null, [
'report_json' => '{}',
'prompt_version' => 'patient-longitudinal-report-nihaixia-ancient-v4',
'ancient_book_sources_json' => $json,
]);
provenanceExpect($patient['ancient_book_sources'] === [$localizedSource], 'patient report localizes saved book and line');
$oldPatient = $patientFormat->invoke(null, ['report_json' => '{}', 'prompt_version' => 'legacy-v2']);
provenanceExpect($oldPatient['ancient_book_sources'] === [], 'old patient report gets no ancient citation');
$anotherSource = $source;
$anotherSource['path'] = '124-测试古籍二.txt';
$anotherSource['line'] = 88;
$anotherSource['url'] = 'https://github.com/xiaopangxia/TCM-Ancient-Books/blob/'
. TcmAncientBooksReference::COMMIT . '/124-%E6%B5%8B%E8%AF%95%E5%8F%A4%E7%B1%8D%E4%BA%8C.txt#L88';
$mergeSources = new ReflectionMethod(PatientAiReportLogic::class, 'mergeAncientBookSources');
$merged = $mergeSources->invoke(null, [$localizedSource], ['ancient_book_sources' => [$source, $anotherSource]]);
$localizedAnother = $anotherSource;
$localizedAnother['url'] = '';
provenanceExpect($merged === [$localizedSource, $localizedAnother], 'different local file lines remain distinct while duplicates collapse');
$prescriptionBody = [
'report' => ['diagnosis' => '保留原有报告'],
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
'ancient_book_sources' => [$source],
];
$prescriptionBody['knowledge_source']['url'] = 'https://github.com/coinweth/nihaixia-skill/blob/'
. NihaixiaClinicalSkill::COMMIT . '/SKILL.md';
$localizePrescription = new ReflectionMethod(PrescriptionAiLogic::class, 'localizeSourceMetadata');
$prescription = $localizePrescription->invoke(null, $prescriptionBody);
provenanceExpect($prescription['report'] === $prescriptionBody['report'], 'prescription report content is preserved');
provenanceExpect($prescription['knowledge_source']['url'] === ''
&& $prescription['knowledge_source']['path'] === 'SKILL.md', 'saved prescription skill source is local');
provenanceExpect($prescription['ancient_book_sources'] === [$localizedSource], 'saved prescription book citation is local');
echo "TCM ancient-books report provenance contract passed.\n";