713 lines
38 KiB
PHP
713 lines
38 KiB
PHP
<?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);
|
||
}
|
||
}
|