Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
552 changes: 552 additions & 0 deletions docs/PROMPT_REWRITER_AGENT_PROPOSAL.md

Large diffs are not rendered by default.

518 changes: 518 additions & 0 deletions docs/PROMPT_REWRITER_IMPLEMENTATION_ROADMAP.md

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions src/prompt_rewriter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
Prompt Rewriter Agent System

An autonomous system for improving prompts based on user feedback.
This module provides agents that analyze feedback, research claims,
propose prompt modifications, test changes, and deploy improvements.

Usage:
from prompt_rewriter import PromptRewriterOrchestrator
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usage example references PromptRewriterOrchestrator which is not exported. Update the docs or export the class if intended.


orchestrator = PromptRewriterOrchestrator()
result = await orchestrator.process_feedback(feedback_event)
"""
Comment on lines +8 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The usage example in the docstring refers to PromptRewriterOrchestrator, which is not implemented in this PR. The main entry point appears to be the prompt_rewriter_flow Prefect flow. To avoid confusion, I suggest updating the example to reflect the current implementation.

Suggested change
Usage:
from prompt_rewriter import PromptRewriterOrchestrator
orchestrator = PromptRewriterOrchestrator()
result = await orchestrator.process_feedback(feedback_event)
"""
Usage:
from prompt_rewriter.main import prompt_rewriter_flow
# The main entry point is the `prompt_rewriter_flow` Prefect flow.
# It is triggered with a proposal_id.
# See `src/prompt_rewriter/main.py` for usage details.
"""


from .models import (
FeedbackEvent,
FeedbackIntent,
ProposalType,
ProposalStatus,
ResearchResult,
PromptProposal,
ExperimentResult,
EvaluationResult,
)

__all__ = [
"FeedbackEvent",
"FeedbackIntent",
"ProposalType",
"ProposalStatus",
"ResearchResult",
"PromptProposal",
"ExperimentResult",
"EvaluationResult",
]
Comment on lines +26 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sort __all__ to satisfy Ruff (RUF022).

Minor lint issue; sorting avoids CI noise.

💡 Suggested fix
 __all__ = [
-    "FeedbackEvent",
-    "FeedbackIntent",
-    "ProposalType",
-    "ProposalStatus",
-    "ResearchResult",
-    "PromptProposal",
-    "ExperimentResult",
-    "EvaluationResult",
+    "EvaluationResult",
+    "ExperimentResult",
+    "FeedbackEvent",
+    "FeedbackIntent",
+    "PromptProposal",
+    "ProposalStatus",
+    "ProposalType",
+    "ResearchResult",
 ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
__all__ = [
"FeedbackEvent",
"FeedbackIntent",
"ProposalType",
"ProposalStatus",
"ResearchResult",
"PromptProposal",
"ExperimentResult",
"EvaluationResult",
]
__all__ = [
"EvaluationResult",
"ExperimentResult",
"FeedbackEvent",
"FeedbackIntent",
"PromptProposal",
"ProposalStatus",
"ProposalType",
"ResearchResult",
]
🧰 Tools
🪛 Ruff (0.14.13)

26-35: __all__ is not sorted

Apply an isort-style sorting to __all__

(RUF022)

🤖 Prompt for AI Agents
In `@src/prompt_rewriter/__init__.py` around lines 26 - 35, The exported names in
the __all__ list are unsorted causing Ruff RUF022; open the module-level __all__
variable in __init__.py and reorder the entries alphabetically (e.g., arrange
"EvaluationResult", "ExperimentResult", "FeedbackEvent", "FeedbackIntent",
"ProposalStatus", "ProposalType", "PromptProposal", "ResearchResult" into
lexicographic order) so the list is sorted to satisfy the linter.


__version__ = "0.1.0"
17 changes: 17 additions & 0 deletions src/prompt_rewriter/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
Prompt Rewriter Agents

Each agent is responsible for a specific step in the prompt rewriting pipeline:

1. FeedbackIntakeAgent - Analyzes user feedback to classify intent
2. ResearchAgent - Conducts web research on disputed claims
3. ProposalWriterAgent - Generates structured prompt modification proposals
4. ExperimentRunnerAgent - Tests proposals against real snippets
5. EvaluationAgent - Decides whether to accept, refine, or reject proposals
6. SemanticSearchAgent - Finds similar snippets for broader testing
7. DeploymentAgent - Applies approved changes to production
"""

