Skip to content

Commit 997561e

Browse files
Merge pull request #1396 from delicatacurtis/patch-18
Create RecordMatcherService.php
2 parents 3118f8b + 89f6ebe commit 997561e

File tree

1 file changed

+188
-13
lines changed

1 file changed

+188
-13
lines changed
Lines changed: 188 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,198 @@
11
<?php
22

3-
namespace App\Services\RecordMatcher\Providers;
3+
namespace App\Services\RecordMatcher;
44

5-
use App\Models\Person;
5+
use App\Models\AIMatchModel;
6+
use App\Models\AISuggestedMatch;
7+
use Illuminate\Support\Str;
68

7-
class ExampleProvider implements ExternalRecordProviderInterface
9+
class RecordMatcherService
810
{
9-
public function search($localPerson): array
11+
protected array $weights;
12+
13+
public function __construct()
14+
{
15+
$this->loadWeights();
16+
}
17+
18+
protected function loadWeights(): void
19+
{
20+
$model = AIMatchModel::orderBy('id', 'desc')->first();
21+
$this->weights = $model?->weights ?? [
22+
'first_name' => 1.0,
23+
'last_name' => 1.0,
24+
'birth_year' => 0.8,
25+
'birth_place' => 0.6,
26+
'parents' => 0.9,
27+
];
28+
}
29+
30+
/**
31+
* Predict matches for a local person given a set of external candidates.
32+
*
33+
* @param \App\Models\Person|int $localPerson
34+
* @param array $candidates
35+
* @return array array of ['candidate' => array, 'score' => float]
36+
*/
37+
public function scoreCandidates($localPerson, array $candidates): array
38+
{
39+
// Normalize local person record
40+
$person = is_int($localPerson) ? \App\Models\Person::find($localPerson) : $localPerson;
41+
if (! $person) {
42+
return [];
43+
}
44+
45+
$results = [];
46+
foreach ($candidates as $cand) {
47+
$score = $this->scoreSingle($person, $cand);
48+
$results[] = ['candidate' => $cand, 'score' => $score];
49+
}
50+
51+
usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
52+
53+
return $results;
54+
}
55+
56+
protected function scoreSingle($person, array $cand): float
57+
{
58+
$totalWeight = array_sum(array_values($this->weights));
59+
$score = 0.0;
60+
61+
// first name similarity
62+
if (!empty($this->weights['first_name'])) {
63+
$firstPerson = Str::lower($person->first_name ?? '');
64+
$firstCand = Str::lower($cand['first_name'] ?? '');
65+
$sim = $this->stringSimilarity($firstPerson, $firstCand);
66+
$score += $this->weights['first_name'] * $sim;
67+
}
68+
69+
// last name
70+
if (!empty($this->weights['last_name'])) {
71+
$lastPerson = Str::lower($person->last_name ?? '');
72+
$lastCand = Str::lower($cand['last_name'] ?? '');
73+
$sim = $this->stringSimilarity($lastPerson, $lastCand);
74+
$score += $this->weights['last_name'] * $sim;
75+
}
76+
77+
// birth year exact/near
78+
if (!empty($this->weights['birth_year'])) {
79+
$py = $person->birth_year ? (int)$person->birth_year : null;
80+
$cy = isset($cand['birth_year']) ? (int)$cand['birth_year'] : null;
81+
$sim = 0.0;
82+
if ($py && $cy) {
83+
$diff = abs($py - $cy);
84+
if ($diff === 0) $sim = 1.0;
85+
elseif ($diff <= 2) $sim = 0.7;
86+
elseif ($diff <= 5) $sim = 0.4;
87+
}
88+
$score += $this->weights['birth_year'] * $sim;
89+
}
90+
91+
// birth place fuzzy match
92+
if (!empty($this->weights['birth_place'])) {
93+
$pp = Str::lower($person->birth_place ?? '');
94+
$cp = Str::lower($cand['birth_place'] ?? '');
95+
$sim = $this->stringSimilarity($pp, $cp);
96+
$score += $this->weights['birth_place'] * $sim;
97+
}
98+
99+
// parents - simplistic check if last names or parent names match
100+
if (!empty($this->weights['parents'])) {
101+
$sim = 0.0;
102+
// example: check if candidate last name equals person last_name or matches parent last_name fields
103+
if (!empty($cand['last_name']) && !empty($person->last_name)) {
104+
$sim = $this->stringSimilarity(Str::lower($person->last_name), Str::lower($cand['last_name']));
105+
}
106+
$score += $this->weights['parents'] * $sim;
107+
}
108+
109+
if ($totalWeight <= 0) {
110+
return 0.0;
111+
}
112+
113+
// normalize to 0..1
114+
return min(1.0, round($score / $totalWeight, 4));
115+
}
116+
117+
protected function stringSimilarity(string $a, string $b): float
118+
{
119+
if ($a === '' || $b === '') {
120+
return 0.0;
121+
}
122+
// use PHP similar_text for a simple score, normalize by max length
123+
similar_text($a, $b, $perc);
124+
return $perc / 100.0;
125+
}
126+
127+
/**
128+
* Persist suggestions into DB (upsert).
129+
*
130+
* @param int $localPersonId
131+
* @param string $provider
132+
* @param array $candidate
133+
* @param float $confidence
134+
* @return \App\Models\AISuggestedMatch
135+
*/
136+
public function persistSuggestion(int $localPersonId, string $provider, array $candidate, float $confidence): AISuggestedMatch
10137
{
11-
// Stub: in production replace with a real API client to FamilySearch, Ancestry, etc.
12-
// Return an array of candidate records.
13-
return [
138+
return AISuggestedMatch::updateOrCreate(
14139
[
15-
'id' => 'example-1',
16-
'first_name' => 'John',
17-
'last_name' => 'Doe',
18-
'birth_year' => 1879,
19-
'birth_place' => 'County X',
140+
'provider' => $provider,
141+
'external_record_id' => $candidate['id'] ?? ($candidate['external_id'] ?? null),
142+
'local_person_id' => $localPersonId,
20143
],
21-
];
144+
[
145+
'candidate_data' => $candidate,
146+
'confidence' => $confidence,
147+
'status' => 'pending',
148+
]
149+
);
150+
}
151+
152+
/**
153+
* Update model weights based on feedback (simple incremental algorithm).
154+
*
155+
* @param \App\Models\AISuggestedMatch $suggestedMatch
156+
* @param string $action 'confirm'|'reject'
157+
* @return void
158+
*/
159+
public function learnFromFeedback($suggestedMatch, string $action): void
160+
{
161+
// Basic approach:
162+
// - If confirmed, slightly increase weights of fields that matched strongly for this candidate.
163+
// - If rejected, slightly decrease weights of those fields.
164+
$delta = $action === 'confirm' ? 0.02 : -0.03;
165+
166+
$candidate = $suggestedMatch->candidate_data;
167+
$local = \App\Models\Person::find($suggestedMatch->local_person_id);
168+
if (!$local || !$candidate) {
169+
return;
170+
}
171+
172+
// For each tracked field compute similarity; adjust weight by delta * similarity
173+
$fields = array_keys($this->weights);
174+
foreach ($fields as $field) {
175+
$sim = 0.0;
176+
if (in_array($field, ['first_name', 'last_name', 'birth_place', 'parents'])) {
177+
$lv = strtolower((string)($local->{$field} ?? ''));
178+
$cv = strtolower((string)($candidate[$field] ?? ''));
179+
$sim = $this->stringSimilarity($lv, $cv);
180+
} elseif ($field === 'birth_year') {
181+
$py = $local->birth_year ? (int)$local->birth_year : null;
182+
$cy = isset($candidate['birth_year']) ? (int)$candidate['birth_year'] : null;
183+
if ($py && $cy) {
184+
$diff = abs($py - $cy);
185+
$sim = $diff === 0 ? 1.0 : ($diff <= 2 ? 0.7 : ($diff <= 5 ? 0.4 : 0.0));
186+
}
187+
}
188+
189+
$this->weights[$field] = max(0.0, ($this->weights[$field] ?? 0.0) + ($delta * $sim));
190+
}
191+
192+
// Persist updated weights as a new model snapshot
193+
AIMatchModel::create([
194+
'name' => 'snapshot_' . now()->format('YmdHis'),
195+
'weights' => $this->weights,
196+
]);
22197
}
23198
}

0 commit comments

Comments
 (0)