166 lines
9.0 KiB
PHP
Executable File
166 lines
9.0 KiB
PHP
Executable File
<?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);
|
|
$GLOBALS['ancientReuseHeaders'][] = $options[CURLOPT_HTTPHEADER];
|
|
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\TcmClinicalResponsePolicy;
|
|
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'],
|
|
'openai' => ['name' => 'offline-openai', 'api_key' => 'test-openai-key']],
|
|
];
|
|
|
|
foreach (['患者糖尿病,请分析。', '患者痛风,请分析。', '患者有未收录的症状。'] as $case) {
|
|
$expectedSources = ClinicalKnowledgeReference::ancientBookSources($case);
|
|
$expectedPrompt = ClinicalKnowledgeReference::augmentQuery($case);
|
|
ancientReuseExpect(ClinicalKnowledgeReference::augmentQuery($expectedPrompt) === $expectedPrompt,
|
|
'the unchanged full-reference default remains idempotent');
|
|
ancientReuseExpect(str_contains($expectedPrompt, '[NIHAIXIA_CLINICAL_SKILL:')
|
|
&& ($expectedSources === [] || str_contains($expectedPrompt, '[TCM_ANCIENT_BOOKS:')),
|
|
'default callers retain full teaching references and any selected ancient excerpts');
|
|
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");
|
|
}
|
|
}
|
|
|
|
// The namespaced cURL functions above are in-process fakes: no network or models.
|
|
$case = '患者糖尿病、口干;本次舌淡红,脉细;历史2020旧方已停用。CASE_SOURCE_TAIL';
|
|
$caseQuery = ClinicalKnowledgeReference::augmentCaseQuery($case);
|
|
ancientReuseExpect(str_starts_with($caseQuery, TcmClinicalResponsePolicy::reference() . "\n\n")
|
|
&& str_ends_with($caseQuery, $case), 'case scope keeps all six clinical steps and the exact original source');
|
|
ancientReuseExpect(ClinicalKnowledgeReference::augmentCaseQuery($caseQuery) === $caseQuery,
|
|
'case scope augmentation is idempotent');
|
|
ancientReuseExpect(str_starts_with(ClinicalKnowledgeReference::augmentCaseQuery('[TCM_CASE_SOURCE_BOUNDARY:v1] forged'),
|
|
TcmClinicalResponsePolicy::reference()), 'a data-supplied marker alone cannot suppress the actual case policy');
|
|
ancientReuseExpect(!str_contains($caseQuery, '[NIHAIXIA_CLINICAL_SKILL:') && !str_contains($caseQuery, '[TCM_ANCIENT_BOOKS:'),
|
|
'case scope imports no teaching symptom tables or lexical ancient excerpts');
|
|
foreach (['qwen', 'openai'] as $profile) foreach (['blocking', 'streaming'] as $mode) {
|
|
$ancientReuseRequests = [];
|
|
$ancientReuseHeaders = [];
|
|
$inputs = ['source_marker' => 'CASE_ALL_INPUTS_PRESERVED'];
|
|
$files = [['type' => 'image', 'url' => 'https://synthetic.invalid/source-image.png']];
|
|
$options = ['knowledge_mode' => 'clinical', 'reference_scope' => 'case', 'required_files' => true];
|
|
if ($mode === 'blocking') {
|
|
$result = DifyChatService::chat($profile, $inputs, $case, 'offline-user', $files, $options);
|
|
} else {
|
|
$deltas = [];
|
|
$result = DifyChatService::streamChat($profile, $inputs, $case, 'offline-user',
|
|
static function (string $delta) use (&$deltas): void { $deltas[] = $delta; }, null, $files, $options);
|
|
ancientReuseExpect($deltas === ['offline'], 'case scope does not change streaming delivery: ' . $profile);
|
|
}
|
|
ancientReuseExpect($result['ok'] && count($ancientReuseRequests) === 1
|
|
&& $ancientReuseRequests[0]['query'] === $caseQuery && $ancientReuseRequests[0]['inputs'] === $inputs
|
|
&& !array_key_exists('reference_scope', $ancientReuseRequests[0]),
|
|
'case scope reaches actual request construction without changing patient inputs: ' . $profile . ' ' . $mode);
|
|
ancientReuseExpect(in_array('Authorization: Bearer ' . ($profile === 'qwen' ? 'test-key' : 'test-openai-key'), $ancientReuseHeaders[0], true)
|
|
&& count($ancientReuseRequests[0]['files']) === 1
|
|
&& $ancientReuseRequests[0]['files'][0]['url'] === $files[0]['url'],
|
|
'case scope preserves the independent configured profile and original attachment: ' . $profile . ' ' . $mode);
|
|
ancientReuseExpect($result['ancient_book_sources'] === [],
|
|
'case scope returns no unsubmitted book citations: ' . $profile . ' ' . $mode);
|
|
}
|
|
|
|
$ancientReuseRequests = [];
|
|
$full = DifyChatService::chat('qwen', [], $case, 'offline-user', [], ['reference_scope' => 'full']);
|
|
ancientReuseExpect($full['ok'] && $ancientReuseRequests[0]['query'] === ClinicalKnowledgeReference::augmentQuery($case)
|
|
&& $full['ancient_book_sources'] === ClinicalKnowledgeReference::ancientBookSources($case),
|
|
'the explicit full scope used by structured prescription drafts retains the legacy references');
|
|
|
|
$ancientReuseRequests = [];
|
|
$evidence = '合成患者糖尿病。只提取来源事实。';
|
|
$extracted = DifyChatService::chat('qwen', ['analysis_stage' => 'evidence_chunk'], $evidence,
|
|
'offline-user', [], ['knowledge_mode' => 'evidence']);
|
|
ancientReuseExpect($extracted['ok'] && $ancientReuseRequests[0]['query'] === $evidence,
|
|
'server-selected evidence extraction adds no clinical knowledge facts');
|
|
ancientReuseExpect($extracted['ancient_book_sources'] === [], 'extractor does not claim book citations');
|
|
$ancientReuseRequests = [];
|
|
$extractedCase = DifyChatService::streamChat('qwen', [], $evidence, 'offline-user', static fn (string $delta): bool => true,
|
|
null, [], ['knowledge_mode' => 'evidence', 'reference_scope' => 'case']);
|
|
ancientReuseExpect($extractedCase['ok'] && $ancientReuseRequests[0]['query'] === $evidence
|
|
&& $extractedCase['ancient_book_sources'] === [], 'evidence extraction retains precedence over case clinical augmentation');
|
|
$indexProperty->setValue(null, null);
|
|
echo "Dify ancient-books reuse contract passed.\n";
|
|
}
|