from .base import BaseAgent

__all__ = ["BaseAgent"]
250 changes: 250 additions & 0 deletions src/prompt_rewriter/agents/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
"""
Base agent class for the Prompt Rewriter system.

All agents inherit from BaseAgent, which provides:
- Logging infrastructure
- LLM calling utilities
- Error handling and retry logic
- Database access
"""

import logging
import time
from abc import ABC, abstractmethod
from datetime import datetime
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing import for timezone. The code uses datetime.utcnow() but should use datetime.now(timezone.utc) instead. Add timezone to the import: from datetime import datetime, timezone.

Suggested change
from datetime import datetime
from datetime import datetime, timezone

Copilot uses AI. Check for mistakes.
from typing import Any, Generic, TypeVar
from uuid import UUID

from ..config import PromptRewriterConfig, default_config
from ..models import AgentLogEntry

logger = logging.getLogger(__name__)

InputT = TypeVar("InputT")
OutputT = TypeVar("OutputT")


class BaseAgent(ABC, Generic[InputT, OutputT]):
"""
Base class for all prompt rewriter agents.

Subclasses must implement:
- name: str - The agent's name for logging
- run(input: InputT) -> OutputT - The main execution method
"""

name: str = "base_agent"

def __init__(self, config: PromptRewriterConfig | None = None):
self.config = config or default_config
self._supabase_client = None

@property
def supabase(self):
"""Lazy-load Supabase client."""
if self._supabase_client is None:
from supabase import create_client

self._supabase_client = create_client(
self.config.supabase_url,
self.config.supabase_key,
)
return self._supabase_client

@abstractmethod
async def run(self, input_data: InputT) -> OutputT:
"""
Execute the agent's main logic.

Args:
input_data: Input data specific to this agent type

Returns:
Output data specific to this agent type
"""
pass

async def execute(
self, input_data: InputT, proposal_id: UUID | None = None
) -> OutputT:
"""
Execute the agent with logging and error handling.

This is the main entry point for running an agent. It wraps
the run() method with logging, timing, and error handling.

Args:
input_data: Input data for the agent
proposal_id: Optional proposal ID for logging context

Returns:
Output from the run() method
"""
log_entry = AgentLogEntry(
agent_name=self.name,
proposal_id=proposal_id or UUID("00000000-0000-0000-0000-000000000000"),
input_summary=self._summarize_input(input_data),
)
Comment on lines +83 to +87
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When proposal_id is None, it's defaulted to a nil UUID. This will cause a foreign key violation when _save_log_entry is called, as a proposal with a nil UUID won't exist in the prompt_rewrite_proposals table. The proposal_id should be passed as None to the AgentLogEntry constructor. I've added a separate comment on src/prompt_rewriter/models.py to make the proposal_id field optional in the AgentLogEntry model to support this change.

Suggested change
log_entry = AgentLogEntry(
agent_name=self.name,
proposal_id=proposal_id or UUID("00000000-0000-0000-0000-000000000000"),
input_summary=self._summarize_input(input_data),
)
log_entry = AgentLogEntry(
agent_name=self.name,
proposal_id=proposal_id,
input_summary=self._summarize_input(input_data),
)


start_time = time.time()
logger.info(f"[{self.name}] Starting execution")

try:
result = await self.run(input_data)

elapsed_ms = int((time.time() - start_time) * 1000)
log_entry.completed_at = datetime.utcnow()
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using datetime.utcnow() is deprecated as of Python 3.12. The existing codebase uses datetime.now(timezone.utc) (see src/processing_pipeline/stage_3.py:379 and src/processing_pipeline/supabase_utils.py:290). Please update to use datetime.now(timezone.utc) for consistency and to avoid deprecation warnings.

Copilot uses AI. Check for mistakes.
log_entry.duration_ms = elapsed_ms
log_entry.status = "completed"
log_entry.output_summary = self._summarize_output(result)

logger.info(f"[{self.name}] Completed in {elapsed_ms}ms")

if proposal_id and self.config.log_all_llm_calls:
await self._save_log_entry(log_entry)

return result

