This commit is contained in:
Your Name
2026-09-10 15:19:17 +08:00
parent 27fbef9321
commit 36975c6c1b
487 changed files with 15696 additions and 78 deletions
@@ -0,0 +1,712 @@
<?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);
}
}