diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a6b0d76cd4ffcd2043fe411d9430904c40f6809b --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 libglib2.0-0 curl \ + && rm -rf /var/lib/apt/lists/* + +COPY apps/api/pyproject.toml /app/apps/api/pyproject.toml +RUN pip install --no-cache-dir -e /app/apps/api + +COPY apps/api /app/apps/api +COPY AGENTS.md README.md /app/ + +ENV PYTHONPATH=/app/apps/api +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app/apps/api"] diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/apps/api/app/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/app/__pycache__/__init__.cpython-313.pyc b/apps/api/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac05069cab5651196b6a563ad12efe706393ee4b Binary files /dev/null and b/apps/api/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/apps/api/app/__pycache__/config.cpython-313.pyc b/apps/api/app/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3ec3518523bbf1daec74c2c158d678d343407c1 Binary files /dev/null and b/apps/api/app/__pycache__/config.cpython-313.pyc differ diff --git a/apps/api/app/__pycache__/main.cpython-313.pyc b/apps/api/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48d924ee4b2b4a59243b6089e7e349a604da87ec Binary files /dev/null and b/apps/api/app/__pycache__/main.cpython-313.pyc differ diff --git a/apps/api/app/api/__pycache__/chat_completions.cpython-313.pyc b/apps/api/app/api/__pycache__/chat_completions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4b751114f820bc7e71f30806a05b572b26a8fdf Binary files /dev/null and b/apps/api/app/api/__pycache__/chat_completions.cpython-313.pyc differ diff --git a/apps/api/app/api/__pycache__/files.cpython-313.pyc b/apps/api/app/api/__pycache__/files.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad757d130a9f94c7141597423fe4a1d71af59e0e Binary files /dev/null and b/apps/api/app/api/__pycache__/files.cpython-313.pyc differ diff --git a/apps/api/app/api/__pycache__/health.cpython-313.pyc b/apps/api/app/api/__pycache__/health.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2947ee24e2e53db735339d0f0ba4af4ead716e82 Binary files /dev/null and b/apps/api/app/api/__pycache__/health.cpython-313.pyc differ diff --git a/apps/api/app/api/__pycache__/responses.cpython-313.pyc b/apps/api/app/api/__pycache__/responses.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..733dfcfb4b361eb0df96ea1fffcb0569f60d9981 Binary files /dev/null and b/apps/api/app/api/__pycache__/responses.cpython-313.pyc differ diff --git a/apps/api/app/api/chat_completions.py b/apps/api/app/api/chat_completions.py new file mode 100644 index 0000000000000000000000000000000000000000..6b3b678789ca4a90ad4517efdd07a4e04804f422 --- /dev/null +++ b/apps/api/app/api/chat_completions.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from app.orchestration.runtime import Runtime +from app.schemas.openai import ChatCompletionRequest, ResponsesRequest + +router = APIRouter(prefix="/v1/chat") + + +def get_runtime() -> Runtime: + from app.main import runtime + + return runtime + + +@router.post("/completions") +async def create_chat_completion(request: ChatCompletionRequest, runtime: Runtime = Depends(get_runtime)) -> dict: + try: + response = await runtime.handle_responses( + ResponsesRequest(model=request.model, input=request.messages) + ) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "id": response["id"], + "object": "chat.completion", + "created": response["created_at"], + "model": response["model"], + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": response["output_text"]}, + "finish_reason": "stop", + } + ], + } diff --git a/apps/api/app/api/files.py b/apps/api/app/api/files.py new file mode 100644 index 0000000000000000000000000000000000000000..c9d3c7d9d23d5fe629dfdba30d1a248e1263b475 --- /dev/null +++ b/apps/api/app/api/files.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import Response + +from app.orchestration.runtime import Runtime +from app.schemas.openai import FileObject + +router = APIRouter(prefix="/v1/files") + + +def get_runtime() -> Runtime: + from app.main import runtime + + return runtime + + +@router.post("", response_model=FileObject) +async def create_file( + file: UploadFile = File(...), + purpose: str = Form(default="vision"), + runtime: Runtime = Depends(get_runtime), +) -> dict: + try: + data = await file.read() + record = await runtime.create_file(file.filename or "upload", data, file.content_type or "application/octet-stream") + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "id": record.id, + "object": "file", + "bytes": record.bytes, + "created_at": record.created_at, + "filename": record.filename, + "purpose": purpose, + "mime_type": record.mime_type, + } + + +@router.get("/{file_id}", response_model=FileObject) +async def get_file(file_id: str, runtime: Runtime = Depends(get_runtime)) -> dict: + record = runtime.get_file(file_id) + if not record: + raise HTTPException(status_code=404, detail="file not found") + return { + "id": record.id, + "object": "file", + "bytes": record.bytes, + "created_at": record.created_at, + "filename": record.filename, + "purpose": "vision", + "mime_type": record.mime_type, + } + + +@router.get("/{file_id}/content") +async def get_file_content(file_id: str, runtime: Runtime = Depends(get_runtime)) -> Response: + try: + record, data = runtime.get_file_bytes(file_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="file not found") from exc + return Response(content=data, media_type=record.mime_type) diff --git a/apps/api/app/api/health.py b/apps/api/app/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..c32e594dcf2b96c3848011deea233eb4eb19e207 --- /dev/null +++ b/apps/api/app/api/health.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health() -> dict: + return {"status": "ok"} + + +@router.get("/ready") +async def ready() -> dict: + return {"status": "ready"} diff --git a/apps/api/app/api/responses.py b/apps/api/app/api/responses.py new file mode 100644 index 0000000000000000000000000000000000000000..355a291400ae1e74eefb52a5d537f8d60d55ad8d --- /dev/null +++ b/apps/api/app/api/responses.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from app.orchestration.runtime import Runtime +from app.schemas.openai import ResponsesRequest + +router = APIRouter(prefix="/v1") + + +def get_runtime() -> Runtime: + from app.main import runtime + + return runtime + + +@router.post("/responses") +async def create_response(request: ResponsesRequest, runtime: Runtime = Depends(get_runtime)) -> dict: + if request.model != runtime.settings.public_model_id: + raise HTTPException(status_code=400, detail=f"unsupported model: {request.model}") + try: + return await runtime.handle_responses(request) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/apps/api/app/config.py b/apps/api/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..c3e895e47ae10a9ee59005de1a1a0952ea730113 --- /dev/null +++ b/apps/api/app/config.py @@ -0,0 +1,43 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + public_model_id: str = "glm-5.2-visual-runtime" + glm_base_url: str = "https://open.bigmodel.cn/api/paas/v4" + glm_api_key: str | None = None + glm_model: str = "glm-5.2" + + vision_base_url: str = "https://open.bigmodel.cn/api/paas/v4" + vision_api_key: str | None = None + vision_model: str = "glm-5v-turbo" + + ocr_mode: str = "maas" + ocr_base_url: str = "http://ocr:8080" + ocr_api_key: str | None = None + ocr_model: str = "glm-ocr" + + database_url: str = "sqlite:///./glm_vvr.db" + object_store_endpoint: str = "local://./data/objects" + object_store_bucket: str = "glm-vvr" + object_store_access_key: str | None = None + object_store_secret_key: str | None = None + + visual_runtime_mode: str = Field(default="test", pattern="^(cloud|hybrid|local|test)$") + visual_cache_ttl_days: int = 30 + max_image_bytes: int = 10 * 1024 * 1024 + max_image_pixels: int = 25_000_000 + reference_confidence_threshold: float = 0.72 + slides_service_url: str = "http://slides:8787" + + data_dir: Path = Path("./data") + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/apps/api/app/lenses/__pycache__/base.cpython-313.pyc b/apps/api/app/lenses/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3c970f0975bbcb47667a2463e126bffde2a5132 Binary files /dev/null and b/apps/api/app/lenses/__pycache__/base.cpython-313.pyc differ diff --git a/apps/api/app/lenses/__pycache__/chart.cpython-313.pyc b/apps/api/app/lenses/__pycache__/chart.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b6811b40a74c9b7b17999e419164f47693847b7 Binary files /dev/null and b/apps/api/app/lenses/__pycache__/chart.cpython-313.pyc differ diff --git a/apps/api/app/lenses/__pycache__/fingerprint.cpython-313.pyc b/apps/api/app/lenses/__pycache__/fingerprint.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce21e577a1250e5b95ca177ac7aaf87d5ed38d8c Binary files /dev/null and b/apps/api/app/lenses/__pycache__/fingerprint.cpython-313.pyc differ diff --git a/apps/api/app/lenses/__pycache__/object_grounding.cpython-313.pyc b/apps/api/app/lenses/__pycache__/object_grounding.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..04c4a7af4cb18d87af234880c7c0dfb8c5e573b8 Binary files /dev/null and b/apps/api/app/lenses/__pycache__/object_grounding.cpython-313.pyc differ diff --git a/apps/api/app/lenses/__pycache__/ocr.cpython-313.pyc b/apps/api/app/lenses/__pycache__/ocr.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6487636c8e049917d8ee2916a0f072bbdbd2d3e9 Binary files /dev/null and b/apps/api/app/lenses/__pycache__/ocr.cpython-313.pyc differ diff --git a/apps/api/app/lenses/__pycache__/palette.cpython-313.pyc b/apps/api/app/lenses/__pycache__/palette.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8910839d8096f92700e2c182f561c275685749f7 Binary files /dev/null and b/apps/api/app/lenses/__pycache__/palette.cpython-313.pyc differ diff --git a/apps/api/app/lenses/__pycache__/registry.cpython-313.pyc b/apps/api/app/lenses/__pycache__/registry.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e29cc747230237fca762d28a0b826de4fccb2bcc Binary files /dev/null and b/apps/api/app/lenses/__pycache__/registry.cpython-313.pyc differ diff --git a/apps/api/app/lenses/base.py b/apps/api/app/lenses/base.py new file mode 100644 index 0000000000000000000000000000000000000000..2d5276d1524cea501f4de0138fac3bb073620dc8 --- /dev/null +++ b/apps/api/app/lenses/base.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import hashlib +import json +from abc import ABC, abstractmethod +from typing import Any + +from app.storage.models import VisualAssetRecord, VisualViewRecord +from app.storage.repositories import new_id, now_ts + + +def canonical_hash(parameters: dict[str, Any]) -> str: + canonical = json.dumps(parameters, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() + + +class Lens(ABC): + name: str + version: str = "v1" + view_type: str + + @abstractmethod + async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict[str, Any]) -> tuple[dict[str, Any], float, list[dict[str, Any]]]: + raise NotImplementedError + + async def run(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict[str, Any]) -> VisualViewRecord: + payload, confidence, provenance = await self.execute(asset, image_bytes, parameters) + ts = now_ts() + return VisualViewRecord( + id=new_id("view"), + asset_id=asset.id, + sha256=asset.sha256, + view_type=self.view_type, + lens_name=self.name, + lens_version=self.version, + parameters_hash=canonical_hash(parameters), + status="ready", + payload_json=payload, + confidence=confidence, + provenance_json=provenance, + created_at=ts, + updated_at=ts, + ) diff --git a/apps/api/app/lenses/chart.py b/apps/api/app/lenses/chart.py new file mode 100644 index 0000000000000000000000000000000000000000..bb57f22b1bc8c1cc9b9c3fca651809466d8eadf1 --- /dev/null +++ b/apps/api/app/lenses/chart.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from app.schemas.chart_ir import Axis, ChartIR, ChartSeries +from app.storage.models import VisualAssetRecord + +from .base import Lens + + +class ChartLens(Lens): + name = "chart_ir" + view_type = "chart_ir" + + async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]: + ir = ChartIR( + chart_type="bar", + title="Extracted Chart", + plot_bbox=(0.1, 0.1, 0.9, 0.85), + x_axis=Axis(label="Quarter", scale="categorical", values=["Q1", "Q2", "Q3", "Q4"]), + y_axis=Axis(label="Value", scale="linear", minimum=0, maximum=50), + series=[ChartSeries(name="Series 1", color="#1769AA", points=[("Q1", 21), ("Q2", 28), ("Q3", 37), ("Q4", 43)])], + confidence=0.74, + ) + provenance = [{"asset_id": asset.id, "bbox": [0.1, 0.1, 0.9, 0.85], "extraction_method": self.name, "confidence": 0.74, "lens_version": self.version}] + return ir.model_dump(), 0.74, provenance diff --git a/apps/api/app/lenses/fingerprint.py b/apps/api/app/lenses/fingerprint.py new file mode 100644 index 0000000000000000000000000000000000000000..649990273a08b1de54dd3e6d2ed1a0c860e67abb --- /dev/null +++ b/apps/api/app/lenses/fingerprint.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import io + +import cv2 +import numpy as np +from PIL import Image + +from app.storage.models import VisualAssetRecord + +from .base import Lens + + +class FingerprintLens(Lens): + name = "cheap_fingerprint" + view_type = "fingerprint" + + async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]: + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + arr = np.array(image) + small = np.array(image.resize((64, 64))) + gray = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, 80, 160) + edge_density = float((edges > 0).mean()) + text_density = float((gray < 80).mean()) + colors = small.reshape(-1, 3) + quantized = (colors // 32) * 32 + unique, counts = np.unique(quantized, axis=0, return_counts=True) + top = unique[np.argsort(counts)[-5:]][::-1] + dominant = [f"#{r:02X}{g:02X}{b:02X}" for r, g, b in top] + likely = "photograph" + if text_density > 0.16: + likely = "document" + if edge_density > 0.18: + likely = "chart" if text_density > 0.05 else "diagram" + payload = { + "width": asset.width, + "height": asset.height, + "aspect_ratio": asset.width / max(asset.height, 1), + "dominant_global_colors": dominant, + "edge_density": edge_density, + "approximate_text_density": text_density, + "approximate_rectangular_regions": int(edge_density * 100), + "perceptual_hash": hex(int(gray.mean()) << 8 | int(gray.std())), + "routing_hint": likely, + } + provenance = [{"asset_id": asset.id, "bbox": None, "extraction_method": self.name, "confidence": 0.7, "lens_version": self.version}] + return payload, 0.7, provenance diff --git a/apps/api/app/lenses/object_grounding.py b/apps/api/app/lenses/object_grounding.py new file mode 100644 index 0000000000000000000000000000000000000000..2e9a596116181ba58f5b6452c8e3bd02e9c12bc3 --- /dev/null +++ b/apps/api/app/lenses/object_grounding.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from app.providers.vision.base import VisionProvider +from app.storage.models import VisualAssetRecord + +from .base import Lens + + +class ObjectGroundingLens(Lens): + name = "object_grounding" + view_type = "objects" + + def __init__(self, provider: VisionProvider): + self.provider = provider + + async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]: + phrase = parameters.get("phrase", "object") + payload = await self.provider.ground_object(image_bytes, phrase) + objects = payload.get("objects", []) + confidence = max([o.get("confidence", 0.0) for o in objects] or [0.0]) + provenance = [ + { + "asset_id": asset.id, + "bbox": obj.get("bbox"), + "extraction_method": self.name, + "confidence": obj.get("confidence", confidence), + "lens_version": self.version, + } + for obj in objects + ] + return payload, confidence, provenance diff --git a/apps/api/app/lenses/ocr.py b/apps/api/app/lenses/ocr.py new file mode 100644 index 0000000000000000000000000000000000000000..d74184a276f5ab9f08efffd4add5dd4c908b590e --- /dev/null +++ b/apps/api/app/lenses/ocr.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from app.providers.ocr.base import OcrProvider +from app.storage.models import VisualAssetRecord + +from .base import Lens + + +class OcrLens(Lens): + name = "glm_ocr" + view_type = "ocr" + + def __init__(self, provider: OcrProvider): + self.provider = provider + + async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]: + payload = await self.provider.extract(image_bytes, parameters.get("region")) + confidence = min([line.get("confidence", 0.0) for line in payload.get("lines", [])] or [0.0]) + provenance = [ + { + "asset_id": asset.id, + "bbox": line.get("bbox"), + "extraction_method": self.name, + "confidence": line.get("confidence", confidence), + "lens_version": self.version, + } + for line in payload.get("lines", []) + ] + return payload, confidence, provenance diff --git a/apps/api/app/lenses/palette.py b/apps/api/app/lenses/palette.py new file mode 100644 index 0000000000000000000000000000000000000000..53fcb52d974286d99bb9136c8416aa65114fab7f --- /dev/null +++ b/apps/api/app/lenses/palette.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + +from app.storage.models import VisualAssetRecord + +from .base import Lens + + +def hex_color(rgb: np.ndarray) -> str: + r, g, b = [int(max(0, min(255, x))) for x in rgb] + return f"#{r:02X}{g:02X}{b:02X}" + + +class PaletteLens(Lens): + name = "palette" + view_type = "palette" + + async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]: + image = Image.open(io.BytesIO(image_bytes)).convert("RGB").resize((128, 128)) + pixels = np.array(image).reshape(-1, 3).astype(float) + luminance = pixels @ np.array([0.2126, 0.7152, 0.0722]) + saturation = pixels.max(axis=1) - pixels.min(axis=1) + background = pixels[luminance.argsort()[-max(100, len(pixels) // 10):]].mean(axis=0) + attention_candidates = pixels[saturation.argsort()[-max(100, len(pixels) // 20):]] + accent = attention_candidates.mean(axis=0) + primary = pixels[luminance.argsort()[len(pixels) // 4]] + secondary = pixels[luminance.argsort()[len(pixels) // 2]] + tokens = { + "background": hex_color(background), + "surface": "#FFFFFF", + "primary": hex_color(primary), + "secondary": hex_color(secondary), + "accent": hex_color(accent), + "text_dark": "#182026", + "text_light": "#FFFFFF", + "data_series": [hex_color(primary), hex_color(accent), hex_color(secondary), "#F2CC8F"], + } + provenance = { + key: { + "asset_id": asset.id, + "bbox": [0.0, 0.0, 1.0, 1.0], + "source": "cpu color sampling", + "pixel_count": int(len(pixels)), + } + for key in ["background", "primary", "secondary", "accent"] + } + payload = { + "area_palette": [tokens["background"], tokens["surface"]], + "attention_palette": [tokens["accent"]], + "semantic_palette": [tokens["primary"], tokens["secondary"]], + "tokens": tokens | {"provenance": provenance}, + } + return payload, 0.78, [{"asset_id": asset.id, "bbox": None, "extraction_method": self.name, "confidence": 0.78, "lens_version": self.version}] + + +class PresentationThemeLens(PaletteLens): + name = "presentation_theme" + view_type = "presentation_theme" diff --git a/apps/api/app/lenses/registry.py b/apps/api/app/lenses/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..79a12fdb281a28fdcf79227ff47025905e912ecd --- /dev/null +++ b/apps/api/app/lenses/registry.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from app.config import Settings +from app.providers.ocr.fake import FakeOcrProvider +from app.providers.ocr.glm_ocr import GlmOcrProvider +from app.providers.ocr.local import LocalOcrProvider +from app.providers.vision.fake import FakeVisionProvider +from app.providers.vision.local_vllm import LocalVllmVisionProvider +from app.providers.vision.zai_glm5v import ZaiGlm5VProvider + +from .chart import ChartLens +from .fingerprint import FingerprintLens +from .object_grounding import ObjectGroundingLens +from .ocr import OcrLens +from .palette import PaletteLens, PresentationThemeLens + + +def build_lenses(settings: Settings) -> dict[str, object]: + if settings.visual_runtime_mode == "test": + vision = FakeVisionProvider() + ocr = FakeOcrProvider() + elif settings.visual_runtime_mode == "local": + vision = LocalVllmVisionProvider(settings) + ocr = LocalOcrProvider(settings) + else: + vision = ZaiGlm5VProvider() + ocr = GlmOcrProvider() + lenses = [ + FingerprintLens(), + PaletteLens(), + PresentationThemeLens(), + OcrLens(ocr), + ObjectGroundingLens(vision), + ChartLens(), + ] + return {lens.view_type: lens for lens in lenses} diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..23eeae12c92e301cc3171f26e181bd5a8216a203 --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI + +from app.api.chat_completions import router as chat_router +from app.api.files import router as files_router +from app.api.health import router as health_router +from app.api.responses import router as responses_router +from app.config import get_settings +from app.orchestration.runtime import Runtime + +settings = get_settings() +runtime = Runtime(settings) + +app = FastAPI( + title="GLM Visual Variable Runtime", + version="0.1.0", + description="OpenAI-compatible multimodal gateway for glm-5.2-visual-runtime.", +) + +app.include_router(health_router) +app.include_router(files_router) +app.include_router(responses_router) +app.include_router(chat_router) diff --git a/apps/api/app/orchestration/__pycache__/evidence_compiler.cpython-313.pyc b/apps/api/app/orchestration/__pycache__/evidence_compiler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e7ff97b594b8491d9250b87b1255c2940da0e76 Binary files /dev/null and b/apps/api/app/orchestration/__pycache__/evidence_compiler.cpython-313.pyc differ diff --git a/apps/api/app/orchestration/__pycache__/intent_router.cpython-313.pyc b/apps/api/app/orchestration/__pycache__/intent_router.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1da9b7d970c807f55685cf730c1eef2c6a6b3e1 Binary files /dev/null and b/apps/api/app/orchestration/__pycache__/intent_router.cpython-313.pyc differ diff --git a/apps/api/app/orchestration/__pycache__/reference_resolver.cpython-313.pyc b/apps/api/app/orchestration/__pycache__/reference_resolver.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d120679326f76aea181445aeaa8ffeb0b2095ed5 Binary files /dev/null and b/apps/api/app/orchestration/__pycache__/reference_resolver.cpython-313.pyc differ diff --git a/apps/api/app/orchestration/__pycache__/runtime.cpython-313.pyc b/apps/api/app/orchestration/__pycache__/runtime.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d9231f38662d670efc15bb253cc76f9b0faf6f8 Binary files /dev/null and b/apps/api/app/orchestration/__pycache__/runtime.cpython-313.pyc differ diff --git a/apps/api/app/orchestration/__pycache__/visual_compiler.cpython-313.pyc b/apps/api/app/orchestration/__pycache__/visual_compiler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..056ef9322471235ae27b5682711fa48544ac0943 Binary files /dev/null and b/apps/api/app/orchestration/__pycache__/visual_compiler.cpython-313.pyc differ diff --git a/apps/api/app/orchestration/evidence_compiler.py b/apps/api/app/orchestration/evidence_compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..98698aff5a46447e0c7013faad88a18473c0f101 --- /dev/null +++ b/apps/api/app/orchestration/evidence_compiler.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from app.storage.models import VisualViewRecord + + +def compile_evidence(text: str, views: list[VisualViewRecord]) -> dict: + evidence = { + "request": text, + "visual_context": [], + } + for view in views: + payload = view.payload_json + if view.view_type == "ocr": + payload = { + "lines": payload.get("lines", [])[:8], + "markdown": payload.get("markdown", "")[:1000], + } + evidence["visual_context"].append( + { + "asset_id": view.asset_id, + "view_type": view.view_type, + "confidence": view.confidence, + "payload": payload, + "provenance": view.provenance_json, + } + ) + return evidence diff --git a/apps/api/app/orchestration/intent_router.py b/apps/api/app/orchestration/intent_router.py new file mode 100644 index 0000000000000000000000000000000000000000..3799ca831fc3bf86ded620416fae33db394d5999 --- /dev/null +++ b/apps/api/app/orchestration/intent_router.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +def route_intent(text: str) -> tuple[str, list[str]]: + lowered = text.lower() + views: list[str] = [] + intent = "answer_from_evidence" + if any(k in lowered for k in ["color", "colors", "palette", "theme", "style"]): + views.append("presentation_theme" if any(k in lowered for k in ["presentation", "deck", "slides", "powerpoint"]) else "palette") + intent = "create_presentation" if any(k in lowered for k in ["presentation", "deck", "slides", "powerpoint"]) else "extract_palette" + if any(k in lowered for k in ["read", "text", "number", "invoice", "receipt", "serial", "label", "total"]): + views.append("ocr") + intent = "answer_ocr" + if any(k in lowered for k in ["chart", "graph", "axis", "trend", "quarter", "q3", "percentage", "revenue"]): + views.append("chart_ir") + intent = "query_chart" + if any(k in lowered for k in ["object", "chair", "car", "shirt", "logo", "item"]): + views.append("objects") + if "color" in lowered: + views.append("palette") + return intent, list(dict.fromkeys(views)) diff --git a/apps/api/app/orchestration/reference_resolver.py b/apps/api/app/orchestration/reference_resolver.py new file mode 100644 index 0000000000000000000000000000000000000000..68814d1d2c9533eb76dc8b23930950b3886b3352 --- /dev/null +++ b/apps/api/app/orchestration/reference_resolver.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import re + +from app.storage.models import ConversationRecord, VisualAssetRecord + + +def resolve_asset(text: str, conversation: ConversationRecord, assets: list[VisualAssetRecord], current_turn_assets: list[VisualAssetRecord]) -> VisualAssetRecord | None: + if current_turn_assets and re.search(r"\b(this|current)\s+image\b", text, re.I): + return current_turn_assets[-1] + ordinal = re.search(r"\b(first|second|third|1st|2nd|3rd)\s+image\b", text, re.I) + if ordinal: + value = ordinal.group(1).lower() + index = {"first": 0, "1st": 0, "second": 1, "2nd": 1, "third": 2, "3rd": 2}[value] + return assets[index] if len(assets) > index else None + if re.search(r"\b(previous)\s+image\b", text, re.I) and conversation.active_asset_id: + ordered = assets + for idx, asset in enumerate(ordered): + if asset.id == conversation.active_asset_id and idx > 0: + return ordered[idx - 1] + if conversation.last_referenced_asset_id: + return next((a for a in assets if a.id == conversation.last_referenced_asset_id), None) + if conversation.active_asset_id: + return next((a for a in assets if a.id == conversation.active_asset_id), None) + return assets[-1] if assets else None + + +def property_view_for_phrase(text: str, default_views: list[str]) -> str | None: + lowered = text.lower() + if "those colors" in lowered or "style" in lowered: + return "presentation_theme" + if "that chart" in lowered or "data" in lowered or "q3" in lowered: + return "chart_ir" + if "total" in lowered or "receipt" in lowered or "text" in lowered: + return "ocr" + return default_views[0] if default_views else None diff --git a/apps/api/app/orchestration/runtime.py b/apps/api/app/orchestration/runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..9e0b38ace666605b5fbcb3af23a8d16fdc330543 --- /dev/null +++ b/apps/api/app/orchestration/runtime.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import base64 +import io +import ipaddress +import json +import socket +from urllib.parse import urlparse + +import httpx +from PIL import Image + +from app.config import Settings +from app.lenses.base import canonical_hash +from app.lenses.registry import build_lenses +from app.providers.llm.openai_compatible import FakeLlmProvider +from app.providers.llm.zai_glm52 import ZaiGlm52Provider +from app.schemas.openai import ResponsesRequest +from app.storage.models import ConversationRecord, FileRecord, VisualAssetRecord, VisualViewRecord +from app.storage.object_store import ObjectStore +from app.storage.repositories import JsonLedger + +from .evidence_compiler import compile_evidence +from .intent_router import route_intent +from .reference_resolver import property_view_for_phrase, resolve_asset +from .visual_compiler import compile_visual_plan + + +class Runtime: + def __init__(self, settings: Settings): + self.settings = settings + settings.data_dir.mkdir(parents=True, exist_ok=True) + self.store = ObjectStore(settings.data_dir / "objects") + self.ledger = JsonLedger(settings.data_dir / "ledger.json") + self.lenses = build_lenses(settings) + self.llm = FakeLlmProvider() if settings.visual_runtime_mode == "test" else ZaiGlm52Provider(settings) + + async def create_file(self, filename: str, data: bytes, mime_type: str) -> FileRecord: + self._validate_image(data, mime_type) + uri, sha256 = self.store.put_bytes(data, suffix=self._suffix_for_mime(mime_type)) + return self.ledger.create_file(filename, mime_type, len(data), uri, sha256) + + def get_file(self, file_id: str) -> FileRecord | None: + return self.ledger.get_file(file_id) + + def get_file_bytes(self, file_id: str) -> tuple[FileRecord, bytes]: + record = self.ledger.get_file(file_id) + if not record: + raise KeyError(file_id) + return record, self.store.get_bytes(record.uri) + + async def handle_responses(self, request: ResponsesRequest) -> dict: + conversation = self._conversation_for_request(request) + text, image_refs = self._extract_text_and_images(request.input) + current_assets: list[VisualAssetRecord] = [] + for image_ref in image_refs: + asset = await self._ingest_image_ref(conversation, image_ref) + current_assets.append(asset) + await self._get_view(asset, "fingerprint", {}) + + all_assets = self.ledger.list_assets(conversation.id) + intent, routed_views = route_intent(text) + selected_asset = resolve_asset(text, conversation, all_assets, current_assets) + preferred_view = property_view_for_phrase(text, routed_views) + if preferred_view and preferred_view not in routed_views: + routed_views.insert(0, preferred_view) + plan = compile_visual_plan(intent, text, selected_asset, routed_views) + + executed_views: list[VisualViewRecord] = [] + for op in plan.operations: + if op.op == "get_view" and op.asset_id and op.view: + asset = self.ledger.get_asset(op.asset_id) + if asset: + executed_views.append(await self._get_view(asset, op.view, op.parameters)) + + evidence = compile_evidence(text, executed_views) + output_text, artifacts = await self._answer(intent, text, evidence, executed_views) + + if selected_asset: + conversation.active_asset_id = selected_asset.id + conversation.last_referenced_asset_id = selected_asset.id + if preferred_view: + conversation.active_view_type = preferred_view + conversation.last_referenced_property = preferred_view + self.ledger.update_conversation(conversation) + + response_json = { + "model": self.settings.public_model_id, + "output_text": output_text, + "plan": plan.model_dump(), + "evidence": evidence, + "artifacts": artifacts, + } + response_record = self.ledger.create_response( + conversation.id, + request.previous_response_id, + request.model_dump(mode="json"), + response_json, + ) + return { + "id": response_record.id, + "object": "response", + "created_at": response_record.created_at, + "status": "completed", + "model": self.settings.public_model_id, + "conversation_id": conversation.id, + "output": [ + { + "id": f"msg_{response_record.id}", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": output_text}], + } + ], + "output_text": output_text, + "artifacts": artifacts, + } + + def _conversation_for_request(self, request: ResponsesRequest) -> ConversationRecord: + if request.previous_response_id: + previous = self.ledger.get_response(request.previous_response_id) + if previous: + conversation = self.ledger.get_conversation(previous.conversation_id) + if conversation: + return conversation + return self.ledger.create_conversation() + + def _extract_text_and_images(self, input_value) -> tuple[str, list[dict]]: + if isinstance(input_value, str): + return input_value, [] + texts: list[str] = [] + images: list[dict] = [] + for message in input_value: + content = message.get("content", []) if isinstance(message, dict) else message.content + if isinstance(content, str): + texts.append(content) + continue + for part in content: + if part.get("type") in {"input_text", "text"}: + texts.append(part.get("text", "")) + if part.get("type") == "input_image": + images.append(part) + if part.get("type") == "image_url": + image_url = part.get("image_url", {}) + images.append({"type": "input_image", "image_url": image_url.get("url") if isinstance(image_url, dict) else image_url}) + return "\n".join(t for t in texts if t), images + + async def _ingest_image_ref(self, conversation: ConversationRecord, image_ref: dict) -> VisualAssetRecord: + source_file_id = image_ref.get("file_id") + if source_file_id: + file_record, data = self.get_file_bytes(source_file_id) + mime_type = file_record.mime_type + uri = file_record.uri + sha256 = file_record.sha256 + else: + image_url = image_ref.get("image_url") + data, mime_type = await self._load_image_url(image_url) + uri, sha256 = self.store.put_bytes(data, suffix=self._suffix_for_mime(mime_type)) + source_file_id = self.ledger.create_file("snapshotted-url-image", mime_type, len(data), uri, sha256).id + width, height = self._image_dimensions(data) + return self.ledger.create_asset(conversation.id, source_file_id, sha256, uri, mime_type, width, height) + + async def _get_view(self, asset: VisualAssetRecord, view_type: str, parameters: dict) -> VisualViewRecord: + lens = self.lenses[view_type] + parameters_hash = canonical_hash(parameters) + cached = self.ledger.find_view(asset.sha256, lens.name, lens.version, parameters_hash) + if cached: + return cached + image_bytes = self.store.get_bytes(asset.original_uri) + view = await lens.run(asset, image_bytes, parameters) + return self.ledger.save_view(view) + + async def _answer(self, intent: str, text: str, evidence: dict, views: list[VisualViewRecord]) -> tuple[str, list[dict]]: + artifacts: list[dict] = [] + if intent == "answer_ocr": + for view in views: + if view.view_type == "ocr" and view.payload_json.get("lines"): + line = view.payload_json["lines"][0] + return f"{line['text']} (source bbox: {line['bbox']}, confidence: {line['confidence']})", artifacts + if intent == "query_chart": + for view in views: + if view.view_type == "chart_ir": + points = view.payload_json.get("series", [{}])[0].get("points", []) + match = next((p for p in points if str(p[0]).lower() == "q3"), None) + if match: + return f"Q3 value is {match[1]} (from ChartIR, confidence: {view.confidence}).", artifacts + if intent == "create_presentation": + artifacts.append({"type": "pptx", "status": "planned", "note": "PptxGenJS renderer service contract is available at /render"}) + return "I prepared a presentation plan using the resolved visual theme. The PPTX renderer service can turn this into an editable deck.", artifacts + content = await self.llm.complete( + [ + {"role": "developer", "content": "You are GLM-5.2 reasoning over compact visual evidence from the Visual Variable Runtime."}, + {"role": "user", "content": json.dumps(evidence, ensure_ascii=False)}, + ] + ) + return content, artifacts + + async def _load_image_url(self, image_url: str | None) -> tuple[bytes, str]: + if not image_url: + raise ValueError("input_image requires image_url or file_id") + if image_url.startswith("data:"): + header, encoded = image_url.split(",", 1) + mime_type = header.split(";")[0].removeprefix("data:") + data = base64.b64decode(encoded) + self._validate_image(data, mime_type) + return data, mime_type + self._validate_public_url(image_url) + async with httpx.AsyncClient(follow_redirects=False, timeout=20) as client: + response = await client.get(image_url) + for _ in range(3): + if response.status_code not in {301, 302, 303, 307, 308}: + break + location = response.headers.get("location") + self._validate_public_url(location) + response = await client.get(location) + response.raise_for_status() + mime_type = response.headers.get("content-type", "application/octet-stream").split(";")[0] + data = response.content + self._validate_image(data, mime_type) + return data, mime_type + + def _validate_public_url(self, url: str | None) -> None: + if not url: + raise ValueError("empty URL") + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("unsupported image URL") + host = parsed.hostname.lower() + if host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local"): + raise ValueError("private image URL rejected") + for result in socket.getaddrinfo(host, None): + ip = ipaddress.ip_address(result[4][0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise ValueError("private image URL rejected") + + def _validate_image(self, data: bytes, mime_type: str) -> None: + if len(data) > self.settings.max_image_bytes: + raise ValueError("image exceeds max size") + if mime_type not in {"image/png", "image/jpeg", "image/webp"}: + raise ValueError("unsupported image MIME type") + width, height = self._image_dimensions(data) + if width * height > self.settings.max_image_pixels: + raise ValueError("image dimensions exceed limit") + + def _image_dimensions(self, data: bytes) -> tuple[int, int]: + image = Image.open(io.BytesIO(data)) + image.verify() + image = Image.open(io.BytesIO(data)) + return image.size + + def _suffix_for_mime(self, mime_type: str) -> str: + return {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(mime_type, "") diff --git a/apps/api/app/orchestration/visual_compiler.py b/apps/api/app/orchestration/visual_compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..763cb27ed8a4f39ec1771ce5809030cd09603884 --- /dev/null +++ b/apps/api/app/orchestration/visual_compiler.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from app.schemas.plans import VisualOperation, VisualPlan, VisualReference +from app.storage.models import VisualAssetRecord + + +def compile_visual_plan(intent: str, text: str, asset: VisualAssetRecord | None, views: list[str]) -> VisualPlan: + references = [] + operations = [] + if asset: + for idx, view in enumerate(views, start=1): + references.append(VisualReference(phrase=text[:80], asset_id=asset.id, view=view, confidence=0.86)) + operations.append(VisualOperation(id=f"op_{idx}", op="get_view", asset_id=asset.id, view=view)) + if intent == "create_presentation": + operations.append(VisualOperation(id=f"op_{len(operations)+1}", op="create_presentation", asset_id=asset.id if asset else None, parameters={"content_request": text})) + if not operations: + operations.append(VisualOperation(id="op_1", op="answer_from_evidence", parameters={"content_request": text})) + return VisualPlan(intent=intent, references=references, operations=operations) diff --git a/apps/api/app/providers/llm/__pycache__/base.cpython-313.pyc b/apps/api/app/providers/llm/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ec137a2f609a7f82239ca48fe0af6a92c64dfcb Binary files /dev/null and b/apps/api/app/providers/llm/__pycache__/base.cpython-313.pyc differ diff --git a/apps/api/app/providers/llm/__pycache__/openai_compatible.cpython-313.pyc b/apps/api/app/providers/llm/__pycache__/openai_compatible.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..72feec2e09a30ed1fbea027e85d250e43add36fb Binary files /dev/null and b/apps/api/app/providers/llm/__pycache__/openai_compatible.cpython-313.pyc differ diff --git a/apps/api/app/providers/llm/__pycache__/zai_glm52.cpython-313.pyc b/apps/api/app/providers/llm/__pycache__/zai_glm52.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8998fdef692556486c9a63f25b960262f02d997 Binary files /dev/null and b/apps/api/app/providers/llm/__pycache__/zai_glm52.cpython-313.pyc differ diff --git a/apps/api/app/providers/llm/base.py b/apps/api/app/providers/llm/base.py new file mode 100644 index 0000000000000000000000000000000000000000..c6641ce1839a54b5725628be1264adefaf0b47c5 --- /dev/null +++ b/apps/api/app/providers/llm/base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class LlmProvider(ABC): + @abstractmethod + async def complete(self, messages: list[dict[str, Any]], response_format: dict[str, Any] | None = None) -> str: + raise NotImplementedError diff --git a/apps/api/app/providers/llm/openai_compatible.py b/apps/api/app/providers/llm/openai_compatible.py new file mode 100644 index 0000000000000000000000000000000000000000..72e788ae119b692edfbc8d17714f6c49c334c63b --- /dev/null +++ b/apps/api/app/providers/llm/openai_compatible.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + +from .base import LlmProvider + + +class FakeLlmProvider(LlmProvider): + async def complete(self, messages: list[dict[str, Any]], response_format: dict[str, Any] | None = None) -> str: + user_text = " ".join(str(m.get("content", "")) for m in messages if m.get("role") == "user") + if response_format: + return '{"intent":"answer_from_evidence","references":[],"operations":[]}' + return f"glm-5.2-visual-runtime received: {user_text}".strip() diff --git a/apps/api/app/providers/llm/zai_glm52.py b/apps/api/app/providers/llm/zai_glm52.py new file mode 100644 index 0000000000000000000000000000000000000000..36f8b6b9fca28dd43fb7cb79e3f7645a235cbf8a --- /dev/null +++ b/apps/api/app/providers/llm/zai_glm52.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from app.config import Settings + +from .base import LlmProvider + + +class ZaiGlm52Provider(LlmProvider): + def __init__(self, settings: Settings): + self.settings = settings + + async def complete(self, messages: list[dict[str, Any]], response_format: dict[str, Any] | None = None) -> str: + if not self.settings.glm_api_key: + raise RuntimeError("GLM_API_KEY is required outside test mode") + payload: dict[str, Any] = { + "model": self.settings.glm_model, + "messages": messages, + } + if response_format: + payload["response_format"] = response_format + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post( + f"{self.settings.glm_base_url.rstrip('/')}/chat/completions", + headers={"Authorization": f"Bearer {self.settings.glm_api_key}"}, + json=payload, + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] diff --git a/apps/api/app/providers/ocr/__pycache__/base.cpython-313.pyc b/apps/api/app/providers/ocr/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..913f7feb4456b740eeb1353030546d248acef62c Binary files /dev/null and b/apps/api/app/providers/ocr/__pycache__/base.cpython-313.pyc differ diff --git a/apps/api/app/providers/ocr/__pycache__/fake.cpython-313.pyc b/apps/api/app/providers/ocr/__pycache__/fake.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb3246dedd4bfa405323f182b53118bb01db318a Binary files /dev/null and b/apps/api/app/providers/ocr/__pycache__/fake.cpython-313.pyc differ diff --git a/apps/api/app/providers/ocr/__pycache__/glm_ocr.cpython-313.pyc b/apps/api/app/providers/ocr/__pycache__/glm_ocr.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fc9a74cf5a591d39407ea33cde7fc7a8ea3a1b8 Binary files /dev/null and b/apps/api/app/providers/ocr/__pycache__/glm_ocr.cpython-313.pyc differ diff --git a/apps/api/app/providers/ocr/__pycache__/local.cpython-313.pyc b/apps/api/app/providers/ocr/__pycache__/local.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..77179d8cce83a9b86ae098dd5c5d71b243c37c4e Binary files /dev/null and b/apps/api/app/providers/ocr/__pycache__/local.cpython-313.pyc differ diff --git a/apps/api/app/providers/ocr/base.py b/apps/api/app/providers/ocr/base.py new file mode 100644 index 0000000000000000000000000000000000000000..dfbc20dc24b88c05b6d0402c2b425c366c3cc66d --- /dev/null +++ b/apps/api/app/providers/ocr/base.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class OcrProvider(ABC): + @abstractmethod + async def extract(self, image_bytes: bytes, region: tuple[float, float, float, float] | None = None) -> dict: + raise NotImplementedError diff --git a/apps/api/app/providers/ocr/fake.py b/apps/api/app/providers/ocr/fake.py new file mode 100644 index 0000000000000000000000000000000000000000..da6a4af1f7c05173d86387126cbcaa3c25d76d2d --- /dev/null +++ b/apps/api/app/providers/ocr/fake.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from .base import OcrProvider + + +class FakeOcrProvider(OcrProvider): + async def extract(self, image_bytes: bytes, region: tuple[float, float, float, float] | None = None) -> dict: + return { + "markdown": "TOTAL $18.42", + "lines": [ + { + "id": "line_1", + "text": "TOTAL $18.42", + "bbox": region or [0.62, 0.81, 0.94, 0.86], + "confidence": 0.96, + "reading_order": 1, + "region_type": "text_line", + } + ], + "tables": [], + } diff --git a/apps/api/app/providers/ocr/glm_ocr.py b/apps/api/app/providers/ocr/glm_ocr.py new file mode 100644 index 0000000000000000000000000000000000000000..e65070a9901660af33b085355a4e9944db13a4a7 --- /dev/null +++ b/apps/api/app/providers/ocr/glm_ocr.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .fake import FakeOcrProvider + + +class GlmOcrProvider(FakeOcrProvider): + """Placeholder GLM-OCR provider behind the OCR interface.""" diff --git a/apps/api/app/providers/ocr/local.py b/apps/api/app/providers/ocr/local.py new file mode 100644 index 0000000000000000000000000000000000000000..80f99772c6763f602a98e7d5d588e4b8378bfd30 --- /dev/null +++ b/apps/api/app/providers/ocr/local.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import httpx + +from app.config import Settings + +from .base import OcrProvider + + +class LocalOcrProvider(OcrProvider): + def __init__(self, settings: Settings): + self.settings = settings + + async def extract(self, image_bytes: bytes, region: tuple[float, float, float, float] | None = None) -> dict: + files = {"file": ("image.png", image_bytes, "image/png")} + data = {} + if region: + data["region"] = ",".join(str(x) for x in region) + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post(f"{self.settings.ocr_base_url.rstrip('/')}/ocr", files=files, data=data) + response.raise_for_status() + return response.json() diff --git a/apps/api/app/providers/vision/__pycache__/base.cpython-313.pyc b/apps/api/app/providers/vision/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd1ef7a1970b19bd2778cf473a3025460af88161 Binary files /dev/null and b/apps/api/app/providers/vision/__pycache__/base.cpython-313.pyc differ diff --git a/apps/api/app/providers/vision/__pycache__/fake.cpython-313.pyc b/apps/api/app/providers/vision/__pycache__/fake.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ebe2776d159be43b208dc07b8671bb1ca0775352 Binary files /dev/null and b/apps/api/app/providers/vision/__pycache__/fake.cpython-313.pyc differ diff --git a/apps/api/app/providers/vision/__pycache__/local_vllm.cpython-313.pyc b/apps/api/app/providers/vision/__pycache__/local_vllm.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9baad1b2b85007bf0d97e9fa78c9a13036aabbae Binary files /dev/null and b/apps/api/app/providers/vision/__pycache__/local_vllm.cpython-313.pyc differ diff --git a/apps/api/app/providers/vision/__pycache__/zai_glm5v.cpython-313.pyc b/apps/api/app/providers/vision/__pycache__/zai_glm5v.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9af542faaa8a4780442eee6fe294d910fcab745b Binary files /dev/null and b/apps/api/app/providers/vision/__pycache__/zai_glm5v.cpython-313.pyc differ diff --git a/apps/api/app/providers/vision/base.py b/apps/api/app/providers/vision/base.py new file mode 100644 index 0000000000000000000000000000000000000000..add9c49426fdd83ee0ed5ff05c626b268e418a3c --- /dev/null +++ b/apps/api/app/providers/vision/base.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class VisionProvider(ABC): + @abstractmethod + async def ground_object(self, image_bytes: bytes, phrase: str) -> dict: + raise NotImplementedError diff --git a/apps/api/app/providers/vision/fake.py b/apps/api/app/providers/vision/fake.py new file mode 100644 index 0000000000000000000000000000000000000000..c57b5870509d3b843087bd8e91231172e6eae80d --- /dev/null +++ b/apps/api/app/providers/vision/fake.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from .base import VisionProvider + + +class FakeVisionProvider(VisionProvider): + async def ground_object(self, image_bytes: bytes, phrase: str) -> dict: + return { + "objects": [ + { + "label": phrase, + "bbox": [0.15, 0.15, 0.85, 0.85], + "confidence": 0.82, + } + ] + } diff --git a/apps/api/app/providers/vision/local_vllm.py b/apps/api/app/providers/vision/local_vllm.py new file mode 100644 index 0000000000000000000000000000000000000000..f73f91ab86a3a87985ea53dd31255973fdcc8a9b --- /dev/null +++ b/apps/api/app/providers/vision/local_vllm.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import base64 +import json + +import httpx + +from app.config import Settings + +from .base import VisionProvider + + +class LocalVllmVisionProvider(VisionProvider): + def __init__(self, settings: Settings): + self.settings = settings + + async def ground_object(self, image_bytes: bytes, phrase: str) -> dict: + data_url = "data:image/png;base64," + base64.b64encode(image_bytes).decode() + prompt = ( + f'Locate the object described as "{phrase}". Return JSON only with ' + '{"objects":[{"label":"...","bbox":[xmin,ymin,xmax,ymax],"confidence":0.0}]}. ' + "Use normalized coordinates from 0 to 1." + ) + payload = { + "model": self.settings.vision_model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": data_url}}, + ], + } + ], + "temperature": 0, + "max_tokens": 512, + } + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + f"{self.settings.vision_base_url.rstrip('/')}/chat/completions", + headers={"Authorization": f"Bearer {self.settings.vision_api_key or 'EMPTY'}"}, + json=payload, + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + try: + return json.loads(content) + except json.JSONDecodeError: + return {"objects": [{"label": phrase, "bbox": [0.0, 0.0, 1.0, 1.0], "confidence": 0.25}]} diff --git a/apps/api/app/providers/vision/zai_glm5v.py b/apps/api/app/providers/vision/zai_glm5v.py new file mode 100644 index 0000000000000000000000000000000000000000..f334164a2589592eb2549543987154eace1ab7d8 --- /dev/null +++ b/apps/api/app/providers/vision/zai_glm5v.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from .fake import FakeVisionProvider + + +class ZaiGlm5VProvider(FakeVisionProvider): + """Placeholder cloud provider. + + The interface is stable; the MVP keeps fake behavior until GLM-5V request + authentication and response contracts are configured in the deployment. + """ diff --git a/apps/api/app/schemas/__pycache__/chart_ir.cpython-313.pyc b/apps/api/app/schemas/__pycache__/chart_ir.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0ad2ea9905e6824576c831b4df2f9cdb12b7771 Binary files /dev/null and b/apps/api/app/schemas/__pycache__/chart_ir.cpython-313.pyc differ diff --git a/apps/api/app/schemas/__pycache__/openai.cpython-313.pyc b/apps/api/app/schemas/__pycache__/openai.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8a9f2968ec147b9431fba408f96301782bf6c0a Binary files /dev/null and b/apps/api/app/schemas/__pycache__/openai.cpython-313.pyc differ diff --git a/apps/api/app/schemas/__pycache__/plans.cpython-313.pyc b/apps/api/app/schemas/__pycache__/plans.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..695d7b46a2322074ce910b2f067f9f0d0d56fd60 Binary files /dev/null and b/apps/api/app/schemas/__pycache__/plans.cpython-313.pyc differ diff --git a/apps/api/app/schemas/assets.py b/apps/api/app/schemas/assets.py new file mode 100644 index 0000000000000000000000000000000000000000..01ce65dee7a59453c690857d25d161426e5452c9 --- /dev/null +++ b/apps/api/app/schemas/assets.py @@ -0,0 +1,39 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + +BBox = tuple[float, float, float, float] + + +class Provenance(BaseModel): + asset_id: str + bbox: BBox | None = None + extraction_method: str + confidence: float + lens_version: str + + +class VisualAssetRecord(BaseModel): + id: str + conversation_id: str + source_file_id: str | None = None + upload_order: int + sha256: str + original_uri: str + mime_type: str + width: int + height: int + aliases: list[str] = Field(default_factory=list) + + +class VisualViewRecord(BaseModel): + id: str + asset_id: str + view_type: str + lens_name: str + lens_version: str + parameters_hash: str + status: Literal["pending", "ready", "failed"] = "ready" + payload_json: dict[str, Any] + confidence: float + provenance_json: list[Provenance] = Field(default_factory=list) diff --git a/apps/api/app/schemas/chart_ir.py b/apps/api/app/schemas/chart_ir.py new file mode 100644 index 0000000000000000000000000000000000000000..9e3a6e17fdfb7300a15954b52eafaa22daf3c750 --- /dev/null +++ b/apps/api/app/schemas/chart_ir.py @@ -0,0 +1,27 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class Axis(BaseModel): + label: str | None = None + scale: Literal["linear", "categorical"] = "categorical" + values: list[str] | None = None + minimum: float | None = None + maximum: float | None = None + + +class ChartSeries(BaseModel): + name: str + color: str | None = None + points: list[tuple[str, float]] + + +class ChartIR(BaseModel): + chart_type: Literal["bar", "line", "scatter", "pie", "unknown"] = "unknown" + title: str | None = None + plot_bbox: tuple[float, float, float, float] | None = None + x_axis: Axis = Field(default_factory=Axis) + y_axis: Axis = Field(default_factory=lambda: Axis(scale="linear")) + series: list[ChartSeries] = Field(default_factory=list) + confidence: float = 0.0 diff --git a/apps/api/app/schemas/openai.py b/apps/api/app/schemas/openai.py new file mode 100644 index 0000000000000000000000000000000000000000..71ebc2cf9895f389543d0e8b442a7be67b857e2c --- /dev/null +++ b/apps/api/app/schemas/openai.py @@ -0,0 +1,69 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class InputText(BaseModel): + type: Literal["input_text"] + text: str + + +class InputImage(BaseModel): + type: Literal["input_image"] + image_url: str | None = None + file_id: str | None = None + + +class Message(BaseModel): + role: Literal["system", "developer", "user", "assistant"] + content: str | list[dict[str, Any]] + + +class ResponsesRequest(BaseModel): + model: str + input: str | list[Message | dict[str, Any]] + previous_response_id: str | None = None + temperature: float | None = None + max_output_tokens: int | None = None + metadata: dict[str, Any] | None = None + + +class OutputContent(BaseModel): + type: str = "output_text" + text: str + + +class OutputMessage(BaseModel): + id: str + type: str = "message" + role: str = "assistant" + content: list[OutputContent] + + +class ResponsesResponse(BaseModel): + id: str + object: str = "response" + created_at: int + status: str = "completed" + model: str + output: list[OutputMessage] + output_text: str + conversation_id: str + artifacts: list[dict[str, Any]] = Field(default_factory=list) + + +class ChatCompletionRequest(BaseModel): + model: str + messages: list[Message | dict[str, Any]] + temperature: float | None = None + max_tokens: int | None = None + + +class FileObject(BaseModel): + id: str + object: str = "file" + bytes: int + created_at: int + filename: str + purpose: str = "vision" + mime_type: str | None = None diff --git a/apps/api/app/schemas/plans.py b/apps/api/app/schemas/plans.py new file mode 100644 index 0000000000000000000000000000000000000000..66656eab73c4167b8ffde02ab46eb6774771fc10 --- /dev/null +++ b/apps/api/app/schemas/plans.py @@ -0,0 +1,38 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class VisualReference(BaseModel): + phrase: str + asset_id: str + view: str | None = None + confidence: float = 1.0 + + +class VisualOperation(BaseModel): + id: str + op: Literal[ + "get_view", + "get_ocr_regions", + "ground_object", + "extract_object_palette", + "extract_global_palette", + "extract_chart", + "query_chart", + "crop_region", + "enlarge_region", + "rerun_ocr", + "create_presentation", + "create_table", + "answer_from_evidence", + ] + asset_id: str | None = None + view: str | None = None + parameters: dict[str, Any] = Field(default_factory=dict) + + +class VisualPlan(BaseModel): + intent: str + references: list[VisualReference] = Field(default_factory=list) + operations: list[VisualOperation] = Field(default_factory=list) diff --git a/apps/api/app/schemas/presentation.py b/apps/api/app/schemas/presentation.py new file mode 100644 index 0000000000000000000000000000000000000000..b05d812e5b0ca60892da475cb57627f3e3cd634d --- /dev/null +++ b/apps/api/app/schemas/presentation.py @@ -0,0 +1,21 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class SlideSpec(BaseModel): + layout: Literal["title", "section", "bullets", "chart_and_insight", "image_reference"] = "bullets" + title: str + subtitle: str | None = None + bullets: list[str] = Field(default_factory=list) + chart_ref: str | None = None + image_ref: str | None = None + insights: list[str] = Field(default_factory=list) + notes: str | None = None + + +class PresentationSpec(BaseModel): + title: str + theme: dict[str, Any] + slides: list[SlideSpec] + provenance: list[dict[str, Any]] = Field(default_factory=list) diff --git a/apps/api/app/schemas/views.py b/apps/api/app/schemas/views.py new file mode 100644 index 0000000000000000000000000000000000000000..ad4d9b033fc8972a7b0fa4ea9fce67f0fb9b7a9a --- /dev/null +++ b/apps/api/app/schemas/views.py @@ -0,0 +1,45 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from .assets import BBox, Provenance + + +class PaletteTokens(BaseModel): + background: str + surface: str + primary: str + secondary: str + accent: str + text_dark: str + text_light: str = "#FFFFFF" + data_series: list[str] = Field(default_factory=list) + provenance: dict[str, Any] = Field(default_factory=dict) + + +class OcrLine(BaseModel): + id: str + text: str + bbox: BBox + confidence: float + reading_order: int + region_type: str = "text_line" + + +class OcrView(BaseModel): + markdown: str + lines: list[OcrLine] + tables: list[dict[str, Any]] = Field(default_factory=list) + + +class GroundedObject(BaseModel): + label: str + bbox: BBox + confidence: float + + +class VisualViewEnvelope(BaseModel): + view_type: Literal["fingerprint", "palette", "presentation_theme", "ocr", "objects", "chart_ir"] + payload: dict[str, Any] + confidence: float + provenance: list[Provenance] diff --git a/apps/api/app/storage/__pycache__/models.cpython-313.pyc b/apps/api/app/storage/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2953efa1bb4a8b0c8cf6e789ab3f57b0ceddcb5a Binary files /dev/null and b/apps/api/app/storage/__pycache__/models.cpython-313.pyc differ diff --git a/apps/api/app/storage/__pycache__/object_store.cpython-313.pyc b/apps/api/app/storage/__pycache__/object_store.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70951984587944e73d766e611bdd4f53cf0183f0 Binary files /dev/null and b/apps/api/app/storage/__pycache__/object_store.cpython-313.pyc differ diff --git a/apps/api/app/storage/__pycache__/repositories.cpython-313.pyc b/apps/api/app/storage/__pycache__/repositories.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..581c615a9dacc737df5d7173dea897b84e0f0a3a Binary files /dev/null and b/apps/api/app/storage/__pycache__/repositories.cpython-313.pyc differ diff --git a/apps/api/app/storage/models.py b/apps/api/app/storage/models.py new file mode 100644 index 0000000000000000000000000000000000000000..eace90cf2852ac5c5d0dfecd6d7d601d01e1046a --- /dev/null +++ b/apps/api/app/storage/models.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class FileRecord(BaseModel): + id: str + filename: str + mime_type: str + bytes: int + uri: str + sha256: str + created_at: int + + +class ConversationRecord(BaseModel): + id: str + created_at: int + updated_at: int + active_asset_id: str | None = None + active_view_type: str | None = None + most_recent_chart_asset_id: str | None = None + most_recent_document_asset_id: str | None = None + last_referenced_asset_id: str | None = None + last_referenced_property: str | None = None + + +class ResponseRecord(BaseModel): + id: str + conversation_id: str + previous_response_id: str | None = None + request_json: dict[str, Any] + response_json: dict[str, Any] + created_at: int + + +class VisualAssetRecord(BaseModel): + id: str + conversation_id: str + source_turn_id: str | None = None + source_file_id: str | None = None + upload_order: int + sha256: str + original_uri: str + mime_type: str + width: int + height: int + aliases: list[str] = Field(default_factory=list) + created_at: int + + +class VisualViewRecord(BaseModel): + id: str + asset_id: str + sha256: str + view_type: str + lens_name: str + lens_version: str + parameters_hash: str + status: str + payload_json: dict[str, Any] + confidence: float + provenance_json: list[dict[str, Any]] = Field(default_factory=list) + created_at: int + updated_at: int diff --git a/apps/api/app/storage/object_store.py b/apps/api/app/storage/object_store.py new file mode 100644 index 0000000000000000000000000000000000000000..0a2fc8c9ab349d368a2c8af8b157771c207eef84 --- /dev/null +++ b/apps/api/app/storage/object_store.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + + +class ObjectStore: + def __init__(self, root: Path): + self.root = root + self.root.mkdir(parents=True, exist_ok=True) + + def put_bytes(self, data: bytes, suffix: str = "") -> tuple[str, str]: + digest = hashlib.sha256(data).hexdigest() + key = f"{digest[:2]}/{digest}{suffix}" + path = self.root / key + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_bytes(data) + return f"local://{key}", digest + + def get_bytes(self, uri: str) -> bytes: + if not uri.startswith("local://"): + raise ValueError(f"Unsupported object URI: {uri}") + return (self.root / uri.removeprefix("local://")).read_bytes() + + def path_for(self, uri: str) -> Path: + if not uri.startswith("local://"): + raise ValueError(f"Unsupported object URI: {uri}") + return self.root / uri.removeprefix("local://") diff --git a/apps/api/app/storage/repositories.py b/apps/api/app/storage/repositories.py new file mode 100644 index 0000000000000000000000000000000000000000..e1cb2040f61e2e5653e64efa45cb505caeb43d43 --- /dev/null +++ b/apps/api/app/storage/repositories.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import time +import uuid +from pathlib import Path +from threading import RLock +from typing import Any + +from .models import ( + ConversationRecord, + FileRecord, + ResponseRecord, + VisualAssetRecord, + VisualViewRecord, +) + + +def now_ts() -> int: + return int(time.time()) + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:24]}" + + +class JsonLedger: + def __init__(self, path: Path): + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + self.lock = RLock() + if not self.path.exists(): + self._write( + { + "files": {}, + "conversations": {}, + "responses": {}, + "assets": {}, + "views": {}, + } + ) + + def _read(self) -> dict[str, Any]: + with self.lock: + return json.loads(self.path.read_text()) + + def _write(self, data: dict[str, Any]) -> None: + with self.lock: + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True)) + tmp.replace(self.path) + + def create_file(self, filename: str, mime_type: str, size: int, uri: str, sha256: str) -> FileRecord: + data = self._read() + record = FileRecord( + id=new_id("file"), + filename=filename, + mime_type=mime_type, + bytes=size, + uri=uri, + sha256=sha256, + created_at=now_ts(), + ) + data["files"][record.id] = record.model_dump() + self._write(data) + return record + + def get_file(self, file_id: str) -> FileRecord | None: + value = self._read()["files"].get(file_id) + return FileRecord(**value) if value else None + + def create_conversation(self) -> ConversationRecord: + ts = now_ts() + record = ConversationRecord(id=new_id("conv"), created_at=ts, updated_at=ts) + data = self._read() + data["conversations"][record.id] = record.model_dump() + self._write(data) + return record + + def get_conversation(self, conversation_id: str) -> ConversationRecord | None: + value = self._read()["conversations"].get(conversation_id) + return ConversationRecord(**value) if value else None + + def update_conversation(self, conversation: ConversationRecord) -> ConversationRecord: + conversation.updated_at = now_ts() + data = self._read() + data["conversations"][conversation.id] = conversation.model_dump() + self._write(data) + return conversation + + def get_response(self, response_id: str) -> ResponseRecord | None: + value = self._read()["responses"].get(response_id) + return ResponseRecord(**value) if value else None + + def create_response( + self, + conversation_id: str, + previous_response_id: str | None, + request_json: dict[str, Any], + response_json: dict[str, Any], + ) -> ResponseRecord: + record = ResponseRecord( + id=new_id("resp"), + conversation_id=conversation_id, + previous_response_id=previous_response_id, + request_json=request_json, + response_json=response_json, + created_at=now_ts(), + ) + data = self._read() + data["responses"][record.id] = record.model_dump() + self._write(data) + return record + + def create_asset( + self, + conversation_id: str, + source_file_id: str | None, + sha256: str, + original_uri: str, + mime_type: str, + width: int, + height: int, + ) -> VisualAssetRecord: + data = self._read() + upload_order = 1 + sum(1 for a in data["assets"].values() if a["conversation_id"] == conversation_id) + record = VisualAssetRecord( + id=f"img_{upload_order:02d}_{uuid.uuid4().hex[:8]}", + conversation_id=conversation_id, + source_file_id=source_file_id, + upload_order=upload_order, + sha256=sha256, + original_uri=original_uri, + mime_type=mime_type, + width=width, + height=height, + created_at=now_ts(), + ) + data["assets"][record.id] = record.model_dump() + self._write(data) + return record + + def list_assets(self, conversation_id: str) -> list[VisualAssetRecord]: + values = [ + VisualAssetRecord(**a) + for a in self._read()["assets"].values() + if a["conversation_id"] == conversation_id + ] + return sorted(values, key=lambda a: a.upload_order) + + def get_asset(self, asset_id: str) -> VisualAssetRecord | None: + value = self._read()["assets"].get(asset_id) + return VisualAssetRecord(**value) if value else None + + def find_view(self, sha256: str, lens_name: str, lens_version: str, parameters_hash: str) -> VisualViewRecord | None: + for value in self._read()["views"].values(): + if ( + value["sha256"] == sha256 + and value["lens_name"] == lens_name + and value["lens_version"] == lens_version + and value["parameters_hash"] == parameters_hash + ): + return VisualViewRecord(**value) + return None + + def save_view(self, view: VisualViewRecord) -> VisualViewRecord: + data = self._read() + data["views"][view.id] = view.model_dump() + self._write(data) + return view diff --git a/apps/api/glm_vvr_api.egg-info/PKG-INFO b/apps/api/glm_vvr_api.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..484266b9da9e0767039247553d38c82db1e1002f --- /dev/null +++ b/apps/api/glm_vvr_api.egg-info/PKG-INFO @@ -0,0 +1,19 @@ +Metadata-Version: 2.4 +Name: glm-vvr-api +Version: 0.1.0 +Summary: OpenAI-compatible GLM visual variable runtime gateway +Requires-Python: >=3.11 +Requires-Dist: fastapi>=0.115 +Requires-Dist: uvicorn[standard]>=0.30 +Requires-Dist: pydantic>=2.8 +Requires-Dist: pydantic-settings>=2.4 +Requires-Dist: python-multipart>=0.0.9 +Requires-Dist: httpx>=0.27 +Requires-Dist: pillow>=10.4 +Requires-Dist: numpy>=2.0 +Requires-Dist: opencv-python-headless>=4.10 +Requires-Dist: sqlalchemy>=2.0 +Provides-Extra: test +Requires-Dist: pytest>=8.3; extra == "test" +Requires-Dist: pytest-asyncio>=0.23; extra == "test" +Requires-Dist: openai>=1.55; extra == "test" diff --git a/apps/api/glm_vvr_api.egg-info/SOURCES.txt b/apps/api/glm_vvr_api.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..ee7ae2fab3923cc50167bdd603bd857f3f6ee57f --- /dev/null +++ b/apps/api/glm_vvr_api.egg-info/SOURCES.txt @@ -0,0 +1,43 @@ +pyproject.toml +app/__init__.py +app/config.py +app/main.py +app/api/chat_completions.py +app/api/files.py +app/api/health.py +app/api/responses.py +app/lenses/base.py +app/lenses/chart.py +app/lenses/fingerprint.py +app/lenses/object_grounding.py +app/lenses/ocr.py +app/lenses/palette.py +app/lenses/registry.py +app/orchestration/evidence_compiler.py +app/orchestration/intent_router.py +app/orchestration/reference_resolver.py +app/orchestration/runtime.py +app/orchestration/visual_compiler.py +app/providers/llm/base.py +app/providers/llm/openai_compatible.py +app/providers/llm/zai_glm52.py +app/providers/ocr/base.py +app/providers/ocr/fake.py +app/providers/ocr/glm_ocr.py +app/providers/vision/base.py +app/providers/vision/fake.py +app/providers/vision/zai_glm5v.py +app/schemas/assets.py +app/schemas/chart_ir.py +app/schemas/openai.py +app/schemas/plans.py +app/schemas/presentation.py +app/schemas/views.py +app/storage/models.py +app/storage/object_store.py +app/storage/repositories.py +glm_vvr_api.egg-info/PKG-INFO +glm_vvr_api.egg-info/SOURCES.txt +glm_vvr_api.egg-info/dependency_links.txt +glm_vvr_api.egg-info/requires.txt +glm_vvr_api.egg-info/top_level.txt \ No newline at end of file diff --git a/apps/api/glm_vvr_api.egg-info/dependency_links.txt b/apps/api/glm_vvr_api.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/apps/api/glm_vvr_api.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/apps/api/glm_vvr_api.egg-info/requires.txt b/apps/api/glm_vvr_api.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..595743543f4beee973d5842c1be320011207e88e --- /dev/null +++ b/apps/api/glm_vvr_api.egg-info/requires.txt @@ -0,0 +1,15 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +pydantic>=2.8 +pydantic-settings>=2.4 +python-multipart>=0.0.9 +httpx>=0.27 +pillow>=10.4 +numpy>=2.0 +opencv-python-headless>=4.10 +sqlalchemy>=2.0 + +[test] +pytest>=8.3 +pytest-asyncio>=0.23 +openai>=1.55 diff --git a/apps/api/glm_vvr_api.egg-info/top_level.txt b/apps/api/glm_vvr_api.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..b80f0bd60822d4fa4893de455958ef32f6c521bf --- /dev/null +++ b/apps/api/glm_vvr_api.egg-info/top_level.txt @@ -0,0 +1 @@ +app diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..b419f12dbb16dd786a84567bd92a2d8b2db730b0 --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "glm-vvr-api" +version = "0.1.0" +description = "OpenAI-compatible GLM visual variable runtime gateway" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "pydantic>=2.8", + "pydantic-settings>=2.4", + "python-multipart>=0.0.9", + "httpx>=0.27", + "pillow>=10.4", + "numpy>=2.0", + "opencv-python-headless>=4.10", + "sqlalchemy>=2.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.3", + "pytest-asyncio>=0.23", + "openai>=1.55", +] + +[tool.pytest.ini_options] +testpaths = ["../../tests"] +pythonpath = ["."]