更新
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user