Unify name builder usage

This commit is contained in:
Eugene Burmakin 2025-05-12 23:36:46 +02:00
parent aa521dba9b
commit 857f1da942
10 changed files with 349 additions and 100 deletions

View file

@ -7,15 +7,17 @@ class Api::PlaceSerializer
def call
{
id: place.id,
name: place.name,
longitude: place.lon,
latitude: place.lat,
city: place.city,
country: place.country,
source: place.source,
geodata: place.geodata,
reverse_geocoded_at: place.reverse_geocoded_at
id: place.id,
name: place.name,
longitude: place.lon,
latitude: place.lat,
city: place.city,
country: place.country,
source: place.source,
geodata: place.geodata,
reverse_geocoded_at: place.reverse_geocoded_at,
created_at: place.created_at,
updated_at: place.updated_at
}
end

View file

@ -11,7 +11,7 @@ module Visits
def initialize(points)
@points = points
@place_name_suggester = PlaceNameSuggester
@place_name_suggester = Visits::Names::Suggester
end
def detect_potential_visits
@ -90,7 +90,7 @@ module Visits
center_lat: center[0],
center_lon: center[1],
radius: calculate_visit_radius(points, center),
suggested_name: suggest_place_name(points)
suggested_name: suggest_place_name(points) || fetch_place_name(center)
)
end
@ -114,5 +114,9 @@ module Visits
def suggest_place_name(points)
place_name_suggester.new(points).call
end
def fetch_place_name(center)
Visits::Names::Fetcher.new(center).call
end
end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
module Visits
module Names
# Builds descriptive names for places from geodata features
class Builder
def self.build_from_properties(properties)
return nil if properties.blank?
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['city'],
properties['state']
].compact.reject(&:empty?).uniq
name_components.any? ? name_components.join(', ') : nil
end
def initialize(features, feature_type, name)
@features = features
@feature_type = feature_type
@name = name
end
def call
return nil if features.blank? || feature_type.blank? || name.blank?
return nil unless feature
[
name,
properties['street'],
properties['city'],
properties['state']
].compact.uniq.join(', ')
end
private
attr_reader :features, :feature_type, :name
def feature
@feature ||= find_feature
end
def find_feature
features.find do |f|
f.dig('properties', 'type') == feature_type &&
f.dig('properties', 'name') == name
end || find_feature_by_osm_value
end
def find_feature_by_osm_value
features.find do |f|
f.dig('properties', 'osm_value') == feature_type &&
f.dig('properties', 'name') == name
end
end
def properties
return {} unless feature && feature['properties'].is_a?(Hash)
feature['properties']
end
end
end
end

View file

@ -0,0 +1,56 @@
# frozen_string_literal: true
module Visits
module Names
# Fetches names for places from reverse geocoding API
class Fetcher
def initialize(center)
@center = center
end
def call
return nil if geocoder_results.blank?
build_place_name
end
private
attr_reader :center
def geocoder_results
@geocoder_results ||= Geocoder.search(
center, limit: 10, distance_sort: true, radius: 1, units: ::DISTANCE_UNIT
)
end
def build_place_name
return nil if geocoder_results.first&.data.blank?
properties = geocoder_results.first.data['properties']
return nil unless properties.present?
# First try the direct properties approach
name = Visits::Names::Builder.build_from_properties(properties)
return name if name.present?
# Fall back to the instance-based approach
return nil unless properties['name'] && properties['osm_value']
Visits::Names::Builder.new(
features,
properties['osm_value'],
properties['name']
).call
end
def features
geocoder_results.map do |result|
{
'properties' => result.data['properties']
}
end.compact
end
end
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
module Visits
module Names
# Suggests names for places based on geodata from tracked points
class Suggester
def initialize(points)
@points = points
end
def call
geocoded_points = extract_geocoded_points(points)
return nil if geocoded_points.empty?
features = extract_features(geocoded_points)
return nil if features.empty?
most_common_type = find_most_common_feature_type(features)
return nil unless most_common_type
most_common_name = find_most_common_name(features, most_common_type)
return nil if most_common_name.blank?
Visits::Names::Builder.new(
features, most_common_type, most_common_name
).call
end
private
attr_reader :points
def extract_geocoded_points(points)
points.select { |p| p.geodata.present? && !p.geodata.empty? }
end
def extract_features(geocoded_points)
geocoded_points.flat_map do |point|
next [] unless point.geodata['features'].is_a?(Array)
point.geodata['features']
end.compact
end
def find_most_common_feature_type(features)
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
.transform_values(&:size)
feature_counts.max_by { |_, count| count }&.first
end
def find_most_common_name(features, feature_type)
common_features = features.select { |f| f.dig('properties', 'type') == feature_type }
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
.transform_values(&:size)
name_counts.max_by { |_, count| count }&.first
end
end
end
end