except Exception as e:
elapsed_ms = int((time.time() - start_time) * 1000)
log_entry.completed_at = datetime.utcnow()
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using datetime.utcnow() is deprecated as of Python 3.12. The existing codebase uses datetime.now(timezone.utc) (see src/processing_pipeline/stage_3.py:379 and src/processing_pipeline/supabase_utils.py:290). Please update to use datetime.now(timezone.utc) for consistency and to avoid deprecation warnings.

Copilot uses AI. Check for mistakes.
log_entry.duration_ms = elapsed_ms
log_entry.status = "failed"
log_entry.error_message = str(e)

logger.error(f"[{self.name}] Failed after {elapsed_ms}ms: {e}")

if proposal_id and self.config.log_all_llm_calls:
await self._save_log_entry(log_entry)

raise

def _summarize_input(self, input_data: Any) -> str:
"""Create a brief summary of input for logging."""
if hasattr(input_data, "model_dump"):
data = input_data.model_dump()
return str(data)[:500]
return str(input_data)[:500]

def _summarize_output(self, output_data: Any) -> str:
"""Create a brief summary of output for logging."""
if hasattr(output_data, "model_dump"):
data = output_data.model_dump()
return str(data)[:500]
return str(output_data)[:500]

async def _save_log_entry(self, log_entry: AgentLogEntry) -> None:
"""Save a log entry to the database."""
try:
self.supabase.table("prompt_rewriter_agent_logs").insert(
{
"agent_name": log_entry.agent_name,
"proposal_id": str(log_entry.proposal_id),
"started_at": log_entry.started_at.isoformat(),
"completed_at": (
log_entry.completed_at.isoformat()
if log_entry.completed_at
else None
),
"duration_ms": log_entry.duration_ms,
"status": log_entry.status,
"input_data": {"summary": log_entry.input_summary},
"output_data": {"summary": log_entry.output_summary},
"error_message": log_entry.error_message,
"llm_total_tokens": log_entry.llm_tokens_used,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's an inconsistency between the Pydantic model and the database schema. The AgentLogEntry model uses llm_tokens_used, but the database column is llm_total_tokens. The key used here for insertion is llm_total_tokens. To improve clarity and maintainability, it's best to use the same name in all places. I'd suggest renaming llm_tokens_used to llm_total_tokens in src/prompt_rewriter/models.py.

}
).execute()
Comment on lines +139 to +156
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Supabase insert operation is not awaited in this async function. The supabase-py client's execute() method is synchronous and will block the event loop. Consider using asyncio.to_thread() to run this in a thread pool, or verify if there's an async version of the Supabase client available.

Copilot uses AI. Check for mistakes.
except Exception as e:
logger.warning(f"[{self.name}] Failed to save log entry: {e}")

async def call_llm(
self,
prompt: str,
system_instruction: str | None = None,
model: str | None = None,
temperature: float = 0.7,
max_tokens: int = 4096,
) -> str:
"""
Call an LLM with the given prompt.

This is a utility method for subclasses to use when they need
to call an LLM. It handles model selection and basic error handling.

Args:
prompt: The user prompt
system_instruction: Optional system instruction
model: Model to use (defaults to config.default_llm_model)
temperature: Sampling temperature
max_tokens: Maximum tokens to generate

Returns:
The LLM's response text
"""
import google.generativeai as genai
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code imports google.generativeai (the older SDK), but the existing codebase uses from google import genai (the newer Google Genai SDK). This creates an inconsistency and may cause compatibility issues. Consider updating to use the newer SDK's genai.Client API pattern as shown in src/processing_pipeline/stage_1.py:570 and src/processing_pipeline/stage_3.py:373, which provides better async support and consistency with the rest of the codebase.

Copilot uses AI. Check for mistakes.

model_name = model or self.config.default_llm_model

generation_config = genai.GenerationConfig(
temperature=temperature,
max_output_tokens=max_tokens,
)

model_instance = genai.GenerativeModel(
model_name=model_name,
system_instruction=system_instruction,
generation_config=generation_config,
)

response = model_instance.generate_content(prompt)
return response.text
Comment on lines +160 to +200
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model_instance.generate_content() call in call_llm() is not awaited, but this is an async function. The google.generativeai library may not support async operations, which could cause this async function to block. Consider either making this a synchronous function or using asyncio.to_thread() to run the blocking call in a thread pool to avoid blocking the event loop.

Copilot uses AI. Check for mistakes.

