prospector_engine 0.1.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +333 -0
- data/Rakefile +9 -0
- data/app/CLAUDE.md +43 -0
- data/app/assets/stylesheets/prospector/application.css +476 -0
- data/app/controllers/prospector/application_controller.rb +16 -0
- data/app/controllers/prospector/candidates_controller.rb +31 -0
- data/app/controllers/prospector/keyword_generations_controller.rb +10 -0
- data/app/controllers/prospector/keywords_controller.rb +38 -0
- data/app/controllers/prospector/run_bulk_approvals_controller.rb +13 -0
- data/app/controllers/prospector/run_cancellations_controller.rb +9 -0
- data/app/controllers/prospector/run_reclassifications_controller.rb +21 -0
- data/app/controllers/prospector/run_restarts_controller.rb +14 -0
- data/app/controllers/prospector/run_retries_controller.rb +14 -0
- data/app/controllers/prospector/runs_controller.rb +47 -0
- data/app/jobs/prospector/application_job.rb +5 -0
- data/app/jobs/prospector/bulk_approve_job.rb +14 -0
- data/app/jobs/prospector/classify_job.rb +17 -0
- data/app/jobs/prospector/fetch_job.rb +8 -0
- data/app/models/prospector/application_record.rb +6 -0
- data/app/models/prospector/candidate.rb +93 -0
- data/app/models/prospector/classification_run.rb +15 -0
- data/app/models/prospector/keyword.rb +16 -0
- data/app/models/prospector/run.rb +94 -0
- data/app/views/prospector/candidates/show.html.erb +63 -0
- data/app/views/prospector/keywords/index.html.erb +72 -0
- data/app/views/prospector/layouts/prospector.html.erb +38 -0
- data/app/views/prospector/runs/index.html.erb +33 -0
- data/app/views/prospector/runs/new.html.erb +109 -0
- data/app/views/prospector/runs/show.html.erb +111 -0
- data/config/routes.rb +15 -0
- data/db/prospector_schema.rb +81 -0
- data/lib/generators/prospector/install/install_generator.rb +31 -0
- data/lib/generators/prospector/install/templates/create_prospector_tables.rb +83 -0
- data/lib/generators/prospector/install/templates/prospector.rb +37 -0
- data/lib/prospector/CLAUDE.md +52 -0
- data/lib/prospector/classification/runner.rb +105 -0
- data/lib/prospector/configuration.rb +56 -0
- data/lib/prospector/engine.rb +18 -0
- data/lib/prospector/enrichment/contact_scraper.rb +188 -0
- data/lib/prospector/error.rb +8 -0
- data/lib/prospector/geography/base.rb +40 -0
- data/lib/prospector/geography/bounding_box.rb +58 -0
- data/lib/prospector/geography/city.rb +29 -0
- data/lib/prospector/geography/coordinates.rb +43 -0
- data/lib/prospector/geography/metro_area.rb +74 -0
- data/lib/prospector/geography/zip_code.rb +25 -0
- data/lib/prospector/keywords/generator.rb +74 -0
- data/lib/prospector/pipeline/normalizer.rb +57 -0
- data/lib/prospector/pipeline/orchestrator.rb +151 -0
- data/lib/prospector/sources/base.rb +13 -0
- data/lib/prospector/sources/google_places/adapter.rb +92 -0
- data/lib/prospector/sources/google_places/client.rb +58 -0
- data/lib/prospector/sources/google_places/us_address_validator.rb +24 -0
- data/lib/prospector/sources/result.rb +21 -0
- data/lib/prospector/version.rb +3 -0
- data/lib/prospector.rb +20 -0
- metadata +185 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Prospector
|
|
2
|
+
class BulkApproveJob < ApplicationJob
|
|
3
|
+
def perform(run_id)
|
|
4
|
+
run = Run.find(run_id)
|
|
5
|
+
run.candidates.where(status: "pending").find_each do |candidate|
|
|
6
|
+
next unless candidate.approvable?
|
|
7
|
+
|
|
8
|
+
candidate.approve!
|
|
9
|
+
rescue => e
|
|
10
|
+
Rails.logger.warn "Prospector: skip approve #{candidate.id}: #{e.message}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Prospector
|
|
2
|
+
class ClassifyJob < ApplicationJob
|
|
3
|
+
def perform(run_id, force: false, model: nil, classification_run_id: nil)
|
|
4
|
+
run = Run.find(run_id)
|
|
5
|
+
classification_run = ClassificationRun.find_by(id: classification_run_id)
|
|
6
|
+
|
|
7
|
+
classification_run&.update!(status: "running", started_at: Time.current)
|
|
8
|
+
|
|
9
|
+
Classification::Runner.new(
|
|
10
|
+
run,
|
|
11
|
+
model: model,
|
|
12
|
+
include_approved: force,
|
|
13
|
+
classification_run: classification_run
|
|
14
|
+
).perform
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module Prospector
|
|
2
|
+
class Candidate < ApplicationRecord
|
|
3
|
+
STATUSES = %w[pending approved rejected].freeze
|
|
4
|
+
|
|
5
|
+
belongs_to :run
|
|
6
|
+
|
|
7
|
+
validates :name, presence: true
|
|
8
|
+
validates :address, presence: true
|
|
9
|
+
validates :source_uid, presence: true, uniqueness: { scope: :run_id }
|
|
10
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
11
|
+
|
|
12
|
+
if defined?(Turbo::Broadcastable)
|
|
13
|
+
broadcasts_refreshes_to :run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
17
|
+
scope :pending, -> { where(status: "pending") }
|
|
18
|
+
scope :approved, -> { where(status: "approved") }
|
|
19
|
+
scope :rejected, -> { where(status: "rejected") }
|
|
20
|
+
scope :ai_unprocessed, -> { where("NOT (metadata ? 'llm_categories')") }
|
|
21
|
+
scope :ai_processed, -> { where("metadata ? 'llm_categories'") }
|
|
22
|
+
scope :auto_rejected, -> { rejected.where("metadata->>'rejection_reason' = ?", "no_relevant_categories") }
|
|
23
|
+
|
|
24
|
+
def pending? = status == "pending"
|
|
25
|
+
def approved? = status == "approved"
|
|
26
|
+
def rejected? = status == "rejected"
|
|
27
|
+
|
|
28
|
+
def approve!
|
|
29
|
+
raise Error, "Candidate is not approvable" unless approvable?
|
|
30
|
+
|
|
31
|
+
update!(status: "approved")
|
|
32
|
+
complete_approval
|
|
33
|
+
rescue => e
|
|
34
|
+
update!(status: "pending") if approved?
|
|
35
|
+
raise Error, "Approval failed: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reject!(reason: nil)
|
|
39
|
+
attrs = { status: "rejected" }
|
|
40
|
+
attrs[:metadata] = metadata.merge("rejection_reason" => reason) if reason
|
|
41
|
+
update!(attrs)
|
|
42
|
+
instrument("prospector.candidate.rejected", candidate: self, reason: reason)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def restore_to_pending!
|
|
46
|
+
new_metadata = metadata.except("rejection_reason")
|
|
47
|
+
update!(status: "pending", metadata: new_metadata)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalized_data
|
|
51
|
+
metadata["normalized_data"] || {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def llm_categories
|
|
55
|
+
metadata["llm_categories"] || []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def llm_confidence
|
|
59
|
+
metadata["llm_confidence"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def llm_reasoning
|
|
63
|
+
metadata["llm_reasoning"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
SOCIAL_FIELDS = %i[facebook_url instagram_url linkedin_url tiktok_url youtube_url].freeze
|
|
67
|
+
|
|
68
|
+
def approvable?
|
|
69
|
+
pending? && normalized_data["state"].present?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def has_contact_info?
|
|
73
|
+
email.present? || SOCIAL_FIELDS.any? { |f| self[f].present? }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def complete_approval
|
|
79
|
+
record = invoke_on_approve
|
|
80
|
+
instrument("prospector.candidate.approved", candidate: self, record: record)
|
|
81
|
+
record
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def invoke_on_approve
|
|
85
|
+
callback = Prospector.config.on_approve
|
|
86
|
+
callback ? callback.call(self) : nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def instrument(event, payload = {})
|
|
90
|
+
ActiveSupport::Notifications.instrument(event, payload)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Prospector
|
|
2
|
+
class ClassificationRun < ApplicationRecord
|
|
3
|
+
STATUSES = %w[pending running completed failed].freeze
|
|
4
|
+
|
|
5
|
+
belongs_to :run
|
|
6
|
+
|
|
7
|
+
validates :ai_model, presence: true
|
|
8
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
9
|
+
|
|
10
|
+
def pending? = status == "pending"
|
|
11
|
+
def running? = status == "running"
|
|
12
|
+
def completed? = status == "completed"
|
|
13
|
+
def failed? = status == "failed"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Prospector
|
|
2
|
+
class Keyword < ApplicationRecord
|
|
3
|
+
validates :domain, presence: true
|
|
4
|
+
validates :category, presence: true
|
|
5
|
+
validates :keyword, presence: true, uniqueness: { scope: %i[domain category] }
|
|
6
|
+
validates :source, presence: true
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where(active: true) }
|
|
9
|
+
scope :for_domain, ->(domain) { where(domain: domain) }
|
|
10
|
+
scope :for_category, ->(category) { where(category: category) }
|
|
11
|
+
|
|
12
|
+
def self.keywords_for(domain:, category:)
|
|
13
|
+
active.for_domain(domain).for_category(category).pluck(:keyword)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Prospector
|
|
2
|
+
class Run < ApplicationRecord
|
|
3
|
+
STATUSES = %w[pending running classifying completed failed cancelled].freeze
|
|
4
|
+
|
|
5
|
+
has_many :candidates, dependent: :destroy
|
|
6
|
+
has_many :classification_runs, dependent: :destroy
|
|
7
|
+
|
|
8
|
+
validates :status, presence: true, inclusion: { in: STATUSES }
|
|
9
|
+
validates :source_adapter, presence: true
|
|
10
|
+
validates :geography_type, presence: true
|
|
11
|
+
|
|
12
|
+
if defined?(Turbo::Broadcastable)
|
|
13
|
+
broadcasts_refreshes
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
after_update_commit :instrument_status_change, if: :saved_change_to_status?
|
|
17
|
+
|
|
18
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
19
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
20
|
+
|
|
21
|
+
def pending? = status == "pending"
|
|
22
|
+
def running? = status == "running"
|
|
23
|
+
def classifying? = status == "classifying"
|
|
24
|
+
def completed? = status == "completed"
|
|
25
|
+
def failed? = status == "failed"
|
|
26
|
+
def cancelled? = status == "cancelled"
|
|
27
|
+
|
|
28
|
+
def cancellable?
|
|
29
|
+
pending? || running? || classifying?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def restartable?
|
|
33
|
+
completed? || failed? || cancelled?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def retryable? = failed?
|
|
37
|
+
|
|
38
|
+
def cancel!
|
|
39
|
+
update!(status: "cancelled", completed_at: Time.current)
|
|
40
|
+
instrument("prospector.run.cancelled", run: self)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset_for_retry!
|
|
44
|
+
update!(
|
|
45
|
+
status: "pending",
|
|
46
|
+
error_messages: nil,
|
|
47
|
+
error_count: 0,
|
|
48
|
+
started_at: nil,
|
|
49
|
+
completed_at: nil
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def restart!
|
|
54
|
+
transaction do
|
|
55
|
+
candidates.where(status: %w[pending rejected]).destroy_all
|
|
56
|
+
update!(
|
|
57
|
+
status: "pending",
|
|
58
|
+
total_found: 0,
|
|
59
|
+
fetched_count: 0,
|
|
60
|
+
skipped_count: 0,
|
|
61
|
+
error_count: 0,
|
|
62
|
+
error_messages: nil,
|
|
63
|
+
metadata: {},
|
|
64
|
+
started_at: nil,
|
|
65
|
+
completed_at: nil
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def geography
|
|
71
|
+
Geography::Base.from_h(geography_data.merge("type" => geography_type))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def geography=(geo)
|
|
75
|
+
self.geography_type = geo.type
|
|
76
|
+
self.geography_data = geo.to_h
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def instrument_status_change
|
|
82
|
+
ActiveSupport::Notifications.instrument(
|
|
83
|
+
"prospector.run.status_changed",
|
|
84
|
+
run: self,
|
|
85
|
+
status: status,
|
|
86
|
+
previous_status: status_before_last_save
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def instrument(event, payload = {})
|
|
91
|
+
ActiveSupport::Notifications.instrument(event, payload)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%= link_to "← Back to run".html_safe, run_path(@run, filter: @candidate.status), class: "prospector-text-muted prospector-text-sm" %>
|
|
2
|
+
|
|
3
|
+
<h1 class="prospector-heading prospector-mt-4"><%= @candidate.name %></h1>
|
|
4
|
+
|
|
5
|
+
<div class="prospector-flex prospector-gap-2" style="margin-bottom: 1rem;">
|
|
6
|
+
<span class="prospector-badge prospector-badge-<%= @candidate.status %>"><%= @candidate.status.titleize %></span>
|
|
7
|
+
<% if @candidate.llm_confidence %>
|
|
8
|
+
<span class="prospector-text-muted prospector-text-sm">Confidence: <%= (@candidate.llm_confidence * 100).round %>%</span>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="prospector-card">
|
|
13
|
+
<h2 class="prospector-subheading">Details</h2>
|
|
14
|
+
<table class="prospector-table">
|
|
15
|
+
<tbody>
|
|
16
|
+
<tr><td><strong>Address</strong></td><td><%= @candidate.address %></td></tr>
|
|
17
|
+
<tr><td><strong>Phone</strong></td><td><%= @candidate.phone_number || "-" %></td></tr>
|
|
18
|
+
<tr><td><strong>Website</strong></td><td><%= @candidate.website.present? ? link_to(@candidate.website, @candidate.website, target: "_blank") : "-" %></td></tr>
|
|
19
|
+
<tr><td><strong>Category</strong></td><td><%= @candidate.category || "-" %></td></tr>
|
|
20
|
+
<tr><td><strong>Source UID</strong></td><td class="prospector-text-muted"><%= @candidate.source_uid %></td></tr>
|
|
21
|
+
<tr><td><strong>Rating</strong></td><td><%= @candidate.metadata["rating"] || "-" %> (<%= @candidate.metadata["rating_count"] || 0 %> reviews)</td></tr>
|
|
22
|
+
</tbody>
|
|
23
|
+
</table>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<% if @candidate.has_contact_info? %>
|
|
27
|
+
<div class="prospector-card">
|
|
28
|
+
<h2 class="prospector-subheading">Contact & Social</h2>
|
|
29
|
+
<table class="prospector-table">
|
|
30
|
+
<tbody>
|
|
31
|
+
<% if @candidate.email.present? %>
|
|
32
|
+
<tr><td><strong>Email</strong></td><td><%= mail_to @candidate.email %></td></tr>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% Prospector::Candidate::SOCIAL_FIELDS.each do |field| %>
|
|
35
|
+
<% url = @candidate[field] %>
|
|
36
|
+
<% if url.present? %>
|
|
37
|
+
<tr><td><strong><%= field.to_s.chomp("_url").titleize %></strong></td><td><%= link_to truncate(url, length: 50), url, target: "_blank" %></td></tr>
|
|
38
|
+
<% end %>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<% if @candidate.llm_categories.any? %>
|
|
46
|
+
<div class="prospector-card">
|
|
47
|
+
<h2 class="prospector-subheading">AI Classification</h2>
|
|
48
|
+
<p><strong>Categories:</strong> <%= @candidate.llm_categories.join(", ") %></p>
|
|
49
|
+
<p class="prospector-text-muted prospector-text-sm"><%= @candidate.llm_reasoning %></p>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
|
|
53
|
+
<div class="prospector-flex prospector-gap-2 prospector-mt-4">
|
|
54
|
+
<% if @candidate.pending? && @candidate.approvable? %>
|
|
55
|
+
<%= button_to "Approve", run_candidate_path(@run, @candidate, status: "approved"), method: :patch, class: "prospector-btn prospector-btn-primary" %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% if @candidate.pending? %>
|
|
58
|
+
<%= button_to "Reject", run_candidate_path(@run, @candidate, status: "rejected"), method: :patch, class: "prospector-btn prospector-btn-danger" %>
|
|
59
|
+
<% end %>
|
|
60
|
+
<% if @candidate.rejected? %>
|
|
61
|
+
<%= button_to "Restore to Pending", run_candidate_path(@run, @candidate, status: "pending"), method: :patch, class: "prospector-btn prospector-btn-outline" %>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<div class="prospector-flex prospector-flex-between">
|
|
2
|
+
<h1 class="prospector-heading">Keywords</h1>
|
|
3
|
+
<span class="prospector-text-muted prospector-text-sm">Domain: <strong><%= @domain %></strong></span>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="prospector-card">
|
|
7
|
+
<h2 class="prospector-subheading">Add Keyword</h2>
|
|
8
|
+
<%= form_with model: Prospector::Keyword.new, url: keywords_path, method: :post do |f| %>
|
|
9
|
+
<%= f.hidden_field :domain, value: @domain %>
|
|
10
|
+
<div class="prospector-flex prospector-gap-4" style="align-items: flex-end;">
|
|
11
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
12
|
+
<%= f.label :category, class: "prospector-label" %>
|
|
13
|
+
<%= f.text_field :category, class: "prospector-input", placeholder: "e.g. mechanic" %>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="prospector-form-group" style="flex: 2; margin-bottom: 0;">
|
|
16
|
+
<%= f.label :keyword, class: "prospector-label" %>
|
|
17
|
+
<%= f.text_field :keyword, class: "prospector-input", placeholder: "e.g. motorcycle repair shop" %>
|
|
18
|
+
</div>
|
|
19
|
+
<div>
|
|
20
|
+
<%= f.submit "Add", class: "prospector-btn prospector-btn-primary" %>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<% if @categories.any? %>
|
|
27
|
+
<% @categories.each do |category| %>
|
|
28
|
+
<div class="prospector-card">
|
|
29
|
+
<div class="prospector-flex prospector-flex-between" style="margin-bottom: 0.75rem;">
|
|
30
|
+
<h2 class="prospector-subheading" style="margin-bottom: 0;"><%= category %></h2>
|
|
31
|
+
<%= button_to "Generate via AI",
|
|
32
|
+
keyword_generations_path(domain: @domain, category: category),
|
|
33
|
+
method: :post,
|
|
34
|
+
class: "prospector-btn prospector-btn-outline prospector-btn-sm" %>
|
|
35
|
+
</div>
|
|
36
|
+
<table class="prospector-table">
|
|
37
|
+
<thead>
|
|
38
|
+
<tr>
|
|
39
|
+
<th>Keyword</th>
|
|
40
|
+
<th>Source</th>
|
|
41
|
+
<th>Active</th>
|
|
42
|
+
<th></th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
<% @keywords.where(category: category).each do |kw| %>
|
|
47
|
+
<tr>
|
|
48
|
+
<td><%= kw.keyword %></td>
|
|
49
|
+
<td>
|
|
50
|
+
<span class="prospector-badge <%= kw.source == 'llm' ? 'prospector-badge-classifying' : 'prospector-badge-completed' %>">
|
|
51
|
+
<%= kw.source %>
|
|
52
|
+
</span>
|
|
53
|
+
</td>
|
|
54
|
+
<td>
|
|
55
|
+
<%= button_to keyword_path(kw), method: :patch, class: "prospector-btn prospector-btn-outline prospector-btn-sm" do %>
|
|
56
|
+
<%= kw.active? ? "Active" : "Disabled" %>
|
|
57
|
+
<% end %>
|
|
58
|
+
</td>
|
|
59
|
+
<td>
|
|
60
|
+
<%= button_to "Remove", keyword_path(kw), method: :delete, class: "prospector-btn prospector-btn-danger prospector-btn-sm" %>
|
|
61
|
+
</td>
|
|
62
|
+
</tr>
|
|
63
|
+
<% end %>
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
<% else %>
|
|
69
|
+
<div class="prospector-card" style="text-align: center; padding: 3rem;">
|
|
70
|
+
<p class="prospector-text-muted">No keywords yet. Add one manually or generate via AI.</p>
|
|
71
|
+
</div>
|
|
72
|
+
<% end %>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Prospector</title>
|
|
7
|
+
<%= stylesheet_link_tag "prospector/application", media: "all" %>
|
|
8
|
+
<%= csrf_meta_tags %>
|
|
9
|
+
</head>
|
|
10
|
+
<body style="margin: 0;">
|
|
11
|
+
<div class="prospector-ui">
|
|
12
|
+
<nav class="prospector-nav">
|
|
13
|
+
<div class="prospector-nav-brand">
|
|
14
|
+
<%= link_to "Prospector", prospector.root_path, class: "prospector-nav-title" %>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="prospector-nav-links">
|
|
17
|
+
<%= link_to "Runs", prospector.runs_path, class: "prospector-nav-link" %>
|
|
18
|
+
<%= link_to "Keywords", prospector.keywords_path, class: "prospector-nav-link" %>
|
|
19
|
+
</div>
|
|
20
|
+
</nav>
|
|
21
|
+
<main class="prospector-main">
|
|
22
|
+
<% if notice.present? %>
|
|
23
|
+
<div class="prospector-flash prospector-flash-notice"><%= notice %></div>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% if alert.present? %>
|
|
26
|
+
<div class="prospector-flash prospector-flash-alert"><%= alert %></div>
|
|
27
|
+
<% end %>
|
|
28
|
+
<%= yield %>
|
|
29
|
+
</main>
|
|
30
|
+
<footer class="prospector-footer">
|
|
31
|
+
<div class="prospector-footer-inner">
|
|
32
|
+
<span>© <%= Time.current.year %> Prospector</span>
|
|
33
|
+
<span>An <a href="https://axiumfoundry.com" target="_blank" rel="noopener noreferrer">Axium Foundry</a> product</span>
|
|
34
|
+
</div>
|
|
35
|
+
</footer>
|
|
36
|
+
</div>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<div class="prospector-flex prospector-flex-between">
|
|
2
|
+
<h1 class="prospector-heading">Runs</h1>
|
|
3
|
+
<%= link_to "New Run", new_run_path, class: "prospector-btn prospector-btn-primary" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="prospector-card">
|
|
7
|
+
<table class="prospector-table">
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th>Label</th>
|
|
11
|
+
<th>Source</th>
|
|
12
|
+
<th>Geography</th>
|
|
13
|
+
<th>Status</th>
|
|
14
|
+
<th>Found</th>
|
|
15
|
+
<th>Created</th>
|
|
16
|
+
<th></th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<% @runs.each do |run| %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td><%= link_to run.label || "##{run.id}", run_path(run) %></td>
|
|
23
|
+
<td><%= run.source_adapter %></td>
|
|
24
|
+
<td><%= run.geography.label %></td>
|
|
25
|
+
<td><span class="prospector-badge prospector-badge-<%= run.status %>"><%= run.status.titleize %></span></td>
|
|
26
|
+
<td><%= run.total_found %></td>
|
|
27
|
+
<td class="prospector-text-muted prospector-text-sm"><%= run.created_at.strftime("%b %d, %Y %H:%M") %></td>
|
|
28
|
+
<td><%= link_to "View", run_path(run), class: "prospector-btn prospector-btn-outline prospector-btn-sm" %></td>
|
|
29
|
+
</tr>
|
|
30
|
+
<% end %>
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
</div>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<h1 class="prospector-heading">New Run</h1>
|
|
2
|
+
|
|
3
|
+
<div class="prospector-card">
|
|
4
|
+
<%= form_with model: @run, url: runs_path, method: :post do |f| %>
|
|
5
|
+
<div class="prospector-form-group">
|
|
6
|
+
<%= f.label :label, "Label (optional)", class: "prospector-label" %>
|
|
7
|
+
<%= f.text_field :label, class: "prospector-input", placeholder: "e.g. Dallas motorcycle shops" %>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="prospector-form-group">
|
|
11
|
+
<%= f.label :source_adapter, "Source", class: "prospector-label" %>
|
|
12
|
+
<%= f.select :source_adapter, Prospector.config.sources.keys.map { |k| [k.to_s.titleize, k] }, {}, class: "prospector-select" %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div class="prospector-form-group">
|
|
16
|
+
<%= f.label :geography_type, "Geography Type", class: "prospector-label" %>
|
|
17
|
+
<%= f.select :geography_type,
|
|
18
|
+
Prospector::Geography::Base::TYPES.map { |t| [t.titleize, t] },
|
|
19
|
+
{},
|
|
20
|
+
class: "prospector-select",
|
|
21
|
+
onchange: "document.querySelectorAll('[data-geo-fields]').forEach(el => el.style.display = el.dataset.geoFields === this.value ? '' : 'none')" %>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<fieldset data-geo-fields="metro_area" class="prospector-form-group">
|
|
25
|
+
<legend class="prospector-label">Metro Area</legend>
|
|
26
|
+
<div class="prospector-form-group" style="margin-bottom: 8px;">
|
|
27
|
+
<select class="prospector-select" onchange="
|
|
28
|
+
var opt = this.options[this.selectedIndex];
|
|
29
|
+
var nameField = this.closest('fieldset').querySelector('[name*=name]');
|
|
30
|
+
var stateField = this.closest('fieldset').querySelector('[name*=primary_state]');
|
|
31
|
+
nameField.value = opt.dataset.name || '';
|
|
32
|
+
stateField.value = opt.dataset.state || '';
|
|
33
|
+
">
|
|
34
|
+
<option value="">Select a metro area or enter custom below</option>
|
|
35
|
+
<% Prospector::Geography::MetroArea::PRELOADED.each do |metro| %>
|
|
36
|
+
<option data-name="<%= metro[:name] %>" data-state="<%= metro[:primary_state] %>">
|
|
37
|
+
<%= metro[:name] %>, <%= metro[:primary_state] %>
|
|
38
|
+
</option>
|
|
39
|
+
<% end %>
|
|
40
|
+
</select>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="prospector-flex prospector-gap-4">
|
|
43
|
+
<div class="prospector-form-group" style="flex: 2; margin-bottom: 0;">
|
|
44
|
+
<%= text_field_tag "run[geography_data][name]", nil, class: "prospector-input", placeholder: "Metro area name, e.g. Dallas-Fort Worth" %>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
47
|
+
<%= text_field_tag "run[geography_data][primary_state]", nil, class: "prospector-input", placeholder: "State, e.g. TX" %>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</fieldset>
|
|
51
|
+
|
|
52
|
+
<fieldset data-geo-fields="city" class="prospector-form-group" style="display: none;">
|
|
53
|
+
<legend class="prospector-label">City</legend>
|
|
54
|
+
<div class="prospector-flex prospector-gap-4">
|
|
55
|
+
<div class="prospector-form-group" style="flex: 2; margin-bottom: 0;">
|
|
56
|
+
<%= text_field_tag "run[geography_data][city]", nil, class: "prospector-input", placeholder: "City name, e.g. Austin" %>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
59
|
+
<%= text_field_tag "run[geography_data][state]", nil, class: "prospector-input", placeholder: "State, e.g. TX" %>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</fieldset>
|
|
63
|
+
|
|
64
|
+
<fieldset data-geo-fields="coordinates" class="prospector-form-group" style="display: none;">
|
|
65
|
+
<legend class="prospector-label">Coordinates</legend>
|
|
66
|
+
<div class="prospector-flex prospector-gap-4">
|
|
67
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
68
|
+
<%= text_field_tag "run[geography_data][lat]", nil, class: "prospector-input", placeholder: "Latitude, e.g. 30.267" %>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
71
|
+
<%= text_field_tag "run[geography_data][lng]", nil, class: "prospector-input", placeholder: "Longitude, e.g. -97.743" %>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
74
|
+
<%= text_field_tag "run[geography_data][radius_meters]", "10000", class: "prospector-input", placeholder: "Radius (meters)" %>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</fieldset>
|
|
78
|
+
|
|
79
|
+
<fieldset data-geo-fields="zip_code" class="prospector-form-group" style="display: none;">
|
|
80
|
+
<legend class="prospector-label">ZIP Code</legend>
|
|
81
|
+
<div class="prospector-form-group" style="max-width: 200px; margin-bottom: 0;">
|
|
82
|
+
<%= text_field_tag "run[geography_data][zip]", nil, class: "prospector-input", placeholder: "e.g. 75201" %>
|
|
83
|
+
</div>
|
|
84
|
+
</fieldset>
|
|
85
|
+
|
|
86
|
+
<fieldset data-geo-fields="bounding_box" class="prospector-form-group" style="display: none;">
|
|
87
|
+
<legend class="prospector-label">Bounding Box</legend>
|
|
88
|
+
<div class="prospector-flex prospector-gap-4">
|
|
89
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
90
|
+
<%= text_field_tag "run[geography_data][sw_lat]", nil, class: "prospector-input", placeholder: "SW Latitude" %>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
93
|
+
<%= text_field_tag "run[geography_data][sw_lng]", nil, class: "prospector-input", placeholder: "SW Longitude" %>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
96
|
+
<%= text_field_tag "run[geography_data][ne_lat]", nil, class: "prospector-input", placeholder: "NE Latitude" %>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="prospector-form-group" style="flex: 1; margin-bottom: 0;">
|
|
99
|
+
<%= text_field_tag "run[geography_data][ne_lng]", nil, class: "prospector-input", placeholder: "NE Longitude" %>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</fieldset>
|
|
103
|
+
|
|
104
|
+
<div class="prospector-form-group">
|
|
105
|
+
<%= f.submit "Start Run", class: "prospector-btn prospector-btn-primary" %>
|
|
106
|
+
<%= link_to "Cancel", runs_path, class: "prospector-btn prospector-btn-outline" %>
|
|
107
|
+
</div>
|
|
108
|
+
<% end %>
|
|
109
|
+
</div>
|