Files
zyt/server/app/common/service/prescriptionai/PrescriptionAiContext.php
T
2026-09-10 15:19:17 +08:00

713 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service\prescriptionai;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
use app\common\service\DataScope\DataScopeService;
use app\common\service\FileService;
use think\facade\Db;
/** Builds one frozen, permission-scoped input shared by independent model tasks. */
final class PrescriptionAiContext
{
public const SCHEMA_VERSION = 'prescription-evidence-v1';
private const TABLES = [
'doctor_notes' => 'doctor_note', 'tracking_notes' => 'tracking_note',
'blood_records' => 'tcm_blood_record', 'diet_records' => 'patient_diet_record',
'exercise_records' => 'patient_exercise_record', 'prescriptions' => 'tcm_prescription',
'im_messages' => 'tcm_im_chat_message', 'wechat_messages' => 'wechat_chat_record',
'call_records' => 'tcm_call_record',
];
private const ATTACHMENTS = [
'tongue_images', 'tongue_photo', 'tongue_image', 'report_files', 'examination_report',
'image_url', 'file_url', 'media_url', 'breakfast_images', 'lunch_images', 'dinner_images', 'images',
];
private const SAFETY_FIELDS = [
'allergy_history' => ['allergy_history_text', 'allergy_history_desc', 'allergy_history'],
'pregnancy_history' => ['pregnancy_history_text', 'pregnancy_history_desc', 'pregnancy_history'],
'current_medications' => ['current_medications', 'current_medicine', 'current_medication'],
];
private const SOURCE_PREFIXES = [
'diagnoses' => 'diagnoses', 'doctor_notes' => 'doctor_notes', 'tracking_notes' => 'tracking_notes',
'blood_records' => 'blood_glucose_pressure', 'diet_records' => 'diet', 'exercise_records' => 'exercise',
'prescriptions' => 'prescriptions', 'im_messages' => 'tencent_im', 'wechat_messages' => 'wechat_work',
'call_records' => 'video_calls', 'transcript_segments' => 'transcript_segments',
];
public static function build(array $prescription, int $adminId, array $adminInfo, int $decisionAt): array
{
$diagnosisId = (int) ($prescription['diagnosis_id'] ?? 0);
if ($adminId <= 0 || $diagnosisId <= 0 || !PrescriptionLogic::canViewPrescription($prescription, $adminId, $adminInfo)) {
throw new \RuntimeException('PATIENT_BINDING_OR_PERMISSION_REQUIRED');
}
// Stable binding only. Appointment.patient_id is a diagnosis ID, never a patient ID.
$diagnosis = Db::name('tcm_diagnosis')->where('id', $diagnosisId)->whereNull('delete_time')->find();
$patientId = (int) ($diagnosis['patient_id'] ?? 0);
$rxPatientId = (int) ($prescription['patient_id'] ?? 0);
if ($patientId <= 0 || ($rxPatientId > 0 && $rxPatientId !== $patientId)) {
throw new \RuntimeException('PATIENT_BINDING_REQUIRED');
}
$query = Db::name('tcm_diagnosis')->alias('d')->where('d.patient_id', $patientId)->whereNull('d.delete_time');
MyPatientLogic::applyScope($query, $adminId, $adminInfo);
$diagnoses = $query->order('d.diagnosis_date', 'asc')->order('d.id', 'asc')->select()->toArray();
$ids = array_map(static fn (array $row): int => (int) $row['id'], $diagnoses);
if (!in_array($diagnosisId, $ids, true)) {
throw new \RuntimeException('PATIENT_BINDING_OR_PERMISSION_REQUIRED');
}
$cutoff = time();
$rows = ['patient_id' => $patientId, 'diagnoses' => $diagnoses];
$missing = [];
$staffIds = self::visibleStaffIds($adminId, $adminInfo);
$staffWechatIds = $staffIds === null ? null : Db::name('admin')->whereIn('id', $staffIds)->column('work_wechat_userid');
foreach (self::TABLES as $kind => $table) {
try {
$fields = Db::name($table)->getTableFields();
if (!in_array('diagnosis_id', $fields, true)) {
$rows[$kind] = [];
$missing[] = self::gap($kind, 'SOURCE_AUTHORIZATION_LINK_UNAVAILABLE');
continue;
}
// Deliberately no patient OR union: unlinked records need their own proven policy.
$sourceQuery = Db::name($table)->whereIn('diagnosis_id', $ids);
if (in_array('delete_time', $fields, true)) {
$sourceQuery->whereNull('delete_time');
}
if (in_array('create_time', $fields, true)) {
$sourceQuery->where('create_time', '<=', $cutoff);
}
$loaded = $sourceQuery->order('id', 'asc')->select()->toArray();
$rows[$kind] = [];
foreach ($loaded as $row) {
if ((int) ($row['patient_id'] ?? 0) > 0 && (int) $row['patient_id'] !== $patientId) {
$missing[] = self::gap($kind, 'SOURCE_PATIENT_CONFLICT');
continue;
}
if ($kind === 'prescriptions' && !PrescriptionLogic::canViewPrescription($row, $adminId, $adminInfo)) {
$missing[] = self::gap($kind, 'SOURCE_ACCESS_RESTRICTED');
continue;
}
// Chats and call transcripts also intersect staff/departments; diagnosis
// access alone does not grant access to another staff member's archive.
if (!self::sourceStaffAllowed($kind, $row, $staffIds, $staffWechatIds)) {
$missing[] = self::gap($kind, 'SOURCE_ACCESS_RESTRICTED');
continue;
}
$rows[$kind][] = $row;
}
if (in_array('patient_id', $fields, true) && in_array($kind, ['blood_records', 'diet_records', 'exercise_records', 'im_messages', 'wechat_messages'], true)) {
$unlinked = Db::name($table)->where('patient_id', $patientId)->where('diagnosis_id', 0)->count();
if ($unlinked > 0) {
$missing[] = self::gap($kind, 'UNLINKED_SOURCE_REQUIRES_AUTHORIZATION');
}
}
} catch (\Throwable $e) {
// No SQL, exception text, patient text, or remote URL is logged or returned.
$rows[$kind] = [];
$missing[] = self::gap($kind, 'SOURCE_READ_UNAVAILABLE');
}
}
$callIds = array_map(static fn (array $row): int => (int) $row['id'], $rows['call_records']);
$rows['transcript_segments'] = [];
if ($callIds !== []) {
try {
$rows['transcript_segments'] = Db::name('tcm_call_transcript_segment')->whereIn('call_record_id', $callIds)
->where('create_time', '<=', $cutoff)->order('call_record_id', 'asc')->order('timestamp_ms', 'asc')->order('id', 'asc')->select()->toArray();
} catch (\Throwable $e) {
$missing[] = self::gap('transcript_segments', 'SOURCE_READ_UNAVAILABLE');
}
}
// Archive watermarks do not currently prove full synchronization of either channel.
$missing[] = self::gap('chat_records', 'ARCHIVE_SYNC_WATERMARK_UNAVAILABLE');
return self::fromAuthorizedRows($prescription, $rows, $decisionAt, $cutoff, $missing);
}
private static function visibleStaffIds(int $adminId, array $adminInfo): ?array
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return null;
}
$roles = array_map('intval', (array) ($adminInfo['role_id'] ?? []));
if (array_intersect($roles, [3, 7, 8]) !== []) {
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
return [$adminId];
}
/** Recheck every frozen source against current row bindings and current access rules. */
public static function assertSnapshotAccess(array $context, int $adminId, array $adminInfo): bool
{
$manifest = $context['source_access_manifest'] ?? null;
if ($adminId <= 0 || !is_array($manifest) || ($manifest['schema_version'] ?? '') !== 'prescription-source-access-v1'
|| !is_array($manifest['records'] ?? null) || !is_array($manifest['target'] ?? null)) {
return false;
}
try {
$patientId = (int) ($manifest['patient_id'] ?? 0);
$target = $manifest['target'];
$rx = Db::name('tcm_prescription')->where('id', (int) ($target['prescription_id'] ?? 0))->whereNull('delete_time')->find();
if (!$rx || (int) ($rx['diagnosis_id'] ?? 0) !== (int) ($target['diagnosis_id'] ?? 0)
|| ((int) ($rx['patient_id'] ?? 0) > 0 && (int) $rx['patient_id'] !== $patientId)
|| !PrescriptionLogic::canViewPrescription($rx, $adminId, $adminInfo)) {
return false;
}
$diagnosisIds = array_values(array_unique(array_merge([(int) ($target['diagnosis_id'] ?? 0)], array_map(
static fn (array $row): int => (int) ($row['diagnosis_id'] ?? 0), $manifest['records']
))));
if ($patientId <= 0 || in_array(0, $diagnosisIds, true)) {
return false;
}
$query = Db::name('tcm_diagnosis')->alias('d')->whereIn('d.id', $diagnosisIds)->where('d.patient_id', $patientId)->whereNull('d.delete_time');
MyPatientLogic::applyScope($query, $adminId, $adminInfo);
$authorizedIds = array_map('intval', $query->column('d.id'));
if (array_diff($diagnosisIds, $authorizedIds) !== []) {
return false;
}
$tables = array_merge(self::TABLES, ['diagnoses' => 'tcm_diagnosis', 'transcript_segments' => 'tcm_call_transcript_segment']);
$byKind = [];
foreach ($manifest['records'] as $entry) {
$kind = (string) ($entry['source_kind'] ?? '');
if (!isset($tables[$kind]) || (int) ($entry['id'] ?? 0) <= 0) {
return false;
}
$byKind[$kind][] = (int) $entry['id'];
}
$live = [];
foreach ($byKind as $kind => $ids) {
$sourceQuery = Db::name($tables[$kind])->whereIn('id', $ids);
if (in_array('delete_time', Db::name($tables[$kind])->getTableFields(), true)) {
$sourceQuery->whereNull('delete_time');
}
$live[$kind] = $sourceQuery->select()->toArray();
}
$staffIds = self::visibleStaffIds($adminId, $adminInfo);
$wechatIds = $staffIds === null ? null : Db::name('admin')->whereIn('id', $staffIds)->column('work_wechat_userid');
return self::manifestRowsAccessible($manifest, $live, $authorizedIds, $staffIds, $wechatIds,
static fn (array $row): bool => PrescriptionLogic::canViewPrescription($row, $adminId, $adminInfo));
} catch (\Throwable $e) {
return false;
}
}
/** Pure row-policy core, exercised without any production database in regression tests. */
public static function manifestRowsAccessible(array $manifest, array $live, array $diagnosisIds, ?array $staffIds, ?array $wechatIds, callable $prescriptionVisible): bool
{
$indexed = [];
foreach ($live as $kind => $rows) {
if (!is_array($rows)) {
continue;
}
foreach ($rows as $row) {
if (!is_array($row)) {
return false;
}
$indexed[$kind][(int) ($row['id'] ?? 0)] = $row;
}
}
$patientId = (int) ($manifest['patient_id'] ?? 0);
foreach ((array) ($manifest['records'] ?? []) as $entry) {
$kind = (string) ($entry['source_kind'] ?? '');
$id = (int) ($entry['id'] ?? 0);
$diagnosisId = (int) ($entry['diagnosis_id'] ?? 0);
$row = $indexed[$kind][$id] ?? null;
if (!is_array($row) || !empty($row['delete_time']) || $patientId <= 0 || !in_array($diagnosisId, $diagnosisIds, true)) {
return false;
}
if ($kind === 'transcript_segments') {
$callId = (int) ($entry['call_record_id'] ?? 0);
$call = $indexed['call_records'][$callId] ?? [];
if ((int) ($row['call_record_id'] ?? 0) !== $callId || (int) ($call['diagnosis_id'] ?? 0) !== $diagnosisId
|| (string) ($row['transcription_session_id'] ?? '') !== (string) ($entry['transcription_session_id'] ?? '')) {
return false;
}
} elseif (($kind === 'diagnoses' ? (int) ($row['id'] ?? 0) : (int) ($row['diagnosis_id'] ?? 0)) !== $diagnosisId) {
return false;
}
if ((int) ($row['patient_id'] ?? 0) > 0 && (int) $row['patient_id'] !== $patientId) {
return false;
}
// Both the originally frozen archive owner and its current owner must be visible.
if (!self::sourceStaffAllowed($kind, (array) ($entry['staff'] ?? []), $staffIds, $wechatIds)
|| !self::sourceStaffAllowed($kind, $row, $staffIds, $wechatIds)) {
return false;
}
if ($kind === 'prescriptions' && !$prescriptionVisible($row)) {
return false;
}
}
return !empty($manifest['records']);
}
/** Pure per-source staff policy; null means explicitly authorized all staff. */
public static function sourceStaffAllowed(string $kind, array $row, ?array $staffIds, ?array $wechatIds): bool
{
if ($staffIds === null) {
return true;
}
if ($kind === 'call_records') {
return ($row['caller_type'] ?? '') === 'doctor' && in_array((int) ($row['caller_id'] ?? 0), $staffIds, true);
}
if ($kind === 'wechat_messages') {
return trim((string) ($row['staff_userid'] ?? '')) !== '' && in_array($row['staff_userid'], $wechatIds ?? [], true);
}
if ($kind === 'im_messages') {
$peers = array_map(static fn ($id): string => 'doctor_' . (int) $id, $staffIds);
$peer = (string) ($row['doctor_peer_account'] ?? '');
return in_array($peer, $peers, true) || in_array((string) ($row['from_account'] ?? ''), $peers, true)
|| in_array((string) ($row['to_account'] ?? ''), $peers, true);
}
return true;
}
/** Pure builder for already-authorized rows, also used by offline fixture tests. */
private static function plainText($value): string
{
return is_string($value) || is_numeric($value) ? trim((string) $value) : '';
}
/** Mirrors prescription_analysis.transcript_wait_seconds for offline use. */
private const DEFAULT_TRANSCRIPT_GRACE = 300;
public static function fromAuthorizedRows(array $prescription, array $rows, int $decisionAt, int $cutoff, array $missing = []): array
{
$targetDiagnosis = (int) ($prescription['diagnosis_id'] ?? 0);
$targetId = (int) ($prescription['id'] ?? 0);
$rows['prescriptions'] = array_values(array_filter((array) ($rows['prescriptions'] ?? []), static function (array $row) use ($targetId, $targetDiagnosis, $prescription): bool {
if ((int) ($row['id'] ?? 0) === $targetId) {
return false;
}
// Same-encounter, same-day versions/drafts may contain the target plan.
return !((int) ($row['diagnosis_id'] ?? 0) === $targetDiagnosis
&& (string) ($row['prescription_date'] ?? '') === (string) ($prescription['prescription_date'] ?? ''));
}));
$accessRows = $rows;
$exclusions = ['SOURCE_HISTORY_VERSIONS_UNAVAILABLE'];
$nonIndependent = false;
$redactions = [];
$herbs = self::decode($prescription['herbs'] ?? []);
$names = [];
foreach ($herbs as $herb) {
if (is_array($herb)) {
$name = trim((string) ($herb['name'] ?? $herb['herb_name'] ?? $herb['medicine_name'] ?? ''));
if ($name !== '') {
$names[] = $name;
}
}
}
// Strip explicit target treatment fields and isolate recognizable copies in free text.
foreach ($rows as $kind => &$records) {
if (!is_array($records) || $kind === 'prescriptions') {
continue;
}
foreach ($records as &$row) {
if (!is_array($row)) {
continue;
}
$isTarget = (int) ($kind === 'diagnoses' ? ($row['id'] ?? 0) : ($row['diagnosis_id'] ?? 0)) === $targetDiagnosis;
if ($isTarget && $kind === 'diagnoses') {
foreach (['prescription', 'prescription_opinion', 'prescription_advice', 'treatment_principle', 'doctor_advice'] as $key) {
if (!empty($row[$key])) {
unset($row[$key]);
$redactions[] = $kind . ':' . (int) ($row['id'] ?? 0) . ':' . $key;
}
}
}
self::isolatePlanText($row, $names, $kind . ':' . (int) ($row['id'] ?? 0), $redactions);
if ($isTarget) {
foreach (self::SAFETY_FIELDS as $aliases) {
foreach ($aliases as $field) {
$clinicalText = isset($row[$field]) ? self::json($row[$field]) : '';
foreach ($names as $name) {
if (str_contains($clinicalText, $name)) {
// A current medication/allergy may legitimately name a target
// herb: retain the safety fact and disclaim independence.
$nonIndependent = true;
}
}
}
}
}
if ($isTarget && in_array($kind, ['doctor_notes', 'tracking_notes', 'im_messages', 'wechat_messages', 'call_records'], true)) {
// A phrase or handwritten attachment can reveal a plan without matching names.
$nonIndependent = true;
}
}
unset($row);
}
unset($records);
$wait = false;
$transcriptGrace = function_exists('config')
? max(0, (int) config('prescription_analysis.transcript_wait_seconds', 300))
: self::DEFAULT_TRANSCRIPT_GRACE;
$byCall = [];
foreach ((array) ($rows['transcript_segments'] ?? []) as $segment) {
$byCall[(int) ($segment['call_record_id'] ?? 0)][] = $segment;
}
$acceptedSegments = [];
$rows['call_records'] = (array) ($rows['call_records'] ?? []);
foreach ($rows['call_records'] as &$call) {
// The denormalized fallback may belong to an earlier transcription session.
// Rebuild from this call's current persisted session segments below.
$call['transcript_text'] = '';
if (in_array((int) ($call['status'] ?? 0), [3, 4], true)) {
// Missed/cancelled calls contain no completed clinical conversation to await.
continue;
}
$id = (int) ($call['id'] ?? 0);
$session = (string) ($call['transcription_session_id'] ?? '');
$segments = array_values(array_filter($byCall[$id] ?? [], static fn (array $row): bool => $session !== '' && (string) ($row['transcription_session_id'] ?? '') === $session));
$status = (string) ($call['transcription_status'] ?? '');
$current = (int) ($call['diagnosis_id'] ?? 0) === $targetDiagnosis
&& ((int) ($prescription['appointment_id'] ?? 0) <= 0 || !isset($call['appointment_id']) || (int) $call['appointment_id'] === (int) $prescription['appointment_id']);
// Only wait while a transcript can still plausibly arrive: the call is live, a
// transcription is pending/running, or an un-transcribed call ended just now. An older
// call that ended without any transcription session stays an explicit gap instead of
// stalling every batch for the whole wait window.
$ended = max((int) ($call['end_time'] ?? 0), (int) ($call['update_time'] ?? 0));
$unstarted = $status === '' && $segments === [];
if ($current && ((int) ($call['status'] ?? 0) === 1 || in_array($status, ['pending', 'running'], true)
|| ($unstarted && $ended > 0 && $cutoff - $ended <= $transcriptGrace))) {
$wait = true;
}
if (!self::transcriptComplete($call, $segments)) {
$missing[] = self::gap('call_records:' . $id, 'TRANSCRIPT_' . (in_array($status, ['partial', 'failed', 'running'], true) ? strtoupper($status) : 'NOT_VERIFIED_COMPLETE'), $current);
}
$acceptedSegments = array_merge($acceptedSegments, $segments);
}
unset($call);
$rows['transcript_segments'] = $acceptedSegments;
$accessRows['transcript_segments'] = $acceptedSegments;
$accessManifest = self::accessManifest($prescription, $accessRows);
$snapshot = PatientAiReportLogic::normalizeAuthorizedClinicalRows($rows);
// The existing shared normalizer covers canonical database columns. Also retain the
// explicitly supported clinical aliases used by the workstation, without copying other
// arbitrary database fields or replacing contradictory canonical/description values.
$rawDiagnoses = [];
foreach ($rows['diagnoses'] as $rawDiagnosis) {
$rawDiagnoses[(int) $rawDiagnosis['id']] = $rawDiagnosis;
}
foreach ($snapshot['diagnoses'] as &$normalizedDiagnosis) {
$rawDiagnosis = $rawDiagnoses[(int) $normalizedDiagnosis['id']] ?? [];
foreach (self::SAFETY_FIELDS as $aliases) {
foreach ($aliases as $field) {
if (array_key_exists($field, $rawDiagnosis)) {
$normalizedDiagnosis[$field] = $rawDiagnosis[$field];
}
}
}
$normalizedDiagnosis['gender_label'] = self::genderLabel($normalizedDiagnosis['gender'] ?? null);
}
unset($normalizedDiagnosis);
$snapshot['patient']['gender_label'] = self::genderLabel($snapshot['patient']['gender'] ?? null);
unset($snapshot['source_summary']);
$records = [];
foreach (['diagnoses', 'doctor_notes', 'tracking_notes', 'prescriptions', 'video_calls'] as $kind) {
foreach ($snapshot[$kind] ?? [] as $row) {
$records[] = ['source_id' => $kind . ':' . (int) ($row['id'] ?? 0), 'kind' => $kind, 'data' => $row];
}
}
foreach (['daily_records', 'chat_records'] as $group) {
foreach ($snapshot[$group] ?? [] as $kind => $groupRows) {
foreach ($groupRows as $row) {
$records[] = ['source_id' => $kind . ':' . (int) ($row['id'] ?? 0), 'kind' => $kind, 'data' => $row];
}
}
}
$files = [];
foreach ($records as &$record) {
self::collectFiles($record['data'], $record['source_id'], $files, $missing);
$record['file_ids'] = [];
foreach ($files as $file) {
if (in_array($record['source_id'], $file['source_ids'], true)) {
$record['file_ids'][] = $file['file_id'];
}
}
}
unset($record);
if ($files !== []) {
$nonIndependent = true;
$exclusions[] = 'ATTACHMENT_TARGET_PLAN_LEAKAGE_UNVERIFIED';
}
if ($nonIndependent || $redactions !== []) {
$exclusions[] = 'UNSTRUCTURED_TARGET_PLAN_LEAKAGE_UNVERIFIED';
}
if ($redactions !== []) {
$missing[] = self::gap('target_plan', 'TARGET_PLAN_COPY_ISOLATED');
}
$clinical = PatientAiReportLogic::redactClinicalSource(['patient' => $snapshot['patient'], 'records' => $records]);
// Mark missing safety facts explicitly. Never interpret an empty field as a negative finding.
foreach (['age', 'gender', 'allergy_history', 'current_medications', 'pregnancy_history'] as $field) {
if ($field === 'pregnancy_history' && self::genderLabel($snapshot['patient']['gender'] ?? null) === '男') {
continue;
}
$known = false;
foreach ($snapshot['diagnoses'] as $diagnosis) {
foreach (self::SAFETY_FIELDS[$field] ?? [$field] as $alias) {
if (array_key_exists($alias, $diagnosis)) {
$known = $known || self::safetyValueKnown($field, $diagnosis[$alias]);
}
}
}
if (!$known) {
$missing[] = self::gap('clinical.' . $field, 'CRITICAL_CLINICAL_FACT_MISSING', true);
}
}
$missing = array_values(array_unique($missing, SORT_REGULAR));
$summary = ['source_record_count' => count($records), 'attachment_count' => count($files), 'missing_count' => count($missing),
'snapshot_complete' => false, 'may_be_truncated' => false, 'history_versioning' => 'unavailable', 'archive_sync_verified' => false];
foreach ($records as $record) {
$key = $record['kind'] . '_count';
$summary[$key] = ($summary[$key] ?? 0) + 1;
}
// Dispensing form, per-herb unit and dose basis describe how this clinic's pharmacy
// fills any prescription. They carry no herb, dosage or treatment decision, and both
// models need them to express a comparable candidate.
$dispensing = ['formulation' => self::plainText($prescription['prescription_type'] ?? ''),
'unit' => self::plainText($prescription['dosage_unit'] ?? ''),
'dose_basis' => in_array(self::plainText($prescription['dose_unit'] ?? ''), ['剂', '付'], true) ? 'per_dose' : ''];
$source = ['schema_version' => self::SCHEMA_VERSION, 'cutoff_at' => $cutoff, 'decision_at' => $decisionAt,
'dispensing' => $dispensing,
'patient' => $clinical['patient'], 'records' => $clinical['records'], 'missing' => $missing,
'clinical_field_semantics' => ['gender' => '诊单0=女、1=男;兼容2=女,优先结合gender_label。',
'history_flags' => '过敏史及妊娠哺乳史0/false=数据库记录无,1/true=有。数值字段可能来自系统默认,不能等同医生已核实或患者明确否认;须结合带来源的正文及冲突复核。']];
return ['source' => $source, 'source_summary' => $summary, 'files' => array_values($files),
'source_hash' => self::stableSourceHash($source, array_values($files), $accessManifest),
'cutoff_at' => $cutoff, 'decision_at' => $decisionAt, 'missing' => $missing,
'comparison_type' => $nonIndependent || $redactions !== [] ? 'non_independent' : 'latest_context',
'baseline_eligible' => false, 'baseline_exclusion_reasons' => array_values(array_unique($exclusions)),
'wait_for_transcript' => $wait, 'source_diagnosis_ids' => array_map(static fn (array $r): int => (int) $r['id'], $rows['diagnoses']),
'schema_version' => self::SCHEMA_VERSION, 'redaction_manifest' => $redactions, 'source_access_manifest' => $accessManifest];
}
private static function accessManifest(array $prescription, array $rows): array
{
$patientId = (int) ($rows['patient_id'] ?? 0);
$callDiagnosisIds = [];
foreach ((array) ($rows['call_records'] ?? []) as $call) {
$callDiagnosisIds[(int) ($call['id'] ?? 0)] = (int) ($call['diagnosis_id'] ?? 0);
}
$entries = [];
foreach (self::SOURCE_PREFIXES as $kind => $prefix) {
foreach ((array) ($rows[$kind] ?? []) as $row) {
$id = (int) ($row['id'] ?? 0);
$diagnosisId = $kind === 'diagnoses' ? $id : (int) ($row['diagnosis_id'] ?? 0);
if ($kind === 'transcript_segments') {
$diagnosisId = $callDiagnosisIds[(int) ($row['call_record_id'] ?? 0)] ?? 0;
}
$staff = [];
foreach (['doctor_id', 'admin_id', 'creator_id', 'assistant_id', 'doctor_peer_account', 'from_account', 'to_account', 'staff_userid', 'caller_type', 'caller_id'] as $field) {
if (array_key_exists($field, $row)) {
$staff[$field] = $row[$field];
}
}
$entry = ['source_id' => $prefix . ':' . $id, 'source_kind' => $kind, 'id' => $id,
'diagnosis_id' => $diagnosisId, 'patient_id' => (int) ($row['patient_id'] ?? $patientId), 'staff' => $staff];
if ($kind === 'transcript_segments') {
$entry['call_record_id'] = (int) ($row['call_record_id'] ?? 0);
$entry['transcription_session_id'] = (string) ($row['transcription_session_id'] ?? '');
}
$entries[] = $entry;
}
}
return ['schema_version' => 'prescription-source-access-v1', 'patient_id' => $patientId,
'target' => ['prescription_id' => (int) ($prescription['id'] ?? 0), 'diagnosis_id' => (int) ($prescription['diagnosis_id'] ?? 0)], 'records' => $entries];
}
/** A later refresh clock does not mean new clinical evidence or authorize another model run. */
public static function stableSourceHash(array $source, array $files, array $accessManifest): string
{
unset($source['cutoff_at']);
return hash('sha256', self::json(self::canonicalize(['source' => $source, 'files' => $files, 'access_manifest' => $accessManifest])));
}
private static function canonicalize($value)
{
if (!is_array($value)) {
return $value;
}
if (!array_is_list($value)) {
ksort($value);
}
foreach ($value as $key => $child) {
$value[$key] = self::canonicalize($child);
}
return $value;
}
private static function genderLabel($value): string
{
if ($value === null || is_array($value) || is_bool($value)) {
return '未知';
}
$value = strtolower(trim((string) $value));
return in_array($value, ['1', 'm', 'male', '男'], true) ? '男'
: (in_array($value, ['0', '2', 'f', 'female', '女'], true) ? '女' : '未知');
}
private static function safetyValueKnown(string $field, $value): bool
{
if ($field === 'gender') {
return self::genderLabel($value) !== '未知';
}
if ($field === 'age') {
return is_numeric($value) && (float) $value > 0;
}
if ($value === null || (is_array($value) && $value === [])) {
return false;
}
// In diagnosis schema allergy_history and pregnancy_history use 0=no, 1=yes.
// Explicit false/0/“无” survive normalization and are not empty/missing answers.
if (is_bool($value)) {
return true;
}
if (is_array($value)) {
foreach ($value as $item) {
if (self::safetyValueKnown($field, $item)) {
return true;
}
}
return false;
}
$text = trim((string) $value);
return $text !== '' && !in_array(strtolower($text), ['未知', '未填写', '不详', '未提供', '未询问', '待补充', 'unknown', 'null', 'n/a'], true);
}
public static function transcriptComplete(array $call, array $segments): bool
{
$expected = (int) ($call['transcription_segment_count'] ?? 0);
if ((int) ($call['status'] ?? 0) !== 2 || ($call['transcription_status'] ?? '') !== 'completed'
|| (int) ($call['transcription_finished_at'] ?? 0) <= 0 || $expected <= 0 || count($segments) !== $expected
|| trim((string) ($call['transcription_session_id'] ?? '')) === '') {
return false;
}
foreach ($segments as $segment) {
if ((int) ($segment['call_record_id'] ?? 0) !== (int) ($call['id'] ?? 0)
|| (string) ($segment['transcription_session_id'] ?? '') !== (string) $call['transcription_session_id']
|| trim((string) ($segment['text'] ?? '')) === '') {
return false;
}
}
return true;
}
private static function isolatePlanText(array &$row, array $names, string $sourceId, array &$redactions): void
{
foreach ($row as $key => &$value) {
$safetyFields = array_merge(...array_values(self::SAFETY_FIELDS));
if (in_array((string) $key, self::ATTACHMENTS, true) || in_array((string) $key, array_merge($safetyFields, ['western_medicine', 'insulin']), true)) {
continue;
}
if (is_array($value)) {
self::isolatePlanText($value, $names, $sourceId . ':' . $key, $redactions);
} elseif (is_string($value)) {
foreach ($names as $name) {
if (str_contains($value, $name)) {
$value = '[本次治疗方案的可能重复副本已隔离]';
$redactions[] = $sourceId . ':' . $key;
break;
}
}
}
}
unset($value);
}
private static function collectFiles(array &$value, string $sourceId, array &$files, array &$missing): void
{
foreach ($value as $key => &$item) {
if ((string) $key === 'recording_urls') {
$item = ['raw_recordings_not_sent' => true];
continue;
}
if (!in_array((string) $key, self::ATTACHMENTS, true)) {
if (is_array($item)) {
self::collectFiles($item, $sourceId, $files, $missing);
}
continue;
}
$refs = [];
foreach (self::decode($item) as $attachment) {
$uri = is_string($attachment) ? trim($attachment) : '';
if (is_array($attachment)) {
$uri = (string) ($attachment['url'] ?? $attachment['uri'] ?? $attachment['path'] ?? $attachment['file_url'] ?? $attachment['image_url'] ?? '');
}
if ($uri === '') {
continue;
}
$id = 'file:' . hash('sha256', $uri);
$refs[] = $id;
if (isset($files[$id])) {
$files[$id]['source_ids'] = array_values(array_unique(array_merge($files[$id]['source_ids'], [$sourceId])));
continue;
}
try {
$url = FileService::getFileUrl($uri);
$storage = FileService::getFileUrl();
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
$storageHost = strtolower((string) parse_url($storage, PHP_URL_HOST));
$assetPath = rawurldecode((string) parse_url($url, PHP_URL_PATH));
$publicHost = filter_var($host, FILTER_VALIDATE_IP)
? filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
: str_contains($host, '.') && !preg_match('/(?:^|\.)(?:localhost|local|internal)$/i', $host);
$valid = in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
&& $host !== '' && $storageHost !== '' && hash_equals($storageHost, $host)
&& $publicHost && preg_match('#(?:^|/)uploads/#', $assetPath)
&& !preg_match('#(?:^|/)\.\.(?:/|$)|[\\\\\x00]#', $assetPath)
&& !parse_url($url, PHP_URL_USER) && !parse_url($url, PHP_URL_PASS);
} catch (\Throwable $e) {
$url = '';
$valid = false;
}
$path = strtolower((string) parse_url($uri, PHP_URL_PATH));
$extension = pathinfo($path, PATHINFO_EXTENSION);
$type = in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tif', 'tiff'], true) ? 'image' : 'document';
if (in_array($extension, ['mp3', 'wav', 'm4a', 'mp4', 'mov', 'ogg'], true)) {
$type = 'unsupported';
}
$hash = is_array($attachment) ? (string) ($attachment['sha256'] ?? $attachment['content_hash'] ?? '') : '';
$hash = preg_match('/^[a-f0-9]{64}$/i', $hash) ? strtolower($hash) : null;
$files[$id] = ['file_id' => $id, 'source_ids' => [$sourceId], 'type' => $type, 'transfer_method' => 'remote_url',
'url' => $valid ? $url : '', 'status' => $valid ? 'pending' : 'restricted', 'content_hash' => $hash,
'version_verified' => false, 'purpose' => str_contains((string) $key, 'tongue') ? 'tongue_image' : 'clinical_attachment'];
if (!$valid) {
$missing[] = self::gap($id, 'FILE_STORAGE_AUTHORIZATION_UNVERIFIED', true);
}
// Metadata hashes alone do not prove the remote URL still serves those bytes.
$missing[] = self::gap($id, 'FILE_CONTENT_VERSION_UNVERIFIED');
}
$item = ['evidence_file_ids' => $refs];
}
unset($item);
}
private static function decode($value): array
{
if (is_array($value)) {
return array_is_list($value) ? $value : [$value];
}
if (!is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
if (is_array($decoded)) {
return array_is_list($decoded) ? $decoded : [$decoded];
}
return preg_split('/[,\r\n]+/u', is_string($decoded) ? $decoded : $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
private static function gap(string $source, string $code, bool $critical = false): array
{
return ['source_id' => $source, 'code' => $code, 'critical' => $critical];
}
private static function json($value): string
{
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
}