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)/(: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');
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
{
|
||||
$q = trim((string) $this->request->getGet('q'));
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control" id="complaint_lookup"
|
||||
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"
|
||||
data-section="complaints" data-target="complaints" title="Save as custom keyword">+keyword</button>
|
||||
</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>
|
||||
</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 -->
|
||||
<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">
|
||||
|
|
@ -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('<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) ─────
|
||||
function openComplaintInlineEditor(idx) {
|
||||
setTimeout(function() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user