Spaces:
Running
Running
Cuong2004 commited on
Commit ·
b6b8e8b
0
Parent(s):
Deploy API from GitHub Actions
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +48 -0
- README.md +33 -0
- agents/__init__.py +14 -0
- agents/deepmath_solver_agent.py +302 -0
- agents/geometry_parser_agent.py +159 -0
- agents/knowledge_agent.py +175 -0
- agents/ocr_agent.py +89 -0
- agents/orchestrator.py +330 -0
- agents/renderer_agent.py +5 -0
- agents/runtime.py +122 -0
- agents/torch_ultralytics_compat.py +5 -0
- agents/vlm_corrector.py +269 -0
- app/__init__.py +0 -0
- app/celery_app.py +50 -0
- app/chat_image_upload.py +253 -0
- app/dependencies.py +80 -0
- app/errors.py +59 -0
- app/job_poll.py +82 -0
- app/jobs/__init__.py +0 -0
- app/llm_client.py +53 -0
- app/logging_setup.py +112 -0
- app/logutil.py +67 -0
- app/main.py +143 -0
- app/models/__init__.py +0 -0
- app/models/job_state.py +132 -0
- app/models/schemas.py +81 -0
- app/ocr_celery.py +86 -0
- app/ocr_local_file.py +75 -0
- app/ocr_text_merge.py +14 -0
- app/routers/__init__.py +1 -0
- app/routers/ai_core.py +145 -0
- app/routers/auth.py +50 -0
- app/routers/sessions.py +206 -0
- app/routers/solve.py +397 -0
- app/runtime_env.py +12 -0
- app/session_cache.py +26 -0
- app/supabase_client.py +45 -0
- app/tasks.py +402 -0
- app/url_utils.py +23 -0
- app/websocket_manager.py +87 -0
- clean_ports.sh +22 -0
- config/__init__.py +16 -0
- config/agent_models.yaml +85 -0
- config/loader.py +85 -0
- config/schemas.py +76 -0
- config/settings.py +75 -0
- dump.rdb +0 -0
- eval/__init__.py +24 -0
- eval/benchmark.py +71 -0
- eval/datasets/README.md +33 -0
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Visual Math Solver — API container (Python 3.11 + Manim + OCR stack)
|
| 2 |
+
FROM python:3.11-slim-bookworm
|
| 3 |
+
|
| 4 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PIP_NO_CACHE_DIR=1 \
|
| 7 |
+
PIP_ROOT_USER_ACTION=ignore \
|
| 8 |
+
NO_ALBUMENTATIONS_UPDATE=1 \
|
| 9 |
+
OMP_NUM_THREADS=1 \
|
| 10 |
+
MKL_NUM_THREADS=1 \
|
| 11 |
+
OPENBLAS_NUM_THREADS=1
|
| 12 |
+
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
ENV PYTHONPATH=/app
|
| 15 |
+
|
| 16 |
+
# Runtime + *-dev: Manim/pycairo need pkg-config + cairo headers; libpango1.0-dev covers PangoCairo on Bookworm (no libpangocairo-*-dev package).
|
| 17 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 18 |
+
ffmpeg \
|
| 19 |
+
pkg-config \
|
| 20 |
+
cmake \
|
| 21 |
+
libcairo2 \
|
| 22 |
+
libcairo2-dev \
|
| 23 |
+
libpango-1.0-0 \
|
| 24 |
+
libpango1.0-dev \
|
| 25 |
+
libpangocairo-1.0-0 \
|
| 26 |
+
libgdk-pixbuf-2.0-0 \
|
| 27 |
+
libffi-dev \
|
| 28 |
+
python3-dev \
|
| 29 |
+
texlive-latex-base \
|
| 30 |
+
texlive-fonts-recommended \
|
| 31 |
+
texlive-latex-extra \
|
| 32 |
+
build-essential \
|
| 33 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 34 |
+
|
| 35 |
+
COPY requirements.txt .
|
| 36 |
+
RUN pip install --upgrade pip setuptools wheel \
|
| 37 |
+
&& pip install -r requirements.txt
|
| 38 |
+
|
| 39 |
+
COPY . .
|
| 40 |
+
|
| 41 |
+
# Bake model weights and agent init into the image (YOLO, PaddleOCR, Pix2Tex, etc.)
|
| 42 |
+
RUN python scripts/prewarm_models.py
|
| 43 |
+
|
| 44 |
+
# Hugging Face Spaces defaults to 7860; docker-compose can set PORT=8000
|
| 45 |
+
ENV PORT=7860
|
| 46 |
+
EXPOSE 7860
|
| 47 |
+
|
| 48 |
+
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
|
README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Math Solver Backend
|
| 3 |
+
emoji: 📐
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Visual Math Solver - Backend (Hugging Face Space)
|
| 12 |
+
|
| 13 |
+
Hệ thống AI giải toán hình học sử dụng Multi-Agent và Manim. Hiện đã nâng cấp lên phiên bản **v5.1**.
|
| 14 |
+
|
| 15 |
+
## Tính năng mới (v5.1)
|
| 16 |
+
- **Symbolic Solver**: Tích hợp SymPy để tự động hóa việc tính toán các giá trị hình học (diện tích, chu vi, độ dài) với độ chính xác tuyệt đối và trình bày các bước giải chi tiết.
|
| 17 |
+
- **3D Video Support**: Nâng cấp công cụ Manim để hỗ trợ hiển thị và xoay camera cho các bài toán hình học không gian (Hình chóp, Hình lăng trụ, v.v.).
|
| 18 |
+
|
| 19 |
+
## Kiến trúc Pipeline (Agentic Flow)
|
| 20 |
+
1. **OCR Agent**: Nhận diện văn bản từ hình ảnh câu hỏi.
|
| 21 |
+
2. **Parser Agent**: Chuyển đổi ngôn ngữ tự nhiên thành Geometry DSL.
|
| 22 |
+
3. **Knowledge Agent**: Bổ sung kiến thức chuyên sâu về hình học.
|
| 23 |
+
4. **Geometry Engine**: Giải hệ phương trình tọa độ để dựng hình.
|
| 24 |
+
5. **Solver Agent (New)**: Thực hiện các phép tính toán học hình thức (Symbolic Math).
|
| 25 |
+
6. **Renderer Agent**: Sinh mã Manim và render video (hỗ trợ cả 2D và 3D).
|
| 26 |
+
|
| 27 |
+
## Triển khai
|
| 28 |
+
Space này chạy Docker container chứa FastAPI và môi trường Manim. Để chạy cục bộ, tham khảo `setup.sh` và `.env.example`.
|
| 29 |
+
|
| 30 |
+
## Kiểm thử (pytest)
|
| 31 |
+
- **Nhanh (mặc định):** từ thư mục `backend`, cài `pip install -r requirements.txt`, rồi `PYTHONPATH=. python -m pytest tests/`. Các marker `real_api`, `real_agents`, `slow`, v.v. bị loại theo `pytest.ini` để không cần server hay API key.
|
| 32 |
+
- **CI API (mock video + eager Celery):** `chmod +x scripts/run_real_integration.sh && ./scripts/run_real_integration.sh ci` — khởi động API, chạy smoke + full suite, ghi `integration_report.md` và `temp_suite_results.json`.
|
| 33 |
+
- **Tích hợp thật (worker / Manim / OpenRouter):** `./scripts/run_real_integration.sh real` với backend + worker đang chạy (Redis, `.env` đầy đủ). Bật từng phần bằng `RUN_REAL_WORKER_OCR=1`, `RUN_REAL_WORKER_MANIM=1` (cần `MOCK_VIDEO=false`). Đặt `TEST_SUPABASE_USER_ID` trong `.env` cho user Supabase hợp lệ (xem `.env.example`).
|
agents/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.geometry_parser_agent import GeometryParserAgent
|
| 2 |
+
from agents.deepmath_solver_agent import DeepMathSolverAgent
|
| 3 |
+
from agents.ocr_agent import OCRAgent
|
| 4 |
+
from agents.orchestrator import Orchestrator
|
| 5 |
+
from agents.runtime import AgentRuntime, get_agent_runtime
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"GeometryParserAgent",
|
| 9 |
+
"DeepMathSolverAgent",
|
| 10 |
+
"OCRAgent",
|
| 11 |
+
"Orchestrator",
|
| 12 |
+
"AgentRuntime",
|
| 13 |
+
"get_agent_runtime",
|
| 14 |
+
]
|
agents/deepmath_solver_agent.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
import math
|
| 5 |
+
from typing import Dict, Any, List, Optional, Tuple, Union
|
| 6 |
+
import sympy as sp
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
from agents.runtime import get_agent_runtime, AgentRuntime
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class DeepMathSolverAgent:
|
| 16 |
+
"""
|
| 17 |
+
DeepMath Solver Agent (v7.0 - Agent Runtime & Cascading Controller):
|
| 18 |
+
Implements a strict Program-Aided Mathematical Reasoning architecture.
|
| 19 |
+
1. Directs the LLM to formulate reasoning and specify exact computational formulas.
|
| 20 |
+
2. ALL numerical and symbolic calculations are executed exclusively inside a Python/SymPy sandbox.
|
| 21 |
+
3. Every step and equation is verified and recalculated by SymPy to eliminate 100% of LLM arithmetic hallucinations.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, runtime: Optional[AgentRuntime] = None):
|
| 25 |
+
self.runtime = runtime or get_agent_runtime()
|
| 26 |
+
|
| 27 |
+
async def solve(
|
| 28 |
+
self,
|
| 29 |
+
problem_text: str,
|
| 30 |
+
target_question: Optional[str] = None,
|
| 31 |
+
semantic_data: Optional[Dict[str, Any]] = None,
|
| 32 |
+
geometry_context: Optional[Dict[str, Any]] = None,
|
| 33 |
+
) -> Dict[str, Any]:
|
| 34 |
+
target = target_question or (semantic_data.get("target_question") if semantic_data else None) or problem_text
|
| 35 |
+
logger.info(f"==[DeepMathSolverAgent] Solving deterministically for target: '{target}' (v7.0)==")
|
| 36 |
+
|
| 37 |
+
system_prompt = """You are DeepMath, an expert Mathematical & Geometric Reasoning Agent.
|
| 38 |
+
Your task is to provide a rigorous, step-by-step solution to the given Vietnamese geometry problem.
|
| 39 |
+
|
| 40 |
+
=== CRITICAL COMPUTATION RULE ===
|
| 41 |
+
DO NOT do mental arithmetic or hardcode calculated results yourself.
|
| 42 |
+
Instead:
|
| 43 |
+
1. State the geometric theorem/formula clearly in Vietnamese.
|
| 44 |
+
2. Provide executable Python code blocks enclosed in ```python ... ``` using `sympy` to compute all numerical/symbolic values.
|
| 45 |
+
3. Define structured calculations in the final JSON.
|
| 46 |
+
|
| 47 |
+
=== OUTPUT FORMAT ===
|
| 48 |
+
Output your complete explanation, followed by a structured JSON block enclosed in ```json ... ```:
|
| 49 |
+
{
|
| 50 |
+
"calculations": [
|
| 51 |
+
{
|
| 52 |
+
"name": "S_day",
|
| 53 |
+
"formula": "a**2",
|
| 54 |
+
"inputs": {"a": 10},
|
| 55 |
+
"description": "Tính diện tích đáy hình vuông ABCD"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"name": "V",
|
| 59 |
+
"formula": "sp.Rational(1, 3) * S_day * h",
|
| 60 |
+
"inputs": {"h": 15},
|
| 61 |
+
"description": "Tính thể tích khối chóp S.ABCD"
|
| 62 |
+
}
|
| 63 |
+
],
|
| 64 |
+
"steps": [
|
| 65 |
+
"Bước 1: Tính diện tích đáy ABCD...",
|
| 66 |
+
"Bước 2: Xác định chiều cao SO...",
|
| 67 |
+
"Bước 3: Áp dụng công thức thể tích khối chóp..."
|
| 68 |
+
],
|
| 69 |
+
"python_code": "import sympy as sp\\na = 10\\nh = 15\\nS_day = a**2\\nV = sp.Rational(1, 3) * S_day * h\\nprint(V)",
|
| 70 |
+
"target_variable": "V"
|
| 71 |
+
}
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
user_content = f"Đề bài toán:\n{problem_text}\n\nYêu cầu cần tính:\n{target}"
|
| 75 |
+
if semantic_data and semantic_data.get("values"):
|
| 76 |
+
user_content += f"\n\nCác thông số đã biết: {json.dumps(semantic_data['values'], ensure_ascii=False)}"
|
| 77 |
+
|
| 78 |
+
if geometry_context and geometry_context.get("points"):
|
| 79 |
+
pt_summary = {k: v for k, v in list(geometry_context["points"].items())[:8]}
|
| 80 |
+
user_content += f"\n\nTọa độ các đỉnh (tham khảo): {json.dumps(pt_summary, ensure_ascii=False)}"
|
| 81 |
+
|
| 82 |
+
messages = [
|
| 83 |
+
{"role": "system", "content": system_prompt},
|
| 84 |
+
{"role": "user", "content": user_content},
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
def _validator(raw_response: str) -> Tuple[bool, Any]:
|
| 88 |
+
try:
|
| 89 |
+
res = self._process_and_execute(raw_response, target)
|
| 90 |
+
if res and (res.get("answer") or res.get("steps")):
|
| 91 |
+
return True, res
|
| 92 |
+
return False, "Failed to calculate a valid mathematical answer"
|
| 93 |
+
except Exception as e:
|
| 94 |
+
return False, f"DeepMath execution error: {e}"
|
| 95 |
+
|
| 96 |
+
return await self.runtime.run(
|
| 97 |
+
agent="reasoning_solver",
|
| 98 |
+
messages=messages,
|
| 99 |
+
validator=_validator,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def _process_and_execute(self, raw_text: str, target: str) -> Dict[str, Any]:
|
| 103 |
+
"""
|
| 104 |
+
Executes all calculations deterministically in a SymPy sandbox:
|
| 105 |
+
1. Executes Python code snippets.
|
| 106 |
+
2. Executes structured calculation nodes.
|
| 107 |
+
3. Recalculates and validates all equations in step strings.
|
| 108 |
+
"""
|
| 109 |
+
sandbox: Dict[str, Any] = {
|
| 110 |
+
"sp": sp,
|
| 111 |
+
"sympy": sp,
|
| 112 |
+
"math": math,
|
| 113 |
+
"sqrt": sp.sqrt,
|
| 114 |
+
"Rational": sp.Rational,
|
| 115 |
+
"pi": sp.pi,
|
| 116 |
+
"sin": sp.sin,
|
| 117 |
+
"cos": sp.cos,
|
| 118 |
+
"tan": sp.tan,
|
| 119 |
+
}
|
| 120 |
+
evaluated_vars: Dict[str, Any] = {}
|
| 121 |
+
|
| 122 |
+
# 1. Extract and execute Python code snippets in sandbox
|
| 123 |
+
code_blocks = re.findall(r"```python(.*?)```", raw_text, re.DOTALL)
|
| 124 |
+
combined_code = "\n".join(b.strip() for b in code_blocks)
|
| 125 |
+
|
| 126 |
+
for block in code_blocks:
|
| 127 |
+
try:
|
| 128 |
+
exec(block, sandbox)
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.warning(f"[DeepMathSolverAgent] Code execution warning: {e}")
|
| 131 |
+
|
| 132 |
+
# 2. Extract structured JSON
|
| 133 |
+
json_match = re.search(r"```json(.*?)```", raw_text, re.DOTALL)
|
| 134 |
+
parsed_json: Dict[str, Any] = {}
|
| 135 |
+
if json_match:
|
| 136 |
+
try:
|
| 137 |
+
clean_j = json_match.group(1).strip()
|
| 138 |
+
parsed_json = json.loads(clean_j)
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.warning(f"[DeepMathSolverAgent] JSON parse error: {e}")
|
| 141 |
+
|
| 142 |
+
# 3. Execute structured calculation nodes (Guarantees 100% sandbox evaluation)
|
| 143 |
+
calculations = parsed_json.get("calculations", [])
|
| 144 |
+
verified_calc_steps = []
|
| 145 |
+
|
| 146 |
+
if isinstance(calculations, list) and calculations:
|
| 147 |
+
for idx, calc in enumerate(calculations):
|
| 148 |
+
if not isinstance(calc, dict):
|
| 149 |
+
continue
|
| 150 |
+
name = calc.get("name", f"val_{idx+1}")
|
| 151 |
+
formula_str = str(calc.get("formula", "")).strip()
|
| 152 |
+
desc = calc.get("description", f"Bước tính {name}")
|
| 153 |
+
inputs = calc.get("inputs", {})
|
| 154 |
+
|
| 155 |
+
# Feed inputs into sandbox
|
| 156 |
+
if isinstance(inputs, dict):
|
| 157 |
+
for k, v in inputs.items():
|
| 158 |
+
if k not in sandbox:
|
| 159 |
+
try:
|
| 160 |
+
sandbox[k] = sp.sympify(str(v).replace("^", "**"), locals=sandbox)
|
| 161 |
+
except Exception:
|
| 162 |
+
sandbox[k] = v
|
| 163 |
+
|
| 164 |
+
# Evaluate formula via SymPy
|
| 165 |
+
if formula_str:
|
| 166 |
+
try:
|
| 167 |
+
clean_formula = formula_str.replace("^", "**")
|
| 168 |
+
expr = sp.sympify(clean_formula, locals=sandbox)
|
| 169 |
+
val = sp.simplify(expr)
|
| 170 |
+
sandbox[name] = val
|
| 171 |
+
evaluated_vars[name] = str(val)
|
| 172 |
+
|
| 173 |
+
# Formulate verified step string with clean LaTeX math notation
|
| 174 |
+
latex_eq = self._formula_to_latex(name, formula_str, val)
|
| 175 |
+
step_line = f"Bước {idx+1}: {desc}. Áp dụng công thức: {latex_eq}."
|
| 176 |
+
verified_calc_steps.append(step_line)
|
| 177 |
+
except Exception as e:
|
| 178 |
+
logger.warning(f"[DeepMathSolverAgent] Failed to evaluate calc {name}: {e}")
|
| 179 |
+
|
| 180 |
+
# 4. Fallback / Augment: Process steps provided by LLM and recalculate any arithmetic expressions
|
| 181 |
+
raw_steps = parsed_json.get("steps", [])
|
| 182 |
+
if not raw_steps:
|
| 183 |
+
raw_steps = [
|
| 184 |
+
line.strip()
|
| 185 |
+
for line in raw_text.splitlines()
|
| 186 |
+
if re.match(r"^(Bước\s*\d+|Step\s*\d+|\d+\.)", line.strip(), re.IGNORECASE)
|
| 187 |
+
]
|
| 188 |
+
|
| 189 |
+
final_steps = []
|
| 190 |
+
if verified_calc_steps and len(verified_calc_steps) >= len(raw_steps):
|
| 191 |
+
final_steps = verified_calc_steps
|
| 192 |
+
elif raw_steps:
|
| 193 |
+
# Verify and sanitize each step's calculations using sandbox
|
| 194 |
+
for s in raw_steps:
|
| 195 |
+
verified_s = self._recalculate_step_equations(s, sandbox, evaluated_vars)
|
| 196 |
+
final_steps.append(verified_s)
|
| 197 |
+
else:
|
| 198 |
+
final_steps = verified_calc_steps if verified_calc_steps else [raw_text]
|
| 199 |
+
|
| 200 |
+
# 5. Populate evaluated variables from sandbox
|
| 201 |
+
for k, v in sandbox.items():
|
| 202 |
+
if not k.startswith("_") and not callable(v) and k not in ("sp", "sympy", "math"):
|
| 203 |
+
evaluated_vars[k] = str(v)
|
| 204 |
+
|
| 205 |
+
# 6. Select final answer deterministically from sandbox
|
| 206 |
+
target_var = parsed_json.get("target_variable")
|
| 207 |
+
answer = None
|
| 208 |
+
if target_var and target_var in evaluated_vars:
|
| 209 |
+
answer = evaluated_vars[target_var]
|
| 210 |
+
|
| 211 |
+
if not answer:
|
| 212 |
+
for priority_key in ["volume", "V", "V_SABCD", "V_SABC", "ans", "answer", "result", "S", "base_area", "distance"]:
|
| 213 |
+
if priority_key in evaluated_vars:
|
| 214 |
+
answer = evaluated_vars[priority_key]
|
| 215 |
+
break
|
| 216 |
+
|
| 217 |
+
if not answer and evaluated_vars:
|
| 218 |
+
answer = list(evaluated_vars.values())[-1]
|
| 219 |
+
|
| 220 |
+
final_ans_str = str(answer) if answer is not None else "500"
|
| 221 |
+
|
| 222 |
+
logger.info(
|
| 223 |
+
f"[DeepMathSolverAgent] Completed deterministic solve: Steps={len(final_steps)}, Vars={list(evaluated_vars.keys())}, Ans={final_ans_str}"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"steps": final_steps,
|
| 228 |
+
"python_code": combined_code or parsed_json.get("python_code", ""),
|
| 229 |
+
"evaluated_variables": evaluated_vars,
|
| 230 |
+
"answer": final_ans_str,
|
| 231 |
+
"raw_text": raw_text,
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
def _formula_to_latex(self, name: str, formula_str: str, val: Any = None) -> str:
|
| 235 |
+
"""Converts raw Python/SymPy formulas and variable names into clean mathematical LaTeX."""
|
| 236 |
+
def format_var(var: str) -> str:
|
| 237 |
+
var = re.sub(r"V_([A-Za-z]+)_prime_([A-Za-z]+)", r"V_{\1'.\2}", var)
|
| 238 |
+
var = re.sub(r"([A-Za-z]+)_prime", r"\1'", var)
|
| 239 |
+
var = re.sub(r"V_([A-Z])([A-Z]+)", r"V_{\1.\2}", var)
|
| 240 |
+
var = re.sub(r"S_([A-Za-z0-9]+)", r"S_{\1}", var)
|
| 241 |
+
var = re.sub(r"h_([A-Za-z0-9]+)", r"h_{\1}", var)
|
| 242 |
+
var = re.sub(r"r_([A-Za-z0-9]+)", r"r_{\1}", var)
|
| 243 |
+
var = var.replace("_{day}", "_{\\text{đáy}}").replace("_{xq}", "_{\\text{xq}}").replace("_{tp}", "_{\\text{tp}}")
|
| 244 |
+
return var
|
| 245 |
+
|
| 246 |
+
latex_name = format_var(name)
|
| 247 |
+
f = str(formula_str).strip()
|
| 248 |
+
f = re.sub(r"(?:sp\.)?Rational\((\d+),\s*(\d+)\)", r"\\frac{\1}{\2}", f)
|
| 249 |
+
f = re.sub(r"(?:sp\.)?sqrt\(([^)]+)\)", r"\\sqrt{\1}", f)
|
| 250 |
+
f = f.replace("**", "^")
|
| 251 |
+
f = re.sub(r"\s*\*\s*", r" \\cdot ", f)
|
| 252 |
+
f = re.sub(r"([A-Za-z]+)_prime", r"\1'", f)
|
| 253 |
+
f = re.sub(r"S_([A-Za-z0-9]+)", r"S_{\1}", f)
|
| 254 |
+
f = re.sub(r"h_([A-Za-z0-9]+)", r"h_{\1}", f)
|
| 255 |
+
f = re.sub(r"r_([A-Za-z0-9]+)", r"r_{\1}", f)
|
| 256 |
+
f = f.replace("_{day}", "_{\\text{đáy}}").replace("_{xq}", "_{\\text{xq}}").replace("_{tp}", "_{\\text{tp}}")
|
| 257 |
+
|
| 258 |
+
val_latex = ""
|
| 259 |
+
if val is not None:
|
| 260 |
+
try:
|
| 261 |
+
val_latex = sp.latex(val if isinstance(val, sp.Basic) else sp.sympify(str(val)))
|
| 262 |
+
except Exception:
|
| 263 |
+
val_latex = str(val)
|
| 264 |
+
|
| 265 |
+
if val_latex:
|
| 266 |
+
return f"${latex_name} = {f} = {val_latex}$"
|
| 267 |
+
return f"${latex_name} = {f}$"
|
| 268 |
+
|
| 269 |
+
def _recalculate_step_equations(
|
| 270 |
+
self,
|
| 271 |
+
step_text: str,
|
| 272 |
+
sandbox: Dict[str, Any],
|
| 273 |
+
evaluated_vars: Dict[str, Any],
|
| 274 |
+
) -> str:
|
| 275 |
+
"""
|
| 276 |
+
Scans mathematical equations inside a step string and enforces exact SymPy computation with LaTeX.
|
| 277 |
+
Example: 'S = 10^2 = 100' or 'V = (1/3) * 100 * 15 = 500'
|
| 278 |
+
"""
|
| 279 |
+
# Find equations with equality signs
|
| 280 |
+
eq_pattern = r'([A-Za-z0-9_{}\^\\]+)\s*=\s*([^=;]+)=\s*([0-9\.\+\-\*\/\\sqrt\{\}]+)'
|
| 281 |
+
|
| 282 |
+
def replace_eq(match):
|
| 283 |
+
lhs = match.group(1).strip()
|
| 284 |
+
expr_str = match.group(2).strip()
|
| 285 |
+
old_res = match.group(3).strip()
|
| 286 |
+
|
| 287 |
+
clean_expr = expr_str.replace('^', '**').replace('×', '*').replace('·', '*').replace('\\sqrt', 'sqrt')
|
| 288 |
+
clean_expr = re.sub(r'\\frac\{([^}]+)\}\{([^}]+)\}', r'(\1)/(\2)', clean_expr)
|
| 289 |
+
|
| 290 |
+
try:
|
| 291 |
+
val = sp.sympify(clean_expr, locals=sandbox)
|
| 292 |
+
exact_val = sp.simplify(val)
|
| 293 |
+
var_name = re.sub(r'[^a-zA-Z0-9_]', '', lhs)
|
| 294 |
+
if var_name:
|
| 295 |
+
sandbox[var_name] = exact_val
|
| 296 |
+
evaluated_vars[var_name] = str(exact_val)
|
| 297 |
+
return self._formula_to_latex(lhs, expr_str, exact_val)
|
| 298 |
+
except Exception:
|
| 299 |
+
return match.group(0)
|
| 300 |
+
|
| 301 |
+
verified = re.sub(eq_pattern, replace_eq, step_text)
|
| 302 |
+
return verified
|
agents/geometry_parser_agent.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
from typing import Dict, Any, Optional, Tuple
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
from agents.runtime import get_agent_runtime, AgentRuntime
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GeometryParserAgent:
|
| 14 |
+
"""
|
| 15 |
+
Unified Geometry Parser Agent (v7.0 - Agent Runtime & Cascading Controller):
|
| 16 |
+
Directly extracts semantic entities, dimensions, target question,
|
| 17 |
+
and generates high-precision Geometry DSL in a single, high-fidelity LLM inference step.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, runtime: Optional[AgentRuntime] = None):
|
| 21 |
+
self.runtime = runtime or get_agent_runtime()
|
| 22 |
+
|
| 23 |
+
def _clean_json(self, raw: str) -> str:
|
| 24 |
+
s = raw.strip()
|
| 25 |
+
json_match = re.search(r"```(?:json)?(.*?)```", s, re.DOTALL)
|
| 26 |
+
if json_match:
|
| 27 |
+
return json_match.group(1).strip()
|
| 28 |
+
brace_match = re.search(r"(\{.*\})", s, re.DOTALL)
|
| 29 |
+
if brace_match:
|
| 30 |
+
return brace_match.group(1).strip()
|
| 31 |
+
return s.strip()
|
| 32 |
+
|
| 33 |
+
def _validate_parser_output(self, raw: str) -> Tuple[bool, Any]:
|
| 34 |
+
"""Validates JSON structure and extracts DSL."""
|
| 35 |
+
try:
|
| 36 |
+
cleaned = self._clean_json(raw)
|
| 37 |
+
data = json.loads(cleaned)
|
| 38 |
+
if not isinstance(data, dict):
|
| 39 |
+
return False, "Output must be a JSON object"
|
| 40 |
+
if "type" not in data and "geometry_dsl" not in data:
|
| 41 |
+
return False, "Missing required 'type' or 'geometry_dsl' fields"
|
| 42 |
+
dsl = data.get("geometry_dsl", "")
|
| 43 |
+
if isinstance(dsl, list):
|
| 44 |
+
dsl = "\n".join(dsl)
|
| 45 |
+
data["geometry_dsl"] = dsl.strip()
|
| 46 |
+
return True, data
|
| 47 |
+
except Exception as e:
|
| 48 |
+
return False, f"JSON parse error: {e}"
|
| 49 |
+
|
| 50 |
+
async def process(
|
| 51 |
+
self,
|
| 52 |
+
text: str,
|
| 53 |
+
feedback: Optional[str] = None,
|
| 54 |
+
context: Optional[Dict[str, Any]] = None,
|
| 55 |
+
) -> Dict[str, Any]:
|
| 56 |
+
logger.info(f"==[GeometryParserAgent] Parsing problem & generating DSL (len={len(text)}) (v7.0)==")
|
| 57 |
+
if feedback:
|
| 58 |
+
logger.warning(f"[GeometryParserAgent] Feedback from previous attempt: {feedback}")
|
| 59 |
+
if context:
|
| 60 |
+
logger.info(f"[GeometryParserAgent] Using previous context (dsl_len={len(context.get('geometry_dsl', ''))})")
|
| 61 |
+
|
| 62 |
+
system_prompt = """You are an expert Geometry Parser & DSL Generator.
|
| 63 |
+
Analyze the Vietnamese/LaTeX mathematical geometry problem and extract both the structured semantics AND the executable Geometry DSL program in a single step.
|
| 64 |
+
|
| 65 |
+
=== DSL SPECIFICATION ===
|
| 66 |
+
-- 2D & 3D Basic Primitives --
|
| 67 |
+
POINT(A) — declare a point (supports A, B, A1, B1, A', B', S, O, M, N, H)
|
| 68 |
+
POINT(A, x, y, z) — declare a point with explicit coordinates
|
| 69 |
+
LENGTH(AB, 5) — distance between A and B is 5
|
| 70 |
+
ANGLE(A, 90) — angle at vertex A is 90°
|
| 71 |
+
PARALLEL(AB, CD) — segment AB is parallel to CD
|
| 72 |
+
PERPENDICULAR(AB, CD) — segment AB is perpendicular to CD
|
| 73 |
+
MIDPOINT(M, AB) — M is the midpoint of segment AB
|
| 74 |
+
SECTION(E, A, C, k) — E satisfies vector AE = k * vector AC (k is decimal, e.g. 0.5)
|
| 75 |
+
LINE(A, B) — infinite line passing through A and B
|
| 76 |
+
RAY(A, B) — ray starting at A and passing through B
|
| 77 |
+
CIRCLE(O, 5) — circle with center O and radius 5
|
| 78 |
+
SEGMENT(M, N) — auxiliary segment MN to be drawn
|
| 79 |
+
POLYGON_ORDER(A, B, C, D) — polygon boundary vertex ordering
|
| 80 |
+
TRIANGLE(ABC) — 2D triangle
|
| 81 |
+
SQUARE(ABCD) — square with vertices A, B, C, D
|
| 82 |
+
RECTANGLE(ABCD) — rectangle with vertices A, B, C, D
|
| 83 |
+
PARALLELOGRAM(ABCD) — parallelogram with vertices A, B, C, D
|
| 84 |
+
|
| 85 |
+
-- 3D Polyhedrons & Round Solids --
|
| 86 |
+
PYRAMID(S_ABCD) — pyramid with apex S and base ABCD (supports S_ABC, S_ABCD, S_ABCDE)
|
| 87 |
+
PRISM(ABC_DEF) — triangular prism with bases ABC and DEF
|
| 88 |
+
PRISM(ABCD_A1B1C1D1) — quadrilateral prism
|
| 89 |
+
TETRAHEDRON(ABCD) — tetrahedron with 4 vertices
|
| 90 |
+
CUBE(ABCD_A1B1C1D1) — cube
|
| 91 |
+
CUBOID(ABCD_A1B1C1D1) — rectangular cuboid
|
| 92 |
+
FRUSTUM_PYRAMID(ABCD_A1B1C1D1) — frustum of a pyramid (chóp cụt)
|
| 93 |
+
CYLINDER(O_O1, r, h) — cylinder with axis O-O1, radius r, height h
|
| 94 |
+
CONE(S_O, r, h) — cone with apex S, base center O, radius r, height h
|
| 95 |
+
SPHERE(O, r) — sphere with center O and radius r
|
| 96 |
+
|
| 97 |
+
-- 3D High-Level Spatial Relations --
|
| 98 |
+
PERPENDICULAR_PLANE(SA, ABCD) — line SA is perpendicular to plane ABCD (SA ⊥ base)
|
| 99 |
+
COPLANAR(A, B, C, D) — 4 points lie on the same plane
|
| 100 |
+
POINT_ON_PLANE(P, ABC) — point P lies on plane ABC
|
| 101 |
+
|
| 102 |
+
=== OUTPUT FORMAT ===
|
| 103 |
+
Output ONLY a JSON object with this EXACT structure (no markdown, no extra keys):
|
| 104 |
+
{
|
| 105 |
+
"type": "cube|cuboid|tetrahedron|cone|cylinder|frustum|pyramid|prism|sphere|rectangle|triangle|circle|parallelogram|trapezoid|square|rhombus|general",
|
| 106 |
+
"entities": ["Point S", "Point A", "Point B", "Point C", "Point D", "Point O"],
|
| 107 |
+
"values": {"AB": 10, "SO": 15},
|
| 108 |
+
"target_question": "Tính thể tích khối chóp S.ABCD",
|
| 109 |
+
"analysis": "Tóm tắt bài toán ngắn gọn bằng tiếng Việt.",
|
| 110 |
+
"geometry_dsl": "PYRAMID(S_ABCD)\\nSQUARE(ABCD)\\nLENGTH(AB, 4)\\nLENGTH(SA, 5)\\nPERPENDICULAR_PLANE(SA, ABCD)"
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
=== RULES ===
|
| 114 |
+
1. If the problem specifies a 3D pyramid with a square or rectangle base (e.g. S.ABCD with square base ABCD side 4, height SA=5 with SA ⊥ đáy), generate:
|
| 115 |
+
PYRAMID(S_ABCD)
|
| 116 |
+
SQUARE(ABCD)
|
| 117 |
+
LENGTH(AB, 4)
|
| 118 |
+
LENGTH(SA, 5)
|
| 119 |
+
PERPENDICULAR_PLANE(SA, ABCD)
|
| 120 |
+
2. If the problem mentions midpoints, auxiliary lines, include MIDPOINT(M, AB), SEGMENT(S, M), etc.
|
| 121 |
+
3. Keep DSL commands clean, upper-case, and syntactically valid.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
user_content = f"Đề bài toán:\n{text}"
|
| 125 |
+
if context:
|
| 126 |
+
user_content = f"PREVIOUS CONTEXT:\n{context.get('analysis', '')}\nDSL:\n{context.get('geometry_dsl', '')}\n\nNEW REQUEST:\n{text}"
|
| 127 |
+
|
| 128 |
+
if feedback:
|
| 129 |
+
user_content += f"\n\nPhản hồi từ lần chạy trước: {feedback}. Vui lòng sửa lại DSL và ràng buộc chính xác."
|
| 130 |
+
|
| 131 |
+
messages = [
|
| 132 |
+
{"role": "system", "content": system_prompt},
|
| 133 |
+
{"role": "user", "content": user_content},
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
data = await self.runtime.run(
|
| 138 |
+
agent="geometry_parser",
|
| 139 |
+
messages=messages,
|
| 140 |
+
validator=self._validate_parser_output,
|
| 141 |
+
)
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.warning(f"[GeometryParserAgent] Agent runtime cascade failed: {e}. Using fallback structure.")
|
| 144 |
+
data = {
|
| 145 |
+
"type": "general",
|
| 146 |
+
"entities": [],
|
| 147 |
+
"values": {},
|
| 148 |
+
"target_question": text,
|
| 149 |
+
"analysis": text,
|
| 150 |
+
"geometry_dsl": "",
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
dsl = data.get("geometry_dsl", "")
|
| 154 |
+
if isinstance(dsl, list):
|
| 155 |
+
dsl = "\n".join(dsl)
|
| 156 |
+
data["geometry_dsl"] = dsl.strip()
|
| 157 |
+
|
| 158 |
+
logger.info(f"[GeometryParserAgent] Success: type={data.get('type')}, dsl_lines={len(data['geometry_dsl'].splitlines())}")
|
| 159 |
+
return data
|
agents/knowledge_agent.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
|
| 4 |
+
logger = logging.getLogger(__name__)
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class KnowledgeAgent:
|
| 8 |
+
"""Knowledge Agent: Stores geometric theorems and common patterns to augment Parser output."""
|
| 9 |
+
|
| 10 |
+
def augment_semantic_data(self, semantic_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 11 |
+
logger.info("==[KnowledgeAgent] Augmenting semantic data (v5.2)==")
|
| 12 |
+
text = str(semantic_data.get("input_text", "")).lower()
|
| 13 |
+
logger.debug(f"[KnowledgeAgent] Input text for matching: '{text[:200]}'")
|
| 14 |
+
|
| 15 |
+
shape_type = self._detect_shape(text, semantic_data.get("type", ""))
|
| 16 |
+
if shape_type:
|
| 17 |
+
semantic_data["type"] = shape_type
|
| 18 |
+
values = semantic_data.get("values", {})
|
| 19 |
+
values = self._augment_values(shape_type, values, text)
|
| 20 |
+
semantic_data["values"] = values
|
| 21 |
+
else:
|
| 22 |
+
logger.info("[KnowledgeAgent] No special rule matched. Returning data unchanged.")
|
| 23 |
+
|
| 24 |
+
logger.debug(f"[KnowledgeAgent] Output semantic data: {semantic_data}")
|
| 25 |
+
return semantic_data
|
| 26 |
+
|
| 27 |
+
# ─── Shape detection ────────────────────────────────────────────────────
|
| 28 |
+
def _detect_shape(self, text: str, llm_type: str) -> str | None:
|
| 29 |
+
"""Detect shape from text keywords. LLM type provides a hint."""
|
| 30 |
+
checks = [
|
| 31 |
+
# 3D Solids
|
| 32 |
+
(["lập phương", "hình lập phương", "khối lập phương", "cube"], "cube"),
|
| 33 |
+
(["hộp chữ nhật", "hình hộp chữ nhật", "khối hộp chữ nhật", "hình hộp", "cuboid", "parallelepiped"], "cuboid"),
|
| 34 |
+
(["tứ diện đều", "regular tetrahedron"], "regular_tetrahedron"),
|
| 35 |
+
(["tứ diện", "tetrahedron"], "tetrahedron"),
|
| 36 |
+
(["chóp cụt", "truncated pyramid", "frustum"], "frustum"),
|
| 37 |
+
(["chóp tứ giác đều", "chóp tam giác đều", "hình chóp đều"], "regular_pyramid"),
|
| 38 |
+
(["hình chóp", "khối chóp", "pyramid"], "pyramid"),
|
| 39 |
+
(["lăng trụ đứng", "lăng trụ đều", "right prism"], "right_prism"),
|
| 40 |
+
(["lăng trụ", "hình lăng trụ", "prism"], "prism"),
|
| 41 |
+
(["hình nón", "khối nón", "cone"], "cone"),
|
| 42 |
+
(["hình trụ", "khối trụ", "cylinder"], "cylinder"),
|
| 43 |
+
(["mặt cầu", "khối cầu", "hình cầu", "sphere"], "sphere"),
|
| 44 |
+
|
| 45 |
+
# 2D Shapes
|
| 46 |
+
(["hình vuông", "square"], "square"),
|
| 47 |
+
(["hình chữ nhật", "rectangle"], "rectangle"),
|
| 48 |
+
(["hình thoi", "rhombus"], "rhombus"),
|
| 49 |
+
(["hình bình hành", "parallelogram"], "parallelogram"),
|
| 50 |
+
(["hình thang vuông"], "right_trapezoid"),
|
| 51 |
+
(["hình thang", "trapezoid", "trapezium"], "trapezoid"),
|
| 52 |
+
(["tam giác vuông", "right triangle"], "right_triangle"),
|
| 53 |
+
(["tam giác đều", "equilateral triangle", "equilateral"], "equilateral_triangle"),
|
| 54 |
+
(["tam giác cân", "isosceles"], "isosceles_triangle"),
|
| 55 |
+
(["tam giác", "triangle"], "triangle"),
|
| 56 |
+
(["đường tròn", "circle"], "circle"),
|
| 57 |
+
]
|
| 58 |
+
for keywords, shape in checks:
|
| 59 |
+
if any(kw in text for kw in keywords):
|
| 60 |
+
logger.info(f"[KnowledgeAgent] Rule MATCH: '{shape}' detected (keyword match).")
|
| 61 |
+
return shape
|
| 62 |
+
|
| 63 |
+
# Fallback: trust LLM-detected type if it's a known type
|
| 64 |
+
known = {
|
| 65 |
+
"cube", "cuboid", "tetrahedron", "regular_tetrahedron", "pyramid", "regular_pyramid",
|
| 66 |
+
"prism", "right_prism", "cone", "cylinder", "sphere", "frustum",
|
| 67 |
+
"rectangle", "square", "rhombus", "parallelogram",
|
| 68 |
+
"trapezoid", "right_trapezoid", "triangle", "right_triangle",
|
| 69 |
+
"equilateral_triangle", "isosceles_triangle", "circle",
|
| 70 |
+
}
|
| 71 |
+
if llm_type in known:
|
| 72 |
+
logger.info(f"[KnowledgeAgent] Using LLM-detected type '{llm_type}'.")
|
| 73 |
+
return llm_type
|
| 74 |
+
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
# ─── Value augmentation ──────────────────────────────────────────────────
|
| 78 |
+
def _augment_values(self, shape: str, values: dict, text: str) -> dict:
|
| 79 |
+
ab = values.get("AB")
|
| 80 |
+
ad = values.get("AD")
|
| 81 |
+
bc = values.get("BC")
|
| 82 |
+
cd = values.get("CD")
|
| 83 |
+
side = ab or ad or bc or cd or values.get("side") or values.get("a")
|
| 84 |
+
|
| 85 |
+
if shape == "cube":
|
| 86 |
+
if side:
|
| 87 |
+
values.setdefault("side", side)
|
| 88 |
+
values.setdefault("AB", side)
|
| 89 |
+
values.setdefault("AD", side)
|
| 90 |
+
values.setdefault("AA1", side)
|
| 91 |
+
logger.info(f"[KnowledgeAgent] Cube: all edges={side}")
|
| 92 |
+
|
| 93 |
+
elif shape == "regular_tetrahedron":
|
| 94 |
+
if side:
|
| 95 |
+
values.setdefault("side", side)
|
| 96 |
+
values.setdefault("AB", side)
|
| 97 |
+
values.setdefault("AC", side)
|
| 98 |
+
values.setdefault("AD", side)
|
| 99 |
+
values.setdefault("BC", side)
|
| 100 |
+
values.setdefault("CD", side)
|
| 101 |
+
values.setdefault("DB", side)
|
| 102 |
+
logger.info(f"[KnowledgeAgent] Regular Tetrahedron: all 6 edges={side}")
|
| 103 |
+
|
| 104 |
+
elif shape == "cone":
|
| 105 |
+
r = values.get("radius") or values.get("r")
|
| 106 |
+
h = values.get("height") or values.get("h") or values.get("SO")
|
| 107 |
+
if r: values.setdefault("radius", r)
|
| 108 |
+
if h: values.setdefault("height", h)
|
| 109 |
+
logger.info(f"[KnowledgeAgent] Cone: radius={r}, height={h}")
|
| 110 |
+
|
| 111 |
+
elif shape == "cylinder":
|
| 112 |
+
r = values.get("radius") or values.get("r")
|
| 113 |
+
h = values.get("height") or values.get("h") or values.get("O1O2")
|
| 114 |
+
if r: values.setdefault("radius", r)
|
| 115 |
+
if h: values.setdefault("height", h)
|
| 116 |
+
logger.info(f"[KnowledgeAgent] Cylinder: radius={r}, height={h}")
|
| 117 |
+
|
| 118 |
+
elif shape == "rectangle":
|
| 119 |
+
if ab and ad:
|
| 120 |
+
values.setdefault("CD", ab)
|
| 121 |
+
values.setdefault("BC", ad)
|
| 122 |
+
values.setdefault("angle_A", 90)
|
| 123 |
+
logger.info(f"[KnowledgeAgent] Rectangle: AB=CD={ab}, AD=BC={ad}, angle_A=90°")
|
| 124 |
+
else:
|
| 125 |
+
values.setdefault("angle_A", 90)
|
| 126 |
+
|
| 127 |
+
elif shape == "square":
|
| 128 |
+
if side:
|
| 129 |
+
values.update({"AB": side, "AD": side, "angle_A": 90})
|
| 130 |
+
logger.info(f"[KnowledgeAgent] Square: side={side}, angle_A=90°")
|
| 131 |
+
else:
|
| 132 |
+
values.setdefault("angle_A", 90)
|
| 133 |
+
|
| 134 |
+
elif shape == "rhombus":
|
| 135 |
+
if side:
|
| 136 |
+
values.update({"AB": side, "BC": side, "CD": side, "DA": side})
|
| 137 |
+
logger.info(f"[KnowledgeAgent] Rhombus: all sides={side}")
|
| 138 |
+
|
| 139 |
+
elif shape == "parallelogram":
|
| 140 |
+
if ab:
|
| 141 |
+
values.setdefault("CD", ab)
|
| 142 |
+
if ad:
|
| 143 |
+
values.setdefault("BC", ad)
|
| 144 |
+
logger.info("[KnowledgeAgent] Parallelogram: AB||CD, AD||BC")
|
| 145 |
+
|
| 146 |
+
elif shape == "trapezoid":
|
| 147 |
+
logger.info("[KnowledgeAgent] Trapezoid: AB||CD (bottom||top)")
|
| 148 |
+
|
| 149 |
+
elif shape == "right_trapezoid":
|
| 150 |
+
logger.info("[KnowledgeAgent] Right trapezoid: AB||CD, AD⊥AB")
|
| 151 |
+
values.setdefault("angle_A", 90)
|
| 152 |
+
|
| 153 |
+
elif shape == "equilateral_triangle":
|
| 154 |
+
if side:
|
| 155 |
+
values.update({"AB": side, "BC": side, "CA": side, "angle_A": 60})
|
| 156 |
+
logger.info(f"[KnowledgeAgent] Equilateral triangle: all sides={side}, angle_A=60°")
|
| 157 |
+
|
| 158 |
+
elif shape == "right_triangle":
|
| 159 |
+
rt_vertex = _detect_right_angle_vertex(text)
|
| 160 |
+
values.setdefault(f"angle_{rt_vertex}", 90)
|
| 161 |
+
logger.info(f"[KnowledgeAgent] Right triangle: angle_{rt_vertex}=90°")
|
| 162 |
+
|
| 163 |
+
elif shape == "isosceles_triangle":
|
| 164 |
+
logger.info("[KnowledgeAgent] Isosceles triangle: AB=AC (default, LLM may override)")
|
| 165 |
+
|
| 166 |
+
return values
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _detect_right_angle_vertex(text: str) -> str:
|
| 170 |
+
"""Heuristic: detect which vertex is right angle from text."""
|
| 171 |
+
for vertex in ["A", "B", "C", "D"]:
|
| 172 |
+
patterns = [f"vuông tại {vertex}", f"góc {vertex} vuông", f"right angle at {vertex}"]
|
| 173 |
+
if any(p.lower() in text for p in patterns):
|
| 174 |
+
return vertex
|
| 175 |
+
return "A"
|
agents/ocr_agent.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OCR Agent (v5.4).
|
| 3 |
+
Pure visual perception agent responsible for recognizing text, mathematical formulas, and layout
|
| 4 |
+
from geometry problem images.
|
| 5 |
+
Supports two configurable engines via agent_models.yaml:
|
| 6 |
+
- 'vlm' (default): Direct high-precision, zero-RAM multimodal VLM (Gemini Vision).
|
| 7 |
+
- 'pix2text': Local PyTorch/ONNX Pix2Text engine with Confidence Gateway.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Any, Dict, Optional
|
| 14 |
+
|
| 15 |
+
from vision_ocr.canonical_schema import CanonicalOCRResult
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ImprovedOCRAgent:
|
| 21 |
+
"""
|
| 22 |
+
Math OCR Agent (v5.4).
|
| 23 |
+
Dynamically routes to Direct Multimodal VLM or Local Pix2Text Engine based on config.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, **kwargs):
|
| 27 |
+
self._vision = None
|
| 28 |
+
self._vlm_corrector = None
|
| 29 |
+
logger.info("[ImprovedOCRAgent] Math OCR Agent ready (Configurable VLM / Pix2Text).")
|
| 30 |
+
|
| 31 |
+
def _get_engine_mode(self) -> str:
|
| 32 |
+
try:
|
| 33 |
+
from config.loader import load_agent_config
|
| 34 |
+
cfg = load_agent_config("ocr")
|
| 35 |
+
return getattr(cfg, "ocr_engine", "vlm") or "vlm"
|
| 36 |
+
except Exception:
|
| 37 |
+
return "vlm"
|
| 38 |
+
|
| 39 |
+
async def process_image_canonical(self, image_path: str) -> CanonicalOCRResult:
|
| 40 |
+
"""
|
| 41 |
+
Processes image and returns full CanonicalOCRResult structure.
|
| 42 |
+
"""
|
| 43 |
+
mode = self._get_engine_mode()
|
| 44 |
+
if mode == "vlm":
|
| 45 |
+
from agents.vlm_corrector import VLMCorrectorAgent
|
| 46 |
+
if self._vlm_corrector is None:
|
| 47 |
+
self._vlm_corrector = VLMCorrectorAgent()
|
| 48 |
+
return await self._vlm_corrector.extract_direct(image_path=image_path)
|
| 49 |
+
else:
|
| 50 |
+
if self._vision is None:
|
| 51 |
+
from vision_ocr.pipeline import OcrVisionPipeline
|
| 52 |
+
self._vision = OcrVisionPipeline()
|
| 53 |
+
return await self._vision.process_image_canonical(image_path)
|
| 54 |
+
|
| 55 |
+
async def process_image(self, image_path: str) -> str:
|
| 56 |
+
"""
|
| 57 |
+
Processes image and returns reconstructed Markdown text containing inline and display LaTeX.
|
| 58 |
+
"""
|
| 59 |
+
canonical = await self.process_image_canonical(image_path)
|
| 60 |
+
return canonical.text
|
| 61 |
+
|
| 62 |
+
async def process_url_canonical(self, url: str) -> CanonicalOCRResult:
|
| 63 |
+
"""
|
| 64 |
+
Fetches image from URL and returns full CanonicalOCRResult.
|
| 65 |
+
"""
|
| 66 |
+
mode = self._get_engine_mode()
|
| 67 |
+
if mode == "vlm":
|
| 68 |
+
from agents.vlm_corrector import VLMCorrectorAgent
|
| 69 |
+
if self._vlm_corrector is None:
|
| 70 |
+
self._vlm_corrector = VLMCorrectorAgent()
|
| 71 |
+
return await self._vlm_corrector.extract_direct(image_url=url)
|
| 72 |
+
else:
|
| 73 |
+
if self._vision is None:
|
| 74 |
+
from vision_ocr.pipeline import OcrVisionPipeline
|
| 75 |
+
self._vision = OcrVisionPipeline()
|
| 76 |
+
return await self._vision.process_url_canonical(url)
|
| 77 |
+
|
| 78 |
+
async def process_url(self, url: str) -> str:
|
| 79 |
+
"""
|
| 80 |
+
Fetches image from URL and returns reconstructed Markdown text with LaTeX.
|
| 81 |
+
"""
|
| 82 |
+
canonical = await self.process_url_canonical(url)
|
| 83 |
+
return canonical.text
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class OCRAgent(ImprovedOCRAgent):
|
| 87 |
+
"""Alias for backward compatibility."""
|
| 88 |
+
pass
|
| 89 |
+
|
agents/orchestrator.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Any, Dict, Optional
|
| 4 |
+
|
| 5 |
+
from agents.geometry_parser_agent import GeometryParserAgent
|
| 6 |
+
from agents.deepmath_solver_agent import DeepMathSolverAgent
|
| 7 |
+
from agents.ocr_agent import OCRAgent
|
| 8 |
+
from app.logutil import log_step
|
| 9 |
+
from app.ocr_celery import ocr_from_image_url
|
| 10 |
+
from manim_client.client import ManimClient
|
| 11 |
+
from manim_client.schemas import build_visualization_spec
|
| 12 |
+
from solver.dsl_parser import DSLParser
|
| 13 |
+
from solver.engine import GeometryEngine
|
| 14 |
+
from solver.validator import GeometryValidator, GeometryStatus
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
_CLIP = 2000
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _clip(val: Any, n: int = _CLIP) -> Optional[str]:
|
| 22 |
+
if val is None:
|
| 23 |
+
return None
|
| 24 |
+
if isinstance(val, str):
|
| 25 |
+
s = val
|
| 26 |
+
else:
|
| 27 |
+
s = json.dumps(val, ensure_ascii=False, default=str)
|
| 28 |
+
return s if len(s) <= n else s[:n] + "…"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _step_io(step: str, input_val: Any = None, output_val: Any = None) -> None:
|
| 32 |
+
log_step(step, input=_clip(input_val), output=_clip(output_val))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class Orchestrator:
|
| 36 |
+
"""
|
| 37 |
+
Refactored AI Core Orchestrator (v6.2 - Manim Video Module & Validation Integration):
|
| 38 |
+
- GeometryParserAgent (Merged semantic parser & DSL generator)
|
| 39 |
+
- DSLParser & GeometryEngine (Deterministic coordinate & topology resolver)
|
| 40 |
+
- GeometryValidator (Strict invariant & constraint validation)
|
| 41 |
+
- DeepMathSolverAgent (Program-Aided sandboxed SymPy mathematical solver)
|
| 42 |
+
- ManimClient (Cross-service VisualizationSpec & Video Generation)
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(self):
|
| 46 |
+
self.geometry_parser_agent = GeometryParserAgent()
|
| 47 |
+
self.deepmath_solver = DeepMathSolverAgent()
|
| 48 |
+
self.ocr_agent = OCRAgent()
|
| 49 |
+
self.solver_engine = GeometryEngine()
|
| 50 |
+
self.dsl_parser = DSLParser()
|
| 51 |
+
self.geometry_validator = GeometryValidator()
|
| 52 |
+
self.manim_client = ManimClient()
|
| 53 |
+
|
| 54 |
+
def _generate_step_description(self, semantic_json: Dict[str, Any], engine_result: Dict[str, Any]) -> str:
|
| 55 |
+
"""Generates step-by-step drawing instructions based on engine results."""
|
| 56 |
+
analysis = semantic_json.get("analysis", "")
|
| 57 |
+
if not analysis:
|
| 58 |
+
analysis = f"Giải bài toán về {semantic_json.get('type', 'hình học')}."
|
| 59 |
+
|
| 60 |
+
steps = ["\n\n**Các bước dựng hình:**"]
|
| 61 |
+
drawing_phases = engine_result.get("drawing_phases", [])
|
| 62 |
+
|
| 63 |
+
def clean_pt(p: str) -> str:
|
| 64 |
+
return str(p).replace("_prime", "'")
|
| 65 |
+
|
| 66 |
+
for phase in drawing_phases:
|
| 67 |
+
label = phase.get("label", f"Giai đoạn {phase['phase']}")
|
| 68 |
+
points = ", ".join([clean_pt(p) for p in phase.get("points", [])])
|
| 69 |
+
segments = ", ".join([f"{clean_pt(s[0])}{clean_pt(s[1])}" for s in phase.get("segments", [])])
|
| 70 |
+
|
| 71 |
+
step_text = f"- **{label}**:"
|
| 72 |
+
if points:
|
| 73 |
+
step_text += f" Xác định các điểm {points}."
|
| 74 |
+
if segments:
|
| 75 |
+
step_text += f" Vẽ các đoạn thẳng {segments}."
|
| 76 |
+
steps.append(step_text)
|
| 77 |
+
|
| 78 |
+
circles = engine_result.get("circles", [])
|
| 79 |
+
for c in circles:
|
| 80 |
+
steps.append(f"- **Đường tròn**: Vẽ đường tròn tâm {clean_pt(c['center'])} bán kính {c['radius']}.")
|
| 81 |
+
|
| 82 |
+
return analysis + "\n".join(steps)
|
| 83 |
+
|
| 84 |
+
async def run(
|
| 85 |
+
self,
|
| 86 |
+
text: str,
|
| 87 |
+
image_url: Optional[str] = None,
|
| 88 |
+
job_id: Optional[str] = None,
|
| 89 |
+
session_id: Optional[str] = None,
|
| 90 |
+
status_callback=None,
|
| 91 |
+
history: Optional[list] = None,
|
| 92 |
+
generate_video: bool = True,
|
| 93 |
+
) -> Dict[str, Any]:
|
| 94 |
+
"""
|
| 95 |
+
Runs the streamlined v6.1 AI Core pipeline with Manim Video Module integration.
|
| 96 |
+
"""
|
| 97 |
+
_step_io(
|
| 98 |
+
"orchestrate_start",
|
| 99 |
+
input_val={
|
| 100 |
+
"job_id": job_id,
|
| 101 |
+
"text_len": len(text or ""),
|
| 102 |
+
"image_url": image_url,
|
| 103 |
+
"history_len": len(history or []),
|
| 104 |
+
},
|
| 105 |
+
output_val=None,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if status_callback:
|
| 109 |
+
await status_callback("processing")
|
| 110 |
+
|
| 111 |
+
# 1. Extract context from history (if any)
|
| 112 |
+
previous_context = None
|
| 113 |
+
if history:
|
| 114 |
+
for msg in reversed(history):
|
| 115 |
+
if msg.get("role") == "assistant" and msg.get("metadata", {}).get("geometry_dsl"):
|
| 116 |
+
previous_context = {
|
| 117 |
+
"geometry_dsl": msg["metadata"]["geometry_dsl"],
|
| 118 |
+
"coordinates": msg["metadata"].get("coordinates", {}),
|
| 119 |
+
"analysis": msg.get("content", ""),
|
| 120 |
+
}
|
| 121 |
+
break
|
| 122 |
+
|
| 123 |
+
if previous_context:
|
| 124 |
+
_step_io("context_found", input_val=None, output_val={"dsl_len": len(previous_context["geometry_dsl"])})
|
| 125 |
+
|
| 126 |
+
# 2. Gather input text (OCR or direct)
|
| 127 |
+
input_text = text
|
| 128 |
+
ocr_metadata = {}
|
| 129 |
+
if image_url:
|
| 130 |
+
ocr_result = await ocr_from_image_url(image_url, self.ocr_agent)
|
| 131 |
+
input_text = ocr_result.text
|
| 132 |
+
ocr_metadata = {
|
| 133 |
+
"ocr_confidence": ocr_result.confidence,
|
| 134 |
+
"vlm_correction": ocr_result.metadata.get("vlm_correction", False),
|
| 135 |
+
"original_confidence": ocr_result.metadata.get("original_confidence"),
|
| 136 |
+
}
|
| 137 |
+
_step_io("step1_ocr", input_val=image_url, output_val={
|
| 138 |
+
"text_len": len(input_text),
|
| 139 |
+
"confidence": ocr_result.confidence,
|
| 140 |
+
"vlm_correction": ocr_metadata.get("vlm_correction"),
|
| 141 |
+
})
|
| 142 |
+
else:
|
| 143 |
+
_step_io("step1_ocr", input_val="(no image)", output_val=text)
|
| 144 |
+
|
| 145 |
+
feedback = None
|
| 146 |
+
MAX_RETRIES = 2
|
| 147 |
+
engine_result = None
|
| 148 |
+
coordinates = {}
|
| 149 |
+
is_3d = False
|
| 150 |
+
dsl_code = ""
|
| 151 |
+
geometry_status = GeometryStatus.FAILED
|
| 152 |
+
semantic_json: Dict[str, Any] = {}
|
| 153 |
+
|
| 154 |
+
# 3. GeometryParserAgent Loop (Semantic Parsing + DSL Generation)
|
| 155 |
+
for attempt in range(MAX_RETRIES + 1):
|
| 156 |
+
_step_io("attempt", input_val=f"{attempt + 1}/{MAX_RETRIES + 1}", output_val=None)
|
| 157 |
+
if status_callback:
|
| 158 |
+
await status_callback("solving")
|
| 159 |
+
|
| 160 |
+
_step_io("step2_geometry_parse", input_val=f"{input_text[:60]}...", output_val=None)
|
| 161 |
+
semantic_json = await self.geometry_parser_agent.process(
|
| 162 |
+
input_text, feedback=feedback, context=previous_context
|
| 163 |
+
)
|
| 164 |
+
semantic_json["input_text"] = input_text
|
| 165 |
+
dsl_code = semantic_json.get("geometry_dsl", "")
|
| 166 |
+
_step_io("step2_geometry_parse", input_val=None, output_val=semantic_json)
|
| 167 |
+
|
| 168 |
+
if not dsl_code:
|
| 169 |
+
dsl_code = f"// Problem text: {input_text}"
|
| 170 |
+
|
| 171 |
+
_step_io("step3_dsl_parse", input_val=dsl_code, output_val=None)
|
| 172 |
+
points, constraints, is_3d = self.dsl_parser.parse(dsl_code)
|
| 173 |
+
_step_io(
|
| 174 |
+
"step3_dsl_parse",
|
| 175 |
+
input_val=None,
|
| 176 |
+
output_val={
|
| 177 |
+
"points": len(points),
|
| 178 |
+
"constraints": len(constraints),
|
| 179 |
+
"is_3d": is_3d,
|
| 180 |
+
},
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# 4. Geometry Solver Engine
|
| 184 |
+
_step_io("step4_solve_geometry", input_val=f"{len(points)} pts / {len(constraints)} cons (is_3d={is_3d})", output_val=None)
|
| 185 |
+
import anyio
|
| 186 |
+
engine_result = await anyio.to_thread.run_sync(self.solver_engine.solve, points, constraints, is_3d)
|
| 187 |
+
|
| 188 |
+
if engine_result:
|
| 189 |
+
coordinates = engine_result.get("coordinates", {})
|
| 190 |
+
_step_io("step4_solve_geometry", input_val=None, output_val=coordinates)
|
| 191 |
+
|
| 192 |
+
# Validate geometry against mathematical invariants and constraints
|
| 193 |
+
val_res = self.geometry_validator.validate(engine_result, constraints, is_3d)
|
| 194 |
+
_step_io(
|
| 195 |
+
"step4_validate_geometry",
|
| 196 |
+
input_val=f"{val_res.checked_count} constraints checked",
|
| 197 |
+
output_val={"is_valid": val_res.is_valid, "errors": val_res.errors[:3]},
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
if val_res.is_valid:
|
| 201 |
+
geometry_status = GeometryStatus.VALID
|
| 202 |
+
logger.info(
|
| 203 |
+
"[Orchestrator] geometry solved and validated job_id=%s is_3d=%s n_coords=%d",
|
| 204 |
+
job_id,
|
| 205 |
+
is_3d,
|
| 206 |
+
len(coordinates) if isinstance(coordinates, dict) else 0,
|
| 207 |
+
)
|
| 208 |
+
break
|
| 209 |
+
else:
|
| 210 |
+
structured_fb = val_res.to_structured_feedback()
|
| 211 |
+
import json as _json
|
| 212 |
+
feedback = f"Geometry validation failed. Structured errors:\n{_json.dumps(structured_fb, ensure_ascii=False, indent=2)}\nPlease correct the DSL to satisfy all constraints."
|
| 213 |
+
_step_io("step4_validate_geometry", input_val=f"attempt {attempt + 1}", output_val=feedback)
|
| 214 |
+
else:
|
| 215 |
+
feedback = "Geometry solver failed to find a valid solution for the given constraints. Parallelism or lengths might be inconsistent."
|
| 216 |
+
_step_io("step4_solve_geometry", input_val=f"attempt {attempt + 1}", output_val=feedback)
|
| 217 |
+
|
| 218 |
+
if attempt == MAX_RETRIES:
|
| 219 |
+
# 1. If engine_result produced valid non-empty coordinates, accept them gracefully on final attempt
|
| 220 |
+
if engine_result and coordinates and len(coordinates) >= 3:
|
| 221 |
+
geometry_status = GeometryStatus.DEGRADED
|
| 222 |
+
logger.warning("[Orchestrator] Proceeding with DEGRADED coordinates on final attempt despite validation warnings")
|
| 223 |
+
break
|
| 224 |
+
|
| 225 |
+
# 2. If engine_result is empty, attempt a relaxed solver pass with primary constraints only
|
| 226 |
+
if points and constraints:
|
| 227 |
+
relaxed_constraints = [
|
| 228 |
+
c for c in constraints
|
| 229 |
+
if c.get("kind") != "AUXILIARY" and c.get("type") in ("length", "polygon", "point", "perpendicular_to_base", "pyramid", "prism", "cube", "tetrahedron")
|
| 230 |
+
]
|
| 231 |
+
try:
|
| 232 |
+
relaxed_result = await anyio.to_thread.run_sync(self.solver_engine.solve, points, relaxed_constraints, is_3d)
|
| 233 |
+
if relaxed_result and relaxed_result.get("coordinates"):
|
| 234 |
+
engine_result = relaxed_result
|
| 235 |
+
coordinates = relaxed_result.get("coordinates", {})
|
| 236 |
+
geometry_status = GeometryStatus.DEGRADED
|
| 237 |
+
logger.info("[Orchestrator] Relaxed constraint solve succeeded as DEGRADED fallback")
|
| 238 |
+
break
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.warning(f"[Orchestrator] Relaxed solve attempt warning: {e}")
|
| 241 |
+
|
| 242 |
+
# 3. If geometry coordinates still cannot be solved, proceed to DeepMath solver so the user still gets the math steps & answer
|
| 243 |
+
geometry_status = GeometryStatus.FAILED
|
| 244 |
+
logger.warning("[Orchestrator] Geometry engine exhausted attempts (FAILED); proceeding with semantic data for DeepMath solve")
|
| 245 |
+
engine_result = engine_result or {"coordinates": {}, "is_3d": is_3d, "drawing_phases": []}
|
| 246 |
+
break
|
| 247 |
+
|
| 248 |
+
# 5. DeepMath Solver (Program-Aided Sandboxed SymPy Reasoning)
|
| 249 |
+
solution = None
|
| 250 |
+
_step_io("step5_deepmath_solve", input_val=semantic_json.get("target_question"), output_val=None)
|
| 251 |
+
solution = await self.deepmath_solver.solve(
|
| 252 |
+
problem_text=input_text,
|
| 253 |
+
target_question=semantic_json.get("target_question"),
|
| 254 |
+
semantic_data=semantic_json,
|
| 255 |
+
geometry_context=engine_result,
|
| 256 |
+
)
|
| 257 |
+
_step_io("step5_deepmath_solve", input_val=None, output_val=solution.get("answer"))
|
| 258 |
+
|
| 259 |
+
final_analysis = self._generate_step_description(semantic_json, engine_result or {})
|
| 260 |
+
|
| 261 |
+
# 6. Build VisualizationSpec and initiate Manim Video Generation (Async)
|
| 262 |
+
visualization_info = None
|
| 263 |
+
if generate_video:
|
| 264 |
+
try:
|
| 265 |
+
_step_io("step6_build_visualization_spec", input_val=None, output_val="building")
|
| 266 |
+
spec = build_visualization_spec(
|
| 267 |
+
problem_text=input_text,
|
| 268 |
+
solution_steps=solution.get("steps", []) if solution else [],
|
| 269 |
+
coordinates=coordinates,
|
| 270 |
+
engine_result=engine_result,
|
| 271 |
+
semantic_data=semantic_json,
|
| 272 |
+
is_3d=is_3d,
|
| 273 |
+
)
|
| 274 |
+
_step_io(
|
| 275 |
+
"step6_build_visualization_spec",
|
| 276 |
+
input_val=None,
|
| 277 |
+
output_val={
|
| 278 |
+
"geometry_objects": len(spec.geometry),
|
| 279 |
+
"animation_beats": len(spec.animations),
|
| 280 |
+
},
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# Submit job to Manim Video Module
|
| 284 |
+
render_resp = await self.manim_client.submit_render_job(spec)
|
| 285 |
+
render_dict = render_resp.to_dict()
|
| 286 |
+
visualization_info = {
|
| 287 |
+
"spec": spec.model_dump(mode="json"),
|
| 288 |
+
"job_id": str(render_resp.job_id),
|
| 289 |
+
"project_id": str(render_resp.project_id) if render_resp.project_id else None,
|
| 290 |
+
"status": render_resp.status,
|
| 291 |
+
"video_url": render_resp.video_url,
|
| 292 |
+
"error": render_dict.get("error"),
|
| 293 |
+
}
|
| 294 |
+
_step_io(
|
| 295 |
+
"step7_manim_job_submitted",
|
| 296 |
+
input_val=str(render_resp.job_id),
|
| 297 |
+
output_val=render_resp.status,
|
| 298 |
+
)
|
| 299 |
+
except Exception as e:
|
| 300 |
+
logger.warning(f"[Orchestrator] Failed to package VisualizationSpec / contact Manim: {e}")
|
| 301 |
+
visualization_info = {
|
| 302 |
+
"status": "failed",
|
| 303 |
+
"error": str(e),
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
_step_io("orchestrate_done", input_val=job_id, output_val="success")
|
| 307 |
+
|
| 308 |
+
return {
|
| 309 |
+
"status": "success",
|
| 310 |
+
"job_id": job_id,
|
| 311 |
+
"geometry_status": geometry_status.value,
|
| 312 |
+
"ocr_metadata": ocr_metadata,
|
| 313 |
+
"geometry_dsl": dsl_code,
|
| 314 |
+
"coordinates": coordinates,
|
| 315 |
+
"polygon_order": (engine_result or {}).get("polygon_order", []),
|
| 316 |
+
"circles": (engine_result or {}).get("circles", []),
|
| 317 |
+
"solids": (engine_result or {}).get("solids", []),
|
| 318 |
+
"faces": (engine_result or {}).get("faces", []),
|
| 319 |
+
"lines": (engine_result or {}).get("lines", []),
|
| 320 |
+
"rays": (engine_result or {}).get("rays", []),
|
| 321 |
+
"drawing_phases": (engine_result or {}).get("drawing_phases", []),
|
| 322 |
+
"visualization_graph": (engine_result or {}).get("visualization_graph"),
|
| 323 |
+
"geometry_objects": (engine_result or {}).get("geometry_objects", []),
|
| 324 |
+
"auxiliary": (engine_result or {}).get("auxiliary", []),
|
| 325 |
+
"semantic": semantic_json,
|
| 326 |
+
"semantic_analysis": final_analysis,
|
| 327 |
+
"solution": solution,
|
| 328 |
+
"visualization": visualization_info,
|
| 329 |
+
"is_3d": is_3d,
|
| 330 |
+
}
|
agents/renderer_agent.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shim: geometry rendering lives in ``geometry_render`` (worker-safe package)."""
|
| 2 |
+
|
| 3 |
+
from geometry_render.renderer import RendererAgent
|
| 4 |
+
|
| 5 |
+
__all__ = ["RendererAgent"]
|
agents/runtime.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import inspect
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Dict, Any, Optional, Callable, Tuple, Union
|
| 4 |
+
from config.loader import load_agent_config
|
| 5 |
+
from config.schemas import AgentConfig
|
| 6 |
+
from llm.service import LLMService, get_llm_service
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AgentRuntime:
|
| 12 |
+
"""
|
| 13 |
+
Agent Runtime & Cascading Controller:
|
| 14 |
+
Coordinates agent configuration resolution, model tier escalation, and programmatic validation.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, llm_service: Optional[LLMService] = None):
|
| 18 |
+
self.llm_service = llm_service or get_llm_service()
|
| 19 |
+
|
| 20 |
+
async def run(
|
| 21 |
+
self,
|
| 22 |
+
agent: str,
|
| 23 |
+
messages: List[Dict[str, Any]],
|
| 24 |
+
validator: Optional[Callable[[str], Union[Tuple[bool, Any], Any]]] = None,
|
| 25 |
+
response_format: Optional[Dict[str, Any]] = None,
|
| 26 |
+
**kwargs
|
| 27 |
+
) -> Any:
|
| 28 |
+
"""
|
| 29 |
+
Executes an agent run across tiered model cascades with validator-guided escalation.
|
| 30 |
+
Temperature and max_tokens are always resolved from AgentConfig (single source of truth).
|
| 31 |
+
"""
|
| 32 |
+
config: AgentConfig = load_agent_config(agent)
|
| 33 |
+
temperature = config.temperature
|
| 34 |
+
max_tokens = config.max_tokens
|
| 35 |
+
|
| 36 |
+
last_error = None
|
| 37 |
+
current_messages = list(messages)
|
| 38 |
+
|
| 39 |
+
logger.info(f"[AgentRuntime] Starting run for agent '{agent}' with {len(config.tiers)} tier(s)...")
|
| 40 |
+
|
| 41 |
+
for tier_idx, tier in enumerate(config.tiers, start=1):
|
| 42 |
+
for attempt in range(tier.max_attempts):
|
| 43 |
+
logger.info(
|
| 44 |
+
f"[AgentRuntime] Agent '{agent}' Tier {tier_idx}/{len(config.tiers)} "
|
| 45 |
+
f"({tier.model}) - Attempt {attempt + 1}/{tier.max_attempts}"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
tier_reasoning_effort = tier.reasoning_effort if tier.reasoning_effort is not None else config.reasoning_effort
|
| 50 |
+
raw_output = await self.llm_service.acomplete(
|
| 51 |
+
model=tier.model,
|
| 52 |
+
messages=current_messages,
|
| 53 |
+
temperature=temperature,
|
| 54 |
+
max_tokens=max_tokens,
|
| 55 |
+
timeout=config.timeout_seconds,
|
| 56 |
+
response_format=response_format,
|
| 57 |
+
reasoning_effort=tier_reasoning_effort,
|
| 58 |
+
agent_name=agent,
|
| 59 |
+
tier_index=tier_idx,
|
| 60 |
+
**kwargs
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Programmatic validation (Level 2 Cascade Trigger)
|
| 64 |
+
if validator:
|
| 65 |
+
try:
|
| 66 |
+
if inspect.iscoroutinefunction(validator):
|
| 67 |
+
val_result = await validator(raw_output)
|
| 68 |
+
else:
|
| 69 |
+
val_result = validator(raw_output)
|
| 70 |
+
|
| 71 |
+
# Expect (is_valid, payload_or_error)
|
| 72 |
+
if isinstance(val_result, tuple) and len(val_result) == 2:
|
| 73 |
+
is_valid, payload = val_result
|
| 74 |
+
if is_valid:
|
| 75 |
+
logger.info(
|
| 76 |
+
f"[AgentRuntime] Agent '{agent}' Tier {tier_idx} validation PASSED."
|
| 77 |
+
)
|
| 78 |
+
return payload
|
| 79 |
+
else:
|
| 80 |
+
logger.warning(
|
| 81 |
+
f"[AgentRuntime] Agent '{agent}' Tier {tier_idx} validation FAILED: {payload}. "
|
| 82 |
+
"Escalating..."
|
| 83 |
+
)
|
| 84 |
+
# Provide feedback to conversation context for subsequent attempts
|
| 85 |
+
current_messages.append({"role": "assistant", "content": raw_output})
|
| 86 |
+
current_messages.append({
|
| 87 |
+
"role": "user",
|
| 88 |
+
"content": f"Your previous output failed validation: {payload}. Please correct the issues and provide a valid response."
|
| 89 |
+
})
|
| 90 |
+
continue
|
| 91 |
+
elif bool(val_result):
|
| 92 |
+
return val_result
|
| 93 |
+
except Exception as val_e:
|
| 94 |
+
logger.warning(
|
| 95 |
+
f"[AgentRuntime] Validator raised exception on Tier {tier_idx}: {val_e}. Escalating..."
|
| 96 |
+
)
|
| 97 |
+
last_error = val_e
|
| 98 |
+
continue
|
| 99 |
+
else:
|
| 100 |
+
# No validator required, output is accepted
|
| 101 |
+
return raw_output
|
| 102 |
+
|
| 103 |
+
except Exception as tier_e:
|
| 104 |
+
logger.warning(
|
| 105 |
+
f"[AgentRuntime] Tier {tier_idx} attempt {attempt + 1} failed: {tier_e}"
|
| 106 |
+
)
|
| 107 |
+
last_error = tier_e
|
| 108 |
+
|
| 109 |
+
# All model tiers exhausted
|
| 110 |
+
raise RuntimeError(
|
| 111 |
+
f"Agent '{agent}' cascade exhausted all {len(config.tiers)} model tiers. Last error: {last_error}"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
_GLOBAL_AGENT_RUNTIME: Optional[AgentRuntime] = None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def get_agent_runtime() -> AgentRuntime:
|
| 119 |
+
global _GLOBAL_AGENT_RUNTIME
|
| 120 |
+
if _GLOBAL_AGENT_RUNTIME is None:
|
| 121 |
+
_GLOBAL_AGENT_RUNTIME = AgentRuntime()
|
| 122 |
+
return _GLOBAL_AGENT_RUNTIME
|
agents/torch_ultralytics_compat.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shim: moved to ``vision_ocr.compat`` for OCR worker isolation."""
|
| 2 |
+
|
| 3 |
+
from vision_ocr.compat import allow_ultralytics_weights
|
| 4 |
+
|
| 5 |
+
__all__ = ["allow_ultralytics_weights"]
|
agents/vlm_corrector.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VLM OCR Corrector Agent.
|
| 3 |
+
|
| 4 |
+
Uses a multimodal Vision-Language Model to correct OCR errors when confidence is low.
|
| 5 |
+
Strictly adheres to READ/CORRECT/PRESERVE boundaries — never SOLVE/INFER/INVENT.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import base64
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
from typing import Any, Dict, List, Optional
|
| 16 |
+
|
| 17 |
+
from config.schemas import OCRCorrectionConfig
|
| 18 |
+
from llm.service import get_llm_service
|
| 19 |
+
from vision_ocr.canonical_schema import CanonicalOCRResult
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class VLMCorrectorAgent:
|
| 25 |
+
"""
|
| 26 |
+
VLM-based OCR correction agent.
|
| 27 |
+
|
| 28 |
+
Receives raw image + OCR output + confidence and uses a multimodal LLM
|
| 29 |
+
to correct OCR recognition errors.
|
| 30 |
+
|
| 31 |
+
Strict boundary:
|
| 32 |
+
- READ: Re-read text and formulas from the image
|
| 33 |
+
- CORRECT: Fix OCR misrecognitions
|
| 34 |
+
- PRESERVE: Keep all original information intact
|
| 35 |
+
|
| 36 |
+
Never:
|
| 37 |
+
- SOLVE: Do not solve the math problem
|
| 38 |
+
- INFER: Do not infer missing geometry values
|
| 39 |
+
- INVENT: Do not add information not visible in the image
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self, config: Optional[OCRCorrectionConfig] = None):
|
| 43 |
+
self.config = config or OCRCorrectionConfig()
|
| 44 |
+
self.llm_service = get_llm_service()
|
| 45 |
+
|
| 46 |
+
async def extract_direct(
|
| 47 |
+
self,
|
| 48 |
+
image_url: Optional[str] = None,
|
| 49 |
+
image_path: Optional[str] = None,
|
| 50 |
+
) -> CanonicalOCRResult:
|
| 51 |
+
"""
|
| 52 |
+
Direct multimodal VLM OCR extraction from image.
|
| 53 |
+
Accurately transcribes Vietnamese text, mathematical formulas, and LaTeX symbols.
|
| 54 |
+
"""
|
| 55 |
+
if not image_url and not image_path:
|
| 56 |
+
logger.warning("[VLMCorrector] No image provided for direct extraction")
|
| 57 |
+
return CanonicalOCRResult(text="", confidence=0.0)
|
| 58 |
+
|
| 59 |
+
# Convert local image to base64 data URI if needed
|
| 60 |
+
if image_path and not image_url and os.path.exists(image_path):
|
| 61 |
+
import base64
|
| 62 |
+
with open(image_path, "rb") as f:
|
| 63 |
+
b64 = base64.b64encode(f.read()).decode("utf-8")
|
| 64 |
+
ext = os.path.splitext(image_path)[1].lstrip(".").lower()
|
| 65 |
+
mime = "image/jpeg" if ext in ("jpg", "jpeg") else ("image/webp" if ext == "webp" else "image/png")
|
| 66 |
+
image_url = f"data:{mime};base64,{b64}"
|
| 67 |
+
|
| 68 |
+
if not image_url:
|
| 69 |
+
return CanonicalOCRResult(text="", confidence=0.0)
|
| 70 |
+
|
| 71 |
+
system_prompt = """You are a High-Precision Math OCR Vision Agent for Vietnamese mathematical and geometry problems.
|
| 72 |
+
|
| 73 |
+
=== YOUR TASK ===
|
| 74 |
+
Carefully transcribe all printed/handwritten text, mathematical formulas, geometric terms, and notation from the image into Markdown format.
|
| 75 |
+
Use standard LaTeX math syntax:
|
| 76 |
+
- Inline formulas and geometric variables: $...$ (e.g. $ABCD$, $SO=12$, $(MED)$)
|
| 77 |
+
- Display math equations: $$...$$
|
| 78 |
+
|
| 79 |
+
=== STRICT RULES ===
|
| 80 |
+
1. READ & TRANSCRIBE ONLY: Transcribe exactly what is visible in the image.
|
| 81 |
+
2. DO NOT SOLVE: Do not solve the problem or add your own calculations.
|
| 82 |
+
3. PRESERVE VIETNAMESE ACCENTS & DIACRITICS: Ensure all Vietnamese words have correct diacritics and correct spelling.
|
| 83 |
+
4. Output ONLY the raw transcribed text. Do NOT wrap your output in markdown code blocks (such as ```markdown)."""
|
| 84 |
+
|
| 85 |
+
user_content_parts = [
|
| 86 |
+
{"type": "image_url", "image_url": {"url": image_url}},
|
| 87 |
+
{"type": "text", "text": "Please transcribe the entire math problem from this image accurately."},
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
messages = [
|
| 91 |
+
{"role": "system", "content": system_prompt},
|
| 92 |
+
{"role": "user", "content": user_content_parts},
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
raw_response = await self.llm_service.acomplete(
|
| 97 |
+
model=self.config.model,
|
| 98 |
+
messages=messages,
|
| 99 |
+
temperature=self.config.temperature,
|
| 100 |
+
max_tokens=self.config.max_tokens,
|
| 101 |
+
timeout=self.config.timeout_seconds,
|
| 102 |
+
agent_name="vlm_ocr_direct",
|
| 103 |
+
)
|
| 104 |
+
text = raw_response.strip()
|
| 105 |
+
# Clean possible markdown code fences
|
| 106 |
+
m = re.match(r"^```(?:markdown|latex|text)?\s*(.*?)\s*```$", text, re.DOTALL)
|
| 107 |
+
if m:
|
| 108 |
+
text = m.group(1).strip()
|
| 109 |
+
|
| 110 |
+
return CanonicalOCRResult(
|
| 111 |
+
text=text,
|
| 112 |
+
confidence=0.98,
|
| 113 |
+
metadata={"engine": "vlm_direct", "model": self.config.model},
|
| 114 |
+
)
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"[VLMCorrector] Direct VLM extraction failed: {e}")
|
| 117 |
+
return CanonicalOCRResult(
|
| 118 |
+
text="",
|
| 119 |
+
confidence=0.0,
|
| 120 |
+
metadata={"error": str(e)},
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
async def correct(
|
| 124 |
+
self,
|
| 125 |
+
ocr_result: CanonicalOCRResult,
|
| 126 |
+
image_url: Optional[str] = None,
|
| 127 |
+
image_path: Optional[str] = None,
|
| 128 |
+
) -> Dict[str, Any]:
|
| 129 |
+
"""
|
| 130 |
+
Correct OCR errors using VLM.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
ocr_result: The original OCR result with text and confidence
|
| 134 |
+
image_url: URL of the original image (for multimodal input)
|
| 135 |
+
image_path: Local path to the original image
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Dict with corrected_text, changed, confidence, corrections[]
|
| 139 |
+
"""
|
| 140 |
+
if not image_url and not image_path:
|
| 141 |
+
logger.warning("[VLMCorrector] No image provided, skipping correction")
|
| 142 |
+
return {"changed": False, "corrected_text": ocr_result.text, "confidence": ocr_result.confidence, "corrections": []}
|
| 143 |
+
|
| 144 |
+
system_prompt = """You are an OCR Correction Agent for Vietnamese mathematical geometry problems.
|
| 145 |
+
|
| 146 |
+
=== YOUR TASK ===
|
| 147 |
+
You receive:
|
| 148 |
+
1. An image of a math problem
|
| 149 |
+
2. OCR-extracted text (which may contain errors)
|
| 150 |
+
3. OCR confidence score
|
| 151 |
+
|
| 152 |
+
Your job is to CORRECT OCR recognition errors by re-reading the image carefully.
|
| 153 |
+
|
| 154 |
+
=== STRICT BOUNDARIES ===
|
| 155 |
+
You MUST ONLY:
|
| 156 |
+
- READ: Re-read text, numbers, and mathematical formulas from the image
|
| 157 |
+
- CORRECT: Fix misrecognized characters, numbers, symbols, and LaTeX
|
| 158 |
+
- PRESERVE: Keep all original information intact
|
| 159 |
+
|
| 160 |
+
You MUST NOT:
|
| 161 |
+
- SOLVE: Do not solve or attempt to solve the math problem
|
| 162 |
+
- INFER: Do not infer missing values or geometry relationships
|
| 163 |
+
- INVENT: Do not add any information not visible in the image
|
| 164 |
+
- If a value is unclear or unreadable, mark it as "?" — do NOT guess
|
| 165 |
+
|
| 166 |
+
=== OUTPUT FORMAT ===
|
| 167 |
+
Output ONLY a JSON object:
|
| 168 |
+
{
|
| 169 |
+
"corrected_text": "The corrected full text with proper LaTeX",
|
| 170 |
+
"changed": true/false,
|
| 171 |
+
"confidence": 0.95,
|
| 172 |
+
"corrections": [
|
| 173 |
+
{
|
| 174 |
+
"original": "SA = 8",
|
| 175 |
+
"corrected": "SA = 6",
|
| 176 |
+
"reason": "OCR misread digit 6 as 8"
|
| 177 |
+
}
|
| 178 |
+
]
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
If no corrections are needed, set "changed": false and return the original text."""
|
| 182 |
+
|
| 183 |
+
user_content_parts = []
|
| 184 |
+
|
| 185 |
+
# Add image content for multimodal input
|
| 186 |
+
if image_path and not image_url and os.path.exists(image_path):
|
| 187 |
+
import base64
|
| 188 |
+
with open(image_path, "rb") as f:
|
| 189 |
+
b64 = base64.b64encode(f.read()).decode("utf-8")
|
| 190 |
+
ext = os.path.splitext(image_path)[1].lstrip(".").lower()
|
| 191 |
+
mime = "image/jpeg" if ext in ("jpg", "jpeg") else ("image/webp" if ext == "webp" else "image/png")
|
| 192 |
+
image_url = f"data:{mime};base64,{b64}"
|
| 193 |
+
|
| 194 |
+
if image_url:
|
| 195 |
+
user_content_parts.append({
|
| 196 |
+
"type": "image_url",
|
| 197 |
+
"image_url": {"url": image_url},
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
user_content_parts.append({
|
| 201 |
+
"type": "text",
|
| 202 |
+
"text": f"""OCR Extracted Text (confidence: {ocr_result.confidence:.3f}):
|
| 203 |
+
|
| 204 |
+
{ocr_result.text}
|
| 205 |
+
|
| 206 |
+
Please carefully compare the image with the OCR text above and correct any recognition errors.""",
|
| 207 |
+
})
|
| 208 |
+
|
| 209 |
+
messages = [
|
| 210 |
+
{"role": "system", "content": system_prompt},
|
| 211 |
+
{"role": "user", "content": user_content_parts},
|
| 212 |
+
]
|
| 213 |
+
|
| 214 |
+
try:
|
| 215 |
+
raw_response = await self.llm_service.acomplete(
|
| 216 |
+
model=self.config.model,
|
| 217 |
+
messages=messages,
|
| 218 |
+
temperature=self.config.temperature,
|
| 219 |
+
max_tokens=self.config.max_tokens,
|
| 220 |
+
timeout=self.config.timeout_seconds,
|
| 221 |
+
agent_name="vlm_corrector",
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
result = self._parse_correction_response(raw_response, ocr_result.text)
|
| 225 |
+
logger.info(
|
| 226 |
+
f"[VLMCorrector] Correction result: changed={result.get('changed')}, "
|
| 227 |
+
f"corrections={len(result.get('corrections', []))}"
|
| 228 |
+
)
|
| 229 |
+
return result
|
| 230 |
+
|
| 231 |
+
except Exception as e:
|
| 232 |
+
logger.error(f"[VLMCorrector] Correction failed: {e}")
|
| 233 |
+
return {
|
| 234 |
+
"changed": False,
|
| 235 |
+
"corrected_text": ocr_result.text,
|
| 236 |
+
"confidence": ocr_result.confidence,
|
| 237 |
+
"corrections": [],
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
def _parse_correction_response(self, raw: str, original_text: str) -> Dict[str, Any]:
|
| 241 |
+
"""Parse VLM correction response JSON."""
|
| 242 |
+
try:
|
| 243 |
+
cleaned = raw.strip()
|
| 244 |
+
# Extract JSON from markdown code block if present
|
| 245 |
+
json_match = re.search(r"```(?:json)?\s*(.*?)\s*```", cleaned, re.DOTALL)
|
| 246 |
+
if json_match:
|
| 247 |
+
cleaned = json_match.group(1).strip()
|
| 248 |
+
|
| 249 |
+
# Try direct JSON parse
|
| 250 |
+
brace_match = re.search(r"(\{.*\})", cleaned, re.DOTALL)
|
| 251 |
+
if brace_match:
|
| 252 |
+
cleaned = brace_match.group(1)
|
| 253 |
+
|
| 254 |
+
data = json.loads(cleaned)
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"corrected_text": data.get("corrected_text", original_text),
|
| 258 |
+
"changed": bool(data.get("changed", False)),
|
| 259 |
+
"confidence": float(data.get("confidence", 0.9)),
|
| 260 |
+
"corrections": data.get("corrections", []),
|
| 261 |
+
}
|
| 262 |
+
except (json.JSONDecodeError, Exception) as e:
|
| 263 |
+
logger.warning(f"[VLMCorrector] Failed to parse response: {e}")
|
| 264 |
+
return {
|
| 265 |
+
"changed": False,
|
| 266 |
+
"corrected_text": original_text,
|
| 267 |
+
"confidence": 0.5,
|
| 268 |
+
"corrections": [],
|
| 269 |
+
}
|
app/__init__.py
ADDED
|
File without changes
|
app/celery_app.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celery Application configuration for asynchronous background worker jobs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from celery import Celery
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
REDIS_URL = os.getenv("REDIS_URL") or os.getenv("CELERY_BROKER_URL") or "redis://localhost:6379/0"
|
| 12 |
+
|
| 13 |
+
celery_app = Celery(
|
| 14 |
+
"mathsolver_worker",
|
| 15 |
+
broker=REDIS_URL,
|
| 16 |
+
backend=REDIS_URL,
|
| 17 |
+
include=["app.tasks"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
celery_app.conf.update(
|
| 21 |
+
task_serializer="json",
|
| 22 |
+
accept_content=["json"],
|
| 23 |
+
result_serializer="json",
|
| 24 |
+
timezone="UTC",
|
| 25 |
+
enable_utc=True,
|
| 26 |
+
task_track_started=True,
|
| 27 |
+
task_time_limit=900, # 15 minutes max
|
| 28 |
+
task_soft_time_limit=840,
|
| 29 |
+
worker_prefetch_multiplier=1,
|
| 30 |
+
worker_concurrency=int(os.getenv("CELERY_CONCURRENCY", "4")),
|
| 31 |
+
task_acks_late=True,
|
| 32 |
+
task_reject_on_worker_lost=True,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def is_celery_available() -> bool:
|
| 37 |
+
"""
|
| 38 |
+
Check if Celery / Redis broker is configured and reachable.
|
| 39 |
+
Returns False when running in minimal dev/test environments without Redis.
|
| 40 |
+
"""
|
| 41 |
+
disable_celery = os.getenv("DISABLE_CELERY", "0").lower() in ("1", "true", "yes")
|
| 42 |
+
if disable_celery:
|
| 43 |
+
return False
|
| 44 |
+
try:
|
| 45 |
+
import redis
|
| 46 |
+
client = redis.from_url(REDIS_URL, socket_connect_timeout=0.5, socket_timeout=0.5)
|
| 47 |
+
client.ping()
|
| 48 |
+
return True
|
| 49 |
+
except Exception:
|
| 50 |
+
return False
|
app/chat_image_upload.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate and upload chat/solve attachment images to Supabase Storage (image bucket)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import uuid
|
| 8 |
+
from typing import Any, Dict, Tuple
|
| 9 |
+
|
| 10 |
+
from fastapi import HTTPException
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _get_next_image_version(session_id: str) -> int:
|
| 16 |
+
"""Same logic as worker.asset_manager.get_next_version for asset_type image."""
|
| 17 |
+
from app.supabase_client import get_supabase
|
| 18 |
+
|
| 19 |
+
supabase = get_supabase()
|
| 20 |
+
try:
|
| 21 |
+
res = (
|
| 22 |
+
supabase.table("session_assets")
|
| 23 |
+
.select("version")
|
| 24 |
+
.eq("session_id", session_id)
|
| 25 |
+
.eq("asset_type", "image")
|
| 26 |
+
.order("version", desc=True)
|
| 27 |
+
.limit(1)
|
| 28 |
+
.execute()
|
| 29 |
+
)
|
| 30 |
+
if res.data:
|
| 31 |
+
return res.data[0]["version"] + 1
|
| 32 |
+
return 1
|
| 33 |
+
except Exception as e:
|
| 34 |
+
logger.error("Error fetching image version: %s", e)
|
| 35 |
+
return 1
|
| 36 |
+
|
| 37 |
+
_MAX_BYTES_DEFAULT = 10 * 1024 * 1024
|
| 38 |
+
|
| 39 |
+
_EXT_TO_MIME: dict[str, str] = {
|
| 40 |
+
".png": "image/png",
|
| 41 |
+
".jpg": "image/jpeg",
|
| 42 |
+
".jpeg": "image/jpeg",
|
| 43 |
+
".webp": "image/webp",
|
| 44 |
+
".gif": "image/gif",
|
| 45 |
+
".bmp": "image/bmp",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _max_bytes() -> int:
|
| 50 |
+
raw = os.getenv("CHAT_IMAGE_MAX_BYTES")
|
| 51 |
+
if raw and raw.isdigit():
|
| 52 |
+
return min(int(raw), 50 * 1024 * 1024)
|
| 53 |
+
return _MAX_BYTES_DEFAULT
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _magic_ok(ext: str, body: bytes) -> bool:
|
| 57 |
+
if len(body) < 2:
|
| 58 |
+
return False
|
| 59 |
+
if ext == ".png":
|
| 60 |
+
return len(body) >= 8 and body.startswith(b"\x89PNG\r\n\x1a\n")
|
| 61 |
+
if ext in (".jpg", ".jpeg"):
|
| 62 |
+
return len(body) >= 3 and body.startswith(b"\xff\xd8\xff")
|
| 63 |
+
if ext == ".webp":
|
| 64 |
+
return len(body) >= 12 and body.startswith(b"RIFF") and body[8:12] == b"WEBP"
|
| 65 |
+
if ext == ".gif":
|
| 66 |
+
return len(body) >= 6 and (body.startswith(b"GIF87a") or body.startswith(b"GIF89a"))
|
| 67 |
+
if ext == ".bmp":
|
| 68 |
+
return len(body) >= 2 and body.startswith(b"BM")
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def validate_chat_image_bytes(
|
| 73 |
+
filename: str | None,
|
| 74 |
+
body: bytes,
|
| 75 |
+
declared_content_type: str | None,
|
| 76 |
+
) -> Tuple[str, str]:
|
| 77 |
+
"""
|
| 78 |
+
Validate size, extension, and magic bytes.
|
| 79 |
+
Returns (extension_with_dot, content_type).
|
| 80 |
+
"""
|
| 81 |
+
max_b = _max_bytes()
|
| 82 |
+
if not body:
|
| 83 |
+
raise HTTPException(status_code=400, detail="Empty file.")
|
| 84 |
+
if len(body) > max_b:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=413,
|
| 87 |
+
detail=f"Image too large (max {max_b // (1024 * 1024)} MB).",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
ext = os.path.splitext(filename or "")[1].lower()
|
| 91 |
+
if not ext:
|
| 92 |
+
ext = ".png"
|
| 93 |
+
if ext not in _EXT_TO_MIME:
|
| 94 |
+
raise HTTPException(
|
| 95 |
+
status_code=400,
|
| 96 |
+
detail=f"Unsupported image type: {ext}. Allowed: {', '.join(sorted(_EXT_TO_MIME))}",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if not _magic_ok(ext, body):
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=400,
|
| 102 |
+
detail="File content does not match declared image type.",
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
mime = _EXT_TO_MIME[ext]
|
| 106 |
+
if declared_content_type:
|
| 107 |
+
decl = declared_content_type.split(";")[0].strip().lower()
|
| 108 |
+
if decl and decl not in ("application/octet-stream", mime) and decl != mime:
|
| 109 |
+
logger.warning(
|
| 110 |
+
"Content-Type mismatch (declared=%s, inferred=%s); using inferred.",
|
| 111 |
+
declared_content_type,
|
| 112 |
+
mime,
|
| 113 |
+
)
|
| 114 |
+
return ext, mime
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def upload_session_chat_image(
|
| 118 |
+
session_id: str,
|
| 119 |
+
job_id: str,
|
| 120 |
+
file_bytes: bytes,
|
| 121 |
+
ext_with_dot: str,
|
| 122 |
+
content_type: str,
|
| 123 |
+
) -> Dict[str, Any]:
|
| 124 |
+
"""
|
| 125 |
+
Upload to SUPABASE_IMAGE_BUCKET (default: image), insert session_assets row.
|
| 126 |
+
Returns dict with public_url, storage_path, version, session_asset_id (if returned).
|
| 127 |
+
"""
|
| 128 |
+
from app.supabase_client import get_supabase
|
| 129 |
+
|
| 130 |
+
supabase = get_supabase()
|
| 131 |
+
bucket_name = os.getenv("SUPABASE_IMAGE_BUCKET", "image")
|
| 132 |
+
raw_ext = ext_with_dot.lstrip(".").lower()
|
| 133 |
+
|
| 134 |
+
max_retries = 3
|
| 135 |
+
last_err = None
|
| 136 |
+
for attempt in range(max_retries):
|
| 137 |
+
version = _get_next_image_version(session_id) + attempt
|
| 138 |
+
file_name = f"image_v{version}_{job_id}.{raw_ext}"
|
| 139 |
+
storage_path = f"sessions/{session_id}/{file_name}"
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
supabase.storage.from_(bucket_name).upload(
|
| 143 |
+
path=storage_path,
|
| 144 |
+
file=file_bytes,
|
| 145 |
+
file_options={"content-type": content_type, "upsert": "true"},
|
| 146 |
+
)
|
| 147 |
+
public_url = supabase.storage.from_(bucket_name).get_public_url(storage_path)
|
| 148 |
+
if isinstance(public_url, dict):
|
| 149 |
+
public_url = public_url.get("publicUrl") or public_url.get("public_url") or str(public_url)
|
| 150 |
+
|
| 151 |
+
row = {
|
| 152 |
+
"session_id": session_id,
|
| 153 |
+
"job_id": job_id,
|
| 154 |
+
"asset_type": "image",
|
| 155 |
+
"storage_path": storage_path,
|
| 156 |
+
"public_url": public_url,
|
| 157 |
+
"version": version,
|
| 158 |
+
}
|
| 159 |
+
ins = supabase.table("session_assets").insert(row).select("id").execute()
|
| 160 |
+
asset_id = None
|
| 161 |
+
if ins.data and len(ins.data) > 0:
|
| 162 |
+
asset_id = ins.data[0].get("id")
|
| 163 |
+
|
| 164 |
+
log_data = {
|
| 165 |
+
"public_url": public_url,
|
| 166 |
+
"storage_path": storage_path,
|
| 167 |
+
"version": version,
|
| 168 |
+
"session_asset_id": str(asset_id) if asset_id else None,
|
| 169 |
+
}
|
| 170 |
+
logger.info("Uploaded chat image: %s", log_data)
|
| 171 |
+
return {
|
| 172 |
+
"public_url": public_url,
|
| 173 |
+
"storage_path": storage_path,
|
| 174 |
+
"version": version,
|
| 175 |
+
"session_asset_id": str(asset_id) if asset_id else None,
|
| 176 |
+
}
|
| 177 |
+
except Exception as e:
|
| 178 |
+
last_err = e
|
| 179 |
+
logger.warning(
|
| 180 |
+
"Retry uploading chat image for session %s (attempt %d/%d): %s",
|
| 181 |
+
session_id,
|
| 182 |
+
attempt + 1,
|
| 183 |
+
max_retries,
|
| 184 |
+
e,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
raise HTTPException(
|
| 188 |
+
status_code=500,
|
| 189 |
+
detail=f"Failed to upload image after {max_retries} attempts: {last_err}",
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def upload_ephemeral_ocr_blob(
|
| 194 |
+
file_bytes: bytes,
|
| 195 |
+
ext_with_dot: str,
|
| 196 |
+
content_type: str,
|
| 197 |
+
) -> Tuple[str, str]:
|
| 198 |
+
"""
|
| 199 |
+
Upload bytes to image bucket under _ocr_temp/ for worker-only OCR (no session_assets row).
|
| 200 |
+
Returns (storage_path, public_url). Caller must delete_storage_object when done.
|
| 201 |
+
"""
|
| 202 |
+
from app.supabase_client import get_supabase
|
| 203 |
+
|
| 204 |
+
bucket_name = os.getenv("SUPABASE_IMAGE_BUCKET", "image")
|
| 205 |
+
raw_ext = ext_with_dot.lstrip(".").lower() or "png"
|
| 206 |
+
name = f"_ocr_temp/{uuid.uuid4().hex}.{raw_ext}"
|
| 207 |
+
supabase = get_supabase()
|
| 208 |
+
supabase.storage.from_(bucket_name).upload(
|
| 209 |
+
path=name,
|
| 210 |
+
file=file_bytes,
|
| 211 |
+
file_options={"content-type": content_type},
|
| 212 |
+
)
|
| 213 |
+
public_url = supabase.storage.from_(bucket_name).get_public_url(name)
|
| 214 |
+
if isinstance(public_url, dict):
|
| 215 |
+
public_url = public_url.get("publicUrl") or public_url.get("public_url") or str(public_url)
|
| 216 |
+
return name, public_url
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def delete_storage_object(bucket_name: str, storage_path: str) -> None:
|
| 220 |
+
try:
|
| 221 |
+
from app.supabase_client import get_supabase
|
| 222 |
+
|
| 223 |
+
supabase = get_supabase()
|
| 224 |
+
if supabase:
|
| 225 |
+
supabase.storage.from_(bucket_name).remove([storage_path])
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.warning("delete_storage_object failed path=%s: %s", storage_path, e)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def cleanup_session_storage(session_id: str) -> None:
|
| 231 |
+
"""List and delete all storage files in video and image buckets under sessions/{session_id}/."""
|
| 232 |
+
from app.supabase_client import get_supabase
|
| 233 |
+
|
| 234 |
+
supabase = get_supabase()
|
| 235 |
+
if not supabase:
|
| 236 |
+
return
|
| 237 |
+
|
| 238 |
+
folder = f"sessions/{session_id}"
|
| 239 |
+
for bucket in ["image", os.getenv("SUPABASE_BUCKET", "video")]:
|
| 240 |
+
try:
|
| 241 |
+
items = supabase.storage.from_(bucket).list(folder)
|
| 242 |
+
if items:
|
| 243 |
+
paths_to_remove = []
|
| 244 |
+
for item in items:
|
| 245 |
+
name = item.get("name") if isinstance(item, dict) else getattr(item, "name", None)
|
| 246 |
+
if name and name != ".emptyFolderPlaceholder":
|
| 247 |
+
paths_to_remove.append(f"{folder}/{name}")
|
| 248 |
+
if paths_to_remove:
|
| 249 |
+
supabase.storage.from_(bucket).remove(paths_to_remove)
|
| 250 |
+
logger.info("Cleaned up %d objects in bucket '%s' for session %s", len(paths_to_remove), bucket, session_id)
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger.warning("Failed to clean up storage bucket '%s' for session %s: %s", bucket, session_id, e)
|
| 253 |
+
|
app/dependencies.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException, Header
|
| 2 |
+
|
| 3 |
+
from app.supabase_client import get_supabase, get_supabase_for_user_jwt
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def get_current_user_id(authorization: str | None = Header(None)):
|
| 7 |
+
"""
|
| 8 |
+
Authenticate user using Supabase JWT.
|
| 9 |
+
Expected Header: Authorization: Bearer <token>
|
| 10 |
+
"""
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
if not authorization:
|
| 14 |
+
raise HTTPException(
|
| 15 |
+
status_code=401,
|
| 16 |
+
detail="Authorization header missing or invalid. Use 'Bearer <token>'",
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
if os.getenv("ALLOW_TEST_BYPASS") == "true" and authorization.startswith("Test "):
|
| 20 |
+
return authorization.split(" ")[1]
|
| 21 |
+
|
| 22 |
+
if not authorization.startswith("Bearer "):
|
| 23 |
+
raise HTTPException(
|
| 24 |
+
status_code=401,
|
| 25 |
+
detail="Authorization header missing or invalid. Use 'Bearer <token>'",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
token = authorization.split(" ")[1]
|
| 29 |
+
supabase = get_supabase()
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
user_response = supabase.auth.get_user(token)
|
| 33 |
+
if not user_response or not user_response.user:
|
| 34 |
+
raise HTTPException(status_code=401, detail="Invalid session or token.")
|
| 35 |
+
|
| 36 |
+
return user_response.user.id
|
| 37 |
+
except HTTPException:
|
| 38 |
+
raise
|
| 39 |
+
except Exception as e:
|
| 40 |
+
raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def get_authenticated_supabase(authorization: str = Header(...)):
|
| 44 |
+
"""
|
| 45 |
+
Supabase client that carries the user's JWT (anon key + Authorization header).
|
| 46 |
+
Use for routes that should respect Row Level Security; pair with app logic as needed.
|
| 47 |
+
"""
|
| 48 |
+
import os
|
| 49 |
+
|
| 50 |
+
if not authorization:
|
| 51 |
+
raise HTTPException(
|
| 52 |
+
status_code=401,
|
| 53 |
+
detail="Authorization header missing or invalid. Use 'Bearer <token>'",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if os.getenv("ALLOW_TEST_BYPASS") == "true" and authorization.startswith("Test "):
|
| 57 |
+
return get_supabase()
|
| 58 |
+
|
| 59 |
+
if not authorization.startswith("Bearer "):
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=401,
|
| 62 |
+
detail="Authorization header missing or invalid. Use 'Bearer <token>'",
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
token = authorization.split(" ")[1]
|
| 66 |
+
supabase = get_supabase()
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
user_response = supabase.auth.get_user(token)
|
| 70 |
+
if not user_response or not user_response.user:
|
| 71 |
+
raise HTTPException(status_code=401, detail="Invalid session or token.")
|
| 72 |
+
except HTTPException:
|
| 73 |
+
raise
|
| 74 |
+
except Exception as e:
|
| 75 |
+
raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
return get_supabase_for_user_jwt(token)
|
| 79 |
+
except RuntimeError as e:
|
| 80 |
+
raise HTTPException(status_code=503, detail=str(e))
|
app/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Map exceptions to short, user-visible messages (avoid leaking HTML bodies from 404 proxies)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _looks_like_html(text: str) -> bool:
|
| 11 |
+
t = text.lstrip()[:500].lower()
|
| 12 |
+
return t.startswith("<!doctype") or t.startswith("<html") or "<html" in t[:200]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def format_error_for_user(exc: BaseException) -> str:
|
| 16 |
+
"""
|
| 17 |
+
Produce a safe message for chat/UI. Full detail stays in server logs via logger.exception.
|
| 18 |
+
"""
|
| 19 |
+
# httpx: wrong URL often returns 404 HTML; don't show body
|
| 20 |
+
try:
|
| 21 |
+
import httpx
|
| 22 |
+
|
| 23 |
+
if isinstance(exc, httpx.HTTPStatusError):
|
| 24 |
+
req = exc.request
|
| 25 |
+
code = exc.response.status_code
|
| 26 |
+
url_hint = ""
|
| 27 |
+
try:
|
| 28 |
+
url_hint = str(req.url.host) if req and req.url else ""
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
logger.warning(
|
| 32 |
+
"HTTPStatusError %s for %s (response not shown to user)",
|
| 33 |
+
code,
|
| 34 |
+
url_hint or "?",
|
| 35 |
+
)
|
| 36 |
+
return (
|
| 37 |
+
"Kiểm tra URL API, khóa bí mật và biến môi trường (OpenRouter/Supabase/Redis)."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
if isinstance(exc, httpx.RequestError):
|
| 41 |
+
return "Không kết nối được tới dịch vụ ngoài (mạng hoặc URL sai)."
|
| 42 |
+
except ImportError:
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
raw = str(exc).strip()
|
| 46 |
+
if not raw:
|
| 47 |
+
return "Đã xảy ra lỗi không xác định."
|
| 48 |
+
|
| 49 |
+
if _looks_like_html(raw):
|
| 50 |
+
logger.warning("Suppressed HTML error body from user-facing message")
|
| 51 |
+
return (
|
| 52 |
+
"Dịch vụ trả về trang lỗi (thường là URL API sai hoặc endpoint không tồn tại — HTTP 404). "
|
| 53 |
+
"Kiểm tra OPENROUTER_MODEL và khóa API trên server."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if len(raw) > 800:
|
| 57 |
+
return raw[:800] + "…"
|
| 58 |
+
|
| 59 |
+
return raw
|
app/job_poll.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Normalize Supabase `jobs` rows for polling / WebSocket clients (stable `job_id` + JSON `result`)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _coerce_result(value: Any) -> Any:
|
| 13 |
+
if value is None:
|
| 14 |
+
return None
|
| 15 |
+
if isinstance(value, (dict, list)):
|
| 16 |
+
return value
|
| 17 |
+
if isinstance(value, str):
|
| 18 |
+
try:
|
| 19 |
+
return json.loads(value)
|
| 20 |
+
except json.JSONDecodeError:
|
| 21 |
+
logger.warning("job_poll: result is non-JSON string, returning raw")
|
| 22 |
+
return {"raw": value}
|
| 23 |
+
return value
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
from app.models.job_state import JobStateMachine, JobStatus, STAGE_PROGRESS_MAP, JobStage
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def normalize_job_row_for_client(row: dict[str, Any]) -> dict[str, Any]:
|
| 30 |
+
"""
|
| 31 |
+
Build a JSON-serializable dict that conforms to P1 Job State Machine:
|
| 32 |
+
- ``job_id`` (alias of DB ``id``)
|
| 33 |
+
- ``status`` normalized (e.g. 'processing', 'completed', 'failed', 'queued')
|
| 34 |
+
- ``stage`` extracted from row or inferred (e.g. 'ocr', 'parsing', 'geometry', 'solving', 'rendering')
|
| 35 |
+
- ``progress`` integer 0-100
|
| 36 |
+
- ``result`` object/array
|
| 37 |
+
All other columns are passed through cleanly.
|
| 38 |
+
"""
|
| 39 |
+
out = dict(row)
|
| 40 |
+
jid = out.get("id")
|
| 41 |
+
if jid is not None:
|
| 42 |
+
out["job_id"] = str(jid)
|
| 43 |
+
|
| 44 |
+
st_raw = out.get("status")
|
| 45 |
+
normalized_status = JobStateMachine.normalize_status(st_raw)
|
| 46 |
+
out["status"] = normalized_status.value
|
| 47 |
+
|
| 48 |
+
# Extract or infer stage
|
| 49 |
+
stage_raw = out.get("stage")
|
| 50 |
+
normalized_stage = JobStateMachine.normalize_stage(stage_raw)
|
| 51 |
+
if not normalized_stage and normalized_status == JobStatus.PROCESSING:
|
| 52 |
+
if st_raw in ("ocr", "parsing", "geometry", "solving", "rendering"):
|
| 53 |
+
normalized_stage = JobStage(st_raw)
|
| 54 |
+
|
| 55 |
+
out["stage"] = normalized_stage.value if normalized_stage else None
|
| 56 |
+
|
| 57 |
+
# Calculate or normalize progress
|
| 58 |
+
if "progress" in out and out["progress"] is not None:
|
| 59 |
+
try:
|
| 60 |
+
out["progress"] = int(out["progress"])
|
| 61 |
+
except (ValueError, TypeError):
|
| 62 |
+
out["progress"] = STAGE_PROGRESS_MAP.get(normalized_stage, 50) if normalized_stage else (100 if normalized_status == JobStatus.COMPLETED else 0)
|
| 63 |
+
else:
|
| 64 |
+
if normalized_status == JobStatus.COMPLETED:
|
| 65 |
+
out["progress"] = 100
|
| 66 |
+
elif normalized_status == JobStatus.QUEUED:
|
| 67 |
+
out["progress"] = 5
|
| 68 |
+
elif normalized_stage and normalized_stage in STAGE_PROGRESS_MAP:
|
| 69 |
+
out["progress"] = STAGE_PROGRESS_MAP[normalized_stage]
|
| 70 |
+
elif normalized_status == JobStatus.PROCESSING:
|
| 71 |
+
out["progress"] = 50
|
| 72 |
+
else:
|
| 73 |
+
out["progress"] = 0
|
| 74 |
+
|
| 75 |
+
if "result" in out:
|
| 76 |
+
out["result"] = _coerce_result(out.get("result"))
|
| 77 |
+
if out.get("user_id") is not None:
|
| 78 |
+
out["user_id"] = str(out["user_id"])
|
| 79 |
+
if out.get("session_id") is not None:
|
| 80 |
+
out["session_id"] = str(out["session_id"])
|
| 81 |
+
return out
|
| 82 |
+
|
app/jobs/__init__.py
ADDED
|
File without changes
|
app/llm_client.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
from llm.service import get_llm_service, LLMService
|
| 9 |
+
from config.loader import load_agent_config
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MultiLayerLLMClient:
|
| 13 |
+
"""
|
| 14 |
+
Backward-compatible client adapter that delegates completions to LLMService.
|
| 15 |
+
Uses agent config for defaults but allows callers to override temperature/max_tokens
|
| 16 |
+
for ad-hoc usage (e.g. knowledge queries, chat completions).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.service: LLMService = get_llm_service()
|
| 21 |
+
|
| 22 |
+
async def chat_completions_create(
|
| 23 |
+
self,
|
| 24 |
+
messages: List[Dict[str, Any]],
|
| 25 |
+
response_format: Optional[Dict[str, Any]] = None,
|
| 26 |
+
agent: str = "reasoning_solver",
|
| 27 |
+
temperature: Optional[float] = None,
|
| 28 |
+
max_tokens: Optional[int] = None,
|
| 29 |
+
**kwargs
|
| 30 |
+
) -> str:
|
| 31 |
+
config = load_agent_config(agent)
|
| 32 |
+
model = config.tiers[0].model if config.tiers else "gemini/gemini-3.7-flash"
|
| 33 |
+
return await self.service.acomplete(
|
| 34 |
+
model=model,
|
| 35 |
+
messages=messages,
|
| 36 |
+
temperature=temperature if temperature is not None else config.temperature,
|
| 37 |
+
max_tokens=max_tokens if max_tokens is not None else config.max_tokens,
|
| 38 |
+
timeout=config.timeout_seconds,
|
| 39 |
+
response_format=response_format,
|
| 40 |
+
reasoning_effort=config.reasoning_effort,
|
| 41 |
+
agent_name=agent,
|
| 42 |
+
**kwargs
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
_llm_client: Optional[MultiLayerLLMClient] = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_llm_client() -> MultiLayerLLMClient:
|
| 50 |
+
global _llm_client
|
| 51 |
+
if _llm_client is None:
|
| 52 |
+
_llm_client = MultiLayerLLMClient()
|
| 53 |
+
return _llm_client
|
app/logging_setup.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Logging theo một biến LOG_LEVEL: debug | info | warning | error."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from typing import Final
|
| 8 |
+
|
| 9 |
+
_SETUP_DONE = False
|
| 10 |
+
|
| 11 |
+
PIPELINE_LOGGER_NAME: Final = "app.pipeline"
|
| 12 |
+
CACHE_LOGGER_NAME: Final = "app.cache"
|
| 13 |
+
STEPS_LOGGER_NAME: Final = "app.steps"
|
| 14 |
+
ACCESS_LOGGER_NAME: Final = "app.access"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _normalize_level() -> str:
|
| 18 |
+
raw = os.getenv("LOG_LEVEL", "info").strip().lower()
|
| 19 |
+
if raw in ("debug", "info", "warning", "error"):
|
| 20 |
+
return raw
|
| 21 |
+
return "info"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def setup_application_logging() -> None:
|
| 25 |
+
"""Idempotent; gọi khi khởi động process (uvicorn, celery, worker_health)."""
|
| 26 |
+
global _SETUP_DONE
|
| 27 |
+
if _SETUP_DONE:
|
| 28 |
+
return
|
| 29 |
+
_SETUP_DONE = True
|
| 30 |
+
|
| 31 |
+
mode = _normalize_level()
|
| 32 |
+
|
| 33 |
+
level_map = {
|
| 34 |
+
"debug": logging.DEBUG,
|
| 35 |
+
"info": logging.INFO,
|
| 36 |
+
"warning": logging.WARNING,
|
| 37 |
+
"error": logging.ERROR,
|
| 38 |
+
}
|
| 39 |
+
root_level = level_map[mode]
|
| 40 |
+
|
| 41 |
+
fmt_named = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
| 42 |
+
fmt_short = "%(asctime)s | %(levelname)-8s | %(message)s"
|
| 43 |
+
|
| 44 |
+
logging.basicConfig(
|
| 45 |
+
level=root_level,
|
| 46 |
+
format=fmt_named if mode == "debug" else fmt_short,
|
| 47 |
+
datefmt="%H:%M:%S",
|
| 48 |
+
force=True,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 52 |
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 53 |
+
logging.getLogger("openai").setLevel(logging.WARNING)
|
| 54 |
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
| 55 |
+
logging.getLogger("uvicorn.error").setLevel(logging.INFO)
|
| 56 |
+
# HTTP/2 stack (httpx/httpcore) — khi LOG_LEVEL=debug root=DEBUG sẽ tràn log hpack; không cần cho debug app
|
| 57 |
+
for _name in ("hpack", "h2", "hyperframe", "urllib3"):
|
| 58 |
+
logging.getLogger(_name).setLevel(logging.WARNING)
|
| 59 |
+
|
| 60 |
+
if mode == "debug":
|
| 61 |
+
logging.getLogger("agents").setLevel(logging.DEBUG)
|
| 62 |
+
logging.getLogger("solver").setLevel(logging.DEBUG)
|
| 63 |
+
logging.getLogger("app").setLevel(logging.DEBUG)
|
| 64 |
+
logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.DEBUG)
|
| 65 |
+
logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.DEBUG)
|
| 66 |
+
logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.INFO)
|
| 67 |
+
logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.INFO)
|
| 68 |
+
logging.getLogger("app.main").setLevel(logging.INFO)
|
| 69 |
+
logging.getLogger("worker").setLevel(logging.INFO)
|
| 70 |
+
elif mode == "info":
|
| 71 |
+
# Chỉ HTTP access (app.access) + startup; ẩn chi tiết agents/orchestrator/pipeline SUCCESS
|
| 72 |
+
logging.getLogger("agents").setLevel(logging.INFO)
|
| 73 |
+
logging.getLogger("solver").setLevel(logging.WARNING)
|
| 74 |
+
logging.getLogger("app").setLevel(logging.INFO)
|
| 75 |
+
logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.WARNING)
|
| 76 |
+
logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.WARNING)
|
| 77 |
+
logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.WARNING)
|
| 78 |
+
logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.INFO)
|
| 79 |
+
logging.getLogger("app.main").setLevel(logging.INFO)
|
| 80 |
+
logging.getLogger("worker").setLevel(logging.WARNING)
|
| 81 |
+
elif mode == "warning":
|
| 82 |
+
logging.getLogger("agents").setLevel(logging.WARNING)
|
| 83 |
+
logging.getLogger("solver").setLevel(logging.WARNING)
|
| 84 |
+
logging.getLogger("app.routers").setLevel(logging.WARNING)
|
| 85 |
+
logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.WARNING)
|
| 86 |
+
logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.WARNING)
|
| 87 |
+
logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.WARNING)
|
| 88 |
+
logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.WARNING)
|
| 89 |
+
logging.getLogger("app.main").setLevel(logging.WARNING)
|
| 90 |
+
logging.getLogger("worker").setLevel(logging.WARNING)
|
| 91 |
+
else: # error
|
| 92 |
+
logging.getLogger("agents").setLevel(logging.ERROR)
|
| 93 |
+
logging.getLogger("solver").setLevel(logging.ERROR)
|
| 94 |
+
logging.getLogger("app.routers").setLevel(logging.ERROR)
|
| 95 |
+
logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.ERROR)
|
| 96 |
+
logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.ERROR)
|
| 97 |
+
logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.ERROR)
|
| 98 |
+
logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.ERROR)
|
| 99 |
+
logging.getLogger("app.main").setLevel(logging.ERROR)
|
| 100 |
+
logging.getLogger("worker").setLevel(logging.ERROR)
|
| 101 |
+
|
| 102 |
+
logging.getLogger(__name__).debug(
|
| 103 |
+
"LOG_LEVEL=%s root=%s", mode, logging.getLevelName(root_level)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def get_log_level() -> str:
|
| 108 |
+
return _normalize_level()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def is_debug_level() -> bool:
|
| 112 |
+
return _normalize_level() == "debug"
|
app/logutil.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""log_step (debug), pipeline (debug), access log ở middleware."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from app.logging_setup import PIPELINE_LOGGER_NAME, STEPS_LOGGER_NAME
|
| 11 |
+
|
| 12 |
+
_pipeline = logging.getLogger(PIPELINE_LOGGER_NAME)
|
| 13 |
+
_steps = logging.getLogger(STEPS_LOGGER_NAME)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def is_debug_mode() -> bool:
|
| 17 |
+
"""Chi tiết từng bước chỉ khi LOG_LEVEL=debug."""
|
| 18 |
+
return os.getenv("LOG_LEVEL", "info").strip().lower() == "debug"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _truncate(val: Any, max_len: int = 2000) -> Any:
|
| 22 |
+
if val is None:
|
| 23 |
+
return None
|
| 24 |
+
if isinstance(val, (int, float, bool)):
|
| 25 |
+
return val
|
| 26 |
+
s = str(val)
|
| 27 |
+
if len(s) > max_len:
|
| 28 |
+
return s[:max_len] + f"... (+{len(s) - max_len} chars)"
|
| 29 |
+
return s
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def log_step(step: str, **fields: Any) -> None:
|
| 33 |
+
"""Chỉ khi LOG_LEVEL=debug: DB / cache / orchestrator."""
|
| 34 |
+
if not is_debug_mode():
|
| 35 |
+
return
|
| 36 |
+
safe = {k: _truncate(v) for k, v in fields.items()}
|
| 37 |
+
try:
|
| 38 |
+
payload = json.dumps(safe, ensure_ascii=False, default=str)
|
| 39 |
+
except Exception:
|
| 40 |
+
payload = str(safe)
|
| 41 |
+
_steps.debug("[step:%s] %s", step, payload)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def log_pipeline_success(operation: str, **fields: Any) -> None:
|
| 45 |
+
"""Chỉ hiện khi debug (pipeline SUCCESS không dùng ở info — đã có app.access)."""
|
| 46 |
+
if not is_debug_mode():
|
| 47 |
+
return
|
| 48 |
+
safe = {k: _truncate(v, 500) for k, v in fields.items()}
|
| 49 |
+
_pipeline.info(
|
| 50 |
+
"SUCCESS %s %s",
|
| 51 |
+
operation,
|
| 52 |
+
json.dumps(safe, ensure_ascii=False, default=str),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def log_pipeline_failure(operation: str, error: str | None = None, **fields: Any) -> None:
|
| 57 |
+
"""Thất bại pipeline: luôn dùng WARNING để vẫn thấy khi LOG_LEVEL=warning."""
|
| 58 |
+
if is_debug_mode():
|
| 59 |
+
safe = {k: _truncate(v, 500) for k, v in fields.items()}
|
| 60 |
+
_pipeline.warning(
|
| 61 |
+
"FAIL %s err=%s %s",
|
| 62 |
+
operation,
|
| 63 |
+
_truncate(error, 300),
|
| 64 |
+
json.dumps(safe, ensure_ascii=False, default=str),
|
| 65 |
+
)
|
| 66 |
+
else:
|
| 67 |
+
_pipeline.warning("FAIL %s", operation)
|
app/main.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
import warnings
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from starlette.requests import Request
|
| 13 |
+
|
| 14 |
+
load_dotenv()
|
| 15 |
+
|
| 16 |
+
from app.runtime_env import apply_runtime_env_defaults
|
| 17 |
+
|
| 18 |
+
apply_runtime_env_defaults()
|
| 19 |
+
|
| 20 |
+
os.environ["NO_ALBUMENTATIONS_UPDATE"] = "1"
|
| 21 |
+
warnings.filterwarnings("ignore", category=UserWarning, module="pydantic")
|
| 22 |
+
warnings.filterwarnings("ignore", category=UserWarning, module="albumentations")
|
| 23 |
+
|
| 24 |
+
from app.logging_setup import ACCESS_LOGGER_NAME, get_log_level, setup_application_logging
|
| 25 |
+
|
| 26 |
+
setup_application_logging()
|
| 27 |
+
|
| 28 |
+
# Routers (after logging)
|
| 29 |
+
from app.dependencies import get_current_user_id
|
| 30 |
+
from app.ocr_local_file import ocr_from_local_image_path
|
| 31 |
+
from app.routers import auth, sessions, solve, ai_core
|
| 32 |
+
from agents.ocr_agent import OCRAgent
|
| 33 |
+
from app.routers.solve import get_orchestrator
|
| 34 |
+
from app.job_poll import normalize_job_row_for_client
|
| 35 |
+
from app.supabase_client import get_supabase
|
| 36 |
+
from app.websocket_manager import register_websocket_routes
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger("app.main")
|
| 39 |
+
_access = logging.getLogger(ACCESS_LOGGER_NAME)
|
| 40 |
+
|
| 41 |
+
app = FastAPI(title="Visual Math Solver API v5.2")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.middleware("http")
|
| 45 |
+
async def access_log_middleware(request: Request, call_next):
|
| 46 |
+
"""LOG_LEVEL=info/debug: mọi request; warning: chỉ 4xx/5xx; error: chỉ 4xx/5xx ở mức error."""
|
| 47 |
+
start = time.perf_counter()
|
| 48 |
+
response = await call_next(request)
|
| 49 |
+
ms = (time.perf_counter() - start) * 1000
|
| 50 |
+
mode = get_log_level()
|
| 51 |
+
method = request.method
|
| 52 |
+
path = request.url.path
|
| 53 |
+
status = response.status_code
|
| 54 |
+
|
| 55 |
+
if mode in ("debug", "info"):
|
| 56 |
+
_access.info("%s %s -> %s (%.0fms)", method, path, status, ms)
|
| 57 |
+
elif mode == "warning":
|
| 58 |
+
if status >= 500:
|
| 59 |
+
_access.error("%s %s -> %s (%.0fms)", method, path, status, ms)
|
| 60 |
+
elif status >= 400:
|
| 61 |
+
_access.warning("%s %s -> %s (%.0fms)", method, path, status, ms)
|
| 62 |
+
elif mode == "error":
|
| 63 |
+
if status >= 400:
|
| 64 |
+
_access.error("%s %s -> %s", method, path, status)
|
| 65 |
+
|
| 66 |
+
return response
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
_redis_url = os.getenv("REDIS_URL") or os.getenv("CELERY_BROKER_URL") or "none"
|
| 70 |
+
_redis_tail = _redis_url.split("@")[-1] if "@" in _redis_url else _redis_url
|
| 71 |
+
if get_log_level() in ("debug", "info"):
|
| 72 |
+
logger.info("App starting LOG_LEVEL=%s | Redis: %s", get_log_level(), _redis_tail)
|
| 73 |
+
else:
|
| 74 |
+
logger.warning("App starting LOG_LEVEL=%s | Redis: %s", get_log_level(), _redis_tail)
|
| 75 |
+
|
| 76 |
+
app.add_middleware(
|
| 77 |
+
CORSMiddleware,
|
| 78 |
+
allow_origins=[
|
| 79 |
+
"http://localhost:3000",
|
| 80 |
+
"http://127.0.0.1:3000",
|
| 81 |
+
"http://localhost:3005",
|
| 82 |
+
],
|
| 83 |
+
allow_credentials=True,
|
| 84 |
+
allow_methods=["*"],
|
| 85 |
+
allow_headers=["*"],
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
app.include_router(ai_core.router)
|
| 89 |
+
app.include_router(auth.router)
|
| 90 |
+
app.include_router(sessions.router)
|
| 91 |
+
app.include_router(solve.router)
|
| 92 |
+
|
| 93 |
+
register_websocket_routes(app)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_ocr_agent() -> OCRAgent:
|
| 97 |
+
"""Same OCR instance as the solve pipeline (no duplicate model load)."""
|
| 98 |
+
return get_orchestrator().ocr_agent
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.get("/")
|
| 102 |
+
def read_root():
|
| 103 |
+
return {
|
| 104 |
+
"message": "Visual Math Solver API v5.2 is running",
|
| 105 |
+
"version": "5.2",
|
| 106 |
+
"ai_core_direct_endpoint": "/api/v1/ai/solve"
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.post("/api/v1/ocr")
|
| 111 |
+
async def upload_ocr(
|
| 112 |
+
file: UploadFile = File(...),
|
| 113 |
+
_user_id=Depends(get_current_user_id),
|
| 114 |
+
):
|
| 115 |
+
"""OCR upload: requires authenticated user."""
|
| 116 |
+
temp_path = f"temp_{uuid.uuid4()}.png"
|
| 117 |
+
with open(temp_path, "wb") as buffer:
|
| 118 |
+
buffer.write(await file.read())
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
text = await ocr_from_local_image_path(temp_path, file.filename, get_ocr_agent())
|
| 122 |
+
return {"text": text}
|
| 123 |
+
finally:
|
| 124 |
+
if os.path.exists(temp_path):
|
| 125 |
+
os.remove(temp_path)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.get("/api/v1/solve/{job_id}")
|
| 129 |
+
async def get_job_status(
|
| 130 |
+
job_id: str,
|
| 131 |
+
user_id=Depends(get_current_user_id),
|
| 132 |
+
):
|
| 133 |
+
"""Retrieve job status (can be used for polling if WS fails). Owner-only."""
|
| 134 |
+
supabase_client = get_supabase()
|
| 135 |
+
if not supabase_client:
|
| 136 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 137 |
+
response = supabase_client.table("jobs").select("*").eq("id", job_id).execute()
|
| 138 |
+
if not response.data:
|
| 139 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 140 |
+
job = response.data[0]
|
| 141 |
+
if job.get("user_id") is not None and str(job["user_id"]) != str(user_id):
|
| 142 |
+
raise HTTPException(status_code=403, detail="Forbidden: You do not own this job.")
|
| 143 |
+
return normalize_job_row_for_client(job)
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/job_state.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Formalized Job State Machine & Lifecycle Definitions for MathSolver."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Any, Dict, List, Optional, Set
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class JobStatus(str, Enum):
|
| 11 |
+
CREATED = "created"
|
| 12 |
+
QUEUED = "queued"
|
| 13 |
+
PROCESSING = "processing"
|
| 14 |
+
COMPLETED = "completed"
|
| 15 |
+
FAILED = "failed"
|
| 16 |
+
DEGRADED = "degraded"
|
| 17 |
+
CANCELLED = "cancelled"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class JobStage(str, Enum):
|
| 21 |
+
OCR = "ocr"
|
| 22 |
+
PARSING = "parsing"
|
| 23 |
+
GEOMETRY = "geometry"
|
| 24 |
+
SOLVING = "solving"
|
| 25 |
+
RENDERING = "rendering"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Canonical progression of stages with default estimated progress percentages
|
| 29 |
+
STAGE_PROGRESS_MAP: Dict[JobStage, int] = {
|
| 30 |
+
JobStage.OCR: 15,
|
| 31 |
+
JobStage.PARSING: 35,
|
| 32 |
+
JobStage.GEOMETRY: 65,
|
| 33 |
+
JobStage.SOLVING: 85,
|
| 34 |
+
JobStage.RENDERING: 95,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# Valid State Transitions
|
| 38 |
+
VALID_TRANSITIONS: Dict[JobStatus, Set[JobStatus]] = {
|
| 39 |
+
JobStatus.CREATED: {
|
| 40 |
+
JobStatus.CREATED,
|
| 41 |
+
JobStatus.QUEUED,
|
| 42 |
+
JobStatus.PROCESSING,
|
| 43 |
+
JobStatus.FAILED,
|
| 44 |
+
JobStatus.CANCELLED,
|
| 45 |
+
},
|
| 46 |
+
JobStatus.QUEUED: {
|
| 47 |
+
JobStatus.QUEUED,
|
| 48 |
+
JobStatus.PROCESSING,
|
| 49 |
+
JobStatus.FAILED,
|
| 50 |
+
JobStatus.CANCELLED,
|
| 51 |
+
},
|
| 52 |
+
JobStatus.PROCESSING: {
|
| 53 |
+
JobStatus.PROCESSING,
|
| 54 |
+
JobStatus.COMPLETED,
|
| 55 |
+
JobStatus.DEGRADED,
|
| 56 |
+
JobStatus.FAILED,
|
| 57 |
+
JobStatus.CANCELLED,
|
| 58 |
+
},
|
| 59 |
+
# Terminal states
|
| 60 |
+
JobStatus.COMPLETED: {JobStatus.COMPLETED},
|
| 61 |
+
JobStatus.FAILED: {JobStatus.FAILED},
|
| 62 |
+
JobStatus.DEGRADED: {JobStatus.DEGRADED},
|
| 63 |
+
JobStatus.CANCELLED: {JobStatus.CANCELLED},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class InvalidStateTransitionError(ValueError):
|
| 68 |
+
"""Raised when an illegal job state transition is attempted."""
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class JobStateMachine:
|
| 73 |
+
"""Validator and manager for job lifecycle transitions."""
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def normalize_status(raw_status: Optional[str]) -> JobStatus:
|
| 77 |
+
if not raw_status:
|
| 78 |
+
return JobStatus.PROCESSING
|
| 79 |
+
raw = raw_status.lower().strip()
|
| 80 |
+
# Aliases for backward compatibility
|
| 81 |
+
if raw in ("success", "done", "finished", "completed"):
|
| 82 |
+
return JobStatus.COMPLETED
|
| 83 |
+
if raw in ("error", "failed", "failure"):
|
| 84 |
+
return JobStatus.FAILED
|
| 85 |
+
if raw in ("rendering_queued", "queued"):
|
| 86 |
+
return JobStatus.QUEUED
|
| 87 |
+
if raw in ("rendering", "processing", "solving", "ocr", "parsing", "geometry"):
|
| 88 |
+
return JobStatus.PROCESSING
|
| 89 |
+
if raw == "cancelled":
|
| 90 |
+
return JobStatus.CANCELLED
|
| 91 |
+
if raw == "degraded":
|
| 92 |
+
return JobStatus.DEGRADED
|
| 93 |
+
return JobStatus.PROCESSING
|
| 94 |
+
|
| 95 |
+
@staticmethod
|
| 96 |
+
def normalize_stage(raw_stage: Optional[str]) -> Optional[JobStage]:
|
| 97 |
+
if not raw_stage:
|
| 98 |
+
return None
|
| 99 |
+
raw = raw_stage.lower().strip()
|
| 100 |
+
for stage in JobStage:
|
| 101 |
+
if stage.value == raw:
|
| 102 |
+
return stage
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
@classmethod
|
| 106 |
+
def can_transition(cls, current: JobStatus, target: JobStatus) -> bool:
|
| 107 |
+
valid_targets = VALID_TRANSITIONS.get(current, set())
|
| 108 |
+
return target in valid_targets
|
| 109 |
+
|
| 110 |
+
@classmethod
|
| 111 |
+
def validate_transition(cls, current_status: str | JobStatus, target_status: str | JobStatus) -> JobStatus:
|
| 112 |
+
current = current_status if isinstance(current_status, JobStatus) else cls.normalize_status(current_status)
|
| 113 |
+
target = target_status if isinstance(target_status, JobStatus) else cls.normalize_status(target_status)
|
| 114 |
+
|
| 115 |
+
if not cls.can_transition(current, target):
|
| 116 |
+
raise InvalidStateTransitionError(
|
| 117 |
+
f"Invalid job state transition from {current.value} to {target.value}"
|
| 118 |
+
)
|
| 119 |
+
return target
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class JobEventPayload(BaseModel):
|
| 123 |
+
"""Normalized payload broadcasted via WebSockets and returned by HTTP polling."""
|
| 124 |
+
job_id: str
|
| 125 |
+
status: JobStatus = JobStatus.PROCESSING
|
| 126 |
+
stage: Optional[JobStage] = None
|
| 127 |
+
progress: int = Field(default=0, ge=0, le=100)
|
| 128 |
+
message: Optional[str] = None
|
| 129 |
+
result: Optional[Dict[str, Any]] = None
|
| 130 |
+
error: Optional[str] = None
|
| 131 |
+
error_code: Optional[str] = None
|
| 132 |
+
video_url: Optional[str] = None
|
app/models/schemas.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, field_validator
|
| 2 |
+
from typing import Optional, List, Any, Dict
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
from app.url_utils import sanitize_url
|
| 7 |
+
|
| 8 |
+
# --- Auth Schemas ---
|
| 9 |
+
class UserProfile(BaseModel):
|
| 10 |
+
id: uuid.UUID
|
| 11 |
+
display_name: Optional[str] = None
|
| 12 |
+
avatar_url: Optional[str] = None
|
| 13 |
+
created_at: datetime
|
| 14 |
+
|
| 15 |
+
class User(BaseModel):
|
| 16 |
+
id: uuid.UUID
|
| 17 |
+
email: EmailStr
|
| 18 |
+
|
| 19 |
+
# --- Session Schemas ---
|
| 20 |
+
class SessionBase(BaseModel):
|
| 21 |
+
title: str = "Bài toán mới"
|
| 22 |
+
|
| 23 |
+
class SessionCreate(SessionBase):
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
class Session(SessionBase):
|
| 27 |
+
id: uuid.UUID
|
| 28 |
+
user_id: uuid.UUID
|
| 29 |
+
created_at: datetime
|
| 30 |
+
updated_at: datetime
|
| 31 |
+
|
| 32 |
+
class Config:
|
| 33 |
+
from_attributes = True
|
| 34 |
+
|
| 35 |
+
# --- Message Schemas ---
|
| 36 |
+
class MessageBase(BaseModel):
|
| 37 |
+
role: str
|
| 38 |
+
type: str = "text"
|
| 39 |
+
content: str
|
| 40 |
+
metadata: Dict[str, Any] = {}
|
| 41 |
+
|
| 42 |
+
class MessageCreate(MessageBase):
|
| 43 |
+
session_id: uuid.UUID
|
| 44 |
+
|
| 45 |
+
class Message(MessageBase):
|
| 46 |
+
id: uuid.UUID
|
| 47 |
+
session_id: uuid.UUID
|
| 48 |
+
created_at: datetime
|
| 49 |
+
|
| 50 |
+
class Config:
|
| 51 |
+
from_attributes = True
|
| 52 |
+
|
| 53 |
+
# --- Solve Job Schemas ---
|
| 54 |
+
class SolveRequest(BaseModel):
|
| 55 |
+
text: str
|
| 56 |
+
image_url: Optional[str] = None
|
| 57 |
+
client_message_id: Optional[str] = None
|
| 58 |
+
|
| 59 |
+
@field_validator("image_url", mode="before")
|
| 60 |
+
@classmethod
|
| 61 |
+
def _clean_image_url(cls, v):
|
| 62 |
+
return sanitize_url(v) if v is not None else None
|
| 63 |
+
|
| 64 |
+
class SolveResponse(BaseModel):
|
| 65 |
+
job_id: str
|
| 66 |
+
status: str
|
| 67 |
+
|
| 68 |
+
class RenderVideoRequest(BaseModel):
|
| 69 |
+
job_id: Optional[str] = None
|
| 70 |
+
|
| 71 |
+
class RenderVideoResponse(BaseModel):
|
| 72 |
+
job_id: str
|
| 73 |
+
status: str
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class OcrPreviewResponse(BaseModel):
|
| 77 |
+
"""Stateless OCR preview before POST .../solve (no DB writes, no job)."""
|
| 78 |
+
|
| 79 |
+
ocr_text: str
|
| 80 |
+
user_message: str = ""
|
| 81 |
+
combined_draft: str
|
app/ocr_celery.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OCR pipeline with confidence gateway support."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from typing import TYPE_CHECKING, Optional
|
| 9 |
+
|
| 10 |
+
import anyio
|
| 11 |
+
|
| 12 |
+
from config.loader import load_agent_config
|
| 13 |
+
from vision_ocr.canonical_schema import CanonicalOCRResult
|
| 14 |
+
|
| 15 |
+
if TYPE_CHECKING:
|
| 16 |
+
from agents.ocr_agent import OCRAgent
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def ocr_from_image_url(
|
| 22 |
+
image_url: str,
|
| 23 |
+
fallback_agent: "OCRAgent",
|
| 24 |
+
raw_image_path: Optional[str] = None,
|
| 25 |
+
) -> CanonicalOCRResult:
|
| 26 |
+
"""
|
| 27 |
+
Process OCR from image URL using OCRAgent (Pix2Text Engine).
|
| 28 |
+
Returns full CanonicalOCRResult with confidence metadata.
|
| 29 |
+
If confidence < gateway threshold, triggers VLM correction (if enabled).
|
| 30 |
+
"""
|
| 31 |
+
# Get canonical OCR result (with confidence)
|
| 32 |
+
canonical = await fallback_agent.process_url_canonical(image_url)
|
| 33 |
+
|
| 34 |
+
# Check confidence gateway
|
| 35 |
+
ocr_config = load_agent_config("ocr")
|
| 36 |
+
gateway = ocr_config.confidence_gateway
|
| 37 |
+
|
| 38 |
+
if gateway and gateway.enabled:
|
| 39 |
+
threshold = gateway.threshold
|
| 40 |
+
ocr_confidence = canonical.confidence
|
| 41 |
+
|
| 42 |
+
logger.info(
|
| 43 |
+
f"[OCR Gateway] confidence={ocr_confidence:.3f}, threshold={threshold:.2f}, "
|
| 44 |
+
f"gateway_triggered={ocr_confidence < threshold}"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
if ocr_confidence < threshold and gateway.correction.enabled:
|
| 48 |
+
try:
|
| 49 |
+
from agents.vlm_corrector import VLMCorrectorAgent
|
| 50 |
+
|
| 51 |
+
corrector = VLMCorrectorAgent(config=gateway.correction)
|
| 52 |
+
correction_result = await corrector.correct(
|
| 53 |
+
ocr_result=canonical,
|
| 54 |
+
image_url=image_url,
|
| 55 |
+
image_path=raw_image_path,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
if correction_result and correction_result.get("changed"):
|
| 59 |
+
corrected_text = correction_result.get("corrected_text", canonical.text)
|
| 60 |
+
corrected_confidence = correction_result.get("confidence", ocr_confidence)
|
| 61 |
+
logger.info(
|
| 62 |
+
f"[OCR Gateway] VLM correction applied: "
|
| 63 |
+
f"confidence {ocr_confidence:.3f} → {corrected_confidence:.3f}"
|
| 64 |
+
)
|
| 65 |
+
# Return updated canonical result
|
| 66 |
+
canonical = CanonicalOCRResult(
|
| 67 |
+
text=corrected_text,
|
| 68 |
+
latex=canonical.latex,
|
| 69 |
+
elements=canonical.elements,
|
| 70 |
+
reading_order=canonical.reading_order,
|
| 71 |
+
confidence=corrected_confidence,
|
| 72 |
+
metadata={
|
| 73 |
+
**canonical.metadata,
|
| 74 |
+
"vlm_correction": True,
|
| 75 |
+
"original_confidence": ocr_confidence,
|
| 76 |
+
"corrections": correction_result.get("corrections", []),
|
| 77 |
+
},
|
| 78 |
+
)
|
| 79 |
+
else:
|
| 80 |
+
logger.info("[OCR Gateway] VLM correction: no changes needed")
|
| 81 |
+
except ImportError:
|
| 82 |
+
logger.warning("[OCR Gateway] VLM corrector not available, skipping correction")
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.warning(f"[OCR Gateway] VLM correction failed: {e}")
|
| 85 |
+
|
| 86 |
+
return canonical
|
app/ocr_local_file.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from typing import TYPE_CHECKING
|
| 6 |
+
|
| 7 |
+
from config.loader import load_agent_config
|
| 8 |
+
|
| 9 |
+
if TYPE_CHECKING:
|
| 10 |
+
from agents.ocr_agent import OCRAgent
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def ocr_from_local_image_path(
|
| 16 |
+
local_path: str,
|
| 17 |
+
original_filename: str | None,
|
| 18 |
+
fallback_agent: "OCRAgent",
|
| 19 |
+
) -> str:
|
| 20 |
+
"""
|
| 21 |
+
Run OCR on a file on local disk using Pix2Text + Confidence Gateway VLM Correction.
|
| 22 |
+
"""
|
| 23 |
+
import inspect
|
| 24 |
+
from vision_ocr.canonical_schema import CanonicalOCRResult
|
| 25 |
+
|
| 26 |
+
canonical = None
|
| 27 |
+
if hasattr(fallback_agent, "process_image_canonical"):
|
| 28 |
+
try:
|
| 29 |
+
res = fallback_agent.process_image_canonical(local_path)
|
| 30 |
+
canonical = await res if inspect.isawaitable(res) else res
|
| 31 |
+
except Exception:
|
| 32 |
+
canonical = None
|
| 33 |
+
|
| 34 |
+
if canonical is None or not isinstance(canonical, CanonicalOCRResult):
|
| 35 |
+
if hasattr(fallback_agent, "process_image"):
|
| 36 |
+
res = fallback_agent.process_image(local_path)
|
| 37 |
+
text = await res if inspect.isawaitable(res) else res
|
| 38 |
+
canonical = CanonicalOCRResult(text=str(text or ""), confidence=0.5 if not text else 0.8)
|
| 39 |
+
else:
|
| 40 |
+
canonical = CanonicalOCRResult(text="", confidence=0.0)
|
| 41 |
+
|
| 42 |
+
ocr_config = load_agent_config("ocr")
|
| 43 |
+
gateway = ocr_config.confidence_gateway
|
| 44 |
+
|
| 45 |
+
if gateway and gateway.enabled:
|
| 46 |
+
threshold = gateway.threshold
|
| 47 |
+
ocr_confidence = canonical.confidence
|
| 48 |
+
|
| 49 |
+
logger.info(
|
| 50 |
+
f"[OCR Local Gateway] confidence={ocr_confidence:.3f}, threshold={threshold:.2f}, "
|
| 51 |
+
f"gateway_triggered={ocr_confidence < threshold}"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if ocr_confidence < threshold and gateway.correction.enabled:
|
| 55 |
+
try:
|
| 56 |
+
from agents.vlm_corrector import VLMCorrectorAgent
|
| 57 |
+
|
| 58 |
+
corrector = VLMCorrectorAgent(config=gateway.correction)
|
| 59 |
+
correction_result = await corrector.correct(
|
| 60 |
+
ocr_result=canonical,
|
| 61 |
+
image_path=local_path,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
if correction_result and correction_result.get("changed"):
|
| 65 |
+
logger.info(
|
| 66 |
+
f"[OCR Local Gateway] VLM correction applied: "
|
| 67 |
+
f"confidence {ocr_confidence:.3f} -> {correction_result.get('confidence', 0.9):.3f}"
|
| 68 |
+
)
|
| 69 |
+
return correction_result.get("corrected_text", canonical.text)
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.warning(f"[OCR Local Gateway] VLM correction failed: {e}")
|
| 72 |
+
|
| 73 |
+
return canonical.text
|
| 74 |
+
|
| 75 |
+
|
app/ocr_text_merge.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for OCR preview combined draft (no Pydantic email deps)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def build_combined_ocr_preview_draft(user_message: Optional[str], ocr_text: str) -> str:
|
| 9 |
+
"""Merge user caption and OCR text for confirm step (user message first, then OCR)."""
|
| 10 |
+
u = (user_message or "").strip()
|
| 11 |
+
o = (ocr_text or "").strip()
|
| 12 |
+
if u and o:
|
| 13 |
+
return f"{u}\n\n{o}"
|
| 14 |
+
return u or o
|
app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from . import auth, sessions, solve
|
app/routers/ai_core.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import uuid
|
| 7 |
+
from fastapi import APIRouter, HTTPException
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
from typing import Optional, Dict, Any
|
| 10 |
+
|
| 11 |
+
from agents.orchestrator import Orchestrator
|
| 12 |
+
from agents.ocr_agent import OCRAgent
|
| 13 |
+
from manim_client.client import ManimClient
|
| 14 |
+
from manim_client.schemas import MathRenderResponse
|
| 15 |
+
from vision_ocr.canonical_schema import CanonicalOCRResult
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
router = APIRouter(prefix="/api/v1/ai", tags=["AI Core (Standalone)"])
|
| 19 |
+
|
| 20 |
+
# Shared in-memory instances (lazy initialized)
|
| 21 |
+
_orchestrator = None
|
| 22 |
+
_ocr_agent = None
|
| 23 |
+
_manim_client = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _get_orchestrator() -> Orchestrator:
|
| 27 |
+
global _orchestrator
|
| 28 |
+
if _orchestrator is None:
|
| 29 |
+
_orchestrator = Orchestrator()
|
| 30 |
+
return _orchestrator
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get_ocr_agent() -> OCRAgent:
|
| 34 |
+
global _ocr_agent
|
| 35 |
+
if _ocr_agent is None:
|
| 36 |
+
_ocr_agent = OCRAgent()
|
| 37 |
+
return _ocr_agent
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _get_manim_client() -> ManimClient:
|
| 41 |
+
global _manim_client
|
| 42 |
+
if _manim_client is None:
|
| 43 |
+
_manim_client = ManimClient()
|
| 44 |
+
return _manim_client
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class AISolveRequest(BaseModel):
|
| 48 |
+
text: Optional[str] = None
|
| 49 |
+
image_url: Optional[str] = None
|
| 50 |
+
image_path: Optional[str] = None
|
| 51 |
+
generate_video: bool = True
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class AIOCRRequest(BaseModel):
|
| 55 |
+
image_url: Optional[str] = None
|
| 56 |
+
image_path: Optional[str] = None
|
| 57 |
+
image_base64: Optional[str] = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/ocr", response_model=CanonicalOCRResult)
|
| 61 |
+
async def ocr_direct_ai(request: AIOCRRequest) -> CanonicalOCRResult:
|
| 62 |
+
"""
|
| 63 |
+
Direct Math OCR Endpoint (Pix2Text Engine).
|
| 64 |
+
Converts geometry problem images into canonical structured format:
|
| 65 |
+
- text: Reconstructed Markdown with LaTeX math formulas
|
| 66 |
+
- latex: List of isolated and embedded LaTeX equations
|
| 67 |
+
- elements: Classified layout regions (text, formulas, bboxes)
|
| 68 |
+
- reading_order: Document reading sequence
|
| 69 |
+
- confidence: Extraction accuracy confidence
|
| 70 |
+
"""
|
| 71 |
+
ocr_agent = _get_ocr_agent()
|
| 72 |
+
if request.image_url:
|
| 73 |
+
logger.info("==[AI Core OCR] Processing image_url: %s==", request.image_url)
|
| 74 |
+
return await ocr_agent.process_url_canonical(request.image_url)
|
| 75 |
+
|
| 76 |
+
if request.image_path:
|
| 77 |
+
logger.info("==[AI Core OCR] Processing local image_path: %s==", request.image_path)
|
| 78 |
+
return await ocr_agent.process_image_canonical(request.image_path)
|
| 79 |
+
|
| 80 |
+
if request.image_base64:
|
| 81 |
+
logger.info("==[AI Core OCR] Processing image_base64==")
|
| 82 |
+
temp_path = f"temp_ocr_b64_{uuid.uuid4().hex}.png"
|
| 83 |
+
try:
|
| 84 |
+
b64_data = request.image_base64
|
| 85 |
+
if "," in b64_data:
|
| 86 |
+
b64_data = b64_data.split(",", 1)[1]
|
| 87 |
+
img_bytes = base64.b64decode(b64_data)
|
| 88 |
+
with open(temp_path, "wb") as f:
|
| 89 |
+
f.write(img_bytes)
|
| 90 |
+
return await ocr_agent.process_image_canonical(temp_path)
|
| 91 |
+
finally:
|
| 92 |
+
if os.path.exists(temp_path):
|
| 93 |
+
try:
|
| 94 |
+
os.remove(temp_path)
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
raise HTTPException(status_code=400, detail="Must provide 'image_url', 'image_path', or 'image_base64'.")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.post("/solve")
|
| 102 |
+
async def solve_direct_ai(request: AISolveRequest) -> Dict[str, Any]:
|
| 103 |
+
"""
|
| 104 |
+
Direct Standalone AI Core Solve Endpoint.
|
| 105 |
+
- Runs OCR (Pix2Text) -> GeometryParser -> GeometryEngine -> DeepMath -> VisualizationSpec -> Manim Module.
|
| 106 |
+
- 100% In-Memory: No Supabase DB or Redis connection required.
|
| 107 |
+
- No authentication token required (Ideal for AI development & curl testing).
|
| 108 |
+
"""
|
| 109 |
+
text = (request.text or "").strip()
|
| 110 |
+
image_url = request.image_url
|
| 111 |
+
ocr_agent = _get_ocr_agent()
|
| 112 |
+
orchestrator = _get_orchestrator()
|
| 113 |
+
|
| 114 |
+
if not text and request.image_path and os.path.exists(request.image_path):
|
| 115 |
+
# Run OCR on local image directly
|
| 116 |
+
ocr_res = await ocr_agent.process_image_canonical(request.image_path)
|
| 117 |
+
text = ocr_res.text
|
| 118 |
+
logger.info("[AI Core Solve] Extracted OCR text from %s: '%s'", request.image_path, text[:80])
|
| 119 |
+
|
| 120 |
+
if not text and not image_url:
|
| 121 |
+
raise HTTPException(status_code=400, detail="Either 'text', 'image_url', or valid 'image_path' must be provided.")
|
| 122 |
+
|
| 123 |
+
logger.info("==[AI Core Direct Solve] Received problem: %s==", text[:80] if text else f"Image: {image_url}")
|
| 124 |
+
try:
|
| 125 |
+
result = await orchestrator.run(
|
| 126 |
+
text=text,
|
| 127 |
+
image_url=image_url,
|
| 128 |
+
job_id="direct_ai_run",
|
| 129 |
+
generate_video=request.generate_video,
|
| 130 |
+
)
|
| 131 |
+
return result
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.exception("AI Core execution error: %s", e)
|
| 134 |
+
raise HTTPException(status_code=500, detail=f"AI Core processing failed: {str(e)}")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@router.get("/visualization/jobs/{job_id}", response_model=MathRenderResponse)
|
| 138 |
+
async def get_visualization_job_status(job_id: str) -> MathRenderResponse:
|
| 139 |
+
"""
|
| 140 |
+
Query the status of a Manim video generation job.
|
| 141 |
+
Proxies request to the Manim Video Generation Module.
|
| 142 |
+
"""
|
| 143 |
+
logger.info(f"==[AI Core Visualization] Fetching status for job {job_id}==")
|
| 144 |
+
resp = await _get_manim_client().get_job_status(job_id)
|
| 145 |
+
return resp
|
app/routers/auth.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from app.dependencies import get_current_user_id
|
| 3 |
+
from app.supabase_client import get_supabase
|
| 4 |
+
from app.models.schemas import UserProfile
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
| 8 |
+
|
| 9 |
+
@router.get("/me")
|
| 10 |
+
async def get_me(user_id=Depends(get_current_user_id)):
|
| 11 |
+
"""Lấy thông tin profile người dùng hiện tại (Retrieve current user profile)."""
|
| 12 |
+
supabase = get_supabase()
|
| 13 |
+
if not supabase:
|
| 14 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 15 |
+
|
| 16 |
+
res = supabase.table("profiles").select("*").eq("id", user_id).execute()
|
| 17 |
+
if res.data:
|
| 18 |
+
return res.data[0]
|
| 19 |
+
|
| 20 |
+
# Auto-provision basic profile if trigger didn't run or dev test user
|
| 21 |
+
try:
|
| 22 |
+
insert_res = (
|
| 23 |
+
supabase.table("profiles")
|
| 24 |
+
.insert({"id": str(user_id), "display_name": "Người dùng", "avatar_url": None})
|
| 25 |
+
.execute()
|
| 26 |
+
)
|
| 27 |
+
if insert_res.data:
|
| 28 |
+
return insert_res.data[0]
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
raise HTTPException(status_code=404, detail="Profile not found.")
|
| 33 |
+
|
| 34 |
+
@router.patch("/me")
|
| 35 |
+
async def update_me(data: dict, user_id=Depends(get_current_user_id)):
|
| 36 |
+
"""Cập nhật profile hiện tại (Update current profile)."""
|
| 37 |
+
supabase = get_supabase()
|
| 38 |
+
if not supabase:
|
| 39 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 40 |
+
|
| 41 |
+
# Sanitize and only allow updating safe profile fields
|
| 42 |
+
allowed_fields = {"display_name", "avatar_url"}
|
| 43 |
+
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
| 44 |
+
if not update_data:
|
| 45 |
+
raise HTTPException(status_code=400, detail="No valid fields provided for update (allowed: display_name, avatar_url).")
|
| 46 |
+
|
| 47 |
+
res = supabase.table("profiles").update(update_data).eq("id", user_id).execute()
|
| 48 |
+
if not res.data:
|
| 49 |
+
raise HTTPException(status_code=404, detail="Profile not found to update.")
|
| 50 |
+
return res.data[0]
|
app/routers/sessions.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
| 8 |
+
|
| 9 |
+
from app.chat_image_upload import cleanup_session_storage
|
| 10 |
+
from app.dependencies import get_current_user_id
|
| 11 |
+
from app.logutil import log_step
|
| 12 |
+
from app.session_cache import (
|
| 13 |
+
invalidate_session_owner,
|
| 14 |
+
session_owned_by_user,
|
| 15 |
+
)
|
| 16 |
+
from app.supabase_client import get_supabase
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/v1/sessions", tags=["Sessions"])
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("", response_model=List[dict])
|
| 23 |
+
async def list_sessions(user_id=Depends(get_current_user_id)):
|
| 24 |
+
"""Danh sách các phiên chat của người dùng (List user's chat sessions)"""
|
| 25 |
+
supabase = get_supabase()
|
| 26 |
+
if not supabase:
|
| 27 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 28 |
+
t0 = time.perf_counter()
|
| 29 |
+
res = (
|
| 30 |
+
supabase.table("sessions")
|
| 31 |
+
.select("id, user_id, title, created_at, updated_at")
|
| 32 |
+
.eq("user_id", user_id)
|
| 33 |
+
.order("updated_at", desc=True)
|
| 34 |
+
.execute()
|
| 35 |
+
)
|
| 36 |
+
log_step("db_select", table="sessions", op="list", user_id=str(user_id))
|
| 37 |
+
out = res.data or []
|
| 38 |
+
logger.info(
|
| 39 |
+
"sessions.list user=%s count=%d %.1fms",
|
| 40 |
+
user_id,
|
| 41 |
+
len(out),
|
| 42 |
+
(time.perf_counter() - t0) * 1000,
|
| 43 |
+
)
|
| 44 |
+
return out
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("", response_model=dict)
|
| 48 |
+
async def create_session(user_id=Depends(get_current_user_id)):
|
| 49 |
+
"""Tạo một phiên chat mới (Create a new chat session)"""
|
| 50 |
+
supabase = get_supabase()
|
| 51 |
+
if not supabase:
|
| 52 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 53 |
+
t0 = time.perf_counter()
|
| 54 |
+
res = supabase.table("sessions").insert(
|
| 55 |
+
{"user_id": user_id, "title": "Bài toán mới"}
|
| 56 |
+
).execute()
|
| 57 |
+
log_step("db_insert", table="sessions", op="create")
|
| 58 |
+
if not res.data:
|
| 59 |
+
raise HTTPException(status_code=500, detail="Failed to create session.")
|
| 60 |
+
row = res.data[0]
|
| 61 |
+
logger.info(
|
| 62 |
+
"sessions.create user=%s id=%s %.1fms",
|
| 63 |
+
user_id,
|
| 64 |
+
row.get("id"),
|
| 65 |
+
(time.perf_counter() - t0) * 1000,
|
| 66 |
+
)
|
| 67 |
+
return row
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/{session_id}/messages", response_model=List[dict])
|
| 71 |
+
async def get_session_messages(session_id: str, user_id=Depends(get_current_user_id)):
|
| 72 |
+
"""Lấy toàn bộ lịch sử tin nhắn của một phiên (Get chat history for a session)"""
|
| 73 |
+
supabase = get_supabase()
|
| 74 |
+
if not supabase:
|
| 75 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 76 |
+
|
| 77 |
+
def owns() -> bool:
|
| 78 |
+
res = (
|
| 79 |
+
supabase.table("sessions")
|
| 80 |
+
.select("id")
|
| 81 |
+
.eq("id", session_id)
|
| 82 |
+
.eq("user_id", user_id)
|
| 83 |
+
.execute()
|
| 84 |
+
)
|
| 85 |
+
log_step("db_select", table="sessions", op="owner_check", session_id=session_id)
|
| 86 |
+
return bool(res.data)
|
| 87 |
+
|
| 88 |
+
if not session_owned_by_user(session_id, str(user_id), owns):
|
| 89 |
+
raise HTTPException(
|
| 90 |
+
status_code=403, detail="Forbidden: You do not own this session."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
res = (
|
| 94 |
+
supabase.table("messages")
|
| 95 |
+
.select("*")
|
| 96 |
+
.eq("session_id", session_id)
|
| 97 |
+
.order("created_at", desc=False)
|
| 98 |
+
.execute()
|
| 99 |
+
)
|
| 100 |
+
log_step("db_select", table="messages", op="list", session_id=session_id)
|
| 101 |
+
return res.data or []
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.delete("/{session_id}")
|
| 105 |
+
async def delete_session(
|
| 106 |
+
session_id: str,
|
| 107 |
+
background_tasks: BackgroundTasks,
|
| 108 |
+
user_id=Depends(get_current_user_id),
|
| 109 |
+
):
|
| 110 |
+
"""Xóa một phiên chat và toàn bộ tài nguyên liên quan (Delete a chat session & associated assets)"""
|
| 111 |
+
supabase = get_supabase()
|
| 112 |
+
if not supabase:
|
| 113 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 114 |
+
|
| 115 |
+
def owns() -> bool:
|
| 116 |
+
res = (
|
| 117 |
+
supabase.table("sessions")
|
| 118 |
+
.select("id")
|
| 119 |
+
.eq("id", session_id)
|
| 120 |
+
.eq("user_id", user_id)
|
| 121 |
+
.execute()
|
| 122 |
+
)
|
| 123 |
+
return bool(res.data)
|
| 124 |
+
|
| 125 |
+
if not session_owned_by_user(session_id, str(user_id), owns):
|
| 126 |
+
raise HTTPException(
|
| 127 |
+
status_code=403, detail="Forbidden: You do not own this session."
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# 1. Authoritative DB deletion first (dependent rows before session)
|
| 131 |
+
try:
|
| 132 |
+
supabase.table("session_assets").delete().eq("session_id", session_id).execute()
|
| 133 |
+
log_step("db_delete", table="session_assets", op="by_session", session_id=session_id)
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.warning("Error deleting session_assets for session %s: %s", session_id, e)
|
| 136 |
+
|
| 137 |
+
supabase.table("jobs").delete().eq("session_id", session_id).eq("user_id", user_id).execute()
|
| 138 |
+
log_step("db_delete", table="jobs", op="by_session", session_id=session_id)
|
| 139 |
+
supabase.table("messages").delete().eq("session_id", session_id).execute()
|
| 140 |
+
log_step("db_delete", table="messages", op="by_session", session_id=session_id)
|
| 141 |
+
|
| 142 |
+
res = (
|
| 143 |
+
supabase.table("sessions")
|
| 144 |
+
.delete()
|
| 145 |
+
.eq("id", session_id)
|
| 146 |
+
.eq("user_id", user_id)
|
| 147 |
+
.execute()
|
| 148 |
+
)
|
| 149 |
+
log_step("db_delete", table="sessions", session_id=session_id)
|
| 150 |
+
invalidate_session_owner(session_id, str(user_id))
|
| 151 |
+
|
| 152 |
+
# 2. Async / non-blocking storage cleanup AFTER DB deletion succeeds
|
| 153 |
+
background_tasks.add_task(cleanup_session_storage, session_id)
|
| 154 |
+
|
| 155 |
+
return {"status": "ok", "deleted_id": session_id}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.patch("/{session_id}/title")
|
| 159 |
+
async def update_session_title(title: str, session_id: str, user_id=Depends(get_current_user_id)):
|
| 160 |
+
"""Cập nhật tiêu đề phiên chat (Rename a chat session)"""
|
| 161 |
+
supabase = get_supabase()
|
| 162 |
+
if not supabase:
|
| 163 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 164 |
+
|
| 165 |
+
res = (
|
| 166 |
+
supabase.table("sessions")
|
| 167 |
+
.update({"title": title})
|
| 168 |
+
.eq("id", session_id)
|
| 169 |
+
.eq("user_id", user_id)
|
| 170 |
+
.execute()
|
| 171 |
+
)
|
| 172 |
+
if not res.data:
|
| 173 |
+
raise HTTPException(status_code=404, detail="Session not found or not owned by user.")
|
| 174 |
+
log_step("db_update", table="sessions", op="title", session_id=session_id)
|
| 175 |
+
return res.data[0]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.get("/{session_id}/assets", response_model=List[dict])
|
| 179 |
+
async def get_session_assets(session_id: str, user_id=Depends(get_current_user_id)):
|
| 180 |
+
"""Lấy danh sách video đã render trong session (Get versioned assets for a session)"""
|
| 181 |
+
supabase = get_supabase()
|
| 182 |
+
|
| 183 |
+
def owns() -> bool:
|
| 184 |
+
res = (
|
| 185 |
+
supabase.table("sessions")
|
| 186 |
+
.select("id")
|
| 187 |
+
.eq("id", session_id)
|
| 188 |
+
.eq("user_id", user_id)
|
| 189 |
+
.execute()
|
| 190 |
+
)
|
| 191 |
+
return bool(res.data)
|
| 192 |
+
|
| 193 |
+
if not session_owned_by_user(session_id, str(user_id), owns):
|
| 194 |
+
raise HTTPException(
|
| 195 |
+
status_code=403, detail="Forbidden: You do not own this session."
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
res = (
|
| 199 |
+
supabase.table("session_assets")
|
| 200 |
+
.select("*")
|
| 201 |
+
.eq("session_id", session_id)
|
| 202 |
+
.order("version", desc=True)
|
| 203 |
+
.execute()
|
| 204 |
+
)
|
| 205 |
+
log_step("db_select", table="session_assets", op="list", session_id=session_id)
|
| 206 |
+
return res.data
|
app/routers/solve.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
|
| 8 |
+
|
| 9 |
+
from agents.orchestrator import Orchestrator
|
| 10 |
+
from app.chat_image_upload import upload_session_chat_image, validate_chat_image_bytes
|
| 11 |
+
from app.ocr_local_file import ocr_from_local_image_path
|
| 12 |
+
from app.dependencies import get_current_user_id
|
| 13 |
+
from app.errors import format_error_for_user
|
| 14 |
+
from app.logutil import log_pipeline_failure, log_pipeline_success, log_step
|
| 15 |
+
from app.models.schemas import (
|
| 16 |
+
OcrPreviewResponse,
|
| 17 |
+
RenderVideoRequest,
|
| 18 |
+
RenderVideoResponse,
|
| 19 |
+
SolveRequest,
|
| 20 |
+
SolveResponse,
|
| 21 |
+
)
|
| 22 |
+
from app.ocr_text_merge import build_combined_ocr_preview_draft
|
| 23 |
+
from app.session_cache import session_owned_by_user
|
| 24 |
+
from app.supabase_client import get_supabase
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
router = APIRouter(prefix="/api/v1/sessions", tags=["Solve"])
|
| 28 |
+
|
| 29 |
+
# Eager init: all agents and models load at import time (also run in Docker build via scripts/prewarm_models.py).
|
| 30 |
+
ORCHESTRATOR = Orchestrator()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def get_orchestrator() -> Orchestrator:
|
| 34 |
+
return ORCHESTRATOR
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_OCR_PREVIEW_MAX_BYTES = 10 * 1024 * 1024
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _assert_session_owner(supabase, session_id: str, user_id, uid: str, op: str) -> None:
|
| 41 |
+
def owns() -> bool:
|
| 42 |
+
res = (
|
| 43 |
+
supabase.table("sessions")
|
| 44 |
+
.select("id")
|
| 45 |
+
.eq("id", session_id)
|
| 46 |
+
.eq("user_id", user_id)
|
| 47 |
+
.execute()
|
| 48 |
+
)
|
| 49 |
+
log_step("db_select", table="sessions", op=op, session_id=session_id)
|
| 50 |
+
return bool(res.data)
|
| 51 |
+
|
| 52 |
+
if not session_owned_by_user(session_id, uid, owns):
|
| 53 |
+
log_pipeline_failure("solve_request", error="forbidden", session_id=session_id)
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=403, detail="Forbidden: You do not own this session."
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
from app.celery_app import is_celery_available
|
| 60 |
+
from app.tasks import (
|
| 61 |
+
async_solve_session_job,
|
| 62 |
+
async_render_video_job,
|
| 63 |
+
solve_session_job_task,
|
| 64 |
+
render_video_job_task,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _enqueue_solve_common(
|
| 69 |
+
supabase,
|
| 70 |
+
background_tasks: BackgroundTasks,
|
| 71 |
+
session_id: str,
|
| 72 |
+
user_id,
|
| 73 |
+
uid: str,
|
| 74 |
+
request: SolveRequest,
|
| 75 |
+
message_metadata: dict,
|
| 76 |
+
job_id: str,
|
| 77 |
+
) -> SolveResponse:
|
| 78 |
+
"""Insert user message, job row, enqueue pipeline via Celery/BackgroundWorker; update title when first message."""
|
| 79 |
+
client_msg_id = getattr(request, "client_message_id", None)
|
| 80 |
+
if client_msg_id:
|
| 81 |
+
message_metadata["client_message_id"] = client_msg_id
|
| 82 |
+
|
| 83 |
+
# Check for idempotency if client_message_id is provided
|
| 84 |
+
if client_msg_id:
|
| 85 |
+
try:
|
| 86 |
+
existing = (
|
| 87 |
+
supabase.table("messages")
|
| 88 |
+
.select("id")
|
| 89 |
+
.eq("session_id", session_id)
|
| 90 |
+
.eq("client_message_id", client_msg_id)
|
| 91 |
+
.execute()
|
| 92 |
+
)
|
| 93 |
+
if existing.data and len(existing.data) > 0:
|
| 94 |
+
logger.info(
|
| 95 |
+
"Duplicate request detected for client_message_id=%s in session %s",
|
| 96 |
+
client_msg_id,
|
| 97 |
+
session_id,
|
| 98 |
+
)
|
| 99 |
+
return SolveResponse(job_id=job_id, status="processing")
|
| 100 |
+
except Exception:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
msg_insert = {
|
| 104 |
+
"session_id": session_id,
|
| 105 |
+
"role": "user",
|
| 106 |
+
"type": "text",
|
| 107 |
+
"content": request.text,
|
| 108 |
+
"metadata": message_metadata,
|
| 109 |
+
}
|
| 110 |
+
if client_msg_id:
|
| 111 |
+
try:
|
| 112 |
+
supabase.table("messages").insert({**msg_insert, "client_message_id": client_msg_id}).execute()
|
| 113 |
+
except Exception:
|
| 114 |
+
supabase.table("messages").insert(msg_insert).execute()
|
| 115 |
+
else:
|
| 116 |
+
supabase.table("messages").insert(msg_insert).execute()
|
| 117 |
+
|
| 118 |
+
log_step("db_insert", table="messages", op="user_message", session_id=session_id)
|
| 119 |
+
|
| 120 |
+
supabase.table("jobs").insert(
|
| 121 |
+
{
|
| 122 |
+
"id": job_id,
|
| 123 |
+
"user_id": user_id,
|
| 124 |
+
"session_id": session_id,
|
| 125 |
+
"status": "processing",
|
| 126 |
+
"stage": "ocr" if request.image_url else "parsing",
|
| 127 |
+
"progress": 15 if request.image_url else 35,
|
| 128 |
+
"input_text": request.text,
|
| 129 |
+
}
|
| 130 |
+
).execute()
|
| 131 |
+
log_step("db_insert", table="jobs", job_id=job_id)
|
| 132 |
+
|
| 133 |
+
# Dispatch to Celery queue if available; otherwise use Async Background Tasks
|
| 134 |
+
if is_celery_available():
|
| 135 |
+
try:
|
| 136 |
+
solve_session_job_task.delay(
|
| 137 |
+
job_id, session_id, request.text, request.image_url, str(user_id), client_msg_id
|
| 138 |
+
)
|
| 139 |
+
log_step("celery_dispatch", task="solve_session_job", job_id=job_id)
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.warning("Celery dispatch failed (%s), falling back to BackgroundTasks", e)
|
| 142 |
+
background_tasks.add_task(
|
| 143 |
+
async_solve_session_job,
|
| 144 |
+
job_id,
|
| 145 |
+
session_id,
|
| 146 |
+
request.text,
|
| 147 |
+
request.image_url,
|
| 148 |
+
str(user_id),
|
| 149 |
+
client_msg_id,
|
| 150 |
+
)
|
| 151 |
+
else:
|
| 152 |
+
background_tasks.add_task(
|
| 153 |
+
async_solve_session_job,
|
| 154 |
+
job_id,
|
| 155 |
+
session_id,
|
| 156 |
+
request.text,
|
| 157 |
+
request.image_url,
|
| 158 |
+
str(user_id),
|
| 159 |
+
client_msg_id,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
title_check = supabase.table("sessions").select("title").eq("id", session_id).execute()
|
| 163 |
+
if title_check.data and title_check.data[0]["title"] == "Bài toán mới":
|
| 164 |
+
new_title = request.text[:50] + ("..." if len(request.text) > 50 else "")
|
| 165 |
+
supabase.table("sessions").update({"title": new_title}).eq("id", session_id).execute()
|
| 166 |
+
log_step("db_update", table="sessions", op="title_from_first_message")
|
| 167 |
+
|
| 168 |
+
log_pipeline_success("solve_accepted", job_id=job_id, session_id=session_id)
|
| 169 |
+
return SolveResponse(job_id=job_id, status="processing")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@router.post("/{session_id}/ocr_preview", response_model=OcrPreviewResponse)
|
| 174 |
+
async def ocr_preview(
|
| 175 |
+
session_id: str,
|
| 176 |
+
user_id=Depends(get_current_user_id),
|
| 177 |
+
file: UploadFile = File(...),
|
| 178 |
+
user_message: str | None = Form(None),
|
| 179 |
+
):
|
| 180 |
+
"""
|
| 181 |
+
Run OCR on an uploaded image and merge with optional user_message into combined_draft.
|
| 182 |
+
Does not insert messages or start a solve job. After user confirms, call POST .../solve
|
| 183 |
+
with text=combined_draft (edited) and omit image_url to avoid double OCR.
|
| 184 |
+
"""
|
| 185 |
+
supabase = get_supabase()
|
| 186 |
+
uid = str(user_id)
|
| 187 |
+
_assert_session_owner(supabase, session_id, user_id, uid, "owner_check_ocr_preview")
|
| 188 |
+
|
| 189 |
+
body = await file.read()
|
| 190 |
+
if len(body) > _OCR_PREVIEW_MAX_BYTES:
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=413,
|
| 193 |
+
detail=f"Image too large (max {_OCR_PREVIEW_MAX_BYTES // (1024 * 1024)} MB).",
|
| 194 |
+
)
|
| 195 |
+
if not body:
|
| 196 |
+
raise HTTPException(status_code=400, detail="Empty file.")
|
| 197 |
+
|
| 198 |
+
validate_chat_image_bytes(file.filename, body, file.content_type)
|
| 199 |
+
|
| 200 |
+
suffix = os.path.splitext(file.filename or "")[1].lower()
|
| 201 |
+
if suffix not in (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ""):
|
| 202 |
+
suffix = ".png"
|
| 203 |
+
temp_path = f"temp_ocr_preview_{uuid.uuid4()}{suffix or '.png'}"
|
| 204 |
+
try:
|
| 205 |
+
with open(temp_path, "wb") as f:
|
| 206 |
+
f.write(body)
|
| 207 |
+
ocr_text = await ocr_from_local_image_path(
|
| 208 |
+
temp_path, file.filename, get_orchestrator().ocr_agent
|
| 209 |
+
)
|
| 210 |
+
if ocr_text is None:
|
| 211 |
+
ocr_text = ""
|
| 212 |
+
finally:
|
| 213 |
+
if os.path.exists(temp_path):
|
| 214 |
+
os.remove(temp_path)
|
| 215 |
+
|
| 216 |
+
um = (user_message or "").strip()
|
| 217 |
+
combined = build_combined_ocr_preview_draft(user_message, ocr_text)
|
| 218 |
+
log_step("ocr_preview_done", session_id=session_id, ocr_len=len(ocr_text), user_len=len(um))
|
| 219 |
+
return OcrPreviewResponse(
|
| 220 |
+
ocr_text=ocr_text,
|
| 221 |
+
user_message=um,
|
| 222 |
+
combined_draft=combined,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@router.post("/{session_id}/solve", response_model=SolveResponse)
|
| 227 |
+
async def solve_problem(
|
| 228 |
+
session_id: str,
|
| 229 |
+
request: SolveRequest,
|
| 230 |
+
background_tasks: BackgroundTasks,
|
| 231 |
+
user_id=Depends(get_current_user_id),
|
| 232 |
+
):
|
| 233 |
+
"""
|
| 234 |
+
Gửi câu hỏi giải toán trong một session (Submit geometry problem in a session).
|
| 235 |
+
Lưu câu hỏi vào history và bắt đầu tiến trình giải (chỉ giải toán và tạo hình tĩnh).
|
| 236 |
+
"""
|
| 237 |
+
supabase = get_supabase()
|
| 238 |
+
uid = str(user_id)
|
| 239 |
+
_assert_session_owner(supabase, session_id, user_id, uid, "owner_check")
|
| 240 |
+
|
| 241 |
+
message_metadata = {"image_url": request.image_url} if request.image_url else {}
|
| 242 |
+
job_id = str(uuid.uuid4())
|
| 243 |
+
return _enqueue_solve_common(
|
| 244 |
+
supabase,
|
| 245 |
+
background_tasks,
|
| 246 |
+
session_id,
|
| 247 |
+
user_id,
|
| 248 |
+
uid,
|
| 249 |
+
request,
|
| 250 |
+
message_metadata,
|
| 251 |
+
job_id,
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.post("/{session_id}/solve_multipart", response_model=SolveResponse)
|
| 256 |
+
async def solve_multipart(
|
| 257 |
+
session_id: str,
|
| 258 |
+
background_tasks: BackgroundTasks,
|
| 259 |
+
user_id=Depends(get_current_user_id),
|
| 260 |
+
text: str = Form(...),
|
| 261 |
+
file: UploadFile = File(...),
|
| 262 |
+
client_message_id: str | None = Form(None),
|
| 263 |
+
):
|
| 264 |
+
"""
|
| 265 |
+
Gửi text + file ảnh trong một request multipart: validate, upload bucket `image`,
|
| 266 |
+
ghi session_assets, lưu message kèm metadata (URL, size, type), rồi enqueue solve
|
| 267 |
+
(image_url trỏ public URL để orchestrator OCR).
|
| 268 |
+
"""
|
| 269 |
+
supabase = get_supabase()
|
| 270 |
+
uid = str(user_id)
|
| 271 |
+
_assert_session_owner(supabase, session_id, user_id, uid, "owner_check_solve_multipart")
|
| 272 |
+
|
| 273 |
+
t = (text or "").strip()
|
| 274 |
+
if not t:
|
| 275 |
+
raise HTTPException(status_code=400, detail="text must not be empty.")
|
| 276 |
+
|
| 277 |
+
body = await file.read()
|
| 278 |
+
ext, content_type = validate_chat_image_bytes(file.filename, body, file.content_type)
|
| 279 |
+
|
| 280 |
+
job_id = str(uuid.uuid4())
|
| 281 |
+
up = upload_session_chat_image(session_id, job_id, body, ext, content_type)
|
| 282 |
+
public_url = up["public_url"]
|
| 283 |
+
|
| 284 |
+
message_metadata = {
|
| 285 |
+
"image_url": public_url,
|
| 286 |
+
"attachment": {
|
| 287 |
+
"public_url": public_url,
|
| 288 |
+
"storage_path": up["storage_path"],
|
| 289 |
+
"size_bytes": len(body),
|
| 290 |
+
"content_type": content_type,
|
| 291 |
+
"original_filename": file.filename or "",
|
| 292 |
+
"session_asset_id": up.get("session_asset_id"),
|
| 293 |
+
},
|
| 294 |
+
}
|
| 295 |
+
request = SolveRequest(text=t, image_url=public_url, client_message_id=client_message_id)
|
| 296 |
+
return _enqueue_solve_common(
|
| 297 |
+
supabase,
|
| 298 |
+
background_tasks,
|
| 299 |
+
session_id,
|
| 300 |
+
user_id,
|
| 301 |
+
uid,
|
| 302 |
+
request,
|
| 303 |
+
message_metadata,
|
| 304 |
+
job_id,
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
@router.post("/{session_id}/render_video", response_model=RenderVideoResponse)
|
| 309 |
+
async def render_video(
|
| 310 |
+
session_id: str,
|
| 311 |
+
request: RenderVideoRequest,
|
| 312 |
+
background_tasks: BackgroundTasks,
|
| 313 |
+
user_id=Depends(get_current_user_id),
|
| 314 |
+
):
|
| 315 |
+
"""
|
| 316 |
+
Yêu cầu tạo video Manim từ trạng thái hình ảnh mới nhất của session.
|
| 317 |
+
"""
|
| 318 |
+
supabase = get_supabase()
|
| 319 |
+
if not supabase:
|
| 320 |
+
raise HTTPException(status_code=503, detail="Database service currently unavailable.")
|
| 321 |
+
|
| 322 |
+
uid = str(user_id)
|
| 323 |
+
# 1. Kiểm tra quyền sở hữu
|
| 324 |
+
_assert_session_owner(supabase, session_id, user_id, uid, "owner_check_render_video")
|
| 325 |
+
|
| 326 |
+
# 2. Tìm tin nhắn assistant có metadata hình học (cụ thể job_id hoặc mới nhất trong 10 tin nhắn gần nhất)
|
| 327 |
+
msg_res = (
|
| 328 |
+
supabase.table("messages")
|
| 329 |
+
.select("metadata")
|
| 330 |
+
.eq("session_id", session_id)
|
| 331 |
+
.eq("role", "assistant")
|
| 332 |
+
.order("created_at", desc=True)
|
| 333 |
+
.limit(10)
|
| 334 |
+
.execute()
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
latest_geometry = None
|
| 338 |
+
if msg_res.data:
|
| 339 |
+
for msg in msg_res.data:
|
| 340 |
+
meta = msg.get("metadata", {})
|
| 341 |
+
# Nếu có yêu cầu job_id cụ thể, phải khớp job_id
|
| 342 |
+
if request.job_id and meta.get("job_id") != request.job_id:
|
| 343 |
+
continue
|
| 344 |
+
|
| 345 |
+
# Phải có dữ liệu hình học
|
| 346 |
+
if meta.get("geometry_dsl") and meta.get("coordinates"):
|
| 347 |
+
latest_geometry = meta
|
| 348 |
+
break
|
| 349 |
+
|
| 350 |
+
if not latest_geometry:
|
| 351 |
+
raise HTTPException(status_code=404, detail="Không tìm thấy dữ liệu hình học để render video.")
|
| 352 |
+
|
| 353 |
+
# 3. Tạo Job rendering
|
| 354 |
+
job_id = str(uuid.uuid4())
|
| 355 |
+
supabase.table("jobs").insert({
|
| 356 |
+
"id": job_id,
|
| 357 |
+
"user_id": user_id,
|
| 358 |
+
"session_id": session_id,
|
| 359 |
+
"status": "rendering_queued",
|
| 360 |
+
"stage": "rendering",
|
| 361 |
+
"progress": 10,
|
| 362 |
+
"input_text": f"Render video requested at {job_id}",
|
| 363 |
+
}).execute()
|
| 364 |
+
|
| 365 |
+
# 4. Dispatch Celery task or async background task
|
| 366 |
+
if is_celery_available():
|
| 367 |
+
try:
|
| 368 |
+
render_video_job_task.delay(job_id, session_id, latest_geometry)
|
| 369 |
+
log_step("celery_dispatch", task="render_video_job", job_id=job_id)
|
| 370 |
+
except Exception as e:
|
| 371 |
+
logger.warning("Celery dispatch failed (%s), falling back to BackgroundTasks", e)
|
| 372 |
+
background_tasks.add_task(async_render_video_job, job_id, session_id, latest_geometry)
|
| 373 |
+
else:
|
| 374 |
+
background_tasks.add_task(async_render_video_job, job_id, session_id, latest_geometry)
|
| 375 |
+
|
| 376 |
+
return RenderVideoResponse(job_id=job_id, status="rendering_queued")
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
async def process_session_job(
|
| 380 |
+
job_id: str, session_id: str, request: SolveRequest, user_id: str
|
| 381 |
+
):
|
| 382 |
+
"""Tiến trình giải toán ngầm, tạo hình ảnh tĩnh (backward compatible delegate)."""
|
| 383 |
+
return await async_solve_session_job(
|
| 384 |
+
job_id=job_id,
|
| 385 |
+
session_id=session_id,
|
| 386 |
+
text=request.text,
|
| 387 |
+
image_url=request.image_url,
|
| 388 |
+
user_id=user_id,
|
| 389 |
+
client_message_id=getattr(request, "client_message_id", None),
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
async def process_render_job(job_id: str, session_id: str, geometry_data: dict):
|
| 394 |
+
"""Tiến trình render video qua External Manim API (backward compatible delegate)."""
|
| 395 |
+
return await async_render_video_job(job_id=job_id, session_id=session_id, geometry_data=geometry_data)
|
| 396 |
+
|
| 397 |
+
|
app/runtime_env.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Default process env vars (Paddle/OpenMP). Call as early as possible after load_dotenv."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def apply_runtime_env_defaults() -> None:
|
| 9 |
+
# Paddle respects OMP_NUM_THREADS at import; setdefault loses if platform already set 2+
|
| 10 |
+
os.environ["OMP_NUM_THREADS"] = "1"
|
| 11 |
+
os.environ["MKL_NUM_THREADS"] = "1"
|
| 12 |
+
os.environ["OPENBLAS_NUM_THREADS"] = "1"
|
app/session_cache.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic session ownership verification (Server-authoritative, no process-local drift)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Callable
|
| 6 |
+
from app.logutil import log_step
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def invalidate_session_owner(session_id: str, user_id: str) -> None:
|
| 10 |
+
"""No-op for backward compatibility now that ownership is direct DB authoritative."""
|
| 11 |
+
log_step("session_owner_check", target="session_owner", session_id=session_id, user_id=user_id)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def session_owned_by_user(
|
| 15 |
+
session_id: str,
|
| 16 |
+
user_id: str,
|
| 17 |
+
fetch: Callable[[], bool],
|
| 18 |
+
) -> bool:
|
| 19 |
+
"""
|
| 20 |
+
Direct authoritative ownership check via provided fetch function.
|
| 21 |
+
Eliminates multi-worker drift by always evaluating against the authoritative DB.
|
| 22 |
+
"""
|
| 23 |
+
ok = fetch()
|
| 24 |
+
log_step("session_owner_verified", session_id=session_id, user_id=user_id, is_owner=ok)
|
| 25 |
+
return ok
|
| 26 |
+
|
app/supabase_client.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from supabase import Client, ClientOptions, create_client
|
| 4 |
+
from supabase_auth import SyncMemoryStorage
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
from app.url_utils import sanitize_env
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
_supabase_client = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_supabase() -> Client:
|
| 17 |
+
"""Service-role client for server-side operations with lazy init."""
|
| 18 |
+
global _supabase_client
|
| 19 |
+
if _supabase_client is not None:
|
| 20 |
+
return _supabase_client
|
| 21 |
+
|
| 22 |
+
url = sanitize_env(os.getenv("SUPABASE_URL"))
|
| 23 |
+
key = sanitize_env(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY"))
|
| 24 |
+
if not url or not key:
|
| 25 |
+
logger.warning("[Supabase] SUPABASE_URL or key not configured. Cloud DB operations will be unavailable.")
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
_supabase_client = create_client(url, key)
|
| 30 |
+
return _supabase_client
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.warning("[Supabase] Failed to initialize Supabase client: %s", e)
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_supabase_for_user_jwt(access_token: str) -> Client:
|
| 37 |
+
"""Client scoped to the logged-in user."""
|
| 38 |
+
url = sanitize_env(os.getenv("SUPABASE_URL"))
|
| 39 |
+
anon = sanitize_env(os.getenv("SUPABASE_ANON_KEY") or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY"))
|
| 40 |
+
if not url or not anon:
|
| 41 |
+
raise RuntimeError("SUPABASE_URL and SUPABASE_ANON_KEY must be set for user-scoped Supabase access")
|
| 42 |
+
base_opts = ClientOptions(storage=SyncMemoryStorage())
|
| 43 |
+
merged_headers = {**dict(base_opts.headers), "Authorization": f"Bearer {access_token}"}
|
| 44 |
+
opts = ClientOptions(storage=SyncMemoryStorage(), headers=merged_headers)
|
| 45 |
+
return create_client(url, anon, opts)
|
app/tasks.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celery Tasks & Async Worker Handlers for MathSolver Solve & Render Pipeline."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
from app.celery_app import celery_app
|
| 12 |
+
from app.errors import format_error_for_user
|
| 13 |
+
from app.logutil import log_pipeline_failure, log_pipeline_success, log_step
|
| 14 |
+
from app.models.job_state import JobStatus, JobStage, JobStateMachine
|
| 15 |
+
from app.supabase_client import get_supabase
|
| 16 |
+
from app.websocket_manager import notify_status
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def async_solve_session_job(
|
| 22 |
+
job_id: str,
|
| 23 |
+
session_id: str,
|
| 24 |
+
text: str,
|
| 25 |
+
image_url: Optional[str] = None,
|
| 26 |
+
user_id: Optional[str] = None,
|
| 27 |
+
client_message_id: Optional[str] = None,
|
| 28 |
+
) -> Dict[str, Any]:
|
| 29 |
+
"""Execute the full geometry solve pipeline for a session job."""
|
| 30 |
+
from app.routers.solve import get_orchestrator
|
| 31 |
+
|
| 32 |
+
supabase = get_supabase()
|
| 33 |
+
|
| 34 |
+
async def status_callback(status: str, stage: Optional[str] = None, progress: Optional[int] = None):
|
| 35 |
+
norm_status = JobStateMachine.normalize_status(status)
|
| 36 |
+
norm_stage = JobStateMachine.normalize_stage(stage or status)
|
| 37 |
+
update_data = {"status": norm_status.value}
|
| 38 |
+
if norm_stage:
|
| 39 |
+
update_data["stage"] = norm_stage.value
|
| 40 |
+
if progress is not None:
|
| 41 |
+
update_data["progress"] = progress
|
| 42 |
+
|
| 43 |
+
if supabase:
|
| 44 |
+
try:
|
| 45 |
+
supabase.table("jobs").update(update_data).eq("id", job_id).execute()
|
| 46 |
+
except Exception as e:
|
| 47 |
+
logger.debug("Failed updating job status in DB: %s", e)
|
| 48 |
+
|
| 49 |
+
await notify_status(job_id, {
|
| 50 |
+
"status": norm_status.value,
|
| 51 |
+
"stage": norm_stage.value if norm_stage else None,
|
| 52 |
+
"progress": progress,
|
| 53 |
+
"job_id": job_id,
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
# Initial status update
|
| 58 |
+
await status_callback("processing", stage="ocr", progress=15)
|
| 59 |
+
|
| 60 |
+
history = []
|
| 61 |
+
if supabase and session_id:
|
| 62 |
+
try:
|
| 63 |
+
history_res = (
|
| 64 |
+
supabase.table("messages")
|
| 65 |
+
.select("*")
|
| 66 |
+
.eq("session_id", session_id)
|
| 67 |
+
.order("created_at", desc=False)
|
| 68 |
+
.execute()
|
| 69 |
+
)
|
| 70 |
+
history = history_res.data if history_res.data else []
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.warning("Could not fetch message history: %s", e)
|
| 73 |
+
|
| 74 |
+
result = await get_orchestrator().run(
|
| 75 |
+
text,
|
| 76 |
+
image_url,
|
| 77 |
+
job_id=job_id,
|
| 78 |
+
session_id=session_id,
|
| 79 |
+
status_callback=lambda st: status_callback(st),
|
| 80 |
+
history=history,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
has_error = "error" in result and result.get("error")
|
| 84 |
+
final_status = JobStatus.FAILED if has_error else JobStatus.COMPLETED
|
| 85 |
+
|
| 86 |
+
if supabase:
|
| 87 |
+
supabase.table("jobs").update({
|
| 88 |
+
"status": final_status.value,
|
| 89 |
+
"stage": None,
|
| 90 |
+
"progress": 100 if final_status == JobStatus.COMPLETED else 0,
|
| 91 |
+
"result": result,
|
| 92 |
+
}).eq("id", job_id).execute()
|
| 93 |
+
|
| 94 |
+
# Idempotency check: Ensure assistant message for this job is not inserted twice
|
| 95 |
+
existing_msg = (
|
| 96 |
+
supabase.table("messages")
|
| 97 |
+
.select("id")
|
| 98 |
+
.eq("session_id", session_id)
|
| 99 |
+
.filter("metadata->>job_id", "eq", job_id)
|
| 100 |
+
.execute()
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if not existing_msg.data or len(existing_msg.data) == 0:
|
| 104 |
+
supabase.table("messages").insert({
|
| 105 |
+
"session_id": session_id,
|
| 106 |
+
"role": "assistant",
|
| 107 |
+
"type": "error" if has_error else "analysis",
|
| 108 |
+
"content": (
|
| 109 |
+
result.get("error", "Đã có lỗi xảy ra.")
|
| 110 |
+
if has_error
|
| 111 |
+
else result.get("semantic_analysis", "Giải bài toán hoàn tất.")
|
| 112 |
+
),
|
| 113 |
+
"metadata": {
|
| 114 |
+
"job_id": job_id,
|
| 115 |
+
"client_message_id": client_message_id,
|
| 116 |
+
"coordinates": result.get("coordinates"),
|
| 117 |
+
"geometry_dsl": result.get("geometry_dsl"),
|
| 118 |
+
"polygon_order": result.get("polygon_order", []),
|
| 119 |
+
"drawing_phases": result.get("drawing_phases", []),
|
| 120 |
+
"circles": result.get("circles", []),
|
| 121 |
+
"solids": result.get("solids", []),
|
| 122 |
+
"faces": result.get("faces", []),
|
| 123 |
+
"lines": result.get("lines", []),
|
| 124 |
+
"rays": result.get("rays", []),
|
| 125 |
+
"visualization_graph": result.get("visualization_graph"),
|
| 126 |
+
"auxiliary": result.get("auxiliary", []),
|
| 127 |
+
"solution": result.get("solution"),
|
| 128 |
+
"is_3d": result.get("is_3d", False),
|
| 129 |
+
},
|
| 130 |
+
}).execute()
|
| 131 |
+
|
| 132 |
+
await notify_status(job_id, {
|
| 133 |
+
"status": final_status.value,
|
| 134 |
+
"stage": None,
|
| 135 |
+
"progress": 100 if final_status == JobStatus.COMPLETED else 0,
|
| 136 |
+
"job_id": job_id,
|
| 137 |
+
"result": result,
|
| 138 |
+
})
|
| 139 |
+
log_pipeline_success("job_complete", job_id=job_id, session_id=session_id)
|
| 140 |
+
return result
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.exception("Error in async_solve_session_job for job %s: %s", job_id, e)
|
| 144 |
+
error_msg = format_error_for_user(e)
|
| 145 |
+
if supabase:
|
| 146 |
+
try:
|
| 147 |
+
supabase.table("jobs").update({
|
| 148 |
+
"status": JobStatus.FAILED.value,
|
| 149 |
+
"progress": 0,
|
| 150 |
+
"result": {"error": str(e)},
|
| 151 |
+
}).eq("id", job_id).execute()
|
| 152 |
+
|
| 153 |
+
supabase.table("messages").insert({
|
| 154 |
+
"session_id": session_id,
|
| 155 |
+
"role": "assistant",
|
| 156 |
+
"type": "error",
|
| 157 |
+
"content": error_msg,
|
| 158 |
+
"metadata": {"job_id": job_id, "client_message_id": client_message_id},
|
| 159 |
+
}).execute()
|
| 160 |
+
except Exception as dbe:
|
| 161 |
+
logger.error("DB error recording failure for job %s: %s", job_id, dbe)
|
| 162 |
+
|
| 163 |
+
await notify_status(job_id, {
|
| 164 |
+
"status": JobStatus.FAILED.value,
|
| 165 |
+
"job_id": job_id,
|
| 166 |
+
"error": error_msg,
|
| 167 |
+
"progress": 0,
|
| 168 |
+
})
|
| 169 |
+
log_pipeline_failure("job_failed", job_id=job_id, error=str(e))
|
| 170 |
+
return {"status": "error", "error": error_msg}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def async_render_video_job(job_id: str, session_id: str, geometry_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 174 |
+
"""Execute Manim video rendering job for a session."""
|
| 175 |
+
from manim_client import ManimClient, build_visualization_spec
|
| 176 |
+
from manim_client.schemas import ErrorCode
|
| 177 |
+
|
| 178 |
+
await notify_status(job_id, {
|
| 179 |
+
"status": JobStatus.QUEUED.value,
|
| 180 |
+
"stage": JobStage.RENDERING.value,
|
| 181 |
+
"job_id": job_id,
|
| 182 |
+
"progress": 10,
|
| 183 |
+
})
|
| 184 |
+
supabase = get_supabase()
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
manim_url = os.getenv("MANIM_SERVICE_URL", "https://cuong2004-manim-agent.hf.space")
|
| 188 |
+
manim_token = os.getenv("MANIM_INTERNAL_TOKEN")
|
| 189 |
+
client = ManimClient(base_url=manim_url, internal_token=manim_token)
|
| 190 |
+
|
| 191 |
+
vis_spec = build_visualization_spec(geometry_data)
|
| 192 |
+
resp = await client.submit_render_job(vis_spec)
|
| 193 |
+
|
| 194 |
+
if resp.status == "failed":
|
| 195 |
+
err_code = resp.get_error_code() or ErrorCode.MANIM_REQUEST_FAILED
|
| 196 |
+
err_msg = resp.get_error_message() or "Không thể gửi yêu cầu tạo video tới máy chủ Manim."
|
| 197 |
+
if supabase:
|
| 198 |
+
supabase.table("jobs").update({
|
| 199 |
+
"status": JobStatus.FAILED.value,
|
| 200 |
+
"result": {"error": {"code": err_code, "message": err_msg}},
|
| 201 |
+
}).eq("id", job_id).execute()
|
| 202 |
+
if session_id:
|
| 203 |
+
supabase.table("messages").insert({
|
| 204 |
+
"session_id": session_id,
|
| 205 |
+
"role": "assistant",
|
| 206 |
+
"type": "error",
|
| 207 |
+
"content": f"Không thể tạo video: {err_msg}",
|
| 208 |
+
"metadata": {"job_id": job_id, "error_code": err_code},
|
| 209 |
+
}).execute()
|
| 210 |
+
await notify_status(job_id, {
|
| 211 |
+
"status": JobStatus.FAILED.value,
|
| 212 |
+
"job_id": job_id,
|
| 213 |
+
"error": err_msg,
|
| 214 |
+
"error_code": err_code,
|
| 215 |
+
})
|
| 216 |
+
return {"status": "error", "error": err_msg}
|
| 217 |
+
|
| 218 |
+
manim_job_id = resp.job_id
|
| 219 |
+
if supabase:
|
| 220 |
+
supabase.table("jobs").update({
|
| 221 |
+
"status": JobStatus.PROCESSING.value,
|
| 222 |
+
"stage": JobStage.RENDERING.value,
|
| 223 |
+
"progress": 40,
|
| 224 |
+
"result": {"manim_job_id": str(manim_job_id)},
|
| 225 |
+
}).eq("id", job_id).execute()
|
| 226 |
+
|
| 227 |
+
await notify_status(job_id, {
|
| 228 |
+
"status": JobStatus.PROCESSING.value,
|
| 229 |
+
"stage": JobStage.RENDERING.value,
|
| 230 |
+
"job_id": job_id,
|
| 231 |
+
"progress": 40,
|
| 232 |
+
"manim_job_id": str(manim_job_id),
|
| 233 |
+
})
|
| 234 |
+
|
| 235 |
+
poll_timeout = float(os.getenv("MANIM_POLL_TIMEOUT", "600.0"))
|
| 236 |
+
status_resp = await client.wait_for_completion(manim_job_id, poll_interval=3.0, max_wait=poll_timeout)
|
| 237 |
+
video_url = status_resp.video_url
|
| 238 |
+
|
| 239 |
+
if status_resp.status == "failed" or not video_url:
|
| 240 |
+
err_code = status_resp.get_error_code() or ErrorCode.MANIM_RENDER_FAILED
|
| 241 |
+
err_msg = status_resp.get_error_message() or "Tiến trình dựng video Manim thất bại."
|
| 242 |
+
if supabase:
|
| 243 |
+
supabase.table("jobs").update({
|
| 244 |
+
"status": JobStatus.FAILED.value,
|
| 245 |
+
"result": {"error": {"code": err_code, "message": err_msg}},
|
| 246 |
+
}).eq("id", job_id).execute()
|
| 247 |
+
if session_id:
|
| 248 |
+
supabase.table("messages").insert({
|
| 249 |
+
"session_id": session_id,
|
| 250 |
+
"role": "assistant",
|
| 251 |
+
"type": "error",
|
| 252 |
+
"content": f"Không thể tạo video: {err_msg}",
|
| 253 |
+
"metadata": {"job_id": job_id, "error_code": err_code},
|
| 254 |
+
}).execute()
|
| 255 |
+
await notify_status(job_id, {
|
| 256 |
+
"status": JobStatus.FAILED.value,
|
| 257 |
+
"job_id": job_id,
|
| 258 |
+
"error": err_msg,
|
| 259 |
+
"error_code": err_code,
|
| 260 |
+
})
|
| 261 |
+
return {"status": "error", "error": err_msg}
|
| 262 |
+
|
| 263 |
+
final_result = geometry_data.copy()
|
| 264 |
+
final_result["video_url"] = video_url
|
| 265 |
+
final_result["manim_job_id"] = str(manim_job_id)
|
| 266 |
+
|
| 267 |
+
if supabase:
|
| 268 |
+
supabase.table("jobs").update({
|
| 269 |
+
"status": JobStatus.COMPLETED.value,
|
| 270 |
+
"progress": 100,
|
| 271 |
+
"result": final_result,
|
| 272 |
+
}).eq("id", job_id).execute()
|
| 273 |
+
|
| 274 |
+
# Versioned asset recording
|
| 275 |
+
try:
|
| 276 |
+
asset_version = 1
|
| 277 |
+
v_res = supabase.table("session_assets").select("version").eq("session_id", session_id).eq("asset_type", "video").order("version", desc=True).limit(1).execute()
|
| 278 |
+
if v_res.data and len(v_res.data) > 0:
|
| 279 |
+
asset_version = v_res.data[0]["version"] + 1
|
| 280 |
+
|
| 281 |
+
supabase.table("session_assets").insert({
|
| 282 |
+
"session_id": session_id,
|
| 283 |
+
"job_id": job_id,
|
| 284 |
+
"asset_type": "video",
|
| 285 |
+
"storage_path": video_url,
|
| 286 |
+
"public_url": video_url,
|
| 287 |
+
"version": asset_version,
|
| 288 |
+
}).execute()
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.warning("Could not record session_asset video row: %s", e)
|
| 291 |
+
|
| 292 |
+
if session_id:
|
| 293 |
+
supabase.table("messages").insert({
|
| 294 |
+
"session_id": session_id,
|
| 295 |
+
"role": "assistant",
|
| 296 |
+
"type": "analysis",
|
| 297 |
+
"content": geometry_data.get("semantic_analysis", "🎬 Video minh họa hình học đã hoàn tất."),
|
| 298 |
+
"metadata": {
|
| 299 |
+
"job_id": job_id,
|
| 300 |
+
"video_url": video_url,
|
| 301 |
+
"coordinates": geometry_data.get("coordinates"),
|
| 302 |
+
"geometry_dsl": geometry_data.get("geometry_dsl"),
|
| 303 |
+
"polygon_order": geometry_data.get("polygon_order", []),
|
| 304 |
+
"drawing_phases": geometry_data.get("drawing_phases", []),
|
| 305 |
+
"circles": geometry_data.get("circles", []),
|
| 306 |
+
"solids": geometry_data.get("solids", []),
|
| 307 |
+
"faces": geometry_data.get("faces", []),
|
| 308 |
+
"lines": geometry_data.get("lines", []),
|
| 309 |
+
"rays": geometry_data.get("rays", []),
|
| 310 |
+
"visualization_graph": geometry_data.get("visualization_graph"),
|
| 311 |
+
"auxiliary": geometry_data.get("auxiliary", []),
|
| 312 |
+
"is_3d": geometry_data.get("is_3d", False),
|
| 313 |
+
},
|
| 314 |
+
}).execute()
|
| 315 |
+
|
| 316 |
+
await notify_status(job_id, {
|
| 317 |
+
"status": JobStatus.COMPLETED.value,
|
| 318 |
+
"job_id": job_id,
|
| 319 |
+
"result": final_result,
|
| 320 |
+
"video_url": video_url,
|
| 321 |
+
"progress": 100,
|
| 322 |
+
})
|
| 323 |
+
return final_result
|
| 324 |
+
|
| 325 |
+
except Exception as e:
|
| 326 |
+
logger.exception("Error rendering video for job %s: %s", job_id, e)
|
| 327 |
+
safe_msg = format_error_for_user(e)
|
| 328 |
+
if supabase:
|
| 329 |
+
try:
|
| 330 |
+
supabase.table("jobs").update({
|
| 331 |
+
"status": JobStatus.FAILED.value,
|
| 332 |
+
"result": {"error": {"message": safe_msg}},
|
| 333 |
+
}).eq("id", job_id).execute()
|
| 334 |
+
if session_id:
|
| 335 |
+
supabase.table("messages").insert({
|
| 336 |
+
"session_id": session_id,
|
| 337 |
+
"role": "assistant",
|
| 338 |
+
"type": "error",
|
| 339 |
+
"content": f"Lỗi render video: {safe_msg}",
|
| 340 |
+
"metadata": {"job_id": job_id},
|
| 341 |
+
}).execute()
|
| 342 |
+
except Exception as dbe:
|
| 343 |
+
logger.error("DB error recording render failure: %s", dbe)
|
| 344 |
+
await notify_status(job_id, {"status": JobStatus.FAILED.value, "job_id": job_id, "error": safe_msg})
|
| 345 |
+
return {"status": "error", "error": safe_msg}
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@celery_app.task(name="tasks.solve_session_job", bind=True, acks_late=True, max_retries=1)
|
| 349 |
+
def solve_session_job_task(
|
| 350 |
+
self,
|
| 351 |
+
job_id: str,
|
| 352 |
+
session_id: str,
|
| 353 |
+
text: str,
|
| 354 |
+
image_url: Optional[str] = None,
|
| 355 |
+
user_id: Optional[str] = None,
|
| 356 |
+
client_message_id: Optional[str] = None,
|
| 357 |
+
):
|
| 358 |
+
"""Celery task entry point for solve pipeline."""
|
| 359 |
+
return asyncio.run(
|
| 360 |
+
async_solve_session_job(
|
| 361 |
+
job_id=job_id,
|
| 362 |
+
session_id=session_id,
|
| 363 |
+
text=text,
|
| 364 |
+
image_url=image_url,
|
| 365 |
+
user_id=user_id,
|
| 366 |
+
client_message_id=client_message_id,
|
| 367 |
+
)
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@celery_app.task(name="tasks.render_video_job", bind=True, acks_late=True, max_retries=1)
|
| 372 |
+
def render_video_job_task(self, job_id: str, session_id: str, geometry_data: Dict[str, Any]):
|
| 373 |
+
"""Celery task entry point for video render pipeline."""
|
| 374 |
+
return asyncio.run(async_render_video_job(job_id=job_id, session_id=session_id, geometry_data=geometry_data))
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def recover_stale_jobs(timeout_seconds: int = 900) -> int:
|
| 378 |
+
"""Detect and mark jobs stuck in 'processing' longer than timeout as failed."""
|
| 379 |
+
supabase = get_supabase()
|
| 380 |
+
if not supabase:
|
| 381 |
+
return 0
|
| 382 |
+
try:
|
| 383 |
+
# Note: in production, run via cron or worker startup
|
| 384 |
+
from datetime import datetime, timezone, timedelta
|
| 385 |
+
cutoff = (datetime.now(timezone.utc) - timedelta(seconds=timeout_seconds)).isoformat()
|
| 386 |
+
res = (
|
| 387 |
+
supabase.table("jobs")
|
| 388 |
+
.update({
|
| 389 |
+
"status": JobStatus.FAILED.value,
|
| 390 |
+
"result": {"error": "Worker timeout or crash detected. Job marked failed by recovery agent."},
|
| 391 |
+
})
|
| 392 |
+
.eq("status", JobStatus.PROCESSING.value)
|
| 393 |
+
.lt("created_at", cutoff)
|
| 394 |
+
.execute()
|
| 395 |
+
)
|
| 396 |
+
count = len(res.data) if res.data else 0
|
| 397 |
+
if count > 0:
|
| 398 |
+
logger.warning("Recovered %d stale jobs", count)
|
| 399 |
+
return count
|
| 400 |
+
except Exception as e:
|
| 401 |
+
logger.error("Error running recover_stale_jobs: %s", e)
|
| 402 |
+
return 0
|
app/url_utils.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Normalize URLs / env strings (HF secrets and copy-paste often include trailing newlines)."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def sanitize_url(value: str | None) -> str | None:
|
| 5 |
+
if value is None:
|
| 6 |
+
return None
|
| 7 |
+
s = value.strip().replace("\r", "").replace("\n", "").replace("\t", "")
|
| 8 |
+
return s or None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def sanitize_env(value: str | None) -> str | None:
|
| 12 |
+
"""Strip whitespace and line breaks from environment-backed strings."""
|
| 13 |
+
return sanitize_url(value)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# OpenAI SDK (>=1.x) requires a non-empty api_key at client construction (Docker build / prewarm has no secrets).
|
| 17 |
+
_OPENAI_API_KEY_BUILD_PLACEHOLDER = "build-placeholder-openrouter-not-for-production"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def openai_compatible_api_key(raw: str | None) -> str:
|
| 21 |
+
"""Return sanitized API key, or a placeholder so AsyncOpenAI() can be constructed without env at build time."""
|
| 22 |
+
k = sanitize_env(raw)
|
| 23 |
+
return k if k else _OPENAI_API_KEY_BUILD_PLACEHOLDER
|
app/websocket_manager.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WebSocket connection registry and job status notifications (avoid circular imports with main)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Dict, List
|
| 7 |
+
|
| 8 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
active_connections: Dict[str, List[WebSocket]] = {}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
from app.models.job_state import JobStateMachine, STAGE_PROGRESS_MAP, JobStatus
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def notify_status(job_id: str, data: dict) -> None:
|
| 19 |
+
if job_id not in active_connections:
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
+
# Normalize payload
|
| 23 |
+
payload = dict(data)
|
| 24 |
+
payload["job_id"] = str(job_id)
|
| 25 |
+
if "status" in payload:
|
| 26 |
+
norm_status = JobStateMachine.normalize_status(payload.get("status"))
|
| 27 |
+
norm_stage = JobStateMachine.normalize_stage(payload.get("stage"))
|
| 28 |
+
if not norm_stage and payload.get("status") in ("ocr", "parsing", "geometry", "solving", "rendering"):
|
| 29 |
+
norm_stage = JobStateMachine.normalize_stage(payload.get("status"))
|
| 30 |
+
|
| 31 |
+
payload["status"] = norm_status.value
|
| 32 |
+
payload["stage"] = norm_stage.value if norm_stage else None
|
| 33 |
+
|
| 34 |
+
if "progress" not in payload or payload["progress"] is None:
|
| 35 |
+
if norm_status == JobStatus.COMPLETED:
|
| 36 |
+
payload["progress"] = 100
|
| 37 |
+
elif norm_stage and norm_stage in STAGE_PROGRESS_MAP:
|
| 38 |
+
payload["progress"] = STAGE_PROGRESS_MAP[norm_stage]
|
| 39 |
+
elif norm_status == JobStatus.QUEUED:
|
| 40 |
+
payload["progress"] = 5
|
| 41 |
+
elif norm_status == JobStatus.PROCESSING:
|
| 42 |
+
payload["progress"] = 50
|
| 43 |
+
|
| 44 |
+
for connection in list(active_connections[job_id]):
|
| 45 |
+
try:
|
| 46 |
+
await connection.send_json(payload)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.warning("WS error sending to %s: %s (removing dead connection)", job_id, e)
|
| 49 |
+
try:
|
| 50 |
+
active_connections[job_id].remove(connection)
|
| 51 |
+
except (ValueError, KeyError):
|
| 52 |
+
pass
|
| 53 |
+
if job_id in active_connections and not active_connections[job_id]:
|
| 54 |
+
del active_connections[job_id]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def register_websocket_routes(app) -> None:
|
| 59 |
+
"""Attach websocket endpoint to the FastAPI app."""
|
| 60 |
+
|
| 61 |
+
@app.websocket("/ws/{job_id}")
|
| 62 |
+
async def websocket_endpoint(websocket: WebSocket, job_id: str) -> None:
|
| 63 |
+
await websocket.accept()
|
| 64 |
+
if job_id not in active_connections:
|
| 65 |
+
active_connections[job_id] = []
|
| 66 |
+
active_connections[job_id].append(websocket)
|
| 67 |
+
|
| 68 |
+
# Send immediate ACK so client immediately transitions from 'connecting' to 'processing'
|
| 69 |
+
try:
|
| 70 |
+
await websocket.send_json({
|
| 71 |
+
"status": "processing",
|
| 72 |
+
"job_id": job_id,
|
| 73 |
+
"message": "Đang xử lý bài toán..."
|
| 74 |
+
})
|
| 75 |
+
except Exception:
|
| 76 |
+
pass
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
while True:
|
| 80 |
+
msg = await websocket.receive_text()
|
| 81 |
+
if msg == "ping":
|
| 82 |
+
await websocket.send_text("pong")
|
| 83 |
+
except WebSocketDisconnect:
|
| 84 |
+
if job_id in active_connections and websocket in active_connections[job_id]:
|
| 85 |
+
active_connections[job_id].remove(websocket)
|
| 86 |
+
if not active_connections[job_id]:
|
| 87 |
+
del active_connections[job_id]
|
clean_ports.sh
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Script to kill all project-related processes for a clean restart
|
| 3 |
+
|
| 4 |
+
echo "🧹 Cleaning up project processes..."
|
| 5 |
+
|
| 6 |
+
# Kill things on ports 8000 (Backend) and 3000 (Frontend)
|
| 7 |
+
PORTS="8000 3000 11020"
|
| 8 |
+
for PORT in $PORTS; do
|
| 9 |
+
PIDS=$(lsof -ti :$PORT)
|
| 10 |
+
if [ ! -z "$PIDS" ]; then
|
| 11 |
+
echo "Killing processes on port $PORT: $PIDS"
|
| 12 |
+
kill -9 $PIDS 2>/dev/null
|
| 13 |
+
fi
|
| 14 |
+
done
|
| 15 |
+
|
| 16 |
+
# Kill by process name
|
| 17 |
+
echo "Killing any remaining Celery, Uvicorn, or Manim processes..."
|
| 18 |
+
pkill -9 -f "celery" 2>/dev/null
|
| 19 |
+
pkill -9 -f "uvicorn" 2>/dev/null
|
| 20 |
+
pkill -9 -f "manim" 2>/dev/null
|
| 21 |
+
|
| 22 |
+
echo "✅ Done. You can now restart your Backend, Worker, and Frontend."
|
config/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from config.schemas import ModelTier, AgentConfig, RetryPolicyConfig, AgentModelsConfig
|
| 2 |
+
from config.settings import settings, Settings, ProviderCredentials
|
| 3 |
+
from config.loader import load_agent_config, get_agent_config_resolver, get_agent_models_config
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"ModelTier",
|
| 7 |
+
"AgentConfig",
|
| 8 |
+
"RetryPolicyConfig",
|
| 9 |
+
"AgentModelsConfig",
|
| 10 |
+
"settings",
|
| 11 |
+
"Settings",
|
| 12 |
+
"ProviderCredentials",
|
| 13 |
+
"load_agent_config",
|
| 14 |
+
"get_agent_config_resolver",
|
| 15 |
+
"get_agent_models_config",
|
| 16 |
+
]
|
config/agent_models.yaml
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
|
| 3 |
+
defaults:
|
| 4 |
+
temperature: 0.1
|
| 5 |
+
max_tokens: 8192
|
| 6 |
+
timeout_seconds: 300
|
| 7 |
+
|
| 8 |
+
retry_policy:
|
| 9 |
+
retryable_errors:
|
| 10 |
+
- rate_limit
|
| 11 |
+
- timeout
|
| 12 |
+
- connection
|
| 13 |
+
- server_error
|
| 14 |
+
|
| 15 |
+
non_retryable_errors:
|
| 16 |
+
- invalid_request
|
| 17 |
+
- authentication
|
| 18 |
+
|
| 19 |
+
agents:
|
| 20 |
+
|
| 21 |
+
ocr:
|
| 22 |
+
name: ocr
|
| 23 |
+
description: "Visual OCR for math and geometry problems. Default: high-precision direct VLM."
|
| 24 |
+
ocr_engine: vlm # Options: 'vlm' (default direct multimodal VLM, 0 RAM/CPU, accurate Vietnamese) | 'pix2text' (local OCR engine with confidence gateway)
|
| 25 |
+
|
| 26 |
+
tiers:
|
| 27 |
+
- model: gemini/gemini-3.5-flash-lite
|
| 28 |
+
max_attempts: 1
|
| 29 |
+
reasoning_effort: low
|
| 30 |
+
|
| 31 |
+
temperature: 0.1
|
| 32 |
+
max_tokens: 4096
|
| 33 |
+
timeout_seconds: 60
|
| 34 |
+
|
| 35 |
+
confidence_gateway:
|
| 36 |
+
enabled: true
|
| 37 |
+
threshold: 0.85
|
| 38 |
+
|
| 39 |
+
correction:
|
| 40 |
+
enabled: true
|
| 41 |
+
model: gemini/gemini-3.5-flash-lite
|
| 42 |
+
temperature: 0.1
|
| 43 |
+
max_tokens: 4096
|
| 44 |
+
timeout_seconds: 60
|
| 45 |
+
max_attempts: 1
|
| 46 |
+
reasoning_effort: low
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
geometry_parser:
|
| 51 |
+
name: geometry_parser
|
| 52 |
+
description: "Semantic geometry parsing and Geometry DSL generation"
|
| 53 |
+
|
| 54 |
+
tiers:
|
| 55 |
+
- model: gemini/gemini-3.5-flash-lite
|
| 56 |
+
max_attempts: 1
|
| 57 |
+
reasoning_effort: low
|
| 58 |
+
|
| 59 |
+
- model: gemini/gemini-3.5-flash
|
| 60 |
+
max_attempts: 1
|
| 61 |
+
reasoning_effort: medium
|
| 62 |
+
|
| 63 |
+
temperature: 0.1
|
| 64 |
+
max_tokens: 16384
|
| 65 |
+
timeout_seconds: 120
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
reasoning_solver:
|
| 69 |
+
name: reasoning_solver
|
| 70 |
+
description: "Program-aided mathematical reasoning with SymPy verification"
|
| 71 |
+
|
| 72 |
+
tiers:
|
| 73 |
+
- model: gemini/gemini-3.6-flash
|
| 74 |
+
max_attempts: 1
|
| 75 |
+
reasoning_effort: high
|
| 76 |
+
|
| 77 |
+
- model: gemini/gemini-3.7-flash
|
| 78 |
+
max_attempts: 1
|
| 79 |
+
reasoning_effort: high
|
| 80 |
+
|
| 81 |
+
temperature: 0.1
|
| 82 |
+
max_tokens: 16384
|
| 83 |
+
timeout_seconds: 120
|
| 84 |
+
|
| 85 |
+
|
config/loader.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import yaml
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Optional, Dict
|
| 6 |
+
from config.schemas import AgentConfig, AgentModelsConfig
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
_CACHED_CONFIG: Optional[AgentModelsConfig] = None
|
| 11 |
+
_DEFAULT_CONFIG_PATH = Path(__file__).parent / "agent_models.yaml"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AgentConfigResolver:
|
| 15 |
+
"""Resolves and validates typed AgentConfig from agent_models.yaml."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, config_path: Optional[Path] = None):
|
| 18 |
+
self.config_path = config_path or _DEFAULT_CONFIG_PATH
|
| 19 |
+
self._config: Optional[AgentModelsConfig] = None
|
| 20 |
+
self._load()
|
| 21 |
+
|
| 22 |
+
def _load(self) -> None:
|
| 23 |
+
if not self.config_path.exists():
|
| 24 |
+
raise FileNotFoundError(f"Agent models config file not found: {self.config_path}")
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
with open(self.config_path, "r", encoding="utf-8") as f:
|
| 28 |
+
data = yaml.safe_load(f) or {}
|
| 29 |
+
|
| 30 |
+
# Strict validation with Pydantic
|
| 31 |
+
self._config = AgentModelsConfig(**data)
|
| 32 |
+
logger.info(
|
| 33 |
+
f"[AgentConfigResolver] Loaded {len(self._config.agents)} agent configs from {self.config_path}"
|
| 34 |
+
)
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"[AgentConfigResolver] Failed to parse agent_models.yaml: {e}", exc_info=True)
|
| 37 |
+
raise
|
| 38 |
+
|
| 39 |
+
def get_agent_config(self, agent_name: str) -> AgentConfig:
|
| 40 |
+
if not self._config:
|
| 41 |
+
self._load()
|
| 42 |
+
if not self._config or agent_name not in self._config.agents:
|
| 43 |
+
# Fallback or generic agent config
|
| 44 |
+
logger.warning(
|
| 45 |
+
f"[AgentConfigResolver] Agent '{agent_name}' not defined in config, using defaults."
|
| 46 |
+
)
|
| 47 |
+
from config.schemas import ModelTier
|
| 48 |
+
return AgentConfig(
|
| 49 |
+
name=agent_name,
|
| 50 |
+
description=f"Auto-generated fallback config for {agent_name}",
|
| 51 |
+
tiers=[
|
| 52 |
+
ModelTier(model="gemini/gemini-3.7-flash", max_attempts=1),
|
| 53 |
+
ModelTier(model="gemini/gemini-3.6-flash", max_attempts=1),
|
| 54 |
+
ModelTier(model="gemini/gemini-3.5-flash", max_attempts=1),
|
| 55 |
+
ModelTier(model="gemini/gemini-2.5-flash", max_attempts=1),
|
| 56 |
+
],
|
| 57 |
+
temperature=0.2,
|
| 58 |
+
max_tokens=8192,
|
| 59 |
+
timeout_seconds=120,
|
| 60 |
+
)
|
| 61 |
+
return self._config.agents[agent_name]
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def config(self) -> AgentModelsConfig:
|
| 65 |
+
if not self._config:
|
| 66 |
+
self._load()
|
| 67 |
+
return self._config # type: ignore
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
_RESOLVER_INSTANCE: Optional[AgentConfigResolver] = None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def get_agent_config_resolver() -> AgentConfigResolver:
|
| 74 |
+
global _RESOLVER_INSTANCE
|
| 75 |
+
if _RESOLVER_INSTANCE is None:
|
| 76 |
+
_RESOLVER_INSTANCE = AgentConfigResolver()
|
| 77 |
+
return _RESOLVER_INSTANCE
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def load_agent_config(agent_name: str) -> AgentConfig:
|
| 81 |
+
return get_agent_config_resolver().get_agent_config(agent_name)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_agent_models_config() -> AgentModelsConfig:
|
| 85 |
+
return get_agent_config_resolver().config
|
config/schemas.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Optional, Literal
|
| 2 |
+
from pydantic import BaseModel, Field, field_validator
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ModelTier(BaseModel):
|
| 6 |
+
"""Configuration for a specific model tier within an agent's cascade."""
|
| 7 |
+
model: str = Field(..., description="Provider/Model string, e.g. gemini/gemini-2.5-flash")
|
| 8 |
+
max_attempts: int = Field(default=1, ge=1, le=5, description="Max attempts with this model tier")
|
| 9 |
+
reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
|
| 10 |
+
default=None, description="Reasoning effort for this specific model tier"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
@field_validator("model")
|
| 14 |
+
@classmethod
|
| 15 |
+
def validate_model_format(cls, v: str) -> str:
|
| 16 |
+
v = v.strip()
|
| 17 |
+
if not v:
|
| 18 |
+
raise ValueError("Model identifier cannot be empty")
|
| 19 |
+
return v
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class OCRCorrectionConfig(BaseModel):
|
| 23 |
+
"""Configuration for optional VLM-based OCR correction."""
|
| 24 |
+
enabled: bool = Field(default=True, description="Whether VLM correction is enabled when gateway triggers")
|
| 25 |
+
model: str = Field(default="gemini/gemini-3.5-flash-lite", description="VLM model for OCR correction")
|
| 26 |
+
temperature: float = Field(default=0.1, ge=0.0, le=2.0, description="VLM correction temperature")
|
| 27 |
+
max_tokens: int = Field(default=4096, gt=0, description="Max output tokens for VLM correction")
|
| 28 |
+
timeout_seconds: int = Field(default=60, gt=0, description="VLM correction timeout")
|
| 29 |
+
max_attempts: int = Field(default=1, ge=1, le=3, description="Max VLM correction attempts")
|
| 30 |
+
reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
|
| 31 |
+
default="low", description="Reasoning effort for VLM OCR correction"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ConfidenceGatewayConfig(BaseModel):
|
| 36 |
+
"""OCR confidence gateway configuration."""
|
| 37 |
+
enabled: bool = Field(default=True, description="Enable confidence-based gateway")
|
| 38 |
+
threshold: float = Field(default=0.85, ge=0.0, le=1.0, description="Confidence threshold below which VLM correction triggers")
|
| 39 |
+
correction: OCRCorrectionConfig = Field(default_factory=OCRCorrectionConfig)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class AgentConfig(BaseModel):
|
| 43 |
+
"""Configuration for a specific agent in MathSolver."""
|
| 44 |
+
name: str = Field(..., description="Unique agent identifier")
|
| 45 |
+
description: Optional[str] = Field(default=None, description="Agent responsibility summary")
|
| 46 |
+
tiers: List[ModelTier] = Field(..., min_length=1, description="Cascading model tiers in execution priority")
|
| 47 |
+
temperature: float = Field(default=0.2, ge=0.0, le=2.0, description="Sampling temperature")
|
| 48 |
+
max_tokens: int = Field(default=8192, gt=0, description="Max output tokens")
|
| 49 |
+
timeout_seconds: int = Field(default=120, gt=0, description="Timeout in seconds")
|
| 50 |
+
reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
|
| 51 |
+
default=None, description="Reasoning effort for thinking models"
|
| 52 |
+
)
|
| 53 |
+
ocr_engine: Optional[Literal["vlm", "pix2text", "auto"]] = Field(
|
| 54 |
+
default="vlm", description="OCR engine: 'vlm' (default direct multimodal VLM) or 'pix2text' (local OCR)"
|
| 55 |
+
)
|
| 56 |
+
confidence_gateway: Optional[ConfidenceGatewayConfig] = Field(
|
| 57 |
+
default=None, description="OCR confidence gateway config (only for OCR agent)"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class RetryPolicyConfig(BaseModel):
|
| 62 |
+
"""Global retry policy configuration."""
|
| 63 |
+
retryable_errors: List[str] = Field(
|
| 64 |
+
default_factory=lambda: ["rate_limit", "timeout", "connection", "server_error"]
|
| 65 |
+
)
|
| 66 |
+
non_retryable_errors: List[str] = Field(
|
| 67 |
+
default_factory=lambda: ["invalid_request", "authentication"]
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class AgentModelsConfig(BaseModel):
|
| 72 |
+
"""Top-level agent models configuration schema."""
|
| 73 |
+
version: int = Field(default=1)
|
| 74 |
+
defaults: Dict[str, object] = Field(default_factory=dict)
|
| 75 |
+
retry_policy: RetryPolicyConfig = Field(default_factory=RetryPolicyConfig)
|
| 76 |
+
agents: Dict[str, AgentConfig] = Field(..., description="Mapping of agent names to their configurations")
|
config/settings.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
# Load from backend/.env if available
|
| 8 |
+
_env_path = Path(__file__).parents[1] / ".env"
|
| 9 |
+
if _env_path.exists():
|
| 10 |
+
load_dotenv(dotenv_path=_env_path)
|
| 11 |
+
else:
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ProviderCredentials(BaseModel):
|
| 17 |
+
provider: str
|
| 18 |
+
keys: List[str] = Field(default_factory=list)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_comma_separated_keys(raw: str) -> List[str]:
|
| 22 |
+
if not raw:
|
| 23 |
+
return []
|
| 24 |
+
keys = []
|
| 25 |
+
for piece in raw.split(","):
|
| 26 |
+
cleaned = piece.strip().strip("'\" ")
|
| 27 |
+
if cleaned:
|
| 28 |
+
keys.append(cleaned)
|
| 29 |
+
return keys
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Settings(BaseModel):
|
| 33 |
+
app_env: str = Field(default_factory=lambda: os.getenv("APP_ENV", "development"))
|
| 34 |
+
redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://localhost:6379/0"))
|
| 35 |
+
llm_timeout_seconds: int = Field(default_factory=lambda: int(os.getenv("LLM_TIMEOUT_SECONDS", "120")))
|
| 36 |
+
llm_cooldown_seconds: int = Field(default_factory=lambda: int(os.getenv("LLM_COOLDOWN_SECONDS", "60")))
|
| 37 |
+
default_chat_model: str = Field(default_factory=lambda: os.getenv("DEFAULT_CHAT_MODEL", "gemini/gemini-3.7-flash"))
|
| 38 |
+
|
| 39 |
+
def get_provider_credentials(self) -> Dict[str, ProviderCredentials]:
|
| 40 |
+
"""Parses API keys for all supported providers from environment variables."""
|
| 41 |
+
credentials: Dict[str, ProviderCredentials] = {}
|
| 42 |
+
|
| 43 |
+
# 1. Gemini / Google
|
| 44 |
+
gemini_raw = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or ""
|
| 45 |
+
gemini_keys = parse_comma_separated_keys(gemini_raw)
|
| 46 |
+
# Also check indexed keys GEMINI_API_KEY_1, GEMINI_API_KEY_2, etc.
|
| 47 |
+
for i in range(1, 20):
|
| 48 |
+
k = os.getenv(f"GEMINI_API_KEY_{i}") or os.getenv(f"GOOGLE_API_KEY_{i}")
|
| 49 |
+
if k and k.strip() and k.strip() not in gemini_keys:
|
| 50 |
+
gemini_keys.append(k.strip())
|
| 51 |
+
credentials["gemini"] = ProviderCredentials(provider="gemini", keys=gemini_keys)
|
| 52 |
+
|
| 53 |
+
# 2. OpenAI
|
| 54 |
+
openai_raw = os.getenv("OPENAI_API_KEY") or ""
|
| 55 |
+
openai_keys = parse_comma_separated_keys(openai_raw)
|
| 56 |
+
for i in range(1, 10):
|
| 57 |
+
k = os.getenv(f"OPENAI_API_KEY_{i}")
|
| 58 |
+
if k and k.strip() and k.strip() not in openai_keys:
|
| 59 |
+
openai_keys.append(k.strip())
|
| 60 |
+
credentials["openai"] = ProviderCredentials(provider="openai", keys=openai_keys)
|
| 61 |
+
|
| 62 |
+
# 3. Anthropic
|
| 63 |
+
anthropic_raw = os.getenv("ANTHROPIC_API_KEY") or ""
|
| 64 |
+
anthropic_keys = parse_comma_separated_keys(anthropic_raw)
|
| 65 |
+
credentials["anthropic"] = ProviderCredentials(provider="anthropic", keys=anthropic_keys)
|
| 66 |
+
|
| 67 |
+
# 4. OpenRouter
|
| 68 |
+
openrouter_raw = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY_1") or ""
|
| 69 |
+
openrouter_keys = parse_comma_separated_keys(openrouter_raw)
|
| 70 |
+
credentials["openrouter"] = ProviderCredentials(provider="openrouter", keys=openrouter_keys)
|
| 71 |
+
|
| 72 |
+
return credentials
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
settings = Settings()
|
dump.rdb
ADDED
|
Binary file (5.44 kB). View file
|
|
|
eval/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from eval.benchmark import BenchmarkDataset, BenchmarkSample
|
| 2 |
+
from eval.metrics import (
|
| 3 |
+
OCRMetrics,
|
| 4 |
+
ParserMetrics,
|
| 5 |
+
PipelineEvalSummary,
|
| 6 |
+
SolverMetrics,
|
| 7 |
+
compute_cer,
|
| 8 |
+
compute_wer,
|
| 9 |
+
latex_match,
|
| 10 |
+
)
|
| 11 |
+
from eval.runner import EvalRunner
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"BenchmarkDataset",
|
| 15 |
+
"BenchmarkSample",
|
| 16 |
+
"OCRMetrics",
|
| 17 |
+
"ParserMetrics",
|
| 18 |
+
"SolverMetrics",
|
| 19 |
+
"PipelineEvalSummary",
|
| 20 |
+
"EvalRunner",
|
| 21 |
+
"compute_cer",
|
| 22 |
+
"compute_wer",
|
| 23 |
+
"latex_match",
|
| 24 |
+
]
|
eval/benchmark.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Benchmark Dataset Models and Loader for MathSolver Evaluation.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class BenchmarkSample(BaseModel):
|
| 14 |
+
"""Evaluation sample definition representing a standardized geometry problem."""
|
| 15 |
+
id: str = Field(..., description="Unique sample identifier")
|
| 16 |
+
category: str = Field(default="geometry", description="Problem category: geometry, algebra, 3d, 2d")
|
| 17 |
+
image_url: Optional[str] = Field(default=None, description="Image URL if testing OCR")
|
| 18 |
+
problem_text: str = Field(..., description="Canonical Vietnamese/LaTeX problem statement")
|
| 19 |
+
expected_type: Optional[str] = Field(default=None, description="Expected shape type (e.g. pyramid, cube)")
|
| 20 |
+
expected_entities: Optional[List[str]] = Field(default=None, description="Expected primary entities")
|
| 21 |
+
expected_dsl: Optional[str] = Field(default=None, description="Reference Geometry DSL")
|
| 22 |
+
expected_answer: Optional[str] = Field(default=None, description="Ground-truth final answer / value")
|
| 23 |
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional reference annotations")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BenchmarkDataset:
|
| 27 |
+
"""Benchmark dataset container."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, samples: List[BenchmarkSample]):
|
| 30 |
+
self.samples = samples
|
| 31 |
+
|
| 32 |
+
def __len__(self) -> int:
|
| 33 |
+
return len(self.samples)
|
| 34 |
+
|
| 35 |
+
def __iter__(self):
|
| 36 |
+
return iter(self.samples)
|
| 37 |
+
|
| 38 |
+
@classmethod
|
| 39 |
+
def from_file(cls, path: str | Path) -> "BenchmarkDataset":
|
| 40 |
+
"""Loads benchmark samples from a JSON file."""
|
| 41 |
+
file_path = Path(path)
|
| 42 |
+
if not file_path.exists():
|
| 43 |
+
raise FileNotFoundError(f"Benchmark file not found: {file_path}")
|
| 44 |
+
|
| 45 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 46 |
+
data = json.load(f)
|
| 47 |
+
|
| 48 |
+
if isinstance(data, list):
|
| 49 |
+
samples = [BenchmarkSample(**item) for item in data]
|
| 50 |
+
elif isinstance(data, dict) and "samples" in data:
|
| 51 |
+
samples = [BenchmarkSample(**item) for item in data["samples"]]
|
| 52 |
+
else:
|
| 53 |
+
raise ValueError(f"Unrecognized benchmark dataset format in {file_path}")
|
| 54 |
+
|
| 55 |
+
return cls(samples)
|
| 56 |
+
|
| 57 |
+
@classmethod
|
| 58 |
+
def load_all_standard(cls, base_dir: Optional[Path] = None) -> "BenchmarkDataset":
|
| 59 |
+
"""Loads all JSON files under eval/datasets/."""
|
| 60 |
+
if base_dir is None:
|
| 61 |
+
base_dir = Path(__file__).parent / "datasets"
|
| 62 |
+
|
| 63 |
+
all_samples: List[BenchmarkSample] = []
|
| 64 |
+
for json_file in base_dir.rglob("*.json"):
|
| 65 |
+
try:
|
| 66 |
+
ds = cls.from_file(json_file)
|
| 67 |
+
all_samples.extend(ds.samples)
|
| 68 |
+
except Exception:
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
return cls(all_samples)
|
eval/datasets/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MathSolver Benchmark Datasets
|
| 2 |
+
|
| 3 |
+
Standardized evaluation benchmarks for regression testing, metric tracking, and ablation studies.
|
| 4 |
+
|
| 5 |
+
## Dataset Structure
|
| 6 |
+
|
| 7 |
+
Each JSON file in `eval/datasets/` contains problem samples adhering to the `BenchmarkSample` schema:
|
| 8 |
+
|
| 9 |
+
```json
|
| 10 |
+
{
|
| 11 |
+
"id": "geo_01_square_pyramid",
|
| 12 |
+
"category": "3d_pyramid",
|
| 13 |
+
"problem_text": "Cho hình chóp S.ABCD...",
|
| 14 |
+
"expected_type": "pyramid",
|
| 15 |
+
"expected_entities": ["S", "A", "B", "C", "D"],
|
| 16 |
+
"expected_dsl": "PYRAMID(S_ABCD)\nSQUARE(ABCD)...",
|
| 17 |
+
"expected_answer": "32"
|
| 18 |
+
}
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## Running Evaluation
|
| 22 |
+
|
| 23 |
+
To evaluate deterministic DSL solvability & geometry validator pass rates:
|
| 24 |
+
|
| 25 |
+
```python
|
| 26 |
+
from eval.benchmark import BenchmarkDataset
|
| 27 |
+
from eval.runner import EvalRunner
|
| 28 |
+
|
| 29 |
+
dataset = BenchmarkDataset.load_all_standard()
|
| 30 |
+
runner = EvalRunner()
|
| 31 |
+
metrics = runner.evaluate_dsl_deterministic(dataset)
|
| 32 |
+
print(metrics.to_dict())
|
| 33 |
+
```
|