unique-sdk 2026.20.0.dev4__tar.gz → 2026.20.0.dev6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/PKG-INFO +1 -1
  2. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/pyproject.toml +1 -1
  3. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/__init__.py +1 -0
  4. unique_sdk-2026.20.0.dev6/unique_sdk/api_resources/_module.py +235 -0
  5. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/utils/file_io.py +66 -74
  6. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/README.md +0 -0
  7. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_api_requestor.py +0 -0
  8. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_api_resource.py +0 -0
  9. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_api_version.py +0 -0
  10. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_error.py +0 -0
  11. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_http_client.py +0 -0
  12. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_list_object.py +0 -0
  13. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_object_classes.py +0 -0
  14. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_request_options.py +0 -0
  15. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_unique_object.py +0 -0
  16. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_unique_ql.py +0 -0
  17. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_unique_response.py +0 -0
  18. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_util.py +0 -0
  19. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_version.py +0 -0
  20. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/_webhook.py +0 -0
  21. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/__init__.py +0 -0
  22. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_acronyms.py +0 -0
  23. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_agentic_table.py +0 -0
  24. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_benchmarking.py +0 -0
  25. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_chat_completion.py +0 -0
  26. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_content.py +0 -0
  27. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_elicitation.py +0 -0
  28. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_embedding.py +0 -0
  29. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_event.py +0 -0
  30. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_folder.py +0 -0
  31. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_group.py +0 -0
  32. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_integrated.py +0 -0
  33. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_llm_models.py +0 -0
  34. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_mcp.py +0 -0
  35. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_message.py +0 -0
  36. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_message_assessment.py +0 -0
  37. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_message_execution.py +0 -0
  38. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_message_log.py +0 -0
  39. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_message_tool.py +0 -0
  40. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  41. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_search.py +0 -0
  42. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_search_string.py +0 -0
  43. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  44. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_space.py +0 -0
  45. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/api_resources/_user.py +0 -0
  46. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/__init__.py +0 -0
  47. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/__main__.py +0 -0
  48. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/cli.py +0 -0
  49. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/__init__.py +0 -0
  50. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/elicitation.py +0 -0
  51. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/files.py +0 -0
  52. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/folders.py +0 -0
  53. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/mcp.py +0 -0
  54. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/navigation.py +0 -0
  55. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  56. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/commands/search.py +0 -0
  57. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/config.py +0 -0
  58. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/formatting.py +0 -0
  59. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/shell.py +0 -0
  60. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  61. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  62. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  63. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  64. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  65. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/cli/state.py +0 -0
  66. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/utils/benchmarking_run.py +0 -0
  67. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/utils/chat_history.py +0 -0
  68. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/utils/chat_in_space.py +0 -0
  69. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/utils/sources.py +0 -0
  70. {unique_sdk-2026.20.0.dev4 → unique_sdk-2026.20.0.dev6}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.20.0.dev4
3
+ Version: 2026.20.0.dev6
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.20.0.dev4"
3
+ version = "2026.20.0.dev6"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -101,6 +101,7 @@ from unique_sdk.api_resources._benchmarking import (
101
101
  Benchmarking as Benchmarking,
102
102
  )
103
103
  from unique_sdk.api_resources._scheduled_task import ScheduledTask as ScheduledTask
104
+ from unique_sdk.api_resources._module import Module as Module
104
105
 
105
106
  # Unique QL
