优化agent
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -34,10 +34,10 @@ def extract_document_text(data: bytes) -> str:
|
||||
return text[:MAX_EXTRACTED_CHARS]
|
||||
|
||||
|
||||
def attachment_record(task_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]:
|
||||
def attachment_record(conversation_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]:
|
||||
return {
|
||||
"id": Path(relative_path).stem,
|
||||
"task_id": task_id,
|
||||
"conversation_id": conversation_id,
|
||||
"name": filename,
|
||||
"kind": kind,
|
||||
"path": relative_path,
|
||||
|
||||
@@ -147,10 +147,6 @@ def apply_direct_edit(
|
||||
revision_id = str((task or {}).get("current_revision") or "")
|
||||
if source is None or not revision_id:
|
||||
raise ValueError("Task has no successful CDSL revision")
|
||||
current_revision = next(
|
||||
(item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id),
|
||||
{},
|
||||
)
|
||||
frame = _selection_frame(selection)
|
||||
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
|
||||
features = cdsl.setdefault("features", [])
|
||||
@@ -190,7 +186,4 @@ def apply_direct_edit(
|
||||
operation={"type": operation, "selection": selection, "parameters": parameters},
|
||||
part_skills=None,
|
||||
generation_assumptions=[],
|
||||
design_intent_id=str(current_revision.get("design_intent_id") or ""),
|
||||
design_intent_path=str(current_revision.get("design_intent_path") or ""),
|
||||
design_intent_status="accepted" if current_revision.get("design_intent_id") else "",
|
||||
)
|
||||
|
||||
@@ -75,6 +75,76 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
|
||||
|
||||
|
||||
def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None:
|
||||
if isinstance(offset, bool) or not isinstance(offset, (int, float)):
|
||||
return None
|
||||
distance = float(offset)
|
||||
definitions = {
|
||||
"XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
"XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||||
"YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
|
||||
}
|
||||
definition = definitions.get(plane.upper())
|
||||
if definition is None:
|
||||
return None
|
||||
origin, x_dir, normal = definition
|
||||
return {"origin_mm": origin, "x_dir": x_dir, "normal": normal}
|
||||
|
||||
|
||||
def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Convert unambiguous legacy LLM aliases into the current CDSL dialect.
|
||||
|
||||
This intentionally does not infer dimensions, selectors, or feature
|
||||
dependencies. Any non-mechanical error remains visible to the validator.
|
||||
"""
|
||||
normalized = copy.deepcopy(cdsl)
|
||||
repairs: list[str] = []
|
||||
geometry = normalized.get("geometry")
|
||||
sketches = geometry.get("sketches") if isinstance(geometry, dict) else None
|
||||
if isinstance(sketches, list):
|
||||
for index, sketch in enumerate(sketches):
|
||||
if not isinstance(sketch, dict):
|
||||
continue
|
||||
if "id" not in sketch and isinstance(sketch.get("sketch_id"), str):
|
||||
sketch["id"] = sketch.pop("sketch_id")
|
||||
repairs.append(f"geometry.sketches[{index}]: sketch_id -> id")
|
||||
|
||||
legacy_plane: Any = sketch.get("plane")
|
||||
legacy_offset: Any = sketch.get("offset_mm", 0)
|
||||
workplane_value = sketch.get("workplane")
|
||||
if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value:
|
||||
legacy_plane = workplane_value.get("plane")
|
||||
legacy_offset = workplane_value.get("offset_mm", 0)
|
||||
elif "workplane" in sketch:
|
||||
continue
|
||||
|
||||
workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None
|
||||
if workplane is None:
|
||||
continue
|
||||
sketch["workplane"] = workplane
|
||||
sketch.pop("plane", None)
|
||||
sketch.pop("offset_mm", None)
|
||||
repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane")
|
||||
|
||||
features = normalized.get("features")
|
||||
if isinstance(features, list):
|
||||
for index, feature in enumerate(features):
|
||||
if not isinstance(feature, dict):
|
||||
continue
|
||||
if "sketch_id" not in feature and isinstance(feature.get("sketch"), str):
|
||||
feature["sketch_id"] = feature.pop("sketch")
|
||||
repairs.append(f"features[{index}]: sketch -> sketch_id")
|
||||
if "depends_on" not in feature:
|
||||
feature["depends_on"] = []
|
||||
repairs.append(f"features[{index}]: added empty depends_on")
|
||||
params = feature.get("params")
|
||||
axis = params.get("axis") if isinstance(params, dict) else None
|
||||
if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis:
|
||||
axis["origin_mm"] = axis.pop("point_mm")
|
||||
repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm")
|
||||
return normalized, repairs
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
@@ -392,7 +462,7 @@ def build_revision(
|
||||
summary: str,
|
||||
parent_revision_id: str | None = None,
|
||||
operation: dict[str, Any] | None = None,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
input_attachments: list[dict[str, Any]] | None = None,
|
||||
part_skills: dict[str, Any] | None = None,
|
||||
generation_assumptions: list[str] | None = None,
|
||||
design_intent: dict[str, Any] | None = None,
|
||||
@@ -455,7 +525,7 @@ def build_revision(
|
||||
"summary": summary,
|
||||
"parent_revision_id": parent_revision_id or "",
|
||||
"operation": operation or {},
|
||||
"attachments": attachments or [],
|
||||
"input_attachments": input_attachments or [],
|
||||
}
|
||||
if status == "success":
|
||||
record.update({
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
TEMPLATE_ID = "flange_sleeve_v1"
|
||||
|
||||
# These defaults describe the pictured family, but the model may override every
|
||||
# dimension that affects the visible form. The backend owns all CDSL plumbing.
|
||||
DEFAULT_PLAN: dict[str, float | str] = {
|
||||
"name": "Parameterized flange sleeve",
|
||||
"flange_width_mm": 120.0,
|
||||
"flange_height_mm": 120.0,
|
||||
"flange_thickness_mm": 14.0,
|
||||
"corner_chamfer_mm": 10.0,
|
||||
"tube_outer_diameter_mm": 70.0,
|
||||
"tube_straight_length_mm": 95.0,
|
||||
"tip_outer_diameter_mm": 62.0,
|
||||
"tip_length_mm": 28.0,
|
||||
"bore_diameter_mm": 46.0,
|
||||
"boss_outer_diameter_mm": 82.0,
|
||||
"boss_height_mm": 5.0,
|
||||
"mount_hole_diameter_mm": 12.0,
|
||||
"mount_counterbore_diameter_mm": 24.0,
|
||||
"mount_counterbore_depth_mm": 5.0,
|
||||
"mount_hole_u_mm": 42.0,
|
||||
"mount_hole_v_mm": 42.0,
|
||||
}
|
||||
|
||||
_DIMENSION_FIELDS = tuple(key for key in DEFAULT_PLAN if key != "name")
|
||||
_PART_ID = re.compile(r"^[A-Za-z0-9_-]{3,80}$")
|
||||
|
||||
|
||||
def flange_sleeve_plan_schema() -> dict[str, Any]:
|
||||
"""Return the compact semantic plan accepted by the flange-sleeve tool."""
|
||||
properties: dict[str, Any] = {
|
||||
"template": {"const": TEMPLATE_ID},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"},
|
||||
}
|
||||
for field in _DIMENSION_FIELDS:
|
||||
minimum = 0 if field == "corner_chamfer_mm" else 0.001
|
||||
properties[field] = {"type": "number", "exclusiveMinimum": minimum} if minimum else {
|
||||
"type": "number", "minimum": 0,
|
||||
}
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": ["template"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def normalize_flange_sleeve_plan(plan: Any) -> dict[str, float | str]:
|
||||
"""Validate only semantic dimensions; do not accept CDSL implementation data."""
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("flange sleeve plan must be a JSON object")
|
||||
if plan.get("template") != TEMPLATE_ID:
|
||||
raise ValueError(f"flange sleeve plan template must be {TEMPLATE_ID}")
|
||||
allowed = {"template", "part_id", *DEFAULT_PLAN}
|
||||
unknown = sorted(str(key) for key in plan if key not in allowed)
|
||||
if unknown:
|
||||
raise ValueError(f"flange sleeve plan has unsupported fields: {', '.join(unknown)}")
|
||||
|
||||
normalized = copy.deepcopy(DEFAULT_PLAN)
|
||||
for field in _DIMENSION_FIELDS:
|
||||
if field not in plan:
|
||||
continue
|
||||
value = plan[field]
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
raise ValueError(f"flange sleeve plan {field} must be a finite number")
|
||||
number = float(value)
|
||||
if number < 0 if field == "corner_chamfer_mm" else number <= 0:
|
||||
comparator = "non-negative" if field == "corner_chamfer_mm" else "greater than zero"
|
||||
raise ValueError(f"flange sleeve plan {field} must be {comparator}")
|
||||
normalized[field] = number
|
||||
|
||||
name = str(plan.get("name") or normalized["name"]).strip()
|
||||
if not name:
|
||||
raise ValueError("flange sleeve plan name must not be empty")
|
||||
normalized["name"] = name[:120]
|
||||
part_id = str(plan.get("part_id") or "flange_sleeve_template")
|
||||
if not _PART_ID.fullmatch(part_id):
|
||||
raise ValueError("flange sleeve plan part_id must use letters, numbers, underscores, or hyphens")
|
||||
normalized["part_id"] = part_id
|
||||
_validate_dimensions(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_dimensions(plan: dict[str, float | str]) -> None:
|
||||
number = lambda field: float(plan[field])
|
||||
width, height = number("flange_width_mm"), number("flange_height_mm")
|
||||
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
|
||||
tube_od, tip_od, bore = number("tube_outer_diameter_mm"), number("tip_outer_diameter_mm"), number("bore_diameter_mm")
|
||||
boss_od = number("boss_outer_diameter_mm")
|
||||
hole_od, counterbore_od = number("mount_hole_diameter_mm"), number("mount_counterbore_diameter_mm")
|
||||
counterbore_depth = number("mount_counterbore_depth_mm")
|
||||
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
|
||||
|
||||
if chamfer * 2 >= min(width, height):
|
||||
raise ValueError("flange sleeve plan corner_chamfer_mm must be less than half the flange side")
|
||||
if not bore < min(tube_od, tip_od):
|
||||
raise ValueError("flange sleeve plan bore_diameter_mm must be smaller than both tube diameters")
|
||||
if not tube_od <= boss_od <= min(width, height):
|
||||
raise ValueError("flange sleeve plan boss_outer_diameter_mm must be between tube diameter and flange side")
|
||||
if counterbore_od < hole_od:
|
||||
raise ValueError("flange sleeve plan mount_counterbore_diameter_mm must not be smaller than mount_hole_diameter_mm")
|
||||
if counterbore_depth > thickness:
|
||||
raise ValueError("flange sleeve plan mount_counterbore_depth_mm must not exceed flange_thickness_mm")
|
||||
|
||||
radius = counterbore_od / 2
|
||||
if abs(hole_u) + radius >= width / 2 or abs(hole_v) + radius >= height / 2:
|
||||
raise ValueError("flange sleeve plan mounting counterbores must remain inside the flange boundary")
|
||||
if chamfer and abs(hole_u) > width / 2 - chamfer and abs(hole_v) > height / 2 - chamfer:
|
||||
edge_clearance = (width / 2 - abs(hole_u)) + (height / 2 - abs(hole_v))
|
||||
if edge_clearance < chamfer + radius * math.sqrt(2):
|
||||
raise ValueError("flange sleeve plan mounting counterbores intersect the corner chamfers")
|
||||
|
||||
|
||||
def _x_plane(offset_mm: float, normal_x: float = 1.0) -> dict[str, list[float]]:
|
||||
return {
|
||||
"origin_mm": [offset_mm, 0.0, 0.0],
|
||||
"x_dir": [0.0, 1.0, 0.0],
|
||||
"normal": [normal_x, 0.0, 0.0],
|
||||
}
|
||||
|
||||
|
||||
def _flange_profile(width: float, height: float, chamfer: float) -> dict[str, Any]:
|
||||
if chamfer == 0:
|
||||
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}
|
||||
half_width, half_height = width / 2, height / 2
|
||||
return {
|
||||
"type": "polygon",
|
||||
"vertices": [
|
||||
[-half_width + chamfer, -half_height],
|
||||
[half_width - chamfer, -half_height],
|
||||
[half_width, -half_height + chamfer],
|
||||
[half_width, half_height - chamfer],
|
||||
[half_width - chamfer, half_height],
|
||||
[-half_width + chamfer, half_height],
|
||||
[-half_width, half_height - chamfer],
|
||||
[-half_width, -half_height + chamfer],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_flange_sleeve_cdsl(plan: Any) -> tuple[dict[str, Any], dict[str, float | str]]:
|
||||
"""Build a schema-valid CDSL flange sleeve from semantic, editable parameters."""
|
||||
values = normalize_flange_sleeve_plan(plan)
|
||||
number = lambda field: float(values[field])
|
||||
width, height = number("flange_width_mm"), number("flange_height_mm")
|
||||
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
|
||||
tube_radius, tip_radius, bore_radius = number("tube_outer_diameter_mm") / 2, number("tip_outer_diameter_mm") / 2, number("bore_diameter_mm") / 2
|
||||
straight, tip_length = number("tube_straight_length_mm"), number("tip_length_mm")
|
||||
boss_radius, boss_height = number("boss_outer_diameter_mm") / 2, number("boss_height_mm")
|
||||
hole_radius, counterbore_radius = number("mount_hole_diameter_mm") / 2, number("mount_counterbore_diameter_mm") / 2
|
||||
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
|
||||
hole_centers = [[u, v] for u in (-hole_u, hole_u) for v in (-hole_v, hole_v)]
|
||||
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
"schema_version": "1.3.0",
|
||||
"part_id": str(values["part_id"]),
|
||||
"kind": "part",
|
||||
"meta": {"name": str(values["name"]), "units": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{
|
||||
"id": "flange_outline",
|
||||
"workplane": _x_plane(-thickness),
|
||||
"profile": _flange_profile(width, height, chamfer),
|
||||
},
|
||||
{
|
||||
"id": "sleeve_profile",
|
||||
"workplane": {
|
||||
"origin_mm": [0.0, 0.0, 0.0],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
},
|
||||
"profile": {
|
||||
"type": "polygon",
|
||||
"vertices": [
|
||||
[0.0, bore_radius],
|
||||
[0.0, tube_radius],
|
||||
[straight, tube_radius],
|
||||
[straight + tip_length, tip_radius],
|
||||
[straight + tip_length, bore_radius],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "front_boss_ring",
|
||||
"workplane": _x_plane(0.0),
|
||||
"profile": {
|
||||
"type": "annulus",
|
||||
"inner_radius_mm": bore_radius,
|
||||
"outer_radius_mm": boss_radius,
|
||||
"center": [0.0, 0.0],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "center_bore",
|
||||
"workplane": _x_plane(-thickness),
|
||||
"profile": {"type": "circle", "radius_mm": bore_radius, "center": [0.0, 0.0]},
|
||||
},
|
||||
{
|
||||
"id": "mount_holes",
|
||||
"workplane": _x_plane(-thickness),
|
||||
"profile": {
|
||||
"type": "circles",
|
||||
"items": [{"radius_mm": hole_radius, "center": center} for center in hole_centers],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "mount_counterbores",
|
||||
"workplane": _x_plane(0.0, -1.0),
|
||||
"profile": {
|
||||
"type": "circles",
|
||||
"items": [{"radius_mm": counterbore_radius, "center": center} for center in hole_centers],
|
||||
},
|
||||
},
|
||||
]},
|
||||
"features": [
|
||||
{
|
||||
"id": "flange_plate",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": [],
|
||||
"sketch_id": "flange_outline",
|
||||
"params": {"distance_mm": thickness},
|
||||
},
|
||||
{
|
||||
"id": "sleeve_body",
|
||||
"atomic_id": "revolve_add",
|
||||
"depends_on": ["flange_plate"],
|
||||
"sketch_id": "sleeve_profile",
|
||||
"params": {
|
||||
"angle_deg": 360.0,
|
||||
"axis": {"origin_mm": [0.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "front_boss",
|
||||
"atomic_id": "extrude_add_blind",
|
||||
"depends_on": ["flange_plate", "sleeve_body"],
|
||||
"sketch_id": "front_boss_ring",
|
||||
"params": {"distance_mm": boss_height},
|
||||
},
|
||||
{
|
||||
"id": "center_bore_cut",
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"depends_on": ["flange_plate", "sleeve_body", "front_boss"],
|
||||
"sketch_id": "center_bore",
|
||||
"params": {"distance_mm": thickness + straight + tip_length + boss_height + 1.0},
|
||||
},
|
||||
{
|
||||
"id": "mount_hole_cuts",
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"depends_on": ["center_bore_cut"],
|
||||
"sketch_id": "mount_holes",
|
||||
"params": {"distance_mm": thickness + 1.0},
|
||||
},
|
||||
{
|
||||
"id": "mount_counterbore_cuts",
|
||||
"atomic_id": "extrude_cut_blind",
|
||||
"depends_on": ["mount_hole_cuts"],
|
||||
"sketch_id": "mount_counterbores",
|
||||
"params": {"distance_mm": number("mount_counterbore_depth_mm")},
|
||||
},
|
||||
],
|
||||
}
|
||||
return cdsl, values
|
||||
@@ -207,8 +207,8 @@ class PartSkillLibrary:
|
||||
revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
|
||||
if revision_ids:
|
||||
return revision_ids
|
||||
# A valid DesignIntent creates a task before the first runtime build.
|
||||
# Its canonical backend selection must therefore be inheritable too.
|
||||
# Legacy tasks can contain a DesignIntent created before their first
|
||||
# runtime build. Retain this fallback only for historical artifacts.
|
||||
intent_id = str((task or {}).get("current_design_intent_id") or "")
|
||||
intent = next(
|
||||
(item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id),
|
||||
|
||||
@@ -88,11 +88,26 @@ class WorkspaceStore:
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str:
|
||||
"""Retain a parsed model candidate before validation or execution."""
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json"
|
||||
path = self.conversation_dir(conversation) / relative
|
||||
write_json(path, cdsl)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
|
||||
"""Persist the reason a retained CDSL candidate was rejected."""
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.json"
|
||||
path = self.conversation_dir(conversation) / relative
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def ensure_conversation(
|
||||
self,
|
||||
conversation_id: str | None,
|
||||
current_task_id: str | None = None,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv")
|
||||
path = self.conversation_path(cid)
|
||||
@@ -102,21 +117,18 @@ class WorkspaceStore:
|
||||
if current_task_id:
|
||||
current["current_task_id"] = safe_task_id(current_task_id)
|
||||
changed = True
|
||||
if attachments is not None:
|
||||
current["attachments"] = attachments
|
||||
changed = True
|
||||
if changed:
|
||||
current["updated_at"] = now_iso()
|
||||
write_json(path, current)
|
||||
return current
|
||||
record = {
|
||||
"schema_version": "1.1",
|
||||
"schema_version": "1.2",
|
||||
"conversation_id": cid,
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"current_task_id": safe_task_id(current_task_id) if current_task_id else "",
|
||||
"messages": [],
|
||||
"attachments": attachments or [],
|
||||
"attachments": [],
|
||||
}
|
||||
write_json(path, record)
|
||||
return record
|
||||
@@ -135,14 +147,34 @@ class WorkspaceStore:
|
||||
write_json(self.conversation_path(record["conversation_id"]), record)
|
||||
return record
|
||||
|
||||
def write_upload(self, task_id: str, filename: str, data: bytes) -> tuple[str, Path]:
|
||||
def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]:
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
|
||||
relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}"
|
||||
target = self.artifact_path(task_id, relative.as_posix())
|
||||
target = self.conversation_attachment_path(conversation, relative.as_posix())
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
return relative.as_posix(), target
|
||||
|
||||
def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]:
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
record = self.read_conversation(conversation)
|
||||
if record is None:
|
||||
raise ValueError("Conversation not found")
|
||||
if str(attachment.get("conversation_id") or "") != conversation:
|
||||
raise ValueError("Attachment does not belong to this conversation")
|
||||
attachment_id = str(attachment.get("id") or "")
|
||||
if not attachment_id:
|
||||
raise ValueError("Attachment id is required")
|
||||
self.conversation_attachment_path(conversation, str(attachment.get("path") or ""))
|
||||
attachments = record.setdefault("attachments", [])
|
||||
if any(str(item.get("id") or "") == attachment_id for item in attachments if isinstance(item, dict)):
|
||||
raise ValueError("Attachment already exists")
|
||||
attachments.append(attachment)
|
||||
record["updated_at"] = now_iso()
|
||||
write_json(self.conversation_path(conversation), record)
|
||||
return record
|
||||
|
||||
def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]:
|
||||
tid = safe_task_id(task_id) if task_id else new_id("cad")
|
||||
path = self.task_path(tid)
|
||||
@@ -274,3 +306,11 @@ class WorkspaceStore:
|
||||
if root != target and root not in target.parents:
|
||||
raise ValueError("Artifact path escapes task directory")
|
||||
return target
|
||||
|
||||
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
|
||||
safe = safe_relative_path(relative_path)
|
||||
root = self.conversation_dir(conversation_id).resolve()
|
||||
target = (root / safe).resolve()
|
||||
if root != target and root not in target.parents:
|
||||
raise ValueError("Attachment path escapes conversation directory")
|
||||
return target
|
||||
|
||||
Reference in New Issue
Block a user