devsofttech_hms_ci4/app/Libraries/CsnotkTerminologyService.php
DevSoftTech Server 5d6acdd480 Implement ABDM bridge health record flows
- add facility QR support and OPD queue bridge fixes
- support care-context push warnings for lab, radiology, IPD and remaining HI types
- improve ABHA patient matching and diagnosis autocomplete performance
- clean up webcam streams on page navigation
2026-07-20 03:02:09 +05:30

587 lines
22 KiB
PHP

<?php
namespace App\Libraries;
use CodeIgniter\Database\BaseConnection;
/**
* SNOMED CT terminology service.
* Priority: local DB (snomed_description + snomed_concept tables).
* Falls back to external csnotk.e-atria.in API only when local tables are absent.
*/
class CsnotkTerminologyService
{
/** SNOMED type_id for Fully Specified Names (ends with semantic tag in parentheses) */
private const FSN_TYPE_ID = '900000000000003001';
private string $baseUrl;
private int $timeoutSec;
private bool $apiEnabled;
private ?BaseConnection $db = null;
private bool $localAvailable = false;
private bool $hasFtIndex = false;
public function __construct()
{
$baseUrl = trim((string) env('snomed.csnotk.baseUrl', ''));
if ($baseUrl === '') {
$baseUrl = trim((string) env('CSNOTK_BASE_URL', 'https://csnotk.e-atria.in'));
}
$this->baseUrl = rtrim($baseUrl, '/');
$this->timeoutSec = max(1, (int) env('snomed.csnotk.timeoutSec', 5));
$enabledRaw = strtolower(trim((string) env('snomed.csnotk.enabled', env('CSNOTK_ENABLED', ''))));
$this->apiEnabled = in_array($enabledRaw, ['1', 'true', 'yes', 'on'], true) || $this->baseUrl !== '';
try {
$db = db_connect();
if ($db->tableExists('snomed_description') && $db->tableExists('snomed_concept')) {
$this->db = $db;
$this->localAvailable = true;
$this->hasFtIndex = $this->checkFtIndex($db);
}
} catch (\Throwable $e) {
// Local DB not available — will fall through to API
}
}
public function isEnabled(): bool
{
return $this->localAvailable || ($this->apiEnabled && $this->baseUrl !== '');
}
// ─── Public search API ────────────────────────────────────────────────
/**
* General diagnosis search (findings + disorders).
* @return array<int, array{concept_id:string,term:string,source:string}>
*/
public function searchDiagnosis(string $query, int $limit = 20): array
{
$q = trim($query);
if ($q === '') {
return [];
}
$limit = max(1, min(50, $limit));
// Local DB is primary — ~17ms vs ~330ms for external API from server
$rows = $this->localSearch($q, $limit, ['finding', 'disorder', 'morphologic abnormality', 'disease']);
if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
$rows = $this->mergeFallback($rows, $this->apiSearchDiagnosis($q, $limit), $limit);
}
return array_map(fn ($r) => [
'concept_id' => (string) ($r['concept_id'] ?? ''),
'term' => (string) ($r['term'] ?? ''),
'source' => (string) ($r['source'] ?? 'local'),
], array_slice($rows, 0, $limit));
}
/**
* Search for clinical findings / symptoms / disorders (for chief complaints autocomplete).
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
*/
public function searchFinding(string $query, int $limit = 20): array
{
$q = trim($query);
if ($q === '') {
return [];
}
$limit = max(1, min(50, $limit));
// Local DB is primary — ~17ms vs ~330ms for external API from server
$rows = $this->localSearch($q, $limit, ['finding', 'disorder']);
if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
$rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['finding', 'disorder']), $limit);
}
return array_slice($rows, 0, $limit);
}
/**
* Search for SNOMED CT substance / pharmaceutical product concepts.
* Used for medicine SNOMED CT coding on the opd_medicince page.
*
* Semantic tags searched: substance, product, medicinal product
* Local DB first (snomed_description), falls back to CSNOtk API.
*
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
*/
public function searchSubstance(string $query, int $limit = 15): array
{
$q = trim($query);
if ($q === '') {
return [];
}
$limit = max(1, min(50, $limit));
$substanceTags = ['substance', 'product', 'medicinal product', 'clinical drug'];
$rows = $this->localSearch($q, $limit, $substanceTags);
if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
// CSNOtk: search substance tag first, then product tag as fallback
$apiRows = $this->apiSearchBySemTag($q, $limit, ['substance', 'product']);
$rows = $this->mergeFallback($rows, $apiRows, $limit);
}
// If still empty try broader local search and filter post-hoc for drug-related terms
if (empty($rows) && $this->localAvailable) {
$broader = $this->localSearch($q, $limit, []);
$rows = array_filter($broader, static function (array $r) use ($substanceTags): bool {
$h = mb_strtolower((string) ($r['hierarchy'] ?? ''));
foreach ($substanceTags as $tag) {
if ($h === $tag || str_contains($h, $tag)) {
return true;
}
}
return false;
});
$rows = array_values($rows);
}
return array_slice($rows, 0, $limit);
}
/**
* Search for procedures / observable entities (for investigation advice autocomplete).
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
*/
public function searchProcedure(string $query, int $limit = 20): array
{
$q = trim($query);
if ($q === '') {
return [];
}
$limit = max(1, min(50, $limit));
// Local DB is primary — ~17ms vs ~330ms for external API from server
$rows = $this->localSearch($q, $limit, ['procedure', 'observable entity', 'regime/therapy']);
if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
$rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['procedure', 'observable entity']), $limit);
}
return array_slice($rows, 0, $limit);
}
// ─── Local DB search ──────────────────────────────────────────────────
/**
* Search local snomed_description.
* Uses FULLTEXT MATCH if index exists, falls back to prefix then substring LIKE.
*
* @param string[] $semanticTags e.g. ['finding','disorder'] — matched against FSN suffix
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
*/
private function localSearch(string $q, int $limit, array $semanticTags = []): array
{
if (! $this->localAvailable || $this->db === null) {
return [];
}
$fetchLimit = max($limit * 5, 100);
// 1. Try FULLTEXT
$dbRows = $this->hasFtIndex ? $this->localFtSearch($q, $fetchLimit) : [];
// 2. Try prefix on term_normalized (indexed)
if (empty($dbRows)) {
$dbRows = $this->localPrefixSearch($q, $fetchLimit);
}
// 3. Try substring (slower — full scan but catches mid-word)
if (empty($dbRows)) {
$dbRows = $this->localSubstringSearch($q, $fetchLimit);
}
return $this->filterAndFormat($dbRows, $semanticTags, $limit);
}
/**
* FULLTEXT MATCH ... AGAINST (Boolean Mode) search.
* @return array<int, array<string, string>>
*/
private function localFtSearch(string $q, int $limit): array
{
$words = preg_split('/\s+/', mb_strtolower(trim($q))) ?: [$q];
$ftWords = array_filter($words, fn ($w) => mb_strlen($w) >= 3);
if (empty($ftWords)) {
// FT cannot handle words shorter than innodb_ft_min_token_size (default 3)
return $this->localPrefixSearch($q, $limit);
}
$ftQuery = implode(' ', array_map(fn ($w) => '+' . $w . '*', $ftWords));
try {
return (array) $this->db->query(
"SELECT d.concept_id, d.term
FROM snomed_description d
INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1
WHERE d.active = 1
AND d.language_code = 'en'
AND d.type_id = ?
AND MATCH(d.term) AGAINST(? IN BOOLEAN MODE)
ORDER BY d.term ASC
LIMIT ?",
[self::FSN_TYPE_ID, $ftQuery, $limit]
)->getResultArray();
} catch (\Throwable $e) {
$this->hasFtIndex = false; // Index may not exist yet — disable for this request
return $this->localPrefixSearch($q, $limit);
}
}
/**
* Prefix search: term_normalized LIKE 'query%' (uses B-tree index).
* @return array<int, array<string, string>>
*/
private function localPrefixSearch(string $q, int $limit): array
{
$norm = mb_strtolower(trim($q));
try {
return (array) $this->db->query(
"SELECT d.concept_id, d.term
FROM snomed_description d
INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1
WHERE d.active = 1
AND d.language_code = 'en'
AND d.type_id = ?
AND d.term_normalized LIKE ?
ORDER BY d.term ASC
LIMIT ?",
[self::FSN_TYPE_ID, $norm . '%', $limit]
)->getResultArray();
} catch (\Throwable $e) {
return [];
}
}
/**
* Substring search: term_normalized LIKE '%query%' (full scan — slower).
* @return array<int, array<string, string>>
*/
private function localSubstringSearch(string $q, int $limit): array
{
$norm = mb_strtolower(trim($q));
try {
return (array) $this->db->query(
"SELECT d.concept_id, d.term
FROM snomed_description d
INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1
WHERE d.active = 1
AND d.language_code = 'en'
AND d.type_id = ?
AND d.term_normalized LIKE ?
ORDER BY d.term ASC
LIMIT ?",
[self::FSN_TYPE_ID, '%' . $norm . '%', $limit]
)->getResultArray();
} catch (\Throwable $e) {
return [];
}
}
/**
* Filter raw DB rows by semantic tag and format for output.
*
* @param array<int, array<string, string>> $dbRows
* @param string[] $semanticTags
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
*/
private function filterAndFormat(array $dbRows, array $semanticTags, int $limit): array
{
$tagsLower = array_map('mb_strtolower', $semanticTags);
$out = [];
$seen = [];
foreach ($dbRows as $row) {
if (count($out) >= $limit) {
break;
}
$conceptId = trim((string) ($row['concept_id'] ?? ''));
$fsn = trim((string) ($row['term'] ?? ''));
if ($conceptId === '' || $fsn === '') {
continue;
}
if (isset($seen[$conceptId])) {
continue;
}
// Extract semantic tag from FSN suffix: "Fever (finding)" → "finding"
$hierarchy = '';
$displayTerm = $fsn;
if (preg_match('/^(.*)\(([^)]+)\)$/', $fsn, $m)) {
$hierarchy = trim($m[2]);
$displayTerm = trim($m[1]);
}
// Apply semantic tag filter
if (! empty($tagsLower)) {
if (! in_array(mb_strtolower($hierarchy), $tagsLower, true)) {
continue;
}
}
$seen[$conceptId] = true;
$out[] = [
'concept_id' => $conceptId,
'term' => $displayTerm !== '' ? $displayTerm : $fsn,
'fsn' => $fsn,
'hierarchy' => $hierarchy,
'source' => 'local',
];
}
return $out;
}
// ─── External API fallbacks ───────────────────────────────────────────
/**
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
*/
private function apiSearchDiagnosis(string $q, int $limit): array
{
$rows = [];
$seen = [];
$search = $this->requestJson('/api/search/search', [
'term' => $q,
'state' => 'active',
'acceptability' => 'all',
'groupbyconcept' => 'true',
'fullconcept' => 'false',
'returnlimit' => (string) max(10, $limit * 2),
]);
foreach ($this->extractItems($search['data']) as $item) {
if (! is_array($item) || count($rows) >= $limit) {
break;
}
$conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']);
$term = $this->readString($item, ['term', 'conceptFsn', 'name']);
if (! preg_match('/^[0-9]{6,20}$/', $conceptId) || $term === '') {
continue;
}
if (isset($seen[$conceptId])) {
continue;
}
$seen[$conceptId] = true;
$rows[] = ['concept_id' => $conceptId, 'term' => $term, 'fsn' => $term, 'hierarchy' => '', 'source' => 'csnotk'];
}
return $rows;
}
/**
* @param string[] $semTags
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
*/
private function apiSearchBySemTag(string $q, int $limit, array $semTags): array
{
$rows = [];
$seen = [];
foreach ($semTags as $semTag) {
if (count($rows) >= $limit) {
break;
}
$res = $this->requestJson('/api/search/suggest', [
'term' => $q,
'state' => 'active',
'semantictag' => $semTag,
'acceptability' => 'preferred',
'returnlimit' => (string) max(10, $limit * 2),
]);
foreach ($this->extractItems($res['data']) as $item) {
if (count($rows) >= $limit) {
break;
}
$conceptId = '';
$term = '';
$fsn = '';
$hierarchy = $semTag;
if (is_string($item)) {
$term = trim($item);
} elseif (is_array($item)) {
$conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']);
$term = $this->readString($item, ['term', 'name']);
$fsn = $this->readString($item, ['conceptFsn', 'fsn', 'fullySpecifiedName']);
$hierarchy = $this->readString($item, ['hierarchy', 'semanticTag', 'semantictag']) ?: $semTag;
$activeStatus = $item['activeStatus'] ?? $item['active_status'] ?? null;
if ($activeStatus !== null && (int) $activeStatus !== 1) {
continue;
}
if (strtolower((string) ($item['conceptState'] ?? $item['concept_state'] ?? 'active')) === 'inactive') {
continue;
}
}
if ($term === '') {
continue;
}
$displayTerm = $fsn !== '' ? $fsn : $term;
$key = strtoupper($displayTerm) . '|' . $conceptId;
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$rows[] = [
'concept_id' => $conceptId,
'term' => $displayTerm,
'fsn' => $fsn,
'hierarchy' => $hierarchy,
'source' => 'csnotk',
];
}
}
return $rows;
}
// ─── Shared helpers ───────────────────────────────────────────────────
/**
* Merge primary results with fallback rows, deduplicating by concept_id.
*
* @param array<int, array<string, string>> $primary
* @param array<int, array<string, string>> $fallback
* @return array<int, array<string, string>>
*/
private function mergeFallback(array $primary, array $fallback, int $limit): array
{
$seen = [];
foreach ($primary as $r) {
$k = (string) ($r['concept_id'] ?? '');
if ($k !== '') {
$seen[$k] = true;
}
}
foreach ($fallback as $r) {
if (count($primary) >= $limit) {
break;
}
$k = (string) ($r['concept_id'] ?? '');
if ($k !== '' && isset($seen[$k])) {
continue;
}
if ($k !== '') {
$seen[$k] = true;
}
$primary[] = $r;
}
return $primary;
}
private function checkFtIndex(BaseConnection $db): bool
{
try {
$rows = $db->query(
"SHOW INDEX FROM snomed_description WHERE Index_type = 'FULLTEXT' AND Column_name = 'term'"
)->getResultArray();
return ! empty($rows);
} catch (\Throwable $e) {
return false;
}
}
/**
* @param array<string, mixed> $row
* @param array<int, string> $keys
*/
private function readString(array $row, array $keys): string
{
foreach ($keys as $key) {
if (! isset($row[$key])) {
continue;
}
$value = trim((string) $row[$key]);
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @param mixed $data
* @return array<int, mixed>
*/
private function extractItems($data): array
{
if (! is_array($data)) {
return [];
}
if ($this->isList($data)) {
return $data;
}
foreach (['rows', 'results', 'data', 'items'] as $key) {
if (isset($data[$key]) && is_array($data[$key])) {
return $this->isList($data[$key]) ? $data[$key] : [];
}
}
return [];
}
private function isList(array $arr): bool
{
return $arr === [] || array_keys($arr) === range(0, count($arr) - 1);
}
/**
* @param array<string, string> $query
* @return array{ok:bool,status:int,body:string,data:mixed}
*/
private function requestJson(string $path, array $query): array
{
if (! $this->apiEnabled || $this->baseUrl === '') {
return ['ok' => false, 'status' => 0, 'body' => '', 'data' => null];
}
$url = $this->baseUrl . $path;
$qs = http_build_query($query);
if ($qs !== '') {
$url .= (str_contains($url, '?') ? '&' : '?') . $qs;
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $this->timeoutSec,
CURLOPT_TIMEOUT => $this->timeoutSec,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false || $error !== '') {
return ['ok' => false, 'status' => $status, 'body' => '', 'data' => null];
}
$decoded = json_decode((string) $body, true);
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'body' => (string) $body,
'data' => $decoded,
];
}
}