feat: complaint Extract Codes button with SNOMED chip review panel
- Add 'Extract Codes' button to complaint lookup input - Parse free-text with duration/severity extraction (regex: 3 days, moderate etc) - Call Opd_prescription/complaints_extract endpoint -> local SNOMED FULLTEXT - Render inline chip cards: term, concept_id, duration, severity, confidence% - Doctor clicks confirm (green tick) or reject (red x) per chip in seconds - Confirm pre-fills duration+severity in complaint table row automatically - 'Confirm All' bulk action; Dismiss clears panel - Add complaints_extract controller method + parseComplaintFreeText helper - Add route Opd_prescription/complaints_extract
This commit is contained in:
parent
83685d98be
commit
b702ff6fdc
|
|
@ -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)', '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/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_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/section_recent_entries', 'Opd_prescription::section_recent_entries');
|
||||||
$routes->get('Opd_prescription/provisional_diagnosis_search', 'Opd_prescription::provisional_diagnosis_search');
|
$routes->get('Opd_prescription/provisional_diagnosis_search', 'Opd_prescription::provisional_diagnosis_search');
|
||||||
$routes->get('Opd_prescription/diagnosis_search', 'Opd_prescription::provisional_diagnosis_search');
|
$routes->get('Opd_prescription/diagnosis_search', 'Opd_prescription::provisional_diagnosis_search');
|
||||||
|
|
|
||||||
|
|
@ -4489,6 +4489,112 @@ class Opd_prescription extends BaseController
|
||||||
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
|
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<int,array{term:string,duration:string,severity:string,original:string}>
|
||||||
|
*/
|
||||||
|
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()
|
public function provisional_diagnosis_search()
|
||||||
{
|
{
|
||||||
$q = trim((string) $this->request->getGet('q'));
|
$q = trim((string) $this->request->getGet('q'));
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,33 @@
|
||||||
background: #2b6cb0;
|
background: #2b6cb0;
|
||||||
color: #fff;
|
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 {
|
.btn-complaints-mic {
|
||||||
border-color: #13795b;
|
border-color: #13795b;
|
||||||
color: #13795b;
|
color: #13795b;
|
||||||
|
|
@ -831,7 +858,9 @@
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" class="form-control" id="complaint_lookup"
|
<input type="text" class="form-control" id="complaint_lookup"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder="Type symptom: headache, fever, chest pain...">
|
placeholder="Type symptom or free text: headache 3 days moderate, fever 1 day...">
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="btn_extract_complaint_codes"
|
||||||
|
title="Extract SNOMED codes from typed text with duration & severity">Extract Codes</button>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-save-autotype-keyword"
|
<button type="button" class="btn btn-outline-secondary btn-save-autotype-keyword"
|
||||||
data-section="complaints" data-target="complaints" title="Save as custom keyword">+keyword</button>
|
data-section="complaints" data-target="complaints" title="Save as custom keyword">+keyword</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -839,6 +868,16 @@
|
||||||
style="display:none;position:absolute;left:0;right:0;top:100%;z-index:1060;max-height:260px;overflow-y:auto;"></div>
|
style="display:none;position:absolute;left:0;right:0;top:100%;z-index:1060;max-height:260px;overflow-y:auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SNOMED Extract chips panel -->
|
||||||
|
<div id="complaint_extract_panel" class="mb-2 p-2 border rounded" style="display:none;background:#f8faff;">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<span class="small fw-semibold text-primary">💡 Code Suggestions</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-success py-0 px-2" id="btn_confirm_all_chips" style="font-size:.75rem">✓ Confirm All</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="btn_dismiss_extract" style="font-size:.75rem">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
<div id="complaint_chips_wrap" class="d-flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Complaint rows table -->
|
<!-- Complaint rows table -->
|
||||||
<table class="table table-sm table-bordered align-middle mb-1" id="complaint_table" style="display:none;font-size:.82rem">
|
<table class="table table-sm table-bordered align-middle mb-1" id="complaint_table" style="display:none;font-size:.82rem">
|
||||||
<thead class="table-light" style="font-size:.75rem">
|
<thead class="table-light" style="font-size:.75rem">
|
||||||
|
|
@ -4757,6 +4796,106 @@
|
||||||
return true;
|
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('<span class="text-muted small">Searching SNOMED…</span>');
|
||||||
|
|
||||||
|
$.get('<?= base_url('Opd_prescription/complaints_extract') ?>', { q: text })
|
||||||
|
.done(function (data) {
|
||||||
|
$btn.prop('disabled', false).text('Extract Codes');
|
||||||
|
var rows = (data && data.rows) ? data.rows : [];
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
$wrap.html('<span class="text-muted small">No SNOMED match. Added as free text.</span>');
|
||||||
|
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 = $('<span class="snomed-chip"></span>');
|
||||||
|
$chip.append($('<span class="chip-term"></span>').text(r.term || r.snomed_term || ''));
|
||||||
|
if (r.concept_id) {
|
||||||
|
$chip.append($('<span class="chip-code"></span>').text(' ' + r.concept_id));
|
||||||
|
}
|
||||||
|
if (meta) {
|
||||||
|
$chip.append($('<span class="chip-meta"></span>').text(' · ' + meta));
|
||||||
|
}
|
||||||
|
if (pct > 0) {
|
||||||
|
$chip.append($('<span class="chip-conf"></span>').text(pct + '%'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var $bConfirm = $('<button type="button" class="chip-btn chip-btn-confirm" title="Add complaint">✓</button>');
|
||||||
|
var $bReject = $('<button type="button" class="chip-btn chip-btn-reject" title="Dismiss">✕</button>');
|
||||||
|
|
||||||
|
$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('<span class="text-danger small">Search failed.</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#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) ─────
|
// ─── Per-complaint inline editor — stubs (table replaced the panel) ─────
|
||||||
function openComplaintInlineEditor(idx) {
|
function openComplaintInlineEditor(idx) {
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user