refactor!: make addrkit a multi-city address normalizer
- remove the events/outages module, ArcGIS adapter, portal config and event statuses - add a configurable city dictionary/parser and make City part of StreetKey - seed: read city/city_name, namespace IDs by city, keep structures distinct - fix reliability: schema v2 with fail-closed load, deterministic ordering, input limits, config validation, atomic DB publish, LLM response caps - add read-only shadow mode comparing the old ES path with addrkit - breaking: registry.db must be re-seeded; config has no portal section
This commit is contained in:
@@ -20,6 +20,25 @@ type Rewriter interface {
|
||||
Rewrite(ctx context.Context, raw string) ([]string, error)
|
||||
}
|
||||
|
||||
// Parser is the address parser used by the resolver. It is defined here
|
||||
// (consumer side) so the resolver can be tested with a stub and so a
|
||||
// deployment can plug in its own dictionary.
|
||||
type Parser interface {
|
||||
Parse(raw string) address.ParsedAddress
|
||||
ParseInCity(raw, city string) address.ParsedAddress
|
||||
MatchCity(query string) (address.City, bool)
|
||||
}
|
||||
|
||||
// ResolverOptions tunes optional resolver behaviour.
|
||||
type ResolverOptions struct {
|
||||
// Parser defaults to address.DefaultParser().
|
||||
Parser Parser
|
||||
// OnRewriteError, when set, observes failures of the optional rewriter.
|
||||
// The resolver always keeps the deterministic result, but operators still
|
||||
// need to know that the fallback is broken.
|
||||
OnRewriteError func(error)
|
||||
}
|
||||
|
||||
// Resolver wraps an Index with an optional Rewriter.
|
||||
//
|
||||
// The deterministic path is always tried first. The rewriter is consulted only
|
||||
@@ -27,13 +46,30 @@ type Rewriter interface {
|
||||
// suggestions must pass compatibleVariant before being considered, so the
|
||||
// model cannot substitute a different street or corrupt the house unit.
|
||||
type Resolver struct {
|
||||
index *Index
|
||||
rewriter Rewriter
|
||||
index *Index
|
||||
rewriter Rewriter
|
||||
parser Parser
|
||||
onRewriteError func(error)
|
||||
}
|
||||
|
||||
// NewResolver builds a resolver. rewriter may be nil to disable rewriting.
|
||||
// NewResolver builds a resolver with the default parser and no rewriter
|
||||
// diagnostics.
|
||||
func NewResolver(index *Index, rewriter Rewriter) *Resolver {
|
||||
return &Resolver{index: index, rewriter: rewriter}
|
||||
return NewResolverWithOptions(index, rewriter, ResolverOptions{})
|
||||
}
|
||||
|
||||
// NewResolverWithOptions builds a resolver with explicit options.
|
||||
func NewResolverWithOptions(index *Index, rewriter Rewriter, opts ResolverOptions) *Resolver {
|
||||
parser := opts.Parser
|
||||
if parser == nil {
|
||||
parser = address.DefaultParser()
|
||||
}
|
||||
return &Resolver{
|
||||
index: index,
|
||||
rewriter: rewriter,
|
||||
parser: parser,
|
||||
onRewriteError: opts.OnRewriteError,
|
||||
}
|
||||
}
|
||||
|
||||
// Version returns the current data version.
|
||||
@@ -42,9 +78,35 @@ func (r *Resolver) Version() string { return r.index.Version() }
|
||||
// Ready reports whether the resolver has a usable index.
|
||||
func (r *Resolver) Ready() bool { return r.index != nil && r.index.Size() > 0 }
|
||||
|
||||
// Cities returns the canonical city names present in the index.
|
||||
func (r *Resolver) Cities() []string {
|
||||
if r.index == nil {
|
||||
return nil
|
||||
}
|
||||
return r.index.Cities()
|
||||
}
|
||||
|
||||
// MatchCity reports whether query is a city name/alias and returns the
|
||||
// canonical name. It uses the resolver's parser dictionary, not the index, so
|
||||
// it also recognizes aliases for cities that have no rows yet.
|
||||
func (r *Resolver) MatchCity(query string) (string, bool) {
|
||||
city, ok := r.parser.MatchCity(query)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return city.Name, true
|
||||
}
|
||||
|
||||
// Resolve parses raw and resolves it, consulting the rewriter when enabled.
|
||||
func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
|
||||
parsed := address.Parse(raw)
|
||||
return r.ResolveInCity(ctx, raw, "")
|
||||
}
|
||||
|
||||
// ResolveInCity is Resolve with an explicit city hint. The hint overrides a
|
||||
// city found in the address text and lets clients pass the city as a separate
|
||||
// field.
|
||||
func (r *Resolver) ResolveInCity(ctx context.Context, raw, city string) Result {
|
||||
parsed := r.parse(raw, city)
|
||||
res := r.index.Resolve(parsed)
|
||||
|
||||
if res.Status == address.StatusFound || r.rewriter == nil {
|
||||
@@ -55,13 +117,27 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
|
||||
}
|
||||
|
||||
alternatives, err := r.rewriter.Rewrite(ctx, raw)
|
||||
if err != nil || len(alternatives) == 0 {
|
||||
if err != nil {
|
||||
if r.onRewriteError != nil {
|
||||
r.onRewriteError(err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
if len(alternatives) == 0 {
|
||||
return res
|
||||
}
|
||||
|
||||
best := res
|
||||
for _, alt := range alternatives {
|
||||
variant := address.Parse(alt)
|
||||
variant := r.parse(alt, city)
|
||||
// The embedded prompt asks the model to strip administrative words
|
||||
// (including the city), so a correct variant often has no city. It
|
||||
// inherits the original's city; only a conflicting explicit city is a
|
||||
// disqualification. Without this, the rewriter silently contributes
|
||||
// nothing in multi-city deployments (where default_city is empty).
|
||||
if variant.CityKey == "" && parsed.CityKey != "" {
|
||||
variant = r.parser.ParseInCity(alt, parsed.City)
|
||||
}
|
||||
// The model may fix the spelling of the street, nothing else.
|
||||
if !compatibleVariant(parsed, variant) {
|
||||
continue
|
||||
@@ -74,6 +150,13 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
|
||||
return best
|
||||
}
|
||||
|
||||
func (r *Resolver) parse(raw, city string) address.ParsedAddress {
|
||||
if strings.TrimSpace(city) != "" {
|
||||
return r.parser.ParseInCity(raw, city)
|
||||
}
|
||||
return r.parser.Parse(raw)
|
||||
}
|
||||
|
||||
// compatibleVariant reports whether an LLM rewrite may be considered. A rewrite
|
||||
// is acceptable only when it:
|
||||
// - keeps the exact house unit (number, fraction, letter, corpus, structure
|
||||
@@ -82,6 +165,9 @@ func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
|
||||
// - has the same words in the street name, each within a small edit distance
|
||||
// — so a different street can never be substituted.
|
||||
func compatibleVariant(orig, variant address.ParsedAddress) bool {
|
||||
if orig.CityKey != variant.CityKey {
|
||||
return false
|
||||
}
|
||||
if !sameHouseUnit(orig, variant) {
|
||||
return false
|
||||
}
|
||||
@@ -108,9 +194,13 @@ func nearToken(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
minLen := len([]rune(a))
|
||||
if lb := len([]rune(b)); lb < minLen {
|
||||
minLen = lb
|
||||
ra, rb := len([]rune(a)), len([]rune(b))
|
||||
if ra == 0 || rb == 0 || ra > maxTokenRunes || rb > maxTokenRunes {
|
||||
return false
|
||||
}
|
||||
minLen := ra
|
||||
if rb < minLen {
|
||||
minLen = rb
|
||||
}
|
||||
d := damerau(a, b)
|
||||
switch {
|
||||
@@ -129,7 +219,8 @@ func sameHouseUnit(a, b address.ParsedAddress) bool {
|
||||
a.HouseLetter == b.HouseLetter &&
|
||||
a.Building == b.Building &&
|
||||
a.BuildingFrac == b.BuildingFrac &&
|
||||
a.BuildingLetter == b.BuildingLetter
|
||||
a.BuildingLetter == b.BuildingLetter &&
|
||||
a.Structure == b.Structure
|
||||
}
|
||||
|
||||
// better ranks results: a stronger status wins, then higher confidence.
|
||||
|
||||
Reference in New Issue
Block a user