async def call_llm_with_search(
self,
prompt: str,
system_instruction: str | None = None,
model: str | None = None,
) -> tuple[str, list[dict]]:
"""
Call an LLM with Google Search grounding enabled.

Args:
prompt: The user prompt
system_instruction: Optional system instruction
model: Model to use

Returns:
Tuple of (response_text, grounding_sources)
"""
import google.generativeai as genai
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code imports google.generativeai (the older SDK), but the existing codebase uses from google import genai (the newer Google Genai SDK). This creates an inconsistency and may cause compatibility issues. Consider updating to use the newer SDK's genai.Client API pattern for consistency with the rest of the codebase.

Copilot uses AI. Check for mistakes.
from google.generativeai.types import GenerateContentConfig, Tool
from google.generativeai.types.content_types import GoogleSearch

model_name = model or self.config.research_model

tools = [Tool(google_search=GoogleSearch())]

config = GenerateContentConfig(
tools=tools,
system_instruction=system_instruction,
)

model_instance = genai.GenerativeModel(model_name=model_name)

response = model_instance.generate_content(prompt, generation_config=config)
Comment on lines +227 to +234
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The implementation of call_llm_with_search has a couple of issues that will likely cause a runtime error:

  1. GenerateContentConfig is passed to the generation_config parameter of generate_content, which expects a genai.GenerationConfig instance.
  2. The system_instruction is passed to GenerateContentConfig but it should be passed to the GenerativeModel constructor. The model is currently instantiated without it.

To fix this, you should pass system_instruction to the model constructor and tools directly to generate_content.

Suggested change
config = GenerateContentConfig(
tools=tools,
system_instruction=system_instruction,
)
model_instance = genai.GenerativeModel(model_name=model_name)
response = model_instance.generate_content(prompt, generation_config=config)
model_instance = genai.GenerativeModel(
model_name=model_name,
system_instruction=system_instruction,
)
response = model_instance.generate_content(prompt, tools=tools)

Comment on lines +202 to +234
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model_instance.generate_content() call in call_llm_with_search() is not awaited, but this is an async function. The google.generativeai library may not support async operations, which could cause this async function to block. Consider either making this a synchronous function or using asyncio.to_thread() to run the blocking call in a thread pool to avoid blocking the event loop.

Copilot uses AI. Check for mistakes.

Comment on lines +160 to +235
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

google-generativeai Python SDK async methods generate_content_async

💡 Result:

In the legacy google-generativeai Python SDK (the one you import as google.generativeai as genai), there is a method named GenerativeModel.generate_content_async(...), but Google’s own migration guide shows it being called without await (it returns an object directly), and there are open bug reports where trying to await it raises TypeError: object GenerateContentResponse can't be used in 'await' expression (notably in Colab / Python 3.11). [1], [2]

In the current SDK (google-genai, imported as from google import genai), you don’t use generate_content_async. Instead, every async method lives under client.aio and is awaited:

from google import genai

client = genai.Client()

response = await client.aio.models.generate_content(
    model="gemini-2.0-flash",
    contents="Tell me a story in 300 words."
)
print(response.text)

This is the recommended replacement for generate_content_async, and streaming also has an async version under client.aio. [1], [3]

Sources: [1] Google migration guide, [2] legacy SDK issue #732, [3] googleapis/python-genai (Google Gen AI Python SDK) README.


🏁 Script executed:

# Search for any usage of response.candidates in the codebase
rg "response\.candidates" --type py

Repository: PublicDataWorks/verdad

Length of output: 1314


🏁 Script executed:

# Check the actual implementation around line 160-235
cd src && head -250 prompt_rewriter/agents/base.py | tail -100

Repository: PublicDataWorks/verdad

Length of output: 3482


🏁 Script executed:

# Look for any imports or existing async patterns in the file
rg "asyncio|async def" src/prompt_rewriter/agents/base.py

Repository: PublicDataWorks/verdad

Length of output: 275


Avoid blocking the event loop in async LLM calls; handle empty response candidates.

The generate_content() method is synchronous and will block the event loop when called directly in async methods. Additionally, response.candidates[0] is accessed without ensuring the candidates list is non-empty, which can raise IndexError.

For the blocking I/O, use asyncio.to_thread() as a practical workaround for the legacy SDK. The long-term solution is to migrate to the newer google-genai SDK (imported as from google import genai), which provides proper async methods via client.aio.models.generate_content().

