Class: PartyResearch::Adapters::Gemini

Inherits:
Base
  • Object
show all
Defined in:
app/services/party_research/adapters/gemini.rb

Defined Under Namespace

Classes: GroundedResearch

Constant Summary collapse

SCHEMA =

JSON Schema for the structured Gemini response. Grounding makes
phone/email/address categories safe to allow — when Gemini
surfaces one of those values, it's coming from a cited public
source (nonprofit directory, business registry, public bio),
not training-data guesswork.

{
  type: 'object',
  additionalProperties: false,
  properties: {
    findings: {
      type: 'array',
      description: 'Zero or more findings about this party. Empty when nothing reliable can be inferred.',
      items: {
        type: 'object',
        additionalProperties: false,
        properties: {
          category: {
            type: 'string',
            enum: %w[name profile note contact_suggestion phone email address social_profile inactive],
            description: 'Which kind of fact this finding describes.'
          },
          proposed_value: {
            description: 'String for name/note/phone/email/industry; integer profile_id for ' \
                         'profile; object {name,title,email,phone} for contact_suggestion; ' \
                         'object {street_line_1,street_line_2,city,state_code,postal_code,country_code} ' \
                         'for address (street_line_1 REQUIRED — do not emit an address finding ' \
                         'without a full street; city/state/zip alone is not actionable); ' \
                         'object {platform,url} for social_profile (platform one of ' \
                         'website|facebook|twitter|pinterest|linkedin|instagram|houzz, ' \
                         'or any other label — unknown platforms map to a generic website ' \
                         'contact point on apply); object {reason} for inactive (short ' \
                         'human-readable explanation of why this Party is no longer relevant).'
          },
          confidence: {
            type: 'integer',
            minimum: 0,
            maximum: 100,
            description: 'How certain you are this finding is correct, 0–100.'
          },
          rationale: {
            type: 'string',
            description: 'Source citation (URL or publication name) and short justification.'
          }
        },
        required: %w[category proposed_value confidence rationale]
      }
    }
  },
  required: %w[findings]
}.freeze

Constants inherited from Base

Base::ALL, Base::PERSONAL_EMAIL_DOMAINS

Instance Attribute Summary

Attributes inherited from Base

#party, #raw_findings

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

adapter_name, #finding, #initialize, lookup, registered

Constructor Details

This class inherits a constructor from PartyResearch::Adapters::Base

Class Method Details

.arbiter?Boolean

Gemini synthesizes findings by evaluating the raw outputs of
every other adapter together with Google Search results, so
its output is flagged arbiter: true and shown above raw
adapter findings in the review UI.

Returns:

  • (Boolean)


105
106
107
# File 'app/services/party_research/adapters/gemini.rb', line 105

def arbiter?
  true
end

.configured?Boolean

Gemini uses the globally-configured API key; treat as configured
whenever any key is present in ruby_llm's config.

Returns:

  • (Boolean)


88
89
90
# File 'app/services/party_research/adapters/gemini.rb', line 88

def configured?
  RubyLLM.config.gemini_api_key.present?
end

.relevant_for?(_party) ⇒ Boolean

Grounded Gemini is useful for anyone with at least one
corroborating signal — homeowner emails resolve to nonprofit
directories, business names resolve to licensure records, etc.
The enrichable_via_research? gate on the controller already
ensures we have some anchoring signal, so we trust that here.

Returns:

  • (Boolean)


97
98
99
# File 'app/services/party_research/adapters/gemini.rb', line 97

def relevant_for?(_party)
  true
end

Instance Method Details

#findings_forObject

Raises:



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'app/services/party_research/adapters/gemini.rb', line 110

def findings_for
  raise ConfigurationError, 'Gemini api_key is not configured (config.gemini_api_key)' unless self.class.configured?

  # A party with no name has nothing for Gemini to anchor on — skip
  # rather than send a useless prompt.
  return [] if party.full_name.blank?

  # Step 1: grounded web research as FREE TEXT. Grounding citations
  # routinely corrupt structured JSON, so we don't ask for a schema
  # on this pass.
  research = grounded_research
  # A genuinely empty research write-up means there's nothing to
  # surface — a legitimate (not failed) empty result.
  return [] if research.text.blank?

  # Step 2: structure the research into a schema-conformant findings
  # array in a SEPARATE call with no grounding tool, where structured
  # output is reliable.
  findings = structured_findings(research.text)

  # We had real grounded research but the structuring pass never
  # returned a findings array — a non-Hash, or a Hash like {} /
  # {"error":...} that lacks the array. That's a FAILURE, not "found
  # nothing": raise so the orchestrator marks the adapter failed
  # (visible + retryable) instead of silently zeroing the run — the
  # bug that hid a fully-populated result behind a malformed parse
  # (prod run #21).
  unless findings.is_a?(Array)
    raise AdapterError,
          'Gemini returned grounded research but its structured-output ' \
          "pass did not return a findings array after #{STRUCTURE_ATTEMPTS} attempts"
  end

  # Skip non-hash items (Gemini occasionally yields a stray
  # primitive when its structured output drifts) and items
  # missing a category — one malformed entry shouldn't abort the
  # whole run.
  normalized = findings.filter_map do |item|
    next unless item.is_a?(Hash) && item['category'].present?

    finding(
      category: item['category'],
      proposed_value: item['proposed_value'],
      confidence: item['confidence'],
      evidence: { rationale: item['rationale'].to_s, source: 'gemini' }
    )
  end

  # A non-personal email domain is a strong anchor — grounded search
  # should surface the company/person behind it. Zero findings when
  # grounding never actually ran (no web-search queries, even after a
  # retry) is far more likely a grounding miss than a true negative,
  # so fail loudly to get the run retried rather than recording a
  # confident "no findings" (the prod run #21 failure mode).
  if normalized.empty? && !research.grounded? && corporate_email_anchor?
    raise AdapterError,
          'Gemini produced no findings and grounding never ran despite a ' \
          'corporate email anchor on file — likely a grounding miss; flagging for retry'
  end

  normalized
end