vehicles 0.4.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +164 -0
- data/CHANGELOG.md +321 -0
- data/README.md +88 -15
- data/data/PROVENANCE-vehicles.md +12 -0
- data/data/plates/PROVENANCE.md +10 -0
- data/data/plates/_decode/at-districts.yml +287 -0
- data/data/plates/_decode/ch-cantons.yml +110 -0
- data/data/plates/_decode/cn-issuing-offices.yml +463 -0
- data/data/plates/_decode/cn-provinces.yml +614 -0
- data/data/plates/_decode/cz-kraj.yml +159 -0
- data/data/plates/_decode/dk-serial-blocks.yml +327 -0
- data/data/plates/_decode/es-provinces.yml +116 -0
- data/data/plates/_decode/fr-departements.yml +191 -0
- data/data/plates/_decode/gb-age-identifiers.yml +170 -0
- data/data/plates/_decode/gb-memory-tags.yml +202 -0
- data/data/plates/_decode/gr-prefectures.yml +1408 -0
- data/data/plates/_decode/hr-cities.yml +315 -0
- data/data/plates/_decode/ie-counties.yml +227 -0
- data/data/plates/_decode/in-state-codes.yml +169 -0
- data/data/plates/_decode/is-districts-1938.yml +116 -0
- data/data/plates/_decode/it-provinces.yml +348 -0
- data/data/plates/_decode/jp-office-marks.yml +960 -0
- data/data/plates/_decode/jp-use-marks.yml +205 -0
- data/data/plates/_decode/kr-authority-marks.yml +146 -0
- data/data/plates/_decode/kr-use-syllables.yml +194 -0
- data/data/plates/_decode/lu-series-order.yml +638 -0
- data/data/plates/_decode/mx-entity-series.yml +380 -0
- data/data/plates/_decode/my-index-marks.yml +271 -0
- data/data/plates/_decode/no-counties-1913.yml +148 -0
- data/data/plates/_decode/no-districts.yml +901 -0
- data/data/plates/_decode/pl-powiats.yml +567 -0
- data/data/plates/_decode/pt-trailer-services.yml +96 -0
- data/data/plates/_decode/ro-counties.yml +213 -0
- data/data/plates/_decode/se-diplomatic.yml +220 -0
- data/data/plates/_decode/si-regions.yml +123 -0
- data/data/plates/_decode/sk-districts.yml +314 -0
- data/data/plates/_decode/th-registration-elements.yml +208 -0
- data/data/plates/_decode/ua-regions.yml +393 -0
- data/data/plates/_decode/za-licence-marks.yml +215 -0
- data/data/plates/_meta/classes.yml +26 -0
- data/data/plates/_meta/separators.yml +102 -0
- data/data/plates/ad.yml +961 -0
- data/data/plates/ar.yml +762 -0
- data/data/plates/at.yml +1424 -0
- data/data/plates/au-act.yml +472 -0
- data/data/plates/au-nsw.yml +282 -0
- data/data/plates/au-nt.yml +431 -0
- data/data/plates/au-qld.yml +272 -0
- data/data/plates/au-sa.yml +248 -0
- data/data/plates/au-tas.yml +302 -0
- data/data/plates/au-vic.yml +215 -0
- data/data/plates/au-wa.yml +309 -0
- data/data/plates/be.yml +1840 -0
- data/data/plates/br.yml +1152 -0
- data/data/plates/ca-ab.yml +430 -0
- data/data/plates/ca-bc.yml +697 -0
- data/data/plates/ca-mb.yml +499 -0
- data/data/plates/ca-nb.yml +537 -0
- data/data/plates/ca-nl.yml +743 -0
- data/data/plates/ca-ns.yml +808 -0
- data/data/plates/ca-nt.yml +610 -0
- data/data/plates/ca-nu.yml +280 -0
- data/data/plates/ca-on.yml +881 -0
- data/data/plates/ca-pe.yml +592 -0
- data/data/plates/ca-qc.yml +1036 -0
- data/data/plates/ca-sk.yml +256 -0
- data/data/plates/ca-yt.yml +656 -0
- data/data/plates/ch.yml +1273 -0
- data/data/plates/cn.yml +1431 -0
- data/data/plates/cy.yml +688 -0
- data/data/plates/cz.yml +1179 -0
- data/data/plates/de.yml +1107 -0
- data/data/plates/dk.yml +1647 -0
- data/data/plates/ee.yml +1275 -0
- data/data/plates/es.yml +888 -0
- data/data/plates/fi.yml +1271 -0
- data/data/plates/fr.yml +1265 -0
- data/data/plates/gb.yml +1188 -0
- data/data/plates/gr.yml +721 -0
- data/data/plates/hr.yml +860 -0
- data/data/plates/hu.yml +959 -0
- data/data/plates/ie.yml +775 -0
- data/data/plates/in.yml +1172 -0
- data/data/plates/is.yml +667 -0
- data/data/plates/it.yml +1317 -0
- data/data/plates/jp.yml +3650 -0
- data/data/plates/kr.yml +1375 -0
- data/data/plates/li.yml +1010 -0
- data/data/plates/lt.yml +879 -0
- data/data/plates/lu.yml +842 -0
- data/data/plates/lv.yml +924 -0
- data/data/plates/mc.yml +1024 -0
- data/data/plates/mt.yml +854 -0
- data/data/plates/mx.yml +1372 -0
- data/data/plates/my.yml +685 -0
- data/data/plates/nl.yml +1016 -0
- data/data/plates/no.yml +1018 -0
- data/data/plates/nz.yml +1699 -0
- data/data/plates/pl.yml +1296 -0
- data/data/plates/pt.yml +847 -0
- data/data/plates/ro.yml +785 -0
- data/data/plates/se.yml +1091 -0
- data/data/plates/si.yml +1105 -0
- data/data/plates/sk.yml +1439 -0
- data/data/plates/sm.yml +1119 -0
- data/data/plates/th.yml +1150 -0
- data/data/plates/ua.yml +1672 -0
- data/data/plates/us-ak.yml +763 -0
- data/data/plates/us-al.yml +457 -0
- data/data/plates/us-ar.yml +427 -0
- data/data/plates/us-as.yml +454 -0
- data/data/plates/us-az.yml +430 -0
- data/data/plates/us-ca.yml +892 -0
- data/data/plates/us-co.yml +452 -0
- data/data/plates/us-ct.yml +427 -0
- data/data/plates/us-dc.yml +614 -0
- data/data/plates/us-de.yml +551 -0
- data/data/plates/us-fl.yml +741 -0
- data/data/plates/us-ga.yml +644 -0
- data/data/plates/us-gu.yml +658 -0
- data/data/plates/us-hi.yml +585 -0
- data/data/plates/us-ia.yml +438 -0
- data/data/plates/us-id.yml +531 -0
- data/data/plates/us-il.yml +516 -0
- data/data/plates/us-in.yml +324 -0
- data/data/plates/us-ks.yml +351 -0
- data/data/plates/us-ky.yml +770 -0
- data/data/plates/us-la.yml +568 -0
- data/data/plates/us-ma.yml +379 -0
- data/data/plates/us-md.yml +536 -0
- data/data/plates/us-me.yml +445 -0
- data/data/plates/us-mi.yml +408 -0
- data/data/plates/us-mn.yml +411 -0
- data/data/plates/us-mo.yml +356 -0
- data/data/plates/us-mp.yml +402 -0
- data/data/plates/us-ms.yml +432 -0
- data/data/plates/us-mt.yml +619 -0
- data/data/plates/us-nc.yml +602 -0
- data/data/plates/us-nd.yml +419 -0
- data/data/plates/us-ne.yml +483 -0
- data/data/plates/us-nh.yml +394 -0
- data/data/plates/us-nj.yml +547 -0
- data/data/plates/us-nm.yml +574 -0
- data/data/plates/us-nv.yml +655 -0
- data/data/plates/us-ny.yml +592 -0
- data/data/plates/us-oh.yml +404 -0
- data/data/plates/us-ok.yml +513 -0
- data/data/plates/us-or.yml +392 -0
- data/data/plates/us-pa.yml +506 -0
- data/data/plates/us-pr.yml +507 -0
- data/data/plates/us-ri.yml +445 -0
- data/data/plates/us-sc.yml +605 -0
- data/data/plates/us-sd.yml +428 -0
- data/data/plates/us-tn.yml +471 -0
- data/data/plates/us-tx.yml +535 -0
- data/data/plates/us-ut.yml +384 -0
- data/data/plates/us-va.yml +567 -0
- data/data/plates/us-vi.yml +489 -0
- data/data/plates/us-vt.yml +458 -0
- data/data/plates/us-wa.yml +484 -0
- data/data/plates/us-wi.yml +509 -0
- data/data/plates/us-wv.yml +446 -0
- data/data/plates/us-wy.yml +523 -0
- data/data/plates/va.yml +604 -0
- data/data/plates/za.yml +1141 -0
- data/data/vehicles.json +160653 -105090
- data/lib/vehicles/configuration.rb +18 -2
- data/lib/vehicles/dataset.rb +73 -18
- data/lib/vehicles/fuzzy.rb +115 -0
- data/lib/vehicles/http.rb +48 -0
- data/lib/vehicles/make.rb +36 -6
- data/lib/vehicles/mcp_server.rb +5 -5
- data/lib/vehicles/model.rb +42 -6
- data/lib/vehicles/plates/jurisdiction.rb +32 -0
- data/lib/vehicles/plates/series.rb +291 -0
- data/lib/vehicles/plates/template_fill.rb +188 -0
- data/lib/vehicles/plates/templates.rb +162 -0
- data/lib/vehicles/plates.rb +122 -0
- data/lib/vehicles/providers/hosted_provider.rb +49 -58
- data/lib/vehicles/providers/local_provider.rb +4 -3
- data/lib/vehicles/validators/vehicle_make_validator.rb +3 -1
- data/lib/vehicles/validators/vehicle_model_validator.rb +1 -1
- data/lib/vehicles/version.rb +1 -1
- data/lib/vehicles.rb +53 -16
- data/rakelib/data_refresh.rake +109 -0
- data/rakelib/plates_refresh.rake +29 -0
- data/sig/vehicles.rbs +18 -0
- metadata +174 -2
|
@@ -22,13 +22,28 @@ module Vehicles
|
|
|
22
22
|
# works on the bundled data — the gem is standalone first, SDK second.
|
|
23
23
|
attr_accessor :api_key
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# Where the hosted VehiclesDB API lives — the ORIGIN, without the /api/v1
|
|
26
|
+
# (endpoint paths carry it: HostedProvider::API_PREFIX). Overridable for
|
|
27
|
+
# self-hosting, staging or a local server: "http://localhost:3030".
|
|
26
28
|
attr_accessor :api_base_url
|
|
27
29
|
|
|
28
30
|
# Network timeout (seconds) for hosted API calls. Kept short so a slow/missing
|
|
29
31
|
# API never blocks a request — hosted lookups degrade to the local data.
|
|
30
32
|
attr_accessor :api_timeout
|
|
31
33
|
|
|
34
|
+
# Plate templates (Vehicles::Plates.template / .render): where fetched
|
|
35
|
+
# templates are kept, and for how long. Any store with read(key) and
|
|
36
|
+
# write(key, value, expires_in:) works — `Rails.cache` shares them across
|
|
37
|
+
# processes. Defaults to an in-process cache. Templates are public and
|
|
38
|
+
# carry nothing personal, so a long TTL is safe; the server re-renders
|
|
39
|
+
# them only when VehiclesDB's plate art changes.
|
|
40
|
+
attr_writer :plate_template_cache
|
|
41
|
+
attr_accessor :plate_template_ttl
|
|
42
|
+
|
|
43
|
+
def plate_template_cache
|
|
44
|
+
@plate_template_cache ||= Plates::TemplateCache.new
|
|
45
|
+
end
|
|
46
|
+
|
|
32
47
|
# Extra make aliases, merged over the built-in ones. Keys are matched
|
|
33
48
|
# forgivingly (case/diacritics-insensitive); values are canonical make names.
|
|
34
49
|
attr_reader :aliases
|
|
@@ -63,7 +78,7 @@ module Vehicles
|
|
|
63
78
|
def initialize
|
|
64
79
|
@region = nil # no continent filter by default (global dataset)
|
|
65
80
|
@api_key = nil
|
|
66
|
-
@api_base_url = "https://
|
|
81
|
+
@api_base_url = "https://vehiclesdb.com"
|
|
67
82
|
@api_timeout = 2
|
|
68
83
|
@aliases = {}
|
|
69
84
|
@other_label = "Other"
|
|
@@ -71,6 +86,7 @@ module Vehicles
|
|
|
71
86
|
@cache_path = default_cache_path
|
|
72
87
|
@use_cache = true
|
|
73
88
|
@refresh_timeout = 5
|
|
89
|
+
@plate_template_ttl = 86_400 # a day, the server's own shared-cache lifetime
|
|
74
90
|
end
|
|
75
91
|
|
|
76
92
|
# Normalize alias keys at assignment time so lookups stay O(1) and forgiving.
|
data/lib/vehicles/dataset.rb
CHANGED
|
@@ -78,7 +78,11 @@ module Vehicles
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# Resolve a make from a String/Symbol/Make via aliases, slug, or name.
|
|
81
|
-
|
|
81
|
+
# With `fuzzy:` (the default), a query that matches nothing exactly falls
|
|
82
|
+
# back to the closest make by edit distance — "marcedes" → Mercedes-Benz —
|
|
83
|
+
# but only when ONE make is strictly closest; equally close makes → nil.
|
|
84
|
+
# Pass `fuzzy: false` where a typo must NOT count as a match (validators).
|
|
85
|
+
def find_make(query, fuzzy: true)
|
|
82
86
|
return query if query.is_a?(Make)
|
|
83
87
|
|
|
84
88
|
q = Vehicles.normalize(query)
|
|
@@ -95,25 +99,31 @@ module Vehicles
|
|
|
95
99
|
end
|
|
96
100
|
|
|
97
101
|
# 3. direct slug / normalized name / make alias
|
|
98
|
-
|
|
102
|
+
# 4. bounded edit-distance fallback over names/slugs/aliases (Fuzzy);
|
|
103
|
+
# user-configured aliases are not fuzzy-matched
|
|
104
|
+
# — never for the "Other" escape hatch's label ("Otra" is one edit
|
|
105
|
+
# from the make Ora; Vehicles.models("Otra", include_other: true)
|
|
106
|
+
# must stay [other_label])
|
|
107
|
+
# — and never for id/slug/path-shaped input (Fuzzy.eligible_input?)
|
|
108
|
+
fuzzy &&= Fuzzy.eligible_input?(query) && !Vehicles.other?(query)
|
|
109
|
+
@by_slug[q] || @index[q] || (fuzzy ? Fuzzy.unique_closest(q, fuzzy_keys) : nil)
|
|
99
110
|
end
|
|
100
111
|
|
|
101
112
|
# Resolve a free-text "make + model" string into one Model. Tries the longest
|
|
102
113
|
# leading make prefix first ("land rover defender"), then the remainder as the
|
|
103
114
|
# model. Returns nil if nothing matches.
|
|
104
|
-
|
|
115
|
+
#
|
|
116
|
+
# The exact pass runs first over EVERY split; only if nothing matches is
|
|
117
|
+
# the make prefix retried with typo tolerance, so no answer that resolved
|
|
118
|
+
# before 0.7.8 can change ("volkswagon golf" now resolves; "vw golf" is
|
|
119
|
+
# answered by the exact pass exactly as before).
|
|
120
|
+
def find_model(query, fuzzy: true)
|
|
105
121
|
q = Vehicles.normalize(query)
|
|
106
122
|
tokens = q.split
|
|
107
123
|
return nil if tokens.empty?
|
|
108
124
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
next unless make
|
|
112
|
-
|
|
113
|
-
model = make.model(tokens[i..].join(" "))
|
|
114
|
-
return model if model
|
|
115
|
-
end
|
|
116
|
-
nil
|
|
125
|
+
fuzzy &&= Fuzzy.eligible_input?(query) # ids/slugs/paths resolve exactly or not at all
|
|
126
|
+
model_by_split(tokens, fuzzy: false) || (fuzzy ? model_by_split(tokens, fuzzy: true) : nil)
|
|
117
127
|
end
|
|
118
128
|
|
|
119
129
|
# Every model whose name (or full name) matches the query, ranked: exact name,
|
|
@@ -148,20 +158,43 @@ module Vehicles
|
|
|
148
158
|
@kinds ||= @makes.flat_map(&:kinds).uniq.sort.freeze
|
|
149
159
|
end
|
|
150
160
|
|
|
151
|
-
# Ranked models by popularity
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
161
|
+
# Ranked models by popularity. Unranked models (nil decile — catalog-only
|
|
162
|
+
# evidence) never appear: "unknown" must not outrank "known".
|
|
163
|
+
#
|
|
164
|
+
# Without `country:` — global decile, then breadth of availability, then
|
|
165
|
+
# name. WITH `country:` — that country's OWN rank (Model#rank_in) first,
|
|
166
|
+
# so a UK list reads in UK order, not in the presence-weighted global
|
|
167
|
+
# order (until 0.7.7 a filtered list kept the global sort). Models
|
|
168
|
+
# available there but without a rank there follow, in global order; a
|
|
169
|
+
# snapshot without `country_ranks` (data before 2026.10) degrades to the
|
|
170
|
+
# 0.7.7 order. Ranks are per KIND, so pass `kind:` for a clean ranking —
|
|
171
|
+
# without it, kinds interleave by rank (car #1, motorcycle #1, …).
|
|
172
|
+
#
|
|
173
|
+
# `by:` picks the decile: :global_decile (default — presence average,
|
|
174
|
+
# "popular in many places") or :mass_decile (per-kind registration mass,
|
|
175
|
+
# "how many exist"; data 2026.10.1+). Records without the chosen decile
|
|
176
|
+
# never appear, so `by: :mass_decile` on an older snapshot returns [].
|
|
177
|
+
# top_models(kind: :car, country: :gb, limit: 10)
|
|
178
|
+
# With `country:`, the country's own rank still sorts first and the
|
|
179
|
+
# chosen decile only breaks ties among models ranked equally there.
|
|
180
|
+
# top_models(kind: :car, by: :mass_decile, limit: 10)
|
|
155
181
|
# top_models(kind: :motorcycle, region: :as, limit: 10) # by continent
|
|
156
|
-
def top_models(kind: nil, country: nil, region: nil, limit: 20)
|
|
182
|
+
def top_models(kind: nil, country: nil, region: nil, limit: 20, by: :global_decile)
|
|
183
|
+
raise ArgumentError, "by: must be :global_decile or :mass_decile" unless RANKINGS.include?(by)
|
|
184
|
+
|
|
157
185
|
c = country&.to_s&.downcase
|
|
158
|
-
list = all_models.select(
|
|
186
|
+
list = all_models.select(&by)
|
|
159
187
|
list = list.select { |m| m.kind == kind.to_sym } if kind
|
|
160
188
|
list = list.select { |m| m.availability.include?(c) } if c
|
|
161
189
|
list = list.select { |m| m.available_in_region?(region) } if region
|
|
162
|
-
list.sort_by
|
|
190
|
+
list.sort_by do |m|
|
|
191
|
+
rank = c && m.rank_in(c)
|
|
192
|
+
[rank ? 0 : 1, rank || 0, m.public_send(by), -m.availability.size, m.name]
|
|
193
|
+
end.first(limit)
|
|
163
194
|
end
|
|
164
195
|
|
|
196
|
+
RANKINGS = %i[global_decile mass_decile].freeze
|
|
197
|
+
|
|
165
198
|
# Every model matching optional kind/region/rarity filters, ranked by
|
|
166
199
|
# popularity. The "give me a sensible slice" entry point.
|
|
167
200
|
def all_models_filtered(kind: nil, region: nil, rarity: nil, max_decile: nil)
|
|
@@ -186,6 +219,28 @@ module Vehicles
|
|
|
186
219
|
@index[Vehicles.normalize(key)] ||= make
|
|
187
220
|
end
|
|
188
221
|
|
|
222
|
+
# The longest-make-prefix split behind #find_model, one pass.
|
|
223
|
+
def model_by_split(tokens, fuzzy:)
|
|
224
|
+
(tokens.length - 1).downto(1) do |i|
|
|
225
|
+
make = find_make(tokens[0, i].join(" "), fuzzy: fuzzy)
|
|
226
|
+
next unless make
|
|
227
|
+
|
|
228
|
+
model = make.model(tokens[i..].join(" "))
|
|
229
|
+
return model if model
|
|
230
|
+
end
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Every normalized key a make answers to (name, slug, published aliases,
|
|
235
|
+
# built-in aliases) — the haystack for #fuzzy_make. Built once.
|
|
236
|
+
def fuzzy_keys
|
|
237
|
+
@fuzzy_keys ||= begin
|
|
238
|
+
keys = @index.to_a
|
|
239
|
+
BUILTIN_ALIASES.each { |key, slug| keys << [key, @by_slug[slug]] if @by_slug[slug] }
|
|
240
|
+
keys.reject { |k, _m| k.empty? }.freeze
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
189
244
|
def region_match?(region)
|
|
190
245
|
Vehicles.normalize(@region) == "global" ||
|
|
191
246
|
Vehicles.normalize(region) == Vehicles.normalize(@region)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# Typo tolerance for make lookups (0.7.8): bounded Levenshtein distance,
|
|
5
|
+
# stdlib only. "marcedes" → Mercedes-Benz, "volkswagon" → Volkswagen.
|
|
6
|
+
#
|
|
7
|
+
# Deliberately conservative — a confident WRONG make is worse than none:
|
|
8
|
+
# - queries under MIN_LENGTH characters never fuzzy-match;
|
|
9
|
+
# - the edit budget is 1 for 4-5 characters and 2 from 6 up (never more);
|
|
10
|
+
# - the answer must be UNIQUE: if two different makes tie at the best
|
|
11
|
+
# distance, the result is nil ("acra": one edit from Acura AND Acma);
|
|
12
|
+
# - a candidate may never have FEWER words than the query: deleting a
|
|
13
|
+
# whole word is how a make prefix swallows a model token ("volvo v" is
|
|
14
|
+
# two edits from "volvo", which turned "Volvo V 60" into Volvo 60);
|
|
15
|
+
# - every word keeps its first letter (word for word when the counts
|
|
16
|
+
# match, else the first letter of the query): typos rarely hit the
|
|
17
|
+
# first letter, model tokens almost always differ there ("citroen e"
|
|
18
|
+
# is two edits from "citroen ds" — that is the e-C3, not Citroën DS).
|
|
19
|
+
module Fuzzy
|
|
20
|
+
MIN_LENGTH = 4
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Only HUMAN-typed names are fuzzy-matched. Anything shaped like an id, a
|
|
25
|
+
# path or a slug — any "/" ("car/mercedes-benz/c-class", "/makes/marcedes",
|
|
26
|
+
# "marcedes-benz/c-class") or all-lowercase hyphenated ("marcedes-benz") —
|
|
27
|
+
# is machine input: it must resolve exactly or not at all, never to a
|
|
28
|
+
# guessed make (web routing and API ids rely on it; web-session ruling).
|
|
29
|
+
# Symbols are always machine input; so is anything with / _ + or a
|
|
30
|
+
# backslash, a leading/trailing/double hyphen, or a single-case
|
|
31
|
+
# hyphenated token ("marcedes-benz", "MARCEDES-BENZ"). Mixed case
|
|
32
|
+
# ("Marcedes-Benz") and plain words ("marcedes") are human. Verifier
|
|
33
|
+
# finding (0.7.8): each of those machine shapes fuzzed for every typo.
|
|
34
|
+
def eligible_input?(raw)
|
|
35
|
+
return false if raw.is_a?(Symbol)
|
|
36
|
+
|
|
37
|
+
s = raw.to_s.strip
|
|
38
|
+
return false if s.match?(%r{[/_+\\]}) || s.start_with?("-") || s.end_with?("-") || s.include?("--")
|
|
39
|
+
|
|
40
|
+
!s.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)+\z/) && !s.match?(/\A[A-Z0-9]+(?:-[A-Z0-9]+)+\z/)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Maximum edit distance allowed for a query of this many characters.
|
|
44
|
+
def budget(length)
|
|
45
|
+
length >= 6 ? 2 : 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The single Make closest to `query` among `keys` ([[normalized key, Make],
|
|
49
|
+
# …]), or nil when nothing is within budget or two makes tie.
|
|
50
|
+
def unique_closest(query, keys)
|
|
51
|
+
return nil if query.length < MIN_LENGTH
|
|
52
|
+
|
|
53
|
+
limit = budget(query.length)
|
|
54
|
+
words = query.count(" ") + 1
|
|
55
|
+
best = limit + 1
|
|
56
|
+
hits = {}
|
|
57
|
+
keys.each do |key, make|
|
|
58
|
+
next if (key.length - query.length).abs > limit # cannot be within budget
|
|
59
|
+
next if key.count(" ") + 1 < words # would swallow a word
|
|
60
|
+
next unless initials_match?(query, key)
|
|
61
|
+
|
|
62
|
+
dist = distance(query, key, limit)
|
|
63
|
+
# `distance` reports out-of-budget as limit + 1, the same value `best`
|
|
64
|
+
# starts at — test the LIMIT, or a lone length-compatible key would be
|
|
65
|
+
# accepted at any distance (codex review, 0.7.8: "z" * 28 → a make).
|
|
66
|
+
next if dist > limit || dist > best
|
|
67
|
+
|
|
68
|
+
hits = {} if dist < best
|
|
69
|
+
best = dist
|
|
70
|
+
hits[make.slug] = make
|
|
71
|
+
end
|
|
72
|
+
hits.size == 1 ? hits.values.first : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# First letters agree: word for word when both have the same number of
|
|
76
|
+
# words, otherwise (the query has fewer words, "delorean" vs
|
|
77
|
+
# "de lorean") just the first letter.
|
|
78
|
+
def initials_match?(query, key)
|
|
79
|
+
qw = query.split
|
|
80
|
+
kw = key.split
|
|
81
|
+
return qw.first[0] == kw.first[0] unless qw.size == kw.size
|
|
82
|
+
|
|
83
|
+
qw.zip(kw).all? { |q, k| q[0] == k[0] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Edit distance (optimal string alignment: Levenshtein plus adjacent
|
|
87
|
+
# transposition = 1 edit, so "telsa" is ONE edit from "tesla" — plain
|
|
88
|
+
# Levenshtein counted it as two and let the one-substitution "temsa" win).
|
|
89
|
+
# Gives up early: returns `limit + 1` once two consecutive rows exceed
|
|
90
|
+
# `limit` (a transposition reads two rows back, so one row is not enough).
|
|
91
|
+
def distance(left, right, limit)
|
|
92
|
+
prev2 = nil
|
|
93
|
+
prev = (0..right.length).to_a
|
|
94
|
+
left.each_char.with_index(1) do |lch, row|
|
|
95
|
+
cur = [row]
|
|
96
|
+
right.each_char.with_index(1) do |rch, col|
|
|
97
|
+
swap = prev2 && col > 1 && lch == right[col - 2] && left[row - 2] == rch
|
|
98
|
+
cur << cell(prev, cur, col, lch == rch, swap ? prev2[col - 2] : nil)
|
|
99
|
+
end
|
|
100
|
+
return limit + 1 if cur.min > limit && prev.min > limit
|
|
101
|
+
|
|
102
|
+
prev2 = prev
|
|
103
|
+
prev = cur
|
|
104
|
+
end
|
|
105
|
+
[prev.last, limit + 1].min
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One DP cell: deletion, insertion, substitution, and — when the two
|
|
109
|
+
# characters are swapped neighbours — transposition from two rows back.
|
|
110
|
+
def cell(prev, cur, col, same, swap_from)
|
|
111
|
+
best = [prev[col] + 1, cur[col - 1] + 1, prev[col - 1] + (same ? 0 : 1)].min
|
|
112
|
+
swap_from ? [best, swap_from + 1].min : best
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# The one way this gem talks to a VehiclesDB server: a single GET, capped by
|
|
5
|
+
# `config.api_timeout`, never retried, never raising. Shared by the hosted
|
|
6
|
+
# images provider (keyed, JSON) and the plate templates (public, SVG), so
|
|
7
|
+
# the latency promise and the no-silent-retry rule live in one place.
|
|
8
|
+
module HTTP
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# `config.api_base_url` + path + query. Plain concatenation, not
|
|
12
|
+
# URI.join: an origin ("http://localhost:3030") and an origin behind a
|
|
13
|
+
# path prefix ("https://proxy.example/vdb") both keep everything they say.
|
|
14
|
+
def url(path, params = {})
|
|
15
|
+
# Lazy-required: the gem is standalone-first, and a key-less install
|
|
16
|
+
# that renders no plates never touches the network.
|
|
17
|
+
require "uri"
|
|
18
|
+
|
|
19
|
+
uri = URI("#{Vehicles.configuration.api_base_url.to_s.chomp("/")}#{path}")
|
|
20
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
21
|
+
uri
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The response body of a 2xx, or nil on ANY failure (network, timeout,
|
|
25
|
+
# non-2xx). Callers treat nil as "fall back".
|
|
26
|
+
def get(uri, headers = {})
|
|
27
|
+
require "net/http"
|
|
28
|
+
|
|
29
|
+
config = Vehicles.configuration
|
|
30
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
31
|
+
http.use_ssl = uri.scheme == "https"
|
|
32
|
+
http.open_timeout = config.api_timeout
|
|
33
|
+
http.read_timeout = config.api_timeout
|
|
34
|
+
# Net::HTTP silently re-sends an idempotent GET once after a read
|
|
35
|
+
# timeout. That would double the latency we promise to cap AND bill a
|
|
36
|
+
# served image twice, so one request means one request.
|
|
37
|
+
http.max_retries = 0
|
|
38
|
+
|
|
39
|
+
request = Net::HTTP::Get.new(uri)
|
|
40
|
+
headers.each { |name, value| request[name] = value }
|
|
41
|
+
response = http.request(request)
|
|
42
|
+
response.body if response.is_a?(Net::HTTPSuccess)
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
warn "[vehicles] request to VehiclesDB failed (#{e.class}); falling back" if $DEBUG
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/vehicles/make.rb
CHANGED
|
@@ -9,6 +9,10 @@ module Vehicles
|
|
|
9
9
|
# make.models # => ["A3", "A4", "Q3", ...]
|
|
10
10
|
# make.model("a3")# => #<Vehicles::Model "Audi A3">
|
|
11
11
|
class Make
|
|
12
|
+
# "Serie 3" — how BMW sells the 3 Series in Spain and Italy (and "Série"
|
|
13
|
+
# in France, which normalizes the same) — read as "3 Series".
|
|
14
|
+
LOCALIZED_SERIES = /\Aserie (\w+)\z/
|
|
15
|
+
|
|
12
16
|
attr_reader :name, :slug, :aliases, :kinds
|
|
13
17
|
|
|
14
18
|
def initialize(attrs)
|
|
@@ -58,15 +62,26 @@ module Vehicles
|
|
|
58
62
|
models(**filters).map { |m| [m.name, m.model_slug] }
|
|
59
63
|
end
|
|
60
64
|
|
|
61
|
-
# Find one model within this make by
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
|
|
65
|
+
# Find one model within this make by name or slug. Forgiving about case,
|
|
66
|
+
# accents and spacing — "a3", "A 3", "XCeed" (the dataset's "X Ceed") and
|
|
67
|
+
# BMW's Spanish "Serie 3" (→ "3 Series") all resolve — but partial input
|
|
68
|
+
# does NOT (so `model("a")` returns nil, not "A3" — important, since the
|
|
69
|
+
# vehicle_model validator relies on this). Fuzzy/partial matching lives in
|
|
70
|
+
# `Vehicles.search`.
|
|
71
|
+
#
|
|
72
|
+
# Some names exist as several kinds (Toyota "Land Cruiser" is a car, a
|
|
73
|
+
# van, a truck and a bus). Without `kind:` the car wins — it's what people
|
|
74
|
+
# mean — and pass `kind:` to pick another:
|
|
75
|
+
# toyota.model("Land Cruiser") # => the car
|
|
76
|
+
# toyota.model("Land Cruiser", kind: :bus) # => the bus
|
|
77
|
+
def model(query, kind: nil)
|
|
66
78
|
q = Vehicles.normalize(query)
|
|
67
79
|
return nil if q.empty?
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
pool = models(kind: kind)
|
|
82
|
+
series = q.sub(LOCALIZED_SERIES, '\\1 series')
|
|
83
|
+
matches = named(pool, q) || (named(pool, series) unless series == q)
|
|
84
|
+
matches && (matches.find(&:car?) || matches.first)
|
|
70
85
|
end
|
|
71
86
|
|
|
72
87
|
def to_h
|
|
@@ -89,5 +104,20 @@ module Vehicles
|
|
|
89
104
|
def inspect
|
|
90
105
|
%(#<Vehicles::Make "#{name}">)
|
|
91
106
|
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# The models answering to a normalized query: by exact name or slug; failing
|
|
111
|
+
# that, by the same letters and digits with spacing ignored ("xceed" is
|
|
112
|
+
# "X Ceed", "a 5" is "A5") — but only when that lands on ONE name, never a
|
|
113
|
+
# guess between two. nil when nothing answers.
|
|
114
|
+
def named(pool, query)
|
|
115
|
+
exact = pool.select { |m| Vehicles.normalize(m.name) == query || m.model_slug == query }
|
|
116
|
+
return exact if exact.any?
|
|
117
|
+
|
|
118
|
+
squeezed = query.delete(" ")
|
|
119
|
+
loose = pool.select { |m| Vehicles.normalize(m.name).delete(" ") == squeezed }
|
|
120
|
+
loose if loose.map { |m| Vehicles.normalize(m.name) }.uniq.one?
|
|
121
|
+
end
|
|
92
122
|
end
|
|
93
123
|
end
|
data/lib/vehicles/mcp_server.rb
CHANGED
|
@@ -162,7 +162,7 @@ module Vehicles
|
|
|
162
162
|
makes = Vehicles.dataset.makes(kind: kind)
|
|
163
163
|
unless q.strip.empty?
|
|
164
164
|
n = Vehicles.normalize(q)
|
|
165
|
-
exact = Vehicles.dataset.find_make(q)
|
|
165
|
+
exact = Vehicles.dataset.find_make(q, fuzzy: false) # MCP tools stay exact (agents pass ids/slugs)
|
|
166
166
|
makes = makes.select { |m| Vehicles.normalize(m.name).include?(n) }
|
|
167
167
|
makes = [exact, *makes].uniq if exact && (kind.nil? || exact.kinds.include?(kind))
|
|
168
168
|
end
|
|
@@ -178,8 +178,8 @@ module Vehicles
|
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
def get_model(args)
|
|
181
|
-
m = Vehicles.model(args.fetch("make"), args.fetch("model")) ||
|
|
182
|
-
Vehicles.find("#{args.fetch("make")} #{args.fetch("model")}")
|
|
181
|
+
m = Vehicles.model(args.fetch("make"), args.fetch("model"), fuzzy: false) ||
|
|
182
|
+
Vehicles.find("#{args.fetch("make")} #{args.fetch("model")}", fuzzy: false)
|
|
183
183
|
if m
|
|
184
184
|
model_json(m)
|
|
185
185
|
else
|
|
@@ -194,8 +194,8 @@ module Vehicles
|
|
|
194
194
|
list = Vehicles.dataset.top_models(kind: args["kind"]&.to_sym,
|
|
195
195
|
country: args["country"], limit: limit)
|
|
196
196
|
{ models: list.map { |m| model_json(m) },
|
|
197
|
-
note: "Ranked
|
|
198
|
-
"
|
|
197
|
+
note: "Ranked from official registration counts: with a country, by that country's own " \
|
|
198
|
+
"rank (per kind — pass kind for a clean list); without one, by global popularity decile." }
|
|
199
199
|
end
|
|
200
200
|
|
|
201
201
|
def model_json(model)
|
data/lib/vehicles/model.rb
CHANGED
|
@@ -25,10 +25,10 @@ module Vehicles
|
|
|
25
25
|
REGIONS = %i[eu na as sa oc af].freeze
|
|
26
26
|
|
|
27
27
|
attr_reader :make, :make_slug, :name, :kind, :body_type, :global_decile,
|
|
28
|
-
:availability, :regions, :aliases
|
|
28
|
+
:availability, :regions, :aliases, :former_ids, :country_ranks, :mass_decile
|
|
29
29
|
|
|
30
30
|
# @param attrs [Hash] one model entry from the dataset (name/slug/kind +
|
|
31
|
-
# optional body_type/global_decile/availability/regions/aliases)
|
|
31
|
+
# optional body_type/global_decile/availability/regions/aliases/former_ids)
|
|
32
32
|
# @param make [String] the parent make's display name
|
|
33
33
|
# @param make_slug [String] the parent make's slug (for the composite slug)
|
|
34
34
|
def initialize(attrs, make:, make_slug:)
|
|
@@ -51,6 +51,22 @@ module Vehicles
|
|
|
51
51
|
@regions = (attrs["regions"] || []).map(&:to_sym).freeze
|
|
52
52
|
# Documented alternate names (nicknames, native scripts, market names).
|
|
53
53
|
@aliases = (attrs["aliases"] || []).freeze
|
|
54
|
+
# Full canonical ids ("car/alfa-romeo/159sw") this record absorbed via
|
|
55
|
+
# the dataset's append-only migration contract (SCHEMA.md: renames
|
|
56
|
+
# alias, nothing is silently deleted). Empty for never-renamed records.
|
|
57
|
+
@former_ids = (attrs["former_ids"] || []).freeze
|
|
58
|
+
# { "gb" => 2, "nl" => 1, ... } — the model's rank among every model of
|
|
59
|
+
# its KIND in that country, from official registration counts (dataset
|
|
60
|
+
# projection key `country_ranks`, data 2026.10+). Empty when unranked or
|
|
61
|
+
# when the snapshot predates the key; `rank_in` then returns nil.
|
|
62
|
+
@country_ranks = (attrs["country_ranks"] || {}).freeze
|
|
63
|
+
# Per-KIND mass decile 1 (heaviest 10% by registrations summed across
|
|
64
|
+
# measured countries) … 10 — "how many exist", where global_decile
|
|
65
|
+
# (a presence average) answers "popular in many places". Published from
|
|
66
|
+
# dataset 2026.10.1 (pipeline#255, ruling R3); nil on older snapshots
|
|
67
|
+
# (including the bundled 2026.10.0) and for unranked records.
|
|
68
|
+
pop = attrs["popularity"]
|
|
69
|
+
@mass_decile = attrs["mass_decile"] || (pop.is_a?(Hash) ? pop["mass_decile"] : nil)
|
|
54
70
|
freeze
|
|
55
71
|
end
|
|
56
72
|
|
|
@@ -86,6 +102,13 @@ module Vehicles
|
|
|
86
102
|
availability.include?(country.to_s.downcase)
|
|
87
103
|
end
|
|
88
104
|
|
|
105
|
+
# Rank in one country among all models of the same kind (1 = most
|
|
106
|
+
# registered there), or nil when that country has no ranking for it.
|
|
107
|
+
# Vehicles.find("vw golf").rank_in(:gb) # => 2
|
|
108
|
+
def rank_in(country)
|
|
109
|
+
country_ranks[country.to_s.downcase]
|
|
110
|
+
end
|
|
111
|
+
|
|
89
112
|
# Evidence of presence on a continent (:eu/:na/:sa/:as/:oc/:af):
|
|
90
113
|
# Vehicles.find("perodua myvi").available_in_region?(:as) # => true
|
|
91
114
|
def available_in_region?(region)
|
|
@@ -126,7 +149,8 @@ module Vehicles
|
|
|
126
149
|
def to_h
|
|
127
150
|
{ make: make, model: name, slug: slug, kind: kind, body_type: body_type,
|
|
128
151
|
global_decile: global_decile, rarity: rarity,
|
|
129
|
-
availability: availability, regions: regions, aliases: aliases
|
|
152
|
+
availability: availability, regions: regions, aliases: aliases,
|
|
153
|
+
former_ids: former_ids, country_ranks: country_ranks, mass_decile: mass_decile }
|
|
130
154
|
end
|
|
131
155
|
|
|
132
156
|
# Value-object equality — two models with the same slug are equal.
|
|
@@ -159,9 +183,21 @@ module Vehicles
|
|
|
159
183
|
Vehicles.resolve(:segment, self)
|
|
160
184
|
end
|
|
161
185
|
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
186
|
+
# One rendered image URL, ready for an <img src>. `size` picks the
|
|
187
|
+
# variant (:sm/:md/:lg), `color` a palette slug (Vehicles.colors); an
|
|
188
|
+
# un-rendered color falls back honestly server-side rather than erroring.
|
|
189
|
+
# `year` is accepted for forward-compatibility but not yet served.
|
|
190
|
+
# nil without hosted data — always render a placeholder path.
|
|
191
|
+
def image(year: nil, color: nil, size: :md)
|
|
192
|
+
Vehicles.resolve(:image, self, year: year, color: color, size: size)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The full hosted images payload — every variant with dimensions, the
|
|
196
|
+
# rendered palette for this model, served-vs-requested color, provenance.
|
|
197
|
+
# Reach for this when one URL isn't enough (srcset, color pickers,
|
|
198
|
+
# caching whole responses). nil without hosted data.
|
|
199
|
+
def images(color: nil)
|
|
200
|
+
Vehicles.resolve(:images, self, color: color)
|
|
165
201
|
end
|
|
166
202
|
end
|
|
167
203
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
module Plates
|
|
5
|
+
# One issuing jurisdiction — a country, or a state where plates are
|
|
6
|
+
# state-issued ("us-fl"; a bare "us" will never exist). Wraps the raw
|
|
7
|
+
# dataset YAML: authority, binding (vehicle vs owner), the
|
|
8
|
+
# jurisdiction-wide charset law, and every series.
|
|
9
|
+
class Jurisdiction
|
|
10
|
+
attr_reader :code, :authority_name, :authority_url, :binding, :charset, :series
|
|
11
|
+
|
|
12
|
+
def self.load(path)
|
|
13
|
+
raw = YAML.safe_load_file(path, aliases: true)
|
|
14
|
+
new(code: File.basename(path, ".yml"), raw: raw)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(code:, raw:)
|
|
18
|
+
@code = code
|
|
19
|
+
@authority_name = raw.dig("authority", "name")
|
|
20
|
+
@authority_url = raw.dig("authority", "url")
|
|
21
|
+
@binding = raw["binding"]
|
|
22
|
+
@charset = (raw["charset_defaults"] || {}).freeze
|
|
23
|
+
@series = (raw["series"] || []).map { |entry| Series.new(entry) }
|
|
24
|
+
.sort_by { |s| [ s.klass == "standard" ? 0 : 1, s.klass, s.period["start"] || 0 ] }.freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s = code
|
|
29
|
+
def inspect = %(#<Vehicles::Plates::Jurisdiction #{code} (#{series.size} series)>)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|