221 lines
15 KiB
PHP
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";
|