Basic backend for app
This commit is contained in:
parent
747acd6f6a
commit
050ea8df70
4 changed files with 363 additions and 0 deletions
9
app/.env.example
Normal file
9
app/.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# API Keys (at least one required for LLM)
|
||||||
|
ANTHROPIC_API_KEY=your-anthropic-key
|
||||||
|
OPENAI_API_KEY=your-openai-key
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH=/path/to/your/haiku.rag.lancedb
|
||||||
|
|
||||||
|
# Optional: Ollama base URL (if using local models)
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
124
app/backend/agent.py
Normal file
124
app/backend/agent.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.config.models import AppConfig
|
||||||
|
from haiku.rag.store.models import SearchResult
|
||||||
|
from haiku.rag.utils import get_model
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from haiku.rag.graph.agui.emitter import AGUIEmitter
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionState(BaseModel):
|
||||||
|
"""State shared between frontend and agent via AG-UI."""
|
||||||
|
|
||||||
|
session_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatDeps:
|
||||||
|
"""Dependencies for chat agent."""
|
||||||
|
|
||||||
|
client: HaikuRAG
|
||||||
|
config: AppConfig
|
||||||
|
agui_emitter: "AGUIEmitter | None" = None
|
||||||
|
search_results: list[SearchResult] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_SYSTEM_PROMPT = """You are a helpful research assistant powered by haiku.rag, a knowledge base system.
|
||||||
|
|
||||||
|
You have access to a knowledge base of documents. Use your tools to search and answer questions.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
1. For greetings or casual chat: respond directly WITHOUT using any tools
|
||||||
|
2. For substantive questions requiring information: use the search or ask tools
|
||||||
|
3. NEVER make up information - always use tools to get facts from the knowledge base
|
||||||
|
4. When citing sources, reference the chunk IDs from search results
|
||||||
|
|
||||||
|
How to decide which tool to use:
|
||||||
|
- "search" - When you need to find relevant documents or explore what's in the knowledge base
|
||||||
|
- "ask" - When you have a specific question that needs a direct answer with citations
|
||||||
|
|
||||||
|
Be friendly and conversational. When you use tools, summarize the key findings for the user."""
|
||||||
|
|
||||||
|
|
||||||
|
def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
|
"""Create the chat agent with search and ask tools."""
|
||||||
|
model = get_model(config.qa.model, config)
|
||||||
|
|
||||||
|
agent: Agent[ChatDeps, str] = Agent(
|
||||||
|
model,
|
||||||
|
deps_type=ChatDeps,
|
||||||
|
output_type=str,
|
||||||
|
instructions=CHAT_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def search(
|
||||||
|
ctx: RunContext[ChatDeps],
|
||||||
|
query: str,
|
||||||
|
limit: int = 5,
|
||||||
|
document_filter: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Search the knowledge base for relevant documents.
|
||||||
|
|
||||||
|
Use this when you need to find documents or explore the knowledge base.
|
||||||
|
Returns relevant chunks with metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The search query
|
||||||
|
limit: Maximum number of results (default 5)
|
||||||
|
document_filter: Optional SQL WHERE clause to filter documents (e.g. "id IN ('doc1', 'doc2')")
|
||||||
|
"""
|
||||||
|
if ctx.deps.agui_emitter:
|
||||||
|
ctx.deps.agui_emitter.log(f"Searching: {query}")
|
||||||
|
|
||||||
|
results = await ctx.deps.client.search(
|
||||||
|
query, limit=limit, filter=document_filter
|
||||||
|
)
|
||||||
|
results = await ctx.deps.client.expand_context(results)
|
||||||
|
|
||||||
|
# Store for potential citation resolution
|
||||||
|
ctx.deps.search_results = results
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return "No results found for your query."
|
||||||
|
|
||||||
|
# Format results for the agent
|
||||||
|
parts = [r.format_for_agent() for r in results]
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def ask(
|
||||||
|
ctx: RunContext[ChatDeps],
|
||||||
|
question: str,
|
||||||
|
document_filter: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Answer a specific question using the knowledge base.
|
||||||
|
|
||||||
|
Use this for direct questions that need a focused answer with citations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: The question to answer
|
||||||
|
document_filter: Optional SQL WHERE clause to filter documents (e.g. "id IN ('doc1', 'doc2')")
|
||||||
|
"""
|
||||||
|
if ctx.deps.agui_emitter:
|
||||||
|
ctx.deps.agui_emitter.log(f"Answering: {question}")
|
||||||
|
|
||||||
|
answer, citations = await ctx.deps.client.ask(question, filter=document_filter)
|
||||||
|
|
||||||
|
# Format answer with citations
|
||||||
|
if citations:
|
||||||
|
citation_list = "\n".join(
|
||||||
|
f" [{i + 1}] {c.document_uri or c.document_title or 'Unknown'} (chunk: {c.chunk_id})"
|
||||||
|
for i, c in enumerate(citations)
|
||||||
|
)
|
||||||
|
return f"{answer}\n\nSources:\n{citation_list}"
|
||||||
|
|
||||||
|
return answer
|
||||||
|
|
||||||
|
return agent
|
||||||
205
app/backend/main.py
Normal file
205
app/backend/main.py
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from agent import ChatDeps, ChatSessionState, create_chat_agent
|
||||||
|
from anyio import create_memory_object_stream, create_task_group
|
||||||
|
from anyio.streams.memory import MemoryObjectSendStream
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.middleware import Middleware
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse, StreamingResponse
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.config import load_yaml_config
|
||||||
|
from haiku.rag.config.models import AppConfig
|
||||||
|
from haiku.rag.graph.agui.emitter import AGUIEmitter
|
||||||
|
from haiku.rag.graph.agui.server import RunAgentInput, format_sse_event
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config_path = Path("/app/haiku.rag.yaml")
|
||||||
|
if config_path.exists():
|
||||||
|
yaml_data = load_yaml_config(config_path)
|
||||||
|
Config = AppConfig.model_validate(yaml_data)
|
||||||
|
else:
|
||||||
|
Config = AppConfig()
|
||||||
|
|
||||||
|
# Get DB path from environment
|
||||||
|
db_path_str = os.getenv("DB_PATH", "haiku_rag.lancedb")
|
||||||
|
db_path = Path(db_path_str)
|
||||||
|
|
||||||
|
logger.info(f"Database path: {db_path}")
|
||||||
|
logger.info(f"QA Provider: {Config.qa.model.provider}, Model: {Config.qa.model.name}")
|
||||||
|
|
||||||
|
# Create the chat agent
|
||||||
|
chat_agent = create_chat_agent(Config)
|
||||||
|
|
||||||
|
# Client cache for proper lifecycle
|
||||||
|
_client_cache: dict[str, HaikuRAG] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_client(effective_db_path: Path) -> HaikuRAG:
|
||||||
|
"""Get or create cached client."""
|
||||||
|
path_key = str(effective_db_path)
|
||||||
|
if path_key not in _client_cache:
|
||||||
|
_client_cache[path_key] = HaikuRAG(
|
||||||
|
db_path=effective_db_path, config=Config, create=True
|
||||||
|
)
|
||||||
|
return _client_cache[path_key]
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_chat(request: Request) -> StreamingResponse:
|
||||||
|
"""Chat streaming endpoint with AG-UI protocol."""
|
||||||
|
body = await request.json()
|
||||||
|
logger.info(f"Received request: {list(body.keys())}")
|
||||||
|
input_data = RunAgentInput(**body)
|
||||||
|
|
||||||
|
user_message = ""
|
||||||
|
if input_data.messages:
|
||||||
|
user_message = input_data.messages[-1].get("content", "")
|
||||||
|
|
||||||
|
send_stream, receive_stream = create_memory_object_stream[str]()
|
||||||
|
|
||||||
|
async def run_agent_with_streaming(
|
||||||
|
send_stream: MemoryObjectSendStream[str],
|
||||||
|
) -> None:
|
||||||
|
"""Execute agent and forward events to stream."""
|
||||||
|
async with send_stream:
|
||||||
|
try:
|
||||||
|
# Create emitter for streaming
|
||||||
|
emitter: AGUIEmitter = AGUIEmitter(
|
||||||
|
thread_id=input_data.thread_id,
|
||||||
|
run_id=input_data.run_id,
|
||||||
|
use_deltas=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get client
|
||||||
|
effective_db_path = db_path
|
||||||
|
if input_data.config and input_data.config.get("db_path"):
|
||||||
|
effective_db_path = Path(input_data.config["db_path"])
|
||||||
|
client = get_client(effective_db_path)
|
||||||
|
|
||||||
|
# Create deps
|
||||||
|
deps = ChatDeps(
|
||||||
|
client=client,
|
||||||
|
config=Config,
|
||||||
|
agui_emitter=emitter,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start run with empty state
|
||||||
|
initial_state = ChatSessionState(
|
||||||
|
session_id=input_data.thread_id or "",
|
||||||
|
)
|
||||||
|
emitter.start_run(initial_state=initial_state)
|
||||||
|
|
||||||
|
# Forward events
|
||||||
|
async def forward_events():
|
||||||
|
async for event in emitter:
|
||||||
|
event_type = event.get("type")
|
||||||
|
logger.debug(f"AG-UI event: {event_type}")
|
||||||
|
await send_stream.send(format_sse_event(event))
|
||||||
|
|
||||||
|
# Run agent and forward concurrently
|
||||||
|
async with create_task_group() as tg:
|
||||||
|
tg.start_soon(forward_events)
|
||||||
|
|
||||||
|
result = await chat_agent.run(user_message, deps=deps)
|
||||||
|
emitter.log(result.output)
|
||||||
|
emitter.finish_run(result.output)
|
||||||
|
await emitter.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error executing agent")
|
||||||
|
try:
|
||||||
|
await send_stream.send(
|
||||||
|
format_sse_event({"type": "RUN_ERROR", "message": str(e)})
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
"""Generate SSE events."""
|
||||||
|
async with create_task_group() as tg:
|
||||||
|
tg.start_soon(run_agent_with_streaming, send_stream)
|
||||||
|
async with receive_stream:
|
||||||
|
async for event_str in receive_stream:
|
||||||
|
yield event_str
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def health_check(_: Request) -> JSONResponse:
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"agent_model": str(chat_agent.model),
|
||||||
|
"qa_provider": Config.qa.model.provider,
|
||||||
|
"qa_model": Config.qa.model.name,
|
||||||
|
"db_path": str(db_path),
|
||||||
|
"db_exists": db_path.exists(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_documents(_: Request) -> JSONResponse:
|
||||||
|
"""List all documents in the database."""
|
||||||
|
if not db_path.exists():
|
||||||
|
return JSONResponse({"documents": [], "error": "Database not found"})
|
||||||
|
|
||||||
|
client = get_client(db_path)
|
||||||
|
docs = await client.document_repository.list_all()
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"documents": [
|
||||||
|
{"id": doc.id, "title": doc.title, "uri": doc.uri} for doc in docs
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Create Starlette app
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route("/v1/chat/stream", stream_chat, methods=["POST"]),
|
||||||
|
Route("/api/documents", list_documents, methods=["GET"]),
|
||||||
|
Route("/health", health_check, methods=["GET"]),
|
||||||
|
],
|
||||||
|
middleware=[
|
||||||
|
Middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3000", "http://frontend:3000"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=True,
|
||||||
|
)
|
||||||
25
app/backend/pyproject.toml
Normal file
25
app/backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[project]
|
||||||
|
name = "haiku-rag-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Conversational RAG application with haiku.rag"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"starlette>=0.50.0",
|
||||||
|
"uvicorn[standard]>=0.40.0",
|
||||||
|
"pydantic-ai-slim[ag-ui,anthropic,openai]>=1.39.0",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
"haiku.rag-slim[agui]>=0.23.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pyright>=1.1.407", "ruff>=0.14.10"]
|
||||||
|
|
||||||
|
[tool.hatch.metadata]
|
||||||
|
allow-direct-references = true
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["."]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
Loading…
Reference in a new issue