122 lines
8.5 KiB
PHP
122 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Offline only: authorization, aggregation and transport all use local 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 ['tcm.diagnosis/aiassistant', 'tcm.diagnosis/aianalysis', 'tcm.diagnosis/aigenerateprescription']; }
|
|
}
|
|
}
|
|
namespace app\adminapi\logic\firstvisit {
|
|
class MyPatientLogic {
|
|
public static function canAccessDiagnosis($id, $adminId, $adminInfo): bool { return true; }
|
|
}
|
|
}
|
|
namespace app\adminapi\logic\tcm {
|
|
function config(string $key): array {
|
|
return ['timeout' => 90, 'models' => ['qwen' => ['name' => 'offline-model', 'label' => '离线模型']]];
|
|
}
|
|
class DiagnosisLogic {
|
|
public static function detail($params, $adminInfo): array { return $GLOBALS['r36Current']; }
|
|
}
|
|
class PatientAiReportLogic {
|
|
public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { return $GLOBALS['r36Longitudinal']; }
|
|
public static function compactSourceForPrompt(...$args): array { throw new \RuntimeException('Unexpected compaction.'); }
|
|
}
|
|
}
|
|
namespace app\common\service {
|
|
class DifyChatService {
|
|
public static array $calls = [];
|
|
public static string $reply = '';
|
|
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
|
|
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options');
|
|
return ['ok' => true, 'content' => self::$reply];
|
|
}
|
|
public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array {
|
|
self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options');
|
|
if ($delta(self::$reply . "\n\n") === false) return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
|
|
return ['ok' => true, 'content' => self::$reply . "\n\n"];
|
|
}
|
|
}
|
|
}
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
|
use app\adminapi\service\AiRequiredSource;
|
|
use app\common\service\DifyChatService;
|
|
|
|
$checks = [];
|
|
function integrationR36(bool $ok, string $name): void { $GLOBALS['checks'][] = ['name' => $name, 'passed' => $ok]; }
|
|
$r36Current = ['id' => 36, 'patient_id' => 99, 'age' => 42, 'diagnosis_date' => '2026-10-26',
|
|
'chief_complaint' => '口干、乏力。', 'tongue' => '舌淡红,苔薄白。', 'pulse' => '', 'pulse_condition' => ''];
|
|
$sections = []; $metadata = [];
|
|
foreach (AiRequiredSource::LABELS as $key => $label) {
|
|
$sections[] = ['key' => $key, 'label' => $label, 'value' => [['marker' => 'R36_' . $key]]];
|
|
$metadata[] = ['key' => $key, 'label' => $label, 'count' => 1, 'availability' => 'present', 'status' => 'prepared'];
|
|
}
|
|
foreach ($sections as &$section) if ($section['key'] === 'diagnosis_info') {
|
|
$section['value'] = [$r36Current, ['id' => 1, 'diagnosis_date' => '2020-01-01', 'pulse' => '历史脉弦细。']];
|
|
}
|
|
unset($section);
|
|
$r36Longitudinal = ['snapshot' => ['required_content' => $sections],
|
|
'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true],
|
|
'source_diagnosis_ids' => [1, 36], 'files' => []];
|
|
$frozen = serialize($r36Longitudinal);
|
|
$prepared = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []);
|
|
integrationR36(is_array($prepared) && !$prepared['tcm_evidence_guard']['restricted'], 'actual preparation accepts empty pulse with independent current evidence');
|
|
integrationR36($prepared['reference_scope'] === 'consultation' && count($prepared['required_sections']) === 13,
|
|
'preparation keeps all thirteen sources and selects scoped policy');
|
|
integrationR36(str_contains($prepared['query'], '历史脉弦细。') && str_contains($prepared['inputs']['herbs_json'], '历史脉弦细。'),
|
|
'historical raw pulse remains in both full upstream input channels');
|
|
DifyChatService::$reply = '八纲判断须结合实采舌象及症状,证候和用药仍待医师复核。';
|
|
$ordinary = DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []);
|
|
integrationR36(is_array($ordinary) && $ordinary['answer_mode'] === 'clinical_analysis', 'ordinary answer is not replaced with source-only pulse-missing fallback');
|
|
integrationR36(end(DifyChatService::$calls)['options']['reference_scope'] === 'consultation', 'ordinary transport receives explicit consultation scope');
|
|
foreach (['脉细支持本次辨证。', '脉象未采集,请补采。', '缺脉象,不得选方。'] as $bad) {
|
|
DifyChatService::$reply = $bad;
|
|
integrationR36(DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []) === null,
|
|
'ordinary endpoint rejects whole forbidden response: ' . $bad);
|
|
}
|
|
DifyChatService::$reply = '请补采脉象。';
|
|
$visible = '';
|
|
$stream = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $part) use (&$visible): bool { $visible .= $part; return true; });
|
|
integrationR36($stream === null && $visible === '', 'stream does not deliver forbidden pulse paragraph');
|
|
$analysis = ['diagnosis_advice' => '证候方向须结合实采舌象和症状由医师复核。',
|
|
'treatment_advice' => '本次用药须按实际支持证据及禁忌复核。', 'risk_assessment' => []];
|
|
DifyChatService::$reply = json_encode($analysis, JSON_UNESCAPED_UNICODE);
|
|
$structured = DiagnosisAiLogic::analysis(36, 7, []);
|
|
integrationR36(is_array($structured) && $structured['answer_mode'] === 'clinical_analysis', 'structured diagnosis remains clinical with empty pulse');
|
|
integrationR36(end(DifyChatService::$calls)['options']['reference_scope'] === 'consultation', 'structured transport receives scoped policy');
|
|
$analysis['treatment_advice'] = '请补采脉象后用药。';
|
|
DifyChatService::$reply = json_encode($analysis, JSON_UNESCAPED_UNICODE);
|
|
integrationR36(DiagnosisAiLogic::analysis(36, 7, []) === null, 'structured endpoint rejects pulse collection requirement');
|
|
$draftPrepared = DiagnosisAiLogic::prepareAssistant(36, 'prescription_generate', '生成草稿', 7, []);
|
|
integrationR36(is_array($draftPrepared) && !$draftPrepared['tcm_evidence_guard']['restricted'], 'prescription preparation is not blocked solely by absent pulse');
|
|
$draft = ['clinical_diagnosis' => '医师待复核方向', 'herbs' => [['name' => '测试药材', 'dosage' => 1, 'formula_type' => '主方']],
|
|
'rationale' => '由医师结合实际证据逐味复核。'];
|
|
DifyChatService::$reply = json_encode(['prescription_draft' => $draft], JSON_UNESCAPED_UNICODE);
|
|
$generated = DiagnosisAiLogic::assistant(36, 'prescription_generate', '生成草稿', 7, []);
|
|
integrationR36(is_array($generated) && !array_key_exists('pulse', $generated['prescription_draft']), 'draft endpoint returns new schema without pulse');
|
|
DifyChatService::$reply = json_encode(['prescription_draft' => $draft + ['pulse' => '脉细']], JSON_UNESCAPED_UNICODE);
|
|
integrationR36(DiagnosisAiLogic::assistant(36, 'prescription_generate', '生成草稿', 7, []) === null, 'draft endpoint rejects obsolete pulse data rather than concealing it');
|
|
$r36Current['tongue'] = '';
|
|
$incomplete = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []);
|
|
integrationR36(is_array($incomplete) && $incomplete['tcm_evidence_guard']['restricted']
|
|
&& $incomplete['tcm_evidence_guard']['missing'] === ['舌象'], 'independent tongue gap still selects source-only safety path');
|
|
integrationR36(DiagnosisAiLogic::prepareAssistant(36, 'prescription_generate', '生成草稿', 7, []) === null,
|
|
'independent tongue gap still blocks a new prescription');
|
|
integrationR36(serialize($r36Longitudinal) === $frozen, 'endpoint preparation never rewrites original full source snapshot');
|
|
$failed = array_values(array_filter($checks, static fn (array $check): bool => !$check['passed']));
|
|
echo json_encode(['suite' => 'DiagnosisAiPulseExcludedR36IntegrationTest', 'checks' => count($checks),
|
|
'passed' => count($checks) - count($failed), 'failed' => count($failed), 'failures' => $failed], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
|
|
exit($failed === [] ? 0 : 1);
|
|
}
|