106
107
  from unique_sdk._unique_ql import UQLOperator as UQLOperator
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ from typing import Any, Literal, NotRequired, TypedDict, Unpack, cast
5
+ from urllib.parse import quote_plus
6
+
7
+ from unique_sdk._api_resource import APIResource
8
+ from unique_sdk._request_options import RequestOptions
9
+ from unique_sdk._util import classproperty
10
+
11
+
12
+ class Module(APIResource["Module"]):
13
+ """An assistant module that configures behaviour, tools, and instructions.
14
+
15
+ Modules are attached to assistants (spaces) and define their capabilities,
16
+ configuration, and tool definitions. They control weight/priority, whether
17
+ the module is external, and whether custom instructions are enabled.
18
+ """
19
+
20
+ @classproperty
21
+ def OBJECT_NAME(cls) -> Literal["module"]:
22
+ return "module"
23
+
24
+ RESOURCE_URL = "/modules"
25
+
26
+ @classmethod
27
+ def class_url(cls) -> str:
28
+ return cls.RESOURCE_URL
29
+
30
+ class ListParams(RequestOptions):
31
+ assistantId: NotRequired[str | None]
32
+
33
+ class CreateParams(RequestOptions):
34
+ assistantId: str
35
+ name: str
36
+ description: NotRequired[str | None]
37
+ weight: NotRequired[int | None]
38
+ isExternal: NotRequired[bool | None]
39
+ isCustomInstructionEnabled: NotRequired[bool | None]
40
+ configuration: NotRequired[dict[str, Any] | None]
41
+ toolDefinition: NotRequired[dict[str, Any] | None]
42
+ moduleTemplateId: NotRequired[str | None]
43
+
44
+ class ModifyParams(RequestOptions):
45
+ name: NotRequired[str | None]
46
+ description: NotRequired[str | None]
47
+ weight: NotRequired[int | None]
48
+ isExternal: NotRequired[bool | None]
49
+ isCustomInstructionEnabled: NotRequired[bool | None]
50
+ configuration: NotRequired[dict[str, Any] | None]
51
+ toolDefinition: NotRequired[dict[str, Any] | None]
52
+
53
+ class DeletedObject(TypedDict):
54
+ id: str
55
+ object: str
56
+ deleted: bool
57
+
58
+ id: str
59
+ name: str
60
+ description: str | None
61
+ toolDefinition: dict[str, Any] | None
62
+ configuration: dict[str, Any] | None
63
+ assistantId: str
64
+ weight: int | None
65
+ isExternal: bool
66
+ isCustomInstructionEnabled: bool
67
+ moduleTemplateId: str | None
68
+ createdAt: str
69
+ updatedAt: str
70
+
71
+ @classmethod
72
+ def list(
73
+ cls,
74
+ *,
75
+ user_id: str,
76
+ company_id: str,
77
+ **params: Unpack["Module.ListParams"],
78
+ ) -> builtins.list["Module"]:
79
+ result = cls._static_request(
80
+ "get",
81
+ cls.RESOURCE_URL,
82
+ user_id,
83
+ company_id,
84
+ params or None,
85
+ )
86
+ if isinstance(result, builtins.list):
87
+ data = result
88
+ else:
89
+ data = result.get("data", []) if hasattr(result, "get") else []
90
+ return cast(builtins.list["Module"], data)
91
+
92
+ @classmethod
93
+ async def list_async(
94
+ cls,
95
+ *,
96
+ user_id: str,
97
+ company_id: str,
98
+ **params: Unpack["Module.ListParams"],
99
+ ) -> builtins.list["Module"]:
100
+ result = await cls._static_request_async(
101
+ "get",
102
+ cls.RESOURCE_URL,
103
+ user_id,
104
+ company_id,
105
+ params or None,
106
+ )
107
+ if isinstance(result, builtins.list):
108
+ data = result
109
+ else:
110
+ data = result.get("data", []) if hasattr(result, "get") else []
111
+ return cast(builtins.list["Module"], data)
112
+
113
+ @classmethod
114
+ def retrieve(
115
+ cls,
116
+ *,
117
+ user_id: str,
118
+ company_id: str,
119
+ id: str,
120
+ ) -> "Module":
121
+ url = "%s/%s" % (cls.RESOURCE_URL, quote_plus(id))
122
+ return cast(
123
+ "Module",
124
+ cls._static_request("get", url, user_id, company_id),
125
+ )
126
+
127
+ @classmethod
128
+ async def retrieve_async(
129
+ cls,
130
+ *,
131
+ user_id: str,
132
+ company_id: str,
133
+ id: str,
134
+ ) -> "Module":
135
+ url = "%s/%s" % (cls.RESOURCE_URL, quote_plus(id))
136
+ return cast(
137
+ "Module",
138
+ await cls._static_request_async("get", url, user_id, company_id),
139
+ )
140
+
141
+ @classmethod
142
+ def create(
143
+ cls,
144
+ *,
145
+ user_id: str,
146
+ company_id: str,
147
+ **params: Unpack["Module.CreateParams"],
148
+ ) -> "Module":
149
+ return cast(
150
+ "Module",
151
+ cls._static_request(
152
+ "post",
153
+ cls.RESOURCE_URL,
154
+ user_id,
155
+ company_id,
156
+ params,
157
+ ),
158
+ )
159
+
160
+ @classmethod
161
+ async def create_async(
162
+ cls,
163
+ *,
164
+ user_id: str,
165
+ company_id: str,
166
+ **params: Unpack["Module.CreateParams"],
167
+ ) -> "Module":
168
+ return cast(
169
+ "Module",
170
+ await cls._static_request_async(
171
+ "post",
172
+ cls.RESOURCE_URL,
173
+ user_id,
174
+ company_id,
175
+ params,
176
+ ),
177
+ )
178
+
179
+ @classmethod
180
+ def modify(
181
+ cls,
182
+ *,
183
+ user_id: str,
184
+ company_id: str,
185
+ id: str,
186
+ **params: Unpack["Module.ModifyParams"],
187
+ ) -> "Module":
188
+ url = "%s/%s" % (cls.RESOURCE_URL, quote_plus(id))
189
+ return cast(
190
+ "Module",
191
+ cls._static_request("patch", url, user_id, company_id, params),
192
+ )
193
+
194
+ @classmethod
195
+ async def modify_async(
196
+ cls,
197
+ *,
198
+ user_id: str,
199
+ company_id: str,
200
+ id: str,
201
+ **params: Unpack["Module.ModifyParams"],
202
+ ) -> "Module":
203
+ url = "%s/%s" % (cls.RESOURCE_URL, quote_plus(id))
204
+ return cast(
205
+ "Module",
206
+ await cls._static_request_async("patch", url, user_id, company_id, params),
207
+ )
208
+
209
+ @classmethod
210
+ def delete(
211
+ cls,
212
+ *,
213
+ user_id: str,
214
+ company_id: str,
215
+ id: str,
216
+ ) -> "Module.DeletedObject":
217
+ url = "%s/%s" % (cls.RESOURCE_URL, quote_plus(id))
218
+ return cast(
219
+ "Module.DeletedObject",
220
+ cls._static_request("delete", url, user_id, company_id),
221
+ )
222
+
223
+ @classmethod
224
+ async def delete_async(
225
+ cls,
226
+ *,
227
+ user_id: str,
228
+ company_id: str,
229
+ id: str,
230
+ ) -> "Module.DeletedObject":
231
+ url = "%s/%s" % (cls.RESOURCE_URL, quote_plus(id))
232
+ return cast(
233
+ "Module.DeletedObject",
234
+ await cls._static_request_async("delete", url, user_id, company_id),
235
+ )
@@ -44,17 +44,6 @@ def download_file(url: str, filename: str):
44
44
  _PREVIEW_PDF_MIME_TYPE = "application/pdf"
