Skip to content

Commit f879a8f

Browse files
refactor(migration): use project-transfer for CustomToolPhase
Drop the field-by-field reconcile loop (create_custom_tool + delete_profile + create_profile + set_default_profile + create_prompt) in favour of the backend's purpose-built endpoints: - GET prompt-studio/project-transfer/{id} bundles tool_metadata, tool_settings, default_profile_settings, prompts in one shot. - POST prompt-studio/project-transfer/ creates the tool, default profile (wired with target-org adapter ids the SDK supplies), and prompts server-side in one call. - POST prompt-studio/{id}/sync-prompts/ rip-and-replaces prompts on an existing target tool for the adopt path. Adapter ids for the import are resolved from the source's default ProfileManager via the adapter remap table; missing remap fails the tool cleanly instead of landing a half-wired profile. Removes hardcoded PROFILE_WRITABLE / PROMPT_WRITABLE frozensets and the OPTIONS schema fetch for prompt-studio — the project-transfer endpoint owns the field shape server-side. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2a0604c commit f879a8f

3 files changed

Lines changed: 456 additions & 542 deletions

File tree

src/unstract/migration/client.py

Lines changed: 87 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from __future__ import annotations
1212

13+
import json as json_lib
1314
import logging
1415
from typing import Any
1516

@@ -54,6 +55,8 @@ def _request(
5455
*,
5556
params: dict[str, Any] | None = None,
5657
json: Any = None,
58+
files: dict[str, Any] | None = None,
59+
data: dict[str, Any] | None = None,
5760
) -> Any:
5861
url = self._url(path)
5962
# Redact secrets from logs: only entity path + method, never body.
@@ -63,6 +66,8 @@ def _request(
6366
url,
6467
params=params,
6568
json=json,
69+
files=files,
70+
data=data,
6671
timeout=self.timeout,
6772
verify=self.verify,
6873
)
@@ -164,67 +169,104 @@ def list_custom_tools(self) -> list[dict[str, Any]]:
164169
result = self._request("GET", "prompt-studio/")
165170
return result if isinstance(result, list) else result.get("results", [])
166171

