<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hastimal Jangid</title>
    <description>The latest articles on DEV Community by Hastimal Jangid (@hjangid).</description>
    <link>https://dev.to/hjangid</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4034691%2Fddb3be5f-665a-4fd7-aba8-19b6ad3b1ca1.jpg</url>
      <title>DEV Community: Hastimal Jangid</title>
      <link>https://dev.to/hjangid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hjangid"/>
    <language>en</language>
    <item>
      <title>From Search to Care: Applying Gemini Search Journeys to Healthcare Discovery</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:26:01 +0000</pubDate>
      <link>https://dev.to/hjangid/from-search-to-care-applying-gemini-search-journeys-to-healthcare-discovery-3h05</link>
      <guid>https://dev.to/hjangid/from-search-to-care-applying-gemini-search-journeys-to-healthcare-discovery-3h05</guid>
      <description>&lt;p&gt;Across this series, I started with a simple question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How does a user query become a grounded decision?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That took the project through query fan-out, retrieval, ranking, AI visibility, agents, OpenTelemetry, and AgentOps.&lt;/p&gt;

&lt;p&gt;For the final part, I wanted to apply the same design ideas to a domain where evidence boundaries matter much more: &lt;em&gt;&lt;strong&gt;healthcare discovery&lt;/strong&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The goal is not to build an AI doctor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The goal is narrower:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can an AI-assisted search journey help someone discover appropriate healthcare providers while making the supporting evidence, limitations, and source provenance visible?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For this experiment, I used pediatric dental discovery as the example.&lt;/p&gt;

&lt;p&gt;A parent might ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find three pediatric dentists in Houston for a child who is scared of going to the dentist. Compare them using trustworthy sources, provider credentials, services, and location, and explain why you selected each one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sounds like a local-search problem.&lt;/p&gt;

&lt;p&gt;But healthcare changes the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: The query needs more than location
&lt;/h2&gt;

&lt;p&gt;For a coffee-shop search, the system might extract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;location
group size
opening hours
quiet/work preference

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For healthcare discovery, the same query can contain several different information needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;specialty
location
patient need
provider discovery
credential context
clinical evidence
care-related constraints
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the first step is still intent understanding and query fan-out — but the search plan becomes more deliberate.&lt;/p&gt;

&lt;p&gt;For the pediatric-dentist example, the search planner can generate queries for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provider discovery
Dental-anxiety experience
Professional directory discovery
State licensing sources
Biomedical evidence on behavior guidance
Biomedical evidence in sedation / nitrous oxide
FHIR interoperability context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The healthcare platform represents these as a structured SearchPlan rather than treating the entire request as one search string.&lt;/p&gt;

&lt;p&gt;This is the same idea I used earlier in the AI Search Journey Lab:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;one user question does not necessarily equal one retrieval query&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdi9ssz0fpkkilmg6nag1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdi9ssz0fpkkilmg6nag1.png" alt="Gemini converts the healthcare question into separate provider, credential-source, research, and interoperability retrieval tasks." width="800" height="1448"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Different questions need different sources
&lt;/h2&gt;

&lt;p&gt;This is where healthcare becomes more interesting.&lt;/p&gt;

&lt;p&gt;A single retrieval source is not enough.&lt;/p&gt;

&lt;p&gt;For this workflow, I separate the evidence paths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provider discovery
      ↓
NPPES

Biomedical evidence
      ↓
PubMed

Interoperability exploration
      ↓
FHIR R4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The healthcare platform uses Google ADK with an MCP-based retrieval layer to route provider and research queries to the appropriate sources.&lt;/p&gt;

&lt;p&gt;The important point is that these sources do different jobs.&lt;br&gt;
NPPES can provide structured provider information.&lt;/p&gt;

&lt;p&gt;PubMed can provide biomedical literature.&lt;/p&gt;

&lt;p&gt;FHIR can represent standardized relationships between practitioners, specialties, organizations, and locations.&lt;/p&gt;

&lt;p&gt;I do not want to mix those evidence types and pretend they prove the same thing.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: Discovery is not verification
&lt;/h2&gt;

&lt;p&gt;This became one of the most important healthcare rules in the project.&lt;/p&gt;

&lt;p&gt;Generating a query such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Texas State Board of Dental Examiners dentist license verification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does &lt;strong&gt;not&lt;/strong&gt; mean a provider's license has been verified.&lt;/p&gt;

&lt;p&gt;Similarly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AAPD find a pediatric dentist Houston TX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does not mean professional membership or board status has been &lt;em&gt;confirmed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The search-planning instructions explicitly preserve that distinction: identifying an authoritative source is not the same as retrieving and validating evidence from it.&lt;/p&gt;

&lt;p&gt;That leads to a simple rule I use throughout the healthcare work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No evidence → no claim.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: Let agents plan, but keep evidence deterministic
&lt;/h2&gt;

&lt;p&gt;The healthcare architecture uses three core agents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Healthcare Question
          ↓
Search Planner Agent
          ↓
SearchPlan
          ↓
Healthcare Research Agent
          ↓
MCP Retrieval
          ↓
Evidence
          ↓
Grounded Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broader platform uses Gemini or Gemma for reasoning while keeping the Google ADK workflow and deterministic evidence boundaries intact.&lt;/p&gt;

&lt;p&gt;This separation matters.&lt;/p&gt;

&lt;p&gt;I am comfortable letting a model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;interpret the question&lt;/li&gt;
&lt;li&gt;create retrieval plans&lt;/li&gt;
&lt;li&gt;select tools&lt;/li&gt;
&lt;li&gt;summarize evidence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I do not want the model to manufacture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;provider credentials&lt;/li&gt;
&lt;li&gt;services&lt;/li&gt;
&lt;li&gt;citation IDs&lt;/li&gt;
&lt;li&gt;verification claims&lt;/li&gt;
&lt;li&gt;unsupported healthcare facts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, citation references are validated against the actual selected evidence, and invalid citation IDs are rejected.&lt;/p&gt;

&lt;p&gt;That makes the system less magical, but much more inspectable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgi9uq0cibn4bwsrjvp8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgi9uq0cibn4bwsrjvp8l.png" alt="Search-to-care architecture: Gemini plans the journey, specialized sources provide evidence, and deterministic checks constrain the final provider-discovery answer." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: The answer should expose its boundaries
&lt;/h2&gt;

&lt;p&gt;Healthcare search should not sound more certain than the evidence allows.&lt;/p&gt;

&lt;p&gt;A useful result should distinguish:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What was found&lt;br&gt;
What was supported&lt;br&gt;
What still needs verification&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example, the system may find a provider through NPPES and biomedical evidence about pediatric dental anxiety through PubMed.&lt;/p&gt;

&lt;p&gt;That does not automatically prove that the specific provider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;offers sedation&lt;/li&gt;
&lt;li&gt;accepts a particular insurance plan&lt;/li&gt;
&lt;li&gt;has a particular appointment available&lt;/li&gt;
&lt;li&gt;has specialized expertise in a specific anxiety-management technique&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those require their own evidence.&lt;/p&gt;

&lt;p&gt;This is the healthcare version of a principle that appeared throughout the AI Search Journey Lab:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Make the evidence boundary visible.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 6: Provider discovery and medical advice are different
&lt;/h2&gt;

&lt;p&gt;I also wanted a very clear product boundary.&lt;/p&gt;

&lt;p&gt;This workflow supports healthcare discovery.&lt;/p&gt;

&lt;p&gt;It is not intended to diagnose a patient or prescribe treatment.&lt;/p&gt;

&lt;p&gt;There is a significant difference between:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find pediatric dentists near Houston and show me the evidence you used.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Tell me which medical treatment my child should receive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first is primarily a discovery and evidence-retrieval problem.&lt;br&gt;
The second can become clinical decision-making.&lt;/p&gt;

&lt;p&gt;I keep those two problems separate.&lt;/p&gt;

&lt;p&gt;The system can retrieve research about dental anxiety and behavior guidance to provide context, but that evidence should not be transformed into individualized medical advice.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 7: Run the same journey as an inspectable workflow
&lt;/h2&gt;

&lt;p&gt;What I like about applying the search-journey architecture to healthcare is that the journey remains inspectable.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question
   ↓
LLM
   ↓
Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question
   ↓
Intent
   ↓
Search Plan
   ↓
Generated Queries
   ↓
Sources Used
   ↓
Retrieved Records
   ↓
Evidence Selection
   ↓
Ranking
   ↓
Citations
   ↓
Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is far more useful for healthcare than a confident paragraph with invisible provenance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fso0u1w5zy7fn79gg2lxp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fso0u1w5zy7fn79gg2lxp.png" alt="The final provider-discovery result keeps recommendations connected to the evidence used to support them." width="800" height="1086"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What changed when I moved from search to care
&lt;/h2&gt;

&lt;p&gt;The basic search architecture still worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Intent
↓
Fan-Out
↓
Retrieval
↓
Evidence
↓
Ranking
↓
Grounded Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuba4r85t61qe645fduba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuba4r85t61qe645fduba.png" alt="Basic Structure" width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But healthcare forced me to be stricter about several things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Source roles&lt;/strong&gt;&lt;br&gt;
Not every source proves the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Claim boundaries&lt;/strong&gt;&lt;br&gt;
Finding a provider is not the same as verifying every provider attribute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Citations&lt;/strong&gt;&lt;br&gt;
The model should not be able to invent evidence references.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Safety&lt;/strong&gt;&lt;br&gt;
Provider discovery is not medical diagnosis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Explainability&lt;/strong&gt;&lt;br&gt;
Unknown information should remain unknown rather than being filled in by the model.&lt;/p&gt;

&lt;p&gt;Those lessons are useful well beyond healthcare.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I learned from the full series
&lt;/h2&gt;

&lt;p&gt;This project started as a search demo.&lt;/p&gt;

&lt;p&gt;It gradually became a study of how AI systems can make decisions more inspectable.&lt;/p&gt;

&lt;p&gt;The progression looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Search
   ↓
Grounding
   ↓
Journey Analysis
   ↓
Visibility
   ↓
Agents
   ↓
Tracing
   ↓
AgentOps
   ↓
Healthcare Discovery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The biggest lesson for me is still the simplest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A good AI search system should not only tell me the answer. It should help me understand where that answer came from.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In healthcare, that becomes even more important.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cleanup Resource
&lt;/h2&gt;

&lt;p&gt;To clean up all cloud resources, avoid cost, just run below -&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./scripts/delete_cloud_run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1sb7o129fv0rsyo0f4jd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1sb7o129fv0rsyo0f4jd.png" alt="Delete resources script" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Source code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. AI Search Journey Lab&lt;/strong&gt;:&lt;br&gt;
&lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;https://github.com/hastimal/ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Healthcare agentic platform&lt;/strong&gt;:&lt;br&gt;
&lt;a href="https://github.com/hastimal/gemini-healthcare-agentic-platform" rel="noopener noreferrer"&gt;https://github.com/hastimal/gemini-healthcare-agentic-platform&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two projects explore the same underlying idea from different directions:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;retrieve evidence, preserve provenance, expose uncertainty, and let the model reason inside explicit boundaries.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>healthcare</category>
      <category>gemini</category>
      <category>agents</category>
    </item>
    <item>
      <title>Building AgentOps for Gemini Agents with Grafana, Tempo, and Prometheus</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:14:08 +0000</pubDate>
      <link>https://dev.to/hjangid/building-agentops-for-gemini-agents-with-grafana-tempo-and-prometheus-10o2</link>
      <guid>https://dev.to/hjangid/building-agentops-for-gemini-agents-with-grafana-tempo-and-prometheus-10o2</guid>
      <description>&lt;p&gt;In Part 5, I added OpenTelemetry tracing to the AI Search Journey Lab.&lt;/p&gt;

&lt;p&gt;That changed the way I could debug a single execution.&lt;/p&gt;

&lt;p&gt;Instead of seeing only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Query
    ↓
...
    ↓
Final Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.execution
├── intent parsing
├── query fan-out
├── retrieval
├── search grounding
├── evidence aggregation
├── ranking
└── synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage had timing, status, attributes, and parent-child relationships.&lt;/p&gt;

&lt;p&gt;That solved one problem:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happened during this request?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But once I had traces, another question appeared:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How is the system behaving across many requests?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted to see more than one execution.&lt;/p&gt;

&lt;p&gt;I wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request volume&lt;/li&gt;
&lt;li&gt;average latency&lt;/li&gt;
&lt;li&gt;failure rates&lt;/li&gt;
&lt;li&gt;latency by workflow&lt;/li&gt;
&lt;li&gt;V4 agent behavior&lt;/li&gt;
&lt;li&gt;MCP tool activity&lt;/li&gt;
&lt;li&gt;trace drill-down&lt;/li&gt;
&lt;li&gt;historical operational trends&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That pushed the project from observability toward AgentOps.&lt;/p&gt;

&lt;p&gt;For this milestone, I built a local Docker-based observability stack using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenTelemetry
Grafana
Tempo
Prometheus
Docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entire stack can run alongside the application locally.&lt;/p&gt;




&lt;h2&gt;
  
  
  From tracing to AgentOps
&lt;/h2&gt;

&lt;p&gt;OpenTelemetry gave me the instrumentation.&lt;/p&gt;

&lt;p&gt;But instrumentation alone is not an operational system.&lt;/p&gt;

&lt;p&gt;I needed somewhere for traces to go.&lt;/p&gt;

&lt;p&gt;I needed somewhere for metrics to go.&lt;/p&gt;

&lt;p&gt;And I needed a UI where I could investigate both.&lt;/p&gt;

&lt;p&gt;The architecture became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemini / AI Search Application
              ↓
        OpenTelemetry
              ↓
      OTel Collector
         ↙         ↘
      Tempo     Prometheus
      Traces      Metrics
         ↘         ↙
            Grafana
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the basic AgentOps stack I wanted.&lt;/p&gt;

&lt;p&gt;The application remains responsible for generating telemetry.&lt;/p&gt;

&lt;p&gt;The OpenTelemetry Collector handles telemetry transport.&lt;/p&gt;

&lt;p&gt;Tempo stores traces.&lt;/p&gt;

&lt;p&gt;Prometheus stores metrics.&lt;/p&gt;

&lt;p&gt;Grafana becomes the operational view.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5nap3gde7p75fim2lw66.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5nap3gde7p75fim2lw66.png" alt="The local AgentOps architecture routes OpenTelemetry traces to Tempo and operational metrics to Prometheus, with Grafana providing a unified investigation layer." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Keep the observability stack local
&lt;/h2&gt;

&lt;p&gt;One goal was important from the beginning:&lt;/p&gt;

&lt;p&gt;I did not need another cloud service just to inspect my AI application during development.&lt;/p&gt;

&lt;p&gt;So I created a Docker Compose stack for the observability infrastructure.&lt;/p&gt;

&lt;p&gt;The project includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker-compose.observability.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;GitHub Code&lt;/strong&gt;: &lt;a href="https://github.com/hastimal/ai-search-journey-lab/blob/main/docker-compose.observability.yml" rel="noopener noreferrer"&gt;Source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;along with configuration for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observability/
├── otel-collector/
├── prometheus/
├── tempo/
└── grafana/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Docker Compose stack runs the OpenTelemetry Collector, Prometheus, Grafana Tempo, and Grafana locally.&lt;/p&gt;

&lt;p&gt;That gives me a repeatable development environment without manually installing each observability component.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Use the OpenTelemetry Collector as the routing layer
&lt;/h2&gt;

&lt;p&gt;I did not want the application to know how every backend worked.&lt;br&gt;
The application should emit telemetry.&lt;/p&gt;

&lt;p&gt;The collector should decide where that telemetry goes.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    ↓
OTLP
    ↓
OpenTelemetry Collector
    ├── traces → Tempo
    └── metrics → Prometheus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project has a dedicated collector configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observability/otel-collector/otel-collector-config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which routes traces and metrics into the local backends.&lt;/p&gt;

&lt;p&gt;This is a useful separation because the application instrumentation does not have to be rewritten every time I change the backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Export traces from the application
&lt;/h2&gt;

&lt;p&gt;In V5, traces could already be inspected locally.&lt;/p&gt;

&lt;p&gt;For AgentOps, I extended the telemetry initialization so traces can also be exported over OTLP when a local collector endpoint is configured.&lt;/p&gt;

&lt;p&gt;The local endpoint is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:4318
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfrbxw8f7u9kem5ssc3i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfrbxw8f7u9kem5ssc3i.png" alt="Export traces from the application" width="798" height="198"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The telemetry client can conditionally attach the OTLP exporter while preserving the original in-memory tracing behavior.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;settings.otel_exporter_otlp_endpoint = "http://localhost:4318"
init_telemetry()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then normal instrumentation continues to use the same span helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;with trace_span(
    "journey.search_to_decision",
    attributes={
        "workflow.stage": "v1",
    },
):
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is that the completed trace can now leave the Python process and become visible in Tempo and Grafana.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Send traces to Tempo
&lt;/h2&gt;

&lt;p&gt;Tempo becomes the trace backend.&lt;/p&gt;

&lt;p&gt;That gives me persistent trace inspection outside the Streamlit application.&lt;/p&gt;

&lt;p&gt;For example, I can query Tempo directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -s "http://localhost:3200/api/search"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dsnfi1e2n9g83ogs0vz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dsnfi1e2n9g83ogs0vz.png" alt="Trace Curl" width="800" height="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And if I know a trace ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -s \
  "http://localhost:3200/api/traces/&amp;lt;TRACE_ID&amp;gt;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During implementation, I used those API calls to verify that traces were actually reaching Tempo before relying on Grafana to visualize them.&lt;/p&gt;

&lt;p&gt;I like this verification pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application generated span
        ↓
Collector received span
        ↓
Tempo stored trace
        ↓
Grafana displayed trace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: If the Grafana panel is empty, I can work backwards through the pipeline.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa29jtyjinlw9npd2pe9t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa29jtyjinlw9npd2pe9t.png" alt="Grafana Tempo showing a complete AI or agent execution as a distributed trace with stage-level spans and latency." width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Add operational metrics
&lt;/h2&gt;