View file

@ -51,7 +51,7 @@ module Visits
return existing_by_location if existing_by_location
# Then try by name if available
return nil unless name.present?
return nil if name.blank?
Place.where(name: name)
.near([lat, lon], SEARCH_RADIUS, :m)
@ -64,16 +64,13 @@ module Visits
lon = visit_data[:center_lon]
# Get places from points' geodata
places_from_points = extract_places_from_points(visit_data[:points], lat, lon)
# Get places from external API
places_from_api = fetch_places_from_api(lat, lon)
places_from_points = extract_places_from_points(visit_data[:points])
# Combine and deduplicate by name
combined_places = []
# Add API places first (usually better quality)
places_from_api.each do |api_place|
reverse_geocoded_places(lat, lon).each do |api_place|
combined_places << api_place unless place_name_exists?(combined_places, api_place.name)
end
@ -86,7 +83,7 @@ module Visits
end
# Step 3: Extract places from points
def extract_places_from_points(points, center_lat, center_lon)
def extract_places_from_points(points)
return [] if points.blank?
# Filter points with geodata
@ -101,7 +98,7 @@ module Visits
places << place if place
end
places.uniq { |place| place.name }
places.uniq(&:name)
end
# Step 4: Create place from point
@ -141,7 +138,7 @@ module Visits
end
# Step 5: Fetch places from API
def fetch_places_from_api(lat, lon)
def reverse_geocoded_places(lat, lon)
# Get broader search results from Geocoder
geocoder_results = Geocoder.search([lat, lon], units: :km, limit: 20, distance_sort: true)
return [] if geocoder_results.blank?
@ -228,15 +225,22 @@ module Visits
# Helper methods
def build_place_name(properties)
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.reject(&:empty?).uniq
# First try building with our name builder
built_name = Visits::Names::Builder.build_from_properties(properties)
return built_name if built_name.present?
name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
# Try using the instance-based approach as a fallback
features = [{ 'properties' => properties }]
feature_type = properties['type'] || properties['osm_value']
name = properties['name']
if feature_type.present? && name.present?
built_name = Visits::Names::Builder.new(features, feature_type, name).call
return built_name if built_name.present?
end
# Fallback to the default name if all else fails
Place::DEFAULT_NAME
end
def place_name_exists?(places, name)

View file

@ -1,70 +0,0 @@
# frozen_string_literal: true
module Visits
# Suggests names for places based on geodata from tracked points
class PlaceNameSuggester
def initialize(points)
@points = points
end
def call
geocoded_points = extract_geocoded_points(points)
return nil if geocoded_points.empty?
features = extract_features(geocoded_points)
return nil if features.empty?
most_common_type = find_most_common_feature_type(features)
return nil unless most_common_type
most_common_name = find_most_common_name(features, most_common_type)
return nil if most_common_name.blank?
build_descriptive_name(features, most_common_type, most_common_name)
end
private
attr_reader :points
def extract_geocoded_points(points)
points.select { |p| p.geodata.present? && !p.geodata.empty? }
end
def extract_features(geocoded_points)
geocoded_points.flat_map do |point|
next [] unless point.geodata['features'].is_a?(Array)
point.geodata['features']
end.compact
end
def find_most_common_feature_type(features)
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
.transform_values(&:size)
feature_counts.max_by { |_, count| count }&.first
end
def find_most_common_name(features, feature_type)
common_features = features.select { |f| f.dig('properties', 'type') == feature_type }
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
.transform_values(&:size)
name_counts.max_by { |_, count| count }&.first
end
def build_descriptive_name(features, feature_type, name)
feature = features.find do |f|
f.dig('properties', 'type') == feature_type &&
f.dig('properties', 'name') == name
end
properties = feature['properties']
[
name,
properties['street'],
properties['city'],
properties['state']
].compact.uniq.join(', ')
end
end
end

