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
4 changes: 2 additions & 2 deletions .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:
- name: Setup .env
run: |
cat <<EOF > .env
MISTRAL_API_KEY=${{ secrets.MISTRAL_API_KEY }}
LLM_PROVIDER_KEY=${{ secrets.MISTRAL_API_KEY }}
MAPTILER_API_KEY=${{ secrets.MAPTILER_KEY }}
LOG_LEVEL=DEBUG
VERSION="x.x.x-testing"
Expand Down Expand Up @@ -125,4 +125,4 @@ jobs:
- name: Build image
run: |
cd frontend
scripts/build-docker-image --maptiler-key ${{ secrets.MAPTILER_KEY }}
scripts/build-docker-image
32 changes: 25 additions & 7 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
MISTRAL_API_KEY=your_mistral_api_key_here
# Copy to .env and fill in the blanks.

# Get a free key from https://www.maptiler.com/ (used both by the frontend
# map and this backend's get_satellite_image tool)
MAPTILER_API_KEY=your_maptiler_api_key
# --- LLM ----------------------------------------------------------------
# API key for the provider named in LLM_PROVIDER (required).
LLM_PROVIDER_KEY=
# LangChain provider id. Change this and the two models below to use
# something other than Mistral, and install that provider's integration
# package (e.g. `uv add langchain-openai`).
LLM_PROVIDER=mistralai
LLM_LARGE_MODEL=mistral-large-latest
LLM_SMALL_MODEL=mistral-small-latest

# LANGFUSE_HOST=
LANGFUSE_ENABLED=false
# --- Map / imagery ------------------------------------------------------
# Free key from https://www.maptiler.com/ (same key the frontend uses).
MAPTILER_API_KEY=

# --- API ----------------------------------------------------------------
# Origins allowed to call this API. The frontend dev server runs on :9000.
ALLOWED_ORIGINS=["http://localhost:9000"]

LOG_LEVEL=INFO
STABLE=true
VERSION=x.x.x-dev

# --- Tracing (optional) -------------------------------------------------
# Langfuse powers the /ratings endpoint; leave disabled for local dev.
LANGFUSE_ENABLED=false
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_HOST=
14 changes: 13 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,19 @@ You will need the following installed:

## LLM dependencies

The app uses the Mistral AI API for the LLM, which requires an API key. Set `MISTRAL_API_KEY` in your `.env` file.
The LLM provider is configurable. The agent uses two models — a `large` one (used by the
agent graph) and a `small` one (available for cheaper, narrower tasks) — both created via
LangChain's `init_chat_model`, so any provider it supports can be used.

Set the following in your `.env` file:

- `LLM_PROVIDER_KEY` (required) - the API key for the provider
- `LLM_PROVIDER` - the LangChain provider id, defaults to `mistralai`
- `LLM_LARGE_MODEL` - defaults to `mistral-large-latest`
- `LLM_SMALL_MODEL` - defaults to `mistral-small-latest`

If you switch provider, install its LangChain integration package (e.g.
`uv add langchain-openai`) in place of `langchain-mistralai`.

## Map / imagery dependencies

Expand Down
3 changes: 1 addition & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
[project]
name = "agentkai"
version = "0.12.0"
version = "0.13.0"
description = "A barebones demo of an agentic chat application"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"dotenv>=0.9.9",
"fastapi>=0.115.9",
"httpx[http2]>=0.28.1",
"langchain>=0.3.26",
Expand Down
13 changes: 9 additions & 4 deletions backend/src/agent_kai/agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