45
45
 
46
46
 
47
- def _derive_preview_pdf_filename(displayed_filename: str) -> str:
48
- """Return ``<stem>_preview.pdf`` for *displayed_filename*.
49
-
50
- Keeps the preview blob filename predictable and collision-free with
51
- the original (which has the user-visible extension), while still
52
- making it obvious in storage which content the preview belongs to.
53
- """
54
- stem = displayed_filename.rsplit(".", 1)[0] or displayed_filename
55
- return f"{stem}_preview.pdf"
56
-
57
-
58
47
  def _put_preview_pdf(write_url: str, preview_pdf_path: str) -> None:
59
48
  """PUT *preview_pdf_path* bytes to the SAS URL returned by upsert."""
60
49
  with open(preview_pdf_path, "rb") as preview_file:
@@ -85,17 +74,25 @@ def upload_file(
85
74
  ingestion_config: Content.IngestionConfig | None = None,
86
75
  metadata: dict[str, Any] | None = None,
87
76
  preview_pdf_path: str | None = None,
88
- preview_pdf_filename: str | None = None,
89
77
  ):
90
78
  """Upload *path_to_file* as a Unique :class:`Content`.
91
79
 
92
80
  When ``preview_pdf_path`` is provided, the caller gets a single-call
93
- Office → PDF preview flow: we attach a ``previewPdfFileName`` to the
94
- upserted content (so the platform mints a ``pdfPreviewWriteUrl``),
95
- PUT the original blob, then PUT the PDF preview blob. The chat side
96
- panel will pick the preview up automatically when rendering
97
- PowerPoint / Word / similar formats whose in-browser preview is
98
- unreliable.
81
+ Office → PDF preview flow: the SDK does a two-upsert handshake — a
82
+ first upsert to obtain the content id, then a finalize upsert that
83
+ registers ``previewPdfFileName = f"{content.id}_pdfPreview"`` on
84
+ the row and mints a ``pdfPreviewWriteUrl`` SAS URL the SDK PUTs the
85
+ PDF bytes to.
86
+
87
+ Naming the preview blob ``${content.id}_pdfPreview`` matches the
88
+ convention used by the platform's ingestion worker
89
+ (``next/services/node-ingestion-worker/src/pdf-preview-converter/pdf-preview-converter.service.ts``)
90
+ and is collision-free across uploads sharing a ``displayed_filename``
91
+ because content ids are globally unique. The platform stores the
92
+ ``previewPdfFileName`` value verbatim — collision-free naming is a
93
+ client contract, not a server invariant — so every client of
94
+ node-ingestion (workers, SDKs, integrations) MUST follow this
95
+ convention.
99
96
 
100
97
  Args:
101
98
  userId: Acting user id.
@@ -112,15 +109,14 @@ def upload_file(
112
109
  metadata: Free-form metadata.
113
110
  preview_pdf_path: Optional path to a sibling PDF that should be
114
111
  rendered as the side-panel preview for this content. When
115
- set, the upsert registers ``previewPdfFileName`` on the row
116
- and the PDF bytes are PUT to ``pdfPreviewWriteUrl`` after the
117
- original upload.
118
- preview_pdf_filename: Optional override for the blob filename
119
- stored against ``previewPdfFileName``. Defaults to
120
- ``<displayed_filename without extension>_preview.pdf`` so
121
- the preview blob is uniquely identifiable in storage.
112
+ set, the SDK derives the blob name from the upserted
113
+ content id, registers it on the row via the finalize
114
+ upsert, and PUTs the PDF bytes to the SAS URL the server
115
+ returns in ``pdfPreviewWriteUrl``. Naming is the SDK's
116
+ responsibility there is no override kwarg, by design,
117
+ so all callers land on the same ``${content.id}_pdfPreview``
118
+ convention as the ingestion worker.
122
119
  """
