mirror of
https://github.com/Shubhamsaboo/awesome-llm-apps.git
synced 2026-09-01 15:00:52 +08:00
251 lines
8.1 KiB
Python
251 lines
8.1 KiB
Python
import ipaddress
|
|
import os
|
|
import socket
|
|
from typing import Any, Literal, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
from bs4 import BeautifulSoup
|
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from google.genai import types as genai_types
|
|
from pydantic import BaseModel, Field, HttpUrl
|
|
from starlette.concurrency import run_in_threadpool
|
|
|
|
from app_state import RAG_STORE
|
|
|
|
SETUP_ERROR = ""
|
|
|
|
try:
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions import InMemorySessionService
|
|
from agentic_rag_agent.agent import build_agent
|
|
|
|
ADK_AVAILABLE = bool(os.getenv("GOOGLE_API_KEY"))
|
|
except Exception:
|
|
Runner = None
|
|
InMemorySessionService = None
|
|
build_agent = None
|
|
ADK_AVAILABLE = False
|
|
SETUP_ERROR = "Google ADK could not be imported. Install backend requirements and set GOOGLE_API_KEY."
|
|
|
|
if not os.getenv("GOOGLE_API_KEY"):
|
|
SETUP_ERROR = "GOOGLE_API_KEY is required for Gemini Embedding 2 and the ADK answer flow."
|
|
|
|
|
|
APP_NAME = "multimodal_agentic_rag"
|
|
USER_ID = "demo-user"
|
|
|
|
app = FastAPI(title="Multimodal Agentic RAG ADK")
|
|
allowed_origins = [
|
|
origin.strip()
|
|
for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:5177,http://127.0.0.1:5177").split(",")
|
|
if origin.strip()
|
|
]
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=allowed_origins,
|
|
allow_credentials=False,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
session_service = InMemorySessionService() if ADK_AVAILABLE else None
|
|
|
|
|
|
class TextSourceRequest(BaseModel):
|
|
title: str
|
|
text: str
|
|
modality: Literal["text"] = "text"
|
|
|
|
|
|
class UrlSourceRequest(BaseModel):
|
|
url: HttpUrl
|
|
title: Optional[str] = None
|
|
|
|
|
|
class AskRequest(BaseModel):
|
|
question: str
|
|
top_k: int = Field(6, ge=1, le=12)
|
|
|
|
|
|
def _extract_text_from_html(html: str) -> str:
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
|
tag.decompose()
|
|
return " ".join(soup.get_text(" ").split())
|
|
|
|
|
|
def _validate_fetch_url(url: str) -> None:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
raise ValueError("Only HTTP and HTTPS URLs are supported.")
|
|
|
|
if os.getenv("ALLOW_PRIVATE_URLS", "").lower() == "true":
|
|
return
|
|
|
|
if parsed.hostname.lower() == "localhost":
|
|
raise ValueError("Private and localhost URLs are disabled for URL ingestion.")
|
|
|
|
try:
|
|
address_info = socket.getaddrinfo(parsed.hostname, None)
|
|
except socket.gaierror as exc:
|
|
raise ValueError(f"Could not resolve URL host: {parsed.hostname}") from exc
|
|
|
|
for item in address_info:
|
|
address = ipaddress.ip_address(item[4][0])
|
|
if address.is_private or address.is_loopback or address.is_link_local or address.is_reserved:
|
|
raise ValueError("Private and localhost URLs are disabled for URL ingestion.")
|
|
|
|
|
|
def _event_text(event: Any) -> str:
|
|
if not getattr(event, "content", None) or not event.content.parts:
|
|
return ""
|
|
fragments = []
|
|
for part in event.content.parts:
|
|
text = getattr(part, "text", None)
|
|
if text:
|
|
fragments.append(text)
|
|
return "".join(fragments)
|
|
|
|
|
|
async def _run_adk_agent(question: str, retrieval: dict[str, Any]) -> str:
|
|
if not ADK_AVAILABLE:
|
|
raise HTTPException(503, SETUP_ERROR or "Google ADK is unavailable.")
|
|
|
|
def retrieve_relevant_context(query: str, top_k: int = 6) -> dict:
|
|
"""Return the exact retrieval packet already embedded for this request."""
|
|
return retrieval
|
|
|
|
request_agent = build_agent(retrieve_relevant_context)
|
|
request_runner = Runner(agent=request_agent, app_name=APP_NAME, session_service=session_service)
|
|
session = await session_service.create_session(app_name=APP_NAME, user_id=USER_ID)
|
|
content = genai_types.Content(
|
|
role="user",
|
|
parts=[genai_types.Part(text=f"Question: {question}\nUse the retrieval tool result for this exact question.")],
|
|
)
|
|
final_text = ""
|
|
async for event in request_runner.run_async(
|
|
user_id=USER_ID,
|
|
session_id=session.id,
|
|
new_message=content,
|
|
):
|
|
text = _event_text(event)
|
|
if text:
|
|
final_text = text
|
|
return final_text
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {
|
|
"status": "ok" if ADK_AVAILABLE and not SETUP_ERROR else "setup_required",
|
|
"adk": ADK_AVAILABLE,
|
|
"setup_error": SETUP_ERROR,
|
|
**await run_in_threadpool(RAG_STORE.space_tool),
|
|
}
|
|
|
|
|
|
@app.get("/space")
|
|
async def space():
|
|
return await run_in_threadpool(RAG_STORE.snapshot)
|
|
|
|
|
|
@app.post("/sources/text")
|
|
async def add_text_source(req: TextSourceRequest):
|
|
try:
|
|
source = await run_in_threadpool(RAG_STORE.add_text_source, req.title, req.text, req.modality)
|
|
snapshot = await run_in_threadpool(RAG_STORE.snapshot)
|
|
except Exception as exc:
|
|
raise HTTPException(400, str(exc)) from exc
|
|
return {"source": source.__dict__, "space": snapshot}
|
|
|
|
|
|
@app.post("/sources/url")
|
|
async def add_url_source(req: UrlSourceRequest):
|
|
try:
|
|
url = str(req.url)
|
|
await run_in_threadpool(_validate_fetch_url, url)
|
|
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
|
response = await client.get(url)
|
|
response.raise_for_status()
|
|
text = _extract_text_from_html(response.text)
|
|
title = req.title or url.replace("https://", "").replace("http://", "")[:80]
|
|
source = await run_in_threadpool(RAG_STORE.add_text_source, title, text[:12000], "url")
|
|
snapshot = await run_in_threadpool(RAG_STORE.snapshot)
|
|
except Exception as exc:
|
|
raise HTTPException(400, f"Could not ingest URL: {exc}") from exc
|
|
return {"source": source.__dict__, "space": snapshot}
|
|
|
|
|
|
@app.post("/sources/file")
|
|
async def add_file_source(
|
|
file: UploadFile = File(...),
|
|
title: str = Form(""),
|
|
notes: str = Form(""),
|
|
):
|
|
data = await file.read()
|
|
if len(data) > 120 * 1024 * 1024:
|
|
raise HTTPException(400, "File too large for this demo. Keep uploads under 120 MB.")
|
|
try:
|
|
source = await run_in_threadpool(
|
|
RAG_STORE.add_file_source,
|
|
title=title or file.filename or "Uploaded source",
|
|
data=data,
|
|
mime_type=file.content_type or "application/octet-stream",
|
|
notes=notes,
|
|
)
|
|
snapshot = await run_in_threadpool(RAG_STORE.snapshot)
|
|
except Exception as exc:
|
|
raise HTTPException(400, str(exc)) from exc
|
|
return {"source": source.__dict__, "space": snapshot}
|
|
|
|
|
|
@app.delete("/sources/{source_id}")
|
|
async def delete_source(source_id: str):
|
|
removed = await run_in_threadpool(RAG_STORE.remove_source, source_id)
|
|
if not removed:
|
|
raise HTTPException(404, "Source not found.")
|
|
return {"deleted": source_id, "space": await run_in_threadpool(RAG_STORE.snapshot)}
|
|
|
|
|
|
@app.post("/ask")
|
|
async def ask(req: AskRequest):
|
|
if not req.question.strip():
|
|
raise HTTPException(400, "Question is required.")
|
|
|
|
retrieval = await run_in_threadpool(RAG_STORE.search, req.question, req.top_k)
|
|
retrieval_payload = RAG_STORE.retrieval_payload(retrieval)
|
|
answer = await _run_adk_agent(req.question, retrieval_payload)
|
|
trace = [
|
|
{
|
|
"agent": "space_inspector",
|
|
"status": "complete",
|
|
"detail": f"{len(RAG_STORE.sources)} sources, {len(RAG_STORE.chunks)} chunks, {RAG_STORE.dimensions} dimensions",
|
|
},
|
|
{
|
|
"agent": "retrieval_tool",
|
|
"status": "complete",
|
|
"detail": f"Embedded query and retrieved {len(retrieval['matches'])} nearest sources",
|
|
},
|
|
{
|
|
"agent": "answer_synthesizer",
|
|
"status": "complete",
|
|
"detail": "Generated grounded answer; citations are shown separately",
|
|
},
|
|
]
|
|
return {
|
|
"answer": answer,
|
|
"matches": retrieval["matches"],
|
|
"query_point": retrieval["query_point"],
|
|
"trace": trace,
|
|
"space": retrieval["space"],
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
port = int(os.getenv("PORT", "8897"))
|
|
uvicorn.run(app, host="0.0.0.0", port=port)
|