from agent_kai.agent.llm import mistral_large as llm
from agent_kai.agent.llm import large as llm
from agent_kai.agent.state import AgentState
from agent_kai.tools.aoi import get_area_of_interest
from agent_kai.tools.satellite_image import get_satellite_image
Expand All @@ -20,17 +20,22 @@
imagery of the current area of interest. This requires an AOI to already
be set — call `get_area_of_interest` first if the user hasn't named a
place yet in this conversation. Note: this demo does not call a real
satellite imagery provider — it returns a random fun image (a cat, a
flower, or NASA's Astronomy Picture of the Day) instead. Present it to
satellite imagery provider — it returns a random fun image (a cat or a
flower) instead. Present it to
the user as the fun surprise image it is, not as real imagery of the
place.
place.
- IT IS IMPERITIVE YOU NEVER RETURN A PICTURE IN YOUR RESPONSE. Tools will set an image
in state if necessary and those are rendered clientside.

General guidelines:
- Strictly use the tools provided to answer queries. Do not rely on internal
knowledge about specific places.
- If you are unable to fulfil a request with the available tools, say so
plainly rather than guessing.
"""


"""
============================================================================
HOW TO ADD A NEW TOOL
============================================================================
Expand Down
39 changes: 11 additions & 28 deletions backend/src/agent_kai/agent/llm.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,19 @@
from langchain_mistralai import ChatMistralAI
from pydantic import SecretStr
from langchain.chat_models import init_chat_model
from langchain_core.language_models import BaseChatModel

from agent_kai.settings import get_settings

settings = get_settings()

mistral_large = ChatMistralAI(
model_name="mistral-large-latest",
api_key=SecretStr(settings.mistral_api_key),
temperature=0.0,
)

mistral_medium = ChatMistralAI(
model_name="mistral-medium-latest",
api_key=SecretStr(settings.mistral_api_key),
temperature=0.0,
)
def _build_model(model_name: str) -> BaseChatModel:
return init_chat_model(
model_name,
model_provider=settings.llm_provider,
api_key=settings.llm_provider_key,
temperature=0.0,
)

mistral_small = ChatMistralAI(
model_name="mistral-small-latest",
api_key=SecretStr(settings.mistral_api_key),
temperature=0.0,
)

magistral_medium = ChatMistralAI(
model_name="magistral-medium-latest",
api_key=SecretStr(settings.mistral_api_key),
temperature=0.0,
)

magistral_small = ChatMistralAI(
model_name="magistral-small-latest",
api_key=SecretStr(settings.mistral_api_key),
temperature=0.0,
)
large: BaseChatModel = _build_model(settings.llm_large_model)
small: BaseChatModel = _build_model(settings.llm_small_model)
2 changes: 1 addition & 1 deletion backend/src/agent_kai/agent/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ class AgentState(LangchainAgentState):
Annotated[
dict[str, Any] | None,
"An image artifact to display to the user, shaped as "
"{'type': 'image/png', 'data': <base64-encoded PNG>}.",
"{'type': <mime type>, 'url': <publicly reachable image URL>}.",
]
]
27 changes: 10 additions & 17 deletions backend/src/agent_kai/api/app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import datetime
import json
import logging
import sys
from contextlib import aclosing, asynccontextmanager
from http import HTTPStatus
from typing import Any, AsyncGenerator, Dict, cast

from dotenv import load_dotenv
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
Expand All @@ -25,7 +23,6 @@
from agent_kai.api.schemas.version import VersionResponse
from agent_kai.settings import get_settings

load_dotenv()
settings = get_settings()

# Whitelist state fields that can be set by the user.
Expand Down Expand Up @@ -55,7 +52,10 @@ async def lifespan(app: FastAPI):

app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows all origins
# Set ALLOWED_ORIGINS in your .env to wherever the frontend is served from.
# Browsers reject a wildcard origin on credentialed requests, so this has
# to be an explicit list.
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"], # Allows all HTTP methods (GET, POST, PUT, DELETE, etc.)
allow_headers=["*"], # Allows all headers
Expand Down Expand Up @@ -85,19 +85,6 @@ async def readiness() -> JSONResponse:
)


def _stringify_dates(obj: Any) -> Any:
if isinstance(obj, list):
return [_stringify_dates(item) for item in obj]
elif isinstance(obj, dict):
return {key: _stringify_dates(value) for key, value in obj.items()}
elif isinstance(obj, datetime.date):
return obj.strftime("%Y%m%d")
elif isinstance(obj, datetime.datetime):
return obj.isoformat()
else:
return obj