For response handling, check that response.candidates is non-empty before accessing response.candidates[0]:

💡 Suggested fixes
+import asyncio
 ...
-        response = model_instance.generate_content(prompt)
+        response = await asyncio.to_thread(model_instance.generate_content, prompt)
         return response.text
 ...
-        response = model_instance.generate_content(prompt, generation_config=config)
+        response = await asyncio.to_thread(
+            model_instance.generate_content,
+            prompt,
+            generation_config=config,
+        )

         # Extract grounding metadata
         sources = []
-        if hasattr(response.candidates[0], "grounding_metadata"):
-            metadata = response.candidates[0].grounding_metadata
+        if response.candidates and hasattr(response.candidates[0], "grounding_metadata"):
+            metadata = response.candidates[0].grounding_metadata
🤖 Prompt for AI Agents
In `@src/prompt_rewriter/agents/base.py` around lines 160 - 235,
call_llm_with_search currently calls the synchronous genai.generate_content
directly (blocking the event loop) and assumes response.candidates[0] exists;
change it to run the blocking call inside asyncio.to_thread(...) (wrapping the
model_instance.generate_content call) and after getting the response validate
that response.candidates is non-empty before accessing [0]; if empty, return a
sensible fallback (e.g., empty string and empty grounding list) and otherwise
extract response.candidates[0].content/text and grounding sources to return the
tuple (ensure you keep using model_instance.generate_content, generation config
construction with GenerateContentConfig/Tool/GoogleSearch, and check
response.candidates to avoid IndexError).

# Extract grounding metadata
sources = []
if hasattr(response.candidates[0], "grounding_metadata"):
metadata = response.candidates[0].grounding_metadata
if hasattr(metadata, "grounding_chunks"):
for chunk in metadata.grounding_chunks:
if hasattr(chunk, "web"):
sources.append(
{
"url": chunk.web.uri,
"title": chunk.web.title,
}
)

Comment on lines +236 to +249
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the base.py file
find . -name "base.py" -path "*/prompt_rewriter/agents/*" -type f

Repository: PublicDataWorks/verdad

Length of output: 102


🏁 Script executed:

# Check the repository structure
git ls-files | grep -E "(base\.py|requirements|setup\.py|pyproject\.toml)" | head -20

Repository: PublicDataWorks/verdad

Length of output: 191


🌐 Web query:

google-generativeai SDK generate_content empty candidates list behavior

💡 Result:

In the Gemini API (which the google-generativeai SDK wraps), an empty response.candidates list is expected only when the prompt is blocked / rejected, not when only the model output is blocked.

  • The API “returns either all requested candidates or none of them” and “returns no candidates at all only if there was something wrong with the prompt (check promptFeedback).” If promptFeedback.blockReason is set, “the prompt was blocked and no candidates are returned.” [1]
  • Separately, you can get a candidate object whose content has no parts / no text when the candidate is blocked by safety filters, in which case you should inspect candidate.finish_reason and candidate.safety_ratings instead of assuming content.parts[0].text exists. (This commonly shows up as “Response candidate content has no parts … likely blocked by the safety filters.”) [2]
  • If you’re seeing “sometimes empty text” in output.candidates[0].content.parts[0].text even for benign prompts, that has been reported as intermittent in Google’s SDK issue trackers; you should treat it as a case your code must handle (and log full response incl. promptFeedback, finish_reason, and safety ratings). [3]

Practical handling:

  1. If not response.candidates: check response.prompt_feedback.block_reason (+ safety_ratings) and treat as “prompt blocked/invalid”.
  2. Else if candidate exists but has no text/parts: treat as “output blocked” and use candidate.finish_reason / candidate.safety_ratings.
  3. Only read response.text / candidate.content.parts[0].text after those checks.

Sources: [1] Gemini API GenerateContentResponse / PromptFeedback reference, [2] example of blocked candidate with no parts in Vertex AI issue, [3] intermittent empty text report in Google’s SDK issue tracker.


🌐 Web query:

google-generativeai SDK response structure grounding_metadata documentation

💡 Result:

In the google-generativeai Python SDK, grounding information is returned per candidate. After a generate_content(...) call, you access it like:

  • response.candidates[0].grounding_metadata
  • then typically:
    • response.candidates[0].grounding_metadata.grounding_chunks
    • response.candidates[0].grounding_metadata.grounding_supports