123
- # check that chatid or scope_or_unique_path is provided
124
120
  if not chat_id and not scope_or_unique_path:
125
121
  raise ValueError("chat_id or scope_or_unique_path must be provided")
126
122
 
@@ -129,32 +125,13 @@ def upload_file(
129
125
  f"preview_pdf_path does not point to a readable file: {preview_pdf_path}"
130
126
  )
131
127
 
132
- # A filename without a path would register a phantom preview on the
133
- # Content row (the upsert mints a pdfPreviewWriteUrl, but we never
134
- # PUT any bytes), leaving the side panel pointing at a nonexistent
135
- # blob. Treat it as a programmer mistake and fail fast.
136
- if preview_pdf_filename is not None and preview_pdf_path is None:
137
- raise ValueError(
138
- "preview_pdf_filename was provided without preview_pdf_path; "
139
- "pass the PDF path so the preview blob actually gets uploaded."
140
- )
141
-
142
128
  size = os.path.getsize(path_to_file)
143
- resolved_preview_filename = (
144
- (preview_pdf_filename or _derive_preview_pdf_filename(displayed_filename))
145
- if preview_pdf_path is not None
146
- else None
147
- )
148
- # Only forward ``previewPdfFileName`` when we actually have one.
149
- # Sending ``None`` would serialize as ``"previewPdfFileName": null``,
150
- # which the server treats as a clearing operation and would erase
151
- # an existing preview on a re-upload that doesn't ship one.
152
- preview_kwargs: _PreviewKwargs = (
153
- {"previewPdfFileName": resolved_preview_filename}
154
- if resolved_preview_filename is not None
155
- else {}
156
- )
157
129
 
130
+ # Step 1 — first upsert WITHOUT ``previewPdfFileName``. The id we
131
+ # need to derive a collision-free preview blob name does not exist
132
+ # yet; deriving from ``displayed_filename`` instead would race
133
+ # across uploads sharing the same key (two ``report.pptx`` uploads
134
+ # in the same company would overwrite each other's preview blob).
158
135
  createdContent = Content.upsert(
159
136
  user_id=userId,
160
137
  company_id=companyId,
@@ -168,12 +145,10 @@ def upload_file(
168
145
  },
169
146
  scopeId=scope_or_unique_path,
170
147
  chatId=chat_id,
171
- **preview_kwargs,
172
148
  )
173
149
 
150
+ # Step 2 — PUT the original bytes to the SAS URL minted by Step 1.
174
151
  uploadUrl = createdContent.writeUrl
