Skip to content
Merged
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
43 changes: 30 additions & 13 deletions app/features/agents/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import os
from typing import Any

import structlog
Expand Down Expand Up @@ -35,27 +36,29 @@ def get_fallback_model() -> str:


def get_model_settings() -> dict[str, Any]:
"""Get model settings from configuration.
"""Get model settings from configuration for PydanticAI Agent.

Returns:
Dictionary with temperature, max_tokens, and optional thinking settings.
Dictionary with model_settings wrapped for Agent constructor.
"""
settings = get_settings()
model_settings: dict[str, Any] = {
inner_settings: dict[str, Any] = {
"temperature": settings.agent_temperature,
"max_tokens": settings.agent_max_tokens,
}

# Add thinking budget if configured (Gemini 2.5+ extended reasoning)
if settings.agent_thinking_budget:
model_settings["thinking"] = {"budget": settings.agent_thinking_budget}
inner_settings["thinking"] = {"budget": settings.agent_thinking_budget}

return model_settings
return {"model_settings": inner_settings}


def validate_api_key_for_model(model: str) -> None:
"""Validate that required API key is configured for model.

Also exports the API key to environment for PydanticAI compatibility.

Args:
model: Model identifier (provider:model-name).

Expand All @@ -65,14 +68,28 @@ def validate_api_key_for_model(model: str) -> None:
settings = get_settings()
provider = model.split(":")[0]

if provider == "anthropic" and not settings.anthropic_api_key:
raise ValueError(
"Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable."
)
elif provider == "openai" and not settings.openai_api_key:
raise ValueError("OpenAI API key not configured. Set OPENAI_API_KEY environment variable.")
elif provider in ["google-gla", "google-vertex"] and not settings.google_api_key:
raise ValueError("Google API key not configured. Set GOOGLE_API_KEY environment variable.")
if provider == "anthropic":
if not settings.anthropic_api_key:
raise ValueError(
"Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable."
)
# Only set env var if not already present to avoid repeated mutations
if "ANTHROPIC_API_KEY" not in os.environ:
os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
elif provider == "openai":
if not settings.openai_api_key:
raise ValueError(
"OpenAI API key not configured. Set OPENAI_API_KEY environment variable."
)
if "OPENAI_API_KEY" not in os.environ:
os.environ["OPENAI_API_KEY"] = settings.openai_api_key
elif provider in ["google-gla", "google-vertex"]:
if not settings.google_api_key:
raise ValueError(
"Google API key not configured. Set GOOGLE_API_KEY environment variable."
)
if "GOOGLE_API_KEY" not in os.environ:
os.environ["GOOGLE_API_KEY"] = settings.google_api_key
Comment on lines +71 to +92

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

Handle empty env-var values when exporting API keys.

If the env var exists but is an empty string, the code will skip setting the real key and downstream clients will still see a blank value. Consider treating empty as “missing.”

🛠️ Proposed fix
-        if "ANTHROPIC_API_KEY" not in os.environ:
+        if not os.environ.get("ANTHROPIC_API_KEY"):
             os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
@@
-        if "OPENAI_API_KEY" not in os.environ:
+        if not os.environ.get("OPENAI_API_KEY"):
             os.environ["OPENAI_API_KEY"] = settings.openai_api_key
@@
-        if "GOOGLE_API_KEY" not in os.environ:
+        if not os.environ.get("GOOGLE_API_KEY"):
             os.environ["GOOGLE_API_KEY"] = settings.google_api_key
🤖 Prompt for AI Agents
In `@app/features/agents/agents/base.py` around lines 71 - 92, The env-var
presence checks in the provider branch (provider == "anthropic", "openai",
"google-gla"/"google-vertex") only test for key existence in os.environ and
therefore treat an existing empty string as valid; change the logic to treat
empty strings as missing by checking both presence and non-empty value (e.g., if
os.environ.get("ANTHROPIC_API_KEY") not truthy) before skipping setting, and
when settings.anthropic_api_key / settings.openai_api_key /
settings.google_api_key are provided, set os.environ[...] to that value if the
current env var is missing or empty so downstream clients see the real key
(apply the same pattern for ANTHROPIC_API_KEY, OPENAI_API_KEY, and
GOOGLE_API_KEY).


