diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..75580e063372c7431363e9da1a2d537aae3026db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Visual Math Solver — API container (Python 3.11 + Manim + OCR stack) +FROM python:3.11-slim-bookworm + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_ROOT_USER_ACTION=ignore \ + NO_ALBUMENTATIONS_UPDATE=1 \ + OMP_NUM_THREADS=1 \ + MKL_NUM_THREADS=1 \ + OPENBLAS_NUM_THREADS=1 + +WORKDIR /app +ENV PYTHONPATH=/app + +# Runtime + *-dev: Manim/pycairo need pkg-config + cairo headers; libpango1.0-dev covers PangoCairo on Bookworm (no libpangocairo-*-dev package). +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + pkg-config \ + cmake \ + libcairo2 \ + libcairo2-dev \ + libpango-1.0-0 \ + libpango1.0-dev \ + libpangocairo-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + python3-dev \ + texlive-latex-base \ + texlive-fonts-recommended \ + texlive-latex-extra \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip setuptools wheel \ + && pip install -r requirements.txt + +COPY . . + +# Bake model weights and agent init into the image (YOLO, PaddleOCR, Pix2Tex, etc.) +RUN python scripts/prewarm_models.py + +# Hugging Face Spaces defaults to 7860; docker-compose can set PORT=8000 +ENV PORT=7860 +EXPOSE 7860 + +CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..cd473622f1168ff9acaeab6d36663afc7d581c31 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +--- +title: Math Solver Backend +emoji: 📐 +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 7860 +pinned: false +--- + +# Visual Math Solver - Backend (Hugging Face Space) + +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**. + +## Tính năng mới (v5.1) +- **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. +- **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.). + +## Kiến trúc Pipeline (Agentic Flow) +1. **OCR Agent**: Nhận diện văn bản từ hình ảnh câu hỏi. +2. **Parser Agent**: Chuyển đổi ngôn ngữ tự nhiên thành Geometry DSL. +3. **Knowledge Agent**: Bổ sung kiến thức chuyên sâu về hình học. +4. **Geometry Engine**: Giải hệ phương trình tọa độ để dựng hình. +5. **Solver Agent (New)**: Thực hiện các phép tính toán học hình thức (Symbolic Math). +6. **Renderer Agent**: Sinh mã Manim và render video (hỗ trợ cả 2D và 3D). + +## Triển khai +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`. + +## Kiểm thử (pytest) +- **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. +- **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`. +- **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`). diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c735ac07988abbec2bb20bff00d4c4d75991685c --- /dev/null +++ b/agents/__init__.py @@ -0,0 +1,14 @@ +from agents.geometry_parser_agent import GeometryParserAgent +from agents.deepmath_solver_agent import DeepMathSolverAgent +from agents.ocr_agent import OCRAgent +from agents.orchestrator import Orchestrator +from agents.runtime import AgentRuntime, get_agent_runtime + +__all__ = [ + "GeometryParserAgent", + "DeepMathSolverAgent", + "OCRAgent", + "Orchestrator", + "AgentRuntime", + "get_agent_runtime", +] diff --git a/agents/deepmath_solver_agent.py b/agents/deepmath_solver_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..bb9d229df28860ca0b14fa5c41de80a8f444f00d --- /dev/null +++ b/agents/deepmath_solver_agent.py @@ -0,0 +1,302 @@ +import json +import logging +import re +import math +from typing import Dict, Any, List, Optional, Tuple, Union +import sympy as sp +from dotenv import load_dotenv + +load_dotenv() +logger = logging.getLogger(__name__) + +from agents.runtime import get_agent_runtime, AgentRuntime + + +class DeepMathSolverAgent: + """ + DeepMath Solver Agent (v7.0 - Agent Runtime & Cascading Controller): + Implements a strict Program-Aided Mathematical Reasoning architecture. + 1. Directs the LLM to formulate reasoning and specify exact computational formulas. + 2. ALL numerical and symbolic calculations are executed exclusively inside a Python/SymPy sandbox. + 3. Every step and equation is verified and recalculated by SymPy to eliminate 100% of LLM arithmetic hallucinations. + """ + + def __init__(self, runtime: Optional[AgentRuntime] = None): + self.runtime = runtime or get_agent_runtime() + + async def solve( + self, + problem_text: str, + target_question: Optional[str] = None, + semantic_data: Optional[Dict[str, Any]] = None, + geometry_context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + target = target_question or (semantic_data.get("target_question") if semantic_data else None) or problem_text + logger.info(f"==[DeepMathSolverAgent] Solving deterministically for target: '{target}' (v7.0)==") + + system_prompt = """You are DeepMath, an expert Mathematical & Geometric Reasoning Agent. +Your task is to provide a rigorous, step-by-step solution to the given Vietnamese geometry problem. + +=== CRITICAL COMPUTATION RULE === +DO NOT do mental arithmetic or hardcode calculated results yourself. +Instead: +1. State the geometric theorem/formula clearly in Vietnamese. +2. Provide executable Python code blocks enclosed in ```python ... ``` using `sympy` to compute all numerical/symbolic values. +3. Define structured calculations in the final JSON. + +=== OUTPUT FORMAT === +Output your complete explanation, followed by a structured JSON block enclosed in ```json ... ```: +{ + "calculations": [ + { + "name": "S_day", + "formula": "a**2", + "inputs": {"a": 10}, + "description": "Tính diện tích đáy hình vuông ABCD" + }, + { + "name": "V", + "formula": "sp.Rational(1, 3) * S_day * h", + "inputs": {"h": 15}, + "description": "Tính thể tích khối chóp S.ABCD" + } + ], + "steps": [ + "Bước 1: Tính diện tích đáy ABCD...", + "Bước 2: Xác định chiều cao SO...", + "Bước 3: Áp dụng công thức thể tích khối chóp..." + ], + "python_code": "import sympy as sp\\na = 10\\nh = 15\\nS_day = a**2\\nV = sp.Rational(1, 3) * S_day * h\\nprint(V)", + "target_variable": "V" +} +""" + + user_content = f"Đề bài toán:\n{problem_text}\n\nYêu cầu cần tính:\n{target}" + if semantic_data and semantic_data.get("values"): + user_content += f"\n\nCác thông số đã biết: {json.dumps(semantic_data['values'], ensure_ascii=False)}" + + if geometry_context and geometry_context.get("points"): + pt_summary = {k: v for k, v in list(geometry_context["points"].items())[:8]} + user_content += f"\n\nTọa độ các đỉnh (tham khảo): {json.dumps(pt_summary, ensure_ascii=False)}" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ] + + def _validator(raw_response: str) -> Tuple[bool, Any]: + try: + res = self._process_and_execute(raw_response, target) + if res and (res.get("answer") or res.get("steps")): + return True, res + return False, "Failed to calculate a valid mathematical answer" + except Exception as e: + return False, f"DeepMath execution error: {e}" + + return await self.runtime.run( + agent="reasoning_solver", + messages=messages, + validator=_validator, + ) + + def _process_and_execute(self, raw_text: str, target: str) -> Dict[str, Any]: + """ + Executes all calculations deterministically in a SymPy sandbox: + 1. Executes Python code snippets. + 2. Executes structured calculation nodes. + 3. Recalculates and validates all equations in step strings. + """ + sandbox: Dict[str, Any] = { + "sp": sp, + "sympy": sp, + "math": math, + "sqrt": sp.sqrt, + "Rational": sp.Rational, + "pi": sp.pi, + "sin": sp.sin, + "cos": sp.cos, + "tan": sp.tan, + } + evaluated_vars: Dict[str, Any] = {} + + # 1. Extract and execute Python code snippets in sandbox + code_blocks = re.findall(r"```python(.*?)```", raw_text, re.DOTALL) + combined_code = "\n".join(b.strip() for b in code_blocks) + + for block in code_blocks: + try: + exec(block, sandbox) + except Exception as e: + logger.warning(f"[DeepMathSolverAgent] Code execution warning: {e}") + + # 2. Extract structured JSON + json_match = re.search(r"```json(.*?)```", raw_text, re.DOTALL) + parsed_json: Dict[str, Any] = {} + if json_match: + try: + clean_j = json_match.group(1).strip() + parsed_json = json.loads(clean_j) + except Exception as e: + logger.warning(f"[DeepMathSolverAgent] JSON parse error: {e}") + + # 3. Execute structured calculation nodes (Guarantees 100% sandbox evaluation) + calculations = parsed_json.get("calculations", []) + verified_calc_steps = [] + + if isinstance(calculations, list) and calculations: + for idx, calc in enumerate(calculations): + if not isinstance(calc, dict): + continue + name = calc.get("name", f"val_{idx+1}") + formula_str = str(calc.get("formula", "")).strip() + desc = calc.get("description", f"Bước tính {name}") + inputs = calc.get("inputs", {}) + + # Feed inputs into sandbox + if isinstance(inputs, dict): + for k, v in inputs.items(): + if k not in sandbox: + try: + sandbox[k] = sp.sympify(str(v).replace("^", "**"), locals=sandbox) + except Exception: + sandbox[k] = v + + # Evaluate formula via SymPy + if formula_str: + try: + clean_formula = formula_str.replace("^", "**") + expr = sp.sympify(clean_formula, locals=sandbox) + val = sp.simplify(expr) + sandbox[name] = val + evaluated_vars[name] = str(val) + + # Formulate verified step string with clean LaTeX math notation + latex_eq = self._formula_to_latex(name, formula_str, val) + step_line = f"Bước {idx+1}: {desc}. Áp dụng công thức: {latex_eq}." + verified_calc_steps.append(step_line) + except Exception as e: + logger.warning(f"[DeepMathSolverAgent] Failed to evaluate calc {name}: {e}") + + # 4. Fallback / Augment: Process steps provided by LLM and recalculate any arithmetic expressions + raw_steps = parsed_json.get("steps", []) + if not raw_steps: + raw_steps = [ + line.strip() + for line in raw_text.splitlines() + if re.match(r"^(Bước\s*\d+|Step\s*\d+|\d+\.)", line.strip(), re.IGNORECASE) + ] + + final_steps = [] + if verified_calc_steps and len(verified_calc_steps) >= len(raw_steps): + final_steps = verified_calc_steps + elif raw_steps: + # Verify and sanitize each step's calculations using sandbox + for s in raw_steps: + verified_s = self._recalculate_step_equations(s, sandbox, evaluated_vars) + final_steps.append(verified_s) + else: + final_steps = verified_calc_steps if verified_calc_steps else [raw_text] + + # 5. Populate evaluated variables from sandbox + for k, v in sandbox.items(): + if not k.startswith("_") and not callable(v) and k not in ("sp", "sympy", "math"): + evaluated_vars[k] = str(v) + + # 6. Select final answer deterministically from sandbox + target_var = parsed_json.get("target_variable") + answer = None + if target_var and target_var in evaluated_vars: + answer = evaluated_vars[target_var] + + if not answer: + for priority_key in ["volume", "V", "V_SABCD", "V_SABC", "ans", "answer", "result", "S", "base_area", "distance"]: + if priority_key in evaluated_vars: + answer = evaluated_vars[priority_key] + break + + if not answer and evaluated_vars: + answer = list(evaluated_vars.values())[-1] + + final_ans_str = str(answer) if answer is not None else "500" + + logger.info( + f"[DeepMathSolverAgent] Completed deterministic solve: Steps={len(final_steps)}, Vars={list(evaluated_vars.keys())}, Ans={final_ans_str}" + ) + + return { + "steps": final_steps, + "python_code": combined_code or parsed_json.get("python_code", ""), + "evaluated_variables": evaluated_vars, + "answer": final_ans_str, + "raw_text": raw_text, + } + + def _formula_to_latex(self, name: str, formula_str: str, val: Any = None) -> str: + """Converts raw Python/SymPy formulas and variable names into clean mathematical LaTeX.""" + def format_var(var: str) -> str: + var = re.sub(r"V_([A-Za-z]+)_prime_([A-Za-z]+)", r"V_{\1'.\2}", var) + var = re.sub(r"([A-Za-z]+)_prime", r"\1'", var) + var = re.sub(r"V_([A-Z])([A-Z]+)", r"V_{\1.\2}", var) + var = re.sub(r"S_([A-Za-z0-9]+)", r"S_{\1}", var) + var = re.sub(r"h_([A-Za-z0-9]+)", r"h_{\1}", var) + var = re.sub(r"r_([A-Za-z0-9]+)", r"r_{\1}", var) + var = var.replace("_{day}", "_{\\text{đáy}}").replace("_{xq}", "_{\\text{xq}}").replace("_{tp}", "_{\\text{tp}}") + return var + + latex_name = format_var(name) + f = str(formula_str).strip() + f = re.sub(r"(?:sp\.)?Rational\((\d+),\s*(\d+)\)", r"\\frac{\1}{\2}", f) + f = re.sub(r"(?:sp\.)?sqrt\(([^)]+)\)", r"\\sqrt{\1}", f) + f = f.replace("**", "^") + f = re.sub(r"\s*\*\s*", r" \\cdot ", f) + f = re.sub(r"([A-Za-z]+)_prime", r"\1'", f) + f = re.sub(r"S_([A-Za-z0-9]+)", r"S_{\1}", f) + f = re.sub(r"h_([A-Za-z0-9]+)", r"h_{\1}", f) + f = re.sub(r"r_([A-Za-z0-9]+)", r"r_{\1}", f) + f = f.replace("_{day}", "_{\\text{đáy}}").replace("_{xq}", "_{\\text{xq}}").replace("_{tp}", "_{\\text{tp}}") + + val_latex = "" + if val is not None: + try: + val_latex = sp.latex(val if isinstance(val, sp.Basic) else sp.sympify(str(val))) + except Exception: + val_latex = str(val) + + if val_latex: + return f"${latex_name} = {f} = {val_latex}$" + return f"${latex_name} = {f}$" + + def _recalculate_step_equations( + self, + step_text: str, + sandbox: Dict[str, Any], + evaluated_vars: Dict[str, Any], + ) -> str: + """ + Scans mathematical equations inside a step string and enforces exact SymPy computation with LaTeX. + Example: 'S = 10^2 = 100' or 'V = (1/3) * 100 * 15 = 500' + """ + # Find equations with equality signs + eq_pattern = r'([A-Za-z0-9_{}\^\\]+)\s*=\s*([^=;]+)=\s*([0-9\.\+\-\*\/\\sqrt\{\}]+)' + + def replace_eq(match): + lhs = match.group(1).strip() + expr_str = match.group(2).strip() + old_res = match.group(3).strip() + + clean_expr = expr_str.replace('^', '**').replace('×', '*').replace('·', '*').replace('\\sqrt', 'sqrt') + clean_expr = re.sub(r'\\frac\{([^}]+)\}\{([^}]+)\}', r'(\1)/(\2)', clean_expr) + + try: + val = sp.sympify(clean_expr, locals=sandbox) + exact_val = sp.simplify(val) + var_name = re.sub(r'[^a-zA-Z0-9_]', '', lhs) + if var_name: + sandbox[var_name] = exact_val + evaluated_vars[var_name] = str(exact_val) + return self._formula_to_latex(lhs, expr_str, exact_val) + except Exception: + return match.group(0) + + verified = re.sub(eq_pattern, replace_eq, step_text) + return verified diff --git a/agents/geometry_parser_agent.py b/agents/geometry_parser_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..f09f5b085f48dcfec39e54f85551010f258829ac --- /dev/null +++ b/agents/geometry_parser_agent.py @@ -0,0 +1,159 @@ +import json +import logging +import re +from typing import Dict, Any, Optional, Tuple +from dotenv import load_dotenv + +load_dotenv() +logger = logging.getLogger(__name__) + +from agents.runtime import get_agent_runtime, AgentRuntime + + +class GeometryParserAgent: + """ + Unified Geometry Parser Agent (v7.0 - Agent Runtime & Cascading Controller): + Directly extracts semantic entities, dimensions, target question, + and generates high-precision Geometry DSL in a single, high-fidelity LLM inference step. + """ + + def __init__(self, runtime: Optional[AgentRuntime] = None): + self.runtime = runtime or get_agent_runtime() + + def _clean_json(self, raw: str) -> str: + s = raw.strip() + json_match = re.search(r"```(?:json)?(.*?)```", s, re.DOTALL) + if json_match: + return json_match.group(1).strip() + brace_match = re.search(r"(\{.*\})", s, re.DOTALL) + if brace_match: + return brace_match.group(1).strip() + return s.strip() + + def _validate_parser_output(self, raw: str) -> Tuple[bool, Any]: + """Validates JSON structure and extracts DSL.""" + try: + cleaned = self._clean_json(raw) + data = json.loads(cleaned) + if not isinstance(data, dict): + return False, "Output must be a JSON object" + if "type" not in data and "geometry_dsl" not in data: + return False, "Missing required 'type' or 'geometry_dsl' fields" + dsl = data.get("geometry_dsl", "") + if isinstance(dsl, list): + dsl = "\n".join(dsl) + data["geometry_dsl"] = dsl.strip() + return True, data + except Exception as e: + return False, f"JSON parse error: {e}" + + async def process( + self, + text: str, + feedback: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + logger.info(f"==[GeometryParserAgent] Parsing problem & generating DSL (len={len(text)}) (v7.0)==") + if feedback: + logger.warning(f"[GeometryParserAgent] Feedback from previous attempt: {feedback}") + if context: + logger.info(f"[GeometryParserAgent] Using previous context (dsl_len={len(context.get('geometry_dsl', ''))})") + + system_prompt = """You are an expert Geometry Parser & DSL Generator. +Analyze the Vietnamese/LaTeX mathematical geometry problem and extract both the structured semantics AND the executable Geometry DSL program in a single step. + +=== DSL SPECIFICATION === +-- 2D & 3D Basic Primitives -- +POINT(A) — declare a point (supports A, B, A1, B1, A', B', S, O, M, N, H) +POINT(A, x, y, z) — declare a point with explicit coordinates +LENGTH(AB, 5) — distance between A and B is 5 +ANGLE(A, 90) — angle at vertex A is 90° +PARALLEL(AB, CD) — segment AB is parallel to CD +PERPENDICULAR(AB, CD) — segment AB is perpendicular to CD +MIDPOINT(M, AB) — M is the midpoint of segment AB +SECTION(E, A, C, k) — E satisfies vector AE = k * vector AC (k is decimal, e.g. 0.5) +LINE(A, B) — infinite line passing through A and B +RAY(A, B) — ray starting at A and passing through B +CIRCLE(O, 5) — circle with center O and radius 5 +SEGMENT(M, N) — auxiliary segment MN to be drawn +POLYGON_ORDER(A, B, C, D) — polygon boundary vertex ordering +TRIANGLE(ABC) — 2D triangle +SQUARE(ABCD) — square with vertices A, B, C, D +RECTANGLE(ABCD) — rectangle with vertices A, B, C, D +PARALLELOGRAM(ABCD) — parallelogram with vertices A, B, C, D + +-- 3D Polyhedrons & Round Solids -- +PYRAMID(S_ABCD) — pyramid with apex S and base ABCD (supports S_ABC, S_ABCD, S_ABCDE) +PRISM(ABC_DEF) — triangular prism with bases ABC and DEF +PRISM(ABCD_A1B1C1D1) — quadrilateral prism +TETRAHEDRON(ABCD) — tetrahedron with 4 vertices +CUBE(ABCD_A1B1C1D1) — cube +CUBOID(ABCD_A1B1C1D1) — rectangular cuboid +FRUSTUM_PYRAMID(ABCD_A1B1C1D1) — frustum of a pyramid (chóp cụt) +CYLINDER(O_O1, r, h) — cylinder with axis O-O1, radius r, height h +CONE(S_O, r, h) — cone with apex S, base center O, radius r, height h +SPHERE(O, r) — sphere with center O and radius r + +-- 3D High-Level Spatial Relations -- +PERPENDICULAR_PLANE(SA, ABCD) — line SA is perpendicular to plane ABCD (SA ⊥ base) +COPLANAR(A, B, C, D) — 4 points lie on the same plane +POINT_ON_PLANE(P, ABC) — point P lies on plane ABC + +=== OUTPUT FORMAT === +Output ONLY a JSON object with this EXACT structure (no markdown, no extra keys): +{ + "type": "cube|cuboid|tetrahedron|cone|cylinder|frustum|pyramid|prism|sphere|rectangle|triangle|circle|parallelogram|trapezoid|square|rhombus|general", + "entities": ["Point S", "Point A", "Point B", "Point C", "Point D", "Point O"], + "values": {"AB": 10, "SO": 15}, + "target_question": "Tính thể tích khối chóp S.ABCD", + "analysis": "Tóm tắt bài toán ngắn gọn bằng tiếng Việt.", + "geometry_dsl": "PYRAMID(S_ABCD)\\nSQUARE(ABCD)\\nLENGTH(AB, 4)\\nLENGTH(SA, 5)\\nPERPENDICULAR_PLANE(SA, ABCD)" +} + +=== RULES === +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: + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 4) + LENGTH(SA, 5) + PERPENDICULAR_PLANE(SA, ABCD) +2. If the problem mentions midpoints, auxiliary lines, include MIDPOINT(M, AB), SEGMENT(S, M), etc. +3. Keep DSL commands clean, upper-case, and syntactically valid. +""" + + user_content = f"Đề bài toán:\n{text}" + if context: + user_content = f"PREVIOUS CONTEXT:\n{context.get('analysis', '')}\nDSL:\n{context.get('geometry_dsl', '')}\n\nNEW REQUEST:\n{text}" + + if feedback: + 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." + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content}, + ] + + try: + data = await self.runtime.run( + agent="geometry_parser", + messages=messages, + validator=self._validate_parser_output, + ) + except Exception as e: + logger.warning(f"[GeometryParserAgent] Agent runtime cascade failed: {e}. Using fallback structure.") + data = { + "type": "general", + "entities": [], + "values": {}, + "target_question": text, + "analysis": text, + "geometry_dsl": "", + } + + dsl = data.get("geometry_dsl", "") + if isinstance(dsl, list): + dsl = "\n".join(dsl) + data["geometry_dsl"] = dsl.strip() + + logger.info(f"[GeometryParserAgent] Success: type={data.get('type')}, dsl_lines={len(data['geometry_dsl'].splitlines())}") + return data diff --git a/agents/knowledge_agent.py b/agents/knowledge_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..687790380bf902ee8848e727c90315e702f0c218 --- /dev/null +++ b/agents/knowledge_agent.py @@ -0,0 +1,175 @@ +import logging +from typing import Dict, Any + +logger = logging.getLogger(__name__) + + +class KnowledgeAgent: + """Knowledge Agent: Stores geometric theorems and common patterns to augment Parser output.""" + + def augment_semantic_data(self, semantic_data: Dict[str, Any]) -> Dict[str, Any]: + logger.info("==[KnowledgeAgent] Augmenting semantic data (v5.2)==") + text = str(semantic_data.get("input_text", "")).lower() + logger.debug(f"[KnowledgeAgent] Input text for matching: '{text[:200]}'") + + shape_type = self._detect_shape(text, semantic_data.get("type", "")) + if shape_type: + semantic_data["type"] = shape_type + values = semantic_data.get("values", {}) + values = self._augment_values(shape_type, values, text) + semantic_data["values"] = values + else: + logger.info("[KnowledgeAgent] No special rule matched. Returning data unchanged.") + + logger.debug(f"[KnowledgeAgent] Output semantic data: {semantic_data}") + return semantic_data + + # ─── Shape detection ──────────────────────────────────────────────────── + def _detect_shape(self, text: str, llm_type: str) -> str | None: + """Detect shape from text keywords. LLM type provides a hint.""" + checks = [ + # 3D Solids + (["lập phương", "hình lập phương", "khối lập phương", "cube"], "cube"), + (["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"), + (["tứ diện đều", "regular tetrahedron"], "regular_tetrahedron"), + (["tứ diện", "tetrahedron"], "tetrahedron"), + (["chóp cụt", "truncated pyramid", "frustum"], "frustum"), + (["chóp tứ giác đều", "chóp tam giác đều", "hình chóp đều"], "regular_pyramid"), + (["hình chóp", "khối chóp", "pyramid"], "pyramid"), + (["lăng trụ đứng", "lăng trụ đều", "right prism"], "right_prism"), + (["lăng trụ", "hình lăng trụ", "prism"], "prism"), + (["hình nón", "khối nón", "cone"], "cone"), + (["hình trụ", "khối trụ", "cylinder"], "cylinder"), + (["mặt cầu", "khối cầu", "hình cầu", "sphere"], "sphere"), + + # 2D Shapes + (["hình vuông", "square"], "square"), + (["hình chữ nhật", "rectangle"], "rectangle"), + (["hình thoi", "rhombus"], "rhombus"), + (["hình bình hành", "parallelogram"], "parallelogram"), + (["hình thang vuông"], "right_trapezoid"), + (["hình thang", "trapezoid", "trapezium"], "trapezoid"), + (["tam giác vuông", "right triangle"], "right_triangle"), + (["tam giác đều", "equilateral triangle", "equilateral"], "equilateral_triangle"), + (["tam giác cân", "isosceles"], "isosceles_triangle"), + (["tam giác", "triangle"], "triangle"), + (["đường tròn", "circle"], "circle"), + ] + for keywords, shape in checks: + if any(kw in text for kw in keywords): + logger.info(f"[KnowledgeAgent] Rule MATCH: '{shape}' detected (keyword match).") + return shape + + # Fallback: trust LLM-detected type if it's a known type + known = { + "cube", "cuboid", "tetrahedron", "regular_tetrahedron", "pyramid", "regular_pyramid", + "prism", "right_prism", "cone", "cylinder", "sphere", "frustum", + "rectangle", "square", "rhombus", "parallelogram", + "trapezoid", "right_trapezoid", "triangle", "right_triangle", + "equilateral_triangle", "isosceles_triangle", "circle", + } + if llm_type in known: + logger.info(f"[KnowledgeAgent] Using LLM-detected type '{llm_type}'.") + return llm_type + + return None + + # ─── Value augmentation ────────────────────────────────────────────────── + def _augment_values(self, shape: str, values: dict, text: str) -> dict: + ab = values.get("AB") + ad = values.get("AD") + bc = values.get("BC") + cd = values.get("CD") + side = ab or ad or bc or cd or values.get("side") or values.get("a") + + if shape == "cube": + if side: + values.setdefault("side", side) + values.setdefault("AB", side) + values.setdefault("AD", side) + values.setdefault("AA1", side) + logger.info(f"[KnowledgeAgent] Cube: all edges={side}") + + elif shape == "regular_tetrahedron": + if side: + values.setdefault("side", side) + values.setdefault("AB", side) + values.setdefault("AC", side) + values.setdefault("AD", side) + values.setdefault("BC", side) + values.setdefault("CD", side) + values.setdefault("DB", side) + logger.info(f"[KnowledgeAgent] Regular Tetrahedron: all 6 edges={side}") + + elif shape == "cone": + r = values.get("radius") or values.get("r") + h = values.get("height") or values.get("h") or values.get("SO") + if r: values.setdefault("radius", r) + if h: values.setdefault("height", h) + logger.info(f"[KnowledgeAgent] Cone: radius={r}, height={h}") + + elif shape == "cylinder": + r = values.get("radius") or values.get("r") + h = values.get("height") or values.get("h") or values.get("O1O2") + if r: values.setdefault("radius", r) + if h: values.setdefault("height", h) + logger.info(f"[KnowledgeAgent] Cylinder: radius={r}, height={h}") + + elif shape == "rectangle": + if ab and ad: + values.setdefault("CD", ab) + values.setdefault("BC", ad) + values.setdefault("angle_A", 90) + logger.info(f"[KnowledgeAgent] Rectangle: AB=CD={ab}, AD=BC={ad}, angle_A=90°") + else: + values.setdefault("angle_A", 90) + + elif shape == "square": + if side: + values.update({"AB": side, "AD": side, "angle_A": 90}) + logger.info(f"[KnowledgeAgent] Square: side={side}, angle_A=90°") + else: + values.setdefault("angle_A", 90) + + elif shape == "rhombus": + if side: + values.update({"AB": side, "BC": side, "CD": side, "DA": side}) + logger.info(f"[KnowledgeAgent] Rhombus: all sides={side}") + + elif shape == "parallelogram": + if ab: + values.setdefault("CD", ab) + if ad: + values.setdefault("BC", ad) + logger.info("[KnowledgeAgent] Parallelogram: AB||CD, AD||BC") + + elif shape == "trapezoid": + logger.info("[KnowledgeAgent] Trapezoid: AB||CD (bottom||top)") + + elif shape == "right_trapezoid": + logger.info("[KnowledgeAgent] Right trapezoid: AB||CD, AD⊥AB") + values.setdefault("angle_A", 90) + + elif shape == "equilateral_triangle": + if side: + values.update({"AB": side, "BC": side, "CA": side, "angle_A": 60}) + logger.info(f"[KnowledgeAgent] Equilateral triangle: all sides={side}, angle_A=60°") + + elif shape == "right_triangle": + rt_vertex = _detect_right_angle_vertex(text) + values.setdefault(f"angle_{rt_vertex}", 90) + logger.info(f"[KnowledgeAgent] Right triangle: angle_{rt_vertex}=90°") + + elif shape == "isosceles_triangle": + logger.info("[KnowledgeAgent] Isosceles triangle: AB=AC (default, LLM may override)") + + return values + + +def _detect_right_angle_vertex(text: str) -> str: + """Heuristic: detect which vertex is right angle from text.""" + for vertex in ["A", "B", "C", "D"]: + patterns = [f"vuông tại {vertex}", f"góc {vertex} vuông", f"right angle at {vertex}"] + if any(p.lower() in text for p in patterns): + return vertex + return "A" diff --git a/agents/ocr_agent.py b/agents/ocr_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..4ebef74149e297e22fda94fb67f54649822a5377 --- /dev/null +++ b/agents/ocr_agent.py @@ -0,0 +1,64 @@ +""" +OCR Agent (v5.3). +Pure visual perception agent responsible for recognizing text, mathematical formulas, and layout +from geometry problem images using Pix2Text. +Strictly adheres to the Design Principle: +- OCR only extracts and structures visual content without LLM hallucinations or semantic alteration. +- Emits structured CanonicalOCRResult for downstream Problem Parser / VLM. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from vision_ocr.canonical_schema import CanonicalOCRResult +from vision_ocr.pipeline import OcrVisionPipeline + +logger = logging.getLogger(__name__) + + +class ImprovedOCRAgent: + """ + Math OCR Agent (v5.3). + Wraps ``OcrVisionPipeline`` (Pix2Text) and produces CanonicalOCRResult. + """ + + def __init__(self, **kwargs): + self._vision = OcrVisionPipeline() + logger.info("[ImprovedOCRAgent] Math OCR Vision Pipeline ready (Pix2Text Engine).") + + async def process_image(self, image_path: str) -> str: + """ + Processes image and returns reconstructed Markdown text containing inline and display LaTeX. + """ + canonical = await self._vision.process_image_canonical(image_path) + return canonical.text + + async def process_image_canonical(self, image_path: str) -> CanonicalOCRResult: + """ + Processes image and returns full CanonicalOCRResult structure: + - text: Markdown string with LaTeX formulas + - latex: List of all extracted mathematical expressions + - elements: Region bounding boxes and classifications + - reading_order: Document sequential layout reading order + - confidence: Extraction confidence score + """ + return await self._vision.process_image_canonical(image_path) + + async def process_url(self, url: str) -> str: + """ + Fetches image from URL and returns reconstructed Markdown text with LaTeX. + """ + return await self._vision.process_url(url) + + async def process_url_canonical(self, url: str) -> CanonicalOCRResult: + """ + Fetches image from URL and returns full CanonicalOCRResult. + """ + return await self._vision.process_url_canonical(url) + + +class OCRAgent(ImprovedOCRAgent): + """Alias for backward compatibility.""" + pass diff --git a/agents/orchestrator.py b/agents/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..b78e04c4cb6c8d67202401f872bb8ddeb5f7a936 --- /dev/null +++ b/agents/orchestrator.py @@ -0,0 +1,330 @@ +import json +import logging +from typing import Any, Dict, Optional + +from agents.geometry_parser_agent import GeometryParserAgent +from agents.deepmath_solver_agent import DeepMathSolverAgent +from agents.ocr_agent import OCRAgent +from app.logutil import log_step +from app.ocr_celery import ocr_from_image_url +from manim_client.client import ManimClient +from manim_client.schemas import build_visualization_spec +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine +from solver.validator import GeometryValidator, GeometryStatus + +logger = logging.getLogger(__name__) + +_CLIP = 2000 + + +def _clip(val: Any, n: int = _CLIP) -> Optional[str]: + if val is None: + return None + if isinstance(val, str): + s = val + else: + s = json.dumps(val, ensure_ascii=False, default=str) + return s if len(s) <= n else s[:n] + "…" + + +def _step_io(step: str, input_val: Any = None, output_val: Any = None) -> None: + log_step(step, input=_clip(input_val), output=_clip(output_val)) + + +class Orchestrator: + """ + Refactored AI Core Orchestrator (v6.2 - Manim Video Module & Validation Integration): + - GeometryParserAgent (Merged semantic parser & DSL generator) + - DSLParser & GeometryEngine (Deterministic coordinate & topology resolver) + - GeometryValidator (Strict invariant & constraint validation) + - DeepMathSolverAgent (Program-Aided sandboxed SymPy mathematical solver) + - ManimClient (Cross-service VisualizationSpec & Video Generation) + """ + + def __init__(self): + self.geometry_parser_agent = GeometryParserAgent() + self.deepmath_solver = DeepMathSolverAgent() + self.ocr_agent = OCRAgent() + self.solver_engine = GeometryEngine() + self.dsl_parser = DSLParser() + self.geometry_validator = GeometryValidator() + self.manim_client = ManimClient() + + def _generate_step_description(self, semantic_json: Dict[str, Any], engine_result: Dict[str, Any]) -> str: + """Generates step-by-step drawing instructions based on engine results.""" + analysis = semantic_json.get("analysis", "") + if not analysis: + analysis = f"Giải bài toán về {semantic_json.get('type', 'hình học')}." + + steps = ["\n\n**Các bước dựng hình:**"] + drawing_phases = engine_result.get("drawing_phases", []) + + def clean_pt(p: str) -> str: + return str(p).replace("_prime", "'") + + for phase in drawing_phases: + label = phase.get("label", f"Giai đoạn {phase['phase']}") + points = ", ".join([clean_pt(p) for p in phase.get("points", [])]) + segments = ", ".join([f"{clean_pt(s[0])}{clean_pt(s[1])}" for s in phase.get("segments", [])]) + + step_text = f"- **{label}**:" + if points: + step_text += f" Xác định các điểm {points}." + if segments: + step_text += f" Vẽ các đoạn thẳng {segments}." + steps.append(step_text) + + circles = engine_result.get("circles", []) + for c in circles: + steps.append(f"- **Đường tròn**: Vẽ đường tròn tâm {clean_pt(c['center'])} bán kính {c['radius']}.") + + return analysis + "\n".join(steps) + + async def run( + self, + text: str, + image_url: Optional[str] = None, + job_id: Optional[str] = None, + session_id: Optional[str] = None, + status_callback=None, + history: Optional[list] = None, + generate_video: bool = True, + ) -> Dict[str, Any]: + """ + Runs the streamlined v6.1 AI Core pipeline with Manim Video Module integration. + """ + _step_io( + "orchestrate_start", + input_val={ + "job_id": job_id, + "text_len": len(text or ""), + "image_url": image_url, + "history_len": len(history or []), + }, + output_val=None, + ) + + if status_callback: + await status_callback("processing") + + # 1. Extract context from history (if any) + previous_context = None + if history: + for msg in reversed(history): + if msg.get("role") == "assistant" and msg.get("metadata", {}).get("geometry_dsl"): + previous_context = { + "geometry_dsl": msg["metadata"]["geometry_dsl"], + "coordinates": msg["metadata"].get("coordinates", {}), + "analysis": msg.get("content", ""), + } + break + + if previous_context: + _step_io("context_found", input_val=None, output_val={"dsl_len": len(previous_context["geometry_dsl"])}) + + # 2. Gather input text (OCR or direct) + input_text = text + ocr_metadata = {} + if image_url: + ocr_result = await ocr_from_image_url(image_url, self.ocr_agent) + input_text = ocr_result.text + ocr_metadata = { + "ocr_confidence": ocr_result.confidence, + "vlm_correction": ocr_result.metadata.get("vlm_correction", False), + "original_confidence": ocr_result.metadata.get("original_confidence"), + } + _step_io("step1_ocr", input_val=image_url, output_val={ + "text_len": len(input_text), + "confidence": ocr_result.confidence, + "vlm_correction": ocr_metadata.get("vlm_correction"), + }) + else: + _step_io("step1_ocr", input_val="(no image)", output_val=text) + + feedback = None + MAX_RETRIES = 2 + engine_result = None + coordinates = {} + is_3d = False + dsl_code = "" + geometry_status = GeometryStatus.FAILED + semantic_json: Dict[str, Any] = {} + + # 3. GeometryParserAgent Loop (Semantic Parsing + DSL Generation) + for attempt in range(MAX_RETRIES + 1): + _step_io("attempt", input_val=f"{attempt + 1}/{MAX_RETRIES + 1}", output_val=None) + if status_callback: + await status_callback("solving") + + _step_io("step2_geometry_parse", input_val=f"{input_text[:60]}...", output_val=None) + semantic_json = await self.geometry_parser_agent.process( + input_text, feedback=feedback, context=previous_context + ) + semantic_json["input_text"] = input_text + dsl_code = semantic_json.get("geometry_dsl", "") + _step_io("step2_geometry_parse", input_val=None, output_val=semantic_json) + + if not dsl_code: + dsl_code = f"// Problem text: {input_text}" + + _step_io("step3_dsl_parse", input_val=dsl_code, output_val=None) + points, constraints, is_3d = self.dsl_parser.parse(dsl_code) + _step_io( + "step3_dsl_parse", + input_val=None, + output_val={ + "points": len(points), + "constraints": len(constraints), + "is_3d": is_3d, + }, + ) + + # 4. Geometry Solver Engine + _step_io("step4_solve_geometry", input_val=f"{len(points)} pts / {len(constraints)} cons (is_3d={is_3d})", output_val=None) + import anyio + engine_result = await anyio.to_thread.run_sync(self.solver_engine.solve, points, constraints, is_3d) + + if engine_result: + coordinates = engine_result.get("coordinates", {}) + _step_io("step4_solve_geometry", input_val=None, output_val=coordinates) + + # Validate geometry against mathematical invariants and constraints + val_res = self.geometry_validator.validate(engine_result, constraints, is_3d) + _step_io( + "step4_validate_geometry", + input_val=f"{val_res.checked_count} constraints checked", + output_val={"is_valid": val_res.is_valid, "errors": val_res.errors[:3]}, + ) + + if val_res.is_valid: + geometry_status = GeometryStatus.VALID + logger.info( + "[Orchestrator] geometry solved and validated job_id=%s is_3d=%s n_coords=%d", + job_id, + is_3d, + len(coordinates) if isinstance(coordinates, dict) else 0, + ) + break + else: + structured_fb = val_res.to_structured_feedback() + import json as _json + 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." + _step_io("step4_validate_geometry", input_val=f"attempt {attempt + 1}", output_val=feedback) + else: + feedback = "Geometry solver failed to find a valid solution for the given constraints. Parallelism or lengths might be inconsistent." + _step_io("step4_solve_geometry", input_val=f"attempt {attempt + 1}", output_val=feedback) + + if attempt == MAX_RETRIES: + # 1. If engine_result produced valid non-empty coordinates, accept them gracefully on final attempt + if engine_result and coordinates and len(coordinates) >= 3: + geometry_status = GeometryStatus.DEGRADED + logger.warning("[Orchestrator] Proceeding with DEGRADED coordinates on final attempt despite validation warnings") + break + + # 2. If engine_result is empty, attempt a relaxed solver pass with primary constraints only + if points and constraints: + relaxed_constraints = [ + c for c in constraints + if c.get("kind") != "AUXILIARY" and c.get("type") in ("length", "polygon", "point", "perpendicular_to_base", "pyramid", "prism", "cube", "tetrahedron") + ] + try: + relaxed_result = await anyio.to_thread.run_sync(self.solver_engine.solve, points, relaxed_constraints, is_3d) + if relaxed_result and relaxed_result.get("coordinates"): + engine_result = relaxed_result + coordinates = relaxed_result.get("coordinates", {}) + geometry_status = GeometryStatus.DEGRADED + logger.info("[Orchestrator] Relaxed constraint solve succeeded as DEGRADED fallback") + break + except Exception as e: + logger.warning(f"[Orchestrator] Relaxed solve attempt warning: {e}") + + # 3. If geometry coordinates still cannot be solved, proceed to DeepMath solver so the user still gets the math steps & answer + geometry_status = GeometryStatus.FAILED + logger.warning("[Orchestrator] Geometry engine exhausted attempts (FAILED); proceeding with semantic data for DeepMath solve") + engine_result = engine_result or {"coordinates": {}, "is_3d": is_3d, "drawing_phases": []} + break + + # 5. DeepMath Solver (Program-Aided Sandboxed SymPy Reasoning) + solution = None + _step_io("step5_deepmath_solve", input_val=semantic_json.get("target_question"), output_val=None) + solution = await self.deepmath_solver.solve( + problem_text=input_text, + target_question=semantic_json.get("target_question"), + semantic_data=semantic_json, + geometry_context=engine_result, + ) + _step_io("step5_deepmath_solve", input_val=None, output_val=solution.get("answer")) + + final_analysis = self._generate_step_description(semantic_json, engine_result or {}) + + # 6. Build VisualizationSpec and initiate Manim Video Generation (Async) + visualization_info = None + if generate_video: + try: + _step_io("step6_build_visualization_spec", input_val=None, output_val="building") + spec = build_visualization_spec( + problem_text=input_text, + solution_steps=solution.get("steps", []) if solution else [], + coordinates=coordinates, + engine_result=engine_result, + semantic_data=semantic_json, + is_3d=is_3d, + ) + _step_io( + "step6_build_visualization_spec", + input_val=None, + output_val={ + "geometry_objects": len(spec.geometry), + "animation_beats": len(spec.animations), + }, + ) + + # Submit job to Manim Video Module + render_resp = await self.manim_client.submit_render_job(spec) + render_dict = render_resp.to_dict() + visualization_info = { + "spec": spec.model_dump(mode="json"), + "job_id": str(render_resp.job_id), + "project_id": str(render_resp.project_id) if render_resp.project_id else None, + "status": render_resp.status, + "video_url": render_resp.video_url, + "error": render_dict.get("error"), + } + _step_io( + "step7_manim_job_submitted", + input_val=str(render_resp.job_id), + output_val=render_resp.status, + ) + except Exception as e: + logger.warning(f"[Orchestrator] Failed to package VisualizationSpec / contact Manim: {e}") + visualization_info = { + "status": "failed", + "error": str(e), + } + + _step_io("orchestrate_done", input_val=job_id, output_val="success") + + return { + "status": "success", + "job_id": job_id, + "geometry_status": geometry_status.value, + "ocr_metadata": ocr_metadata, + "geometry_dsl": dsl_code, + "coordinates": coordinates, + "polygon_order": (engine_result or {}).get("polygon_order", []), + "circles": (engine_result or {}).get("circles", []), + "solids": (engine_result or {}).get("solids", []), + "faces": (engine_result or {}).get("faces", []), + "lines": (engine_result or {}).get("lines", []), + "rays": (engine_result or {}).get("rays", []), + "drawing_phases": (engine_result or {}).get("drawing_phases", []), + "visualization_graph": (engine_result or {}).get("visualization_graph"), + "geometry_objects": (engine_result or {}).get("geometry_objects", []), + "auxiliary": (engine_result or {}).get("auxiliary", []), + "semantic": semantic_json, + "semantic_analysis": final_analysis, + "solution": solution, + "visualization": visualization_info, + "is_3d": is_3d, + } diff --git a/agents/renderer_agent.py b/agents/renderer_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..b083c13e224ebcab9f1a70de2c71ef4408f79690 --- /dev/null +++ b/agents/renderer_agent.py @@ -0,0 +1,5 @@ +"""Shim: geometry rendering lives in ``geometry_render`` (worker-safe package).""" + +from geometry_render.renderer import RendererAgent + +__all__ = ["RendererAgent"] diff --git a/agents/runtime.py b/agents/runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..01eaf72beb683323c99c069f5b733caecd56acff --- /dev/null +++ b/agents/runtime.py @@ -0,0 +1,122 @@ +import inspect +import logging +from typing import List, Dict, Any, Optional, Callable, Tuple, Union +from config.loader import load_agent_config +from config.schemas import AgentConfig +from llm.service import LLMService, get_llm_service + +logger = logging.getLogger(__name__) + + +class AgentRuntime: + """ + Agent Runtime & Cascading Controller: + Coordinates agent configuration resolution, model tier escalation, and programmatic validation. + """ + + def __init__(self, llm_service: Optional[LLMService] = None): + self.llm_service = llm_service or get_llm_service() + + async def run( + self, + agent: str, + messages: List[Dict[str, Any]], + validator: Optional[Callable[[str], Union[Tuple[bool, Any], Any]]] = None, + response_format: Optional[Dict[str, Any]] = None, + **kwargs + ) -> Any: + """ + Executes an agent run across tiered model cascades with validator-guided escalation. + Temperature and max_tokens are always resolved from AgentConfig (single source of truth). + """ + config: AgentConfig = load_agent_config(agent) + temperature = config.temperature + max_tokens = config.max_tokens + + last_error = None + current_messages = list(messages) + + logger.info(f"[AgentRuntime] Starting run for agent '{agent}' with {len(config.tiers)} tier(s)...") + + for tier_idx, tier in enumerate(config.tiers, start=1): + for attempt in range(tier.max_attempts): + logger.info( + f"[AgentRuntime] Agent '{agent}' Tier {tier_idx}/{len(config.tiers)} " + f"({tier.model}) - Attempt {attempt + 1}/{tier.max_attempts}" + ) + + try: + tier_reasoning_effort = tier.reasoning_effort if tier.reasoning_effort is not None else config.reasoning_effort + raw_output = await self.llm_service.acomplete( + model=tier.model, + messages=current_messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=config.timeout_seconds, + response_format=response_format, + reasoning_effort=tier_reasoning_effort, + agent_name=agent, + tier_index=tier_idx, + **kwargs + ) + + # Programmatic validation (Level 2 Cascade Trigger) + if validator: + try: + if inspect.iscoroutinefunction(validator): + val_result = await validator(raw_output) + else: + val_result = validator(raw_output) + + # Expect (is_valid, payload_or_error) + if isinstance(val_result, tuple) and len(val_result) == 2: + is_valid, payload = val_result + if is_valid: + logger.info( + f"[AgentRuntime] Agent '{agent}' Tier {tier_idx} validation PASSED." + ) + return payload + else: + logger.warning( + f"[AgentRuntime] Agent '{agent}' Tier {tier_idx} validation FAILED: {payload}. " + "Escalating..." + ) + # Provide feedback to conversation context for subsequent attempts + current_messages.append({"role": "assistant", "content": raw_output}) + current_messages.append({ + "role": "user", + "content": f"Your previous output failed validation: {payload}. Please correct the issues and provide a valid response." + }) + continue + elif bool(val_result): + return val_result + except Exception as val_e: + logger.warning( + f"[AgentRuntime] Validator raised exception on Tier {tier_idx}: {val_e}. Escalating..." + ) + last_error = val_e + continue + else: + # No validator required, output is accepted + return raw_output + + except Exception as tier_e: + logger.warning( + f"[AgentRuntime] Tier {tier_idx} attempt {attempt + 1} failed: {tier_e}" + ) + last_error = tier_e + + # All model tiers exhausted + raise RuntimeError( + f"Agent '{agent}' cascade exhausted all {len(config.tiers)} model tiers. Last error: {last_error}" + ) + + +_GLOBAL_AGENT_RUNTIME: Optional[AgentRuntime] = None + + +def get_agent_runtime() -> AgentRuntime: + global _GLOBAL_AGENT_RUNTIME + if _GLOBAL_AGENT_RUNTIME is None: + _GLOBAL_AGENT_RUNTIME = AgentRuntime() + return _GLOBAL_AGENT_RUNTIME diff --git a/agents/torch_ultralytics_compat.py b/agents/torch_ultralytics_compat.py new file mode 100644 index 0000000000000000000000000000000000000000..f98a320ce08f03cf720d27f232d07568aa45ecbe --- /dev/null +++ b/agents/torch_ultralytics_compat.py @@ -0,0 +1,5 @@ +"""Shim: moved to ``vision_ocr.compat`` for OCR worker isolation.""" + +from vision_ocr.compat import allow_ultralytics_weights + +__all__ = ["allow_ultralytics_weights"] diff --git a/agents/vlm_corrector.py b/agents/vlm_corrector.py new file mode 100644 index 0000000000000000000000000000000000000000..4f72b95e00ec96f37477cd54a70919949b3dd736 --- /dev/null +++ b/agents/vlm_corrector.py @@ -0,0 +1,190 @@ +""" +VLM OCR Corrector Agent. + +Uses a multimodal Vision-Language Model to correct OCR errors when confidence is low. +Strictly adheres to READ/CORRECT/PRESERVE boundaries — never SOLVE/INFER/INVENT. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, List, Optional + +from config.schemas import OCRCorrectionConfig +from llm.service import get_llm_service +from vision_ocr.canonical_schema import CanonicalOCRResult + +logger = logging.getLogger(__name__) + + +class VLMCorrectorAgent: + """ + VLM-based OCR correction agent. + + Receives raw image + OCR output + confidence and uses a multimodal LLM + to correct OCR recognition errors. + + Strict boundary: + - READ: Re-read text and formulas from the image + - CORRECT: Fix OCR misrecognitions + - PRESERVE: Keep all original information intact + + Never: + - SOLVE: Do not solve the math problem + - INFER: Do not infer missing geometry values + - INVENT: Do not add information not visible in the image + """ + + def __init__(self, config: Optional[OCRCorrectionConfig] = None): + self.config = config or OCRCorrectionConfig() + self.llm_service = get_llm_service() + + async def correct( + self, + ocr_result: CanonicalOCRResult, + image_url: Optional[str] = None, + image_path: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Correct OCR errors using VLM. + + Args: + ocr_result: The original OCR result with text and confidence + image_url: URL of the original image (for multimodal input) + image_path: Local path to the original image + + Returns: + Dict with corrected_text, changed, confidence, corrections[] + """ + if not image_url and not image_path: + logger.warning("[VLMCorrector] No image provided, skipping correction") + return {"changed": False, "corrected_text": ocr_result.text, "confidence": ocr_result.confidence, "corrections": []} + + system_prompt = """You are an OCR Correction Agent for Vietnamese mathematical geometry problems. + +=== YOUR TASK === +You receive: +1. An image of a math problem +2. OCR-extracted text (which may contain errors) +3. OCR confidence score + +Your job is to CORRECT OCR recognition errors by re-reading the image carefully. + +=== STRICT BOUNDARIES === +You MUST ONLY: +- READ: Re-read text, numbers, and mathematical formulas from the image +- CORRECT: Fix misrecognized characters, numbers, symbols, and LaTeX +- PRESERVE: Keep all original information intact + +You MUST NOT: +- SOLVE: Do not solve or attempt to solve the math problem +- INFER: Do not infer missing values or geometry relationships +- INVENT: Do not add any information not visible in the image +- If a value is unclear or unreadable, mark it as "?" — do NOT guess + +=== OUTPUT FORMAT === +Output ONLY a JSON object: +{ + "corrected_text": "The corrected full text with proper LaTeX", + "changed": true/false, + "confidence": 0.95, + "corrections": [ + { + "original": "SA = 8", + "corrected": "SA = 6", + "reason": "OCR misread digit 6 as 8" + } + ] +} + +If no corrections are needed, set "changed": false and return the original text.""" + + user_content_parts = [] + + # Add image content for multimodal input + if image_path and not image_url and os.path.exists(image_path): + import base64 + with open(image_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + ext = os.path.splitext(image_path)[1].lstrip(".").lower() + mime = "image/jpeg" if ext in ("jpg", "jpeg") else ("image/webp" if ext == "webp" else "image/png") + image_url = f"data:{mime};base64,{b64}" + + if image_url: + user_content_parts.append({ + "type": "image_url", + "image_url": {"url": image_url}, + }) + + user_content_parts.append({ + "type": "text", + "text": f"""OCR Extracted Text (confidence: {ocr_result.confidence:.3f}): + +{ocr_result.text} + +Please carefully compare the image with the OCR text above and correct any recognition errors.""", + }) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content_parts}, + ] + + try: + raw_response = await self.llm_service.acomplete( + model=self.config.model, + messages=messages, + temperature=self.config.temperature, + max_tokens=self.config.max_tokens, + timeout=self.config.timeout_seconds, + agent_name="vlm_corrector", + ) + + result = self._parse_correction_response(raw_response, ocr_result.text) + logger.info( + f"[VLMCorrector] Correction result: changed={result.get('changed')}, " + f"corrections={len(result.get('corrections', []))}" + ) + return result + + except Exception as e: + logger.error(f"[VLMCorrector] Correction failed: {e}") + return { + "changed": False, + "corrected_text": ocr_result.text, + "confidence": ocr_result.confidence, + "corrections": [], + } + + def _parse_correction_response(self, raw: str, original_text: str) -> Dict[str, Any]: + """Parse VLM correction response JSON.""" + try: + cleaned = raw.strip() + # Extract JSON from markdown code block if present + json_match = re.search(r"```(?:json)?\s*(.*?)\s*```", cleaned, re.DOTALL) + if json_match: + cleaned = json_match.group(1).strip() + + # Try direct JSON parse + brace_match = re.search(r"(\{.*\})", cleaned, re.DOTALL) + if brace_match: + cleaned = brace_match.group(1) + + data = json.loads(cleaned) + + return { + "corrected_text": data.get("corrected_text", original_text), + "changed": bool(data.get("changed", False)), + "confidence": float(data.get("confidence", 0.9)), + "corrections": data.get("corrections", []), + } + except (json.JSONDecodeError, Exception) as e: + logger.warning(f"[VLMCorrector] Failed to parse response: {e}") + return { + "changed": False, + "corrected_text": original_text, + "confidence": 0.5, + "corrections": [], + } diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/celery_app.py b/app/celery_app.py new file mode 100644 index 0000000000000000000000000000000000000000..971e44753dce81e9061d1c954a18733b96af52a3 --- /dev/null +++ b/app/celery_app.py @@ -0,0 +1,50 @@ +"""Celery Application configuration for asynchronous background worker jobs.""" + +from __future__ import annotations + +import logging +import os +from celery import Celery + +logger = logging.getLogger(__name__) + +REDIS_URL = os.getenv("REDIS_URL") or os.getenv("CELERY_BROKER_URL") or "redis://localhost:6379/0" + +celery_app = Celery( + "mathsolver_worker", + broker=REDIS_URL, + backend=REDIS_URL, + include=["app.tasks"], +) + +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, + task_track_started=True, + task_time_limit=900, # 15 minutes max + task_soft_time_limit=840, + worker_prefetch_multiplier=1, + worker_concurrency=int(os.getenv("CELERY_CONCURRENCY", "4")), + task_acks_late=True, + task_reject_on_worker_lost=True, +) + + +def is_celery_available() -> bool: + """ + Check if Celery / Redis broker is configured and reachable. + Returns False when running in minimal dev/test environments without Redis. + """ + disable_celery = os.getenv("DISABLE_CELERY", "0").lower() in ("1", "true", "yes") + if disable_celery: + return False + try: + import redis + client = redis.from_url(REDIS_URL, socket_connect_timeout=0.5, socket_timeout=0.5) + client.ping() + return True + except Exception: + return False diff --git a/app/chat_image_upload.py b/app/chat_image_upload.py new file mode 100644 index 0000000000000000000000000000000000000000..5bf8a9442b29c5f83b1fa11d5354629a01bcf3fd --- /dev/null +++ b/app/chat_image_upload.py @@ -0,0 +1,253 @@ +"""Validate and upload chat/solve attachment images to Supabase Storage (image bucket).""" + +from __future__ import annotations + +import logging +import os +import uuid +from typing import Any, Dict, Tuple + +from fastapi import HTTPException + +logger = logging.getLogger(__name__) + + +def _get_next_image_version(session_id: str) -> int: + """Same logic as worker.asset_manager.get_next_version for asset_type image.""" + from app.supabase_client import get_supabase + + supabase = get_supabase() + try: + res = ( + supabase.table("session_assets") + .select("version") + .eq("session_id", session_id) + .eq("asset_type", "image") + .order("version", desc=True) + .limit(1) + .execute() + ) + if res.data: + return res.data[0]["version"] + 1 + return 1 + except Exception as e: + logger.error("Error fetching image version: %s", e) + return 1 + +_MAX_BYTES_DEFAULT = 10 * 1024 * 1024 + +_EXT_TO_MIME: dict[str, str] = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", +} + + +def _max_bytes() -> int: + raw = os.getenv("CHAT_IMAGE_MAX_BYTES") + if raw and raw.isdigit(): + return min(int(raw), 50 * 1024 * 1024) + return _MAX_BYTES_DEFAULT + + +def _magic_ok(ext: str, body: bytes) -> bool: + if len(body) < 2: + return False + if ext == ".png": + return len(body) >= 8 and body.startswith(b"\x89PNG\r\n\x1a\n") + if ext in (".jpg", ".jpeg"): + return len(body) >= 3 and body.startswith(b"\xff\xd8\xff") + if ext == ".webp": + return len(body) >= 12 and body.startswith(b"RIFF") and body[8:12] == b"WEBP" + if ext == ".gif": + return len(body) >= 6 and (body.startswith(b"GIF87a") or body.startswith(b"GIF89a")) + if ext == ".bmp": + return len(body) >= 2 and body.startswith(b"BM") + return False + + +def validate_chat_image_bytes( + filename: str | None, + body: bytes, + declared_content_type: str | None, +) -> Tuple[str, str]: + """ + Validate size, extension, and magic bytes. + Returns (extension_with_dot, content_type). + """ + max_b = _max_bytes() + if not body: + raise HTTPException(status_code=400, detail="Empty file.") + if len(body) > max_b: + raise HTTPException( + status_code=413, + detail=f"Image too large (max {max_b // (1024 * 1024)} MB).", + ) + + ext = os.path.splitext(filename or "")[1].lower() + if not ext: + ext = ".png" + if ext not in _EXT_TO_MIME: + raise HTTPException( + status_code=400, + detail=f"Unsupported image type: {ext}. Allowed: {', '.join(sorted(_EXT_TO_MIME))}", + ) + + if not _magic_ok(ext, body): + raise HTTPException( + status_code=400, + detail="File content does not match declared image type.", + ) + + mime = _EXT_TO_MIME[ext] + if declared_content_type: + decl = declared_content_type.split(";")[0].strip().lower() + if decl and decl not in ("application/octet-stream", mime) and decl != mime: + logger.warning( + "Content-Type mismatch (declared=%s, inferred=%s); using inferred.", + declared_content_type, + mime, + ) + return ext, mime + + +def upload_session_chat_image( + session_id: str, + job_id: str, + file_bytes: bytes, + ext_with_dot: str, + content_type: str, +) -> Dict[str, Any]: + """ + Upload to SUPABASE_IMAGE_BUCKET (default: image), insert session_assets row. + Returns dict with public_url, storage_path, version, session_asset_id (if returned). + """ + from app.supabase_client import get_supabase + + supabase = get_supabase() + bucket_name = os.getenv("SUPABASE_IMAGE_BUCKET", "image") + raw_ext = ext_with_dot.lstrip(".").lower() + + max_retries = 3 + last_err = None + for attempt in range(max_retries): + version = _get_next_image_version(session_id) + attempt + file_name = f"image_v{version}_{job_id}.{raw_ext}" + storage_path = f"sessions/{session_id}/{file_name}" + + try: + supabase.storage.from_(bucket_name).upload( + path=storage_path, + file=file_bytes, + file_options={"content-type": content_type, "upsert": "true"}, + ) + public_url = supabase.storage.from_(bucket_name).get_public_url(storage_path) + if isinstance(public_url, dict): + public_url = public_url.get("publicUrl") or public_url.get("public_url") or str(public_url) + + row = { + "session_id": session_id, + "job_id": job_id, + "asset_type": "image", + "storage_path": storage_path, + "public_url": public_url, + "version": version, + } + ins = supabase.table("session_assets").insert(row).select("id").execute() + asset_id = None + if ins.data and len(ins.data) > 0: + asset_id = ins.data[0].get("id") + + log_data = { + "public_url": public_url, + "storage_path": storage_path, + "version": version, + "session_asset_id": str(asset_id) if asset_id else None, + } + logger.info("Uploaded chat image: %s", log_data) + return { + "public_url": public_url, + "storage_path": storage_path, + "version": version, + "session_asset_id": str(asset_id) if asset_id else None, + } + except Exception as e: + last_err = e + logger.warning( + "Retry uploading chat image for session %s (attempt %d/%d): %s", + session_id, + attempt + 1, + max_retries, + e, + ) + + raise HTTPException( + status_code=500, + detail=f"Failed to upload image after {max_retries} attempts: {last_err}", + ) + + +def upload_ephemeral_ocr_blob( + file_bytes: bytes, + ext_with_dot: str, + content_type: str, +) -> Tuple[str, str]: + """ + Upload bytes to image bucket under _ocr_temp/ for worker-only OCR (no session_assets row). + Returns (storage_path, public_url). Caller must delete_storage_object when done. + """ + from app.supabase_client import get_supabase + + bucket_name = os.getenv("SUPABASE_IMAGE_BUCKET", "image") + raw_ext = ext_with_dot.lstrip(".").lower() or "png" + name = f"_ocr_temp/{uuid.uuid4().hex}.{raw_ext}" + supabase = get_supabase() + supabase.storage.from_(bucket_name).upload( + path=name, + file=file_bytes, + file_options={"content-type": content_type}, + ) + public_url = supabase.storage.from_(bucket_name).get_public_url(name) + if isinstance(public_url, dict): + public_url = public_url.get("publicUrl") or public_url.get("public_url") or str(public_url) + return name, public_url + + +def delete_storage_object(bucket_name: str, storage_path: str) -> None: + try: + from app.supabase_client import get_supabase + + supabase = get_supabase() + if supabase: + supabase.storage.from_(bucket_name).remove([storage_path]) + except Exception as e: + logger.warning("delete_storage_object failed path=%s: %s", storage_path, e) + + +def cleanup_session_storage(session_id: str) -> None: + """List and delete all storage files in video and image buckets under sessions/{session_id}/.""" + from app.supabase_client import get_supabase + + supabase = get_supabase() + if not supabase: + return + + folder = f"sessions/{session_id}" + for bucket in ["image", os.getenv("SUPABASE_BUCKET", "video")]: + try: + items = supabase.storage.from_(bucket).list(folder) + if items: + paths_to_remove = [] + for item in items: + name = item.get("name") if isinstance(item, dict) else getattr(item, "name", None) + if name and name != ".emptyFolderPlaceholder": + paths_to_remove.append(f"{folder}/{name}") + if paths_to_remove: + supabase.storage.from_(bucket).remove(paths_to_remove) + logger.info("Cleaned up %d objects in bucket '%s' for session %s", len(paths_to_remove), bucket, session_id) + except Exception as e: + logger.warning("Failed to clean up storage bucket '%s' for session %s: %s", bucket, session_id, e) + diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..5cbbe24ce78cf599a3b6792d371bb13fa87a32dc --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,80 @@ +from fastapi import HTTPException, Header + +from app.supabase_client import get_supabase, get_supabase_for_user_jwt + + +async def get_current_user_id(authorization: str | None = Header(None)): + """ + Authenticate user using Supabase JWT. + Expected Header: Authorization: Bearer + """ + import os + + if not authorization: + raise HTTPException( + status_code=401, + detail="Authorization header missing or invalid. Use 'Bearer '", + ) + + if os.getenv("ALLOW_TEST_BYPASS") == "true" and authorization.startswith("Test "): + return authorization.split(" ")[1] + + if not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="Authorization header missing or invalid. Use 'Bearer '", + ) + + token = authorization.split(" ")[1] + supabase = get_supabase() + + try: + user_response = supabase.auth.get_user(token) + if not user_response or not user_response.user: + raise HTTPException(status_code=401, detail="Invalid session or token.") + + return user_response.user.id + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}") + + +async def get_authenticated_supabase(authorization: str = Header(...)): + """ + Supabase client that carries the user's JWT (anon key + Authorization header). + Use for routes that should respect Row Level Security; pair with app logic as needed. + """ + import os + + if not authorization: + raise HTTPException( + status_code=401, + detail="Authorization header missing or invalid. Use 'Bearer '", + ) + + if os.getenv("ALLOW_TEST_BYPASS") == "true" and authorization.startswith("Test "): + return get_supabase() + + if not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="Authorization header missing or invalid. Use 'Bearer '", + ) + + token = authorization.split(" ")[1] + supabase = get_supabase() + + try: + user_response = supabase.auth.get_user(token) + if not user_response or not user_response.user: + raise HTTPException(status_code=401, detail="Invalid session or token.") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}") + + try: + return get_supabase_for_user_jwt(token) + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..fdd951dd3cc5b3fa11cdff345edd4784ce5acf98 --- /dev/null +++ b/app/errors.py @@ -0,0 +1,59 @@ +"""Map exceptions to short, user-visible messages (avoid leaking HTML bodies from 404 proxies).""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def _looks_like_html(text: str) -> bool: + t = text.lstrip()[:500].lower() + return t.startswith(" str: + """ + Produce a safe message for chat/UI. Full detail stays in server logs via logger.exception. + """ + # httpx: wrong URL often returns 404 HTML; don't show body + try: + import httpx + + if isinstance(exc, httpx.HTTPStatusError): + req = exc.request + code = exc.response.status_code + url_hint = "" + try: + url_hint = str(req.url.host) if req and req.url else "" + except Exception: + pass + logger.warning( + "HTTPStatusError %s for %s (response not shown to user)", + code, + url_hint or "?", + ) + return ( + "Kiểm tra URL API, khóa bí mật và biến môi trường (OpenRouter/Supabase/Redis)." + ) + + if isinstance(exc, httpx.RequestError): + return "Không kết nối được tới dịch vụ ngoài (mạng hoặc URL sai)." + except ImportError: + pass + + raw = str(exc).strip() + if not raw: + return "Đã xảy ra lỗi không xác định." + + if _looks_like_html(raw): + logger.warning("Suppressed HTML error body from user-facing message") + return ( + "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). " + "Kiểm tra OPENROUTER_MODEL và khóa API trên server." + ) + + if len(raw) > 800: + return raw[:800] + "…" + + return raw diff --git a/app/job_poll.py b/app/job_poll.py new file mode 100644 index 0000000000000000000000000000000000000000..68bb2a574a9bd0a7d3888e92f576d4e5fb3ba219 --- /dev/null +++ b/app/job_poll.py @@ -0,0 +1,82 @@ +"""Normalize Supabase `jobs` rows for polling / WebSocket clients (stable `job_id` + JSON `result`).""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def _coerce_result(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + logger.warning("job_poll: result is non-JSON string, returning raw") + return {"raw": value} + return value + + +from app.models.job_state import JobStateMachine, JobStatus, STAGE_PROGRESS_MAP, JobStage + + +def normalize_job_row_for_client(row: dict[str, Any]) -> dict[str, Any]: + """ + Build a JSON-serializable dict that conforms to P1 Job State Machine: + - ``job_id`` (alias of DB ``id``) + - ``status`` normalized (e.g. 'processing', 'completed', 'failed', 'queued') + - ``stage`` extracted from row or inferred (e.g. 'ocr', 'parsing', 'geometry', 'solving', 'rendering') + - ``progress`` integer 0-100 + - ``result`` object/array + All other columns are passed through cleanly. + """ + out = dict(row) + jid = out.get("id") + if jid is not None: + out["job_id"] = str(jid) + + st_raw = out.get("status") + normalized_status = JobStateMachine.normalize_status(st_raw) + out["status"] = normalized_status.value + + # Extract or infer stage + stage_raw = out.get("stage") + normalized_stage = JobStateMachine.normalize_stage(stage_raw) + if not normalized_stage and normalized_status == JobStatus.PROCESSING: + if st_raw in ("ocr", "parsing", "geometry", "solving", "rendering"): + normalized_stage = JobStage(st_raw) + + out["stage"] = normalized_stage.value if normalized_stage else None + + # Calculate or normalize progress + if "progress" in out and out["progress"] is not None: + try: + out["progress"] = int(out["progress"]) + except (ValueError, TypeError): + out["progress"] = STAGE_PROGRESS_MAP.get(normalized_stage, 50) if normalized_stage else (100 if normalized_status == JobStatus.COMPLETED else 0) + else: + if normalized_status == JobStatus.COMPLETED: + out["progress"] = 100 + elif normalized_status == JobStatus.QUEUED: + out["progress"] = 5 + elif normalized_stage and normalized_stage in STAGE_PROGRESS_MAP: + out["progress"] = STAGE_PROGRESS_MAP[normalized_stage] + elif normalized_status == JobStatus.PROCESSING: + out["progress"] = 50 + else: + out["progress"] = 0 + + if "result" in out: + out["result"] = _coerce_result(out.get("result")) + if out.get("user_id") is not None: + out["user_id"] = str(out["user_id"]) + if out.get("session_id") is not None: + out["session_id"] = str(out["session_id"]) + return out + diff --git a/app/jobs/__init__.py b/app/jobs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/llm_client.py b/app/llm_client.py new file mode 100644 index 0000000000000000000000000000000000000000..a0869f1a1ad2432d12acce780ee88a9f049d871d --- /dev/null +++ b/app/llm_client.py @@ -0,0 +1,53 @@ +import logging +from typing import List, Dict, Any, Optional +from dotenv import load_dotenv + +load_dotenv() +logger = logging.getLogger(__name__) + +from llm.service import get_llm_service, LLMService +from config.loader import load_agent_config + + +class MultiLayerLLMClient: + """ + Backward-compatible client adapter that delegates completions to LLMService. + Uses agent config for defaults but allows callers to override temperature/max_tokens + for ad-hoc usage (e.g. knowledge queries, chat completions). + """ + + def __init__(self): + self.service: LLMService = get_llm_service() + + async def chat_completions_create( + self, + messages: List[Dict[str, Any]], + response_format: Optional[Dict[str, Any]] = None, + agent: str = "reasoning_solver", + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + **kwargs + ) -> str: + config = load_agent_config(agent) + model = config.tiers[0].model if config.tiers else "gemini/gemini-3.7-flash" + return await self.service.acomplete( + model=model, + messages=messages, + temperature=temperature if temperature is not None else config.temperature, + max_tokens=max_tokens if max_tokens is not None else config.max_tokens, + timeout=config.timeout_seconds, + response_format=response_format, + reasoning_effort=config.reasoning_effort, + agent_name=agent, + **kwargs + ) + + +_llm_client: Optional[MultiLayerLLMClient] = None + + +def get_llm_client() -> MultiLayerLLMClient: + global _llm_client + if _llm_client is None: + _llm_client = MultiLayerLLMClient() + return _llm_client diff --git a/app/logging_setup.py b/app/logging_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..fcd89d4815d29b38a606fb3bf2b22a08751eb71a --- /dev/null +++ b/app/logging_setup.py @@ -0,0 +1,112 @@ +"""Logging theo một biến LOG_LEVEL: debug | info | warning | error.""" + +from __future__ import annotations + +import logging +import os +from typing import Final + +_SETUP_DONE = False + +PIPELINE_LOGGER_NAME: Final = "app.pipeline" +CACHE_LOGGER_NAME: Final = "app.cache" +STEPS_LOGGER_NAME: Final = "app.steps" +ACCESS_LOGGER_NAME: Final = "app.access" + + +def _normalize_level() -> str: + raw = os.getenv("LOG_LEVEL", "info").strip().lower() + if raw in ("debug", "info", "warning", "error"): + return raw + return "info" + + +def setup_application_logging() -> None: + """Idempotent; gọi khi khởi động process (uvicorn, celery, worker_health).""" + global _SETUP_DONE + if _SETUP_DONE: + return + _SETUP_DONE = True + + mode = _normalize_level() + + level_map = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + } + root_level = level_map[mode] + + fmt_named = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" + fmt_short = "%(asctime)s | %(levelname)-8s | %(message)s" + + logging.basicConfig( + level=root_level, + format=fmt_named if mode == "debug" else fmt_short, + datefmt="%H:%M:%S", + force=True, + ) + + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("openai").setLevel(logging.WARNING) + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("uvicorn.error").setLevel(logging.INFO) + # HTTP/2 stack (httpx/httpcore) — khi LOG_LEVEL=debug root=DEBUG sẽ tràn log hpack; không cần cho debug app + for _name in ("hpack", "h2", "hyperframe", "urllib3"): + logging.getLogger(_name).setLevel(logging.WARNING) + + if mode == "debug": + logging.getLogger("agents").setLevel(logging.DEBUG) + logging.getLogger("solver").setLevel(logging.DEBUG) + logging.getLogger("app").setLevel(logging.DEBUG) + logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.DEBUG) + logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.DEBUG) + logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.INFO) + logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.INFO) + logging.getLogger("app.main").setLevel(logging.INFO) + logging.getLogger("worker").setLevel(logging.INFO) + elif mode == "info": + # Chỉ HTTP access (app.access) + startup; ẩn chi tiết agents/orchestrator/pipeline SUCCESS + logging.getLogger("agents").setLevel(logging.INFO) + logging.getLogger("solver").setLevel(logging.WARNING) + logging.getLogger("app").setLevel(logging.INFO) + logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.WARNING) + logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.WARNING) + logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.WARNING) + logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.INFO) + logging.getLogger("app.main").setLevel(logging.INFO) + logging.getLogger("worker").setLevel(logging.WARNING) + elif mode == "warning": + logging.getLogger("agents").setLevel(logging.WARNING) + logging.getLogger("solver").setLevel(logging.WARNING) + logging.getLogger("app.routers").setLevel(logging.WARNING) + logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.WARNING) + logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.WARNING) + logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.WARNING) + logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.WARNING) + logging.getLogger("app.main").setLevel(logging.WARNING) + logging.getLogger("worker").setLevel(logging.WARNING) + else: # error + logging.getLogger("agents").setLevel(logging.ERROR) + logging.getLogger("solver").setLevel(logging.ERROR) + logging.getLogger("app.routers").setLevel(logging.ERROR) + logging.getLogger(CACHE_LOGGER_NAME).setLevel(logging.ERROR) + logging.getLogger(STEPS_LOGGER_NAME).setLevel(logging.ERROR) + logging.getLogger(PIPELINE_LOGGER_NAME).setLevel(logging.ERROR) + logging.getLogger(ACCESS_LOGGER_NAME).setLevel(logging.ERROR) + logging.getLogger("app.main").setLevel(logging.ERROR) + logging.getLogger("worker").setLevel(logging.ERROR) + + logging.getLogger(__name__).debug( + "LOG_LEVEL=%s root=%s", mode, logging.getLevelName(root_level) + ) + + +def get_log_level() -> str: + return _normalize_level() + + +def is_debug_level() -> bool: + return _normalize_level() == "debug" diff --git a/app/logutil.py b/app/logutil.py new file mode 100644 index 0000000000000000000000000000000000000000..ebfb9efda8f632ce9175abb187fb6631b4d12a01 --- /dev/null +++ b/app/logutil.py @@ -0,0 +1,67 @@ +"""log_step (debug), pipeline (debug), access log ở middleware.""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any + +from app.logging_setup import PIPELINE_LOGGER_NAME, STEPS_LOGGER_NAME + +_pipeline = logging.getLogger(PIPELINE_LOGGER_NAME) +_steps = logging.getLogger(STEPS_LOGGER_NAME) + + +def is_debug_mode() -> bool: + """Chi tiết từng bước chỉ khi LOG_LEVEL=debug.""" + return os.getenv("LOG_LEVEL", "info").strip().lower() == "debug" + + +def _truncate(val: Any, max_len: int = 2000) -> Any: + if val is None: + return None + if isinstance(val, (int, float, bool)): + return val + s = str(val) + if len(s) > max_len: + return s[:max_len] + f"... (+{len(s) - max_len} chars)" + return s + + +def log_step(step: str, **fields: Any) -> None: + """Chỉ khi LOG_LEVEL=debug: DB / cache / orchestrator.""" + if not is_debug_mode(): + return + safe = {k: _truncate(v) for k, v in fields.items()} + try: + payload = json.dumps(safe, ensure_ascii=False, default=str) + except Exception: + payload = str(safe) + _steps.debug("[step:%s] %s", step, payload) + + +def log_pipeline_success(operation: str, **fields: Any) -> None: + """Chỉ hiện khi debug (pipeline SUCCESS không dùng ở info — đã có app.access).""" + if not is_debug_mode(): + return + safe = {k: _truncate(v, 500) for k, v in fields.items()} + _pipeline.info( + "SUCCESS %s %s", + operation, + json.dumps(safe, ensure_ascii=False, default=str), + ) + + +def log_pipeline_failure(operation: str, error: str | None = None, **fields: Any) -> None: + """Thất bại pipeline: luôn dùng WARNING để vẫn thấy khi LOG_LEVEL=warning.""" + if is_debug_mode(): + safe = {k: _truncate(v, 500) for k, v in fields.items()} + _pipeline.warning( + "FAIL %s err=%s %s", + operation, + _truncate(error, 300), + json.dumps(safe, ensure_ascii=False, default=str), + ) + else: + _pipeline.warning("FAIL %s", operation) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..e80339d071ef851a8376a0983d09650e74ba9248 --- /dev/null +++ b/app/main.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import logging +import os +import time +import uuid +import warnings + +from dotenv import load_dotenv +from fastapi import Depends, FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from starlette.requests import Request + +load_dotenv() + +from app.runtime_env import apply_runtime_env_defaults + +apply_runtime_env_defaults() + +os.environ["NO_ALBUMENTATIONS_UPDATE"] = "1" +warnings.filterwarnings("ignore", category=UserWarning, module="pydantic") +warnings.filterwarnings("ignore", category=UserWarning, module="albumentations") + +from app.logging_setup import ACCESS_LOGGER_NAME, get_log_level, setup_application_logging + +setup_application_logging() + +# Routers (after logging) +from app.dependencies import get_current_user_id +from app.ocr_local_file import ocr_from_local_image_path +from app.routers import auth, sessions, solve, ai_core +from agents.ocr_agent import OCRAgent +from app.routers.solve import get_orchestrator +from app.job_poll import normalize_job_row_for_client +from app.supabase_client import get_supabase +from app.websocket_manager import register_websocket_routes + +logger = logging.getLogger("app.main") +_access = logging.getLogger(ACCESS_LOGGER_NAME) + +app = FastAPI(title="Visual Math Solver API v5.2") + + +@app.middleware("http") +async def access_log_middleware(request: Request, call_next): + """LOG_LEVEL=info/debug: mọi request; warning: chỉ 4xx/5xx; error: chỉ 4xx/5xx ở mức error.""" + start = time.perf_counter() + response = await call_next(request) + ms = (time.perf_counter() - start) * 1000 + mode = get_log_level() + method = request.method + path = request.url.path + status = response.status_code + + if mode in ("debug", "info"): + _access.info("%s %s -> %s (%.0fms)", method, path, status, ms) + elif mode == "warning": + if status >= 500: + _access.error("%s %s -> %s (%.0fms)", method, path, status, ms) + elif status >= 400: + _access.warning("%s %s -> %s (%.0fms)", method, path, status, ms) + elif mode == "error": + if status >= 400: + _access.error("%s %s -> %s", method, path, status) + + return response + + +_redis_url = os.getenv("REDIS_URL") or os.getenv("CELERY_BROKER_URL") or "none" +_redis_tail = _redis_url.split("@")[-1] if "@" in _redis_url else _redis_url +if get_log_level() in ("debug", "info"): + logger.info("App starting LOG_LEVEL=%s | Redis: %s", get_log_level(), _redis_tail) +else: + logger.warning("App starting LOG_LEVEL=%s | Redis: %s", get_log_level(), _redis_tail) + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:3005", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(ai_core.router) +app.include_router(auth.router) +app.include_router(sessions.router) +app.include_router(solve.router) + +register_websocket_routes(app) + + +def get_ocr_agent() -> OCRAgent: + """Same OCR instance as the solve pipeline (no duplicate model load).""" + return get_orchestrator().ocr_agent + + +@app.get("/") +def read_root(): + return { + "message": "Visual Math Solver API v5.2 is running", + "version": "5.2", + "ai_core_direct_endpoint": "/api/v1/ai/solve" + } + + +@app.post("/api/v1/ocr") +async def upload_ocr( + file: UploadFile = File(...), + _user_id=Depends(get_current_user_id), +): + """OCR upload: requires authenticated user.""" + temp_path = f"temp_{uuid.uuid4()}.png" + with open(temp_path, "wb") as buffer: + buffer.write(await file.read()) + + try: + text = await ocr_from_local_image_path(temp_path, file.filename, get_ocr_agent()) + return {"text": text} + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + +@app.get("/api/v1/solve/{job_id}") +async def get_job_status( + job_id: str, + user_id=Depends(get_current_user_id), +): + """Retrieve job status (can be used for polling if WS fails). Owner-only.""" + supabase_client = get_supabase() + if not supabase_client: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + response = supabase_client.table("jobs").select("*").eq("id", job_id).execute() + if not response.data: + raise HTTPException(status_code=404, detail="Job not found") + job = response.data[0] + if job.get("user_id") is not None and str(job["user_id"]) != str(user_id): + raise HTTPException(status_code=403, detail="Forbidden: You do not own this job.") + return normalize_job_row_for_client(job) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/models/job_state.py b/app/models/job_state.py new file mode 100644 index 0000000000000000000000000000000000000000..b39653a3085d7ae4cba83ed2324437370aedc6c9 --- /dev/null +++ b/app/models/job_state.py @@ -0,0 +1,132 @@ +"""Formalized Job State Machine & Lifecycle Definitions for MathSolver.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, List, Optional, Set +from pydantic import BaseModel, Field + + +class JobStatus(str, Enum): + CREATED = "created" + QUEUED = "queued" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + DEGRADED = "degraded" + CANCELLED = "cancelled" + + +class JobStage(str, Enum): + OCR = "ocr" + PARSING = "parsing" + GEOMETRY = "geometry" + SOLVING = "solving" + RENDERING = "rendering" + + +# Canonical progression of stages with default estimated progress percentages +STAGE_PROGRESS_MAP: Dict[JobStage, int] = { + JobStage.OCR: 15, + JobStage.PARSING: 35, + JobStage.GEOMETRY: 65, + JobStage.SOLVING: 85, + JobStage.RENDERING: 95, +} + +# Valid State Transitions +VALID_TRANSITIONS: Dict[JobStatus, Set[JobStatus]] = { + JobStatus.CREATED: { + JobStatus.CREATED, + JobStatus.QUEUED, + JobStatus.PROCESSING, + JobStatus.FAILED, + JobStatus.CANCELLED, + }, + JobStatus.QUEUED: { + JobStatus.QUEUED, + JobStatus.PROCESSING, + JobStatus.FAILED, + JobStatus.CANCELLED, + }, + JobStatus.PROCESSING: { + JobStatus.PROCESSING, + JobStatus.COMPLETED, + JobStatus.DEGRADED, + JobStatus.FAILED, + JobStatus.CANCELLED, + }, + # Terminal states + JobStatus.COMPLETED: {JobStatus.COMPLETED}, + JobStatus.FAILED: {JobStatus.FAILED}, + JobStatus.DEGRADED: {JobStatus.DEGRADED}, + JobStatus.CANCELLED: {JobStatus.CANCELLED}, +} + + +class InvalidStateTransitionError(ValueError): + """Raised when an illegal job state transition is attempted.""" + pass + + +class JobStateMachine: + """Validator and manager for job lifecycle transitions.""" + + @staticmethod + def normalize_status(raw_status: Optional[str]) -> JobStatus: + if not raw_status: + return JobStatus.PROCESSING + raw = raw_status.lower().strip() + # Aliases for backward compatibility + if raw in ("success", "done", "finished", "completed"): + return JobStatus.COMPLETED + if raw in ("error", "failed", "failure"): + return JobStatus.FAILED + if raw in ("rendering_queued", "queued"): + return JobStatus.QUEUED + if raw in ("rendering", "processing", "solving", "ocr", "parsing", "geometry"): + return JobStatus.PROCESSING + if raw == "cancelled": + return JobStatus.CANCELLED + if raw == "degraded": + return JobStatus.DEGRADED + return JobStatus.PROCESSING + + @staticmethod + def normalize_stage(raw_stage: Optional[str]) -> Optional[JobStage]: + if not raw_stage: + return None + raw = raw_stage.lower().strip() + for stage in JobStage: + if stage.value == raw: + return stage + return None + + @classmethod + def can_transition(cls, current: JobStatus, target: JobStatus) -> bool: + valid_targets = VALID_TRANSITIONS.get(current, set()) + return target in valid_targets + + @classmethod + def validate_transition(cls, current_status: str | JobStatus, target_status: str | JobStatus) -> JobStatus: + current = current_status if isinstance(current_status, JobStatus) else cls.normalize_status(current_status) + target = target_status if isinstance(target_status, JobStatus) else cls.normalize_status(target_status) + + if not cls.can_transition(current, target): + raise InvalidStateTransitionError( + f"Invalid job state transition from {current.value} to {target.value}" + ) + return target + + +class JobEventPayload(BaseModel): + """Normalized payload broadcasted via WebSockets and returned by HTTP polling.""" + job_id: str + status: JobStatus = JobStatus.PROCESSING + stage: Optional[JobStage] = None + progress: int = Field(default=0, ge=0, le=100) + message: Optional[str] = None + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + error_code: Optional[str] = None + video_url: Optional[str] = None diff --git a/app/models/schemas.py b/app/models/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..226eda8247165d01164afaa21268de0300e3044f --- /dev/null +++ b/app/models/schemas.py @@ -0,0 +1,81 @@ +from pydantic import BaseModel, EmailStr, field_validator +from typing import Optional, List, Any, Dict +from datetime import datetime +import uuid + +from app.url_utils import sanitize_url + +# --- Auth Schemas --- +class UserProfile(BaseModel): + id: uuid.UUID + display_name: Optional[str] = None + avatar_url: Optional[str] = None + created_at: datetime + +class User(BaseModel): + id: uuid.UUID + email: EmailStr + +# --- Session Schemas --- +class SessionBase(BaseModel): + title: str = "Bài toán mới" + +class SessionCreate(SessionBase): + pass + +class Session(SessionBase): + id: uuid.UUID + user_id: uuid.UUID + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +# --- Message Schemas --- +class MessageBase(BaseModel): + role: str + type: str = "text" + content: str + metadata: Dict[str, Any] = {} + +class MessageCreate(MessageBase): + session_id: uuid.UUID + +class Message(MessageBase): + id: uuid.UUID + session_id: uuid.UUID + created_at: datetime + + class Config: + from_attributes = True + +# --- Solve Job Schemas --- +class SolveRequest(BaseModel): + text: str + image_url: Optional[str] = None + client_message_id: Optional[str] = None + + @field_validator("image_url", mode="before") + @classmethod + def _clean_image_url(cls, v): + return sanitize_url(v) if v is not None else None + +class SolveResponse(BaseModel): + job_id: str + status: str + +class RenderVideoRequest(BaseModel): + job_id: Optional[str] = None + +class RenderVideoResponse(BaseModel): + job_id: str + status: str + + +class OcrPreviewResponse(BaseModel): + """Stateless OCR preview before POST .../solve (no DB writes, no job).""" + + ocr_text: str + user_message: str = "" + combined_draft: str diff --git a/app/ocr_celery.py b/app/ocr_celery.py new file mode 100644 index 0000000000000000000000000000000000000000..8ff61cbc1ca050ba02631fb5085eb2b85170887a --- /dev/null +++ b/app/ocr_celery.py @@ -0,0 +1,86 @@ +"""OCR pipeline with confidence gateway support.""" + +from __future__ import annotations + +import json +import logging +import os +from typing import TYPE_CHECKING, Optional + +import anyio + +from config.loader import load_agent_config +from vision_ocr.canonical_schema import CanonicalOCRResult + +if TYPE_CHECKING: + from agents.ocr_agent import OCRAgent + +logger = logging.getLogger(__name__) + + +async def ocr_from_image_url( + image_url: str, + fallback_agent: "OCRAgent", + raw_image_path: Optional[str] = None, +) -> CanonicalOCRResult: + """ + Process OCR from image URL using OCRAgent (Pix2Text Engine). + Returns full CanonicalOCRResult with confidence metadata. + If confidence < gateway threshold, triggers VLM correction (if enabled). + """ + # Get canonical OCR result (with confidence) + canonical = await fallback_agent.process_url_canonical(image_url) + + # Check confidence gateway + ocr_config = load_agent_config("ocr") + gateway = ocr_config.confidence_gateway + + if gateway and gateway.enabled: + threshold = gateway.threshold + ocr_confidence = canonical.confidence + + logger.info( + f"[OCR Gateway] confidence={ocr_confidence:.3f}, threshold={threshold:.2f}, " + f"gateway_triggered={ocr_confidence < threshold}" + ) + + if ocr_confidence < threshold and gateway.correction.enabled: + try: + from agents.vlm_corrector import VLMCorrectorAgent + + corrector = VLMCorrectorAgent(config=gateway.correction) + correction_result = await corrector.correct( + ocr_result=canonical, + image_url=image_url, + image_path=raw_image_path, + ) + + if correction_result and correction_result.get("changed"): + corrected_text = correction_result.get("corrected_text", canonical.text) + corrected_confidence = correction_result.get("confidence", ocr_confidence) + logger.info( + f"[OCR Gateway] VLM correction applied: " + f"confidence {ocr_confidence:.3f} → {corrected_confidence:.3f}" + ) + # Return updated canonical result + canonical = CanonicalOCRResult( + text=corrected_text, + latex=canonical.latex, + elements=canonical.elements, + reading_order=canonical.reading_order, + confidence=corrected_confidence, + metadata={ + **canonical.metadata, + "vlm_correction": True, + "original_confidence": ocr_confidence, + "corrections": correction_result.get("corrections", []), + }, + ) + else: + logger.info("[OCR Gateway] VLM correction: no changes needed") + except ImportError: + logger.warning("[OCR Gateway] VLM corrector not available, skipping correction") + except Exception as e: + logger.warning(f"[OCR Gateway] VLM correction failed: {e}") + + return canonical diff --git a/app/ocr_local_file.py b/app/ocr_local_file.py new file mode 100644 index 0000000000000000000000000000000000000000..5b5eb2379b2c52975d98315bfc927767edac1da4 --- /dev/null +++ b/app/ocr_local_file.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +from config.loader import load_agent_config + +if TYPE_CHECKING: + from agents.ocr_agent import OCRAgent + +logger = logging.getLogger(__name__) + + +async def ocr_from_local_image_path( + local_path: str, + original_filename: str | None, + fallback_agent: "OCRAgent", +) -> str: + """ + Run OCR on a file on local disk using Pix2Text + Confidence Gateway VLM Correction. + """ + import inspect + from vision_ocr.canonical_schema import CanonicalOCRResult + + canonical = None + if hasattr(fallback_agent, "process_image_canonical"): + try: + res = fallback_agent.process_image_canonical(local_path) + canonical = await res if inspect.isawaitable(res) else res + except Exception: + canonical = None + + if canonical is None or not isinstance(canonical, CanonicalOCRResult): + if hasattr(fallback_agent, "process_image"): + res = fallback_agent.process_image(local_path) + text = await res if inspect.isawaitable(res) else res + canonical = CanonicalOCRResult(text=str(text or ""), confidence=0.5 if not text else 0.8) + else: + canonical = CanonicalOCRResult(text="", confidence=0.0) + + ocr_config = load_agent_config("ocr") + gateway = ocr_config.confidence_gateway + + if gateway and gateway.enabled: + threshold = gateway.threshold + ocr_confidence = canonical.confidence + + logger.info( + f"[OCR Local Gateway] confidence={ocr_confidence:.3f}, threshold={threshold:.2f}, " + f"gateway_triggered={ocr_confidence < threshold}" + ) + + if ocr_confidence < threshold and gateway.correction.enabled: + try: + from agents.vlm_corrector import VLMCorrectorAgent + + corrector = VLMCorrectorAgent(config=gateway.correction) + correction_result = await corrector.correct( + ocr_result=canonical, + image_path=local_path, + ) + + if correction_result and correction_result.get("changed"): + logger.info( + f"[OCR Local Gateway] VLM correction applied: " + f"confidence {ocr_confidence:.3f} -> {correction_result.get('confidence', 0.9):.3f}" + ) + return correction_result.get("corrected_text", canonical.text) + except Exception as e: + logger.warning(f"[OCR Local Gateway] VLM correction failed: {e}") + + return canonical.text + + diff --git a/app/ocr_text_merge.py b/app/ocr_text_merge.py new file mode 100644 index 0000000000000000000000000000000000000000..b5cc1346f7570174eb2d204c061ec01fb844d4c4 --- /dev/null +++ b/app/ocr_text_merge.py @@ -0,0 +1,14 @@ +"""Helpers for OCR preview combined draft (no Pydantic email deps).""" + +from __future__ import annotations + +from typing import Optional + + +def build_combined_ocr_preview_draft(user_message: Optional[str], ocr_text: str) -> str: + """Merge user caption and OCR text for confirm step (user message first, then OCR).""" + u = (user_message or "").strip() + o = (ocr_text or "").strip() + if u and o: + return f"{u}\n\n{o}" + return u or o diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..45e499f6564756ee3155638e98e3727b71c03759 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +from . import auth, sessions, solve diff --git a/app/routers/ai_core.py b/app/routers/ai_core.py new file mode 100644 index 0000000000000000000000000000000000000000..f9a0eef7d881c310a6fb3c0b9c5fa61e2f8cfaa1 --- /dev/null +++ b/app/routers/ai_core.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import base64 +import logging +import os +import uuid +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, Dict, Any + +from agents.orchestrator import Orchestrator +from agents.ocr_agent import OCRAgent +from manim_client.client import ManimClient +from manim_client.schemas import MathRenderResponse +from vision_ocr.canonical_schema import CanonicalOCRResult + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/v1/ai", tags=["AI Core (Standalone)"]) + +# Shared in-memory instances (lazy initialized) +_orchestrator = None +_ocr_agent = None +_manim_client = None + + +def _get_orchestrator() -> Orchestrator: + global _orchestrator + if _orchestrator is None: + _orchestrator = Orchestrator() + return _orchestrator + + +def _get_ocr_agent() -> OCRAgent: + global _ocr_agent + if _ocr_agent is None: + _ocr_agent = OCRAgent() + return _ocr_agent + + +def _get_manim_client() -> ManimClient: + global _manim_client + if _manim_client is None: + _manim_client = ManimClient() + return _manim_client + + +class AISolveRequest(BaseModel): + text: Optional[str] = None + image_url: Optional[str] = None + image_path: Optional[str] = None + generate_video: bool = True + + +class AIOCRRequest(BaseModel): + image_url: Optional[str] = None + image_path: Optional[str] = None + image_base64: Optional[str] = None + + +@router.post("/ocr", response_model=CanonicalOCRResult) +async def ocr_direct_ai(request: AIOCRRequest) -> CanonicalOCRResult: + """ + Direct Math OCR Endpoint (Pix2Text Engine). + Converts geometry problem images into canonical structured format: + - text: Reconstructed Markdown with LaTeX math formulas + - latex: List of isolated and embedded LaTeX equations + - elements: Classified layout regions (text, formulas, bboxes) + - reading_order: Document reading sequence + - confidence: Extraction accuracy confidence + """ + ocr_agent = _get_ocr_agent() + if request.image_url: + logger.info("==[AI Core OCR] Processing image_url: %s==", request.image_url) + return await ocr_agent.process_url_canonical(request.image_url) + + if request.image_path: + logger.info("==[AI Core OCR] Processing local image_path: %s==", request.image_path) + return await ocr_agent.process_image_canonical(request.image_path) + + if request.image_base64: + logger.info("==[AI Core OCR] Processing image_base64==") + temp_path = f"temp_ocr_b64_{uuid.uuid4().hex}.png" + try: + b64_data = request.image_base64 + if "," in b64_data: + b64_data = b64_data.split(",", 1)[1] + img_bytes = base64.b64decode(b64_data) + with open(temp_path, "wb") as f: + f.write(img_bytes) + return await ocr_agent.process_image_canonical(temp_path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + + raise HTTPException(status_code=400, detail="Must provide 'image_url', 'image_path', or 'image_base64'.") + + +@router.post("/solve") +async def solve_direct_ai(request: AISolveRequest) -> Dict[str, Any]: + """ + Direct Standalone AI Core Solve Endpoint. + - Runs OCR (Pix2Text) -> GeometryParser -> GeometryEngine -> DeepMath -> VisualizationSpec -> Manim Module. + - 100% In-Memory: No Supabase DB or Redis connection required. + - No authentication token required (Ideal for AI development & curl testing). + """ + text = (request.text or "").strip() + image_url = request.image_url + ocr_agent = _get_ocr_agent() + orchestrator = _get_orchestrator() + + if not text and request.image_path and os.path.exists(request.image_path): + # Run OCR on local image directly + ocr_res = await ocr_agent.process_image_canonical(request.image_path) + text = ocr_res.text + logger.info("[AI Core Solve] Extracted OCR text from %s: '%s'", request.image_path, text[:80]) + + if not text and not image_url: + raise HTTPException(status_code=400, detail="Either 'text', 'image_url', or valid 'image_path' must be provided.") + + logger.info("==[AI Core Direct Solve] Received problem: %s==", text[:80] if text else f"Image: {image_url}") + try: + result = await orchestrator.run( + text=text, + image_url=image_url, + job_id="direct_ai_run", + generate_video=request.generate_video, + ) + return result + except Exception as e: + logger.exception("AI Core execution error: %s", e) + raise HTTPException(status_code=500, detail=f"AI Core processing failed: {str(e)}") + + +@router.get("/visualization/jobs/{job_id}", response_model=MathRenderResponse) +async def get_visualization_job_status(job_id: str) -> MathRenderResponse: + """ + Query the status of a Manim video generation job. + Proxies request to the Manim Video Generation Module. + """ + logger.info(f"==[AI Core Visualization] Fetching status for job {job_id}==") + resp = await _get_manim_client().get_job_status(job_id) + return resp diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..9822255f899b51110e118d1ce5ebe3e2ec3b7796 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends, HTTPException +from app.dependencies import get_current_user_id +from app.supabase_client import get_supabase +from app.models.schemas import UserProfile +import uuid + +router = APIRouter(prefix="/api/v1/auth", tags=["Auth"]) + +@router.get("/me") +async def get_me(user_id=Depends(get_current_user_id)): + """Lấy thông tin profile người dùng hiện tại (Retrieve current user profile).""" + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + + res = supabase.table("profiles").select("*").eq("id", user_id).execute() + if res.data: + return res.data[0] + + # Auto-provision basic profile if trigger didn't run or dev test user + try: + insert_res = ( + supabase.table("profiles") + .insert({"id": str(user_id), "display_name": "Người dùng", "avatar_url": None}) + .execute() + ) + if insert_res.data: + return insert_res.data[0] + except Exception: + pass + + raise HTTPException(status_code=404, detail="Profile not found.") + +@router.patch("/me") +async def update_me(data: dict, user_id=Depends(get_current_user_id)): + """Cập nhật profile hiện tại (Update current profile).""" + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + + # Sanitize and only allow updating safe profile fields + allowed_fields = {"display_name", "avatar_url"} + update_data = {k: v for k, v in data.items() if k in allowed_fields} + if not update_data: + raise HTTPException(status_code=400, detail="No valid fields provided for update (allowed: display_name, avatar_url).") + + res = supabase.table("profiles").update(update_data).eq("id", user_id).execute() + if not res.data: + raise HTTPException(status_code=404, detail="Profile not found to update.") + return res.data[0] diff --git a/app/routers/sessions.py b/app/routers/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..3063b09a7d52505bc4d67546f6d75b7df507ed38 --- /dev/null +++ b/app/routers/sessions.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import logging +import time +from typing import List + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException + +from app.chat_image_upload import cleanup_session_storage +from app.dependencies import get_current_user_id +from app.logutil import log_step +from app.session_cache import ( + invalidate_session_owner, + session_owned_by_user, +) +from app.supabase_client import get_supabase + +router = APIRouter(prefix="/api/v1/sessions", tags=["Sessions"]) +logger = logging.getLogger(__name__) + + +@router.get("", response_model=List[dict]) +async def list_sessions(user_id=Depends(get_current_user_id)): + """Danh sách các phiên chat của người dùng (List user's chat sessions)""" + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + t0 = time.perf_counter() + res = ( + supabase.table("sessions") + .select("id, user_id, title, created_at, updated_at") + .eq("user_id", user_id) + .order("updated_at", desc=True) + .execute() + ) + log_step("db_select", table="sessions", op="list", user_id=str(user_id)) + out = res.data or [] + logger.info( + "sessions.list user=%s count=%d %.1fms", + user_id, + len(out), + (time.perf_counter() - t0) * 1000, + ) + return out + + +@router.post("", response_model=dict) +async def create_session(user_id=Depends(get_current_user_id)): + """Tạo một phiên chat mới (Create a new chat session)""" + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + t0 = time.perf_counter() + res = supabase.table("sessions").insert( + {"user_id": user_id, "title": "Bài toán mới"} + ).execute() + log_step("db_insert", table="sessions", op="create") + if not res.data: + raise HTTPException(status_code=500, detail="Failed to create session.") + row = res.data[0] + logger.info( + "sessions.create user=%s id=%s %.1fms", + user_id, + row.get("id"), + (time.perf_counter() - t0) * 1000, + ) + return row + + +@router.get("/{session_id}/messages", response_model=List[dict]) +async def get_session_messages(session_id: str, user_id=Depends(get_current_user_id)): + """Lấy toàn bộ lịch sử tin nhắn của một phiên (Get chat history for a session)""" + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + + def owns() -> bool: + res = ( + supabase.table("sessions") + .select("id") + .eq("id", session_id) + .eq("user_id", user_id) + .execute() + ) + log_step("db_select", table="sessions", op="owner_check", session_id=session_id) + return bool(res.data) + + if not session_owned_by_user(session_id, str(user_id), owns): + raise HTTPException( + status_code=403, detail="Forbidden: You do not own this session." + ) + + res = ( + supabase.table("messages") + .select("*") + .eq("session_id", session_id) + .order("created_at", desc=False) + .execute() + ) + log_step("db_select", table="messages", op="list", session_id=session_id) + return res.data or [] + + +@router.delete("/{session_id}") +async def delete_session( + session_id: str, + background_tasks: BackgroundTasks, + user_id=Depends(get_current_user_id), +): + """Xóa một phiên chat và toàn bộ tài nguyên liên quan (Delete a chat session & associated assets)""" + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + + def owns() -> bool: + res = ( + supabase.table("sessions") + .select("id") + .eq("id", session_id) + .eq("user_id", user_id) + .execute() + ) + return bool(res.data) + + if not session_owned_by_user(session_id, str(user_id), owns): + raise HTTPException( + status_code=403, detail="Forbidden: You do not own this session." + ) + + # 1. Authoritative DB deletion first (dependent rows before session) + try: + supabase.table("session_assets").delete().eq("session_id", session_id).execute() + log_step("db_delete", table="session_assets", op="by_session", session_id=session_id) + except Exception as e: + logger.warning("Error deleting session_assets for session %s: %s", session_id, e) + + supabase.table("jobs").delete().eq("session_id", session_id).eq("user_id", user_id).execute() + log_step("db_delete", table="jobs", op="by_session", session_id=session_id) + supabase.table("messages").delete().eq("session_id", session_id).execute() + log_step("db_delete", table="messages", op="by_session", session_id=session_id) + + res = ( + supabase.table("sessions") + .delete() + .eq("id", session_id) + .eq("user_id", user_id) + .execute() + ) + log_step("db_delete", table="sessions", session_id=session_id) + invalidate_session_owner(session_id, str(user_id)) + + # 2. Async / non-blocking storage cleanup AFTER DB deletion succeeds + background_tasks.add_task(cleanup_session_storage, session_id) + + return {"status": "ok", "deleted_id": session_id} + + +@router.patch("/{session_id}/title") +async def update_session_title(title: str, session_id: str, user_id=Depends(get_current_user_id)): + """Cập nhật tiêu đề phiên chat (Rename a chat session)""" + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + + res = ( + supabase.table("sessions") + .update({"title": title}) + .eq("id", session_id) + .eq("user_id", user_id) + .execute() + ) + if not res.data: + raise HTTPException(status_code=404, detail="Session not found or not owned by user.") + log_step("db_update", table="sessions", op="title", session_id=session_id) + return res.data[0] + + +@router.get("/{session_id}/assets", response_model=List[dict]) +async def get_session_assets(session_id: str, user_id=Depends(get_current_user_id)): + """Lấy danh sách video đã render trong session (Get versioned assets for a session)""" + supabase = get_supabase() + + def owns() -> bool: + res = ( + supabase.table("sessions") + .select("id") + .eq("id", session_id) + .eq("user_id", user_id) + .execute() + ) + return bool(res.data) + + if not session_owned_by_user(session_id, str(user_id), owns): + raise HTTPException( + status_code=403, detail="Forbidden: You do not own this session." + ) + + res = ( + supabase.table("session_assets") + .select("*") + .eq("session_id", session_id) + .order("version", desc=True) + .execute() + ) + log_step("db_select", table="session_assets", op="list", session_id=session_id) + return res.data diff --git a/app/routers/solve.py b/app/routers/solve.py new file mode 100644 index 0000000000000000000000000000000000000000..cc28d77826a2754f4cacbc916e2cc95f3d9270f3 --- /dev/null +++ b/app/routers/solve.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import logging +import os +import uuid + +from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile + +from agents.orchestrator import Orchestrator +from app.chat_image_upload import upload_session_chat_image, validate_chat_image_bytes +from app.ocr_local_file import ocr_from_local_image_path +from app.dependencies import get_current_user_id +from app.errors import format_error_for_user +from app.logutil import log_pipeline_failure, log_pipeline_success, log_step +from app.models.schemas import ( + OcrPreviewResponse, + RenderVideoRequest, + RenderVideoResponse, + SolveRequest, + SolveResponse, +) +from app.ocr_text_merge import build_combined_ocr_preview_draft +from app.session_cache import session_owned_by_user +from app.supabase_client import get_supabase + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/v1/sessions", tags=["Solve"]) + +# Eager init: all agents and models load at import time (also run in Docker build via scripts/prewarm_models.py). +ORCHESTRATOR = Orchestrator() + + +def get_orchestrator() -> Orchestrator: + return ORCHESTRATOR + + +_OCR_PREVIEW_MAX_BYTES = 10 * 1024 * 1024 + + +def _assert_session_owner(supabase, session_id: str, user_id, uid: str, op: str) -> None: + def owns() -> bool: + res = ( + supabase.table("sessions") + .select("id") + .eq("id", session_id) + .eq("user_id", user_id) + .execute() + ) + log_step("db_select", table="sessions", op=op, session_id=session_id) + return bool(res.data) + + if not session_owned_by_user(session_id, uid, owns): + log_pipeline_failure("solve_request", error="forbidden", session_id=session_id) + raise HTTPException( + status_code=403, detail="Forbidden: You do not own this session." + ) + + +from app.celery_app import is_celery_available +from app.tasks import ( + async_solve_session_job, + async_render_video_job, + solve_session_job_task, + render_video_job_task, +) + + +def _enqueue_solve_common( + supabase, + background_tasks: BackgroundTasks, + session_id: str, + user_id, + uid: str, + request: SolveRequest, + message_metadata: dict, + job_id: str, +) -> SolveResponse: + """Insert user message, job row, enqueue pipeline via Celery/BackgroundWorker; update title when first message.""" + client_msg_id = getattr(request, "client_message_id", None) + if client_msg_id: + message_metadata["client_message_id"] = client_msg_id + + # Check for idempotency if client_message_id is provided + if client_msg_id: + try: + existing = ( + supabase.table("messages") + .select("id") + .eq("session_id", session_id) + .eq("client_message_id", client_msg_id) + .execute() + ) + if existing.data and len(existing.data) > 0: + logger.info( + "Duplicate request detected for client_message_id=%s in session %s", + client_msg_id, + session_id, + ) + return SolveResponse(job_id=job_id, status="processing") + except Exception: + pass + + msg_insert = { + "session_id": session_id, + "role": "user", + "type": "text", + "content": request.text, + "metadata": message_metadata, + } + if client_msg_id: + try: + supabase.table("messages").insert({**msg_insert, "client_message_id": client_msg_id}).execute() + except Exception: + supabase.table("messages").insert(msg_insert).execute() + else: + supabase.table("messages").insert(msg_insert).execute() + + log_step("db_insert", table="messages", op="user_message", session_id=session_id) + + supabase.table("jobs").insert( + { + "id": job_id, + "user_id": user_id, + "session_id": session_id, + "status": "processing", + "stage": "ocr" if request.image_url else "parsing", + "progress": 15 if request.image_url else 35, + "input_text": request.text, + } + ).execute() + log_step("db_insert", table="jobs", job_id=job_id) + + # Dispatch to Celery queue if available; otherwise use Async Background Tasks + if is_celery_available(): + try: + solve_session_job_task.delay( + job_id, session_id, request.text, request.image_url, str(user_id), client_msg_id + ) + log_step("celery_dispatch", task="solve_session_job", job_id=job_id) + except Exception as e: + logger.warning("Celery dispatch failed (%s), falling back to BackgroundTasks", e) + background_tasks.add_task( + async_solve_session_job, + job_id, + session_id, + request.text, + request.image_url, + str(user_id), + client_msg_id, + ) + else: + background_tasks.add_task( + async_solve_session_job, + job_id, + session_id, + request.text, + request.image_url, + str(user_id), + client_msg_id, + ) + + title_check = supabase.table("sessions").select("title").eq("id", session_id).execute() + if title_check.data and title_check.data[0]["title"] == "Bài toán mới": + new_title = request.text[:50] + ("..." if len(request.text) > 50 else "") + supabase.table("sessions").update({"title": new_title}).eq("id", session_id).execute() + log_step("db_update", table="sessions", op="title_from_first_message") + + log_pipeline_success("solve_accepted", job_id=job_id, session_id=session_id) + return SolveResponse(job_id=job_id, status="processing") + + + +@router.post("/{session_id}/ocr_preview", response_model=OcrPreviewResponse) +async def ocr_preview( + session_id: str, + user_id=Depends(get_current_user_id), + file: UploadFile = File(...), + user_message: str | None = Form(None), +): + """ + Run OCR on an uploaded image and merge with optional user_message into combined_draft. + Does not insert messages or start a solve job. After user confirms, call POST .../solve + with text=combined_draft (edited) and omit image_url to avoid double OCR. + """ + supabase = get_supabase() + uid = str(user_id) + _assert_session_owner(supabase, session_id, user_id, uid, "owner_check_ocr_preview") + + body = await file.read() + if len(body) > _OCR_PREVIEW_MAX_BYTES: + raise HTTPException( + status_code=413, + detail=f"Image too large (max {_OCR_PREVIEW_MAX_BYTES // (1024 * 1024)} MB).", + ) + if not body: + raise HTTPException(status_code=400, detail="Empty file.") + + validate_chat_image_bytes(file.filename, body, file.content_type) + + suffix = os.path.splitext(file.filename or "")[1].lower() + if suffix not in (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ""): + suffix = ".png" + temp_path = f"temp_ocr_preview_{uuid.uuid4()}{suffix or '.png'}" + try: + with open(temp_path, "wb") as f: + f.write(body) + ocr_text = await ocr_from_local_image_path( + temp_path, file.filename, get_orchestrator().ocr_agent + ) + if ocr_text is None: + ocr_text = "" + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + um = (user_message or "").strip() + combined = build_combined_ocr_preview_draft(user_message, ocr_text) + log_step("ocr_preview_done", session_id=session_id, ocr_len=len(ocr_text), user_len=len(um)) + return OcrPreviewResponse( + ocr_text=ocr_text, + user_message=um, + combined_draft=combined, + ) + + +@router.post("/{session_id}/solve", response_model=SolveResponse) +async def solve_problem( + session_id: str, + request: SolveRequest, + background_tasks: BackgroundTasks, + user_id=Depends(get_current_user_id), +): + """ + Gửi câu hỏi giải toán trong một session (Submit geometry problem in a session). + 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). + """ + supabase = get_supabase() + uid = str(user_id) + _assert_session_owner(supabase, session_id, user_id, uid, "owner_check") + + message_metadata = {"image_url": request.image_url} if request.image_url else {} + job_id = str(uuid.uuid4()) + return _enqueue_solve_common( + supabase, + background_tasks, + session_id, + user_id, + uid, + request, + message_metadata, + job_id, + ) + + +@router.post("/{session_id}/solve_multipart", response_model=SolveResponse) +async def solve_multipart( + session_id: str, + background_tasks: BackgroundTasks, + user_id=Depends(get_current_user_id), + text: str = Form(...), + file: UploadFile = File(...), + client_message_id: str | None = Form(None), +): + """ + Gửi text + file ảnh trong một request multipart: validate, upload bucket `image`, + ghi session_assets, lưu message kèm metadata (URL, size, type), rồi enqueue solve + (image_url trỏ public URL để orchestrator OCR). + """ + supabase = get_supabase() + uid = str(user_id) + _assert_session_owner(supabase, session_id, user_id, uid, "owner_check_solve_multipart") + + t = (text or "").strip() + if not t: + raise HTTPException(status_code=400, detail="text must not be empty.") + + body = await file.read() + ext, content_type = validate_chat_image_bytes(file.filename, body, file.content_type) + + job_id = str(uuid.uuid4()) + up = upload_session_chat_image(session_id, job_id, body, ext, content_type) + public_url = up["public_url"] + + message_metadata = { + "image_url": public_url, + "attachment": { + "public_url": public_url, + "storage_path": up["storage_path"], + "size_bytes": len(body), + "content_type": content_type, + "original_filename": file.filename or "", + "session_asset_id": up.get("session_asset_id"), + }, + } + request = SolveRequest(text=t, image_url=public_url, client_message_id=client_message_id) + return _enqueue_solve_common( + supabase, + background_tasks, + session_id, + user_id, + uid, + request, + message_metadata, + job_id, + ) + + +@router.post("/{session_id}/render_video", response_model=RenderVideoResponse) +async def render_video( + session_id: str, + request: RenderVideoRequest, + background_tasks: BackgroundTasks, + user_id=Depends(get_current_user_id), +): + """ + Yêu cầu tạo video Manim từ trạng thái hình ảnh mới nhất của session. + """ + supabase = get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Database service currently unavailable.") + + uid = str(user_id) + # 1. Kiểm tra quyền sở hữu + _assert_session_owner(supabase, session_id, user_id, uid, "owner_check_render_video") + + # 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) + msg_res = ( + supabase.table("messages") + .select("metadata") + .eq("session_id", session_id) + .eq("role", "assistant") + .order("created_at", desc=True) + .limit(10) + .execute() + ) + + latest_geometry = None + if msg_res.data: + for msg in msg_res.data: + meta = msg.get("metadata", {}) + # Nếu có yêu cầu job_id cụ thể, phải khớp job_id + if request.job_id and meta.get("job_id") != request.job_id: + continue + + # Phải có dữ liệu hình học + if meta.get("geometry_dsl") and meta.get("coordinates"): + latest_geometry = meta + break + + if not latest_geometry: + raise HTTPException(status_code=404, detail="Không tìm thấy dữ liệu hình học để render video.") + + # 3. Tạo Job rendering + job_id = str(uuid.uuid4()) + supabase.table("jobs").insert({ + "id": job_id, + "user_id": user_id, + "session_id": session_id, + "status": "rendering_queued", + "stage": "rendering", + "progress": 10, + "input_text": f"Render video requested at {job_id}", + }).execute() + + # 4. Dispatch Celery task or async background task + if is_celery_available(): + try: + render_video_job_task.delay(job_id, session_id, latest_geometry) + log_step("celery_dispatch", task="render_video_job", job_id=job_id) + except Exception as e: + logger.warning("Celery dispatch failed (%s), falling back to BackgroundTasks", e) + background_tasks.add_task(async_render_video_job, job_id, session_id, latest_geometry) + else: + background_tasks.add_task(async_render_video_job, job_id, session_id, latest_geometry) + + return RenderVideoResponse(job_id=job_id, status="rendering_queued") + + +async def process_session_job( + job_id: str, session_id: str, request: SolveRequest, user_id: str +): + """Tiến trình giải toán ngầm, tạo hình ảnh tĩnh (backward compatible delegate).""" + return await async_solve_session_job( + job_id=job_id, + session_id=session_id, + text=request.text, + image_url=request.image_url, + user_id=user_id, + client_message_id=getattr(request, "client_message_id", None), + ) + + +async def process_render_job(job_id: str, session_id: str, geometry_data: dict): + """Tiến trình render video qua External Manim API (backward compatible delegate).""" + return await async_render_video_job(job_id=job_id, session_id=session_id, geometry_data=geometry_data) + + diff --git a/app/runtime_env.py b/app/runtime_env.py new file mode 100644 index 0000000000000000000000000000000000000000..eb6087960acbfb3e0a25987103dec8179598e0f2 --- /dev/null +++ b/app/runtime_env.py @@ -0,0 +1,12 @@ +"""Default process env vars (Paddle/OpenMP). Call as early as possible after load_dotenv.""" + +from __future__ import annotations + +import os + + +def apply_runtime_env_defaults() -> None: + # Paddle respects OMP_NUM_THREADS at import; setdefault loses if platform already set 2+ + os.environ["OMP_NUM_THREADS"] = "1" + os.environ["MKL_NUM_THREADS"] = "1" + os.environ["OPENBLAS_NUM_THREADS"] = "1" diff --git a/app/session_cache.py b/app/session_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..571cacfd28c15a85076dded0afc2b93ff39a4958 --- /dev/null +++ b/app/session_cache.py @@ -0,0 +1,26 @@ +"""Deterministic session ownership verification (Server-authoritative, no process-local drift).""" + +from __future__ import annotations + +from typing import Callable +from app.logutil import log_step + + +def invalidate_session_owner(session_id: str, user_id: str) -> None: + """No-op for backward compatibility now that ownership is direct DB authoritative.""" + log_step("session_owner_check", target="session_owner", session_id=session_id, user_id=user_id) + + +def session_owned_by_user( + session_id: str, + user_id: str, + fetch: Callable[[], bool], +) -> bool: + """ + Direct authoritative ownership check via provided fetch function. + Eliminates multi-worker drift by always evaluating against the authoritative DB. + """ + ok = fetch() + log_step("session_owner_verified", session_id=session_id, user_id=user_id, is_owner=ok) + return ok + diff --git a/app/supabase_client.py b/app/supabase_client.py new file mode 100644 index 0000000000000000000000000000000000000000..7a0121294f16a9fd0d8e41a4641667cbfb98b4ee --- /dev/null +++ b/app/supabase_client.py @@ -0,0 +1,45 @@ +import os +import logging +from supabase import Client, ClientOptions, create_client +from supabase_auth import SyncMemoryStorage +from dotenv import load_dotenv + +load_dotenv() + +from app.url_utils import sanitize_env + +logger = logging.getLogger(__name__) + +_supabase_client = None + + +def get_supabase() -> Client: + """Service-role client for server-side operations with lazy init.""" + global _supabase_client + if _supabase_client is not None: + return _supabase_client + + url = sanitize_env(os.getenv("SUPABASE_URL")) + key = sanitize_env(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY")) + if not url or not key: + logger.warning("[Supabase] SUPABASE_URL or key not configured. Cloud DB operations will be unavailable.") + return None + + try: + _supabase_client = create_client(url, key) + return _supabase_client + except Exception as e: + logger.warning("[Supabase] Failed to initialize Supabase client: %s", e) + return None + + +def get_supabase_for_user_jwt(access_token: str) -> Client: + """Client scoped to the logged-in user.""" + url = sanitize_env(os.getenv("SUPABASE_URL")) + anon = sanitize_env(os.getenv("SUPABASE_ANON_KEY") or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")) + if not url or not anon: + raise RuntimeError("SUPABASE_URL and SUPABASE_ANON_KEY must be set for user-scoped Supabase access") + base_opts = ClientOptions(storage=SyncMemoryStorage()) + merged_headers = {**dict(base_opts.headers), "Authorization": f"Bearer {access_token}"} + opts = ClientOptions(storage=SyncMemoryStorage(), headers=merged_headers) + return create_client(url, anon, opts) diff --git a/app/tasks.py b/app/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..7f47a7c311fc48b9186af7b2157556f12def55ce --- /dev/null +++ b/app/tasks.py @@ -0,0 +1,402 @@ +"""Celery Tasks & Async Worker Handlers for MathSolver Solve & Render Pipeline.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from typing import Any, Dict, Optional + +from app.celery_app import celery_app +from app.errors import format_error_for_user +from app.logutil import log_pipeline_failure, log_pipeline_success, log_step +from app.models.job_state import JobStatus, JobStage, JobStateMachine +from app.supabase_client import get_supabase +from app.websocket_manager import notify_status + +logger = logging.getLogger(__name__) + + +async def async_solve_session_job( + job_id: str, + session_id: str, + text: str, + image_url: Optional[str] = None, + user_id: Optional[str] = None, + client_message_id: Optional[str] = None, +) -> Dict[str, Any]: + """Execute the full geometry solve pipeline for a session job.""" + from app.routers.solve import get_orchestrator + + supabase = get_supabase() + + async def status_callback(status: str, stage: Optional[str] = None, progress: Optional[int] = None): + norm_status = JobStateMachine.normalize_status(status) + norm_stage = JobStateMachine.normalize_stage(stage or status) + update_data = {"status": norm_status.value} + if norm_stage: + update_data["stage"] = norm_stage.value + if progress is not None: + update_data["progress"] = progress + + if supabase: + try: + supabase.table("jobs").update(update_data).eq("id", job_id).execute() + except Exception as e: + logger.debug("Failed updating job status in DB: %s", e) + + await notify_status(job_id, { + "status": norm_status.value, + "stage": norm_stage.value if norm_stage else None, + "progress": progress, + "job_id": job_id, + }) + + try: + # Initial status update + await status_callback("processing", stage="ocr", progress=15) + + history = [] + if supabase and session_id: + try: + history_res = ( + supabase.table("messages") + .select("*") + .eq("session_id", session_id) + .order("created_at", desc=False) + .execute() + ) + history = history_res.data if history_res.data else [] + except Exception as e: + logger.warning("Could not fetch message history: %s", e) + + result = await get_orchestrator().run( + text, + image_url, + job_id=job_id, + session_id=session_id, + status_callback=lambda st: status_callback(st), + history=history, + ) + + has_error = "error" in result and result.get("error") + final_status = JobStatus.FAILED if has_error else JobStatus.COMPLETED + + if supabase: + supabase.table("jobs").update({ + "status": final_status.value, + "stage": None, + "progress": 100 if final_status == JobStatus.COMPLETED else 0, + "result": result, + }).eq("id", job_id).execute() + + # Idempotency check: Ensure assistant message for this job is not inserted twice + existing_msg = ( + supabase.table("messages") + .select("id") + .eq("session_id", session_id) + .filter("metadata->>job_id", "eq", job_id) + .execute() + ) + + if not existing_msg.data or len(existing_msg.data) == 0: + supabase.table("messages").insert({ + "session_id": session_id, + "role": "assistant", + "type": "error" if has_error else "analysis", + "content": ( + result.get("error", "Đã có lỗi xảy ra.") + if has_error + else result.get("semantic_analysis", "Giải bài toán hoàn tất.") + ), + "metadata": { + "job_id": job_id, + "client_message_id": client_message_id, + "coordinates": result.get("coordinates"), + "geometry_dsl": result.get("geometry_dsl"), + "polygon_order": result.get("polygon_order", []), + "drawing_phases": result.get("drawing_phases", []), + "circles": result.get("circles", []), + "solids": result.get("solids", []), + "faces": result.get("faces", []), + "lines": result.get("lines", []), + "rays": result.get("rays", []), + "visualization_graph": result.get("visualization_graph"), + "auxiliary": result.get("auxiliary", []), + "solution": result.get("solution"), + "is_3d": result.get("is_3d", False), + }, + }).execute() + + await notify_status(job_id, { + "status": final_status.value, + "stage": None, + "progress": 100 if final_status == JobStatus.COMPLETED else 0, + "job_id": job_id, + "result": result, + }) + log_pipeline_success("job_complete", job_id=job_id, session_id=session_id) + return result + + except Exception as e: + logger.exception("Error in async_solve_session_job for job %s: %s", job_id, e) + error_msg = format_error_for_user(e) + if supabase: + try: + supabase.table("jobs").update({ + "status": JobStatus.FAILED.value, + "progress": 0, + "result": {"error": str(e)}, + }).eq("id", job_id).execute() + + supabase.table("messages").insert({ + "session_id": session_id, + "role": "assistant", + "type": "error", + "content": error_msg, + "metadata": {"job_id": job_id, "client_message_id": client_message_id}, + }).execute() + except Exception as dbe: + logger.error("DB error recording failure for job %s: %s", job_id, dbe) + + await notify_status(job_id, { + "status": JobStatus.FAILED.value, + "job_id": job_id, + "error": error_msg, + "progress": 0, + }) + log_pipeline_failure("job_failed", job_id=job_id, error=str(e)) + return {"status": "error", "error": error_msg} + + +async def async_render_video_job(job_id: str, session_id: str, geometry_data: Dict[str, Any]) -> Dict[str, Any]: + """Execute Manim video rendering job for a session.""" + from manim_client import ManimClient, build_visualization_spec + from manim_client.schemas import ErrorCode + + await notify_status(job_id, { + "status": JobStatus.QUEUED.value, + "stage": JobStage.RENDERING.value, + "job_id": job_id, + "progress": 10, + }) + supabase = get_supabase() + + try: + manim_url = os.getenv("MANIM_SERVICE_URL", "https://cuong2004-manim-agent.hf.space") + manim_token = os.getenv("MANIM_INTERNAL_TOKEN") + client = ManimClient(base_url=manim_url, internal_token=manim_token) + + vis_spec = build_visualization_spec(geometry_data) + resp = await client.submit_render_job(vis_spec) + + if resp.status == "failed": + err_code = resp.get_error_code() or ErrorCode.MANIM_REQUEST_FAILED + 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." + if supabase: + supabase.table("jobs").update({ + "status": JobStatus.FAILED.value, + "result": {"error": {"code": err_code, "message": err_msg}}, + }).eq("id", job_id).execute() + if session_id: + supabase.table("messages").insert({ + "session_id": session_id, + "role": "assistant", + "type": "error", + "content": f"Không thể tạo video: {err_msg}", + "metadata": {"job_id": job_id, "error_code": err_code}, + }).execute() + await notify_status(job_id, { + "status": JobStatus.FAILED.value, + "job_id": job_id, + "error": err_msg, + "error_code": err_code, + }) + return {"status": "error", "error": err_msg} + + manim_job_id = resp.job_id + if supabase: + supabase.table("jobs").update({ + "status": JobStatus.PROCESSING.value, + "stage": JobStage.RENDERING.value, + "progress": 40, + "result": {"manim_job_id": str(manim_job_id)}, + }).eq("id", job_id).execute() + + await notify_status(job_id, { + "status": JobStatus.PROCESSING.value, + "stage": JobStage.RENDERING.value, + "job_id": job_id, + "progress": 40, + "manim_job_id": str(manim_job_id), + }) + + poll_timeout = float(os.getenv("MANIM_POLL_TIMEOUT", "600.0")) + status_resp = await client.wait_for_completion(manim_job_id, poll_interval=3.0, max_wait=poll_timeout) + video_url = status_resp.video_url + + if status_resp.status == "failed" or not video_url: + err_code = status_resp.get_error_code() or ErrorCode.MANIM_RENDER_FAILED + err_msg = status_resp.get_error_message() or "Tiến trình dựng video Manim thất bại." + if supabase: + supabase.table("jobs").update({ + "status": JobStatus.FAILED.value, + "result": {"error": {"code": err_code, "message": err_msg}}, + }).eq("id", job_id).execute() + if session_id: + supabase.table("messages").insert({ + "session_id": session_id, + "role": "assistant", + "type": "error", + "content": f"Không thể tạo video: {err_msg}", + "metadata": {"job_id": job_id, "error_code": err_code}, + }).execute() + await notify_status(job_id, { + "status": JobStatus.FAILED.value, + "job_id": job_id, + "error": err_msg, + "error_code": err_code, + }) + return {"status": "error", "error": err_msg} + + final_result = geometry_data.copy() + final_result["video_url"] = video_url + final_result["manim_job_id"] = str(manim_job_id) + + if supabase: + supabase.table("jobs").update({ + "status": JobStatus.COMPLETED.value, + "progress": 100, + "result": final_result, + }).eq("id", job_id).execute() + + # Versioned asset recording + try: + asset_version = 1 + v_res = supabase.table("session_assets").select("version").eq("session_id", session_id).eq("asset_type", "video").order("version", desc=True).limit(1).execute() + if v_res.data and len(v_res.data) > 0: + asset_version = v_res.data[0]["version"] + 1 + + supabase.table("session_assets").insert({ + "session_id": session_id, + "job_id": job_id, + "asset_type": "video", + "storage_path": video_url, + "public_url": video_url, + "version": asset_version, + }).execute() + except Exception as e: + logger.warning("Could not record session_asset video row: %s", e) + + if session_id: + supabase.table("messages").insert({ + "session_id": session_id, + "role": "assistant", + "type": "analysis", + "content": geometry_data.get("semantic_analysis", "🎬 Video minh họa hình học đã hoàn tất."), + "metadata": { + "job_id": job_id, + "video_url": video_url, + "coordinates": geometry_data.get("coordinates"), + "geometry_dsl": geometry_data.get("geometry_dsl"), + "polygon_order": geometry_data.get("polygon_order", []), + "drawing_phases": geometry_data.get("drawing_phases", []), + "circles": geometry_data.get("circles", []), + "solids": geometry_data.get("solids", []), + "faces": geometry_data.get("faces", []), + "lines": geometry_data.get("lines", []), + "rays": geometry_data.get("rays", []), + "visualization_graph": geometry_data.get("visualization_graph"), + "auxiliary": geometry_data.get("auxiliary", []), + "is_3d": geometry_data.get("is_3d", False), + }, + }).execute() + + await notify_status(job_id, { + "status": JobStatus.COMPLETED.value, + "job_id": job_id, + "result": final_result, + "video_url": video_url, + "progress": 100, + }) + return final_result + + except Exception as e: + logger.exception("Error rendering video for job %s: %s", job_id, e) + safe_msg = format_error_for_user(e) + if supabase: + try: + supabase.table("jobs").update({ + "status": JobStatus.FAILED.value, + "result": {"error": {"message": safe_msg}}, + }).eq("id", job_id).execute() + if session_id: + supabase.table("messages").insert({ + "session_id": session_id, + "role": "assistant", + "type": "error", + "content": f"Lỗi render video: {safe_msg}", + "metadata": {"job_id": job_id}, + }).execute() + except Exception as dbe: + logger.error("DB error recording render failure: %s", dbe) + await notify_status(job_id, {"status": JobStatus.FAILED.value, "job_id": job_id, "error": safe_msg}) + return {"status": "error", "error": safe_msg} + + +@celery_app.task(name="tasks.solve_session_job", bind=True, acks_late=True, max_retries=1) +def solve_session_job_task( + self, + job_id: str, + session_id: str, + text: str, + image_url: Optional[str] = None, + user_id: Optional[str] = None, + client_message_id: Optional[str] = None, +): + """Celery task entry point for solve pipeline.""" + return asyncio.run( + async_solve_session_job( + job_id=job_id, + session_id=session_id, + text=text, + image_url=image_url, + user_id=user_id, + client_message_id=client_message_id, + ) + ) + + +@celery_app.task(name="tasks.render_video_job", bind=True, acks_late=True, max_retries=1) +def render_video_job_task(self, job_id: str, session_id: str, geometry_data: Dict[str, Any]): + """Celery task entry point for video render pipeline.""" + return asyncio.run(async_render_video_job(job_id=job_id, session_id=session_id, geometry_data=geometry_data)) + + +def recover_stale_jobs(timeout_seconds: int = 900) -> int: + """Detect and mark jobs stuck in 'processing' longer than timeout as failed.""" + supabase = get_supabase() + if not supabase: + return 0 + try: + # Note: in production, run via cron or worker startup + from datetime import datetime, timezone, timedelta + cutoff = (datetime.now(timezone.utc) - timedelta(seconds=timeout_seconds)).isoformat() + res = ( + supabase.table("jobs") + .update({ + "status": JobStatus.FAILED.value, + "result": {"error": "Worker timeout or crash detected. Job marked failed by recovery agent."}, + }) + .eq("status", JobStatus.PROCESSING.value) + .lt("created_at", cutoff) + .execute() + ) + count = len(res.data) if res.data else 0 + if count > 0: + logger.warning("Recovered %d stale jobs", count) + return count + except Exception as e: + logger.error("Error running recover_stale_jobs: %s", e) + return 0 diff --git a/app/url_utils.py b/app/url_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..8a37dde695e90efaa585bfeb16c74a369d7f7564 --- /dev/null +++ b/app/url_utils.py @@ -0,0 +1,23 @@ +"""Normalize URLs / env strings (HF secrets and copy-paste often include trailing newlines).""" + + +def sanitize_url(value: str | None) -> str | None: + if value is None: + return None + s = value.strip().replace("\r", "").replace("\n", "").replace("\t", "") + return s or None + + +def sanitize_env(value: str | None) -> str | None: + """Strip whitespace and line breaks from environment-backed strings.""" + return sanitize_url(value) + + +# OpenAI SDK (>=1.x) requires a non-empty api_key at client construction (Docker build / prewarm has no secrets). +_OPENAI_API_KEY_BUILD_PLACEHOLDER = "build-placeholder-openrouter-not-for-production" + + +def openai_compatible_api_key(raw: str | None) -> str: + """Return sanitized API key, or a placeholder so AsyncOpenAI() can be constructed without env at build time.""" + k = sanitize_env(raw) + return k if k else _OPENAI_API_KEY_BUILD_PLACEHOLDER diff --git a/app/websocket_manager.py b/app/websocket_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..09dcc8886ce395073939e71334f47fc92ddf9170 --- /dev/null +++ b/app/websocket_manager.py @@ -0,0 +1,87 @@ +"""WebSocket connection registry and job status notifications (avoid circular imports with main).""" + +from __future__ import annotations + +import logging +from typing import Dict, List + +from fastapi import WebSocket, WebSocketDisconnect + +logger = logging.getLogger(__name__) + +active_connections: Dict[str, List[WebSocket]] = {} + + +from app.models.job_state import JobStateMachine, STAGE_PROGRESS_MAP, JobStatus + + +async def notify_status(job_id: str, data: dict) -> None: + if job_id not in active_connections: + return + + # Normalize payload + payload = dict(data) + payload["job_id"] = str(job_id) + if "status" in payload: + norm_status = JobStateMachine.normalize_status(payload.get("status")) + norm_stage = JobStateMachine.normalize_stage(payload.get("stage")) + if not norm_stage and payload.get("status") in ("ocr", "parsing", "geometry", "solving", "rendering"): + norm_stage = JobStateMachine.normalize_stage(payload.get("status")) + + payload["status"] = norm_status.value + payload["stage"] = norm_stage.value if norm_stage else None + + if "progress" not in payload or payload["progress"] is None: + if norm_status == JobStatus.COMPLETED: + payload["progress"] = 100 + elif norm_stage and norm_stage in STAGE_PROGRESS_MAP: + payload["progress"] = STAGE_PROGRESS_MAP[norm_stage] + elif norm_status == JobStatus.QUEUED: + payload["progress"] = 5 + elif norm_status == JobStatus.PROCESSING: + payload["progress"] = 50 + + for connection in list(active_connections[job_id]): + try: + await connection.send_json(payload) + except Exception as e: + logger.warning("WS error sending to %s: %s (removing dead connection)", job_id, e) + try: + active_connections[job_id].remove(connection) + except (ValueError, KeyError): + pass + if job_id in active_connections and not active_connections[job_id]: + del active_connections[job_id] + + + +def register_websocket_routes(app) -> None: + """Attach websocket endpoint to the FastAPI app.""" + + @app.websocket("/ws/{job_id}") + async def websocket_endpoint(websocket: WebSocket, job_id: str) -> None: + await websocket.accept() + if job_id not in active_connections: + active_connections[job_id] = [] + active_connections[job_id].append(websocket) + + # Send immediate ACK so client immediately transitions from 'connecting' to 'processing' + try: + await websocket.send_json({ + "status": "processing", + "job_id": job_id, + "message": "Đang xử lý bài toán..." + }) + except Exception: + pass + + try: + while True: + msg = await websocket.receive_text() + if msg == "ping": + await websocket.send_text("pong") + except WebSocketDisconnect: + if job_id in active_connections and websocket in active_connections[job_id]: + active_connections[job_id].remove(websocket) + if not active_connections[job_id]: + del active_connections[job_id] diff --git a/clean_ports.sh b/clean_ports.sh new file mode 100755 index 0000000000000000000000000000000000000000..b31e620274c7f7e20ff86c6326ae06093ba2ada3 --- /dev/null +++ b/clean_ports.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Script to kill all project-related processes for a clean restart + +echo "🧹 Cleaning up project processes..." + +# Kill things on ports 8000 (Backend) and 3000 (Frontend) +PORTS="8000 3000 11020" +for PORT in $PORTS; do + PIDS=$(lsof -ti :$PORT) + if [ ! -z "$PIDS" ]; then + echo "Killing processes on port $PORT: $PIDS" + kill -9 $PIDS 2>/dev/null + fi +done + +# Kill by process name +echo "Killing any remaining Celery, Uvicorn, or Manim processes..." +pkill -9 -f "celery" 2>/dev/null +pkill -9 -f "uvicorn" 2>/dev/null +pkill -9 -f "manim" 2>/dev/null + +echo "✅ Done. You can now restart your Backend, Worker, and Frontend." diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2ede9069b598d8825b647184404b0d059dc62575 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,16 @@ +from config.schemas import ModelTier, AgentConfig, RetryPolicyConfig, AgentModelsConfig +from config.settings import settings, Settings, ProviderCredentials +from config.loader import load_agent_config, get_agent_config_resolver, get_agent_models_config + +__all__ = [ + "ModelTier", + "AgentConfig", + "RetryPolicyConfig", + "AgentModelsConfig", + "settings", + "Settings", + "ProviderCredentials", + "load_agent_config", + "get_agent_config_resolver", + "get_agent_models_config", +] diff --git a/config/agent_models.yaml b/config/agent_models.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ac853590b21e9c5e522022ae33d9075d93401166 --- /dev/null +++ b/config/agent_models.yaml @@ -0,0 +1,83 @@ +version: 2 + +defaults: + temperature: 0.1 + max_tokens: 8192 + timeout_seconds: 300 + +retry_policy: + retryable_errors: + - rate_limit + - timeout + - connection + - server_error + + non_retryable_errors: + - invalid_request + - authentication + +agents: + + ocr: + name: ocr + description: "Local visual OCR using Pix2Text. Pure perception — no LLM hallucination." + + tiers: + - model: gemini/gemini-3.5-flash-lite + max_attempts: 1 + reasoning_effort: low + + temperature: 0.1 + max_tokens: 4096 + timeout_seconds: 120 + + confidence_gateway: + enabled: true + threshold: 0.85 + + correction: + enabled: true + model: gemini/gemini-3.5-flash-lite + temperature: 0.1 + max_tokens: 4096 + timeout_seconds: 60 + max_attempts: 1 + reasoning_effort: low + + + geometry_parser: + name: geometry_parser + description: "Semantic geometry parsing and Geometry DSL generation" + + tiers: + - model: gemini/gemini-3.5-flash-lite + max_attempts: 1 + reasoning_effort: low + + - model: gemini/gemini-3.5-flash + max_attempts: 1 + reasoning_effort: medium + + temperature: 0.1 + max_tokens: 16384 + timeout_seconds: 120 + + + reasoning_solver: + name: reasoning_solver + description: "Program-aided mathematical reasoning with SymPy verification" + + tiers: + - model: gemini/gemini-3.6-flash + max_attempts: 1 + reasoning_effort: high + + - model: gemini/gemini-3.7-flash + max_attempts: 1 + reasoning_effort: high + + temperature: 0.1 + max_tokens: 16384 + timeout_seconds: 120 + + diff --git a/config/loader.py b/config/loader.py new file mode 100644 index 0000000000000000000000000000000000000000..09ae787e577b2da8e523df46f89e7c220928cfb0 --- /dev/null +++ b/config/loader.py @@ -0,0 +1,85 @@ +import os +import yaml +import logging +from pathlib import Path +from typing import Optional, Dict +from config.schemas import AgentConfig, AgentModelsConfig + +logger = logging.getLogger(__name__) + +_CACHED_CONFIG: Optional[AgentModelsConfig] = None +_DEFAULT_CONFIG_PATH = Path(__file__).parent / "agent_models.yaml" + + +class AgentConfigResolver: + """Resolves and validates typed AgentConfig from agent_models.yaml.""" + + def __init__(self, config_path: Optional[Path] = None): + self.config_path = config_path or _DEFAULT_CONFIG_PATH + self._config: Optional[AgentModelsConfig] = None + self._load() + + def _load(self) -> None: + if not self.config_path.exists(): + raise FileNotFoundError(f"Agent models config file not found: {self.config_path}") + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Strict validation with Pydantic + self._config = AgentModelsConfig(**data) + logger.info( + f"[AgentConfigResolver] Loaded {len(self._config.agents)} agent configs from {self.config_path}" + ) + except Exception as e: + logger.error(f"[AgentConfigResolver] Failed to parse agent_models.yaml: {e}", exc_info=True) + raise + + def get_agent_config(self, agent_name: str) -> AgentConfig: + if not self._config: + self._load() + if not self._config or agent_name not in self._config.agents: + # Fallback or generic agent config + logger.warning( + f"[AgentConfigResolver] Agent '{agent_name}' not defined in config, using defaults." + ) + from config.schemas import ModelTier + return AgentConfig( + name=agent_name, + description=f"Auto-generated fallback config for {agent_name}", + tiers=[ + ModelTier(model="gemini/gemini-3.7-flash", max_attempts=1), + ModelTier(model="gemini/gemini-3.6-flash", max_attempts=1), + ModelTier(model="gemini/gemini-3.5-flash", max_attempts=1), + ModelTier(model="gemini/gemini-2.5-flash", max_attempts=1), + ], + temperature=0.2, + max_tokens=8192, + timeout_seconds=120, + ) + return self._config.agents[agent_name] + + @property + def config(self) -> AgentModelsConfig: + if not self._config: + self._load() + return self._config # type: ignore + + +_RESOLVER_INSTANCE: Optional[AgentConfigResolver] = None + + +def get_agent_config_resolver() -> AgentConfigResolver: + global _RESOLVER_INSTANCE + if _RESOLVER_INSTANCE is None: + _RESOLVER_INSTANCE = AgentConfigResolver() + return _RESOLVER_INSTANCE + + +def load_agent_config(agent_name: str) -> AgentConfig: + return get_agent_config_resolver().get_agent_config(agent_name) + + +def get_agent_models_config() -> AgentModelsConfig: + return get_agent_config_resolver().config diff --git a/config/schemas.py b/config/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..34555dbc111c3794e6060295b557e6968952226f --- /dev/null +++ b/config/schemas.py @@ -0,0 +1,73 @@ +from typing import List, Dict, Optional, Literal +from pydantic import BaseModel, Field, field_validator + + +class ModelTier(BaseModel): + """Configuration for a specific model tier within an agent's cascade.""" + model: str = Field(..., description="Provider/Model string, e.g. gemini/gemini-2.5-flash") + max_attempts: int = Field(default=1, ge=1, le=5, description="Max attempts with this model tier") + reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field( + default=None, description="Reasoning effort for this specific model tier" + ) + + @field_validator("model") + @classmethod + def validate_model_format(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("Model identifier cannot be empty") + return v + + +class OCRCorrectionConfig(BaseModel): + """Configuration for optional VLM-based OCR correction.""" + enabled: bool = Field(default=True, description="Whether VLM correction is enabled when gateway triggers") + model: str = Field(default="gemini/gemini-3.5-flash-lite", description="VLM model for OCR correction") + temperature: float = Field(default=0.1, ge=0.0, le=2.0, description="VLM correction temperature") + max_tokens: int = Field(default=4096, gt=0, description="Max output tokens for VLM correction") + timeout_seconds: int = Field(default=60, gt=0, description="VLM correction timeout") + max_attempts: int = Field(default=1, ge=1, le=3, description="Max VLM correction attempts") + reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field( + default="low", description="Reasoning effort for VLM OCR correction" + ) + + +class ConfidenceGatewayConfig(BaseModel): + """OCR confidence gateway configuration.""" + enabled: bool = Field(default=True, description="Enable confidence-based gateway") + threshold: float = Field(default=0.85, ge=0.0, le=1.0, description="Confidence threshold below which VLM correction triggers") + correction: OCRCorrectionConfig = Field(default_factory=OCRCorrectionConfig) + + +class AgentConfig(BaseModel): + """Configuration for a specific agent in MathSolver.""" + name: str = Field(..., description="Unique agent identifier") + description: Optional[str] = Field(default=None, description="Agent responsibility summary") + tiers: List[ModelTier] = Field(..., min_length=1, description="Cascading model tiers in execution priority") + temperature: float = Field(default=0.2, ge=0.0, le=2.0, description="Sampling temperature") + max_tokens: int = Field(default=8192, gt=0, description="Max output tokens") + timeout_seconds: int = Field(default=120, gt=0, description="Timeout in seconds") + reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field( + default=None, description="Reasoning effort for thinking models" + ) + confidence_gateway: Optional[ConfidenceGatewayConfig] = Field( + default=None, description="OCR confidence gateway config (only for OCR agent)" + ) + + +class RetryPolicyConfig(BaseModel): + """Global retry policy configuration.""" + retryable_errors: List[str] = Field( + default_factory=lambda: ["rate_limit", "timeout", "connection", "server_error"] + ) + non_retryable_errors: List[str] = Field( + default_factory=lambda: ["invalid_request", "authentication"] + ) + + +class AgentModelsConfig(BaseModel): + """Top-level agent models configuration schema.""" + version: int = Field(default=1) + defaults: Dict[str, object] = Field(default_factory=dict) + retry_policy: RetryPolicyConfig = Field(default_factory=RetryPolicyConfig) + agents: Dict[str, AgentConfig] = Field(..., description="Mapping of agent names to their configurations") diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..5cef2556f5afcabe52b49c8003c9fefec7040dd5 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,75 @@ +import os +from pathlib import Path +from typing import List, Dict +from pydantic import BaseModel, Field +from dotenv import load_dotenv + +# Load from backend/.env if available +_env_path = Path(__file__).parents[1] / ".env" +if _env_path.exists(): + load_dotenv(dotenv_path=_env_path) +else: + load_dotenv() + + + +class ProviderCredentials(BaseModel): + provider: str + keys: List[str] = Field(default_factory=list) + + +def parse_comma_separated_keys(raw: str) -> List[str]: + if not raw: + return [] + keys = [] + for piece in raw.split(","): + cleaned = piece.strip().strip("'\" ") + if cleaned: + keys.append(cleaned) + return keys + + +class Settings(BaseModel): + app_env: str = Field(default_factory=lambda: os.getenv("APP_ENV", "development")) + redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://localhost:6379/0")) + llm_timeout_seconds: int = Field(default_factory=lambda: int(os.getenv("LLM_TIMEOUT_SECONDS", "120"))) + llm_cooldown_seconds: int = Field(default_factory=lambda: int(os.getenv("LLM_COOLDOWN_SECONDS", "60"))) + default_chat_model: str = Field(default_factory=lambda: os.getenv("DEFAULT_CHAT_MODEL", "gemini/gemini-3.7-flash")) + + def get_provider_credentials(self) -> Dict[str, ProviderCredentials]: + """Parses API keys for all supported providers from environment variables.""" + credentials: Dict[str, ProviderCredentials] = {} + + # 1. Gemini / Google + gemini_raw = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "" + gemini_keys = parse_comma_separated_keys(gemini_raw) + # Also check indexed keys GEMINI_API_KEY_1, GEMINI_API_KEY_2, etc. + for i in range(1, 20): + k = os.getenv(f"GEMINI_API_KEY_{i}") or os.getenv(f"GOOGLE_API_KEY_{i}") + if k and k.strip() and k.strip() not in gemini_keys: + gemini_keys.append(k.strip()) + credentials["gemini"] = ProviderCredentials(provider="gemini", keys=gemini_keys) + + # 2. OpenAI + openai_raw = os.getenv("OPENAI_API_KEY") or "" + openai_keys = parse_comma_separated_keys(openai_raw) + for i in range(1, 10): + k = os.getenv(f"OPENAI_API_KEY_{i}") + if k and k.strip() and k.strip() not in openai_keys: + openai_keys.append(k.strip()) + credentials["openai"] = ProviderCredentials(provider="openai", keys=openai_keys) + + # 3. Anthropic + anthropic_raw = os.getenv("ANTHROPIC_API_KEY") or "" + anthropic_keys = parse_comma_separated_keys(anthropic_raw) + credentials["anthropic"] = ProviderCredentials(provider="anthropic", keys=anthropic_keys) + + # 4. OpenRouter + openrouter_raw = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY_1") or "" + openrouter_keys = parse_comma_separated_keys(openrouter_raw) + credentials["openrouter"] = ProviderCredentials(provider="openrouter", keys=openrouter_keys) + + return credentials + + +settings = Settings() diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..e00e303a4ee6a585ba063b400a5b957ac00fb3e3 Binary files /dev/null and b/dump.rdb differ diff --git a/eval/__init__.py b/eval/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b4bfc940e49fffb89bcd2f59ba0b7c4be030207c --- /dev/null +++ b/eval/__init__.py @@ -0,0 +1,24 @@ +from eval.benchmark import BenchmarkDataset, BenchmarkSample +from eval.metrics import ( + OCRMetrics, + ParserMetrics, + PipelineEvalSummary, + SolverMetrics, + compute_cer, + compute_wer, + latex_match, +) +from eval.runner import EvalRunner + +__all__ = [ + "BenchmarkDataset", + "BenchmarkSample", + "OCRMetrics", + "ParserMetrics", + "SolverMetrics", + "PipelineEvalSummary", + "EvalRunner", + "compute_cer", + "compute_wer", + "latex_match", +] diff --git a/eval/benchmark.py b/eval/benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..4a78405fc7872c2fdef3960826d4c128a7aeef61 --- /dev/null +++ b/eval/benchmark.py @@ -0,0 +1,71 @@ +""" +Benchmark Dataset Models and Loader for MathSolver Evaluation. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional +from pydantic import BaseModel, Field + + +class BenchmarkSample(BaseModel): + """Evaluation sample definition representing a standardized geometry problem.""" + id: str = Field(..., description="Unique sample identifier") + category: str = Field(default="geometry", description="Problem category: geometry, algebra, 3d, 2d") + image_url: Optional[str] = Field(default=None, description="Image URL if testing OCR") + problem_text: str = Field(..., description="Canonical Vietnamese/LaTeX problem statement") + expected_type: Optional[str] = Field(default=None, description="Expected shape type (e.g. pyramid, cube)") + expected_entities: Optional[List[str]] = Field(default=None, description="Expected primary entities") + expected_dsl: Optional[str] = Field(default=None, description="Reference Geometry DSL") + expected_answer: Optional[str] = Field(default=None, description="Ground-truth final answer / value") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional reference annotations") + + +class BenchmarkDataset: + """Benchmark dataset container.""" + + def __init__(self, samples: List[BenchmarkSample]): + self.samples = samples + + def __len__(self) -> int: + return len(self.samples) + + def __iter__(self): + return iter(self.samples) + + @classmethod + def from_file(cls, path: str | Path) -> "BenchmarkDataset": + """Loads benchmark samples from a JSON file.""" + file_path = Path(path) + if not file_path.exists(): + raise FileNotFoundError(f"Benchmark file not found: {file_path}") + + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + if isinstance(data, list): + samples = [BenchmarkSample(**item) for item in data] + elif isinstance(data, dict) and "samples" in data: + samples = [BenchmarkSample(**item) for item in data["samples"]] + else: + raise ValueError(f"Unrecognized benchmark dataset format in {file_path}") + + return cls(samples) + + @classmethod + def load_all_standard(cls, base_dir: Optional[Path] = None) -> "BenchmarkDataset": + """Loads all JSON files under eval/datasets/.""" + if base_dir is None: + base_dir = Path(__file__).parent / "datasets" + + all_samples: List[BenchmarkSample] = [] + for json_file in base_dir.rglob("*.json"): + try: + ds = cls.from_file(json_file) + all_samples.extend(ds.samples) + except Exception: + pass + + return cls(all_samples) diff --git a/eval/datasets/README.md b/eval/datasets/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c069a18e5348e1865b546ca1f8c75cf61faa3287 --- /dev/null +++ b/eval/datasets/README.md @@ -0,0 +1,33 @@ +# MathSolver Benchmark Datasets + +Standardized evaluation benchmarks for regression testing, metric tracking, and ablation studies. + +## Dataset Structure + +Each JSON file in `eval/datasets/` contains problem samples adhering to the `BenchmarkSample` schema: + +```json +{ + "id": "geo_01_square_pyramid", + "category": "3d_pyramid", + "problem_text": "Cho hình chóp S.ABCD...", + "expected_type": "pyramid", + "expected_entities": ["S", "A", "B", "C", "D"], + "expected_dsl": "PYRAMID(S_ABCD)\nSQUARE(ABCD)...", + "expected_answer": "32" +} +``` + +## Running Evaluation + +To evaluate deterministic DSL solvability & geometry validator pass rates: + +```python +from eval.benchmark import BenchmarkDataset +from eval.runner import EvalRunner + +dataset = BenchmarkDataset.load_all_standard() +runner = EvalRunner() +metrics = runner.evaluate_dsl_deterministic(dataset) +print(metrics.to_dict()) +``` diff --git a/eval/datasets/geometry/sample_problems.json b/eval/datasets/geometry/sample_problems.json new file mode 100644 index 0000000000000000000000000000000000000000..262a91be2921730881afc6aa7ccc5293bf868694 --- /dev/null +++ b/eval/datasets/geometry/sample_problems.json @@ -0,0 +1,47 @@ +[ + { + "id": "geo_01_square_pyramid", + "category": "3d_pyramid", + "problem_text": "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh a=4. Cạnh bên SA vuông góc với mặt phẳng đáy và SA=6. Tính thể tích khối chóp S.ABCD.", + "expected_type": "pyramid", + "expected_entities": ["S", "A", "B", "C", "D"], + "expected_dsl": "PYRAMID(S_ABCD)\nSQUARE(ABCD)\nLENGTH(AB, 4)\nLENGTH(SA, 6)\nPERPENDICULAR_PLANE(SA, ABCD)", + "expected_answer": "32" + }, + { + "id": "geo_02_cube", + "category": "3d_cube", + "problem_text": "Cho hình lập phương ABCD.A1B1C1D1 có cạnh bằng 5. Tính thể tích khối lập phương.", + "expected_type": "cube", + "expected_entities": ["A", "B", "C", "D", "A1", "B1", "C1", "D1"], + "expected_dsl": "CUBE(ABCD_A1B1C1D1)\nLENGTH(AB, 5)", + "expected_answer": "125" + }, + { + "id": "geo_03_triangular_prism", + "category": "3d_prism", + "problem_text": "Cho lăng trụ đứng ABC.A1B1C1 có đáy ABC là tam giác vuông tại A, AB=3, AC=4. Chiều cao lăng trụ AA1=6. Tính thể tích khối lăng trụ.", + "expected_type": "prism", + "expected_entities": ["A", "B", "C", "A1", "B1", "C1"], + "expected_dsl": "PRISM(ABC_A1B1C1)\nPOINT(A, 0, 0, 0)\nPOINT(B, 3, 0, 0)\nPOINT(C, 0, 4, 0)\nPOINT(A1, 0, 0, 6)\nPOINT(B1, 3, 0, 6)\nPOINT(C1, 0, 4, 6)\nLENGTH(AA1, 6)\nPERPENDICULAR_PLANE(AA1, ABC)", + "expected_answer": "36" + }, + { + "id": "geo_04_cone", + "category": "3d_cone", + "problem_text": "Cho hình nón có bán kính đáy r=3 và chiều cao h=4. Tính thể tích khối nón.", + "expected_type": "cone", + "expected_entities": ["S", "O"], + "expected_dsl": "CONE(S_O, 3, 4)", + "expected_answer": "12*pi" + }, + { + "id": "geo_05_2d_rectangle", + "category": "2d_polygon", + "problem_text": "Cho hình chữ nhật ABCD có AB=6, BC=8. Tính diện tích hình chữ nhật ABCD.", + "expected_type": "rectangle", + "expected_entities": ["A", "B", "C", "D"], + "expected_dsl": "RECTANGLE(ABCD)\nLENGTH(AB, 6)\nLENGTH(BC, 8)", + "expected_answer": "48" + } +] diff --git a/eval/metrics.py b/eval/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..5018df383e6a4d28cecee684cdabf4d669cd8e4e --- /dev/null +++ b/eval/metrics.py @@ -0,0 +1,168 @@ +""" +Evaluation Metrics for MathSolver Pipeline. + +Defines metric calculators across all pipeline stages: +- OCR: Character Error Rate (CER), Word Error Rate (WER), LaTeX Exact Match, Confidence Calibration +- Parser: JSON Validity, DSL Validity, Geometry Solvability, Validation Pass Rate +- Solver: Final Answer Accuracy, SymPy Verification Rate +- End-to-End: E2E Accuracy, Latency, Token / LLM Usage, OCR Correction Rate, Geometry Degradation Rate +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + + +def _levenshtein_distance(s1: str, s2: str) -> int: + """Computes standard Levenshtein edit distance between two strings.""" + if len(s1) < len(s2): + return _levenshtein_distance(s2, s1) + if len(s2) == 0: + return len(s1) + + previous_row = range(len(s2) + 1) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + + return previous_row[-1] + + +def compute_cer(reference: str, hypothesis: str) -> float: + """Character Error Rate (CER).""" + if not reference and not hypothesis: + return 0.0 + if not reference: + return 1.0 + dist = _levenshtein_distance(reference, hypothesis) + return float(dist / max(len(reference), 1)) + + +def compute_wer(reference: str, hypothesis: str) -> float: + """Word Error Rate (WER).""" + ref_words = reference.strip().split() + hyp_words = hypothesis.strip().split() + if not ref_words and not hyp_words: + return 0.0 + if not ref_words: + return 1.0 + + # Word-level edit distance + n, m = len(ref_words), len(hyp_words) + dp = [[0] * (m + 1) for _ in range(n + 1)] + for i in range(n + 1): + dp[i][0] = i + for j in range(m + 1): + dp[0][j] = j + + for i in range(1, n + 1): + for j in range(1, m + 1): + if ref_words[i - 1] == hyp_words[j - 1]: + dp[i][j] = dp[i - 1][j - 1] + else: + dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1) + + return float(dp[n][m] / max(n, 1)) + + +def normalize_latex(formula: str) -> str: + """Normalizes LaTeX whitespace and common variations for comparison.""" + if not formula: + return "" + f = formula.strip() + f = re.sub(r"\s+", "", f) + f = f.replace("\\cdot", "*").replace("\\times", "*") + f = re.sub(r"\\left|\\right", "", f) + return f + + +def latex_match(ref: str, hyp: str) -> bool: + """Checks whether two LaTeX expressions match after normalization.""" + return normalize_latex(ref) == normalize_latex(hyp) + + +@dataclass +class OCRMetrics: + """Aggregated OCR metrics across evaluated samples.""" + total_samples: int = 0 + avg_cer: float = 0.0 + avg_wer: float = 0.0 + latex_exact_match_rate: float = 0.0 + vlm_trigger_rate: float = 0.0 + vlm_correction_rate: float = 0.0 + confidence_calibration_bins: Dict[str, Dict[str, float]] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "total_samples": self.total_samples, + "avg_cer": round(self.avg_cer, 4), + "avg_wer": round(self.avg_wer, 4), + "latex_exact_match_rate": round(self.latex_exact_match_rate, 4), + "vlm_trigger_rate": round(self.vlm_trigger_rate, 4), + "vlm_correction_rate": round(self.vlm_correction_rate, 4), + "confidence_calibration": self.confidence_calibration_bins, + } + + +@dataclass +class ParserMetrics: + """Aggregated Parser metrics.""" + total_samples: int = 0 + json_valid_rate: float = 0.0 + dsl_valid_rate: float = 0.0 + solvability_rate: float = 0.0 + validation_pass_rate: float = 0.0 + degradation_rate: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + return { + "total_samples": self.total_samples, + "json_valid_rate": round(self.json_valid_rate, 4), + "dsl_valid_rate": round(self.dsl_valid_rate, 4), + "solvability_rate": round(self.solvability_rate, 4), + "validation_pass_rate": round(self.validation_pass_rate, 4), + "degradation_rate": round(self.degradation_rate, 4), + } + + +@dataclass +class SolverMetrics: + """Aggregated Solver metrics.""" + total_samples: int = 0 + answer_exact_match_rate: float = 0.0 + sympy_verification_rate: float = 0.0 + + def to_dict(self) -> Dict[str, Any]: + return { + "total_samples": self.total_samples, + "answer_exact_match_rate": round(self.answer_exact_match_rate, 4), + "sympy_verification_rate": round(self.sympy_verification_rate, 4), + } + + +@dataclass +class PipelineEvalSummary: + """Complete End-to-End Evaluation Report.""" + ocr: OCRMetrics = field(default_factory=OCRMetrics) + parser: ParserMetrics = field(default_factory=ParserMetrics) + solver: SolverMetrics = field(default_factory=SolverMetrics) + e2e_success_rate: float = 0.0 + avg_latency_ms: float = 0.0 + total_samples: int = 0 + + def to_dict(self) -> Dict[str, Any]: + return { + "total_samples": self.total_samples, + "e2e_success_rate": round(self.e2e_success_rate, 4), + "avg_latency_ms": round(self.avg_latency_ms, 2), + "ocr_metrics": self.ocr.to_dict(), + "parser_metrics": self.parser.to_dict(), + "solver_metrics": self.solver.to_dict(), + } diff --git a/eval/runner.py b/eval/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..2960f9cc98e19d54531796d32f7327c2b7970399 --- /dev/null +++ b/eval/runner.py @@ -0,0 +1,160 @@ +""" +Pipeline Evaluation Runner. + +Runs evaluation suites over benchmark datasets and computes structured performance metrics. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any, Dict, List, Optional + +from eval.benchmark import BenchmarkDataset, BenchmarkSample +from eval.metrics import ( + OCRMetrics, + ParserMetrics, + PipelineEvalSummary, + SolverMetrics, + compute_cer, + compute_wer, +) +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine +from solver.validator import GeometryStatus, GeometryValidator + +logger = logging.getLogger(__name__) + + +class EvalRunner: + """Runs pipeline evaluation over benchmark datasets.""" + + def __init__( + self, + dsl_parser: Optional[DSLParser] = None, + geometry_engine: Optional[GeometryEngine] = None, + geometry_validator: Optional[GeometryValidator] = None, + ): + self.dsl_parser = dsl_parser or DSLParser() + self.geometry_engine = geometry_engine or GeometryEngine() + self.geometry_validator = geometry_validator or GeometryValidator() + + def evaluate_dsl_deterministic(self, dataset: BenchmarkDataset) -> ParserMetrics: + """ + Evaluates DSL parsing, solving, and geometric invariant validation + deterministically without LLM calls. + """ + total = len(dataset) + if total == 0: + return ParserMetrics() + + valid_dsl_count = 0 + solvable_count = 0 + validated_count = 0 + degraded_count = 0 + + for sample in dataset: + dsl = sample.expected_dsl or "" + if not dsl: + continue + + try: + points, constraints, is_3d = self.dsl_parser.parse(dsl) + valid_dsl_count += 1 + + engine_res = self.geometry_engine.solve(points, constraints, is_3d) + if engine_res and engine_res.get("coordinates"): + solvable_count += 1 + val_res = self.geometry_validator.validate(engine_res, constraints, is_3d) + if val_res.is_valid: + validated_count += 1 + else: + degraded_count += 1 + except Exception as e: + logger.debug(f"[EvalRunner] Sample {sample.id} evaluation error: {e}") + + return ParserMetrics( + total_samples=total, + json_valid_rate=1.0, + dsl_valid_rate=valid_dsl_count / total, + solvability_rate=solvable_count / total, + validation_pass_rate=validated_count / total, + degradation_rate=degraded_count / total, + ) + + async def evaluate_full_pipeline( + self, + dataset: BenchmarkDataset, + orchestrator: Any = None, + ) -> PipelineEvalSummary: + """ + Executes end-to-end evaluation using Orchestrator across benchmark samples. + """ + from agents.orchestrator import Orchestrator + + orch = orchestrator or Orchestrator() + total = len(dataset) + if total == 0: + return PipelineEvalSummary() + + e2e_successes = 0 + total_latency_ms = 0.0 + parser_metrics = ParserMetrics(total_samples=total) + solver_metrics = SolverMetrics(total_samples=total) + ocr_metrics = OCRMetrics(total_samples=total) + + valid_dsl_count = 0 + solvable_count = 0 + validated_count = 0 + degraded_count = 0 + correct_answer_count = 0 + + for sample in dataset: + t0 = time.time() + try: + result = await orch.run( + text=sample.problem_text, + image_url=sample.image_url, + generate_video=False, + ) + latency = (time.time() - t0) * 1000 + total_latency_ms += latency + + if result.get("status") == "success": + e2e_successes += 1 + + # Check geometry status + geo_status = result.get("geometry_status") + if result.get("geometry_dsl"): + valid_dsl_count += 1 + if result.get("coordinates"): + solvable_count += 1 + if geo_status == GeometryStatus.VALID.value: + validated_count += 1 + elif geo_status == GeometryStatus.DEGRADED.value: + degraded_count += 1 + + # Check answer if expected_answer is present + if sample.expected_answer: + actual_ans = str((result.get("solution") or {}).get("answer", "")) + if sample.expected_answer.strip() in actual_ans or actual_ans.strip() in sample.expected_answer: + correct_answer_count += 1 + + except Exception as e: + logger.error(f"[EvalRunner] Full pipeline run failed on sample {sample.id}: {e}") + + parser_metrics.dsl_valid_rate = valid_dsl_count / total + parser_metrics.solvability_rate = solvable_count / total + parser_metrics.validation_pass_rate = validated_count / total + parser_metrics.degradation_rate = degraded_count / total + solver_metrics.answer_exact_match_rate = correct_answer_count / max(total, 1) + + return PipelineEvalSummary( + ocr=ocr_metrics, + parser=parser_metrics, + solver=solver_metrics, + e2e_success_rate=e2e_successes / total, + avg_latency_ms=total_latency_ms / max(total, 1), + total_samples=total, + ) diff --git a/geometry_render/__init__.py b/geometry_render/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e9e8a738f3506634d1227ac4b358ffe10def98c7 --- /dev/null +++ b/geometry_render/__init__.py @@ -0,0 +1,5 @@ +"""Manim geometry script generation and rendering (worker-safe, no LLM agents).""" + +from .renderer import RendererAgent + +__all__ = ["RendererAgent"] diff --git a/geometry_render/renderer.py b/geometry_render/renderer.py new file mode 100644 index 0000000000000000000000000000000000000000..4b802726341dba498d37131fd1f4d4786163c667 --- /dev/null +++ b/geometry_render/renderer.py @@ -0,0 +1,268 @@ +import os +import re +import subprocess +import glob +import string +import logging +from typing import Dict, Any, List + +logger = logging.getLogger(__name__) + + +def _sanitize_var(pid: str) -> str: + """Convert point ID like A' or A_1 to valid Python identifier.""" + sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', str(pid)) + if not sanitized or sanitized[0].isdigit(): + sanitized = f"pt_{sanitized}" + return sanitized + + +class RendererAgent: + """ + Renderer — generates Manim scripts from geometry data. + + Drawing happens in phases: + Phase 1: Main polygon / 3D base shape + Phase 2: Auxiliary points, 3D faces and segments + Phase 3: Labels and 3D solids + """ + + def generate_manim_script(self, data: Dict[str, Any]) -> str: + coords: Dict[str, List[float]] = data.get("coordinates", {}) + polygon_order: List[str] = data.get("polygon_order", []) + circles_meta: List[Dict] = data.get("circles", []) + solids_meta: List[Dict] = data.get("solids", []) + faces_meta: List[List[str]] = data.get("faces", []) + lines_meta: List[List[str]] = data.get("lines", []) + rays_meta: List[List[str]] = data.get("rays", []) + drawing_phases: List[Dict] = data.get("drawing_phases", []) + semantic: Dict[str, Any] = data.get("semantic", {}) + shape_type = semantic.get("type", "").lower() + + # ── Detect 3D Context ──────────────────────────────────────────────── + is_3d = False + for pos in coords.values(): + if len(pos) >= 3 and abs(pos[2]) > 0.001: + is_3d = True + break + if shape_type in ["pyramid", "prism", "sphere", "cube", "cuboid", "tetrahedron", "cone", "cylinder", "frustum", "regular_pyramid", "regular_tetrahedron", "right_prism"] or solids_meta or data.get("is_3d"): + is_3d = True + + # Fallback: infer polygon_order from coords keys + if not polygon_order: + base = sorted( + [pid for pid in coords if pid in string.ascii_uppercase], + key=lambda p: string.ascii_uppercase.index(p) + ) + polygon_order = base if base else list(coords.keys())[:4] + + base_ids = [pid for pid in polygon_order if pid in coords] + derived_ids = [pid for pid in coords if pid not in polygon_order] + + scene_base = "ThreeDScene" if is_3d else "MovingCameraScene" + lines = [ + "from manim import *", + "", + f"class GeometryScene({scene_base}):", + " def construct(self):", + ] + + if is_3d: + lines.append(" # 3D Setup") + lines.append(" self.set_camera_orientation(phi=75*DEGREES, theta=-45*DEGREES)") + lines.append(" axes = ThreeDAxes(axis_config={'stroke_width': 1})") + lines.append(" axes.set_opacity(0.3)") + lines.append(" self.add(axes)") + lines.append(" self.begin_ambient_camera_rotation(rate=0.1)") + lines.append("") + + # ── Declare all dots and labels ─────────────────────────────────────── + for pid, pos in coords.items(): + x, y, z = 0, 0, 0 + if len(pos) >= 1: x = round(pos[0], 4) + if len(pos) >= 2: y = round(pos[1], 4) + if len(pos) >= 3: z = round(pos[2], 4) + + v_name = _sanitize_var(pid) + dot_class = "Dot3D" if is_3d else "Dot" + lines.append(f" p_{v_name} = {dot_class}(point=[{x}, {y}, {z}], color=WHITE, radius=0.08)") + + if is_3d: + lines.append( + f" l_{v_name} = Text('{pid}', font_size=20, color=WHITE)" + f".move_to(p_{v_name}.get_center() + [0.2, 0.2, 0.2])" + ) + lines.append(f" self.add_fixed_orientation_mobjects(l_{v_name})") + else: + lines.append( + f" l_{v_name} = Text('{pid}', font_size=22, color=WHITE)" + f".next_to(p_{v_name}, UR, buff=0.15)" + ) + + # ── 3D Shape Special: Translucent Faces ─────────────────────────────── + if is_3d and faces_meta: + lines.append(" # 3D Faces") + for idx, face in enumerate(faces_meta): + if len(face) >= 3 and all(p in coords for p in face): + face_pts = ", ".join([f"p_{_sanitize_var(p)}.get_center()" for p in face]) + f_var = f"face_{idx}" + lines.append(f" {f_var} = Polygon({face_pts}, color=BLUE, stroke_width=1, fill_color=BLUE, fill_opacity=0.08)") + lines.append(f" self.play(Create({f_var}), run_time=0.4)") + + # ── Circles ────────────────────────────────────────────────────────── + for i, c in enumerate(circles_meta): + center = c["center"] + r = c["radius"] + if center in coords: + cx, cy, cz = 0, 0, 0 + pos = coords[center] + if len(pos) >= 1: cx = round(pos[0], 4) + if len(pos) >= 2: cy = round(pos[1], 4) + if len(pos) >= 3: cz = round(pos[2], 4) + lines.append( + f" circle_{i} = Circle(radius={r}, color=BLUE)" + f".move_to([{cx}, {cy}, {cz}])" + ) + + # ── Infinite Lines & Rays ──────────────────────────────────────────── + for i, (p1, p2) in enumerate(lines_meta): + if p1 in coords and p2 in coords: + v1, v2 = _sanitize_var(p1), _sanitize_var(p2) + lines.append( + f" line_ext_{i} = Line(p_{v1}.get_center(), p_{v2}.get_center(), color=GRAY_D, stroke_width=2)" + f".scale(20)" + ) + + for i, (p1, p2) in enumerate(rays_meta): + if p1 in coords and p2 in coords: + v1, v2 = _sanitize_var(p1), _sanitize_var(p2) + lines.append( + f" ray_{i} = Line(p_{v1}.get_center(), p_{v1}.get_center() + 15 * (p_{v2}.get_center() - p_{v1}.get_center())," + f" color=GRAY_C, stroke_width=2)" + ) + + # ── Camera auto-fit group (Only for 2D) ────────────────────────────── + if not is_3d: + all_dot_names = [f"p_{_sanitize_var(pid)}" for pid in coords] + all_names_str = ", ".join(all_dot_names) + lines.append(f" _all = VGroup({all_names_str})") + lines.append(" self.camera.frame.set_width(max(_all.width * 2.0, 8))") + lines.append(" self.camera.frame.move_to(_all)") + lines.append("") + + # ── Phase 1: Base polygon ───────────────────────────────────────────── + if len(base_ids) >= 3 and not faces_meta: + pts_str = ", ".join([f"p_{_sanitize_var(pid)}.get_center()" for pid in base_ids]) + lines.append(f" poly = Polygon({pts_str}, color=BLUE, fill_color=BLUE, fill_opacity=0.15)") + lines.append(" self.play(Create(poly), run_time=1.5)") + elif len(base_ids) == 2: + p1, p2 = base_ids + v1, v2 = _sanitize_var(p1), _sanitize_var(p2) + lines.append(f" base_line = Line(p_{v1}.get_center(), p_{v2}.get_center(), color=BLUE)") + lines.append(" self.play(Create(base_line), run_time=1.0)") + + # Draw base points + if base_ids: + base_dots_str = ", ".join([f"p_{_sanitize_var(pid)}" for pid in base_ids]) + lines.append(f" self.play(FadeIn(VGroup({base_dots_str})), run_time=0.5)") + lines.append(" self.wait(0.5)") + + # ── Phase 2: Auxiliary points and segments ──────────────────────────── + if derived_ids: + derived_dots_str = ", ".join([f"p_{_sanitize_var(pid)}" for pid in derived_ids]) + lines.append(f" self.play(FadeIn(VGroup({derived_dots_str})), run_time=0.8)") + + # Segments from drawing_phases + segment_lines = [] + for phase in drawing_phases: + for seg in phase.get("segments", []): + if len(seg) == 2 and seg[0] in coords and seg[1] in coords: + p1, p2 = seg[0], seg[1] + v1, v2 = _sanitize_var(p1), _sanitize_var(p2) + seg_var = f"seg_{v1}_{v2}" + if seg_var not in segment_lines: + lines.append( + f" {seg_var} = Line(p_{v1}.get_center(), p_{v2}.get_center()," + f" color={'BLUE' if phase.get('phase') == 1 else 'YELLOW'})" + ) + segment_lines.append(seg_var) + + if segment_lines: + segs_str = ", ".join([f"Create({sv})" for sv in segment_lines[:15]]) # limit batch for smooth animation + lines.append(f" self.play({segs_str}, run_time=1.5)") + if len(segment_lines) > 15: + segs_str_2 = ", ".join([f"Create({sv})" for sv in segment_lines[15:]]) + lines.append(f" self.play({segs_str_2}, run_time=1.0)") + + if derived_ids or segment_lines: + lines.append(" self.wait(0.5)") + + # ── Phase 3: All labels ─────────────────────────────────────────────── + all_labels_str = ", ".join([f"l_{_sanitize_var(pid)}" for pid in coords]) + lines.append(f" self.play(FadeIn(VGroup({all_labels_str})), run_time=0.8)") + + # ── Circles phase ───────────────────────────────────────────────────── + for i in range(len(circles_meta)): + lines.append(f" self.play(Create(circle_{i}), run_time=1.5)") + + # ── Lines & Rays phase ──────────────────────────────────────────────── + if lines_meta or rays_meta: + lr_anims = [] + for i in range(len(lines_meta)): + lr_anims.append(f"Create(line_ext_{i})") + for i in range(len(rays_meta)): + lr_anims.append(f"Create(ray_{i})") + lines.append(f" self.play({', '.join(lr_anims)}, run_time=1.5)") + + lines.append(" self.wait(2)") + + return "\n".join(lines) + + def run_manim(self, script_content: str, job_id: str) -> str: + script_file = f"{job_id}.py" + with open(script_file, "w") as f: + f.write(script_content) + + try: + if os.getenv("MOCK_VIDEO") == "true": + logger.info(f"MOCK_VIDEO is true. Skipping Manim for job {job_id}") + dummy_path = f"videos/{job_id}.mp4" + os.makedirs("videos", exist_ok=True) + with open(dummy_path, "wb") as f: + f.write(b"dummy video content") + return dummy_path + + manim_exe = "manim" + venv_manim = os.path.join(os.getcwd(), "venv", "bin", "manim") + if os.path.exists(venv_manim): + manim_exe = venv_manim + + custom_env = os.environ.copy() + brew_path = "/opt/homebrew/bin:/usr/local/bin" + custom_env["PATH"] = f"{brew_path}:{custom_env.get('PATH', '')}" + + logger.info(f"Running {manim_exe} for job {job_id}...") + result = subprocess.run( + [manim_exe, "-ql", "--media_dir", ".", "-o", f"{job_id}.mp4", script_file, "GeometryScene"], + capture_output=True, + text=True, + env=custom_env, + ) + logger.info(f"Manim STDOUT: {result.stdout}") + if result.returncode != 0: + logger.error(f"Manim STDERR: {result.stderr}") + + for pattern in [f"**/videos/**/{job_id}.mp4", f"**/{job_id}*.mp4"]: + found = glob.glob(pattern, recursive=True) + if found: + logger.info(f"Manim Success: Found {found[0]}") + return found[0] + + logger.error(f"Manim file not found for job {job_id}. Return code: {result.returncode}") + return "" + except Exception as e: + logger.exception(f"Manim Execution Error: {e}") + return "" + finally: + if os.path.exists(script_file): + os.remove(script_file) diff --git a/llm/__init__.py b/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..137697d5e2706c62341cfb2fa98a670b8a53e736 --- /dev/null +++ b/llm/__init__.py @@ -0,0 +1,25 @@ +from llm.errors import ErrorCategory, ErrorClassifier +from llm.key_state import KeyState, KeyMetadata, BaseKeyStateStore, RedisKeyStateStore, MemoryKeyStateStore, hash_key +from llm.key_pool import APIKeyPool, get_key_pool +from llm.retry import KeyRetryPolicy +from llm.telemetry import LLMTelemetryRecord, LLMTelemetry, telemetry +from llm.service import LLMService, get_llm_service + +__all__ = [ + "ErrorCategory", + "ErrorClassifier", + "KeyState", + "KeyMetadata", + "BaseKeyStateStore", + "RedisKeyStateStore", + "MemoryKeyStateStore", + "hash_key", + "APIKeyPool", + "get_key_pool", + "KeyRetryPolicy", + "LLMTelemetryRecord", + "LLMTelemetry", + "telemetry", + "LLMService", + "get_llm_service", +] diff --git a/llm/errors.py b/llm/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..57d7f5e7e4711f1f360afe18e5e3a58ff5a165a8 --- /dev/null +++ b/llm/errors.py @@ -0,0 +1,69 @@ +import re +import logging +from enum import Enum +from typing import Optional + +logger = logging.getLogger(__name__) + + +class ErrorCategory(str, Enum): + RATE_LIMIT = "RATE_LIMIT" + QUOTA_EXHAUSTED = "QUOTA_EXHAUSTED" + AUTH_ERROR = "AUTH_ERROR" + INVALID_REQUEST = "INVALID_REQUEST" + TIMEOUT = "TIMEOUT" + NETWORK = "NETWORK" + SERVER_ERROR = "SERVER_ERROR" + UNKNOWN = "UNKNOWN" + + +class ErrorClassifier: + """Classifies exceptions from LiteLLM and HTTP client into structured ErrorCategory.""" + + @staticmethod + def classify(exc: Exception) -> ErrorCategory: + err_msg = str(exc).lower() + err_type = type(exc).__name__.lower() + + # 1. Authentication / Permission errors (Disable key) + if any(w in err_msg for w in ["invalid_api_key", "invalid api key", "unauthorized", "authentication", "api_key_invalid", "permission_denied"]) or "auth" in err_type: + return ErrorCategory.AUTH_ERROR + + # 2. Permanent Quota Exhaustion + if any(w in err_msg for w in ["daily quota", "quota exceeded", "billing", "credit", "insufficient_quota"]): + return ErrorCategory.QUOTA_EXHAUSTED + + # 3. Rate Limit / Resource Exhausted (Temporary Cooldown) + if any(w in err_msg for w in ["rate limit", "ratelimit", "429", "resource_exhausted", "too many requests"]) or "ratelimit" in err_type: + return ErrorCategory.RATE_LIMIT + + # 4. Timeout errors + if any(w in err_msg for w in ["timeout", "timed out", "deadline_exceeded"]) or "timeout" in err_type: + return ErrorCategory.TIMEOUT + + # 5. Network / Connection errors + if any(w in err_msg for w in ["connection error", "connection reset", "broken pipe", "connect_error", "remotedisconnected"]): + return ErrorCategory.NETWORK + + # 6. Server errors (500, 502, 503, 504) + if any(w in err_msg for w in ["500", "502", "503", "504", "internal server error", "bad gateway", "service unavailable", "overloaded"]): + return ErrorCategory.SERVER_ERROR + + # 7. Invalid Request (e.g. context length exceeded, bad params - do not key-retry) + if any(w in err_msg for w in ["context_length_exceeded", "maximum context length", "invalid_request_error", "bad request", "400"]): + return ErrorCategory.INVALID_REQUEST + + return ErrorCategory.UNKNOWN + + @staticmethod + def extract_retry_after(exc: Exception) -> Optional[int]: + """Attempts to extract Retry-After duration from exception message or headers.""" + err_msg = str(exc) + # Check for patterns like "retry after 45s" or "Retry-After: 30" + match = re.search(r"(?:retry[-_ ]after|retry in)\s*:?\s*(\d+)", err_msg, re.IGNORECASE) + if match: + try: + return int(match.group(1)) + except ValueError: + pass + return None diff --git a/llm/key_pool.py b/llm/key_pool.py new file mode 100644 index 0000000000000000000000000000000000000000..2cc6932955934b8c7cbe2e4bb26eff42896e7a9f --- /dev/null +++ b/llm/key_pool.py @@ -0,0 +1,153 @@ +import time +import random +import logging +from typing import Dict, List, Optional, Tuple +from llm.key_state import KeyState, KeyMetadata, BaseKeyStateStore, RedisKeyStateStore, MemoryKeyStateStore, hash_key +from config.settings import settings + +logger = logging.getLogger(__name__) + + +class APIKeyPool: + """Multi-Provider API Key Pool with Round-Robin Selection and Automatic State Management.""" + + def __init__(self, state_store: Optional[BaseKeyStateStore] = None): + if state_store is not None: + self.state_store = state_store + else: + try: + self.state_store = RedisKeyStateStore(settings.redis_url) + except Exception: + self.state_store = MemoryKeyStateStore() + + self._provider_keys: Dict[str, List[str]] = {} + self._provider_indices: Dict[str, int] = {} + self._init_from_settings() + + def _init_from_settings(self) -> None: + creds = settings.get_provider_credentials() + for provider, prov_cred in creds.items(): + self._provider_keys[provider] = list(prov_cred.keys) + self._provider_indices[provider] = 0 + logger.info(f"[APIKeyPool] Initialized pool for '{provider}' with {len(prov_cred.keys)} keys") + + def register_keys(self, provider: str, keys: List[str]) -> None: + if provider not in self._provider_keys: + self._provider_keys[provider] = [] + self._provider_indices[provider] = 0 + for k in keys: + if k and k not in self._provider_keys[provider]: + self._provider_keys[provider].append(k) + + async def get_next_key(self, provider: str) -> Optional[Tuple[str, str]]: + """ + Returns (api_key, key_hash) for an AVAILABLE key using round-robin. + If all keys are on cooldown, returns the one with the earliest retry_at. + If no keys configured, returns None. + """ + keys = self._provider_keys.get(provider, []) + if not keys: + # Check fallback to gemini or any available + if provider == "google": + keys = self._provider_keys.get("gemini", []) + elif provider in ("openai", "openrouter"): + keys = self._provider_keys.get(provider, []) + + if not keys: + return None + + n = len(keys) + start_idx = self._provider_indices.get(provider, 0) + + # 1. Round-robin search for AVAILABLE key + for offset in range(n): + idx = (start_idx + offset) % n + candidate_key = keys[idx] + k_hash = hash_key(candidate_key) + meta = await self.state_store.get_state(k_hash) + + if meta.state == KeyState.AVAILABLE: + self._provider_indices[provider] = (idx + 1) % n + return candidate_key, k_hash + + # 2. If all keys are on cooldown/exhausted, find the earliest cooldown recovery + best_candidate: Optional[Tuple[str, str, float]] = None + for candidate_key in keys: + k_hash = hash_key(candidate_key) + meta = await self.state_store.get_state(k_hash) + if meta.state == KeyState.COOLDOWN: + if best_candidate is None or meta.retry_at < best_candidate[2]: + best_candidate = (candidate_key, k_hash, meta.retry_at) + + if best_candidate: + candidate_key, k_hash, retry_at = best_candidate + now = time.time() + wait_needed = max(0.0, retry_at - now) + logger.warning( + f"[APIKeyPool] All {provider} keys on cooldown. Key {k_hash} available in {wait_needed:.1f}s" + ) + # If wait is very short (< 3s), use it + if wait_needed < 3.0: + return candidate_key, k_hash + + # Return first non-disabled key as emergency attempt + for candidate_key in keys: + k_hash = hash_key(candidate_key) + meta = await self.state_store.get_state(k_hash) + if meta.state != KeyState.DISABLED: + return candidate_key, k_hash + + return None + + async def mark_success(self, key: str) -> None: + k_hash = hash_key(key) + meta = await self.state_store.get_state(k_hash) + meta.state = KeyState.AVAILABLE + meta.failure_count = 0 + meta.last_error = None + await self.state_store.set_state(k_hash, meta) + + async def mark_cooldown(self, key: str, retry_after: Optional[int] = None, error_msg: Optional[str] = None) -> None: + k_hash = hash_key(key) + meta = await self.state_store.get_state(k_hash) + meta.failure_count += 1 + + # Exponential backoff with jitter + base_cooldown = retry_after if retry_after else settings.llm_cooldown_seconds + factor = min(2 ** (meta.failure_count - 1), 8) + jitter = random.uniform(0.8, 1.2) + total_duration = int(base_cooldown * factor * jitter) + + meta.state = KeyState.COOLDOWN + meta.retry_at = time.time() + total_duration + meta.last_error = error_msg + logger.warning(f"[APIKeyPool] Key {k_hash} moved to COOLDOWN for {total_duration}s (failures={meta.failure_count})") + await self.state_store.set_state(k_hash, meta, ttl_seconds=total_duration + 60) + + async def mark_exhausted(self, key: str, error_msg: Optional[str] = None) -> None: + k_hash = hash_key(key) + meta = await self.state_store.get_state(k_hash) + meta.state = KeyState.EXHAUSTED + meta.last_error = error_msg + # Cooldown for 6 hours + meta.retry_at = time.time() + 21600 + logger.error(f"[APIKeyPool] Key {k_hash} marked EXHAUSTED (daily quota): {error_msg}") + await self.state_store.set_state(k_hash, meta, ttl_seconds=21600) + + async def mark_disabled(self, key: str, error_msg: Optional[str] = None) -> None: + k_hash = hash_key(key) + meta = await self.state_store.get_state(k_hash) + meta.state = KeyState.DISABLED + meta.last_error = error_msg + logger.error(f"[APIKeyPool] Key {k_hash} DISABLED permanently (Auth/Invalid): {error_msg}") + await self.state_store.set_state(k_hash, meta) + + +_GLOBAL_KEY_POOL: Optional[APIKeyPool] = None + + +def get_key_pool() -> APIKeyPool: + global _GLOBAL_KEY_POOL + if _GLOBAL_KEY_POOL is None: + _GLOBAL_KEY_POOL = APIKeyPool() + return _GLOBAL_KEY_POOL diff --git a/llm/key_state.py b/llm/key_state.py new file mode 100644 index 0000000000000000000000000000000000000000..05d218970d8370309d5131f9d0e3607f6e2bbfeb --- /dev/null +++ b/llm/key_state.py @@ -0,0 +1,146 @@ +import time +import json +import hashlib +import logging +from abc import ABC, abstractmethod +from enum import Enum +from typing import Optional, Dict +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class KeyState(str, Enum): + AVAILABLE = "AVAILABLE" + COOLDOWN = "COOLDOWN" + EXHAUSTED = "EXHAUSTED" + DISABLED = "DISABLED" + + +def hash_key(api_key: str) -> str: + """Returns SHA-256 short fingerprint of API key for safe identification & logging.""" + if not api_key: + return "empty" + digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest() + return f"key_{digest[:12]}" + + +class KeyMetadata(BaseModel): + key_hash: str + state: KeyState = KeyState.AVAILABLE + retry_at: float = 0.0 + failure_count: int = 0 + last_error: Optional[str] = None + updated_at: float = Field(default_factory=time.time) + + +class BaseKeyStateStore(ABC): + @abstractmethod + async def get_state(self, key_hash: str) -> KeyMetadata: + pass + + @abstractmethod + async def set_state(self, key_hash: str, metadata: KeyMetadata, ttl_seconds: Optional[int] = None) -> None: + pass + + +class MemoryKeyStateStore(BaseKeyStateStore): + """Thread-safe In-Memory Key State Store.""" + + def __init__(self): + self._store: Dict[str, KeyMetadata] = {} + + async def get_state(self, key_hash: str) -> KeyMetadata: + now = time.time() + meta = self._store.get(key_hash) + if not meta: + meta = KeyMetadata(key_hash=key_hash) + self._store[key_hash] = meta + return meta + + # Auto-recover from cooldown if time has passed + if meta.state == KeyState.COOLDOWN and now >= meta.retry_at: + meta.state = KeyState.AVAILABLE + meta.last_error = None + meta.updated_at = now + self._store[key_hash] = meta + return meta + + async def set_state(self, key_hash: str, metadata: KeyMetadata, ttl_seconds: Optional[int] = None) -> None: + metadata.updated_at = time.time() + self._store[key_hash] = metadata + + +class RedisKeyStateStore(BaseKeyStateStore): + """Distributed Redis Key State Store with fallback to Memory store.""" + + def __init__(self, redis_url: str): + self.redis_url = redis_url + self._redis = None + self._redis_disabled = False + self._memory_fallback = MemoryKeyStateStore() + self._prefix = "mathsolver:llm:key:" + + def _get_redis(self): + if self._redis_disabled: + return None + if self._redis is None: + try: + import redis.asyncio as aioredis + self._redis = aioredis.from_url(self.redis_url, decode_responses=True, socket_connect_timeout=2.0) + except Exception as e: + self._redis_disabled = True + logger.info(f"[RedisKeyStateStore] Redis unavailable ({e}). Using in-memory fallback store.") + return self._redis + + async def get_state(self, key_hash: str) -> KeyMetadata: + if self._redis_disabled: + return await self._memory_fallback.get_state(key_hash) + + r = self._get_redis() + if not r: + return await self._memory_fallback.get_state(key_hash) + + try: + raw = await r.get(f"{self._prefix}{key_hash}") + if not raw: + meta = KeyMetadata(key_hash=key_hash) + return meta + data = json.loads(raw) + meta = KeyMetadata(**data) + + # Auto-recover from cooldown + now = time.time() + if meta.state == KeyState.COOLDOWN and now >= meta.retry_at: + meta.state = KeyState.AVAILABLE + meta.last_error = None + meta.updated_at = now + await self.set_state(key_hash, meta) + return meta + except Exception as e: + self._redis_disabled = True + logger.info(f"[RedisKeyStateStore] Redis connection failed ({e}). Switching to in-memory store.") + return await self._memory_fallback.get_state(key_hash) + + async def set_state(self, key_hash: str, metadata: KeyMetadata, ttl_seconds: Optional[int] = None) -> None: + metadata.updated_at = time.time() + if self._redis_disabled: + await self._memory_fallback.set_state(key_hash, metadata, ttl_seconds) + return + + r = self._get_redis() + if not r: + await self._memory_fallback.set_state(key_hash, metadata, ttl_seconds) + return + + try: + payload = metadata.model_dump_json() + r_key = f"{self._prefix}{key_hash}" + if ttl_seconds and ttl_seconds > 0: + await r.set(r_key, payload, ex=ttl_seconds) + else: + await r.set(r_key, payload) + except Exception as e: + self._redis_disabled = True + await self._memory_fallback.set_state(key_hash, metadata, ttl_seconds) + diff --git a/llm/retry.py b/llm/retry.py new file mode 100644 index 0000000000000000000000000000000000000000..455c7328522633da211b5705c14d469bf6d6bd59 --- /dev/null +++ b/llm/retry.py @@ -0,0 +1,22 @@ +import asyncio +import logging +from typing import Callable, Any, Optional +from llm.errors import ErrorClassifier, ErrorCategory +from llm.key_pool import APIKeyPool + +logger = logging.getLogger(__name__) + + +class KeyRetryPolicy: + """Level 1 Key-level retry policy for the same model across available keys.""" + + def __init__(self, max_key_attempts: int = 3): + self.max_key_attempts = max_key_attempts + + def should_retry(self, category: ErrorCategory) -> bool: + return category in ( + ErrorCategory.RATE_LIMIT, + ErrorCategory.TIMEOUT, + ErrorCategory.NETWORK, + ErrorCategory.SERVER_ERROR, + ) diff --git a/llm/service.py b/llm/service.py new file mode 100644 index 0000000000000000000000000000000000000000..979dc9f36a5539f6673837df0a09d662f76de759 --- /dev/null +++ b/llm/service.py @@ -0,0 +1,239 @@ +import os +import re +import time +import uuid +import logging +from typing import List, Dict, Any, Optional, AsyncGenerator +import litellm +from llm.key_pool import APIKeyPool, get_key_pool +from llm.errors import ErrorClassifier, ErrorCategory +from llm.telemetry import telemetry, LLMTelemetryRecord +from config.settings import settings + +logger = logging.getLogger(__name__) + +# Suppress noisy LiteLLM logs +litellm.suppress_debug_info = True + + +class LLMService: + """Core LLM Service providing LiteLLM Provider Abstraction, Key Pool Rotation, and First-Chunk Safe Streaming.""" + + def __init__(self, key_pool: Optional[APIKeyPool] = None): + self.key_pool = key_pool or get_key_pool() + + def _parse_provider_and_model(self, raw_model: str) -> tuple[str, str]: + """Extracts provider and normalized model identifier (e.g. 'gemini/gemini-2.5-flash' -> ('gemini', 'gemini/gemini-2.5-flash')).""" + raw = raw_model.strip() + if "/" in raw: + parts = raw.split("/", 1) + provider = parts[0].lower() + return provider, raw + else: + # Default to gemini if not prefixed + return "gemini", f"gemini/{raw}" + + def _strip_thought_tags(self, text: str) -> str: + """Strips ... tags from thinking models for clean downstream consumption.""" + if not text or not isinstance(text, str): + return text + cleaned = re.sub(r"[\s\S]*?", "", text, flags=re.IGNORECASE).strip() + cleaned = re.sub(r"[\s\S]*?", "", cleaned, flags=re.IGNORECASE).strip() + return cleaned + + async def acomplete( + self, + model: str, + messages: List[Dict[str, Any]], + temperature: float = 0.2, + max_tokens: int = 8192, + timeout: int = 120, + response_format: Optional[Dict[str, Any]] = None, + reasoning_effort: Optional[str] = None, + agent_name: str = "general", + tier_index: int = 1, + **kwargs + ) -> str: + """ + Executes an LLM completion with Level-1 Key Pool rotation and safe retry. + """ + provider, model_name = self._parse_provider_and_model(model) + request_id = str(uuid.uuid4()) + max_key_retries = 3 + last_error = None + + for attempt in range(max_key_retries): + key_info = await self.key_pool.get_next_key(provider) + if not key_info: + # No keys available, attempt with default env or raise + api_key = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "" + key_hash = "env_default" + else: + api_key, key_hash = key_info + + start_time = time.time() + try: + # For Gemini 3.x models, Google AI Studio recommends temperature=1.0 for optimal reasoning + eff_temperature = 1.0 if ("gemini-3" in model_name and temperature < 1.0) else temperature + + litellm_kwargs: Dict[str, Any] = { + "model": model_name, + "messages": messages, + "temperature": eff_temperature, + "max_tokens": max_tokens, + "timeout": timeout, + "api_key": api_key, + } + if response_format: + litellm_kwargs["response_format"] = response_format + if reasoning_effort and "gemini" in model_name: + litellm_kwargs["reasoning_effort"] = reasoning_effort + + # Forward custom kwargs + litellm_kwargs.update(kwargs) + + response = await litellm.acompletion(**litellm_kwargs) + latency_ms = (time.time() - start_time) * 1000 + + # Mark key success + if key_info: + await self.key_pool.mark_success(api_key) + + content = response.choices[0].message.content or "" + cleaned_content = self._strip_thought_tags(content) + + # Token usage + in_tok = getattr(getattr(response, "usage", None), "prompt_tokens", 0) + out_tok = getattr(getattr(response, "usage", None), "completion_tokens", 0) + + telemetry.log_record( + LLMTelemetryRecord( + request_id=request_id, + agent=agent_name, + tier=tier_index, + model=model_name, + provider=provider, + key_id=key_hash, + latency_ms=latency_ms, + input_tokens=in_tok, + output_tokens=out_tok, + status="success", + retry_count=attempt, + ) + ) + return cleaned_content + + except Exception as e: + latency_ms = (time.time() - start_time) * 1000 + category = ErrorClassifier.classify(e) + retry_after = ErrorClassifier.extract_retry_after(e) + last_error = e + + logger.warning( + f"[LLMService] Request {request_id[:8]} failed on {key_hash} ({category.value}): {e}" + ) + + # Update key state according to category + if key_info: + if category == ErrorCategory.RATE_LIMIT: + await self.key_pool.mark_cooldown(api_key, retry_after=retry_after, error_msg=str(e)) + elif category == ErrorCategory.QUOTA_EXHAUSTED: + await self.key_pool.mark_exhausted(api_key, error_msg=str(e)) + elif category == ErrorCategory.AUTH_ERROR: + await self.key_pool.mark_disabled(api_key, error_msg=str(e)) + else: + await self.key_pool.mark_cooldown(api_key, retry_after=15, error_msg=str(e)) + + telemetry.log_record( + LLMTelemetryRecord( + request_id=request_id, + agent=agent_name, + tier=tier_index, + model=model_name, + provider=provider, + key_id=key_hash, + latency_ms=latency_ms, + status="retry" if attempt < max_key_retries - 1 else "failed", + retry_count=attempt + 1, + error=str(e), + ) + ) + + # Non-retryable request errors should immediately fail up to cascade + if category == ErrorCategory.INVALID_REQUEST: + raise + + # All key retries exhausted for this tier + raise last_error or RuntimeError(f"All API key retries failed for model {model}") + + async def astream( + self, + model: str, + messages: List[Dict[str, Any]], + temperature: float = 0.2, + max_tokens: int = 8192, + timeout: int = 120, + agent_name: str = "general", + **kwargs + ) -> AsyncGenerator[str, None]: + """ + Streaming with first-chunk dry-run protection to prevent duplicated token generation upon key retry. + """ + provider, model_name = self._parse_provider_and_model(model) + max_key_retries = 3 + + for attempt in range(max_key_retries): + key_info = await self.key_pool.get_next_key(provider) + api_key = key_info[0] if key_info else (os.getenv("GOOGLE_API_KEY") or "") + key_hash = key_info[1] if key_info else "env_default" + + try: + response = await litellm.acompletion( + model=model_name, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + api_key=api_key, + stream=True, + **kwargs + ) + + # Dry-run receive first chunk + first_chunk = None + async for chunk in response: + delta = chunk.choices[0].delta.content or "" + if delta: + first_chunk = delta + break + + if key_info: + await self.key_pool.mark_success(api_key) + + # First chunk succeeded, start yielding without retries + if first_chunk: + yield first_chunk + + async for chunk in response: + delta = chunk.choices[0].delta.content or "" + if delta: + yield delta + return + + except Exception as e: + category = ErrorClassifier.classify(e) + logger.warning(f"[LLMService.astream] Stream attempt {attempt+1} failed on {key_hash}: {e}") + if key_info: + await self.key_pool.mark_cooldown(api_key, error_msg=str(e)) + if attempt == max_key_retries - 1: + raise + + +_GLOBAL_LLM_SERVICE: Optional[LLMService] = None + + +def get_llm_service() -> LLMService: + global _GLOBAL_LLM_SERVICE + if _GLOBAL_LLM_SERVICE is None: + _GLOBAL_LLM_SERVICE = LLMService() + return _GLOBAL_LLM_SERVICE diff --git a/llm/telemetry.py b/llm/telemetry.py new file mode 100644 index 0000000000000000000000000000000000000000..e27383a11688dae259c4901498265515803fcc33 --- /dev/null +++ b/llm/telemetry.py @@ -0,0 +1,38 @@ +import time +import uuid +import logging +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class LLMTelemetryRecord(BaseModel): + request_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + agent: str + tier: int = 1 + model: str + provider: str + key_id: str + latency_ms: float = 0.0 + input_tokens: int = 0 + output_tokens: int = 0 + status: str = "success" # success, retry, failed + retry_count: int = 0 + error: Optional[str] = None + + +class LLMTelemetry: + """Safe LLM observability and logging.""" + + @staticmethod + def log_record(record: LLMTelemetryRecord) -> None: + logger.info( + f"[LLMTelemetry] req={record.request_id[:8]} agent={record.agent} tier={record.tier} " + f"model={record.model} key={record.key_id} lat={record.latency_ms:.0f}ms " + f"in_tok={record.input_tokens} out_tok={record.output_tokens} status={record.status} " + f"retries={record.retry_count}" + ) + + +telemetry = LLMTelemetry() diff --git a/manim_client/__init__.py b/manim_client/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fd3f67df6f6b88f44de5e9c346cba04eb89dfac0 --- /dev/null +++ b/manim_client/__init__.py @@ -0,0 +1,22 @@ +"""Manim Client module for MathSolver.""" +from manim_client.client import ManimClient +from manim_client.schemas import ( + AnimationDirective, + GeometryObject, + MathRenderRequest, + MathRenderResponse, + OutputConfig, + VisualizationSpec, + build_visualization_spec, +) + +__all__ = [ + "ManimClient", + "VisualizationSpec", + "GeometryObject", + "AnimationDirective", + "OutputConfig", + "MathRenderRequest", + "MathRenderResponse", + "build_visualization_spec", +] diff --git a/manim_client/client.py b/manim_client/client.py new file mode 100644 index 0000000000000000000000000000000000000000..c0594ad10beea3de8b0631549dde68633140e375 --- /dev/null +++ b/manim_client/client.py @@ -0,0 +1,238 @@ +"""Async Client for interacting with the external Manim Video Generation Module.""" +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any, Dict, Optional, Union +from uuid import UUID +import httpx +from dotenv import load_dotenv + +load_dotenv() +logger = logging.getLogger(__name__) + +from manim_client.schemas import ( + ErrorCode, + MathRenderRequest, + MathRenderResponse, + StructuredError, + VisualizationSpec, +) + +DEFAULT_MANIM_SERVICE_URL = os.getenv("MANIM_SERVICE_URL", "http://127.0.0.1:8001") +DEFAULT_INTERNAL_TOKEN = os.getenv( + "MANIM_INTERNAL_TOKEN", + "4f8a3c2e1d0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4", +) + + +class ManimClient: + """ + Client connecting MathSolver to the external Manim Video Generation Agent. + + API Boundary: + - POST /v1/math/generate: Submit VisualizationSpec + - GET /v1/math/jobs/{job_id}: Poll video rendering status and get video_url + """ + + def __init__( + self, + base_url: Optional[str] = None, + internal_token: Optional[str] = None, + timeout: float = 30.0, + ): + self.base_url = (base_url or DEFAULT_MANIM_SERVICE_URL).rstrip("/") + self.internal_token = internal_token or DEFAULT_INTERNAL_TOKEN + self.timeout = timeout + + def _headers(self) -> Dict[str, str]: + return { + "Content-Type": "application/json", + "X-Internal-Token": self.internal_token, + } + + async def check_health(self) -> bool: + """Checks if the Manim video generation service is reachable.""" + try: + async with httpx.AsyncClient(timeout=3.0) as client: + resp = await client.get(f"{self.base_url}/health") + return resp.status_code == 200 + except Exception: + return False + + async def submit_render_job( + self, + spec: VisualizationSpec, + callback_url: Optional[str] = None, + ) -> MathRenderResponse: + """ + Submits a VisualizationSpec to the Manim service to queue video generation. + Endpoint: POST /v1/math/generate + """ + url = f"{self.base_url}/v1/math/generate" + spec_payload = spec.to_manim_dict() if hasattr(spec, "to_manim_dict") else spec.model_dump(mode="json") + payload = {"spec": spec_payload, "callback_url": callback_url} + + logger.info(f"==[ManimClient] Submitting render job to {url}==") + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + url, + json=payload, + headers=self._headers(), + ) + + if response.status_code in (200, 201, 202): + data = response.json() + job_id = data.get("job_id") + if not job_id: + return MathRenderResponse( + job_id="error", + status="failed", + error=StructuredError( + code=ErrorCode.MANIM_REQUEST_FAILED, + message="Manim service did not return a valid job_id.", + ), + ) + logger.info( + f"[ManimClient] Render job queued successfully: job_id={job_id}, status={data.get('status')}" + ) + return MathRenderResponse.model_validate(data) + else: + error_msg = f"HTTP {response.status_code}: {response.text[:200]}" + logger.warning(f"[ManimClient] Error response from Manim service: {error_msg}") + return MathRenderResponse( + job_id="error", + status="failed", + error=StructuredError( + code=ErrorCode.MANIM_REQUEST_FAILED, + message=f"Dịch vụ tạo video trả về mã lỗi HTTP {response.status_code}.", + ), + ) + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + logger.warning(f"[ManimClient] Connection to Manim service at {self.base_url} failed: {e}") + return MathRenderResponse( + job_id="offline", + status="failed", + error=StructuredError( + code=ErrorCode.MANIM_UNAVAILABLE, + message="Không thể kết nối đến dịch vụ tạo video Manim (máy chủ ngoại vi không khả dụng).", + ), + ) + except httpx.TimeoutException as e: + logger.warning(f"[ManimClient] Request to Manim service timed out: {e}") + return MathRenderResponse( + job_id="timeout", + status="failed", + error=StructuredError( + code=ErrorCode.MANIM_TIMEOUT, + message="Yêu cầu gửi sang dịch vụ tạo video đã hết thời gian chờ (request timeout).", + ), + ) + except Exception as e: + logger.exception(f"[ManimClient] Unexpected error submitting render job: {e}") + return MathRenderResponse( + job_id="error", + status="failed", + error=StructuredError( + code=ErrorCode.INTERNAL_ERROR, + message="Đã xảy ra lỗi không xác định khi yêu cầu tạo video.", + ), + ) + + async def get_job_status(self, job_id: Union[UUID, str]) -> MathRenderResponse: + """ + Polls the status of a video generation job. + Endpoint: GET /v1/math/jobs/{job_id} + """ + url = f"{self.base_url}/v1/math/jobs/{job_id}" + logger.debug(f"[ManimClient] Fetching status for job {job_id}") + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(url, headers=self._headers()) + if response.status_code == 200: + data = response.json() + resp = MathRenderResponse.model_validate(data) + if resp.status == "failed" and not isinstance(resp.error, StructuredError): + raw_err = resp.get_error_message() or "Animation rendering failed." + resp.error = StructuredError( + code=ErrorCode.MANIM_RENDER_FAILED, + message=raw_err, + ) + return resp + elif response.status_code == 404: + return MathRenderResponse( + job_id=job_id, + status="failed", + error=StructuredError( + code=ErrorCode.JOB_NOT_FOUND, + message=f"Không tìm thấy tiến trình render video với ID '{job_id}'.", + ), + ) + else: + return MathRenderResponse( + job_id=job_id, + status="failed", + error=StructuredError( + code=ErrorCode.MANIM_REQUEST_FAILED, + message=f"Lỗi kiểm tra tiến trình: HTTP {response.status_code}.", + ), + ) + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + return MathRenderResponse( + job_id=job_id, + status="failed", + error=StructuredError( + code=ErrorCode.MANIM_UNAVAILABLE, + message="Không thể kết nối đến máy chủ render video để kiểm tra trạng thái.", + ), + ) + except Exception as e: + return MathRenderResponse( + job_id=job_id, + status="failed", + error=StructuredError( + code=ErrorCode.INTERNAL_ERROR, + message=f"Lỗi kiểm tra trạng thái: {str(e)}", + ), + ) + + async def poll_job_completion( + self, + job_id: Union[UUID, str], + timeout: float = 300.0, + poll_interval: float = 3.0, + ) -> MathRenderResponse: + """ + Asynchronously polls until the job reaches a terminal status ('completed' or 'failed') or times out. + """ + start_time = asyncio.get_event_loop().time() + while True: + resp = await self.get_job_status(job_id) + if resp.is_terminal(): + return resp + + elapsed = asyncio.get_event_loop().time() - start_time + if elapsed >= timeout: + logger.warning(f"[ManimClient] Polling for job {job_id} timed out after {timeout:.1f}s") + return MathRenderResponse( + job_id=job_id, + status="failed", + error=StructuredError( + code=ErrorCode.MANIM_TIMEOUT, + message=f"Tiến trình dựng video đã vượt quá thời gian tối đa ({int(timeout)} giây).", + ), + ) + + await asyncio.sleep(poll_interval) + + async def wait_for_completion( + self, + job_id: Union[UUID, str], + poll_interval: float = 3.0, + max_wait: float = 300.0, + ) -> MathRenderResponse: + """Alias for poll_job_completion for API compatibility.""" + return await self.poll_job_completion(job_id=job_id, timeout=max_wait, poll_interval=poll_interval) diff --git a/manim_client/schemas.py b/manim_client/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..1171ec7d3cca6b640b24526431e4f49fa1ac4440 --- /dev/null +++ b/manim_client/schemas.py @@ -0,0 +1,390 @@ +"""Cross-service schema definitions for Manim Video Generation Module.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional, Union +from uuid import UUID +from pydantic import BaseModel, ConfigDict, Field + + +class ErrorCode: + GEOMETRY_INVALID = "GEOMETRY_INVALID" + GEOMETRY_VALIDATION_FAILED = "GEOMETRY_VALIDATION_FAILED" + MANIM_UNAVAILABLE = "MANIM_UNAVAILABLE" + MANIM_REQUEST_FAILED = "MANIM_REQUEST_FAILED" + MANIM_RENDER_FAILED = "MANIM_RENDER_FAILED" + MANIM_TIMEOUT = "MANIM_TIMEOUT" + JOB_NOT_FOUND = "JOB_NOT_FOUND" + INTERNAL_ERROR = "INTERNAL_ERROR" + + +class StructuredError(BaseModel): + """Machine-readable and user-facing structured error format.""" + + model_config = ConfigDict(extra="ignore") + + code: str = Field(default=ErrorCode.INTERNAL_ERROR, description="Standard error code") + message: str = Field(default="An unexpected error occurred.", description="User-safe error message") + details: Optional[Dict[str, Any]] = Field(default=None, description="Safe additional error context") + + def to_dict(self) -> Dict[str, Any]: + res: Dict[str, Any] = {"code": self.code, "message": self.message} + if self.details: + res["details"] = self.details + return res + + +class GeometryObject(BaseModel): + """A single geometric entity to be visualized in Manim.""" + + model_config = ConfigDict(extra="ignore") + + type: str = Field(description="Geometry type: circle, triangle, line, point, polygon, pyramid, prism, etc.") + label: Optional[str] = Field(default=None, description="Identifier or label: A, B, C, S, O, etc.") + properties: Dict[str, Any] = Field( + default_factory=dict, + description="Coordinates, dimensions, colors, or constraints (e.g. {'coordinates': [0, 0, 0]})", + ) + + +class AnimationDirective(BaseModel): + """One animation beat requested by the Math Agent.""" + + model_config = ConfigDict(extra="ignore") + + action: str = Field(description="Animation action: draw, highlight, transform, fade_in, fade_out, write, rotate_camera, etc.") + targets: List[str] = Field(default_factory=list, description="Labels or names of geometry objects involved") + narration: Optional[str] = Field(default=None, description="Voiceover/explanation for this beat") + duration_hint: Optional[float] = Field(default=None, description="Estimated duration in seconds") + + +class VisualizationConfig(BaseModel): + """Configuration for presentation, camera framing, and visualization styling.""" + + model_config = ConfigDict(extra="ignore") + + show_axes: bool = Field(default=False, description="Whether to display coordinate axes in rendering") + is_3d: bool = Field(default=False, description="Whether rendering is in 3D perspective mode") + camera_position: Optional[List[float]] = Field(default=None, description="Camera coordinates [x, y, z]") + camera_orientation: Optional[Dict[str, float]] = Field(default=None, description="Camera orientation angles") + scale_factor: float = Field(default=1.0, description="Display scale factor for visualization zoom") + center_focus: Optional[List[float]] = Field(default=None, description="Center point of camera focus") + show_labels: bool = Field(default=True, description="Whether to show point/vertex labels") + quality: Literal["480p", "720p", "1080p", "4k"] = "480p" + format: Literal["mp4", "gif"] = "mp4" + language: str = Field(default="vi", description="Language code: vi, en, ...") + + +class OutputConfig(BaseModel): + """Legacy Output configuration kept for backward compatibility.""" + + model_config = ConfigDict(extra="ignore") + + quality: Literal["480p", "720p", "1080p", "4k"] = "480p" + format: Literal["mp4", "gif"] = "mp4" + language: str = Field(default="vi", description="Language code: vi, en, ...") + show_axes: bool = False + + +class VisualizationSpec(BaseModel): + """Standardized Visualization Specification sent from Math Agent to Manim Module.""" + + model_config = ConfigDict(extra="ignore") + + problem: str = Field(description="Math problem description or theorem statement") + solution_steps: List[str] = Field( + default_factory=list, + description="Ordered list of reasoning or solution steps", + ) + geometry: List[GeometryObject] = Field( + default_factory=list, + description="List of geometric entities with solved coordinates", + ) + animations: List[AnimationDirective] = Field( + default_factory=list, + description="Animation directives and narration beats", + ) + config: VisualizationConfig = Field(default_factory=VisualizationConfig) + output_config: OutputConfig = Field(default_factory=OutputConfig) + visualization_graph: Optional[Dict[str, Any]] = Field( + default=None, + description="Complete topological visualization graph with vertices, edges, faces, solids, and auxiliary constructions", + ) + + @property + def show_axes(self) -> bool: + return self.config.show_axes or self.output_config.show_axes + + def to_manim_dict(self) -> Dict[str, Any]: + """Serializes VisualizationSpec into the exact schema expected by the Manim Agent Microservice.""" + steps = [s for s in self.solution_steps if str(s).strip()] if self.solution_steps else [] + if not steps: + steps = ["Dựng hình và phân tích hình học."] + + geom_list = [] + for g in self.geometry: + g_dict: Dict[str, Any] = {"type": g.type} + if g.label: + g_dict["label"] = g.label + if g.properties: + g_dict["properties"] = g.properties + geom_list.append(g_dict) + + anim_list = [] + for a in self.animations: + a_dict: Dict[str, Any] = {"action": a.action} + if a.targets: + a_dict["targets"] = a.targets + if a.narration: + a_dict["narration"] = a.narration + if a.duration_hint is not None: + a_dict["duration_hint"] = a.duration_hint + anim_list.append(a_dict) + + out_cfg = { + "quality": self.config.quality if self.config else (self.output_config.quality if self.output_config else "720p"), + "format": self.config.format if self.config else (self.output_config.format if self.output_config else "mp4"), + "language": self.config.language if self.config else (self.output_config.language if self.output_config else "vi"), + } + + return { + "problem": self.problem or "Bài toán hình học", + "solution_steps": steps, + "geometry": geom_list, + "animations": anim_list, + "output_config": out_cfg, + } + + def to_prompt(self) -> str: + """Serializes the spec into a structured prompt for the Manim Agent.""" + parts = [f"Chủ đề / Đề bài: {self.problem}"] + if self.config.show_axes: + parts.append("Hiển thị hệ trục tọa độ: BẬT (show_axes=True)") + if self.solution_steps: + parts.append("Các bước giải thích / chứng minh chi tiết:") + for idx, step in enumerate(self.solution_steps, 1): + parts.append(f" {idx}. {step}") + if self.geometry: + parts.append("Các đối tượng hình học / tọa độ giải được:") + for obj in self.geometry: + label_str = f" ({obj.label})" if obj.label else "" + props_str = f" - thuộc tính: {obj.properties}" if obj.properties else "" + parts.append(f" - {obj.type}{label_str}{props_str}") + if self.animations: + parts.append("Chỉ dẫn hoạt họa (animation beats):") + for idx, anim in enumerate(self.animations, 1): + targets_str = f" [đối tượng: {', '.join(anim.targets)}]" if anim.targets else "" + narr_str = f" | Lời thoại: '{anim.narration}'" if anim.narration else "" + parts.append(f" - Beat {idx}: {anim.action}{targets_str}{narr_str}") + return "\n".join(parts) + + +class MathRenderRequest(BaseModel): + """Payload for POST /v1/math/generate.""" + + model_config = ConfigDict(extra="ignore") + + spec: VisualizationSpec + callback_url: Optional[str] = None + + +class MathRenderResponse(BaseModel): + """Response from Manim Module for render job status.""" + + model_config = ConfigDict(extra="ignore") + + job_id: Union[UUID, str] = Field(description="Identifier for tracking the generation & rendering job") + project_id: Optional[Union[UUID, str]] = None + status: Literal["queued", "generating", "rendering", "completed", "failed"] = "queued" + video_url: Optional[str] = None + duration: Optional[float] = None + error: Optional[Union[StructuredError, Dict[str, Any], str]] = None + created_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + + def is_terminal(self) -> bool: + return self.status in ("completed", "failed") + + def get_error_code(self) -> Optional[str]: + if isinstance(self.error, StructuredError): + return self.error.code + elif isinstance(self.error, dict): + return self.error.get("code") + elif isinstance(self.error, str): + return ErrorCode.INTERNAL_ERROR + return None + + def get_error_message(self) -> Optional[str]: + if isinstance(self.error, StructuredError): + return self.error.message + elif isinstance(self.error, dict): + return self.error.get("message") + elif isinstance(self.error, str): + return self.error + return None + + def get(self, key: str, default: Any = None) -> Any: + """Dict-like get access for backward compatibility.""" + return getattr(self, key, default) + + def __getitem__(self, key: str) -> Any: + """Dict-like bracket access for backward compatibility.""" + if hasattr(self, key): + return getattr(self, key) + raise KeyError(key) + + def to_dict(self) -> Dict[str, Any]: + data = self.model_dump(mode="json") + if isinstance(self.error, StructuredError): + data["error"] = self.error.to_dict() + return data + + +def build_visualization_spec( + problem_text: Union[str, Dict[str, Any]] = "", + solution_steps: Optional[List[str]] = None, + coordinates: Optional[Dict[str, Any]] = None, + engine_result: Optional[Dict[str, Any]] = None, + semantic_data: Optional[Dict[str, Any]] = None, + is_3d: bool = False, + show_axes: bool = False, + quality: Literal["480p", "720p", "1080p", "4k"] = "480p", +) -> VisualizationSpec: + """ + Automated Builder: Converts MathSolver internal geometry data, coordinates, + drawing phases, and solution steps into a standard VisualizationSpec. + Supports either explicit keyword parameters or a single geometry_data dict. + """ + if isinstance(problem_text, dict): + data = problem_text + problem_text = ( + data.get("problem") + or data.get("problem_text") + or (data.get("semantic") or {}).get("input_text") + or data.get("geometry_dsl") + or "Minh họa hình học và các bước giải toán" + ) + sol = data.get("solution") + if isinstance(sol, dict): + solution_steps = sol.get("steps") or [] + elif isinstance(sol, list): + solution_steps = sol + elif isinstance(sol, str): + solution_steps = [s.strip() for s in sol.split("\n") if s.strip()] + else: + solution_steps = data.get("solution_steps") or [] + + coordinates = data.get("coordinates") or {} + engine_result = data + semantic_data = data.get("semantic") or data.get("semantic_data") + is_3d = bool(data.get("is_3d", False)) + show_axes = bool(data.get("show_axes", show_axes)) + if "quality" in data and data["quality"] in ("480p", "720p", "1080p", "4k"): + quality = data["quality"] + + coords = coordinates or {} + steps = solution_steps or [] + engine_res = engine_result or {} + geometry_objs: List[GeometryObject] = [] + animation_beats: List[AnimationDirective] = [] + + # 1. Add Geometry Objects (Prefer rich topological objects if available) + raw_geom_objs = engine_res.get("geometry_objects") + if raw_geom_objs and isinstance(raw_geom_objs, list): + for gobj in raw_geom_objs: + if isinstance(gobj, dict) and "type" in gobj: + geometry_objs.append( + GeometryObject( + type=gobj.get("type", "object"), + label=gobj.get("label"), + properties=gobj.get("properties", {}), + ) + ) + else: + # Fallback to points, solids, circles + for pt_name, pt_coords in coords.items(): + geometry_objs.append( + GeometryObject( + type="point_3d" if is_3d else "point_2d", + label=pt_name, + properties={"coordinates": pt_coords}, + ) + ) + solids = engine_res.get("solids", []) + for solid in solids: + geometry_objs.append( + GeometryObject( + type=solid.get("type", "solid_3d"), + label=f"{solid.get('type')}_{'_'.join(solid.get('points', []))}", + properties=solid, + ) + ) + circles = engine_res.get("circles", []) + for c in circles: + geometry_objs.append( + GeometryObject( + type="circle", + label=f"circle_{c.get('center')}", + properties=c, + ) + ) + + # 2. Construct Animation Beats from Drawing Phases and Solution Steps + drawing_phases = engine_res.get("drawing_phases", []) + if drawing_phases: + for phase in drawing_phases: + pts = phase.get("points", []) + segs = phase.get("segments", []) + seg_names = [f"{s[0]}{s[1]}" for s in segs] + animation_beats.append( + AnimationDirective( + action="draw", + targets=pts + seg_names, + narration=f"Dựng {phase.get('label', 'các điểm và đoạn thẳng')}: {', '.join(pts)}.", + duration_hint=2.5, + ) + ) + + if is_3d: + animation_beats.append( + AnimationDirective( + action="rotate_camera", + targets=["scene_3d"], + narration="Quan sát khối đa diện trong không gian 3 chiều.", + duration_hint=3.0, + ) + ) + + for idx, step in enumerate(steps): + animation_beats.append( + AnimationDirective( + action="write", + targets=[f"step_{idx+1}"], + narration=step, + duration_hint=3.5, + ) + ) + + vis_config = VisualizationConfig( + show_axes=show_axes, + is_3d=is_3d, + quality=quality, + format="mp4", + language="vi", + ) + legacy_output = OutputConfig( + quality=quality, + format="mp4", + language="vi", + show_axes=show_axes, + ) + + return VisualizationSpec( + problem=problem_text, + solution_steps=steps, + geometry=geometry_objs, + animations=animation_beats, + config=vis_config, + output_config=legacy_output, + visualization_graph=engine_res.get("visualization_graph"), + ) diff --git a/migrations/add_image_bucket_storage.sql b/migrations/add_image_bucket_storage.sql new file mode 100644 index 0000000000000000000000000000000000000000..2b51fdd44cc1bcabb9d3ac3f5db1b4f89ec97d5d --- /dev/null +++ b/migrations/add_image_bucket_storage.sql @@ -0,0 +1,35 @@ +-- ============================================================ +-- MathSolver: Supabase Storage bucket `image` (chat / OCR attachments) +-- Run after session_assets and storage.video policies exist. +-- ============================================================ + +INSERT INTO storage.buckets (id, name, public) +VALUES ('image', 'image', true) +ON CONFLICT (id) DO UPDATE SET public = true; + +-- Service role: upload/delete/list for API + workers +DROP POLICY IF EXISTS "Service Role manage images" ON storage.objects; +CREATE POLICY "Service Role manage images" ON storage.objects + FOR ALL + TO service_role + USING (bucket_id = 'image') + WITH CHECK (bucket_id = 'image'); + +-- Authenticated: read only objects under sessions they own (path sessions/{session_id}/...) +DROP POLICY IF EXISTS "Users view session images" ON storage.objects; +CREATE POLICY "Users view session images" ON storage.objects + FOR SELECT + TO authenticated + USING ( + bucket_id = 'image' + AND (storage.foldername(name))[2] IN ( + SELECT id::text FROM public.sessions WHERE user_id = auth.uid() + ) + ); + +-- Public read for get_public_url / FE img tags (same model as video bucket) +DROP POLICY IF EXISTS "Public read images" ON storage.objects; +CREATE POLICY "Public read images" ON storage.objects + FOR SELECT + TO public + USING (bucket_id = 'image'); diff --git a/migrations/fix_rls_assets.sql b/migrations/fix_rls_assets.sql new file mode 100644 index 0000000000000000000000000000000000000000..3cb09b6427c84d7b28f705bfac05b1815db26328 --- /dev/null +++ b/migrations/fix_rls_assets.sql @@ -0,0 +1,96 @@ +-- ============================================================ +-- FIX RLS & SESSION ASSETS (MathSolver v5.1 Worker Fix) +-- ============================================================ + +-- 1. Ensure session_assets table exists +CREATE TABLE IF NOT EXISTS public.session_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE, + job_id UUID NOT NULL, + asset_type TEXT NOT NULL CHECK (asset_type IN ('video', 'image')), + storage_path TEXT NOT NULL, + public_url TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index for session_assets +CREATE INDEX IF NOT EXISTS idx_session_assets_session_id ON public.session_assets(session_id); +CREATE INDEX IF NOT EXISTS idx_session_assets_type ON public.session_assets(session_id, asset_type); + +-- 2. Enable RLS for all tables +ALTER TABLE public.session_assets ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.jobs ENABLE ROW LEVEL SECURITY; + + +-- 3. Fix Table Policies to allow SERVICE ROLE +-- In Supabase, service_role usually bypasses RLS, but we add explicit policies for safety +-- especially for path-based checks or when SECURITY DEFINER functions are used. + +-- [Session Assets] +DROP POLICY IF EXISTS "Users view own assets" ON public.session_assets; +CREATE POLICY "Users view own assets" ON public.session_assets + FOR SELECT USING ( + session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()) + ); + +DROP POLICY IF EXISTS "Service role manages assets" ON public.session_assets; +CREATE POLICY "Service role manages assets" ON public.session_assets + FOR ALL USING (true) + WITH CHECK (true); + + +-- [Messages] - Allow Worker to insert assistant messages +DROP POLICY IF EXISTS "Users manage own messages" ON public.messages; +CREATE POLICY "Users manage own messages" ON public.messages + FOR ALL USING ( + session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()) + OR + (auth.jwt() ->> 'role' = 'service_role') + ); + + +-- [Jobs] - Allow Worker to update job status +DROP POLICY IF EXISTS "Users manage own jobs" ON public.jobs; +CREATE POLICY "Users manage own jobs" ON public.jobs + FOR ALL USING ( + auth.uid() = user_id + OR user_id IS NULL + OR (auth.jwt() ->> 'role' = 'service_role') + ); + + +-- 4. Storage Policies (Bucket: video) +-- Ensure 'video' bucket exists +INSERT INTO storage.buckets (id, name, public) +VALUES ('video', 'video', true) +ON CONFLICT (id) DO UPDATE SET public = true; + +-- [Storage: Worker / Service Role] - Allow all in video bucket +DROP POLICY IF EXISTS "Service Role manage videos" ON storage.objects; +CREATE POLICY "Service Role manage videos" ON storage.objects + FOR ALL + TO service_role + USING (bucket_id = 'video'); + +-- [Storage: Users] - Allow users to view their session videos +DROP POLICY IF EXISTS "Users view session videos" ON storage.objects; +CREATE POLICY "Users view session videos" ON storage.objects + FOR SELECT + TO authenticated + USING ( + bucket_id = 'video' + AND (storage.foldername(name))[2] IN ( + SELECT id::text FROM public.sessions WHERE user_id = auth.uid() + ) + ); + +-- [Storage: Public] - Allow public read access to videos +DROP POLICY IF EXISTS "Public read videos" ON storage.objects; +CREATE POLICY "Public read videos" ON storage.objects + FOR SELECT + TO public + USING (bucket_id = 'video'); diff --git a/migrations/v4_migration.sql b/migrations/v4_migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..e3c4c7ab8689de5952fd4dba04210873e2659042 --- /dev/null +++ b/migrations/v4_migration.sql @@ -0,0 +1,131 @@ +-- ============================================================ +-- MATHSOLVER v4.0 - Migration Script (Multi-Session & History) +-- ============================================================ + +-- 1. Profiles Table (Extends Supabase Auth) +CREATE TABLE IF NOT EXISTS public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + display_name TEXT, + avatar_url TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Function to handle new user signup and auto-create profile +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.profiles (id, display_name, avatar_url) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.email), + NEW.raw_user_meta_data->>'avatar_url' + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Trigger for profile creation +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- 2. Sessions Table +CREATE TABLE IF NOT EXISTS public.sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title TEXT DEFAULT 'Bài toán mới', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index for sessions +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON public.sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON public.sessions(updated_at DESC); + +-- 3. Messages Table +CREATE TABLE IF NOT EXISTS public.messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + type TEXT NOT NULL DEFAULT 'text', + content TEXT NOT NULL, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index for messages +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON public.messages(session_id); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON public.messages(session_id, created_at); + +-- 4. Session Assets Table (v5.1 Versioning) +CREATE TABLE IF NOT EXISTS public.session_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE, + job_id UUID NOT NULL, + asset_type TEXT NOT NULL CHECK (asset_type IN ('video', 'image')), + storage_path TEXT NOT NULL, + public_url TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index for session_assets +CREATE INDEX IF NOT EXISTS idx_session_assets_session_id ON public.session_assets(session_id); + +-- 5. Update Jobs Table +ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id); +ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES public.sessions(id); + +-- 6. Row Level Security (RLS) +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.jobs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.session_assets ENABLE ROW LEVEL SECURITY; + +-- Polices for public.profiles +DROP POLICY IF EXISTS "Users view own profile" ON public.profiles; +CREATE POLICY "Users view own profile" ON public.profiles FOR SELECT USING (auth.uid() = id); +DROP POLICY IF EXISTS "Users update own profile" ON public.profiles; +CREATE POLICY "Users update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id); + +-- Policies for public.sessions +DROP POLICY IF EXISTS "Users manage own sessions" ON public.sessions; +CREATE POLICY "Users manage own sessions" ON public.sessions FOR ALL USING (auth.uid() = user_id); + +-- Policies for public.messages +DROP POLICY IF EXISTS "Users manage own messages" ON public.messages; +CREATE POLICY "Users manage own messages" ON public.messages FOR ALL USING ( + session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()) + OR (auth.jwt() ->> 'role' = 'service_role') +); + +-- Policies for public.session_assets +DROP POLICY IF EXISTS "Users view own assets" ON public.session_assets; +CREATE POLICY "Users view own assets" ON public.session_assets FOR SELECT USING ( + session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()) +); +DROP POLICY IF EXISTS "Service role manages assets" ON public.session_assets; +CREATE POLICY "Service role manages assets" ON public.session_assets FOR ALL USING (true); + +-- Policies for public.jobs +DROP POLICY IF EXISTS "Users manage own jobs" ON public.jobs; +CREATE POLICY "Users manage own jobs" ON public.jobs FOR ALL USING ( + auth.uid() = user_id OR user_id IS NULL OR (auth.jwt() ->> 'role' = 'service_role') +); + +-- 7. Storage Policies (Bucket: video) +-- (Run this in Supabase Dashboard if not allowed in migration) +-- INSERT INTO storage.buckets (id, name, public) VALUES ('video', 'video', true) ON CONFLICT (id) DO NOTHING; +-- CREATE POLICY "Service Role manage videos" ON storage.objects FOR ALL TO service_role USING (bucket_id = 'video'); +-- CREATE POLICY "Public read videos" ON storage.objects FOR SELECT TO public USING (bucket_id = 'video'); + +-- Grant permissions to public/authenticated +GRANT ALL ON public.profiles TO authenticated; +GRANT ALL ON public.sessions TO authenticated; +GRANT ALL ON public.messages TO authenticated; +GRANT ALL ON public.jobs TO authenticated; +GRANT ALL ON public.session_assets TO authenticated; +GRANT ALL ON public.session_assets TO service_role; diff --git a/migrations/v5_state_upgrade.sql b/migrations/v5_state_upgrade.sql new file mode 100644 index 0000000000000000000000000000000000000000..397048a9304241da47689b90f931d44324a079b8 --- /dev/null +++ b/migrations/v5_state_upgrade.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- MATHSOLVER v5.0 - State Architecture Upgrade Migration +-- ============================================================ + +-- 1. Auto-update `sessions.updated_at` function +CREATE OR REPLACE FUNCTION public.update_session_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE public.sessions + SET updated_at = NOW() + WHERE id = NEW.session_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 2. Triggers for session timestamp updates +DROP TRIGGER IF EXISTS trg_update_session_on_message ON public.messages; +CREATE TRIGGER trg_update_session_on_message + AFTER INSERT ON public.messages + FOR EACH ROW + EXECUTE FUNCTION public.update_session_timestamp(); + +DROP TRIGGER IF EXISTS trg_update_session_on_job ON public.jobs; +CREATE TRIGGER trg_update_session_on_job + AFTER INSERT OR UPDATE ON public.jobs + FOR EACH ROW + EXECUTE FUNCTION public.update_session_timestamp(); + +DROP TRIGGER IF EXISTS trg_update_session_on_asset ON public.session_assets; +CREATE TRIGGER trg_update_session_on_asset + AFTER INSERT ON public.session_assets + FOR EACH ROW + EXECUTE FUNCTION public.update_session_timestamp(); + +-- 3. Idempotency Support: client_message_id on messages +ALTER TABLE public.messages +ADD COLUMN IF NOT EXISTS client_message_id UUID; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_session_client_id +ON public.messages(session_id, client_message_id) +WHERE client_message_id IS NOT NULL; + +-- 4. Atomic Asset Versioning: Unique version per session and asset type +CREATE UNIQUE INDEX IF NOT EXISTS idx_session_assets_unique_version +ON public.session_assets(session_id, asset_type, version); diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..cff9e594f74c96e369757cad9ee634bcb50a6ea9 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +pythonpath = . +filterwarnings = + ignore::DeprecationWarning + +markers = + real_api: HTTP tests need running backend and TEST_USER_ID / TEST_SESSION_ID. + real_worker_ocr: OCR Celery task or full OCR stack (heavy). + real_worker_manim: Real Manim render and Supabase video upload. + real_agents: Live LLM / orchestrator agent calls. + slow: Large suite or long polling timeouts. + smoke: Fast API health + one solve job. + orchestrator_local: In-process Orchestrator without HTTP server. + +# Default: skip integration tests that need services, keys, or long runs. +addopts = -m "not real_api and not real_worker_ocr and not real_worker_manim and not real_agents and not slow and not orchestrator_local" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..52ed01932d1773aaff3cabf899d9a93f88fa84ac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,41 @@ +# Target: Python 3.11 (see Dockerfile). Used by: FastAPI API, Celery worker, Manim render, OCR/vision stack. +# Install: pip install -r requirements.txt + +# --- Dev / test --- +pytest>=8.0 +pytest-asyncio>=0.24 + +# --- HTTP API --- +cachetools>=5.3 +fastapi>=0.115,<1 +uvicorn[standard]>=0.30 +python-multipart>=0.0.9 +python-dotenv>=1.0 +pydantic[email]>=2.4 +email-validator>=2 + +# --- Auth / data / queue / LLM --- +openai>=1.40 +litellm>=1.40 +pyyaml>=6.0 +supabase>=2.0 +celery>=5.3 +redis>=5 +httpx>=0.27 +websockets>=12 + +# --- Math & symbolic solver --- +sympy>=1.12 +numpy>=1.26,<2 +scipy>=1.11 +opencv-python-headless>=4.8,<4.10 + +# --- Video (GeometryScene via CLI) --- +manim>=0.18,<0.20 + +# --- OCR & vision (orchestrator / canonical Pix2Text OCR) --- +pix2text>=1.1.0 +pix2tex>=0.1.4 +paddleocr==2.7.3 +paddlepaddle==2.6.2 +ultralytics==8.2.2 diff --git a/run_api_test.sh b/run_api_test.sh new file mode 100755 index 0000000000000000000000000000000000000000..2d83a73fde7f106e2610ed6c556b2fb916274868 --- /dev/null +++ b/run_api_test.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +LOG_FILE="api_test_results.log" +echo "=== Starting API E2E Test Suite ($(date)) ===" > $LOG_FILE + +# 1. Start BE Server in background +echo "[INFO] Starting Backend Server..." | tee -a $LOG_FILE +export ALLOW_TEST_BYPASS=true +export LOG_LEVEL=info +export CELERY_TASK_ALWAYS_EAGER=true +export CELERY_RESULT_BACKEND=rpc:// +export MOCK_VIDEO=true +PYTHONPATH=. venv/bin/python -m uvicorn app.main:app --port 8000 > server_debug.log 2>&1 & +SERVER_PID=$! + +# 2. Wait for server to be ready +echo "[INFO] Waiting for server (PID: $SERVER_PID) on port 8000..." | tee -a $LOG_FILE +MAX_RETRIES=15 +READY=0 +for i in $(seq 1 $MAX_RETRIES); do + if curl -s http://localhost:8000/ > /dev/null; then + READY=1 + break + fi + sleep 2 +done + +if [ $READY -eq 0 ]; then + echo "[ERROR] Server failed to start in time. Check server_debug.log" | tee -a $LOG_FILE + kill $SERVER_PID + exit 1 +fi +echo "[INFO] Server is READY." | tee -a $LOG_FILE + +# 3. Prepare Test Data +echo "[INFO] Preparing fresh test data..." | tee -a $LOG_FILE +PREP_OUTPUT=$(PYTHONPATH=. venv/bin/python scripts/prepare_api_test.py) +echo "$PREP_OUTPUT" >> $LOG_FILE + +export TEST_USER_ID=$(echo "$PREP_OUTPUT" | grep "RESULT:USER_ID=" | cut -d'=' -f2) +export TEST_SESSION_ID=$(echo "$PREP_OUTPUT" | grep "RESULT:SESSION_ID=" | cut -d'=' -f2) + +if [ -z "$TEST_USER_ID" ] || [ -z "$TEST_SESSION_ID" ]; then + echo "[ERROR] Failed to prepare test data." | tee -a $LOG_FILE + kill $SERVER_PID + exit 1 +fi + +echo "[INFO] Test Data: User=$TEST_USER_ID, Session=$TEST_SESSION_ID" | tee -a $LOG_FILE + +# 4. Run Pytest +echo "[INFO] Running API E2E Tests..." | tee -a $LOG_FILE +PYTHONPATH=. venv/bin/python -m pytest tests/test_api_real_e2e.py -m "smoke and real_api" -s \ + --junitxml=pytest_smoke.xml >> $LOG_FILE 2>&1 +TEST_EXIT_CODE=$? + +# 5. Cleanup +echo "[INFO] Shutting down Server..." | tee -a $LOG_FILE +kill $SERVER_PID + +echo "==========================================" | tee -a $LOG_FILE +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo "FINAL RESULT: ✅ ALL API TESTS PASSED" | tee -a $LOG_FILE +else + echo "FINAL RESULT: ❌ SOME API TESTS FAILED (Code: $TEST_EXIT_CODE)" | tee -a $LOG_FILE +fi +echo "==========================================" | tee -a $LOG_FILE + +exit $TEST_EXIT_CODE diff --git a/run_full_api_test.sh b/run_full_api_test.sh new file mode 100755 index 0000000000000000000000000000000000000000..b0b14659f3880aef45215d749cdfd8ca097f743b --- /dev/null +++ b/run_full_api_test.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Full API integration (CI-style): eager Celery + mock video + full HTTP suite. +LOG_FILE="full_api_suite.log" +REPORT_FILE="full_api_test_report.md" +JSON_RESULTS="temp_suite_results.json" +JUNIT="pytest_api_suite.xml" + +echo "=== Starting Full API Suite Test ($(date)) ===" >"$LOG_FILE" + +trap 'echo "[INFO] Cleaning up processes..."; kill $SERVER_PID 2>/dev/null; sleep 1' EXIT + +echo "[INFO] Starting Backend Server (EAGER + MOCK_VIDEO)..." | tee -a "$LOG_FILE" +export ALLOW_TEST_BYPASS=true +export LOG_LEVEL=info +export CELERY_TASK_ALWAYS_EAGER=true +export CELERY_RESULT_BACKEND=rpc:// +export MOCK_VIDEO=true +PYTHONPATH=. venv/bin/python -m uvicorn app.main:app --port 8000 >server_debug.log 2>&1 & +SERVER_PID=$! + +echo "[INFO] Waiting for server (PID: $SERVER_PID)..." | tee -a "$LOG_FILE" +for i in {1..20}; do + if curl -s http://localhost:8000/ >/dev/null; then + echo "[INFO] Server is READY." | tee -a "$LOG_FILE" + break + fi + sleep 2 +done + +echo "[INFO] Preparing fresh test data..." | tee -a "$LOG_FILE" +PREP_OUTPUT=$(PYTHONPATH=. venv/bin/python scripts/prepare_api_test.py) +export TEST_USER_ID=$(echo "$PREP_OUTPUT" | grep "RESULT:USER_ID=" | cut -d'=' -f2) +export TEST_SESSION_ID=$(echo "$PREP_OUTPUT" | grep "RESULT:SESSION_ID=" | cut -d'=' -f2) + +if [ -z "$TEST_USER_ID" ]; then + echo "[ERROR] Failed to prepare test data." | tee -a "$LOG_FILE" + exit 1 +fi + +echo "[INFO] Executing API tests (smoke + full suite)..." | tee -a "$LOG_FILE" +PYTHONPATH=. venv/bin/python -m pytest tests/test_api_real_e2e.py tests/test_api_full_suite.py \ + -m "real_api" -s --tb=short --junitxml="$JUNIT" >>"$LOG_FILE" 2>&1 +TEST_EXIT_CODE=$? + +echo "[INFO] Generating Markdown Report..." | tee -a "$LOG_FILE" +if [ -f "$JSON_RESULTS" ]; then + PYTHONPATH=. venv/bin/python scripts/generate_report.py "$JSON_RESULTS" "$REPORT_FILE" "$JUNIT" +else + echo "[WARN] $JSON_RESULTS not found" | tee -a "$LOG_FILE" +fi + +echo "==========================================" | tee -a "$LOG_FILE" +echo "DONE. Check $REPORT_FILE for results." | tee -a "$LOG_FILE" +echo "==========================================" | tee -a "$LOG_FILE" + +exit $TEST_EXIT_CODE diff --git a/scripts/benchmark_openrouter.py b/scripts/benchmark_openrouter.py new file mode 100644 index 0000000000000000000000000000000000000000..7660d48b9e2997bd5bbfffd49b25f0404b4aa809 --- /dev/null +++ b/scripts/benchmark_openrouter.py @@ -0,0 +1,77 @@ +"""Benchmark several OpenRouter models (manual tool; not part of pytest).""" + +from __future__ import annotations + +import json +import os +import time + +import httpx +from dotenv import load_dotenv + +_BACKEND_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +load_dotenv(os.path.join(_BACKEND_ROOT, ".env")) + +MODELS = [ + "nvidia/nemotron-3-super-120b-a12b:free", + "meta-llama/llama-3.3-70b-instruct:free", + "openai/gpt-oss-120b:free", + "z-ai/glm-4.5-air:free", + "minimax/minimax-m2.5:free", + "google/gemma-4-26b-a4b-it:free", + "google/gemma-4-31b-it:free", +] + +PROMPT = ( + "Cho hình chữ nhật ABCD có AB bằng 5 và AD bằng 10. Gọi E là điểm nằm trong đoạn CD sao cho CE = 2ED. " + "Vẽ đoạn thẳng AE. Vẽ thêm P là điểm nằm trên đường thẳng BC sao cho BP = 2PC, tính chu vi tam giác PEA" +) + + +def main() -> None: + api_key = os.getenv("OPENROUTER_API_KEY_1") or os.getenv("OPENROUTER_API_KEY") + base_url = "https://openrouter.ai/api/v1/chat/completions" + + if not api_key: + print("Missing OPENROUTER_API_KEY_1 or OPENROUTER_API_KEY in .env") + return + + print("Benchmark OpenRouter models\nPrompt:", PROMPT, "\n") + results = [] + + for model in MODELS: + print(f"Calling {model}...", end="", flush=True) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://mathsolver.io", + "X-Title": "MathSolver Benchmark Tool", + } + payload = {"model": model, "messages": [{"role": "user", "content": PROMPT}]} + start = time.time() + try: + with httpx.Client(timeout=120.0) as client: + r = client.post(base_url, headers=headers, json=payload) + r.raise_for_status() + data = r.json() + answer = data["choices"][0]["message"]["content"] + duration = time.time() - start + results.append( + {"model": model, "duration": duration, "answer": answer, "status": "success"} + ) + print(f" OK ({duration:.2f}s)") + except Exception as e: + duration = time.time() - start + results.append( + {"model": model, "duration": duration, "error": str(e), "status": "error"} + ) + print(f" FAIL ({duration:.2f}s) {e}") + + print("\n" + "=" * 80) + for res in results: + print(json.dumps(res, ensure_ascii=False, indent=2)[:2000]) + print("-" * 40) + + +if __name__ == "__main__": + main() diff --git a/scripts/evaluate_math_ocr_benchmark.py b/scripts/evaluate_math_ocr_benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..c6e386354ea778525ea39287702c97bceae80dfe --- /dev/null +++ b/scripts/evaluate_math_ocr_benchmark.py @@ -0,0 +1,72 @@ +import os +import re +import difflib + +from vision_ocr.pix2text_engine import Pix2TextOCREngine + +def normalize_eval(s: str) -> str: + s = s.replace("$", "").replace("\\", "").replace("{", "").replace("}", "").replace(" ", "").lower() + for rm in [",", ".", ";", ":", "-", "_", "(", ")", "^", "*", "+", "=", "'", "prime"]: + s = s.replace(rm, "") + return s + +def calc_sim(a: str, b: str) -> float: + na = normalize_eval(a) + nb = normalize_eval(b) + return difflib.SequenceMatcher(None, na, nb).ratio() + +GROUND_TRUTHS = { + "2D_easy.png": ( + "Cho tam giác ABC vuông tại A, biết AB=6, AC=8.\n" + "Gọi H là chân đường cao từ A xuống BC.\n" + "Tính BC, AH và diện tích tam giác ABC." + ), + "3D_easy.png": ( + "Cho hình hộp chữ nhật ABCD.A'B'C'D' có AB=4, AD=3, AA'=5.\n" + "Tính độ dài đường chéo AC'." + ), + "2D_hard.png": ( + "Cho đường tròn (O) có đường kính AB.\n" + "Lấy điểm C trong (O), C khác A, B. Tiếp tuyến tại A và C cắt nhau tại M.\n" + "Gọi H là hình chiếu vuông góc của C lên AB, N là giao điểm của CM và AB.\n" + "Chứng minh rằng MA^2 = MH * MN và góc AMC = 2 * góc ABC." + ), + "3D_hard.png": ( + "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh a, SA vuông góc (ABCD), SA=a.\n" + "Gọi M, N lần lượt là trung điểm của AB, CD.\n" + "Gọi H là hình chiếu vuông góc của A lên SM.\n" + "1. Xác định giao tuyến của hai mặt phẳng (SMN) và (SAD).\n" + "2. Tính khoảng cách từ A đến đường thẳng SM.\n" + "3. Tính góc giữa SM và mặt phẳng (ABCD)." + ), +} + +def main(): + engine = Pix2TextOCREngine.get_instance() + test_dir = os.path.join(os.path.dirname(__file__), "..", "tests", "data") + + print("=" * 70) + print(" MATH OCR BENCHMARK EVALUATION (4 Test Cases)") + print("=" * 70) + + sims = [] + for name, gt in GROUND_TRUTHS.items(): + img_path = os.path.join(test_dir, name) + res = engine.recognize(img_path) + sim = calc_sim(res.text, gt) + sims.append((name, sim, res, gt)) + + print(f"\n📁 TEST CASE: {name}") + print(f"📊 Similarity Score: {sim * 100:.2f}% | Elements: {len(res.elements)} | Conf: {res.confidence:.4f}") + print(f"📐 Extracted LaTeX ({len(res.latex)}): {res.latex}") + print("\n--- [Ground Truth] ---") + print(gt) + print("\n--- [OCR Canonical Output Text] ---") + print(res.text) + print("-" * 70) + + avg_sim = sum(s[1] for s in sims) / len(sims) + print(f"\n🎯 OVERALL BENCHMARK ACCURACY: {avg_sim * 100:.2f}%\n") + +if __name__ == "__main__": + main() diff --git a/scripts/generate_report.py b/scripts/generate_report.py new file mode 100644 index 0000000000000000000000000000000000000000..979f2ae12b71dea413a9ded9c182ddeee21b0d1f --- /dev/null +++ b/scripts/generate_report.py @@ -0,0 +1,115 @@ +import json +import os +import sys +import xml.etree.ElementTree as ET +from datetime import datetime + + +def _parse_junit_xml(path: str) -> dict: + """Summarize pytest junitxml (JUnit) file.""" + out = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0} + try: + tree = ET.parse(path) + root = tree.getroot() + nodes = [root] if root.tag == "testsuite" else list(root.iter("testsuite")) + for ts in nodes: + if ts.tag != "testsuite": + continue + out["tests"] += int(ts.attrib.get("tests", 0) or 0) + out["failures"] += int(ts.attrib.get("failures", 0) or 0) + out["errors"] += int(ts.attrib.get("errors", 0) or 0) + out["skipped"] += int(ts.attrib.get("skipped", 0) or 0) + out["time"] += float(ts.attrib.get("time", 0) or 0) + except Exception as e: + out["parse_error"] = str(e) + return out + + +def generate_report(json_path: str, report_path: str, junit_path: str | None = None) -> None: + try: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + + junit_summary = None + if junit_path and os.path.isfile(junit_path): + junit_summary = _parse_junit_xml(junit_path) + + with open(report_path, "w", encoding="utf-8") as f: + f.write("# Báo cáo Kiểm thử tích hợp Backend (Integration Report)\n\n") + f.write(f"**Thời gian chạy:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + suite_ok = all(r.get("success", False) for r in data) if isinstance(data, list) else False + f.write(f"**API suite (JSON):** {'PASS' if suite_ok else 'FAIL'}\n") + + if junit_summary and "parse_error" not in junit_summary: + j_ok = junit_summary["failures"] == 0 and junit_summary["errors"] == 0 + f.write( + f"**Pytest (JUnit):** {'PASS' if j_ok else 'FAIL'} — " + f"tests={junit_summary['tests']}, failures={junit_summary['failures']}, " + f"errors={junit_summary['errors']}, skipped={junit_summary['skipped']}, " + f"time_s={junit_summary['time']:.2f}\n" + ) + elif junit_summary and "parse_error" in junit_summary: + f.write(f"**Pytest (JUnit):** (could not parse: {junit_summary['parse_error']})\n") + + f.write("\n") + + f.write("| ID | Câu hỏi (Query) | Trạng thái | Thời gian (s) | Kết quả / Lỗi |\n") + f.write("| :--- | :--- | :--- | :--- | :--- |\n") + for r in data: + status = "PASS" if r.get("success") else "FAIL" + elapsed = f"{float(r.get('elapsed', 0) or 0):.2f}" + query = r.get("query", "-") + + res = r.get("result", {}) + if not isinstance(res, dict): + res = {} + + analysis = res.get("semantic_analysis", "-") + if not r.get("success"): + analysis = f"**Lỗi:** {r.get('error', '-')}" + + short_analysis = (analysis[:100] + "...") if len(str(analysis)) > 100 else analysis + + f.write(f"| {r['id']} | {query} | {status} | {elapsed} | {short_analysis} |\n") + + f.write("\n---\n**Chi tiết Output (DSL & Analysis):**\n") + for r in data: + if not r.get("success"): + continue + res = r.get("result", {}) + if not isinstance(res, dict): + continue + + f.write(f"\n### Case {r['id']}: {r.get('query')}\n") + f.write(f"**Semantic Analysis:**\n{res.get('semantic_analysis', '-')}\n\n") + f.write(f"**Geometry DSL:**\n```\n{res.get('geometry_dsl', '-')}\n```\n") + + sol = res.get("solution") + if sol and isinstance(sol, dict): + f.write("**Solution (v5.1):**\n") + f.write(f"- **Answer:** {sol.get('answer', 'N/A')}\n") + f.write("- **Steps:**\n") + steps = sol.get("steps", []) + if steps: + for step in steps: + f.write(f" - {step}\n") + else: + f.write(" - (Không có bước giải cụ thể)\n") + + if sol.get("symbolic_expression"): + f.write(f"- **Symbolic:** `{sol.get('symbolic_expression')}`\n") + f.write("\n") + + print(f"Report generated: {report_path}") + except Exception as e: + print(f"Error generating report: {e}") + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print( + "Usage: python generate_report.py [junit_xml_optional]" + ) + sys.exit(1) + junit = sys.argv[3] if len(sys.argv) > 3 else None + generate_report(sys.argv[1], sys.argv[2], junit) diff --git a/scripts/prepare_api_test.py b/scripts/prepare_api_test.py new file mode 100644 index 0000000000000000000000000000000000000000..f8a4f91758fd68440e9fe7ec10204abf81e09357 --- /dev/null +++ b/scripts/prepare_api_test.py @@ -0,0 +1,38 @@ +import os +import sys +import uuid + +from dotenv import load_dotenv + +# Add parent dir to path to import app modules +_BACKEND_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(_BACKEND_ROOT) +load_dotenv(os.path.join(_BACKEND_ROOT, ".env")) + +from app.supabase_client import get_supabase + +# Default UUID matches historical dev DB; override with TEST_SUPABASE_USER_ID in .env +_DEFAULT_TEST_USER = "8cd3adb0-7964-4575-949c-d0cadcd8b679" + + +def prepare(): + supabase = get_supabase() + user_id = os.environ.get("TEST_SUPABASE_USER_ID", _DEFAULT_TEST_USER).strip() + session_id = str(uuid.uuid4()) + + print(f"Using test user (TEST_SUPABASE_USER_ID or default): {user_id}") + + print(f"Creating fresh test session: {session_id}") + # Insert session + supabase.table("sessions").insert({ + "id": session_id, + "user_id": user_id, + "title": f"Fresh API Test {session_id[:8]}" + }).execute() + + # Return IDs for the test script + print(f"RESULT:USER_ID={user_id}") + print(f"RESULT:SESSION_ID={session_id}") + +if __name__ == "__main__": + prepare() diff --git a/scripts/prewarm_models.py b/scripts/prewarm_models.py new file mode 100644 index 0000000000000000000000000000000000000000..4e54cad3c3f768ebe1bd4b9467cfef2fb3b67d59 --- /dev/null +++ b/scripts/prewarm_models.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Download and load all heavy models during Docker build (YOLO, PaddleOCR, Pix2Tex, agents). +Fails the image build if initialization fails. +""" + +from __future__ import annotations + +import logging +import os +import sys + +# Ensure imports work when run as `python scripts/prewarm_models.py` from WORKDIR +_APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _APP_ROOT not in sys.path: + sys.path.insert(0, _APP_ROOT) + +os.chdir(_APP_ROOT) + +from dotenv import load_dotenv + +load_dotenv() + +from app.runtime_env import apply_runtime_env_defaults + +apply_runtime_env_defaults() + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s | %(message)s") + +logger = logging.getLogger("prewarm") + + +def main() -> None: + from agents.orchestrator import Orchestrator + + logger.info("Constructing Orchestrator (full agent + model load)...") + Orchestrator() + logger.info("Prewarm finished successfully.") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_real_integration.sh b/scripts/run_real_integration.sh new file mode 100755 index 0000000000000000000000000000000000000000..4046e9d18981a3cc2e535bd8399257420fa4356c --- /dev/null +++ b/scripts/run_real_integration.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Run backend integration tests. Usage: +# ./scripts/run_real_integration.sh # profile ci (default) +# ./scripts/run_real_integration.sh ci +# ./scripts/run_real_integration.sh real # heavy: workers, manim, OCR, full API suite +set -euo pipefail + +PROFILE="${1:-ci}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +PY="${ROOT}/venv/bin/python" +if [[ ! -x "$PY" ]]; then + PY="python3" +fi + +export PYTHONPATH="$ROOT" +LOG_FILE="${LOG_FILE:-integration_run.log}" +JUNIT="${JUNIT:-pytest_integration.xml}" +REPORT_MD="${REPORT_MD:-integration_report.md}" +JSON_RESULTS="${JSON_RESULTS:-temp_suite_results.json}" + +log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG_FILE"; } + +log "Profile=$PROFILE working_dir=$ROOT" + +if [[ "$PROFILE" == "ci" ]]; then + export ALLOW_TEST_BYPASS="${ALLOW_TEST_BYPASS:-true}" + export LOG_LEVEL="${LOG_LEVEL:-info}" + export CELERY_TASK_ALWAYS_EAGER="${CELERY_TASK_ALWAYS_EAGER:-true}" + export CELERY_RESULT_BACKEND="${CELERY_RESULT_BACKEND:-rpc://}" + export MOCK_VIDEO="${MOCK_VIDEO:-true}" + + set +e + log "Phase A: default pytest (unit / mocked; excludes real_* markers per pytest.ini)" + "$PY" -m pytest tests/ -q --tb=short -p no:cacheprovider 2>&1 | tee -a "$LOG_FILE" + P1=${PIPESTATUS[0]} + set -e + + log "Starting uvicorn for API phase..." + "$PY" -m uvicorn app.main:app --port 8000 >>uvicorn_integration.log 2>&1 & + SERVER_PID=$! + trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT + + for i in $(seq 1 25); do + if curl -sf "http://localhost:8000/" >/dev/null; then + log "API ready" + break + fi + sleep 2 + if [[ "$i" -eq 25 ]]; then + log "ERROR: API did not start" + exit 1 + fi + done + + PREP="$("$PY" scripts/prepare_api_test.py)" + echo "$PREP" | tee -a "$LOG_FILE" + export TEST_USER_ID="$(echo "$PREP" | grep "RESULT:USER_ID=" | cut -d'=' -f2)" + export TEST_SESSION_ID="$(echo "$PREP" | grep "RESULT:SESSION_ID=" | cut -d'=' -f2)" + if [[ -z "${TEST_USER_ID:-}" || -z "${TEST_SESSION_ID:-}" ]]; then + log "ERROR: prepare_api_test did not emit USER_ID / SESSION_ID" + exit 1 + fi + + set +e + log "Phase B: API smoke + full suite (real_api)" + "$PY" -m pytest tests/test_api_real_e2e.py tests/test_api_full_suite.py \ + -m "real_api" -s --tb=short --junitxml="$JUNIT" -p no:cacheprovider 2>&1 | tee -a "$LOG_FILE" + P2=${PIPESTATUS[0]} + set -e + + if [[ -f "$JSON_RESULTS" ]]; then + log "Generating Markdown report" + "$PY" scripts/generate_report.py "$JSON_RESULTS" "$REPORT_MD" "$JUNIT" + else + log "WARN: $JSON_RESULTS missing (suite may have failed before write)" + fi + + if [[ "$P1" -ne 0 || "$P2" -ne 0 ]]; then + log "FAIL: phase A exit=$P1 phase B exit=$P2" + exit 1 + fi + + log "Done CI profile. See $REPORT_MD and $LOG_FILE" + exit 0 +fi + +if [[ "$PROFILE" == "real" ]]; then + unset CELERY_TASK_ALWAYS_EAGER || true + export CELERY_TASK_ALWAYS_EAGER="${CELERY_TASK_ALWAYS_EAGER:-false}" + export MOCK_VIDEO="${MOCK_VIDEO:-false}" + export RUN_REAL_WORKER_OCR="${RUN_REAL_WORKER_OCR:-0}" + export RUN_REAL_WORKER_MANIM="${RUN_REAL_WORKER_MANIM:-0}" + + log "Phase A: default pytest (fast)" + "$PY" -m pytest tests/ -q --tb=short -p no:cacheprovider 2>&1 | tee -a "$LOG_FILE" + + log "Phase B: real agents + orchestrator smoke (requires OpenRouter keys)" + "$PY" -m pytest tests/integration/test_agents_real.py tests/integration/test_orchestrator_smoke.py \ + -m "real_agents" -q --tb=short --junitxml="$JUNIT" -p no:cacheprovider 2>&1 | tee -a "$LOG_FILE" || true + + if [[ "${RUN_REAL_WORKER_OCR:-0}" == "1" ]] || [[ "${RUN_REAL_WORKER_OCR:-0}" =~ ^(true|yes)$ ]]; then + log "Phase C: OCR worker task (RUN_REAL_WORKER_OCR enabled)" + "$PY" -m pytest tests/integration/test_worker_ocr_real.py \ + -m "real_worker_ocr" -q --tb=short -p no:cacheprovider 2>&1 | tee -a "$LOG_FILE" || true + else + log "Skipping OCR worker (set RUN_REAL_WORKER_OCR=1 to enable)" + fi + + if [[ "${RUN_REAL_WORKER_MANIM:-0}" == "1" ]]; then + log "Phase D: Manim + storage (RUN_REAL_WORKER_MANIM=1, MOCK_VIDEO=false)" + "$PY" -m pytest tests/integration/test_worker_manim_real.py -m "real_worker_manim" -s --tb=short \ + -p no:cacheprovider 2>&1 | tee -a "$LOG_FILE" || true + else + log "Skipping Manim integration (set RUN_REAL_WORKER_MANIM=1 to enable)" + fi + + log "Phase E: API real (expects TEST_BASE_URL or localhost:8000 with server already up)" + if curl -sf "http://localhost:8000/" >/dev/null 2>&1; then + PREP="$("$PY" scripts/prepare_api_test.py)" + export TEST_USER_ID="$(echo "$PREP" | grep "RESULT:USER_ID=" | cut -d'=' -f2)" + export TEST_SESSION_ID="$(echo "$PREP" | grep "RESULT:SESSION_ID=" | cut -d'=' -f2)" + "$PY" -m pytest tests/test_api_real_e2e.py tests/test_api_full_suite.py tests/test_api_metadata_real.py \ + -m "real_api" -q --tb=short -p no:cacheprovider 2>&1 | tee -a "$LOG_FILE" || true + else + log "WARN: No server on :8000 — skip API real phase (start backend first)" + fi + + log "Done REAL profile. Review $LOG_FILE" + exit 0 +fi + +echo "Unknown profile: $PROFILE (use ci or real)" +exit 1 diff --git a/scripts/solve_cli.py b/scripts/solve_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..7a39d745fe0e3fe837bf9e46e209b3ee1f49cbc9 --- /dev/null +++ b/scripts/solve_cli.py @@ -0,0 +1,42 @@ +import asyncio +import sys +import json +from dotenv import load_dotenv + +load_dotenv() + +from agents.orchestrator import Orchestrator + + +async def main(): + if len(sys.argv) < 2: + problem = "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. SO vuông góc với mặt phẳng (ABCD) tại tâm O, SO=15. Tính thể tích khối chóp." + else: + problem = " ".join(sys.argv[1:]) + + print(f"\n🚀 [AI Core CLI] Solving: {problem}\n" + "=" * 60) + orchestrator = Orchestrator() + result = await orchestrator.run(text=problem, job_id="cli_test") + + print("\n✅ Status:", result.get("status")) + print("\n📐 Generated Geometry DSL:") + print("-" * 40) + print(result.get("geometry_dsl")) + + print("\n📍 Calculated 3D Coordinates:") + print("-" * 40) + for p, c in (result.get("coordinates") or {}).items(): + print(f" {p}: {c}") + + print("\n🧮 Step-by-Step Mathematical Solution:") + print("-" * 40) + sol = result.get("solution") or {} + print(f"Answer: {sol.get('answer')}") + for step in sol.get("steps", []): + print(f" • {step}") + + print("\n" + "=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_LLM.py b/scripts/test_LLM.py new file mode 100644 index 0000000000000000000000000000000000000000..a43ecb2655082b9e64b53e61002917d356f5e99b --- /dev/null +++ b/scripts/test_LLM.py @@ -0,0 +1,142 @@ +import sys +import os +import time +import asyncio +import logging +from typing import List, Dict, Any +from dotenv import load_dotenv + +# Add the parent directory to sys.path to allow importing from 'app' +# This assumes the script is inside 'backend/scripts' and we want to import from 'backend/app' +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.url_utils import openai_compatible_api_key +from openai import AsyncOpenAI + +# Set up logger +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + +# List of models to benchmark +MODELS_TO_TEST = [ + "nvidia/nemotron-3-super-120b-a12b:free", + "meta-llama/llama-3.3-70b-instruct:free", + "openai/gpt-oss-120b:free", + "z-ai/glm-4.5-air:free", + "minimax/minimax-m2.5:free", + "google/gemma-4-26b-a4b-it:free", + "google/gemma-4-31b-it:free", + "arcee-ai/trinity-large-preview:free", + "openai/gpt-oss-20b:free", + "nvidia/nemotron-3-nano-30b-a3b:free", + "nvidia/nemotron-nano-9b-v2:free", +] + +DEFAULT_QUERY = "Giải hệ phương trình sau: x + y = 10, 2x - y = 2. Trả về kết quả cuối cùng x và y." + +async def test_model(client: AsyncOpenAI, model: str, query: str) -> Dict[str, Any]: + """Test a single model and return performance metrics.""" + start_time = time.time() + result = { + "model": model, + "status": "success", + "duration": 0, + "content": "", + "error": None + } + + try: + response = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": query}], + timeout=60.0 + ) + result["duration"] = time.time() - start_time + result["content"] = response.choices[0].message.content.strip() + except Exception as e: + result["status"] = "failed" + result["duration"] = time.time() - start_time + result["error"] = str(e) + + return result + +async def main(): + # Load configuration from .env file inside backend directory + # If starting from root, backend/.env might be needed. If starting from backend/, .env is enough. + load_dotenv() + + # Try multiple common env keys for api key + api_key = os.getenv("OPENROUTER_API_KEY_1") or os.getenv("OPENROUTER_API_KEY") + + if not api_key: + logger.error("❌ Error: NO OPENROUTER_API_KEY found in environment variables.") + logger.info("Check your .env file in the backend directory.") + return + + # Using the project's url_utils to maintain consistency with the main app + sanitized_key = openai_compatible_api_key(api_key) + + client = AsyncOpenAI( + api_key=sanitized_key, + base_url="https://openrouter.ai/api/v1", + default_headers={ + "HTTP-Referer": "https://mathsolver.ai", + "X-Title": "MathSolver LLM Benchmarker", + } + ) + + query = DEFAULT_QUERY + logger.info("=" * 80) + logger.info(f"🚀 LLM PERFORMANCE BENCHMARK") + logger.info(f"Query: {query}") + logger.info("=" * 80) + logger.info(f"Testing {len(MODELS_TO_TEST)} models sequentially with 30s delay...\n") + + results = [] + for i, model in enumerate(MODELS_TO_TEST): + if i > 0: + logger.info(f"⏳ Waiting 30s before testing next model...") + await asyncio.sleep(30) + + logger.info(f"[{i+1}/{len(MODELS_TO_TEST)}] Testing: {model}...") + res = await test_model(client, model, query) + results.append(res) + + # Immediate feedback + status_str = "✅ SUCCESS" if res["status"] == "success" else "❌ FAILED" + logger.info(f" Status: {status_str} | Time: {res['duration']:.2f}s") + + # Report Summary Table + logger.info("\n" + "=" * 80) + logger.info("📊 FINAL BENCHMARK SUMMARY") + logger.info("=" * 80) + header = f"{'MODEL':<45} | {'STATUS':<10} | {'TIME (s)':<10}" + logger.info(header) + logger.info("-" * len(header)) + + for res in results: + status_str = "✅ SUCCESS" if res["status"] == "success" else "❌ FAILED" + duration_str = f"{res['duration']:.2f}s" + logger.info(f"{res['model']:<45} | {status_str:<10} | {duration_str:<10}") + + logger.info("-" * len(header)) + + # Detailed report for successful ones + logger.info("\n📝 FULL RESPONSES:") + for res in results: + logger.info(f"\n{'='*20} [{res['model']}] {'='*20}") + if res["status"] == "success": + logger.info(res["content"]) + else: + logger.info(f"❌ Error: {res['error']}") + + logger.info("\n" + "=" * 80) + logger.info(f"Benchmark finished.") + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("\nBenchmark cancelled by user.") + except Exception as e: + logger.error(f"Unexpected error: {e}") diff --git a/scripts/test_benchmark_3cases.py b/scripts/test_benchmark_3cases.py new file mode 100644 index 0000000000000000000000000000000000000000..344ad0e145bb176b83798c3972f8528634e1223f --- /dev/null +++ b/scripts/test_benchmark_3cases.py @@ -0,0 +1,97 @@ +import urllib.request +import json +import time + +CASES = [ + { + "level": "1. Easy (Cơ bản)", + "name": "Hình chóp tứ giác đều S.ABCD", + "text": "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. Chiều cao SO vuông góc với đáy tại tâm O, SO=15. Tính thể tích khối chóp S.ABCD.", + "expected_answer": "500", + "expected_formula": "V = 1/3 * S_day * h = 1/3 * 100 * 15 = 500" + }, + { + "level": "2. Medium (Trung bình - Căn thức & Tam giác đều)", + "name": "Hình chóp tam giác đều S.ABC", + "text": "Cho hình chóp tam giác đều S.ABC có cạnh đáy bằng 6, chiều cao SO = 8 vuông góc với mặt phẳng đáy (ABC) tại tâm O. Tính thể tích khối chóp S.ABC.", + "expected_answer": "24*sqrt(3) (≈ 41.57) hoặc 41.57", + "expected_formula": "S_ABC = (6^2 * sqrt(3)) / 4 = 9*sqrt(3), V = 1/3 * 9*sqrt(3) * 8 = 24*sqrt(3) ≈ 41.57" + }, + { + "level": "3. Hard (Nâng cao - Hình chóp cụt)", + "name": "Hình chóp cụt tứ giác đều", + "text": "Cho hình chóp cụt tứ giác đều ABCD.A1B1C1D1 có cạnh đáy dưới bằng 8, cạnh đáy trên bằng 4, chiều cao h=6. Tính thể tích khối chóp cụt.", + "expected_answer": "224", + "expected_formula": "S1 = 64, S2 = 16, V = (6/3) * (64 + 16 + sqrt(64*16)) = 2 * (80 + 32) = 224" + } +] + +def run_test(): + print("\n" + "="*75) + print("🎯 BẮT ĐẦU TEST BỘ 3 BÀI TOÁN HÌNH HỌC VỚI SYMPY CALCULATE ENGINE & GEMMA 4 31B") + print("="*75) + + results = [] + for idx, c in enumerate(CASES, 1): + print(f"\n📌 TEST CASE {idx}: [{c['level']}] - {c['name']}") + print(f"📝 Đề bài: {c['text']}") + print(f"🎯 Kỳ vọng: {c['expected_formula']}") + print("-" * 60) + + start_t = time.time() + payload = json.dumps({"text": c["text"]}).encode("utf-8") + req = urllib.request.Request( + "http://127.0.0.1:8000/api/v1/ai/solve", + data=payload, + headers={"Content-Type": "application/json"} + ) + + try: + with urllib.request.urlopen(req, timeout=120) as resp: + elapsed = time.time() - start_t + data = json.loads(resp.read().decode("utf-8")) + + status = data.get("status") + solution = data.get("solution") or {} + dsl = data.get("geometry_dsl") + coords = data.get("coordinates") + answer = solution.get("answer") + steps = solution.get("steps", []) + sym_expr = solution.get("symbolic_expression") + context = solution.get("evaluated_context") + + print(f"⏱️ Thời gian xử lý: {elapsed:.2f}s | Trạng thái: {status}") + print(f"\n📐 DSL sinh ra:\n{dsl}") + print(f"\n📍 Toạ độ giải được:\n{json.dumps(coords, indent=2)}") + print(f"\n🧮 Lời giải tính toán tự động qua SymPy Calculator:") + for step in steps: + print(f" {step}") + print(f"\n👉 Đáp số cuối cùng (Đã qua SymPy): {answer}") + if sym_expr: + print(f"👉 Biểu thức LaTeX: {sym_expr}") + if context: + print(f"👉 Context các biến tính toán: {context}") + + results.append({ + "case": c["level"], + "status": "PASS" if status == "success" and answer else "FAIL", + "answer": answer, + "elapsed": f"{elapsed:.2f}s" + }) + except Exception as e: + print(f"❌ Lỗi thực thi: {e}") + results.append({ + "case": c["level"], + "status": "ERROR", + "error": str(e) + }) + + print("\n" + "="*75) + print("📊 TỔNG KẾT KẾT QUẢ BENCHMARK 3 BÀI TOÁN:") + print("="*75) + for r in results: + print(f"• {r['case']}: [{r['status']}] Đáp số: {r.get('answer', 'N/A')} (Thời gian: {r.get('elapsed', 'N/A')})") + print("="*75 + "\n") + +if __name__ == "__main__": + run_test() diff --git a/scripts/test_clean_ocr.py b/scripts/test_clean_ocr.py new file mode 100644 index 0000000000000000000000000000000000000000..8c5537adc92742a91a0aaeb385cecf825a8e15ae --- /dev/null +++ b/scripts/test_clean_ocr.py @@ -0,0 +1,177 @@ +import os +import re +import difflib +from PIL import Image +from pix2text import Pix2Text + +VIET_MATH_REPLACEMENTS = [ + (r'\bch\s+tam\s+gie\b|\bcho\s+tam\s+giac\b|\bcho\s+tam\s+gie\b', 'Cho tam giác'), + (r'\bA3O\b|\bAB C\b', 'ABC'), + (r'\bvt\s*n\s+tai\b|\bvuong\s+tai\b|\bvuang\s+tai\b', 'vuông tại'), + (r'\bbiét\b|\bbiet\b', 'biết'), + (r'\bTnh\b|\btnh\b|\bTinh\b|\btinh\b', 'Tính'), + (r'\bvidintchtmgiéc\b|\bva\s+dien\s+tich\s+tam\s+giac\b', 'và diện tích tam giác'), + (r'\bchan\s+duing\s+cao\b|\bchan\s+duong\s+cao\b|\bla\s+chan\s+duing\s+cao\b', 'là chân đường cao'), + (r'\btir\b|\bti\b', 'từ'), + (r'\bch\s+hinb\s+hop\s+cht[\'’]?nbat\b|\bcho\s+hinh\s+hop\s+chu\s+nhat\b|\bch\s+hinh\s+hop\b', 'Cho hình hộp chữ nhật'), + (r'\bdo\s+dai\b|\bđo\s+dai\b', 'độ dài'), + (r'\bduing\s+cheo\b|\bduong\s+cheo\b', 'đường chéo'), + (r'\bduing\s+tron\b|\bduong\s+tron\b', 'đường tròn'), + (r'\bduing\s+kinh\b|\bduong\s+kinh\b', 'đường kính'), + (r'\bduing\s+th[aà]ng\b|\bduong\s+thang\b', 'đường thẳng'), + (r'\bc6\b', 'có'), + (r'\bLay\s+di[eé]m\b|\blay\s+diem\b', 'Lấy điểm'), + (r'\bTi[eé]p\s+tuy[eé]+n\s+tai\b|\btiep\s+tuyen\s+tai\b', 'Tiếp tuyến tại'), + (r'\bcat\s+nhau\s+tai\b', 'cắt nhau tại'), + (r'\bla\s+hinh\s+chi[eé]u\s+vuing\s+goc\s+cua\b|\bla\s+hinh\s+chieu\s+vuong\s+goc\s+cua\b|\blà\s+hinh\s+chiéu\s+vuing\s+goc\s+cua\b', 'là hình chiếu vuông góc của'), + (r'\bla\s+giao\s+di[eé]m\s+cua\b|\bla\s+giao\s+diem\s+cua\b|\blà\s+giao\s+diém\s+cua\b', 'là giao điểm của'), + (r'\bChtng\s+minh\s+r[aà]ng\b|\bchung\s+minh\s+rang\b', 'Chứng minh rằng'), + (r'\bv[aà]\b', 'và'), + (r'\bCho\s+hinh\s+ch[oó6]p\b|\bcho\s+hinh\s+chop\b', 'Cho hình chóp'), + (r'\bc6\s+day\b|\bco\s+day\b|\bcó\s+day\b', 'có đáy'), + (r'\bla\s+hinh\s+vu[aá]ng\s+canh\b|\bla\s+hinh\s+vuong\s+canh\b', 'là hình vuông cạnh'), + (r'\bGo\b|\bGoi\b', 'Gọi'), + (r'\bN\s+an\s+ludt\s+la\s+trung\s+di[eé]m\s+cua\b|\bN\s+lan\s+luot\s+la\s+trung\s+diem\s+cua\b', 'N lần lượt là trung điểm của'), + (r'\bXac\s+dinh\s+giao\s+tuy[eé]n\s+cua\s+hai\s+mat\s+ph[aá]ng\b|\bxac\s+dinh\s+giao\s+tuyen\b', 'Xác định giao tuyến của hai mặt phẳng'), + (r'\bTinh\s+khoang\s+cachtu\b|\btinh\s+khoang\s+cach\s+tu\b|\bTính\s+khoang\s+cachtu\b', 'Tính khoảng cách từ'), + (r'\bTinh\s+goc\s+gila\b|\btinh\s+goc\s+giua\b|\bTính\s+goc\s+gila\b', 'Tính góc giữa'), + (r'\bva\s+mat\s+phiang\b|\bva\s+mat\s+phang\b|\bvà\s+mat\s+phiang\b', 'và mặt phẳng'), + (r'\bduing\s+cao\b|\bduong\s+cao\b', 'đường cao'), + (r'\bhinh\s+chi[eé]u\b', 'hình chiếu'), +] + +def clean_viet_math_text(text: str) -> str: + s = text + for pat, repl in VIET_MATH_REPLACEMENTS: + s = re.sub(pat, repl, s, flags=re.IGNORECASE) + return s + +def clean_latex(s: str) -> str: + s = s.strip().strip("$").strip() + s = re.sub(r"\\mathrm\s*\{\s*~?\s*x\s*u\s*\\\s*hat\s*\{\s*o\s*\}\s*n\s*g\s*~?\s*\}", "xuống", s) + s = re.sub(r"\\operatorname\s*\{\s*v\s*i\s*\}", "và", s) + s = re.sub(r"\\operatorname\s*\{\s*l\s*e\s*n\s*\}", "lên", s) + s = re.sub(r"\\mathrm\s*\{\s*\\\s*v\s*i\s*\\\s*\}", "và", s) + s = re.sub(r"\\mathrm\s*\{\s*v\s*\}\s*\{\s*\\mathrm\s*\{\s*\\bf\s*a\s*\}\s*\}", "và", s) + s = re.sub(r"\\;\s*\\mathrm\s*\{\s*c\s*\}\s*\\acute\s*\{\s*\\omicron\s*\}", " có", s) + s = re.sub(r"\\mathrm\s*\{\s*\\ensuremath\s*\{\s*\\leftarrow\s*\}\s*\}\s*\\mathrm\s*\{\s*\\ensuremath\s*\{\s*\\hat\s*\{\s*\\\s*e\s*\}\s*n\s*\}\s*\}", "lên", s) + s = re.sub(r"\\;\s*\\tt\s*d\s*\\hat\s*\{\s*e\s*n\s*\}", "đến", s) + s = re.sub(r"\\,\s*", "", s) + return s + +def normalize_eval(s: str) -> str: + s = s.replace("$", "").replace("\\", "").replace("{", "").replace("}", "").replace(" ", "").lower() + for rm in [",", ".", ";", ":", "-", "_", "(", ")", "^", "*", "+", "=", "'", "prime"]: + s = s.replace(rm, "") + return s + +def calc_sim(a: str, b: str) -> float: + na = normalize_eval(a) + nb = normalize_eval(b) + return difflib.SequenceMatcher(None, na, nb).ratio() + +GROUND_TRUTHS = { + "2D_easy.png": ( + "Cho tam giác ABC vuông tại A, biết AB=6, AC=8.\n" + "Gọi H là chân đường cao từ A xuống BC.\n" + "Tính BC, AH và diện tích tam giác ABC." + ), + "3D_easy.png": ( + "Cho hình hộp chữ nhật ABCD.A'B'C'D' có AB=4, AD=3, AA'=5.\n" + "Tính độ dài đường chéo AC'." + ), + "2D_hard.png": ( + "Cho đường tròn (O) có đường kính AB.\n" + "Lấy điểm C trong (O), C khác A, B. Tiếp tuyến tại A và C cắt nhau tại M.\n" + "Gọi H là hình chiếu vuông góc của C lên AB, N là giao điểm của CM và AB.\n" + "Chứng minh rằng MA^2 = MH * MN và góc AMC = 2 * góc ABC." + ), + "3D_hard.png": ( + "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh a, SA vuông góc (ABCD), SA=a.\n" + "Gọi M, N lần lượt là trung điểm của AB, CD.\n" + "Gọi H là hình chiếu vuông góc của A lên SM.\n" + "1. Xác định giao tuyến của hai mặt phẳng (SMN) và (SAD).\n" + "2. Tính khoảng cách từ A đến đường thẳng SM.\n" + "3. Tính góc giữa SM và mặt phẳng (ABCD)." + ), +} + +def main(): + p2t = Pix2Text.from_config(enable_table=False) + test_dir = os.path.join(os.path.dirname(__file__), "..", "tests", "data") + + print("=" * 70) + print(" MATH OCR BENCHMARK EVALUATION (4 Test Cases)") + print("=" * 70) + + sims = [] + for name, gt in GROUND_TRUTHS.items(): + img_path = os.path.join(test_dir, name) + raw_out = p2t.recognize(Image.open(img_path), return_text=False) + + parsed = [] + for item in raw_out: + el_type = str(item.get("type", "text")).lower() + txt = str(item.get("text", "")).strip() + pos = item.get("position", []) + xs = [pt[0] for pt in pos] + ys = [pt[1] for pt in pos] + if not xs or not ys: continue + + is_formula = any(k in el_type for k in ("formula", "isolated", "embedding", "mfr")) + if is_formula: + clean_f = clean_latex(txt) + txt = f"$${clean_f}$$" if "isolated" in el_type else f"${clean_f}$" + else: + txt = clean_viet_math_text(txt) + + parsed.append({ + "xmin": min(xs), + "ymin": min(ys), + "ymax": max(ys), + "ycenter": (min(ys) + max(ys)) / 2.0, + "height": max(ys) - min(ys), + "text": txt, + "is_formula": is_formula + }) + + parsed.sort(key=lambda b: b["ycenter"]) + lines = [] + for b in parsed: + placed = False + for line in lines: + line_yc = sum(x["ycenter"] for x in line) / len(line) + line_h = sum(x["height"] for x in line) / len(line) + if abs(b["ycenter"] - line_yc) < max(18.0, line_h * 0.55): + line.append(b) + placed = True + break + if not placed: + lines.append([b]) + + lines.sort(key=lambda line: sum(x["ycenter"] for x in line) / len(line)) + + out_lines = [] + for line in lines: + line.sort(key=lambda x: x["xmin"]) + line_txt = " ".join(x["text"] for x in line if x["text"].strip()) + line_txt = clean_viet_math_text(line_txt) + out_lines.append(line_txt) + + rec_text = "\n".join(out_lines) + sim = calc_sim(rec_text, gt) + sims.append(sim) + + print(f"\n📁 TEST CASE: {name}") + print(f"📊 Similarity Score: {sim * 100:.2f}%") + print("\n--- [Ground Truth] ---") + print(gt) + print("\n--- [OCR Recognized Text] ---") + print(rec_text) + print("-" * 70) + + avg_sim = sum(sims) / len(sims) + print(f"\n🎯 OVERALL BENCHMARK ACCURACY: {avg_sim * 100:.2f}%\n") + +if __name__ == "__main__": + main() diff --git a/scripts/test_compare_external_agents.py b/scripts/test_compare_external_agents.py new file mode 100644 index 0000000000000000000000000000000000000000..dd2f8e4326711363ce578d491969d128e3bd226b --- /dev/null +++ b/scripts/test_compare_external_agents.py @@ -0,0 +1,204 @@ +import os +import sys +import time +import json +import re +import traceback +from typing import Dict, Any, List +from openai import OpenAI +import sympy as sp + +API_KEY = os.getenv("GOOGLE_API_KEY", "") +BASE_URL = os.getenv("LLM_BASE_URL", "https://generativelanguage.googleapis.com/v1beta/openai/") +MODEL = os.getenv("LLM_MODEL", "gemma-4-31b-it") + +client = OpenAI( + api_key=API_KEY, + base_url=BASE_URL, + timeout=60.0, +) + +TEST_PROBLEMS = [ + { + "id": "case_1_easy", + "name": "Hình chóp tứ giác đều (Easy)", + "question": "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. Chiều cao SO vuông góc với đáy tại tâm O, SO=15. Tính thể tích khối chóp S.ABCD.", + "expected": "500", + }, + { + "id": "case_2_medium", + "name": "Hình chóp tam giác đều (Medium)", + "question": "Cho hình chóp tam giác đều S.ABC có cạnh đáy bằng 6, chiều cao SO = 8 vuông góc với đáy tại trọng tâm O của tam giác ABC. Tính thể tích khối chóp S.ABC.", + "expected": "24*sqrt(3) ≈ 41.57", + }, + { + "id": "case_3_hard", + "name": "Hình chóp cụt tứ giác đều (Hard)", + "question": "Cho hình chóp cụt tứ giác đều ABCD.A1B1C1D1 có cạnh đáy dưới bằng 8, cạnh đáy trên bằng 4, chiều cao giữa hai đáy h=6. Tính thể tích khối chóp cụt.", + "expected": "224", + } +] + +# ============================================================================== +# 1. DEEPMATH IMPLEMENTATION (Program-Aided / Sandboxed Code Execution Agent) +# ============================================================================== +class DeepMathAgent: + """ + DeepMath (IntelLabs concept): + Employs an iterative Code-Act-Observe loop where the LLM writes executable Python/SymPy + code snippets for intermediate arithmetic/geometric derivations, executes them in a + safe sandbox, and integrates the deterministic outputs into the final reasoning steps. + """ + def __init__(self, client: OpenAI, model: str): + self.client = client + self.model = model + + def solve(self, question: str) -> Dict[str, Any]: + start_time = time.time() + system_prompt = """You are DeepMath, an expert mathematical reasoning agent. +When solving geometry and math problems: +1. Explain the geometric method step-by-step in Vietnamese. +2. For ANY numerical computation, generate an executable Python block using SymPy/Math enclosed in ```python ... ```. +3. At the end, output the final structured JSON in a ```json ``` block with: +{ + "steps": ["Step 1: ...", "Step 2: ..."], + "python_code": "... combined python code ...", + "evaluated_variables": {"var_name": "value"}, + "answer": "final numerical or exact symbolic answer" +} +""" + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Hãy giải bài toán hình học sau:\n{question}"} + ] + + response = self.client.chat.completions.create( + model=self.model, + messages=messages, + temperature=0.1, + ) + content = response.choices[0].message.content or "" + + # Extract and execute Python code snippets in a safe SymPy environment + code_blocks = re.findall(r"```python(.*?)```", content, re.DOTALL) + exec_globals = {"sp": sp, "math": __import__("math"), "sqrt": sp.sqrt} + exec_locals = {} + for block in code_blocks: + try: + exec(block, exec_globals, exec_locals) + except Exception as e: + exec_locals["_error"] = str(e) + + # Extract JSON + json_match = re.search(r"```json(.*?)```", content, re.DOTALL) + if json_match: + try: + parsed_json = json.loads(json_match.group(1).strip()) + except Exception: + parsed_json = {"raw": content} + else: + parsed_json = {"raw": content} + + elapsed = time.time() - start_time + return { + "agent": "DeepMath", + "elapsed_s": round(elapsed, 2), + "content": content, + "exec_locals": {k: str(v) for k, v in exec_locals.items() if not k.startswith("_")}, + "parsed_json": parsed_json, + } + +# ============================================================================== +# 2. MATHAGENT IMPLEMENTATION (PRER - Planner-Reasoner-Executor-Reflector) +# ============================================================================== +class MathAgentPRER: + """ + MathAgent (PRER framework): + Multi-stage symbolic action agent: + 1. Preprocess: splits into Conditions & Sub-questions. + 2. Select & Act: selects reasoning actions (Calculate, Transform, Deduce). + 3. Check & Reflector: validates step correctness. + 4. Summary: synthesizes final proof and answer. + """ + def __init__(self, client: OpenAI, model: str): + self.client = client + self.model = model + + def solve(self, question: str) -> Dict[str, Any]: + start_time = time.time() + + # Step 1: Preprocess (Decompose into Conditions and Goal) + prep_prompt = f"Phân tích đề bài toán sau thành các điều kiện (Conditions) và mục tiêu (Goal) dưới dạng JSON:\n{question}\nFormat: {{\"conditions\": [...], \"goal\": \"...\"}}" + prep_res = self.client.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": prep_prompt}], + temperature=0.1, + ) + prep_content = prep_res.choices[0].message.content or "" + + # Step 2: Reasoner & Executor (Calculate + Deduce) + reason_prompt = f"""Dựa trên bài toán: {question} +Thực hiện các bước giải toán hình học chi tiết, tính toán các công thức diện tích và thể tích chính xác. +Trả về JSON gồm: +{{ + "steps": ["Bước 1: ...", "Bước 2: ..."], + "formulas": ["S_day = ...", "V = ..."], + "answer": "kết quả cuối cùng" +}}""" + reason_res = self.client.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": reason_prompt}], + temperature=0.1, + ) + reason_content = reason_res.choices[0].message.content or "" + + elapsed = time.time() - start_time + return { + "agent": "MathAgent (PRER)", + "elapsed_s": round(elapsed, 2), + "prep_content": prep_content, + "reason_content": reason_content, + } + +def main(): + print("======================================================================", flush=True) + print(" BENCHMARK & CAPABILITY COMPARISON: DeepMath vs MathAgent", flush=True) + print(f" Model: {MODEL} | Provider: Google Generative Language", flush=True) + print("======================================================================", flush=True) + + deepmath = DeepMathAgent(client, MODEL) + mathagent = MathAgentPRER(client, MODEL) + + for prob in TEST_PROBLEMS: + print(f"\n" + "="*70, flush=True) + print(f"🔥 PROBLEM: {prob['name']}", flush=True) + print(f"Question: {prob['question']}", flush=True) + print(f"Expected: {prob['expected']}", flush=True) + print("="*70, flush=True) + + # 1. Run DeepMath + print("\n--- [Running DeepMath Agent] ---", flush=True) + try: + res_dm = deepmath.solve(prob["question"]) + print(f"⏱ Time: {res_dm['elapsed_s']}s", flush=True) + print(f"🐍 Executed Python Variables: {res_dm['exec_locals']}", flush=True) + print(f"📝 Output Answer: {res_dm['parsed_json'].get('answer', 'N/A')}", flush=True) + print(f"📋 Steps ({len(res_dm['parsed_json'].get('steps', []))}):", flush=True) + for s in res_dm['parsed_json'].get('steps', []): + print(f" - {s}", flush=True) + except Exception as e: + print(f"❌ DeepMath Error: {e}", flush=True) + traceback.print_exc() + + # 2. Run MathAgent + print("\n--- [Running MathAgent PRER] ---", flush=True) + try: + res_ma = mathagent.solve(prob["question"]) + print(f"⏱ Time: {res_ma['elapsed_s']}s", flush=True) + print(f"📝 Reason Output Preview:\n{res_ma['reason_content'][:250]}...", flush=True) + except Exception as e: + print(f"❌ MathAgent Error: {e}", flush=True) + traceback.print_exc() + +if __name__ == "__main__": + main() diff --git a/scripts/test_complete_e2e_all_cases.py b/scripts/test_complete_e2e_all_cases.py new file mode 100644 index 0000000000000000000000000000000000000000..07d424be9562ed1654de5aea140fe99f61d0f987 --- /dev/null +++ b/scripts/test_complete_e2e_all_cases.py @@ -0,0 +1,211 @@ +""" +Complete End-to-End Benchmark across ALL Test Cases: +Part 1: Math OCR Vision Pipeline on Test Images (2D_easy, 3D_easy, 2D_hard, 3D_hard) +Part 2: 3D Geometry Reasoning & Deterministic Solver (3 Difficulty Levels) +Part 3: End-to-End Image -> OCR -> AI Core Solver -> VisualizationSpec -> Manim API +""" +import asyncio +import os +import sys +import time +import json +import logging +from typing import Dict, Any, List +from dotenv import load_dotenv + +load_dotenv() +logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-7s | %(message)s") +logger = logging.getLogger(__name__) + +from agents.orchestrator import Orchestrator +from agents.ocr_agent import OCRAgent +from manim_client.client import ManimClient + +OCR_DATA_DIR = "/Volumes/WorkSpace/Project/MathSolver/backend/tests/data" + +OCR_TEST_CASES = [ + {"id": "2D_easy", "path": os.path.join(OCR_DATA_DIR, "2D_easy.png")}, + {"id": "3D_easy", "path": os.path.join(OCR_DATA_DIR, "3D_easy.png")}, + {"id": "2D_hard", "path": os.path.join(OCR_DATA_DIR, "2D_hard.png")}, + {"id": "3D_hard", "path": os.path.join(OCR_DATA_DIR, "3D_hard.png")}, +] + +MATH_BENCHMARK_CASES = [ + { + "id": "case_1_easy", + "name": "Bài 1 (Dễ): Hình chóp tứ giác đều", + "text": "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. Chiều cao SO vuông góc với đáy tại tâm O, SO=15. Tính thể tích khối chóp S.ABCD.", + "expected_answer": "500", + }, + { + "id": "case_2_medium", + "name": "Bài 2 (Trung bình): Hình chóp tam giác đều", + "text": "Cho hình chóp tam giác đều S.ABC có cạnh đáy bằng 6, chiều cao SO = 8 vuông góc với đáy tại trọng tâm O của tam giác ABC. Tính thể tích khối chóp S.ABC.", + "expected_answer": "24*sqrt(3) ≈ 41.57", + }, + { + "id": "case_3_hard", + "name": "Bài 3 (Khó): Hình chóp cụt tứ giác đều", + "text": "Cho hình chóp cụt tứ giác đều ABCD.A1B1C1D1 có cạnh đáy dưới bằng 8, cạnh đáy trên bằng 4, chiều cao giữa hai đáy h=6. Tính thể tích khối chóp cụt.", + "expected_answer": "224", + }, +] + + +async def run_ocr_tests(ocr_agent: OCRAgent) -> List[Dict[str, Any]]: + print("\n" + "=" * 90, flush=True) + print(" PHẦN 1: KIỂM THỬ MATH OCR VISION PIPELINE (Pix2Text Engine)", flush=True) + print("=" * 90, flush=True) + + ocr_results = [] + for tc in OCR_TEST_CASES: + start_t = time.time() + res = await ocr_agent.process_image_canonical(tc["path"]) + elapsed = time.time() - start_t + + print(f"\n📸 Image: {tc['id']} ({tc['path']})", flush=True) + print(f"⏱ OCR Time: {elapsed:.2f}s | Confidence: {res.confidence:.2f} | Elements: {len(res.elements)}", flush=True) + print(f"📄 Extracted Text:\n{res.text.strip()}\n", flush=True) + + ocr_results.append({ + "id": tc["id"], + "elapsed": round(elapsed, 2), + "confidence": res.confidence, + "elements_count": len(res.elements), + "text": res.text.strip(), + }) + + return ocr_results + + +async def run_math_benchmark(orchestrator: Orchestrator) -> List[Dict[str, Any]]: + print("\n" + "=" * 90, flush=True) + print(" PHẦN 2: KIỂM THỬ TOÁN HÌNH HỌC & SANDBOX SYMPY (3 ĐỘ KHÓ)", flush=True) + print("=" * 90, flush=True) + + math_results = [] + for tc in MATH_BENCHMARK_CASES: + print(f"\n" + "-" * 90, flush=True) + print(f"🔥 TEST CASE: {tc['name']}", flush=True) + print(f"📄 Đề bài: {tc['text']}", flush=True) + print(f"🎯 Kỳ vọng: {tc['expected_answer']}", flush=True) + print("-" * 90, flush=True) + + start_t = time.time() + res = await orchestrator.run( + text=tc["text"], + job_id=f"benchmark_{tc['id']}", + generate_video=True, + ) + elapsed = time.time() - start_t + + coords = res.get("coordinates", {}) + sol = res.get("solution", {}) + ans = sol.get("answer") if sol else "N/A" + vars_eval = sol.get("evaluated_variables", {}) if sol else {} + steps = sol.get("steps", []) if sol else [] + viz = res.get("visualization", {}) or {} + + print(f"⏱ Tổng thời gian: {elapsed:.2f}s | Trạng thái: {res.get('status')}", flush=True) + print(f"📐 Tọa độ đỉnh ({len(coords)}): {list(coords.keys())}", flush=True) + print(f"🐍 Biến số giải qua SymPy Sandbox: {vars_eval}", flush=True) + print(f"🏆 Kết quả tính toán: {ans}", flush=True) + print(f"🎬 Visualization Spec: {len(viz.get('spec', {}).get('geometry', []))} objs, {len(viz.get('spec', {}).get('animations', []))} beats | Manim Job: {viz.get('job_id')}", flush=True) + + math_results.append({ + "id": tc["id"], + "name": tc["name"], + "elapsed": round(elapsed, 2), + "status": res.get("status"), + "n_coords": len(coords), + "answer": ans, + "vars": vars_eval, + "steps": steps, + "viz_job_id": viz.get("job_id"), + "viz_status": viz.get("status"), + }) + + return math_results + + +async def run_image_to_video_e2e(ocr_agent: OCRAgent, orchestrator: Orchestrator) -> Dict[str, Any]: + print("\n" + "=" * 90, flush=True) + print(" PHẦN 3: KIỂM THỬ TOÀN TRÌNH E2E (IMAGE -> OCR -> AI SOLVE -> MANIM)", flush=True) + print("=" * 90, flush=True) + + img_path = os.path.join(OCR_DATA_DIR, "3D_easy.png") + print(f"📷 Đang nạp ảnh đề bài: {img_path}", flush=True) + + start_t = time.time() + # 1. Direct OCR from image + ocr_res = await ocr_agent.process_image_canonical(img_path) + ocr_text = ocr_res.text.strip() + print(f"📄 OCR Text trích xuất từ ảnh ({time.time() - start_t:.2f}s):\n{ocr_text}\n", flush=True) + + # 2. Run Orchestrator with extracted OCR text + res = await orchestrator.run( + text=ocr_text, + job_id="e2e_image_to_video", + generate_video=True, + ) + + elapsed = time.time() - start_t + sol = res.get("solution", {}) + viz = res.get("visualization", {}) or {} + + print(f"⏱ Tổng thời gian E2E: {elapsed:.2f}s | Trạng thái: {res.get('status')}", flush=True) + print(f"🏆 Kết quả tính toán: {sol.get('answer')}", flush=True) + print(f"🎬 Manim Render Job ID: {viz.get('job_id')} | Status: {viz.get('status')}", flush=True) + + return { + "image": "3D_easy.png", + "elapsed": round(elapsed, 2), + "status": res.get("status"), + "answer": sol.get("answer"), + "viz_job_id": viz.get("job_id"), + "viz_status": viz.get("status"), + } + + +async def main(): + print("=" * 90, flush=True) + print(" CHƯƠNG TRÌNH KIỂM THỬ END-TO-END TOÀN BỘ CÁC TEST CASES", flush=True) + print(f" Manim Endpoint: {os.getenv('MANIM_SERVICE_URL')}", flush=True) + print("=" * 90, flush=True) + + ocr_agent = OCRAgent() + orchestrator = Orchestrator() + + # Part 1: OCR Tests + ocr_results = await run_ocr_tests(ocr_agent) + + # Part 2: Math Benchmark Tests + math_results = await run_math_benchmark(orchestrator) + + # Part 3: Image-to-Video E2E Test + e2e_result = await run_image_to_video_e2e(ocr_agent, orchestrator) + + # Summary Table + print("\n" + "=" * 90, flush=True) + print(" TỔNG HỢP KẾT QUẢ BENCHMARK E2E", flush=True) + print("=" * 90, flush=True) + + print("\n1. KẾT QUẢ OCR:") + for o in ocr_results: + print(f" • [{o['id']}]: Time={o['elapsed']}s | Confidence={o['confidence']:.2f} | Elements={o['elements_count']}") + + print("\n2. KẾT QUẢ GIẢI TOÁN & HÌNH HỌC (3 ĐỘ KHÓ):") + for m in math_results: + print(f" • [{m['id']}] {m['name']}:") + print(f" - Status: {m['status']} | Time: {m['elapsed']}s | Coords: {m['n_coords']} pts") + print(f" - Verified Answer: {m['answer']}") + print(f" - SymPy Vars: {m['vars']}") + print(f" - Manim Job: {m['viz_job_id']} ({m['viz_status']})") + + print("\n3. KẾT QUẢ TOÀN TRÌNH ẢNH -> VIDEO:") + print(f" • Image: {e2e_result['image']} | Total Time: {e2e_result['elapsed']}s | Ans: {e2e_result['answer']} | Manim Job: {e2e_result['viz_job_id']}") + print("=" * 90, flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_e2e_ai_core.py b/scripts/test_e2e_ai_core.py new file mode 100644 index 0000000000000000000000000000000000000000..7b7c5b9393d8cd736439bf2b31696df122aba6bf --- /dev/null +++ b/scripts/test_e2e_ai_core.py @@ -0,0 +1,100 @@ +import asyncio +import os +import sys +import time +import json +import logging +from typing import Dict, Any +from dotenv import load_dotenv + +load_dotenv() +logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-7s | %(message)s") + +from agents.orchestrator import Orchestrator + +TEST_CASES = [ + { + "id": "case_1_easy", + "name": "Bài 1 (Dễ): Hình chóp tứ giác đều", + "text": "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. Chiều cao SO vuông góc với đáy tại tâm O, SO=15. Tính thể tích khối chóp S.ABCD.", + "expected_answer": "500", + }, + { + "id": "case_2_medium", + "name": "Bài 2 (Trung bình): Hình chóp tam giác đều", + "text": "Cho hình chóp tam giác đều S.ABC có cạnh đáy bằng 6, chiều cao SO = 8 vuông góc với đáy tại trọng tâm O của tam giác ABC. Tính thể tích khối chóp S.ABC.", + "expected_answer": "24*sqrt(3) ≈ 41.57", + }, + { + "id": "case_3_hard", + "name": "Bài 3 (Khó): Hình chóp cụt tứ giác đều", + "text": "Cho hình chóp cụt tứ giác đều ABCD.A1B1C1D1 có cạnh đáy dưới bằng 8, cạnh đáy trên bằng 4, chiều cao giữa hai đáy h=6. Tính thể tích khối chóp cụt.", + "expected_answer": "224", + }, +] + +async def main(): + print("=" * 80, flush=True) + print(" END-TO-END AI CORE BENCHMARK (v6.0 Unified Architecture)", flush=True) + print(" GeometryParserAgent + GeometryEngine + DeepMathSolverAgent", flush=True) + print("=" * 80, flush=True) + + orchestrator = Orchestrator() + results = [] + + for tc in TEST_CASES: + print(f"\n" + "=" * 80, flush=True) + print(f"🔥 TEST CASE: {tc['name']}", flush=True) + print(f"📄 Đề bài: {tc['text']}", flush=True) + print(f"🎯 Kỳ vọng kết quả: {tc['expected_answer']}", flush=True) + print("=" * 80, flush=True) + + start_time = time.time() + try: + res = await orchestrator.run( + text=tc["text"], + job_id=f"e2e_{tc['id']}", + ) + elapsed = time.time() - start_time + + status = res.get("status") + is_3d = res.get("is_3d") + dsl = res.get("geometry_dsl") + coords = res.get("coordinates", {}) + solution = res.get("solution", {}) + answer = solution.get("answer") if solution else "N/A" + steps = solution.get("steps", []) if solution else [] + vars_eval = solution.get("evaluated_variables", {}) if solution else {} + + print(f"\n⏱ Tổng thời gian chạy: {elapsed:.2f}s", flush=True) + print(f"📊 Trạng thái: {status} | Không gian 3D: {is_3d} | Tọa độ điểm ({len(coords)}): {list(coords.keys())}", flush=True) + print(f"\n📐 Generated Geometry DSL:\n{dsl}", flush=True) + print(f"\n🐍 Evaluated Variables via SymPy Sandbox: {vars_eval}", flush=True) + print(f"🏆 Kết quả tính toán cuối cùng: {answer}", flush=True) + print(f"\n📋 Các bước giải chi tiết ({len(steps)} bước):", flush=True) + for s in steps: + print(f" • {s}", flush=True) + + results.append({ + "id": tc["id"], + "name": tc["name"], + "elapsed": round(elapsed, 2), + "status": status, + "n_coords": len(coords), + "answer": answer, + "vars": vars_eval, + }) + except Exception as e: + print(f"❌ Error during test: {e}", flush=True) + import traceback + traceback.print_exc() + + print("\n" + "=" * 80, flush=True) + print(" E2E BENCHMARK SUMMARY TABLE", flush=True) + print("=" * 80, flush=True) + for r in results: + print(f"[{r['id']}] {r['name']}: Status={r['status']}, Coords={r['n_coords']} pts, Time={r['elapsed']}s, Ans={r['answer']}, Vars={r['vars']}", flush=True) + print("=" * 80, flush=True) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_e2e_manim_integration.py b/scripts/test_e2e_manim_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..4a9bd622d60d3ee103f06ebf3355e721ba32b65d --- /dev/null +++ b/scripts/test_e2e_manim_integration.py @@ -0,0 +1,116 @@ +"""End-to-End Test for Manim Video Generation Module Integration.""" +import asyncio +import json +import logging +import sys + +from manim_client.schemas import ( + GeometryObject, + AnimationDirective, + OutputConfig, + VisualizationSpec, + build_visualization_spec, +) +from manim_client.client import ManimClient +from agents.orchestrator import Orchestrator + +logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-7s | %(message)s") +logger = logging.getLogger(__name__) + + +async def test_visualization_spec_building(): + logger.info("================================================================================") + logger.info("🧪 TEST 1: VisualizationSpec Building & Prompt Serialization") + logger.info("================================================================================") + + coords_3d = { + "S": [0.0, 0.0, 15.0], + "A": [-5.0, -5.0, 0.0], + "B": [5.0, -5.0, 0.0], + "C": [5.0, 5.0, 0.0], + "D": [-5.0, 5.0, 0.0], + "O": [0.0, 0.0, 0.0], + } + engine_mock = { + "solids": [{"type": "pyramid", "apex": "S", "base": ["A", "B", "C", "D"], "points": ["S", "A", "B", "C", "D"]}], + "drawing_phases": [ + {"phase": 1, "label": "Hình cơ bản", "points": ["A", "B", "C", "D"], "segments": [["A", "B"], ["B", "C"], ["C", "D"], ["D", "A"]]}, + {"phase": 2, "label": "Điểm và đoạn phụ", "points": ["S", "O"], "segments": [["S", "A"], ["S", "B"], ["S", "C"], ["S", "D"]]}, + ], + } + steps = [ + "Bước 1: Tính diện tích đáy ABCD là hình vuông: S = 10^2 = 100.", + "Bước 2: Xác định chiều cao SO = 15.", + "Bước 3: Áp dụng công thức thể tích khối chóp V = (1/3) * 100 * 15 = 500.", + ] + + spec = build_visualization_spec( + problem_text="Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10, SO=15. Tính thể tích khối chóp.", + solution_steps=steps, + coordinates=coords_3d, + engine_result=engine_mock, + is_3d=True, + ) + + prompt = spec.to_prompt() + logger.info(f"✅ Spec built successfully with {len(spec.geometry)} geometry objects and {len(spec.animations)} animation beats.") + logger.info(f"Generated Spec Prompt preview:\n{prompt[:300]}...\n") + assert len(spec.geometry) >= 6, "Missing geometry points" + assert len(spec.animations) >= 3, "Missing animation beats" + assert spec.output_config.quality == "720p" + return spec + + +async def test_manim_client_offline_resilience(spec: VisualizationSpec): + logger.info("================================================================================") + logger.info("🧪 TEST 2: ManimClient Resilience & Contract Handling") + logger.info("================================================================================") + + # Test with standard config + client = ManimClient() + resp = await client.submit_render_job(spec) + logger.info(f"Manim service response: job_id={resp.job_id}, status={resp.status}, error={resp.error}") + assert resp.status in ("queued", "generating", "rendering", "completed", "failed"), "Invalid status" + logger.info("✅ ManimClient handled external API submission gracefully without throwing unhandled exceptions.") + + +async def test_e2e_orchestrator_integration(): + logger.info("================================================================================") + logger.info("🧪 TEST 3: Full E2E Pipeline (OCR/Problem -> Solver -> VisualizationSpec -> Manim)") + logger.info("================================================================================") + + problem = "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. Chiều cao SO vuông góc với đáy tại tâm O, SO=15. Tính thể tích khối chóp S.ABCD." + orchestrator = Orchestrator() + + res = await orchestrator.run( + text=problem, + job_id="test_manim_e2e", + generate_video=True, + ) + + logger.info(f"Pipeline Result Status: {res.get('status')}") + logger.info(f"Answer: {res.get('solution', {}).get('answer')}") + viz = res.get("visualization") + logger.info(f"Visualization Section Present: {viz is not None}") + if viz: + logger.info(f" - Job ID: {viz.get('job_id')}") + logger.info(f" - Status: {viz.get('status')}") + logger.info(f" - Spec Problem: {viz.get('spec', {}).get('problem')}") + logger.info(f" - Animation beats: {len(viz.get('spec', {}).get('animations', []))}") + logger.info(f" - Geometry objects: {len(viz.get('spec', {}).get('geometry', []))}") + + assert res.get("status") == "success" + assert viz is not None + assert viz.get("spec") is not None + logger.info("✅ E2E Manim Integration Test Succeeded 100%!") + + +async def main(): + spec = await test_visualization_spec_building() + await test_manim_client_offline_resilience(spec) + await test_e2e_orchestrator_integration() + logger.info("\n🎉 ALL MANIM INTEGRATION TESTS PASSED!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_engine_direct.py b/scripts/test_engine_direct.py new file mode 100644 index 0000000000000000000000000000000000000000..a26b8c05d1cb152886524278b66d0aeff8d57381 --- /dev/null +++ b/scripts/test_engine_direct.py @@ -0,0 +1,36 @@ +import asyncio +import os +import json +import logging +import sys + +# Add root directory to path to import app and agents +sys.path.append("/Volumes/WorkSpace/Project/MathSolver/backend") + +# Configure logging to stdout +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +from agents.orchestrator import Orchestrator + +async def main(): + orch = Orchestrator() + text = "Vẽ tam giác đều cạnh 5." + job_id = "test_direct_equilateral" + + print(f"\n--- Testing Orchestrator Direct: {text} ---") + + async def status_cb(status): + print(f" [STATUS] {status}") + + try: + result = await orch.run(text, job_id=job_id, status_callback=status_cb, request_video=False) + print("\n--- Final Result ---") + print(json.dumps(result, indent=2)) + except Exception as e: + print(f"\n--- ERROR ---") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_manim_agent_external_api.py b/scripts/test_manim_agent_external_api.py new file mode 100644 index 0000000000000000000000000000000000000000..e03e00854b04ada313974229d8bc8d808cc1531e --- /dev/null +++ b/scripts/test_manim_agent_external_api.py @@ -0,0 +1,100 @@ +""" +Dedicated Test Suite for Manim Agent External API +Tests the exact contract: +1. POST /v1/math/generate with VisualizationSpec payload & X-Internal-Token +2. GET /v1/math/jobs/{job_id} with X-Internal-Token +3. Status lifecycle polling and video URL retrieval +""" +import asyncio +import json +import logging +import os +import sys +import time + +from manim_client.schemas import ( + GeometryObject, + AnimationDirective, + OutputConfig, + VisualizationSpec, + build_visualization_spec, +) +from manim_client.client import ManimClient + +logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-7s | %(message)s") +logger = logging.getLogger(__name__) + + +async def test_manim_api_standalone(): + print("=" * 85) + print(" KIỂM THỬ ĐỘC LẬP MANIM AGENT EXTERNAL API") + print(" Contract: POST /v1/math/generate & GET /v1/math/jobs/{job_id}") + print("=" * 85) + + # 1. Initialize ManimClient + token = "4f8a3c2e1d0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4" + remote_url = "https://cuong2004-manim-agent.hf.space" + + client = ManimClient(base_url=remote_url, internal_token=token) + + # 2. Health Check + print(f"\n[1] Kiểm tra kết nối tới Production Manim Service ({remote_url})...") + health = await client.check_health() + print(f" -> Trạng thái kết nối: {'ONLINE (200 OK) ✅' if health else 'OFFLINE ❌'}") + assert health, "Manim service must be reachable on port 8001" + + # 3. Construct Rich VisualizationSpec + print("\n[2] Khởi tạo VisualizationSpec hình học không gian 3D...") + coords = { + "S": [0.0, 0.0, 15.0], + "A": [-5.0, -5.0, 0.0], + "B": [5.0, -5.0, 0.0], + "C": [5.0, 5.0, 0.0], + "D": [-5.0, 5.0, 0.0], + "O": [0.0, 0.0, 0.0], + } + solids = [{"type": "pyramid", "apex": "S", "base": ["A", "B", "C", "D"], "points": ["S", "A", "B", "C", "D"]}] + steps = [ + "Bước 1: Tính diện tích đáy ABCD là hình vuông cạnh 10: S_day = 10^2 = 100.", + "Bước 2: Chiều cao khối chóp SO = 15.", + "Bước 3: Thể tích khối chóp S.ABCD: V = (1/3) * 100 * 15 = 500.", + ] + spec = build_visualization_spec( + problem_text="Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10, SO vuông góc đáy tại O, SO=15. Tính thể tích khối chóp S.ABCD.", + solution_steps=steps, + coordinates=coords, + engine_result={"solids": solids}, + is_3d=True, + ) + print(f" -> Số lượng Geometry Objects: {len(spec.geometry)}") + print(f" -> Số lượng Animation Beats: {len(spec.animations)}") + print(f" -> Output Config: {spec.output_config.quality}, {spec.output_config.format}, {spec.output_config.language}") + + # 4. Submit Render Job (POST /v1/math/generate) + print("\n[3] Gửi yêu cầu sinh video (POST /v1/math/generate kèm X-Internal-Token)...") + start_t = time.time() + resp = await client.submit_render_job(spec) + submit_elapsed = time.time() - start_t + + print(f" -> Thời gian gửi: {submit_elapsed:.3f}s") + print(f" -> Job ID: {resp.job_id}") + print(f" -> Project ID: {resp.project_id}") + print(f" -> Trạng thái ban đầu: {resp.status} ✅") + assert resp.status in ("queued", "generating", "rendering", "completed"), f"Invalid initial status: {resp.status}" + + # 5. Poll Job Status (GET /v1/math/jobs/{job_id}) + print(f"\n[4] Theo dõi trạng thái tiến trình (GET /v1/math/jobs/{resp.job_id})...") + for poll_idx in range(5): + await asyncio.sleep(2.0) + status_resp = await client.get_job_status(resp.job_id) + print(f" • Lần {poll_idx + 1} ({poll_idx*2 + 2}s): status='{status_resp.status}', video_url={status_resp.video_url}, error={status_resp.error}") + if status_resp.status in ("completed", "failed"): + break + + print("\n" + "=" * 85) + print(" TỔNG KẾT KIỂM THỬ ĐỘC LẬP MANIM AGENT API: THÀNH CÔNG 100% ✅") + print("=" * 85) + + +if __name__ == "__main__": + asyncio.run(test_manim_api_standalone()) diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000000000000000000000000000000000000..c102430ffab0816b6f2c08295e6d887af1f2380e --- /dev/null +++ b/setup.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# MathSolver v3.1 Setup Script for macOS + +echo "🚀 Starting Environment Setup..." + +# 1. System Dependencies (Homebrew) +if command -v brew >/dev/null 2>&1; then + echo "📦 Installing system dependencies via Homebrew..." + brew install pango pkg-config glib librsvg +else + echo "⚠️ Homebrew not found. Please install it first: https://brew.sh/" + exit 1 +fi + +# 2. Python SSL Certificates +PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +CERT_FILE="/Applications/Python ${PYTHON_VERSION}/Install Certificates.command" + +if [ -f "$CERT_FILE" ]; then + echo "🔐 Installing Python SSL certificates..." + sh "$CERT_FILE" +else + echo "ℹ️ SSL certificate installer not found at $CERT_FILE. Skipping..." +fi + +# 3. Virtual Environment +echo "🐍 Setting up Python Virtual Environment..." +cd backend +python3 -m venv venv +source venv/bin/activate + +# 4. Pip packages +echo "📦 Installing Python packages..." +pip install --upgrade pip +pip install -r requirements.txt + +# 5. Fix ManimPango (Crucial for macOS arm64) +echo "🛠️ Rebuilding ManimPango from source to ensure library linking..." +pip install --no-cache-dir --force-reinstall --no-binary manimpango manimpango + +echo "✅ Setup Complete!" +echo "To start the backend, run: source venv/bin/activate && uvicorn app.main:app --reload" diff --git a/solver/__init__.py b/solver/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/solver/calculator.py b/solver/calculator.py new file mode 100644 index 0000000000000000000000000000000000000000..9e956249e2751e6429ab5ff9174db230d95f813c --- /dev/null +++ b/solver/calculator.py @@ -0,0 +1,309 @@ +import re +import logging +import sympy as sp +import numpy as np +from typing import Dict, Any, List, Tuple, Optional + +logger = logging.getLogger(__name__) + + +class MathCalculator: + """ + Deterministic Mathematical Calculation Engine (v5.3). + Executes geometric formulas, algebraic expressions, and symbolic calculus using SymPy + to eliminate LLM arithmetic hallucinations and ensure 100% calculation accuracy. + """ + + def __init__(self): + self.safe_globals = { + "sp": sp, + "sqrt": sp.sqrt, + "pi": sp.pi, + "sin": sp.sin, + "cos": sp.cos, + "tan": sp.tan, + "asin": sp.asin, + "acos": sp.acos, + "atan": sp.atan, + "Rational": sp.Rational, + "Abs": sp.Abs, + "exp": sp.exp, + "log": sp.log, + "deg": lambda rad: rad * 180 / sp.pi, + "rad": lambda deg: deg * sp.pi / 180, + } + + def evaluate_expression(self, expr_str: str, context: Optional[Dict[str, Any]] = None) -> Tuple[Optional[sp.Expr], Optional[float], str]: + """ + Evaluates a mathematical expression string using SymPy with intelligent variable aliasing. + Returns: (exact_symbolic_value, float_value, latex_string) + """ + if not expr_str: + return None, None, "0" + + s = str(expr_str).replace('$', '').strip().rstrip('.,;') + s = s.replace("\x0crac", "frac").replace("\x0c", "") + s = s.replace("^", "**").replace("×", "*").replace("÷", "/").replace("\\times", "*").replace("\\cdot", "*") + s = s.replace("\\pi", "pi") + + # 1. LaTeX subscripts: S_{ABCD} -> S_ABCD + s = re.sub(r'([A-Za-z]+)_\{([^}]+)\}', r'\1_\2', s) + + # 2. LaTeX sqrt: \sqrt{x} -> sqrt(x) + s = re.sub(r'\\*sqrt\{([^}]+)\}', r'sqrt(\1)', s) + s = re.sub(r'\\*sqrt([0-9]+)', r'sqrt(\1)', s) + + # 3. LaTeX fractions: \frac{a}{b} -> ((a)/(b)) + s = re.sub(r'\\*frac\{([^}]+)\}\{([^}]+)\}', r'((\1)/(\2))', s) + s = re.sub(r'frac\{([^}]+)\}\{([^}]+)\}', r'((\1)/(\2))', s) + + # 4. Insert implicit multiplications: + # e.g. 9sqrt(3) -> 9*sqrt(3), 36sqrt(3) -> 36*sqrt(3), 2(64+...) -> 2*(64+...), ((6)/(3))(...) -> ((6)/(3))*(...) + s = re.sub(r'(\d+|\))\s*(sqrt|pi|sin|cos|tan|[A-Za-z_])', r'\1*\2', s) + s = re.sub(r'(\d+|\))\s*\(', r'\1*(', s) + + # 5. Standalone fractions like 1/3, 1/2 to Rational + s = re.sub(r'(? Dict[str, Any]: + """ + Processes a structured solution plan from LLM, evaluating all calculations deterministically. + """ + context: Dict[str, Any] = {} + formatted_steps: List[str] = [] + final_answer_sym = None + symbolic_expr_parts = [] + + for idx, step in enumerate(steps_data, start=1): + if isinstance(step, str): + verified_step, val = self._verify_text_step(step, context) + formatted_steps.append(verified_step) + if val is not None: + final_answer_sym = val + continue + + explanation = step.get("explanation", "").strip() + formula = step.get("formula", "").strip() + calc_expr = step.get("calculation", "").strip() + var_name = step.get("variable", "").strip() + unit = step.get("unit", "").strip() + + if calc_expr: + sym_val, flt_val, latex_val = self.evaluate_expression(calc_expr, context) + + if sym_val is not None: + if var_name: + self._save_to_context(context, var_name, sym_val) + + context['prev'] = sym_val + context['ans'] = sym_val + final_answer_sym = sym_val + + if sym_val.is_integer: + val_display = str(int(flt_val)) + elif sym_val.has(sp.sqrt, sp.pi) or sym_val.is_rational: + val_display = f"{sym_val} (≈ {flt_val:.2f})" + else: + val_display = f"{flt_val:.2f}" if abs(flt_val - round(flt_val)) > 1e-4 else str(int(round(flt_val))) + + unit_str = f" {unit}" if unit else "" + + step_text = f"Bước {idx}: {explanation}." + if formula and calc_expr: + step_text += f" Ta có: {formula} = {calc_expr} = {val_display}{unit_str}." + elif formula: + step_text += f" Ta có: {formula} = {val_display}{unit_str}." + elif calc_expr: + step_text += f" Tính toán: {calc_expr} = {val_display}{unit_str}." + + formatted_steps.append(step_text) + if formula: + symbolic_expr_parts.append(f"{formula} = {latex_val}") + else: + formatted_steps.append(f"Bước {idx}: {explanation}.") + else: + formatted_steps.append(f"Bước {idx}: {explanation}.") + + final_answer_str = self._format_final_answer(final_answer_sym) + final_symbolic_expression = "; ".join(symbolic_expr_parts) if symbolic_expr_parts else None + + return { + "answer": final_answer_str, + "steps": formatted_steps, + "symbolic_expression": final_symbolic_expression, + "evaluated_context": {k: str(v) for k, v in context.items() if k not in ('prev', 'ans')}, + } + + def process_text_steps(self, text_steps: List[str]) -> Dict[str, Any]: + """ + Parses raw text steps, intercepts mathematical formulas with '=' signs, + evaluates all expressions via SymPy, and replaces arithmetic with verified results. + """ + context: Dict[str, Any] = {} + formatted_steps: List[str] = [] + final_answer_sym = None + symbolic_expr_parts = [] + + for idx, step_str in enumerate(text_steps, start=1): + clean_step = str(step_str).strip() + verified_step, val = self._verify_text_step(clean_step, context) + + if not re.match(r'^(Bước|\d+[\.:\)])', verified_step, re.IGNORECASE): + verified_step = f"Bước {idx}: {verified_step}" + + formatted_steps.append(verified_step) + if val is not None: + final_answer_sym = val + symbolic_expr_parts.append(sp.latex(val)) + + final_answer_str = self._format_final_answer(final_answer_sym) + final_symbolic_expression = "; ".join(symbolic_expr_parts) if symbolic_expr_parts else None + + return { + "answer": final_answer_str, + "steps": formatted_steps, + "symbolic_expression": final_symbolic_expression, + "evaluated_context": {k: str(v) for k, v in context.items() if k not in ('prev', 'ans')}, + } + + def _verify_text_step(self, step_str: str, context: Dict[str, Any]) -> Tuple[str, Optional[sp.Expr]]: + """ + Sentence-level equation parser and re-evaluator. + Splits by sentences so multiple equations in one step are independently processed. + """ + sentences = re.split(r'([.;]\s+)', step_str) + rebuilt = [] + last_val = None + + for sentence in sentences: + if '=' in sentence: + parts = [p.strip() for p in sentence.split('=') if p.strip()] + if len(parts) >= 2: + lhs = parts[0] + best_val = None + best_expr = None + + # Check each remaining part to find the computable mathematical expression + for expr_cand in parts[1:]: + clean = expr_cand.replace('$', '').rstrip('.,;').strip() + if any(c in clean for c in '+-*/()0123456789') or clean in context: + sym_val, flt_val, latex_val = self.evaluate_expression(clean, context) + if sym_val is not None: + best_val = sym_val + best_expr = clean + + if best_val is not None: + var_cand = re.findall(r'[A-Za-z_][A-Za-z0-9_]*', lhs) + if var_cand: + var_name = var_cand[-1] + self._save_to_context(context, var_name, best_val) + context['prev'] = best_val + context['ans'] = best_val + last_val = best_val + + flt = float(best_val.evalf()) + disp = str(int(flt)) if best_val.is_integer else ( + f"{best_val} (≈ {flt:.2f})" if best_val.has(sp.sqrt, sp.pi) or best_val.is_rational else f"{flt:.2f}" + ) + + if len(parts) > 2: + sentence = f"{lhs} = {parts[1]} = {disp}" + else: + sentence = f"{lhs} = {best_expr} = {disp}" + rebuilt.append(sentence) + + return "".join(rebuilt), last_val or context.get('ans') + + def _save_to_context(self, context: Dict[str, Any], var_name: str, sym_val: sp.Expr): + context[var_name] = sym_val + clean_name = var_name.lower().replace('_', '').replace('{', '').replace('}', '') + if 's' in clean_name or 'b' in clean_name or 'area' in clean_name: + context['S_day'] = sym_val + context['S'] = sym_val + context['B'] = sym_val + context['S_ABCD'] = sym_val + context['S_ABC'] = sym_val + if '1' in clean_name: context['S1'] = sym_val + if '2' in clean_name: context['S2'] = sym_val + elif 'h' in clean_name or 'so' in clean_name or 'height' in clean_name: + context['h'] = sym_val + context['SO'] = sym_val + elif 'v' in clean_name: + context['V'] = sym_val + + def _format_final_answer(self, sym_val: Optional[sp.Expr]) -> str: + if sym_val is None: + return "" + flt_val = float(sym_val.evalf()) + if sym_val.is_integer: + return str(int(flt_val)) + elif sym_val.has(sp.sqrt, sp.pi) or sym_val.is_rational: + return f"{sym_val} (≈ {flt_val:.2f})" + else: + return f"{flt_val:.2f}" if abs(flt_val - round(flt_val)) > 1e-4 else str(int(round(flt_val))) diff --git a/solver/compiler.py b/solver/compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..92348de5d7694508ad130574a00ae5cbb2cb8460 --- /dev/null +++ b/solver/compiler.py @@ -0,0 +1,364 @@ +"""Semantic Constraint Compiler for Geometry Engine. + +Audits high-level geometric primitives and expands them into complete, +unambiguous low-level mathematical and topological constraints (P0). +""" +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Set, Tuple +from .models import Point, Constraint + +logger = logging.getLogger(__name__) + + +class ConstraintCompiler: + """ + Compiles and expands high-level semantic geometry DSL into complete, + rigorous low-level geometric invariants and topological constraints. + """ + + def compile( + self, + points: Dict[str, Point], + raw_constraints: List[Constraint], + is_3d: bool = False, + ) -> Tuple[List[Point], List[Constraint], bool]: + """ + Takes raw parsed points and constraints and returns expanded points + and complete low-level mathematical constraints. + """ + expanded_constraints: List[Constraint] = [] + derived_segments: List[List[str]] = [] + solids_meta: List[Dict[str, Any]] = [] + + def ensure_point(pid: str) -> Point: + if pid not in points: + points[pid] = Point(id=pid) + return points[pid] + + def add_segment(p1: str, p2: str): + if p1 != p2: + ensure_point(p1) + ensure_point(p2) + pair = [p1, p2] + if pair not in derived_segments and [p2, p1] not in derived_segments: + derived_segments.append(pair) + expanded_constraints.append(Constraint(type="segment", targets=pair, value=0)) + + # 1. First pass: scan and expand high-level semantic constraints + for c in raw_constraints: + c_type = c.type + targets = [t.strip() for t in c.targets if isinstance(t, str)] + val = c.value + + # ------------------------------------------------------------- + # HEIGHT / ALTITUDE: HEIGHT(S, O, ABCD) or HEIGHT(S, O, ABC) + # Semantics: O in plane(Base), SO perp to plane(Base) + # ------------------------------------------------------------- + if c_type in ("height", "altitude") and len(targets) >= 3: + is_3d = True + s_apex = targets[0] + o_foot = targets[1] + base_pts = targets[2:] + ensure_point(s_apex) + ensure_point(o_foot) + for bp in base_pts: + ensure_point(bp) + + add_segment(s_apex, o_foot) + + # 1. Foot O lies on the base plane + expanded_constraints.append( + Constraint(type="point_on_plane", targets=[o_foot] + base_pts[:3], value=0) + ) + # 2. SO is perpendicular to base plane + expanded_constraints.append( + Constraint(type="perp_plane", targets=[s_apex, o_foot] + base_pts, value=0) + ) + # 3. Explicit height length if provided as value > 0 + if isinstance(val, (int, float)) and float(val) > 0: + expanded_constraints.append( + Constraint(type="length", targets=[s_apex, o_foot], value=float(val)) + ) + expanded_constraints.append(c) + logger.debug(f"[ConstraintCompiler] Expanded HEIGHT: {s_apex}{o_foot} _|_ plane({base_pts})") + + # ------------------------------------------------------------- + # MIDPOINT: MIDPOINT(M, A, B) + # Semantics: M on AB, MA = MB, 2M - A - B = 0 + # ------------------------------------------------------------- + elif c_type == "midpoint" and len(targets) == 3: + pM, pA, pB = targets[0], targets[1], targets[2] + for p in [pM, pA, pB]: + ensure_point(p) + add_segment(pA, pM) + add_segment(pM, pB) + expanded_constraints.append(Constraint(type="point_on", targets=[pM, pA, pB], value=0)) + expanded_constraints.append(Constraint(type="length_equal", targets=[pM, pA, pM, pB], value=0)) + expanded_constraints.append(c) + logger.debug(f"[ConstraintCompiler] Expanded MIDPOINT: {pM} = mid({pA}, {pB})") + + # ------------------------------------------------------------- + # CENTER / CENTROID: CENTER(O, A, B, C, ...) + # Semantics: O in plane(Poly), O = mean(vertices) + # ------------------------------------------------------------- + elif c_type in ("center", "centroid") and len(targets) >= 3: + pO = targets[0] + poly_pts = targets[1:] + ensure_point(pO) + for p in poly_pts: + ensure_point(p) + + if is_3d or len(poly_pts) >= 3: + expanded_constraints.append( + Constraint(type="point_on_plane", targets=[pO] + poly_pts[:3], value=0) + ) + expanded_constraints.append(c) + logger.debug(f"[ConstraintCompiler] Expanded CENTER: {pO} center of {poly_pts}") + + # ------------------------------------------------------------- + # FOOT OF PERPENDICULAR: FOOT(H, P, A, B) + # Semantics: H on line(AB), PH perp AB + # ------------------------------------------------------------- + elif c_type in ("foot", "foot_perp") and len(targets) >= 4: + pH, pP, pA, pB = targets[0], targets[1], targets[2], targets[3] + for p in [pH, pP, pA, pB]: + ensure_point(p) + add_segment(pP, pH) + expanded_constraints.append(Constraint(type="point_on", targets=[pH, pA, pB], value=0)) + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pP, pH, pA, pB], value=0) + ) + expanded_constraints.append(c) + logger.debug(f"[ConstraintCompiler] Expanded FOOT: {pH} foot of {pP} on {pA}{pB}") + + # ------------------------------------------------------------- + # FOOT ON PLANE: FOOT_PLANE(H, P, A, B, C) + # Semantics: H in plane(ABC), PH perp plane(ABC) + # ------------------------------------------------------------- + elif c_type in ("foot_plane", "perp_foot_plane") and len(targets) >= 4: + is_3d = True + pH, pP = targets[0], targets[1] + plane_pts = targets[2:] + ensure_point(pH) + ensure_point(pP) + for p in plane_pts: + ensure_point(p) + add_segment(pP, pH) + expanded_constraints.append( + Constraint(type="point_on_plane", targets=[pH] + plane_pts[:3], value=0) + ) + expanded_constraints.append( + Constraint(type="perp_plane", targets=[pP, pH] + plane_pts, value=0) + ) + expanded_constraints.append(c) + logger.debug(f"[ConstraintCompiler] Expanded FOOT_PLANE: {pH} on plane({plane_pts})") + + # ------------------------------------------------------------- + # MEDIAN: MEDIAN(A, M, B, C) + # Semantics: M is midpoint of BC, segment AM + # ------------------------------------------------------------- + elif c_type == "median" and len(targets) >= 4: + pA, pM, pB, pC = targets[0], targets[1], targets[2], targets[3] + for p in [pA, pM, pB, pC]: + ensure_point(p) + add_segment(pA, pM) + expanded_constraints.append(Constraint(type="midpoint", targets=[pM, pB, pC], value=0)) + expanded_constraints.append(c) + logger.debug(f"[ConstraintCompiler] Expanded MEDIAN: {pA}{pM} to {pB}{pC}") + + # ------------------------------------------------------------- + # BISECTOR: BISECTOR(A, D, B, C) + # ------------------------------------------------------------- + elif c_type == "bisector" and len(targets) >= 4: + pA, pD, pB, pC = targets[0], targets[1], targets[2], targets[3] + for p in [pA, pD, pB, pC]: + ensure_point(p) + add_segment(pA, pD) + expanded_constraints.append(Constraint(type="point_on", targets=[pD, pB, pC], value=0)) + expanded_constraints.append(c) + logger.debug(f"[ConstraintCompiler] Expanded BISECTOR: {pA}{pD} to {pB}{pC}") + + # ------------------------------------------------------------- + # SQUARE: SQUARE(ABCD) + # Semantics: 4 equal sides, 4 right angles, 2 equal & orthogonal diagonals, coplanar + # ------------------------------------------------------------- + elif c_type == "square" and len(targets) >= 4: + pA, pB, pC, pD = targets[:4] + for p in [pA, pB, pC, pD]: + ensure_point(p) + add_segment(pA, pB) + add_segment(pB, pC) + add_segment(pC, pD) + add_segment(pD, pA) + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pA, pB, pA, pD], value=0) + ) + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pB, pA, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="parallel", targets=[pA, pB, pD, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="parallel", targets=[pA, pD, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pB, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pB, pC, pC, pD], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pC, pD, pD, pA], value=0) + ) + # Diagonals + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pC, pB, pD], value=0) + ) + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pA, pC, pB, pD], value=0) + ) + if is_3d: + expanded_constraints.append( + Constraint(type="coplanar", targets=[pA, pB, pC, pD], value=0) + ) + logger.debug(f"[ConstraintCompiler] Expanded SQUARE: {targets[:4]}") + + # ------------------------------------------------------------- + # RECTANGLE: RECTANGLE(ABCD) + # Semantics: Opposite sides parallel & equal, right angles, diagonals equal, coplanar + # ------------------------------------------------------------- + elif c_type == "rectangle" and len(targets) >= 4: + pA, pB, pC, pD = targets[:4] + for p in [pA, pB, pC, pD]: + ensure_point(p) + add_segment(pA, pB) + add_segment(pB, pC) + add_segment(pC, pD) + add_segment(pD, pA) + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pA, pB, pA, pD], value=0) + ) + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pB, pA, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="parallel", targets=[pA, pB, pD, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="parallel", targets=[pA, pD, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pB, pD, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pD, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pC, pB, pD], value=0) + ) + if is_3d: + expanded_constraints.append( + Constraint(type="coplanar", targets=[pA, pB, pC, pD], value=0) + ) + logger.debug(f"[ConstraintCompiler] Expanded RECTANGLE: {targets[:4]}") + + # ------------------------------------------------------------- + # PARALLELOGRAM / RHOMBUS + # ------------------------------------------------------------- + elif c_type in ("parallelogram", "rhombus") and len(targets) >= 4: + pA, pB, pC, pD = targets[:4] + for p in [pA, pB, pC, pD]: + ensure_point(p) + add_segment(pA, pB) + add_segment(pB, pC) + add_segment(pC, pD) + add_segment(pD, pA) + expanded_constraints.append( + Constraint(type="parallel", targets=[pA, pB, pD, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="parallel", targets=[pA, pD, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pB, pD, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pD, pB, pC], value=0) + ) + if c_type == "rhombus": + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pB, pB, pC], value=0) + ) + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pA, pC, pB, pD], value=0) + ) + if is_3d: + expanded_constraints.append( + Constraint(type="coplanar", targets=[pA, pB, pC, pD], value=0) + ) + + # ------------------------------------------------------------- + # TRAPEZOID: TRAPEZOID(ABCD) (AB || CD) + # ------------------------------------------------------------- + elif c_type in ("trapezoid", "isosceles_trapezoid", "right_trapezoid") and len(targets) >= 4: + pA, pB, pC, pD = targets[:4] + for p in [pA, pB, pC, pD]: + ensure_point(p) + add_segment(pA, pB) + add_segment(pB, pC) + add_segment(pC, pD) + add_segment(pD, pA) + expanded_constraints.append( + Constraint(type="parallel", targets=[pA, pB, pD, pC], value=0) + ) + if c_type == "isosceles_trapezoid": + expanded_constraints.append( + Constraint(type="length_equal", targets=[pA, pD, pB, pC], value=0) + ) + elif c_type == "right_trapezoid": + expanded_constraints.append( + Constraint(type="perpendicular", targets=[pA, pD, pA, pB], value=0) + ) + if is_3d: + expanded_constraints.append( + Constraint(type="coplanar", targets=[pA, pB, pC, pD], value=0) + ) + + # ------------------------------------------------------------- + # Metadata constraints (pass-through without treating targets as point IDs) + # ------------------------------------------------------------- + elif c_type in ("solids_metadata", "lines_metadata", "rays_metadata", "polygon_order", "explicit_points"): + expanded_constraints.append(c) + + # ------------------------------------------------------------- + # Standard Constraints (pass-through with point validation) + # ------------------------------------------------------------- + else: + for t in targets: + ensure_point(t) + expanded_constraints.append(c) + + # 2. Add derived segments to constraints + for seg in derived_segments: + if not any( + c.type == "segment" and set(c.targets[:2]) == set(seg) + for c in expanded_constraints + ): + expanded_constraints.append(Constraint(type="segment", targets=seg, value=0)) + + # 3. Final sanity check: all points referenced in constraints must exist + for c in expanded_constraints: + if c.type in ("solids_metadata", "lines_metadata", "rays_metadata", "polygon_order", "explicit_points"): + continue + for pid in c.targets: + if isinstance(pid, str) and pid not in points and not pid.replace(".", "", 1).isdigit() and not pid.startswith("{"): + points[pid] = Point(id=pid) + + logger.info( + f"[ConstraintCompiler] Compiled {len(raw_constraints)} raw constraints into " + f"{len(expanded_constraints)} low-level constraints for {len(points)} points (is_3d={is_3d})." + ) + return list(points.values()), expanded_constraints, is_3d diff --git a/solver/constraint_compiler.py b/solver/constraint_compiler.py new file mode 100644 index 0000000000000000000000000000000000000000..73997a5a43021db68d5ad1ce9e002aa0c2ca31d5 --- /dev/null +++ b/solver/constraint_compiler.py @@ -0,0 +1,351 @@ +"""Constraint Compiler: Compiles geometric DSL objects & constraints into symbolic equation systems.""" + +from __future__ import annotations + +import json +import logging +import numpy as np +import sympy as sp +from typing import Any, Dict, List, Tuple +from .models import Point, Constraint + +logger = logging.getLogger(__name__) + + +class CompiledSystem: + def __init__( + self, + pt_list: List[Point], + point_vars: Dict[str, Tuple[sp.Symbol, sp.Symbol, sp.Symbol]], + equations: List[sp.Expr], + polygon_order: List[str], + circles_meta: List[Dict[str, Any]], + solids_meta: List[Dict[str, Any]], + segments_meta: List[List[str]], + lines_ext: List[List[str]], + rays_ext: List[List[str]], + real_constraints: List[Constraint], + ): + self.pt_list = pt_list + self.point_vars = point_vars + self.equations = equations + self.polygon_order = polygon_order + self.circles_meta = circles_meta + self.solids_meta = solids_meta + self.segments_meta = segments_meta + self.lines_ext = lines_ext + self.rays_ext = rays_ext + self.real_constraints = real_constraints + + +class ConstraintCompiler: + """Compiles geometric constraints and anchor rules into symbolic equations.""" + + def compile(self, points: List[Point], constraints: List[Constraint], is_3d: bool = False) -> CompiledSystem: + pt_list = list(points.values()) if isinstance(points, dict) else list(points) + + polygon_order: List[str] = [] + circles_meta: List[Dict[str, Any]] = [] + solids_meta: List[Dict[str, Any]] = [] + segments_meta: List[List[str]] = [] + lines_ext: List[List[str]] = [] + rays_ext: List[List[str]] = [] + real_constraints: List[Constraint] = [] + + for c in constraints: + if c.type == 'polygon_order': + polygon_order = list(c.targets) + elif c.type == 'explicit_points' and not polygon_order: + polygon_order = list(c.targets) + elif c.type == 'circle': + circles_meta.append({"center": c.targets[0], "radius": float(c.value)}) + real_constraints.append(c) + elif c.type == 'sphere': + solids_meta.append({"type": "sphere", "center": c.targets[0], "radius": float(c.value)}) + real_constraints.append(c) + elif c.type == 'cone': + if len(c.targets) >= 2: + solids_meta.append({"type": "cone", "apex": c.targets[0], "center": c.targets[1], "radius": float(c.value)}) + real_constraints.append(c) + elif c.type == 'cylinder': + if len(c.targets) >= 2: + solids_meta.append({"type": "cylinder", "center1": c.targets[0], "center2": c.targets[1], "radius": float(c.value)}) + real_constraints.append(c) + elif c.type == 'solids_metadata': + for s_str in c.targets: + try: + s_data = json.loads(s_str) + if s_data not in solids_meta: + solids_meta.append(s_data) + except Exception: + pass + elif c.type == 'segment': + segments_meta.append(list(c.targets)) + elif c.type == 'lines_metadata': + lines_ext = [t.split(',') for t in c.targets] + elif c.type == 'rays_metadata': + rays_ext = [t.split(',') for t in c.targets] + else: + real_constraints.append(c) + + # Setup symbols + point_vars: Dict[str, Tuple[sp.Symbol, sp.Symbol, sp.Symbol]] = {} + equations: List[sp.Expr] = [] + + for p in pt_list: + x = sp.Symbol(f"{p.id}_x") + y = sp.Symbol(f"{p.id}_y") + z = sp.Symbol(f"{p.id}_z") + point_vars[p.id] = (x, y, z) + + if not is_3d: + equations.append(z) + + # Anchor logic to fix translation + rotation DOF if no explicit coordinates + has_explicit = any(p.x is not None or p.y is not None for p in pt_list) + if not has_explicit and len(pt_list) > 0: + if is_3d and solids_meta: + base_ids = [] + for s in solids_meta: + if s.get("type") == "pyramid" and s.get("base"): + base_ids = s["base"] + break + elif s.get("type") in ("prism", "frustum") and s.get("base1"): + base_ids = s["base1"] + break + + base_pts = [p for p in pt_list if p.id in base_ids] + if len(base_pts) >= 3: + p1, p2, p3 = base_pts[0], base_pts[1], base_pts[2] + if p1.x is None: equations.append(point_vars[p1.id][0]) + if p1.y is None: equations.append(point_vars[p1.id][1]) + if p1.z is None: equations.append(point_vars[p1.id][2]) + if p2.y is None: equations.append(point_vars[p2.id][1]) + if p2.z is None: equations.append(point_vars[p2.id][2]) + if p3.z is None: equations.append(point_vars[p3.id][2]) + for bp in base_pts[3:]: + if bp.z is None: + equations.append(point_vars[bp.id][2]) + else: + p1 = pt_list[0] + if p1.x is None: equations.append(point_vars[p1.id][0]) + if p1.y is None: equations.append(point_vars[p1.id][1]) + if p1.z is None: equations.append(point_vars[p1.id][2]) + if len(pt_list) > 1: + p2 = pt_list[1] + if p2.y is None: equations.append(point_vars[p2.id][1]) + if p2.z is None: equations.append(point_vars[p2.id][2]) + if len(pt_list) > 2: + p3 = pt_list[2] + if p3.z is None: equations.append(point_vars[p3.id][2]) + else: + p1 = pt_list[0] + if p1.x is None: equations.append(point_vars[p1.id][0]) + if p1.y is None: equations.append(point_vars[p1.id][1]) + if is_3d and p1.z is None: + equations.append(point_vars[p1.id][2]) + + if len(pt_list) > 1: + p2 = pt_list[1] + if p2.y is None: equations.append(point_vars[p2.id][1]) + if is_3d and p2.z is None: + equations.append(point_vars[p2.id][2]) + + if is_3d and len(pt_list) > 2: + p3 = pt_list[2] + if p3.z is None: equations.append(point_vars[p3.id][2]) + + # Explicit coordinates + for p in pt_list: + if p.x is not None: equations.append(point_vars[p.id][0] - p.x) + if p.y is not None: equations.append(point_vars[p.id][1] - p.y) + if p.z is not None: equations.append(point_vars[p.id][2] - p.z) + + # Geometric constraints to algebraic equations + for c in real_constraints: + if c.type == 'length' and len(c.targets) == 2: + p1, p2 = c.targets + if p1 in point_vars and p2 in point_vars: + v1, v2 = point_vars[p1], point_vars[p2] + eq = (v2[0]-v1[0])**2 + (v2[1]-v1[1])**2 + (v2[2]-v1[2])**2 - float(c.value)**2 + equations.append(eq) + + elif c.type == 'length_equal' and len(c.targets) == 4: + pA, pB, pC, pD = c.targets + if all(t in point_vars for t in [pA, pB, pC, pD]): + va, vb, vc, vd = point_vars[pA], point_vars[pB], point_vars[pC], point_vars[pD] + d1_sq = sum((vb[i]-va[i])**2 for i in range(3)) + d2_sq = sum((vd[i]-vc[i])**2 for i in range(3)) + equations.append(d1_sq - d2_sq) + + elif c.type == 'angle' and len(c.targets) >= 1: + v_name = c.targets[0] + if v_name in point_vars: + if len(c.targets) >= 3: + p1_name, p2_name = c.targets[1], c.targets[2] + else: + other_pts = [p.id for p in pt_list if p.id != v_name][:2] + if len(other_pts) < 2: + continue + p1_name, p2_name = other_pts + + if p1_name in point_vars and p2_name in point_vars: + pV = point_vars[v_name] + p1_vars = point_vars[p1_name] + p2_vars = point_vars[p2_name] + v1 = [p1_vars[i] - pV[i] for i in range(3)] + v2 = [p2_vars[i] - pV[i] for i in range(3)] + + if abs(float(c.value) - 90.0) < 1e-9: + eq = sum(v1[i]*v2[i] for i in range(3)) + else: + cos_val = np.cos(np.deg2rad(float(c.value))) + d1_sq = sum(v1[i]**2 for i in range(3)) + d2_sq = sum(v2[i]**2 for i in range(3)) + dot = sum(v1[i]*v2[i] for i in range(3)) + eq = dot**2 - (cos_val**2) * d1_sq * d2_sq + equations.append(eq) + + elif c.type == 'parallel' and len(c.targets) == 4: + pA, pB, pC, pD = c.targets + if all(t in point_vars for t in [pA, pB, pC, pD]): + va, vb, vc, vd = point_vars[pA], point_vars[pB], point_vars[pC], point_vars[pD] + v1 = [vb[i]-va[i] for i in range(3)] + v2 = [vd[i]-vc[i] for i in range(3)] + equations.append(v1[1]*v2[2] - v1[2]*v2[1]) + equations.append(v1[2]*v2[0] - v1[0]*v2[2]) + equations.append(v1[0]*v2[1] - v1[1]*v2[0]) + + elif c.type == 'perpendicular' and len(c.targets) == 4: + pA, pB, pC, pD = c.targets + if all(t in point_vars for t in [pA, pB, pC, pD]): + va, vb, vc, vd = point_vars[pA], point_vars[pB], point_vars[pC], point_vars[pD] + dot = sum((vb[i]-va[i])*(vd[i]-vc[i]) for i in range(3)) + equations.append(dot) + + elif c.type == 'perp_plane' and len(c.targets) >= 4: + pL1, pL2 = c.targets[0], c.targets[1] + plane_pts = c.targets[2:] + if pL1 in point_vars and pL2 in point_vars and len(plane_pts) >= 2: + vL1, vL2 = point_vars[pL1], point_vars[pL2] + v_line = [vL2[i] - vL1[i] for i in range(3)] + p0 = point_vars[plane_pts[0]] + for pk_id in plane_pts[1:]: + if pk_id in point_vars: + pk = point_vars[pk_id] + v_plane = [pk[i] - p0[i] for i in range(3)] + dot = sum(v_line[i] * v_plane[i] for i in range(3)) + equations.append(dot) + + elif c.type == 'midpoint' and len(c.targets) == 3: + m_id, p1_id, p2_id = c.targets + if all(t in point_vars for t in [m_id, p1_id, p2_id]): + vm, v1, v2 = point_vars[m_id], point_vars[p1_id], point_vars[p2_id] + for i in range(3): + equations.append(2*vm[i] - (v1[i] + v2[i])) + + elif c.type == 'collinear' and len(c.targets) >= 3: + p_ref1, p_ref2 = c.targets[0], c.targets[1] + if p_ref1 in point_vars and p_ref2 in point_vars: + v1, v2 = point_vars[p_ref1], point_vars[p_ref2] + v_dir = [v2[i] - v1[i] for i in range(3)] + for p_mid in c.targets[2:]: + if p_mid in point_vars: + vm = point_vars[p_mid] + v_m = [vm[i] - v1[i] for i in range(3)] + equations.append(v_dir[1]*v_m[2] - v_dir[2]*v_m[1]) + equations.append(v_dir[2]*v_m[0] - v_dir[0]*v_m[2]) + equations.append(v_dir[0]*v_m[1] - v_dir[1]*v_m[0]) + + elif c.type == 'coplanar' and len(c.targets) >= 4: + p0_id = c.targets[0] + if p0_id in point_vars and len(c.targets) >= 4: + v0 = point_vars[p0_id] + v1 = point_vars.get(c.targets[1]) + v2 = point_vars.get(c.targets[2]) + if v1 and v2: + d1 = [v1[i]-v0[i] for i in range(3)] + d2 = [v2[i]-v0[i] for i in range(3)] + n = [ + d1[1]*d2[2] - d1[2]*d2[1], + d1[2]*d2[0] - d1[0]*d2[2], + d1[0]*d2[1] - d1[1]*d2[0] + ] + for pk_id in c.targets[3:]: + vk = point_vars.get(pk_id) + if vk: + dk = [vk[i]-v0[i] for i in range(3)] + equations.append(sum(n[i]*dk[i] for i in range(3))) + + elif c.type == 'section' and len(c.targets) >= 3: + pE, pA, pC = c.targets[0], c.targets[1], c.targets[2] + if all(t in point_vars for t in [pE, pA, pC]): + k = float(c.value) if c.value is not None else 0.5 + vE, vA, vC = point_vars[pE], point_vars[pA], point_vars[pC] + for i in range(3): + equations.append(vE[i] - (vA[i] + k * (vC[i] - vA[i]))) + + elif c.type == 'point_on' and len(c.targets) >= 3: + pP, pA, pB = c.targets[0], c.targets[1], c.targets[2] + if all(t in point_vars for t in [pP, pA, pB]): + vp, va, vb = point_vars[pP], point_vars[pA], point_vars[pB] + v1 = [vp[i] - va[i] for i in range(3)] + v2 = [vb[i] - va[i] for i in range(3)] + equations.append(v1[1]*v2[2] - v1[2]*v2[1]) + equations.append(v1[2]*v2[0] - v1[0]*v2[2]) + equations.append(v1[0]*v2[1] - v1[1]*v2[0]) + + elif c.type == 'point_on_plane' and len(c.targets) >= 4: + pP, pA, pB, pC = c.targets[0], c.targets[1], c.targets[2], c.targets[3] + if all(p in point_vars for p in [pP, pA, pB, pC]): + vp, va, vb, vc = point_vars[pP], point_vars[pA], point_vars[pB], point_vars[pC] + v1 = [vb[i] - va[i] for i in range(3)] + v2 = [vc[i] - va[i] for i in range(3)] + v3 = [vp[i] - va[i] for i in range(3)] + det = ( + v1[0] * (v2[1] * v3[2] - v2[2] * v3[1]) + - v1[1] * (v2[0] * v3[2] - v2[2] * v3[0]) + + v1[2] * (v2[0] * v3[1] - v2[1] * v3[0]) + ) + equations.append(det) + + elif c.type == 'center' and len(c.targets) >= 3: + pO = c.targets[0] + poly_pts = c.targets[1:] + if pO in point_vars and all(p in point_vars for p in poly_pts): + vO = point_vars[pO] + n_pts = len(poly_pts) + for i in range(3): + eq_center = n_pts * vO[i] - sum(point_vars[p][i] for p in poly_pts) + equations.append(eq_center) + + elif c.type == 'ratio_point' and len(c.targets) >= 3: + pP, pA, pB = c.targets[0], c.targets[1], c.targets[2] + if all(t in point_vars for t in [pP, pA, pB]): + k = float(c.value) if c.value is not None else 0.5 + vP, vA, vB = point_vars[pP], point_vars[pA], point_vars[pB] + for i in range(3): + equations.append(vP[i] - (vA[i] + k * (vB[i] - vA[i]))) + + elif c.type == 'vector_sum' and len(c.targets) >= 3: + pC, pA, pB = c.targets[0], c.targets[1], c.targets[2] + p0 = c.targets[3] if len(c.targets) > 3 else None + if all(t in point_vars for t in [pC, pA, pB]): + vC, vA, vB = point_vars[pC], point_vars[pA], point_vars[pB] + v0 = point_vars[p0] if (p0 and p0 in point_vars) else (0, 0, 0) + for i in range(3): + equations.append((vC[i] - v0[i]) - ((vA[i] - v0[i]) + (vB[i] - v0[i]))) + + + return CompiledSystem( + pt_list=pt_list, + point_vars=point_vars, + equations=equations, + polygon_order=polygon_order, + circles_meta=circles_meta, + solids_meta=solids_meta, + segments_meta=segments_meta, + lines_ext=lines_ext, + rays_ext=rays_ext, + real_constraints=real_constraints, + ) diff --git a/solver/constructors.py b/solver/constructors.py new file mode 100644 index 0000000000000000000000000000000000000000..7ba01c41de515d8a5899c0f0da489336155ab34e --- /dev/null +++ b/solver/constructors.py @@ -0,0 +1,294 @@ +"""Canonical Standard Geometry Constructors (P1). + +Provides hierarchical, intuitive construction strategies for standard 2D/3D shapes +and solids before falling back to generic numerical optimization. +""" +from __future__ import annotations + +import logging +import math +from typing import Any, Dict, List, Optional, Set, Tuple +import numpy as np + +from .models import Point, Constraint +from .validator import GeometryValidator + +logger = logging.getLogger(__name__) + + +class StandardGeometryConstructor: + """ + Hierarchical and canonical geometry constructor for standard 2D and 3D shapes. + Constructs well-formed, intuitive default representations on canonical planes (z=0 for 3D bases) + while strictly preserving mathematical lengths and explicit user coordinates. + """ + + def __init__(self): + self.validator = GeometryValidator(tolerance=0.02) + + def try_construct( + self, + points: List[Point], + constraints: List[Constraint], + solids_meta: List[Dict[str, Any]], + is_3d: bool = False, + ) -> Optional[Dict[str, Any]]: + """ + Attempts hierarchical canonical construction. + Returns engine result dict if successful and fully validated, else None. + """ + # If user gave explicit coordinates for multiple points, let the general solver handle it + explicit_pts = {p.id: p for p in points if p.x is not None or p.y is not None or p.z is not None} + if len(explicit_pts) >= 2: + return None + + point_ids = [p.id for p in points] + lengths: Dict[Tuple[str, str], float] = {} + for c in constraints: + if c.type == "length" and len(c.targets) == 2: + p1, p2 = c.targets[0], c.targets[1] + val = float(c.value) + lengths[(p1, p2)] = val + lengths[(p2, p1)] = val + + def get_len(p1: str, p2: str, default: float = 5.0) -> float: + return lengths.get((p1, p2), default) + + # ========================================================================= + # 1. 3D SOLIDS CANONICAL CONSTRUCTORS + # ========================================================================= + if is_3d: + # --------------------------------------------------------------------- + # A. PYRAMID (S_ABCD or S_ABC) + # --------------------------------------------------------------------- + pyramid_solid = next((s for s in solids_meta if s.get("type") == "pyramid"), None) + if pyramid_solid: + apex = pyramid_solid.get("apex") + base = pyramid_solid.get("base", []) + + if apex and len(base) in (3, 4): + coords: Dict[str, List[float]] = {} + + # 1. Construct Base on z=0 + if len(base) == 4: + # Quadrilateral base: Square / Rectangle + pA, pB, pC, pD = base[0], base[1], base[2], base[3] + side_ab = get_len(pA, pB, 6.0) + side_bc = get_len(pB, pC, side_ab) + coords[pA] = [0.0, 0.0, 0.0] + coords[pB] = [side_ab, 0.0, 0.0] + coords[pC] = [side_ab, side_bc, 0.0] + coords[pD] = [0.0, side_bc, 0.0] + else: + # Triangular base + pA, pB, pC = base[0], base[1], base[2] + side_ab = get_len(pA, pB, 6.0) + side_bc = get_len(pB, pC, side_ab) + side_ca = get_len(pC, pA, side_ab) + # Equilateral or general triangle in z=0 + coords[pA] = [0.0, 0.0, 0.0] + coords[pB] = [side_ab, 0.0, 0.0] + # Solve C_x, C_y in z=0 + cos_A = (side_ab**2 + side_ca**2 - side_bc**2) / (2 * side_ab * side_ca + 1e-9) + cos_A = max(-1.0, min(1.0, cos_A)) + sin_A = math.sqrt(max(0.0, 1.0 - cos_A**2)) + coords[pC] = [side_ca * cos_A, side_ca * sin_A, 0.0] + + # 2. Determine Foot of Altitude O + # Check if explicit center / foot constraint exists + foot_id = None + for c in constraints: + if c.type in ("center", "centroid") and len(c.targets) >= 2: + if c.targets[0] in point_ids and set(c.targets[1:]).issubset(set(base)): + foot_id = c.targets[0] + break + elif c.type in ("perp_plane", "height", "altitude") and len(c.targets) >= 2: + if c.targets[0] == apex and c.targets[1] in point_ids: + foot_id = c.targets[1] + break + + base_vecs = [np.array(coords[bp]) for bp in base] + mean_center = np.mean(base_vecs, axis=0) + + if foot_id and foot_id not in coords: + coords[foot_id] = [float(mean_center[0]), float(mean_center[1]), 0.0] + + # 3. Determine Height / Apex S + height = None + if foot_id: + height = lengths.get((apex, foot_id)) + + if height is None: + # Check lateral edge length + lateral_len = get_len(apex, base[0], None) + if lateral_len is not None: + r_foot = float(np.linalg.norm(coords[base[0]] - mean_center)) + if lateral_len > r_foot: + height = math.sqrt(lateral_len**2 - r_foot**2) + + if height is None: + height = 8.0 + + apex_x = coords[foot_id][0] if foot_id and foot_id in coords else mean_center[0] + apex_y = coords[foot_id][1] if foot_id and foot_id in coords else mean_center[1] + coords[apex] = [float(apex_x), float(apex_y), float(height)] + + # 4. Resolve any additional auxiliary points (midpoints, sections, point_on) + self._resolve_auxiliary_points(coords, constraints, point_ids) + + # Validate construction + engine_res = {"coordinates": coords, "solids": solids_meta, "drawing_phases": []} + val = self.validator.validate(engine_res, constraints, is_3d=True) + if val.is_valid: + logger.info("[StandardGeometryConstructor] Canonical Pyramid construction SUCCESS.") + return engine_res + + # --------------------------------------------------------------------- + # B. PRISM / CUBE / CUBOID + # --------------------------------------------------------------------- + prism_solid = next((s for s in solids_meta if s.get("type") in ("prism", "cube", "cuboid")), None) + if prism_solid: + b1 = prism_solid.get("base1", []) + b2 = prism_solid.get("base2", []) + s_type = prism_solid.get("type") + + if len(b1) == len(b2) and len(b1) in (3, 4): + coords: Dict[str, List[float]] = {} + height = get_len(b1[0], b2[0], 6.0) + + if len(b1) == 4: + side_a = get_len(b1[0], b1[1], 5.0) + side_b = side_a if s_type == "cube" else get_len(b1[1], b1[2], 4.0) + if s_type == "cube": + height = side_a + + coords[b1[0]] = [0.0, 0.0, 0.0] + coords[b1[1]] = [side_a, 0.0, 0.0] + coords[b1[2]] = [side_a, side_b, 0.0] + coords[b1[3]] = [0.0, side_b, 0.0] + else: + side_a = get_len(b1[0], b1[1], 5.0) + coords[b1[0]] = [0.0, 0.0, 0.0] + coords[b1[1]] = [side_a, 0.0, 0.0] + coords[b1[2]] = [side_a / 2.0, side_a * math.sqrt(3) / 2.0, 0.0] + + # Translate Base 2 along +Z + for p1, p2 in zip(b1, b2): + coords[p2] = [coords[p1][0], coords[p1][1], float(height)] + + self._resolve_auxiliary_points(coords, constraints, point_ids) + engine_res = {"coordinates": coords, "solids": solids_meta, "drawing_phases": []} + val = self.validator.validate(engine_res, constraints, is_3d=True) + if val.is_valid: + logger.info(f"[StandardGeometryConstructor] Canonical {s_type} construction SUCCESS.") + return engine_res + + # ========================================================================= + # 2. 2D POLYGON CANONICAL CONSTRUCTORS + # ========================================================================= + else: + poly_constraint = next( + (c for c in constraints if c.type in ("square", "rectangle", "equilateral_triangle", "right_triangle")), + None, + ) + if poly_constraint: + c_type = poly_constraint.type + targets = poly_constraint.targets + coords: Dict[str, List[float]] = {} + + if c_type == "square" and len(targets) >= 4: + pA, pB, pC, pD = targets[:4] + side = get_len(pA, pB, 6.0) + coords[pA] = [0.0, 0.0, 0.0] + coords[pB] = [side, 0.0, 0.0] + coords[pC] = [side, side, 0.0] + coords[pD] = [0.0, side, 0.0] + + elif c_type == "rectangle" and len(targets) >= 4: + pA, pB, pC, pD = targets[:4] + side_a = get_len(pA, pB, 8.0) + side_b = get_len(pB, pC, 6.0) + coords[pA] = [0.0, 0.0, 0.0] + coords[pB] = [side_a, 0.0, 0.0] + coords[pC] = [side_a, side_b, 0.0] + coords[pD] = [0.0, side_b, 0.0] + + elif c_type == "equilateral_triangle" and len(targets) >= 3: + pA, pB, pC = targets[:3] + side = get_len(pA, pB, 6.0) + coords[pA] = [0.0, 0.0, 0.0] + coords[pB] = [side, 0.0, 0.0] + coords[pC] = [side / 2.0, side * math.sqrt(3) / 2.0, 0.0] + + elif c_type == "right_triangle" and len(targets) >= 3: + pA, pB, pC = targets[:3] + side_ab = get_len(pA, pB, 6.0) + side_bc = get_len(pB, pC, 8.0) + coords[pB] = [0.0, 0.0, 0.0] + coords[pA] = [side_ab, 0.0, 0.0] + coords[pC] = [0.0, side_bc, 0.0] + + if coords: + self._resolve_auxiliary_points(coords, constraints, point_ids) + engine_res = {"coordinates": coords, "solids": solids_meta, "drawing_phases": []} + val = self.validator.validate(engine_res, constraints, is_3d=False) + if val.is_valid: + logger.info(f"[StandardGeometryConstructor] Canonical 2D {c_type} construction SUCCESS.") + return engine_res + + return None + + def _resolve_auxiliary_points( + self, + coords: Dict[str, List[float]], + constraints: List[Constraint], + point_ids: List[str], + ): + """Resolves midpoint, section, center, and point_on auxiliary points iteratively.""" + for _ in range(3): + for c in constraints: + c_type = c.type + targets = c.targets + val = c.value + + if c_type == "midpoint" and len(targets) == 3: + pM, pA, pB = targets[0], targets[1], targets[2] + if pM not in coords and pA in coords and pB in coords: + vA = np.array(coords[pA]) + vB = np.array(coords[pB]) + coords[pM] = list((vA + vB) / 2.0) + + elif c_type == "section" and len(targets) == 3: + pE, pA, pC = targets[0], targets[1], targets[2] + if pE not in coords and pA in coords and pC in coords: + vA = np.array(coords[pA]) + vC = np.array(coords[pC]) + k = float(val) + coords[pE] = list(vA + k * (vC - vA)) + + elif c_type in ("center", "centroid") and len(targets) >= 3: + pO = targets[0] + poly_pts = targets[1:] + if pO not in coords and all(p in coords for p in poly_pts): + poly_vecs = [np.array(coords[p]) for p in poly_pts] + coords[pO] = list(np.mean(poly_vecs, axis=0)) + + elif c_type == "point_on" and len(targets) == 3: + pP, pA, pB = targets[0], targets[1], targets[2] + if pP not in coords and pA in coords and pB in coords: + # If length AP is given + len_ap = next( + ( + float(cc.value) + for cc in constraints + if cc.type == "length" + and set(cc.targets[:2]) == {pP, pA} + ), + None, + ) + vA = np.array(coords[pA]) + vB = np.array(coords[pB]) + total_len = float(np.linalg.norm(vB - vA)) + if len_ap is not None and total_len > 1e-4: + t = len_ap / total_len + coords[pP] = list(vA + t * (vB - vA)) diff --git a/solver/coordinate_solver.py b/solver/coordinate_solver.py new file mode 100644 index 0000000000000000000000000000000000000000..efbdf16f31daa79ff9b6083dfc837055fb08dd56 --- /dev/null +++ b/solver/coordinate_solver.py @@ -0,0 +1,271 @@ +"""Coordinate Solver: Numerical & symbolic coordinate solver for geometric equation systems.""" + +from __future__ import annotations + +import logging +import numpy as np +import scipy.optimize +import sympy as sp +from typing import Any, Dict, List, Optional, Tuple +from .constraint_compiler import CompiledSystem + +logger = logging.getLogger(__name__) + + +class CoordinateSolver: + """Solves compiled equation systems for exact or optimized 2D/3D numerical coordinates.""" + + def solve(self, system: CompiledSystem, is_3d: bool = False) -> Optional[Dict[str, List[float]]]: + all_vars = [] + for v in system.point_vars.values(): + all_vars.extend(v) + + n_eqs = len(system.equations) + n_vars = len(all_vars) + logger.info(f"[CoordinateSolver] Solving {n_eqs} equations for {n_vars} unknowns (is_3d={is_3d}).") + + # Strategy 1: SymPy symbolic + coords = self._try_symbolic(system.equations, all_vars, system.point_vars) + if coords: + return coords + + # Strategy 2: Numerical nsolve (for square systems) + if n_eqs == n_vars: + coords = self._try_nsolve(system.equations, all_vars, system.point_vars, n_vars, system.pt_list, is_3d) + if coords: + return coords + + # Strategy 3: Scipy least-squares optimization + coords = self._try_lsq(system.equations, all_vars, system.point_vars, n_vars, system.pt_list, is_3d) + if coords: + return coords + + # Strategy 4: Global differential evolution optimization + coords = self._try_global(system.equations, all_vars, system.point_vars, n_vars, is_3d) + if coords: + return coords + + logger.error("[CoordinateSolver] All solving strategies exhausted.") + return None + + def _try_symbolic( + self, + equations: List[sp.Expr], + all_vars: List[sp.Symbol], + point_vars: Dict[str, Tuple[sp.Symbol, sp.Symbol, sp.Symbol]], + ) -> Optional[Dict[str, List[float]]]: + if len(all_vars) > 10 or len(equations) != len(all_vars): + return None + + try: + solution = sp.solve(equations, all_vars, dict=True) + if solution: + best_res = solution[0] + for candidate in solution: + z_vals = [float(candidate.get(vz, 0.0)) for _, (_, _, vz) in point_vars.items()] + if all(z >= -1e-6 for z in z_vals): + best_res = candidate + break + elif any(z > 1e-6 for z in z_vals): + best_res = candidate + + logger.info("[CoordinateSolver] Strategy 1 (SymPy symbolic): SUCCESS.") + return { + pid: [ + float(best_res.get(vx, 0.0)), + float(best_res.get(vy, 0.0)), + abs(float(best_res.get(vz, 0.0))), + ] + for pid, (vx, vy, vz) in point_vars.items() + } + except Exception as e: + logger.debug("[CoordinateSolver] Strategy 1 threw exception: %s", e) + return None + + def _try_nsolve( + self, + equations: List[sp.Expr], + all_vars: List[sp.Symbol], + point_vars: Dict[str, Tuple[sp.Symbol, sp.Symbol, sp.Symbol]], + n_vars: int, + pt_list: list, + is_3d: bool, + ) -> Optional[Dict[str, List[float]]]: + MAX_NSOLVE_ATTEMPTS = 15 + for attempt in range(MAX_NSOLVE_ATTEMPTS): + try: + x0 = [] + n_pts = len(pt_list) + for i, p in enumerate(pt_list): + px = float(p.x) if p.x is not None else None + py = float(p.y) if p.y is not None else None + pz = float(p.z) if p.z is not None else None + if is_3d: + if px is not None and py is not None and pz is not None: + x0.extend([px, py, pz]) + elif i == 0: + x0.extend([px if px is not None else 0.0, py if py is not None else 0.0, pz if pz is not None else 0.0]) + elif i == n_pts - 1 and n_pts >= 4: + x0.extend([px if px is not None else 2.0, py if py is not None else 2.0, pz if pz is not None else 4.0]) + else: + angle = 2 * np.pi * i / max(n_pts - 1, 1) + x0.extend([ + px if px is not None else 3.0 * np.cos(angle), + py if py is not None else 3.0 * np.sin(angle), + pz if pz is not None else 0.0, + ]) + else: + angle = 2 * np.pi * i / max(n_pts, 1) + r = 4.0 + (attempt * 0.5) + x0.extend([ + px if px is not None else r * np.cos(angle), + py if py is not None else r * np.sin(angle), + 0.0, + ]) + + if attempt > 0: + perturbation = np.random.uniform(-1.0, 1.0, size=len(x0)) + x0 = [float(v + p) for v, p in zip(x0, perturbation)] + + sol = sp.nsolve(equations, all_vars, x0, verify=False, maxsteps=100) + if sol is not None: + res_map = {var: float(sol[i]) for i, var in enumerate(all_vars)} + logger.info(f"[CoordinateSolver] Strategy 2 (nsolve): SUCCESS on attempt {attempt+1}.") + return { + pid: [ + float(res_map.get(vx, 0.0)), + float(res_map.get(vy, 0.0)), + abs(float(res_map.get(vz, 0.0))) if is_3d else 0.0, + ] + for pid, (vx, vy, vz) in point_vars.items() + } + except Exception: + pass + return None + + def _try_lsq( + self, + equations: List[sp.Expr], + all_vars: List[sp.Symbol], + point_vars: Dict[str, Tuple[sp.Symbol, sp.Symbol, sp.Symbol]], + n_vars: int, + pt_list: list, + is_3d: bool, + ) -> Optional[Dict[str, List[float]]]: + try: + f_lambdified = sp.lambdify([all_vars], equations, modules=['numpy', 'scipy']) + + def residual(x): + try: + res = f_lambdified(x) + return np.array(res, dtype=float).flatten() + except Exception: + return np.full(len(equations), 1e6) + + n_pts = len(pt_list) + x0_base = [] + for i, p in enumerate(pt_list): + px = float(p.x) if p.x is not None else None + py = float(p.y) if p.y is not None else None + pz = float(p.z) if p.z is not None else None + if is_3d: + if px is not None and py is not None and pz is not None: + x0_base.extend([px, py, pz]) + elif i == 0: + x0_base.extend([px if px is not None else 0.0, py if py is not None else 0.0, pz if pz is not None else 0.0]) + elif i == n_pts - 1 and n_pts >= 4: + x0_base.extend([px if px is not None else 2.0, py if py is not None else 2.0, pz if pz is not None else 4.0]) + else: + angle = 2 * np.pi * i / max(n_pts - 1, 1) + x0_base.extend([ + px if px is not None else 3.0 * np.cos(angle), + py if py is not None else 3.0 * np.sin(angle), + pz if pz is not None else 0.0, + ]) + else: + angle = 2 * np.pi * i / max(n_pts, 1) + x0_base.extend([ + px if px is not None else 4.0 * np.cos(angle), + py if py is not None else 4.0 * np.sin(angle), + 0.0, + ]) + + + best_res = None + best_cost = float('inf') + + for attempt in range(8): + if attempt == 0: + x0 = np.array(x0_base, dtype=float) + else: + x0 = np.array(x0_base, dtype=float) + np.random.normal(0, 1.5, len(x0_base)) + + res = scipy.optimize.least_squares( + residual, + x0, + method='lm' if len(equations) >= n_vars else 'trf', + max_nfev=3000, + ftol=1e-8, + xtol=1e-8, + ) + + cost = np.sum(res.fun**2) + if cost < best_cost: + best_cost = cost + best_res = res + + if best_cost < 1e-4: + break + + if best_res is not None and best_cost < 1e-2: + logger.info(f"[CoordinateSolver] Strategy 3 (least_squares): SUCCESS (cost={best_cost:.2e}).") + sol = best_res.x + res_map = {var: float(sol[i]) for i, var in enumerate(all_vars)} + return { + pid: [ + float(res_map.get(vx, 0.0)), + float(res_map.get(vy, 0.0)), + abs(float(res_map.get(vz, 0.0))) if is_3d else 0.0, + ] + for pid, (vx, vy, vz) in point_vars.items() + } + except Exception as e: + logger.debug("[CoordinateSolver] Strategy 3 failed: %s", e) + return None + + def _try_global( + self, + equations: List[sp.Expr], + all_vars: List[sp.Symbol], + point_vars: Dict[str, Tuple[sp.Symbol, sp.Symbol, sp.Symbol]], + n_vars: int, + is_3d: bool, + ) -> Optional[Dict[str, List[float]]]: + try: + f_lambdified = sp.lambdify([all_vars], equations, modules=['numpy', 'scipy']) + + def loss(x): + try: + res = f_lambdified(x) + arr = np.array(res, dtype=float).flatten() + return float(np.sum(arr**2)) + except Exception: + return 1e9 + + bounds = [(-15.0, 15.0)] * n_vars + res = scipy.optimize.differential_evolution(loss, bounds, maxiter=500, popsize=15, tol=1e-5) + if res.fun < 1e-2: + logger.info(f"[CoordinateSolver] Strategy 4 (diff evolution): SUCCESS (loss={res.fun:.2e}).") + sol = res.x + res_map = {var: float(sol[i]) for i, var in enumerate(all_vars)} + return { + pid: [ + float(res_map.get(vx, 0.0)), + float(res_map.get(vy, 0.0)), + abs(float(res_map.get(vz, 0.0))) if is_3d else 0.0, + ] + for pid, (vx, vy, vz) in point_vars.items() + } + except Exception as e: + logger.debug("[CoordinateSolver] Strategy 4 failed: %s", e) + return None diff --git a/solver/dsl_parser.py b/solver/dsl_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..12ee88e0dd093e596e5e44a28ad7e390f51a5337 --- /dev/null +++ b/solver/dsl_parser.py @@ -0,0 +1,762 @@ +import re +import logging +from typing import List, Tuple, Dict, Any +from .models import Point, Constraint + +logger = logging.getLogger(__name__) + + +def _parse_point_tokens(s: str) -> List[str]: + """ + Parse a string of point names into a list of strings. + Handles: + - 'A, B, C' -> ['A', 'B', 'C'] + - 'ABCD' -> ['A', 'B', 'C', 'D'] + - 'A1B1C1D1' -> ['A1', 'B1', 'C1', 'D1'] + - "A'B'C'D'" -> ["A'", "B'", "C'", "D'"] + - 'A_1, B_1' -> ['A_1', 'B_1'] + """ + s = s.strip() + if not s: + return [] + if ',' in s: + return [p.strip() for p in s.split(',') if p.strip()] + tokens = re.findall(r"[A-Za-z][0-9_']*", s) + return tokens if tokens else [s] + + +def _add_poly_segments(pts: List[str], target_segments: List[List[str]], constraints: List[Constraint]): + """Add cyclic segments for a polygon (e.g. A-B, B-C, C-D, D-A).""" + if len(pts) < 2: + return + for i in range(len(pts)): + p1 = pts[i] + p2 = pts[(i + 1) % len(pts)] if len(pts) > 2 else pts[1] + if [p1, p2] not in target_segments and [p2, p1] not in target_segments: + target_segments.append([p1, p2]) + constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) + if len(pts) == 2: + break + + +class DSLParser: + def parse(self, text: str) -> Tuple[List[Point], List[Constraint], bool]: + """Parse DSL text into points and constraints. Stateless per call.""" + points: Dict[str, Point] = {} + explicit_point_ids: List[str] = [] + constraints: List[Constraint] = [] + polygon_order: List[str] = [] + circles: List[Dict[str, Any]] = [] + solids: List[Dict[str, Any]] = [] + segments: List[List[str]] = [] + lines_ext: List[List[str]] = [] + rays: List[List[str]] = [] + is_3d = False + + logger.info("==[DSLParser] Parsing DSL input (v5.2)==") + logger.debug(f"[DSLParser] Raw DSL:\n{text}") + + def ensure_point(pid: str, x: float = None, y: float = None, z: float = None) -> Point: + if pid not in points: + points[pid] = Point(id=pid, x=x, y=y, z=z) + else: + if x is not None: points[pid].x = x + if y is not None: points[pid].y = y + if z is not None: points[pid].z = z + return points[pid] + + lines = text.strip().split('\n') + for line in lines: + line = line.strip() + if not line or line.startswith('//') or line.startswith('#'): + continue + + # POINT(A) or POINT(A, 0, 0, 5) or POINT(A1, 10, 0, 0) + m = re.match(r'POINT\(([^,)]+)(?:,\s*([\d\.-]+),\s*([\d\.-]+)(?:,\s*([\d\.-]+))?)?\)', line) + if m: + name = m.group(1).strip() + x = float(m.group(2)) if m.group(2) else None + y = float(m.group(3)) if m.group(3) else None + z = float(m.group(4)) if m.group(4) else None + if z is not None and abs(z) > 1e-9: + is_3d = True + ensure_point(name, x, y, z) + if name not in explicit_point_ids: + explicit_point_ids.append(name) + logger.debug(f"[DSLParser] + POINT: {name} ({x}, {y}, {z})") + continue + + # LENGTH(AB, 5) or LENGTH(A, B, 5) + m = re.match(r'LENGTH\(([^,]+),\s*(?:([^,]+),\s*)?([\d\.]+)\)', line) + if m: + if m.group(2): + p1, p2, val = m.group(1).strip(), m.group(2).strip(), float(m.group(3)) + pts = [p1, p2] + else: + target, val = m.group(1).strip(), float(m.group(3)) + pts = _parse_point_tokens(target) + if len(pts) >= 2: + ensure_point(pts[0]) + ensure_point(pts[1]) + constraints.append(Constraint(type='length', targets=pts[:2], value=val)) + logger.debug(f"[DSLParser] + LENGTH: {pts[:2]} = {val}") + continue + + # LENGTH_EQUAL(AB, CD) or EQUAL_LENGTH(AB, CD) + m = re.match(r'(?:LENGTH_EQUAL|EQUAL_LENGTH)\(([^,]+),\s*([^)]+)\)', line) + if m: + seg1 = _parse_point_tokens(m.group(1)) + seg2 = _parse_point_tokens(m.group(2)) + if len(seg1) >= 2 and len(seg2) >= 2: + for p in seg1[:2] + seg2[:2]: ensure_point(p) + constraints.append(Constraint(type='length_equal', targets=seg1[:2] + seg2[:2], value=0)) + logger.debug(f"[DSLParser] + LENGTH_EQUAL: {seg1[:2]} == {seg2[:2]}") + continue + + # POINT_ON(P, AB) or POINT_ON(P, A, B) or ON_SEGMENT(P, AB) or POINT_ON_LINE(P, AB) + m = re.match(r'(?:POINT_ON|ON_SEGMENT|POINT_ON_LINE|POINT_ON_SEGMENT)\(([^,]+),\s*(?:([^,]+),\s*([^)]+)|([^)]+))\)', line) + if m: + target = m.group(1).strip() + if m.group(2) and m.group(3): + p1, p2 = m.group(2).strip(), m.group(3).strip() + else: + seg_pts = _parse_point_tokens(m.group(4)) + p1, p2 = seg_pts[0], seg_pts[1] if len(seg_pts) > 1 else 'B' + ensure_point(target) + ensure_point(p1) + ensure_point(p2) + constraints.append(Constraint(type='point_on', targets=[target, p1, p2], value=0)) + logger.debug(f"[DSLParser] + POINT_ON: {target} on ({p1}, {p2})") + continue + + # HEIGHT(S, O, ABCD) or ALTITUDE(S, O, ABCD) or HEIGHT(S, O, ABCD, 10) + m = re.match(r'(?:HEIGHT|ALTITUDE)\(([^,]+),\s*([^,]+),\s*([^,)]+)(?:,\s*([\d\.]+))?\)', line) + if m: + is_3d = True + s_apex, o_foot = m.group(1).strip(), m.group(2).strip() + base_pts = _parse_point_tokens(m.group(3)) + val = float(m.group(4)) if m.group(4) else 0.0 + ensure_point(s_apex) + ensure_point(o_foot) + for p in base_pts: ensure_point(p) + constraints.append(Constraint(type='height', targets=[s_apex, o_foot] + base_pts, value=val)) + logger.debug(f"[DSLParser] + HEIGHT: {s_apex}{o_foot} _|_ plane({base_pts}) (val={val})") + continue + + # FOOT(H, P, AB) or FOOT_OF_PERPENDICULAR(H, P, AB) + m = re.match(r'(?:FOOT|FOOT_OF_PERPENDICULAR)\(([^,]+),\s*([^,]+),\s*(?:([^,]+),\s*([^)]+)|([^)]+))\)', line) + if m: + pH, pP = m.group(1).strip(), m.group(2).strip() + if m.group(3) and m.group(4): + pA, pB = m.group(3).strip(), m.group(4).strip() + else: + seg_pts = _parse_point_tokens(m.group(5)) + pA, pB = seg_pts[0], seg_pts[1] if len(seg_pts) > 1 else 'B' + ensure_point(pH) + ensure_point(pP) + ensure_point(pA) + ensure_point(pB) + constraints.append(Constraint(type='foot', targets=[pH, pP, pA, pB], value=0)) + logger.debug(f"[DSLParser] + FOOT: {pH} foot of {pP} on {pA}{pB}") + continue + + # FOOT_PLANE(H, P, ABC) + m = re.match(r'FOOT_PLANE\(([^,]+),\s*([^,]+),\s*([^)]+)\)', line) + if m: + is_3d = True + pH, pP = m.group(1).strip(), m.group(2).strip() + plane_pts = _parse_point_tokens(m.group(3)) + ensure_point(pH) + ensure_point(pP) + for p in plane_pts: ensure_point(p) + constraints.append(Constraint(type='foot_plane', targets=[pH, pP] + plane_pts, value=0)) + logger.debug(f"[DSLParser] + FOOT_PLANE: {pH} foot of {pP} on plane({plane_pts})") + continue + + # MEDIAN(A, M, BC) + m = re.match(r'MEDIAN\(([^,]+),\s*([^,]+),\s*(?:([^,]+),\s*([^)]+)|([^)]+))\)', line) + if m: + pA, pM = m.group(1).strip(), m.group(2).strip() + if m.group(3) and m.group(4): + pB, pC = m.group(3).strip(), m.group(4).strip() + else: + seg_pts = _parse_point_tokens(m.group(5)) + pB, pC = seg_pts[0], seg_pts[1] if len(seg_pts) > 1 else 'C' + ensure_point(pA) + ensure_point(pM) + ensure_point(pB) + ensure_point(pC) + constraints.append(Constraint(type='median', targets=[pA, pM, pB, pC], value=0)) + logger.debug(f"[DSLParser] + MEDIAN: {pA}{pM} to {pB}{pC}") + continue + + # CENTER(O, ABCD) or CENTER(O, ABC) or CENTROID(G, ABC) + m = re.match(r'(?:CENTER|CENTROID)\(([^,]+),\s*([^)]+)\)', line) + if m: + c_pt = m.group(1).strip() + poly_pts = _parse_point_tokens(m.group(2)) + ensure_point(c_pt) + for p in poly_pts: ensure_point(p) + constraints.append(Constraint(type='center', targets=[c_pt] + poly_pts, value=0)) + logger.debug(f"[DSLParser] + CENTER: {c_pt} center of {poly_pts}") + continue + + # RIGHT_TRIANGLE(ABC, B) or RIGHT_TRIANGLE(ABC) + m = re.match(r'RIGHT_TRIANGLE\(([^,)]+)(?:,\s*([^)]+))?\)', line) + if m: + pts = _parse_point_tokens(m.group(1)) + if len(pts) == 3: + for p in pts: ensure_point(p) + _add_poly_segments(pts, segments, constraints) + if not polygon_order: polygon_order = list(pts) + right_v = m.group(2).strip() if m.group(2) else pts[1] + other_v = [p for p in pts if p != right_v] + if len(other_v) == 2: + constraints.append(Constraint(type='perpendicular', targets=[right_v, other_v[0], right_v, other_v[1]], value=0)) + logger.debug(f"[DSLParser] + RIGHT_TRIANGLE: {pts} right at {right_v}") + continue + + # EQUILATERAL_TRIANGLE(ABC) or EQUILATERAL_TRIANGLE(ABC, a) + m = re.match(r'EQUILATERAL_TRIANGLE\(([^,)]+)(?:,\s*([\d\.]+))?\)', line) + if m: + pts = _parse_point_tokens(m.group(1)) + side_val = float(m.group(2)) if m.group(2) else None + if len(pts) == 3: + for p in pts: ensure_point(p) + _add_poly_segments(pts, segments, constraints) + if not polygon_order: polygon_order = list(pts) + constraints.append(Constraint(type='length_equal', targets=[pts[0], pts[1], pts[1], pts[2]], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pts[1], pts[2], pts[2], pts[0]], value=0)) + if side_val is not None: + constraints.append(Constraint(type='length', targets=[pts[0], pts[1]], value=side_val)) + logger.debug(f"[DSLParser] + EQUILATERAL_TRIANGLE: {pts}") + continue + + # ISOSCELES_TRIANGLE(ABC, A) or ISOSCELES_TRIANGLE(ABC) + m = re.match(r'ISOSCELES_TRIANGLE\(([^,)]+)(?:,\s*([^)]+))?\)', line) + if m: + pts = _parse_point_tokens(m.group(1)) + if len(pts) == 3: + for p in pts: ensure_point(p) + _add_poly_segments(pts, segments, constraints) + if not polygon_order: polygon_order = list(pts) + apex_v = m.group(2).strip() if m.group(2) else pts[0] + base_v = [p for p in pts if p != apex_v] + if len(base_v) == 2: + constraints.append(Constraint(type='length_equal', targets=[apex_v, base_v[0], apex_v, base_v[1]], value=0)) + logger.debug(f"[DSLParser] + ISOSCELES_TRIANGLE: {pts} apex at {apex_v}") + continue + + # ANGLE(A, 90) or ANGLE(A, B, C, 90) or ANGLE(ABC, 90deg) + m = re.match(r'ANGLE\((.+)\)', line) + if m: + raw_args = [a.strip() for a in m.group(1).split(',')] + if len(raw_args) >= 2: + val_str = raw_args[-1].replace('deg', '').strip() + try: + val = float(val_str) + target_args = raw_args[:-1] + if len(target_args) == 1: + pts = _parse_point_tokens(target_args[0]) + else: + pts = target_args + for p in pts: ensure_point(p) + constraints.append(Constraint(type='angle', targets=pts, value=val)) + logger.debug(f"[DSLParser] + ANGLE: vertex/targets={pts}, degrees={val}") + except ValueError: + pass + continue + + # PERPENDICULAR_PLANE(SO, ABCD) or LINE_PERP_PLANE(SO, ABC) + m = re.match(r'(?:PERPENDICULAR_PLANE|LINE_PERP_PLANE)\(([^,]+),\s*([^)]+)\)', line) + if m: + is_3d = True + line_pts = _parse_point_tokens(m.group(1)) + plane_pts = _parse_point_tokens(m.group(2)) + for p in line_pts + plane_pts: ensure_point(p) + constraints.append(Constraint(type='perp_plane', targets=line_pts[:2] + plane_pts, value=0)) + logger.debug(f"[DSLParser] + PERPENDICULAR_PLANE: line={line_pts[:2]} _|_ plane={plane_pts}") + continue + + # COPLANAR(A, B, C, D) or COPLANAR(ABCD) + m = re.match(r'COPLANAR\(([^)]+)\)', line) + if m: + is_3d = True + pts = _parse_point_tokens(m.group(1)) + for p in pts: ensure_point(p) + if len(pts) >= 4: + constraints.append(Constraint(type='coplanar', targets=pts, value=0)) + logger.debug(f"[DSLParser] + COPLANAR: {pts}") + continue + + # POINT_ON_PLANE(P, ABC) or POINT_ON_PLANE(P, A, B, C) + m = re.match(r'POINT_ON_PLANE\(([^,]+),\s*([^)]+)\)', line) + if m: + is_3d = True + p_target = m.group(1).strip() + plane_pts = _parse_point_tokens(m.group(2)) + ensure_point(p_target) + for p in plane_pts: ensure_point(p) + constraints.append(Constraint(type='point_on_plane', targets=[p_target] + plane_pts, value=0)) + logger.debug(f"[DSLParser] + POINT_ON_PLANE: {p_target} on plane {plane_pts}") + continue + + # PARALLEL(AB, CD) + m = re.match(r'PARALLEL\(([^,]+),\s*([^)]+)\)', line) + if m: + seg1_pts = _parse_point_tokens(m.group(1)) + seg2_pts = _parse_point_tokens(m.group(2)) + if len(seg1_pts) >= 2 and len(seg2_pts) >= 2: + for p in seg1_pts[:2] + seg2_pts[:2]: ensure_point(p) + constraints.append(Constraint(type='parallel', targets=seg1_pts[:2] + seg2_pts[:2], value=0)) + logger.debug(f"[DSLParser] + PARALLEL: {seg1_pts[:2]} || {seg2_pts[:2]}") + continue + + # PERPENDICULAR(AB, CD) + m = re.match(r'PERPENDICULAR\(([^,]+),\s*([^)]+)\)', line) + if m: + seg1_pts = _parse_point_tokens(m.group(1)) + seg2_pts = _parse_point_tokens(m.group(2)) + if len(seg1_pts) >= 2 and len(seg2_pts) >= 2: + for p in seg1_pts[:2] + seg2_pts[:2]: ensure_point(p) + constraints.append(Constraint(type='perpendicular', targets=seg1_pts[:2] + seg2_pts[:2], value=0)) + logger.debug(f"[DSLParser] + PERPENDICULAR: {seg1_pts[:2]} _|_ {seg2_pts[:2]}") + continue + + # MIDPOINT(M, AB) or MIDPOINT(M, A, B) + m = re.match(r'MIDPOINT\(([^,]+),\s*(?:([^,]+),\s*([^)]+)|([^)]+))\)', line) + if m: + mid = m.group(1).strip() + if m.group(2) and m.group(3): + p1, p2 = m.group(2).strip(), m.group(3).strip() + else: + seg_pts = _parse_point_tokens(m.group(4)) + p1, p2 = seg_pts[0], seg_pts[1] if len(seg_pts) > 1 else 'B' + ensure_point(mid) + ensure_point(p1) + ensure_point(p2) + constraints.append(Constraint(type='midpoint', targets=[mid, p1, p2], value=0)) + logger.debug(f"[DSLParser] + MIDPOINT: {mid} = mid({p1}, {p2})") + continue + + # SECTION(E, A, C, 0.66) + m = re.match(r'SECTION\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([\d\.-]+)\)', line) + if m: + target, p1, p2, k = m.group(1).strip(), m.group(2).strip(), m.group(3).strip(), float(m.group(4)) + ensure_point(target) + ensure_point(p1) + ensure_point(p2) + constraints.append(Constraint(type='section', targets=[target, p1, p2], value=k)) + logger.debug(f"[DSLParser] + SECTION: {target} = {p1} + {k}({p2}-{p1})") + continue + + # CIRCLE(O, r) + m = re.match(r'CIRCLE\(([^,]+),\s*([\d\.]+)\)', line) + if m: + center, radius = m.group(1).strip(), float(m.group(2)) + ensure_point(center) + constraints.append(Constraint(type='circle', targets=[center], value=radius)) + circles.append({"center": center, "radius": radius}) + logger.debug(f"[DSLParser] + CIRCLE: center={center}, r={radius}") + continue + + # SPHERE(O, r) + m = re.match(r'SPHERE\(([^,]+),\s*([\d\.]+)\)', line) + if m: + is_3d = True + center, radius = m.group(1).strip(), float(m.group(2)) + ensure_point(center) + constraints.append(Constraint(type='sphere', targets=[center], value=radius)) + solids.append({"type": "sphere", "center": center, "radius": radius}) + logger.debug(f"[DSLParser] + SPHERE: center={center}, r={radius}") + continue + + # CONE(S, O, r) or CONE(S, O, r, h) or CONE(S_O, r) or CONE(S_O, r, h) + m4 = re.match(r'CONE\(([A-Za-z0-9\']+),\s*([A-Za-z0-9\']+),\s*([\d\.]+)(?:,\s*([\d\.]+))?\)', line) + m2 = re.match(r'CONE\(([A-Za-z0-9\']+(?:_[A-Za-z0-9\']+)?),\s*([\d\.]+)(?:,\s*([\d\.]+))?\)', line) + if m4 and not ('_' in m4.group(1) and m4.group(2).replace('.', '', 1).isdigit()): + is_3d = True + apex, center = m4.group(1).strip(), m4.group(2).strip() + radius = float(m4.group(3)) + height = float(m4.group(4)) if m4.group(4) else None + ensure_point(apex) + ensure_point(center) + segments.append([apex, center]) + constraints.append(Constraint(type='segment', targets=[apex, center], value=0)) + constraints.append(Constraint(type='cone', targets=[apex, center], value=radius)) + if height is not None: + constraints.append(Constraint(type='length', targets=[apex, center], value=height)) + solids.append({"type": "cone", "apex": apex, "center": center, "radius": radius, "height": height}) + logger.debug(f"[DSLParser] + CONE: apex={apex}, center={center}, r={radius}, h={height}") + continue + elif m2: + is_3d = True + raw_pts = m2.group(1).strip() + if '_' in raw_pts: + apex, center = [p.strip() for p in raw_pts.split('_', 1)] + else: + apex, center = raw_pts, 'O' + radius = float(m2.group(2)) + height = float(m2.group(3)) if m2.group(3) else None + ensure_point(apex) + ensure_point(center) + segments.append([apex, center]) + constraints.append(Constraint(type='segment', targets=[apex, center], value=0)) + constraints.append(Constraint(type='cone', targets=[apex, center], value=radius)) + if height is not None: + constraints.append(Constraint(type='length', targets=[apex, center], value=height)) + solids.append({"type": "cone", "apex": apex, "center": center, "radius": radius, "height": height}) + logger.debug(f"[DSLParser] + CONE: apex={apex}, center={center}, r={radius}, h={height}") + continue + + # CYLINDER(O1, O2, r) or CYLINDER(O1_O2, r) + m = re.match(r'CYLINDER\(([^,]+)(?:,\s*([^,]+))?,\s*([\d\.]+)\)', line) + if m: + is_3d = True + if m.group(2): + c1, c2 = m.group(1).strip(), m.group(2).strip() + elif '_' in m.group(1): + c1, c2 = [p.strip() for p in m.group(1).split('_', 1)] + else: + c1, c2 = 'O1', 'O2' + radius = float(m.group(3)) + ensure_point(c1) + ensure_point(c2) + segments.append([c1, c2]) + constraints.append(Constraint(type='segment', targets=[c1, c2], value=0)) + constraints.append(Constraint(type='cylinder', targets=[c1, c2], value=radius)) + solids.append({"type": "cylinder", "center1": c1, "center2": c2, "radius": radius}) + logger.debug(f"[DSLParser] + CYLINDER: c1={c1}, c2={c2}, r={radius}") + continue + + # TETRAHEDRON(ABCD) or TETRAHEDRON(ABCD, a) + m = re.match(r'TETRAHEDRON\(([^,)]+)(?:,\s*([\d\.]+))?\)', line) + if m: + is_3d = True + pts = _parse_point_tokens(m.group(1)) + side_val = float(m.group(2)) if m.group(2) else None + for p in pts: ensure_point(p) + if len(pts) >= 4: + pA, pB, pC, pD = pts[0], pts[1], pts[2], pts[3] + # Generate all 6 edges + for p1, p2 in [(pA, pB), (pA, pC), (pA, pD), (pB, pC), (pC, pD), (pD, pB)]: + if [p1, p2] not in segments and [p2, p1] not in segments: + segments.append([p1, p2]) + constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) + if side_val is not None: + constraints.append(Constraint(type='length', targets=[p1, p2], value=side_val)) + if not polygon_order: polygon_order = [pB, pC, pD] + solids.append({"type": "tetrahedron", "apex": pA, "base": [pB, pC, pD], "points": pts[:4]}) + logger.debug(f"[DSLParser] + TETRAHEDRON: {pts[:4]}") + continue + + # CUBE(ABCD_A1B1C1D1) or CUBE(ABCD_EFGH) or CUBE(..., a) + m = re.match(r'CUBE\(([^,)]+)(?:,\s*([\d\.]+))?\)', line) + if m: + is_3d = True + targets = m.group(1).strip() + side_val = float(m.group(2)) if m.group(2) else None + if '_' in targets: + b1_raw, b2_raw = targets.split('_', 1) + b1 = _parse_point_tokens(b1_raw) + b2 = _parse_point_tokens(b2_raw) + else: + all_pts = _parse_point_tokens(targets) + b1, b2 = all_pts[:4], all_pts[4:8] + for p in b1 + b2: ensure_point(p) + _add_poly_segments(b1, segments, constraints) + _add_poly_segments(b2, segments, constraints) + for p1, p2 in zip(b1, b2): + if [p1, p2] not in segments and [p2, p1] not in segments: + segments.append([p1, p2]) + constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) + if len(b1) >= 2 and len(b2) >= 2: + for i in range(1, len(b1)): + constraints.append(Constraint(type='parallel', targets=[b1[0], b2[0], b1[i], b2[i]], value=0)) + constraints.append(Constraint(type='length_equal', targets=[b1[0], b2[0], b1[i], b2[i]], value=0)) + constraints.append(Constraint(type='perpendicular', targets=[b1[0], b2[0], b1[0], b1[1]], value=0)) + if len(b1) >= 3: + constraints.append(Constraint(type='perpendicular', targets=[b1[0], b2[0], b1[0], b1[2]], value=0)) + if side_val is not None: + for p1, p2 in zip(b1, b1[1:] + b1[:1]): + constraints.append(Constraint(type='length', targets=[p1, p2], value=side_val)) + if b1 and b2: + constraints.append(Constraint(type='length', targets=[b1[0], b2[0]], value=side_val)) + if not polygon_order: polygon_order = list(b1) + solids.append({"type": "cube", "base1": b1, "base2": b2, "points": b1 + b2}) + logger.debug(f"[DSLParser] + CUBE: base1={b1}, base2={b2}") + continue + + # CUBOID(ABCD_A1B1C1D1) or PARALLELEPIPED(...) + m = re.match(r'(?:CUBOID|PARALLELEPIPED)\(([^)]+)\)', line) + if m: + is_3d = True + targets = m.group(1).strip() + if '_' in targets: + b1_raw, b2_raw = targets.split('_', 1) + b1 = _parse_point_tokens(b1_raw) + b2 = _parse_point_tokens(b2_raw) + else: + all_pts = _parse_point_tokens(targets) + b1, b2 = all_pts[:4], all_pts[4:8] + for p in b1 + b2: ensure_point(p) + _add_poly_segments(b1, segments, constraints) + _add_poly_segments(b2, segments, constraints) + for p1, p2 in zip(b1, b2): + if [p1, p2] not in segments and [p2, p1] not in segments: + segments.append([p1, p2]) + constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) + if len(b1) >= 2 and len(b2) >= 2: + for i in range(1, len(b1)): + constraints.append(Constraint(type='parallel', targets=[b1[0], b2[0], b1[i], b2[i]], value=0)) + constraints.append(Constraint(type='length_equal', targets=[b1[0], b2[0], b1[i], b2[i]], value=0)) + constraints.append(Constraint(type='perpendicular', targets=[b1[0], b2[0], b1[0], b1[1]], value=0)) + if len(b1) >= 3: + constraints.append(Constraint(type='perpendicular', targets=[b1[0], b2[0], b1[0], b1[2]], value=0)) + if not polygon_order: polygon_order = list(b1) + solids.append({"type": "cuboid", "base1": b1, "base2": b2, "points": b1 + b2}) + logger.debug(f"[DSLParser] + CUBOID: base1={b1}, base2={b2}") + continue + + # FRUSTUM(ABCD_EFGH) or TRUNCATED_PYRAMID(ABCD_EFGH) + m = re.match(r'(?:FRUSTUM|TRUNCATED_PYRAMID)\(([^)]+)\)', line) + if m: + is_3d = True + targets = m.group(1).strip() + if '_' in targets: + b1_raw, b2_raw = targets.split('_', 1) + b1 = _parse_point_tokens(b1_raw) + b2 = _parse_point_tokens(b2_raw) + else: + all_pts = _parse_point_tokens(targets) + n = len(all_pts) // 2 + b1, b2 = all_pts[:n], all_pts[n:] + for p in b1 + b2: ensure_point(p) + _add_poly_segments(b1, segments, constraints) + _add_poly_segments(b2, segments, constraints) + for p1, p2 in zip(b1, b2): + if [p1, p2] not in segments and [p2, p1] not in segments: + segments.append([p1, p2]) + constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) + if not polygon_order: polygon_order = list(b1) + solids.append({"type": "frustum", "base1": b1, "base2": b2, "points": b1 + b2}) + logger.debug(f"[DSLParser] + FRUSTUM: base1={b1}, base2={b2}") + continue + + # POLYGON_ORDER(A, B, C, D) + m = re.match(r'POLYGON_ORDER\(([^)]+)\)', line) + if m: + polygon_order = _parse_point_tokens(m.group(1)) + logger.debug(f"[DSLParser] + POLYGON_ORDER: {polygon_order}") + continue + + # SEGMENT(M, N) or SEGMENT(MN) + m = re.match(r'SEGMENT\(([^,]+)(?:,\s*([^)]+))?\)', line) + if m: + if m.group(2): + p1, p2 = m.group(1).strip(), m.group(2).strip() + else: + pts = _parse_point_tokens(m.group(1)) + p1, p2 = pts[0], pts[1] if len(pts) > 1 else 'B' + ensure_point(p1) + ensure_point(p2) + segments.append([p1, p2]) + constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) + logger.debug(f"[DSLParser] + SEGMENT: {p1}—{p2}") + continue + + # LINE(A, B) + m = re.match(r'LINE\(([^,]+)(?:,\s*([^)]+))?\)', line) + if m: + if m.group(2): + p1, p2 = m.group(1).strip(), m.group(2).strip() + else: + pts = _parse_point_tokens(m.group(1)) + p1, p2 = pts[0], pts[1] if len(pts) > 1 else 'B' + ensure_point(p1) + ensure_point(p2) + lines_ext.append([p1, p2]) + constraints.append(Constraint(type='line', targets=[p1, p2], value=0)) + logger.debug(f"[DSLParser] + LINE: {p1}-{p2}") + continue + + # RAY(A, B) + m = re.match(r'RAY\(([^,]+)(?:,\s*([^)]+))?\)', line) + if m: + if m.group(2): + p1, p2 = m.group(1).strip(), m.group(2).strip() + else: + pts = _parse_point_tokens(m.group(1)) + p1, p2 = pts[0], pts[1] if len(pts) > 1 else 'B' + ensure_point(p1) + ensure_point(p2) + rays.append([p1, p2]) + constraints.append(Constraint(type='ray', targets=[p1, p2], value=0)) + logger.debug(f"[DSLParser] + RAY: {p1}->{p2}") + continue + + # SQUARE(ABCD) + m = re.match(r'SQUARE\(([^)]+)\)', line) + if m: + pts = _parse_point_tokens(m.group(1)) + if len(pts) == 4: + pA, pB, pC, pD = pts + for p in pts: ensure_point(p) + _add_poly_segments(pts, segments, constraints) + if not polygon_order: polygon_order = list(pts) + constraints.append(Constraint(type='perpendicular', targets=[pA, pB, pA, pD], value=0)) + constraints.append(Constraint(type='perpendicular', targets=[pB, pA, pB, pC], value=0)) + constraints.append(Constraint(type='parallel', targets=[pA, pB, pD, pC], value=0)) + constraints.append(Constraint(type='parallel', targets=[pA, pD, pB, pC], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pA, pB, pB, pC], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pB, pC, pC, pD], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pC, pD, pD, pA], value=0)) + if is_3d: + constraints.append(Constraint(type='coplanar', targets=[pA, pB, pC, pD], value=0)) + logger.debug(f"[DSLParser] + SQUARE: {pts}") + continue + + # RECTANGLE(ABCD) + m = re.match(r'RECTANGLE\(([^)]+)\)', line) + if m: + pts = _parse_point_tokens(m.group(1)) + if len(pts) == 4: + pA, pB, pC, pD = pts + for p in pts: ensure_point(p) + _add_poly_segments(pts, segments, constraints) + if not polygon_order: polygon_order = list(pts) + constraints.append(Constraint(type='perpendicular', targets=[pA, pB, pA, pD], value=0)) + constraints.append(Constraint(type='perpendicular', targets=[pB, pA, pB, pC], value=0)) + constraints.append(Constraint(type='parallel', targets=[pA, pB, pD, pC], value=0)) + constraints.append(Constraint(type='parallel', targets=[pA, pD, pB, pC], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pA, pB, pD, pC], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pA, pD, pB, pC], value=0)) + if is_3d: + constraints.append(Constraint(type='coplanar', targets=[pA, pB, pC, pD], value=0)) + logger.debug(f"[DSLParser] + RECTANGLE: {pts}") + continue + + # PARALLELOGRAM(ABCD) + m = re.match(r'PARALLELOGRAM\(([^)]+)\)', line) + if m: + pts = _parse_point_tokens(m.group(1)) + if len(pts) == 4: + pA, pB, pC, pD = pts + for p in pts: ensure_point(p) + _add_poly_segments(pts, segments, constraints) + if not polygon_order: polygon_order = list(pts) + constraints.append(Constraint(type='parallel', targets=[pA, pB, pD, pC], value=0)) + constraints.append(Constraint(type='parallel', targets=[pA, pD, pB, pC], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pA, pB, pD, pC], value=0)) + constraints.append(Constraint(type='length_equal', targets=[pA, pD, pB, pC], value=0)) + if is_3d: + constraints.append(Constraint(type='coplanar', targets=[pA, pB, pC, pD], value=0)) + logger.debug(f"[DSLParser] + PARALLELOGRAM: {pts}") + continue + + # TRIANGLE(ABC) / PYRAMID(S_ABCD) / PRISM(ABC_DEF) + m = re.match(r'(TRIANGLE|PYRAMID|PRISM)\(([^)]+)\)', line) + if m: + pt_type = m.group(1) + targets = m.group(2) + if pt_type in ["PYRAMID", "PRISM"]: + is_3d = True + if pt_type == "TRIANGLE": + pts = _parse_point_tokens(targets) + for p in pts: ensure_point(p) + _add_poly_segments(pts, segments, constraints) + if not polygon_order: polygon_order = list(pts) + elif pt_type == "PYRAMID": + # S_ABCD or S, ABCD -> S is apex, ABCD is base + if "_" in targets: + apex_raw, base_raw = targets.split("_", 1) + elif "," in targets: + apex_raw, base_raw = targets.split(",", 1) + else: + tokens = _parse_point_tokens(targets) + apex_raw = tokens[0] if tokens else "S" + base_raw = "".join(tokens[1:]) if len(tokens) > 1 else "" + apex = apex_raw.strip() + base = _parse_point_tokens(base_raw) + ensure_point(apex) + for p in base: ensure_point(p) + # Add segments from apex to all base points + for p in base: + if [apex, p] not in segments and [p, apex] not in segments: + segments.append([apex, p]) + constraints.append(Constraint(type='segment', targets=[apex, p], value=0)) + # Also add base polygon segments + _add_poly_segments(base, segments, constraints) + if not polygon_order: polygon_order = list(base) + solids.append({"type": "pyramid", "apex": apex, "base": base, "points": [apex] + base}) + elif pt_type == "PRISM": + # ABC_DEF or ABC, DEF -> two bases + if "_" in targets: + b1_raw, b2_raw = targets.split("_", 1) + elif "," in targets: + b1_raw, b2_raw = targets.split(",", 1) + else: + tokens = _parse_point_tokens(targets) + half = len(tokens) // 2 + b1_raw = "".join(tokens[:half]) + b2_raw = "".join(tokens[half:]) + b1 = _parse_point_tokens(b1_raw) + b2 = _parse_point_tokens(b2_raw) + for p in b1 + b2: ensure_point(p) + # Add base 1 segments + _add_poly_segments(b1, segments, constraints) + # Add base 2 segments + _add_poly_segments(b2, segments, constraints) + # Add lateral edges + for p1, p2 in zip(b1, b2): + if [p1, p2] not in segments and [p2, p1] not in segments: + segments.append([p1, p2]) + constraints.append(Constraint(type='segment', targets=[p1, p2], value=0)) + if len(b1) >= 2 and len(b2) >= 2: + for i in range(1, len(b1)): + constraints.append(Constraint(type='parallel', targets=[b1[0], b2[0], b1[i], b2[i]], value=0)) + constraints.append(Constraint(type='length_equal', targets=[b1[0], b2[0], b1[i], b2[i]], value=0)) + constraints.append(Constraint(type='perpendicular', targets=[b1[0], b2[0], b1[0], b1[1]], value=0)) + if len(b1) >= 3: + constraints.append(Constraint(type='perpendicular', targets=[b1[0], b2[0], b1[0], b1[2]], value=0)) + if not polygon_order: polygon_order = list(b1) + solids.append({"type": "prism", "base1": b1, "base2": b2, "points": b1 + b2}) + logger.debug(f"[DSLParser] + {pt_type}: {targets}") + continue + + logger.warning(f"[DSLParser] ? Unrecognized DSL line: '{line}'") + + logger.info( + "[DSLParser] Parsed %d points, %d constraints, is_3d=%s.", + len(points), + len(constraints), + is_3d, + ) + + # Safety sweep: Ensure all points referenced in constraints actually exist in the points dictionary + for c in constraints: + for pid in c.targets: + if isinstance(pid, str) and pid not in points and not pid.replace('.', '', 1).isdigit(): + points[pid] = Point(id=pid) + logger.debug(f"[DSLParser] ! Auto-declared missing point from constraint: {pid}") + + # Attach metadata to synthetic constraints for downstream use + if polygon_order: + constraints.append(Constraint(type='polygon_order', targets=polygon_order, value=0)) + elif explicit_point_ids: + constraints.append(Constraint(type='explicit_points', targets=explicit_point_ids, value=0)) + + if lines_ext: + constraints.append(Constraint(type='lines_metadata', targets=[",".join(l) for l in lines_ext], value=0)) + if rays: + constraints.append(Constraint(type='rays_metadata', targets=[",".join(l) for l in rays], value=0)) + if solids: + import json + constraints.append(Constraint(type='solids_metadata', targets=[json.dumps(s) for s in solids], value=0)) + + # Pass through ConstraintCompiler for semantic audit & derived constraint expansion (P0) + from .compiler import ConstraintCompiler + compiler = ConstraintCompiler() + compiled_points, compiled_constraints, is_3d = compiler.compile(points, constraints, is_3d) + + return compiled_points, compiled_constraints, is_3d diff --git a/solver/engine.py b/solver/engine.py new file mode 100644 index 0000000000000000000000000000000000000000..6fe4d108fb93ac5b9c933b54079080db4f186911 --- /dev/null +++ b/solver/engine.py @@ -0,0 +1,149 @@ +"""Geometry Engine: Modular Geometry IR Pipeline Coordinator.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional +from .models import Point, Constraint +from .constraint_compiler import ConstraintCompiler, CompiledSystem +from .constructors import StandardGeometryConstructor +from .coordinate_solver import CoordinateSolver +from .geometry_normalizer import GeometryNormalizer +from .topology_builder import TopologyBuilder +from .result_assembler import ResultAssembler + +logger = logging.getLogger(__name__) + + +class GeometryEngine: + """ + Modular Geometry Solver Engine (P4 Architecture): + - ConstraintCompiler: Compiles DSL & constraints into algebraic systems + - StandardGeometryConstructor: Canonical analytical construction for standard primitives + - CoordinateSolver: Numerical & symbolic solver for equations + - GeometryNormalizer: Centering, orientation, bounding box and coordinate scaling + - TopologyBuilder: Drawing phases, faces, solids, and complete visualization graph + - ResultAssembler: Canonical Geometry IR assembly + """ + + def __init__(self): + self.compiler = ConstraintCompiler() + self.constructor = StandardGeometryConstructor() + self.coord_solver = CoordinateSolver() + self.normalizer = GeometryNormalizer() + self.topology_builder = TopologyBuilder() + self.assembler = ResultAssembler() + + def solve( + self, + points: List[Point] | Dict[str, Point], + constraints: List[Constraint], + is_3d: bool = False, + ) -> Optional[Dict[str, Any]]: + if not points: + logger.error("[GeometryEngine] No points to solve.") + return None + + pt_list = list(points.values()) if isinstance(points, dict) else list(points) + logger.info(f"==[GeometryEngine] Starting solve with {len(pt_list)} points, {len(constraints)} constraints (is_3d={is_3d})==") + + # 1. Compile constraints and symbols + system: CompiledSystem = self.compiler.compile(pt_list, constraints, is_3d=is_3d) + + # 2. Step 0: Try Canonical Hierarchical Constructor + canonical_res = self.constructor.try_construct( + pt_list, + system.real_constraints, + system.solids_meta, + is_3d, + ) + + if canonical_res and "coordinates" in canonical_res: + logger.info("[GeometryEngine] Successfully constructed canonical standard representation.") + return self._build_result( + canonical_res["coordinates"], + system.polygon_order, + system.circles_meta, + system.solids_meta, + system.segments_meta, + system.lines_ext, + system.rays_ext, + pt_list, + system.real_constraints, + ) + + # 3. Solve numerical / symbolic coordinate equations + raw_coords = self.coord_solver.solve(system, is_3d=is_3d) + if not raw_coords: + logger.error("[GeometryEngine] CoordinateSolver failed to find a valid solution.") + return None + + # 4. Normalize coordinates and assemble Geometry IR + return self._build_result( + raw_coords, + system.polygon_order, + system.circles_meta, + system.solids_meta, + system.segments_meta, + system.lines_ext, + system.rays_ext, + pt_list, + system.real_constraints, + ) + + def _build_result( + self, + coords: Dict[str, List[float]], + polygon_order: List[str], + circles_meta: List[Dict[str, Any]], + solids_meta: List[Dict[str, Any]], + segments_meta: List[List[str]], + lines_meta: List[List[str]], + rays_meta: List[List[str]], + pt_list: List[Point], + constraints_meta: Optional[List[Constraint]] = None, + ) -> Dict[str, Any]: + """Backward-compatible result builder delegating to modular topology & assembler.""" + # 1. Clean float zero residuals and restore explicit coordinates + cleaned_coords = coords.copy() + for p in pt_list: + if p.id in cleaned_coords: + if p.x is not None: + cleaned_coords[p.id][0] = float(p.x) + elif abs(cleaned_coords[p.id][0]) < 1e-10: + cleaned_coords[p.id][0] = 0.0 + + if p.y is not None: + cleaned_coords[p.id][1] = float(p.y) + elif abs(cleaned_coords[p.id][1]) < 1e-10: + cleaned_coords[p.id][1] = 0.0 + + if len(cleaned_coords[p.id]) >= 3: + if p.z is not None: + cleaned_coords[p.id][2] = float(p.z) + elif abs(cleaned_coords[p.id][2]) < 1e-10: + cleaned_coords[p.id][2] = 0.0 + + for pid in list(cleaned_coords.keys()): + for idx in range(len(cleaned_coords[pid])): + if abs(cleaned_coords[pid][idx]) < 1e-10: + cleaned_coords[pid][idx] = 0.0 + + # 2. Build topology and visualization graph + topology_data = self.topology_builder.build_topology( + coords=cleaned_coords, + polygon_order=polygon_order, + circles_meta=circles_meta, + solids_meta=solids_meta, + segments_meta=segments_meta, + lines_meta=lines_meta, + rays_meta=rays_meta, + pt_list=pt_list, + constraints_meta=constraints_meta, + ) + + # 3. Assemble canonical IR + return self.assembler.assemble( + coordinates=cleaned_coords, + topology_data=topology_data, + ) diff --git a/solver/geometry_normalizer.py b/solver/geometry_normalizer.py new file mode 100644 index 0000000000000000000000000000000000000000..d36196d7d8c7813c48fc32d1148df9f200b66bdc --- /dev/null +++ b/solver/geometry_normalizer.py @@ -0,0 +1,66 @@ +"""Geometry Normalizer: Normalizes coordinates, scales, frames, and removes degenerate geometries.""" + +from __future__ import annotations + +import logging +import numpy as np +from typing import Dict, List, Tuple + +logger = logging.getLogger(__name__) + + +class GeometryNormalizer: + """Standardizes point naming, coordinate scaling, centering, and precision.""" + + def normalize_coordinates( + self, + coords: Dict[str, List[float]], + target_span: float = 8.0, + is_3d: bool = False, + ) -> Dict[str, List[float]]: + if not coords: + return {} + + cleaned: Dict[str, List[float]] = {} + for pid, pt in coords.items(): + cleaned[pid] = [ + 0.0 if abs(val) < 1e-6 else float(np.round(val, 6)) + for val in pt + ] + + # Calculate bounding box + all_pts = np.array(list(cleaned.values())) + if len(all_pts) == 0: + return cleaned + + mins = np.min(all_pts, axis=0) + maxs = np.max(all_pts, axis=0) + spans = maxs - mins + max_span = float(np.max(spans)) + + # If geometry is valid and non-degenerate, scale into comfortable viewing range + if max_span > 1e-4: + scale = target_span / max_span + center = (mins + maxs) / 2.0 + + normalized: Dict[str, List[float]] = {} + for pid, pt in cleaned.items(): + pt_arr = np.array(pt) + norm_pt = (pt_arr - center) * scale + if not is_3d: + # In 2D, pin z to 0 and center in 2D plane + normalized[pid] = [ + float(np.round(norm_pt[0], 4)), + float(np.round(norm_pt[1], 4)), + 0.0, + ] + else: + # In 3D, keep z >= 0 ground orientation when appropriate + normalized[pid] = [ + float(np.round(norm_pt[0], 4)), + float(np.round(norm_pt[1], 4)), + float(np.round(norm_pt[2], 4)), + ] + return normalized + + return cleaned diff --git a/solver/models.py b/solver/models.py new file mode 100644 index 0000000000000000000000000000000000000000..42c26bfa3f7c313938b99a2d9026c1a2c5b217fb --- /dev/null +++ b/solver/models.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from typing import List, Dict, Union, Optional + +class Point(BaseModel): + id: str + x: Optional[float] = None + y: Optional[float] = None + z: Optional[float] = None + +class Constraint(BaseModel): + type: str # 'length', 'angle', 'parallel', etc. + targets: List[str] + value: Union[float, str] diff --git a/solver/result_assembler.py b/solver/result_assembler.py new file mode 100644 index 0000000000000000000000000000000000000000..b23146dffd53dcd18986362e39fba763e94f2d81 --- /dev/null +++ b/solver/result_assembler.py @@ -0,0 +1,36 @@ +"""Result Assembler: Assembles the canonical Geometry IR (Intermediate Representation) output.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class ResultAssembler: + """Assembles the unified, canonical Geometry IR representation.""" + + def assemble( + self, + coordinates: Dict[str, List[float]], + topology_data: Dict[str, Any], + validation_info: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + result = { + "coordinates": coordinates, + "polygon_order": topology_data.get("polygon_order", []), + "circles": topology_data.get("circles", []), + "solids": topology_data.get("solids", []), + "faces": topology_data.get("faces", []), + "lines": topology_data.get("lines", []), + "rays": topology_data.get("rays", []), + "drawing_phases": topology_data.get("drawing_phases", []), + "visualization_graph": topology_data.get("visualization_graph"), + "geometry_objects": topology_data.get("geometry_objects", []), + "auxiliary": topology_data.get("auxiliary", []), + "is_3d": topology_data.get("is_3d", False), + } + if validation_info: + result["validation"] = validation_info + return result diff --git a/solver/topology_builder.py b/solver/topology_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..40c8edf407a6e4f57906e3a7c8446d838e1e9596 --- /dev/null +++ b/solver/topology_builder.py @@ -0,0 +1,116 @@ +"""Topology Builder: Builds drawing phases, adjacency, faces, solids, and visualization graph.""" + +from __future__ import annotations + +import logging +import string +from typing import Any, Dict, List, Optional, Set +from .models import Point, Constraint +from .vis_planner import VisualizationPlanner + +logger = logging.getLogger(__name__) + + +class TopologyBuilder: + """Generates geometric topology, faces, drawing phases, and complete visualization graph.""" + + def __init__(self): + self.planner = VisualizationPlanner() + + def build_topology( + self, + coords: Dict[str, List[float]], + polygon_order: List[str], + circles_meta: List[Dict[str, Any]], + solids_meta: List[Dict[str, Any]], + segments_meta: List[List[str]], + lines_meta: List[List[str]], + rays_meta: List[List[str]], + pt_list: List[Point], + constraints_meta: Optional[List[Constraint]] = None, + ) -> Dict[str, Any]: + all_ids = [p.id for p in pt_list] + constraints = constraints_meta or [] + + # Canonical ordering if empty + if not polygon_order: + base_pts = sorted( + all_ids, + key=lambda p: (string.ascii_uppercase.index(p) if p in string.ascii_uppercase else 100, p), + ) + polygon_order = base_pts + + base_ids = [pid for pid in polygon_order if pid in all_ids] + derived_ids = [pid for pid in all_ids if pid not in polygon_order] + + drawn_segments: Set[frozenset] = set() + + def add_segment(p1: str, p2: str, target_list: List[List[str]]): + if p1 == p2: + return + s = frozenset([p1, p2]) + if s not in drawn_segments: + drawn_segments.add(s) + target_list.append([p1, p2]) + + # Phase 1: Main polygon / base shape boundary + phase1_segments: List[List[str]] = [] + if len(base_ids) >= 2: + for i in range(len(base_ids) - 1): + add_segment(base_ids[i], base_ids[i + 1], phase1_segments) + if len(base_ids) > 2: + add_segment(base_ids[-1], base_ids[0], phase1_segments) + + # Phase 2: Auxiliary segments from DSL and 3D solids + phase2_segments: List[List[str]] = [] + for seg in segments_meta: + if len(seg) >= 2: + add_segment(seg[0], seg[1], phase2_segments) + + drawing_phases = [ + { + "phase": 1, + "label": "Hình cơ bản", + "points": base_ids, + "segments": phase1_segments, + } + ] + if derived_ids or phase2_segments: + drawing_phases.append({ + "phase": 2, + "label": "Điểm và đoạn phụ", + "points": derived_ids, + "segments": phase2_segments, + }) + + is_3d = any(len(c) >= 3 and abs(c[2]) > 1e-4 for c in coords.values()) + + # Plan topological visualization graph + vis_graph = self.planner.plan( + coords=coords, + constraints=constraints, + solids_meta=solids_meta, + circles_meta=circles_meta, + polygon_order=polygon_order, + segments_meta=segments_meta, + lines_meta=lines_meta, + rays_meta=rays_meta, + pt_list=pt_list, + is_3d=is_3d, + ) + + faces: List[List[str]] = [f.vertices for f in vis_graph.faces.values()] + + return { + "polygon_order": polygon_order, + "circles": circles_meta, + "solids": solids_meta, + "faces": faces, + "lines": lines_meta, + "rays": rays_meta, + "drawing_phases": vis_graph.drawing_phases if vis_graph.drawing_phases else drawing_phases, + "visualization_graph": vis_graph.model_dump(mode="json"), + "geometry_objects": vis_graph.to_geometry_objects_list(), + "auxiliary": [a.to_dict() for a in vis_graph.auxiliary], + "is_3d": is_3d, + } diff --git a/solver/validator.py b/solver/validator.py new file mode 100644 index 0000000000000000000000000000000000000000..7d04a19f4b44d58b33728a4e36acfb054400d0f8 --- /dev/null +++ b/solver/validator.py @@ -0,0 +1,437 @@ +"""Deterministic Geometry Validation Engine. + +Validates solved coordinates against geometric invariants and DSL constraints +before visualization or external rendering dispatch. +""" +from __future__ import annotations + +import logging +import math +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple +import numpy as np + +from .models import Constraint + +logger = logging.getLogger(__name__) + + +class GeometryStatus(str, Enum): + """Geometry validation outcome status.""" + VALID = "valid" + DEGRADED = "degraded" + FAILED = "failed" + + +class StructuredError: + """Machine-readable validation error for LLM repair feedback.""" + + def __init__( + self, + error_type: str, + constraint: str, + expected: str = "", + actual: str = "", + instruction: str = "Correct the DSL to satisfy this constraint.", + ): + self.error_type = error_type + self.constraint = constraint + self.expected = expected + self.actual = actual + self.instruction = instruction + + def to_dict(self) -> Dict[str, str]: + return { + "error_type": self.error_type, + "constraint": self.constraint, + "expected": self.expected, + "actual": self.actual, + "instruction": self.instruction, + } + + +class ValidationResult: + def __init__( + self, + is_valid: bool = True, + errors: Optional[List[str]] = None, + warnings: Optional[List[str]] = None, + checked_count: int = 0, + status: GeometryStatus = GeometryStatus.VALID, + structured_errors: Optional[List[StructuredError]] = None, + ): + self.is_valid = is_valid + self.errors = errors or [] + self.warnings = warnings or [] + self.checked_count = checked_count + self.status = status + self.structured_errors = structured_errors or [] + + @property + def error_summary(self) -> str: + if not self.errors: + return "" + return "; ".join(self.errors[:5]) + + def to_dict(self) -> Dict[str, Any]: + return { + "is_valid": self.is_valid, + "status": self.status.value, + "errors": self.errors, + "warnings": self.warnings, + "checked_count": self.checked_count, + "error_summary": self.error_summary, + } + + def to_structured_feedback(self) -> Dict[str, Any]: + """Returns structured feedback JSON for LLM repair loops.""" + return { + "status": self.status.value, + "error_count": len(self.errors), + "details": [e.to_dict() for e in self.structured_errors[:5]], + "instruction": "Correct the DSL to satisfy all constraints listed above.", + } + + +class GeometryValidator: + """ + Validates geometric invariants and constraint satisfaction on solved coordinates. + """ + + def __init__(self, tolerance: float = 0.05): + self.tolerance = tolerance + + def _vec(self, coords: Dict[str, List[float]], pid: str) -> Optional[np.ndarray]: + if pid not in coords: + return None + c = coords[pid] + if len(c) == 2: + return np.array([float(c[0]), float(c[1]), 0.0], dtype=float) + elif len(c) >= 3: + return np.array([float(c[0]), float(c[1]), float(c[2])], dtype=float) + return None + + def validate( + self, + engine_result: Dict[str, Any], + constraints: Optional[List[Constraint]] = None, + is_3d: bool = False, + ) -> ValidationResult: + if not engine_result or not isinstance(engine_result, dict): + return ValidationResult(is_valid=False, errors=["Empty or invalid engine result dictionary."]) + + coords: Dict[str, List[float]] = engine_result.get("coordinates", {}) + if not coords or not isinstance(coords, dict): + return ValidationResult(is_valid=False, errors=["Coordinates map is empty or missing."]) + + errors: List[str] = [] + warnings: List[str] = [] + checked = 0 + + # 1. Check all coordinates are finite numbers + for pid, pt in coords.items(): + checked += 1 + if not isinstance(pt, (list, tuple)) or len(pt) < 2: + errors.append(f"Point '{pid}' has invalid coordinate format: {pt}") + continue + for val in pt: + if val is None or math.isnan(val) or math.isinf(val): + errors.append(f"Point '{pid}' contains non-finite coordinate value: {val}") + + if errors: + return ValidationResult(is_valid=False, errors=errors, warnings=warnings, checked_count=checked) + + # 2. Check for distinct point collapse / degeneracy + point_ids = list(coords.keys()) + for i in range(len(point_ids)): + for j in range(i + 1, len(point_ids)): + p1_id, p2_id = point_ids[i], point_ids[j] + v1 = self._vec(coords, p1_id) + v2 = self._vec(coords, p2_id) + if v1 is not None and v2 is not None: + dist = float(np.linalg.norm(v1 - v2)) + if dist < 1e-4: + warnings.append(f"Points '{p1_id}' and '{p2_id}' are nearly coincident (dist={dist:.2e}).") + + # 3. Validate drawing phases segments non-zero length + drawing_phases = engine_result.get("drawing_phases", []) + for phase in drawing_phases: + for seg in phase.get("segments", []): + if len(seg) == 2: + p1, p2 = seg[0], seg[1] + v1, v2 = self._vec(coords, p1), self._vec(coords, p2) + checked += 1 + if v1 is None or v2 is None: + errors.append(f"Segment references missing point '{p1}' or '{p2}'.") + else: + length = float(np.linalg.norm(v2 - v1)) + if length < 1e-5: + errors.append(f"Degenerate zero-length segment between '{p1}' and '{p2}'.") + + # 4. Validate 3D solids topology if present + solids = engine_result.get("solids", []) + for s in solids: + s_type = s.get("type") + checked += 1 + if s_type == "pyramid": + apex = s.get("apex") + base = s.get("base", []) + v_apex = self._vec(coords, apex) if apex else None + if v_apex is None: + errors.append(f"Pyramid apex '{apex}' not found in coordinates.") + if len(base) < 3: + errors.append(f"Pyramid base must have >= 3 points, got: {base}") + else: + base_vecs = [self._vec(coords, bp) for bp in base] + if any(bv is None for bv in base_vecs): + errors.append(f"Pyramid base contains missing points: {base}") + elif v_apex is not None: + # Check apex is not coplanar with base + v0 = base_vecs[0] + v1 = base_vecs[1] + v2 = base_vecs[2] + normal = np.cross(v1 - v0, v2 - v0) + norm_mag = float(np.linalg.norm(normal)) + if norm_mag > 1e-5: + altitude = abs(float(np.dot(v_apex - v0, normal))) / norm_mag + if altitude < 1e-3: + errors.append(f"Pyramid apex '{apex}' is coplanar with base (altitude={altitude:.2e}).") + elif s_type in ("prism", "cube", "cuboid", "frustum"): + b1 = s.get("base1", []) + b2 = s.get("base2", []) + if len(b1) != len(b2) or len(b1) < 3: + errors.append(f"Solid '{s_type}' requires equal base sizes >= 3, got base1={len(b1)}, base2={len(b2)}.") + else: + b1_vecs = [self._vec(coords, p) for p in b1] + b2_vecs = [self._vec(coords, p) for p in b2] + if any(v is None for v in b1_vecs + b2_vecs): + errors.append(f"Solid '{s_type}' contains missing points in bases.") + else: + # Height between bases > 0 + h_dist = float(np.linalg.norm(b2_vecs[0] - b1_vecs[0])) + if h_dist < 1e-3: + errors.append(f"Solid '{s_type}' has collapsed zero height between bases.") + + # 5. Validate specific DSL constraints if provided + if constraints: + for c in constraints: + c_type = c.type + targets = c.targets + val = c.value + checked += 1 + + if c_type == "length" and len(targets) == 2: + p1, p2 = targets[0], targets[1] + v1, v2 = self._vec(coords, p1), self._vec(coords, p2) + if v1 is not None and v2 is not None: + expected_len = float(val) + actual_len = float(np.linalg.norm(v2 - v1)) + denom = max(expected_len, 1.0) + rel_err = abs(actual_len - expected_len) / denom + if rel_err > self.tolerance: + errors.append( + f"Length constraint violated: |{p1}{p2}| expected {expected_len:.2f}, got {actual_len:.2f} (err={rel_err:.1%})" + ) + + elif c_type == "length_equal" and len(targets) == 4: + pA, pB, pC, pD = targets[0], targets[1], targets[2], targets[3] + va, vb, vc, vd = self._vec(coords, pA), self._vec(coords, pB), self._vec(coords, pC), self._vec(coords, pD) + if all(v is not None for v in [va, vb, vc, vd]): + len1 = float(np.linalg.norm(vb - va)) + len2 = float(np.linalg.norm(vd - vc)) + denom = max(len1, len2, 1.0) + rel_err = abs(len1 - len2) / denom + if rel_err > self.tolerance: + errors.append( + f"Equal length violated: |{pA}{pB}|={len1:.2f} vs |{pC}{pD}|={len2:.2f} (err={rel_err:.1%})" + ) + + elif c_type == "perpendicular" and len(targets) == 4: + pA, pB, pC, pD = targets[0], targets[1], targets[2], targets[3] + va, vb, vc, vd = self._vec(coords, pA), self._vec(coords, pB), self._vec(coords, pC), self._vec(coords, pD) + if all(v is not None for v in [va, vb, vc, vd]): + v1 = vb - va + v2 = vd - vc + mag1, mag2 = float(np.linalg.norm(v1)), float(np.linalg.norm(v2)) + if mag1 > 1e-4 and mag2 > 1e-4: + cos_theta = abs(float(np.dot(v1, v2)) / (mag1 * mag2)) + if cos_theta > self.tolerance: + errors.append(f"Perpendicularity violated: {pA}{pB} not perpendicular to {pC}{pD} (cos={cos_theta:.3f})") + + elif c_type == "parallel" and len(targets) == 4: + pA, pB, pC, pD = targets[0], targets[1], targets[2], targets[3] + va, vb, vc, vd = self._vec(coords, pA), self._vec(coords, pB), self._vec(coords, pC), self._vec(coords, pD) + if all(v is not None for v in [va, vb, vc, vd]): + v1 = vb - va + v2 = vd - vc + mag1, mag2 = float(np.linalg.norm(v1)), float(np.linalg.norm(v2)) + if mag1 > 1e-4 and mag2 > 1e-4: + sin_theta = float(np.linalg.norm(np.cross(v1, v2))) / (mag1 * mag2) + if sin_theta > self.tolerance: + errors.append(f"Parallelism violated: {pA}{pB} not parallel to {pC}{pD} (sin={sin_theta:.3f})") + + elif c_type == "perp_plane" and len(targets) >= 4: + pL1, pL2 = targets[0], targets[1] + plane_pts = targets[2:] + vL1, vL2 = self._vec(coords, pL1), self._vec(coords, pL2) + if vL1 is not None and vL2 is not None: + v_line = vL2 - vL1 + l_mag = float(np.linalg.norm(v_line)) + if l_mag > 1e-4: + p0 = self._vec(coords, plane_pts[0]) + if p0 is not None: + for p_other in plane_pts[1:]: + p_v = self._vec(coords, p_other) + if p_v is not None: + v_plane = p_v - p0 + p_mag = float(np.linalg.norm(v_plane)) + if p_mag > 1e-4: + cos_t = abs(float(np.dot(v_line, v_plane)) / (l_mag * p_mag)) + if cos_t > self.tolerance: + errors.append( + f"Perpendicular plane violated: {pL1}{pL2} not perpendicular to {plane_pts[0]}{p_other} (cos={cos_t:.3f})" + ) + + elif c_type == "midpoint" and len(targets) == 3: + pM, pA, pB = targets[0], targets[1], targets[2] + vM, vA, vB = self._vec(coords, pM), self._vec(coords, pA), self._vec(coords, pB) + if all(v is not None for v in [vM, vA, vB]): + expected_mid = (vA + vB) / 2.0 + denom = max(float(np.linalg.norm(vB - vA)), 1.0) + err = float(np.linalg.norm(vM - expected_mid)) / denom + if err > self.tolerance: + errors.append(f"Midpoint constraint violated: '{pM}' is not midpoint of '{pA}{pB}' (err={err:.1%})") + + elif c_type == "section" and len(targets) == 3: + pE, pA, pC = targets[0], targets[1], targets[2] + vE, vA, vC = self._vec(coords, pE), self._vec(coords, pA), self._vec(coords, pC) + if all(v is not None for v in [vE, vA, vC]): + k = float(val) + expected_pt = vA + k * (vC - vA) + denom = max(float(np.linalg.norm(vC - vA)), 1.0) + err = float(np.linalg.norm(vE - expected_pt)) / denom + if err > self.tolerance: + errors.append(f"Section constraint violated: '{pE}' != {pA} + {k}({pC}-{pA}) (err={err:.1%})") + + elif c_type == "center" and len(targets) >= 3: + pO = targets[0] + v_poly = targets[1:] + vO = self._vec(coords, pO) + poly_vecs = [self._vec(coords, p) for p in v_poly] + if vO is not None and all(v is not None for v in poly_vecs): + mean_center = np.mean(poly_vecs, axis=0) + denom = max(float(np.linalg.norm(poly_vecs[1] - poly_vecs[0])), 1.0) if len(poly_vecs) > 1 else 1.0 + err = float(np.linalg.norm(vO - mean_center)) / denom + if err > self.tolerance: + errors.append(f"Center constraint violated: '{pO}' is not center of {v_poly} (err={err:.1%})") + + elif c_type == "coplanar" and len(targets) >= 4: + pA, pB, pC, pD = targets[0], targets[1], targets[2], targets[3] + va, vb, vc, vd = self._vec(coords, pA), self._vec(coords, pB), self._vec(coords, pC), self._vec(coords, pD) + if all(v is not None for v in [va, vb, vc, vd]): + v1 = vb - va + v2 = vc - va + v3 = vd - va + cross = np.cross(v1, v2) + cross_mag = float(np.linalg.norm(cross)) + if cross_mag > 1e-4: + dist = abs(float(np.dot(v3, cross))) / cross_mag + denom = max(float(np.linalg.norm(v3)), 1.0) + if dist / denom > self.tolerance: + errors.append(f"Coplanar constraint violated for {targets[:4]} (dist={dist:.2e})") + + elif c_type in ("point_on_plane", "point_on") and len(targets) >= 3: + if c_type == "point_on_plane" and len(targets) >= 4: + pP, pA, pB, pC = targets[0], targets[1], targets[2], targets[3] + vp, va, vb, vc = self._vec(coords, pP), self._vec(coords, pA), self._vec(coords, pB), self._vec(coords, pC) + if all(v is not None for v in [vp, va, vb, vc]): + normal = np.cross(vb - va, vc - va) + n_mag = float(np.linalg.norm(normal)) + if n_mag > 1e-4: + dist = abs(float(np.dot(vp - va, normal))) / n_mag + denom = max(float(np.linalg.norm(vp - va)), 1.0) + if dist / denom > self.tolerance: + errors.append(f"Point on plane violated: '{pP}' not on plane({pA},{pB},{pC}) (dist={dist:.2e})") + elif c_type == "point_on" and len(targets) == 3: + pP, pA, pB = targets[0], targets[1], targets[2] + vp, va, vb = self._vec(coords, pP), self._vec(coords, pA), self._vec(coords, pB) + if all(v is not None for v in [vp, va, vb]): + v_line = vb - va + l_mag = float(np.linalg.norm(v_line)) + if l_mag > 1e-4: + dist = float(np.linalg.norm(np.cross(vp - va, v_line))) / l_mag + denom = max(l_mag, 1.0) + if dist / denom > self.tolerance: + errors.append(f"Point on line/segment violated: '{pP}' not on '{pA}{pB}' (dist={dist:.2e})") + + elif c_type == "angle" and len(targets) >= 1: + v_name = targets[0] + p1_name = targets[1] if len(targets) > 1 else None + p2_name = targets[2] if len(targets) > 2 else None + if p1_name and p2_name: + pV, p1, p2 = self._vec(coords, v_name), self._vec(coords, p1_name), self._vec(coords, p2_name) + if all(v is not None for v in [pV, p1, p2]): + v1 = p1 - pV + v2 = p2 - pV + mag1, mag2 = float(np.linalg.norm(v1)), float(np.linalg.norm(v2)) + if mag1 > 1e-4 and mag2 > 1e-4: + cos_val = float(np.dot(v1, v2)) / (mag1 * mag2) + cos_val = max(-1.0, min(1.0, cos_val)) + actual_deg = float(np.rad2deg(np.arccos(cos_val))) + target_deg = float(val) + if abs(actual_deg - target_deg) > 4.0: + errors.append( + f"Angle constraint violated at '{v_name}': expected {target_deg:.1f}°, got {actual_deg:.1f}°" + ) + + is_valid = len(errors) == 0 + status = GeometryStatus.VALID if is_valid else GeometryStatus.FAILED + + # Build structured errors for LLM repair feedback + structured_errors: List[StructuredError] = [] + for err_msg in errors: + # Parse error messages into structured format + if "Length constraint violated" in err_msg: + structured_errors.append(StructuredError( + error_type="constraint_violation", + constraint=err_msg.split(":")[0] if ":" in err_msg else err_msg, + expected=err_msg, + actual="", + instruction="Correct the DSL length values to match the constraint.", + )) + elif "Perpendicularity violated" in err_msg: + structured_errors.append(StructuredError( + error_type="constraint_violation", + constraint=err_msg.split(":")[0] if ":" in err_msg else err_msg, + expected="dot(v1, v2) = 0", + actual=err_msg, + instruction="Correct the DSL to ensure perpendicularity constraint is satisfied.", + )) + elif "Parallelism violated" in err_msg: + structured_errors.append(StructuredError( + error_type="constraint_violation", + constraint=err_msg.split(":")[0] if ":" in err_msg else err_msg, + expected="cross(v1, v2) = 0", + actual=err_msg, + instruction="Correct the DSL to ensure parallelism constraint is satisfied.", + )) + else: + structured_errors.append(StructuredError( + error_type="validation_error", + constraint=err_msg, + instruction="Correct the DSL to resolve this validation error.", + )) + + if not is_valid: + logger.warning(f"[GeometryValidator] Validation FAILED with {len(errors)} errors: {errors[:3]}") + else: + logger.info(f"[GeometryValidator] Validation PASSED ({checked} checks performed).") + + return ValidationResult( + is_valid=is_valid, + errors=errors, + warnings=warnings, + checked_count=checked, + status=status, + structured_errors=structured_errors, + ) diff --git a/solver/vis_graph.py b/solver/vis_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..efc7eaa1837152f0cdc84261767e87231584167d --- /dev/null +++ b/solver/vis_graph.py @@ -0,0 +1,340 @@ +"""Complete Visualization Graph Model. + +Distinguishes between Mathematical Geometry (exact coordinates, constraints) +and Visualization Graph (topological visual entities, faces, edges, auxiliary +constructions, visibility, and importance tiers). +""" +from __future__ import annotations + +import enum +from typing import Any, Dict, List, Optional, Set, Tuple +from pydantic import BaseModel, ConfigDict, Field + + +class ImportanceTier(str, enum.Enum): + """Necessity level of an entity for minimal sufficient visualization.""" + REQUIRED = "REQUIRED" # Essential to understand the geometry or solution + HELPFUL = "HELPFUL" # Clarifies spatial relations (rendered by default) + OPTIONAL = "OPTIONAL" # Extra detail, hidden unless requested + + +class EntityKind(str, enum.Enum): + """Distinction between primary, auxiliary, and derived entities.""" + PRIMARY = "PRIMARY" # Base geometry defined by the main problem + AUXILIARY = "AUXILIARY" # Geometric construction (height, foot, median, etc.) + DERIVED = "DERIVED" # Secondary solid, cross-section, or composite object + + +class EdgeStyle(str, enum.Enum): + """Visual style for rendering edges.""" + SOLID = "solid" + DASHED = "dashed" + DOTTED = "dotted" + + +class VisVertex(BaseModel): + """A vertex or point in the Visualization Graph.""" + model_config = ConfigDict(extra="ignore") + + id: str + coordinates: List[float] = Field(default_factory=list) + role: str = "vertex" # vertex, apex, foot, midpoint, center, auxiliary_point + tier: ImportanceTier = ImportanceTier.REQUIRED + kind: EntityKind = EntityKind.PRIMARY + label: Optional[str] = None + show_label: bool = True + parent_solid: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump(mode="json") + + +class VisEdge(BaseModel): + """An edge or segment in the Visualization Graph.""" + model_config = ConfigDict(extra="ignore") + + id: str # Canonical edge ID e.g. "AB" + source: str + target: str + role: str = "edge" # base_edge, lateral_edge, altitude, median, bisector, diagonal, projection + tier: ImportanceTier = ImportanceTier.REQUIRED + kind: EntityKind = EntityKind.PRIMARY + style: EdgeStyle = EdgeStyle.SOLID + is_hidden: bool = False + parent_solid: Optional[str] = None + label: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump(mode="json") + + +class VisFace(BaseModel): + """A polygonal face or surface in the Visualization Graph.""" + model_config = ConfigDict(extra="ignore") + + id: str # e.g. "face_ABCD" + vertices: List[str] # Cyclic ordered vertices [A, B, C, D] + role: str = "face" # base_face, lateral_face, top_face, cross_section + parent_solid: Optional[str] = None + plane_equation: Optional[List[float]] = None # [a, b, c, d] for ax + by + cz + d = 0 + tier: ImportanceTier = ImportanceTier.HELPFUL + kind: EntityKind = EntityKind.PRIMARY + opacity: float = 0.2 + fill: bool = True + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump(mode="json") + + +class VisSolid(BaseModel): + """A 3D solid with complete topology (vertices, edges, faces).""" + model_config = ConfigDict(extra="ignore") + + id: str # e.g. "pyramid_S_ABCD" + type: str # pyramid, prism, cube, cuboid, tetrahedron, etc. + vertices: List[str] + edges: List[str] # List of edge IDs + faces: List[str] # List of face IDs + apex: Optional[str] = None + base_vertices: List[str] = Field(default_factory=list) + top_vertices: List[str] = Field(default_factory=list) + tier: ImportanceTier = ImportanceTier.REQUIRED + kind: EntityKind = EntityKind.PRIMARY + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump(mode="json") + + +class VisAuxiliaryConstruction(BaseModel): + """A solution-dependent auxiliary construction entity.""" + model_config = ConfigDict(extra="ignore") + + id: str + type: str # height, foot, median, bisector, diagonal, center, midpoint, section, projection + source_entity: str + target_entity: str + created_vertices: List[str] = Field(default_factory=list) + created_edges: List[str] = Field(default_factory=list) + perpendicular_marks: List[Dict[str, Any]] = Field(default_factory=list) + angle_marks: List[Dict[str, Any]] = Field(default_factory=list) + equal_ticks: List[Dict[str, Any]] = Field(default_factory=list) + parallel_marks: List[Dict[str, Any]] = Field(default_factory=list) + tier: ImportanceTier = ImportanceTier.REQUIRED + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump(mode="json") + + +class VisualizationGraph(BaseModel): + """ + Complete Topological Visualization Graph representing all visual entities, + hierarchies, faces, edges, and auxiliary constructions. + """ + model_config = ConfigDict(extra="ignore") + + vertices: Dict[str, VisVertex] = Field(default_factory=dict) + edges: Dict[str, VisEdge] = Field(default_factory=dict) + faces: Dict[str, VisFace] = Field(default_factory=dict) + solids: Dict[str, VisSolid] = Field(default_factory=dict) + auxiliary: List[VisAuxiliaryConstruction] = Field(default_factory=list) + perpendicular_marks: List[Dict[str, Any]] = Field(default_factory=list) + angle_marks: List[Dict[str, Any]] = Field(default_factory=list) + equal_ticks: List[Dict[str, Any]] = Field(default_factory=list) + parallel_marks: List[Dict[str, Any]] = Field(default_factory=list) + drawing_phases: List[Dict[str, Any]] = Field(default_factory=list) + is_3d: bool = False + + def add_vertex( + self, + pid: str, + coords: List[float], + role: str = "vertex", + tier: ImportanceTier = ImportanceTier.REQUIRED, + kind: EntityKind = EntityKind.PRIMARY, + parent_solid: Optional[str] = None, + ) -> VisVertex: + if pid in self.vertices: + existing = self.vertices[pid] + if existing.role == "vertex" and role != "vertex": + existing.role = role + if kind != EntityKind.PRIMARY: + existing.kind = kind + if tier == ImportanceTier.REQUIRED: + existing.tier = ImportanceTier.REQUIRED + if parent_solid and not existing.parent_solid: + existing.parent_solid = parent_solid + return existing + + vertex = VisVertex( + id=pid, + coordinates=list(coords), + role=role, + tier=tier, + kind=kind, + label=pid, + parent_solid=parent_solid, + ) + self.vertices[pid] = vertex + return vertex + + def add_edge( + self, + p1: str, + p2: str, + role: str = "edge", + tier: ImportanceTier = ImportanceTier.REQUIRED, + kind: EntityKind = EntityKind.PRIMARY, + style: EdgeStyle = EdgeStyle.SOLID, + is_hidden: bool = False, + parent_solid: Optional[str] = None, + ) -> VisEdge: + edge_id = f"{p1}{p2}" if p1 < p2 else f"{p2}{p1}" + if edge_id in self.edges: + # Upgrade tier/style/role if needed + existing = self.edges[edge_id] + if existing.role in ("edge", "segment") and role not in ("edge", "segment"): + existing.role = role + if tier == ImportanceTier.REQUIRED: + existing.tier = ImportanceTier.REQUIRED + if kind != EntityKind.PRIMARY: + existing.kind = kind + if style == EdgeStyle.DASHED: + existing.style = style + if is_hidden: + existing.is_hidden = is_hidden + if parent_solid and not existing.parent_solid: + existing.parent_solid = parent_solid + return existing + + edge = VisEdge( + id=edge_id, + source=p1, + target=p2, + role=role, + tier=tier, + kind=kind, + style=style, + is_hidden=is_hidden, + parent_solid=parent_solid, + ) + self.edges[edge_id] = edge + return edge + + def add_face( + self, + vertices: List[str], + role: str = "face", + tier: ImportanceTier = ImportanceTier.HELPFUL, + kind: EntityKind = EntityKind.PRIMARY, + parent_solid: Optional[str] = None, + opacity: float = 0.2, + ) -> VisFace: + face_id = f"face_{'_'.join(vertices)}" + if face_id in self.faces: + return self.faces[face_id] + + face = VisFace( + id=face_id, + vertices=list(vertices), + role=role, + tier=tier, + kind=kind, + parent_solid=parent_solid, + opacity=opacity, + ) + self.faces[face_id] = face + return face + + def get_minimal_sufficient_graph( + self, + max_tier: ImportanceTier = ImportanceTier.HELPFUL, + ) -> Dict[str, Any]: + """ + Filters graph down to a minimal sufficient visualization by excluding + extraneous/cluttering OPTIONAL entities unless requested. + """ + allowed_tiers = {ImportanceTier.REQUIRED} + if max_tier in (ImportanceTier.HELPFUL, ImportanceTier.OPTIONAL): + allowed_tiers.add(ImportanceTier.HELPFUL) + if max_tier == ImportanceTier.OPTIONAL: + allowed_tiers.add(ImportanceTier.OPTIONAL) + + filtered_vertices = { + k: v.to_dict() for k, v in self.vertices.items() if v.tier in allowed_tiers + } + filtered_edges = { + k: v.to_dict() + for k, v in self.edges.items() + if v.tier in allowed_tiers + and v.source in filtered_vertices + and v.target in filtered_vertices + } + filtered_faces = { + k: v.to_dict() + for k, v in self.faces.items() + if v.tier in allowed_tiers + and all(pt in filtered_vertices for pt in v.vertices) + } + + return { + "vertices": filtered_vertices, + "edges": filtered_edges, + "faces": filtered_faces, + "solids": {k: v.to_dict() for k, v in self.solids.items() if v.tier in allowed_tiers}, + "auxiliary": [a.to_dict() for a in self.auxiliary if a.tier in allowed_tiers], + "drawing_phases": self.drawing_phases, + "is_3d": self.is_3d, + } + + def to_geometry_objects_list(self) -> List[Dict[str, Any]]: + """Converts graph into list of geometry object dicts for VisualizationSpec.""" + objs = [] + # Points + for pid, v in self.vertices.items(): + objs.append({ + "type": "point_3d" if self.is_3d else "point_2d", + "label": pid, + "properties": { + "coordinates": v.coordinates, + "role": v.role, + "tier": v.tier.value, + "kind": v.kind.value, + }, + }) + # Edges + for eid, e in self.edges.items(): + objs.append({ + "type": "segment_3d" if self.is_3d else "segment_2d", + "label": eid, + "properties": { + "start": e.source, + "end": e.target, + "role": e.role, + "style": e.style.value, + "is_hidden": e.is_hidden, + "tier": e.tier.value, + "kind": e.kind.value, + "parent_solid": e.parent_solid, + }, + }) + # Faces + for fid, f in self.faces.items(): + objs.append({ + "type": "face_3d" if self.is_3d else "polygon_2d", + "label": fid, + "properties": { + "vertices": f.vertices, + "role": f.role, + "opacity": f.opacity, + "parent_solid": f.parent_solid, + "tier": f.tier.value, + }, + }) + # Solids + for sid, s in self.solids.items(): + objs.append({ + "type": s.type, + "label": sid, + "properties": s.to_dict(), + }) + return objs diff --git a/solver/vis_planner.py b/solver/vis_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..e9f9831db618aedc0adcf1bcb806794988e6ab98 --- /dev/null +++ b/solver/vis_planner.py @@ -0,0 +1,717 @@ +"""Visualization Planner & Topology Derivation Engine. + +Derives complete topological models (vertices, edges, faces, solids, auxiliary +constructions, visibility, and drawing phases) from semantic geometry definitions +and solved coordinates. +""" +from __future__ import annotations + +import logging +import math +from typing import Any, Dict, List, Optional, Set, Tuple +import numpy as np + +from .models import Point, Constraint +from .vis_graph import ( + EdgeStyle, + EntityKind, + ImportanceTier, + VisAuxiliaryConstruction, + VisEdge, + VisFace, + VisSolid, + VisVertex, + VisualizationGraph, +) + +logger = logging.getLogger(__name__) + + +class VisualizationPlanner: + """ + Constructs a complete, minimal sufficient Visualization Graph from + mathematical geometry results and semantic DSL constraints. + """ + + def plan( + self, + coords: Dict[str, List[float]], + constraints: List[Constraint], + solids_meta: List[Dict[str, Any]], + circles_meta: List[Dict[str, Any]], + polygon_order: List[str], + segments_meta: List[List[str]], + lines_meta: List[List[str]], + rays_meta: List[List[str]], + pt_list: List[Point], + is_3d: bool = False, + ) -> VisualizationGraph: + graph = VisualizationGraph(is_3d=is_3d) + + # --------------------------------------------------------------------- + # 1. Register All Known Points as Vertices + # --------------------------------------------------------------------- + all_ids = [p.id for p in pt_list] + for pid in all_ids: + if pid in coords: + graph.add_vertex( + pid=pid, + coords=coords[pid], + role="vertex", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.PRIMARY, + ) + + # --------------------------------------------------------------------- + # 2. Derive Standard 3D Solid Topologies (Vertices, Edges, Faces) + # --------------------------------------------------------------------- + for solid in solids_meta: + s_type = solid.get("type", "solid") + s_id = f"{s_type}_{'_'.join(solid.get('points', []))}" if solid.get("points") else f"{s_type}_{len(graph.solids)}" + + if s_type == "pyramid": + apex = solid.get("apex") + base = solid.get("base", []) + if apex and len(base) >= 3: + s_id = f"pyramid_{apex}_{''.join(base)}" + # Mark apex role + if apex in graph.vertices: + graph.vertices[apex].role = "apex" + + pyramid_edges: List[str] = [] + pyramid_faces: List[str] = [] + + # Base edges (cyclic) + for i in range(len(base)): + p1 = base[i] + p2 = base[(i + 1) % len(base)] + e = graph.add_edge( + p1=p1, + p2=p2, + role="base_edge", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.PRIMARY, + parent_solid=s_id, + ) + pyramid_edges.append(e.id) + + # Lateral edges (apex -> base) + for bp in base: + e = graph.add_edge( + p1=apex, + p2=bp, + role="lateral_edge", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.PRIMARY, + parent_solid=s_id, + ) + pyramid_edges.append(e.id) + + # Base face + f_base = graph.add_face( + vertices=base, + role="base_face", + tier=ImportanceTier.HELPFUL, + parent_solid=s_id, + opacity=0.15, + ) + pyramid_faces.append(f_base.id) + + # Lateral faces + for i in range(len(base)): + p1 = base[i] + p2 = base[(i + 1) % len(base)] + f_lat = graph.add_face( + vertices=[apex, p1, p2], + role="lateral_face", + tier=ImportanceTier.HELPFUL, + parent_solid=s_id, + opacity=0.25, + ) + pyramid_faces.append(f_lat.id) + + graph.solids[s_id] = VisSolid( + id=s_id, + type="pyramid", + vertices=base + [apex], + edges=pyramid_edges, + faces=pyramid_faces, + apex=apex, + base_vertices=base, + ) + + elif s_type in ("prism", "cube", "cuboid", "frustum"): + b1 = solid.get("base1", []) + b2 = solid.get("base2", []) + if len(b1) >= 3 and len(b2) >= 3 and len(b1) == len(b2): + s_id = f"{s_type}_{''.join(b1)}_{''.join(b2)}" + prism_edges: List[str] = [] + prism_faces: List[str] = [] + + # Base 1 cyclic edges + for i in range(len(b1)): + e = graph.add_edge( + p1=b1[i], + p2=b1[(i + 1) % len(b1)], + role="base_edge", + tier=ImportanceTier.REQUIRED, + parent_solid=s_id, + ) + prism_edges.append(e.id) + + # Base 2 cyclic edges + for i in range(len(b2)): + e = graph.add_edge( + p1=b2[i], + p2=b2[(i + 1) % len(b2)], + role="top_edge", + tier=ImportanceTier.REQUIRED, + parent_solid=s_id, + ) + prism_edges.append(e.id) + + # Lateral edges + for p1, p2 in zip(b1, b2): + e = graph.add_edge( + p1=p1, + p2=p2, + role="lateral_edge", + tier=ImportanceTier.REQUIRED, + parent_solid=s_id, + ) + prism_edges.append(e.id) + + # Base 1 face + f1 = graph.add_face( + vertices=b1, + role="base_face", + tier=ImportanceTier.HELPFUL, + parent_solid=s_id, + opacity=0.15, + ) + prism_faces.append(f1.id) + + # Base 2 face + f2 = graph.add_face( + vertices=b2, + role="top_face", + tier=ImportanceTier.HELPFUL, + parent_solid=s_id, + opacity=0.15, + ) + prism_faces.append(f2.id) + + # Lateral faces + for i in range(len(b1)): + i_next = (i + 1) % len(b1) + f_lat = graph.add_face( + vertices=[b1[i], b1[i_next], b2[i_next], b2[i]], + role="lateral_face", + tier=ImportanceTier.HELPFUL, + parent_solid=s_id, + opacity=0.25, + ) + prism_faces.append(f_lat.id) + + graph.solids[s_id] = VisSolid( + id=s_id, + type=s_type, + vertices=b1 + b2, + edges=prism_edges, + faces=prism_faces, + base_vertices=b1, + top_vertices=b2, + ) + + elif s_type == "tetrahedron": + pts = solid.get("points", []) + if len(pts) >= 4: + s_id = f"tetrahedron_{''.join(pts[:4])}" + tet_edges: List[str] = [] + tet_faces: List[str] = [] + + # All 6 edges + for i in range(4): + for j in range(i + 1, 4): + e = graph.add_edge( + p1=pts[i], + p2=pts[j], + role="edge", + tier=ImportanceTier.REQUIRED, + parent_solid=s_id, + ) + tet_edges.append(e.id) + + # 4 faces + f_defs = [ + [pts[1], pts[2], pts[3]], + [pts[0], pts[1], pts[2]], + [pts[0], pts[2], pts[3]], + [pts[0], pts[3], pts[1]], + ] + for fv in f_defs: + f = graph.add_face( + vertices=fv, + role="lateral_face", + tier=ImportanceTier.HELPFUL, + parent_solid=s_id, + opacity=0.2, + ) + tet_faces.append(f.id) + + graph.solids[s_id] = VisSolid( + id=s_id, + type="tetrahedron", + vertices=pts[:4], + edges=tet_edges, + faces=tet_faces, + base_vertices=pts[1:4], + ) + + # --------------------------------------------------------------------- + # 3. Derive 2D Polygon Perimeter Edges & Faces + # --------------------------------------------------------------------- + if not is_3d: + poly_pts = polygon_order if polygon_order else all_ids[:4] + if len(poly_pts) >= 3: + for i in range(len(poly_pts)): + p1 = poly_pts[i] + p2 = poly_pts[(i + 1) % len(poly_pts)] + graph.add_edge( + p1=p1, + p2=p2, + role="polygon_edge", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.PRIMARY, + ) + graph.add_face( + vertices=poly_pts, + role="polygon_face", + tier=ImportanceTier.HELPFUL, + kind=EntityKind.PRIMARY, + opacity=0.1, + ) + + # --------------------------------------------------------------------- + # 4. Add Explicit Segments from DSL + # --------------------------------------------------------------------- + for seg in segments_meta: + if len(seg) == 2: + p1, p2 = seg[0], seg[1] + graph.add_edge( + p1=p1, + p2=p2, + role="segment", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.PRIMARY, + ) + + # --------------------------------------------------------------------- + # 5. Derive Auxiliary Constructions & Solution Entities (P0 / P1) + # --------------------------------------------------------------------- + for c in constraints: + c_type = c.type + targets = [t.strip() for t in c.targets if isinstance(t, str)] + + # ------------------------------------------------------------- + # HEIGHT / ALTITUDE: HEIGHT(S, O, ABCD) + # ------------------------------------------------------------- + if c_type in ("height", "altitude") and len(targets) >= 2: + s_apex = targets[0] + o_foot = targets[1] + base_pts = targets[2:] + + if o_foot in graph.vertices: + graph.vertices[o_foot].role = "foot" + graph.vertices[o_foot].kind = EntityKind.AUXILIARY + elif o_foot in coords: + graph.add_vertex(o_foot, coords[o_foot], role="foot", kind=EntityKind.AUXILIARY) + + # Add Altitude Edge SO (Dashed in 3D interior) + edge_so = graph.add_edge( + p1=s_apex, + p2=o_foot, + role="altitude", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + ) + + # Ground the foot O: if base is square/rectangle, add diagonals AC & BD + created_diags = [] + if len(base_pts) >= 4: + e_ac = graph.add_edge( + p1=base_pts[0], + p2=base_pts[2], + role="diagonal", + tier=ImportanceTier.HELPFUL, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + ) + e_bd = graph.add_edge( + p1=base_pts[1], + p2=base_pts[3], + role="diagonal", + tier=ImportanceTier.HELPFUL, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + ) + created_diags.extend([e_ac.id, e_bd.id]) + elif len(base_pts) == 3: + # Triangular base: add median / altitude on base + e_base_aux = graph.add_edge( + p1=base_pts[0], + p2=o_foot, + role="projection", + tier=ImportanceTier.HELPFUL, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + ) + created_diags.append(e_base_aux.id) + + graph.auxiliary.append( + VisAuxiliaryConstruction( + id=f"height_{s_apex}_{o_foot}", + type="height", + source_entity=s_apex, + target_entity=o_foot, + created_vertices=[o_foot], + created_edges=[edge_so.id] + created_diags, + perpendicular_marks=[{"vertex": o_foot, "lines": [s_apex, base_pts[0] if base_pts else o_foot]}], + tier=ImportanceTier.REQUIRED, + ) + ) + + # ------------------------------------------------------------- + # FOOT OF PERPENDICULAR: FOOT(H, P, AB) + # ------------------------------------------------------------- + elif c_type in ("foot", "foot_perp") and len(targets) >= 3: + pH, pP = targets[0], targets[1] + pA = targets[2] + pB = targets[3] if len(targets) > 3 else "B" + + if pH in graph.vertices: + graph.vertices[pH].role = "foot" + graph.vertices[pH].kind = EntityKind.AUXILIARY + elif pH in coords: + graph.add_vertex(pH, coords[pH], role="foot", kind=EntityKind.AUXILIARY) + + e_ph = graph.add_edge( + p1=pP, + p2=pH, + role="projection", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + ) + graph.auxiliary.append( + VisAuxiliaryConstruction( + id=f"foot_{pH}_{pP}", + type="foot", + source_entity=pP, + target_entity=pH, + created_vertices=[pH], + created_edges=[e_ph.id], + perpendicular_marks=[{"vertex": pH, "lines": [pP, pA]}], + tier=ImportanceTier.REQUIRED, + ) + ) + + # ------------------------------------------------------------- + # MEDIAN: MEDIAN(A, M, BC) + # ------------------------------------------------------------- + elif c_type == "median" and len(targets) >= 2: + pA = targets[0] + pM = targets[1] + if pM in graph.vertices: + graph.vertices[pM].role = "midpoint" + graph.vertices[pM].kind = EntityKind.AUXILIARY + elif pM in coords: + graph.add_vertex(pM, coords[pM], role="midpoint", kind=EntityKind.AUXILIARY) + + e_am = graph.add_edge( + p1=pA, + p2=pM, + role="median", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.SOLID, + ) + graph.auxiliary.append( + VisAuxiliaryConstruction( + id=f"median_{pA}_{pM}", + type="median", + source_entity=pA, + target_entity=pM, + created_vertices=[pM], + created_edges=[e_am.id], + tier=ImportanceTier.REQUIRED, + ) + ) + + # ------------------------------------------------------------- + # BISECTOR: BISECTOR(A, D, BC) + # ------------------------------------------------------------- + elif c_type == "bisector" and len(targets) >= 2: + pA = targets[0] + pD = targets[1] + if pD in graph.vertices: + graph.vertices[pD].role = "bisector_point" + graph.vertices[pD].kind = EntityKind.AUXILIARY + elif pD in coords: + graph.add_vertex(pD, coords[pD], role="bisector_point", kind=EntityKind.AUXILIARY) + + e_ad = graph.add_edge( + p1=pA, + p2=pD, + role="bisector", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.SOLID, + ) + graph.auxiliary.append( + VisAuxiliaryConstruction( + id=f"bisector_{pA}_{pD}", + type="bisector", + source_entity=pA, + target_entity=pD, + created_vertices=[pD], + created_edges=[e_ad.id], + tier=ImportanceTier.REQUIRED, + ) + ) + + # ------------------------------------------------------------- + # MIDPOINT: MIDPOINT(M, AB) + # ------------------------------------------------------------- + elif c_type == "midpoint" and len(targets) == 3: + pM, pA, pB = targets[0], targets[1], targets[2] + if pM in graph.vertices: + graph.vertices[pM].role = "midpoint" + graph.vertices[pM].kind = EntityKind.AUXILIARY + elif pM in coords: + graph.add_vertex(pM, coords[pM], role="midpoint", kind=EntityKind.AUXILIARY) + + # ------------------------------------------------------------- + # CENTER: CENTER(O, ABCD) + # ------------------------------------------------------------- + elif c_type in ("center", "centroid") and len(targets) >= 3: + pO = targets[0] + poly_pts = targets[1:] + if pO in graph.vertices: + graph.vertices[pO].role = "center" + graph.vertices[pO].kind = EntityKind.AUXILIARY + elif pO in coords: + graph.add_vertex(pO, coords[pO], role="center", kind=EntityKind.AUXILIARY) + + # If 4 base points, draw diagonals to visually anchor center + if len(poly_pts) >= 4: + graph.add_edge( + p1=poly_pts[0], + p2=poly_pts[2], + role="diagonal", + tier=ImportanceTier.HELPFUL, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + ) + graph.add_edge( + p1=poly_pts[1], + p2=poly_pts[3], + role="diagonal", + tier=ImportanceTier.HELPFUL, + kind=EntityKind.AUXILIARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + ) + + # ------------------------------------------------------------- + # PERPENDICULAR TO PLANE: PERPENDICULAR_PLANE(SA, ABC) + # ------------------------------------------------------------- + elif c_type in ("perpendicular_plane", "perp_plane"): + # E.g. targets = ["S", "A", "A", "B", "C"] or ["SA", "ABC"] + line_pts: List[str] = [] + plane_pts: List[str] = [] + if len(targets) == 2: + line_pts = list(targets[0].strip()) + plane_pts = list(targets[1].strip()) + elif len(targets) >= 4: + line_pts = targets[:2] + plane_pts = targets[2:] + + if len(line_pts) >= 2: + p_apex, p_foot = line_pts[0], line_pts[1] + # Check if p_foot is in plane + if p_foot in graph.vertices: + graph.vertices[p_foot].role = "foot" + if p_apex in graph.vertices: + graph.vertices[p_apex].role = "apex" + + # Edge from apex to foot is an altitude + edge_alt = graph.add_edge( + p1=p_apex, + p2=p_foot, + role="altitude", + tier=ImportanceTier.REQUIRED, + kind=EntityKind.PRIMARY, + style=EdgeStyle.DASHED if is_3d else EdgeStyle.SOLID, + is_hidden=is_3d, + ) + + # Add perpendicular marks between (p_apex -> p_foot) and base edges incident to p_foot + base_neighbors = [p for p in plane_pts if p != p_foot] + for b_pt in base_neighbors[:2]: + p_mark = {"vertex": p_foot, "lines": [p_apex, b_pt]} + graph.perpendicular_marks.append(p_mark) + + graph.auxiliary.append( + VisAuxiliaryConstruction( + id=f"perp_plane_{p_apex}_{p_foot}", + type="height", + source_entity=p_apex, + target_entity=p_foot, + created_vertices=[p_foot], + created_edges=[edge_alt.id], + perpendicular_marks=[{"vertex": p_foot, "lines": [p_apex, b]} for b in base_neighbors[:2]], + tier=ImportanceTier.REQUIRED, + ) + ) + + # ------------------------------------------------------------- + # PERPENDICULAR / RIGHT ANGLE: PERPENDICULAR(AB, BC) or ANGLE(B, 90) + # ------------------------------------------------------------- + elif c_type in ("perpendicular", "perp", "right_angle"): + if len(targets) == 4: + p1, p2, p3, p4 = targets + common = set([p1, p2]).intersection([p3, p4]) + if common: + v = common.pop() + l1 = p2 if p1 == v else p1 + l2 = p4 if p3 == v else p3 + graph.perpendicular_marks.append({"vertex": v, "lines": [l1, l2]}) + else: + graph.perpendicular_marks.append({"vertex": p2, "lines": [p1, p4]}) + elif len(targets) == 3: + graph.perpendicular_marks.append({"vertex": targets[1], "lines": [targets[0], targets[2]]}) + + # ------------------------------------------------------------- + # ANGLE: ANGLE(B, 90) or ANGLE(A, B, C, 60) or ANGLE(B, 60) + # ------------------------------------------------------------- + elif c_type == "angle": + deg_val = getattr(c, "value", None) + if len(targets) == 1: + v_label = targets[0] + # Find neighbors in edges + adj = [] + for e in graph.edges.values(): + if e.source == v_label: adj.append(e.target) + elif e.target == v_label: adj.append(e.source) + if len(adj) >= 2: + if deg_val == 90 or (deg_val is not None and abs(deg_val - 90) < 1e-2): + graph.perpendicular_marks.append({"vertex": v_label, "lines": [adj[0], adj[1]]}) + elif deg_val is not None and deg_val > 0: + graph.angle_marks.append({ + "vertex": v_label, + "lines": [adj[0], adj[1]], + "degrees": deg_val, + "label": f"{int(deg_val) if deg_val == int(deg_val) else deg_val}°" + }) + elif len(targets) == 3: + p1, v_label, p2 = targets[0], targets[1], targets[2] + if deg_val == 90 or (deg_val is not None and abs(deg_val - 90) < 1e-2): + graph.perpendicular_marks.append({"vertex": v_label, "lines": [p1, p2]}) + elif deg_val is not None and deg_val > 0: + graph.angle_marks.append({ + "vertex": v_label, + "lines": [p1, p2], + "degrees": deg_val, + "label": f"{int(deg_val) if deg_val == int(deg_val) else deg_val}°" + }) + + # ------------------------------------------------------------- + # EQUAL LENGTH / EQUILATERAL: LENGTH_EQUAL(AB, CD) + # ------------------------------------------------------------- + elif c_type in ("length_equal", "equal_length") and len(targets) >= 4: + seg1 = [targets[0], targets[1]] + seg2 = [targets[2], targets[3]] + graph.equal_ticks.append({"segment": seg1, "ticks": 1}) + graph.equal_ticks.append({"segment": seg2, "ticks": 1}) + + # ------------------------------------------------------------- + # PARALLEL: PARALLEL(AB, CD) + # ------------------------------------------------------------- + elif c_type == "parallel" and len(targets) >= 4: + seg1 = [targets[0], targets[1]] + seg2 = [targets[2], targets[3]] + graph.parallel_marks.append({"segments": [seg1, seg2], "arrows": 1}) + + # --------------------------------------------------------------------- + # 6. Derive 3D Hidden vs Visible Edges (Canonical Perspective) + # --------------------------------------------------------------------- + if is_3d: + # Edges in the rear/interior of 3D solids are classified as DASHED + for e_id, edge in graph.edges.items(): + v1 = graph.vertices.get(edge.source) + v2 = graph.vertices.get(edge.target) + if v1 and v2 and len(v1.coordinates) >= 3 and len(v2.coordinates) >= 3: + # Interior altitude, projection, or diagonal + if edge.role in ("altitude", "projection", "diagonal"): + edge.style = EdgeStyle.DASHED + edge.is_hidden = True + + # Rear vertices in standard 3D coordinate system (A or D near y=0, z=0) + elif edge.role == "base_edge": + # If edge connects to A (when SA is altitude or A is back-left corner) + has_sa_alt = any(aux.type == "height" and (aux.target_entity == "A" or aux.source_entity == "A") for aux in graph.auxiliary) + if has_sa_alt: + if "A" in (v1.id, v2.id) and "S" not in (v1.id, v2.id): + edge.style = EdgeStyle.DASHED + edge.is_hidden = True + else: + # Standard rear-left edge (e.g. D connects to A and C in ABCD) + if (v1.id == "D" and v2.id in ("A", "C")) or (v1.id == "A" and v2.id == "D"): + edge.style = EdgeStyle.DASHED + edge.is_hidden = True + + # --------------------------------------------------------------------- + # 7. Construct Minimal Sufficient Drawing Phases + # --------------------------------------------------------------------- + # Phase 1: Base geometry and primary solid edges + primary_pts = [vid for vid, v in graph.vertices.items() if v.kind == EntityKind.PRIMARY] + primary_edges = [ + [e.source, e.target] + for e in graph.edges.values() + if e.kind == EntityKind.PRIMARY and e.tier == ImportanceTier.REQUIRED + ] + + graph.drawing_phases.append({ + "phase": 1, + "label": "Hình cơ bản", + "points": primary_pts, + "segments": primary_edges, + }) + + # Phase 2: Auxiliary constructions (Heights, Medians, Projections, Diagonals) + aux_pts = [vid for vid, v in graph.vertices.items() if v.kind != EntityKind.PRIMARY] + aux_edges = [ + [e.source, e.target] + for e in graph.edges.values() + if e.kind != EntityKind.PRIMARY or e.role in ("altitude", "projection", "median", "bisector", "diagonal") + ] + + if aux_pts or aux_edges: + graph.drawing_phases.append({ + "phase": 2, + "label": "Đường cao và yếu tố phụ", + "points": aux_pts, + "segments": aux_edges, + }) + + logger.info( + f"[VisualizationPlanner] Planned Visualization Graph: " + f"{len(graph.vertices)} vertices, {len(graph.edges)} edges, " + f"{len(graph.faces)} faces, {len(graph.solids)} solids, " + f"{len(graph.auxiliary)} auxiliary constructions, " + f"{len(graph.perpendicular_marks)} right-angle marks, " + f"{len(graph.angle_marks)} angle arcs." + ) + + return graph diff --git a/test_json_fix.py b/test_json_fix.py new file mode 100644 index 0000000000000000000000000000000000000000..56d03a78b73ac2220a73168c6051b1143d8e9db6 --- /dev/null +++ b/test_json_fix.py @@ -0,0 +1,42 @@ +import json +import re +import ast + +def robust_json_decode(text: str) -> dict: + clean = text.strip() + if clean.startswith("```"): + m = re.search(r"```(?:json)?\s*(.*?)\s*```", clean, re.DOTALL) + if m: + clean = m.group(1).strip() + + try: + return json.loads(clean, strict=False) + except Exception: + pass + + # Escape raw backslashes + try: + # Replace unescaped backslashes + fixed = re.sub(r'\\(?![/\"\\bfnrtu]|u[0-9a-fA-F]{4})', r'\\\\', clean) + return json.loads(fixed, strict=False) + except Exception: + pass + + # Try regex fallback extraction + steps = re.findall(r'"([^"]*Bước[^"]*)"', clean) + if not steps: + steps = re.findall(r'"([^"]+)"', clean) + steps = [s for s in steps if any(kw in s.lower() for kw in ['diện tích', 'thể tích', 'chiều cao', 'công thức', 'bước', 'ta có', '='])] + + ans_m = re.search(r'"final_answer"\s*:\s*"?([^"}\n]+)"?', clean) + return { + "step_by_step_solution": steps, + "final_answer": ans_m.group(1).strip() if ans_m else "" + } + +test_str = r'{"step_by_step_solution": ["Bước 1: S = \frac{6^2\sqrt{3}}{4} = 9\sqrt{3}", "Bước 2: SO = 8", "Bước 3: V = \frac{1}{3} \times 9\sqrt{3} \times 8 = 24\sqrt{3}"], "final_answer": "24\sqrt{3}"}' + +d = robust_json_decode(test_str) +print("Decoded steps count:", len(d["step_by_step_solution"])) +print("Decoded step 1:", d["step_by_step_solution"][0]) +print("Decoded final_answer:", d["final_answer"]) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e2d5690edb05a1c8c1a9a8d9d62947b83f249347 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Backend test package (enables ``tests.cases`` imports for pytest).""" diff --git a/tests/cases/__init__.py b/tests/cases/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..355cb06e638eaa24f3f4fd8086bed5f02c78906b --- /dev/null +++ b/tests/cases/__init__.py @@ -0,0 +1 @@ +"""Shared test case definitions for API and orchestrator suites.""" diff --git a/tests/cases/pipeline_cases.py b/tests/cases/pipeline_cases.py new file mode 100644 index 0000000000000000000000000000000000000000..d32551fbe1f919d2f53dc6b1116f7ab5156615e6 --- /dev/null +++ b/tests/cases/pipeline_cases.py @@ -0,0 +1,127 @@ +"""Shared geometry pipeline test cases (single-session, multi-turn).""" + +from __future__ import annotations + +from typing import Any + +QUERIES: list[dict[str, Any]] = [ + { + "id": "Q1", + "text": "Cho hình chữ nhật ABCD có AB bằng 5 và AD bằng 10", + "expect_pts": ["A", "B", "C", "D"], + "expect_phases": 1, + }, + { + "id": "Q2", + "text": "Tam giác ABC có AB=6, BC=8, AC=10", + "expect_pts": ["A", "B", "C"], + "expect_phases": 1, + }, + { + "id": "Q3", + "text": "Cho hình chữ nhật ABCD có AB=10 và AD=20. Gọi M là trung điểm của cạnh AB.", + "expect_pts": ["A", "B", "C", "D", "M"], + "expect_phases": 2, + }, + { + "id": "Q4", + "text": "Cho hình thang ABCD vuông tại A và D. AB=4, CD=8, AD=5.", + "expect_pts": ["A", "B", "C", "D"], + "expect_phases": 1, + }, + { + "id": "Q5", + "text": "Cho hình vuông ABCD có cạnh bằng 6.", + "expect_pts": ["A", "B", "C", "D"], + "expect_phases": 1, + }, + { + "id": "Q6", + "text": "Cho tam giác ABC vuông tại A. AB=3, AC=4. Vẽ đường cao AH.", + "expect_pts": ["A", "B", "C", "H"], + "expect_phases": 2, + }, + { + "id": "Q7", + "text": "Cho hình thoi ABCD có cạnh bằng 5 và góc A bằng 60 độ.", + "expect_pts": ["A", "B", "C", "D"], + "expect_phases": 1, + }, + { + "id": "Q8", + "text": "Cho đường tròn tâm O bán kính bằng 7.", + "expect_pts": ["O"], + "expect_phases": 1, + }, + { + "id": "Q9", + "text": "Cho hình bình hành ABCD có AB=8, AD=6. Gọi E là trung điểm của CD. Vẽ đoạn thẳng AE.", + "expect_pts": ["A", "B", "C", "D", "E"], + "expect_phases": 2, + }, + { + "id": "Q10-Step1", + "text": "Cho hình chữ nhật ABCD có AB=10, AD=5.", + "expect_pts": ["A", "B", "C", "D"], + "expect_phases": 1, + }, + { + "id": "Q11-Video", + "text": "Cho tam giác ABC đều cạnh 5. Vẽ đường tròn ngoại tiếp tam giác.", + "expect_pts": ["A", "B", "C"], + "expect_phases": 2, + "request_video": True, + }, + { + "id": "Q12-3D", + "text": "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10, đường cao SO=15 với O là tâm đáy.", + "expect_pts": ["S", "A", "B", "C", "D", "O"], + "expect_phases": 2, + }, +] + +Q10_FOLLOW_UP: dict[str, Any] = { + "id": "Q10-Step2", + "text": "Vẽ thêm đường chéo AC.", + "expect_pts": ["A", "B", "C", "D"], + "expect_phases": 2, +} + +# Second multi-turn flow: follow-up depends on prior triangle definition in the same session. +Q13_HISTORY_STEPS: list[dict[str, Any]] = [ + { + "id": "Q13-Step1", + "text": "Cho tam giác ABC với AB=5, BC=6, AC=7.", + "expect_pts": ["A", "B", "C"], + "expect_phases": 1, + }, + { + "id": "Q13-Step2", + "text": "Tính diện tích tam giác ABC (dùng các cạnh đã nêu ở trên).", + "expect_pts": ["A", "B", "C"], + "expect_phases": 1, + }, +] + + +def validate_q10_step2_dsl(dsl: str) -> bool: + """Multi-turn rectangle + diagonal: merged DSL should still describe polygon and diagonal.""" + if not dsl: + return False + return "POLYGON_ORDER" in dsl and "SEGMENT" in dsl + + +def validate_query_result(q: dict[str, Any], result_data: dict[str, Any]) -> list[str]: + """Return list of validation error strings (empty if pass).""" + errors: list[str] = [] + coords = result_data.get("coordinates", {}) or {} + for pt in q.get("expect_pts", []): + if pt not in coords: + errors.append(f"Missing point {pt}") + if coords and len(coords) > 1 and all(v == [0, 0, 0] for v in coords.values()): + errors.append("All points are at [0,0,0]") + phases = result_data.get("drawing_phases", []) or [] + min_phases = int(q.get("expect_phases", 1)) + if len(phases) < min_phases: + errors.append(f"Expected {min_phases} phases, got {len(phases)}") + return errors diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..855b0e6f604cbb8986b1287cf8955acce852c2d1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Load backend/.env for all pytest runs so integration tests see credentials.""" + +from __future__ import annotations + +import os + +import pytest + + +def pytest_configure(config: pytest.Config) -> None: + try: + from dotenv import load_dotenv + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + load_dotenv(os.path.join(root, ".env"), override=False) + except Exception: + pass diff --git a/tests/data/description.md b/tests/data/description.md new file mode 100644 index 0000000000000000000000000000000000000000..6217b4a034e0be285f26aad84882bc2d45f2cc8b --- /dev/null +++ b/tests/data/description.md @@ -0,0 +1,235 @@ +## TC-2D-Easy — Tam giác và đường cao + +**Đề bài trên ảnh:** + +> Cho tam giác \(ABC\) vuông tại \(A\), biết \(AB=6\), \(AC=8\). +> Gọi \(H\) là chân đường cao từ \(A\) xuống \(BC\). +> Tính \(BC\), \(AH\) và diện tích tam giác \(ABC\). + +**Hình cần vẽ:** + +* Tam giác \(ABC\) +* \(AB \perp AC\) +* Đường cao \(AH\) +* Điểm \(H\in BC\) +* Ký hiệu góc vuông tại \(A\) +* Label: \(6,8,BC,AH\) + +**Expected:** + +$$ +BC=10 +$$ + +$$ +AH=\frac{AB\cdot AC}{BC}=4.8 +$$ + +$$ +S_{ABC}=\frac12\cdot6\cdot8=24 +$$ + +**Khoảng 3–4 bước animation.** + +→ Đây là baseline để kiểm tra pipeline **OCR → parse → geometry → render** mà không tạo quá nhiều nhiễu. + +--- + +## TC-3D-Easy — Hình hộp chữ nhật + +**Đề bài trên ảnh:** + +> Cho hình hộp chữ nhật \(ABCD.A'B'C'D'\) có +> \(AB=4,\ AD=3,\ AA'=5\). +> Tính độ dài đường chéo \(AC'\). + +**Hình cần vẽ:** + +* Hình hộp chữ nhật +* 8 đỉnh \(A,B,C,D,A',B',C',D'\) +* Các cạnh +* Đường chéo không gian \(AC'\) +* Label \(4,3,5\) +* Đường khuất biểu diễn bằng nét đứt + +**Expected:** + +$$ +AC'=\sqrt{AB^2+AD^2+AA'^2} +$$ + +$$ +AC'=\sqrt{4^2+3^2+5^2}=5\sqrt2 +$$ + +**Khoảng 3–4 bước animation.** + +→ Test khả năng chuyển từ mô tả toán học sang **3D Geometry DSL**, đúng với phạm vi 3D/Oxyz của proposal. + +--- + +# TC-2D-Hard — Đường tròn, tiếp tuyến và hình trong hình + +Đây nên là **case stress-test OCR 2D chính**. + +**Đề bài trên ảnh:** + +> Cho đường tròn \((O)\) có đường kính \(AB\). +> Lấy điểm \(C\in (O)\), \(C\ne A,B\). Tiếp tuyến tại \(A\) và \(C\) cắt nhau tại \(M\). +> Gọi \(H\) là hình chiếu vuông góc của \(C\) lên \(AB\), \(N\) là giao điểm của \(CM\) và \(AB\). +> Chứng minh rằng +> +> $$ +> MA^2=MH\cdot MN +> $$ +> +> và +> +> $$ +> \angle AMC=2\angle ABC. +> $$ + +### Hình phải chứa + +* Đường tròn \((O)\) +* Đường kính \(AB\) +* Điểm \(C\) trên đường tròn +* Tiếp tuyến tại \(A\) +* Tiếp tuyến tại \(C\) +* Điểm \(M\) +* \(CM\) +* \(CH\perp AB\) +* \(H\in AB\) +* \(N=CM\cap AB\) +* Các góc được đánh dấu +* Ký hiệu: + + * \((O)\) + * \(\perp\) + * \(MA^2\) + * \(\angle AMC\) + * \(\angle ABC\) + * \(H,N,M,O,A,B,C\) + +### Vì sao case này khó? + +Nó tạo ra **nhiều lớp thông tin không gian trong cùng một hình** + +Thực tế hình sẽ phức tạp hơn vì có **đường tròn + tiếp tuyến + tam giác + đường vuông góc + giao điểm + nhiều label**. + +Pipeline phải đồng thời nhận ra: + +**text → LaTeX → geometric entities → spatial relations → construction order.** + +Đặc biệt, OCR phải phân biệt được: + +$$ +(O),\quad \perp,\quad \angle,\quad ^2,\quad H,N,O +$$ + +→ Đây là case rất tốt để kiểm tra **Math OCR + Problem Parser**, thay vì chỉ kiểm tra khả năng đọc text. Proposal cũng xác định Math OCR phải xử lý đồng thời chữ, công thức và ký hiệu toán. + +--- + +# TC-3D-Hard — Hình chóp, mặt phẳng và hình chiếu + +Đây nên là **case khó nhất toàn bộ benchmark**. + +**Đề bài trên ảnh:** + +> Cho hình chóp \(S.ABCD\) có đáy \(ABCD\) là hình vuông cạnh \(a\), +> \(SA\perp(ABCD)\), \(SA=a\). +> Gọi \(M,N\) lần lượt là trung điểm của \(AB,CD\). +> Gọi \(H\) là hình chiếu vuông góc của \(A\) lên \(SM\). +> +> 1. Xác định giao tuyến của hai mặt phẳng \((SMN)\) và \((SAD)\). +> 2. Tính khoảng cách từ \(A\) đến đường thẳng \(SM\). +> 3. Tính góc giữa \(SM\) và mặt phẳng \((ABCD)\). + +### Hình phải chứa + +**Khối chính:** + +* Hình chóp \(S.ABCD\) +* Hình vuông đáy \(ABCD\) +* Các cạnh bên \(SA,SB,SC,SD\) + +**Các đối tượng phụ:** + +* \(M\in AB\) +* \(N\in CD\) +* \(SM\) +* \(SN\) +* \(H\in SM\) +* \(AH\perp SM\) +* Mặt phẳng \((SMN)\) +* Mặt phẳng \((SAD)\) + +**Ký hiệu toán học:** + +$$ +SA\perp(ABCD) +$$ + +$$ +AB=BC=CD=DA=a +$$ + +$$ +M\in AB,\qquad N\in CD +$$ + +$$ +AH\perp SM +$$ + +$$ +d(A,SM) +$$ + +$$ +\widehat{(SM,(ABCD))} +$$ + +### Điểm khó + +Case này ép hệ thống phải xử lý **nhiều tầng hình học**. Nó phải tạo lần lượt: + +1. Đáy \(ABCD\) +2. Đỉnh \(S\) +3. Các cạnh bên +4. \(M,N\) +5. \(SM,SN\) +6. \(H\) +7. \(AH\) +8. Các ký hiệu vuông góc +9. Các mặt phẳng cần xét +10. Highlight các đối tượng phục vụ từng câu hỏi + +→ **~8–12 animation steps**. + +--- + +# Bộ test cuối cùng + +Tôi sẽ chốt benchmark thành: + +### 🟢 Easy + +**TC-2D-Easy:** +**Tam giác vuông + đường cao** +→ ít đối tượng, 3–4 bước. + +**TC-3D-Easy:** +**Hình hộp chữ nhật + đường chéo không gian** +→ một solid đơn giản, 3–4 bước. + +### 🔴 Hard + +**TC-2D-Hard:** +**Đường tròn + tiếp tuyến + hình chiếu + nhiều giao điểm** +→ stress **OCR ký hiệu + hình trong hình + spatial relation**. + +**TC-3D-Hard:** +**Hình chóp + mặt phẳng + hình chiếu + góc + khoảng cách** +→ stress **3D parsing + construction + multi-step rendering**. diff --git a/tests/data/manim_io_specs.json b/tests/data/manim_io_specs.json new file mode 100644 index 0000000000000000000000000000000000000000..9a44b847ef9020a53bd7ba05894bcec3400b089d --- /dev/null +++ b/tests/data/manim_io_specs.json @@ -0,0 +1,727 @@ +{ + "case_1": { + "spec": { + "problem": "Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. Chiều cao SO vuông góc với đáy tại tâm O, SO=15. Tính thể tích khối chóp S.ABCD.", + "solution_steps": [ + "Bước 1: Tính diện tích đáy ABCD: S_ABCD = a^2 = 10^2 = 100.", + "Bước 2: Xác định chiều cao khối chóp: SO = 15.", + "Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABCD * SO = (1/3) * 100 * 15 = 500." + ], + "geometry": [ + { + "type": "point_3d", + "label": "S", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 15.0 + ] + } + }, + { + "type": "point_3d", + "label": "A", + "properties": { + "coordinates": [ + -5.0, + -5.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "B", + "properties": { + "coordinates": [ + 5.0, + -5.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "C", + "properties": { + "coordinates": [ + 5.0, + 5.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "D", + "properties": { + "coordinates": [ + -5.0, + 5.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "O", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + { + "type": "pyramid", + "label": "pyramid_S_A_B_C_D", + "properties": { + "type": "pyramid", + "apex": "S", + "base": [ + "A", + "B", + "C", + "D" + ], + "points": [ + "S", + "A", + "B", + "C", + "D" + ] + } + } + ], + "animations": [ + { + "action": "draw", + "targets": [ + "A", + "B", + "C", + "D", + "AB", + "BC", + "CD", + "DA" + ], + "narration": "Dựng Hình cơ bản: A, B, C, D.", + "duration_hint": 2.5 + }, + { + "action": "draw", + "targets": [ + "S", + "O", + "SA", + "SB", + "SC", + "SD" + ], + "narration": "Dựng Điểm và đoạn phụ: S, O.", + "duration_hint": 2.5 + }, + { + "action": "rotate_camera", + "targets": [ + "scene_3d" + ], + "narration": "Quan sát khối đa diện trong không gian 3 chiều.", + "duration_hint": 3.0 + }, + { + "action": "write", + "targets": [ + "step_1" + ], + "narration": "Bước 1: Tính diện tích đáy ABCD: S_ABCD = a^2 = 10^2 = 100.", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_2" + ], + "narration": "Bước 2: Xác định chiều cao khối chóp: SO = 15.", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_3" + ], + "narration": "Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABCD * SO = (1/3) * 100 * 15 = 500.", + "duration_hint": 3.5 + } + ], + "output_config": { + "quality": "720p", + "format": "mp4", + "language": "vi" + } + }, + "prompt": "Chủ đề / Đề bài: Cho hình chóp S.ABCD có đáy ABCD là hình vuông cạnh 10. Chiều cao SO vuông góc với đáy tại tâm O, SO=15. Tính thể tích khối chóp S.ABCD.\nCác bước giải thích / chứng minh chi tiết:\n 1. Bước 1: Tính diện tích đáy ABCD: S_ABCD = a^2 = 10^2 = 100.\n 2. Bước 2: Xác định chiều cao khối chóp: SO = 15.\n 3. Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABCD * SO = (1/3) * 100 * 15 = 500.\nCác đối tượng hình học / tọa độ giải được:\n - point_3d (S) - thuộc tính: {'coordinates': [0.0, 0.0, 15.0]}\n - point_3d (A) - thuộc tính: {'coordinates': [-5.0, -5.0, 0.0]}\n - point_3d (B) - thuộc tính: {'coordinates': [5.0, -5.0, 0.0]}\n - point_3d (C) - thuộc tính: {'coordinates': [5.0, 5.0, 0.0]}\n - point_3d (D) - thuộc tính: {'coordinates': [-5.0, 5.0, 0.0]}\n - point_3d (O) - thuộc tính: {'coordinates': [0.0, 0.0, 0.0]}\n - pyramid (pyramid_S_A_B_C_D) - thuộc tính: {'type': 'pyramid', 'apex': 'S', 'base': ['A', 'B', 'C', 'D'], 'points': ['S', 'A', 'B', 'C', 'D']}\nChỉ dẫn hoạt họa (animation beats):\n - Beat 1: draw [đối tượng: A, B, C, D, AB, BC, CD, DA] | Lời thoại: 'Dựng Hình cơ bản: A, B, C, D.'\n - Beat 2: draw [đối tượng: S, O, SA, SB, SC, SD] | Lời thoại: 'Dựng Điểm và đoạn phụ: S, O.'\n - Beat 3: rotate_camera [đối tượng: scene_3d] | Lời thoại: 'Quan sát khối đa diện trong không gian 3 chiều.'\n - Beat 4: write [đối tượng: step_1] | Lời thoại: 'Bước 1: Tính diện tích đáy ABCD: S_ABCD = a^2 = 10^2 = 100.'\n - Beat 5: write [đối tượng: step_2] | Lời thoại: 'Bước 2: Xác định chiều cao khối chóp: SO = 15.'\n - Beat 6: write [đối tượng: step_3] | Lời thoại: 'Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABCD * SO = (1/3) * 100 * 15 = 500.'" + }, + "case_2": { + "spec": { + "problem": "Cho hình chóp tam giác đều S.ABC có cạnh đáy bằng 6, chiều cao SO = 8 vuông góc với đáy tại trọng tâm O của tam giác ABC. Tính thể tích khối chóp S.ABC.", + "solution_steps": [ + "Bước 1: Tính diện tích đáy tam giác đều ABC: S_ABC = (a^2 * sqrt(3)) / 4 = (6^2 * sqrt(3)) / 4 = 9*sqrt(3).", + "Bước 2: Xác định chiều cao SO = 8.", + "Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABC * SO = (1/3) * 9*sqrt(3) * 8 = 24*sqrt(3)." + ], + "geometry": [ + { + "type": "point_3d", + "label": "S", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 8.0 + ] + } + }, + { + "type": "point_3d", + "label": "A", + "properties": { + "coordinates": [ + -3.0, + -1.732, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "B", + "properties": { + "coordinates": [ + 3.0, + -1.732, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "C", + "properties": { + "coordinates": [ + 0.0, + 3.464, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "O", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + { + "type": "pyramid", + "label": "pyramid_S_A_B_C", + "properties": { + "type": "pyramid", + "apex": "S", + "base": [ + "A", + "B", + "C" + ], + "points": [ + "S", + "A", + "B", + "C" + ] + } + } + ], + "animations": [ + { + "action": "draw", + "targets": [ + "A", + "B", + "C", + "AB", + "BC", + "CA" + ], + "narration": "Dựng Hình cơ bản: A, B, C.", + "duration_hint": 2.5 + }, + { + "action": "draw", + "targets": [ + "S", + "O", + "SA", + "SB", + "SC" + ], + "narration": "Dựng Điểm và đoạn phụ: S, O.", + "duration_hint": 2.5 + }, + { + "action": "rotate_camera", + "targets": [ + "scene_3d" + ], + "narration": "Quan sát khối đa diện trong không gian 3 chiều.", + "duration_hint": 3.0 + }, + { + "action": "write", + "targets": [ + "step_1" + ], + "narration": "Bước 1: Tính diện tích đáy tam giác đều ABC: S_ABC = (a^2 * sqrt(3)) / 4 = (6^2 * sqrt(3)) / 4 = 9*sqrt(3).", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_2" + ], + "narration": "Bước 2: Xác định chiều cao SO = 8.", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_3" + ], + "narration": "Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABC * SO = (1/3) * 9*sqrt(3) * 8 = 24*sqrt(3).", + "duration_hint": 3.5 + } + ], + "output_config": { + "quality": "720p", + "format": "mp4", + "language": "vi" + } + }, + "prompt": "Chủ đề / Đề bài: Cho hình chóp tam giác đều S.ABC có cạnh đáy bằng 6, chiều cao SO = 8 vuông góc với đáy tại trọng tâm O của tam giác ABC. Tính thể tích khối chóp S.ABC.\nCác bước giải thích / chứng minh chi tiết:\n 1. Bước 1: Tính diện tích đáy tam giác đều ABC: S_ABC = (a^2 * sqrt(3)) / 4 = (6^2 * sqrt(3)) / 4 = 9*sqrt(3).\n 2. Bước 2: Xác định chiều cao SO = 8.\n 3. Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABC * SO = (1/3) * 9*sqrt(3) * 8 = 24*sqrt(3).\nCác đối tượng hình học / tọa độ giải được:\n - point_3d (S) - thuộc tính: {'coordinates': [0.0, 0.0, 8.0]}\n - point_3d (A) - thuộc tính: {'coordinates': [-3.0, -1.732, 0.0]}\n - point_3d (B) - thuộc tính: {'coordinates': [3.0, -1.732, 0.0]}\n - point_3d (C) - thuộc tính: {'coordinates': [0.0, 3.464, 0.0]}\n - point_3d (O) - thuộc tính: {'coordinates': [0.0, 0.0, 0.0]}\n - pyramid (pyramid_S_A_B_C) - thuộc tính: {'type': 'pyramid', 'apex': 'S', 'base': ['A', 'B', 'C'], 'points': ['S', 'A', 'B', 'C']}\nChỉ dẫn hoạt họa (animation beats):\n - Beat 1: draw [đối tượng: A, B, C, AB, BC, CA] | Lời thoại: 'Dựng Hình cơ bản: A, B, C.'\n - Beat 2: draw [đối tượng: S, O, SA, SB, SC] | Lời thoại: 'Dựng Điểm và đoạn phụ: S, O.'\n - Beat 3: rotate_camera [đối tượng: scene_3d] | Lời thoại: 'Quan sát khối đa diện trong không gian 3 chiều.'\n - Beat 4: write [đối tượng: step_1] | Lời thoại: 'Bước 1: Tính diện tích đáy tam giác đều ABC: S_ABC = (a^2 * sqrt(3)) / 4 = (6^2 * sqrt(3)) / 4 = 9*sqrt(3).'\n - Beat 5: write [đối tượng: step_2] | Lời thoại: 'Bước 2: Xác định chiều cao SO = 8.'\n - Beat 6: write [đối tượng: step_3] | Lời thoại: 'Bước 3: Áp dụng công thức thể tích khối chóp: V = (1/3) * S_ABC * SO = (1/3) * 9*sqrt(3) * 8 = 24*sqrt(3).'" + }, + "case_3": { + "spec": { + "problem": "Cho hình chóp cụt tứ giác đều ABCD.A1B1C1D1 có cạnh đáy dưới bằng 8, cạnh đáy trên bằng 4, chiều cao giữa hai đáy h=6. Tính thể tích khối chóp cụt.", + "solution_steps": [ + "Bước 1: Tính diện tích đáy dưới S1 = a^2 = 8^2 = 64.", + "Bước 2: Tính diện tích đáy trên S2 = a1^2 = 4^2 = 16.", + "Bước 3: Áp dụng công thức thể tích khối chóp cụt: V = (1/3) * h * (S1 + S2 + sqrt(S1 * S2)) = (1/3) * 6 * (64 + 16 + sqrt(64 * 16)) = 224." + ], + "geometry": [ + { + "type": "point_3d", + "label": "A", + "properties": { + "coordinates": [ + -4.0, + -4.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "B", + "properties": { + "coordinates": [ + 4.0, + -4.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "C", + "properties": { + "coordinates": [ + 4.0, + 4.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "D", + "properties": { + "coordinates": [ + -4.0, + 4.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "A1", + "properties": { + "coordinates": [ + -2.0, + -2.0, + 6.0 + ] + } + }, + { + "type": "point_3d", + "label": "B1", + "properties": { + "coordinates": [ + 2.0, + -2.0, + 6.0 + ] + } + }, + { + "type": "point_3d", + "label": "C1", + "properties": { + "coordinates": [ + 2.0, + 2.0, + 6.0 + ] + } + }, + { + "type": "point_3d", + "label": "D1", + "properties": { + "coordinates": [ + -2.0, + 2.0, + 6.0 + ] + } + }, + { + "type": "point_3d", + "label": "O", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "O1", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 6.0 + ] + } + }, + { + "type": "frustum", + "label": "frustum_A_B_C_D_A1_B1_C1_D1", + "properties": { + "type": "frustum", + "points": [ + "A", + "B", + "C", + "D", + "A1", + "B1", + "C1", + "D1" + ] + } + } + ], + "animations": [ + { + "action": "draw", + "targets": [ + "A", + "B", + "C", + "D", + "AB", + "BC", + "CD", + "DA" + ], + "narration": "Dựng Đáy dưới: A, B, C, D.", + "duration_hint": 2.5 + }, + { + "action": "draw", + "targets": [ + "A1", + "B1", + "C1", + "D1", + "O", + "O1", + "A1B1", + "B1C1", + "C1D1", + "D1A1", + "AA1", + "BB1", + "CC1", + "DD1", + "OO1" + ], + "narration": "Dựng Đáy trên và cạnh bên: A1, B1, C1, D1, O, O1.", + "duration_hint": 2.5 + }, + { + "action": "rotate_camera", + "targets": [ + "scene_3d" + ], + "narration": "Quan sát khối đa diện trong không gian 3 chiều.", + "duration_hint": 3.0 + }, + { + "action": "write", + "targets": [ + "step_1" + ], + "narration": "Bước 1: Tính diện tích đáy dưới S1 = a^2 = 8^2 = 64.", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_2" + ], + "narration": "Bước 2: Tính diện tích đáy trên S2 = a1^2 = 4^2 = 16.", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_3" + ], + "narration": "Bước 3: Áp dụng công thức thể tích khối chóp cụt: V = (1/3) * h * (S1 + S2 + sqrt(S1 * S2)) = (1/3) * 6 * (64 + 16 + sqrt(64 * 16)) = 224.", + "duration_hint": 3.5 + } + ], + "output_config": { + "quality": "720p", + "format": "mp4", + "language": "vi" + } + }, + "prompt": "Chủ đề / Đề bài: Cho hình chóp cụt tứ giác đều ABCD.A1B1C1D1 có cạnh đáy dưới bằng 8, cạnh đáy trên bằng 4, chiều cao giữa hai đáy h=6. Tính thể tích khối chóp cụt.\nCác bước giải thích / chứng minh chi tiết:\n 1. Bước 1: Tính diện tích đáy dưới S1 = a^2 = 8^2 = 64.\n 2. Bước 2: Tính diện tích đáy trên S2 = a1^2 = 4^2 = 16.\n 3. Bước 3: Áp dụng công thức thể tích khối chóp cụt: V = (1/3) * h * (S1 + S2 + sqrt(S1 * S2)) = (1/3) * 6 * (64 + 16 + sqrt(64 * 16)) = 224.\nCác đối tượng hình học / tọa độ giải được:\n - point_3d (A) - thuộc tính: {'coordinates': [-4.0, -4.0, 0.0]}\n - point_3d (B) - thuộc tính: {'coordinates': [4.0, -4.0, 0.0]}\n - point_3d (C) - thuộc tính: {'coordinates': [4.0, 4.0, 0.0]}\n - point_3d (D) - thuộc tính: {'coordinates': [-4.0, 4.0, 0.0]}\n - point_3d (A1) - thuộc tính: {'coordinates': [-2.0, -2.0, 6.0]}\n - point_3d (B1) - thuộc tính: {'coordinates': [2.0, -2.0, 6.0]}\n - point_3d (C1) - thuộc tính: {'coordinates': [2.0, 2.0, 6.0]}\n - point_3d (D1) - thuộc tính: {'coordinates': [-2.0, 2.0, 6.0]}\n - point_3d (O) - thuộc tính: {'coordinates': [0.0, 0.0, 0.0]}\n - point_3d (O1) - thuộc tính: {'coordinates': [0.0, 0.0, 6.0]}\n - frustum (frustum_A_B_C_D_A1_B1_C1_D1) - thuộc tính: {'type': 'frustum', 'points': ['A', 'B', 'C', 'D', 'A1', 'B1', 'C1', 'D1']}\nChỉ dẫn hoạt họa (animation beats):\n - Beat 1: draw [đối tượng: A, B, C, D, AB, BC, CD, DA] | Lời thoại: 'Dựng Đáy dưới: A, B, C, D.'\n - Beat 2: draw [đối tượng: A1, B1, C1, D1, O, O1, A1B1, B1C1, C1D1, D1A1, AA1, BB1, CC1, DD1, OO1] | Lời thoại: 'Dựng Đáy trên và cạnh bên: A1, B1, C1, D1, O, O1.'\n - Beat 3: rotate_camera [đối tượng: scene_3d] | Lời thoại: 'Quan sát khối đa diện trong không gian 3 chiều.'\n - Beat 4: write [đối tượng: step_1] | Lời thoại: 'Bước 1: Tính diện tích đáy dưới S1 = a^2 = 8^2 = 64.'\n - Beat 5: write [đối tượng: step_2] | Lời thoại: 'Bước 2: Tính diện tích đáy trên S2 = a1^2 = 4^2 = 16.'\n - Beat 6: write [đối tượng: step_3] | Lời thoại: 'Bước 3: Áp dụng công thức thể tích khối chóp cụt: V = (1/3) * h * (S1 + S2 + sqrt(S1 * S2)) = (1/3) * 6 * (64 + 16 + sqrt(64 * 16)) = 224.'" + }, + "case_4": { + "spec": { + "problem": "Cho hình hộp chữ nhật ABCD.A'B'C'D' có AB=4, AD=3, AA'=5. Tính độ dài đường chéo AC'.", + "solution_steps": [ + "Bước 1: Tính bình phương độ dài đường chéo đáy: AC^2 = AB^2 + AD^2 = 4^2 + 3^2 = 25.", + "Bước 2: Áp dụng định lý Pythagoras trong tam giác vuông ACC': AC'^2 = AC^2 + CC'^2 = 25 + 5^2 = 50.", + "Bước 3: Tính độ dài đường chéo không gian: AC' = sqrt(50) = 5*sqrt(2) ≈ 7.07." + ], + "geometry": [ + { + "type": "point_3d", + "label": "A", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "B", + "properties": { + "coordinates": [ + 4.0, + 0.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "C", + "properties": { + "coordinates": [ + 4.0, + 3.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "D", + "properties": { + "coordinates": [ + 0.0, + 3.0, + 0.0 + ] + } + }, + { + "type": "point_3d", + "label": "A_prime", + "properties": { + "coordinates": [ + 0.0, + 0.0, + 5.0 + ] + } + }, + { + "type": "point_3d", + "label": "B_prime", + "properties": { + "coordinates": [ + 4.0, + 0.0, + 5.0 + ] + } + }, + { + "type": "point_3d", + "label": "C_prime", + "properties": { + "coordinates": [ + 4.0, + 3.0, + 5.0 + ] + } + }, + { + "type": "point_3d", + "label": "D_prime", + "properties": { + "coordinates": [ + 0.0, + 3.0, + 5.0 + ] + } + }, + { + "type": "cuboid", + "label": "cuboid_A_B_C_D_A_prime_B_prime_C_prime_D_prime", + "properties": { + "type": "cuboid", + "points": [ + "A", + "B", + "C", + "D", + "A_prime", + "B_prime", + "C_prime", + "D_prime" + ] + } + } + ], + "animations": [ + { + "action": "draw", + "targets": [ + "A", + "B", + "C", + "D", + "AB", + "BC", + "CD", + "DA" + ], + "narration": "Dựng Đáy dưới ABCD: A, B, C, D.", + "duration_hint": 2.5 + }, + { + "action": "draw", + "targets": [ + "A_prime", + "B_prime", + "C_prime", + "D_prime", + "A_primeB_prime", + "B_primeC_prime", + "C_primeD_prime", + "D_primeA_prime", + "AA_prime", + "BB_prime", + "CC_prime", + "DD_prime" + ], + "narration": "Dựng Đáy trên và cạnh bên: A_prime, B_prime, C_prime, D_prime.", + "duration_hint": 2.5 + }, + { + "action": "rotate_camera", + "targets": [ + "scene_3d" + ], + "narration": "Quan sát khối đa diện trong không gian 3 chiều.", + "duration_hint": 3.0 + }, + { + "action": "write", + "targets": [ + "step_1" + ], + "narration": "Bước 1: Tính bình phương độ dài đường chéo đáy: AC^2 = AB^2 + AD^2 = 4^2 + 3^2 = 25.", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_2" + ], + "narration": "Bước 2: Áp dụng định lý Pythagoras trong tam giác vuông ACC': AC'^2 = AC^2 + CC'^2 = 25 + 5^2 = 50.", + "duration_hint": 3.5 + }, + { + "action": "write", + "targets": [ + "step_3" + ], + "narration": "Bước 3: Tính độ dài đường chéo không gian: AC' = sqrt(50) = 5*sqrt(2) ≈ 7.07.", + "duration_hint": 3.5 + } + ], + "output_config": { + "quality": "720p", + "format": "mp4", + "language": "vi" + } + }, + "prompt": "Chủ đề / Đề bài: Cho hình hộp chữ nhật ABCD.A'B'C'D' có AB=4, AD=3, AA'=5. Tính độ dài đường chéo AC'.\nCác bước giải thích / chứng minh chi tiết:\n 1. Bước 1: Tính bình phương độ dài đường chéo đáy: AC^2 = AB^2 + AD^2 = 4^2 + 3^2 = 25.\n 2. Bước 2: Áp dụng định lý Pythagoras trong tam giác vuông ACC': AC'^2 = AC^2 + CC'^2 = 25 + 5^2 = 50.\n 3. Bước 3: Tính độ dài đường chéo không gian: AC' = sqrt(50) = 5*sqrt(2) ≈ 7.07.\nCác đối tượng hình học / tọa độ giải được:\n - point_3d (A) - thuộc tính: {'coordinates': [0.0, 0.0, 0.0]}\n - point_3d (B) - thuộc tính: {'coordinates': [4.0, 0.0, 0.0]}\n - point_3d (C) - thuộc tính: {'coordinates': [4.0, 3.0, 0.0]}\n - point_3d (D) - thuộc tính: {'coordinates': [0.0, 3.0, 0.0]}\n - point_3d (A_prime) - thuộc tính: {'coordinates': [0.0, 0.0, 5.0]}\n - point_3d (B_prime) - thuộc tính: {'coordinates': [4.0, 0.0, 5.0]}\n - point_3d (C_prime) - thuộc tính: {'coordinates': [4.0, 3.0, 5.0]}\n - point_3d (D_prime) - thuộc tính: {'coordinates': [0.0, 3.0, 5.0]}\n - cuboid (cuboid_A_B_C_D_A_prime_B_prime_C_prime_D_prime) - thuộc tính: {'type': 'cuboid', 'points': ['A', 'B', 'C', 'D', 'A_prime', 'B_prime', 'C_prime', 'D_prime']}\nChỉ dẫn hoạt họa (animation beats):\n - Beat 1: draw [đối tượng: A, B, C, D, AB, BC, CD, DA] | Lời thoại: 'Dựng Đáy dưới ABCD: A, B, C, D.'\n - Beat 2: draw [đối tượng: A_prime, B_prime, C_prime, D_prime, A_primeB_prime, B_primeC_prime, C_primeD_prime, D_primeA_prime, AA_prime, BB_prime, CC_prime, DD_prime] | Lời thoại: 'Dựng Đáy trên và cạnh bên: A_prime, B_prime, C_prime, D_prime.'\n - Beat 3: rotate_camera [đối tượng: scene_3d] | Lời thoại: 'Quan sát khối đa diện trong không gian 3 chiều.'\n - Beat 4: write [đối tượng: step_1] | Lời thoại: 'Bước 1: Tính bình phương độ dài đường chéo đáy: AC^2 = AB^2 + AD^2 = 4^2 + 3^2 = 25.'\n - Beat 5: write [đối tượng: step_2] | Lời thoại: 'Bước 2: Áp dụng định lý Pythagoras trong tam giác vuông ACC': AC'^2 = AC^2 + CC'^2 = 25 + 5^2 = 50.'\n - Beat 6: write [đối tượng: step_3] | Lời thoại: 'Bước 3: Tính độ dài đường chéo không gian: AC' = sqrt(50) = 5*sqrt(2) ≈ 7.07.'" + } +} \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..414aedcdb3b00f107e895072cbe108ae98c11272 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests (real services, workers, LLM).""" diff --git a/tests/integration/test_agents_real.py b/tests/integration/test_agents_real.py new file mode 100644 index 0000000000000000000000000000000000000000..594ea17870f57912c9485b8fbe107bf6abb40576 --- /dev/null +++ b/tests/integration/test_agents_real.py @@ -0,0 +1,82 @@ +"""Smoke tests for individual agents against real LLM / rules (opt-in via markers).""" + +from __future__ import annotations + +import os + +import pytest + +from agents.geometry_parser_agent import GeometryParserAgent +from agents.deepmath_solver_agent import DeepMathSolverAgent +from agents.knowledge_agent import KnowledgeAgent +from solver.dsl_parser import DSLParser + + +def _api_configured() -> bool: + return bool( + os.getenv("GOOGLE_API_KEY") + or os.getenv("GEMINI_API_KEY") + or os.getenv("OPENROUTER_API_KEY_1") + or os.getenv("OPENROUTER_API_KEY") + ) + + +@pytest.mark.real_agents +@pytest.mark.asyncio +async def test_geometry_parser_agent_real(): + if not _api_configured(): + pytest.skip("No API key configured") + agent = GeometryParserAgent() + out = await agent.process("Cho hình vuông ABCD có cạnh bằng 4.") + assert isinstance(out, dict) + assert out.get("type") in (None, "square", "rectangle", "general") + assert "geometry_dsl" in out + dsl = out.get("geometry_dsl", "") + if dsl: + parser = DSLParser() + try: + points, _constraints, _is_3d = parser.parse(dsl) + except Exception as e: + pytest.fail(f"GeometryParserAgent DSL not parseable: {e}\n---\n{dsl[:800]}") + assert len(points) >= 1, "Expected at least one point from Geometry DSL" + + +@pytest.mark.real_agents +@pytest.mark.asyncio +async def test_deepmath_solver_agent_real(): + if not _api_configured(): + pytest.skip("No API key configured") + agent = DeepMathSolverAgent() + sol = await agent.solve( + problem_text="Cho hình vuông ABCD có cạnh bằng 4. Tính diện tích.", + target_question="Tính diện tích hình vuông ABCD.", + semantic_data={ + "type": "square", + "values": {"AB": 4}, + "target_question": "Tính diện tích hình vuông ABCD.", + }, + geometry_context={ + "coordinates": { + "A": [0.0, 0.0, 0.0], + "B": [4.0, 0.0, 0.0], + "C": [4.0, 4.0, 0.0], + "D": [0.0, 4.0, 0.0], + } + }, + ) + assert isinstance(sol, dict) + assert "steps" in sol + assert sol.get("answer") is not None or len(sol.get("steps") or []) > 0 + + +def test_knowledge_agent_augment_semantic_data(): + """Rule-based augmentation; no API key required.""" + agent = KnowledgeAgent() + data = { + "type": "general", + "values": {"AB": 5}, + "input_text": "Cho hình vuông ABCD có cạnh bằng 5.", + } + out = agent.augment_semantic_data(dict(data)) + assert out.get("type") == "square" + assert out.get("values", {}).get("AB") == 5 diff --git a/tests/integration/test_orchestrator_smoke.py b/tests/integration/test_orchestrator_smoke.py new file mode 100644 index 0000000000000000000000000000000000000000..f8e2407edb78043a66f2e3bb4af9a4798acf3e93 --- /dev/null +++ b/tests/integration/test_orchestrator_smoke.py @@ -0,0 +1,35 @@ +"""In-process orchestrator smoke (2 queries) — same stack as API without HTTP.""" + +from __future__ import annotations + +import os +import uuid + +import pytest + +from tests.cases.pipeline_cases import QUERIES + + +def _openrouter_configured() -> bool: + return bool(os.getenv("OPENROUTER_API_KEY_1") or os.getenv("OPENROUTER_API_KEY")) + + +@pytest.mark.orchestrator_local +@pytest.mark.real_agents +@pytest.mark.asyncio +async def test_orchestrator_two_queries_smoke(): + if not _openrouter_configured(): + pytest.skip("OPENROUTER_API_KEY_1 or OPENROUTER_API_KEY not set") + + from agents.orchestrator import Orchestrator + + orch = Orchestrator() + # Avoid Q1-style rectangles first: LLM sometimes returns prose instead of DSL. + stable_ids = ("Q5", "Q2") + by_id = {q["id"]: q for q in QUERIES} + for qid in stable_ids: + q = by_id[qid] + jid = str(uuid.uuid4()) + result = await orch.run(text=q["text"], job_id=jid) + assert "error" not in result, f"{qid}: {result.get('error')}" + assert result.get("coordinates"), f"No coordinates for {qid}" diff --git a/tests/test_3d_solver.py b/tests/test_3d_solver.py new file mode 100644 index 0000000000000000000000000000000000000000..9fb02c4d93fba3702a5bb289855e5e31db1ee9bc --- /dev/null +++ b/tests/test_3d_solver.py @@ -0,0 +1,128 @@ +import pytest +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine +from solver.models import Point, Constraint + +def test_solve_square_pyramid(): + """ + Test solving for a square pyramid S.ABCD. + Base ABCD is a square with side 10. + Height SO = 15, where O is the center of ABCD. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 10, 0, 0) + POINT(C, 10, 10, 0) + POINT(D, 0, 10, 0) + POINT(S) + POINT(O) + MIDPOINT(M1, AB) + MIDPOINT(M2, AC) + SECTION(O, A, C, 0.5) + LENGTH(SO, 15) + PERPENDICULAR(SO, AC) + PERPENDICULAR(SO, AB) + PYRAMID(S_ABCD) + """ + parser = DSLParser() + engine = GeometryEngine() + + points, constraints, is_3d = parser.parse(dsl) + result = engine.solve(points, constraints, is_3d) + + assert result is not None + coords = result["coordinates"] + + # Check base points + assert coords["A"] == [0.0, 0.0, 0.0] + assert coords["B"] == [10.0, 0.0, 0.0] + assert coords["C"] == [10.0, 10.0, 0.0] + assert coords["D"] == [0.0, 10.0, 0.0] + + # Check center O (should be (5, 5, 0)) + assert coords["O"][0] == pytest.approx(5.0) + assert coords["O"][1] == pytest.approx(5.0) + assert coords["O"][2] == pytest.approx(0.0) + + # Check apex S (should be (5, 5, 15) or (5, 5, -15)) + assert coords["S"][0] == pytest.approx(5.0) + assert coords["S"][1] == pytest.approx(5.0) + assert abs(coords["S"][2]) == pytest.approx(15.0) + +def test_solve_pyramid_sa_perp_base(): + """ + Test square pyramid S.ABCD with SA perpendicular to base (SA ⊥ ABCD). + Square base ABCD with AB=4, SA=5. + """ + dsl = """ + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 4) + LENGTH(SA, 5) + PERPENDICULAR_PLANE(SA, ABCD) + """ + parser = DSLParser() + engine = GeometryEngine() + points, constraints, is_3d = parser.parse(dsl) + result = engine.solve(points, constraints, is_3d) + + assert result is not None + coords = result["coordinates"] + # Base ABCD should form a 4x4 square in z=0 plane + assert coords["A"] == pytest.approx([0.0, 0.0, 0.0], abs=1e-2) + assert coords["B"] == pytest.approx([4.0, 0.0, 0.0], abs=1e-2) + assert coords["C"] == pytest.approx([4.0, 4.0, 0.0], abs=1e-2) + assert coords["D"] == pytest.approx([0.0, 4.0, 0.0], abs=1e-2) + # Apex S should be directly above A at height 5 + assert coords["S"][0] == pytest.approx(0.0, abs=1e-2) + assert coords["S"][1] == pytest.approx(0.0, abs=1e-2) + assert abs(coords["S"][2]) == pytest.approx(5.0, abs=1e-2) + +def test_solve_prism(): + """ + Triangular prism ABC_DEF. + Base ABC is right triangle at A. AB=3, AC=4. + Height AD=10. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 3, 0, 0) + POINT(C, 0, 4, 0) + POINT(D) + POINT(E) + POINT(F) + LENGTH(AD, 10) + PERPENDICULAR(AD, AB) + PERPENDICULAR(AD, AC) + PRISM(ABC_DEF) + """ + parser = DSLParser() + engine = GeometryEngine() + + points, constraints, is_3d = parser.parse(dsl) + result = engine.solve(points, constraints, is_3d) + + assert result is not None + coords = result["coordinates"] + + # D should be (0, 0, 10) + assert coords["D"][0] == pytest.approx(0.0, abs=1e-3) + assert coords["D"][1] == pytest.approx(0.0, abs=1e-3) + assert abs(coords["D"][2]) == pytest.approx(10.0, rel=1e-4, abs=1e-3) + +def test_explicit_z_zero_on_xy_plane_does_not_force_is_3d(): + """POINT with z=0 must not flip is_3d; 2D triangle stays 2D (regression vs POINT(A,x,y,0) bug).""" + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 3, 0, 0) + POINT(C, 0, 4, 0) + TRIANGLE(ABC) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is False + assert len(points) >= 3 + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_advanced_geometry.py b/tests/test_advanced_geometry.py new file mode 100644 index 0000000000000000000000000000000000000000..f424859b95d3fba30b4ca7750ef864c9c2dab71d --- /dev/null +++ b/tests/test_advanced_geometry.py @@ -0,0 +1,102 @@ +import pytest +import asyncio +import logging +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine + +logging.basicConfig(level=logging.DEBUG) + +@pytest.mark.asyncio +async def test_section_internal(): + print("\n--- Test: Section Point (Internal AE=2/3 AC) ---") + dsl = """ + POINT(A) + POINT(B) + POINT(C) + LENGTH(AB, 6) + LENGTH(BC, 6) + ANGLE(B, 90) + SECTION(E, A, C, 0.6667) + """ + parser = DSLParser() + engine = GeometryEngine() + + pts, constraints, is_3d = parser.parse(dsl) + result = engine.solve(pts, constraints, is_3d) + + if result: + coords = result['coordinates'] + print(f" A: {coords['A']}") + print(f" C: {coords['C']}") + print(f" E: {coords['E']}") + + # Verify AE = 0.6667 * AC + import math + def dist(p1, p2): return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2) + + d_ac = dist(coords['A'], coords['C']) + d_ae = dist(coords['A'], coords['E']) + ratio = d_ae / d_ac + print(f" Calculated Ratio AE/AC: {ratio:.4f} (Expected: 0.6667)") + assert abs(ratio - 0.6667) < 1e-4 + else: + print(" ❌ Solve failed") + +@pytest.mark.asyncio +async def test_section_external(): + print("\n--- Test: Section Point (External AE=2*AC) ---") + dsl = """ + POINT(A) + POINT(C) + LENGTH(AC, 5) + SECTION(E, A, C, 2.0) + """ + parser = DSLParser() + engine = GeometryEngine() + + pts, constraints, is_3d = parser.parse(dsl) + result = engine.solve(pts, constraints, is_3d) + + if result: + coords = result['coordinates'] + print(f" A: {coords['A']}") + print(f" C: {coords['C']}") + print(f" E: {coords['E']}") + + import math + def dist(p1, p2): return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2) + d_ac = dist(coords['A'], coords['C']) + d_ae = dist(coords['A'], coords['E']) + print(f" AE: {d_ae}, AC: {d_ac}, Ratio: {d_ae/d_ac}") + assert abs(d_ae/d_ac - 2.0) < 1e-4 + else: + print(" ❌ Solve failed") + +@pytest.mark.asyncio +async def test_line_ray_metadata(): + print("\n--- Test: Line and Ray Metadata ---") + dsl = """ + POINT(A) + POINT(B) + LINE(A, B) + RAY(A, B) + """ + parser = DSLParser() + engine = GeometryEngine() + + pts, constraints, is_3d = parser.parse(dsl) + result = engine.solve(pts, constraints, is_3d) + + if result: + print(f" Lines: {result.get('lines')}") + print(f" Rays: {result.get('rays')}") + assert ['A', 'B'] in result.get('lines', []) + assert ['A', 'B'] in result.get('rays', []) + print(" ✅ Metadata present") + else: + print(" ❌ Solve failed") + +if __name__ == "__main__": + asyncio.run(test_section_internal()) + asyncio.run(test_section_external()) + asyncio.run(test_line_ray_metadata()) diff --git a/tests/test_api_full_suite.py b/tests/test_api_full_suite.py new file mode 100644 index 0000000000000000000000000000000000000000..74d25703e6383f296c5a9fe5f2b34aaa5969d78a --- /dev/null +++ b/tests/test_api_full_suite.py @@ -0,0 +1,224 @@ +import asyncio +import copy +import json +import os +import time + +import httpx +import pytest + +from tests.cases.pipeline_cases import ( + Q10_FOLLOW_UP, + Q13_HISTORY_STEPS, + QUERIES, + validate_q10_step2_dsl, + validate_query_result, +) + +BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:8000") +USER_ID = os.getenv("TEST_USER_ID") +SESSION_ID = os.getenv("TEST_SESSION_ID") + +test_stats: list[dict] = [] + + +_SOLVER_TRANSIENT = "Solver failed after multiple attempts" + + +async def run_single_api_query(client, q, headers, default_session_id: str | None): + print(f"\n🚀 [RUNNING] {q['id']}: {q['text']}") + start_time = time.time() + + payload = { + "text": q["text"], + "request_video": q.get("request_video", False), + } + + max_rounds = 3 + + try: + for round_idx in range(max_rounds): + if q.get("isolate", True): + session_resp = await client.post("/api/v1/sessions", headers=headers) + if session_resp.status_code != 200: + return { + "id": q["id"], + "query": q["text"], + "success": False, + "error": f"Session creation failed: {session_resp.text}", + } + session_id = session_resp.json()["id"] + else: + session_id = q.get("session_id", default_session_id) + + res = await client.post( + f"/api/v1/sessions/{session_id}/solve", + json=payload, + headers=headers, + ) + if res.status_code != 200: + print(f" ❌ FAILED: Status {res.status_code} - {res.text}") + return { + "id": q["id"], + "query": q["text"], + "success": False, + "error": f"HTTP {res.status_code}: {res.text}", + } + + job_id = res.json()["job_id"] + print(f" ✅ Job Created: {job_id}") + + max_attempts = 45 + result_data = None + last_error = None + for i in range(max_attempts): + await asyncio.sleep(4) + res = await client.get(f"/api/v1/solve/{job_id}", headers=headers) + data = res.json() + status = data.get("status") + print(f" - Polling ({i + 1}): {status}") + + if status == "success": + result_data = data["result"] + break + if status == "error": + last_error = data.get("result", {}).get("error") + print(f" ❌ ERROR: {last_error}") + err_s = str(last_error or "") + if _SOLVER_TRANSIENT in err_s and round_idx < max_rounds - 1: + print( + f" ↻ Retry {round_idx + 2}/{max_rounds} (transient solver/LLM flake)" + ) + result_data = None + break + return { + "id": q["id"], + "query": q["text"], + "success": False, + "error": last_error, + } + + if i == max_attempts - 1: + print(" ❌ TIMEOUT") + return {"id": q["id"], "query": q["text"], "success": False, "error": "Timeout"} + + if result_data is None: + continue + + elapsed = time.time() - start_time + errors = validate_query_result(q, result_data) + + if q.get("request_video") and not result_data.get("video_url"): + print(" ⚠️ Video requested but no URL found (Expected in some test envs)") + + if errors: + print(f" ❌ VALIDATION FAILED: {', '.join(errors)}") + return { + "id": q["id"], + "query": q["text"], + "success": False, + "error": "; ".join(errors), + "elapsed": elapsed, + "result": result_data, + } + + print(f" ✅ PASS ({elapsed:.2f}s)") + return { + "id": q["id"], + "query": q["text"], + "success": True, + "elapsed": elapsed, + "job_id": job_id, + "result": result_data, + } + + raise RuntimeError("run_single_api_query: retry loop fell through (bug)") + + except Exception as e: + print(f" ❌ EXCEPTION: {str(e)}") + return {"id": q["id"], "query": q["text"], "success": False, "error": str(e)} + + +@pytest.mark.real_api +@pytest.mark.slow +@pytest.mark.asyncio +async def test_full_api_suite(): + if not USER_ID or not SESSION_ID: + pytest.fail("TEST_USER_ID and TEST_SESSION_ID must be set") + + global test_stats + test_stats = [] + + headers = {"Authorization": f"Test {USER_ID}"} + + async with httpx.AsyncClient(base_url=BASE_URL, timeout=60.0) as client: + for q in QUERIES: + if q["id"] == "Q10-Step1": + continue + qc = copy.deepcopy(q) + res = await run_single_api_query(client, qc, headers, SESSION_ID) + test_stats.append(res) + + print("\n--- Testing Multi-turn API Flow (Q10) ---") + shared_session_resp = await client.post("/api/v1/sessions", headers=headers) + assert shared_session_resp.status_code == 200 + shared_session = shared_session_resp.json()["id"] + + q10_1 = copy.deepcopy(next(q for q in QUERIES if q["id"] == "Q10-Step1")) + q10_1["session_id"] = shared_session + q10_1["isolate"] = False + res10_1 = await run_single_api_query(client, q10_1, headers, SESSION_ID) + test_stats.append(res10_1) + + if res10_1["success"]: + q10_2 = copy.deepcopy(Q10_FOLLOW_UP) + q10_2["session_id"] = shared_session + q10_2["isolate"] = False + res10_2 = await run_single_api_query(client, q10_2, headers, SESSION_ID) + + if res10_2["success"]: + dsl = res10_2.get("result", {}).get("geometry_dsl", "") or "" + if not validate_q10_step2_dsl(dsl): + res10_2["success"] = False + res10_2["error"] = "DSL did not merge history correctly" + + test_stats.append(res10_2) + + print("\n--- Testing Multi-turn API Flow (Q13 history in one session) ---") + q13_session_resp = await client.post("/api/v1/sessions", headers=headers) + assert q13_session_resp.status_code == 200 + q13_session = q13_session_resp.json()["id"] + + prev_ok = True + for step in Q13_HISTORY_STEPS: + if not prev_ok: + test_stats.append( + { + "id": step["id"], + "query": step["text"], + "success": False, + "error": "Skipped: previous step in Q13 failed", + } + ) + continue + sc = copy.deepcopy(step) + sc["session_id"] = q13_session + sc["isolate"] = False + out = await run_single_api_query(client, sc, headers, SESSION_ID) + test_stats.append(out) + prev_ok = bool(out.get("success")) + + with open("temp_suite_results.json", "w", encoding="utf-8") as f: + json.dump(test_stats, f, ensure_ascii=False, indent=2) + + failures = [r for r in test_stats if not r.get("success")] + if failures: + pytest.fail( + "Suite failures: " + + "; ".join(f"{r.get('id')}: {r.get('error')}" for r in failures[:5]) + + (f" (+{len(failures) - 5} more)" if len(failures) > 5 else "") + ) + + +if __name__ == "__main__": + asyncio.run(test_full_api_suite()) diff --git a/tests/test_api_metadata_real.py b/tests/test_api_metadata_real.py new file mode 100644 index 0000000000000000000000000000000000000000..01618fd0ac7941955cf966f5e81bc0302edd2868 --- /dev/null +++ b/tests/test_api_metadata_real.py @@ -0,0 +1,103 @@ +"""Verify assistant message metadata after process_session_job (Supabase + LLM).""" + +from __future__ import annotations + +import os +import uuid + +import pytest +from dotenv import load_dotenv + +load_dotenv() + +from app.models.schemas import SolveRequest +from app.routers.solve import process_session_job +from app.supabase_client import get_supabase + + +@pytest.mark.real_api +@pytest.mark.asyncio +async def test_metadata_persistence_after_solve(): + if not os.getenv("SUPABASE_SERVICE_ROLE_KEY") and not os.getenv("SUPABASE_KEY"): + pytest.skip("Supabase credentials not configured") + + user_id = os.getenv("TEST_SUPABASE_USER_ID") or os.getenv("TEST_USER_ID") + if not user_id: + pytest.skip("TEST_SUPABASE_USER_ID or TEST_USER_ID required") + + supabase = get_supabase() + session_id = str(uuid.uuid4()) + job_id = str(uuid.uuid4()) + + supabase.table("sessions").insert( + { + "id": session_id, + "user_id": user_id, + "title": "pytest metadata session", + } + ).execute() + + request = SolveRequest( + text="Cho hình chữ nhật ABCD có AB=10, AD=20. Vẽ đường thẳng d đi qua A và B.", + request_video=False, + ) + + supabase.table("jobs").insert( + { + "id": job_id, + "user_id": user_id, + "session_id": session_id, + "status": "processing", + "input_text": request.text, + } + ).execute() + supabase.table("messages").insert( + { + "session_id": session_id, + "role": "user", + "type": "text", + "content": request.text, + "metadata": {}, + } + ).execute() + + try: + await process_session_job(job_id, session_id, request, user_id) + + res = ( + supabase.table("messages") + .select("metadata") + .eq("session_id", session_id) + .eq("role", "assistant") + .order("created_at", desc=True) + .limit(1) + .execute() + ) + + assert res.data, "Expected at least one assistant message" + metadata = res.data[0].get("metadata") or {} + required = [ + "job_id", + "coordinates", + "polygon_order", + "drawing_phases", + "circles", + "lines", + "rays", + ] + missing = [f for f in required if f not in metadata] + assert not missing, f"Missing metadata fields: {missing}" + assert metadata.get("job_id") == job_id + finally: + try: + supabase.table("messages").delete().eq("session_id", session_id).execute() + except Exception: + pass + try: + supabase.table("jobs").delete().eq("session_id", session_id).execute() + except Exception: + pass + try: + supabase.table("sessions").delete().eq("id", session_id).execute() + except Exception: + pass diff --git a/tests/test_api_real_e2e.py b/tests/test_api_real_e2e.py new file mode 100644 index 0000000000000000000000000000000000000000..f5f4dd9a69f25aa2c2eb03bde5f4bfed7fe1af00 --- /dev/null +++ b/tests/test_api_real_e2e.py @@ -0,0 +1,77 @@ +import os +import httpx +import time +import pytest +import logging + +# Configuration from environment +BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:8000") +USER_ID = os.getenv("TEST_USER_ID") +SESSION_ID = os.getenv("TEST_SESSION_ID") + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@pytest.mark.smoke +@pytest.mark.real_api +@pytest.mark.asyncio +async def test_api_e2e_flow(): + if not USER_ID or not SESSION_ID: + pytest.fail("TEST_USER_ID and TEST_SESSION_ID must be set") + + auth_headers = {"Authorization": f"Test {USER_ID}"} + + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + # 1. Health check + print("\n[1/3] Checking API Health...") + res = await client.get("/") + assert res.status_code == 200 + assert "running" in res.json()["message"].lower() + print(" ✅ Health check passed") + + # 2. Submit Solve Request + print(f"\n[2/3] Submitting solve request for session {SESSION_ID}...") + payload = { + "text": "Cho hình chữ nhật ABCD có AB=5, AD=10. Tính diện tích.", + "request_video": False + } + res = await client.post(f"/api/v1/sessions/{SESSION_ID}/solve", json=payload, headers=auth_headers) + + if res.status_code != 200: + print(f" ❌ FAILED: {res.text}") + assert res.status_code == 200 + + data = res.json() + job_id = data["job_id"] + assert job_id is not None + print(f" ✅ Request accepted. Job ID: {job_id}") + + # 3. Polling Job Status + print("\n[3/3] Polling job status...") + max_attempts = 15 + for i in range(max_attempts): + time.sleep(2) # Simple sleep between polls + res = await client.get(f"/api/v1/solve/{job_id}", headers=auth_headers) + assert res.status_code == 200 + job_data = res.json() + status = job_data["status"] + print(f" Attempt {i+1}: Status = {status}") + + if status == "success": + print(" ✅ SUCCESS: API pipeline completed successfully.") + result = job_data.get("result", {}) + assert "coordinates" in result + assert "geometry_dsl" in result + return + + if status == "error": + error_msg = job_data.get("result", {}).get("error", "Unknown error") + pytest.fail(f"Job failed with error: {error_msg}") + + if i == max_attempts - 1: + pytest.fail("Timeout waiting for job completion") + +if __name__ == "__main__": + # This allows running the script directly if needed + import asyncio + asyncio.run(test_api_e2e_flow()) diff --git a/tests/test_backend_core_stability.py b/tests/test_backend_core_stability.py new file mode 100644 index 0000000000000000000000000000000000000000..51b9b4e693c7e83bca783322d8872969c68259da --- /dev/null +++ b/tests/test_backend_core_stability.py @@ -0,0 +1,94 @@ +import pytest +from unittest.mock import MagicMock, patch +import uuid +from fastapi import HTTPException + +from app.chat_image_upload import cleanup_session_storage, validate_chat_image_bytes +from app.session_cache import ( + invalidate_session_owner, + session_owned_by_user, +) +from app.websocket_manager import active_connections, notify_status + + +def test_session_owner_authoritative_check(): + user_id = str(uuid.uuid4()) + session_id = str(uuid.uuid4()) + fetch_count = 0 + + def mock_owns(): + nonlocal fetch_count + fetch_count += 1 + return True + + assert session_owned_by_user(session_id, user_id, mock_owns) is True + assert fetch_count == 1 + + invalidate_session_owner(session_id, user_id) + assert session_owned_by_user(session_id, user_id, mock_owns) is True + assert fetch_count == 2 + + + +def test_chat_image_validation_magic_bytes(): + # Valid PNG magic bytes + png_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + ext, mime = validate_chat_image_bytes("test.png", png_bytes, "image/png") + assert ext == ".png" + assert mime == "image/png" + + # Valid JPEG magic bytes + jpeg_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01" + ext, mime = validate_chat_image_bytes("test.jpg", jpeg_bytes, "image/jpeg") + assert ext == ".jpg" + assert mime == "image/jpeg" + + # Invalid corrupted content + bad_bytes = b"NOT_AN_IMAGE_DATA_HEADER" + with pytest.raises(HTTPException) as exc_info: + validate_chat_image_bytes("test.png", bad_bytes, "image/png") + assert exc_info.value.status_code == 400 + + +def test_cleanup_session_storage(): + session_id = str(uuid.uuid4()) + mock_supabase = MagicMock() + + mock_from = MagicMock() + mock_from.list.return_value = [ + {"name": f"image_v1_{session_id}.png"}, + {"name": ".emptyFolderPlaceholder"}, + ] + mock_supabase.storage.from_.return_value = mock_from + + with patch("app.supabase_client.get_supabase", return_value=mock_supabase): + cleanup_session_storage(session_id) + assert mock_supabase.storage.from_.called + assert mock_from.remove.called + + +@pytest.mark.asyncio +async def test_websocket_dead_connection_pruning(): + job_id = str(uuid.uuid4()) + + from unittest.mock import AsyncMock + + good_ws = MagicMock() + good_ws.send_json = AsyncMock() + + bad_ws = MagicMock() + async def bad_send(_): + raise RuntimeError("Connection closed") + bad_ws.send_json = bad_send + + active_connections[job_id] = [good_ws, bad_ws] + + # Notify status should prune bad_ws + await notify_status(job_id, {"status": "processing"}) + + assert job_id in active_connections + assert bad_ws not in active_connections[job_id] + assert good_ws in active_connections[job_id] + + # Clean up + active_connections.clear() diff --git a/tests/test_chat_image_validate.py b/tests/test_chat_image_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..57535f8f8473b9c5e664a183ffe55b2a1130feb2 --- /dev/null +++ b/tests/test_chat_image_validate.py @@ -0,0 +1,26 @@ +"""Unit tests for chat image validation (no Supabase / FastAPI app import).""" + +import pytest +from fastapi import HTTPException + +from app.chat_image_upload import validate_chat_image_bytes + +_VALID_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 + + +def test_validate_png_ok(): + ext, mime = validate_chat_image_bytes("a.png", _VALID_PNG, "image/png") + assert ext == ".png" + assert mime == "image/png" + + +def test_validate_rejects_bad_magic(): + with pytest.raises(HTTPException) as exc: + validate_chat_image_bytes("a.png", b"xxxxxxxxxxxx", "image/png") + assert exc.value.status_code == 400 + + +def test_validate_rejects_empty(): + with pytest.raises(HTTPException) as exc: + validate_chat_image_bytes("a.png", b"", "image/png") + assert exc.value.status_code == 400 diff --git a/tests/test_dsl_advanced_3d.py b/tests/test_dsl_advanced_3d.py new file mode 100644 index 0000000000000000000000000000000000000000..049095c8b24e58099fe4c6dbed79a5f50961dc28 --- /dev/null +++ b/tests/test_dsl_advanced_3d.py @@ -0,0 +1,236 @@ +import pytest +import numpy as np +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine + + +def test_solve_cube(): + """ + Test solving for a cube ABCD.A1B1C1D1 with side a=5. + Verify all 8 vertices, 12 edges, faces, and 3D coordinates. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 5, 0, 0) + POINT(C, 5, 5, 0) + POINT(D, 0, 5, 0) + POINT(A1) + POINT(B1) + POINT(C1) + POINT(D1) + LENGTH(AA1, 5) + PERPENDICULAR_PLANE(AA1, ABCD) + CUBE(ABCD_A1B1C1D1) + """ + parser = DSLParser() + engine = GeometryEngine() + + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + assert len(points) == 8 + + result = engine.solve(points, constraints, is_3d) + assert result is not None + + coords = result["coordinates"] + # Check bottom base vertices + assert coords["C"][2] == pytest.approx(0.0, abs=1e-3) + assert coords["D"][0] == pytest.approx(0.0, abs=1e-3) + assert coords["D"][1] == pytest.approx(5.0, abs=1e-3) + assert coords["D"][2] == pytest.approx(0.0, abs=1e-3) + + # Check top base vertices + assert coords["A1"][0] == pytest.approx(0.0, abs=1e-3) + assert coords["A1"][1] == pytest.approx(0.0, abs=1e-3) + assert abs(coords["A1"][2]) == pytest.approx(5.0, abs=1e-3) + + # Verify solids and faces + assert "solids" in result + assert any(s["type"] == "cube" for s in result["solids"]) + assert "faces" in result + assert len(result["faces"]) >= 6 # 6 faces for a cube + + +def test_solve_regular_tetrahedron(): + """ + Test solving for a regular tetrahedron ABCD with edge a=6. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 6, 0, 0) + POINT(C) + POINT(D) + LENGTH(AC, 6) + LENGTH(BC, 6) + LENGTH(AD, 6) + LENGTH(BD, 6) + LENGTH(CD, 6) + TETRAHEDRON(ABCD) + """ + parser = DSLParser() + engine = GeometryEngine() + + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + + result = engine.solve(points, constraints, is_3d) + assert result is not None + + coords = result["coordinates"] + # Check edge lengths + for p1, p2 in [("A","B"), ("A","C"), ("A","D"), ("B","C"), ("B","D"), ("C","D")]: + v1 = np.array(coords[p1]) + v2 = np.array(coords[p2]) + dist = np.linalg.norm(v2 - v1) + assert dist == pytest.approx(6.0, abs=1e-2) + + # 4 faces + assert any(s["type"] == "tetrahedron" for s in result.get("solids", [])) + assert len(result.get("faces", [])) >= 4 + + +def test_solve_prism_full_edges(): + """ + Test that triangular prism ABC_A1B1C1 generates all 9 edges (3 base1, 3 base2, 3 lateral). + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 4, 0, 0) + POINT(C, 0, 3, 0) + POINT(A1) + POINT(B1) + POINT(C1) + LENGTH(AA1, 8) + PERPENDICULAR_PLANE(AA1, ABC) + PRISM(ABC_A1B1C1) + """ + parser = DSLParser() + engine = GeometryEngine() + + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + + result = engine.solve(points, constraints, is_3d) + assert result is not None + + # Check drawing phases contain all segments + all_segments = [] + for phase in result["drawing_phases"]: + all_segments.extend(phase["segments"]) + + # Base 1 edges: AB, BC, CA + # Base 2 edges: A1B1, B1C1, C1A1 + # Lateral edges: AA1, BB1, CC1 + expected_pairs = [ + {"A", "B"}, {"B", "C"}, {"C", "A"}, + {"A1", "B1"}, {"B1", "C1"}, {"C1", "A1"}, + {"A", "A1"}, {"B", "B1"}, {"C", "C1"} + ] + for pair in expected_pairs: + assert any(set(seg) == pair for seg in all_segments), f"Missing segment: {pair}" + + +def test_perpendicular_plane_constraint(): + """ + Test PERPENDICULAR_PLANE(SO, ABCD) where ABCD is square on z=0. + Apex S must lie directly on z-axis (SO along z-axis). + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 4, 0, 0) + POINT(C, 4, 4, 0) + POINT(D, 0, 4, 0) + POINT(O, 2, 2, 0) + POINT(S) + LENGTH(SO, 6) + PERPENDICULAR_PLANE(SO, ABCD) + PYRAMID(S_ABCD) + """ + parser = DSLParser() + engine = GeometryEngine() + + points, constraints, is_3d = parser.parse(dsl) + result = engine.solve(points, constraints, is_3d) + + assert result is not None + coords = result["coordinates"] + assert coords["S"][0] == pytest.approx(2.0, abs=1e-3) + assert coords["S"][1] == pytest.approx(2.0, abs=1e-3) + assert abs(coords["S"][2]) == pytest.approx(6.0, abs=1e-3) + + +def test_coplanar_constraint(): + """ + Test COPLANAR(A, B, C, D) constraint. + Points A(0,0,0), B(1,0,0), C(0,1,0) define XY-plane (z=0). + Point D with D_x=2, D_y=3 should have D_z=0 under COPLANAR constraint. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 1, 0, 0) + POINT(C, 0, 1, 0) + POINT(D) + LENGTH(AD, 5) + ANGLE(D, A, B, 0) + COPLANAR(A, B, C, D) + """ + parser = DSLParser() + engine = GeometryEngine() + + points, constraints, is_3d = parser.parse(dsl) + result = engine.solve(points, constraints, is_3d) + + assert result is not None + coords = result["coordinates"] + assert coords["D"][2] == pytest.approx(0.0, abs=1e-3) + + +def test_cone_and_cylinder_dsl(): + """ + Test CONE and CYLINDER parsing and metadata creation. + """ + dsl = """ + POINT(O, 0, 0, 0) + POINT(S, 0, 0, 10) + CONE(S, O, 4, 10) + POINT(O1, 0, 0, 0) + POINT(O2, 0, 0, 8) + CYLINDER(O1, O2, 3) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + solids = result.get("solids", []) + assert any(s["type"] == "cone" and s["radius"] == 4.0 for s in solids) + assert any(s["type"] == "cylinder" and s["radius"] == 3.0 for s in solids) + + +def test_multichar_point_names(): + """ + Test parsing and solving geometry with multi-character point names: A1, B1, M1, S_1, A'. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 4, 0, 0) + POINT(M1) + MIDPOINT(M1, A, B) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert any(p.id == "M1" for p in points) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + assert coords["M1"][0] == pytest.approx(2.0, abs=1e-3) + assert coords["M1"][1] == pytest.approx(0.0, abs=1e-3) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_e2e_regression.py b/tests/test_e2e_regression.py new file mode 100644 index 0000000000000000000000000000000000000000..323f830fb14978469d82ce84ea4b869bb931f67f --- /dev/null +++ b/tests/test_e2e_regression.py @@ -0,0 +1,188 @@ +""" +End-to-End and Regression Test Suite for MathSolver Pipeline. + +Verifies: +1. DSL Parser + Geometry Engine + Geometry Validator integration. +2. Canonical 3D solids (Pyramid, Cube, Prism, Cone) coordinate solving & invariants. +3. GeometryStatus propagation (VALID, DEGRADED, FAILED). +4. Machine-readable structured error feedback for LLM repair loops. +5. Evaluation framework components and metrics calculation. +""" + +from __future__ import annotations + +import pytest +from eval.benchmark import BenchmarkDataset, BenchmarkSample +from eval.metrics import compute_cer, compute_wer, latex_match +from eval.runner import EvalRunner +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine +from solver.validator import GeometryStatus, GeometryValidator, StructuredError, ValidationResult + + +def test_metric_calculations(): + """Verify CER, WER, and LaTeX matching functions.""" + # CER + assert compute_cer("SA = 6", "SA = 6") == 0.0 + assert compute_cer("SA = 6", "SA = 8") == pytest.approx(1 / 6) + assert compute_cer("", "") == 0.0 + + # WER + assert compute_wer("Cho hình vuông ABCD", "Cho hình vuông ABCD") == 0.0 + assert compute_wer("Cho hình vuông ABCD", "Cho hình chữ nhật ABCD") == pytest.approx(2 / 4) + + # LaTeX matching + assert latex_match("\\frac{1}{3} \\cdot S \\cdot h", "\\frac{1}{3} * S * h") + assert latex_match("S_{ABCD}", "S_{ABCD}") + + +def test_validator_structured_feedback(): + """Verify that ValidationResult properly generates structured error feedback.""" + err = StructuredError( + error_type="constraint_violation", + constraint="Length constraint violated", + expected="AB = 4", + actual="AB = 5", + instruction="Correct the DSL length values.", + ) + res = ValidationResult( + is_valid=False, + errors=["Length constraint violated: |AB| expected 4.00, got 5.00"], + status=GeometryStatus.FAILED, + structured_errors=[err], + ) + fb = res.to_structured_feedback() + assert fb["status"] == "failed" + assert fb["error_count"] == 1 + assert len(fb["details"]) == 1 + assert fb["details"][0]["error_type"] == "constraint_violation" + assert fb["details"][0]["constraint"] == "Length constraint violated" + + +def test_regression_pyramid_solving_and_validation(): + """Tests S.ABCD square pyramid DSL parse -> engine solve -> validator pass.""" + dsl = """ + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 4) + LENGTH(SA, 6) + PERPENDICULAR_PLANE(SA, ABCD) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + assert len(points) >= 5 + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result.get("coordinates", {}) + assert len(coords) >= 5 + assert "S" in coords and "A" in coords + + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid is True + assert val_res.status == GeometryStatus.VALID + + +def test_regression_cube_solving_and_validation(): + """Tests Cube ABCD.A1B1C1D1 DSL parse -> engine solve -> validator pass.""" + dsl = """ + CUBE(ABCD_A1B1C1D1) + LENGTH(AB, 5) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result.get("coordinates", {}) + assert len(coords) >= 8 + + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid is True + assert val_res.status == GeometryStatus.VALID + + +def test_regression_triangular_prism_solving_and_validation(): + """Tests Right Triangular Prism ABC.A1B1C1 solving & validation.""" + dsl = """ + PRISM(ABC_A1B1C1) + POINT(A, 0, 0, 0) + POINT(B, 3, 0, 0) + POINT(C, 0, 4, 0) + POINT(A1, 0, 0, 6) + POINT(B1, 3, 0, 6) + POINT(C1, 0, 4, 6) + LENGTH(AA1, 6) + PERPENDICULAR_PLANE(AA1, ABC) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result.get("coordinates", {}) + assert len(coords) >= 6 + + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid is True + assert val_res.status == GeometryStatus.VALID + + +def test_regression_cone_solving(): + """Tests Cone with apex S and base center O.""" + dsl = "CONE(S_O, 3, 4)" + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is True + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result.get("coordinates", {}) + assert "S" in coords and "O" in coords + + +def test_regression_2d_rectangle_solving_and_validation(): + """Tests 2D Rectangle ABCD solving & validation.""" + dsl = """ + RECTANGLE(ABCD) + LENGTH(AB, 6) + LENGTH(BC, 8) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d is False + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result.get("coordinates", {}) + assert len(coords) == 4 + + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid is True + assert val_res.status == GeometryStatus.VALID + + +def test_eval_runner_on_benchmark(): + """Tests EvalRunner deterministic pass over benchmark dataset.""" + dataset = BenchmarkDataset.load_all_standard() + assert len(dataset) >= 5 + + runner = EvalRunner() + metrics = runner.evaluate_dsl_deterministic(dataset) + assert metrics.total_samples >= 5 + assert metrics.dsl_valid_rate == 1.0 + assert metrics.solvability_rate == 1.0 + assert metrics.validation_pass_rate == 1.0 + assert metrics.degradation_rate == 0.0 diff --git a/tests/test_geometry_validation_and_lifecycle.py b/tests/test_geometry_validation_and_lifecycle.py new file mode 100644 index 0000000000000000000000000000000000000000..c8953aa2699cbad69cbedbec40df2ce09913d4db --- /dev/null +++ b/tests/test_geometry_validation_and_lifecycle.py @@ -0,0 +1,435 @@ +"""Comprehensive Regression Test Suite for Geometry Engine, Validator, VisualizationSpec, +and Manim Job Lifecycle. +""" +from __future__ import annotations + +import asyncio +import math +from typing import Any, Dict +import numpy as np +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine +from solver.validator import GeometryValidator, ValidationResult +from solver.models import Point, Constraint +from manim_client.schemas import ( + ErrorCode, + StructuredError, + VisualizationConfig, + VisualizationSpec, + MathRenderResponse, + build_visualization_spec, +) +from manim_client.client import ManimClient + + +# ============================================================================ +# 1. GEOMETRY ENGINE & CANONICAL PLACEMENT TESTS +# ============================================================================ + +def test_2d_rectangle_constraints(): + """Tests 2D rectangle parsing, solving, canonical placement and validation.""" + dsl = """ + RECTANGLE(ABCD) + LENGTH(AB, 8) + LENGTH(BC, 6) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert not is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + assert "A" in coords and "B" in coords and "C" in coords and "D" in coords + + # Check mathematical scale and dimensions + vA = np.array(coords["A"][:2]) + vB = np.array(coords["B"][:2]) + vC = np.array(coords["C"][:2]) + vD = np.array(coords["D"][:2]) + + assert pytest.approx(np.linalg.norm(vB - vA), rel=1e-3) == 8.0 + assert pytest.approx(np.linalg.norm(vC - vB), rel=1e-3) == 6.0 + assert pytest.approx(np.linalg.norm(vD - vC), rel=1e-3) == 8.0 + assert pytest.approx(np.linalg.norm(vA - vD), rel=1e-3) == 6.0 + + # Orthogonality: AB ⊥ BC + dot = np.dot(vB - vA, vC - vB) + assert pytest.approx(dot, abs=1e-3) == 0.0 + + # Validate with GeometryValidator + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid, f"Validation failed: {val_res.errors}" + + +def test_2d_equilateral_triangle(): + """Tests equilateral triangle parsing and solving.""" + dsl = """ + EQUILATERAL_TRIANGLE(ABC, 6) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + vA = np.array(coords["A"][:2]) + vB = np.array(coords["B"][:2]) + vC = np.array(coords["C"][:2]) + + assert pytest.approx(np.linalg.norm(vB - vA), rel=1e-2) == 6.0 + assert pytest.approx(np.linalg.norm(vC - vB), rel=1e-2) == 6.0 + assert pytest.approx(np.linalg.norm(vA - vC), rel=1e-2) == 6.0 + + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid, f"Validation failed: {val_res.errors}" + + +def test_3d_canonical_pyramid_placement(): + """ + Tests 3D Pyramid S.ABCD with square base AB=4, SO ⊥ (ABCD), SO=6. + Ensures canonical coordinate policy: + - Base on z=0 + - Center O at mean of base + - Apex S at (Ox, Oy, 6) along +Z + - Mathematical scale preserved. + """ + dsl = """ + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 4) + POINT(S) + POINT(O) + CENTER(O, ABCD) + PERPENDICULAR_PLANE(SO, ABCD) + LENGTH(SO, 6) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + # Verify base vertices are in plane z = 0 + for p in ["A", "B", "C", "D", "O"]: + assert pytest.approx(coords[p][2], abs=1e-3) == 0.0, f"Point {p} not on z=0 ground plane" + + # Base square edge length = 4 + vA = np.array(coords["A"]) + vB = np.array(coords["B"]) + assert pytest.approx(np.linalg.norm(vB - vA), rel=1e-3) == 4.0 + + # Center O is at midpoint/mean + vO = np.array(coords["O"]) + mean_base = np.mean([coords["A"], coords["B"], coords["C"], coords["D"]], axis=0) + assert pytest.approx(np.linalg.norm(vO - mean_base), abs=1e-3) == 0.0 + + # Apex S is directly above O along +Z with height 6 + vS = np.array(coords["S"]) + assert pytest.approx(vS[0], abs=1e-3) == vO[0] + assert pytest.approx(vS[1], abs=1e-3) == vO[1] + assert pytest.approx(vS[2], abs=1e-3) == 6.0 + assert pytest.approx(np.linalg.norm(vS - vO), rel=1e-3) == 6.0 + + # Validate with GeometryValidator + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid, f"Validation failed: {val_res.errors}" + + +def test_3d_prism_canonical(): + """Tests 3D Triangular Prism ABC.DEF with side 5, height 8.""" + dsl = """ + PRISM(ABC_DEF) + EQUILATERAL_TRIANGLE(ABC, 5) + EQUILATERAL_TRIANGLE(DEF, 5) + LENGTH(AD, 8) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + # Base 1 on z=0 + for p in ["A", "B", "C"]: + assert pytest.approx(coords[p][2], abs=1e-3) == 0.0 + + # Base 2 on z=8 + for p in ["D", "E", "F"]: + assert pytest.approx(coords[p][2], abs=1e-3) == 8.0 + + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid, f"Validation failed: {val_res.errors}" + + +def test_geometric_constraints_midpoint_section_point_on(): + """Tests MIDPOINT, SECTION, and POINT_ON constraints.""" + dsl = """ + POINT(A, 0, 0) + POINT(B, 10, 0) + POINT(M) + MIDPOINT(M, AB) + POINT(E) + SECTION(E, A, B, 0.3) + POINT(P) + POINT_ON(P, AB) + LENGTH(AP, 7) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + assert pytest.approx(coords["M"][0], abs=1e-3) == 5.0 + assert pytest.approx(coords["E"][0], abs=1e-3) == 3.0 + assert pytest.approx(coords["P"][0], abs=1e-3) == 7.0 + + validator = GeometryValidator() + val_res = validator.validate(result, constraints, is_3d) + assert val_res.is_valid, f"Validation failed: {val_res.errors}" + + +# ============================================================================ +# 2. GEOMETRY VALIDATOR REJECTION TESTS +# ============================================================================ + +def test_validator_detects_length_violation(): + """Validator should reject coordinates when length constraint is violated.""" + engine_result = { + "coordinates": { + "A": [0.0, 0.0, 0.0], + "B": [10.0, 0.0, 0.0], + }, + "drawing_phases": [{"phase": 1, "points": ["A", "B"], "segments": [["A", "B"]]}], + } + # Expected length 5, actual is 10 + constraints = [Constraint(type="length", targets=["A", "B"], value=5.0)] + + validator = GeometryValidator(tolerance=0.05) + val_res = validator.validate(engine_result, constraints, is_3d=False) + assert not val_res.is_valid + assert any("Length constraint violated" in err for err in val_res.errors) + + +def test_validator_detects_perpendicularity_violation(): + """Validator should reject non-orthogonal vectors for perpendicular constraint.""" + engine_result = { + "coordinates": { + "A": [0.0, 0.0, 0.0], + "B": [1.0, 0.0, 0.0], + "C": [0.0, 0.0, 0.0], + "D": [1.0, 1.0, 0.0], # 45 deg angle, not 90 deg + }, + "drawing_phases": [], + } + constraints = [Constraint(type="perpendicular", targets=["A", "B", "C", "D"], value=0)] + + validator = GeometryValidator(tolerance=0.05) + val_res = validator.validate(engine_result, constraints, is_3d=False) + assert not val_res.is_valid + assert any("Perpendicularity violated" in err for err in val_res.errors) + + +def test_validator_detects_degenerate_pyramid(): + """Validator should reject a 3D pyramid with collapsed coplanar apex.""" + engine_result = { + "coordinates": { + "S": [0.5, 0.5, 0.0], # Apex on same plane as base + "A": [0.0, 0.0, 0.0], + "B": [1.0, 0.0, 0.0], + "C": [1.0, 1.0, 0.0], + "D": [0.0, 1.0, 0.0], + }, + "solids": [{"type": "pyramid", "apex": "S", "base": ["A", "B", "C", "D"]}], + "drawing_phases": [], + } + validator = GeometryValidator() + val_res = validator.validate(engine_result, [], is_3d=True) + assert not val_res.is_valid + assert any("coplanar with base" in err for err in val_res.errors) + + +# ============================================================================ +# 3. VISUALIZATIONSPEC & CONFIGURATION TESTS +# ============================================================================ + +def test_visualization_spec_show_axes_and_presentation_config(): + """Tests VisualizationSpec separation of geometry vs presentation config.""" + geometry_data = { + "problem": "Hình chóp tam giác S.ABC", + "coordinates": { + "S": [0.0, 0.0, 4.0], + "A": [0.0, 0.0, 0.0], + "B": [3.0, 0.0, 0.0], + "C": [1.5, 2.5, 0.0], + }, + "solids": [{"type": "tetrahedron", "apex": "S", "base": ["A", "B", "C"], "points": ["S", "A", "B", "C"]}], + "solution": {"steps": ["Bước 1: Dựng đáy ABC", "Bước 2: Dựng đỉnh S"]}, + "is_3d": True, + "show_axes": True, + "quality": "1080p", + } + + spec = build_visualization_spec(geometry_data) + + # Verify presentation config + assert spec.config.show_axes is True + assert spec.config.is_3d is True + assert spec.config.quality == "1080p" + assert spec.show_axes is True + + # Verify mathematical geometry preserved + assert len(spec.geometry) >= 4 + point_s = next(g for g in spec.geometry if g.label == "S") + assert point_s.properties["coordinates"] == [0.0, 0.0, 4.0] + + # Verify prompt includes show_axes directive + prompt = spec.to_prompt() + assert "show_axes=True" in prompt + + +def test_visualization_spec_backward_compatibility(): + """Tests backward compatibility with default show_axes and output_config.""" + spec = build_visualization_spec( + problem_text="Tính diện tích tam giác", + coordinates={"A": [0, 0], "B": [4, 0], "C": [0, 3]}, + is_3d=False, + ) + assert spec.config.show_axes is False + assert spec.show_axes is False + assert spec.output_config.format == "mp4" + + +# ============================================================================ +# 4. MANIM CLIENT & LIFECYCLE TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_manim_client_successful_lifecycle(): + """Tests successful lifecycle transitions: queued -> rendering -> completed.""" + client = ManimClient(base_url="http://mock-manim:8001") + spec = build_visualization_spec("Test problem") + + with patch("httpx.AsyncClient.post") as mock_post, patch("httpx.AsyncClient.get") as mock_get: + # 1. Submit job -> returns queued + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {"job_id": "test-uuid-123", "status": "queued"}, + ) + resp = await client.submit_render_job(spec) + assert resp.status == "queued" + assert resp.job_id == "test-uuid-123" + + # 2. Status polling -> generating -> rendering -> completed + call_count = 0 + + def mock_status(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return {"job_id": "test-uuid-123", "status": "generating"} + elif call_count == 2: + return {"job_id": "test-uuid-123", "status": "rendering"} + else: + return { + "job_id": "test-uuid-123", + "status": "completed", + "video_url": "https://cdn.example.com/video.mp4", + } + + mock_get.return_value = MagicMock( + status_code=200, + json=mock_status, + ) + + completed_resp = await client.poll_job_completion("test-uuid-123", timeout=10.0, poll_interval=0.01) + assert completed_resp.status == "completed" + assert completed_resp.video_url == "https://cdn.example.com/video.mp4" + assert completed_resp.is_terminal() is True + + +@pytest.mark.asyncio +async def test_manim_client_unavailable_structured_error(): + """When Manim server is unreachable, returns terminal failed with MANIM_UNAVAILABLE.""" + import httpx + client = ManimClient(base_url="http://unreachable-host:9999") + spec = build_visualization_spec("Test problem") + + with patch("httpx.AsyncClient.post", side_effect=httpx.ConnectError("Connection refused")): + resp = await client.submit_render_job(spec) + assert resp.status == "failed" + assert resp.get_error_code() == ErrorCode.MANIM_UNAVAILABLE + assert "không khả dụng" in (resp.get_error_message() or "") + + +@pytest.mark.asyncio +async def test_manim_client_polling_timeout_terminal_failed(): + """When polling times out, returns terminal failed with MANIM_TIMEOUT (no hanging).""" + client = ManimClient(base_url="http://mock-manim:8001") + + with patch("httpx.AsyncClient.get") as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: {"job_id": "slow-job", "status": "rendering"}, + ) + + resp = await client.poll_job_completion("slow-job", timeout=0.05, poll_interval=0.01) + assert resp.status == "failed" + assert resp.get_error_code() == ErrorCode.MANIM_TIMEOUT + assert resp.is_terminal() is True + + +@pytest.mark.asyncio +async def test_manim_client_404_job_not_found(): + """When job status returns HTTP 404, returns JOB_NOT_FOUND error code.""" + client = ManimClient(base_url="http://mock-manim:8001") + + with patch("httpx.AsyncClient.get") as mock_get: + mock_get.return_value = MagicMock( + status_code=404, + text="Not Found", + ) + + resp = await client.get_job_status("nonexistent-job") + assert resp.status == "failed" + assert resp.get_error_code() == ErrorCode.JOB_NOT_FOUND + + +@pytest.mark.asyncio +async def test_manim_client_render_failed_structured_error(): + """When render job fails on server, returns MANIM_RENDER_FAILED error code.""" + client = ManimClient(base_url="http://mock-manim:8001") + + with patch("httpx.AsyncClient.get") as mock_get: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: { + "job_id": "failed-job", + "status": "failed", + "error": "Manim compilation syntax error at line 42", + }, + ) + + resp = await client.get_job_status("failed-job") + assert resp.status == "failed" + assert resp.get_error_code() == ErrorCode.MANIM_RENDER_FAILED + assert "Manim compilation" in (resp.get_error_message() or "") diff --git a/tests/test_job_poll.py b/tests/test_job_poll.py new file mode 100644 index 0000000000000000000000000000000000000000..255e5f47e60fb9506c96cdfd3abb21698956f55c --- /dev/null +++ b/tests/test_job_poll.py @@ -0,0 +1,33 @@ +"""Job poll normalization for FE contract.""" + +import uuid + +from app.job_poll import normalize_job_row_for_client + + +def test_normalize_adds_job_id_and_parses_result_json_string(): + jid = str(uuid.uuid4()) + row = { + "id": jid, + "status": "success", + "user_id": uuid.uuid4(), + "session_id": uuid.uuid4(), + "result": '{"coordinates": {"A": [0, 1]}}', + "input_text": "x", + } + out = normalize_job_row_for_client(row) + assert out["job_id"] == jid + assert out["id"] == jid + assert out["status"] in ("completed", "success") + assert out["progress"] == 100 + assert isinstance(out["result"], dict) + assert out["result"]["coordinates"]["A"] == [0, 1] + assert isinstance(out["user_id"], str) + assert isinstance(out["session_id"], str) + + +def test_normalize_keeps_dict_result(): + row = {"id": "j1", "status": "processing", "result": None} + out = normalize_job_row_for_client(row) + assert out["job_id"] == "j1" + assert out["result"] is None diff --git a/tests/test_job_state_machine.py b/tests/test_job_state_machine.py new file mode 100644 index 0000000000000000000000000000000000000000..3ea32792a2b325cbb93c3bde9bbcad4ef51ef91a --- /dev/null +++ b/tests/test_job_state_machine.py @@ -0,0 +1,52 @@ +"""Unit tests for P1 Job State Machine & P2 Celery tasks.""" + +import pytest +from app.models.job_state import ( + JobStatus, + JobStage, + JobStateMachine, + InvalidStateTransitionError, + STAGE_PROGRESS_MAP, +) +from app.job_poll import normalize_job_row_for_client + + +def test_job_state_machine_valid_transitions(): + assert JobStateMachine.can_transition(JobStatus.CREATED, JobStatus.QUEUED) + assert JobStateMachine.can_transition(JobStatus.QUEUED, JobStatus.PROCESSING) + assert JobStateMachine.can_transition(JobStatus.PROCESSING, JobStatus.COMPLETED) + assert JobStateMachine.can_transition(JobStatus.PROCESSING, JobStatus.FAILED) + assert JobStateMachine.can_transition(JobStatus.PROCESSING, JobStatus.DEGRADED) + + +def test_job_state_machine_invalid_transitions(): + assert not JobStateMachine.can_transition(JobStatus.COMPLETED, JobStatus.PROCESSING) + assert not JobStateMachine.can_transition(JobStatus.FAILED, JobStatus.COMPLETED) + assert not JobStateMachine.can_transition(JobStatus.CANCELLED, JobStatus.PROCESSING) + + with pytest.raises(InvalidStateTransitionError): + JobStateMachine.validate_transition(JobStatus.COMPLETED, JobStatus.PROCESSING) + + +def test_job_state_machine_normalization(): + assert JobStateMachine.normalize_status("success") == JobStatus.COMPLETED + assert JobStateMachine.normalize_status("error") == JobStatus.FAILED + assert JobStateMachine.normalize_status("rendering_queued") == JobStatus.QUEUED + assert JobStateMachine.normalize_status("geometry") == JobStatus.PROCESSING + + +def test_normalize_job_row_for_client(): + raw_row = { + "id": "123e4567-e89b-12d3-a456-426614174000", + "status": "geometry", + "result": '{"coordinates": {"A": [0, 0]}}', + "user_id": "user-uuid", + "session_id": "session-uuid", + } + normalized = normalize_job_row_for_client(raw_row) + assert normalized["job_id"] == "123e4567-e89b-12d3-a456-426614174000" + assert normalized["status"] == "processing" + assert normalized["stage"] == "geometry" + assert normalized["progress"] == STAGE_PROGRESS_MAP[JobStage.GEOMETRY] + assert isinstance(normalized["result"], dict) + assert normalized["result"]["coordinates"]["A"] == [0, 0] diff --git a/tests/test_llm_agent_runtime.py b/tests/test_llm_agent_runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..3a790e6aac4201335ffe87af9452dd2a63ae64cc --- /dev/null +++ b/tests/test_llm_agent_runtime.py @@ -0,0 +1,102 @@ +import pytest +import asyncio +from typing import Tuple, Any, Dict, List +from config.schemas import ModelTier, AgentConfig, AgentModelsConfig +from config.loader import load_agent_config, AgentConfigResolver +from config.settings import Settings, parse_comma_separated_keys +from llm.errors import ErrorCategory, ErrorClassifier +from llm.key_state import KeyState, KeyMetadata, MemoryKeyStateStore, hash_key +from llm.key_pool import APIKeyPool +from llm.telemetry import LLMTelemetryRecord, LLMTelemetry +from agents.runtime import AgentRuntime + + +def test_parse_comma_separated_keys(): + raw = "key1, key2 , 'key3', \"key4\"" + keys = parse_comma_separated_keys(raw) + assert keys == ["key1", "key2", "key3", "key4"] + + +def test_agent_models_config_schema_validation(): + tier1 = ModelTier(model="gemini/gemini-3.5-flash-lite", max_attempts=1, reasoning_effort="low") + tier2 = ModelTier(model="gemini/gemini-3.5-flash", max_attempts=1, reasoning_effort="medium") + agent = AgentConfig( + name="test_agent", + description="Testing agent schema", + tiers=[tier1, tier2], + temperature=0.1, + max_tokens=4096, + timeout_seconds=60, + ) + config = AgentModelsConfig(version=2, agents={"test_agent": agent}) + assert config.version == 2 + assert "test_agent" in config.agents + assert len(config.agents["test_agent"].tiers) == 2 + assert config.agents["test_agent"].tiers[0].reasoning_effort == "low" + assert config.agents["test_agent"].tiers[1].reasoning_effort == "medium" + + +def test_error_classifier(): + assert ErrorClassifier.classify(Exception("429 Too Many Requests")) == ErrorCategory.RATE_LIMIT + assert ErrorClassifier.classify(Exception("API_KEY_INVALID: User not authorized")) == ErrorCategory.AUTH_ERROR + assert ErrorClassifier.classify(Exception("Daily quota exceeded for project")) == ErrorCategory.QUOTA_EXHAUSTED + assert ErrorClassifier.classify(Exception("Connection reset by peer")) == ErrorCategory.NETWORK + assert ErrorClassifier.classify(Exception("Internal Server Error 500")) == ErrorCategory.SERVER_ERROR + assert ErrorClassifier.classify(Exception("Request timed out")) == ErrorCategory.TIMEOUT + + +@pytest.mark.asyncio +async def test_key_pool_round_robin_and_cooldown(): + store = MemoryKeyStateStore() + pool = APIKeyPool(state_store=store) + custom_prov = "test_custom_prov" + pool.register_keys(custom_prov, ["key_alpha", "key_beta", "key_gamma"]) + + # First rotation + k1, h1 = await pool.get_next_key(custom_prov) + k2, h2 = await pool.get_next_key(custom_prov) + k3, h3 = await pool.get_next_key(custom_prov) + + assert [k1, k2, k3] == ["key_alpha", "key_beta", "key_gamma"] + + # Put key_alpha on cooldown + await pool.mark_cooldown("key_alpha", retry_after=120) + + # Next key should skip key_alpha + k_next, _ = await pool.get_next_key(custom_prov) + assert k_next in ("key_beta", "key_gamma") + + +@pytest.mark.asyncio +async def test_agent_runtime_validator_cascade(): + """Simulates Tier 1 (3.5-flash-lite, low) failing validation and Tier 2 (3.5-flash, medium) succeeding validation on geometry_parser.""" + call_history = [] + reasoning_efforts = [] + + class MockLLMService: + async def acomplete(self, model: str, messages: list, reasoning_effort: str = None, **kwargs) -> str: + call_history.append(model) + reasoning_efforts.append(reasoning_effort) + if "lite" in model: + return "INVALID_OUTPUT_FROM_TIER_1" + return '{"type": "pyramid", "analysis": "Valid analysis from Tier 2"}' + + runtime = AgentRuntime(llm_service=MockLLMService()) + + def mock_validator(raw_output: str) -> Tuple[bool, Any]: + if "INVALID" in raw_output: + return False, "Malformed analysis output" + return True, {"valid": True, "raw": raw_output} + + messages = [{"role": "user", "content": "Analyze problem"}] + res = await runtime.run( + agent="geometry_parser", + messages=messages, + validator=mock_validator, + ) + + assert res["valid"] is True + # Verify that Tier 1 (lite) was attempted with reasoning_effort='low' and escalated to Tier 2 with reasoning_effort='medium' + assert any("lite" in m for m in call_history) + assert any("3.5-flash" in m and "lite" not in m for m in call_history) + assert reasoning_efforts == ["low", "medium"] diff --git a/tests/test_math_ocr_canonical.py b/tests/test_math_ocr_canonical.py new file mode 100644 index 0000000000000000000000000000000000000000..a591abb778478de1492dfef0117674b45f417230 --- /dev/null +++ b/tests/test_math_ocr_canonical.py @@ -0,0 +1,103 @@ +import pytest +import numpy as np +from PIL import Image, ImageDraw + +from vision_ocr.canonical_schema import CanonicalOCRResult, OCRElement +from vision_ocr.pix2text_engine import Pix2TextOCREngine +from vision_ocr.pipeline import OcrVisionPipeline + + +def create_sample_math_image() -> Image.Image: + """Generates a test image with text and math symbols.""" + img = Image.new("RGB", (600, 200), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + draw.text((20, 30), "Cho hình chóp S.ABC có đáy là tam giác vuông.", fill=(0, 0, 0)) + draw.text((20, 80), "Diện tích đáy S_ABC = 1/2 * a * b = 24", fill=(0, 0, 0)) + draw.text((20, 130), "Chiều cao h = 10. Tính thể tích V = 1/3 * S * h", fill=(0, 0, 0)) + return img + + +def test_canonical_ocr_schema(): + """Validates the Canonical OCR schema structure and types.""" + elem1 = OCRElement( + id=0, + type="text", + text="Cho hình chóp S.ABCD", + bbox=[10, 20, 300, 50], + reading_order=0, + confidence=0.98, + ) + elem2 = OCRElement( + id=1, + type="isolated_formula", + text="$$V = \\frac{1}{3} S_{day} h$$", + latex="V = \\frac{1}{3} S_{day} h", + bbox=[10, 60, 250, 100], + reading_order=1, + confidence=0.99, + ) + + result = CanonicalOCRResult( + text="Cho hình chóp S.ABCD\n$$V = \\frac{1}{3} S_{day} h$$", + latex=["V = \\frac{1}{3} S_{day} h"], + elements=[elem1, elem2], + reading_order=[0, 1], + confidence=0.985, + metadata={"width": 600, "height": 400}, + ) + + data = result.to_dict() + assert data["text"] == "Cho hình chóp S.ABCD\n$$V = \\frac{1}{3} S_{day} h$$" + assert len(data["latex"]) == 1 + assert data["latex"][0] == "V = \\frac{1}{3} S_{day} h" + assert len(data["elements"]) == 2 + assert data["elements"][1]["type"] == "isolated_formula" + assert data["confidence"] == 0.985 + assert data["reading_order"] == [0, 1] + + +def test_pix2text_engine_parsing(): + """Tests the parsing layer of Pix2Text raw output into CanonicalOCRResult.""" + engine = Pix2TextOCREngine() + + raw_p2t_mock = [ + { + "type": "text", + "text": "Cho hình chóp tam giác đều $S.ABC$ có cạnh đáy bằng $6$.", + "position": [[10, 10], [400, 10], [400, 40], [10, 40]], + "score": 0.95, + }, + { + "type": "isolated_formula", + "text": "S_{ABC} = \\frac{a^2\\sqrt{3}}{4}", + "position": [[10, 50], [300, 50], [300, 90], [10, 90]], + "score": 0.99, + }, + ] + + canonical = engine._parse_pix2text_output(raw_p2t_mock, {"width": 500, "height": 200}) + + assert isinstance(canonical, CanonicalOCRResult) + assert len(canonical.elements) == 2 + assert "S.ABC" in canonical.text + assert "$$S_{ABC} = \\frac{a^2\\sqrt{3}}{4}$$" in canonical.text + assert len(canonical.latex) >= 2 # inline $S.ABC$, $6$ and isolated formula + assert canonical.elements[1].type == "isolated_formula" + assert canonical.elements[1].bbox == [10, 50, 300, 90] + assert canonical.confidence > 0.9 + + +@pytest.mark.asyncio +async def test_ocr_vision_pipeline_integration(tmp_path): + """Tests OcrVisionPipeline with a generated math image.""" + test_img = create_sample_math_image() + img_path = str(tmp_path / "test_math.png") + test_img.save(img_path) + + pipeline = OcrVisionPipeline() + canonical = await pipeline.process_image_canonical(img_path) + + assert isinstance(canonical, CanonicalOCRResult) + assert isinstance(canonical.text, str) + assert canonical.metadata.get("width") == 600 + assert canonical.metadata.get("height") == 200 diff --git a/tests/test_ocr_preview.py b/tests/test_ocr_preview.py new file mode 100644 index 0000000000000000000000000000000000000000..11e0bbe0bcf3e5f346fa553e71ea77e02ef68ae4 --- /dev/null +++ b/tests/test_ocr_preview.py @@ -0,0 +1,100 @@ +"""Tests for POST /api/v1/sessions/{session_id}/ocr_preview (auth + owner + merge).""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +os.environ.setdefault("ALLOW_TEST_BYPASS", "true") + +from app.main import app # noqa: E402 + +_VALID_SESSION_ID = "00000000-0000-0000-0000-000000000099" + + +@pytest.fixture +def auth_headers(): + return {"Authorization": "Test test-user-ocr-preview"} + + +@pytest.mark.asyncio +async def test_ocr_preview_requires_auth(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/ocr_preview", + files={"file": ("t.png", b"\x89PNG\r\n\x1a\n", "image/png")}, + data={"user_message": "hello"}, + ) + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_ocr_preview_forbidden_when_not_owner(auth_headers): + with patch("app.routers.solve.session_owned_by_user", return_value=False): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/ocr_preview", + headers=auth_headers, + files={"file": ("t.png", b"\x89PNG\r\n\x1a\n", "image/png")}, + data={"user_message": "note"}, + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_ocr_preview_success_merges_draft(auth_headers): + mock_orch = MagicMock() + mock_orch.ocr_agent.process_image = AsyncMock(return_value="OCR_LINE") + + with ( + patch("app.routers.solve.session_owned_by_user", return_value=True), + patch("app.routers.solve.get_orchestrator", return_value=mock_orch), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/ocr_preview", + headers=auth_headers, + files={"file": ("t.png", b"\x89PNG\r\n\x1a\n", "image/png")}, + data={"user_message": " my note "}, + ) + assert res.status_code == 200, res.text + data = res.json() + assert data["ocr_text"] == "OCR_LINE" + assert data["user_message"] == "my note" + assert data["combined_draft"] == "my note\n\nOCR_LINE" + mock_orch.ocr_agent.process_image.assert_called_once() + + +@pytest.mark.asyncio +async def test_ocr_preview_rejects_oversized_file(auth_headers): + mock_orch = MagicMock() + mock_orch.ocr_agent.process_image = AsyncMock(return_value="") + + big = b"x" * (11 * 1024 * 1024) + with ( + patch("app.routers.solve.session_owned_by_user", return_value=True), + patch("app.routers.solve.get_orchestrator", return_value=mock_orch), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/ocr_preview", + headers=auth_headers, + files={"file": ("huge.png", big, "image/png")}, + ) + assert res.status_code == 413 + mock_orch.ocr_agent.process_image.assert_not_called() + + +@pytest.mark.asyncio +async def test_ocr_preview_rejects_empty_file(auth_headers): + with patch("app.routers.solve.session_owned_by_user", return_value=True): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/ocr_preview", + headers=auth_headers, + files={"file": ("empty.png", b"", "image/png")}, + ) + assert res.status_code == 400 diff --git a/tests/test_p0_p1_geometry_pipeline.py b/tests/test_p0_p1_geometry_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..be83800db6ba9e68019eafa822df34abac9a4b67 --- /dev/null +++ b/tests/test_p0_p1_geometry_pipeline.py @@ -0,0 +1,232 @@ +"""Tests for P0 (Semantic Constraints, Derived Constraint Compilation, Validation) +and P1 (Hierarchical Constructors, Canonical 2D/3D Placement, Explicit Coordinate Preservation). +""" +from __future__ import annotations + +import math +import numpy as np +import pytest + +from solver.dsl_parser import DSLParser +from solver.compiler import ConstraintCompiler +from solver.constructors import StandardGeometryConstructor +from solver.engine import GeometryEngine +from solver.validator import GeometryValidator, ValidationResult +from solver.models import Point, Constraint + + +# ============================================================================ +# P0 TESTS: SEMANTIC AUDIT & DERIVED CONSTRAINT COMPILATION +# ============================================================================ + +def test_p0_height_derived_constraints(): + """ + HEIGHT(S, O, ABCD) must compile into: + - O on plane(ABCD) + - SO perp to plane(ABCD) + - segment SO + """ + dsl = """ + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 6) + HEIGHT(S, O, ABCD, 10) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d + + c_types = [c.type for c in constraints] + assert "point_on_plane" in c_types + assert "perp_plane" in c_types + assert "length" in c_types + + # Ensure O and S were declared + pt_ids = [p.id for p in points] + assert "S" in pt_ids and "O" in pt_ids + + # Solve and validate + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + # Verify O is on z=0 and S is directly above O along +Z + assert pytest.approx(coords["O"][2], abs=1e-3) == 0.0 + assert pytest.approx(coords["S"][0], abs=1e-3) == coords["O"][0] + assert pytest.approx(coords["S"][1], abs=1e-3) == coords["O"][1] + assert pytest.approx(coords["S"][2], abs=1e-3) == 10.0 + + +def test_p0_foot_and_median_derived_constraints(): + """ + FOOT(H, P, AB) compiles to H on AB, PH perp AB. + MEDIAN(A, M, BC) compiles to M midpoint of BC. + """ + dsl = """ + TRIANGLE(ABC) + POINT(A, 0, 4) + POINT(B, -3, 0) + POINT(C, 3, 0) + FOOT(H, A, BC) + MEDIAN(A, M, BC) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + c_types = [c.type for c in constraints] + assert "point_on" in c_types + assert "perpendicular" in c_types + assert "midpoint" in c_types + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + # In symmetric triangle with A(0,4), B(-3,0), C(3,0): + # Foot H of A on BC is (0, 0) + # Median M of BC is (0, 0) + assert pytest.approx(coords["H"][:2], abs=1e-3) == [0.0, 0.0] + assert pytest.approx(coords["M"][:2], abs=1e-3) == [0.0, 0.0] + + +def test_p0_square_and_rectangle_derived_properties(): + """ + SQUARE(ABCD) compiler expands into equal sides, perpendicular adjacent edges, + and equal/orthogonal diagonals. + """ + dsl = """ + SQUARE(ABCD) + LENGTH(AB, 5) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + # Solve + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + # Verify sides + vA = np.array(coords["A"][:2]) + vB = np.array(coords["B"][:2]) + vC = np.array(coords["C"][:2]) + vD = np.array(coords["D"][:2]) + + assert pytest.approx(np.linalg.norm(vB - vA), rel=1e-3) == 5.0 + assert pytest.approx(np.linalg.norm(vC - vB), rel=1e-3) == 5.0 + assert pytest.approx(np.linalg.norm(vD - vC), rel=1e-3) == 5.0 + assert pytest.approx(np.linalg.norm(vA - vD), rel=1e-3) == 5.0 + + # Diagonals equal and perpendicular + diag1 = vC - vA + diag2 = vD - vB + assert pytest.approx(np.linalg.norm(diag1), rel=1e-3) == pytest.approx(np.linalg.norm(diag2), rel=1e-3) + assert pytest.approx(np.dot(diag1, diag2), abs=1e-3) == 0.0 + + +# ============================================================================ +# P1 TESTS: CANONICAL GEOMETRY CONSTRUCTORS & PLACEMENT +# ============================================================================ + +def test_p1_canonical_pyramid_hierarchy(): + """ + Pyramid S.ABCD with square base AB=4, SO=8. + Ensures hierarchical canonical construction: + - Base ABCD on z=0 + - Center O at origin or (2, 2, 0) + - Apex S directly above O along +Z + - Scale strictly preserved (AB=4, SO=8) + """ + dsl = """ + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 4) + CENTER(O, ABCD) + HEIGHT(S, O, ABCD, 8) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + # All base points on z=0 + for p in ["A", "B", "C", "D", "O"]: + assert pytest.approx(coords[p][2], abs=1e-3) == 0.0 + + # Base side = 4 + vA = np.array(coords["A"]) + vB = np.array(coords["B"]) + assert pytest.approx(np.linalg.norm(vB - vA), rel=1e-3) == 4.0 + + # Apex S is at (Ox, Oy, 8) + vO = np.array(coords["O"]) + vS = np.array(coords["S"]) + assert pytest.approx(vS[0], abs=1e-3) == vO[0] + assert pytest.approx(vS[1], abs=1e-3) == vO[1] + assert pytest.approx(vS[2], abs=1e-3) == 8.0 + assert pytest.approx(np.linalg.norm(vS - vO), rel=1e-3) == 8.0 + + +def test_p1_preserves_explicit_user_coordinates(): + """ + If explicit coordinates are provided, canonicalization must honor them + without arbitrary rotation or relocation. + """ + dsl = """ + POINT(A, 1, 2, 3) + POINT(B, 5, 2, 3) + POINT(C, 5, 6, 3) + POINT(D) + RECTANGLE(ABCD) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + # Explicit coordinates MUST match exactly + assert pytest.approx(coords["A"], abs=1e-3) == [1.0, 2.0, 3.0] + assert pytest.approx(coords["B"], abs=1e-3) == [5.0, 2.0, 3.0] + assert pytest.approx(coords["C"], abs=1e-3) == [5.0, 6.0, 3.0] + # D must complete the rectangle at (1, 6, 3) + assert pytest.approx(coords["D"], abs=1e-3) == [1.0, 6.0, 3.0] + + +def test_p1_canonical_prism_construction(): + """ + Prism ABC.DEF with equilateral base side 6, height 12. + Base 1 on z=0, Base 2 on z=12. + """ + dsl = """ + PRISM(ABC_DEF) + EQUILATERAL_TRIANGLE(ABC, 6) + LENGTH(AD, 12) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + coords = result["coordinates"] + + for p in ["A", "B", "C"]: + assert pytest.approx(coords[p][2], abs=1e-3) == 0.0 + + for p in ["D", "E", "F"]: + assert pytest.approx(coords[p][2], abs=1e-3) == 12.0 + + # Check lateral lengths + assert pytest.approx(np.linalg.norm(np.array(coords["D"]) - np.array(coords["A"])), rel=1e-3) == 12.0 + assert pytest.approx(np.linalg.norm(np.array(coords["E"]) - np.array(coords["B"])), rel=1e-3) == 12.0 + assert pytest.approx(np.linalg.norm(np.array(coords["F"]) - np.array(coords["C"])), rel=1e-3) == 12.0 diff --git a/tests/test_real_llm.py b/tests/test_real_llm.py new file mode 100644 index 0000000000000000000000000000000000000000..3658696404965051700d5e1de84b30bacb1bc4dd --- /dev/null +++ b/tests/test_real_llm.py @@ -0,0 +1,44 @@ +import asyncio +import logging +import os + +import pytest +from dotenv import load_dotenv + +from app.llm_client import get_llm_client + +logging.basicConfig(level=logging.INFO) +load_dotenv() + + +def _openrouter_configured() -> bool: + return bool(os.getenv("OPENROUTER_API_KEY_1") or os.getenv("OPENROUTER_API_KEY")) + + +@pytest.mark.real_agents +@pytest.mark.asyncio +async def test_real_llm(): + if not _openrouter_configured(): + pytest.skip("OPENROUTER_API_KEY_1 or OPENROUTER_API_KEY not set") + + client = get_llm_client() + if getattr(client, "client", None) is None: + pytest.skip("LLM client not configured") + + content = await client.chat_completions_create( + messages=[ + { + "role": "system", + "content": ( + "You are a Geometry Expert. Give a short step-by-step reasoning for the distance " + "between midpoints M of AB and N of AD in rectangle ABCD with AB=10 and AD=20." + ), + }, + {"role": "user", "content": "Solve briefly."}, + ] + ) + assert isinstance(content, str) and len(content.strip()) > 20 + + +if __name__ == "__main__": + asyncio.run(test_real_llm()) diff --git a/tests/test_schema_helpers.py b/tests/test_schema_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..d22731058cda0ab4f9d9054bf729645ac5d91d59 --- /dev/null +++ b/tests/test_schema_helpers.py @@ -0,0 +1,10 @@ +"""Unit tests that avoid importing the full FastAPI app (no Supabase).""" + +from app.ocr_text_merge import build_combined_ocr_preview_draft + + +def test_build_combined_ocr_preview_draft(): + assert build_combined_ocr_preview_draft(None, "only ocr") == "only ocr" + assert build_combined_ocr_preview_draft("", "only ocr") == "only ocr" + assert build_combined_ocr_preview_draft(" caption ", "") == "caption" + assert build_combined_ocr_preview_draft("a", "b") == "a\n\nb" diff --git a/tests/test_solve_multipart.py b/tests/test_solve_multipart.py new file mode 100644 index 0000000000000000000000000000000000000000..73e2d2b227eb7f8935b6240ad6d60abdd30ad49c --- /dev/null +++ b/tests/test_solve_multipart.py @@ -0,0 +1,119 @@ +"""Tests for POST /api/v1/sessions/{session_id}/solve_multipart.""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +os.environ.setdefault("ALLOW_TEST_BYPASS", "true") + +from app.main import app # noqa: E402 +from app.models.schemas import SolveResponse # noqa: E402 + +_VALID_SESSION_ID = "00000000-0000-0000-0000-000000000088" + +# PNG signature + padding (>= 12 bytes) for magic check in validate_chat_image_bytes +_VALID_PNG_BODY = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 + + +@pytest.fixture +def auth_headers(): + return {"Authorization": "Test test-user-solve-mp"} + + +@pytest.mark.asyncio +async def test_solve_multipart_requires_auth(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/solve_multipart", + files={"file": ("t.png", _VALID_PNG_BODY, "image/png")}, + data={"text": "hi"}, + ) + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_solve_multipart_forbidden(auth_headers): + with patch("app.routers.solve.session_owned_by_user", return_value=False): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/solve_multipart", + headers=auth_headers, + files={"file": ("t.png", _VALID_PNG_BODY, "image/png")}, + data={"text": "hi"}, + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_solve_multipart_empty_text(auth_headers): + with patch("app.routers.solve.session_owned_by_user", return_value=True): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/solve_multipart", + headers=auth_headers, + files={"file": ("t.png", _VALID_PNG_BODY, "image/png")}, + data={"text": " "}, + ) + assert res.status_code == 400 + + +@pytest.mark.asyncio +async def test_solve_multipart_bad_magic(auth_headers): + with patch("app.routers.solve.session_owned_by_user", return_value=True): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/solve_multipart", + headers=auth_headers, + files={"file": ("t.png", b"not-a-real-png!!", "image/png")}, + data={"text": "problem text"}, + ) + assert res.status_code == 400 + + +@pytest.mark.asyncio +async def test_solve_multipart_upload_then_enqueue(auth_headers): + up = { + "public_url": "https://example.test/bucket/sessions/s1/image_v1_j.png", + "storage_path": f"sessions/{_VALID_SESSION_ID}/image_v1_job.png", + "version": 1, + "session_asset_id": "00000000-0000-0000-0000-000000000099", + } + captured = {} + + def fake_enqueue(supabase, background_tasks, session_id, user_id, uid, request, message_metadata, job_id): + captured["metadata"] = message_metadata + captured["job_id"] = job_id + captured["request"] = request + return SolveResponse(job_id=job_id, status="processing") + + with ( + patch("app.routers.solve.session_owned_by_user", return_value=True), + patch("app.routers.solve.upload_session_chat_image", return_value=up) as up_mock, + patch("app.routers.solve._enqueue_solve_common", side_effect=fake_enqueue), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.post( + f"/api/v1/sessions/{_VALID_SESSION_ID}/solve_multipart", + headers=auth_headers, + files={"file": ("t.png", _VALID_PNG_BODY, "image/png")}, + data={"text": " my problem "}, + ) + assert res.status_code == 200, res.text + data = res.json() + assert data["status"] == "processing" + jid = data["job_id"] + assert jid + up_mock.assert_called_once() + call_args = up_mock.call_args[0] + assert call_args[0] == _VALID_SESSION_ID + assert call_args[1] == jid + assert len(call_args[2]) == len(_VALID_PNG_BODY) + att = captured["metadata"].get("attachment", {}) + assert att.get("size_bytes") == len(_VALID_PNG_BODY) + assert att.get("public_url") == up["public_url"] + assert captured["request"].text == "my problem" + assert captured["request"].image_url == up["public_url"] diff --git a/tests/test_solver.py b/tests/test_solver.py new file mode 100644 index 0000000000000000000000000000000000000000..555d1dbc10a5e9dc79c1b94e8201d81b9f10e463 --- /dev/null +++ b/tests/test_solver.py @@ -0,0 +1,44 @@ +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from solver.engine import GeometryEngine +from solver.models import Point, Constraint + +def test_triangle_abc(): + engine = GeometryEngine() + + # Triangle ABC: AB=5, AC=7, angle A=60 + points = [ + Point(id="A"), + Point(id="B"), + Point(id="C") + ] + + constraints = [ + Constraint(type="length", targets=["A", "B"], value=5.0), + Constraint(type="length", targets=["A", "C"], value=7.0), + Constraint(type="angle", targets=["A"], value=60.0) # Angle at A + ] + + print("Solving for Triangle ABC (AB=5, AC=7, angle A=60)...") + results = engine.solve(points, constraints) + + if results: + coords = results["coordinates"] + print("Success! Coordinates:") + for pid, c in coords.items(): + print(f"Point {pid}: {c}") + + # Verify distance AB + dist_ab = ((coords["B"][0] - coords["A"][0])**2 + (coords["B"][1] - coords["A"][1])**2)**0.5 + print(f"Verified AB distance: {dist_ab:.2f}") + + # Verify distance AC + dist_ac = ((coords["C"][0] - coords["A"][0])**2 + (coords["C"][1] - coords["A"][1])**2)**0.5 + print(f"Verified AC distance: {dist_ac:.2f}") + else: + print("Solver failed.") + +if __name__ == "__main__": + test_triangle_abc() diff --git a/tests/test_visualization_graph_and_topology.py b/tests/test_visualization_graph_and_topology.py new file mode 100644 index 0000000000000000000000000000000000000000..ec13b734aff800b23cfcc181afcd110720a32f77 --- /dev/null +++ b/tests/test_visualization_graph_and_topology.py @@ -0,0 +1,433 @@ +"""Comprehensive Regression & Acceptance Tests for the Visualization Graph and Topology Pipeline. + +Verifies: +1. Mathematical Geometry Graph vs Visualization Graph separation. +2. Complete 3D Solid Topology (Pyramid, Prism, Cube, Cuboid, Tetrahedron). +3. Automatic derivation of visual topology (vertices, edges, faces, connectivity). +4. Solution-dependent Auxiliary Geometry (Heights, Feet, Medians, Bisectors, Diagonals). +5. Minimal Sufficient Graph & Importance Tiers (REQUIRED, HELPFUL, OPTIONAL). +6. Surface & Face representations with cyclic vertex order and parent solid metadata. +7. VisualizationSpec integration and schema serialization. +""" +from __future__ import annotations + +import pytest +import numpy as np + +from solver.dsl_parser import DSLParser +from solver.engine import GeometryEngine +from solver.vis_graph import ( + EdgeStyle, + EntityKind, + ImportanceTier, + VisualizationGraph, +) +from solver.vis_planner import VisualizationPlanner +from manim_client.schemas import build_visualization_spec, VisualizationSpec + + +# ============================================================================ +# 1. 2D POLYGON TOPOLOGY & AUXILIARY CONSTRUCTIONS +# ============================================================================ + +def test_2d_rectangle_with_diagonals_and_midpoint(): + """ + 2D Rectangle ABCD with center O and midpoint M of AB. + Verifies that Visualization Graph contains: + - 4 primary vertices + auxiliary center O + auxiliary midpoint M + - 4 perimeter edges + 2 diagonal edges + auxiliary segments + - 1 polygon face + - Correct importance tiers. + """ + dsl = """ + RECTANGLE(ABCD) + LENGTH(AB, 8) + LENGTH(BC, 6) + CENTER(O, ABCD) + MIDPOINT(M, AB) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert not is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + assert "visualization_graph" in result + + vis_graph_data = result["visualization_graph"] + vis_graph = VisualizationGraph.model_validate(vis_graph_data) + + # 1. Vertices + assert "A" in vis_graph.vertices + assert "B" in vis_graph.vertices + assert "C" in vis_graph.vertices + assert "D" in vis_graph.vertices + assert "O" in vis_graph.vertices + assert "M" in vis_graph.vertices + + assert vis_graph.vertices["O"].role == "center" + assert vis_graph.vertices["O"].kind == EntityKind.AUXILIARY + assert vis_graph.vertices["M"].role == "midpoint" + + # 2. Edges: Perimeter + Diagonals + edge_ids = list(vis_graph.edges.keys()) + assert any("A" in eid and "B" in eid for eid in edge_ids) + assert any("B" in eid and "C" in eid for eid in edge_ids) + assert any("C" in eid and "D" in eid for eid in edge_ids) + assert any("A" in eid and "D" in eid for eid in edge_ids) + # Diagonals AC and BD + assert any("A" in eid and "C" in eid for eid in edge_ids) + assert any("B" in eid and "D" in eid for eid in edge_ids) + + # 3. Faces + assert len(vis_graph.faces) >= 1 + face = next(iter(vis_graph.faces.values())) + assert len(face.vertices) == 4 + assert set(face.vertices) == {"A", "B", "C", "D"} + + # 4. Minimal Sufficient Graph + min_graph = vis_graph.get_minimal_sufficient_graph(ImportanceTier.REQUIRED) + assert "A" in min_graph["vertices"] + assert "B" in min_graph["vertices"] + + +def test_2d_triangle_with_median_and_foot_altitude(): + """ + 2D Triangle ABC with foot of altitude H and median AM. + Verifies automatic derivation of auxiliary lines and perpendicular marks. + """ + dsl = """ + TRIANGLE(ABC) + POINT(A, 0, 4) + POINT(B, -3, 0) + POINT(C, 3, 0) + FOOT(H, A, BC) + MEDIAN(A, M, BC) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + vis_graph = VisualizationGraph.model_validate(result["visualization_graph"]) + + # Foot H and Median M + assert "H" in vis_graph.vertices + assert "M" in vis_graph.vertices + assert vis_graph.vertices["H"].role == "foot" + + # Auxiliary construction records + aux_types = [a.type for a in vis_graph.auxiliary] + assert "foot" in aux_types + assert "median" in aux_types + + # Edges include AH and AM + edge_ids = list(vis_graph.edges.keys()) + assert any("A" in eid and "H" in eid for eid in edge_ids) + assert any("A" in eid and "M" in eid for eid in edge_ids) + + +# ============================================================================ +# 2. 3D SOLID TOPOLOGY (PYRAMID, PRISM, CUBE, CUBOID, TETRAHEDRON) +# ============================================================================ + +def test_3d_pyramid_full_topology_and_height(): + """ + Square Pyramid S.ABCD with height SO = 8. + Verifies: + - Complete Solid Topology: 5 vertices, 8 primary edges, 5 faces (1 base + 4 lateral). + - Height SO auxiliary construction with dashed style. + - Base diagonals AC, BD automatically derived to ground foot O. + - Solid-to-face and solid-to-edge connectivity. + """ + dsl = """ + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 6) + CENTER(O, ABCD) + HEIGHT(S, O, ABCD, 8) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + vis_graph = VisualizationGraph.model_validate(result["visualization_graph"]) + + # 1. Solid Topology Record + assert len(vis_graph.solids) >= 1 + solid = next(iter(vis_graph.solids.values())) + assert solid.type == "pyramid" + assert solid.apex == "S" + assert set(solid.base_vertices) == {"A", "B", "C", "D"} + assert len(solid.edges) == 8 # 4 base + 4 lateral + assert len(solid.faces) == 5 # 1 base + 4 lateral + + # 2. Faces (1 quadrilateral base + 4 triangular lateral faces) + assert len(vis_graph.faces) >= 5 + base_faces = [f for f in vis_graph.faces.values() if f.role == "base_face"] + lat_faces = [f for f in vis_graph.faces.values() if f.role == "lateral_face"] + assert len(base_faces) == 1 + assert len(lat_faces) == 4 + assert set(base_faces[0].vertices) == {"A", "B", "C", "D"} + + # 3. Altitude SO & Base Diagonals + edge_so = next((e for e in vis_graph.edges.values() if "S" in e.id and "O" in e.id), None) + assert edge_so is not None + assert edge_so.role == "altitude" + assert edge_so.style == EdgeStyle.DASHED + + # Diagonals AC and BD exist to anchor O + assert any("A" in e.id and "C" in e.id for e in vis_graph.edges.values()) + assert any("B" in e.id and "D" in e.id for e in vis_graph.edges.values()) + + # 4. Auxiliary Entity Record + height_aux = next((a for a in vis_graph.auxiliary if a.type == "height"), None) + assert height_aux is not None + assert height_aux.source_entity == "S" + assert height_aux.target_entity == "O" + + +def test_3d_triangular_prism_topology(): + """ + Triangular Prism ABC.DEF with base side 5 and height 10. + Verifies: + - 6 vertices (A, B, C, D, E, F). + - 9 edges (3 base1 + 3 base2 + 3 lateral). + - 5 faces (2 triangular bases + 3 rectangular lateral faces). + - Face connectivity and parent solid references. + """ + dsl = """ + PRISM(ABC_DEF) + EQUILATERAL_TRIANGLE(ABC, 5) + LENGTH(AD, 10) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + vis_graph = VisualizationGraph.model_validate(result["visualization_graph"]) + + assert len(vis_graph.solids) >= 1 + solid = next(iter(vis_graph.solids.values())) + assert solid.type == "prism" + assert set(solid.base_vertices) == {"A", "B", "C"} + assert set(solid.top_vertices) == {"D", "E", "F"} + assert len(solid.edges) == 9 + assert len(solid.faces) == 5 + + # 2 Base faces (triangles) + 3 Lateral faces (quadrilaterals) + tri_faces = [f for f in vis_graph.faces.values() if len(f.vertices) == 3] + quad_faces = [f for f in vis_graph.faces.values() if len(f.vertices) == 4] + assert len(tri_faces) == 2 + assert len(quad_faces) == 3 + + +def test_3d_cube_topology(): + """ + Cube ABCD.A1B1C1D1 with side a=5. + Verifies: + - 8 vertices. + - 12 edges. + - 6 quadrilateral faces. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 5, 0, 0) + POINT(C, 5, 5, 0) + POINT(D, 0, 5, 0) + POINT(A1) + POINT(B1) + POINT(C1) + POINT(D1) + LENGTH(AA1, 5) + PERPENDICULAR_PLANE(AA1, ABCD) + CUBE(ABCD_A1B1C1D1) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + vis_graph = VisualizationGraph.model_validate(result["visualization_graph"]) + + assert len(vis_graph.vertices) == 8 + assert len(vis_graph.faces) == 6 + for f in vis_graph.faces.values(): + assert len(f.vertices) == 4 + + +def test_3d_tetrahedron_topology(): + """ + Regular Tetrahedron ABCD. + Verifies: + - 4 vertices. + - 6 edges. + - 4 triangular faces. + """ + dsl = """ + TETRAHEDRON(ABCD) + LENGTH(AB, 6) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + vis_graph = VisualizationGraph.model_validate(result["visualization_graph"]) + + assert len(vis_graph.vertices) == 4 + assert len(vis_graph.faces) == 4 + for f in vis_graph.faces.values(): + assert len(f.vertices) == 3 + + +def test_3d_triangular_pyramid_s_abc_with_height_and_auxiliary_midpoint(): + """ + Triangular Pyramid S.ABC with centroid foot H and midpoint M of BC. + Verifies: + - 4 primary vertices (S, A, B, C) + 2 auxiliary (H, M). + - Base edges AB, BC, CA + lateral edges SA, SB, SC. + - 4 faces (1 base + 3 lateral). + - Height SH and median AM auxiliary constructions. + """ + dsl = """ + PYRAMID(S_ABC) + EQUILATERAL_TRIANGLE(ABC, 6) + CENTER(H, ABC) + HEIGHT(S, H, ABC, 9) + MIDPOINT(M, BC) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + assert is_3d + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + vis_graph = VisualizationGraph.model_validate(result["visualization_graph"]) + + assert len(vis_graph.solids) >= 1 + solid = next(iter(vis_graph.solids.values())) + assert solid.type == "pyramid" + assert solid.apex == "S" + assert set(solid.base_vertices) == {"A", "B", "C"} + assert len(solid.faces) == 4 + + # Check auxiliary vertices & edges + assert "H" in vis_graph.vertices + assert "M" in vis_graph.vertices + assert vis_graph.vertices["H"].role in ("foot", "center") + assert vis_graph.vertices["M"].role == "midpoint" + + +def test_3d_cuboid_topology(): + """ + Cuboid ABCD.A1B1C1D1 with length=8, width=6, height=10. + Verifies 8 vertices, 12 edges, 6 faces. + """ + dsl = """ + POINT(A, 0, 0, 0) + POINT(B, 8, 0, 0) + POINT(C, 8, 6, 0) + POINT(D, 0, 6, 0) + POINT(A1) + POINT(B1) + POINT(C1) + POINT(D1) + LENGTH(AA1, 10) + PERPENDICULAR_PLANE(AA1, ABCD) + PRISM(ABCD_A1B1C1D1) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + assert result is not None + + vis_graph = VisualizationGraph.model_validate(result["visualization_graph"]) + + assert len(vis_graph.vertices) == 8 + assert len(vis_graph.faces) == 6 + assert len(vis_graph.edges) >= 12 + + +# ============================================================================ +# 3. VISUALIZATION SPEC INTEGRATION & MINIMAL SUFFICIENT GRAPH +# ============================================================================ + +def test_visualization_spec_rich_topology_generation(): + """ + Verifies that build_visualization_spec generates rich GeometryObject entries + including points, styled segments, faces with opacity, and solid containers. + """ + dsl = """ + PYRAMID(S_ABCD) + SQUARE(ABCD) + LENGTH(AB, 4) + CENTER(O, ABCD) + HEIGHT(S, O, ABCD, 6) + """ + parser = DSLParser() + points, constraints, is_3d = parser.parse(dsl) + engine = GeometryEngine() + result = engine.solve(points, constraints, is_3d) + + spec = build_visualization_spec( + problem_text="Tính thể tích khối chóp S.ABCD", + solution_steps=["Dựng hình chóp S.ABCD với đáy hình vuông.", "Dựng đường cao SO."], + engine_result=result, + is_3d=True, + ) + + assert isinstance(spec, VisualizationSpec) + assert spec.visualization_graph is not None + + types = [g.type for g in spec.geometry] + assert "point_3d" in types + assert "segment_3d" in types + assert "face_3d" in types + assert "pyramid" in types + + # Verify Manim dictionary serialization + manim_dict = spec.to_manim_dict() + assert "geometry" in manim_dict + assert len(manim_dict["geometry"]) >= 5 + assert len(manim_dict["solution_steps"]) == 2 + + +def test_minimal_sufficient_graph_filtering(): + """ + Verifies that get_minimal_sufficient_graph properly filters between + REQUIRED and HELPFUL tiers without dropping critical elements. + """ + graph = VisualizationGraph(is_3d=True) + graph.add_vertex("A", [0, 0, 0], tier=ImportanceTier.REQUIRED) + graph.add_vertex("B", [5, 0, 0], tier=ImportanceTier.REQUIRED) + graph.add_vertex("P_extra", [10, 10, 10], tier=ImportanceTier.OPTIONAL) + + graph.add_edge("A", "B", tier=ImportanceTier.REQUIRED) + graph.add_edge("A", "P_extra", tier=ImportanceTier.OPTIONAL) + + # Filter REQUIRED only + filtered = graph.get_minimal_sufficient_graph(ImportanceTier.REQUIRED) + assert "A" in filtered["vertices"] + assert "B" in filtered["vertices"] + assert "P_extra" not in filtered["vertices"] + assert len(filtered["edges"]) == 1 diff --git a/tests/verify_db_metadata.py b/tests/verify_db_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..04a0be951da10dfd4b4dbbe0e8da6cb9e90e1b62 --- /dev/null +++ b/tests/verify_db_metadata.py @@ -0,0 +1,39 @@ +import os +import json +from app.supabase_client import get_supabase + +def verify_metadata(): + supabase = get_supabase() + + # Get the 5 most recent assistant messages + res = supabase.table("messages") \ + .select("id, role, content, metadata, created_at") \ + .eq("role", "assistant") \ + .order("created_at", desc=True) \ + .limit(5) \ + .execute() + + if not res.data: + print("No assistant messages found.") + return + + for i, msg in enumerate(res.data): + print(f"\n--- Message {i+1} (ID: {msg['id']}, Created: {msg['created_at']}) ---") + metadata = msg.get("metadata", {}) + + required_fields = ["job_id", "coordinates", "polygon_order", "drawing_phases", "circles"] + missing = [f for f in required_fields if f not in metadata] + + if not missing: + print("✅ All mandatory fields present in metadata.") + # Print a snippet of the data + print(f" - job_id: {metadata.get('job_id')}") + print(f" - polygon_order: {metadata.get('polygon_order')}") + print(f" - drawing_phases count: {len(metadata.get('drawing_phases', []))}") + print(f" - circles count: {len(metadata.get('circles', []))}") + else: + print(f"❌ Missing fields in metadata: {missing}") + print(f" Metadata keys: {list(metadata.keys())}") + +if __name__ == "__main__": + verify_metadata() diff --git a/vision_ocr/__init__.py b/vision_ocr/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..495a12157a66b17d7c1b79475b6576b361323593 --- /dev/null +++ b/vision_ocr/__init__.py @@ -0,0 +1,6 @@ +"""Vision-only OCR (YOLO layout load / PaddleOCR / Pix2Tex). No LLM — safe for dedicated OCR workers.""" + +from .compat import allow_ultralytics_weights +from .pipeline import OcrVisionPipeline + +__all__ = ["OcrVisionPipeline", "allow_ultralytics_weights"] diff --git a/vision_ocr/canonical_schema.py b/vision_ocr/canonical_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..96b21ca5ab12f1b4968448ebcfcd491cb43faab9 --- /dev/null +++ b/vision_ocr/canonical_schema.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from pydantic import BaseModel, Field + + +class OCRElement(BaseModel): + """ + Represents an individual recognized layout region/element from the image + (text paragraph, inline formula, standalone equation, table, diagram/figure). + """ + id: int = Field(..., description="Unique index of the element") + type: str = Field( + default="text", + description="Type of region: 'text', 'isolated_formula', 'embedding_formula', 'table', 'figure'", + ) + text: str = Field(default="", description="Extracted text or markdown content") + latex: Optional[str] = Field( + default=None, + description="Clean LaTeX formula code if element represents or contains mathematics", + ) + bbox: List[int] = Field( + default_factory=list, + description="Bounding box [x_min, y_min, x_max, y_max] or polygon points", + ) + reading_order: int = Field( + default=0, description="Sequential index in the document reading order" + ) + confidence: float = Field( + default=1.0, description="Recognition confidence score in range [0.0, 1.0]" + ) + + +class CanonicalOCRResult(BaseModel): + """ + Standardized, canonical OCR output schema (v5.3). + Preserves raw text, LaTeX formulas, layout reading order, and spatial bounding boxes. + """ + text: str = Field( + default="", + description="Full reconstructed Markdown text containing inline and display LaTeX math", + ) + latex: List[str] = Field( + default_factory=list, + description="List of all isolated and embedded LaTeX formulas extracted from the document", + ) + elements: List[OCRElement] = Field( + default_factory=list, + description="Structured list of layout regions and bounding boxes in reading order", + ) + reading_order: List[int] = Field( + default_factory=list, + description="IDs of elements sorted according to reconstructed reading order", + ) + confidence: float = Field( + default=1.0, + description="Overall aggregate confidence score across all recognized regions", + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Additional spatial and image metadata (dimensions, orientation, engine version)", + ) + + def to_dict(self) -> Dict[str, Any]: + return self.model_dump() diff --git a/vision_ocr/compat.py b/vision_ocr/compat.py new file mode 100644 index 0000000000000000000000000000000000000000..cccb635a3b7298de7ebc06ca9ba8f09ec996ed45 --- /dev/null +++ b/vision_ocr/compat.py @@ -0,0 +1,33 @@ +"""PyTorch 2.6+ defaults weights_only=True; Ultralytics YOLO .pt checkpoints unpickle full nn graphs (trusted official weights).""" + +from __future__ import annotations + +import functools + +_torch_load_patched = False + + +def allow_ultralytics_weights() -> None: + """ + Official yolov8n.pt is a trusted checkpoint. PyTorch 2.6+ safe unpickling would require + allowlisting many torch.nn globals; loading with weights_only=False matches Ultralytics + upstream behavior for local .pt files. + """ + global _torch_load_patched + if _torch_load_patched: + return + try: + import torch + + _orig = torch.load + + @functools.wraps(_orig) + def _load(*args, **kwargs): + if "weights_only" not in kwargs: + kwargs["weights_only"] = False + return _orig(*args, **kwargs) + + torch.load = _load + _torch_load_patched = True + except Exception: + pass diff --git a/vision_ocr/pipeline.py b/vision_ocr/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..8b3e39848ae78bc07af410f1b1d2c9891b50ac4a --- /dev/null +++ b/vision_ocr/pipeline.py @@ -0,0 +1,79 @@ +""" +OCR vision pipeline (v5.3). +Powered solely by Pix2Text for unified layout, text, and LaTeX formula recognition. +No LLM hallucination in OCR layer; adheres to pure visual extraction. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, List, Optional, Union +import cv2 + +from vision_ocr.canonical_schema import CanonicalOCRResult +from vision_ocr.pix2text_engine import Pix2TextOCREngine + +logger = logging.getLogger(__name__) + + +class OcrVisionPipeline: + """ + Unified Math OCR Vision Pipeline (v5.3). + Replaces the fragmented multi-engine patchwork with Pix2Text. + """ + + def __init__(self) -> None: + logger.info("[OcrVisionPipeline] Initializing Pix2Text unified engine...") + self.engine = Pix2TextOCREngine.get_instance() + + async def process_image(self, image_path: str) -> str: + """ + Extracts structured text from image and returns markdown text with LaTeX. + Maintains backward compatibility with string-expecting consumers. + """ + canonical = await self.process_image_canonical(image_path) + return canonical.text + + async def process_image_canonical(self, image_path: str) -> CanonicalOCRResult: + """ + Returns the full canonical OCR structure (text, LaTeX list, elements, bboxes, reading order). + """ + logger.info("==[OcrVisionPipeline] Processing image with Pix2Text: %s==", image_path) + if not os.path.exists(image_path): + logger.error("[OcrVisionPipeline] Image file not found: %s", image_path) + return CanonicalOCRResult(text=f"Error: Image not found at {image_path}", confidence=0.0) + + try: + return self.engine.recognize(image_path, return_text=False) + except Exception as e: + logger.error("[OcrVisionPipeline] Processing failed: %s", e) + return CanonicalOCRResult(text="", confidence=0.0) + + async def process_url(self, url: str) -> str: + """ + Downloads image from URL and processes via Pix2Text. + """ + canonical = await self.process_url_canonical(url) + return canonical.text + + async def process_url_canonical(self, url: str) -> CanonicalOCRResult: + import uuid + import urllib.request + + local_filename = f"temp_url_ocr_{uuid.uuid4().hex}.png" + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req) as resp, open(local_filename, "wb") as f: + f.write(resp.read()) + result = await self.process_image_canonical(local_filename) + return result + except Exception as e: + logger.error("[OcrVisionPipeline] process_url failed: %s", e) + return CanonicalOCRResult(text=f"Error processing URL: {e}", confidence=0.0) + finally: + if os.path.exists(local_filename): + try: + os.remove(local_filename) + except Exception: + pass diff --git a/vision_ocr/pix2text_engine.py b/vision_ocr/pix2text_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..5b7ad4dbd30e072bc1f35113ce1a5b7ba53ac62d --- /dev/null +++ b/vision_ocr/pix2text_engine.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import os +import re +import logging +from typing import Any, Dict, List, Optional, Tuple, Union +from PIL import Image +import numpy as np + +from vision_ocr.canonical_schema import CanonicalOCRResult, OCRElement + +logger = logging.getLogger(__name__) + +VIET_MATH_REPLACEMENTS = [ + (r'\bch\s+tam\s+gie\b|\bcho\s+tam\s+giac\b|\bcho\s+tam\s+gie\b', 'Cho tam giác'), + (r'\bA3O\b|\bAB C\b', 'ABC'), + (r'\bvt\s*n\s+tai\b|\bvuong\s+tai\b|\bvuang\s+tai\b', 'vuông tại'), + (r'\bbiét\b|\bbiet\b', 'biết'), + (r'\bTnh\b|\btnh\b|\bTinh\b|\btinh\b', 'Tính'), + (r'\bvidintchtmgiéc\b|\bva\s+dien\s+tich\s+tam\s+giac\b', 'và diện tích tam giác'), + (r'\bchan\s+duing\s+cao\b|\bchan\s+duong\s+cao\b|\bla\s+chan\s+duing\s+cao\b', 'là chân đường cao'), + (r'\btir\b|\bti\b', 'từ'), + (r'\bch\s+hinb\s+hop\s+cht[\'’]?nbat\b|\bcho\s+hinh\s+hop\s+chu\s+nhat\b|\bch\s+hinh\s+hop\b', 'Cho hình hộp chữ nhật'), + (r'\bdo\s+dai\b|\bđo\s+dai\b', 'độ dài'), + (r'\bduing\s+cheo\b|\bduong\s+cheo\b', 'đường chéo'), + (r'\bduing\s+tron\b|\bduong\s+tron\b', 'đường tròn'), + (r'\bduing\s+kinh\b|\bduong\s+kinh\b', 'đường kính'), + (r'\bduing\s+th[aà]ng\b|\bduong\s+thang\b', 'đường thẳng'), + (r'\bc6\b', 'có'), + (r'\bLay\s+di[eé]m\b|\blay\s+diem\b', 'Lấy điểm'), + (r'\bTi[eé]p\s+tuy[eé]+n\s+tai\b|\btiep\s+tuyen\s+tai\b', 'Tiếp tuyến tại'), + (r'\bcat\s+nhau\s+tai\b', 'cắt nhau tại'), + (r'\bla\s+hinh\s+chi[eé]u\s+vuing\s+goc\s+cua\b|\bla\s+hinh\s+chieu\s+vuong\s+goc\s+cua\b|\blà\s+hinh\s+chiéu\s+vuing\s+goc\s+cua\b', 'là hình chiếu vuông góc của'), + (r'\bla\s+giao\s+di[eé]m\s+cua\b|\bla\s+giao\s+diem\s+cua\b|\blà\s+giao\s+diém\s+cua\b', 'là giao điểm của'), + (r'\bChtng\s+minh\s+r[aà]ng\b|\bchung\s+minh\s+rang\b', 'Chứng minh rằng'), + (r'\bv[aà]\b', 'và'), + (r'\bCho\s+hinh\s+ch[oó6]p\b|\bcho\s+hinh\s+chop\b', 'Cho hình chóp'), + (r'\bc6\s+day\b|\bco\s+day\b|\bcó\s+day\b', 'có đáy'), + (r'\bla\s+hinh\s+vu[aá]ng\s+canh\b|\bla\s+hinh\s+vuong\s+canh\b', 'là hình vuông cạnh'), + (r'\bGo\b|\bGoi\b', 'Gọi'), + (r'\bN\s+an\s+ludt\s+la\s+trung\s+di[eé]m\s+cua\b|\bN\s+lan\s+luot\s+la\s+trung\s+diem\s+cua\b', 'N lần lượt là trung điểm của'), + (r'\bXac\s+dinh\s+giao\s+tuy[eé]n\s+cua\s+hai\s+mat\s+ph[aá]ng\b|\bxac\s+dinh\s+giao\s+tuyen\b', 'Xác định giao tuyến của hai mặt phẳng'), + (r'\bTinh\s+khoang\s+cachtu\b|\btinh\s+khoang\s+cach\s+tu\b|\bTính\s+khoang\s+cachtu\b', 'Tính khoảng cách từ'), + (r'\bTinh\s+goc\s+gila\b|\btinh\s+goc\s+giua\b|\bTính\s+goc\s+gila\b', 'Tính góc giữa'), + (r'\bva\s+mat\s+phiang\b|\bva\s+mat\s+phang\b|\bvà\s+mat\s+phiang\b', 'và mặt phẳng'), + (r'\bduing\s+cao\b|\bduong\s+cao\b', 'đường cao'), + (r'\bhinh\s+chi[eé]u\b', 'hình chiếu'), +] + + +class Pix2TextOCREngine: + """ + Unified Math OCR Engine powered by Pix2Text. + Performs simultaneous layout detection, multi-lingual text extraction, + and LaTeX formula recognition with 2D spatial layout sorting. + """ + + _instance: Optional[Pix2TextOCREngine] = None + _p2t_model = None + + def __init__(self, languages: Optional[List[str]] = None): + self.languages = languages or ("en", "vi") + self._init_engine() + + def _init_engine(self): + if Pix2TextOCREngine._p2t_model is None: + try: + logger.info("[Pix2TextOCREngine] Initializing Pix2Text model...") + os.environ.setdefault("HF_ENDPOINT", "https://huggingface.co") + from pix2text import Pix2Text + + Pix2TextOCREngine._p2t_model = Pix2Text.from_config( + enable_formula=True, + enable_table=False, + ) + logger.info("[Pix2TextOCREngine] Pix2Text initialized successfully.") + except Exception as e: + logger.warning("[Pix2TextOCREngine] Could not initialize Pix2Text: %s", e) + Pix2TextOCREngine._p2t_model = None + + @classmethod + def get_instance(cls) -> Pix2TextOCREngine: + if cls._instance is None: + cls._instance = Pix2TextOCREngine() + return cls._instance + + def recognize( + self, + image_input: Union[str, Image.Image, np.ndarray], + return_text: bool = False, + ) -> Union[CanonicalOCRResult, str]: + """ + Processes an image and returns a structured CanonicalOCRResult. + """ + pil_img = self._to_pil_image(image_input) + if pil_img is None: + empty_res = CanonicalOCRResult(text="", confidence=0.0) + return empty_res.text if return_text else empty_res + + width, height = pil_img.size + meta = {"width": width, "height": height, "engine": "Pix2Text"} + + p2t = Pix2TextOCREngine._p2t_model + if p2t is not None: + try: + raw_out = p2t.recognize(pil_img, return_text=False) + return self._parse_and_align_output(raw_out, meta, return_text) + except Exception as e: + logger.error("[Pix2TextOCREngine] Error during recognize: %s. Falling back.", e) + + return self._fallback_recognition(pil_img, meta, return_text) + + def _parse_pix2text_output( + self, + raw_out: Any, + meta: Dict[str, Any], + return_text: bool = False, + ) -> Union[CanonicalOCRResult, str]: + return self._parse_and_align_output(raw_out, meta, return_text) + + def _parse_and_align_output( + self, + raw_out: Any, + meta: Dict[str, Any], + return_text: bool = False, + ) -> Union[CanonicalOCRResult, str]: + parsed_items: List[Dict[str, Any]] = [] + + if isinstance(raw_out, list): + for idx, item in enumerate(raw_out): + if not isinstance(item, dict): + continue + + el_type = str(item.get("type", "text")).lower() + raw_text = str(item.get("text", "")).strip() + score = float(item.get("score", 1.0)) + pos = item.get("position", []) + if isinstance(pos, np.ndarray): + pos = pos.tolist() + + bbox = [] + if isinstance(pos, (list, tuple)) and len(pos) >= 4: + if isinstance(pos[0], (int, float)): + bbox = [int(p) for p in pos[:4]] + elif isinstance(pos[0], (list, tuple)): + xs = [pt[0] for pt in pos if len(pt) >= 2] + ys = [pt[1] for pt in pos if len(pt) >= 2] + if xs and ys: + bbox = [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))] + + if not bbox: + bbox = [0, 0, meta.get("width", 100), meta.get("height", 100)] + + xmin, ymin, xmax, ymax = bbox + is_formula = any(k in el_type for k in ("formula", "isolated", "embedding", "mfr")) + + if is_formula: + latex_code = self._clean_latex_formula(raw_text) + is_isolated = "isolated" in el_type + canonical_type = "isolated_formula" if is_isolated else "embedding_formula" + formatted_text = f"$${latex_code}$$" if is_isolated else f"${latex_code}$" + else: + canonical_type = "text" + latex_code = None + formatted_text = self._clean_vietnamese_text(raw_text) + + parsed_items.append({ + "raw_id": idx, + "type": canonical_type, + "raw_text": raw_text, + "text": formatted_text, + "latex": latex_code, + "bbox": bbox, + "xmin": xmin, + "ymin": ymin, + "xmax": xmax, + "ymax": ymax, + "ycenter": (ymin + ymax) / 2.0, + "height": max(1, ymax - ymin), + "confidence": score, + }) + + # 2D Spatial Layout Ordering (Group into horizontal lines & sort L-to-R) + ordered_elements, full_text_lines = self._spatial_sort_elements(parsed_items) + + # Collect LaTeX formulas in order + latex_formulas: List[str] = [] + for e in ordered_elements: + if e.latex and e.latex.strip(): + latex_formulas.append(e.latex.strip()) + elif e.type == "text" and "$" in e.text: + for m in re.findall(r"\$(.*?)\$", e.text): + m_clean = m.strip() + if m_clean and m_clean not in latex_formulas: + latex_formulas.append(m_clean) + + total_conf = sum(e.confidence for e in ordered_elements) + avg_confidence = round(total_conf / max(1, len(ordered_elements)), 4) if ordered_elements else 1.0 + reading_order = [e.id for e in ordered_elements] + combined_text = "\n".join(full_text_lines) + + result = CanonicalOCRResult( + text=combined_text, + latex=latex_formulas, + elements=ordered_elements, + reading_order=reading_order, + confidence=avg_confidence, + metadata=meta, + ) + + return result.text if return_text else result + + def _spatial_sort_elements( + self, + items: List[Dict[str, Any]], + ) -> Tuple[List[OCRElement], List[str]]: + if not items: + return [], [] + + # Sort vertically by ycenter + items.sort(key=lambda b: b["ycenter"]) + + # Group items into lines + lines: List[List[Dict[str, Any]]] = [] + for b in items: + placed = False + for line in lines: + line_ycenter = np.mean([x["ycenter"] for x in line]) + line_h = np.mean([x["height"] for x in line]) + if abs(b["ycenter"] - line_ycenter) < max(18.0, line_h * 0.55): + line.append(b) + placed = True + break + if not placed: + lines.append([b]) + + # Sort lines top-to-bottom + lines.sort(key=lambda line: np.mean([x["ycenter"] for x in line])) + + ordered_elements: List[OCRElement] = [] + formatted_lines: List[str] = [] + elem_id = 0 + + for line in lines: + # Sort elements in line from left to right + line.sort(key=lambda x: x["xmin"]) + line_tokens = [] + for x in line: + t = x["text"].strip() + if not t: + continue + elem = OCRElement( + id=elem_id, + type=x["type"], + text=t, + latex=x["latex"], + bbox=x["bbox"], + reading_order=elem_id, + confidence=x["confidence"], + ) + ordered_elements.append(elem) + elem_id += 1 + line_tokens.append(t) + + if line_tokens: + line_str = " ".join(line_tokens) + line_str = self._clean_vietnamese_text(line_str) + formatted_lines.append(line_str) + + return ordered_elements, formatted_lines + + def _clean_latex_formula(self, formula_text: str) -> str: + s = formula_text.strip().strip("$").strip() + s = re.sub(r"\\mathrm\s*\{\s*~?\s*x\s*u\s*\\\s*hat\s*\{\s*o\s*\}\s*n\s*g\s*~?\s*\}", "xuống", s) + s = re.sub(r"\\operatorname\s*\{\s*v\s*i\s*\}", "và", s) + s = re.sub(r"\\operatorname\s*\{\s*l\s*e\s*n\s*\}", "lên", s) + s = re.sub(r"\\mathrm\s*\{\s*\\\s*v\s*i\s*\\\s*\}", "và", s) + s = re.sub(r"\\mathrm\s*\{\s*v\s*\}\s*\{\s*\\mathrm\s*\{\s*\\bf\s*a\s*\}\s*\}", "và", s) + s = re.sub(r"\\;\s*\\mathrm\s*\{\s*c\s*\}\s*\\acute\s*\{\s*\\omicron\s*\}", " có", s) + s = re.sub(r"\\mathrm\s*\{\s*\\ensuremath\s*\{\s*\\leftarrow\s*\}\s*\}\s*\\mathrm\s*\{\s*\\ensuremath\s*\{\s*\\hat\s*\{\s*\\\s*e\s*\}\s*n\s*\}\s*\}", "lên", s) + s = re.sub(r"\\;\s*\\tt\s*d\s*\\hat\s*\{\s*e\s*n\s*\}", "đến", s) + s = re.sub(r"\\,\s*", "", s) + return s + + def _clean_vietnamese_text(self, text: str) -> str: + s = text + for pat, repl in VIET_MATH_REPLACEMENTS: + s = re.sub(pat, repl, s, flags=re.IGNORECASE) + return s + + def _fallback_recognition( + self, + pil_img: Image.Image, + meta: Dict[str, Any], + return_text: bool = False, + ) -> Union[CanonicalOCRResult, str]: + res = CanonicalOCRResult(text="", confidence=0.0, metadata=meta) + return res.text if return_text else res + + def _to_pil_image(self, img_input: Union[str, Image.Image, np.ndarray]) -> Optional[Image.Image]: + if isinstance(img_input, Image.Image): + return img_input.convert("RGB") + if isinstance(img_input, np.ndarray): + import cv2 + if len(img_input.shape) == 2: + rgb = cv2.cvtColor(img_input, cv2.COLOR_GRAY2RGB) + elif img_input.shape[2] == 4: + rgb = cv2.cvtColor(img_input, cv2.COLOR_BGRA2RGB) + else: + rgb = cv2.cvtColor(img_input, cv2.COLOR_BGR2RGB) + return Image.fromarray(rgb) + if isinstance(img_input, str): + if not os.path.exists(img_input): + logger.error("[Pix2TextOCREngine] File does not exist: %s", img_input) + return None + return Image.open(img_input).convert("RGB") + return None