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";