logger.debug(
"agents.api_key_validated",
Expand Down
15 changes: 12 additions & 3 deletions app/features/agents/agents/rag_assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import structlog
from pydantic_ai import Agent, RunContext

from app.core.config import get_settings
from app.features.agents.agents.base import (
SAFETY_INSTRUCTIONS,
SYSTEM_PROMPT_HEADER,
Expand Down Expand Up @@ -78,13 +79,17 @@ def create_rag_assistant_agent() -> Agent[AgentDeps, RAGAnswer]:
**get_model_settings(),
)

# Get default threshold from settings
settings = get_settings()
default_threshold = settings.rag_similarity_threshold

# Register tools with the agent
@agent.tool
async def tool_retrieve_context(
ctx: RunContext[AgentDeps],
query: str,
top_k: int = 5,
similarity_threshold: float = 0.7,
similarity_threshold: float | None = None,
source_type: str | None = None,
) -> dict[str, Any]:
"""Retrieve relevant context from the knowledge base.
Expand All @@ -97,24 +102,28 @@ async def tool_retrieve_context(
Args:
query: Search query describing what to find.
top_k: Maximum results to return (default 5).
similarity_threshold: Minimum similarity score (default 0.7).
similarity_threshold: Minimum similarity score (default from settings).
source_type: Filter by source type ('markdown', 'openapi').

Returns:
Dictionary with 'results' list containing chunks with citations.
"""
# Use settings default if not provided
threshold = similarity_threshold if similarity_threshold is not None else default_threshold

ctx.deps.increment_tool_calls()
logger.info(
"agents.rag_assistant.tool_retrieve_context",
session_id=ctx.deps.session_id,
query_length=len(query),
top_k=top_k,
threshold=threshold,
)
return await retrieve_context(
db=ctx.deps.db,
query=query,
top_k=top_k,
similarity_threshold=similarity_threshold,
similarity_threshold=threshold,
source_type=source_type,
)

Expand Down
24 changes: 21 additions & 3 deletions app/features/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from typing import Any, Literal, cast

import structlog
from pydantic_ai import Agent
Expand Down Expand Up @@ -263,8 +263,8 @@ async def chat(
pending_approval = False

# The structured output might indicate approval is needed
# NOTE: PydanticAI's result.data type is generic, cast to Any for attribute access
result_data: Any = result.data # type: ignore[attr-defined]
# NOTE: PydanticAI v1.48.0 uses result.output (not result.data)
result_data: Any = result.output
Comment on lines +266 to +267

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the stream_chat implementation in service.py
grep -n "stream_chat\|get_data\|result\.output" app/features/agents/service.py | head -20

Repository: w7-mgfcode/ForecastLabAI

Length of output: 408


🏁 Script executed:

# Check the dependencies to confirm PydanticAI version
fd -e "requirements.txt" -e "pyproject.toml" -e "poetry.lock" -e "setup.py" | head -5 | xargs grep -l "pydantic" 2>/dev/null

Repository: w7-mgfcode/ForecastLabAI

Length of output: 52


🌐 Web query:

PydanticAI v1.48.0 run_stream API result object output get_data method documentation

💡 Result:

In PydanticAI (pydantic-ai) v1.48.0, the object you get from Agent.run_stream(...) is a StreamedRunResult, and the method to “get the data” is get_output() (not get_data). (ai.pydantic.dev)

What to use in v1.48.0

  • Async streaming run:

    • async with agent.run_stream(...) as result:
    • Stream partial output via result.stream_text(...) / result.stream_output(...)
    • Then get the final validated output via await result.get_output() (ai.pydantic.dev)
  • Sync streaming run:

    • agent.run_stream_sync(...) returns a sync wrapper (StreamedRunResultSync)
    • Then call result.get_output() (sync) (ai.pydantic.dev)

Where it’s documented

  • API reference for StreamedRunResult.get_output() (“Stream the whole response, validate and return it.”) (ai.pydantic.dev)
  • Core concept docs explaining that run_stream() returns a streamed result wrapper around the final output (ai.pydantic.dev)

About get_data()

If you’re seeing get_data() in older/community mirrors (or older docs snapshots), that name doesn’t match the current official v1.48.0 API; in v1.48.0 the corresponding method is get_output(). (ai.pydantic.dev)

Citations:


Replace result.get_data() with result.get_output() in stream_chat().

In PydanticAI v1.48.0, StreamedRunResult uses get_output() (not get_data()) to retrieve the final validated output. Line 417 currently calls await result.get_data(), which will fail at runtime. Update to await result.get_output() to match the corrected API and maintain consistency with the non-streaming chat() method.

🤖 Prompt for AI Agents
In `@app/features/agents/service.py` around lines 266 - 267, In stream_chat(), the
code awaits result.get_data() but PydanticAI v1.48.0's StreamedRunResult exposes
get_output() instead; update the call in the stream_chat function to await
result.get_output() (matching the non-streaming chat() use of get_output()),
ensuring you reference the StreamedRunResult instance named result and replace
get_data() with get_output() so the final validated output is retrieved
correctly.


# Check for pending_action in result data (primary trigger)
# The agent tools should return a pending_action dict with action_type and arguments
Expand Down Expand Up @@ -662,13 +662,31 @@ def _serialize_messages(
List of serializable dictionaries.
"""
import dataclasses
from datetime import datetime

def json_safe(obj: object) -> object:
"""Convert non-JSON-serializable objects to JSON-safe types."""
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, dict):
return {k: json_safe(v) for k, v in obj.items()}
if isinstance(obj, list):
return [json_safe(item) for item in obj]
# Primitive JSON types pass through
if isinstance(obj, (str, int, float, bool, type(None))):
return obj
# Fallback: convert unknown types to string representation
return str(obj)

serialized: list[dict[str, Any]] = []
for msg in messages:
if dataclasses.is_dataclass(msg) and not isinstance(msg, type):
# Convert dataclass to dict, handling nested types
try:
msg_dict = dataclasses.asdict(msg)
# Convert datetime objects to ISO strings
# Cast is safe: json_safe preserves dict structure
msg_dict = cast(dict[str, Any], json_safe(msg_dict))
# Add kind discriminator for deserialization
if hasattr(msg, "kind"):
msg_dict["kind"] = msg.kind
Expand Down
2 changes: 1 addition & 1 deletion app/features/agents/tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def test_chat_success(self, client: AsyncClient) -> None:
with patch("app.features.agents.agents.experiment.get_experiment_agent") as mock_get:
mock_agent = MagicMock()
mock_result = MagicMock()
mock_result.data = ExperimentReport(
mock_result.output = ExperimentReport(
run_id="run123",
status="success",
summary="Test completed",
Expand Down
2 changes: 1 addition & 1 deletion app/features/agents/tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ async def test_chat_success(
# Mock agent
mock_agent = MagicMock()
mock_agent_result = MagicMock()
mock_agent_result.data = sample_experiment_report
mock_agent_result.output = sample_experiment_report
mock_usage = MagicMock()
mock_usage.total_tokens = 100
mock_agent_result.usage.return_value = mock_usage
Expand Down
8 changes: 7 additions & 1 deletion app/features/rag/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import get_settings
from app.core.database import get_db
from app.core.exceptions import DatabaseError
from app.core.logging import get_logger
Expand Down Expand Up @@ -150,7 +151,7 @@ async def index_document(

**Parameters:**
- `top_k`: Number of results (1-50, default: 5)
- `similarity_threshold`: Minimum similarity (0.0-1.0, default: 0.7)
- `similarity_threshold`: Minimum similarity (0.0-1.0, default from RAG_SIMILARITY_THRESHOLD)
- `filters`: Optional metadata filters

**Filters:**
Expand Down Expand Up @@ -183,6 +184,11 @@ async def retrieve(
HTTPException: If embedding generation fails.
DatabaseError: If database operation fails.
"""
# Apply settings default if threshold not provided
settings = get_settings()
if request.similarity_threshold is None:
request.similarity_threshold = settings.rag_similarity_threshold

logger.info(
"rag.retrieve_request_received",
query_length=len(request.query),
Expand Down
4 changes: 2 additions & 2 deletions app/features/rag/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ class RetrieveRequest(BaseModel):

query: str = Field(..., min_length=1, max_length=2000, description="Search query text")
top_k: int = Field(default=5, ge=1, le=50, description="Number of results to return")
similarity_threshold: float = Field(
default=0.7, ge=0.0, le=1.0, description="Minimum similarity score"
similarity_threshold: float | None = Field(
default=None, ge=0.0, le=1.0, description="Minimum similarity score (default from settings)"
)
filters: dict[str, Any] | None = Field(
None, description="Metadata filters (source_type, category, etc.)"
Expand Down
9 changes: 8 additions & 1 deletion app/features/rag/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,14 +287,21 @@ async def retrieve(
# Get total chunk count for statistics
total_chunks = await self._get_total_chunk_count(db)

# Use local variable for effective threshold to avoid modifying request
effective_threshold = (
request.similarity_threshold
if request.similarity_threshold is not None
else self.settings.rag_similarity_threshold
)

# Build similarity search query
# CRITICAL: cosine_distance returns values 0-2, so relevance = 1 - distance/2
# But for cosine similarity on normalized vectors, distance is 0-1
results = await self._search_similar_chunks(
db=db,
query_embedding=query_embedding,
top_k=request.top_k,
threshold=request.similarity_threshold,
threshold=effective_threshold,
filters=request.filters,
)

Expand Down
3 changes: 2 additions & 1 deletion app/features/rag/tests/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ def test_valid_request_defaults(self):
request = RetrieveRequest(query="What is forecasting?")
assert request.query == "What is forecasting?"
assert request.top_k == 5
assert request.similarity_threshold == 0.7
# similarity_threshold defaults to None (service uses settings fallback)
assert request.similarity_threshold is None
assert request.filters is None

def test_valid_request_custom_params(self):
Expand Down
Loading