This access pattern is shown in Google’s Gemini API grounding guide (Python example). [1]

Where this is documented (authoritative schemas)

  1. Gemini API (Generative Language) – Candidate schema
    Candidate includes groundingMetadata (“Grounding metadata for the candidate … populated for GenerateContent calls”). [2]

  2. Vertex AI – GroundingMetadata schema (field meanings)
    Documents the main fields you’ll see inside grounding_metadata, including:

  • webSearchQueries
  • retrievalQueries
  • groundingChunks
  • groundingSupports
  • retrievalMetadata (e.g., googleSearchDynamicRetrievalScore) [3]

Quick mental model of the response structure

  • response.candidates[] → each candidate may have its own grounding_metadata. [1][2]
  • grounding_chunks → the retrieved sources (URLs / docs / maps places, depending on tool). [1][3]
  • grounding_supports → spans/segments of the generated text mapped to chunk indices (for inline citations). [1][3]

Sources:
[1] Gemini API “Grounding with Google Search” (example accessing response.candidates[0].grounding_metadata...)
[2] Gemini API “Candidate” reference (includes groundingMetadata, populated for GenerateContent)
[3] Vertex AI REST reference: GroundingMetadata fields and definitions


🏁 Script executed:

# Read the relevant section of base.py
sed -n '230,260p' src/prompt_rewriter/agents/base.py

Repository: PublicDataWorks/verdad

Length of output: 855


🏁 Script executed:

# Get more context around this code - look at the function definition
sed -n '200,260p' src/prompt_rewriter/agents/base.py

Repository: PublicDataWorks/verdad

Length of output: 1789


🏁 Script executed:

# Check imports to understand how response object is created
head -40 src/prompt_rewriter/agents/base.py

Repository: PublicDataWorks/verdad

Length of output: 1067


🏁 Script executed:

# Search for other uses of response.candidates in the codebase
rg "response\.candidates" --type python -B 2 -A 2

Repository: PublicDataWorks/verdad

Length of output: 96


🏁 Script executed:

# Check if there's any existing error handling for empty candidates
rg "candidates" src/prompt_rewriter/agents/base.py -B 2 -A 2

Repository: PublicDataWorks/verdad

Length of output: 364


🏁 Script executed:

# Verify there's no surrounding try-catch that might handle this
sed -n '220,270p' src/prompt_rewriter/agents/base.py

Repository: PublicDataWorks/verdad

Length of output: 1231


🏁 Script executed:

# Check if there are any tests for this error case
find . -name "*.py" -path "*/test*" -type f | xargs rg "call_llm_with_search|candidates\[0\]" 2>/dev/null | head -20

Repository: PublicDataWorks/verdad

Length of output: 48


Guard against empty candidates before indexing.

response.candidates[0] will raise IndexError if the SDK returns no candidates (which occurs when the prompt is blocked). The defensive check prevents pipeline crashes.

Suggested fix
         sources = []
-        if hasattr(response.candidates[0], "grounding_metadata"):
-            metadata = response.candidates[0].grounding_metadata
+        candidates = getattr(response, "candidates", None) or []
+        if candidates and hasattr(candidates[0], "grounding_metadata"):
+            metadata = candidates[0].grounding_metadata
             if hasattr(metadata, "grounding_chunks"):
                 for chunk in metadata.grounding_chunks:
                     if hasattr(chunk, "web"):
                         sources.append(
                             {
                                 "url": chunk.web.uri,
                                 "title": chunk.web.title,
                             }
                         )
🤖 Prompt for AI Agents
In `@src/prompt_rewriter/agents/base.py` around lines 236 - 249, The code accesses
response.candidates[0] without ensuring candidates is non-empty, causing
IndexError when the SDK returns no candidates; update the grounding metadata
extraction in src/prompt_rewriter/agents/base.py to first check that response
has a non-empty candidates list (e.g., if getattr(response, "candidates", None)
and len(response.candidates) > 0) before referencing response.candidates[0], and
only then proceed to inspect grounding_metadata and grounding_chunks (symbols:
response, candidates, grounding_metadata, grounding_chunks, sources); if there
are no candidates, skip extraction or leave sources empty to avoid crashing the
pipeline.

return response.text, sources
Loading
Loading