async def stream_chat(
ui_state_update: AgentState,
thread_id: UUID4,
Expand Down Expand Up @@ -188,6 +175,12 @@ async def chat(request: ChatRequestBody, http_request: Request) -> StreamingResp

@app.post("/ratings")
async def create_rating(request: CreateRatingBody) -> Response:
# Ratings are stored as Langfuse scores against the trace of the response
# being rated, so there is nowhere to put them when tracing is off.
if not settings.langfuse_enabled:
logger.info("Rating discarded: langfuse is not enabled.")
return Response(status_code=HTTPStatus.SERVICE_UNAVAILABLE)

try:
langfuse_client.create_score(
name="user-feedback",
Expand Down
Empty file.
6 changes: 5 additions & 1 deletion backend/src/agent_kai/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@


class Settings(BaseSettings):
mistral_api_key: str
llm_provider: str = "mistralai"
llm_provider_key: str
llm_large_model: str = "mistral-large-latest"
llm_small_model: str = "mistral-small-latest"
maptiler_api_key: str
langfuse_public_key: str | None = None
langfuse_secret_key: str | None = None
langfuse_host: HttpUrl | None = None
langfuse_enabled: bool = False
allowed_origins: list[str] = ["http://localhost:9000"]
log_level: LOG_LEVEL = "DEBUG"
version: str = "N/A"
stable: bool = True # Represents whether we know of any issues affecting our service that are current
Expand Down
32 changes: 5 additions & 27 deletions backend/src/agent_kai/tools/satellite_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,13 @@
logger = logging.getLogger(__name__)

CAT_API_URL = "https://api.thecatapi.com/v1/images/search"
NASA_APOD_API_URL = "https://api.nasa.gov/planetary/apod"
# NASA's shared, no-signup-required key for api.nasa.gov (rate-limited but
# keyless): https://api.nasa.gov/#api-keys
NASA_APOD_DEMO_KEY = "DEMO_KEY"
WIKIMEDIA_COMMONS_API_URL = "https://commons.wikimedia.org/w/api.php"
FLOWER_CATEGORY = "Category:Flowers"
# Wikimedia's API etiquette policy rejects requests with a generic/missing
# User-Agent: https://meta.wikimedia.org/wiki/User-Agent_policy
WIKIMEDIA_USER_AGENT = "AgentKai/1.0 (demo app)"

IMAGE_SOURCES = ("cat", "flower", "nasa")
IMAGE_SOURCES = ("cat", "flower")


async def fetch_cat_image_url(client: httpx.AsyncClient) -> str:
Expand Down Expand Up @@ -64,36 +60,20 @@ async def fetch_flower_image_url(client: httpx.AsyncClient) -> str:
return random.choice(pages)["imageinfo"][0]["url"]


async def fetch_nasa_apod_image_url(client: httpx.AsyncClient, api_key: str) -> str:
"""Fetch a random NASA Astronomy Picture of the Day image URL.

`count=1` asks NASA's APOD API for one random entry from its archive.
Some entries are videos rather than images, so we retry a few times
until we land on an image.
"""
for _ in range(5):
response = await client.get(NASA_APOD_API_URL, params={"api_key": api_key, "count": 1})
response.raise_for_status()
entry = response.json()[0]
if entry.get("media_type") == "image":
return entry.get("hdurl") or entry["url"]
raise RuntimeError("Could not find a NASA APOD entry with an image after 5 attempts")


async def fetch_random_fun_image_url() -> tuple[str, str]:
"""Pick a random source and fetch an image URL from it.

Returns a (source, image_url) tuple.
"""
source = random.choice(IMAGE_SOURCES)
# http2=True: Wikimedia's edge rejects HTTP/1.1-only clients as bots.
async with httpx.AsyncClient(follow_redirects=True, timeout=15, http2=True) as client:
async with httpx.AsyncClient(
follow_redirects=True, timeout=15, http2=True
) as client:
if source == "cat":
image_url = await fetch_cat_image_url(client)
elif source == "flower":
image_url = await fetch_flower_image_url(client)
else:
image_url = await fetch_nasa_apod_image_url(client, NASA_APOD_DEMO_KEY)
return source, image_url


Expand All @@ -105,9 +85,7 @@ async def get_satellite_image(
"""Fetch an image to show the user in place of real satellite imagery.

This demo does not call a real satellite imagery provider. It instead
returns a random fun image — a cat, a flower, or NASA's Astronomy
Picture of the Day — so the tool-call flow can be exercised without a
satellite imagery API key.
returns a random fun image — a cat, or a flower

Requires an AOI to already be set in this conversation — if the user
hasn't named a place yet, call `get_area_of_interest` first.
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/api/test_ratings_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
from unittest.mock import patch

import pytest


@pytest.fixture
def langfuse_enabled():
with patch("agent_kai.api.app.settings.langfuse_enabled", True):
yield


class TestRatingsEndpoint:
@patch("agent_kai.api.app.langfuse_client", create=True)
def test_ratings_endpoint_invokes_langfuse_client_with_provided_values(
self,
patched_langfuse_client,
test_client,
langfuse_enabled,
):
resp = test_client.post(
"/ratings",
Expand All @@ -26,6 +35,7 @@ def test_ratings_endpoint_returns_error_if_langfuse_does(
self,
patched_langfuse_client,
test_client,
langfuse_enabled,
):
patched_langfuse_client.create_score.side_effect = Exception("An exception")
resp = test_client.post(
Expand All @@ -34,6 +44,15 @@ def test_ratings_endpoint_returns_error_if_langfuse_does(
)
assert resp.status_code == 500

def test_ratings_endpoint_is_unavailable_when_langfuse_is_disabled(
self, test_client
):
resp = test_client.post(
"/ratings",
json={"trace_id": "an-id", "rating": 1, "comment": "this was awesome!"},
)
assert resp.status_code == 503

def test_ratings_endpoint_correctly_validates_inputs(self, test_client):
resp = test_client.post("/ratings", json={"blah": "blah"})
assert resp.status_code == 422
Loading
Loading