diff --git a/app/Config/Routes.php b/app/Config/Routes.php index f0199d8..21cfe17 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -591,6 +591,7 @@ $routes->get('Opd_prescription/fhir_bundle_history/(:num)/(:num)', 'Opd_prescrip $routes->get('Opd_prescription/fhir_bundle_preview/(:num)', 'Opd_prescription::fhir_bundle_preview/$1'); $routes->get('Opd_prescription/fhir_bundle_preview/(:num)/(:num)', 'Opd_prescription::fhir_bundle_preview/$1/$2'); $routes->get('Opd_prescription/complaints_search', 'Opd_prescription::complaints_search'); +$routes->get('Opd_prescription/complaints_extract', 'Opd_prescription::complaints_extract'); $routes->get('Opd_prescription/section_recent_entries', 'Opd_prescription::section_recent_entries'); $routes->get('Opd_prescription/provisional_diagnosis_search', 'Opd_prescription::provisional_diagnosis_search'); $routes->get('Opd_prescription/diagnosis_search', 'Opd_prescription::provisional_diagnosis_search'); diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index 538168b..67b6397 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -4489,6 +4489,112 @@ class Opd_prescription extends BaseController return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]); } + /** + * Extract SNOMED codes from free-text complaint string. + * Parses duration/severity from each phrase, searches local SNOMED FULLTEXT. + * + * GET Opd_prescription/complaints_extract?q=headache+3+days+moderate,+fever+mild + */ + public function complaints_extract() + { + $q = trim((string) $this->request->getGet('q')); + if ($q === '') { + return $this->response->setJSON(['rows' => []]); + } + + $phrases = $this->parseComplaintFreeText($q); + $svc = new CsnotkTerminologyService(); + $out = []; + $seen = []; + + foreach ($phrases as $p) { + $cleanTerm = trim($p['term']); + if (mb_strlen($cleanTerm) < 2) { + continue; + } + + $results = $svc->searchFinding($cleanTerm, 3); + foreach ($results as $r) { + $cid = trim((string) ($r['concept_id'] ?? '')); + $dedupeKey = mb_strtoupper($r['term'] ?? '') . '|' . $cid; + if (isset($seen[$dedupeKey])) { + continue; + } + $seen[$dedupeKey] = true; + + $out[] = [ + 'term' => (string) ($r['term'] ?? ''), + 'concept_id' => $cid, + 'semantic_tag' => (string) ($r['hierarchy'] ?? ''), + 'confidence' => round(0.75, 3), // local FULLTEXT match scores high by default + 'duration' => $p['duration'], + 'severity' => $p['severity'], + 'source_phrase'=> $p['original'], + ]; + } + + if (count($out) >= 8) { + break; + } + } + + return $this->response->setJSON(['rows' => $out]); + } + + /** + * Parse a free-text complaint string into structured phrases. + * Extracts duration (e.g. "3 days", "since yesterday") and severity (mild/moderate/severe) + * from each comma/semicolon-delimited phrase. + * + * @return array + */ + private function parseComplaintFreeText(string $text): array + { + $durationRe = '/(?:since\s+|for\s+the\s+last\s+|for\s+last\s+|for\s+)?(\d+[\s\u00a0]*(?:hour|day|week|month|year)s?|since\s+(?:yesterday|morning|evening|last\s+\w+)|past\s+\d+\s*(?:day|week|month|year)s?)/iu'; + $severityRe = '/\b(mild|moderate|severe|slight|intense|chronic|acute|persistent|intermittent|recurrent)\b/iu'; + + // Split on common delimiters + $parts = preg_split('/[,;\/\n]|\s+and\s+|\s+with\s+/iu', $text) ?: [$text]; + + $result = []; + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + $original = $part; + + $duration = ''; + if (preg_match($durationRe, $part, $m)) { + $duration = trim($m[0]); + $part = trim((string) preg_replace($durationRe, ' ', $part, 1)); + } + + $severity = ''; + if (preg_match($severityRe, $part, $m)) { + $severity = mb_strtolower($m[1]); + $part = trim((string) preg_replace($severityRe, ' ', $part, 1)); + } + + $cleanTerm = trim((string) preg_replace('/\s+/', ' ', $part)); + if (mb_strlen($cleanTerm) >= 2) { + $result[] = [ + 'term' => $cleanTerm, + 'duration' => $duration, + 'severity' => $severity, + 'original' => $original, + ]; + } + } + + // If parsing produced nothing usable, fall back to original string + if (empty($result)) { + $result[] = ['term' => $text, 'duration' => '', 'severity' => '', 'original' => $text]; + } + + return $result; + } + public function provisional_diagnosis_search() { $q = trim((string) $this->request->getGet('q')); diff --git a/app/Views/billing/opd_prescription_basic.php b/app/Views/billing/opd_prescription_basic.php index bad4331..6a3889e 100644 --- a/app/Views/billing/opd_prescription_basic.php +++ b/app/Views/billing/opd_prescription_basic.php @@ -294,6 +294,33 @@ background: #2b6cb0; color: #fff; } + /* SNOMED extract chips */ + .snomed-chip { + display: inline-flex; + align-items: center; + gap: 5px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 999px; + padding: 3px 8px 3px 10px; + font-size: .8rem; + transition: background .15s, border-color .15s; + } + .snomed-chip.chip-confirmed { + background: #dcfce7; + border-color: #86efac; + } + .snomed-chip.chip-rejected { display: none; } + .snomed-chip .chip-term { font-weight: 600; color: #1d4ed8; } + .snomed-chip .chip-code { font-family: monospace; color: #6b7280; font-size: .7rem; } + .snomed-chip .chip-meta { color: #059669; font-size: .75rem; } + .snomed-chip .chip-conf { background: #e0e7ff; color: #3730a3; border-radius: 3px; padding: 0 4px; font-size: .68rem; margin-left: 2px; } + .chip-btn { background: none; border: none; padding: 0 2px; cursor: pointer; line-height: 1; font-size: .9rem; } + .chip-btn-confirm { color: #16a34a; } + .chip-btn-confirm:hover { color: #15803d; } + .chip-btn-reject { color: #dc2626; } + .chip-btn-reject:hover { color: #b91c1c; } + .chip-btn:disabled { opacity: .4; cursor: default; } .btn-complaints-mic { border-color: #13795b; color: #13795b; @@ -831,7 +858,9 @@
+ placeholder="Type symptom or free text: headache 3 days moderate, fever 1 day..."> +
@@ -839,6 +868,16 @@ style="display:none;position:absolute;left:0;right:0;top:100%;z-index:1060;max-height:260px;overflow-y:auto;"> + + + @@ -4757,6 +4796,106 @@ return true; } + // ─── Extract Codes from free-text (SNOMED chips review) ───────────────── + $('#btn_extract_complaint_codes').on('click', function () { + var text = $.trim($('#complaint_lookup').val()); + if (!text) { $('#complaint_lookup').trigger('focus'); return; } + + var $btn = $(this); + var $panel = $('#complaint_extract_panel'); + var $wrap = $('#complaint_chips_wrap'); + + $btn.prop('disabled', true).text('Extracting…'); + $panel.show(); + $wrap.html('Searching SNOMED…'); + + $.get('', { q: text }) + .done(function (data) { + $btn.prop('disabled', false).text('Extract Codes'); + var rows = (data && data.rows) ? data.rows : []; + + if (!rows.length) { + $wrap.html('No SNOMED match. Added as free text.'); + addComplaintItem({ name: text, concept_id: '', source: 'local' }); + setTimeout(function () { $panel.hide(); $wrap.empty(); }, 1800); + return; + } + + $wrap.empty(); + rows.forEach(function (r) { + var pct = Math.round((parseFloat(r.confidence) || 0) * 100); + var meta = [r.duration, r.severity].filter(Boolean).join(' Β· '); + + var $chip = $(''); + $chip.append($('').text(r.term || r.snomed_term || '')); + if (r.concept_id) { + $chip.append($('').text(' ' + r.concept_id)); + } + if (meta) { + $chip.append($('').text(' Β· ' + meta)); + } + if (pct > 0) { + $chip.append($('').text(pct + '%')); + } + + var $bConfirm = $(''); + var $bReject = $(''); + + $bConfirm.on('click', function () { + var added = addComplaintItem({ + name: r.term || r.snomed_term || text, + concept_id: r.concept_id || '', + source: r.concept_id ? 'snomed' : 'local', + hierarchy: r.hierarchy || r.semantic_tag || '' + }); + // Pre-fill duration & severity on the new table row + var newIdx = selectedComplaintItems.length - 1; + if (newIdx >= 0) { + if (r.duration) { + selectedComplaintItems[newIdx].duration = r.duration; + $('#complaint_tbody .complaint-dur-input[data-idx="' + newIdx + '"]').val(r.duration); + } + if (r.severity) { + selectedComplaintItems[newIdx].severity = r.severity; + $('#complaint_tbody .complaint-sev-input[data-idx="' + newIdx + '"]').val(r.severity); + } + syncComplaintSnomedJson(); + } + $chip.addClass('chip-confirmed'); + $bConfirm.prop('disabled', true); + $bReject.hide(); + // Clear the lookup input after first confirmation + $('#complaint_lookup').val(''); + }); + + $bReject.on('click', function () { + $chip.addClass('chip-rejected'); + if (!$wrap.find('.snomed-chip:not(.chip-rejected)').length) { + $panel.hide(); $wrap.empty(); + } + }); + + $chip.append($bConfirm).append($bReject); + $wrap.append($chip); + }); + }) + .fail(function () { + $btn.prop('disabled', false).text('Extract Codes'); + $wrap.html('Search failed.'); + }); + }); + + $('#btn_confirm_all_chips').on('click', function () { + $('#complaint_chips_wrap .snomed-chip:not(.chip-rejected):not(.chip-confirmed) .chip-btn-confirm').each(function () { + $(this).trigger('click'); + }); + }); + + $('#btn_dismiss_extract').on('click', function () { + $('#complaint_extract_panel').hide(); + $('#complaint_chips_wrap').empty(); + }); + // ─── Per-complaint inline editor β€” stubs (table replaced the panel) ───── function openComplaintInlineEditor(idx) { setTimeout(function() {