&lt;p&gt;Traces answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happened in this request?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Metrics answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is happening across the system?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I added a small set of intentionally low-cardinality metrics.&lt;br&gt;
The current implementation emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ai_search_journey_requests_total
ai_search_journey_failures_total
ai_search_journey_duration_seconds_count
ai_search_journey_duration_seconds_sum
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key labels are intentionally constrained to values such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stage = v1 | v3 | v4

status = ok | error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deliberately did not put things like these into metric labels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt
raw query
session ID
trace ID
user text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project explicitly excludes those from metric labels for privacy and cardinality reasons.&lt;/p&gt;

&lt;p&gt;That matters because a metric such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requests_total{prompt="..."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;would quickly become both a privacy problem and a cardinality problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Verify metrics before building dashboards
&lt;/h2&gt;

&lt;p&gt;One thing I learned during this milestone was not to assume that because traces are working, metrics are working too.&lt;/p&gt;

&lt;p&gt;They are separate telemetry paths.&lt;/p&gt;

&lt;p&gt;I verified the collector's Prometheus endpoint directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -s http://localhost:8889/metrics \
  | grep ai_search_journey
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09klnpms5nedaak1jefx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09klnpms5nedaak1jefx.png" alt="Prometheus Tracing" width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Also verify that the collector endpoint itself accepts OTLP traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -i -X POST \
  http://localhost:4318/v1/metrics \
  -H "Content-Type: application/json" \
  -d '{"resourceMetrics":[]}'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns an HTTP response, &lt;code&gt;4318&lt;/code&gt; is fine.&lt;/p&gt;

&lt;p&gt;If after restarting the app and running one workflow you still get no metrics, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker logs otel-collector --tail 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grep -RniE 'OTEL_EXPORTER_OTLP_ENDPOINT|otel_exporter_otlp_endpoint|metrics' \
  .env .env.example src/ai_search_journey/config.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2atku9gwymtb4963hrx4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2atku9gwymtb4963hrx4.png" alt="View collector metrics export" width="797" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then send me those outputs. The Docker stack itself is not the problem; we now need to confirm the &lt;strong&gt;application → collector metrics export path&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That debugging process was actually a good example of why observability needs independent verification.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Query Prometheus directly
&lt;/h2&gt;

&lt;p&gt;Before trusting Grafana, I tested Prometheus directly.&lt;/p&gt;

&lt;p&gt;For example, total V4 requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -sG \
  http://localhost:9090/api/v1/query \
  --data-urlencode \
  'query=sum(ai_search_journey_requests_total{stage="v4"})'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again, Prometheus returned the expected value.&lt;/p&gt;

&lt;p&gt;This gave me three independent checkpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Collector
   ↓
Prometheus
   ↓
Grafana
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: If the Grafana panel looks wrong, you can check the underlying PromQL directly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let's open &lt;code&gt;http://localhost:9090&lt;/code&gt; and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(ai_search_journey_requests_total{stage="v4"})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8161f5xpf3a72un7rs69.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8161f5xpf3a72un7rs69.png" alt="Prometheus receiving the custom AI Search Journey metrics exported through OpenTelemetry." width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Build the Grafana dashboard
&lt;/h2&gt;

&lt;p&gt;Once traces and metrics were verified independently, I built the Grafana layer.&lt;/p&gt;

&lt;p&gt;The project provisions the dashboard automatically through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observability/grafana/provisioning/dashboards/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and provisions the Prometheus and Tempo datasources through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;observability/grafana/provisioning/datasources/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main dashboard is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI Search Journey — Observability Overview&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;with UID:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ai-search-journey-overview&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That means the dashboard is infrastructure-as-code rather than something I have to recreate manually every time I start Grafana.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Track request rate by workflow
&lt;/h2&gt;

&lt;p&gt;One useful panel is request rate grouped by workflow stage.&lt;/p&gt;

&lt;p&gt;The dashboard uses PromQL such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum by (stage) (
  rate(ai_search_journey_requests_total[1m])
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lets me distinguish activity from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v1  → Search to Decision
v3  → AI Visibility Scans
v4  → AI Visibility Agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more useful than a single global request counter.&lt;/p&gt;

&lt;p&gt;I can immediately see which part of the application is actually generating activity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Track average execution duration
&lt;/h2&gt;

&lt;p&gt;The next useful view is latency.&lt;/p&gt;

&lt;p&gt;The dashboard computes average duration using the accumulated duration sum and count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum by (stage) (
  rate(ai_search_journey_duration_seconds_sum[1m])
)
/
sum by (stage) (
  rate(ai_search_journey_duration_seconds_count[1m])
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives me a stage-level operational view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1 average duration
V3 average duration
V4 average duration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is different from the span-level view in Tempo.&lt;/p&gt;

&lt;p&gt;Prometheus answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this workflow getting slower over time?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tempo answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why was this particular request slow?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want &lt;em&gt;both&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Combine metrics and traces
&lt;/h2&gt;

&lt;p&gt;This is where Grafana becomes much more useful than either backend alone.&lt;/p&gt;

&lt;p&gt;Prometheus gives me the macro view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request rate
Average latency
Failures
Workflow health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tempo gives me the micro view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One request
One trace
Individual spans
Tool calls
Timing
Errors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the operational workflow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Grafana metric shows latency increase
             ↓
Choose suspicious time window
             ↓
Open related traces
             ↓
Inspect span hierarchy in Tempo
             ↓
Find slow tool / model / database operation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is much closer to how I want to debug agentic systems.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqqfn5eveaq1gzn34l08i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqqfn5eveaq1gzn34l08i.png" alt="The Grafana AgentOps dashboard combines Prometheus operational metrics with Tempo trace investigation for V1 search, V3 visibility scans, and V4 agent interactions." width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Observe real V4 agent behavior
&lt;/h2&gt;

&lt;p&gt;The AgentOps story becomes more interesting with V4.&lt;/p&gt;

&lt;p&gt;A V4 request is not just: &lt;code&gt;prompt → response&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It can involve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Question
      ↓
Gemini
      ↓
Google ADK
      ↓
MCP tool
      ↓
BigQuery
      ↓
Tool Result
      ↓
Gemini Synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I added child-span instrumentation around the V4 workflow.&lt;/p&gt;

&lt;p&gt;The implementation preserves the root agent span and traces child operations for the agent workflow. A live V4 interaction was used to verify that those traces reached the local Docker observability stack.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That lets me inspect questions such as:&lt;br&gt;
Which tool did the agent choose?&lt;br&gt;
How long did the MCP call take?&lt;br&gt;
Was BigQuery the bottleneck?&lt;br&gt;
Did the agent call multiple tools?&lt;br&gt;
Which stage failed?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is much more meaningful AgentOps than simply charting LLM request counts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1jo5dqhlvn34b7jqdt31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1jo5dqhlvn34b7jqdt31.png" alt="A real V4 Visibility Agent interaction traced through its agent and tool execution stages." width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 13: Use TraceQL for agent investigation
&lt;/h2&gt;

&lt;p&gt;Once the V4 spans included consistent workflow metadata, I could filter traces by attributes rather than manually searching through everything.&lt;/p&gt;

&lt;p&gt;For example, V4 spans can carry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workflow.stage = "v4"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project verified the Grafana-to-Tempo query path against actual V4 agent traces using TraceQL filtering.&lt;/p&gt;

&lt;p&gt;That gives me a useful investigation pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Show me V4 traces
      ↓
Find slow request
      ↓
Open trace
      ↓
Inspect MCP child span
      ↓
Inspect BigQuery span
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This becomes increasingly valuable as the number of agents and tools grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 14: Start everything locally
&lt;/h2&gt;

&lt;p&gt;One thing I wanted from this milestone was a simple local workflow.&lt;/p&gt;

&lt;p&gt;From the repository root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source .venv/bin/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python scripts/run_app_locally.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the normal local startup path for this project.&lt;/p&gt;

&lt;p&gt;The stack gives me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Streamlit
http://localhost:8502

Grafana
http://localhost:3000

Prometheus
http://localhost:9090

Tempo
http://localhost:3200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So one command can give me the application and the local AgentOps environment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 15: Verify that Docker is healthy
&lt;/h2&gt;

&lt;p&gt;If Grafana does not open, I first inspect Docker instead of debugging the Python application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expect the local observability services to be running.&lt;/p&gt;

&lt;p&gt;The project Compose definition can also be validated independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker compose \
  -f docker-compose.observability.yml \
  config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 17: Validate the complete stack
&lt;/h2&gt;

&lt;p&gt;The AgentOps milestone is not complete just because Grafana loads.&lt;/p&gt;

&lt;p&gt;I validate both code and infrastructure.&lt;/p&gt;

&lt;p&gt;The verification suite includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./.venv/bin/ruff check .
./.venv/bin/mypy src
./.venv/bin/pytest -q
git diff --check
docker compose -f docker-compose.observability.yml config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During the completed Dockerized observability implementation, the recorded results were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ruff
All checks passed!

Mypy
Success: no issues found in 38 source files

Pytest
381 passed, 3 warnings

Git diff check
Clean

Docker Compose config
Valid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcv030x5et6c9emdqyolz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcv030x5et6c9emdqyolz.png" alt="Validating both the Python application and the Dockerized AgentOps infrastructure before release." width="799" height="325"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building the stack
&lt;/h2&gt;

&lt;p&gt;The first lesson was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Traces and metrics answer different questions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Trying to force one to do everything makes the system harder to use.&lt;/p&gt;

&lt;p&gt;The second lesson was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Verify every telemetry layer independently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I now think in terms of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application instrumentation

→ Collector ingestion

→ Backend storage

→ Query result

→ Dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third lesson was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Agent observability needs tool-level visibility.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Only tracing the outer Gemini request would hide most of the interesting behavior.&lt;/p&gt;

&lt;p&gt;For an agent, I want to see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reasoning boundary

tool selection

tool execution

database call

response synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth lesson was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Local observability is extremely useful.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I can experiment with AgentOps architecture without keeping another cloud observability environment running continuously.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I intentionally did not do
&lt;/h2&gt;

&lt;p&gt;This project is not trying to rebuild a commercial observability platform.&lt;/p&gt;

&lt;p&gt;I kept the architecture relatively small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenTelemetry
Collector
Tempo
Prometheus
Grafana
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also do not record full prompts as Prometheus labels.&lt;/p&gt;

&lt;p&gt;I do not make trace IDs into high-cardinality metrics.&lt;/p&gt;

&lt;p&gt;And I do not treat raw chain-of-thought as something that should be logged.&lt;/p&gt;

&lt;p&gt;The goal is operational visibility into the system:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which stage ran?&lt;br&gt;
Which tool ran?&lt;br&gt;
How long did it take?&lt;br&gt;
Did it succeed?&lt;br&gt;
How often does it fail?&lt;br&gt;
Which trace should I inspect?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is enough to make debugging substantially better without turning telemetry into a privacy problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  From AgentOps to healthcare discovery
&lt;/h2&gt;

&lt;p&gt;At this point the AI Search Journey Lab has evolved through several layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Grounded Search
      ↓
Journey Analysis
      ↓
AI Visibility
      ↓
AI Visibility Agent
      ↓
OpenTelemetry Tracing
      ↓
AgentOps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the final article in this series, I want to take the architecture into a domain where grounding and evidence matter even more:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;healthcare discovery&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The question changes from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which coffee shop should I choose?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;to something much more sensitive:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How can an AI-assisted search journey help someone discover appropriate care while preserving evidence, source attribution, and explainability?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That becomes Part 7.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next: From Search to Care — Applying Gemini Search Journeys to Healthcare Discovery
&lt;/h2&gt;

&lt;p&gt;In Part 7, I will apply the search journey pattern to healthcare discovery.&lt;/p&gt;

&lt;p&gt;The focus will be on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemini&lt;/li&gt;
&lt;li&gt;healthcare provider discovery&lt;/li&gt;
&lt;li&gt;grounded evidence&lt;/li&gt;
&lt;li&gt;search journey fan-out&lt;/li&gt;
&lt;li&gt;source attribution&lt;/li&gt;
&lt;li&gt;deterministic evaluation&lt;/li&gt;
&lt;li&gt;explainable recommendations&lt;/li&gt;
&lt;li&gt;care-oriented constraints&lt;/li&gt;
&lt;li&gt;the boundary between discovery and medical advice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The goal is not to build an AI doctor.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The goal is to explore how the same grounded search architecture can support a more evidence-oriented path from a healthcare question to provider discovery.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source code
&lt;/h2&gt;

&lt;p&gt;The project is open source: &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;https://github.com/hastimal/ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>prometheus</category>
      <category>grafana</category>
      <category>antigravity</category>
    </item>
    <item>
      <title>From Query Fan-Out to Trace: Observing AI Search Journeys with OpenTelemetry</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:12:36 +0000</pubDate>
      <link>https://dev.to/hjangid/from-query-fan-out-to-trace-observing-ai-search-journeys-with-opentelemetry-3oek</link>
      <guid>https://dev.to/hjangid/from-query-fan-out-to-trace-observing-ai-search-journeys-with-opentelemetry-3oek</guid>
      <description>&lt;p&gt;In the earlier parts of this series, I focused on what the system produced.&lt;/p&gt;

&lt;p&gt;I started with grounded search journeys, then added historical visibility analysis, and eventually put an agent in front of that history with Gemini, Google ADK, MCP, and BigQuery.&lt;/p&gt;

&lt;p&gt;But once the workflow became more complex, a different question started bothering me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When something is slow, fails, or behaves unexpectedly, how do I know which stage caused it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A single request could involve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Intent parsing
      ↓
Query fan-out
      ↓
Places retrieval
      ↓
Search grounding
      ↓
Evidence aggregation
      ↓
Constraint evaluation
      ↓
Ranking
      ↓
Answer synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in later versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent chat
      ↓
MCP tool call
      ↓
BigQuery read
      ↓
Gemini synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looking only at the final answer was no longer enough.&lt;/p&gt;

&lt;p&gt;I needed to see the execution journey itself.&lt;/p&gt;

&lt;p&gt;That became V5: &lt;em&gt;&lt;strong&gt;OpenTelemetry observability for the AI Search Journey Lab.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why tracing became necessary
&lt;/h2&gt;

&lt;p&gt;Consider a query like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find a coffee shop near Geekdom San Antonio for 6 people to work together, preferably quiet, and open after 8 PM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From the UI, I can see whether the final recommendations look reasonable.&lt;/p&gt;

&lt;p&gt;But suppose the request takes six seconds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where did those six seconds go?&lt;br&gt;
Was Gemini intent extraction slow?&lt;br&gt;
Did query fan-out generate too many retrieval tasks?&lt;br&gt;
Did Google Places take most of the time?&lt;br&gt;
Was Search grounding the bottleneck?&lt;br&gt;
Did ranking become expensive?&lt;br&gt;
Did final synthesis take longer than expected?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without tracing, all I really know is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request started

...

Request completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not enough once an AI workflow has multiple stages, external APIs, tools, agents, and data stores.&lt;/p&gt;

&lt;p&gt;I wanted this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
├── intent parsing              410 ms
├── query fan-out               530 ms
├── places retrieval          1,240 ms
├── search grounding            870 ms
├── evidence aggregation        120 ms
├── constraint evaluation        70 ms
├── ranking                      95 ms
└── answer synthesis            690 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I can reason about the system.&lt;/p&gt;

&lt;p&gt;OpenTelemetry turns the AI search journey into a trace composed of spans for intent parsing, fan-out, retrieval, grounding, ranking, and synthesis.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiexvfslwkuy2jx9wn9i7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiexvfslwkuy2jx9wn9i7.png" alt="OpenTelemetry turns the AI search journey into a trace composed of spans for intent parsing, fan-out, retrieval, grounding, ranking, and synthesis."&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Think in traces and spans
&lt;/h2&gt;

&lt;p&gt;The first concept I needed was simple:&lt;/p&gt;

&lt;p&gt;A trace represents one complete operation.&lt;/p&gt;

&lt;p&gt;A span represents one step inside that operation.&lt;/p&gt;

&lt;p&gt;For V1, the root operation is the search journey itself.&lt;/p&gt;

&lt;p&gt;The implementation creates a root span around the full journey and then nests child spans for the major execution stages. The project also propagates a journey.run_id so the spans belonging to the same execution can be correlated.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Trace: journey.execution
│
├── journey.intent_parsing
├── journey.query_fanout
├── journey.places_retrieval
├── journey.normalize
├── journey.search_grounding
├── journey.evidence_aggregation
├── journey.constraint_eval
├── journey.ranking
├── journey.static_map
└── journey.answer_synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those names are not arbitrary UI labels.&lt;/p&gt;

&lt;p&gt;They correspond to actual stages I wanted to inspect independently.  &lt;/p&gt;

&lt;p&gt;That immediately gave me something the original application did not have:&lt;/p&gt;

&lt;p&gt;structure around execution time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Instrument the stages I actually care about
&lt;/h2&gt;

&lt;p&gt;I did not want to create spans for every Python function.&lt;/p&gt;

&lt;p&gt;That would create a lot of noise without adding much value.&lt;/p&gt;

&lt;p&gt;Instead, I instrumented meaningful workflow boundaries.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Intent parsing&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.intent_parsing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This covers Gemini-based intent extraction.&lt;/p&gt;

&lt;p&gt;Useful attributes include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;intent.category&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;intent.has_location&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;intent.constraint_count&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Query fan-out&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.query_fanout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This records the fan-out planning stage.&lt;/p&gt;

&lt;p&gt;Useful attributes include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fanout.total_queries
fanout.places_queries
fanout.search_queries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Places retrieval&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.places_retrieval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful attributes include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;places.task_id
places.raw_count
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Search grounding&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.search_grounding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful attributes include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;search.task_id
search.sources_count
search.queries_count
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Ranking&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.ranking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful attributes include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ranking.candidate_count
ranking.top_score
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Final synthesis
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.answer_synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful attributes include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;answer.recommendations_count
answer.caveats_count
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project also traces normalization, evidence aggregation, constraint evaluation, and static map generation.&lt;/p&gt;

&lt;p&gt;This gave me a trace model that follows the way I already think about the search journey.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Add correlation IDs
&lt;/h2&gt;

&lt;p&gt;Timing alone is useful.&lt;/p&gt;

&lt;p&gt;Correlation is what makes the traces much more powerful.&lt;/p&gt;

&lt;p&gt;The project uses identifiers such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.run_id
visibility.scan_id
session.id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to connect related operations across the different application stages.&lt;/p&gt;

&lt;p&gt;That matters because a V1 journey can later lead to a V3 visibility scan.&lt;/p&gt;

&lt;p&gt;And a V4 agent interaction has its own session and turn IDs.&lt;/p&gt;

&lt;p&gt;So instead of looking at disconnected events, I can follow relationships such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.run_id
      ↓
V1 Search Journey
      ↓
visibility.scan_id
      ↓
V3 Visibility Scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For V4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;session.id
      ↓
v4.session_id
      ↓
v4.turn_id
      ↓
MCP tool call
      ↓
BigQuery read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The V4 tracing model includes spans such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v4.chat_turn

v4.mcp_tool.&amp;lt;tool_name&amp;gt;

v4.bigquery.read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with metadata for tool names, brands, date ranges, read-only BigQuery operations, and rows returned.&lt;/p&gt;

&lt;p&gt;This is where observability starts becoming useful across versions instead of only inside one function.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Propagate trace context
&lt;/h2&gt;

&lt;p&gt;Creating spans is easy.&lt;/p&gt;

&lt;p&gt;Creating the correct parent-child relationships is more important.&lt;br&gt;
OpenTelemetry context propagation keeps child operations attached to the correct parent span.&lt;/p&gt;

&lt;p&gt;The implementation uses standard OpenTelemetry context handling, including span context attachment and propagation, so nested work such as ranking, BigQuery reads, and tool calls stays under the correct workflow.&lt;/p&gt;

&lt;p&gt;That gives me a trace hierarchy like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.execution
│
├── journey.intent_parsing
│
├── journey.query_fanout
│
│   ├── places query 1
│   ├── places query 2
│   └── search query 1
│
├── journey.search_grounding
│
├── journey.ranking
│
└── journey.answer_synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without parent-child relationships, tracing becomes little more than a list of timestamps.&lt;/p&gt;

&lt;p&gt;With them, I get an execution tree.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43410pybqolpabw9c5ib.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43410pybqolpabw9c5ib.png" alt="A complete AI search journey represented as a hierarchy of OpenTelemetry spans."&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Create a reusable tracing helper
&lt;/h2&gt;

&lt;p&gt;I did not want tracing logic scattered through the application.&lt;br&gt;
So the telemetry layer includes a reusable helper around span creation.&lt;/p&gt;

&lt;p&gt;Conceptually, the code looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;journey.ranking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ranking.candidate_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rank_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tracing helper is responsible for things such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;span creation
attributes
duration
status
error capture
sanitization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The telemetry design includes a dedicated tracer module plus a &lt;code&gt;trace_span(...)&lt;/code&gt; context manager so instrumentation can stay small at each call site.&lt;/p&gt;

&lt;p&gt;That matters because I did not want observability code to dominate the actual application logic.&lt;/p&gt;

&lt;p&gt;The business code should still read like business code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Record status, not just duration
&lt;/h2&gt;

&lt;p&gt;A slow span and a failed span are different problems.&lt;/p&gt;

&lt;p&gt;So each span records status.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;StatusCode.OK

or

StatusCode.ERROR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an exception occurs, the span captures the failure before the exception continues through the normal error-handling path.&lt;/p&gt;

&lt;p&gt;That means I can distinguish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.places_retrieval
Duration: 1.8 s
Status: OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.places_retrieval
Duration: 1.8 s
Status: ERROR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is much more useful during debugging.&lt;/p&gt;

&lt;p&gt;The observability tests were designed to verify normal span lifecycle as well as error capture and parent-child relationships.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Treat telemetry as sensitive data
&lt;/h2&gt;

&lt;p&gt;One observability mistake I wanted to avoid was dumping everything into traces.&lt;/p&gt;

&lt;p&gt;An AI application can easily expose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API keys
authorization headers
tokens
raw prompts
private user input
URLs containing credentials
exception payloads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I added an explicit redaction layer.&lt;/p&gt;

&lt;p&gt;Sensitive dictionary keys such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api_key
key
secret
authorization
password
token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;are masked.&lt;/p&gt;

&lt;p&gt;The implementation also scans for credential-like patterns such as Google API keys, OAuth tokens, and bearer tokens.&lt;/p&gt;

&lt;p&gt;I also avoid storing raw prompts as span attributes.&lt;/p&gt;

&lt;p&gt;Instead, telemetry can record safer metadata such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query length
sanitized summary
hash
category
constraint count
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design explicitly avoids verbatim system prompts and user prompts in the trace attributes.&lt;/p&gt;

&lt;p&gt;That became an important principle for me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Observability should make the system easier to inspect without turning telemetry into a second sensitive-data store.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 8: Keep V5 local first
&lt;/h2&gt;

&lt;p&gt;For the initial observability milestone, I deliberately kept the tracing stack lightweight.&lt;/p&gt;

&lt;p&gt;The design uses the OpenTelemetry API and SDK with an in-memory span exporter rather than requiring an external tracing platform.&lt;/p&gt;

&lt;p&gt;The core dependencies are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;opentelemetry-api
opentelemetry-sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was enough to support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TracerProvider
SimpleSpanProcessor
InMemorySpanExporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;without requiring an external collector just to understand the tracing model.&lt;/p&gt;

&lt;p&gt;This made V5 useful locally and kept the milestone focused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instrument
      ↓
Capture
      ↓
Inspect
      ↓
Debug
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Grafana/Tempo/Prometheus stack comes later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;That becomes Part 6.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Retain traces without leaking memory
&lt;/h2&gt;

&lt;p&gt;Streamlit has its own execution model.&lt;/p&gt;

&lt;p&gt;The script can rerun from top to bottom as users interact with the app.&lt;/p&gt;

&lt;p&gt;So trace retention needs to survive UI interactions without growing forever.&lt;/p&gt;

&lt;p&gt;The V5 design uses an in-memory telemetry store plus Streamlit session state.&lt;/p&gt;

&lt;p&gt;Completed traces are kept in a bounded buffer instead of being retained indefinitely. The design considered limits such as the last 50 execution runs or roughly 2,000 spans, with older entries discarded automatically.&lt;/p&gt;

&lt;p&gt;That gives me enough history for local debugging without creating an unbounded memory problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Build an observability UI
&lt;/h2&gt;

&lt;p&gt;Raw spans are useful to developers.&lt;/p&gt;

&lt;p&gt;A visual timeline is faster.&lt;/p&gt;

&lt;p&gt;So V5 adds an Observability [V5] tab to the Streamlit application.&lt;br&gt;
The UI is designed around three things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Run selection
Execution timeline
Span inspection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run selector can distinguish workflows such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1 Search
V3 Visibility Scan
V4 Agent Chat

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeline shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;span name
start offset
duration
status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the span inspector exposes safe attributes, correlation IDs, events, and sanitized exception details.&lt;/p&gt;

&lt;p&gt;That means I can click a run and inspect the journey without leaving the application.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x1nkoj5x2uiw92wn9xx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x1nkoj5x2uiw92wn9xx.png" alt="The V5 Streamlit observability view makes each search, visibility scan, or agent interaction inspectable as a timed execution trace."&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Trace V3 visibility scans too
&lt;/h2&gt;

&lt;p&gt;Once the tracing foundation existed, I did not want it to stop at V1.&lt;/p&gt;

&lt;p&gt;The visibility workflow also gets its own trace hierarchy.&lt;/p&gt;

&lt;p&gt;The V3 root span is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;visibility.scan_execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with attributes such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;visibility.scan_id
visibility.brand_id
journey.run_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Child spans include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;visibility.extract_bundle
visibility.save_bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The persistence span can record metadata such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bigquery.dataset
bigquery.table_count
bigquery.saved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes it possible to distinguish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The search journey was slow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The visibility extraction was slow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The BigQuery persistence stage was slow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are operationally very different problems.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Trace V4 agent/tool behavior
&lt;/h2&gt;

&lt;p&gt;V4 introduced another layer: agent orchestration.&lt;/p&gt;

&lt;p&gt;Now a single user question can result in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemini reasoning
      ↓
ADK tool selection
      ↓
MCP request
      ↓
BigQuery read
      ↓
tool result
      ↓
Gemini synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So V5 also traces agent activity.&lt;/p&gt;

&lt;p&gt;The root agent interaction uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v4.chat_turn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tool calls use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v4.mcp_tool.&amp;lt;tool_name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And BigQuery reads use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v4.bigquery.read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The BigQuery span includes a bigquery.read_only attribute so the observability layer can preserve and expose the read-only nature of the V4 agent.&lt;/p&gt;

&lt;p&gt;This is useful because now I can answer questions such as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which MCP tool did the agent call?&lt;br&gt;
How long did it take?&lt;br&gt;
Which brand did it analyze?&lt;br&gt;
What date range was requested?&lt;br&gt;
How many rows came back?&lt;br&gt;
Did the BigQuery read succeed?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;without needing to infer that from the final answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvf34vqlpzdnkgatrai3c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvf34vqlpzdnkgatrai3c.png" alt="OpenTelemetry makes the V4 agent workflow inspectable down to individual MCP tool calls and read-only BigQuery operations."&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 13: Use traces to find bottlenecks
&lt;/h2&gt;

&lt;p&gt;This is where tracing becomes practical.&lt;/p&gt;

&lt;p&gt;Suppose a journey takes 5.4 seconds.&lt;/p&gt;

&lt;p&gt;The trace might reveal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;intent parsing             310 ms
query fan-out              460 ms
places retrieval         2,310 ms
search grounding           890 ms
ranking                     80 ms
answer synthesis           740 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the bottleneck is obvious.&lt;/p&gt;

&lt;p&gt;I do not need to optimize ranking.&lt;/p&gt;

&lt;p&gt;I do not need to optimize Gemini intent parsing.&lt;/p&gt;

&lt;p&gt;I need to investigate Places retrieval.&lt;/p&gt;

&lt;p&gt;On another run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;intent parsing             280 ms
query fan-out              440 ms
places retrieval           780 ms
search grounding         2,420 ms
ranking                     75 ms
answer synthesis           810 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the problem is different.&lt;/p&gt;

&lt;p&gt;Tracing turns:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The app feels slow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Search grounding contributed almost half the request duration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a much more actionable statement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 14: Use traces to debug failures
&lt;/h2&gt;

&lt;p&gt;Latency is only one use case.&lt;/p&gt;

&lt;p&gt;Failures become much easier to understand too.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Search failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;journey.execution             ERROR
├── journey.intent_parsing     OK
├── journey.query_fanout       OK
├── journey.places_retrieval   OK
├── journey.search_grounding   ERROR
└── remaining stages           not executed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I know exactly where to start.&lt;/p&gt;

&lt;p&gt;The telemetry test plan also explicitly covers failure injection, sanitized error capture, and span status verification.&lt;/p&gt;

&lt;p&gt;This becomes especially valuable once external services are involved.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 15: Run the application locally
&lt;/h2&gt;

&lt;p&gt;For local development, I still use the normal project workflow.&lt;/p&gt;

&lt;p&gt;Activate the virtual environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;run_app_locally&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftuh29hrdhlg1rjs702h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftuh29hrdhlg1rjs702h.png" alt="Running the same search journey locally now produces a complete trace that can be inspected inside the application."&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building the tracing layer
&lt;/h2&gt;

&lt;p&gt;The first lesson was that observability works best when the spans follow the architecture.&lt;/p&gt;

&lt;p&gt;If I create traces around random functions, the timeline becomes noise.&lt;/p&gt;

&lt;p&gt;If I create spans around real workflow stages, the trace becomes documentation.&lt;/p&gt;

&lt;p&gt;The second lesson was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Correlation matters as much as timing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A fast span is useful.&lt;/p&gt;

&lt;p&gt;A fast span tied to the correct search journey, visibility scan, or chat turn is much more useful.&lt;/p&gt;

&lt;p&gt;The third lesson was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Telemetry needs its own privacy model.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Logging every prompt and exception is easy.&lt;/p&gt;

&lt;p&gt;Designing traces that are useful without leaking secrets is harder—and more important.&lt;/p&gt;

&lt;p&gt;The fourth lesson was that observability should be added before the system becomes impossible to reason about.&lt;/p&gt;

&lt;p&gt;V5 was the point where the application had enough moving parts that tracing stopped being optional for me.&lt;/p&gt;




&lt;h2&gt;
  
  
  One thing I intentionally kept separate
&lt;/h2&gt;

&lt;p&gt;OpenTelemetry gives me the instrumentation model.&lt;/p&gt;

&lt;p&gt;It gives me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;traces
spans
attributes
statuses
context propagation
events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But V5 does not need to solve the entire production monitoring problem.&lt;/p&gt;

&lt;p&gt;I deliberately separated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instrumentation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Operational dashboards
metrics backend
trace backend
AgentOps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second layer is the next article.&lt;/p&gt;

&lt;p&gt;The project later extends the same telemetry into a Dockerized stack with an OpenTelemetry Collector, Tempo, Prometheus, and Grafana.&lt;/p&gt;

&lt;p&gt;But I wanted to understand the traces first.&lt;/p&gt;




&lt;h2&gt;
  
  
  From traces to AgentOps
&lt;/h2&gt;

&lt;p&gt;Once the traces were available, the next set of questions became operational:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How many requests are running?&lt;br&gt;
What is the average latency?&lt;br&gt;
Which stage fails most often?&lt;br&gt;
How many tool calls does the agent make?&lt;br&gt;
Which traces should I investigate?&lt;br&gt;
Can I see request metrics and traces together?&lt;br&gt;
Can the entire observability stack run locally?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That led to the next milestone:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;AgentOps&lt;/strong&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Next: Building AgentOps for Gemini Agents with Grafana, Tempo, and Prometheus
&lt;/h2&gt;

&lt;p&gt;In Part 6, I will take the OpenTelemetry instrumentation from V5 and connect it to a complete local observability stack.&lt;/p&gt;

&lt;p&gt;The architecture becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemini / Agent Application
          ↓
OpenTelemetry
          ↓
OTel Collector
       ↙       ↘
   Tempo      Prometheus
   Traces       Metrics
       ↘       ↙
        Grafana
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The focus will move from instrumenting individual runs to operating the system over time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source code
&lt;/h2&gt;

&lt;p&gt;The project is open source: &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;https://github.com/hastimal/ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>antigravity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building an AI Visibility Agent with Gemini, Google ADK, MCP, and BigQuery</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:10:22 +0000</pubDate>
      <link>https://dev.to/hjangid/building-an-ai-visibility-agent-with-gemini-google-adk-mcp-and-bigquery-4jhp</link>
      <guid>https://dev.to/hjangid/building-an-ai-visibility-agent-with-gemini-google-adk-mcp-and-bigquery-4jhp</guid>
      <description>&lt;p&gt;In Part 3, I built the historical layer.&lt;/p&gt;

&lt;p&gt;The system could persist AI visibility scans, brand observations, citations, and fan-out behavior in BigQuery. That gave me something I did not have in the earlier versions of this project: history.&lt;/p&gt;

&lt;p&gt;I could ask questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which brands were mentioned?&lt;/li&gt;
&lt;li&gt;Which brands were recommended?&lt;/li&gt;
&lt;li&gt;Which citation domains appeared?&lt;/li&gt;
&lt;li&gt;Which queries found or missed a brand?&lt;/li&gt;
&lt;li&gt;How did visibility change across repeated scans?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But there was still friction.&lt;/p&gt;

&lt;p&gt;Every time I wanted to investigate something new, I had to open the dashboard or write another SQL query.&lt;/p&gt;

&lt;p&gt;GitHub Code: &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That led to the next step:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What if I could ask an agent questions about the visibility history instead?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not a generic chatbot.&lt;/p&gt;

&lt;p&gt;Not an agent with unrestricted access.&lt;/p&gt;

&lt;p&gt;I wanted a constrained analytical agent that could inspect the visibility data I had already collected, use explicit tools, stay read-only, and return an answer grounded in actual BigQuery history.&lt;/p&gt;

&lt;p&gt;That became V4: the AI Visibility Agent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmijt98yzh8upyvxtvrla.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmijt98yzh8upyvxtvrla.png" alt="AI Visibility Agent Workspace" width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What changed from V3 to V4
&lt;/h2&gt;

&lt;p&gt;V3 gave me this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Visibility Scans
      ↓
BigQuery
      ↓
Dashboard + SQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;V4 adds an agent between the user and that historical data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Question
      ↓
Gemini
      ↓
Google ADK Agent
      ↓
MCP Toolset
      ↓
Local MCP Server
      ↓
Read-Only BigQuery Analytics
      ↓
Grounded Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not simply that an LLM sits in front of BigQuery.&lt;/p&gt;

&lt;p&gt;The important part is the boundary.&lt;/p&gt;

&lt;p&gt;V4 does not execute new Places searches, does not run new web searches, and does not write new visibility data. It acts as an analytical layer over the history produced by V3.&lt;/p&gt;

&lt;p&gt;That separation made the design much easier to reason about.&lt;/p&gt;

&lt;p&gt;V3 creates evidence.&lt;/p&gt;

&lt;p&gt;V4 investigates evidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Define the agent boundary first
&lt;/h2&gt;

&lt;p&gt;Before I built the agent, I decided what it should not be allowed to do.&lt;/p&gt;

&lt;p&gt;That turned out to be one of the most important design choices in this version.&lt;/p&gt;

&lt;p&gt;The V4 agent is intentionally read-only.&lt;/p&gt;

&lt;p&gt;It should not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;insert new BigQuery rows&lt;/li&gt;
&lt;li&gt;update visibility records&lt;/li&gt;
&lt;li&gt;delete historical data&lt;/li&gt;
&lt;li&gt;trigger new visibility scans&lt;/li&gt;
&lt;li&gt;call Google Places&lt;/li&gt;
&lt;li&gt;perform live web searches&lt;/li&gt;
&lt;li&gt;execute arbitrary user-supplied SQL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead, the agent should answer questions using a fixed set of analytical tools.&lt;/p&gt;

&lt;p&gt;That gives me a much cleaner boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1 / V2
Live search journey

V3
Persisted visibility evidence

V4
Read-only analytics over V3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I find this pattern much more useful than giving the model broad database access and trying to control it only through prompting.&lt;/p&gt;

&lt;p&gt;The capability boundary is architectural, not only instructional.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Use Google ADK as the orchestration layer
&lt;/h2&gt;

&lt;p&gt;The agent itself is built with Google ADK.&lt;/p&gt;

&lt;p&gt;Its job is not to contain all of the analytical logic.&lt;/p&gt;

&lt;p&gt;Instead, it handles the conversational layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User asks a question
        ↓
Gemini interprets the intent
        ↓
ADK selects an appropriate tool
        ↓
Tool returns structured data
        ↓
Gemini explains the result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the implementation, the ADK agent connects to the MCP toolset over stdio, while the MCP server runs separately and performs the read-only BigQuery analytics.&lt;/p&gt;

&lt;p&gt;That gives me two useful separations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reasoning
    ≠
Data access
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent orchestration
    ≠
Analytics implementation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model decides what information it needs.&lt;/p&gt;

&lt;p&gt;The tools decide how that information is retrieved.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs8o3jh9fp4c5y4ubv37w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs8o3jh9fp4c5y4ubv37w.png" alt="Gemini and Google ADK orchestrate read-only MCP tools over the historical visibility data created in V3." width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Use a clean architecture diagram showing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Question
      ↓
Gemini
      ↓
Google ADK Agent
      ↓
MCP Toolset
      ↓
Local MCP Server
      ↓
Read-Only BigQuery
      ↓
V3 Visibility History
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: Why I used MCP here
&lt;/h2&gt;

&lt;p&gt;I could have connected the agent directly to Python functions.&lt;/p&gt;

&lt;p&gt;But I wanted the data-access boundary to be more explicit.&lt;br&gt;
That is where MCP became useful.&lt;/p&gt;

&lt;p&gt;The analytical tools live behind a local MCP server that communicates with the ADK agent over JSON-RPC stdio.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ADK Agent
    │
    │ MCP over stdio
    ▼
MCP Server
    │
    ▼
Approved Analytics Tools
    │
    ▼
BigQuery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the agent does not need direct knowledge of the underlying SQL implementation.&lt;/p&gt;

&lt;p&gt;It sees a set of capabilities.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;get_available_history
get_visibility_summary
get_brand_trend
compare_brands
analyze_citations
find_fanout_gaps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those six tools form the analytical surface of V4.&lt;/p&gt;

&lt;p&gt;That is a much more controlled interface than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;run_any_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4: Build tools around analytical questions
&lt;/h2&gt;

&lt;p&gt;I did not want the tool names to mirror database tables.&lt;/p&gt;

&lt;p&gt;I wanted them to mirror the questions I actually ask when analyzing visibility.&lt;/p&gt;

&lt;p&gt;That resulted in six read-only tools.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;get_available_history&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before asking for a trend or comparison, I first need to know what history actually exists.&lt;/p&gt;

&lt;p&gt;This tool discovers things such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;persisted scan counts&lt;/li&gt;
&lt;li&gt;available date ranges&lt;/li&gt;
&lt;li&gt;known brand IDs&lt;/li&gt;
&lt;li&gt;recent scan windows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes it especially useful for broad prompts where the user does not specify a brand or date range.&lt;/p&gt;

&lt;p&gt;This also supports a pattern I like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Discover first, analyze second.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of letting the model assume what history exists, the agent can inspect the available data.&lt;/p&gt;




&lt;ol&gt;
&lt;li&gt;&lt;code&gt;get_visibility_summary&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This tool provides an aggregated visibility view over a selected brand and time window.&lt;/p&gt;

&lt;p&gt;It can work with metrics such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mention rate
recommendation rate
citation rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rather than making the model calculate those metrics itself, I keep them in the analytical layer.&lt;/p&gt;

&lt;p&gt;That makes the agent responsible for explanation rather than arithmetic.&lt;/p&gt;




&lt;ol&gt;
&lt;li&gt;&lt;code&gt;get_brand_trend&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This tool turns historical scan records into a time-oriented visibility view.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scan 1
Scan 2
Scan 3
Scan 4
   ↓
Historical visibility trajectory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives the agent a way to answer questions such as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this brand becoming more or less visible?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The trend logic remains deterministic.&lt;/p&gt;

&lt;p&gt;Gemini interprets the result.&lt;/p&gt;




&lt;ol&gt;
&lt;li&gt;&lt;code&gt;compare_brands&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This tool gives the agent a controlled way to compare a target brand with competitors.&lt;/p&gt;

&lt;p&gt;Instead of letting the model assemble arbitrary joins, the comparison is exposed as an explicit business operation.&lt;/p&gt;

&lt;p&gt;The tool can compare signals such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mentions
recommendations
citations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;between the target and competing brands.&lt;/p&gt;




&lt;ol&gt;
&lt;li&gt;&lt;code&gt;analyze_citations&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In V3, citations became independently queryable.&lt;/p&gt;

&lt;p&gt;V4 turns that into an agent capability.&lt;/p&gt;

&lt;p&gt;The agent can inspect citation-domain behavior across the stored search journeys instead of only answering questions about brand presence.&lt;/p&gt;

&lt;p&gt;That lets me ask questions closer to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which domains repeatedly support this brand?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;rather than only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Was this brand mentioned?&lt;/p&gt;
&lt;/blockquote&gt;




&lt;ol&gt;
&lt;li&gt;&lt;code&gt;find_fanout_gaps&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is one of the more interesting tools.&lt;/p&gt;

&lt;p&gt;The purpose is to identify fan-out queries where a target brand was absent from observed results.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Prompt
   ↓
Fan-Out Query A → Brand found
Fan-Out Query B → Brand found
Fan-Out Query C → Brand missing
Fan-Out Query D → Brand missing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the agent can investigate where visibility breaks down, not only whether overall visibility is high or low.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Keep the tools deterministic
&lt;/h2&gt;

&lt;p&gt;A design principle I carried over from V3 was this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Let the model reason about results, but do not make the model invent the measurements.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The MCP tools perform the analytical work.&lt;/p&gt;

&lt;p&gt;Gemini receives structured results and explains them.&lt;/p&gt;

&lt;p&gt;So instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemini:
"I think the brand visibility is probably around 70%."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the flow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Tool:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;mention_rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;68.2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;recommendation_rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;41.7&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;citation_rate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;53.6&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Gemini:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;"Your brand appeared in roughly two-thirds of the observed journeys,
but it was recommended less frequently..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact values come from data.&lt;/p&gt;

&lt;p&gt;The language comes from the model.&lt;/p&gt;

&lt;p&gt;That separation is important to me.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Use discovery-first grounding
&lt;/h2&gt;

&lt;p&gt;One problem with analytical agents is that users often ask incomplete questions.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Give me a visibility summary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A model could easily make assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which brand?&lt;/li&gt;
&lt;li&gt;what date range?&lt;/li&gt;
&lt;li&gt;which scans?&lt;/li&gt;
&lt;li&gt;what data actually exists?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead, V4 can discover the available history first using &lt;code&gt;get_available_history&lt;/code&gt;. For broad questions or questions without an explicit brand/date window, that discovery step gives the agent actual scan coverage before it performs deeper analysis&lt;/p&gt;

&lt;p&gt;That means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Question
   ↓
Discover available history
   ↓
Choose valid scope
   ↓
Run analysis
   ↓
Explain answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer this to embedding assumed date ranges in the prompt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Run the agent locally
&lt;/h2&gt;

&lt;p&gt;The V4 agent can be used through the Streamlit application or through a standalone CLI demo.&lt;/p&gt;

&lt;p&gt;First activate the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then launch the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;streamlit run src/ai_search_journey/app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqqedgxkcw5vusuj24q21.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqqedgxkcw5vusuj24q21.png" alt="The V4 AI Visibility Agent provides conversational access to the historical visibility evidence created in V3." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Start with a broad-history question
&lt;/h2&gt;

&lt;p&gt;One of the first prompts I use is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Give me a visibility summary for the available history.
Use the most recent available scan window.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important phrase is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;available history
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I do not want the model to assume that a particular brand or date range exists.&lt;/p&gt;

&lt;p&gt;The agent can inspect the historical coverage first and then use the appropriate read-only tools to produce the summary.&lt;/p&gt;

&lt;p&gt;This gives me a much more grounded conversation.&lt;/p&gt;

&lt;p&gt;The user does not need to know the dataset structure.&lt;/p&gt;

&lt;p&gt;The agent does not need to guess the dataset structure.&lt;/p&gt;

&lt;p&gt;The tools bridge that gap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1ndcc6w2znyxf2ytj8x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1ndcc6w2znyxf2ytj8x.png" alt="The agent first discovers available historical coverage before summarizing visibility." width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Ask for a competitor comparison
&lt;/h2&gt;

&lt;p&gt;The next test is more specific.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Compare "pullman_coffee" against its competitors for the most recent available scan window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The important detail is that the brand ID needs to exist in the historical data. The project documentation explicitly treats &lt;code&gt;pullman_coffee&lt;/code&gt; as an example only when it is present in the available scan history.&lt;/p&gt;

&lt;p&gt;In practice, I can first discover the available brand IDs and then choose one.&lt;/p&gt;

&lt;p&gt;The comparison tool can then return a structured view of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mentions
recommendations
citations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;between the target brand and competing brands.&lt;/p&gt;

&lt;p&gt;The model then explains the comparison in natural language.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxv0h30ntipse08leteua.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxv0h30ntipse08leteua.png" alt="A read-only competitive visibility comparison generated from persisted BigQuery scan history." width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Independently verify the agent with BigQuery
&lt;/h2&gt;

&lt;p&gt;One thing I do not want is an agent that becomes the only interface to the data.&lt;/p&gt;

&lt;p&gt;The underlying history should still be independently verifiable.&lt;/p&gt;

&lt;p&gt;V4 never writes to BigQuery; the queries below are only for verifying the V3 data that the agent reads.&lt;/p&gt;

&lt;p&gt;For example, I can inspect per-brand visibility metrics directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bq --project_id=ai-search-journey-lab query \
  --use_legacy_sql=false \
  --format=pretty \
  '
  SELECT
    brand_id,
    COUNT(DISTINCT scan_id) AS total_scans,
    ROUND(100 * AVG(CAST(mentioned AS INT64)), 1) AS mention_rate_pct,
    ROUND(100 * AVG(CAST(recommended AS INT64)), 1) AS recommendation_rate_pct,
    ROUND(100 * AVG(CAST(cited AS INT64)), 1) AS citation_rate_pct,
    MAX(started_at) AS latest_scan_at
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.brand_observations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 365 DAY)
  GROUP BY
    brand_id
  ORDER BY
    citation_rate_pct DESC,
    mention_rate_pct DESC,
    total_scans DESC
  '
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query gives me an independent path to the same underlying evidence used by the agent.&lt;/p&gt;

&lt;p&gt;I can also inspect recent persisted scans:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bq --project_id=ai-search-journey-lab query \
  --use_legacy_sql=false \
  --format=pretty \
  '
  SELECT
    scan_id,
    started_at,
    brand_id,
    brand_name_snapshot,
    prompt_text_snapshot
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.visibility_scans`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 365 DAY)
  ORDER BY
    started_at DESC
  LIMIT 10
  '
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partition filter matters here because the historical tables require filtering on &lt;code&gt;started_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsa3zfxvu877rt5t9ugw6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsa3zfxvu877rt5t9ugw6.png" alt="Verifying the historical visibility records independently from the agent using the BigQuery CLI." width="799" height="227"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Why I did not give the agent raw SQL
&lt;/h2&gt;

&lt;p&gt;A natural question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why not just let Gemini generate SQL?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That would certainly be simpler.&lt;/p&gt;

&lt;p&gt;But it would also create a much broader capability surface.&lt;/p&gt;

&lt;p&gt;With arbitrary SQL generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
  ↓
LLM
  ↓
Generated SQL
  ↓
Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the model has to understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;table names&lt;/li&gt;
&lt;li&gt;schemas&lt;/li&gt;
&lt;li&gt;joins&lt;/li&gt;
&lt;li&gt;partition requirements&lt;/li&gt;
&lt;li&gt;date handling&lt;/li&gt;
&lt;li&gt;security boundaries&lt;/li&gt;
&lt;li&gt;allowed operations&lt;/li&gt;
&lt;li&gt;analytical semantics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then I have to validate all of that.&lt;/p&gt;

&lt;p&gt;With MCP tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
  ↓
Gemini + ADK
  ↓
compare_brands(...)
  ↓
Controlled analytics logic
  ↓
BigQuery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the capability is much narrower.&lt;/p&gt;

&lt;p&gt;The model asks for an operation.&lt;/p&gt;

&lt;p&gt;The tool owns the implementation.&lt;/p&gt;

&lt;p&gt;That is a pattern I increasingly prefer for production-oriented agent systems.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Separate reasoning from authority
&lt;/h2&gt;

&lt;p&gt;This is probably the most important lesson from V4.&lt;/p&gt;

&lt;p&gt;LLMs are very good at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;interpreting a question&lt;/li&gt;
&lt;li&gt;selecting an analytical path&lt;/li&gt;
&lt;li&gt;comparing results&lt;/li&gt;
&lt;li&gt;summarizing findings&lt;/li&gt;
&lt;li&gt;explaining trade-offs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But that does not mean the model needs unrestricted authority over the underlying system.&lt;/p&gt;

&lt;p&gt;I can keep the reasoning flexible while keeping the capabilities constrained.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemini
    ↓
Flexible reasoning

Google ADK
    ↓
Agent orchestration

MCP
    ↓
Explicit capability boundary

BigQuery
    ↓
Deterministic historical evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That architecture is much easier for me to understand and debug.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 13: Preserve unsupported-answer behavior
&lt;/h2&gt;

&lt;p&gt;Another boundary matters just as much:&lt;/p&gt;

&lt;p&gt;The agent should be able to say:&lt;/p&gt;

&lt;p&gt;I do not have enough historical data to answer that.&lt;/p&gt;

&lt;p&gt;If the user asks about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a brand that does not exist in the scan history&lt;/li&gt;
&lt;li&gt;a time period with no scans&lt;/li&gt;
&lt;li&gt;a metric the tools do not expose&lt;/li&gt;
&lt;li&gt;current live-search behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the right response is not to manufacture an answer.&lt;/p&gt;

&lt;p&gt;The system should stay within the evidence available to V4.&lt;/p&gt;

&lt;p&gt;That makes the agent less magical.&lt;/p&gt;

&lt;p&gt;It also makes it much more useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 14: Validate the agent layer
&lt;/h2&gt;

&lt;p&gt;As with the previous versions, I do not consider the feature complete just because the UI responds.&lt;/p&gt;

&lt;p&gt;I run the repository checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./.venv/bin/ruff check . &amp;amp;&amp;amp; \
./.venv/bin/mypy src &amp;amp;&amp;amp; \
./.venv/bin/pytest -q
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftxck0x8u0f463icuwvhh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftxck0x8u0f463icuwvhh.png" alt="Repository validation after adding the V4 agent and MCP analytics layer" width="798" height="187"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What V4 changed for me
&lt;/h2&gt;

&lt;p&gt;V3 taught me to preserve the evidence before calculating the conclusion.&lt;/p&gt;

&lt;p&gt;V4 added another lesson:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Give the model access to capabilities, not unrestricted infrastructure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The final architecture is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemini
   ↓
Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Question
      ↓
Gemini
      ↓
Google ADK
      ↓
Approved MCP Tools
      ↓
Read-Only Analytics
      ↓
BigQuery History
      ↓
Structured Evidence
      ↓
Grounded Explanation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;Gemini provides the reasoning.&lt;/p&gt;

&lt;p&gt;Google ADK orchestrates the workflow.&lt;/p&gt;

&lt;p&gt;MCP defines the tool boundary.&lt;/p&gt;

&lt;p&gt;BigQuery remains the source of historical evidence.&lt;/p&gt;

&lt;p&gt;The analytical tools remain deterministic and read-only.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building the AI Visibility Agent
&lt;/h2&gt;

&lt;p&gt;The first lesson was that an analytical agent becomes more trustworthy when it knows less about the database.&lt;/p&gt;

&lt;p&gt;That may sound backward.&lt;/p&gt;

&lt;p&gt;But the model does not need to know every table, join, and partition rule.&lt;/p&gt;

&lt;p&gt;It needs to know which analytical capability to use.&lt;/p&gt;

&lt;p&gt;The second lesson was that discovery is a tool too.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_available_history&lt;/code&gt; is not just convenience.&lt;/p&gt;

&lt;p&gt;It helps prevent the model from reasoning over data that does not exist.&lt;/p&gt;

&lt;p&gt;The third lesson was that agents become easier to evaluate when the tools reflect real business questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What history exists?

How visible is this brand?

How is visibility changing?

How does it compare with competitors?

Which sources are citing it?

Where does query fan-out miss it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  From agentic analytics to observability
&lt;/h2&gt;

&lt;p&gt;Once the agent started working, another problem became much more obvious.&lt;/p&gt;

&lt;p&gt;Now I had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Question
      ↓
Gemini
      ↓
ADK Agent
      ↓
MCP Tool Selection
      ↓
MCP Server
      ↓
BigQuery
      ↓
Tool Result
      ↓
Gemini Synthesis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if something became slow or failed, I needed to know:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which stage caused it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Was Gemini slow?&lt;/p&gt;

&lt;p&gt;Did the MCP process take too long?&lt;/p&gt;

&lt;p&gt;Did the BigQuery tool fail?&lt;/p&gt;

&lt;p&gt;Which tool did the agent call?&lt;/p&gt;

&lt;p&gt;How much time did each stage take?&lt;/p&gt;

&lt;p&gt;That moved the project into the next phase: &lt;em&gt;observability&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next: From Query Fan-Out to Trace — Observing AI Search Journeys with OpenTelemetry
&lt;/h2&gt;

&lt;p&gt;In Part 5, I will instrument the AI Search Journey so I can trace what happens across the workflow.&lt;/p&gt;

&lt;p&gt;The focus moves from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What answer did the system produce?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How did the system produce it?&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Source code
&lt;/h2&gt;

&lt;p&gt;The project is open source: &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;GitHub Project&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>gemini</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Building an AI Search Visibility &amp; Brand Analyzer with Gemini, BigQuery, and Google Search Grounding</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Tue, 29 Sep 2026 05:22:17 +0000</pubDate>
      <link>https://dev.to/hjangid/building-an-ai-search-visibility-brand-analyzer-with-gemini-bigquery-and-google-search-grounding-286g</link>
      <guid>https://dev.to/hjangid/building-an-ai-search-visibility-brand-analyzer-with-gemini-bigquery-and-google-search-grounding-286g</guid>
      <description>&lt;h2&gt;
  
  
  AI Search Journey Lab — Part 3 of 7
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Measuring how brands appear across AI search journeys using Gemini, Google Search grounding, deterministic visibility scans, and BigQuery history.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Open source:&lt;/strong&gt; &lt;a href="https://github.com/hastimal/ai-search-journey-lab/tree/main" rel="noopener noreferrer"&gt;ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Previous:&lt;/strong&gt; &lt;a href="https://dev.to/hjangid/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud-53lm"&gt;Building Grounded Local Search with Gemini and Google Maps on Google Cloud&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The first two articles answered a user question. This one measures the system itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Part 1, I focused on the search journey:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How does one natural-language request become a grounded decision?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Part 2, I went deeper into the actual local-search implementation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do Gemini, Google Places, Search grounding, deterministic ranking, and Maps work together?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once that worked, I started asking a different question.&lt;/p&gt;

&lt;p&gt;Suppose I run the same kind of search repeatedly.&lt;/p&gt;

&lt;p&gt;What happens if instead of only asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which business should the user choose?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which brands keep appearing?&lt;br&gt;
Which brands are cited?&lt;br&gt;
Which competitors show up more often?&lt;br&gt;
Does a brand appear in the initial answer, the fan-out journey, or only in supporting evidence?&lt;br&gt;
How does that change over time?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the starting point for V3 — AI Visibility.&lt;/p&gt;

&lt;p&gt;This article is about turning an AI search workflow into something measurable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why AI visibility needs a different data model
&lt;/h2&gt;

&lt;p&gt;A single search result is transient.&lt;/p&gt;

&lt;p&gt;If I want to measure visibility, I need history.&lt;/p&gt;

&lt;p&gt;One run is interesting.&lt;/p&gt;

&lt;p&gt;Fifty runs are analyzable.&lt;/p&gt;

&lt;p&gt;Five hundred runs can start showing patterns.&lt;/p&gt;

&lt;p&gt;So V3 introduced a new concern into the project: &lt;strong&gt;persistence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is where BigQuery enters the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I wanted to measure
&lt;/h2&gt;

&lt;p&gt;For each visibility run, I wanted to capture things such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;target brand&lt;/li&gt;
&lt;li&gt;competitors&lt;/li&gt;
&lt;li&gt;original query&lt;/li&gt;
&lt;li&gt;fan-out tasks&lt;/li&gt;
&lt;li&gt;mentions&lt;/li&gt;
&lt;li&gt;citations&lt;/li&gt;
&lt;li&gt;candidate coverage&lt;/li&gt;
&lt;li&gt;source coverage&lt;/li&gt;
&lt;li&gt;ranking position&lt;/li&gt;
&lt;li&gt;execution timestamp&lt;/li&gt;
&lt;li&gt;historical trend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important shift was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1/V2:
user query → recommendation

V3:
many queries → repeatable measurements
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns the project from a search demo (which we have planned) into an analytics system. :) &lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqcw4w3sv2zoy3svw5pn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqcw4w3sv2zoy3svw5pn.png" alt="Building an AI Search Visibility &amp;amp; Brand Analyzer with Gemini" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: I made visibility scans deterministic
&lt;/h2&gt;

&lt;p&gt;One important design choice was that V3 should not rely on a user manually clicking around and interpreting the results.&lt;/p&gt;

&lt;p&gt;I wanted repeatable runs.&lt;/p&gt;

&lt;p&gt;That means the runner should accept an explicit input scope and execute the same workflow consistently.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VisibilityRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;target_brand&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Example Brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;competitors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Competitor A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Competitor B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;best local coffee shops in San Antonio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet coffee shop for remote work&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the runner executes the scan and records the results.&lt;/p&gt;

&lt;p&gt;The goal is reproducibility.&lt;/p&gt;

&lt;p&gt;If I run the same scenario tomorrow, I want to compare the resulting data rather than rely on screenshots or memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nfgjolhk6cunb8qyqs6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nfgjolhk6cunb8qyqs6.png" alt="AI Search Visibility" width="799" height="772"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Separate mention detection from visibility scoring
&lt;/h2&gt;

&lt;p&gt;A brand appearing in a response is not the same as a brand being strongly visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;brand:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;RankRabbit&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Response:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;"Other platforms include RankRabbit, Competitor A, and Competitor B."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a mention.&lt;/p&gt;

&lt;p&gt;But now &lt;strong&gt;compare&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. RankRabbit — recommended first
2. Competitor A
3. Competitor B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two appearances should not necessarily have the same visibility weight.&lt;/p&gt;

&lt;p&gt;So I separate several concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mention presence&lt;/li&gt;
&lt;li&gt;position&lt;/li&gt;
&lt;li&gt;citation presence&lt;/li&gt;
&lt;li&gt;fan-out coverage&lt;/li&gt;
&lt;li&gt;narrative prominence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That lets the application calculate visibility metrics explicitly instead of collapsing everything into one LLM judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Capture evidence before calculating visibility
&lt;/h2&gt;

&lt;p&gt;The workflow should preserve what caused a brand to be counted.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Brand"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mentioned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citation_present"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fanout_coverage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.67&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"evidence_sources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"source-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"source-b"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That data is much more valuable later than a single score such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;visibility_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;74&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because it lets me explain where the score came from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Persist visibility history in BigQuery
&lt;/h2&gt;

&lt;p&gt;V3 can run in-memory for a quick demo, but historical analysis needs persistence.&lt;/p&gt;

&lt;p&gt;I used a &lt;em&gt;BigQuery&lt;/em&gt; dataset for that purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Project:
ai-search-journey-lab

Dataset:
ai_search_journey_v3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's explore how bigQuery tables look like.....&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    table_name,
    table_type,
    creation_time
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.INFORMATION_SCHEMA.TABLES`
  ORDER BY
    table_name
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsozflotc0y5vg95vm1ko.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsozflotc0y5vg95vm1ko.png" alt="Bigquery dataset" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Query historical visibility with SQL
&lt;/h2&gt;

&lt;p&gt;Once I started persisting V3 runs in BigQuery, I no longer had to rely only on what the Streamlit dashboard displayed.&lt;/p&gt;

&lt;p&gt;I could query the visibility history directly from the terminal.&lt;br&gt;
That became one of my favorite parts of V3 because it gave me a second way to validate everything the UI was showing.&lt;/p&gt;

&lt;p&gt;For all of the examples below, I use the BigQuery CLI:&lt;/p&gt;

&lt;p&gt;Once scan results are persisted, SQL becomes one of the most useful tools in the project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frwil79mlf3uokmn8mlwi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frwil79mlf3uokmn8mlwi.png" alt="Big Query visibility scans" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BigQuery dataset showing the persisted visibility-analysis tables.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fboeqi6wy1rk6sx9xd12b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fboeqi6wy1rk6sx9xd12b.png" alt="BigQuery dataset showing the persisted visibility-analysis tables" width="800" height="182"&gt;&lt;/a&gt;&lt;br&gt;
The four tables I use most for visibility analysis are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;visibility_scans
brand_observations
fanout_observations
citations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each answers a different question.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;visibility_scans&lt;/span&gt;
    &lt;span class="s"&gt;└── What happened during the scan?&lt;/span&gt;

&lt;span class="s"&gt;brand_observations&lt;/span&gt;
    &lt;span class="s"&gt;└── How did each brand appear?&lt;/span&gt;

&lt;span class="s"&gt;fanout_observations&lt;/span&gt;
    &lt;span class="s"&gt;└── Where was the brand found during query fan-out?&lt;/span&gt;

&lt;span class="s"&gt;citations&lt;/span&gt;
    &lt;span class="s"&gt;└── Which sources supported the grounded result?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That separation became important as V3 grew.&lt;/p&gt;

&lt;p&gt;I did not want &lt;em&gt;one oversized table&lt;/em&gt; containing execution metadata, brand observations, fan-out evidence, citations, and historical measurements all mixed together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inspect the actual schema&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before writing analytical queries, I wanted to verify the exact schema from the terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    table_name,
    ordinal_position,
    column_name,
    data_type
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.INFORMATION_SCHEMA.COLUMNS`
  WHERE
    table_name IN (
      "visibility_scans",
      "brand_observations",
      "fanout_observations",
      "citations"
    )
  ORDER BY
    table_name,
    ordinal_position
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68asp7wdssojn81dsw24.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68asp7wdssojn81dsw24.png" alt="Inspecting the V3 BigQuery schema directly from the CLI" width="800" height="795"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is useful because the schema itself reflects the measurements I wanted to preserve.&lt;/p&gt;

&lt;p&gt;For example, &lt;code&gt;brand_observations&lt;/code&gt; contains separate fields for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;mentioned&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;mention_count&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;first_mention_position&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;retrieved&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;best_retrieval_position&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;recommended&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;recommendation_position&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;cited&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;citation_urls&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;citation_domains&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was deliberate.&lt;/p&gt;

&lt;p&gt;I wanted to store what actually happened before reducing everything to a single visibility number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query recent visibility scans
&lt;/h2&gt;

&lt;p&gt;The first operational query I usually run is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Show me the latest scans&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    scan_id,
    batch_id,
    project_id,
    brand_id,
    brand_name_snapshot,
    prompt_id,
    model_name,
    location_snapshot,
    started_at,
    completed_at,
    status,
    duration_seconds
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.visibility_scans`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  ORDER BY
    started_at DESC
  LIMIT 20
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Recent AI visibility scans queried directly from BigQuery using the bq CLI.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1m3bj3j7wzxlq45qkznd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1m3bj3j7wzxlq45qkznd.png" alt="Recent AI visibility scans queried directly from BigQuery using the bq CLI." width="800" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The time filter is important.&lt;/p&gt;

&lt;p&gt;My visibility tables are partitioned by started_at, and I intentionally require partition filtering rather than allowing unrestricted scans over the complete history.&lt;/p&gt;

&lt;p&gt;That becomes increasingly useful as the amount of historical data grows.&lt;/p&gt;

&lt;p&gt;From this one query I can inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which scan ran&lt;/li&gt;
&lt;li&gt;which batch it belonged to&lt;/li&gt;
&lt;li&gt;which brand was evaluated&lt;/li&gt;
&lt;li&gt;which prompt triggered it&lt;/li&gt;
&lt;li&gt;which Gemini model was used&lt;/li&gt;
&lt;li&gt;which location context was applied&lt;/li&gt;
&lt;li&gt;when the scan started and completed&lt;/li&gt;
&lt;li&gt;whether it succeeded&lt;/li&gt;
&lt;li&gt;how long it took&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dashboard gives me the visualization.&lt;/p&gt;

&lt;p&gt;BigQuery gives me the underlying record.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Mentioned is not the same as recommended
&lt;/h2&gt;

&lt;p&gt;This became one of the most important distinctions in V3.&lt;/p&gt;

&lt;p&gt;A brand can appear in an AI-generated result in several different ways.&lt;/p&gt;

&lt;p&gt;It might be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retrieved&lt;/li&gt;
&lt;li&gt;mentioned&lt;/li&gt;
&lt;li&gt;recommended&lt;/li&gt;
&lt;li&gt;cited&lt;/li&gt;
&lt;li&gt;ranked before or after another brand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not the same thing.&lt;/p&gt;

&lt;p&gt;Let's consider these two observations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;retrieved&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;mentioned&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;recommended&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;cited&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retrieved = true
mentioned = true
recommended = true
cited = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both brands were visible.&lt;/p&gt;

&lt;p&gt;But they were not visible in the same way.&lt;/p&gt;

&lt;p&gt;The first brand was discovered and mentioned.&lt;/p&gt;

&lt;p&gt;The second made it into the recommendation.&lt;/p&gt;

&lt;p&gt;Instead of asking Gemini to generate a vague visibility score, I first persist these observable states.&lt;/p&gt;

&lt;p&gt;I can inspect them directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    scan_id,
    brand_id,
    role,
    mentioned,
    mention_count,
    first_mention_position,
    retrieved,
    best_retrieval_position,
    recommended,
    recommendation_position,
    cited,
    citation_domains
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.brand_observations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  ORDER BY
    started_at DESC,
    scan_id,
    first_mention_position
  LIMIT 50
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhqjozgtgezxaeg7j2o8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhqjozgtgezxaeg7j2o8.png" alt="BigQuery AI visibility" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I can ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Was the brand retrieved?

Was it mentioned?

Where did the first mention occur?

Was it actually recommended?

At what recommendation position?

Was there citation evidence associated with it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those questions are much easier to reason about than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;visibility_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;74&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;without knowing why the score is 74.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Aggregate visibility across repeated scans
&lt;/h2&gt;

&lt;p&gt;Once multiple scans exist, SQL becomes even more useful.&lt;/p&gt;

&lt;p&gt;For example, I can ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How often does each brand appear, get recommended, or receive citation support?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    brand_id,
    COUNT(*) AS observations,
    COUNTIF(mentioned) AS mentioned_count,
    COUNTIF(retrieved) AS retrieved_count,
    COUNTIF(recommended) AS recommended_count,
    COUNTIF(cited) AS cited_count,
    ROUND(
      SAFE_DIVIDE(COUNTIF(mentioned), COUNT(*)) * 100,
      2
    ) AS mention_rate_pct,
    ROUND(
      SAFE_DIVIDE(COUNTIF(recommended), COUNT(*)) * 100,
      2
    ) AS recommendation_rate_pct
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.brand_observations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  GROUP BY
    brand_id
  ORDER BY
    recommendation_rate_pct DESC,
    mention_rate_pct DESC
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I am no longer asking only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What did Gemini answer?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I can ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Across repeated grounded search journeys, which brands keep appearing?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a very different problem.&lt;/p&gt;

&lt;p&gt;And it is where V3 started becoming an analytics system rather than only a search demo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregating brand mentions, retrievals, recommendations, and citations across repeated scans&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu1yf3fdlsamklpb8ad5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu1yf3fdlsamklpb8ad5.png" alt="Aggregating brand mentions, retrievals, recommendations, and citations across repeated scans" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's be on the same page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Position matters too&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Presence&lt;/em&gt; alone is not enough.&lt;/p&gt;

&lt;p&gt;A brand appearing first in an answer is different from appearing sixth.&lt;/p&gt;

&lt;p&gt;That is why V3 also preserves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;first_mention_position
best_retrieval_position
recommendation_position
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can analyze those separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    brand_id,
    COUNTIF(mentioned) AS mentions,
    ROUND(AVG(first_mention_position), 2) AS avg_first_mention_position,
    COUNTIF(recommended) AS recommendations,
    ROUND(AVG(recommendation_position), 2) AS avg_recommendation_position,
    ROUND(AVG(best_retrieval_position), 2) AS avg_best_retrieval_position
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.brand_observations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  GROUP BY
    brand_id
  ORDER BY
    recommendations DESC,
    avg_recommendation_position ASC
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcrcee02uwmnshjccxik.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcrcee02uwmnshjccxik.png" alt="Aggregating brand mentions, retrievals, recommendations, and citations across repeated scans" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
This lets me separate several ideas that often get grouped together as AI visibility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Presence
Position
Retrieval
Recommendation
Citation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I find that separation much more useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Look inside query fan-out
&lt;/h2&gt;

&lt;p&gt;One thing I learned while building the earlier versions of this project is that the final answer hides a lot of interesting behavior.&lt;/p&gt;

&lt;p&gt;A single user query can create several retrieval tasks.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User prompt
    │
    ├── search intent A
    ├── search intent B
    ├── search intent C
    └── search intent D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A brand may appear in all four branches.&lt;/p&gt;

&lt;p&gt;Or only one.&lt;/p&gt;

&lt;p&gt;Those situations should not necessarily be treated as identical.&lt;/p&gt;

&lt;p&gt;That is why V3 persists &lt;code&gt;fanout_observations&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The table includes fields such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;task_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;query_text&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;tool&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;brand_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;brand_found&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;position_in_task&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can inspect those observations directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    scan_id,
    prompt_id,
    task_id,
    query_text,
    tool,
    brand_id,
    brand_found,
    position_in_task
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.fanout_observations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  ORDER BY
    started_at DESC,
    scan_id,
    task_id,
    position_in_task
  LIMIT 100
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the intermediate journey becomes queryable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Original prompt
      ↓
Fan-out task
      ↓
Generated query
      ↓
Retrieval tool
      ↓
Brand found?
      ↓
Position in task
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives me a way to investigate why one brand appears repeatedly while another does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure fan-out coverage
&lt;/h2&gt;

&lt;p&gt;I can also aggregate the fan-out behavior.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    brand_id,
    COUNT(DISTINCT task_id) AS fanout_tasks,
    COUNTIF(brand_found) AS observations_where_brand_was_found,
    ROUND(
      SAFE_DIVIDE(
        COUNTIF(brand_found),
        COUNT(*)
      ) * 100,
      2
    ) AS found_rate_pct
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.fanout_observations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  GROUP BY
    brand_id
  ORDER BY
    found_rate_pct DESC
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frg9u59wo1wmk6ro1sua8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frg9u59wo1wmk6ro1sua8.png" alt="Measure fan-out coverage" width="800" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I think of this as a project-specific fan-out coverage signal.&lt;/p&gt;

&lt;p&gt;It is not intended to be a universal AI visibility metric.&lt;/p&gt;

&lt;p&gt;It simply tells me how consistently a brand was found across the retrieval observations generated by this implementation.&lt;/p&gt;

&lt;p&gt;Being explicit about that distinction matters.&lt;/p&gt;




&lt;p&gt;Step 9: Make citation visibility queryable&lt;/p&gt;

&lt;p&gt;Mentions are useful.&lt;/p&gt;

&lt;p&gt;Recommendations are useful.&lt;/p&gt;

&lt;p&gt;But I also wanted to understand the evidence behind the grounded answer.&lt;/p&gt;

&lt;p&gt;So citations are persisted separately.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;citations&lt;/code&gt; table contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;scan&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;project&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;prompt&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;started&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;
&lt;span class="k"&gt;url&lt;/span&gt;
&lt;span class="k"&gt;domain&lt;/span&gt;
&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;
&lt;span class="k"&gt;matched&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;brand&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;ids&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can inspect the underlying evidence directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    scan_id,
    prompt_id,
    domain,
    source_type,
    url,
    matched_brand_ids
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.citations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  ORDER BY
    started_at DESC
  LIMIT 100
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2v1so1va3trqrgb5uzr6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2v1so1va3trqrgb5uzr6.png" alt="Evidence query" width="799" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This leads to another interesting question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which domains repeatedly appear as supporting sources?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2qr97vvz2xvc7lj4r8w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg2qr97vvz2xvc7lj4r8w.png" alt="Analyzing which source domains repeatedly support grounded visibility results" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That gives me another dimension of AI visibility.&lt;/p&gt;

&lt;p&gt;Instead of only asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Was my brand mentioned?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I can also ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Which sources repeatedly support grounded answers?

Which domains are connected to particular brands?

Which sources appear across multiple scans?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For me, that makes citation analysis one of the more interesting parts of V3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working with repeated fields in BigQuery
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;matched_brand_ids&lt;/code&gt; is stored as an &lt;code&gt;ARRAY&amp;lt;STRING&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;BigQuery makes it straightforward to expand that repeated field using &lt;code&gt;UNNEST&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    matched_brand_id AS brand_id,
    domain,
    COUNT(*) AS citation_count
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.citations`,
    UNNEST(matched_brand_ids) AS matched_brand_id
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  GROUP BY
    matched_brand_id,
    domain
  ORDER BY
    citation_count DESC
  LIMIT 50
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbc4zl5u8w4yu9bnq18e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdbc4zl5u8w4yu9bnq18e.png" alt="matched_brand_ids based query" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That connects two useful concepts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Brand visibility
        +
Citation visibility
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A brand may appear frequently but have relatively little citation support.&lt;/p&gt;

&lt;p&gt;Another may appear less often but repeatedly connect to a particular set of sources.&lt;/p&gt;

&lt;p&gt;Persisting both lets me investigate that difference instead of hiding it inside one aggregate score.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Turn visibility into a historical signal
&lt;/h2&gt;

&lt;p&gt;The reason I chose BigQuery was not just to persist individual runs.&lt;/p&gt;

&lt;p&gt;The real value appears when the same measurements accumulate over time.&lt;/p&gt;

&lt;p&gt;For example, I can calculate daily visibility observations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="c1"&gt;--format=pretty \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    DATE(started_at) AS scan_date,
    brand_id,
    COUNT(*) AS observations,
    COUNTIF(mentioned) AS mentions,
    COUNTIF(recommended) AS recommendations,
    COUNTIF(cited) AS citations,
    ROUND(
      SAFE_DIVIDE(COUNTIF(mentioned), COUNT(*)) * 100,
      2
    ) AS mention_rate_pct,
    ROUND(
      SAFE_DIVIDE(COUNTIF(recommended), COUNT(*)) * 100,
      2
    ) AS recommendation_rate_pct
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.brand_observations`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  GROUP BY
    scan_date,
    brand_id
  ORDER BY
    scan_date DESC,
    brand_id
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the system evolves from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Search Journey
      ↓
One Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Search Journey
      ↓
Repeated Scans
      ↓
Persisted Observations
      ↓
Historical Visibility
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That historical layer is what I was missing in the first versions of the project.&lt;/p&gt;

&lt;p&gt;A single answer can tell me what happened now.&lt;/p&gt;

&lt;p&gt;A history can start telling me what keeps happening.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: The scan itself is also observable
&lt;/h2&gt;

&lt;p&gt;Another useful part of the schema is that &lt;code&gt;visibility_scans&lt;/code&gt; does not only record the result.&lt;/p&gt;

&lt;p&gt;It also records operational information such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;started_at
completed_at
status
duration_seconds
error_code
error_message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means I can inspect the behavior of the visibility pipeline itself.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bq query \
  --use_legacy_sql=false \
  --format=pretty \
  '
  SELECT
    status,
    COUNT(*) AS scans,
    ROUND(AVG(duration_seconds), 2) AS avg_duration_seconds,
    ROUND(MAX(duration_seconds), 2) AS max_duration_seconds
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.visibility_scans`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  GROUP BY
    status
  ORDER BY
    scans DESC
  '
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I can inspect unsuccessful runs separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bq query \
  --use_legacy_sql=false \
  --format=pretty \
  '
  SELECT
    scan_id,
    brand_name_snapshot,
    prompt_text_snapshot,
    model_name,
    started_at,
    status,
    error_code,
    error_message
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.visibility_scans`
  WHERE
    started_at &amp;gt;= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
    AND status != "completed"
  ORDER BY
    started_at DESC
  LIMIT 25
  '
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwq2osgs6fejqgz0s6j8h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwq2osgs6fejqgz0s6j8h.png" alt="Journey Lab" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This distinction became useful later when I added deeper observability to the project.&lt;/p&gt;

&lt;p&gt;There are really two different things I want to observe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What did the AI search journey produce?

and

How did the system behave while producing it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;V3 primarily focuses on the first.&lt;/p&gt;

&lt;p&gt;Later versions go much deeper into the second.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Keep BigQuery as the analytical source of truth
&lt;/h2&gt;

&lt;p&gt;At this point, the V3 architecture became much clearer to me.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    AI Visibility Scan
                           │
                           ▼
                 Gemini + Search Grounding
                           │
                           ▼
                    Evidence Extraction
                           │
              ┌────────────┼────────────┐
              │            │            │
              ▼            ▼            ▼
        Brand          Fan-out       Citation
      Observation    Observation     Evidence
              │            │            │
              └────────────┼────────────┘
                           │
                           ▼
                        BigQuery
                           │
                 ┌─────────┴─────────┐
                 ▼                   ▼
          Streamlit Dashboard    SQL Analysis
                 │                   │
                 └─────────┬─────────┘
                           ▼
                Historical Visibility
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl6yvkhsu42xqpv5f3wt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl6yvkhsu42xqpv5f3wt.png" alt="BigQuery Analytics Architecture" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;BigQuery gives me an independent analytical layer.&lt;/p&gt;

&lt;p&gt;That means I can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validate what the dashboard shows&lt;/li&gt;
&lt;li&gt;write new analytical queries without changing the UI&lt;/li&gt;
&lt;li&gt;compare historical runs&lt;/li&gt;
&lt;li&gt;inspect fan-out behavior&lt;/li&gt;
&lt;li&gt;study citation sources&lt;/li&gt;
&lt;li&gt;later expose the same evidence to an agent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part becomes important in V4.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgcmasx671t79y01v5rqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgcmasx671t79y01v5rqz.png" alt="Visibility Dashboard" width="800" height="662"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 13: Validate the implementation, not just the demo
&lt;/h2&gt;

&lt;p&gt;I try not to treat a working Streamlit page as proof that the underlying application is healthy.&lt;/p&gt;

&lt;p&gt;Before considering a milestone complete, I run the repository checks together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./.venv/bin/ruff check . &amp;amp;&amp;amp; \
./.venv/bin/mypy src &amp;amp;&amp;amp; \
./.venv/bin/pytest -q
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the time of this build, the repository validation looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ruff
All checks passed!

mypy
Success: no issues found in 38 source files

pytest
382 passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact number of tests will naturally change as the project continues to grow.&lt;/p&gt;

&lt;p&gt;What matters more to me is keeping the same habit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build
  ↓
Run
  ↓
Inspect
  ↓
Test
  ↓
Then extend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That has become especially important as this project has expanded from one search workflow into multiple versions with analytics, agents, and observability.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foecvtefh2m4p1lx6vcs6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foecvtefh2m4p1lx6vcs6.png" alt="Final repository validation for the V3 visibility milestone." width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;From analytics to an agent&lt;/p&gt;

&lt;p&gt;Once V3 was working, I found myself doing something repeatedly.&lt;/p&gt;

&lt;p&gt;I would open the dashboard.&lt;/p&gt;

&lt;p&gt;Then I would run a BigQuery query.&lt;/p&gt;

&lt;p&gt;Then another.&lt;/p&gt;

&lt;p&gt;Then I would compare brands.&lt;/p&gt;

&lt;p&gt;Then citation domains.&lt;/p&gt;

&lt;p&gt;Then fan-out behavior.&lt;/p&gt;

&lt;p&gt;Eventually the next question became obvious:&lt;/p&gt;

&lt;p&gt;What if I could ask an agent questions about this visibility history instead of manually writing every query?&lt;/p&gt;

&lt;p&gt;But I did not want an unconstrained agent with arbitrary access to the analytics store.&lt;/p&gt;

&lt;p&gt;I wanted something much more controlled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User question
      ↓
Gemini
      ↓
Google ADK Agent
      ↓
Approved tools
      ↓
MCP
      ↓
BigQuery visibility history
      ↓
Grounded analytical answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That became V4.&lt;/p&gt;

&lt;p&gt;And it introduced a different engineering problem:&lt;/p&gt;

&lt;p&gt;How do I give an AI agent useful access to analytics while keeping its capabilities explicit, inspectable, and constrained?&lt;/p&gt;

&lt;p&gt;That is where I go next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next: Building an AI Visibility Agent with Gemini, Google ADK, MCP, and BigQuery
&lt;/h2&gt;

&lt;p&gt;In Part 4, I take the visibility history created in V3 and put an agent in front of it.&lt;/p&gt;

&lt;p&gt;The focus will be on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemini for reasoning&lt;/li&gt;
&lt;li&gt;Google ADK for agent orchestration&lt;/li&gt;
&lt;li&gt;MCP for tool access&lt;/li&gt;
&lt;li&gt;BigQuery as the visibility data source&lt;/li&gt;
&lt;li&gt;explicit read-only analytical boundaries&lt;/li&gt;
&lt;li&gt;tool-call visibility&lt;/li&gt;
&lt;li&gt;handling unsupported questions&lt;/li&gt;
&lt;li&gt;keeping deterministic data access separate from model reasoning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to add a chatbot on top of a dashboard.&lt;/p&gt;

&lt;p&gt;The goal is to build an agent that can investigate the evidence already produced by the system.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>bigquery</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Building Grounded Local Search with Gemini and Google Maps on Google Cloud</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Tue, 29 Sep 2026 04:09:07 +0000</pubDate>
      <link>https://dev.to/hjangid/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud-53lm</link>
      <guid>https://dev.to/hjangid/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud-53lm</guid>
      <description>&lt;h2&gt;
  
  
  AI Search Journey Lab — Part 2 of 7
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Building a grounded local-search workflow with Gemini, Google Places API (New), Google Search grounding, deterministic ranking, Maps links, and Cloud Run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Open source:&lt;/strong&gt; &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Previous article: &lt;a href="https://dev.to/hjangid/search-journey-optimization-with-gemini-from-query-fan-out-to-grounded-decisions-3f99"&gt;Search Journey Optimization with Gemini: From Query Fan-Out to Grounded Decisions&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;In Part 1, I explained the journey. In Part 2, I want to show the implementation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the first article, I focused on the architecture behind a search journey:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;intent → query fan-out → retrieval → evidence → deduplication → ranking → grounded answer&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the conceptual layer.&lt;/p&gt;

&lt;p&gt;This article is more practical.&lt;/p&gt;

&lt;p&gt;I want to show how I built the actual local-search workflow behind that architecture using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemini&lt;/li&gt;
&lt;li&gt;Google Places API (New)&lt;/li&gt;
&lt;li&gt;Google Search grounding&lt;/li&gt;
&lt;li&gt;Google Maps links&lt;/li&gt;
&lt;li&gt;deterministic ranking&lt;/li&gt;
&lt;li&gt;Python&lt;/li&gt;
&lt;li&gt;Streamlit&lt;/li&gt;
&lt;li&gt;Cloud Run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original demo query is still the same:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find a coffee shop near Geekdom in San Antonio for six people, quiet enough to work, open after 8 PM, and recommend the top three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The difference now is that I want to focus on what happens after the intent has been understood.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The local-search problem is really two retrieval problems&lt;/strong&gt;&lt;br&gt;
When I first started implementing this workflow, I realized that one retrieval system was not enough.&lt;/p&gt;

&lt;p&gt;Some constraints are structured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;business name&lt;/li&gt;
&lt;li&gt;address&lt;/li&gt;
&lt;li&gt;rating&lt;/li&gt;
&lt;li&gt;review count&lt;/li&gt;
&lt;li&gt;opening hours&lt;/li&gt;
&lt;li&gt;Place ID&lt;/li&gt;
&lt;li&gt;Maps URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google Places is a very good fit for those.&lt;/p&gt;

&lt;p&gt;But some user requirements are much softer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;quiet enough to work&lt;/li&gt;
&lt;li&gt;suitable for a group&lt;/li&gt;
&lt;li&gt;good vegetarian options&lt;/li&gt;
&lt;li&gt;comfortable for studying&lt;/li&gt;
&lt;li&gt;appropriate for a child who is anxious about dental visits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those do not always map cleanly to one structured field.&lt;/p&gt;

&lt;p&gt;That led me to split retrieval into &lt;strong&gt;two paths&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Google Places for structured local data&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Google Search grounding for additional evidence&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That separation is at the heart of this article.&lt;/p&gt;


&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The V1 flow for grounded local search looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User Query → Gemini Intent → Query Fan-Out → Places API + Google Search Grounding → Normalize → Evidence → Score → Gemini Synthesis → Maps-linked Recommendations&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw14523uxzrxw0ujcuk5g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw14523uxzrxw0ujcuk5g.png" alt="Grounded local search architecture" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Start from structured intent
&lt;/h2&gt;

&lt;p&gt;I do not call Places directly with the full user prompt.&lt;/p&gt;

&lt;p&gt;First, I want a structured representation of what the user is asking for.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee_shop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location_reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Geekdom, San Antonio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"party_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"open_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"quiet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"work-friendly"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing here is that the retrieval layer no longer has to interpret every phrase from scratch.&lt;/p&gt;

&lt;p&gt;Gemini handles the language ambiguity.&lt;/p&gt;

&lt;p&gt;The application gets a predictable structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Turn intent into retrieval tasks
&lt;/h2&gt;

&lt;p&gt;Once I have the intent, I generate a bounded retrieval plan.&lt;/p&gt;

&lt;p&gt;A simplified example might look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"places_tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee shops near Geekdom San Antonio"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"work-friendly coffee shops downtown San Antonio"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"search_tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee near Geekdom quiet work open late"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deliberately keep this small.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;goal&lt;/em&gt; is &lt;strong&gt;not to generate ten variants of the same search&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The goal is to cover enough of the user's intent without creating unnecessary duplication and latency.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzy8gs6sx6i5h6ny8h32.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzy8gs6sx6i5h6ny8h32.png" alt="Query Fanout Places and Search" width="800" height="1119"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Google Places becomes the structured candidate source
&lt;/h2&gt;

&lt;p&gt;For local search, Google Places API (New) gives me the first candidate set.&lt;/p&gt;

&lt;p&gt;A simplified Text Search call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://places.googleapis.com/v1/places:searchText&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;textQuery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pageSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-Api-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-FieldMask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.formattedAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.userRatingCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.currentOpeningHours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.googleMapsUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What matters most to me here is not the HTTP request itself.&lt;/p&gt;

&lt;p&gt;It is the set of fields that become available to downstream logic.&lt;/p&gt;




&lt;h2&gt;
  
  
  I use field masks intentionally
&lt;/h2&gt;

&lt;p&gt;For this workflow, I do not need every possible field.&lt;/p&gt;

&lt;p&gt;I only want the fields that are useful for the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;places.id
places.displayName
places.formattedAddress
places.rating
places.userRatingCount
places.currentOpeningHours
places.googleMapsUri
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the retrieval boundary explicit.&lt;/p&gt;

&lt;p&gt;It also makes it easier to understand which parts of the final recommendation came directly from Places data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Place ID becomes the anchor for identity
&lt;/h2&gt;

&lt;p&gt;One thing I did not appreciate enough at the beginning was how important entity identity would become.&lt;/p&gt;

&lt;p&gt;Suppose &lt;strong&gt;two&lt;/strong&gt; fan-out tasks return the &lt;strong&gt;same&lt;/strong&gt; &lt;strong&gt;business&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Without &lt;em&gt;normalization&lt;/em&gt;, I might end up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Candidate A
Candidate A
Candidate B
Candidate C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That creates a &lt;strong&gt;ranking&lt;/strong&gt; &lt;strong&gt;problem&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I use a canonical identifier such as &lt;code&gt;Place ID&lt;/code&gt; to determine whether I am looking at the same entity.&lt;/p&gt;

&lt;p&gt;A simplified deduplication step looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deduplicate_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;place_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → normalize identity → deduplicate → rank&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;NOT&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → rank duplicates → fix identity later&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzngj63e68el6idqykn2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzngj63e68el6idqykn2.png" alt="Places results in search journey" width="800" height="1429"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Generate Google Maps links as part of the decision output
&lt;/h2&gt;

&lt;p&gt;One thing I wanted from the beginning was that the recommendation should not end as plain text.&lt;/p&gt;

&lt;p&gt;If the user asks for a &lt;strong&gt;local business&lt;/strong&gt;, the result should be &lt;strong&gt;actionable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is why I keep the Google Maps URI returned by Places.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formattedAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maps_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googleMapsUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the final recommendation can give the user a direct route from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI recommendation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google Maps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds small, but it changes the experience from “&lt;strong&gt;read an AI answer&lt;/strong&gt;” to “&lt;strong&gt;act on an AI answer.&lt;/strong&gt;”&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Places alone did not solve the problem
&lt;/h2&gt;

&lt;p&gt;Now we reach the interesting part.&lt;/p&gt;

&lt;p&gt;Google Places can tell me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the business exists&lt;/li&gt;
&lt;li&gt;where it is&lt;/li&gt;
&lt;li&gt;its rating&lt;/li&gt;
&lt;li&gt;its opening hours&lt;/li&gt;
&lt;li&gt;its Maps URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the original user also asked for:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;quiet enough to work&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a different class of requirement.&lt;/p&gt;

&lt;p&gt;There may not be a clean structured field for it.&lt;/p&gt;

&lt;p&gt;So I added a second evidence path using Google Search grounding.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Ground the softer constraints
&lt;/h2&gt;

&lt;p&gt;I do not ask Gemini a broad question like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this coffee shop suitable?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I make the verification task more specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Candidate:
Example Coffee

Verify:
1. evidence that it is open late
2. evidence relevant to working/studying
3. evidence relevant to seating/group suitability

For each constraint:
- supported
- unsupported
- supporting evidence
- citation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually, the call sits behind a workflow stage like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_grounding.verify_evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives me a richer candidate representation.&lt;/p&gt;




&lt;h2&gt;
  
  
  A candidate now has two kinds of evidence
&lt;/h2&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"place_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Coffee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"places"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"review_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;825&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"open_after_8"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"search_evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"work_friendly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"group_suitability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;null&lt;/code&gt; is useful.&lt;/p&gt;

&lt;p&gt;If I cannot verify group suitability, I do not want the system to quietly convert uncertainty into confidence.&lt;/p&gt;

&lt;p&gt;That is one of the most important lessons I took from this build:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unsupported should remain unsupported.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpkm2n9z53psoy3g5iew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpkm2n9z53psoy3g5iew.png" alt="Structured &amp;amp; Grounded Evidence by Candidate" width="800" height="848"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: I keep evidence matching separate from synthesis
&lt;/h2&gt;

&lt;p&gt;Another decision I made was to avoid letting the final LLM call discover and interpret everything again from scratch.&lt;/p&gt;

&lt;p&gt;By the time Gemini reaches the final synthesis stage, the system already has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structured intent&lt;/li&gt;
&lt;li&gt;candidate identities&lt;/li&gt;
&lt;li&gt;Places data&lt;/li&gt;
&lt;li&gt;grounded Search evidence&lt;/li&gt;
&lt;li&gt;unsupported constraints&lt;/li&gt;
&lt;li&gt;scores&lt;/li&gt;
&lt;li&gt;rank order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the final prompt much narrower.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Deterministic ranking before final synthesis
&lt;/h2&gt;

&lt;p&gt;I deliberately keep ranking outside Gemini.&lt;/p&gt;

&lt;p&gt;A simplified workflow looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evidence.aggregate_and_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ranked_candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;aggregate_and_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scoring logic can consider signals such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;location relevance&lt;/li&gt;
&lt;li&gt;category match&lt;/li&gt;
&lt;li&gt;opening-hour compatibility&lt;/li&gt;
&lt;li&gt;rating&lt;/li&gt;
&lt;li&gt;rating count&lt;/li&gt;
&lt;li&gt;constraint coverage&lt;/li&gt;
&lt;li&gt;grounded evidence&lt;/li&gt;
&lt;li&gt;unsupported constraints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not that one universal formula can rank every local-search problem.&lt;/p&gt;

&lt;p&gt;The point is that the system can explain why one candidate scored higher than another.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_static_map_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RankedCandidate&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;maptype&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roadmap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Build a Google Maps Static API URL for the top ranked candidates.

    Args:
        ranked_candidates: List of ranked candidates.
        api_key: Optional Google Maps API Key override.
        max_candidates: Maximum candidate markers to place (default: 3).
        width: Image width in pixels (default: 640).
        height: Image height in pixels (default: 360).
        maptype: Map type (default: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;roadmap&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;).

    Returns:
        Fully-formed Static Maps URL including API key.

    Raises:
        ValueError: If GOOGLE_MAPS_API_KEY is not configured or no valid coordinates exist.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_static_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;max_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;maptype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;maptype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;View the complete scoring implementation on &lt;a href="https://github.com/hastimal/ai-search-journey-lab/blob/main/src/ai_search_journey/static_map.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuv03879m8oupg6ndmbrm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuv03879m8oupg6ndmbrm.png" alt="Score and Ranking" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Gemini handles the final explanation
&lt;/h2&gt;

&lt;p&gt;Gemini comes back near the end.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.synthesize_recommendations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;synthesize_recommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By this point, Gemini is not being asked to search the world.&lt;/p&gt;

&lt;p&gt;It is being asked to explain a decision whose evidence is already available.&lt;/p&gt;

&lt;p&gt;That is a much smaller and more controllable problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the final answer should contain
&lt;/h2&gt;

&lt;p&gt;For each recommendation, I want the output to include useful decision context.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Example Coffee
   Rating: 4.6
   Open after 8 PM: Yes
   Work-friendly evidence: Supported
   Group suitability: Not fully verified
   Maps: [Open in Google Maps]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is much more useful than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I recommend Example Coffee because it looks great.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jxusu366h22bux2eh39.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jxusu366h22bux2eh39.png" alt="Final Results" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Validate the architecture with another query
&lt;/h2&gt;

&lt;p&gt;I did not want the workflow to work only for the one query it was designed around.&lt;/p&gt;

&lt;p&gt;So I also tested:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find an Indian restaurant near Trinity University for eight students, open after 9 PM, with vegetarian options. Recommend the top three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This changes several constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;category&lt;/li&gt;
&lt;li&gt;geographic anchor&lt;/li&gt;
&lt;li&gt;party size&lt;/li&gt;
&lt;li&gt;hours&lt;/li&gt;
&lt;li&gt;dietary preference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the architecture stays the same.&lt;/p&gt;

&lt;p&gt;That is important.&lt;/p&gt;

&lt;p&gt;I want a reusable search workflow, not a prompt-specific demo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2t8bk5rk9nn1q7ec0xme.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2t8bk5rk9nn1q7ec0xme.png" alt="Indian restaurant near Trinity University" width="800" height="1352"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Test the workflow like normal software
&lt;/h2&gt;

&lt;p&gt;I keep normal engineering checks around the AI workflow.&lt;/p&gt;

&lt;p&gt;My standard validation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./.venv/bin/ruff check &lt;span class="nb"&gt;.&lt;/span&gt;
./.venv/bin/mypy src
./.venv/bin/pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The point is simple:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemini does not eliminate the need for testable software.&lt;/p&gt;

&lt;p&gt;If anything, combining model behavior with APIs, ranking logic, state, and deployment makes testing more important.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaof809cqk6hqn35ivrw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaof809cqk6hqn35ivrw.png" alt="tests CLI loclaly description" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 13: Run the same workflow on Cloud Run
&lt;/h2&gt;

&lt;p&gt;Once the local implementation was stable, I deployed the same application to Cloud Run.&lt;/p&gt;

&lt;p&gt;That gave me a clean path from local development to a hosted demo.&lt;/p&gt;

&lt;p&gt;You can inspect the service with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services describe ai-search-journey-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"yaml(
      metadata.name,
      status.url,
      status.latestReadyRevisionName,
      status.conditions
  )"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And verify health:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s2"&gt;"https://ai-search-journey-lab-642110324230.us-central1.run.app/_stcore/health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's run locally!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fach3bt76wgillkffevic.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fach3bt76wgillkffevic.png" alt="Cloud Run CLI" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's run the same Geekdom query from the deployed Google cloud service.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqhzpswi3zi28enbapgbs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqhzpswi3zi28enbapgbs.png" alt="Geekdom query in Cloud" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned from building the local-search layer
&lt;/h2&gt;

&lt;p&gt;The biggest lesson was that local AI search is not just:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;prompt → model → answer&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is closer to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;natural-language intent → retrieval plan → entity search → evidence search → identity normalization → constraint verification → deterministic ranking → grounded explanation → actionable Maps result&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distinction is important.&lt;/p&gt;

&lt;p&gt;Because once the application has multiple evidence sources and multiple ranking signals, the LLM becomes one component in the system rather than the entire system.&lt;/p&gt;

&lt;p&gt;That is the architecture I wanted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters for agentic systems
&lt;/h2&gt;

&lt;p&gt;This local-search workflow also became a useful foundation for the later agent work in the repository.&lt;/p&gt;

&lt;p&gt;Once the system already has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structured intent&lt;/li&gt;
&lt;li&gt;tools&lt;/li&gt;
&lt;li&gt;explicit retrieval&lt;/li&gt;
&lt;li&gt;evidence&lt;/li&gt;
&lt;li&gt;state&lt;/li&gt;
&lt;li&gt;ranking&lt;/li&gt;
&lt;li&gt;bounded outputs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;it becomes much easier to add agent orchestration later.&lt;/p&gt;

&lt;p&gt;That is where Google ADK eventually enters the story in Part 4.&lt;/p&gt;

&lt;p&gt;But I deliberately did not start there.&lt;/p&gt;

&lt;p&gt;I first wanted a workflow I could understand without an agent.&lt;br&gt;
Then I could add the agent layer intentionally.&lt;/p&gt;


&lt;h2&gt;
  
  
  Next: Building an AI Search Visibility &amp;amp; Brand Analyzer with Gemini, BigQuery, and Google Search Grounding
&lt;/h2&gt;

&lt;p&gt;In the next article, I will move from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which business should this user consider?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How visible is a brand across AI-generated search journeys?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I will cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;target brands&lt;/li&gt;
&lt;li&gt;competitors&lt;/li&gt;
&lt;li&gt;visibility runs&lt;/li&gt;
&lt;li&gt;mentions&lt;/li&gt;
&lt;li&gt;citations&lt;/li&gt;
&lt;li&gt;fan-out coverage&lt;/li&gt;
&lt;li&gt;deterministic visibility analysis&lt;/li&gt;
&lt;li&gt;BigQuery history&lt;/li&gt;
&lt;li&gt;visibility trends&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Try the project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repository&lt;/strong&gt;: &lt;a href="https://github.com/hastimal/ai-search-journey-lab/tree/main" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local startup:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;run_app_locally&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>gemini</category>
      <category>gemma</category>
      <category>googlemaps</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Search Journey Optimization with Gemini: From Query Fan-Out to Grounded Decisions</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Mon, 28 Sep 2026 21:29:53 +0000</pubDate>
      <link>https://dev.to/hjangid/search-journey-optimization-with-gemini-from-query-fan-out-to-grounded-decisions-3f99</link>
      <guid>https://dev.to/hjangid/search-journey-optimization-with-gemini-from-query-fan-out-to-grounded-decisions-3f99</guid>
      <description>&lt;h2&gt;
  
  
  AI Search Journey Lab — Part 1 of 7
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Building an explainable search-to-decision workflow with Gemini, Google Places, Google Search grounding, deterministic ranking, and Google Cloud.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Open source&lt;/strong&gt;: &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It started with one search query&lt;br&gt;
I started &lt;code&gt;ai-search-journey-lab&lt;/code&gt; with a question that looked simple enough:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find a coffee shop near Geekdom in San Antonio for six people, quiet enough to work, open after 8 PM, and recommend the top three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At first, I thought of it as a local-search problem.&lt;/p&gt;

&lt;p&gt;Then I started implementing it.&lt;/p&gt;

&lt;p&gt;Almost immediately, that single sentence turned into several separate engineering problems.&lt;/p&gt;

&lt;p&gt;I needed to understand what the user actually meant by “&lt;strong&gt;near Geekdom&lt;/strong&gt;.” I needed to capture the group size. I needed opening hours. I needed to find places that might actually work for six people sitting together.&lt;/p&gt;

&lt;p&gt;Then there was the phrase:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;quiet enough to work&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is where things became more interesting.&lt;/p&gt;

&lt;p&gt;A structured Places response can give me things like an address, rating, hours, and a Place ID.&lt;/p&gt;

&lt;p&gt;But “quiet enough to work” is not simply another field I can request.&lt;/p&gt;

&lt;p&gt;So now I needed both structured place data and additional evidence.&lt;/p&gt;

&lt;p&gt;Then, once I started generating more than one retrieval query, another problem appeared: &lt;em&gt;&lt;strong&gt;the same place could be returned by multiple searches&lt;/strong&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That meant I also needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;candidate normalization,&lt;/li&gt;
&lt;li&gt;deduplication,&lt;/li&gt;
&lt;li&gt;evidence matching,&lt;/li&gt;
&lt;li&gt;scoring,&lt;/li&gt;
&lt;li&gt;ranking,&lt;/li&gt;
&lt;li&gt;and finally, a way to explain why one candidate beat another. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Oh gosh..It's getting a lot!!&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At that point, I wasn't building a “&lt;strong&gt;&lt;em&gt;send a prompt to Gemini and print the answer&lt;/em&gt;&lt;/strong&gt;” application anymore.&lt;/p&gt;

&lt;p&gt;I was building a &lt;strong&gt;search journey&lt;/strong&gt;. (and later think about &lt;em&gt;search journey optimization!&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;And that became the idea behind &lt;strong&gt;AI Search Journey Lab&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  AI Search Journey Lab: one project, seven engineering stages
&lt;/h2&gt;

&lt;p&gt;This is the first article in a seven-part series built from the same open-source repository.&lt;/p&gt;

&lt;p&gt;I wanted the series to follow the way the project itself evolved instead of creating seven unrelated AI demos.&lt;/p&gt;

&lt;p&gt;The topics are also centered around the problems I am most interested in as a Google Cloud and AI developer: grounding, search behavior, data, agents, observability, and production deployment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search Journey Optimization&lt;/strong&gt; with &lt;strong&gt;Gemini&lt;/strong&gt;: From &lt;strong&gt;Query Fan-Out&lt;/strong&gt; to Grounded Decisions  (&lt;em&gt;this article&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;Building &lt;strong&gt;Grounded Local Search&lt;/strong&gt; with &lt;strong&gt;Gemini&lt;/strong&gt; and &lt;strong&gt;Google Maps&lt;/strong&gt; on &lt;strong&gt;Google Cloud&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Building an &lt;strong&gt;AI Search Visibility&lt;/strong&gt; &amp;amp; Brand Analyzer with Gemini, BigQuery, and &lt;strong&gt;Google Search Grounding&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Building an &lt;strong&gt;AI Visibility Agent&lt;/strong&gt; with Gemini, &lt;strong&gt;Google ADK&lt;/strong&gt;, &lt;strong&gt;MCP&lt;/strong&gt;, and &lt;strong&gt;BigQuery&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;From Query Fan-Out to Trace: Observing &lt;strong&gt;AI Search Journeys&lt;/strong&gt; with &lt;strong&gt;OpenTelemetry&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Building &lt;strong&gt;AgentOps&lt;/strong&gt; for &lt;strong&gt;Gemini Agents&lt;/strong&gt; with &lt;strong&gt;Grafana&lt;/strong&gt;, &lt;strong&gt;Tempo&lt;/strong&gt;, and &lt;strong&gt;Prometheus&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;From Search to Care: Applying &lt;strong&gt;Gemini Search Journeys&lt;/strong&gt; to &lt;strong&gt;Healthcare Discovery&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The progression looks roughly like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;search journey → grounded retrieval → visibility measurement → agentic analytics → tracing → AgentOps → healthcare application&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My goal with the series is not just to show what Gemini can generate.&lt;/p&gt;

&lt;p&gt;I want to show how the pieces around the model matter just as much: APIs, deterministic logic, persistence, tools, traces, tests, and deployment.&lt;/p&gt;


&lt;h2&gt;
  
  
  Starting from the actual problem
&lt;/h2&gt;

&lt;p&gt;Let's go back to the original request:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find a coffee shop near Geekdom in San Antonio for six people, quiet enough to work, open after 8 PM, and recommend the top three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From an application perspective, that contains several different constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;category: coffee shop&lt;/li&gt;
&lt;li&gt;geographic anchor: Geekdom&lt;/li&gt;
&lt;li&gt;location: San Antonio&lt;/li&gt;
&lt;li&gt;party size: six&lt;/li&gt;
&lt;li&gt;operating-hours requirement: open after 8 PM&lt;/li&gt;
&lt;li&gt;qualitative preference: quiet/work-friendly&lt;/li&gt;
&lt;li&gt;result count: three&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And not all of those constraints should be handled the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Opening hours&lt;/strong&gt; can often come from structured place data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quiet enough to work&lt;/strong&gt; may require additional evidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That distinction ended up shaping most of the architecture.&lt;/p&gt;


&lt;h2&gt;
  
  
  The architecture decision that mattered most
&lt;/h2&gt;

&lt;p&gt;One decision influenced almost everything that came afterward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I did not want Gemini to own the entire workflow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It would have been very easy to send the whole prompt to a model and ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find the best three places and explain why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That would certainly produce an answer.&lt;/p&gt;

&lt;p&gt;But it would make several things harder for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;understanding which constraints were actually verified,&lt;/li&gt;
&lt;li&gt;seeing where candidates came from,&lt;/li&gt;
&lt;li&gt;knowing why something ranked highly,&lt;/li&gt;
&lt;li&gt;reproducing a decision,&lt;/li&gt;
&lt;li&gt;debugging wrong answers,&lt;/li&gt;
&lt;li&gt;and eventually tracing the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I divided the workflow into three responsibilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini reasoning&lt;/strong&gt;&lt;br&gt;
I use Gemini where natural-language interpretation is valuable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;intent extraction,&lt;/li&gt;
&lt;li&gt;planning,&lt;/li&gt;
&lt;li&gt;query fan-out,&lt;/li&gt;
&lt;li&gt;final response synthesis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Retrieval&lt;/strong&gt;&lt;br&gt;
I use external data sources for evidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google Places API (New),&lt;/li&gt;
&lt;li&gt;Google Search grounding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Deterministic application logic&lt;/strong&gt;&lt;br&gt;
I keep things such as these in normal application code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normalization,&lt;/li&gt;
&lt;li&gt;entity matching,&lt;/li&gt;
&lt;li&gt;deduplication,&lt;/li&gt;
&lt;li&gt;evidence aggregation,&lt;/li&gt;
&lt;li&gt;scoring,&lt;/li&gt;
&lt;li&gt;ranking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gave me a much more inspectable pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F101mn0owijqurm66f69q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F101mn0owijqurm66f69q.png" alt="Flow design - Search Journey Optimization" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Running the project
&lt;/h2&gt;

&lt;p&gt;I normally start the local environment with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;run_app_locally&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As the project evolved, I added observability components as well, so the local startup script eventually became responsible for more than just Streamlit.&lt;/p&gt;

&lt;p&gt;For this article, though, I am concentrating on two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;V1 — Search to Decision&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;V2 — Journey Analysis&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The later articles will move into visibility, agents, and observability.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqu9b17so4j3t7eha7924.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqu9b17so4j3t7eha7924.png" alt="Starting AI Search Journey Lab locally" width="799" height="381"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: I first turn the prompt into structured intent
&lt;/h2&gt;

&lt;p&gt;Before I search for anything, I need to understand what the request actually contains.&lt;/p&gt;

&lt;p&gt;Conceptually, the Geekdom query becomes something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee_shop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location_reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Geekdom, San Antonio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"party_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"open_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"quiet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"work-friendly"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is not the &lt;strong&gt;JSON&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is the boundary between &lt;strong&gt;language&lt;/strong&gt; and &lt;strong&gt;application state&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;People do not normally talk in schemas.&lt;/p&gt;

&lt;p&gt;A user might say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Somewhere close to Geekdom. We have six people, don't want somewhere too loud, and we'll probably stay late.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The application still needs to understand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"party_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"noise_preference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quiet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimum_close_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20:00"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a good job for &lt;strong&gt;Gemini&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model interprets the language.&lt;/p&gt;

&lt;p&gt;My application controls the structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  A typed intent model
&lt;/h2&gt;

&lt;p&gt;Since I am more into development and technical architecture role, I prefer moving the result into a typed object rather than passing free-form model text deeper into the workflow.&lt;/p&gt;

&lt;p&gt;A simplified version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;location_reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;party_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;open_after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;preferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives me a cleaner boundary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Natural-language request → Gemini interpretation → Structured application state&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From this point forward, the rest of the pipeline has something predictable to work with.&lt;/p&gt;




&lt;h2&gt;
  
  
  I also made this a real workflow boundary
&lt;/h2&gt;

&lt;p&gt;I didn't want intent extraction buried somewhere inside one large function.&lt;/p&gt;

&lt;p&gt;I gave the stage its own observable boundary.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.extract_intent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That may look like a small detail here.&lt;/p&gt;

&lt;p&gt;It became much more useful later when I added OpenTelemetry.&lt;/p&gt;

&lt;p&gt;A lesson I learned while building this project was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Good architecture boundaries often become good observability boundaries later.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As you run locally, it should open in &lt;code&gt;http://localhost:8502/&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65vxrayf7pi1dbk1436u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65vxrayf7pi1dbk1436u.png" alt="AI Search Journey Lab GUI" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's see the journey now and wait to trace the results!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frihrwh2nfr9wu3t7343c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frihrwh2nfr9wu3t7343c.png" alt="Gemini intent extraction" width="800" height="1361"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: One prompt becomes several retrieval tasks
&lt;/h2&gt;

&lt;p&gt;Once I knew what the user wanted, I ran into the next question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What exactly should I search for?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Using the complete user sentence as one search query would throw many different requirements into the same retrieval call.&lt;/p&gt;

&lt;p&gt;Instead, I started decomposing the request into a small retrieval plan.&lt;/p&gt;

&lt;p&gt;A simplified fan-out might look like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Places task:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;coffee shops near Geekdom San Antonio&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Places task:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;work-friendly coffee shops near downtown San Antonio&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Search evidence task:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;coffee near Geekdom quiet work open late&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the project, I treat that planning step separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.plan_fan_out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fanout_plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;plan_fan_out&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part I refer to as query fan-out.&lt;/p&gt;

&lt;p&gt;One user's decision question becomes multiple narrower retrieval tasks.&lt;/p&gt;




&lt;h2&gt;
  
  
  I learned quickly that more queries are not automatically better
&lt;/h2&gt;

&lt;p&gt;Initially, fan-out sounds like a simple idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If one query is useful, several queries should be better.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But that creates its own problems.&lt;/p&gt;

&lt;p&gt;Imagine generating all of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coffee near Geekdom
quiet coffee near Geekdom
coffee for six near Geekdom
coffee with large tables near Geekdom
coffee open after 8 near Geekdom
coffee good for working near Geekdom
late-night coffee downtown San Antonio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more requests,&lt;/li&gt;
&lt;li&gt;more latency,&lt;/li&gt;
&lt;li&gt;more overlapping results,&lt;/li&gt;
&lt;li&gt;more duplicates,&lt;/li&gt;
&lt;li&gt;more evidence to reconcile,&lt;/li&gt;
&lt;li&gt;and more opportunities for noisy candidates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The opposite extreme is also bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coffee near Geekdom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because most of the intent has disappeared.&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;query fan-out&lt;/strong&gt; became an optimization problem of its own:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Generate enough retrieval tasks to cover the user's important constraints without turning the workflow into uncontrolled query expansion.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a much more interesting problem than simply asking a model to generate ten related searches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqjcfg4jffghwle88zr7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqjcfg4jffghwle88zr7.png" alt="Query fan-out generated from the original prompt" width="800" height="1112"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Google Places gave me the structured candidate layer
&lt;/h2&gt;

&lt;p&gt;Once I had the fan-out plan, I needed actual entities.&lt;/p&gt;

&lt;p&gt;For the local-search portion of the workflow, that meant Google Places API (New).&lt;/p&gt;

&lt;p&gt;This gave me structured information such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Place ID,&lt;/li&gt;
&lt;li&gt;business name,&lt;/li&gt;
&lt;li&gt;address,&lt;/li&gt;
&lt;li&gt;rating,&lt;/li&gt;
&lt;li&gt;number of ratings,&lt;/li&gt;
&lt;li&gt;opening information,&lt;/li&gt;
&lt;li&gt;and Maps URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified retrieval call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://places.googleapis.com/v1/places:searchText&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;textQuery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pageSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-Api-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-FieldMask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.formattedAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.userRatingCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.currentOpeningHours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.googleMapsUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentionally simplified for the article.&lt;/p&gt;

&lt;p&gt;In the repository, there is additional application logic around the retrieval and candidate handling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Place ID turned out to be more important than I expected
&lt;/h2&gt;

&lt;p&gt;At first I was mostly interested in ratings, addresses, and opening hours.&lt;/p&gt;

&lt;p&gt;Once fan-out was involved, identity became equally important.&lt;/p&gt;

&lt;p&gt;Suppose two searches produce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merit Coffee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merit Coffee — Southtown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String matching alone is not a great basis for deciding whether I have one business or two.&lt;/p&gt;

&lt;p&gt;Using a canonical identifier such as &lt;code&gt;Place ID&lt;/code&gt; gives me a stronger entity boundary.&lt;/p&gt;

&lt;p&gt;That became important almost immediately when I started combining results from multiple retrieval tasks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7av09z3hwh1hjcawwl7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7av09z3hwh1hjcawwl7.png" alt="Structured candidate retrieval using Google Places" width="800" height="1038"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Places could not answer everything
&lt;/h2&gt;

&lt;p&gt;This is where the system became more than a Places demo.&lt;/p&gt;

&lt;p&gt;The API can tell me a lot about a place.&lt;/p&gt;

&lt;p&gt;But look again at part of the original requirement:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;quiet enough to work&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not the same kind of data as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rating&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open until 10 PM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same problem appears in other searches:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;good for a large student group&lt;/p&gt;

&lt;p&gt;strong vegan options&lt;/p&gt;

&lt;p&gt;suitable for someone with a specific preference&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;These are softer constraints.&lt;/p&gt;

&lt;p&gt;I needed another evidence path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: I added Google Search grounding for the missing evidence
&lt;/h2&gt;

&lt;p&gt;Rather than asking Gemini:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this place good for working?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted the system to investigate specific claims.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Candidate: Example Coffee

Verify:
- late-hour availability
- evidence relevant to working/studying
- evidence relevant to group seating

For each constraint:
- supported
- unsupported
- evidence
- citation

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually, that stage looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_grounding.verify_evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gave me two different types of evidence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Google Places + Search-grounded evidence&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted to preserve that distinction instead of blending everything together.&lt;/p&gt;

&lt;p&gt;If operating hours come from Places, I should know that.&lt;/p&gt;

&lt;p&gt;If “&lt;strong&gt;work-friendly&lt;/strong&gt;” comes from &lt;strong&gt;grounded Search evidence&lt;/strong&gt;, I should know that too.&lt;/p&gt;

&lt;p&gt;And if I cannot verify something, I want the system to keep that state visible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffi3r3somr8e5tifao71s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffi3r3somr8e5tifao71s.png" alt="Grounded evidence for qualitative constraints" width="800" height="728"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Fan-out created a duplicate problem
&lt;/h2&gt;

&lt;p&gt;Once I started running multiple Places tasks, something predictable happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the same business started appearing more than once.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Places task A ───────┐
                     ├── Candidate X
Places task B ───────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I ranked those raw results directly, Candidate X could appear stronger simply because more than one retrieval task discovered it.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;not&lt;/strong&gt; &lt;strong&gt;relevance&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;duplication&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I introduced normalization and deduplication before scoring.&lt;/p&gt;

&lt;p&gt;A simplified form is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deduplicate_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;place_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sequence matters: &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → normalize → deduplicate → score&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I specifically did &lt;strong&gt;NOT&lt;/strong&gt; want:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → score duplicates → fix identity afterward&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  V2 made this problem visible
&lt;/h2&gt;

&lt;p&gt;For Journey Analysis, I used another demo query:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find an Indian restaurant near Trinity University for eight students, open after 9 PM, with vegetarian options. Recommend the top three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One of my representative executions produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Places tasks                     2
Raw Places results              20
Unique Places candidates        13

Search tasks                     2

Candidates with Search evidence  3
Places-only candidates          10
Unmatched evidence               8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was a useful moment in the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instead of asking only:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What are the top three restaurants?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;I could now ask:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What actually happened during retrieval?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That became the purpose of &lt;strong&gt;V2 — Journey Analysis&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdllco63bjsma6uva9uu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdllco63bjsma6uva9uu.png" alt="Journey Analysis for the Trinity University query" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Step 7: Retrieval and ranking are not the same thing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This sounds obvious, but it became an important rule in my implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval asks:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which candidates might be relevant?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Ranking asks:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which of those candidates best satisfies the original constraints?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A candidate should not rank higher simply because &lt;em&gt;two fan-out queries&lt;/em&gt; happened to retrieve it.&lt;/p&gt;

&lt;p&gt;And the &lt;strong&gt;highest-rated business should not automatically win&lt;/strong&gt; if it fails an important constraint such as &lt;strong&gt;opening hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So after deduplication, I aggregate the evidence and score candidates explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: I keep ranking outside the LLM
&lt;/h2&gt;

&lt;p&gt;This was another decision I made deliberately.&lt;/p&gt;

&lt;p&gt;I did not want this architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Retrieve 10 places
       ↓
Send all 10 to Gemini
       ↓
"Pick the best three"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That would give me an answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;But it would make one of the most important decisions in the system opaque.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead, I keep the ranking logic inspectable in application code.&lt;/p&gt;

&lt;p&gt;Depending on the workflow, signals can include things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;location relevance,&lt;/li&gt;
&lt;li&gt;category match,&lt;/li&gt;
&lt;li&gt;operating-hour compatibility,&lt;/li&gt;
&lt;li&gt;rating,&lt;/li&gt;
&lt;li&gt;review volume,&lt;/li&gt;
&lt;li&gt;constraint coverage,&lt;/li&gt;
&lt;li&gt;grounded evidence,&lt;/li&gt;
&lt;li&gt;unsupported constraints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evidence.aggregate_and_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ranked_candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;aggregate_and_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important property here is not one universal scoring formula.&lt;/p&gt;

&lt;p&gt;It is that I can inspect &lt;em&gt;why a candidate received its score&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_haversine_distance_miles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lon2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculate the great circle distance in miles between two latitude/longitude points.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r_earth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3958.8&lt;/span&gt;  &lt;span class="c1"&gt;# Earth radius in miles
&lt;/span&gt;    &lt;span class="n"&gt;d_lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;d_lon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_lat&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_lon&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r_earth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_proximity_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance_miles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculate smooth bounded proximity bonus from distance in miles (max 12.0 pts).

    Formula: bonus = MAX_PROXIMITY_BONUS / (1.0 + distance_miles)
    - 0.0 mi  -&amp;gt; 12.00 pts
    - 0.5 mi  -&amp;gt; 8.00 pts
    - 1.0 mi  -&amp;gt; 6.00 pts
    - 2.0 mi  -&amp;gt; 4.00 pts
    - 5.0 mi  -&amp;gt; 2.00 pts
    - 190 mi  -&amp;gt; 0.06 pts (Houston vs San Antonio)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;distance_miles&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;bonus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_PROXIMITY_BONUS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;distance_miles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bonus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/hastimal/ai-search-journey-lab/blob/main/src/ai_search_journey/ranking.py/" rel="noopener noreferrer"&gt;View the complete scoring implementation on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Candidates are ranked after normalization and evidence aggregation rather than being silently reordered by the model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpoc5436dep1v63jp8swc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpoc5436dep1v63jp8swc.png" alt="Deterministic candidate scoring in Journey Analysis" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Gemini comes back at the end
&lt;/h2&gt;

&lt;p&gt;Gemini still has an important job after the deterministic ranking stage.&lt;/p&gt;

&lt;p&gt;I use it to turn the structured result into an answer that is useful to a person.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.synthesize_recommendations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;synthesize_recommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But notice what has changed.&lt;/p&gt;

&lt;p&gt;At the start of the workflow, Gemini had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one natural-language request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the end, it can work with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;structured intent
       +
normalized candidates
       +
Places evidence
       +
Search evidence
       +
constraint states
       +
candidate scores
       +
final ranking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So instead of asking Gemini to discover, verify, rank, and explain everything at once, I give it a much narrower final job:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Explain a decision whose evidence has already been assembled.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the architecture I wanted.&lt;/p&gt;




&lt;h2&gt;
  
  
  The complete journey
&lt;/h2&gt;

&lt;p&gt;At a high level, the workflow became:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx16fejr2yhu7gs9y7cxx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx16fejr2yhu7gs9y7cxx.png" alt="AI Search Journey Workflow and Design" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And internally, I started giving the major stages explicit names such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gemini.extract_intent
gemini.plan_fan_out
places.text_search
search_grounding.verify_evidence
candidate.normalize_and_dedup
evidence.aggregate_and_score
gemini.synthesize_recommendations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those names become important again later in this series when I start tracing the workflows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: I test it like normal software
&lt;/h2&gt;

&lt;p&gt;Another goal for this project was to avoid treating AI code as somehow exempt from normal software-engineering practices.&lt;/p&gt;

&lt;p&gt;After making changes, I run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./.venv/bin/ruff check &lt;span class="nb"&gt;.&lt;/span&gt;
./.venv/bin/mypy src
./.venv/bin/pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqk31ytksvjs4y3yj8lhu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqk31ytksvjs4y3yj8lhu.png" alt="Ruff, Mypy and Pytest passing for AI Search Journey Lab" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Then I moved the same application to Cloud Run
&lt;/h2&gt;

&lt;p&gt;I also wanted the project to go beyond my laptop.&lt;/p&gt;

&lt;p&gt;The application runs on Google Cloud Run.&lt;/p&gt;

&lt;p&gt;I can inspect the deployed service from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services describe ai-search-journey-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"yaml(
      metadata.name,
      status.url,
      status.latestReadyRevisionName,
      status.conditions
  )"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And verify the Streamlit health endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s2"&gt;"https://ai-search-journey-lab-642110324230.us-central1.run.app/_stcore/health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For me, this matters because it closes the development loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;execution&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;tests&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;container&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;Cloud&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;live&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshqlqpi4t7d0ijn180oz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshqlqpi4t7d0ijn180oz.png" alt="AI Search Journey Lab running to deploy in Cloud Run" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same Search-to-Decision workflow running from the Cloud Run deployment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr58hsux5w7726ztxvvea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr58hsux5w7726ztxvvea.png" alt="Search to Decision workflow running on Google Cloud Run - GUI" width="799" height="331"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I am not trying to reproduce
&lt;/h2&gt;

&lt;p&gt;There is an important distinction I want to make.&lt;/p&gt;

&lt;p&gt;This project is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a reproduction of Google's internal ranking systems,&lt;/li&gt;
&lt;li&gt;a reverse-engineered implementation of AI Mode,&lt;/li&gt;
&lt;li&gt;an implementation of Google AI Overviews,&lt;/li&gt;
&lt;li&gt;or a claim that my query fan-out represents Google's proprietary query fan-out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am using query fan-out as an application architecture pattern.&lt;/p&gt;

&lt;p&gt;The project asks a developer-focused question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If one natural-language request turns into multiple retrieval tasks, what engineering problems do I have to solve before I can return a grounded decision?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is what I am trying to explore.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens when I run many search journeys?
&lt;/h2&gt;

&lt;p&gt;V1 and V2 focus on one user's decision.&lt;/p&gt;

&lt;p&gt;But imagine running many related prompts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Now I can start asking:&lt;br&gt;
How often does a brand appear?&lt;br&gt;
Which competitors are being mentioned?&lt;br&gt;
Which sources are being cited?&lt;br&gt;
Does the brand appear in the initial query, the fan-out queries, or only the final answer?&lt;br&gt;
How does visibility change across different prompts?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is where this project moved from Search Journey Optimization into AI Search Visibility.&lt;/p&gt;

&lt;p&gt;And that is the next article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next: Building Grounded Local Search with Gemini and Google Maps on Google Cloud
&lt;/h2&gt;

&lt;p&gt;Before I move fully into visibility analytics, Part 2 will go deeper into the actual local-search implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemini,&lt;/li&gt;
&lt;li&gt;Google Places API (New),&lt;/li&gt;
&lt;li&gt;Google Maps,&lt;/li&gt;
&lt;li&gt;Google Search grounding,&lt;/li&gt;
&lt;li&gt;grounded recommendation generation,&lt;/li&gt;
&lt;li&gt;and Cloud Run deployment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then &lt;strong&gt;Part 3 will move into the AI Search Visibility &amp;amp; Brand Analyzer with Gemini and BigQuery&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try the project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local startup:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;run_app_locally&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>gemini</category>
      <category>googlecloud</category>
      <category>ai</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
