diff --git a/docs/tools.md b/docs/tools.md index 84f7131..5dc5410 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -33,6 +33,7 @@ | `ShareFileTool` | `share_file` | Copy an existing local file into session files and return a download link | | `ContentPublishTool` | `content_publish` | Register an existing session file for inline viewing in chat | | `Model3DTool` | `model_3d` | Compile an OpenSCAD script into a binary STL file | +| `ScadLintTool` | `scad_lint` | Lightweight OpenSCAD source linting before STL compilation | | `Render3DTool` | `render_3d` | Render preview PNG images from an STL file (up to 3 views) | | `DeleteToolTool` | `delete_tool` | Delete a user tool file | | `TestToolTool` | `test_tool` | Run a user tool and verify its output | diff --git a/navi/api/routes/agents.py b/navi/api/routes/agents.py index 891f8a2..c0e3660 100644 --- a/navi/api/routes/agents.py +++ b/navi/api/routes/agents.py @@ -23,6 +23,11 @@ "enabled_tools": p.enabled_tools, "llm_backend": p.llm_backend, "model": p.model, + "temperature": p.temperature, + "top_k": p.top_k, + "top_p": p.top_p, + "max_iterations": p.max_iterations, + "iteration_budget_enabled": p.iteration_budget_enabled, } for p in profiles.all() ] diff --git a/navi/core/context_builder.py b/navi/core/context_builder.py index 4494e36..3fc0f40 100644 --- a/navi/core/context_builder.py +++ b/navi/core/context_builder.py @@ -147,19 +147,25 @@ result.extend(conv) if profile.iteration_budget_enabled and iteration is not None and max_iterations is not None: - remaining = max_iterations - iteration - if remaining <= 3: + remaining_after_this = max_iterations - iteration - 1 + if remaining_after_this <= 2: urgency = ( - f" CRITICAL: only {remaining} iteration(s) left — finish or produce " + f" CRITICAL: only {remaining_after_this} iteration(s) left after this one — finish or produce " "a partial result now, do not start new subtasks." ) - elif remaining <= 7: - urgency = " Start wrapping up: prioritize completing current work over starting new subtasks." + elif remaining_after_this <= 5: + urgency = ( + " Low iteration budget: complete the current step, continue necessary " + "verification/publishing, and avoid starting unrelated subtasks." + ) else: urgency = "" result.append(Message( role="system", - content=f"[Iteration {iteration + 1}/{max_iterations} — {remaining} remaining.{urgency}]", + content=( + f"[Iteration {iteration + 1}/{max_iterations} — " + f"{remaining_after_this} iteration(s) after this one.{urgency}]" + ), )) return result diff --git a/navi/core/planning.py b/navi/core/planning.py index bf5dda8..cdda1ff 100644 --- a/navi/core/planning.py +++ b/navi/core/planning.py @@ -270,7 +270,13 @@ + "Now write the execution plan. For each subtask assign a specific executor:\n" "- TOOL: — a single tool call is enough; use exact tool names from the list above\n" "- AGENT: — a bounded subtask needing 3+ tool calls; one subagent handles this ONE step\n" - "- SELF — final synthesis or a context-dependent single action only\n\n" + "- SELF — final user-facing synthesis or an internal judgment that needs no tool call\n\n" + "Executor classification rules (critical):\n" + "- If a step names or implies a tool action, mark it TOOL with that exact tool name, never SELF.\n" + "- Use TOOL for searching, reading, writing files, editing files, scratchpad notes, todo updates, " + "image inspection, rendering, compiling, publishing, sharing, terminal commands, API calls, and verification through tool output.\n" + "- Use SELF only for synthesis, choosing between already-known options, or explaining completed results.\n" + "- If a planned step cannot be completed without later calling a tool, it is not SELF.\n\n" "Planning boundary (critical):\n" "The plan is an execution contract, not an implementation. It may describe intent, order, executor, " "inputs, expected outputs, and verification. It must NOT contain implementation code, source snippets, " diff --git a/navi/core/registry.py b/navi/core/registry.py index 67b5321..23ad6a1 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -16,6 +16,7 @@ ListProfilesTool, MemoryTool, ReflectTool, + ScadLintTool, SpawnAgentTool, SshExecTool, ScratchpadTool, @@ -177,7 +178,7 @@ manual_tool = ToolManualTool(registry=tools) memory_tool = MemoryTool(memory_store) if memory_store else None builtins = [WebSearchTool(), FilesystemTool(ai_helper=ai_helper), HttpRequestTool(), WebViewTool(), - CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), + CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), ScadLintTool(), ShareFileTool(), ContentPublishTool(), TestToolTool(), Model3DTool(), Render3DTool(), TodoTool(), ScratchpadTool(), ReflectTool(ai_helper=ai_helper), diff --git a/navi/profiles/modeler_3d/config.json b/navi/profiles/modeler_3d/config.json index 5e708f9..d2dcd45 100644 --- a/navi/profiles/modeler_3d/config.json +++ b/navi/profiles/modeler_3d/config.json @@ -6,7 +6,7 @@ "full_description": { "specialization": "3D geometry and model generation for additive manufacturing (FDM, SLA, resin). Generates printable STL files from OpenSCAD through dedicated 3D tools, and validates with OpenSCAD compilation plus preview render inspection.", "when_to_use": "When the user needs a physical object modeled for 3D printing: replacement parts, mechanical assemblies, decorative items, functional prototypes, jigs, fixtures, or custom enclosures.", - "key_tools": "filesystem, model_3d, render_3d, image_view, content_publish" + "key_tools": "filesystem, scad_lint, model_3d, render_3d, image_view, content_publish" }, "llm_backend": "ollama", "model": [ @@ -14,8 +14,8 @@ "gemma4:31b-cloud", "qwen3.6:27b" ], - "temperature": 0.35, - "max_iterations": 25, + "temperature": 0.25, + "max_iterations": 45, "planning_enabled": true, "planning_mandatory": false, "planning_phase1_enabled": true, @@ -40,6 +40,7 @@ "code_exec", "terminal", "image_view", + "scad_lint", "content_publish", "model_3d", "render_3d" @@ -56,6 +57,7 @@ "code_exec", "terminal", "image_view", + "scad_lint", "memory", "list_tools", "tool_manual", diff --git a/navi/profiles/modeler_3d/system_prompt.txt b/navi/profiles/modeler_3d/system_prompt.txt index 7276138..dcf5cf4 100644 --- a/navi/profiles/modeler_3d/system_prompt.txt +++ b/navi/profiles/modeler_3d/system_prompt.txt @@ -15,10 +15,11 @@ Use the dedicated 3D tools and use OpenSCAD as the only geometry generator. 1. **`filesystem write`** — write the OpenSCAD script (`.scad`) to the session directory. -2. **`model_3d`** — compile the `.scad` into a binary `.stl`. -3. **`render_3d`** — generate PNG previews from several angles for your own inspection. -4. **`image_view`** — inspect each PNG so YOU can verify geometry. PNG previews are for Navi, not for the user. -5. **`content_publish`** — publish the final STL only after internal checks pass. Include `source_filename` when a real `.scad` source exists in the same session directory. +2. **`scad_lint`** — check the `.scad` source for common LLM mistakes before compiling. +3. **`model_3d`** — compile the `.scad` into a binary `.stl`. +4. **`render_3d`** — generate PNG previews from several angles for your own inspection. +5. **`image_view`** — inspect each PNG so YOU can verify geometry. PNG previews are for Navi, not for the user. +6. **`content_publish`** — publish the final STL only after internal checks pass. Include `source_filename` when a real `.scad` source exists in the same session directory. Do not use Python, CadQuery, trimesh, numpy-stl, or raw mesh scripts to generate or validate the final STL. OpenSCAD compilation plus OpenSCAD-rendered previews are the validation path for this profile. @@ -43,6 +44,37 @@ Do not write OpenSCAD until `technical_spec` exists. +## Functional fit uncertainty + +Functional fit parts require verified dimensions. This includes GPU shrouds, brackets, adapters, enclosures, mounts, ducts, replacement parts, and anything that must attach to an existing real object. + +If web search, memory, docs, or user-provided data do not give trustworthy measurements for the exact target revision: + +- Do not present the result as a final compatible part. +- Switch to a parametric measurement template: make the source easy to adjust and expose all critical dimensions as named parameters. +- Record the missing measurements in `technical_spec` as `required_user_measurements`. +- Add measurement placeholders for interfaces: screw spacing, fan diameter, hole diameter, board/heatsink length, width, height, offsets, clearance, and mounting surface positions as relevant. +- In the final response, clearly say the artifact is a parametric draft/template that must be adjusted to measured hardware before printing. +- If the missing measurement determines whether geometry can physically fit at all, ask the user for it before generating final geometry. + +Never invent exact compatibility dimensions for a functional fit part after failed or irrelevant search results. Reasonable defaults may be used only for a parametric template and must be labeled as defaults. + +## Parameter sanity check + +Before writing OpenSCAD for any functional, mechanical, or parametric fit part, run a numeric parameter sanity check and store it in `scratchpad` section `parameter_sanity_check`. + +The check must verify: + +- Hole variables are used at the correct scale: if `hole_diameter = 3.2`, the OpenSCAD hole must use `d=hole_diameter`, not `d=hole_diameter/2`. +- Interface spacing fits inside the available surface, including tab/flange diameter and edge margin. Example: `mounting_hole_spacing_x + tab_diameter <= body_length`, unless tabs intentionally extend beyond the body and the plan says so. +- Fan diameter, shaft diameter, slot length, clip width, and other interfaces fit inside their mounting surface with clearance. +- Wall thickness is at least the technical spec minimum. +- Cutouts do not remove all material needed for mounting, sealing, or strength. +- All parameters used by geometry are declared once, named clearly, and reused consistently. +- Defaults that are not verified real dimensions are labeled as template defaults. + +If a sanity check fails, revise `technical_spec` or `design_plan` before writing `.scad`. If a later edit changes interface dimensions or formulas, update `parameter_sanity_check` before compiling. + ## Detailed implementation plan After `technical_spec`, create an internal implementation plan in `scratchpad` section `design_plan` before writing OpenSCAD. @@ -65,6 +97,7 @@ - If the STL is ready to publish, call `content_publish` immediately instead of saying you are going to publish it. - If the SCAD is ready to compile, call `model_3d` immediately instead of saying you are going to compile it. +- If the SCAD was just written or edited, call `scad_lint` immediately before `model_3d`. - If previews are ready to inspect, call `image_view` immediately instead of saying you are going to inspect them. - Only send a text-only progress message when you are blocked, need a user decision, or are reporting a completed tool result. @@ -84,14 +117,16 @@ 1. **Clarify only blockers** — ask for critical dimensions, tolerances, material, nozzle/printer limits, or bed size only when the missing information prevents a usable model. 2. **Write technical specification** — use `scratchpad` to store `technical_spec`. Choose explicit defaults for non-blocking unknowns. 3. **Plan printable geometry** — use `scratchpad` to store `design_plan` with scale, modules, orientation, contact face, support strategy, tolerances, weak points, and preview checks. -4. **Write OpenSCAD** — save a clean, parameterized `.scad` script in the session directory. Include the required source comments contract at the top of the file. -5. **Compile STL** — call `model_3d(scad_path=..., output_path=...)`. -6. **Handle compile result** — proceed only if `model_3d` returns success. If it returns `openscad_compile_error`, `no_output`, `scad_not_found`, `wrong_session_dir`, or another error, fix the cause and compile again. -7. **Render previews** — call `render_3d(source="...stl", views=["iso","front","top"])` or other relevant views. -8. **Inspect previews** — call `image_view` on each PNG. Do not publish PNG previews unless the user explicitly asks for preview images. -9. **Run preview checklist** — compare previews against `technical_spec` and `design_plan`. Record the checklist result in `scratchpad` section `preview_check`. -10. **Revise before publishing** — if compilation output or preview inspection reveals a substantial issue, edit the `.scad`, recompile, re-render, and inspect again. -11. **Publish final STL** — only after the model passes the printability gate, call `content_publish(filename="...stl", content_type="stl", source_filename="...scad")`. +4. **Run parameter sanity check** — for functional, mechanical, or parametric fit parts, use `scratchpad` to store `parameter_sanity_check` before writing `.scad`. +5. **Write OpenSCAD** — save a clean, parameterized `.scad` script in the session directory. Include the required source comments contract at the top of the file. +6. **Lint OpenSCAD** — call `scad_lint(path="...scad")`. Fix every error before compiling. Treat warnings as reasons to inspect and revise when they affect printability or source cleanliness. +7. **Compile STL** — call `model_3d(scad_path=..., output_path=...)`. +8. **Handle compile result** — proceed only if `model_3d` returns success. If it returns `openscad_compile_error`, `no_output`, `scad_not_found`, `wrong_session_dir`, or another error, fix the cause and compile again. +9. **Render previews** — call `render_3d(source="...stl", views=["iso","front","top"])` or other relevant views. +10. **Inspect every preview** — call `image_view` on every PNG path returned by `render_3d`. Do not publish PNG previews unless the user explicitly asks for preview images. +11. **Run preview checklist** — compare all inspected previews against `technical_spec` and `design_plan`. Record the checklist result in `scratchpad` section `preview_check`. +12. **Revise before publishing** — if lint, compilation output, or preview inspection reveals a substantial issue, edit the `.scad`, lint again, recompile, re-render, and inspect again. +13. **Publish final STL** — only after the model passes the printability gate, call `content_publish(filename="...stl", content_type="stl", source_filename="...scad")`. ## Source comments contract @@ -109,6 +144,8 @@ Keep the header useful and short. Do not put a long conversation transcript in source comments. +OpenSCAD source must be clean production source. Do not leave abandoned modules, commented-out failed attempts, "wait", "final final", or self-correction notes in the file. If you need to revise, overwrite the file with the clean current version. + ## Pre-publish printability gate Before `content_publish`, verify the model against this checklist: @@ -122,9 +159,14 @@ - Bridges are short enough for the assumed printer/material or are avoided. - Holes, slots, and press-fit features include clearance where relevant. - Functional parts have enough material around holes, tabs, clips, and load-bearing areas. +- Functional, mechanical, and parametric fit parts have a `scratchpad` `parameter_sanity_check` confirming interface dimensions and formulas are internally consistent. - The final `.stl` and `.scad` source are in the current session files directory, not another `session_files/` directory. -- The STL compiled successfully and preview images were inspected. +- The STL compiled successfully. +- Every preview image returned by `render_3d` was inspected with `image_view`; inspecting only `iso` is not enough. +- `scratchpad` section `preview_check` exists and says `Revision required: no`, or records the revision that was made after a failed check. - OpenSCAD warnings/errors from `model_3d` or `render_3d` were handled instead of ignored. +- `scad_lint` was run on the final `.scad`, and all lint errors were fixed before compilation. +- For functional fit parts, exact compatibility dimensions are either verified, provided by the user, or the artifact is explicitly labeled as a parametric template that requires user measurements. If any item fails, revise the design before publishing. @@ -143,7 +185,7 @@ ## Preview inspection checklist -After `image_view`, compare the preview against `technical_spec` and answer these internally in `scratchpad` section `preview_check`: +After all `image_view` calls, compare every preview against `technical_spec` and answer these internally in `scratchpad` section `preview_check`: - Request match: does the silhouette and object class match the user request and technical specification? - Scale/proportions: do dimensions and proportions look plausible for the specified size? @@ -152,10 +194,12 @@ - Thickness: are visible walls, blades, tabs, and decorative details thick enough to print? - Overhang/support: are overhangs, bridges, and unsupported islands acceptable for the stated strategy? - Functional interfaces: do holes, shafts, slots, clips, or mounting points appear usable and reinforced? +- Interface consistency: do interface dimensions fit inside the model bounds? For example, fan screw spacing cannot exceed the available mounting surface width. - Source consistency: does the final STL still correspond to the current `.scad` source in the current session directory? +- Inspected views: list every rendered PNG and mark it inspected. - Revision required: yes/no, with the specific reason. -If any checklist item fails, revise before publishing. Do not rationalize visible problems as acceptable unless the user explicitly accepts that tradeoff. +If any checklist item fails, revise before publishing. Do not rationalize visible problems as acceptable unless the user explicitly accepts that tradeoff. Do not publish if only one rendered view was inspected. For medium or complex 3D tasks, perform at least one critique pass after the first previews. A second SCAD revision is expected when it can improve printability, strength, proportions, or support strategy. Skip revision only for trivial primitive models or when the checklist is fully passing and you record a concise no-change rationale. @@ -187,7 +231,7 @@ - Track progress with the `todo` tool. Do not write manual checkbox status lists in the final message. - Do not claim a task is complete until the corresponding tool result has verified it and `todo` has been updated. - Do not narrate future tool actions as a substitute for performing them. Execute the tool call first, then explain the result. -- Do not claim the model is manifold, watertight, or slicer-safe unless a tool explicitly verified that exact property. OpenSCAD compilation and preview images are useful checks, but they are not proof of manifoldness. +- Do not claim the model is manifold, watertight, or slicer-safe unless a tool explicitly verified that exact property. This also applies to `todo.validation`, `scratchpad`, `preview_check`, and final responses. OpenSCAD compilation and preview images are useful checks, but they are not proof of manifoldness. - Do not use HTML formatting such as `
` in user-facing messages. - Do not paste OpenSCAD code into the text response after publishing; the user can inspect the source through the artifact source viewer. - Do not duplicate the published visual content in text. After publishing, provide only a concise note with assumptions, dimensions, material/print orientation advice, and any support/tolerance caveats. diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 3296243..541741a 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -4,6 +4,7 @@ from .filesystem import FilesystemTool from .http_request import HttpRequestTool from .image_view import ImageViewTool +from .scad_lint import ScadLintTool from .ssh_exec import SshExecTool from .spawn_agent import SpawnAgentTool from .terminal import TerminalTool @@ -28,6 +29,7 @@ "TerminalTool", "SshExecTool", "ImageViewTool", + "ScadLintTool", "WebViewTool", "MemoryTool", "TestToolTool", diff --git a/navi/tools/scad_lint.py b/navi/tools/scad_lint.py new file mode 100644 index 0000000..36c6263 --- /dev/null +++ b/navi/tools/scad_lint.py @@ -0,0 +1,198 @@ +"""scad_lint - lightweight OpenSCAD source sanity checks.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from navi.config import settings +from navi.session_files import session_dir + +from .base import Tool, ToolResult, current_session_id + + +_BUILTINS = { + "abs", "acos", "asin", "atan", "atan2", "ceil", "chr", "concat", "cos", "cross", + "cube", "cylinder", "difference", "echo", "else", "exp", "false", "floor", "for", + "function", "hull", "if", "import", "include", "intersection", "len", "let", "ln", + "log", "lookup", "max", "min", "mirror", "module", "norm", "PI", "polyhedron", + "pow", "projection", "rotate", "round", "scale", "search", "sign", "sin", "sphere", + "sqrt", "str", "surface", "tan", "translate", "true", "undef", "union", "use", +} +_NAMED_ARGS = { + "center", "d", "d1", "d2", "h", "r", "r1", "r2", "$fa", "$fn", "$fs", +} +_NOISY_PATTERNS = ( + r"\b[Ll]et'?s\b", + r"\bActually\b", + r"\bWait\b", + r"\bhacky\b", + r"\bCorrected implementation\b", + r"\bFINAL[, ]+FINAL\b", + r"\bI promise\b", + r"\boverthinking\b", +) + + +class ScadLintTool(Tool): + name = "scad_lint" + description = ( + "Lint an OpenSCAD (.scad) source file before compiling or publishing. " + "This is a lightweight sanity check, not a full CAD validator. It catches common " + "LLM mistakes: abandoned modules, undefined identifiers, draft/self-correction " + "comments, suspicious chamfer hacks, and parameter inconsistencies. " + "Run it after writing or editing .scad and fix errors before model_3d." + ) + parameters = { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": ( + "Absolute or relative path to the .scad file. Simple filenames are " + "resolved inside the current session directory." + ), + }, + "strict": { + "type": "boolean", + "description": "When true, warnings also make the tool fail. Default false.", + }, + }, + "required": ["path"], + } + + async def execute(self, params: dict) -> ToolResult: + session_id = current_session_id.get() + path = self._resolve_path(params["path"], session_id) + session_error = self._validate_current_session_path(path, session_id) + if session_error: + return session_error + + if not path.exists(): + return ToolResult(False, f"SCAD file not found: {path}", error="scad_not_found") + if not path.is_file(): + return ToolResult(False, f"Path is not a file: {path}", error="not_a_file") + if path.suffix.lower() != ".scad": + return ToolResult(False, f"Expected a .scad file: {path}", error="not_scad") + + source = path.read_text(encoding="utf-8", errors="replace") + errors, warnings = lint_scad_source(source) + strict = bool(params.get("strict", False)) + failed = bool(errors or (strict and warnings)) + + output = self._format_output(path, errors, warnings, strict) + return ToolResult( + success=not failed, + output=output, + error="scad_lint_failed" if failed else None, + metadata={"errors": errors, "warnings": warnings}, + ) + + @staticmethod + def _resolve_path(raw_path: str, session_id: str | None) -> Path: + path = Path(raw_path).expanduser() + if session_id and not path.is_absolute() and len(path.parts) == 1: + return (session_dir(session_id) / path).resolve() + return path.resolve() + + @staticmethod + def _validate_current_session_path(path: Path, session_id: str | None) -> ToolResult | None: + if not session_id: + return None + + session_root = Path(settings.session_files_dir).resolve() + current_dir = session_dir(session_id).resolve() + if path.is_relative_to(session_root) and not path.is_relative_to(current_dir): + return ToolResult( + False, + ( + f"path points to a different session directory: {path}\n" + f"Current session directory is: {current_dir}\n" + "Use the exact current session directory. Do not manually reconstruct " + "or retype session IDs." + ), + error="wrong_session_dir", + ) + return None + + @staticmethod + def _format_output(path: Path, errors: list[str], warnings: list[str], strict: bool) -> str: + lines = [f"SCAD lint: {path.name}"] + if not errors and not warnings: + lines.append("OK: no issues found.") + return "\n".join(lines) + + if errors: + lines.append(f"Errors ({len(errors)}):") + lines.extend(f"- {item}" for item in errors) + if warnings: + label = "Warnings as errors" if strict else "Warnings" + lines.append(f"{label} ({len(warnings)}):") + lines.extend(f"- {item}" for item in warnings) + return "\n".join(lines) + + +def lint_scad_source(source: str) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + code = _strip_comments_and_strings(source) + + _check_noisy_source(source, errors) + _check_undefined_identifiers(code, errors) + _check_abandoned_modules(code, warnings) + _check_suspicious_geometry(source, warnings) + + return errors, warnings + + +def _strip_comments_and_strings(source: str) -> str: + source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S) + source = re.sub(r"//.*", " ", source) + source = re.sub(r'"(?:\\.|[^"\\])*"', '""', source) + return source + + +def _check_noisy_source(source: str, errors: list[str]) -> None: + for pattern in _NOISY_PATTERNS: + if re.search(pattern, source): + errors.append( + "Source contains draft/self-correction language " + f"matching `{pattern}`. Rewrite as clean production OpenSCAD." + ) + + +def _check_undefined_identifiers(code: str, errors: list[str]) -> None: + declared = set(re.findall(r"\b([A-Za-z_]\w*)\s*=", code)) + modules = set(re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code)) + functions = set(re.findall(r"\bfunction\s+([A-Za-z_]\w*)\s*\(", code)) + loop_vars = set(re.findall(r"\bfor\s*\(\s*([A-Za-z_]\w*)\s*=", code)) + module_params = set() + for params in re.findall(r"\bmodule\s+[A-Za-z_]\w*\s*\(([^)]*)\)", code): + module_params.update(re.findall(r"\b([A-Za-z_]\w*)\b", params)) + + known = declared | modules | functions | loop_vars | module_params | _BUILTINS | _NAMED_ARGS + tokens = set(re.findall(r"\b[A-Za-z_]\w*\b", code)) + unknown = sorted(t for t in tokens if t not in known) + if unknown: + errors.append("Unknown identifier(s): " + ", ".join(unknown[:20])) + + +def _check_abandoned_modules(code: str, warnings: list[str]) -> None: + modules = re.findall(r"\bmodule\s+([A-Za-z_]\w*)\s*\(", code) + for name in modules: + uses = len(re.findall(rf"\b{re.escape(name)}\s*\(", code)) + if uses <= 1: + warnings.append(f"Module `{name}` is defined but never called.") + + +def _check_suspicious_geometry(source: str, warnings: list[str]) -> None: + if re.search(r"\bcube\s*\(\s*\[\s*[^]]*\+\s*chamfer_size\s*\*\s*2", source): + warnings.append( + "Suspicious chamfer construction: subtracting an oversized cube often slices " + "off a cap instead of creating edge chamfers." + ) + if re.search(r"\bhole_diameter\s*/\s*2\b", source): + warnings.append( + "`hole_diameter / 2` used in geometry. If the OpenSCAD parameter is named " + "`hole_diameter`, cylinder holes should usually use `d=hole_diameter`." + ) diff --git a/tests/unit/core/test_context_builder.py b/tests/unit/core/test_context_builder.py index 29d9c1b..663d063 100644 --- a/tests/unit/core/test_context_builder.py +++ b/tests/unit/core/test_context_builder.py @@ -90,7 +90,7 @@ last = result[-1] assert last.role == "system" assert "Iteration 8/10" in last.content - assert "3 remaining" in last.content + assert "2 iteration(s) after this one" in last.content def test_critical_urgency(self): builder = ContextBuilder(profile_registry=make_profile_registry()) diff --git a/tests/unit/tools/test_scad_lint.py b/tests/unit/tools/test_scad_lint.py new file mode 100644 index 0000000..d4d57f3 --- /dev/null +++ b/tests/unit/tools/test_scad_lint.py @@ -0,0 +1,82 @@ +"""Unit tests for scad_lint tool.""" + +import pytest + +import navi.tools.scad_lint as scad_lint_mod +from navi.tools.base import current_session_id +from navi.tools.scad_lint import ScadLintTool, lint_scad_source + + +def test_lint_detects_draft_language_unknown_identifier_and_abandoned_module(): + source = """ +module unused() { + cube([1, 1, 1]); +} + +// Actually, let's use a corrected implementation. +module final_model() { + cube([pad_recss_width, 2, 3]); +} + +final_model(); +""" + + errors, warnings = lint_scad_source(source) + + assert any("draft/self-correction" in item for item in errors) + assert any("pad_recss_width" in item for item in errors) + assert any("unused" in item for item in warnings) + + +def test_lint_detects_hole_diameter_division_warning(): + source = """ +hole_diameter = 3.2; +cylinder(d = hole_diameter / 2, h = 5); +""" + + errors, warnings = lint_scad_source(source) + + assert errors == [] + assert any("hole_diameter / 2" in item for item in warnings) + + +class TestScadLintTool: + @pytest.fixture + def tool(self, monkeypatch, tmp_path): + monkeypatch.setattr(scad_lint_mod.settings, "session_files_dir", str(tmp_path / "sessions")) + token = current_session_id.set("sess-1") + try: + yield ScadLintTool() + finally: + current_session_id.reset(token) + + async def test_resolves_simple_filename_inside_session_dir(self, tool, tmp_path): + sess_dir = tmp_path / "sessions" / "sess-1" + sess_dir.mkdir(parents=True) + (sess_dir / "ok.scad").write_text("cube([1, 1, 1]);") + + result = await tool.execute({"path": "ok.scad"}) + + assert result.success + assert "OK" in result.output + + async def test_rejects_wrong_session_dir(self, tool, tmp_path): + other_dir = tmp_path / "sessions" / "sess-2" + other_dir.mkdir(parents=True) + other = other_dir / "model.scad" + other.write_text("cube([1, 1, 1]);") + + result = await tool.execute({"path": str(other)}) + + assert not result.success + assert result.error == "wrong_session_dir" + + async def test_strict_fails_on_warnings(self, tool, tmp_path): + sess_dir = tmp_path / "sessions" / "sess-1" + sess_dir.mkdir(parents=True) + (sess_dir / "warn.scad").write_text("hole_diameter = 3.2; cylinder(d=hole_diameter/2, h=5);") + + result = await tool.execute({"path": "warn.scad", "strict": True}) + + assert not result.success + assert result.error == "scad_lint_failed" diff --git a/tools/get_current_datetime.py b/tools/get_current_datetime.py index 52d3b53..01f47f3 100644 --- a/tools/get_current_datetime.py +++ b/tools/get_current_datetime.py @@ -1,5 +1,5 @@ name = "get_current_datetime" -description = "Получает текущую дату и время." +description = "Return the current local date and time." parameters = { "type": "object", "properties": {} @@ -9,7 +9,7 @@ async def execute(params: dict) -> str: """ - Получает текущую дату и время. + Return the current local date and time. """ now = datetime.datetime.now() - return f"Текущая дата и время: {now.strftime('%Y-%m-%d %H:%M:%S')}" \ No newline at end of file + return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')}"