134 lines
4.2 KiB
Go
134 lines
4.2 KiB
Go
package domain
|
||
|
||
import (
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// regionAliases maps common Taiwan place names (zh) → service area code.
|
||
// Exact alias match only — never geo-infer (OP-04).
|
||
var regionAliases = map[string]string{
|
||
"台北": "TPE", "臺北": "TPE", "台北市": "TPE", "臺北市": "TPE", "tpe": "TPE",
|
||
"新北": "NWT", "新北市": "NWT", "nwt": "NWT",
|
||
"桃園": "TAO", "桃園市": "TAO", "tao": "TAO",
|
||
"台中": "TXG", "臺中": "TXG", "台中市": "TXG", "臺中市": "TXG", "txg": "TXG",
|
||
"台南": "TNN", "臺南": "TNN", "台南市": "TNN", "臺南市": "TNN", "tnn": "TNN",
|
||
"高雄": "KHH", "高雄市": "KHH", "khh": "KHH",
|
||
"基隆": "KEE", "基隆市": "KEE", "kee": "KEE",
|
||
"新竹市": "HSZ", "hsz": "HSZ",
|
||
"新竹縣": "HSQ", "hsq": "HSQ", "新竹": "HSZ",
|
||
"苗栗": "MIA", "苗栗縣": "MIA", "mia": "MIA",
|
||
"彰化": "CHA", "彰化縣": "CHA", "cha": "CHA",
|
||
"南投": "NAN", "南投縣": "NAN", "nan": "NAN",
|
||
"雲林": "YUN", "雲林縣": "YUN", "yun": "YUN",
|
||
"嘉義市": "CYI", "cyi": "CYI",
|
||
"嘉義縣": "CYQ", "cyq": "CYQ", "嘉義": "CYI",
|
||
"屏東": "PIF", "屏東縣": "PIF", "pif": "PIF",
|
||
"宜蘭": "ILA", "宜蘭縣": "ILA", "ila": "ILA",
|
||
"花蓮": "HUA", "花蓮縣": "HUA", "hua": "HUA",
|
||
"台東": "TTT", "臺東": "TTT", "台東縣": "TTT", "臺東縣": "TTT", "ttt": "TTT",
|
||
"澎湖": "PEN", "澎湖縣": "PEN", "pen": "PEN",
|
||
"金門": "KIN", "金門縣": "KIN", "kin": "KIN",
|
||
"連江": "LIE", "連江縣": "LIE", "馬祖": "LIE", "lie": "LIE",
|
||
}
|
||
|
||
type regionAliasEntry struct {
|
||
runes []rune
|
||
code string
|
||
}
|
||
|
||
// sortedRegionAliases is built once at startup: longest alias first so 「台北市」
|
||
// wins over 「台北」, with the alias itself breaking ties so detection order is
|
||
// reproducible instead of following map iteration.
|
||
var sortedRegionAliases = buildSortedRegionAliases()
|
||
|
||
func buildSortedRegionAliases() []regionAliasEntry {
|
||
out := make([]regionAliasEntry, 0, len(regionAliases))
|
||
for alias, code := range regionAliases {
|
||
if alias == "" {
|
||
continue
|
||
}
|
||
out = append(out, regionAliasEntry{runes: []rune(strings.ToLower(alias)), code: code})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if len(out[i].runes) != len(out[j].runes) {
|
||
return len(out[i].runes) > len(out[j].runes)
|
||
}
|
||
return string(out[i].runes) < string(out[j].runes)
|
||
})
|
||
return out
|
||
}
|
||
|
||
// DetectRegionCodes finds service-area codes mentioned in free text (exact alias only).
|
||
func DetectRegionCodes(text string) []string {
|
||
lower := strings.ToLower(text)
|
||
runes := []rune(lower)
|
||
if len(runes) == 0 {
|
||
return nil
|
||
}
|
||
seen := map[string]bool{}
|
||
var out []string
|
||
covered := make([]bool, len(runes))
|
||
for _, p := range sortedRegionAliases {
|
||
ar := p.runes
|
||
if len(ar) > len(runes) {
|
||
continue
|
||
}
|
||
for i := 0; i+len(ar) <= len(runes); i++ {
|
||
ok := true
|
||
for k := 0; k < len(ar); k++ {
|
||
if runes[i+k] != ar[k] || covered[i+k] {
|
||
ok = false
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
continue
|
||
}
|
||
for k := 0; k < len(ar); k++ {
|
||
covered[i+k] = true
|
||
}
|
||
if !seen[p.code] {
|
||
seen[p.code] = true
|
||
out = append(out, p.code)
|
||
}
|
||
}
|
||
}
|
||
// also direct code tokens;map 迭代無序,排序後再併入以維持輸出穩定
|
||
direct := make([]string, 0)
|
||
for code := range serviceAreaCodes {
|
||
if strings.Contains(lower, strings.ToLower(code)) && !seen[code] {
|
||
seen[code] = true
|
||
direct = append(direct, code)
|
||
}
|
||
}
|
||
sort.Strings(direct)
|
||
return append(out, direct...)
|
||
}
|
||
|
||
// MatchRegion compares detected codes against the owner's service areas.
|
||
// unknown when nothing detected; mismatch when only non-overlapping codes; match on any overlap.
|
||
func MatchRegion(detected, serviceAreas []string, remoteOK bool) (regionMatch string, score int) {
|
||
if len(detected) == 0 {
|
||
// partial credit for unknown (spec §3.3)
|
||
return RegionUnknown, WeightRegion / 2
|
||
}
|
||
if len(serviceAreas) == 0 {
|
||
return RegionUnknown, WeightRegion / 2
|
||
}
|
||
svc := map[string]bool{}
|
||
for _, a := range serviceAreas {
|
||
svc[strings.ToUpper(strings.TrimSpace(a))] = true
|
||
}
|
||
for _, d := range detected {
|
||
if svc[strings.ToUpper(strings.TrimSpace(d))] {
|
||
return RegionMatch, WeightRegion
|
||
}
|
||
}
|
||
if remoteOK {
|
||
// OP-05: remote_ok → mismatch not hard-reject; full region points
|
||
return RegionMismatch, WeightRegion
|
||
}
|
||
return RegionMismatch, 0
|
||
}
|