167-
def get_custom_tool(self, tool_id: str) -> dict[str, Any]:
168-
"""Tool detail; response includes embedded ``prompts`` + ``default_profile``."""
169-
return self._request("GET", f"prompt-studio/{tool_id}/")
170-
171-
def create_custom_tool(self, payload: dict[str, Any]) -> dict[str, Any]:
172-
"""Create a custom tool. Backend also auto-creates one default ProfileManager."""
173-
return self._request("POST", "prompt-studio/", json=payload)
174-
175-
def export_custom_tool(self, tool_id: str, *, force: bool = True) -> Any:
176-
"""Republish ``PromptStudioRegistry`` from the tool's current target state.
172+
def list_profiles(self, tool_id: str) -> list[dict[str, Any]]:
173+
"""List ProfileManager rows for a tool.
177174
178-
Used after profile+prompt reconciliation so the registry row is
179-
rebuilt without the SDK ever carrying ``tool_metadata`` across orgs.
175+
Migration reads this on the source only — to discover the
176+
default profile's adapter UUIDs so they can be remapped to
177+
target adapter ids for ``import_project``.
180178
"""
181-
return self._request(
182-
"POST",
183-
f"prompt-studio/export/{tool_id}",
184-
json={
185-
"is_shared_with_org": False,
186-
"user_id": [],
187-
"force_export": force,
188-
},
189-
)
190-
191-
# ----- profile managers -----
192-
193-
def list_profiles(self, tool_id: str) -> list[dict[str, Any]]:
194-
"""List ProfileManager rows for a tool via the per-tool list action."""
195179
result = self._request(
196180
"GET", f"prompt-studio/prompt-studio-profile/{tool_id}/"
197181
)
198182
return result if isinstance(result, list) else result.get("results", [])
199183

200-
def create_profile(self, tool_id: str, payload: dict[str, Any]) -> dict[str, Any]:
201-
"""POST to ``prompt-studio/profilemanager/{tool_id}`` (no trailing slash)."""
184+
def export_project(self, tool_id: str) -> dict[str, Any]:
185+
"""Export a prompt-studio project as a portable JSON blob.
186+
187+
Bundles ``tool_metadata``, ``tool_settings``,
188+
``default_profile_settings``, ``prompts``, ``export_metadata`` in
189+
one shot — feed straight into ``import_project`` or
190+
``sync_prompts`` on the target.
191+
"""
192+
return self._request("GET", f"prompt-studio/project-transfer/{tool_id}")
193+
194+
def import_project(
195+
self,
196+
export_data: dict[str, Any],
197+
adapter_ids: dict[str, str | None] | None = None,
198+
) -> dict[str, Any]:
199+
"""Import a prompt-studio project from an export blob.
200+
201+
Backend creates the tool, builds the default ProfileManager from
202+
the supplied target-org adapter ids, and imports all prompts in
203+
one call. On name collision the backend silently uniquifies the
204+
new tool's name — callers should pre-check via
205+
``list_custom_tools`` to avoid that.
206+
207+
``adapter_ids`` keys are the backend's form fields:
208+
``llm_adapter_id``, ``vector_db_adapter_id``,
209+
``embedding_adapter_id``, ``x2text_adapter_id``. All four
210+
required to wire the profile; otherwise backend falls back to
211+
a profile without adapters and flags ``needs_adapter_config``.
212+
"""
213+
tool_name = (
214+
export_data.get("tool_metadata", {}).get("tool_name") or "export"
215+
)
216+
content = json_lib.dumps(export_data).encode()
217+
files = {"file": (f"{tool_name}.json", content, "application/json")}
218+
data: dict[str, Any] = {}
219+
if adapter_ids:
220+
for key in (
221+
"llm_adapter_id",
222+
"vector_db_adapter_id",
223+
"embedding_adapter_id",
224+
"x2text_adapter_id",
225+
):
226+
val = adapter_ids.get(key)
227+
if val:
228+
data[key] = val
202229
return self._request(
203-
"POST", f"prompt-studio/profilemanager/{tool_id}", json=payload
230+
"POST",
231+
"prompt-studio/project-transfer/",
232+
files=files,
233+
data=data,
204234
)
205235

206-
def delete_profile(self, profile_id: str) -> None:
207-
self._request("DELETE", f"profile-manager/{profile_id}/")
236+
def sync_prompts(
237+
self,
238+
tool_id: str,
239+
export_data: dict[str, Any],
240+
*,
241+
create_copy: bool = False,
242+
) -> dict[str, Any]:
243+
"""Rip-and-replace prompts on an existing target tool.
208244
209-
def set_default_profile(self, tool_id: str, profile_id: str) -> Any:
210-
"""Mark a single profile as default for this tool (zeros the rest)."""
245+
Adopt path: target tool already exists with its own
246+
adapter-bound profiles. This overwrites its prompt set (and
247+
``tool_settings``) from source; profiles and uploaded documents
248+
are left untouched.
249+
"""
250+
payload = {"data": export_data, "create_copy": create_copy}
211251
return self._request(
212-
"PATCH",
213-
f"prompt-studio/prompt-studio-profile/{tool_id}/",
214-
json={"default_profile": profile_id},
252+
"POST", f"prompt-studio/{tool_id}/sync-prompts/", json=payload
215253
)
216254

217-
# ----- prompts -----
218-
219-
def list_prompts(self, *, tool_id: str) -> list[dict[str, Any]]:
220-
"""List prompts filtered by tool_id (FilterHelper-backed)."""
221-
result = self._request("GET", "prompt/", params={"tool_id": tool_id})
222-
return result if isinstance(result, list) else result.get("results", [])
255+
def export_custom_tool(self, tool_id: str, *, force: bool = True) -> Any:
256+
"""Republish ``PromptStudioRegistry`` from the tool's current state.
223257
224-
def create_prompt(self, tool_id: str, payload: dict[str, Any]) -> dict[str, Any]:
225-
"""POST to ``prompt-studio/prompt-studio-prompt/{tool_id}/`` (create_prompt action)."""
258+
Called after import/sync so the registry row reflects the
259+
freshly landed prompts. Required for ToolInstancePhase to find
260+
a target registry id to remap.
261+
"""
226262
return self._request(
227-
"POST", f"prompt-studio/prompt-studio-prompt/{tool_id}/", json=payload
263+
"POST",
264+
f"prompt-studio/export/{tool_id}",
265+
json={
266+
"is_shared_with_org": False,
267+
"user_id": [],
268+
"force_export": force,
269+
},
228270
)
229271

230272
# ----- workflows -----

0 commit comments

Comments
 (0)