Add gateway API source for one-click deployment
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- apps/api/Dockerfile +18 -0
- apps/api/app/__init__.py +1 -0
- apps/api/app/__pycache__/__init__.cpython-313.pyc +0 -0
- apps/api/app/__pycache__/config.cpython-313.pyc +0 -0
- apps/api/app/__pycache__/main.cpython-313.pyc +0 -0
- apps/api/app/api/__pycache__/chat_completions.cpython-313.pyc +0 -0
- apps/api/app/api/__pycache__/files.cpython-313.pyc +0 -0
- apps/api/app/api/__pycache__/health.cpython-313.pyc +0 -0
- apps/api/app/api/__pycache__/responses.cpython-313.pyc +0 -0
- apps/api/app/api/chat_completions.py +37 -0
- apps/api/app/api/files.py +62 -0
- apps/api/app/api/health.py +13 -0
- apps/api/app/api/responses.py +24 -0
- apps/api/app/config.py +43 -0
- apps/api/app/lenses/__pycache__/base.cpython-313.pyc +0 -0
- apps/api/app/lenses/__pycache__/chart.cpython-313.pyc +0 -0
- apps/api/app/lenses/__pycache__/fingerprint.cpython-313.pyc +0 -0
- apps/api/app/lenses/__pycache__/object_grounding.cpython-313.pyc +0 -0
- apps/api/app/lenses/__pycache__/ocr.cpython-313.pyc +0 -0
- apps/api/app/lenses/__pycache__/palette.cpython-313.pyc +0 -0
- apps/api/app/lenses/__pycache__/registry.cpython-313.pyc +0 -0
- apps/api/app/lenses/base.py +43 -0
- apps/api/app/lenses/chart.py +24 -0
- apps/api/app/lenses/fingerprint.py +48 -0
- apps/api/app/lenses/object_grounding.py +31 -0
- apps/api/app/lenses/ocr.py +29 -0
- apps/api/app/lenses/palette.py +62 -0
- apps/api/app/lenses/registry.py +36 -0
- apps/api/app/main.py +22 -0
- apps/api/app/orchestration/__pycache__/evidence_compiler.cpython-313.pyc +0 -0
- apps/api/app/orchestration/__pycache__/intent_router.cpython-313.pyc +0 -0
- apps/api/app/orchestration/__pycache__/reference_resolver.cpython-313.pyc +0 -0
- apps/api/app/orchestration/__pycache__/runtime.cpython-313.pyc +0 -0
- apps/api/app/orchestration/__pycache__/visual_compiler.cpython-313.pyc +0 -0
- apps/api/app/orchestration/evidence_compiler.py +27 -0
- apps/api/app/orchestration/intent_router.py +21 -0
- apps/api/app/orchestration/reference_resolver.py +36 -0
- apps/api/app/orchestration/runtime.py +252 -0
- apps/api/app/orchestration/visual_compiler.py +18 -0
- apps/api/app/providers/llm/__pycache__/base.cpython-313.pyc +0 -0
- apps/api/app/providers/llm/__pycache__/openai_compatible.cpython-313.pyc +0 -0
- apps/api/app/providers/llm/__pycache__/zai_glm52.cpython-313.pyc +0 -0
- apps/api/app/providers/llm/base.py +10 -0
- apps/api/app/providers/llm/openai_compatible.py +13 -0
- apps/api/app/providers/llm/zai_glm52.py +33 -0
- apps/api/app/providers/ocr/__pycache__/base.cpython-313.pyc +0 -0
- apps/api/app/providers/ocr/__pycache__/fake.cpython-313.pyc +0 -0
- apps/api/app/providers/ocr/__pycache__/glm_ocr.cpython-313.pyc +0 -0
- apps/api/app/providers/ocr/__pycache__/local.cpython-313.pyc +0 -0
- apps/api/app/providers/ocr/base.py +9 -0
apps/api/Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
libgl1 libglib2.0-0 curl \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
COPY apps/api/pyproject.toml /app/apps/api/pyproject.toml
|
| 10 |
+
RUN pip install --no-cache-dir -e /app/apps/api
|
| 11 |
+
|
| 12 |
+
COPY apps/api /app/apps/api
|
| 13 |
+
COPY AGENTS.md README.md /app/
|
| 14 |
+
|
| 15 |
+
ENV PYTHONPATH=/app/apps/api
|
| 16 |
+
EXPOSE 8000
|
| 17 |
+
|
| 18 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app/apps/api"]
|
apps/api/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
apps/api/app/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (178 Bytes). View file
|
|
|
apps/api/app/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (2.36 kB). View file
|
|
|
apps/api/app/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (983 Bytes). View file
|
|
|
apps/api/app/api/__pycache__/chat_completions.cpython-313.pyc
ADDED
|
Binary file (1.72 kB). View file
|
|
|
apps/api/app/api/__pycache__/files.cpython-313.pyc
ADDED
|
Binary file (3.24 kB). View file
|
|
|
apps/api/app/api/__pycache__/health.cpython-313.pyc
ADDED
|
Binary file (724 Bytes). View file
|
|
|
apps/api/app/api/__pycache__/responses.cpython-313.pyc
ADDED
|
Binary file (1.48 kB). View file
|
|
|
apps/api/app/api/chat_completions.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
|
| 5 |
+
from app.orchestration.runtime import Runtime
|
| 6 |
+
from app.schemas.openai import ChatCompletionRequest, ResponsesRequest
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/v1/chat")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_runtime() -> Runtime:
|
| 12 |
+
from app.main import runtime
|
| 13 |
+
|
| 14 |
+
return runtime
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.post("/completions")
|
| 18 |
+
async def create_chat_completion(request: ChatCompletionRequest, runtime: Runtime = Depends(get_runtime)) -> dict:
|
| 19 |
+
try:
|
| 20 |
+
response = await runtime.handle_responses(
|
| 21 |
+
ResponsesRequest(model=request.model, input=request.messages)
|
| 22 |
+
)
|
| 23 |
+
except Exception as exc:
|
| 24 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 25 |
+
return {
|
| 26 |
+
"id": response["id"],
|
| 27 |
+
"object": "chat.completion",
|
| 28 |
+
"created": response["created_at"],
|
| 29 |
+
"model": response["model"],
|
| 30 |
+
"choices": [
|
| 31 |
+
{
|
| 32 |
+
"index": 0,
|
| 33 |
+
"message": {"role": "assistant", "content": response["output_text"]},
|
| 34 |
+
"finish_reason": "stop",
|
| 35 |
+
}
|
| 36 |
+
],
|
| 37 |
+
}
|
apps/api/app/api/files.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
| 4 |
+
from fastapi.responses import Response
|
| 5 |
+
|
| 6 |
+
from app.orchestration.runtime import Runtime
|
| 7 |
+
from app.schemas.openai import FileObject
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/v1/files")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_runtime() -> Runtime:
|
| 13 |
+
from app.main import runtime
|
| 14 |
+
|
| 15 |
+
return runtime
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.post("", response_model=FileObject)
|
| 19 |
+
async def create_file(
|
| 20 |
+
file: UploadFile = File(...),
|
| 21 |
+
purpose: str = Form(default="vision"),
|
| 22 |
+
runtime: Runtime = Depends(get_runtime),
|
| 23 |
+
) -> dict:
|
| 24 |
+
try:
|
| 25 |
+
data = await file.read()
|
| 26 |
+
record = await runtime.create_file(file.filename or "upload", data, file.content_type or "application/octet-stream")
|
| 27 |
+
except Exception as exc:
|
| 28 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 29 |
+
return {
|
| 30 |
+
"id": record.id,
|
| 31 |
+
"object": "file",
|
| 32 |
+
"bytes": record.bytes,
|
| 33 |
+
"created_at": record.created_at,
|
| 34 |
+
"filename": record.filename,
|
| 35 |
+
"purpose": purpose,
|
| 36 |
+
"mime_type": record.mime_type,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/{file_id}", response_model=FileObject)
|
| 41 |
+
async def get_file(file_id: str, runtime: Runtime = Depends(get_runtime)) -> dict:
|
| 42 |
+
record = runtime.get_file(file_id)
|
| 43 |
+
if not record:
|
| 44 |
+
raise HTTPException(status_code=404, detail="file not found")
|
| 45 |
+
return {
|
| 46 |
+
"id": record.id,
|
| 47 |
+
"object": "file",
|
| 48 |
+
"bytes": record.bytes,
|
| 49 |
+
"created_at": record.created_at,
|
| 50 |
+
"filename": record.filename,
|
| 51 |
+
"purpose": "vision",
|
| 52 |
+
"mime_type": record.mime_type,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.get("/{file_id}/content")
|
| 57 |
+
async def get_file_content(file_id: str, runtime: Runtime = Depends(get_runtime)) -> Response:
|
| 58 |
+
try:
|
| 59 |
+
record, data = runtime.get_file_bytes(file_id)
|
| 60 |
+
except KeyError as exc:
|
| 61 |
+
raise HTTPException(status_code=404, detail="file not found") from exc
|
| 62 |
+
return Response(content=data, media_type=record.mime_type)
|
apps/api/app/api/health.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.get("/health")
|
| 7 |
+
async def health() -> dict:
|
| 8 |
+
return {"status": "ok"}
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.get("/ready")
|
| 12 |
+
async def ready() -> dict:
|
| 13 |
+
return {"status": "ready"}
|
apps/api/app/api/responses.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
|
| 5 |
+
from app.orchestration.runtime import Runtime
|
| 6 |
+
from app.schemas.openai import ResponsesRequest
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/v1")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_runtime() -> Runtime:
|
| 12 |
+
from app.main import runtime
|
| 13 |
+
|
| 14 |
+
return runtime
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.post("/responses")
|
| 18 |
+
async def create_response(request: ResponsesRequest, runtime: Runtime = Depends(get_runtime)) -> dict:
|
| 19 |
+
if request.model != runtime.settings.public_model_id:
|
| 20 |
+
raise HTTPException(status_code=400, detail=f"unsupported model: {request.model}")
|
| 21 |
+
try:
|
| 22 |
+
return await runtime.handle_responses(request)
|
| 23 |
+
except Exception as exc:
|
| 24 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
apps/api/app/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from pydantic import Field
|
| 5 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Settings(BaseSettings):
|
| 9 |
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
| 10 |
+
|
| 11 |
+
public_model_id: str = "glm-5.2-visual-runtime"
|
| 12 |
+
glm_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
|
| 13 |
+
glm_api_key: str | None = None
|
| 14 |
+
glm_model: str = "glm-5.2"
|
| 15 |
+
|
| 16 |
+
vision_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
|
| 17 |
+
vision_api_key: str | None = None
|
| 18 |
+
vision_model: str = "glm-5v-turbo"
|
| 19 |
+
|
| 20 |
+
ocr_mode: str = "maas"
|
| 21 |
+
ocr_base_url: str = "http://ocr:8080"
|
| 22 |
+
ocr_api_key: str | None = None
|
| 23 |
+
ocr_model: str = "glm-ocr"
|
| 24 |
+
|
| 25 |
+
database_url: str = "sqlite:///./glm_vvr.db"
|
| 26 |
+
object_store_endpoint: str = "local://./data/objects"
|
| 27 |
+
object_store_bucket: str = "glm-vvr"
|
| 28 |
+
object_store_access_key: str | None = None
|
| 29 |
+
object_store_secret_key: str | None = None
|
| 30 |
+
|
| 31 |
+
visual_runtime_mode: str = Field(default="test", pattern="^(cloud|hybrid|local|test)$")
|
| 32 |
+
visual_cache_ttl_days: int = 30
|
| 33 |
+
max_image_bytes: int = 10 * 1024 * 1024
|
| 34 |
+
max_image_pixels: int = 25_000_000
|
| 35 |
+
reference_confidence_threshold: float = 0.72
|
| 36 |
+
slides_service_url: str = "http://slides:8787"
|
| 37 |
+
|
| 38 |
+
data_dir: Path = Path("./data")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@lru_cache
|
| 42 |
+
def get_settings() -> Settings:
|
| 43 |
+
return Settings()
|
apps/api/app/lenses/__pycache__/base.cpython-313.pyc
ADDED
|
Binary file (2.42 kB). View file
|
|
|
apps/api/app/lenses/__pycache__/chart.cpython-313.pyc
ADDED
|
Binary file (1.77 kB). View file
|
|
|
apps/api/app/lenses/__pycache__/fingerprint.cpython-313.pyc
ADDED
|
Binary file (3.37 kB). View file
|
|
|
apps/api/app/lenses/__pycache__/object_grounding.cpython-313.pyc
ADDED
|
Binary file (2.04 kB). View file
|
|
|
apps/api/app/lenses/__pycache__/ocr.cpython-313.pyc
ADDED
|
Binary file (1.97 kB). View file
|
|
|
apps/api/app/lenses/__pycache__/palette.cpython-313.pyc
ADDED
|
Binary file (4.1 kB). View file
|
|
|
apps/api/app/lenses/__pycache__/registry.cpython-313.pyc
ADDED
|
Binary file (1.79 kB). View file
|
|
|
apps/api/app/lenses/base.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from abc import ABC, abstractmethod
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from app.storage.models import VisualAssetRecord, VisualViewRecord
|
| 9 |
+
from app.storage.repositories import new_id, now_ts
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def canonical_hash(parameters: dict[str, Any]) -> str:
|
| 13 |
+
canonical = json.dumps(parameters, sort_keys=True, separators=(",", ":"))
|
| 14 |
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Lens(ABC):
|
| 18 |
+
name: str
|
| 19 |
+
version: str = "v1"
|
| 20 |
+
view_type: str
|
| 21 |
+
|
| 22 |
+
@abstractmethod
|
| 23 |
+
async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict[str, Any]) -> tuple[dict[str, Any], float, list[dict[str, Any]]]:
|
| 24 |
+
raise NotImplementedError
|
| 25 |
+
|
| 26 |
+
async def run(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict[str, Any]) -> VisualViewRecord:
|
| 27 |
+
payload, confidence, provenance = await self.execute(asset, image_bytes, parameters)
|
| 28 |
+
ts = now_ts()
|
| 29 |
+
return VisualViewRecord(
|
| 30 |
+
id=new_id("view"),
|
| 31 |
+
asset_id=asset.id,
|
| 32 |
+
sha256=asset.sha256,
|
| 33 |
+
view_type=self.view_type,
|
| 34 |
+
lens_name=self.name,
|
| 35 |
+
lens_version=self.version,
|
| 36 |
+
parameters_hash=canonical_hash(parameters),
|
| 37 |
+
status="ready",
|
| 38 |
+
payload_json=payload,
|
| 39 |
+
confidence=confidence,
|
| 40 |
+
provenance_json=provenance,
|
| 41 |
+
created_at=ts,
|
| 42 |
+
updated_at=ts,
|
| 43 |
+
)
|
apps/api/app/lenses/chart.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.schemas.chart_ir import Axis, ChartIR, ChartSeries
|
| 4 |
+
from app.storage.models import VisualAssetRecord
|
| 5 |
+
|
| 6 |
+
from .base import Lens
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ChartLens(Lens):
|
| 10 |
+
name = "chart_ir"
|
| 11 |
+
view_type = "chart_ir"
|
| 12 |
+
|
| 13 |
+
async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]:
|
| 14 |
+
ir = ChartIR(
|
| 15 |
+
chart_type="bar",
|
| 16 |
+
title="Extracted Chart",
|
| 17 |
+
plot_bbox=(0.1, 0.1, 0.9, 0.85),
|
| 18 |
+
x_axis=Axis(label="Quarter", scale="categorical", values=["Q1", "Q2", "Q3", "Q4"]),
|
| 19 |
+
y_axis=Axis(label="Value", scale="linear", minimum=0, maximum=50),
|
| 20 |
+
series=[ChartSeries(name="Series 1", color="#1769AA", points=[("Q1", 21), ("Q2", 28), ("Q3", 37), ("Q4", 43)])],
|
| 21 |
+
confidence=0.74,
|
| 22 |
+
)
|
| 23 |
+
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}]
|
| 24 |
+
return ir.model_dump(), 0.74, provenance
|
apps/api/app/lenses/fingerprint.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
from app.storage.models import VisualAssetRecord
|
| 10 |
+
|
| 11 |
+
from .base import Lens
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FingerprintLens(Lens):
|
| 15 |
+
name = "cheap_fingerprint"
|
| 16 |
+
view_type = "fingerprint"
|
| 17 |
+
|
| 18 |
+
async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]:
|
| 19 |
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 20 |
+
arr = np.array(image)
|
| 21 |
+
small = np.array(image.resize((64, 64)))
|
| 22 |
+
gray = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY)
|
| 23 |
+
edges = cv2.Canny(gray, 80, 160)
|
| 24 |
+
edge_density = float((edges > 0).mean())
|
| 25 |
+
text_density = float((gray < 80).mean())
|
| 26 |
+
colors = small.reshape(-1, 3)
|
| 27 |
+
quantized = (colors // 32) * 32
|
| 28 |
+
unique, counts = np.unique(quantized, axis=0, return_counts=True)
|
| 29 |
+
top = unique[np.argsort(counts)[-5:]][::-1]
|
| 30 |
+
dominant = [f"#{r:02X}{g:02X}{b:02X}" for r, g, b in top]
|
| 31 |
+
likely = "photograph"
|
| 32 |
+
if text_density > 0.16:
|
| 33 |
+
likely = "document"
|
| 34 |
+
if edge_density > 0.18:
|
| 35 |
+
likely = "chart" if text_density > 0.05 else "diagram"
|
| 36 |
+
payload = {
|
| 37 |
+
"width": asset.width,
|
| 38 |
+
"height": asset.height,
|
| 39 |
+
"aspect_ratio": asset.width / max(asset.height, 1),
|
| 40 |
+
"dominant_global_colors": dominant,
|
| 41 |
+
"edge_density": edge_density,
|
| 42 |
+
"approximate_text_density": text_density,
|
| 43 |
+
"approximate_rectangular_regions": int(edge_density * 100),
|
| 44 |
+
"perceptual_hash": hex(int(gray.mean()) << 8 | int(gray.std())),
|
| 45 |
+
"routing_hint": likely,
|
| 46 |
+
}
|
| 47 |
+
provenance = [{"asset_id": asset.id, "bbox": None, "extraction_method": self.name, "confidence": 0.7, "lens_version": self.version}]
|
| 48 |
+
return payload, 0.7, provenance
|
apps/api/app/lenses/object_grounding.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.providers.vision.base import VisionProvider
|
| 4 |
+
from app.storage.models import VisualAssetRecord
|
| 5 |
+
|
| 6 |
+
from .base import Lens
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ObjectGroundingLens(Lens):
|
| 10 |
+
name = "object_grounding"
|
| 11 |
+
view_type = "objects"
|
| 12 |
+
|
| 13 |
+
def __init__(self, provider: VisionProvider):
|
| 14 |
+
self.provider = provider
|
| 15 |
+
|
| 16 |
+
async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]:
|
| 17 |
+
phrase = parameters.get("phrase", "object")
|
| 18 |
+
payload = await self.provider.ground_object(image_bytes, phrase)
|
| 19 |
+
objects = payload.get("objects", [])
|
| 20 |
+
confidence = max([o.get("confidence", 0.0) for o in objects] or [0.0])
|
| 21 |
+
provenance = [
|
| 22 |
+
{
|
| 23 |
+
"asset_id": asset.id,
|
| 24 |
+
"bbox": obj.get("bbox"),
|
| 25 |
+
"extraction_method": self.name,
|
| 26 |
+
"confidence": obj.get("confidence", confidence),
|
| 27 |
+
"lens_version": self.version,
|
| 28 |
+
}
|
| 29 |
+
for obj in objects
|
| 30 |
+
]
|
| 31 |
+
return payload, confidence, provenance
|
apps/api/app/lenses/ocr.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.providers.ocr.base import OcrProvider
|
| 4 |
+
from app.storage.models import VisualAssetRecord
|
| 5 |
+
|
| 6 |
+
from .base import Lens
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class OcrLens(Lens):
|
| 10 |
+
name = "glm_ocr"
|
| 11 |
+
view_type = "ocr"
|
| 12 |
+
|
| 13 |
+
def __init__(self, provider: OcrProvider):
|
| 14 |
+
self.provider = provider
|
| 15 |
+
|
| 16 |
+
async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]:
|
| 17 |
+
payload = await self.provider.extract(image_bytes, parameters.get("region"))
|
| 18 |
+
confidence = min([line.get("confidence", 0.0) for line in payload.get("lines", [])] or [0.0])
|
| 19 |
+
provenance = [
|
| 20 |
+
{
|
| 21 |
+
"asset_id": asset.id,
|
| 22 |
+
"bbox": line.get("bbox"),
|
| 23 |
+
"extraction_method": self.name,
|
| 24 |
+
"confidence": line.get("confidence", confidence),
|
| 25 |
+
"lens_version": self.version,
|
| 26 |
+
}
|
| 27 |
+
for line in payload.get("lines", [])
|
| 28 |
+
]
|
| 29 |
+
return payload, confidence, provenance
|
apps/api/app/lenses/palette.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
from app.storage.models import VisualAssetRecord
|
| 9 |
+
|
| 10 |
+
from .base import Lens
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def hex_color(rgb: np.ndarray) -> str:
|
| 14 |
+
r, g, b = [int(max(0, min(255, x))) for x in rgb]
|
| 15 |
+
return f"#{r:02X}{g:02X}{b:02X}"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PaletteLens(Lens):
|
| 19 |
+
name = "palette"
|
| 20 |
+
view_type = "palette"
|
| 21 |
+
|
| 22 |
+
async def execute(self, asset: VisualAssetRecord, image_bytes: bytes, parameters: dict) -> tuple[dict, float, list[dict]]:
|
| 23 |
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB").resize((128, 128))
|
| 24 |
+
pixels = np.array(image).reshape(-1, 3).astype(float)
|
| 25 |
+
luminance = pixels @ np.array([0.2126, 0.7152, 0.0722])
|
| 26 |
+
saturation = pixels.max(axis=1) - pixels.min(axis=1)
|
| 27 |
+
background = pixels[luminance.argsort()[-max(100, len(pixels) // 10):]].mean(axis=0)
|
| 28 |
+
attention_candidates = pixels[saturation.argsort()[-max(100, len(pixels) // 20):]]
|
| 29 |
+
accent = attention_candidates.mean(axis=0)
|
| 30 |
+
primary = pixels[luminance.argsort()[len(pixels) // 4]]
|
| 31 |
+
secondary = pixels[luminance.argsort()[len(pixels) // 2]]
|
| 32 |
+
tokens = {
|
| 33 |
+
"background": hex_color(background),
|
| 34 |
+
"surface": "#FFFFFF",
|
| 35 |
+
"primary": hex_color(primary),
|
| 36 |
+
"secondary": hex_color(secondary),
|
| 37 |
+
"accent": hex_color(accent),
|
| 38 |
+
"text_dark": "#182026",
|
| 39 |
+
"text_light": "#FFFFFF",
|
| 40 |
+
"data_series": [hex_color(primary), hex_color(accent), hex_color(secondary), "#F2CC8F"],
|
| 41 |
+
}
|
| 42 |
+
provenance = {
|
| 43 |
+
key: {
|
| 44 |
+
"asset_id": asset.id,
|
| 45 |
+
"bbox": [0.0, 0.0, 1.0, 1.0],
|
| 46 |
+
"source": "cpu color sampling",
|
| 47 |
+
"pixel_count": int(len(pixels)),
|
| 48 |
+
}
|
| 49 |
+
for key in ["background", "primary", "secondary", "accent"]
|
| 50 |
+
}
|
| 51 |
+
payload = {
|
| 52 |
+
"area_palette": [tokens["background"], tokens["surface"]],
|
| 53 |
+
"attention_palette": [tokens["accent"]],
|
| 54 |
+
"semantic_palette": [tokens["primary"], tokens["secondary"]],
|
| 55 |
+
"tokens": tokens | {"provenance": provenance},
|
| 56 |
+
}
|
| 57 |
+
return payload, 0.78, [{"asset_id": asset.id, "bbox": None, "extraction_method": self.name, "confidence": 0.78, "lens_version": self.version}]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class PresentationThemeLens(PaletteLens):
|
| 61 |
+
name = "presentation_theme"
|
| 62 |
+
view_type = "presentation_theme"
|
apps/api/app/lenses/registry.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.config import Settings
|
| 4 |
+
from app.providers.ocr.fake import FakeOcrProvider
|
| 5 |
+
from app.providers.ocr.glm_ocr import GlmOcrProvider
|
| 6 |
+
from app.providers.ocr.local import LocalOcrProvider
|
| 7 |
+
from app.providers.vision.fake import FakeVisionProvider
|
| 8 |
+
from app.providers.vision.local_vllm import LocalVllmVisionProvider
|
| 9 |
+
from app.providers.vision.zai_glm5v import ZaiGlm5VProvider
|
| 10 |
+
|
| 11 |
+
from .chart import ChartLens
|
| 12 |
+
from .fingerprint import FingerprintLens
|
| 13 |
+
from .object_grounding import ObjectGroundingLens
|
| 14 |
+
from .ocr import OcrLens
|
| 15 |
+
from .palette import PaletteLens, PresentationThemeLens
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def build_lenses(settings: Settings) -> dict[str, object]:
|
| 19 |
+
if settings.visual_runtime_mode == "test":
|
| 20 |
+
vision = FakeVisionProvider()
|
| 21 |
+
ocr = FakeOcrProvider()
|
| 22 |
+
elif settings.visual_runtime_mode == "local":
|
| 23 |
+
vision = LocalVllmVisionProvider(settings)
|
| 24 |
+
ocr = LocalOcrProvider(settings)
|
| 25 |
+
else:
|
| 26 |
+
vision = ZaiGlm5VProvider()
|
| 27 |
+
ocr = GlmOcrProvider()
|
| 28 |
+
lenses = [
|
| 29 |
+
FingerprintLens(),
|
| 30 |
+
PaletteLens(),
|
| 31 |
+
PresentationThemeLens(),
|
| 32 |
+
OcrLens(ocr),
|
| 33 |
+
ObjectGroundingLens(vision),
|
| 34 |
+
ChartLens(),
|
| 35 |
+
]
|
| 36 |
+
return {lens.view_type: lens for lens in lenses}
|
apps/api/app/main.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
|
| 3 |
+
from app.api.chat_completions import router as chat_router
|
| 4 |
+
from app.api.files import router as files_router
|
| 5 |
+
from app.api.health import router as health_router
|
| 6 |
+
from app.api.responses import router as responses_router
|
| 7 |
+
from app.config import get_settings
|
| 8 |
+
from app.orchestration.runtime import Runtime
|
| 9 |
+
|
| 10 |
+
settings = get_settings()
|
| 11 |
+
runtime = Runtime(settings)
|
| 12 |
+
|
| 13 |
+
app = FastAPI(
|
| 14 |
+
title="GLM Visual Variable Runtime",
|
| 15 |
+
version="0.1.0",
|
| 16 |
+
description="OpenAI-compatible multimodal gateway for glm-5.2-visual-runtime.",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
app.include_router(health_router)
|
| 20 |
+
app.include_router(files_router)
|
| 21 |
+
app.include_router(responses_router)
|
| 22 |
+
app.include_router(chat_router)
|
apps/api/app/orchestration/__pycache__/evidence_compiler.cpython-313.pyc
ADDED
|
Binary file (1.17 kB). View file
|
|
|
apps/api/app/orchestration/__pycache__/intent_router.cpython-313.pyc
ADDED
|
Binary file (2.63 kB). View file
|
|
|
apps/api/app/orchestration/__pycache__/reference_resolver.cpython-313.pyc
ADDED
|
Binary file (3.01 kB). View file
|
|
|
apps/api/app/orchestration/__pycache__/runtime.cpython-313.pyc
ADDED
|
Binary file (18.1 kB). View file
|
|
|
apps/api/app/orchestration/__pycache__/visual_compiler.cpython-313.pyc
ADDED
|
Binary file (1.65 kB). View file
|
|
|
apps/api/app/orchestration/evidence_compiler.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.storage.models import VisualViewRecord
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def compile_evidence(text: str, views: list[VisualViewRecord]) -> dict:
|
| 7 |
+
evidence = {
|
| 8 |
+
"request": text,
|
| 9 |
+
"visual_context": [],
|
| 10 |
+
}
|
| 11 |
+
for view in views:
|
| 12 |
+
payload = view.payload_json
|
| 13 |
+
if view.view_type == "ocr":
|
| 14 |
+
payload = {
|
| 15 |
+
"lines": payload.get("lines", [])[:8],
|
| 16 |
+
"markdown": payload.get("markdown", "")[:1000],
|
| 17 |
+
}
|
| 18 |
+
evidence["visual_context"].append(
|
| 19 |
+
{
|
| 20 |
+
"asset_id": view.asset_id,
|
| 21 |
+
"view_type": view.view_type,
|
| 22 |
+
"confidence": view.confidence,
|
| 23 |
+
"payload": payload,
|
| 24 |
+
"provenance": view.provenance_json,
|
| 25 |
+
}
|
| 26 |
+
)
|
| 27 |
+
return evidence
|
apps/api/app/orchestration/intent_router.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def route_intent(text: str) -> tuple[str, list[str]]:
|
| 5 |
+
lowered = text.lower()
|
| 6 |
+
views: list[str] = []
|
| 7 |
+
intent = "answer_from_evidence"
|
| 8 |
+
if any(k in lowered for k in ["color", "colors", "palette", "theme", "style"]):
|
| 9 |
+
views.append("presentation_theme" if any(k in lowered for k in ["presentation", "deck", "slides", "powerpoint"]) else "palette")
|
| 10 |
+
intent = "create_presentation" if any(k in lowered for k in ["presentation", "deck", "slides", "powerpoint"]) else "extract_palette"
|
| 11 |
+
if any(k in lowered for k in ["read", "text", "number", "invoice", "receipt", "serial", "label", "total"]):
|
| 12 |
+
views.append("ocr")
|
| 13 |
+
intent = "answer_ocr"
|
| 14 |
+
if any(k in lowered for k in ["chart", "graph", "axis", "trend", "quarter", "q3", "percentage", "revenue"]):
|
| 15 |
+
views.append("chart_ir")
|
| 16 |
+
intent = "query_chart"
|
| 17 |
+
if any(k in lowered for k in ["object", "chair", "car", "shirt", "logo", "item"]):
|
| 18 |
+
views.append("objects")
|
| 19 |
+
if "color" in lowered:
|
| 20 |
+
views.append("palette")
|
| 21 |
+
return intent, list(dict.fromkeys(views))
|
apps/api/app/orchestration/reference_resolver.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from app.storage.models import ConversationRecord, VisualAssetRecord
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def resolve_asset(text: str, conversation: ConversationRecord, assets: list[VisualAssetRecord], current_turn_assets: list[VisualAssetRecord]) -> VisualAssetRecord | None:
|
| 9 |
+
if current_turn_assets and re.search(r"\b(this|current)\s+image\b", text, re.I):
|
| 10 |
+
return current_turn_assets[-1]
|
| 11 |
+
ordinal = re.search(r"\b(first|second|third|1st|2nd|3rd)\s+image\b", text, re.I)
|
| 12 |
+
if ordinal:
|
| 13 |
+
value = ordinal.group(1).lower()
|
| 14 |
+
index = {"first": 0, "1st": 0, "second": 1, "2nd": 1, "third": 2, "3rd": 2}[value]
|
| 15 |
+
return assets[index] if len(assets) > index else None
|
| 16 |
+
if re.search(r"\b(previous)\s+image\b", text, re.I) and conversation.active_asset_id:
|
| 17 |
+
ordered = assets
|
| 18 |
+
for idx, asset in enumerate(ordered):
|
| 19 |
+
if asset.id == conversation.active_asset_id and idx > 0:
|
| 20 |
+
return ordered[idx - 1]
|
| 21 |
+
if conversation.last_referenced_asset_id:
|
| 22 |
+
return next((a for a in assets if a.id == conversation.last_referenced_asset_id), None)
|
| 23 |
+
if conversation.active_asset_id:
|
| 24 |
+
return next((a for a in assets if a.id == conversation.active_asset_id), None)
|
| 25 |
+
return assets[-1] if assets else None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def property_view_for_phrase(text: str, default_views: list[str]) -> str | None:
|
| 29 |
+
lowered = text.lower()
|
| 30 |
+
if "those colors" in lowered or "style" in lowered:
|
| 31 |
+
return "presentation_theme"
|
| 32 |
+
if "that chart" in lowered or "data" in lowered or "q3" in lowered:
|
| 33 |
+
return "chart_ir"
|
| 34 |
+
if "total" in lowered or "receipt" in lowered or "text" in lowered:
|
| 35 |
+
return "ocr"
|
| 36 |
+
return default_views[0] if default_views else None
|
apps/api/app/orchestration/runtime.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import io
|
| 5 |
+
import ipaddress
|
| 6 |
+
import json
|
| 7 |
+
import socket
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
from app.config import Settings
|
| 14 |
+
from app.lenses.base import canonical_hash
|
| 15 |
+
from app.lenses.registry import build_lenses
|
| 16 |
+
from app.providers.llm.openai_compatible import FakeLlmProvider
|
| 17 |
+
from app.providers.llm.zai_glm52 import ZaiGlm52Provider
|
| 18 |
+
from app.schemas.openai import ResponsesRequest
|
| 19 |
+
from app.storage.models import ConversationRecord, FileRecord, VisualAssetRecord, VisualViewRecord
|
| 20 |
+
from app.storage.object_store import ObjectStore
|
| 21 |
+
from app.storage.repositories import JsonLedger
|
| 22 |
+
|
| 23 |
+
from .evidence_compiler import compile_evidence
|
| 24 |
+
from .intent_router import route_intent
|
| 25 |
+
from .reference_resolver import property_view_for_phrase, resolve_asset
|
| 26 |
+
from .visual_compiler import compile_visual_plan
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Runtime:
|
| 30 |
+
def __init__(self, settings: Settings):
|
| 31 |
+
self.settings = settings
|
| 32 |
+
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
self.store = ObjectStore(settings.data_dir / "objects")
|
| 34 |
+
self.ledger = JsonLedger(settings.data_dir / "ledger.json")
|
| 35 |
+
self.lenses = build_lenses(settings)
|
| 36 |
+
self.llm = FakeLlmProvider() if settings.visual_runtime_mode == "test" else ZaiGlm52Provider(settings)
|
| 37 |
+
|
| 38 |
+
async def create_file(self, filename: str, data: bytes, mime_type: str) -> FileRecord:
|
| 39 |
+
self._validate_image(data, mime_type)
|
| 40 |
+
uri, sha256 = self.store.put_bytes(data, suffix=self._suffix_for_mime(mime_type))
|
| 41 |
+
return self.ledger.create_file(filename, mime_type, len(data), uri, sha256)
|
| 42 |
+
|
| 43 |
+
def get_file(self, file_id: str) -> FileRecord | None:
|
| 44 |
+
return self.ledger.get_file(file_id)
|
| 45 |
+
|
| 46 |
+
def get_file_bytes(self, file_id: str) -> tuple[FileRecord, bytes]:
|
| 47 |
+
record = self.ledger.get_file(file_id)
|
| 48 |
+
if not record:
|
| 49 |
+
raise KeyError(file_id)
|
| 50 |
+
return record, self.store.get_bytes(record.uri)
|
| 51 |
+
|
| 52 |
+
async def handle_responses(self, request: ResponsesRequest) -> dict:
|
| 53 |
+
conversation = self._conversation_for_request(request)
|
| 54 |
+
text, image_refs = self._extract_text_and_images(request.input)
|
| 55 |
+
current_assets: list[VisualAssetRecord] = []
|
| 56 |
+
for image_ref in image_refs:
|
| 57 |
+
asset = await self._ingest_image_ref(conversation, image_ref)
|
| 58 |
+
current_assets.append(asset)
|
| 59 |
+
await self._get_view(asset, "fingerprint", {})
|
| 60 |
+
|
| 61 |
+
all_assets = self.ledger.list_assets(conversation.id)
|
| 62 |
+
intent, routed_views = route_intent(text)
|
| 63 |
+
selected_asset = resolve_asset(text, conversation, all_assets, current_assets)
|
| 64 |
+
preferred_view = property_view_for_phrase(text, routed_views)
|
| 65 |
+
if preferred_view and preferred_view not in routed_views:
|
| 66 |
+
routed_views.insert(0, preferred_view)
|
| 67 |
+
plan = compile_visual_plan(intent, text, selected_asset, routed_views)
|
| 68 |
+
|
| 69 |
+
executed_views: list[VisualViewRecord] = []
|
| 70 |
+
for op in plan.operations:
|
| 71 |
+
if op.op == "get_view" and op.asset_id and op.view:
|
| 72 |
+
asset = self.ledger.get_asset(op.asset_id)
|
| 73 |
+
if asset:
|
| 74 |
+
executed_views.append(await self._get_view(asset, op.view, op.parameters))
|
| 75 |
+
|
| 76 |
+
evidence = compile_evidence(text, executed_views)
|
| 77 |
+
output_text, artifacts = await self._answer(intent, text, evidence, executed_views)
|
| 78 |
+
|
| 79 |
+
if selected_asset:
|
| 80 |
+
conversation.active_asset_id = selected_asset.id
|
| 81 |
+
conversation.last_referenced_asset_id = selected_asset.id
|
| 82 |
+
if preferred_view:
|
| 83 |
+
conversation.active_view_type = preferred_view
|
| 84 |
+
conversation.last_referenced_property = preferred_view
|
| 85 |
+
self.ledger.update_conversation(conversation)
|
| 86 |
+
|
| 87 |
+
response_json = {
|
| 88 |
+
"model": self.settings.public_model_id,
|
| 89 |
+
"output_text": output_text,
|
| 90 |
+
"plan": plan.model_dump(),
|
| 91 |
+
"evidence": evidence,
|
| 92 |
+
"artifacts": artifacts,
|
| 93 |
+
}
|
| 94 |
+
response_record = self.ledger.create_response(
|
| 95 |
+
conversation.id,
|
| 96 |
+
request.previous_response_id,
|
| 97 |
+
request.model_dump(mode="json"),
|
| 98 |
+
response_json,
|
| 99 |
+
)
|
| 100 |
+
return {
|
| 101 |
+
"id": response_record.id,
|
| 102 |
+
"object": "response",
|
| 103 |
+
"created_at": response_record.created_at,
|
| 104 |
+
"status": "completed",
|
| 105 |
+
"model": self.settings.public_model_id,
|
| 106 |
+
"conversation_id": conversation.id,
|
| 107 |
+
"output": [
|
| 108 |
+
{
|
| 109 |
+
"id": f"msg_{response_record.id}",
|
| 110 |
+
"type": "message",
|
| 111 |
+
"role": "assistant",
|
| 112 |
+
"content": [{"type": "output_text", "text": output_text}],
|
| 113 |
+
}
|
| 114 |
+
],
|
| 115 |
+
"output_text": output_text,
|
| 116 |
+
"artifacts": artifacts,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
def _conversation_for_request(self, request: ResponsesRequest) -> ConversationRecord:
|
| 120 |
+
if request.previous_response_id:
|
| 121 |
+
previous = self.ledger.get_response(request.previous_response_id)
|
| 122 |
+
if previous:
|
| 123 |
+
conversation = self.ledger.get_conversation(previous.conversation_id)
|
| 124 |
+
if conversation:
|
| 125 |
+
return conversation
|
| 126 |
+
return self.ledger.create_conversation()
|
| 127 |
+
|
| 128 |
+
def _extract_text_and_images(self, input_value) -> tuple[str, list[dict]]:
|
| 129 |
+
if isinstance(input_value, str):
|
| 130 |
+
return input_value, []
|
| 131 |
+
texts: list[str] = []
|
| 132 |
+
images: list[dict] = []
|
| 133 |
+
for message in input_value:
|
| 134 |
+
content = message.get("content", []) if isinstance(message, dict) else message.content
|
| 135 |
+
if isinstance(content, str):
|
| 136 |
+
texts.append(content)
|
| 137 |
+
continue
|
| 138 |
+
for part in content:
|
| 139 |
+
if part.get("type") in {"input_text", "text"}:
|
| 140 |
+
texts.append(part.get("text", ""))
|
| 141 |
+
if part.get("type") == "input_image":
|
| 142 |
+
images.append(part)
|
| 143 |
+
if part.get("type") == "image_url":
|
| 144 |
+
image_url = part.get("image_url", {})
|
| 145 |
+
images.append({"type": "input_image", "image_url": image_url.get("url") if isinstance(image_url, dict) else image_url})
|
| 146 |
+
return "\n".join(t for t in texts if t), images
|
| 147 |
+
|
| 148 |
+
async def _ingest_image_ref(self, conversation: ConversationRecord, image_ref: dict) -> VisualAssetRecord:
|
| 149 |
+
source_file_id = image_ref.get("file_id")
|
| 150 |
+
if source_file_id:
|
| 151 |
+
file_record, data = self.get_file_bytes(source_file_id)
|
| 152 |
+
mime_type = file_record.mime_type
|
| 153 |
+
uri = file_record.uri
|
| 154 |
+
sha256 = file_record.sha256
|
| 155 |
+
else:
|
| 156 |
+
image_url = image_ref.get("image_url")
|
| 157 |
+
data, mime_type = await self._load_image_url(image_url)
|
| 158 |
+
uri, sha256 = self.store.put_bytes(data, suffix=self._suffix_for_mime(mime_type))
|
| 159 |
+
source_file_id = self.ledger.create_file("snapshotted-url-image", mime_type, len(data), uri, sha256).id
|
| 160 |
+
width, height = self._image_dimensions(data)
|
| 161 |
+
return self.ledger.create_asset(conversation.id, source_file_id, sha256, uri, mime_type, width, height)
|
| 162 |
+
|
| 163 |
+
async def _get_view(self, asset: VisualAssetRecord, view_type: str, parameters: dict) -> VisualViewRecord:
|
| 164 |
+
lens = self.lenses[view_type]
|
| 165 |
+
parameters_hash = canonical_hash(parameters)
|
| 166 |
+
cached = self.ledger.find_view(asset.sha256, lens.name, lens.version, parameters_hash)
|
| 167 |
+
if cached:
|
| 168 |
+
return cached
|
| 169 |
+
image_bytes = self.store.get_bytes(asset.original_uri)
|
| 170 |
+
view = await lens.run(asset, image_bytes, parameters)
|
| 171 |
+
return self.ledger.save_view(view)
|
| 172 |
+
|
| 173 |
+
async def _answer(self, intent: str, text: str, evidence: dict, views: list[VisualViewRecord]) -> tuple[str, list[dict]]:
|
| 174 |
+
artifacts: list[dict] = []
|
| 175 |
+
if intent == "answer_ocr":
|
| 176 |
+
for view in views:
|
| 177 |
+
if view.view_type == "ocr" and view.payload_json.get("lines"):
|
| 178 |
+
line = view.payload_json["lines"][0]
|
| 179 |
+
return f"{line['text']} (source bbox: {line['bbox']}, confidence: {line['confidence']})", artifacts
|
| 180 |
+
if intent == "query_chart":
|
| 181 |
+
for view in views:
|
| 182 |
+
if view.view_type == "chart_ir":
|
| 183 |
+
points = view.payload_json.get("series", [{}])[0].get("points", [])
|
| 184 |
+
match = next((p for p in points if str(p[0]).lower() == "q3"), None)
|
| 185 |
+
if match:
|
| 186 |
+
return f"Q3 value is {match[1]} (from ChartIR, confidence: {view.confidence}).", artifacts
|
| 187 |
+
if intent == "create_presentation":
|
| 188 |
+
artifacts.append({"type": "pptx", "status": "planned", "note": "PptxGenJS renderer service contract is available at /render"})
|
| 189 |
+
return "I prepared a presentation plan using the resolved visual theme. The PPTX renderer service can turn this into an editable deck.", artifacts
|
| 190 |
+
content = await self.llm.complete(
|
| 191 |
+
[
|
| 192 |
+
{"role": "developer", "content": "You are GLM-5.2 reasoning over compact visual evidence from the Visual Variable Runtime."},
|
| 193 |
+
{"role": "user", "content": json.dumps(evidence, ensure_ascii=False)},
|
| 194 |
+
]
|
| 195 |
+
)
|
| 196 |
+
return content, artifacts
|
| 197 |
+
|
| 198 |
+
async def _load_image_url(self, image_url: str | None) -> tuple[bytes, str]:
|
| 199 |
+
if not image_url:
|
| 200 |
+
raise ValueError("input_image requires image_url or file_id")
|
| 201 |
+
if image_url.startswith("data:"):
|
| 202 |
+
header, encoded = image_url.split(",", 1)
|
| 203 |
+
mime_type = header.split(";")[0].removeprefix("data:")
|
| 204 |
+
data = base64.b64decode(encoded)
|
| 205 |
+
self._validate_image(data, mime_type)
|
| 206 |
+
return data, mime_type
|
| 207 |
+
self._validate_public_url(image_url)
|
| 208 |
+
async with httpx.AsyncClient(follow_redirects=False, timeout=20) as client:
|
| 209 |
+
response = await client.get(image_url)
|
| 210 |
+
for _ in range(3):
|
| 211 |
+
if response.status_code not in {301, 302, 303, 307, 308}:
|
| 212 |
+
break
|
| 213 |
+
location = response.headers.get("location")
|
| 214 |
+
self._validate_public_url(location)
|
| 215 |
+
response = await client.get(location)
|
| 216 |
+
response.raise_for_status()
|
| 217 |
+
mime_type = response.headers.get("content-type", "application/octet-stream").split(";")[0]
|
| 218 |
+
data = response.content
|
| 219 |
+
self._validate_image(data, mime_type)
|
| 220 |
+
return data, mime_type
|
| 221 |
+
|
| 222 |
+
def _validate_public_url(self, url: str | None) -> None:
|
| 223 |
+
if not url:
|
| 224 |
+
raise ValueError("empty URL")
|
| 225 |
+
parsed = urlparse(url)
|
| 226 |
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
| 227 |
+
raise ValueError("unsupported image URL")
|
| 228 |
+
host = parsed.hostname.lower()
|
| 229 |
+
if host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local"):
|
| 230 |
+
raise ValueError("private image URL rejected")
|
| 231 |
+
for result in socket.getaddrinfo(host, None):
|
| 232 |
+
ip = ipaddress.ip_address(result[4][0])
|
| 233 |
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
| 234 |
+
raise ValueError("private image URL rejected")
|
| 235 |
+
|
| 236 |
+
def _validate_image(self, data: bytes, mime_type: str) -> None:
|
| 237 |
+
if len(data) > self.settings.max_image_bytes:
|
| 238 |
+
raise ValueError("image exceeds max size")
|
| 239 |
+
if mime_type not in {"image/png", "image/jpeg", "image/webp"}:
|
| 240 |
+
raise ValueError("unsupported image MIME type")
|
| 241 |
+
width, height = self._image_dimensions(data)
|
| 242 |
+
if width * height > self.settings.max_image_pixels:
|
| 243 |
+
raise ValueError("image dimensions exceed limit")
|
| 244 |
+
|
| 245 |
+
def _image_dimensions(self, data: bytes) -> tuple[int, int]:
|
| 246 |
+
image = Image.open(io.BytesIO(data))
|
| 247 |
+
image.verify()
|
| 248 |
+
image = Image.open(io.BytesIO(data))
|
| 249 |
+
return image.size
|
| 250 |
+
|
| 251 |
+
def _suffix_for_mime(self, mime_type: str) -> str:
|
| 252 |
+
return {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp"}.get(mime_type, "")
|
apps/api/app/orchestration/visual_compiler.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.schemas.plans import VisualOperation, VisualPlan, VisualReference
|
| 4 |
+
from app.storage.models import VisualAssetRecord
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def compile_visual_plan(intent: str, text: str, asset: VisualAssetRecord | None, views: list[str]) -> VisualPlan:
|
| 8 |
+
references = []
|
| 9 |
+
operations = []
|
| 10 |
+
if asset:
|
| 11 |
+
for idx, view in enumerate(views, start=1):
|
| 12 |
+
references.append(VisualReference(phrase=text[:80], asset_id=asset.id, view=view, confidence=0.86))
|
| 13 |
+
operations.append(VisualOperation(id=f"op_{idx}", op="get_view", asset_id=asset.id, view=view))
|
| 14 |
+
if intent == "create_presentation":
|
| 15 |
+
operations.append(VisualOperation(id=f"op_{len(operations)+1}", op="create_presentation", asset_id=asset.id if asset else None, parameters={"content_request": text}))
|
| 16 |
+
if not operations:
|
| 17 |
+
operations.append(VisualOperation(id="op_1", op="answer_from_evidence", parameters={"content_request": text}))
|
| 18 |
+
return VisualPlan(intent=intent, references=references, operations=operations)
|
apps/api/app/providers/llm/__pycache__/base.cpython-313.pyc
ADDED
|
Binary file (874 Bytes). View file
|
|
|
apps/api/app/providers/llm/__pycache__/openai_compatible.cpython-313.pyc
ADDED
|
Binary file (1.45 kB). View file
|
|
|
apps/api/app/providers/llm/__pycache__/zai_glm52.cpython-313.pyc
ADDED
|
Binary file (2.28 kB). View file
|
|
|
apps/api/app/providers/llm/base.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class LlmProvider(ABC):
|
| 8 |
+
@abstractmethod
|
| 9 |
+
async def complete(self, messages: list[dict[str, Any]], response_format: dict[str, Any] | None = None) -> str:
|
| 10 |
+
raise NotImplementedError
|
apps/api/app/providers/llm/openai_compatible.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from .base import LlmProvider
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FakeLlmProvider(LlmProvider):
|
| 9 |
+
async def complete(self, messages: list[dict[str, Any]], response_format: dict[str, Any] | None = None) -> str:
|
| 10 |
+
user_text = " ".join(str(m.get("content", "")) for m in messages if m.get("role") == "user")
|
| 11 |
+
if response_format:
|
| 12 |
+
return '{"intent":"answer_from_evidence","references":[],"operations":[]}'
|
| 13 |
+
return f"glm-5.2-visual-runtime received: {user_text}".strip()
|
apps/api/app/providers/llm/zai_glm52.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from app.config import Settings
|
| 8 |
+
|
| 9 |
+
from .base import LlmProvider
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ZaiGlm52Provider(LlmProvider):
|
| 13 |
+
def __init__(self, settings: Settings):
|
| 14 |
+
self.settings = settings
|
| 15 |
+
|
| 16 |
+
async def complete(self, messages: list[dict[str, Any]], response_format: dict[str, Any] | None = None) -> str:
|
| 17 |
+
if not self.settings.glm_api_key:
|
| 18 |
+
raise RuntimeError("GLM_API_KEY is required outside test mode")
|
| 19 |
+
payload: dict[str, Any] = {
|
| 20 |
+
"model": self.settings.glm_model,
|
| 21 |
+
"messages": messages,
|
| 22 |
+
}
|
| 23 |
+
if response_format:
|
| 24 |
+
payload["response_format"] = response_format
|
| 25 |
+
async with httpx.AsyncClient(timeout=60) as client:
|
| 26 |
+
response = await client.post(
|
| 27 |
+
f"{self.settings.glm_base_url.rstrip('/')}/chat/completions",
|
| 28 |
+
headers={"Authorization": f"Bearer {self.settings.glm_api_key}"},
|
| 29 |
+
json=payload,
|
| 30 |
+
)
|
| 31 |
+
response.raise_for_status()
|
| 32 |
+
data = response.json()
|
| 33 |
+
return data["choices"][0]["message"]["content"]
|
apps/api/app/providers/ocr/__pycache__/base.cpython-313.pyc
ADDED
|
Binary file (836 Bytes). View file
|
|
|
apps/api/app/providers/ocr/__pycache__/fake.cpython-313.pyc
ADDED
|
Binary file (1.05 kB). View file
|
|
|
apps/api/app/providers/ocr/__pycache__/glm_ocr.cpython-313.pyc
ADDED
|
Binary file (602 Bytes). View file
|
|
|
apps/api/app/providers/ocr/__pycache__/local.cpython-313.pyc
ADDED
|
Binary file (2.09 kB). View file
|
|
|
apps/api/app/providers/ocr/base.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class OcrProvider(ABC):
|
| 7 |
+
@abstractmethod
|
| 8 |
+
async def extract(self, image_bytes: bytes, region: tuple[float, float, float, float] | None = None) -> dict:
|
| 9 |
+
raise NotImplementedError
|