Files
2026-07-22 13:48:46 +08:00

329 lines
13 KiB
Python

"""Requirement refinement tool implemented as a specialist subagent."""
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any, Optional, Union
from SimpleLLMFunc import llm_chat, tool
from SimpleLLMFunc.type import HistoryList
from context.conversation_manager import get_current_sketch_pad
from .builtin_file_toolkit import create_builtin_file_tools
from .command_tools import execute_command
from .common import (
SUBAGENT_MAX_TOOL_CALLS,
build_simplecad_workspace_fact_block,
get_config,
print_tool_output,
)
from .reference_image import resolve_reference_image_path
from .sketch_tools import sketch_pad_operations
from .subagent_utils import run_subagent_with_events
def create_requirement_refinement_subagent_tools(
workspace: Optional[str | Path] = None,
) -> list[Any]:
"""Create the low-level tools owned by the requirement specialist.
Specialist only produces formatted text; SketchPad storage is done by the caller.
"""
return [
execute_command,
sketch_pad_operations,
*create_builtin_file_tools(workspace),
]
_REQUIRED_SECTIONS = [
"## API Reference",
"## Refined User Requirements",
"## Parameter Table",
"## Modeling Process",
"## Notes",
]
def _normalize_requirement_output(text: str) -> str:
"""Trim preamble and ensure all required sections exist."""
t = text.strip()
for h in _REQUIRED_SECTIONS:
idx = t.find(h)
if idx >= 0:
t = t[idx:]
break
for h in _REQUIRED_SECTIONS:
if h not in t:
t += f"\n\n{h}\n"
return t.strip()
def _build_requirement_request(
*,
query: str,
query_image_path: Optional[str],
) -> str:
parts = [
"Generate a detailed modeling specification. Output must include: ## API Reference, ## Refined User Requirements, ## Parameter Table, ## Modeling Process, ## Notes.",
"Use the workspace facts below.",
"Before you write the final answer, you MUST use file tools to read the preferred skill root's `SKILL.md`, then `references/docs/api/README.md`, then the exact API Markdown pages you cite.",
"Do not answer from memory. If you have not read those files yet, continue using tools.",
"In `## API Reference`, cite the concrete file paths you read and only recommend APIs whose exact Markdown pages you actually opened.",
"If the task mentions SketchPad keys, use `sketch_pad_operations` to retrieve them before refining the requirement.",
"",
build_simplecad_workspace_fact_block(),
"",
"[User Query]",
query.strip(),
]
if query_image_path and query_image_path.strip():
parts.append("\n[Reference image attached below]")
return "\n".join(parts)
def _image_path_to_base64_data_url(image_path: str) -> Optional[str]:
"""Read image file and return data URL for OpenAI API."""
p = Path(image_path)
if not p.exists() or not p.is_file():
return None
ext = p.suffix.lower()
mime_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
mime = mime_map.get(ext, "image/jpeg")
try:
b64 = base64.b64encode(p.read_bytes()).decode("utf-8")
return f"data:{mime};base64,{b64}"
except Exception:
return None
def _build_message_with_image(
text: str,
query_image_path: Optional[str],
) -> Union[str, list[dict[str, Any]]]:
"""Build message: text only, or text + image as OpenAI content array."""
if not query_image_path or not query_image_path.strip():
return text
data_url = _image_path_to_base64_data_url(query_image_path.strip())
if not data_url:
raise RuntimeError(
f"Failed to load reference image: {query_image_path.strip()}"
)
return [
{"type": "text", "text": text},
{"type": "image_url", "image_url": {"url": data_url}},
]
@tool(
name="make_user_query_more_detailed",
description=(
"Refine and expand the user's modeling requirement through a specialist subagent. "
"The specialist can inspect local skill docs, inspect APIs, read local files, and consult SketchPad "
"before producing a structured modeling specification."
),
best_practices=[
"Pass the complete user request in `query`, not only a short delta fragment.",
"If the user provided a reference image, pass its workspace-local path in `query_image_path`.",
"If `query_image_path` is omitted, the tool will automatically reuse the latest uploaded image from the active conversation when available.",
"Use this tool when the modeling request is vague, underspecified, or needs a step-by-step plan before coding.",
"The specialist will read local skill docs directly with its file tools to ground the refinement.",
"The final result should include a structured modeling process, not only rewritten prose.",
],
)
async def make_user_query_more_detailed(
query: str,
query_image_path: Optional[str] = None,
event_emitter: Any = None,
) -> str:
"""Refine the user's modeling request via a requirement specialist subagent.
The refined requirement is always stored in SketchPad for downstream tools to reference.
Args:
query: The user's original request. This may also mention SketchPad ids that the
specialist should inspect.
query_image_path: Optional workspace-local reference image path, typically something
like `./uploads/<conversation_id>/query_image_001.png`.
event_emitter: Optional tool event emitter used to forward nested specialist activity.
Returns:
str: Refined requirement text with SketchPad key for reference.
"""
print_tool_output(
title="Requirement Refinement Started",
content=f"Request: {query}",
)
requested_query_image_path = (
query_image_path.strip()
if isinstance(query_image_path, str) and query_image_path.strip()
else None
)
resolved_query_image_path = resolve_reference_image_path(query_image_path)
if requested_query_image_path is not None and resolved_query_image_path is None:
raise RuntimeError(f"Reference image not found: {requested_query_image_path}")
if resolved_query_image_path is not None:
print_tool_output(
title="Reference Image Attached",
content=f"Using reference image: {resolved_query_image_path}",
)
text_content = _build_requirement_request(
query=query,
query_image_path=resolved_query_image_path,
)
message = _build_message_with_image(text_content, resolved_query_image_path)
result_text = await run_subagent_with_events(
specialist_callable=requirement_refinement_specialist,
specialist_kwargs={
"message": message,
"history": [],
},
subagent_label="Requirement Refinement Specialist",
event_emitter=event_emitter,
status_payload={
"query": query,
"query_image_path": resolved_query_image_path,
},
response_transform=_normalize_requirement_output,
)
final_text = result_text.strip()
print_tool_output(title="Refined User Requirements", content=final_text)
sketch_pad = get_current_sketch_pad()
if sketch_pad is None:
raise RuntimeError(
"The refined requirement must be written to SketchPad, but there is no active conversation context."
)
import uuid
sketch_key = f"req_{uuid.uuid4().hex[:8]}"
try:
await sketch_pad.set_item(
key=sketch_key,
value=final_text,
ttl=None,
summary=None,
tags={"detailed_query", "requirements", "expanded"},
)
print_tool_output(
title="💾 Stored In SketchPad",
content=f"Key: {sketch_key}\nThe refined requirement has been saved for downstream tools.",
)
return (
"Detailed requirements generated and stored in SketchPad:\n\n"
f"🔑 SketchPad Key: {sketch_key}\n"
"# Tags: detailed_query, requirements, expanded\n"
f'💡 Tip: You can now reference key "{sketch_key}" in later tool calls, for example:\n'
"- include it in the natural-language task for `cad_code_generator`\n"
"- store it alongside other constraints or debugging notes in SketchPad\n"
"- create the target folder first, then use `echo_into` to write a file if needed\n"
)
except Exception as exc:
print_tool_output(
"❌ SketchPad Store Failed", f"Failed to store in SketchPad: {exc}"
)
raise RuntimeError(
f"The refined requirement must be written to SketchPad, but storage failed: {exc}"
) from exc
@llm_chat(
llm_interface=get_config().MULTIMODALITY_INTERFACE,
toolkit=create_requirement_refinement_subagent_tools(),
max_tool_calls=SUBAGENT_MAX_TOOL_CALLS,
stream=True,
enable_event=True,
timeout=600,
temperature=1.0,
)
async def requirement_refinement_specialist(
message: Union[str, list[dict[str, Any]]],
history: HistoryList | None = None,
) -> None: # type: ignore[misc]
"""Generate a detailed modeling specification. Output: ## API Reference, ## Refined User Requirements, ## Parameter Table, ## Modeling Process, ## Notes.
Use the workspace facts included in the user message. Read `SKILL.md`, then the API index, then the exact API Markdown pages you cite.
REQUIRED: The detailed query MUST use exactly correct API names and code snippets.
Tools: execute_command, sketch_pad_operations, read_file, grep, sed, echo_into.
You MUST read SKILL.md and the API index before choosing APIs. Retrieve SketchPad artifacts when task mentions keys.
<EXAMPLE>
User: "Create a 7.62mm rifle cartridge model"
## Refined User Requirements
1. **Target Object**: A standard 7.62mm caliber rifle cartridge (Full Metal Jacket type).
2. **Components**: The model consists of four parts: the bullet tip (projectile), the cartridge case (neck, shoulder, body), the rim/extractor groove, and a primer base.
3. **Dimensions**:
- **Projectile**: Diameter 7.62mm, ogive shape with a rounded tip.
- **Case Body**: Maximum diameter approx 11.3mm, total case length 51mm (based on 7.62x51mm NATO standard).
- **Shoulder/Neck**: Tapered transition from body to 7.62mm neck.
4. **Output**: A single combined solid representing the exterior geometry of the cartridge.
## Parameter Table
| Parameter | Type | Default Value | Calculation Logic |
|---|---|---|---|
| bullet_dia | float | 7.62 | Nominal caliber |
| case_body_dia | float | 11.3 | Max diameter of the case body |
| total_length | float | 71.0 | Full cartridge length including projectile |
| body_length | float | 38.0 | Length from base to shoulder |
| shoulder_length | float | 3.5 | Length of the tapered shoulder |
| neck_length | float | 8.0 | Length of the neck holding the bullet |
| case_length | float | 51.0 | body_length + shoulder_length + neck_length |
| rim_dia | float | 11.5 | Diameter of the base rim |
## Modeling Process
1. **Create Case Main Body**
- **Purpose**: Create the main cylindrical propellant chamber.
- **API**: `make_cylinder_rsolid`
- **Spatial Reasoning**: Cylinder radius `case_body_dia/2`, height `body_length`, base at (0,0,0).
2. **Create Shoulder and Neck**
- **Purpose**: Model the tapered transition and casing neck.
- **API**: `make_cone_rsolid`, `make_cylinder_rsolid`, `translate_shape`
- **Spatial Reasoning**: Shoulder cone bottom radius `case_body_dia/2`, top `bullet_dia/2`, height `shoulder_length`, translate to Z=body_length. Neck cylinder radius `bullet_dia/2`, height `neck_length`, translate to Z=body_length+shoulder_length.
3. **Create Projectile**
- **Purpose**: Form the aerodynamic tip.
- **API**: `make_cone_rsolid`, `union_rsolidlist`
- **Spatial Reasoning**: Cone base radius `bullet_dia/2`, height `total_length-case_length`, translate to Z=case_length.
4. **Add Extractor Groove and Rim**
- **Purpose**: Model the base where extractor grips.
- **API**: `make_cylinder_rsolid`, `cut_rsolidlist`
- **Spatial Reasoning**: Rim cylinder radius `rim_dia/2`, height 1.5. Cut groove with smaller cylinder.
5. **Final Assembly**
- **Purpose**: Combine into single manifold solid.
- **API**: `union_rsolidlist`
- **Spatial Reasoning**: Boolean union on body, shoulder, neck, projectile, rim.
## Notes
Based on 7.62x51mm NATO standard. APIs must be verified against SKILL.md.
</EXAMPLE>
"""
pass
__all__ = [
"make_user_query_more_detailed",
"requirement_refinement_specialist",
"create_requirement_refinement_subagent_tools",
]