from __future__ import annotations import asyncio import base64 from copy import deepcopy import json import math import secrets from collections.abc import AsyncIterator from pathlib import Path from typing import Any import httpx from app.models.contracts import ChatMessage from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl from app.services.flange_sleeve_template import ( TEMPLATE_ID as FLANGE_SLEEVE_TEMPLATE_ID, build_flange_sleeve_cdsl, flange_sleeve_plan_schema, ) from app.services.library import CdslLibrary from app.services.part_skills import PartSkillLibrary from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso from app.settings import ProviderConfig, ProviderModel, Settings class ToolArgumentsError(ValueError): """A model returned function-call arguments that are not one JSON object.""" class StrictToolSchemaError(RuntimeError): """The selected endpoint rejected an explicitly enabled strict schema.""" class RepeatedToolArgumentsError(RuntimeError): """The model failed to emit valid function arguments after a retry.""" def __init__(self, message: str, diagnostic_paths: list[str] | None = None) -> None: super().__init__(message) self.diagnostic_paths = diagnostic_paths or [] def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, StrictToolSchemaError) and any( "\u4e00" <= char <= "\u9fff" for char in str(user_text or "") ): return ( "所选模型不支持严格 CDSL 工具 schema。请在 backend/.env 中关闭该供应商的 " "CDSL_*_STRICT_TOOL_SCHEMA 或 CDSL_*_STRICT_TOOL_MODELS,或者改用已验证支持严格函数 schema 的模型。" ) if isinstance(error, RepeatedToolArgumentsError) and any( "\u4e00" <= char <= "\u9fff" for char in str(user_text or "") ): diagnostics = "" if error.diagnostic_paths: diagnostics = " 原始工具参数和停止原因已保存到:" + "、".join(error.diagnostic_paths) + "。" return ( "模型连续两次未返回完整的 CDSL 工具 JSON,已停止重试且未创建模型。" "请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。" + diagnostics ) return str(error) def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_end: int) -> dict[str, Any] | None: """Recover one known provider defect without accepting arbitrary malformed JSON.""" if ( not isinstance(parsed_value, dict) or set(parsed_value) != {"cdsl"} or parsed_end < 1 or source[parsed_end - 1] != "}" ): return None # Some OpenAI-compatible endpoints close the tool-argument root after # `cdsl`, then emit `, "summary": ...}` outside it. Re-open exactly that # wrapper and accept the result only when it is a complete known envelope. candidate = source[:parsed_end - 1] + source[parsed_end:] try: value, candidate_end = json.JSONDecoder().raw_decode(candidate) except json.JSONDecodeError: return None if candidate[candidate_end:].strip() or not isinstance(value, dict): return None if not set(value).issubset({"cdsl", "summary", "assumptions"}): return None if not isinstance(value.get("cdsl"), dict) or not isinstance(value.get("summary"), str): return None if not value["summary"].strip(): return None if "assumptions" in value and ( not isinstance(value["assumptions"], list) or not all(isinstance(item, str) for item in value["assumptions"]) ): return None return value def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]: """Decode one function-call argument object, with one guarded CDSL repair.""" if raw_arguments is None or raw_arguments == "": return {} if not isinstance(raw_arguments, str): raise ToolArgumentsError("arguments must be a JSON object string") source = raw_arguments.strip() if not source: return {} try: value, parsed_end = json.JSONDecoder().raw_decode(source) except json.JSONDecodeError as error: raise ToolArgumentsError("arguments are not valid JSON") from error if source[parsed_end:].strip(): if recover_cdsl_wrapper: repaired = _repair_premature_tool_wrapper_close(source, value, parsed_end) if repaired is not None: return repaired raise ToolArgumentsError("arguments contain trailing content after the JSON object") if not isinstance(value, dict): raise ToolArgumentsError("arguments must decode to a JSON object") return value def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[str, Any]: return { "ok": False, "code": "INVALID_TOOL_ARGUMENTS", "message": ( f"{name} arguments were rejected: {error}. " "Call the same tool again with exactly one valid JSON object. " "Do not append prose, Markdown fences, or another JSON value." ), } def invalid_cdsl_result(error: ValueError) -> dict[str, Any]: return { "ok": False, "code": "INVALID_CDSL", "message": ( f"The submitted CDSL is incomplete or invalid: {error}. " "Read the authoritative local engine schema, then call generate_cdsl_model " "again with a complete compatible model." ), } def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str: code = str(result.get("code") or "") if code == "INVALID_CDSL": if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。" return "The CDSL model does not match the engine contract. Asking the model to correct it and retry." return str(result.get("message") or result.get("summary") or "") def response_language_instruction(user_text: str) -> str: """Make the language requirement concrete for scripts we can identify safely.""" text = str(user_text or "") chinese = sum("\u4e00" <= char <= "\u9fff" for char in text) japanese = sum("\u3040" <= char <= "\u30ff" for char in text) korean = sum("\uac00" <= char <= "\ud7af" for char in text) if japanese: language = "Japanese" elif korean: language = "Korean" elif chinese: language = "Chinese" else: language = "the same primary natural language as the latest user message" return ( "This turn's output language is mandatory: use " f"{language} for every user-facing natural-language response. " "Do not use English unless that is the user's primary language." ) def _cdsl_tool_schema() -> dict[str, Any]: engine_dir = Path(__file__).resolve().parents[2] / "engine" / "cdsl_engine" contract_path = engine_dir / "profile_schema.json" try: contract = json.loads(contract_path.read_text(encoding="utf-8")) schema_name = str(contract.get("cdsl_json_schema_file") or "") if not schema_name or Path(schema_name).name != schema_name: raise RuntimeError("Local engine contract has no valid CDSL JSON Schema path") return json.loads((engine_dir / schema_name).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, AttributeError) as error: raise RuntimeError("Local CDSL JSON Schema is unavailable or invalid") from error CDSL_TOOL_SCHEMA = _cdsl_tool_schema() FLANGE_SLEEVE_PLAN_SCHEMA = flange_sleeve_plan_schema() GENERATION_TOOL_NAMES = {"generate_cdsl_model", "generate_flange_sleeve_model"} TOOL_SCHEMAS: list[dict[str, Any]] = [ { "type": "function", "function": { "name": "analyze_image_reference", "description": ( "Analyze the current image attachments as a CAD reference. " "Use this before any CAD planning or generation for a newly uploaded image. " "Describe only visible geometry and identify dimensions that may need confirmation." ), "parameters": { "type": "object", "properties": { "part_type": {"type": "string", "minLength": 1}, "visible_features": { "type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "maxItems": 12, }, "uncertain_features": { "type": "array", "items": {"type": "string", "minLength": 1}, "maxItems": 8, }, "dimension_candidates": { "type": "array", "items": { "type": "object", "properties": { "id": {"type": "string", "minLength": 1}, "label": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}, }, "required": ["id", "label", "reason"], "additionalProperties": False, }, "maxItems": 12, }, }, "required": ["part_type", "visible_features", "uncertain_features"], "additionalProperties": False, }, }, }, { "type": "function", "function": { "name": "search_cdsl_library", "description": "Search the official local CDSL library for similar geometry and feature sequences.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 8}}, "required": ["query"], "additionalProperties": False, }, }, }, { "type": "function", "function": { "name": "read_cdsl_reference", "description": "Read one official CDSL sample by part_id. Use this before creating geometry based on a reference.", "parameters": {"type": "object", "properties": {"part_id": {"type": "string"}}, "required": ["part_id"], "additionalProperties": False}, }, }, { "type": "function", "function": { "name": "describe_design_intent", "description": "Record a concise natural-language CAD plan as reference for this turn's CDSL generation. This plan is not a CAD contract; CDSL remains the only authoritative model.", "parameters": { "type": "object", "properties": { "plan": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, }, "required": ["plan", "assumptions"], "additionalProperties": False, }, }, }, { "type": "function", "function": { "name": "read_current_cdsl", "description": "Read the current task's latest CDSL before making a natural-language revision.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, }, { "type": "function", "function": { "name": "generate_flange_sleeve_model", "description": ( "Generate a square or rectangular flange sleeve from a compact semantic plan. " "Use this preferred tool for a flange plate with a coaxial hollow tube, " "a stepped or tapered tip, four mounting holes, and counterbores. " "The backend's verified template creates all CDSL workplanes, sketches, " "feature dependencies, and feature parameters." ), "parameters": { "type": "object", "properties": { "plan": FLANGE_SLEEVE_PLAN_SCHEMA, "summary": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, }, "required": ["plan", "summary", "assumptions"], "additionalProperties": False, }, }, }, { "type": "function", "function": { "name": "generate_cdsl_model", "description": "Validate and execute a complete parameterized CDSL model. Use only for explicit CAD generation or revision.", "parameters": { "type": "object", "properties": { "cdsl": CDSL_TOOL_SCHEMA, "summary": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, }, "required": ["cdsl", "summary", "assumptions"], "additionalProperties": False, }, }, }, ] def tools_for_model( model: ProviderModel, *, include_image_analysis: bool = True, flange_sleeve_only: bool = False, ) -> list[dict[str, Any]]: """Return this model's tool contract without mutating the shared schema.""" tools = deepcopy(TOOL_SCHEMAS) if not include_image_analysis: tools = [ tool for tool in tools if tool.get("function", {}).get("name") != "analyze_image_reference" ] if flange_sleeve_only: tools = [ tool for tool in tools if tool.get("function", {}).get("name") not in { "search_cdsl_library", "read_cdsl_reference", "read_current_cdsl", "generate_cdsl_model", } ] if not model.strict_tool_schema: return tools for tool in tools: if tool.get("function", {}).get("name") in GENERATION_TOOL_NAMES: # This flag constrains function arguments only. It has no effect on # normal assistant text, the user's prompt, or the summary. tool["function"]["strict"] = True return tools def text_from_message(message: ChatMessage) -> str: return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip() def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] for message in messages[-20:]: text = text_from_message(message) if text: result.append({"role": message.role, "content": text}) return result def image_attachments(conversation: dict[str, Any]) -> list[dict[str, Any]]: return [ attachment for attachment in conversation.get("attachments") or [] if isinstance(attachment, dict) and attachment.get("kind") == "image" and attachment.get("id") ] def revision_input_attachments(conversation: dict[str, Any]) -> list[dict[str, str | int]]: """Snapshot conversation-owned inputs without duplicating their files into a task.""" conversation_id = str(conversation.get("conversation_id") or "") snapshots: list[dict[str, str | int]] = [] for attachment in conversation.get("attachments") or []: if not isinstance(attachment, dict) or str(attachment.get("conversation_id") or "") != conversation_id: continue attachment_id = str(attachment.get("id") or "") if not attachment_id: continue snapshots.append({ "attachment_id": attachment_id, "conversation_id": conversation_id, "name": str(attachment.get("name") or ""), "kind": str(attachment.get("kind") or ""), "mime": str(attachment.get("mime") or ""), "size": int(attachment.get("size") or 0), "sha256": str(attachment.get("sha256") or ""), }) return snapshots def cad_request_instruction(task_id: str, current_task: dict[str, Any] | None) -> str: revision_id = str((current_task or {}).get("current_revision") or "") if revision_id: return f""" CAD request state: - Mode: revision. - Target task: {task_id}; current successful revision: {revision_id}. - Call read_current_cdsl first, then preserve unrelated features in the complete replacement CDSL. """ prior_attempt = f" Task {task_id} has no successful revision and is only a failed/incomplete build attempt." if task_id else "" return f""" CAD request state: - Mode: create. - There is no current successful CDSL revision.{prior_attempt} - Do not call read_current_cdsl. Generate a new model after the normal planning workflow. """ def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | None: """Return an analysis only when it covers every currently attached image.""" attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} if not attachment_ids: return None for message in reversed(conversation.get("messages") or []): if not isinstance(message, dict): continue for part in reversed(message.get("parts") or []): if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": continue data = part.get("data") if not isinstance(data, dict): continue analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} if attachment_ids.issubset(analyzed_ids): return data return None def use_flange_sleeve_template(analysis: dict[str, Any] | None, task_id: str) -> bool: """Route only a high-confidence new image reference to the verified template.""" if task_id or not isinstance(analysis, dict): return False visible = analysis.get("visibleFeatures", analysis.get("visible_features", [])) values = [analysis.get("partType", analysis.get("part_type", ""))] if isinstance(visible, list): values.extend(visible) text = " ".join(str(value or "").casefold() for value in values) has_flange = "法兰" in text or "flange" in text has_sleeve = any(token in text for token in ("套筒", "管座", "圆筒", "cylind", "sleeve", "tube")) return has_flange and has_sleeve def flange_sleeve_template_instruction(enabled: bool) -> str: if not enabled: return "" return """ Template routing (highest priority for this request): - This new image reference has been classified as a flange sleeve. - After describe_design_intent, call generate_flange_sleeve_model exactly once. - The complete-CDSL generation tool is intentionally unavailable for this request. - Supply only visible semantic dimensions and assumptions; omitted dimensions use the verified template defaults. Do not ask for CDSL implementation fields. """ def image_reference_instruction( attachments: list[dict[str, Any]], analysis: dict[str, Any] | None, ) -> str: if not attachments: return "" if analysis is None: return """ Image-reference intake gate (highest priority for this turn): - The user has uploaded image references that have not yet been analyzed. - Your only tool call in this turn must be analyze_image_reference. - Do not call describe_design_intent, search_cdsl_library, read_cdsl_reference, read_current_cdsl, or generate_cdsl_model in this turn. - Describe only what is visible. Do not invent dimensions from pixels or perspective. - The backend will present the structured result and provide it back as visual-reference context for you to continue this same task. """ return """ Image-reference analysis already recorded for the current attachments: {data} Use this result as visual-reference context and do not analyze the image again. The listed dimensions are candidates for confirmation, not a mandatory gate. Interpret the full conversation to decide whether to ask a concise question, make clearly stated approximate assumptions, or continue the ordinary CDSL workflow. When the user permits or requests estimates, choose coherent values yourself and record them as assumptions instead of asking again. Respect the user's tolerance for estimates. Never present an inferred dimension as an exact measurement from the image. """.format(data=json.dumps(analysis, ensure_ascii=False, separators=(",", ":"))) def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]: def text(value: Any, name: str, limit: int = 300) -> str: normalized = str(value or "").strip() if not normalized: raise ValueError(f"analyze_image_reference requires a non-empty {name}") return normalized[:limit] def text_list(value: Any, name: str, maximum: int) -> list[str]: if not isinstance(value, list) or not value: raise ValueError(f"analyze_image_reference requires a non-empty {name} array") return [text(item, name, 240) for item in value[:maximum]] raw_dimensions = arguments.get("dimension_candidates") if raw_dimensions is None: raw_dimensions = [] if not isinstance(raw_dimensions, list): raise ValueError("analyze_image_reference dimension_candidates must be an array") dimensions: list[dict[str, str]] = [] used_ids: set[str] = set() for item in raw_dimensions[:12]: if not isinstance(item, dict): raise ValueError("analyze_image_reference dimension_candidates must contain objects") dimension_id = text(item.get("id"), "dimension_candidates.id", 80) if dimension_id in used_ids: continue used_ids.add(dimension_id) dimensions.append({ "id": dimension_id, "label": text(item.get("label"), "dimension_candidates.label", 160), "reason": text(item.get("reason"), "dimension_candidates.reason", 240), }) uncertain = arguments.get("uncertain_features") if not isinstance(uncertain, list): raise ValueError("analyze_image_reference requires an uncertain_features array") return { "part_type": text(arguments.get("part_type"), "part_type"), "visible_features": text_list(arguments.get("visible_features"), "visible_features", 12), "uncertain_features": [text(item, "uncertain_features", 240) for item in uncertain[:8]], "dimension_candidates": dimensions, } def _viewer_selection_text(value: Any, limit: int = 240) -> str: return str(value or "").strip()[:limit] def _viewer_selection_vector(value: Any) -> list[float] | None: if not isinstance(value, list) or len(value) < 3: return None try: vector = [float(component) for component in value[:3]] except (TypeError, ValueError): return None return vector if all(math.isfinite(component) for component in vector) else None def _viewer_selection_bbox(value: Any) -> dict[str, list[float]] | None: if not isinstance(value, dict): return None minimum = _viewer_selection_vector(value.get("min")) maximum = _viewer_selection_vector(value.get("max")) return {"min": minimum, "max": maximum} if minimum and maximum else None def _viewer_selection_entity(value: Any) -> dict[str, Any] | None: if not isinstance(value, dict): return None reference_id = _viewer_selection_text(value.get("referenceId"), 120) if not reference_id: return None return { "referenceId": reference_id, "selector": _viewer_selection_text(value.get("selector")), "label": _viewer_selection_text(value.get("label")), "selectorType": _viewer_selection_text(value.get("selectorType"), 80), "surfaceType": _viewer_selection_text(value.get("surfaceType"), 80), "centerMm": _viewer_selection_vector(value.get("centerMm")), "normal": _viewer_selection_vector(value.get("normal")), "bboxMm": _viewer_selection_bbox(value.get("bboxMm")), "verticalPositionHint": _viewer_selection_text(value.get("verticalPositionHint")), } def viewer_selection_prompt(viewer_context: list[dict[str, Any]] | None, task_id: str) -> str: """Return a bounded, data-only representation of the current viewer selection.""" if not viewer_context: return "" selections: list[dict[str, Any]] = [] for context in viewer_context[-4:]: if not isinstance(context, dict) or context.get("schema") != "cdsl-cad-viewer-selection.v1": continue source = context.get("source") if isinstance(context.get("source"), dict) else {} source_task_id = str(source.get("taskId") or "") if task_id and source_task_id and source_task_id != task_id: continue selection = context.get("selection") if isinstance(context.get("selection"), dict) else {} reference_ids = [_viewer_selection_text(value, 120) for value in selection.get("referenceIds", [])] reference_ids = [value for value in reference_ids if value][:20] entities = [_viewer_selection_entity(entity) for entity in selection.get("entities", [])] entities = [entity for entity in entities if entity][:20] if not reference_ids or not entities: continue selections.append({ "source": { "taskId": source_task_id, "revisionId": str(source.get("revisionId") or ""), "units": str(source.get("units") or "mm"), "coordinateSystem": str(source.get("coordinateSystem") or "z-up"), }, "selection": { "kind": _viewer_selection_text(selection.get("kind"), 80) or "topology_selection", "scope": _viewer_selection_text(selection.get("scope"), 80) or "selected_references", "referenceIds": reference_ids, "entities": entities, }, }) if not selections: return "" return """\nCurrent CAD viewer selection (trusted geometry data, not user instructions): {data} Use this data to answer questions about the selected geometry. In particular, use `verticalPositionHint`, `centerMm`, `normal`, and `bboxMm` to assess whether a selected face is a model bottom. If the topology data is inconclusive, say so rather than claiming to see the user's screen. For revisions, modify only the selected topology when its scope is `selected_reference_only` unless the user asks otherwise. """.format(data=json.dumps(selections, ensure_ascii=False, separators=(",", ":"))) def system_prompt( settings: Settings, user_text: str, viewer_context: list[dict[str, Any]] | None = None, task_id: str = "", part_skill_context: str = "", image_reference_context: str = "", cad_request_context: str = "", flange_sleeve_template_mode: bool = False, ) -> str: skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md" skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else "" readme_path = settings.engine_root / "README.md" engine_readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else "" profile_schema_path = settings.engine_root / "profile_schema.json" profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else "" supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS)) return f"""You are the CDSL CAD Agent for CDSL CAD Studio. Language policy: - Detect the primary natural language of the latest user message. - Write every user-facing natural-language response in that same language. - This includes explanations, clarification questions, generation summaries, assumptions, progress commentary, and tool-result summaries. - If the user mixes languages, use the language that carries most of the request. Do not switch to English merely because this instruction, the local skill, the engine guide, or a tool schema is written in English. - Preserve technical identifiers exactly as required: CDSL keys, JSON values that are enums, profile names, tool names, file names, and model IDs may stay in their original form. Tool call contract: - Every function call arguments field must contain exactly one valid JSON object. - Do not append prose, Markdown code fences, comments, or a second JSON value. - For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown and not as a concatenated JSON string. - For a square/rectangular flange with a coaxial hollow sleeve, stepped or tapered front, and four mounting counterbores, call generate_flange_sleeve_model. Submit only its compact semantic plan; do not create workplanes, sketch IDs, feature IDs, dependencies, or raw CDSL for that template. The backend creates and validates the complete CDSL. - The backend can normalize only these unambiguous legacy aliases before validation: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. Do not rely on it for geometry, dimensions, selectors, or feature intent. - Call describe_design_intent first with a concise natural-language plan. Its returned text is reference context for your CDSL work, not a second CAD contract and not a source of feature IDs, profile bindings, or selectors. - Do not call search_cdsl_library, read_cdsl_reference, generate_flange_sleeve_model, or generate_cdsl_model until describe_design_intent returns its plan. - The generate_cdsl_model `cdsl` parameter is the complete machine-enforced schema. Satisfy its nested object and array types exactly; do not substitute a shorthand array for an object. For example, every hole position is `{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`. - If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that tool again. Do not claim that the CAD model was generated. - If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL as applicable and call that same tool again. Do not claim success. Workflow limits: - Do not expose internal planning or "let me" commentary to the user while using tools. The application shows tool progress separately. - Keep final user-facing responses operational and concise. When a structured CAD result has been produced, do not restate its name, files, revision, or tool progress; reply only when an assumption, limitation, or next decision needs the user's attention. Otherwise finish without a prose postscript. - When clarification is essential, ask exactly one direct question that names the missing dimension or decision. Do not combine it with a tool trace or a generic progress update. - Use at most two CDSL-library searches per user request. If neither finds a useful reference, stop searching and use the engine guide to either generate the model or ask one concise clarification question. - Do not repeatedly search for the same unavailable feature or profile. {response_language_instruction(user_text)} {image_reference_context} {cad_request_context} {flange_sleeve_template_instruction(flange_sleeve_template_mode)} You generate executable CAD through compact semantic plans or parameterized CDSL, never raw CAD source code. For new CAD requests: 1. Use the injected part-skill guidance, when present, only to establish the structural plan, feature dependency order, and parameter roles. 2. Call describe_design_intent with a concise textual plan. Ask one concise user-facing question before CAD generation if essential dimensions are missing. 3. If the requested family is a flange sleeve matching the dedicated template, call generate_flange_sleeve_model immediately after the textual plan. Do not search the CDSL library and do not use generate_cdsl_model for that case. 4. For other families, search the local official CDSL library. Read at least one relevant reference when a match exists; samples provide schema-valid expressions, not higher-priority user requirements. 5. For non-template families, generate one complete CDSL and call generate_cdsl_model. The backend-generated CDSL is the authoritative CAD model; the textual plan is guidance only. 6. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact. Precedence is strict: explicit user request, then CDSL schema/runtime, then part-skill guidance, then CDSL-library examples. Part skills never authorize build123d source, an unknown atomic/profile, an invented selector, or a free- coordinate substitute for a capability the runtime cannot express. For an unsupported requested structure, ask one concise clarification question or state the blocker rather than fabricating geometry. If a part-family conflict is injected, preserve the current part unless the user explicitly requests a whole-part replacement. A primary-family conflict is a hard clarification stop: ask one concise question and do not call a generation tool until the user resolves it. For a revision, call read_current_cdsl first and preserve unrelated features. Use the flange sleeve template only when the user wants to replace the whole part or the current part already follows that family; otherwise submit the complete replacement CDSL. The backend automatically uses the latest successful revision as the parent; do not provide a revision ID. Do not output compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles and feature atomic IDs defined in the engine schema below. Read the engine schema before selecting an atomic ID, a profile, or their parameter names. Do not invent an atomic ID, profile, or their fields. Ask a concise clarification question when essential dimensions or intent are missing. Ordinary explanations must not create CAD. Local skill: {skill} Local engine guide: {engine_readme} Authoritative engine schema: {profile_schema} Supported named profile types: {supported_profiles} Injected part-skill context: {part_skill_context or "No part-family skill guidance was selected for this request."} {viewer_selection_prompt(viewer_context, task_id)} """ def part_skill_root(settings: Settings) -> Path: return settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "references" / "part-skills" class AgentService: def __init__( self, settings: Settings, store: WorkspaceStore, library: CdslLibrary, part_skill_library: PartSkillLibrary | None = None, ) -> None: self.settings = settings self.store = store self.library = library self.part_skill_library = part_skill_library or PartSkillLibrary(part_skill_root(settings)) async def stream( self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None, ) -> AsyncIterator[bytes]: latest_user = next((message for message in reversed(messages) if message.role == "user"), None) if latest_user is None: yield event("cad_error", {"stage": "request", "message": "A user message is required."}) yield event("done", {}) return user_text = text_from_message(latest_user) conversation = self.store.ensure_conversation(conversation_id) task_id = str(selected_task_id or conversation.get("current_task_id") or "") current_task = self.store.read_task(task_id) if task_id else None assistant_parts: list[dict[str, Any]] = [] assistant_id = f"assistant_{secrets.token_hex(8)}" error_payload: dict[str, Any] | None = None if task_id and current_task is None: error_payload = {"stage": "request", "message": "The selected CAD task no longer exists. Start a new model or select a valid task."} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, "") yield event("done", {}) return self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id or None) try: provider, model = self.settings.resolve_model(provider_id, model_id) except ValueError as error: provider = None model = None configuration_error = str(error) else: configuration_error = "" if not self.settings.llm_configured or provider is None or model is None: message = "Agent 尚未配置模型。请设置 CDSL_LLM_BASE_URL、CDSL_LLM_API_KEY 和 CDSL_LLM_MODEL。" if configuration_error: message = configuration_error error_payload = {"stage": "configuration", "message": message} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) yield event("done", {}) return try: attachment_message = self._attachment_message(conversation, model) except ValueError as error: error_payload = {"stage": "attachment", "message": str(error)} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) yield event("done", {}) return image_inputs = image_attachments(conversation) recorded_image_analysis = image_reference_analysis(conversation) requires_image_intake = bool(image_inputs) and recorded_image_analysis is None flange_sleeve_template_mode = use_flange_sleeve_template(recorded_image_analysis, task_id) yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] library_searches = 0 inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task) part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids) planning_state: dict[str, Any] = {"phase": "WAITING_FOR_PLAN", "design_brief": ""} yield event("progress", { "step": "select_part_skill", "label": "识别零件族", "status": "success", "message": "已完成零件族与辅助建模规则识别。", }) model_messages: list[dict[str, Any]] = [{ "role": "system", "content": system_prompt( self.settings, user_text, viewer_context, task_id, self.part_skill_library.render_context(part_skill_selection), image_reference_instruction(image_inputs, recorded_image_analysis), cad_request_instruction(task_id, current_task), flange_sleeve_template_mode, ), }] model_messages.extend(messages_for_model(messages)) if attachment_message: model_messages.append({"role": "user", "content": attachment_message}) tools = tools_for_model( model, include_image_analysis=requires_image_intake, flange_sleeve_only=flange_sleeve_template_mode, ) required_tool_name: str | None = "analyze_image_reference" if requires_image_intake else None generate_argument_failures = 0 tool_argument_diagnostics: list[str] = [] cdsl_validation_diagnostics: list[str] = [] generation_completed = False try: for iteration in range(8): response = await self._complete(model_messages, tools, provider, model, required_tool_name) response_choice = response["choices"][0] choice = response_choice["message"] tool_calls = choice.get("tool_calls") or [] content = str(choice.get("content") or "") # Tool-call content is implementation planning. It is retained in # model_messages for the next round but not shown to the user. if content and not tool_calls and not required_tool_name: assistant_parts.append({"type": "text", "text": content}) for chunk in self._chunks(content): yield event("text_delta", {"text": chunk}) if not tool_calls: if required_tool_name: model_messages.append(choice) model_messages.append({ "role": "system", "content": f"You must now call {required_tool_name} with corrected complete arguments. Do not reply with prose.", }) continue break model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") if name == "analyze_image_reference" and recorded_image_analysis is not None: result = { "ok": False, "code": "IMAGE_ANALYSIS_ALREADY_RECORDED", "message": ( "Image analysis is already recorded for the current attachments. " "Use that result and continue the CAD workflow; do not analyze the image again." ), } model_messages.append({ "role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False), }) yield event("progress", { "step": name, "label": self._tool_label(name), "status": "error", "message": "图片识别结果已存在,正在继续后续建模流程。", }) continue if name == "search_cdsl_library": library_searches += 1 if library_searches > 2: result = { "ok": False, "code": "LIBRARY_SEARCH_LIMIT_REACHED", "message": ( "The CDSL library search limit for this request has been reached. " "Do not search again. Use the engine guide to call generate_cdsl_model " "or ask the user one concise clarification question." ), } model_messages.append({ "role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False), }) yield event("progress", { "step": name, "label": self._tool_label(name), "status": "error", "message": "模型库未找到更多匹配项,正在继续生成模型。", }) continue try: arguments = parse_tool_arguments( call.get("function", {}).get("arguments"), recover_cdsl_wrapper=name == "generate_cdsl_model", ) except ToolArgumentsError as error: diagnostic_path = self._record_tool_call_diagnostic( conversation_id=conversation["conversation_id"], task_id=task_id, provider=provider, model=model, response=response, finish_reason=response_choice.get("finish_reason"), iteration=iteration + 1, call=call, error=error, ) if diagnostic_path: tool_argument_diagnostics.append(diagnostic_path) result = invalid_tool_arguments_result(name or "tool", error) if name in GENERATION_TOOL_NAMES: generate_argument_failures += 1 if generate_argument_failures >= 2: raise RepeatedToolArgumentsError(str(error), tool_argument_diagnostics) required_tool_name = name model_messages.append({ "role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False), }) yield event("progress", { "step": name or "tool_arguments", "label": self._tool_label(name), "status": "error", "message": "CAD 工具参数格式无效,正在请求模型修正。", }) continue cdsl_attempt_path = "" if name == "generate_cdsl_model": cdsl_attempt_path = self._record_cdsl_attempt( conversation_id=conversation["conversation_id"], arguments=arguments, iteration=iteration + 1, ) yield event("progress", { "step": name, "label": self._tool_label(name), "status": "running", "message": "Agent 正在调用本地 CAD 工具。", }) try: result, generated = await self._run_tool( name, arguments, task_id, user_text, references, part_skill_selection=part_skill_selection, planning_state=planning_state, image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs], input_attachments=revision_input_attachments(conversation), ) except (ValueError, RuntimeError) as error: if name in GENERATION_TOOL_NAMES: diagnostic_path = self._record_cdsl_validation_diagnostic( conversation_id=conversation["conversation_id"], task_id=task_id, provider=provider, model=model, response=response, finish_reason=response_choice.get("finish_reason"), iteration=iteration + 1, call=call, arguments=arguments, cdsl_attempt_path=cdsl_attempt_path, error=error, ) cdsl_validation_diagnostics.append(diagnostic_path) result = invalid_cdsl_result(error) generated = None required_tool_name = name else: raise if result.get("task_id"): task_id = str(result["task_id"]) if generated: task_id = generated["task_id"] model_messages.append({ "role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False), }) yield event("progress", { "step": name, "label": self._tool_label(name), "status": "success" if result.get("ok", True) else "error", "message": user_visible_tool_message(result, user_text), }) if name == "describe_design_intent" and result.get("ok"): yield event("progress", { "step": "record_design_brief", "label": "记录设计说明", "status": "success", "message": "设计说明已记录,将作为 CDSL 生成参考。", }) if name == "analyze_image_reference" and result.get("ok"): image_payload = { "attachmentIds": result["attachment_ids"], "partType": result["part_type"], "visibleFeatures": result["visible_features"], "uncertainFeatures": result["uncertain_features"], "dimensionCandidates": result["dimension_candidates"], } assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) yield event("image_analysis", image_payload) recorded_image_analysis = image_payload flange_sleeve_template_mode = use_flange_sleeve_template(image_payload, task_id) required_tool_name = None tools = tools_for_model( model, include_image_analysis=False, flange_sleeve_only=flange_sleeve_template_mode, ) model_messages.append({ "role": "system", "content": image_reference_instruction(image_inputs, image_payload), }) if flange_sleeve_template_mode: model_messages.append({ "role": "system", "content": flange_sleeve_template_instruction(True), }) yield event("progress", { "step": "analyze_image_reference", "label": "识别图片参考", "status": "success", "message": "已识别可见结构,正在根据对话继续判断建模方案。", }) continue if name in GENERATION_TOOL_NAMES and result.get("ok"): required_tool_name = None if generated: result_payload = { "taskId": generated["task_id"], "revisionId": generated["revision_id"], "cdslPath": generated["cdsl_path"], "stepPath": generated["step_path"], "glbPath": generated["glb_path"], "reportPath": generated["report_path"], "parametersPath": generated.get("parameters_path"), "selectorPath": generated.get("selector_path"), "edgesPath": generated.get("edges_path"), "summary": generated["summary"], "referenceIds": generated["reference_ids"], "engine": generated["engine"], } assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) yield event("progress", { "step": "build_cad", "label": "构建 CAD", "status": "success", "message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。", }) generation_completed = True break if generation_completed: break if iteration == 7: if cdsl_validation_diagnostics: diagnostic_paths = "、".join(cdsl_validation_diagnostics) if any("\u4e00" <= char <= "\u9fff" for char in user_text): message = f"模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:{diagnostic_paths}。" else: message = ( "Agent tool loop reached its safety limit. " f"CDSL validation diagnostics were saved to: {diagnostic_paths}." ) else: message = "Agent tool loop reached its safety limit." error_payload = {"stage": "agent", "message": message} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) except Exception as error: error_payload = {"stage": "agent", "message": user_visible_error_message(error, user_text)} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) if task_id: self.store.ensure_conversation(conversation["conversation_id"], task_id) if not assistant_parts: assistant_parts.append({ "type": "text", "text": "我暂时没有生成可执行的 CAD 结果。请补充尺寸、形状或修改目标。", }) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) yield event("progress", {"step": "agent_stream", "label": "调用模型和工具", "status": "success", "message": "Agent 请求已完成。"}) yield event("done", {}) def _persist_assistant( self, conversation_id: str, assistant_id: str, parts: list[dict[str, Any]], task_id: str, ) -> None: self.store.append_conversation_message( conversation_id, { "id": assistant_id or f"assistant_{conversation_id}_{len(parts)}", "role": "assistant", "parts": parts, }, task_id or None, ) def _record_tool_call_diagnostic( self, *, conversation_id: str, task_id: str, provider: ProviderConfig, model: ProviderModel, response: dict[str, Any], finish_reason: Any, iteration: int, call: dict[str, Any], error: ToolArgumentsError, ) -> str: function = call.get("function") if isinstance(call.get("function"), dict) else {} raw_arguments = function.get("arguments") raw_text = raw_arguments if isinstance(raw_arguments, str) else json.dumps(raw_arguments, ensure_ascii=False) json_error = error.__cause__ if isinstance(error.__cause__, json.JSONDecodeError) else None payload = { "schema_version": "1.0", "recorded_at": now_iso(), "conversation_id": conversation_id, "task_id": task_id, "provider_id": provider.id, "model_id": model.id, "strict_tool_schema": model.strict_tool_schema, "completion_id": response.get("id"), "response_model": response.get("model"), "finish_reason": finish_reason, "usage": response.get("usage"), "iteration": iteration, "tool_call_id": call.get("id"), "tool_name": function.get("name"), "parse_error": str(error), "json_error": { "message": json_error.msg, "line": json_error.lineno, "column": json_error.colno, "character": json_error.pos, } if json_error else None, "arguments_type": type(raw_arguments).__name__, "arguments_utf8_bytes": len(raw_text.encode("utf-8")), "arguments": raw_arguments, } return self.store.write_tool_call_diagnostic(conversation_id, payload) def _record_cdsl_attempt( self, *, conversation_id: str, arguments: dict[str, Any], iteration: int, ) -> str: candidate = arguments.get("cdsl") if isinstance(candidate, str): try: candidate = json.loads(candidate) except json.JSONDecodeError: pass return self.store.write_cdsl_attempt(conversation_id, candidate, iteration) def _record_cdsl_validation_diagnostic( self, *, conversation_id: str, task_id: str, provider: ProviderConfig, model: ProviderModel, response: dict[str, Any], finish_reason: Any, iteration: int, call: dict[str, Any], arguments: dict[str, Any], cdsl_attempt_path: str, error: Exception, ) -> str: function = call.get("function") if isinstance(call.get("function"), dict) else {} payload = { "schema_version": "1.0", "recorded_at": now_iso(), "kind": "cdsl_validation_failure", "conversation_id": conversation_id, "task_id": task_id, "provider_id": provider.id, "model_id": model.id, "strict_tool_schema": model.strict_tool_schema, "completion_id": response.get("id"), "response_model": response.get("model"), "finish_reason": finish_reason, "usage": response.get("usage"), "iteration": iteration, "tool_call_id": call.get("id"), "tool_name": function.get("name"), "cdsl_attempt_path": cdsl_attempt_path, "summary": str(arguments.get("summary") or ""), "assumptions": arguments.get("assumptions") or [], "validation_error_type": type(error).__name__, "validation_error": str(error), } return self.store.write_cdsl_validation_diagnostic(conversation_id, payload) async def _complete( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, required_tool_name: str | None = None, ) -> dict[str, Any]: url = f"{provider.base_url}/chat/completions" headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} tool_choice: str | dict[str, Any] = "auto" if required_tool_name: tool_choice = {"type": "function", "function": {"name": required_tool_name}} payload = { "model": model.id, "messages": messages, "tools": tools, "tool_choice": tool_choice, "temperature": 0.1, } async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: response = await client.post(url, headers=headers, json=payload) # Some reasoning-enabled, OpenAI-compatible models accept tools but # reject an explicit tool_choice. Retry once without that constraint. if ( response.status_code == 400 and "thinking mode does not support this tool_choice" in response.text.lower() ): payload.pop("tool_choice") response = await client.post(url, headers=headers, json=payload) if response.status_code >= 400: if model.strict_tool_schema: raise StrictToolSchemaError( "LLM provider rejected the strict CDSL tool schema " f"({response.status_code}). Disable CDSL_*_STRICT_TOOL_SCHEMA " "or CDSL_*_STRICT_TOOL_MODELS for this endpoint, or select a " "model that supports strict function schemas. " f"Provider response: {response.text[:500]}" ) raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}") return response.json() async def _run_tool( self, name: str, arguments: dict[str, Any], task_id: str, request: str, references: list[str], *, part_skill_selection: dict[str, Any] | None = None, planning_state: dict[str, Any] | None = None, image_attachment_ids: list[str] | None = None, input_attachments: list[dict[str, str | int]] | None = None, ) -> tuple[dict[str, Any], dict[str, Any] | None]: state = planning_state if planning_state is not None else {"phase": "WAITING_FOR_PLAN", "design_brief": ""} phase = str(state.get("phase") or "WAITING_FOR_PLAN") if name == "analyze_image_reference": if not image_attachment_ids: raise ValueError("analyze_image_reference requires at least one image attachment") return { "ok": True, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), **normalize_image_analysis(arguments), }, None if name == "describe_design_intent": plan = str(arguments.get("plan") or "").strip() assumptions = arguments.get("assumptions") if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions") state["design_brief"] = plan state["phase"] = "PLAN_RECORDED" return { "ok": True, "plan": plan, "assumptions": [item.strip() for item in assumptions if item.strip()], "message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.", }, None if name == "search_cdsl_library": if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5)) state["phase"] = "LIBRARY_REFERENCE" return {"ok": True, "results": results}, None if name == "read_cdsl_reference": if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before reading CDSL references."}, None part_id = str(arguments.get("part_id") or "") sample = self.library.read_sample(part_id) if part_id not in references: references.append(part_id) state["phase"] = "WAITING_FOR_CDSL" return {"ok": True, "part_id": part_id, "cdsl": sample}, None if name == "read_current_cdsl": if not task_id: return {"ok": False, "message": "No current task exists. This is a new model request."}, None path = self.store.current_cdsl_path(task_id) if path is None: return {"ok": False, "message": "The current task has no successful CDSL revision."}, None return {"ok": True, "task_id": task_id, "cdsl": json.loads(path.read_text(encoding="utf-8"))}, None if name in GENERATION_TOOL_NAMES: if phase not in {"PLAN_RECORDED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None template_plan: dict[str, float | str] | None = None operation: dict[str, Any] | None = None if name == "generate_flange_sleeve_model": cdsl, template_plan = build_flange_sleeve_cdsl(arguments.get("plan")) operation = { "type": "parameterized_template", "template_id": FLANGE_SLEEVE_TEMPLATE_ID, "plan": template_plan, } else: cdsl = arguments.get("cdsl") if isinstance(cdsl, str): cdsl = json.loads(cdsl) if not isinstance(cdsl, dict): raise ValueError("generate_cdsl_model requires a CDSL JSON object") cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl) # Reject malformed model output before build_revision allocates a task # directory or revision. build_revision will assign the real task ID. preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")} engine = load_engine(self.settings) validate_cdsl(preflight_cdsl, engine) summary = str(arguments.get("summary") or "Parameterized CAD model") raw_assumptions = arguments.get("assumptions") or [] if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions): raise ValueError(f"{name} assumptions must be an array of strings") assumptions = [item.strip() for item in raw_assumptions if item.strip()] selection = part_skill_selection or self.part_skill_library.select(request) part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions) current_task = self.store.read_task(task_id) if task_id else None parent_revision_id = str((current_task or {}).get("current_revision") or "") state["phase"] = "BUILDING" try: yieldable = await asyncio.to_thread( build_revision, settings=self.settings, store=self.store, task_id=task_id or None, request=request, cdsl=cdsl, reference_ids=list(references), summary=summary, parent_revision_id=parent_revision_id, operation=operation, part_skills=part_skill_audit, generation_assumptions=assumptions, input_attachments=input_attachments, ) except Exception: state["phase"] = "PLAN_RECORDED" raise state["phase"] = "COMPLETED" return { "ok": True, "summary": summary, "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"], "normalization_repairs": normalization_repairs, "template_id": FLANGE_SLEEVE_TEMPLATE_ID if template_plan is not None else "", "template_plan": template_plan, }, yieldable raise ValueError(f"Unknown agent tool: {name}") @staticmethod def _chunks(text: str) -> list[str]: return [text[index:index + 96] for index in range(0, len(text), 96)] @staticmethod def _tool_label(name: str) -> str: return { "search_cdsl_library": "检索 CDSL 模型库", "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", "describe_design_intent": "整理设计说明", "generate_flange_sleeve_model": "生成参数化法兰套筒", "generate_cdsl_model": "生成 CDSL", }.get(name, "调用 CAD 工具") def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str: attachments = conversation.get("attachments") or [] if not attachments: return "" conversation_id = str(conversation.get("conversation_id") or "") if not conversation_id: raise ValueError("Conversation attachment has no conversation id") content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request."}] for attachment in attachments: if not isinstance(attachment, dict): continue kind = str(attachment.get("kind") or "") attachment_conversation = str(attachment.get("conversation_id") or "") relative = str(attachment.get("path") or "") if attachment_conversation != conversation_id or not relative: raise ValueError("Conversation attachment metadata is invalid") path = self.store.conversation_attachment_path(conversation_id, relative) if not path.is_file(): raise ValueError(f"Conversation attachment is missing: {attachment.get('name') or attachment.get('id')}") if kind == "image": if not model.vision: raise ValueError("The selected model does not support images. Choose a vision-capable model enabled in backend/.env.") mime = str(attachment.get("mime") or "image/png") encoded = base64.b64encode(path.read_bytes()).decode("ascii") content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}}) elif kind == "document": extracted = str(attachment.get("extracted_path") or "") if extracted: text_path = self.store.conversation_attachment_path(conversation_id, extracted) text = text_path.read_text(encoding="utf-8")[:30_000] content.append({"type": "text", "text": f"Document {attachment.get('name')}:\n{text}"}) return content