devsofttech_hms_ci4/app/Commands/ProcessSnomedCoding.php
DevSoftTech Server a56e8a5776 feat: SNOMED background coding pipeline + ABDM Coding Panel
- Add opd_coding_queue + opd_snomed_suggestions migrations
- Add ProcessSnomedCoding spark command (cron: snomed:process-coding)
  Extracts clinical phrases, matches local SNOMED FULLTEXT, saves suggestions
- Add AbdmCodingPanel controller: index/review/confirm/reject/correct/mark_fhir_ready/tip_check
- Add coding_panel + coding_panel_review views (Bootstrap 5, AJAX actions)
- Inject queue trigger in opd_prescription_save (fail-open, ON DUPLICATE KEY)
- Add consult form tip badge: polls tip_check 4s after save, shows review link
- Add AbdmCodingPanel routes behind abdm permission filter
- Various ABDM gateway, FHIR, ABHA, SNOMED library updates
2026-05-19 20:40:27 +05:30

361 lines
13 KiB
PHP

<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
/**
* Spark command: php spark snomed:process-coding
*
* Runs from cron every minute:
* * * * * * cd /var/www/html && php spark snomed:process-coding >> /dev/null 2>&1
*
* Picks pending rows from opd_coding_queue, extracts clinical phrases from
* free-text OPD fields, queries local SNOMED FULLTEXT index, saves
* suggestions into opd_snomed_suggestions for staff review in ABDM Coding Panel.
*/
class ProcessSnomedCoding extends BaseCommand
{
protected $group = 'ABDM';
protected $name = 'snomed:process-coding';
protected $description = 'Process pending OPD SNOMED coding queue — extract phrases, match SNOMED codes, save suggestions.';
/** Max records per run (keep cron fast) */
private const BATCH = 5;
/** Source fields to process and their SNOMED semantic tag preferences */
private const FIELD_TAGS = [
'complaints' => ['finding', 'disorder', 'symptom', 'observable entity'],
'diagnosis' => ['disorder', 'finding', 'disease'],
'Provisional_diagnosis' => ['disorder', 'finding', 'disease'],
'Finding_Examinations' => ['finding', 'observable entity', 'procedure'],
];
public function run(array $params): void
{
$db = \Config\Database::connect();
// Claim a batch atomically (status = pending → processing)
$rows = $db->table('opd_coding_queue')
->where('status', 'pending')
->orderBy('queued_at', 'ASC')
->limit(self::BATCH)
->get()
->getResultArray();
if (empty($rows)) {
CLI::write('[snomed:process-coding] No pending items.', 'green');
return;
}
foreach ($rows as $qRow) {
$queueId = (int) $qRow['id'];
$opdId = (int) $qRow['opd_id'];
$sessionId = (int) $qRow['opd_session_id'];
// Mark processing to prevent parallel workers grabbing same row
$db->table('opd_coding_queue')
->where('id', $queueId)
->where('status', 'pending') // optimistic lock
->update(['status' => 'processing']);
if ($db->affectedRows() === 0) {
// Another worker claimed it — skip
continue;
}
CLI::write("[snomed:process-coding] Processing queue #{$queueId} opd_id={$opdId} session={$sessionId}");
try {
$prescription = $db->table('opd_prescription')
->where('id', $sessionId)
->where('opd_id', $opdId)
->get(1)
->getRowArray();
if (empty($prescription)) {
throw new \RuntimeException("opd_prescription row not found for session {$sessionId}");
}
$suggestionCount = $this->processPrescription($db, $opdId, $sessionId, $prescription);
$db->table('opd_coding_queue')
->where('id', $queueId)
->update([
'status' => 'done',
'has_suggestions' => $suggestionCount > 0 ? 1 : 0,
'processed_at' => date('Y-m-d H:i:s'),
'error_message' => null,
]);
CLI::write(" → {$suggestionCount} suggestions saved.", 'green');
} catch (\Throwable $e) {
$db->table('opd_coding_queue')
->where('id', $queueId)
->update([
'status' => 'failed',
'processed_at' => date('Y-m-d H:i:s'),
'error_message' => substr($e->getMessage(), 0, 500),
]);
CLI::write(" → FAILED: " . $e->getMessage(), 'red');
}
}
CLI::write('[snomed:process-coding] Done.', 'green');
}
// -------------------------------------------------------------------------
/**
* Process one OPD prescription row — extract phrases from all relevant
* fields and find SNOMED matches.
*
* @return int Number of suggestions inserted
*/
private function processPrescription(\CodeIgniter\Database\BaseConnection $db, int $opdId, int $sessionId, array $prescription): int
{
$inserted = 0;
// Delete any stale suggestions for this session (re-process is safe)
$db->table('opd_snomed_suggestions')
->where('opd_session_id', $sessionId)
->where('status', 'pending_review')
->delete();
foreach (self::FIELD_TAGS as $field => $preferredTags) {
$text = trim((string) ($prescription[$field] ?? ''));
if ($text === '') {
continue;
}
$phrases = $this->extractPhrases($text);
foreach ($phrases as $phrase) {
$matches = $this->searchSnomed($db, $phrase, $preferredTags, 3);
foreach ($matches as $idx => $match) {
// Skip very low confidence
if ($match['confidence'] < 0.15) {
continue;
}
$db->table('opd_snomed_suggestions')->insert([
'opd_id' => $opdId,
'opd_session_id' => $sessionId,
'source_field' => $field,
'source_phrase' => substr($phrase, 0, 500),
'concept_id' => $match['concept_id'],
'snomed_term' => substr($match['term'], 0, 500),
'semantic_tag' => substr($match['semantic_tag'], 0, 100),
'confidence' => round($match['confidence'], 3),
'status' => 'pending_review',
'created_at' => date('Y-m-d H:i:s'),
]);
$inserted++;
}
}
}
return $inserted;
}
/**
* Extract meaningful clinical phrases from free-text.
* Splits on delimiters, filters noise, deduplicates.
*
* @return string[]
*/
private function extractPhrases(string $text): array
{
// Strip NABH structured lines if present (Drug Allergy Status etc.)
$text = preg_replace('/^(Drug Allergy Status|Drug Allergy Details|ADR History|Current Medications)[^\n]*\n?/im', '', $text);
// Normalise separators
$text = str_replace(["\r\n", "\r"], "\n", $text);
$text = preg_replace('/\s*[,;\/|]\s*/', "\n", $text);
$text = preg_replace('/\s+and\s+|\s+with\s+/i', "\n", $text);
$lines = explode("\n", $text);
$phrases = [];
foreach ($lines as $line) {
// Also split on sentence end inside a line
$parts = preg_split('/\.\s+/', $line);
foreach ($parts as $part) {
$part = trim(preg_replace('/\s+/', ' ', $part) ?? $part);
// Filter by length
if (mb_strlen($part) < 3 || mb_strlen($part) > 150) {
continue;
}
// Skip pure numeric or very short
if (preg_match('/^\d+$/', $part)) {
continue;
}
$key = mb_strtolower($part);
$phrases[$key] = $part;
}
}
return array_values($phrases);
}
/**
* Search local SNOMED FULLTEXT for a phrase.
* Returns top $limit results with confidence scores.
*
* @return array<int,array{concept_id:string,term:string,semantic_tag:string,confidence:float}>
*/
private function searchSnomed(\CodeIgniter\Database\BaseConnection $db, string $phrase, array $preferredTags, int $limit): array
{
if (trim($phrase) === '') {
return [];
}
$safeTerm = str_replace(['"', "'", '\\'], ' ', $phrase);
$safeTerm = trim($safeTerm);
if ($safeTerm === '') {
return [];
}
try {
// Try FULLTEXT boolean mode first
$boolTerm = '+' . implode(' +', array_filter(explode(' ', $safeTerm)));
$sql = "SELECT
sd.conceptId AS concept_id,
sd.term,
MATCH(sd.term) AGAINST(? IN BOOLEAN MODE) AS ft_score
FROM snomed_description sd
WHERE sd.active = '1'
AND sd.typeId IN ('900000000000003001','900000000000013009')
AND MATCH(sd.term) AGAINST(? IN BOOLEAN MODE)
ORDER BY ft_score DESC
LIMIT ?";
$results = $db->query($sql, [$boolTerm, $boolTerm, $limit * 4])->getResultArray();
if (empty($results)) {
// Fallback to natural language mode
$sql2 = "SELECT
sd.conceptId AS concept_id,
sd.term,
MATCH(sd.term) AGAINST(? IN NATURAL LANGUAGE MODE) AS ft_score
FROM snomed_description sd
WHERE sd.active = '1'
AND sd.typeId IN ('900000000000003001','900000000000013009')
AND MATCH(sd.term) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY ft_score DESC
LIMIT ?";
$results = $db->query($sql2, [$safeTerm, $safeTerm, $limit * 4])->getResultArray();
}
} catch (\Throwable $e) {
return [];
}
if (empty($results)) {
return [];
}
// Enrich with semantic tag and score
$conceptIds = array_unique(array_column($results, 'concept_id'));
$tagMap = $this->fetchSemanticTags($db, $conceptIds);
$scored = [];
$maxFt = (float) max(array_column($results, 'ft_score'));
if ($maxFt <= 0) {
$maxFt = 1;
}
foreach ($results as $row) {
$conceptId = (string) $row['concept_id'];
$term = (string) $row['term'];
$ftScore = (float) $row['ft_score'];
$semanticTag = $tagMap[$conceptId] ?? '';
// Normalised FT score
$ftNorm = $ftScore / $maxFt;
// Term similarity bonus (word overlap)
$phraseWords = array_filter(explode(' ', mb_strtolower($phrase)));
$termWords = array_filter(explode(' ', mb_strtolower($term)));
$overlap = count(array_intersect($phraseWords, $termWords));
$simBonus = $overlap / max(count($phraseWords), 1) * 0.3;
// Preferred semantic tag bonus
$tagBonus = 0.0;
$tagLower = mb_strtolower($semanticTag);
foreach ($preferredTags as $preferred) {
if (str_contains($tagLower, mb_strtolower($preferred))) {
$tagBonus = 0.2;
break;
}
}
$confidence = min(1.0, $ftNorm * 0.5 + $simBonus + $tagBonus);
$scored[] = [
'concept_id' => $conceptId,
'term' => $term,
'semantic_tag' => $semanticTag,
'confidence' => $confidence,
];
}
// De-duplicate by concept_id, keeping highest confidence
$best = [];
foreach ($scored as $item) {
$cid = $item['concept_id'];
if (!isset($best[$cid]) || $item['confidence'] > $best[$cid]['confidence']) {
$best[$cid] = $item;
}
}
// Sort descending
usort($best, fn($a, $b) => $b['confidence'] <=> $a['confidence']);
return array_slice(array_values($best), 0, $limit);
}
/**
* Fetch semantic tags for an array of conceptIds.
* Parses parenthesised tag from FSN term e.g. "Fever (finding)" → "finding"
*
* @param string[] $conceptIds
* @return array<string,string>
*/
private function fetchSemanticTags(\CodeIgniter\Database\BaseConnection $db, array $conceptIds): array
{
if (empty($conceptIds)) {
return [];
}
$placeholders = implode(',', array_fill(0, count($conceptIds), '?'));
try {
$rows = $db->query(
"SELECT conceptId, term FROM snomed_description
WHERE conceptId IN ({$placeholders})
AND typeId = '900000000000003001'
AND active = '1'
LIMIT " . (count($conceptIds) * 2),
$conceptIds
)->getResultArray();
} catch (\Throwable $e) {
return [];
}
$map = [];
foreach ($rows as $row) {
if (preg_match('/\(([^)]+)\)\s*$/', (string) $row['term'], $m)) {
$map[(string) $row['conceptId']] = $m[1];
}
}
return $map;
}
}