279 lines
20 KiB
PHP
279 lines
20 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/** Offline integration: all patient, authorization and transport boundaries are stubs. */
|
||
namespace think\facade {
|
||
class Log {
|
||
public static function warning(string $message, array $context = []): void {}
|
||
public static function write(string $message, string $level = ''): void {}
|
||
}
|
||
}
|
||
|
||
namespace app\common\cache {
|
||
class AdminAuthCache {
|
||
public function __construct(int $adminId) {}
|
||
public function getAdminUri(): array { return $GLOBALS['completePermissions']; }
|
||
}
|
||
}
|
||
|
||
namespace app\adminapi\logic\firstvisit {
|
||
class MyPatientLogic {
|
||
public static function canAccessDiagnosis($id, $adminId, $adminInfo): bool {
|
||
return $GLOBALS['completeScopeAllowed'];
|
||
}
|
||
}
|
||
}
|
||
|
||
namespace app\adminapi\logic\tcm {
|
||
function config(string $key): array {
|
||
return ['timeout' => 90, 'models' => [
|
||
'qwen' => ['name' => 'offline-qwen', 'label' => '离线千问'],
|
||
'openai' => ['name' => 'offline-openai', 'label' => '离线OpenAI'],
|
||
]];
|
||
}
|
||
class DiagnosisLogic {
|
||
public static function detail($params, $adminInfo): array { return $GLOBALS['completeCurrent']; }
|
||
}
|
||
class PatientAiReportLogic {
|
||
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array {
|
||
++$GLOBALS['completeAggregationCalls'];
|
||
return $GLOBALS['completeLongitudinal'];
|
||
}
|
||
public static function compactSourceForPrompt($profile, $text, $options = []): array {
|
||
throw new \RuntimeException('A below-budget fixture must never compact or call a model.');
|
||
}
|
||
}
|
||
}
|
||
|
||
namespace app\common\service {
|
||
class DifyChatService {
|
||
public static array $calls = [];
|
||
public static array $responses = [];
|
||
public static array $chunks = [];
|
||
public static bool $insideStream = false;
|
||
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
|
||
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'blocking'];
|
||
if (self::$responses === []) throw new \RuntimeException('Unexpected extra upstream call.');
|
||
return array_shift(self::$responses);
|
||
}
|
||
public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array {
|
||
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'stream'];
|
||
self::$insideStream = true;
|
||
try {
|
||
foreach (self::$chunks as $chunk) {
|
||
if ($onDelta($chunk) === false) return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
|
||
}
|
||
} finally {
|
||
self::$insideStream = false;
|
||
}
|
||
if (self::$responses === []) throw new \RuntimeException('Unexpected extra upstream call.');
|
||
return array_shift(self::$responses);
|
||
}
|
||
}
|
||
}
|
||
|
||
namespace {
|
||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||
|
||
use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
||
use app\adminapi\service\AiRequiredSource;
|
||
use app\common\service\DifyChatService;
|
||
|
||
$completeAssertions = 0;
|
||
function completeExpect(bool $ok, string $message): void {
|
||
++$GLOBALS['completeAssertions'];
|
||
if (!$ok) throw new \RuntimeException($message);
|
||
}
|
||
function completeResetTransport(array $responses): void {
|
||
DifyChatService::$calls = [];
|
||
DifyChatService::$responses = $responses;
|
||
DifyChatService::$chunks = [];
|
||
}
|
||
function completeExpectFactFidelity(string $query, string $route): void {
|
||
// Prompt delivery contract only; these assertions cannot certify model compliance.
|
||
$rules = [
|
||
'source polarity, severity and chronology' => ['“明确有”“明确无”“未记录/未采集”', '否定、程度、日期与本次/历史身份'],
|
||
'unrecorded pulse is unknown' => ['心率或舌像', '未记涩、数不能补写“不涩、不数”'],
|
||
'formula indications cannot create patient facts' => ['常见方证表现不得变成本例已有症状', '本例已记录的短证据', '保留反证'],
|
||
'one coherent primary direction' => ['主方向', '不把互相冲突的治法/方剂并列为同时适用'],
|
||
'medication actions require current use' => ['当前确实在用的依据', '早已停用的药物不得再安排'],
|
||
'normal tests do not prove zero risk' => ['未记录异常不等于检查正常', '局部或单次正常检查不等于风险为零'],
|
||
'unsupported claims require removal or explicit uncertainty' => ['找不到原文支持', '删除或改为明确待验证条件'],
|
||
'patient negatives retain literal source boundaries' => ['四诊已记录阴性', '不另写“本例无A、B、C”', '不得写“已排除/肯定不是”'],
|
||
'a primary formula requires traceable case evidence' => ['新增主方方向最多一个', '每个支持点逐字短引本例原文', '不展开备用方名或加味药清单'],
|
||
];
|
||
foreach ($rules as $rule => $fragments) {
|
||
completeExpect(count(array_filter($fragments, static fn (string $fragment): bool => str_contains($query, $fragment))) === count($fragments),
|
||
'Fact-fidelity instructions reach ' . $route . ': ' . $rule);
|
||
}
|
||
}
|
||
|
||
$completePermissions = ['tcm.diagnosis/aiassistant', 'tcm.diagnosis/aianalysis'];
|
||
$completeScopeAllowed = true;
|
||
$completeAggregationCalls = 0;
|
||
$completeCurrent = ['id' => 11, 'patient_id' => 99, 'age' => 42, 'gender' => '女',
|
||
'diagnosis_date' => '2026-10-23',
|
||
'chief_complaint' => '合成病例:乏力、食少持续两周,活动后较明显,休息后稍缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,起止和活动关系按本次访谈记录。',
|
||
'tongue' => '本次舌淡红,苔薄白', 'pulse' => '本次脉弦细',
|
||
'patient_name' => '不应传输的原始姓名', 'phone' => '13800138000'];
|
||
$completeSections = []; $completeMetadata = [];
|
||
foreach (AiRequiredSource::LABELS as $key => $label) {
|
||
$completeSections[] = ['key' => $key, 'label' => $label,
|
||
'value' => [['source_marker' => 'COMPLETE_' . $key]]];
|
||
$completeMetadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present',
|
||
'count' => 1, 'status' => 'prepared'];
|
||
}
|
||
foreach ($completeSections as &$section) {
|
||
if ($section['key'] === 'diagnosis_info') {
|
||
$section['value'][0] += ['diagnosis_date' => '2026-10-23',
|
||
'tongue' => $completeCurrent['tongue'], 'pulse' => $completeCurrent['pulse']];
|
||
$section['value'][] = ['diagnosis_date' => '2020-01-01', 'tongue' => '历史舌红', 'pulse' => '历史脉滑'];
|
||
}
|
||
if ($section['key'] === 'prescriptions') {
|
||
$section['value'][0] += ['prescription_date' => '2020-01-01', 'prescription_name' => '历史四君子汤',
|
||
'herbs' => [['name' => '历史党参', 'dosage' => 10]]];
|
||
}
|
||
}
|
||
unset($section);
|
||
$completeLongitudinal = ['snapshot' => ['required_content' => $completeSections],
|
||
'source_summary' => ['required_sections' => $completeMetadata, 'snapshot_complete' => true],
|
||
'source_diagnosis_ids' => [3, 11],
|
||
'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/not-fetched.png']]];
|
||
|
||
$reflection = new \ReflectionClass(DiagnosisAiLogic::class);
|
||
foreach (['MAX_REPORT_LENGTH' => 12000, 'MAX_PROMPT_SOURCE_BYTES' => 120000,
|
||
'MAX_ANALYSIS_RESPONSE_BYTES' => 32768, 'MAX_ANALYSIS_ADVICE_LENGTH' => 1200,
|
||
'MAX_ANALYSIS_RISK_ITEMS' => 8, 'MAX_ANALYSIS_RISK_LABEL_LENGTH' => 120] as $name => $bound) {
|
||
completeExpect($reflection->getConstant($name) === $bound, 'Established budget remains unchanged: ' . $name);
|
||
}
|
||
completeExpect($reflection->getConstant('MODEL_KEYS') === ['qwen', 'openai'], 'Both existing profile keys remain available.');
|
||
completeExpect(str_ends_with($reflection->getConstant('ASSISTANT_PROMPT_VERSION'), '-v43')
|
||
&& str_ends_with($reflection->getConstant('ANALYSIS_PROMPT_VERSION'), '-v43'), 'Both changed clinical prompts invalidate prior provenance/cache keys.');
|
||
|
||
// Capture a real preparation; a source-only replacement here would lose the clinical prompt.
|
||
$prepared = DiagnosisAiLogic::prepareAssistant(11, 'summary', '请依据本次完整四诊给出完整辨证报告并区分历史资料', 7, []);
|
||
completeExpect($prepared !== null && !$prepared['tcm_evidence_guard']['restricted'], 'Complete CURRENT examination keeps the clinical path.');
|
||
completeExpect($prepared['reference_scope'] === 'case' && $prepared['knowledge_source'] === null,
|
||
'Ordinary assistant preparation selects case references without claiming a teaching source.');
|
||
completeExpect($prepared['tcm_evidence_guard']['current_tongue'] === $completeCurrent['tongue']
|
||
&& $prepared['tcm_evidence_guard']['current_pulse'] === $completeCurrent['pulse'], 'History does not overwrite current examination identity.');
|
||
completeExpect(str_contains($prepared['query'], '四诊采集') && str_contains($prepared['query'], '中药方药')
|
||
&& !str_contains($prepared['query'], '只返回JSON:{"fact_ids"'), 'Ordinary clinical instructions survive preparation.');
|
||
completeExpectFactFidelity($prepared['query'], 'ordinary assistant preparation');
|
||
foreach (array_keys(AiRequiredSource::LABELS) as $key) {
|
||
completeExpect(str_contains($prepared['query'], 'COMPLETE_' . $key), 'No required source category is replaced by the guard index: ' . $key);
|
||
}
|
||
foreach (['本次舌淡红', '本次脉弦细', '历史舌红', '历史脉滑', '2020-01-01', '历史四君子汤', '历史党参'] as $fact) {
|
||
completeExpect(str_contains($prepared['query'], $fact), 'Complete clinical prompt preserves provenance-relevant source text: ' . $fact);
|
||
}
|
||
completeExpect(str_contains($prepared['query'], '不冒充本次新增建议'), 'Historical prescriptions retain an explicit non-new-advice instruction.');
|
||
completeExpect($prepared['files'] === $completeLongitudinal['files'] && count($prepared['required_sections']) === 13
|
||
&& $prepared['source_form'] === 'full_context' && $prepared['upstream_timeout'] === 90,
|
||
'Attachment/manifest/full-context/timeout envelope survives normal preparation.');
|
||
$serialized = json_encode([$prepared['query'], $prepared['inputs']], JSON_UNESCAPED_UNICODE);
|
||
completeExpect(!str_contains($serialized, '不应传输的原始姓名') && !str_contains($serialized, '13800138000'), 'Current raw identity is not inserted in either upstream channel.');
|
||
|
||
// Transport fixture only: this intentionally does not certify the clinical content of a real model.
|
||
// These literal quotations match current s1/s2/s3. The generic material
|
||
// after section one exercises bytes/length only, never clinical quality.
|
||
$report = "一、四诊\n\n望诊:未记录。\n\n闻诊:未记录。\n\n"
|
||
. "问诊:2026-10-23“合成病例:乏力、食少持续两周,活动后较明显,休息后稍缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,起止和活动关系按本次访谈记录。”(s1)。\n\n"
|
||
. "切诊:其他触诊未记录,脉象见独立脉象项。\n\n"
|
||
. "舌象:2026-10-23“本次舌淡红,苔薄白”(s2)。\n\n脉象:2026-10-23“本次脉弦细”(s3)。\n\n"
|
||
. "二、八纲\n\n各对结论区分已知记录、假设与待核实依据。此处仅演示离线工程传输段落,不形成任何临床结论。\n\n"
|
||
. "三、气血津液与脏腑定位\n\n此处仅演示离线工程传输段落,真实定位须由医师核对原始证据及不确定性。\n\n"
|
||
. "四、证名\n\n此处仅演示离线工程传输段落,证名需由医师按实际病例证据复核。\n\n"
|
||
. "五、治法\n\n此处仅演示离线工程传输段落,治法需与经复核的证名和实际病例证据对应。\n\n"
|
||
. "六、中药方药\n\n此处仅演示离线工程传输段落,既往资料和本次资料的时点由来源保留,不能据工程文字执行治疗。\n";
|
||
foreach (range(1, 24) as $number) {
|
||
$report .= '合成长报告传输段' . $number . ':保留本段来源时点、既往记录与不确定性;本测试只检查原文传输,没有模型推理或临床处方结论。' . "\n";
|
||
}
|
||
$report = str_replace("\n", "\n\n", $report);
|
||
completeExpect(mb_strlen($report, 'UTF-8') > 1200 && mb_strlen($report, 'UTF-8') < 12000, 'Fixture exercises a long assistant answer beyond structured-analysis field size.');
|
||
completeResetTransport([['ok' => true, 'content' => $report]]);
|
||
DifyChatService::$chunks = mb_str_split($report, 79, 'UTF-8');
|
||
$visible = []; $visibleDuringStream = [];
|
||
$streamed = DiagnosisAiLogic::streamPreparedAssistant($prepared,
|
||
static function (string $delta) use (&$visible, &$visibleDuringStream): bool {
|
||
$visible[] = $delta;
|
||
$visibleDuringStream[] = DifyChatService::$insideStream;
|
||
return true;
|
||
});
|
||
completeExpect($streamed !== null && $streamed['answer_mode'] === 'clinical_analysis'
|
||
&& $streamed['evidence_selection_valid'] === null, 'Complete-evidence streaming is reported as clinical analysis.');
|
||
completeExpect(count($visible) > 1 && !in_array(false, $visibleDuringStream, true), 'Ordinary clinical deltas are delivered during generation, before the final result.');
|
||
completeExpect(implode('', $visible) === $report && $streamed['answer'] === trim($report), 'Long model text is preserved without source-view substitution or missing tail.');
|
||
completeExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical'
|
||
&& DifyChatService::$calls[0]['options']['reference_scope'] === 'case'
|
||
&& DifyChatService::$calls[0]['options']['required_files'] === true, 'One clinical stream uses the existing profile, case scope and required-source contract.');
|
||
completeExpect($streamed['knowledge_source'] === null && $streamed['ancient_book_sources'] === [],
|
||
'Ordinary streamed answers do not falsely attribute unsubmitted reference material.');
|
||
|
||
completeResetTransport([['ok' => true, 'content' => $report]]);
|
||
$blocking = DiagnosisAiLogic::assistant(11, 'summary', '请给出完整辨证报告', 7, []);
|
||
completeExpect($blocking !== null && $blocking['answer'] === trim($report) && $blocking['knowledge_source'] === null
|
||
&& $blocking['ancient_book_sources'] === [] && count(DifyChatService::$calls) === 1
|
||
&& DifyChatService::$calls[0]['options']['reference_scope'] === 'case'
|
||
&& DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical'
|
||
&& DifyChatService::$calls[0]['files'] === $completeLongitudinal['files'],
|
||
'Blocking assistant retains the case scope, complete report and original attachments.');
|
||
completeResetTransport([['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE'], ['ok' => true, 'content' => $report]]);
|
||
$compatibility = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (string $text): bool => true,
|
||
null, null, ['reference_scope' => 'full']);
|
||
completeExpect($compatibility !== null && count(DifyChatService::$calls) === 2
|
||
&& array_column(DifyChatService::$calls, 'transport') === ['stream', 'blocking']
|
||
&& array_column(array_column(DifyChatService::$calls, 'options'), 'reference_scope') === ['case', 'case']
|
||
&& array_column(array_column(DifyChatService::$calls, 'options'), 'knowledge_mode') === ['clinical', 'clinical'],
|
||
'Both sides of the pre-delta compatibility retry retain the server-selected case scope.');
|
||
$draftPrepared = DiagnosisAiLogic::prepareAssistant(11, 'prescription_generate', '请生成处方草稿', 7, ['root' => 1]);
|
||
completeExpect($draftPrepared !== null && $draftPrepared['reference_scope'] === 'full'
|
||
&& is_array($draftPrepared['knowledge_source']), 'Structured prescription preparation retains its full reference scope.');
|
||
|
||
$clinical = ['diagnosis_advice' => '四诊:本次舌淡红苔薄白、脉弦细。八纲:逐项说明依据与待核实项。气血津液与脏腑:按源事实讨论。证名:待医生复核。',
|
||
'risk_assessment' => [['label' => '合成风险待核实', 'level' => 'low']],
|
||
'treatment_advice' => '治法与证名对应。中药方药:历史处方仅据实复核,非本次新增建议。必要复核:补充不确定性。'];
|
||
foreach (['qwen', 'openai'] as $profile) {
|
||
completeResetTransport([['ok' => true, 'content' => json_encode($clinical, JSON_UNESCAPED_UNICODE)]]);
|
||
$analysis = DiagnosisAiLogic::analysis(11, 7, [], $profile);
|
||
completeExpect($analysis !== null && array_intersect_key($analysis, $clinical) === $clinical,
|
||
'Real analysis parser preserves all three clinical fields rather than substituting a source-only review: ' . $profile);
|
||
completeExpect($analysis['answer_mode'] === 'clinical_analysis' && $analysis['model_key'] === $profile
|
||
&& $analysis['evidence_selection_valid'] === null, 'Profile/mode remains the independently selected clinical route: ' . $profile);
|
||
completeExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['profile'] === $profile
|
||
&& DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical'
|
||
&& DifyChatService::$calls[0]['options']['reference_scope'] === 'case', 'Each profile uses its own existing clinical transport with case references: ' . $profile);
|
||
completeExpect($analysis['knowledge_source'] === null && $analysis['ancient_book_sources'] === [],
|
||
'Case analysis does not claim unsubmitted teaching references: ' . $profile);
|
||
completeExpect(str_contains(DifyChatService::$calls[0]['query'], '只输出一个 JSON 对象')
|
||
&& !str_contains(DifyChatService::$calls[0]['query'], '只返回JSON:{"fact_ids"'), 'Ordinary analysis keeps the JSON prompt: ' . $profile);
|
||
completeExpectFactFidelity(DifyChatService::$calls[0]['query'], $profile . ' structured analysis');
|
||
}
|
||
|
||
completeResetTransport([['ok' => true, 'content' => '合成格式错误'],
|
||
['ok' => true, 'content' => json_encode($clinical, JSON_UNESCAPED_UNICODE)]]);
|
||
$repaired = DiagnosisAiLogic::analysis(11, 7, [], 'qwen');
|
||
completeExpect($repaired !== null && array_intersect_key($repaired, $clinical) === $clinical
|
||
&& $repaired['answer_mode'] === 'clinical_analysis', 'A format failure followed by valid JSON stays an ordinary clinical result.');
|
||
completeExpect(count(DifyChatService::$calls) === 2
|
||
&& array_column(DifyChatService::$calls, 'profile') === ['qwen', 'qwen']
|
||
&& array_column(array_column(DifyChatService::$calls, 'options'), 'reference_scope') === ['case', 'case']
|
||
&& str_contains(DifyChatService::$calls[1]['query'], '格式校验提醒'), 'Format repair stays on the same existing profile with one bounded retry.');
|
||
completeExpectFactFidelity(DifyChatService::$calls[1]['query'], 'same-profile JSON format repair');
|
||
completeResetTransport([['ok' => true, 'content' => 'bad1'], ['ok' => true, 'content' => 'bad2']]);
|
||
completeExpect(DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && count(DifyChatService::$calls) === 2,
|
||
'Repeated invalid clinical JSON fails after the existing two attempts instead of rendering a local successful review.');
|
||
|
||
$completeScopeAllowed = false;
|
||
completeResetTransport([]); $aggregationsBeforeDenial = $completeAggregationCalls;
|
||
completeExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, []) === null
|
||
&& DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && DifyChatService::$calls === []
|
||
&& $completeAggregationCalls === $aggregationsBeforeDenial, 'Denied diagnosis scope blocks aggregation and both clinical transports.');
|
||
$completeScopeAllowed = true; $completePermissions = [];
|
||
completeExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, []) === null
|
||
&& DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && DifyChatService::$calls === []
|
||
&& $completeAggregationCalls === $aggregationsBeforeDenial, 'Missing action permission blocks aggregation and clinical transports.');
|
||
|
||
echo 'Diagnosis AI complete-evidence clinical path: ' . $completeAssertions . " assertions passed (offline stubs only).\n";
|
||
}
|