Files
ladill-care/app/Services/Care/PathwayMatcher.php
T
isaacclad 2ce4bc8993
Deploy Ladill Care / deploy (push) Successful in 1m26s
feat(assessments): layered clinical assessment engine end-to-end
Add a template-driven assessment system with universal intake, clinical
pathways, disease instruments (stroke MVP + extended, diabetes, and ten
specialty packs), scoring, patient outcome trends, REST/API + FHIR
export, and Enterprise org-level assessment analytics. Seed packs and
design/licensing docs ship for deploy and pre-GA review.
2026-07-16 22:58:09 +00:00

136 lines
4.0 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\ClinicalPathway;
use App\Models\Diagnosis;
use Illuminate\Support\Collection;
/**
* Match persisted diagnosis rows to clinical pathways (v1 algorithm).
*/
class PathwayMatcher
{
/**
* @param iterable<Diagnosis|array{code?: ?string, description?: ?string}> $diagnoses
* @return Collection<int, array{
* pathway: ClinicalPathway,
* pathway_code: string,
* pathway_name: string,
* rank: int,
* match_reason: string
* }>
*/
public function match(iterable $diagnoses): Collection
{
$dxList = collect($diagnoses)->map(function ($d) {
if ($d instanceof Diagnosis) {
return [
'code' => $d->code,
'description' => $d->description,
];
}
return [
'code' => $d['code'] ?? null,
'description' => $d['description'] ?? null,
];
})->filter(fn ($d) => filled($d['code']) || filled($d['description']));
if ($dxList->isEmpty()) {
return collect();
}
$pathways = ClinicalPathway::query()
->active()
->orderBy('sort_order')
->get();
$matches = [];
foreach ($pathways as $pathway) {
$rules = $pathway->match_rules ?? [];
$bestRank = null;
$bestReason = null;
foreach ($dxList as $dx) {
$result = $this->matchDiagnosis($dx, $rules);
if ($result === null) {
continue;
}
if ($bestRank === null || $result['rank'] > $bestRank) {
$bestRank = $result['rank'];
$bestReason = $result['reason'];
}
}
if ($bestRank !== null) {
$matches[] = [
'pathway' => $pathway,
'pathway_code' => $pathway->code,
'pathway_name' => $pathway->name,
'rank' => $bestRank,
'match_reason' => $bestReason,
];
}
}
return collect($matches)
->sort(function ($a, $b) {
if ($a['rank'] !== $b['rank']) {
return $b['rank'] <=> $a['rank'];
}
return $a['pathway']->sort_order <=> $b['pathway']->sort_order;
})
->values();
}
/**
* @param array{code?: ?string, description?: ?string} $dx
* @param array<string, mixed> $rules
* @return array{rank: int, reason: string}|null
*/
protected function matchDiagnosis(array $dx, array $rules): ?array
{
$desc = $this->normalize((string) ($dx['description'] ?? ''));
$codeStripped = $this->stripCode((string) ($dx['code'] ?? ''));
foreach ($rules['exclude_keywords'] ?? [] as $exclude) {
$ex = $this->normalize((string) $exclude);
if ($ex !== '' && $desc !== '' && str_contains($desc, $ex)) {
return null;
}
}
foreach ($rules['icd_prefixes'] ?? [] as $prefix) {
$p = $this->stripCode((string) $prefix);
if ($p !== '' && $codeStripped !== '' && str_starts_with($codeStripped, $p)) {
return ['rank' => 100, 'reason' => 'icd_prefix:'.$prefix];
}
}
foreach ($rules['keywords'] ?? [] as $keyword) {
$kw = $this->normalize((string) $keyword);
if ($kw !== '' && $desc !== '' && str_contains($desc, $kw)) {
return ['rank' => 50, 'reason' => 'keyword:'.$keyword];
}
}
return null;
}
protected function normalize(string $s): string
{
$s = mb_strtolower(trim($s));
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
return $s;
}
protected function stripCode(string $code): string
{
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? '');
}
}