Files
zyt/server/tests/AiRequiredSourceCompletenessTest.php
2026-10-27 14:03:35 +08:00

221 lines
15 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\service\AiRequiredSource;
use app\common\service\NihaixiaClinicalSkill;
function requiredSourceExpect(bool $ok, string $message): void
{
if (!$ok) throw new RuntimeException($message);
}
/** In-memory database peer: exercises the production loader without any connection. */
final class RequiredSourceDb
{
public static array $tables = [];
public static array $queries = [];
public static function name(string $table): RequiredSourceQuery
{
return new RequiredSourceQuery($table);
}
}
final class RequiredSourceQuery
{
private array $and = [];
private array $or = [];
private array $orders = [];
private array $result = [];
public function __construct(private string $table) {}
public function whereIn(string $field, array $values): self
{
RequiredSourceDb::$queries[] = [$this->table, 'whereIn', $field, $values];
$this->and[] = static fn (array $row): bool => in_array($row[$field] ?? null, $values);
return $this;
}
public function where($field, $value = null): self
{
if (is_callable($field)) {
$group = new self($this->table);
$field($group);
$this->and[] = static fn (array $row): bool => $group->matches($row);
} else {
$this->and[] = static fn (array $row): bool => ($row[$field] ?? null) == $value;
}
return $this;
}
public function whereOr(string $field, $value): self
{
$this->or[] = static fn (array $row): bool => ($row[$field] ?? null) == $value;
return $this;
}
public function whereNull(string $field): self
{
$this->and[] = static fn (array $row): bool => ($row[$field] ?? null) === null;
return $this;
}
public function order(string $field, string $direction): self
{
$this->orders[] = [$field, $direction];
return $this;
}
public function field(array $fields): self { return $this; }
public function getTableFields(): array { return ['id', 'patient_id', 'diagnosis_id']; }
public function limit(...$args): self { throw new RuntimeException('Mandatory sources cannot paginate'); }
public function select(): self
{
$this->result = array_values(array_filter(RequiredSourceDb::$tables[$this->table] ?? [],
fn (array $row): bool => $this->matches($row)));
usort($this->result, function (array $a, array $b): int {
foreach ($this->orders as [$field, $direction]) {
$cmp = ($a[$field] ?? null) <=> ($b[$field] ?? null);
if ($cmp !== 0) return $direction === 'desc' ? -$cmp : $cmp;
}
return 0;
});
return $this;
}
public function toArray(): array { return $this->result; }
public function matches(array $row): bool
{
$and = true;
foreach ($this->and as $check) $and = $and && $check($row);
if ($and) return true;
foreach ($this->or as $check) if ($check($row)) return true;
return false;
}
}
requiredSourceExpect(class_alias(RequiredSourceDb::class, 'think\\facade\\Db'), 'isolated DB peer is installed');
$reflection = new ReflectionClass(PatientAiReportLogic::class);
$build = $reflection->getMethod('buildSourceSnapshotFromRows');
$load = $reflection->getMethod('buildSourceSnapshot');
$sanitize = $reflection->getMethod('sanitizeSnapshotForUpstream');
$filesMethod = $reflection->getMethod('collectUpstreamFiles');
$now = time();
$longNote = str_repeat('保留全部文件备注内容。', 15000) . '备注终点标记';
$diagnoses = [[
'id' => 101, 'patient_id' => 88, 'patient_name' => '合成测试身份',
'phone' => '13812345678', 'id_card' => '11010519491231002X', 'gender' => 0, 'age' => 52,
'diagnosis_date' => 1700000000, 'height' => 165, 'weight' => 60,
'systolic_pressure' => 128, 'diastolic_pressure' => 82,
'chief_complaint' => '主诉完整标记', 'present_illness' => '现病史完整标记',
'past_history' => 'custom_history', 'appetite' => ['dry', 'bitter'],
'trauma_history' => 0, 'allergy_history' => 1, 'family_history' => 0,
'clinical_diagnosis' => '诊断完整标记', 'remark' => '诊断补充标记',
'current_medications' => '在用药物完整标记', 'create_source' => 'manual', 'show_card' => 0,
]];
$blood = [];
for ($index = 1; $index <= 240; ++$index) {
$blood[] = ['id' => $index, 'diagnosis_id' => 101, 'patient_id' => 88,
'record_date' => 1700000000 + $index * 86400, 'record_time' => '08:00',
'record_time_estimated' => 1, 'record_time_period' => '早晨', 'record_time_text' => '晨起约八点',
'followup_audio_task_id' => 11, 'fasting_blood_sugar' => '7.1',
'postprandial_blood_sugar' => '8.2', 'other_blood_sugar' => '6.5',
'systolic_pressure' => 123, 'diastolic_pressure' => 78,
'western_medicine' => "西药完整标记{$index}", 'insulin' => "胰岛素完整标记{$index}"];
}
$calls = [
['id' => 11, 'diagnosis_id' => 101, 'call_type' => 2, 'status' => 2, 'start_time' => $now - 3600,
'end_time' => $now - 3000, 'transcription_session_id' => 'bound-session',
'recording_urls' => ['https://assets.test/history.mp4'], 'transcript_text' => '既往视频回退文字'],
['id' => 12, 'diagnosis_id' => 101, 'call_type' => 2, 'status' => 1, 'transcript_text' => '当前直播不得提交'],
['id' => 13, 'diagnosis_id' => 101, 'call_type' => 1, 'status' => 2, 'transcript_text' => '语音通话不得提交'],
['id' => 14, 'diagnosis_id' => 101, 'call_type' => 2, 'status' => 3, 'transcript_text' => '未接通不得提交'],
['id' => 15, 'diagnosis_id' => 101, 'call_type' => 2, 'status' => 2, 'start_time' => $now + 1000, 'transcript_text' => '未来不得提交'],
['id' => 16, 'diagnosis_id' => 101, 'call_type' => 2, 'status' => 2, 'delete_time' => 1, 'transcript_text' => '已删不得提交'],
['id' => 17, 'diagnosis_id' => 999, 'call_type' => 2, 'status' => 2, 'transcript_text' => '越权不得提交'],
];
RequiredSourceDb::$tables = [
'dict_data' => [['type_value' => 'past_history', 'value' => 'custom_history', 'name' => '自定义既往史完整标记'],
['type_value' => 'appetite', 'value' => 'dry', 'name' => '口干'],
['type_value' => 'appetite', 'value' => 'bitter', 'name' => '口苦']],
'doctor_note' => [['id' => 1, 'diagnosis_id' => 101, 'note_date' => '2020-01-01', 'content' => $longNote,
'tongue_images' => ['https://assets.test/note1.jpg', 'https://assets.test/ignored.mp4'],
'report_files' => ['https://assets.test/note1.pdf', ['url' => 'https://assets.test/opaque', 'mime_type' => 'video/mp4']]],
['id' => 2, 'diagnosis_id' => 101, 'content' => '纯文字医生备注完整标记'],
['id' => 3, 'diagnosis_id' => 999, 'content' => '越权备注不得提交']],
'tracking_note' => [['id' => 3, 'diagnosis_id' => 101, 'content' => '跟踪备注完整标记']],
'tcm_blood_record' => $blood,
'patient_diet_record' => [['id' => 1, 'diagnosis_id' => 101, 'patient_id' => 88, 'record_time' => '12:00',
'breakfast' => '旧早餐完整标记', 'lunch_foods' => '午餐完整标记', 'dinner_foods' => '晚餐完整标记']],
'patient_exercise_record' => [['id' => 1, 'diagnosis_id' => 101, 'patient_id' => 88, 'record_time' => '17:00',
'exercise_type' => '运动完整标记', 'duration' => 35, 'intensity' => 2, 'note' => '运动备注完整标记']],
'tcm_diagnosis_todo' => [['id' => 1, 'diagnosis_id' => 101, 'patient_id' => 88, 'content' => '日常待办完整标记', 'status' => 0],
['id' => 2, 'diagnosis_id' => 999, 'patient_id' => 99, 'content' => '越权待办不得提交']],
'tcm_prescription' => [['id' => 1, 'diagnosis_id' => 101, 'patient_id' => 88, 'prescription_name' => '既往处方完整标记',
'herbs' => [['name' => '测试药味', 'dosage' => 1]], 'audit_status' => 1, 'void_status' => 0]],
'tcm_im_chat_message' => [['id' => 1, 'diagnosis_id' => 101, 'patient_id' => 88,
'from_account' => 'patient_88', 'to_account' => 'doctor_7', 'msg_type' => 'composite',
'text' => json_encode([['msg_type' => 'text', 'text' => '复合聊天完整标记'],
['msg_type' => 'image', 'image_url' => 'https://assets.test/chat.jpg'],
['msg_type' => 'file', 'file_url' => 'https://assets.test/chat.pdf'],
['msg_type' => 'video', 'file_url' => 'https://assets.test/download/opaque-part']], JSON_UNESCAPED_UNICODE)],
['id' => 2, 'diagnosis_id' => 101, 'patient_id' => 88, 'from_account' => 'patient_99',
'to_account' => 'doctor_7', 'text' => '错绑聊天不得提交'],
['id' => 3, 'diagnosis_id' => 101, 'patient_id' => 88, 'from_account' => 'patient_88',
'to_account' => 'doctor_7', 'msg_type' => 'video', 'file_url' => 'https://assets.test/download/opaque-im', 'text' => '视频附带说明完整标记'],
['id' => 4, 'diagnosis_id' => 101, 'patient_id' => 88, 'from_account' => 'patient_88',
'to_account' => 'doctor_7', 'msg_type' => 'file', 'file_name' => 'clip.MP4', 'file_url' => 'https://assets.test/download/opaque-file']],
'wechat_chat_record' => [['id' => 1, 'diagnosis_id' => 101, 'patient_id' => 88, 'content' => '企微聊天完整标记'],
['id' => 2, 'diagnosis_id' => 101, 'patient_id' => 88, 'msg_type' => 'video', 'media_url' => 'https://assets.test/download/opaque-wechat']],
'tcm_call_record' => $calls,
'tcm_call_transcript_segment' => [
['id' => 1, 'call_record_id' => 11, 'transcription_session_id' => 'bound-session', 'speaker_role' => 'doctor', 'timestamp_ms' => 1000, 'text' => '历史视频医生完整标记'],
['id' => 2, 'call_record_id' => 11, 'transcription_session_id' => 'bound-session', 'speaker_role' => 'patient', 'timestamp_ms' => 2000, 'text' => '历史视频患者完整标记'],
['id' => 3, 'call_record_id' => 12, 'speaker_role' => 'patient', 'text' => '直播片段不得提交'],
['id' => 4, 'call_record_id' => 17, 'speaker_role' => 'patient', 'text' => '越权片段不得提交'],
['id' => 5, 'call_record_id' => 11, 'transcription_session_id' => 'stale-session', 'text' => '旧会话片段不得提交']],
];
$snapshot = $load->invoke(null, 88, $diagnoses);
$manifest = $snapshot['source_summary']['required_sections'];
requiredSourceExpect(array_column($manifest, 'key') === array_keys(AiRequiredSource::LABELS), 'every required section appears in fixed order');
foreach ($manifest as $section) requiredSourceExpect($section['availability'] === 'present', $section['key'] . ' has its actual records');
requiredSourceExpect($snapshot['diagnoses'][0]['past_history_text'] === '自定义既往史完整标记', 'dictionary labels are loaded without dropping source codes');
requiredSourceExpect($snapshot['diagnoses'][0]['trauma_history'] === 0
&& $snapshot['diagnoses'][0]['trauma_history_text'] === '未记录', 'legacy zero preserves its raw code without inventing an interview denial');
requiredSourceExpect(count($snapshot['daily_records']['blood_glucose_pressure']) === 240, 'all old and same-day daily rows are retained without UI window or pagination');
requiredSourceExpect(count($snapshot['video_calls']) === 1 && $snapshot['source_summary']['transcript_segment_count'] === 2,
'only authorized ended past video and its segments are included');
$safe = $sanitize->invoke(null, AiRequiredSource::submission($snapshot));
$json = json_encode($safe, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
foreach (['主诉完整标记', '现病史完整标记', '自定义既往史完整标记', '诊断完整标记', '诊断补充标记',
'在用药物完整标记', '备注终点标记', '纯文字医生备注完整标记', '跟踪备注完整标记', '日常待办完整标记',
'西药完整标记1', '西药完整标记240', '胰岛素完整标记240', '晨起约八点', '旧早餐完整标记', '午餐完整标记',
'晚餐完整标记', '运动完整标记', '运动备注完整标记', '既往处方完整标记', '复合聊天完整标记', '企微聊天完整标记',
'历史视频医生完整标记', '历史视频患者完整标记', '视频附带说明完整标记'] as $marker) {
requiredSourceExpect(str_contains($json, $marker), $marker . ' survives complete model projection');
}
foreach (['当前直播不得提交', '直播片段不得提交', '语音通话不得提交', '未接通不得提交', '未来不得提交',
'已删不得提交', '越权不得提交', '越权备注不得提交', '越权待办不得提交', '越权片段不得提交', '旧会话片段不得提交', '错绑聊天不得提交',
'13812345678', '11010519491231002X', '合成测试身份', 'assets.test'] as $forbidden) {
requiredSourceExpect(!str_contains($json, $forbidden), $forbidden . ' is not exposed to the model');
}
$files = $filesMethod->invoke(null, $snapshot);
requiredSourceExpect(count($files) === 4 && array_column($files, 'type') === ['image', 'document', 'image', 'document'],
'doctor notes and nested composite chat both contribute actual non-video files');
$empty = $sanitize->invoke(null, AiRequiredSource::submission($build->invoke(null, [])));
foreach ($empty['required_content'] as $section) requiredSourceExpect($section['value'] === '无', $section['key'] . ' is explicitly submitted as 无 when absent');
requiredSourceExpect(count($empty['required_content']) === 13, 'empty input never omits a required category');
$sparse = $build->invoke(null, ['diagnoses' => [['id' => 101, 'clinical_diagnosis' => '有诊断但无附件',
'tongue_images' => [], 'report_files' => [], 'family_history' => '无']]]);
$safeSparse = $sanitize->invoke(null, AiRequiredSource::submission($sparse));
$diagnosisSection = array_values(array_filter($safeSparse['required_content'], static fn (array $section): bool => $section['key'] === 'diagnosis_info'))[0];
requiredSourceExpect($diagnosisSection['value'][0]['tongue_images']['attachment_count'] === 0
&& $diagnosisSection['value'][0]['report_files']['attachment_count'] === 0, 'empty attachment markers never invent one file');
$historySection = array_values(array_filter($safeSparse['required_content'], static fn (array $section): bool => $section['key'] === 'other_history'))[0];
requiredSourceExpect($historySection['value'][0]['family_history'] === '无', 'explicit negative history remains a source fact');
foreach ([['unknown_attachment_key' => 'opaque'], 'javascript:synthetic-invalid'] as $invalidFile) {
$rejected = false;
try { $filesMethod->invoke(null, ['doctor_notes' => [['report_files' => [$invalidFile]]]]); }
catch (RuntimeException $error) { $rejected = true; }
requiredSourceExpect($rejected, 'an invalid required source fails instead of disappearing silently');
}
requiredSourceExpect(NihaixiaClinicalSkill::knowledgeSourceForPromptVersion('patient-longitudinal-report-required-sources-v5') !== null,
'new persisted patient report version retains clinical knowledge provenance');
echo "Required AI source loader / full clinical projection / empty markers / history-only video / no binary video: OK\n";