View file

@ -0,0 +1,126 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::Names::Builder do
describe '.build_from_properties' do
it 'builds a name from all available properties' do
properties = {
'name' => 'Coffee Shop',
'street' => 'Main St',
'housenumber' => '123',
'city' => 'New York',
'state' => 'NY'
}
result = described_class.build_from_properties(properties)
expect(result).to eq('Coffee Shop, Main St, 123, New York, NY')
end
it 'handles missing properties' do
properties = {
'name' => 'Coffee Shop',
'city' => 'New York',
'state' => 'NY'
}
result = described_class.build_from_properties(properties)
expect(result).to eq('Coffee Shop, New York, NY')
end
it 'deduplicates components' do
properties = {
'name' => 'New York Cafe',
'city' => 'New York',
'state' => 'NY'
}
result = described_class.build_from_properties(properties)
expect(result).to eq('New York Cafe, New York, NY')
end
it 'returns nil for empty properties' do
result = described_class.build_from_properties({})
expect(result).to be_nil
end
it 'returns nil for nil properties' do
result = described_class.build_from_properties(nil)
expect(result).to be_nil
end
end
describe '#call' do
subject { described_class.new(features, feature_type, name).call }
let(:feature_type) { 'amenity' }
let(:name) { 'Coffee Shop' }
let(:features) do
[
{
'properties' => {
'type' => 'amenity',
'name' => 'Coffee Shop',
'street' => '123 Main St',
'city' => 'San Francisco',
'state' => 'CA'
}
},
{
'properties' => {
'type' => 'park',
'name' => 'Central Park',
'city' => 'New York',
'state' => 'NY'
}
}
]
end
it 'returns a descriptive name with all available components' do
expect(subject).to eq('Coffee Shop, 123 Main St, San Francisco, CA')
end
context 'when feature uses osm_value instead of type' do
let(:features) do
[
{
'properties' => {
'osm_value' => 'amenity',
'name' => 'Coffee Shop',
'street' => '123 Main St',
'city' => 'San Francisco',
'state' => 'CA'
}
}
]
end
it 'finds the feature using osm_value' do
expect(subject).to eq('Coffee Shop, 123 Main St, San Francisco, CA')
end
end
context 'when no matching feature is found' do
let(:name) { 'Non-existent Shop' }
it 'returns nil' do
expect(subject).to be_nil
end
end
context 'with empty inputs' do
it 'returns nil for empty features' do
expect(described_class.new([], feature_type, name).call).to be_nil
end
it 'returns nil for blank feature_type' do
expect(described_class.new(features, '', name).call).to be_nil
end
it 'returns nil for blank name' do
expect(described_class.new(features, feature_type, '').call).to be_nil
end
end
end
end

View file

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe Visits::PlaceNameSuggester do
RSpec.describe Visits::Names::Suggester do
subject(:suggester) { described_class.new(points) }
describe '#call' do

View file

@ -75,7 +75,7 @@ RSpec.describe Visits::PlaceFinder do
before do
allow(Geocoder).to receive(:search).and_return([])
allow(subject).to receive(:fetch_places_from_api).and_return([])
allow(subject).to receive(:reverse_geocoded_places).and_return([])
end
it 'extracts and creates places from point geodata' do