diff --git a/.gitignore b/.gitignore index db0ffc30..c87bbd27 100644 --- a/.gitignore +++ b/.gitignore @@ -272,3 +272,8 @@ celerybeat.pid *.wav *.ogg !demo/*.mp4 + +# ============================================ +# Miscellaneous +# ============================================ +plans diff --git a/config/main.yaml b/config/main.yaml index 6db5d8b5..6cff4d43 100644 --- a/config/main.yaml +++ b/config/main.yaml @@ -27,6 +27,10 @@ tools: query_item: enabled: true max_results: 5 + math_solver: + enabled: true + timeout: 30 + workspace: ./data/user/math_solver_workspace logging: # Global log level for the entire system (DEBUG, INFO, WARNING, ERROR) # This controls both DeepTutor logs and RAG module logs @@ -59,6 +63,7 @@ solve: - "rag_hybrid" - "web_search" - "query_item" + - "math_solver" - "none" agents: investigate_agent: diff --git a/requirements.txt b/requirements.txt index a4ccf82e..c7666fef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,6 +59,7 @@ arxiv>=2.0.0 # Scientific computing (for RAG and code execution) # ============================================ numpy>=1.24.0,<2.0.0 # NumPy 1.24+ required for array API, <2.0 for compatibility +sympy>=1.13.0 # Symbolic mathematics for math solver tool # matplotlib>=3.7.0 # Uncomment if needed for plotting # scipy>=1.11.0 # Uncomment if needed for scientific computing # pandas>=2.0.0 # Uncomment if needed for data analysis diff --git a/src/agents/solve/__init__.py b/src/agents/solve/__init__.py index d0f00f10..03a5db1f 100644 --- a/src/agents/solve/__init__.py +++ b/src/agents/solve/__init__.py @@ -33,9 +33,6 @@ # Main controller from .main_solver import MainSolver - -# Session management -from .session_manager import SolverSessionManager, get_solver_session_manager from .memory import ( InvestigateMemory, KnowledgeItem, @@ -45,6 +42,9 @@ ToolCallRecord, ) +# Session management +from .session_manager import SolverSessionManager, get_solver_session_manager + # Solve loop from .solve_loop import ( ManagerAgent, diff --git a/src/agents/solve/prompts/en/solve_loop/solve_agent.yaml b/src/agents/solve/prompts/en/solve_loop/solve_agent.yaml index 77a2f4a5..88f01c69 100644 --- a/src/agents/solve/prompts/en/solve_loop/solve_agent.yaml +++ b/src/agents/solve/prompts/en/solve_loop/solve_agent.yaml @@ -10,20 +10,41 @@ system: | # Core Decision Logic Choose the most appropriate tool type based on the step: - 1. **Involves calculation, derivation, plotting, or data processing** + 1. **Symbolic mathematics (derivatives, integrals, equations, matrices, simplification)** + -> Select `math_solver` + - Derivatives, integrals, limits + - Equation solving (algebraic, differential) + - Matrix operations (determinant, inverse, eigenvalues) + - Expression simplification + - ❌ Do NOT write SymPy code + - ✅ Provide ONLY the mathematical expression in **Python/sympy syntax** + - Use `**` for powers, `*` for multiplication, `sqrt()` for square root + - For integrals: write `integrate(expression, (variable, lower, upper))` + - For derivatives: write `diff(expression, variable)` + - Examples: + - "x**2 + 2*x + 1 = 0" + - "sqrt(x + a) + sqrt(2x - a) = x" + - "integrate(x**n * exp(-a*x), (x, 0, oo))" + - "diff(x**3, x)" + - ❌ Do NOT use LaTeX symbols (∫, ∞, √, ∂, etc.) - use Python syntax only + + 2. **Numerical computation, plotting, or data processing** -> Select `code_execution` + - General programming tasks + - Visualization (matplotlib, plotting) + - Data analysis and processing - ❌ Do NOT write Python code - - ✅ Only describe the intent of the computation in one short sentence + - ✅ Only describe the intent of the computation - 2. **Involves definition lookup, principle confirmation, or formula retrieval** + 3. **Involves definition lookup, principle confirmation, or formula retrieval** -> Select `rag_naive` or `rag_hybrid` - Precise formula / definition → `rag_naive` - Conceptual understanding / comparison → `rag_hybrid` - 3. **Involves latest information or external knowledge** + 4. **Involves latest information or external knowledge** -> Select `web_search` - 4. **Pure logical reasoning, summarization, or information already sufficient** + 5. **Pure logical reasoning, summarization, or information already sufficient** -> Select `none` - Write the answer directly as intent text @@ -32,6 +53,7 @@ system: | - ❌ Do NOT include code blocks, backticks, or multiline strings - ❌ Do NOT use the field name `query` - ❌ Do NOT simulate execution + - ❌ For math_solver: Do NOT include natural language instructions like "square both sides" or "simplify to obtain" # Role Boundaries - Follow the role of the current step strictly @@ -45,8 +67,8 @@ system: | "thoughts": "Brief explanation of your decision (one sentence)", "tool_calls": [ { - "type": "code_execution | rag_naive | rag_hybrid | web_search | none", - "intent": "One-line description of what the tool should do" + "type": "math_solver | code_execution | rag_naive | rag_hybrid | web_search | none", + "intent": "For math_solver: ONLY the mathematical expression. For others: One-line description of what the tool should do" } ] } diff --git a/src/agents/solve/prompts/zh/solve_loop/solve_agent.yaml b/src/agents/solve/prompts/zh/solve_loop/solve_agent.yaml index eed5331b..e576852d 100644 --- a/src/agents/solve/prompts/zh/solve_loop/solve_agent.yaml +++ b/src/agents/solve/prompts/zh/solve_loop/solve_agent.yaml @@ -2,28 +2,46 @@ system: | # 角色定位 你是 Solve 阶段的**工具策略家 (Tool Strategist)**。 你的任务是:**根据当前步骤目标,决定是否需要调用工具,以及调用哪一种工具**。 - ⚠️ 你只负责“决策”,不负责“执行”。 + ⚠️ 你只负责"决策",不负责"执行"。 # 核心决策逻辑 你需要判断当前步骤的性质,并选择最匹配的工具类型: - 1. **涉及计算、推导、绘图、数据处理** + 1. **符号数学计算(求导、积分、解方程、矩阵运算、化简)** + -> 选择 `math_solver` + - 求导、积分、极限 + - 方程求解(代数、微分) + - 矩阵运算(行列式、逆、特征值) + - 表达式化简 + - ❌ 不要编写 SymPy 代码 + - ✅ 只提供数学表达式,使用 **Python/sympy 语法** + - 用 `**` 表示乘方,`*` 表示乘法,`sqrt()` 表示平方根 + - 对于积分:写 `integrate(表达式, (变量, 下限, 上限))` + - 对于导数:写 `diff(表达式, 变量)` + - 示例: + - "x**2 + 2*x + 1 = 0" + - "sqrt(x + a) + sqrt(2x - a) = x" + - "integrate(x**n * exp(-a*x), (x, 0, oo))" + - "diff(x**3, x)" + - ❌ 不要使用 LaTeX 符号(∫, ∞, √, ∂ 等)- 只用 Python 语法 + + 2. **涉及计算、推导、绘图、数据处理** -> 选择 `code_execution` - ⚠️【重要】你 **绝对不能** 编写或输出任何可执行代码 - - 你只需要用**一句简短的话**描述“要做什么计算 / 推导 / 绘图” + - 你只需要用**一句简短的话**描述"要做什么计算 / 推导 / 绘图" - 例如: - - “使用符号计算推导反向传播中的梯度公式” - - “计算函数在给定区间内的数值并绘制曲线” + - "使用符号计算推导反向传播中的梯度公式" + - "计算函数在给定区间内的数值并绘制曲线" - 2. **涉及定义查找、原理确认、公式检索** + 3. **涉及定义查找、原理确认、公式检索** -> 选择 `rag_naive` 或 `rag_hybrid` - 精确公式 / 定义 → `rag_naive` - 机制理解 / 对比分析 → `rag_hybrid` - 3. **涉及最新信息或外部知识** + 4. **涉及最新信息或外部知识** -> 选择 `web_search` - 4. **纯逻辑推理、总结,或当前信息已经足够** + 5. **纯逻辑推理、总结,或当前信息已经足够** -> 选择 `none` - 在 `query` 字段中直接给出该步骤的文字性结论 @@ -32,10 +50,12 @@ system: | - ❌ 不要输出多行文本 - ❌ 不要使用 ``` 或任何代码块 - ❌ 不要在 JSON 中包含换行符 - - ❌ 不要尝试“模拟执行代码” + - ❌ 不要尝试"模拟执行代码" + - ❌ 对于 math_solver:不要使用 LaTeX 符号(∫, ∞, √ 等) + - ❌ 对于 math_solver:不要包含"两边平方"或"化简得到"等自然语言指令 # 角色边界(必须遵守) - - 你只做“工具选择与意图描述”,不做工具执行 + - 你只做"工具选择与意图描述",不做工具执行 - 不重复已有轨迹中的工具调用 - 一旦信息足够,立刻使用 `none` 结束该步骤 @@ -46,8 +66,8 @@ system: | "thoughts": "简要说明你的决策理由(一句话即可)", "tool_calls": [ { - "type": "code_execution | rag_naive | rag_hybrid | web_search | none", - "intent": "一句话描述你希望工具完成的事情" + "type": "math_solver | code_execution | rag_naive | rag_hybrid | web_search | none", + "intent": "对于 math_solver:只提供 Python/sympy 语法的数学表达式;对于其他工具:一句话描述你希望工具完成的事情" } ] } @@ -71,6 +91,6 @@ user_template: | 要求: 1. 严格按照当前步骤目标 `{step_target}` 行事 2. 避免重复已有轨迹中的工具调用 - 3. 如果需要计算或推导,只描述“做什么”,不要写代码 + 3. 如果需要计算或推导,只描述"做什么",不要写代码 4. 如果信息已经足够,选择 `none` 并直接给出结论 5. 只输出 JSON,不要输出任何解释性文字 diff --git a/src/agents/solve/session_manager.py b/src/agents/solve/session_manager.py index 764169c8..e739a88d 100644 --- a/src/agents/solve/session_manager.py +++ b/src/agents/solve/session_manager.py @@ -112,7 +112,8 @@ def create_session( "title": title[:100], # Limit title length "messages": [], "kb_name": kb_name, - "token_stats": token_stats or { + "token_stats": token_stats + or { "model": "Unknown", "calls": 0, "tokens": 0, diff --git a/src/agents/solve/solve_loop/solve_agent.py b/src/agents/solve/solve_loop/solve_agent.py index a11d1306..3cbc0421 100644 --- a/src/agents/solve/solve_loop/solve_agent.py +++ b/src/agents/solve/solve_loop/solve_agent.py @@ -29,6 +29,7 @@ class SolveAgent(BaseAgent): "rag_hybrid", "web_search", "code_execution", + "math_solver", "finish", } diff --git a/src/config/constants.py b/src/config/constants.py index e21b6265..eeed1169 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -19,6 +19,7 @@ "rag_naive", "rag_hybrid", "query_item", + "math_solver", "none", "finish", ] diff --git a/src/services/llm/capabilities.py b/src/services/llm/capabilities.py index ff95a5cd..035dc4b5 100644 --- a/src/services/llm/capabilities.py +++ b/src/services/llm/capabilities.py @@ -348,4 +348,5 @@ def get_effective_temperature( "has_thinking_tags", "supports_tools", "requires_api_version", - "get_effective_temperature",] + "get_effective_temperature", +] diff --git a/src/services/llm/cloud_provider.py b/src/services/llm/cloud_provider.py index 41712751..fab53343 100644 --- a/src/services/llm/cloud_provider.py +++ b/src/services/llm/cloud_provider.py @@ -296,9 +296,7 @@ async def _openai_stream( data = { "model": model, "messages": msg_list, - "temperature": get_effective_temperature( - binding, model, kwargs.get("temperature", 0.7) - ), + "temperature": get_effective_temperature(binding, model, kwargs.get("temperature", 0.7)), "stream": True, } diff --git a/src/tools/math_solver.py b/src/tools/math_solver.py new file mode 100644 index 00000000..6c09e155 --- /dev/null +++ b/src/tools/math_solver.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Math Solver Tool - Symbolic mathematics execution tool + +Execute mathematical statements with deterministic, verified results using SymPy. +Supports simplification, derivatives, integrals, equation solving, matrix operations, +and numerical evaluation. Output is formatted in LaTeX for proper frontend rendering. +""" + +import asyncio +from dataclasses import dataclass +import logging +from pathlib import Path +import time +from typing import Any + +import sympy as sp +from sympy import ( + Matrix, + N, + diff, + integrate, + simplify, + solve, + symbols, +) +from sympy.parsing.sympy_parser import ( + implicit_multiplication_application, + parse_expr, + standard_transformations, +) + +logger = logging.getLogger("MathSolver") + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent + + +def _load_config() -> dict[str, Any]: + """Load math_solver configuration from config files""" + try: + from src.services.config import load_config_with_main + + config = load_config_with_main("main.yaml", PROJECT_ROOT) + math_config = config.get("tools", {}).get("math_solver", {}) + if math_config: + logger.debug("Loaded math_solver config from main.yaml") + return math_config + except Exception as e: + logger.debug(f"Failed to load math_solver config: {e}") + + return {} + + +def _parse_expression(expr_str: str): + """Parse a string expression into a SymPy expression""" + transformations = standard_transformations + (implicit_multiplication_application,) + expr_str = expr_str.strip() + + if "=" in expr_str: + left, right = expr_str.split("=", 1) + return parse_expr(left.strip(), transformations=transformations), parse_expr( + right.strip(), transformations=transformations + ) + + return parse_expr(expr_str, transformations=transformations) + + +def _to_latex(expr) -> str: + """Convert SymPy expression to LaTeX""" + return sp.latex(expr) + + +def _auto_detect_operation(statement: str) -> str: + """Auto-detect the type of mathematical operation from the statement""" + statement_lower = statement.lower().strip() + + if "=" in statement: + return "solve" + + keywords = { + "derivative": ["derivative", "differentiate", "diff"], + "integral": ["integral", "integrate", "antiderivative"], + "simplify": ["simplify", "expand", "factor"], + "matrix": ["matrix", "determinant", "eigenvalue", "inverse"], + "evaluate": ["evaluate", "compute", "calculate", "numerical"], + } + + for op, keywords_list in keywords.items(): + for keyword in keywords_list: + if keyword in statement_lower: + return op + + return "simplify" + + +class MathSolverError(Exception): + """Math solver error""" + + +@dataclass +class MathSolverResult: + """Result container for math solver computation""" + + status: str + operation: str + input_latex: str + result: str + steps: list[str] + latex: str + elapsed_ms: float + error: str = "" + + +async def _execute_computation( + statement: str, + operation: str, + variables: dict[str, Any] | None, + limits: tuple[Any, Any] | None, + matrix_operation: str | None, +) -> MathSolverResult: + """Execute the mathematical computation (runs inside timeout wrapper)""" + if operation == "auto": + operation = _auto_detect_operation(statement) + + input_expr, output_expr = None, None + steps: list[str] = [] + input_latex = "" + result_latex = "" + + if operation == "simplify": + input_expr = _parse_expression(statement) + input_latex = _to_latex(input_expr) + output_expr = simplify(input_expr) + result_latex = _to_latex(output_expr) + steps = [f"\\text{{Simplify: }} {input_latex} = {result_latex}"] + + elif operation == "derivative": + input_expr = _parse_expression(statement) + input_latex = _to_latex(input_expr) + + var = list(input_expr.free_symbols)[0] if input_expr.free_symbols else symbols("x") + + output_expr = diff(input_expr, var) + result_latex = _to_latex(output_expr) + + if limits is not None: + output_expr = integrate(input_expr, (var, limits[0], limits[1])) + result_latex = _to_latex(output_expr) + steps = [f"\\int_{{{limits[0]}}}^{{{limits[1]}}} {input_latex} \\, dx = {result_latex}"] + else: + steps = [f"\\frac{{d}}{{d{var}}} \\left( {input_latex} \\right) = {result_latex}"] + + elif operation == "integral": + input_expr = _parse_expression(statement) + input_latex = _to_latex(input_expr) + + var = list(input_expr.free_symbols)[0] if input_expr.free_symbols else symbols("x") + + if limits is not None: + output_expr = integrate(input_expr, (var, limits[0], limits[1])) + result_latex = _to_latex(output_expr) + steps = [f"\\int_{{{limits[0]}}}^{{{limits[1]}}} {input_latex} \\, dx = {result_latex}"] + else: + output_expr = integrate(input_expr, var) + result_latex = _to_latex(output_expr) + steps = [f"\\int {input_latex} \\, dx = {result_latex} + C"] + + elif operation == "solve": + input_expr, rhs = _parse_expression(statement) + input_latex = f"{_to_latex(input_expr)} = {_to_latex(rhs)}" + equation = sp.Eq(input_expr, rhs) + solutions = solve(equation, input_expr.free_symbols) + output_expr = solutions + result_latex = ", ".join([_to_latex(s) for s in solutions]) + steps = [f"\\text{{Solve: }} {input_latex}", f"x = {result_latex}"] + + elif operation == "matrix": + matrix_str = statement.strip() + if matrix_str.startswith("[") and matrix_str.endswith("]"): + matrix_str = matrix_str[1:-1] + rows = matrix_str.split("],") + matrix_data = [] + for row in rows: + row = row.strip().strip("[]") + if row: + values = [float(x.strip()) for x in row.split(",")] + matrix_data.append(values) + + mat = Matrix(matrix_data) + input_latex = _to_latex(mat) + + if matrix_operation == "determinant": + output_expr = mat.det() + result_latex = _to_latex(output_expr) + steps = [f"\\det \\begin{{bmatrix}} {input_latex} \\end{{bmatrix}} = {result_latex}"] + elif matrix_operation == "inverse": + output_expr = mat.inv() + result_latex = _to_latex(output_expr) + steps = [ + f"\\left( \\begin{{bmatrix}} {input_latex} \\end{{bmatrix}} \\right)^{{-1}} = {result_latex}" + ] + elif matrix_operation == "eigenvalues": + output_expr = mat.eigenvals() + result_latex = ", ".join( + [f"\\lambda_{{{i + 1}}} = {_to_latex(v)}" for i, v in enumerate(output_expr)] + ) + steps = [f"\\text{{Eigenvalues: }} {result_latex}"] + else: + output_expr = mat + result_latex = _to_latex(mat) + steps = [f"\\begin{{bmatrix}} {input_latex} \\end{{bmatrix}}"] + + elif operation == "evaluate": + input_expr = _parse_expression(statement) + input_latex = _to_latex(input_expr) + + if variables: + subs_expr = input_expr + for var_name, value in variables.items(): + var = symbols(var_name) + subs_expr = subs_expr.subs(var, value) + output_expr = N(subs_expr, 10) + else: + output_expr = N(input_expr, 10) + + result_latex = str(output_expr) + steps = [f"\\text{{Evaluate: }} {input_latex} = {result_latex}"] + + else: + raise MathSolverError(f"Unsupported operation: {operation}") + + if output_expr is None: + output_expr = input_expr + + return MathSolverResult( + status="success", + operation=operation, + input_latex=input_latex if input_latex else _to_latex(input_expr) if input_expr else "", + result=result_latex if result_latex else _to_latex(output_expr), + steps=steps, + latex=f"$$ {steps[-1] if steps else _to_latex(output_expr)} $$", + elapsed_ms=0, + ) + + +async def solve_math( + statement: str, + operation: str = "auto", + timeout: int = 30, + variables: dict[str, Any] | None = None, + limits: tuple[Any, Any] | None = None, + matrix_operation: str | None = None, +) -> dict[str, Any]: + """ + Execute a mathematical statement and return the result. + + Args: + statement: Mathematical statement to execute (e.g., "x**2 + 2*x + 1") + operation: Type of operation (auto, simplify, derivative, integral, solve, matrix, evaluate) + timeout: Execution timeout in seconds + variables: Dictionary of variable values for substitution + limits: Tuple of (lower, upper) limits for definite integration + matrix_operation: Specific matrix operation (determinant, inverse, eigenvalues) + + Returns: + dict with keys: + - status: "success" or "error" + - operation: The operation that was performed + - input_latex: LaTeX representation of input + - result: Final answer (LaTeX formatted) + - steps: List of solution steps (LaTeX formatted) + - latex: Full LaTeX output + - elapsed_ms: Execution time in milliseconds + - error: Error message if failed + """ + start_time = time.time() + + if not statement or not statement.strip(): + return { + "status": "error", + "operation": operation, + "input_latex": "", + "result": "", + "steps": [], + "latex": "", + "elapsed_ms": (time.time() - start_time) * 1000, + "error": "Empty statement provided", + } + + try: + result = await asyncio.wait_for( + _execute_computation(statement, operation, variables, limits, matrix_operation), + timeout=timeout, + ) + + elapsed_ms = (time.time() - start_time) * 1000 + + return { + "status": result.status, + "operation": result.operation, + "input_latex": result.input_latex, + "result": result.result, + "steps": result.steps, + "latex": result.latex, + "elapsed_ms": elapsed_ms, + } + + except asyncio.TimeoutError: + elapsed_ms = (time.time() - start_time) * 1000 + logger.error(f"Math solver timed out after {timeout}s: {statement[:50]}...") + return { + "status": "error", + "operation": operation, + "input_latex": statement, + "result": "", + "steps": [], + "latex": "", + "elapsed_ms": elapsed_ms, + "error": f"Computation timed out after {timeout} seconds", + } + + except Exception as e: + elapsed_ms = (time.time() - start_time) * 1000 + error_msg = str(e) + + logger.error(f"Math solver error: {e}", exc_info=True) + + return { + "status": "error", + "operation": operation, + "input_latex": statement, + "result": "", + "steps": [], + "latex": "", + "elapsed_ms": elapsed_ms, + "error": error_msg, + } + + +def solve_math_sync( + statement: str, + operation: str = "auto", + timeout: int = 30, + **kwargs, +) -> dict[str, Any]: + """ + Synchronous version of solve_math (for non-async environments) + """ + return asyncio.run(solve_math(statement, operation, timeout, **kwargs)) + + +if __name__ == "__main__": + + async def _demo(): + print("==== 1. Test simplify ====") + result = await solve_math("x**2 + 2*x + 1", operation="simplify") + print(f"Status: {result['status']}") + print(f"Result: {result['result']}") + print(f"LaTeX: {result['latex']}") + print("-" * 40) + + print("==== 2. Test derivative ====") + result = await solve_math("x**3", operation="derivative") + print(f"Status: {result['status']}") + print(f"Result: {result['result']}") + print(f"Steps: {result['steps']}") + print("-" * 40) + + print("==== 3. Test solve equation ====") + result = await solve_math("x**2 - 4 = 0", operation="solve") + print(f"Status: {result['status']}") + print(f"Result: {result['result']}") + print("-" * 40) + + print("==== 4. Test integral ====") + result = await solve_math("x**2", operation="integral") + print(f"Status: {result['status']}") + print(f"Result: {result['result']}") + print("-" * 40) + + print("==== 5. Test matrix determinant ====") + result = await solve_math( + "[[1, 2], [3, 4]]", operation="matrix", matrix_operation="determinant" + ) + print(f"Status: {result['status']}") + print(f"Result: {result['result']}") + print("-" * 40) + + print("==== 6. Test auto-detect ====") + result = await solve_math("x + 2 = 10", operation="auto") + print(f"Detected operation: {result['operation']}") + print(f"Result: {result['result']}") + print("-" * 40) + + asyncio.run(_demo()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..344b4b64 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Pytest configuration for DeepTutor tests +Handles import path setup to avoid logging module conflicts +""" + +from pathlib import Path +import sys + +# Add project root to path (not src/) to avoid shadowing stdlib modules +project_root = Path(__file__).resolve().parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + +# Set up environment variables for testing +import os + +os.environ["DEEPTUTOR_LOG_LEVEL"] = "ERROR" +os.environ["DEEPTUTOR_DISABLE_LOGGING"] = "1" diff --git a/tests/tools/test_math_solver.py b/tests/tools/test_math_solver.py new file mode 100644 index 00000000..c4755ff8 --- /dev/null +++ b/tests/tools/test_math_solver.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Unit Tests for Math Solver Tool +Tests for src/tools/math_solver.py +""" + +import asyncio +import pytest +import sys +import importlib.util +from pathlib import Path + +project_root = Path(__file__).resolve().parent.parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + + +def load_math_solver(): + """Load math_solver module directly from file path""" + math_solver_path = "/home/thelooter/Documents/Coding/Python/DeepTutor/src/tools/math_solver.py" + + if "math_solver_direct" not in sys.modules: + spec = importlib.util.spec_from_file_location("math_solver_direct", math_solver_path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules["math_solver_direct"] = module + spec.loader.exec_module(module) + + return sys.modules["math_solver_direct"] + + +@pytest.fixture +def math_solver(): + """Fixture to load math_solver module""" + return load_math_solver() + + +class TestMathSolverImports: + """Test that math_solver module can be imported""" + + def test_module_loads(self): + """Test that math_solver module can be loaded""" + module = load_math_solver() + assert module is not None + + def test_solve_math_function_exists(self): + """Test that solve_math function exists""" + module = load_math_solver() + assert hasattr(module, "solve_math") + assert callable(module.solve_math) + + +class TestMathSolverSimplify: + """Test simplify operation""" + + @pytest.mark.asyncio + async def test_simplify_polynomial(self, math_solver): + """Test simplifying a polynomial expression""" + result = await math_solver.solve_math("x**2 + 2*x + 1", operation="simplify") + assert result["status"] == "success" + assert result["operation"] == "simplify" + assert "result" in result + assert "latex" in result + + @pytest.mark.asyncio + async def test_simplify_trigonometric(self, math_solver): + """Test simplifying trigonometric expression""" + result = await math_solver.solve_math("sin(x)**2 + cos(x)**2", operation="simplify") + assert result["status"] == "success" + assert result["operation"] == "simplify" + + @pytest.mark.asyncio + async def test_simplify_fraction(self, math_solver): + """Test simplifying a rational expression""" + result = await math_solver.solve_math("(x**2 - 1) / (x - 1)", operation="simplify") + assert result["status"] == "success" + assert result["operation"] == "simplify" + + +class TestMathSolverDerivative: + """Test derivative operation""" + + @pytest.mark.asyncio + async def test_derivative_power_rule(self, math_solver): + """Test derivative using power rule""" + result = await math_solver.solve_math("x**3", operation="derivative") + assert result["status"] == "success" + assert result["operation"] == "derivative" + assert "x^{2}" in result["result"] or "x^2" in result["result"] or "2" in result["result"] + + @pytest.mark.asyncio + async def test_derivative_polynomial(self, math_solver): + """Test derivative of polynomial""" + result = await math_solver.solve_math("x**3 + 2*x**2 - 5*x + 3", operation="derivative") + assert result["status"] == "success" + assert result["operation"] == "derivative" + + @pytest.mark.asyncio + async def test_derivative_trigonometric(self, math_solver): + """Test derivative of trigonometric function""" + result = await math_solver.solve_math("sin(x)", operation="derivative") + assert result["status"] == "success" + assert "cos" in result["result"].lower() + + @pytest.mark.asyncio + async def test_derivative_exponential(self, math_solver): + """Test derivative of exponential function""" + result = await math_solver.solve_math("exp(x)", operation="derivative") + assert result["status"] == "success" + assert result["operation"] == "derivative" + + +class TestMathSolverIntegral: + """Test integral operation""" + + @pytest.mark.asyncio + async def test_indefinite_integral_power(self, math_solver): + """Test indefinite integral of power function""" + result = await math_solver.solve_math("x**2", operation="integral") + assert result["status"] == "success" + assert result["operation"] == "integral" + + @pytest.mark.asyncio + async def test_indefinite_integral_polynomial(self, math_solver): + """Test indefinite integral of polynomial""" + result = await math_solver.solve_math("3*x**2 + 2*x + 1", operation="integral") + assert result["status"] == "success" + assert result["operation"] == "integral" + + @pytest.mark.asyncio + async def test_definite_integral(self, math_solver): + """Test definite integral""" + result = await math_solver.solve_math("x**2", operation="integral", limits=(0, 1)) + assert result["status"] == "success" + assert result["operation"] == "integral" + + @pytest.mark.asyncio + async def test_integral_trigonometric(self, math_solver): + """Test integral of trigonometric function""" + result = await math_solver.solve_math("sin(x)", operation="integral") + assert result["status"] == "success" + assert result["operation"] == "integral" + + +class TestMathSolverSolve: + """Test solve equation operation""" + + @pytest.mark.asyncio + async def test_solve_quadratic_equation(self, math_solver): + """Test solving quadratic equation""" + result = await math_solver.solve_math("x**2 - 4 = 0", operation="solve") + assert result["status"] == "success" + assert result["operation"] == "solve" + + @pytest.mark.asyncio + async def test_solve_linear_equation(self, math_solver): + """Test solving linear equation""" + result = await math_solver.solve_math("2*x + 6 = 0", operation="solve") + assert result["status"] == "success" + assert result["operation"] == "solve" + + @pytest.mark.asyncio + async def test_solve_polynomial(self, math_solver): + """Test solving higher degree polynomial""" + result = await math_solver.solve_math("x**3 - x = 0", operation="solve") + assert result["status"] == "success" + assert result["operation"] == "solve" + + +class TestMathSolverMatrix: + """Test matrix operations""" + + @pytest.mark.asyncio + async def test_matrix_determinant_2x2(self, math_solver): + """Test determinant of 2x2 matrix""" + result = await math_solver.solve_math( + "[[1, 2], [3, 4]]", operation="matrix", matrix_operation="determinant" + ) + assert result["status"] == "success" + assert result["operation"] == "matrix" + + @pytest.mark.asyncio + async def test_matrix_determinant_3x3(self, math_solver): + """Test determinant of 3x3 matrix""" + result = await math_solver.solve_math( + "[[1, 2, 3], [4, 5, 6], [7, 8, 9]]", operation="matrix", matrix_operation="determinant" + ) + assert result["status"] == "success" + assert result["operation"] == "matrix" + + @pytest.mark.asyncio + async def test_matrix_inverse(self, math_solver): + """Test matrix inverse""" + result = await math_solver.solve_math( + "[[1, 2], [3, 4]]", operation="matrix", matrix_operation="inverse" + ) + assert result["status"] == "success" + assert result["operation"] == "matrix" + + @pytest.mark.asyncio + async def test_matrix_eigenvalues(self, math_solver): + """Test matrix eigenvalues""" + result = await math_solver.solve_math( + "[[1, 0], [0, 2]]", operation="matrix", matrix_operation="eigenvalues" + ) + assert result["status"] == "success" + assert result["operation"] == "matrix" + + +class TestMathSolverEvaluate: + """Test numerical evaluation""" + + @pytest.mark.asyncio + async def test_evaluate_expression(self, math_solver): + """Test numerical evaluation""" + result = await math_solver.solve_math("sqrt(16) + 5", operation="evaluate") + assert result["status"] == "success" + assert result["operation"] == "evaluate" + + @pytest.mark.asyncio + async def test_evaluate_with_variables(self, math_solver): + """Test evaluation with variable substitution""" + result = await math_solver.solve_math( + "x**2 + y**2", operation="evaluate", variables={"x": 3, "y": 4} + ) + assert result["status"] == "success" + assert result["operation"] == "evaluate" + + +class TestMathSolverAuto: + """Test auto-detect operation""" + + @pytest.mark.asyncio + async def test_auto_detect_simplify(self, math_solver): + """Test auto-detect for simplify""" + result = await math_solver.solve_math("(x + 1)**2 - x**2 - 2*x - 1", operation="auto") + assert result["status"] == "success" + + @pytest.mark.asyncio + async def test_auto_detect_solve(self, math_solver): + """Test auto-detect for solve""" + result = await math_solver.solve_math("x + 2 = 10", operation="auto") + assert result["status"] == "success" + + +class TestMathSolverErrorHandling: + """Test error handling""" + + @pytest.mark.asyncio + async def test_invalid_expression(self, math_solver): + """Test handling of invalid expression""" + result = await math_solver.solve_math("x***2", operation="simplify") + assert result["status"] == "error" + assert "error" in result + + @pytest.mark.asyncio + async def test_unsupported_operation(self, math_solver): + """Test handling of unsupported operation""" + result = await math_solver.solve_math("x**2", operation="unsupported_operation") + assert result["status"] == "error" + + @pytest.mark.asyncio + async def test_empty_statement(self, math_solver): + """Test handling of empty statement""" + result = await math_solver.solve_math("", operation="simplify") + assert result["status"] == "error" + + +class TestMathSolverTimeout: + """Test timeout functionality""" + + @pytest.mark.asyncio + async def test_timeout_parameter(self, math_solver): + """Test that timeout parameter is respected""" + result = await math_solver.solve_math("x**2", operation="simplify", timeout=5) + assert result["status"] == "success" + + +class TestMathSolverOutputFormat: + """Test output format""" + + @pytest.mark.asyncio + async def test_output_contains_required_fields(self, math_solver): + """Test that output contains all required fields""" + result = await math_solver.solve_math("x**2", operation="simplify") + assert "status" in result + assert "operation" in result + assert "latex" in result + assert "elapsed_ms" in result + + @pytest.mark.asyncio + async def test_latex_format(self, math_solver): + """Test that output is properly formatted in LaTeX""" + result = await math_solver.solve_math("x**2 + x", operation="simplify") + assert "$" in result["latex"] or "$$" in result["latex"] + + @pytest.mark.asyncio + async def test_steps_in_output(self, math_solver): + """Test that steps are included when applicable""" + result = await math_solver.solve_math("x**2", operation="derivative") + assert "steps" in result or "result" in result + + +class TestMathSolverEdgeCases: + """Test edge cases""" + + @pytest.mark.asyncio + async def test_complex_expression(self, math_solver): + """Test handling of complex expression""" + result = await math_solver.solve_math("exp(-x**2) * sin(x)", operation="simplify") + assert result["status"] in ["success", "error"] + + @pytest.mark.asyncio + async def test_multiple_variables(self, math_solver): + """Test expression with multiple variables""" + result = await math_solver.solve_math("x*y + y*z + z*x", operation="simplify") + assert result["status"] == "success" + + @pytest.mark.asyncio + async def test_constants(self, math_solver): + """Test expression with constants""" + result = await math_solver.solve_math("pi**2 + E", operation="simplify") + assert result["status"] == "success" + + @pytest.mark.asyncio + async def test_trig_identities(self, math_solver): + """Test trigonometric identities""" + result = await math_solver.solve_math("tan(x) * cos(x)", operation="simplify") + assert result["status"] == "success" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/web/.env.local b/web/.env.local index 0b6543af..8840a64d 100644 --- a/web/.env.local +++ b/web/.env.local @@ -3,7 +3,7 @@ # ============================================ # This file is automatically updated based on config/main.yaml # and environment variables (NEXT_PUBLIC_API_BASE, NEXT_PUBLIC_API_BASE_EXTERNAL) -# +# # To configure for remote access, set in your .env file: # NEXT_PUBLIC_API_BASE=http://your-server-ip:8001 # ============================================ diff --git a/web/app/guide/hooks/useGuideSession.ts b/web/app/guide/hooks/useGuideSession.ts index 30bbae60..b87f4aec 100644 --- a/web/app/guide/hooks/useGuideSession.ts +++ b/web/app/guide/hooks/useGuideSession.ts @@ -31,7 +31,9 @@ export function useGuideSession() { const isHydrated = useRef(false); // Initialize with defaults (same on server and client) - const [sessionState, setSessionState] = useState(INITIAL_SESSION_STATE); + const [sessionState, setSessionState] = useState( + INITIAL_SESSION_STATE, + ); const [chatMessages, setChatMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [loadingMessage, setLoadingMessage] = useState(""); @@ -43,7 +45,7 @@ export function useGuideSession() { const toSave = persistState(state, GUIDE_SESSION_EXCLUDE); saveToStorage(STORAGE_KEYS.GUIDE_SESSION, toSave); }, 500), - [] + [], ); const saveChatMessages = useCallback( @@ -51,7 +53,7 @@ export function useGuideSession() { if (!isHydrated.current) return; saveToStorage(GUIDE_CHAT_KEY, messages); }, 500), - [] + [], ); // Restore persisted state after hydration @@ -60,13 +62,13 @@ export function useGuideSession() { const persistedSession = loadFromStorage>( STORAGE_KEYS.GUIDE_SESSION, - {} + {}, ); const persistedChat = loadFromStorage(GUIDE_CHAT_KEY, []); if (Object.keys(persistedSession).length > 0) { setSessionState((prev) => - mergeWithDefaults(persistedSession, prev, GUIDE_SESSION_EXCLUDE) + mergeWithDefaults(persistedSession, prev, GUIDE_SESSION_EXCLUDE), ); } diff --git a/web/app/history/page.tsx b/web/app/history/page.tsx index 61e1fd17..0b3c4c48 100644 --- a/web/app/history/page.tsx +++ b/web/app/history/page.tsx @@ -98,14 +98,16 @@ export default function HistoryPage() { const [solverSessions, setSolverSessions] = useState([]); const [loading, setLoading] = useState(true); const [loadingSessionId, setLoadingSessionId] = useState(null); - const [loadingSolverSessionId, setLoadingSolverSessionId] = useState(null); + const [loadingSolverSessionId, setLoadingSolverSessionId] = useState< + string | null + >(null); const [selectedEntry, setSelectedEntry] = useState(null); const [selectedChatSession, setSelectedChatSession] = useState( null, ); - const [selectedSolverSession, setSelectedSolverSession] = useState( - null, - ); + const [selectedSolverSession, setSelectedSolverSession] = useState< + string | null + >(null); const [filterType, setFilterType] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -113,7 +115,10 @@ export default function HistoryPage() { setLoading(true); try { // Fetch regular activity history - if (filterType === "all" || (filterType !== "chat" && filterType !== "solve")) { + if ( + filterType === "all" || + (filterType !== "chat" && filterType !== "solve") + ) { const typeParam = filterType !== "all" ? `&type=${filterType}` : ""; const res = await fetch( apiUrl(`/api/v1/dashboard/recent?limit=50${typeParam}`), @@ -316,7 +321,9 @@ export default function HistoryPage() {
{t("Loading")}... - ) : filteredEntries.length === 0 && chatSessions.length === 0 && solverSessions.length === 0 ? ( + ) : filteredEntries.length === 0 && + chatSessions.length === 0 && + solverSessions.length === 0 ? (
@@ -529,7 +536,9 @@ export default function HistoryPage() { .map((session) => (
setSelectedSolverSession(session.session_id)} + onClick={() => + setSelectedSolverSession(session.session_id) + } className="px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors group cursor-pointer" >
@@ -566,11 +575,12 @@ export default function HistoryPage() { KB: {session.kb_name} )} - {session.token_stats?.cost !== undefined && session.token_stats.cost > 0 && ( - - ${session.token_stats.cost.toFixed(4)} - - )} + {session.token_stats?.cost !== undefined && + session.token_stats.cost > 0 && ( + + ${session.token_stats.cost.toFixed(4)} + + )}
{session.last_message && (

@@ -594,7 +604,9 @@ export default function HistoryPage() { e.stopPropagation(); handleLoadSolverSession(session.session_id); }} - disabled={loadingSolverSessionId === session.session_id} + disabled={ + loadingSolverSessionId === session.session_id + } className="px-3 py-1.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors flex items-center gap-1.5 disabled:opacity-50" > {loadingSolverSessionId === session.session_id ? ( diff --git a/web/app/knowledge/page.tsx b/web/app/knowledge/page.tsx index 66a0e68e..dd49fe86 100644 --- a/web/app/knowledge/page.tsx +++ b/web/app/knowledge/page.tsx @@ -744,7 +744,6 @@ export default function KnowledgePage() { }); kbsNamesRef.current = kbs.map((kb) => kb.name); - }, [kbs, loading]); // Cleanup all connections on component unmount diff --git a/web/app/settings/page.tsx b/web/app/settings/page.tsx index 5e85baec..1223c640 100644 --- a/web/app/settings/page.tsx +++ b/web/app/settings/page.tsx @@ -22,9 +22,13 @@ import { LANGUAGE_OPTIONS } from "./constants"; import { getStorageStats } from "@/lib/persistence"; export default function SettingsPage() { - const { uiSettings, updateTheme, updateLanguage, clearAllPersistence } = useGlobal(); + const { uiSettings, updateTheme, updateLanguage, clearAllPersistence } = + useGlobal(); const [showClearConfirm, setShowClearConfirm] = useState(false); - const [storageStats, setStorageStats] = useState<{ totalSize: number; items: { key: string; size: number }[] } | null>(null); + const [storageStats, setStorageStats] = useState<{ + totalSize: number; + items: { key: string; size: number }[]; + } | null>(null); // Load storage stats useEffect(() => { @@ -215,7 +219,9 @@ export default function SettingsPage() {

- {uiSettings.language === "zh" ? "确认清除" : "Confirm Clear"} + {uiSettings.language === "zh" + ? "确认清除" + : "Confirm Clear"}

{uiSettings.language === "zh" diff --git a/web/app/solver/page.tsx b/web/app/solver/page.tsx index 774908b3..1ff926eb 100644 --- a/web/app/solver/page.tsx +++ b/web/app/solver/page.tsx @@ -54,7 +54,8 @@ const resolveArtifactUrl = (url?: string | null, outputDir?: string) => { }; export default function SolverPage() { - const { solverState, setSolverState, startSolver, newSolverSession } = useGlobal(); + const { solverState, setSolverState, startSolver, newSolverSession } = + useGlobal(); // Local state for input const [inputQuestion, setInputQuestion] = useState(""); diff --git a/web/components/ActivityDetail.tsx b/web/components/ActivityDetail.tsx index 054aa256..587f16f3 100644 --- a/web/components/ActivityDetail.tsx +++ b/web/components/ActivityDetail.tsx @@ -16,7 +16,7 @@ function useIsClient() { return useSyncExternalStore( emptySubscribe, () => true, - () => false + () => false, ); } diff --git a/web/components/CoMarkerEditor.tsx b/web/components/CoMarkerEditor.tsx index 8a819a62..94ed89d3 100644 --- a/web/components/CoMarkerEditor.tsx +++ b/web/components/CoMarkerEditor.tsx @@ -1831,7 +1831,8 @@ export default function CoWriterEditor({

- "{op.input?.original_text?.substring(0, 35)}..." + "{op.input?.original_text?.substring(0, 35)} + ..."
{op.source && ( diff --git a/web/components/CoWriterEditor.tsx b/web/components/CoWriterEditor.tsx index 047d8817..9c8a21db 100644 --- a/web/components/CoWriterEditor.tsx +++ b/web/components/CoWriterEditor.tsx @@ -53,7 +53,11 @@ import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import "katex/dist/katex.min.css"; import { processLatexContent } from "@/lib/latex"; -import { loadFromStorage, saveToStorage, STORAGE_KEYS } from "@/lib/persistence"; +import { + loadFromStorage, + saveToStorage, + STORAGE_KEYS, +} from "@/lib/persistence"; import { debounce } from "@/lib/debounce"; interface CoWriterEditorProps { @@ -76,7 +80,9 @@ export default function CoWriterEditor({ const isHydrated = useRef(false); // Initialize with default content (same on server and client) - const [content, setContent] = useState(initialValue || DEFAULT_COWRITER_CONTENT); + const [content, setContent] = useState( + initialValue || DEFAULT_COWRITER_CONTENT, + ); // Debounced save for content const saveContent = useCallback( @@ -84,7 +90,7 @@ export default function CoWriterEditor({ if (!isHydrated.current) return; saveToStorage(STORAGE_KEYS.COWRITER_CONTENT, text); }, 1000), // 1 second debounce for content to avoid too frequent saves while typing - [] + [], ); // Restore persisted content after hydration @@ -95,10 +101,10 @@ export default function CoWriterEditor({ isHydrated.current = true; return; } - + const persistedContent = loadFromStorage( STORAGE_KEYS.COWRITER_CONTENT, - DEFAULT_COWRITER_CONTENT + DEFAULT_COWRITER_CONTENT, ); if (persistedContent !== DEFAULT_COWRITER_CONTENT) { setContent(persistedContent); @@ -1876,7 +1882,8 @@ export default function CoWriterEditor({
- "{op.input?.original_text?.substring(0, 35)}..." + "{op.input?.original_text?.substring(0, 35)} + ..."
{op.source && ( diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index faeebaf9..29ea75ef 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -93,7 +93,7 @@ export default function Sidebar() { icon: ALL_NAV_ITEMS[href].icon, })); }; - + return [ { id: "start" as const, diff --git a/web/components/research/ResearchDashboard.tsx b/web/components/research/ResearchDashboard.tsx index def716eb..23796b8a 100644 --- a/web/components/research/ResearchDashboard.tsx +++ b/web/components/research/ResearchDashboard.tsx @@ -1,4 +1,10 @@ -import React, { useState, useEffect, useRef, useMemo, startTransition } from "react"; +import React, { + useState, + useEffect, + useRef, + useMemo, + startTransition, +} from "react"; import { ResearchState } from "../../types/research"; import { TaskGrid } from "./TaskGrid"; import { ActiveTaskDetail } from "./ActiveTaskDetail"; @@ -66,36 +72,40 @@ export const ResearchDashboard: React.FC = ({ isExportingPdf = false, }) => { const { global, tasks, activeTaskIds, planning, reporting } = state; - + // Track previous stage to detect changes and reset user selection const [prevStage, setPrevStage] = useState(global.stage); - + // Ref to track previous stage for useEffect (needed because state updates synchronously) const prevStageRef = useRef(global.stage); - + // User can override the auto-selected tab - const [userSelectedTab, setUserSelectedTab] = useState(null); - + const [userSelectedTab, setUserSelectedTab] = useState( + null, + ); + // Compute derived tab based on current stage - const derivedProcessTab: ProcessTab = - global.stage === "planning" || global.stage === "researching" || global.stage === "reporting" + const derivedProcessTab: ProcessTab = + global.stage === "planning" || + global.stage === "researching" || + global.stage === "reporting" ? global.stage : "reporting"; - + // Detect stage changes and reset user selection if (prevStage !== global.stage) { setPrevStage(global.stage); setUserSelectedTab(null); } - + // Active tab is user selection or derived const activeProcessTab = userSelectedTab ?? derivedProcessTab; - + // Handler for user tab selection const setActiveProcessTab = (tab: ProcessTab) => { setUserSelectedTab(tab); }; - + // View state const [activeView, setActiveView] = useState<"process" | "report">("process"); @@ -129,7 +139,7 @@ export const ResearchDashboard: React.FC = ({ } }, [global.stage, reporting.generatedReport, activeView]); - // Reset to process view when a new research starts + // Reset to process view when a new research starts useEffect(() => { if (global.stage === "planning" && prevStageRef.current !== "planning") { startTransition(() => { diff --git a/web/context/GlobalContext.tsx b/web/context/GlobalContext.tsx index b0f1e1e0..a18840d8 100644 --- a/web/context/GlobalContext.tsx +++ b/web/context/GlobalContext.tsx @@ -679,7 +679,8 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { const isHydrated = useRef(false); // --- Solver Logic --- - const [solverState, setSolverState] = useState(DEFAULT_SOLVER_STATE); + const [solverState, setSolverState] = + useState(DEFAULT_SOLVER_STATE); const solverWs = useRef(null); // Debounced save for solver state @@ -688,11 +689,11 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { if (!isHydrated.current) return; const toSave = persistState( state, - EXCLUDE_FIELDS.SOLVER as unknown as (keyof SolverState)[] + EXCLUDE_FIELDS.SOLVER as unknown as (keyof SolverState)[], ); saveToStorage(STORAGE_KEYS.SOLVER_STATE, toSave); }, 500), - [] + [], ); // Auto-save solver state on change (only after hydration) @@ -743,11 +744,13 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { ws.onopen = () => { // Send question with current session_id (if any) - ws.send(JSON.stringify({ - question, - kb_name: kb, - session_id: solverSessionIdRef.current, - })); + ws.send( + JSON.stringify({ + question, + kb_name: kb, + session_id: solverSessionIdRef.current, + }), + ); addSolverLog({ type: "system", content: "Initializing connection..." }); }; @@ -875,7 +878,9 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { // Load a solver session from history const loadSolverSession = async (sessionId: string) => { try { - const response = await fetch(apiUrl(`/api/v1/solve/sessions/${sessionId}`)); + const response = await fetch( + apiUrl(`/api/v1/solve/sessions/${sessionId}`), + ); if (!response.ok) { throw new Error("Session not found"); } @@ -896,7 +901,10 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { messages, selectedKb: session.kb_name || prev.selectedKb, tokenStats: session.token_stats || prev.tokenStats, - question: messages.length > 0 && messages[0].role === "user" ? messages[0].content : "", + question: + messages.length > 0 && messages[0].role === "user" + ? messages[0].content + : "", isSolving: false, logs: [], progress: { stage: null, progress: {} }, @@ -912,7 +920,9 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { }; // --- Question Logic --- - const [questionState, setQuestionState] = useState(DEFAULT_QUESTION_STATE); + const [questionState, setQuestionState] = useState( + DEFAULT_QUESTION_STATE, + ); const questionWs = useRef(null); // Debounced save for question state @@ -921,11 +931,11 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { if (!isHydrated.current) return; const toSave = persistState( state, - EXCLUDE_FIELDS.QUESTION as unknown as (keyof QuestionState)[] + EXCLUDE_FIELDS.QUESTION as unknown as (keyof QuestionState)[], ); saveToStorage(STORAGE_KEYS.QUESTION_STATE, toSave); }, 500), - [] + [], ); // Auto-save question state on change (only after hydration) @@ -1547,7 +1557,9 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { }; // --- Research Logic --- - const [researchState, setResearchState] = useState(DEFAULT_RESEARCH_STATE); + const [researchState, setResearchState] = useState( + DEFAULT_RESEARCH_STATE, + ); const researchWs = useRef(null); // Debounced save for research state @@ -1556,11 +1568,11 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { if (!isHydrated.current) return; const toSave = persistState( state, - EXCLUDE_FIELDS.RESEARCH as unknown as (keyof ResearchState)[] + EXCLUDE_FIELDS.RESEARCH as unknown as (keyof ResearchState)[], ); saveToStorage(STORAGE_KEYS.RESEARCH_STATE, toSave); }, 500), - [] + [], ); // Auto-save research state on change (only after hydration) @@ -1738,7 +1750,9 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { }; // --- IdeaGen Logic --- - const [ideaGenState, setIdeaGenState] = useState(DEFAULT_IDEAGEN_STATE); + const [ideaGenState, setIdeaGenState] = useState( + DEFAULT_IDEAGEN_STATE, + ); // Debounced save for ideagen state const saveIdeaGenState = useCallback( @@ -1746,11 +1760,11 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { if (!isHydrated.current) return; const toSave = persistState( state, - EXCLUDE_FIELDS.IDEAGEN as unknown as (keyof IdeaGenState)[] + EXCLUDE_FIELDS.IDEAGEN as unknown as (keyof IdeaGenState)[], ); saveToStorage(STORAGE_KEYS.IDEAGEN_STATE, toSave); }, 500), - [] + [], ); // Auto-save ideagen state on change (only after hydration) @@ -1772,11 +1786,11 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { if (!isHydrated.current) return; const toSave = persistState( state, - EXCLUDE_FIELDS.CHAT as unknown as (keyof ChatState)[] + EXCLUDE_FIELDS.CHAT as unknown as (keyof ChatState)[], ); saveToStorage(STORAGE_KEYS.CHAT_STATE, toSave); }, 500), - [] + [], ); // Auto-save chat state on change (only after hydration) @@ -1794,23 +1808,23 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { // Load persisted states const persistedSolver = loadFromStorage>( STORAGE_KEYS.SOLVER_STATE, - {} + {}, ); const persistedQuestion = loadFromStorage>( STORAGE_KEYS.QUESTION_STATE, - {} + {}, ); const persistedResearch = loadFromStorage>( STORAGE_KEYS.RESEARCH_STATE, - {} + {}, ); const persistedIdeaGen = loadFromStorage>( STORAGE_KEYS.IDEAGEN_STATE, - {} + {}, ); const persistedChat = loadFromStorage>( STORAGE_KEYS.CHAT_STATE, - {} + {}, ); // Restore solver state @@ -1819,8 +1833,8 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { mergeWithDefaults( persistedSolver, prev, - EXCLUDE_FIELDS.SOLVER as unknown as (keyof SolverState)[] - ) + EXCLUDE_FIELDS.SOLVER as unknown as (keyof SolverState)[], + ), ); } @@ -1830,8 +1844,8 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { mergeWithDefaults( persistedQuestion, prev, - EXCLUDE_FIELDS.QUESTION as unknown as (keyof QuestionState)[] - ) + EXCLUDE_FIELDS.QUESTION as unknown as (keyof QuestionState)[], + ), ); } @@ -1841,7 +1855,7 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { const merged = mergeWithDefaults( persistedResearch, prev, - EXCLUDE_FIELDS.RESEARCH as unknown as (keyof ResearchState)[] + EXCLUDE_FIELDS.RESEARCH as unknown as (keyof ResearchState)[], ); if (merged.status === "running") { merged.status = "idle"; @@ -1856,8 +1870,8 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { mergeWithDefaults( persistedIdeaGen, prev, - EXCLUDE_FIELDS.IDEAGEN as unknown as (keyof IdeaGenState)[] - ) + EXCLUDE_FIELDS.IDEAGEN as unknown as (keyof IdeaGenState)[], + ), ); } @@ -1867,7 +1881,7 @@ export function GlobalProvider({ children }: { children: React.ReactNode }) { const merged = mergeWithDefaults( persistedChat, prev, - EXCLUDE_FIELDS.CHAT as unknown as (keyof ChatState)[] + EXCLUDE_FIELDS.CHAT as unknown as (keyof ChatState)[], ); // Also update sessionIdRef if (merged.sessionId) { diff --git a/web/context/settings/SidebarContext.tsx b/web/context/settings/SidebarContext.tsx index 9c1b61d6..1222aead 100644 --- a/web/context/settings/SidebarContext.tsx +++ b/web/context/settings/SidebarContext.tsx @@ -23,7 +23,11 @@ function getInitialSidebarWidth(): number { const storedWidth = localStorage.getItem("sidebarWidth"); if (storedWidth) { const width = parseInt(storedWidth, 10); - if (!isNaN(width) && width >= SIDEBAR_MIN_WIDTH && width <= SIDEBAR_MAX_WIDTH) { + if ( + !isNaN(width) && + width >= SIDEBAR_MIN_WIDTH && + width <= SIDEBAR_MAX_WIDTH + ) { return width; } } @@ -55,8 +59,11 @@ const SidebarContext = createContext(undefined); export function SidebarProvider({ children }: { children: React.ReactNode }) { // Sidebar dimensions state - use lazy initialization - const [sidebarWidth, setSidebarWidthState] = useState(getInitialSidebarWidth); - const [sidebarCollapsed, setSidebarCollapsedState] = useState(getInitialCollapsed); + const [sidebarWidth, setSidebarWidthState] = useState( + getInitialSidebarWidth, + ); + const [sidebarCollapsed, setSidebarCollapsedState] = + useState(getInitialCollapsed); // Sidebar customization state const [sidebarDescription, setSidebarDescriptionState] = useState( diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 5f40a073..2beb0a35 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -8,4 +8,3 @@ const config = [ ]; export default config; - diff --git a/web/hooks/usePersistentState.ts b/web/hooks/usePersistentState.ts index 490112c2..e49dbfb8 100644 --- a/web/hooks/usePersistentState.ts +++ b/web/hooks/usePersistentState.ts @@ -39,7 +39,7 @@ interface PersistentStateOptions { export function usePersistentState>( key: string, initialValue: T, - options: PersistentStateOptions = {} + options: PersistentStateOptions = {}, ): [T, React.Dispatch>] { const { exclude = [], debounceMs = 500, enabled = true } = options; @@ -66,7 +66,7 @@ export function usePersistentState>( const toSave = persistState(value, exclude as (keyof T)[]); saveToStorage(key, toSave); }, debounceMs), - [key, exclude, debounceMs, enabled] + [key, exclude, debounceMs, enabled], ); // Save state whenever it changes (after initial load) @@ -85,7 +85,7 @@ export function usePersistentState>( (action) => { setStateInternal(action); }, - [] + [], ); return [state, setState]; @@ -102,7 +102,7 @@ export function usePersistentState>( export function usePersistentValue( key: string, initialValue: T, - debounceMs: number = 300 + debounceMs: number = 300, ): [T, React.Dispatch>] { const isInitialized = useRef(false); @@ -117,7 +117,7 @@ export function usePersistentValue( debounce((val: T) => { saveToStorage(key, val); }, debounceMs), - [key, debounceMs] + [key, debounceMs], ); useEffect(() => { @@ -145,7 +145,7 @@ export function useStatePersistence>( state: T, defaultState: T, excludeFields: readonly (keyof T)[], - debounceMs: number = 500 + debounceMs: number = 500, ): { loadPersistedState: () => T; saveState: (state: T) => void; @@ -156,7 +156,7 @@ export function useStatePersistence>( const toSave = persistState(currentState, excludeFields as (keyof T)[]); saveToStorage(key, toSave); }, debounceMs), - [key, excludeFields, debounceMs] + [key, excludeFields, debounceMs], ); // Load persisted state @@ -168,7 +168,7 @@ export function useStatePersistence>( return mergeWithDefaults( persisted, defaultState, - excludeFields as (keyof T)[] + excludeFields as (keyof T)[], ); }, [key, defaultState, excludeFields]); diff --git a/web/hooks/useTheme.ts b/web/hooks/useTheme.ts index e6abb1e8..b9c0cd40 100644 --- a/web/hooks/useTheme.ts +++ b/web/hooks/useTheme.ts @@ -15,7 +15,7 @@ function useIsClient() { return useSyncExternalStore( emptySubscribe, () => true, - () => false + () => false, ); } diff --git a/web/lib/persistence.ts b/web/lib/persistence.ts index c2ee6241..919ff0c3 100644 --- a/web/lib/persistence.ts +++ b/web/lib/persistence.ts @@ -45,7 +45,7 @@ export function loadFromStorage(key: string, defaultValue: T): T { // Version check - if version mismatch, return default (can add migration logic here) if (wrapper.version !== STORAGE_VERSION) { console.warn( - `Storage version mismatch for ${key}. Expected ${STORAGE_VERSION}, got ${wrapper.version}. Using default value.` + `Storage version mismatch for ${key}. Expected ${STORAGE_VERSION}, got ${wrapper.version}. Using default value.`, ); return defaultValue; } @@ -81,7 +81,7 @@ export function saveToStorage(key: string, value: T): void { // Handle quota exceeded or other storage errors if (error instanceof Error && error.name === "QuotaExceededError") { console.error( - `localStorage quota exceeded when saving ${key}. Consider clearing old data.` + `localStorage quota exceeded when saving ${key}. Consider clearing old data.`, ); } else { console.warn(`Failed to save ${key} to localStorage:`, error); @@ -140,7 +140,7 @@ export function clearAllStorage(): void { */ export function persistState>( state: T, - exclude: (keyof T)[] + exclude: (keyof T)[], ): Partial { const result: Partial = {}; @@ -164,7 +164,7 @@ export function persistState>( export function mergeWithDefaults>( persistedState: Partial | null | undefined, defaultState: T, - exclude: (keyof T)[] = [] + exclude: (keyof T)[] = [], ): T { if (!persistedState) { return defaultState;