Deploy to K8s / deploy (push) Successful in 20s
Go (go/) becomes the primary implementation. The original Python version moves to legacy/ and is kept runnable; new features are still built in both (see the Dual implementation rule in CLAUDE.md). - git mv app.py, scripts/, templates/, tests/, pyproject.toml, uv.lock and build/ into legacy/ - Makefile: Python targets point at legacy/, venv in legacy/.venv, CREDENTIALS_PATH exported for the root .secret/ file - CI and make image build the Python image with legacy/ as context; the Makefile is no longer copied into the image - legacy/.dockerignore moved to the context root so it takes effect - CLAUDE.md, docs/README.md, Go comments, fixture README, capture script and VS Code launch config updated to the new layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
3.7 KiB
Go
132 lines
3.7 KiB
Go
package matching
|
|
|
|
import (
|
|
"fuj-management/go/internal/domain/czech"
|
|
"strings"
|
|
)
|
|
|
|
// Confidence indicates how certain a member match is.
|
|
type Confidence string
|
|
|
|
const (
|
|
ConfidenceAuto Confidence = "auto"
|
|
ConfidenceReview Confidence = "review"
|
|
)
|
|
|
|
// Match pairs a canonical member name with the confidence of the match.
|
|
type Match struct {
|
|
Name string
|
|
Confidence Confidence
|
|
}
|
|
|
|
var commonSurnames = map[string]bool{
|
|
"novak": true,
|
|
"novakova": true,
|
|
"prach": true,
|
|
}
|
|
|
|
// MatchMembers finds members mentioned in text and returns them with a
|
|
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
|
//
|
|
// Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
|
|
// 1. Exact short-circuit: if any member's full normalized name appears as whole
|
|
// words in normalize(text), return ONLY those matches as auto. This prevents
|
|
// nickname "tov" from matching inside surname "ottova".
|
|
// 2. Per-member first-match-wins: full-name substring → first+last both present
|
|
// (any order) → nickname whole-word. Each yields auto.
|
|
// 3. Review tier: last name (len≥4, not a common surname) → first name (len≥3)
|
|
// → single-part name (len≥4). Each yields review.
|
|
// 4. Final filter: if any auto exists, drop all review.
|
|
func MatchMembers(text string, memberNames []string) []Match {
|
|
normalizedText := czech.Normalize(text)
|
|
|
|
// Pass 1: exact short-circuit
|
|
var exactMatches []Match
|
|
for _, name := range memberNames {
|
|
variants := BuildNameVariants(name)
|
|
if len(variants) == 0 {
|
|
continue
|
|
}
|
|
fullName := variants[0]
|
|
if fullName != "" && wordIn(fullName, normalizedText) {
|
|
exactMatches = append(exactMatches, Match{Name: name, Confidence: ConfidenceAuto})
|
|
}
|
|
}
|
|
if len(exactMatches) > 0 {
|
|
return exactMatches
|
|
}
|
|
|
|
// Pass 2 + 3: fuzzy matching
|
|
var matches []Match
|
|
for _, name := range memberNames {
|
|
variants := BuildNameVariants(name)
|
|
fullName := ""
|
|
if len(variants) > 0 {
|
|
fullName = variants[0]
|
|
}
|
|
parts := strings.Fields(fullName)
|
|
|
|
// Auto tier
|
|
if fullName != "" && strings.Contains(normalizedText, fullName) {
|
|
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
|
|
continue
|
|
}
|
|
if len(parts) >= 2 {
|
|
if wordIn(parts[0], normalizedText) && wordIn(parts[len(parts)-1], normalizedText) {
|
|
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
|
|
continue
|
|
}
|
|
}
|
|
// Nickname check
|
|
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
|
nick := czech.Normalize(m[1])
|
|
if nick != "" && wordIn(nick, normalizedText) {
|
|
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Review tier
|
|
if len(parts) >= 2 {
|
|
lastName := parts[len(parts)-1]
|
|
firstName := parts[0]
|
|
if len(lastName) >= 4 && !commonSurnames[lastName] && wordIn(lastName, normalizedText) {
|
|
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
|
|
continue
|
|
}
|
|
if len(firstName) >= 3 && wordIn(firstName, normalizedText) {
|
|
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
|
|
continue
|
|
}
|
|
} else if len(parts) == 1 {
|
|
if len(parts[0]) >= 4 && wordIn(parts[0], normalizedText) {
|
|
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final filter: drop review if any auto exists
|
|
hasAuto := false
|
|
for _, m := range matches {
|
|
if m.Confidence == ConfidenceAuto {
|
|
hasAuto = true
|
|
break
|
|
}
|
|
}
|
|
if hasAuto {
|
|
filtered := matches[:0]
|
|
for _, m := range matches {
|
|
if m.Confidence == ConfidenceAuto {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
if matches == nil {
|
|
return []Match{}
|
|
}
|
|
return matches
|
|
}
|