| """ |
| 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" |
|
|
|
|
| |
| |
| |
|
|
| @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() |
|
|
|
|
| @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: |
| 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 [], {} |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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) |
|
|
|
|
| |
| _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] |
|
|
|
|
| |
| _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)) |
| 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)} |
|
|