175
-
176
- # upload to azure blob storage SAS url uploadUrl the pdf file translatedFile make sure it is treated as a application/pdf
177
152
  if uploadUrl is None: # guard: writeUrl is Optional, basedpyright needs narrowing
178
153
  raise ValueError("createdContent.writeUrl is None")
179
154
  with open(path_to_file, "rb") as file:
@@ -186,28 +161,24 @@ def upload_file(
186
161
  },
187
162
  )
188
163
 
189
- # Attach the optional preview-PDF blob in the same call. We do this
190
- # *before* the second upsert so the byteSize patch and the preview
191
- # don't race; if the SAS URL is missing we surface a clear error
192
- # (the server only mints it when previewPdfFileName is set, so a
193
- # missing URL signals a server-side regression).
194
- if preview_pdf_path is not None:
195
- # ``getattr`` so an older gateway that omits the field falls
196
- # through to the RuntimeError below instead of raising the
197
- # opaque ``AttributeError`` that ``UniqueObject.__getattr__``
198
- # produces on a missing key.
199
- preview_write_url = getattr(createdContent, "pdfPreviewWriteUrl", None)
200
- if not preview_write_url:
201
- raise RuntimeError(
202
- "preview_pdf_path was provided but the upsert response carries "
203
- "no pdfPreviewWriteUrl — refusing to silently drop the preview. "
204
- "Verify the API gateway and node-ingestion expose "
205
- "previewPdfFileName on the upsert mutation."
206
- )
207
- _put_preview_pdf(preview_write_url, preview_pdf_path)
164
+ # Step 3 finalize upsert: ``byteSize`` + ``fileUrl`` flip the row
165
+ # to bytes-on-blob, and (only when we have a preview to attach)
166
+ # ``previewPdfFileName = f"{content.id}_pdfPreview"`` registers the
167
+ # preview blob name on the row so the ``pdfPreviewWriteUrl`` field
168
+ # resolver mints the SAS URL on this response.
169
+ #
170
+ # ``previewPdfFileName`` is forwarded as a conditional kwarg rather
171
+ # than always-set-to-None; serializing ``"previewPdfFileName": null``
172
+ # would tell the server to clear the field, which would silently
173
+ # regress an existing-row re-upload that does not attach a preview.
174
+ preview_kwargs: _PreviewKwargs = (
175
+ {"previewPdfFileName": f"{createdContent.id}_pdfPreview"}
176
+ if preview_pdf_path is not None
177
+ else {}
178
+ )
208
179
 
209
180
  if chat_id:
210
- unique_sdk.Content.upsert(
181
+ finalizedContent = unique_sdk.Content.upsert(
211
182
  user_id=userId,
212
183
  company_id=companyId,
213
184
  input={
@@ -224,7 +195,7 @@ def upload_file(
224
195
  **preview_kwargs,
225
196
  )
226
197
  else:
227
- unique_sdk.Content.upsert(
198
+ finalizedContent = unique_sdk.Content.upsert(
228
199
  user_id=userId,
229
200
  company_id=companyId,
230
201
  input={
@@ -241,6 +212,27 @@ def upload_file(
241
212
  **preview_kwargs,
242
213
  )
243
214
 
215
+ # Step 4 — PUT the preview bytes (if requested). The SAS URL comes
216
+ # from the FINALIZE response, not the first one, because the first
217
+ # upsert did not carry ``previewPdfFileName`` and so could not have
218
+ # minted ``pdfPreviewWriteUrl``.
219
+ #
220
+ # ``getattr`` so an older gateway that omits the field falls
221
+ # through to the descriptive ``RuntimeError`` below instead of the
222
+ # opaque ``AttributeError`` that ``UniqueObject.__getattr__`` would
223
+ # otherwise raise on a missing key.
224
+ if preview_pdf_path is not None:
225
+ preview_write_url = getattr(finalizedContent, "pdfPreviewWriteUrl", None)
226
+ if not preview_write_url:
227
+ raise RuntimeError(
228
+ "preview_pdf_path was provided but the finalize upsert "
229
+ "response carries no pdfPreviewWriteUrl — refusing to "
230
+ "silently drop the preview. Verify the API gateway and "
231
+ "node-ingestion expose previewPdfFileName on the upsert "
232
+ "mutation."
233
+ )
234
+ _put_preview_pdf(preview_write_url, preview_pdf_path)
235
+
244
236
  return createdContent
245
237
 
246
238