vlps-demo / core.py
ORCMS's picture
Agent shows the tools it called
a059170
Raw
History Blame Contribute Delete
9.47 kB
"""
Shared logic core for the VLPS demo — model/index resources and the business
functions behind every feature, returning plain Python data (no UI framework).
Both the FastAPI site (server.py) and the Gradio app (app.py) import from here,
so there is a single source of truth.
"""
import io
import os
import sys
from collections import OrderedDict
from functools import lru_cache
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
sys.path.append(os.path.abspath("src"))
from dotenv import load_dotenv
from PIL import Image
from vlps.opensearch_config import get_client
from vlps.data import ask_lvlm, load_siglip_model, embed_text_siglip
from vlps import ocr, textvqa
load_dotenv(".env", override=True)
BASE_URL = os.getenv("VLLM_BASE_URL", "https://api.novasearch.org/gemma4/v1")
API_KEY = os.getenv("VLLM_API_KEY", "nova-vl")
MODEL = os.getenv("VLLM_MODEL", "google/gemma-4-31B-it")
USER = os.getenv("OPENSEARCH_USER", "uservl10")
INDEX_BGE = f"{USER}_coco_bge"
INDEX_MULTI = f"{USER}_coco_multimodal"
INDEX_OCR = f"{USER}_textvqa"
# ---------------------------------------------------------------------------
# Lazy, cached resources
# ---------------------------------------------------------------------------
@lru_cache(maxsize=1)
def os_client():
return get_client()
@lru_cache(maxsize=1)
def llm():
from openai import OpenAI
return OpenAI(base_url=BASE_URL, api_key=API_KEY)
@lru_cache(maxsize=1)
def bge():
from sentence_transformers import SentenceTransformer
return SentenceTransformer("BAAI/bge-base-en-v1.5")
@lru_cache(maxsize=1)
def siglip():
return load_siglip_model() # (processor, model, device)
@lru_cache(maxsize=1)
def ocr_reader_en():
return ocr.get_reader(("en",))
@lru_cache(maxsize=1)
def ocr_reader_pt():
return ocr.get_reader(("pt", "en"))
def coco_image_url(image_id) -> str:
"""Public COCO image URL.
HuggingFaceM4/COCO uses 2014 cocoids, so images live in val2014 with the
split-prefixed filename. The S3 path-style host has a valid TLS cert (the bare
images.cocodataset.org domain does not).
"""
return f"https://s3.amazonaws.com/images.cocodataset.org/val2014/COCO_val2014_{int(image_id):012d}.jpg"
@lru_cache(maxsize=512)
def fetch_image(url: str):
import requests
try:
r = requests.get(url, timeout=20)
r.raise_for_status()
return Image.open(io.BytesIO(r.content)).convert("RGB")
except Exception: # noqa: BLE001
return None
@lru_cache(maxsize=1)
def pt_data():
"""(records, {image_id: PIL}) for the student Portuguese set, or ([], {})."""
try:
return textvqa.load_pt_dataset("data/pt_textvqa")
except FileNotFoundError:
return [], {}
# ---------------------------------------------------------------------------
# Feature functions (return plain data)
# ---------------------------------------------------------------------------
def search_images(query: str, mode: str = "knn", top_k: int = 6) -> list:
"""Phase 1 retrieval. mode in {'bm25','knn','crossmodal'}. Returns list of dicts."""
client = os_client()
if mode == "bm25":
body = {"size": top_k, "query": {"match": {"caption": query}}}
index = INDEX_BGE
elif mode == "crossmodal":
proc, model, dev = siglip()
vec = embed_text_siglip(query, proc, model, dev)
body = {"size": top_k, "query": {"knn": {"image_vec": {"vector": vec, "k": top_k}}}}
index = INDEX_MULTI
else: # knn (BGE)
vec = bge().encode(query, normalize_embeddings=True).tolist()
body = {"size": top_k, "query": {"knn": {"caption_vec": {"vector": vec, "k": top_k}}}}
index = INDEX_BGE
resp = client.search(index=index, body=body)
out = []
for h in resp["hits"]["hits"]:
s = h["_source"]
out.append({
"image_id": s["image_id"],
"caption": s.get("caption", ""),
"score": round(h["_score"], 4),
"image_url": coco_image_url(s["image_id"]),
})
return out
def visual_qa(image: Image.Image, question: str, system_prompt: str = "") -> str:
return ask_lvlm(llm(), image, question, model=MODEL, system_prompt=system_prompt or None)
def ocr_qa(image: Image.Image, question: str, lang: str = "en") -> dict:
reader = ocr_reader_pt() if lang == "pt" else ocr_reader_en()
res = ocr.ocr_image(image, reader=reader)
return {
"ocr_text": res["text"],
"answer_ocr": textvqa.answer_with_ocr(llm(), res["text"], question, model=MODEL),
"answer_vision": textvqa.answer_with_vision(llm(), image, question, model=MODEL),
}
def _docs_to_items(sources: list, dataset: str) -> list:
items = []
for s in sources:
iid = s["image_id"]
url = s.get("image_url") or ""
if dataset == "pt" and not url:
url = f"/pt-image/{iid}"
items.append({
"image_id": iid,
"image_url": url,
"question": s.get("question", ""),
"answers": s.get("answers", []),
"ocr_text": s.get("ocr_text", ""),
"language": s.get("language", ""),
"dataset": s.get("dataset", dataset),
})
return items
def dataset_browse(dataset: str = "textvqa", limit: int = 12) -> list:
resp = os_client().search(
index=INDEX_OCR,
body={"size": int(limit), "query": {"term": {"dataset": dataset}}},
)
return _docs_to_items([h["_source"] for h in resp["hits"]["hits"]], dataset)
def dataset_search(dataset: str, query: str, k: int = 6) -> list:
hits = textvqa.retrieve_by_ocr_text(os_client(), query, INDEX_OCR, top_k=int(k), dataset=dataset)
return _docs_to_items(hits, dataset)
# What the agent does during a run (so the UI can show it). Reset per request.
_agent_seen: list = []
_agent_steps: list = []
def _note_image(image_id, caption=""):
_agent_seen.append({"image_id": str(image_id), "caption": caption,
"image_url": coco_image_url(image_id)})
def _note_step(tool: str, **args):
_agent_steps.append({"tool": tool, "args": args})
@lru_cache(maxsize=1)
def _agent_model():
from smolagents import OpenAIServerModel
return OpenAIServerModel(model_id=MODEL, api_base=BASE_URL, api_key=API_KEY)
@lru_cache(maxsize=1)
def _agent_tools():
from smolagents import tool
from vlps.data import retrieve_by_text_knn
@tool
def retrieve_images_by_text(query: str, top_k: int = 3) -> list:
"""
Find COCO images matching a natural-language description (BGE semantic search).
Returns a list of {image_id, caption} dicts.
Args:
query: Natural-language description of the images to find.
top_k: Number of images to return.
"""
_note_step("retrieve_images_by_text", query=query, top_k=top_k)
hits = retrieve_by_text_knn(os_client(), query, INDEX_BGE, bge(), top_k)
out = [{"image_id": h["image_id"], "caption": h.get("caption", "")} for h in hits]
for h in out:
_note_image(h["image_id"], h["caption"])
return out
@tool
def answer_about_image(image_id: str, question: str) -> str:
"""
Answer a visual question about a specific COCO image with the vision model.
Call retrieve_images_by_text first to obtain a relevant image_id.
Args:
image_id: COCO image id to inspect.
question: The visual question to answer.
"""
_note_step("answer_about_image", image_id=image_id)
img = fetch_image(coco_image_url(image_id))
if img is None:
return f"Image {image_id} not found."
_note_image(image_id)
return ask_lvlm(llm(), img, question, model=MODEL,
system_prompt="You are an expert visual analyst. Answer concisely.")
return [retrieve_images_by_text, answer_about_image]
# One agent per browser session, so conversations stay isolated and remember context.
_AGENTS: "OrderedDict[str, object]" = OrderedDict()
_MAX_SESSIONS = 64
def _get_agent(session_id: str):
"""Return (agent, is_new) for a session, evicting the oldest beyond the cap."""
from smolagents import ToolCallingAgent
if session_id in _AGENTS:
_AGENTS.move_to_end(session_id)
return _AGENTS[session_id], False
ag = ToolCallingAgent(tools=_agent_tools(), model=_agent_model(), max_steps=6)
_AGENTS[session_id] = ag
if len(_AGENTS) > _MAX_SESSIONS:
_AGENTS.popitem(last=False)
return ag, True
def reset_agent(session_id: str):
"""Drop a session's conversation memory."""
_AGENTS.pop(session_id, None)
def agent_answer(message: str, session_id: str = "") -> dict:
"""
Run the agent and return its answer plus any images it looked at (deduped).
With a session_id the conversation keeps context across turns; the first turn
starts fresh, later turns retain memory.
"""
_agent_seen.clear()
_agent_steps.clear()
if not session_id:
session_id = "anon"
ag, is_new = _get_agent(session_id)
answer = str(ag.run(message, reset=is_new)) # first turn fresh, later turns keep memory
seen, images = set(), []
for it in _agent_seen:
if it["image_id"] not in seen:
seen.add(it["image_id"])
images.append(it)
return {"answer": answer, "images": images, "tools": list(_agent_steps)}