@weirdfingers/baseboards 0.2.0

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 (139) hide show
  1. package/README.md +191 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +887 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +64 -0
  6. package/templates/README.md +120 -0
  7. package/templates/api/.env.example +62 -0
  8. package/templates/api/Dockerfile +32 -0
  9. package/templates/api/README.md +132 -0
  10. package/templates/api/alembic/env.py +106 -0
  11. package/templates/api/alembic/script.py.mako +28 -0
  12. package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
  13. package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
  14. package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
  15. package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
  16. package/templates/api/alembic.ini +36 -0
  17. package/templates/api/config/generators.yaml +25 -0
  18. package/templates/api/config/storage_config.yaml +26 -0
  19. package/templates/api/docs/ADDING_GENERATORS.md +409 -0
  20. package/templates/api/docs/GENERATORS_API.md +502 -0
  21. package/templates/api/docs/MIGRATIONS.md +472 -0
  22. package/templates/api/docs/storage_providers.md +337 -0
  23. package/templates/api/pyproject.toml +165 -0
  24. package/templates/api/src/boards/__init__.py +10 -0
  25. package/templates/api/src/boards/api/app.py +171 -0
  26. package/templates/api/src/boards/api/auth.py +75 -0
  27. package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
  28. package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
  29. package/templates/api/src/boards/api/endpoints/setup.py +505 -0
  30. package/templates/api/src/boards/api/endpoints/sse.py +129 -0
  31. package/templates/api/src/boards/api/endpoints/storage.py +74 -0
  32. package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
  33. package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
  34. package/templates/api/src/boards/auth/__init__.py +15 -0
  35. package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
  36. package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
  37. package/templates/api/src/boards/auth/adapters/base.py +73 -0
  38. package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
  39. package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
  40. package/templates/api/src/boards/auth/adapters/none.py +102 -0
  41. package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
  42. package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
  43. package/templates/api/src/boards/auth/context.py +35 -0
  44. package/templates/api/src/boards/auth/factory.py +115 -0
  45. package/templates/api/src/boards/auth/middleware.py +221 -0
  46. package/templates/api/src/boards/auth/provisioning.py +129 -0
  47. package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
  48. package/templates/api/src/boards/cli.py +354 -0
  49. package/templates/api/src/boards/config.py +116 -0
  50. package/templates/api/src/boards/database/__init__.py +7 -0
  51. package/templates/api/src/boards/database/cli.py +110 -0
  52. package/templates/api/src/boards/database/connection.py +252 -0
  53. package/templates/api/src/boards/database/models.py +19 -0
  54. package/templates/api/src/boards/database/seed_data.py +182 -0
  55. package/templates/api/src/boards/dbmodels/__init__.py +455 -0
  56. package/templates/api/src/boards/generators/__init__.py +57 -0
  57. package/templates/api/src/boards/generators/artifacts.py +53 -0
  58. package/templates/api/src/boards/generators/base.py +140 -0
  59. package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
  60. package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
  61. package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
  62. package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
  63. package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
  64. package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
  65. package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
  66. package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
  67. package/templates/api/src/boards/generators/loader.py +253 -0
  68. package/templates/api/src/boards/generators/registry.py +114 -0
  69. package/templates/api/src/boards/generators/resolution.py +515 -0
  70. package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
  71. package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
  72. package/templates/api/src/boards/graphql/__init__.py +7 -0
  73. package/templates/api/src/boards/graphql/access_control.py +136 -0
  74. package/templates/api/src/boards/graphql/mutations/root.py +136 -0
  75. package/templates/api/src/boards/graphql/queries/root.py +116 -0
  76. package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
  77. package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
  78. package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
  79. package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
  80. package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
  81. package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
  82. package/templates/api/src/boards/graphql/schema.py +81 -0
  83. package/templates/api/src/boards/graphql/types/board.py +102 -0
  84. package/templates/api/src/boards/graphql/types/generation.py +130 -0
  85. package/templates/api/src/boards/graphql/types/generator.py +17 -0
  86. package/templates/api/src/boards/graphql/types/user.py +47 -0
  87. package/templates/api/src/boards/jobs/repository.py +104 -0
  88. package/templates/api/src/boards/logging.py +195 -0
  89. package/templates/api/src/boards/middleware.py +339 -0
  90. package/templates/api/src/boards/progress/__init__.py +4 -0
  91. package/templates/api/src/boards/progress/models.py +25 -0
  92. package/templates/api/src/boards/progress/publisher.py +64 -0
  93. package/templates/api/src/boards/py.typed +0 -0
  94. package/templates/api/src/boards/redis_pool.py +118 -0
  95. package/templates/api/src/boards/storage/__init__.py +52 -0
  96. package/templates/api/src/boards/storage/base.py +363 -0
  97. package/templates/api/src/boards/storage/config.py +187 -0
  98. package/templates/api/src/boards/storage/factory.py +278 -0
  99. package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
  100. package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
  101. package/templates/api/src/boards/storage/implementations/local.py +201 -0
  102. package/templates/api/src/boards/storage/implementations/s3.py +294 -0
  103. package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
  104. package/templates/api/src/boards/tenant_isolation.py +446 -0
  105. package/templates/api/src/boards/validation.py +262 -0
  106. package/templates/api/src/boards/workers/__init__.py +1 -0
  107. package/templates/api/src/boards/workers/actors.py +201 -0
  108. package/templates/api/src/boards/workers/cli.py +125 -0
  109. package/templates/api/src/boards/workers/context.py +188 -0
  110. package/templates/api/src/boards/workers/middleware.py +58 -0
  111. package/templates/api/src/py.typed +0 -0
  112. package/templates/compose.dev.yaml +39 -0
  113. package/templates/compose.yaml +109 -0
  114. package/templates/docker/env.example +23 -0
  115. package/templates/web/.env.example +28 -0
  116. package/templates/web/Dockerfile +51 -0
  117. package/templates/web/components.json +22 -0
  118. package/templates/web/imageLoader.js +18 -0
  119. package/templates/web/next-env.d.ts +5 -0
  120. package/templates/web/next.config.js +36 -0
  121. package/templates/web/package.json +37 -0
  122. package/templates/web/postcss.config.mjs +7 -0
  123. package/templates/web/public/favicon.ico +0 -0
  124. package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
  125. package/templates/web/src/app/globals.css +120 -0
  126. package/templates/web/src/app/layout.tsx +21 -0
  127. package/templates/web/src/app/page.tsx +35 -0
  128. package/templates/web/src/app/providers.tsx +18 -0
  129. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
  130. package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
  131. package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
  132. package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
  133. package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
  134. package/templates/web/src/components/header.tsx +30 -0
  135. package/templates/web/src/components/ui/button.tsx +58 -0
  136. package/templates/web/src/components/ui/card.tsx +92 -0
  137. package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
  138. package/templates/web/src/lib/utils.ts +6 -0
  139. package/templates/web/tsconfig.json +47 -0
@@ -0,0 +1,515 @@
1
+ """
2
+ Artifact resolution utilities for converting Generation references to actual files.
3
+ """
4
+
5
+ import os
6
+ import tempfile
7
+ import uuid
8
+ from urllib.parse import urlparse
9
+
10
+ import aiofiles
11
+ import httpx
12
+
13
+ from ..logging import get_logger
14
+ from ..storage.base import StorageManager
15
+ from .artifacts import (
16
+ AudioArtifact,
17
+ ImageArtifact,
18
+ LoRArtifact,
19
+ TextArtifact,
20
+ VideoArtifact,
21
+ )
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ async def resolve_artifact(
27
+ artifact: AudioArtifact | VideoArtifact | ImageArtifact | LoRArtifact,
28
+ ) -> str:
29
+ """
30
+ Resolve an artifact to a local file path that can be used by provider SDKs.
31
+
32
+ This function downloads the artifact from storage if needed and returns
33
+ a local file path that generators can pass to provider SDKs.
34
+
35
+ Args:
36
+ artifact: Artifact instance to resolve
37
+
38
+ Returns:
39
+ str: Local file path to the artifact content
40
+
41
+ Raises:
42
+ ValueError: If the artifact type is not supported for file resolution
43
+ httpx.HTTPError: If downloading the artifact fails
44
+ """
45
+ if isinstance(artifact, TextArtifact):
46
+ # Text artifacts don't need file resolution - they contain content directly
47
+ raise ValueError(
48
+ "TextArtifact cannot be resolved to a file path - use artifact.content directly"
49
+ )
50
+
51
+ # Validate that storage_url is actually a URL (not a local file path)
52
+ # This prevents potential security issues with paths like /etc/passwd
53
+ parsed = urlparse(artifact.storage_url)
54
+
55
+ # Check if it's a valid URL with a scheme (http, https, s3, etc.)
56
+ if parsed.scheme in ("http", "https", "s3", "gs"):
57
+ # It's a remote URL, download it
58
+ return await download_artifact_to_temp(artifact)
59
+
60
+ # If no scheme, it might be a local file path
61
+ # Only allow this if the file actually exists (for backward compatibility)
62
+ if os.path.exists(artifact.storage_url):
63
+ logger.debug(
64
+ "Using local file path for artifact",
65
+ storage_url=artifact.storage_url,
66
+ )
67
+ return artifact.storage_url
68
+
69
+ # Download the file to a temporary location
70
+ return await download_artifact_to_temp(artifact)
71
+
72
+
73
+ async def download_artifact_to_temp(
74
+ artifact: AudioArtifact | VideoArtifact | ImageArtifact | LoRArtifact,
75
+ ) -> str:
76
+ """
77
+ Download an artifact from its storage URL to a temporary file.
78
+
79
+ Args:
80
+ artifact: Artifact to download
81
+
82
+ Returns:
83
+ str: Path to the temporary file containing the artifact content
84
+
85
+ Raises:
86
+ httpx.HTTPError: If downloading fails
87
+ """
88
+ # Determine file extension based on artifact type and format
89
+ extension = _get_file_extension(artifact)
90
+
91
+ # Create temporary file with appropriate extension (use random prefix for security)
92
+ random_id = uuid.uuid4().hex[:8]
93
+ temp_fd, temp_path = tempfile.mkstemp(suffix=extension, prefix=f"boards_artifact_{random_id}_")
94
+
95
+ # Set restrictive file permissions (owner read/write only: 0o600)
96
+ os.chmod(temp_path, 0o600)
97
+
98
+ try:
99
+ # Stream the download to avoid loading large files into memory
100
+ async with httpx.AsyncClient(timeout=300.0) as client:
101
+ async with client.stream("GET", artifact.storage_url) as response:
102
+ response.raise_for_status()
103
+
104
+ # Close the file descriptor returned by mkstemp and use aiofiles
105
+ os.close(temp_fd)
106
+
107
+ # Stream content to file using async I/O
108
+ total_bytes = 0
109
+ async with aiofiles.open(temp_path, "wb") as temp_file:
110
+ async for chunk in response.aiter_bytes(chunk_size=8192):
111
+ await temp_file.write(chunk)
112
+ total_bytes += len(chunk)
113
+
114
+ # Validate that we downloaded something
115
+ if total_bytes == 0:
116
+ raise ValueError("Downloaded file is empty")
117
+
118
+ logger.debug(
119
+ "Successfully downloaded artifact to temp file",
120
+ temp_path=temp_path,
121
+ size_bytes=total_bytes,
122
+ )
123
+
124
+ return temp_path
125
+
126
+ except Exception:
127
+ # Clean up the temporary file if download failed
128
+ try:
129
+ os.unlink(temp_path)
130
+ except FileNotFoundError:
131
+ pass
132
+ raise
133
+
134
+
135
+ def _get_file_extension(
136
+ artifact: AudioArtifact | VideoArtifact | ImageArtifact | LoRArtifact,
137
+ ) -> str:
138
+ """
139
+ Get the appropriate file extension for an artifact based on its format.
140
+
141
+ Args:
142
+ artifact: Artifact to get extension for
143
+
144
+ Returns:
145
+ str: File extension including the dot (e.g., '.mp4', '.png')
146
+ """
147
+ format_ext = artifact.format.lower()
148
+
149
+ # Add dot if not present
150
+ if not format_ext.startswith("."):
151
+ format_ext = f".{format_ext}"
152
+
153
+ return format_ext
154
+
155
+
156
+ async def download_from_url(url: str) -> bytes:
157
+ """
158
+ Download content from a URL (typically a provider's temporary URL).
159
+
160
+ This is used to download generated content from providers like Replicate, OpenAI, etc.
161
+ before uploading to our permanent storage.
162
+
163
+ Note: For very large files, consider using streaming downloads directly to storage
164
+ instead of loading into memory.
165
+
166
+ Args:
167
+ url: URL to download from
168
+
169
+ Returns:
170
+ bytes: Downloaded content
171
+
172
+ Raises:
173
+ httpx.HTTPError: If download fails
174
+ ValueError: If downloaded content is empty
175
+ """
176
+ logger.debug("Downloading content from URL", url=url)
177
+
178
+ # Stream download to avoid loading entire file into memory at once
179
+ async with httpx.AsyncClient(timeout=60.0) as client:
180
+ async with client.stream("GET", url) as response:
181
+ response.raise_for_status()
182
+
183
+ # Collect chunks
184
+ chunks = []
185
+ total_bytes = 0
186
+ async for chunk in response.aiter_bytes(chunk_size=8192):
187
+ chunks.append(chunk)
188
+ total_bytes += len(chunk)
189
+
190
+ # Validate content
191
+ if total_bytes == 0:
192
+ raise ValueError(f"Downloaded file from {url} is empty")
193
+
194
+ logger.info(
195
+ "Successfully downloaded content",
196
+ url=url,
197
+ size_bytes=total_bytes,
198
+ )
199
+ return b"".join(chunks)
200
+
201
+
202
+ def _get_content_type_from_format(artifact_type: str, format: str) -> str:
203
+ """
204
+ Get MIME content type from artifact type and format.
205
+
206
+ Args:
207
+ artifact_type: Type of artifact ('image', 'video', 'audio')
208
+ format: Format string (e.g., 'png', 'mp4', 'mp3')
209
+
210
+ Returns:
211
+ str: MIME content type
212
+ """
213
+ format_lower = format.lower()
214
+
215
+ # Map common formats to content types
216
+ content_type_map = {
217
+ "image": {
218
+ "png": "image/png",
219
+ "jpg": "image/jpeg",
220
+ "jpeg": "image/jpeg",
221
+ "webp": "image/webp",
222
+ "gif": "image/gif",
223
+ },
224
+ "video": {
225
+ "mp4": "video/mp4",
226
+ "webm": "video/webm",
227
+ "mov": "video/quicktime",
228
+ },
229
+ "audio": {
230
+ "mp3": "audio/mpeg",
231
+ "wav": "audio/wav",
232
+ "ogg": "audio/ogg",
233
+ },
234
+ }
235
+
236
+ type_map = content_type_map.get(artifact_type, {})
237
+ return type_map.get(format_lower, "application/octet-stream")
238
+
239
+
240
+ async def store_image_result(
241
+ storage_manager: StorageManager,
242
+ generation_id: str,
243
+ tenant_id: str,
244
+ board_id: str,
245
+ storage_url: str,
246
+ format: str,
247
+ width: int,
248
+ height: int,
249
+ ) -> ImageArtifact:
250
+ """
251
+ Store an image result by downloading from provider URL and uploading to storage.
252
+
253
+ Args:
254
+ storage_manager: Storage manager instance
255
+ generation_id: ID of the generation
256
+ tenant_id: Tenant ID for storage isolation
257
+ board_id: Board ID for organization
258
+ storage_url: Provider's temporary URL to download from
259
+ format: Image format (png, jpg, etc.)
260
+ width: Image width in pixels
261
+ height: Image height in pixels
262
+
263
+ Returns:
264
+ ImageArtifact with permanent storage URL
265
+
266
+ Raises:
267
+ StorageException: If storage operation fails
268
+ httpx.HTTPError: If download fails
269
+ """
270
+ logger.info(
271
+ "Storing image result",
272
+ generation_id=generation_id,
273
+ provider_url=storage_url,
274
+ format=format,
275
+ )
276
+
277
+ # Download content from provider URL
278
+ content = await download_from_url(storage_url)
279
+
280
+ # Determine content type
281
+ content_type = _get_content_type_from_format("image", format)
282
+
283
+ # Upload to storage system
284
+ artifact_ref = await storage_manager.store_artifact(
285
+ artifact_id=generation_id,
286
+ content=content,
287
+ artifact_type="image",
288
+ content_type=content_type,
289
+ tenant_id=tenant_id,
290
+ board_id=board_id,
291
+ )
292
+
293
+ logger.info(
294
+ "Image stored successfully",
295
+ generation_id=generation_id,
296
+ storage_key=artifact_ref.storage_key,
297
+ storage_url=artifact_ref.storage_url,
298
+ )
299
+
300
+ # Return artifact with our permanent storage URL
301
+ return ImageArtifact(
302
+ generation_id=generation_id,
303
+ storage_url=artifact_ref.storage_url,
304
+ width=width,
305
+ height=height,
306
+ format=format,
307
+ )
308
+
309
+
310
+ async def store_video_result(
311
+ storage_manager: StorageManager,
312
+ generation_id: str,
313
+ tenant_id: str,
314
+ board_id: str,
315
+ storage_url: str,
316
+ format: str,
317
+ width: int,
318
+ height: int,
319
+ duration: float | None = None,
320
+ fps: float | None = None,
321
+ ) -> VideoArtifact:
322
+ """
323
+ Store a video result by downloading from provider URL and uploading to storage.
324
+
325
+ Args:
326
+ storage_manager: Storage manager instance
327
+ generation_id: ID of the generation
328
+ tenant_id: Tenant ID for storage isolation
329
+ board_id: Board ID for organization
330
+ storage_url: Provider's temporary URL to download from
331
+ format: Video format (mp4, webm, etc.)
332
+ width: Video width in pixels
333
+ height: Video height in pixels
334
+ duration: Video duration in seconds (optional)
335
+ fps: Frames per second (optional)
336
+
337
+ Returns:
338
+ VideoArtifact with permanent storage URL
339
+
340
+ Raises:
341
+ StorageException: If storage operation fails
342
+ httpx.HTTPError: If download fails
343
+ """
344
+ logger.info(
345
+ "Storing video result",
346
+ generation_id=generation_id,
347
+ provider_url=storage_url,
348
+ format=format,
349
+ )
350
+
351
+ # Download content from provider URL
352
+ content = await download_from_url(storage_url)
353
+
354
+ # Determine content type
355
+ content_type = _get_content_type_from_format("video", format)
356
+
357
+ # Upload to storage system
358
+ artifact_ref = await storage_manager.store_artifact(
359
+ artifact_id=generation_id,
360
+ content=content,
361
+ artifact_type="video",
362
+ content_type=content_type,
363
+ tenant_id=tenant_id,
364
+ board_id=board_id,
365
+ )
366
+
367
+ logger.info(
368
+ "Video stored successfully",
369
+ generation_id=generation_id,
370
+ storage_key=artifact_ref.storage_key,
371
+ storage_url=artifact_ref.storage_url,
372
+ )
373
+
374
+ # Return artifact with our permanent storage URL
375
+ return VideoArtifact(
376
+ generation_id=generation_id,
377
+ storage_url=artifact_ref.storage_url,
378
+ width=width,
379
+ height=height,
380
+ format=format,
381
+ duration=duration,
382
+ fps=fps,
383
+ )
384
+
385
+
386
+ async def store_audio_result(
387
+ storage_manager: StorageManager,
388
+ generation_id: str,
389
+ tenant_id: str,
390
+ board_id: str,
391
+ storage_url: str,
392
+ format: str,
393
+ duration: float | None = None,
394
+ sample_rate: int | None = None,
395
+ channels: int | None = None,
396
+ ) -> AudioArtifact:
397
+ """
398
+ Store an audio result by downloading from provider URL and uploading to storage.
399
+
400
+ Args:
401
+ storage_manager: Storage manager instance
402
+ generation_id: ID of the generation
403
+ tenant_id: Tenant ID for storage isolation
404
+ board_id: Board ID for organization
405
+ storage_url: Provider's temporary URL to download from
406
+ format: Audio format (mp3, wav, etc.)
407
+ duration: Audio duration in seconds (optional)
408
+ sample_rate: Sample rate in Hz (optional)
409
+ channels: Number of audio channels (optional)
410
+
411
+ Returns:
412
+ AudioArtifact with permanent storage URL
413
+
414
+ Raises:
415
+ StorageException: If storage operation fails
416
+ httpx.HTTPError: If download fails
417
+ """
418
+ logger.info(
419
+ "Storing audio result",
420
+ generation_id=generation_id,
421
+ provider_url=storage_url,
422
+ format=format,
423
+ )
424
+
425
+ # Download content from provider URL
426
+ content = await download_from_url(storage_url)
427
+
428
+ # Determine content type
429
+ content_type = _get_content_type_from_format("audio", format)
430
+
431
+ # Upload to storage system
432
+ artifact_ref = await storage_manager.store_artifact(
433
+ artifact_id=generation_id,
434
+ content=content,
435
+ artifact_type="audio",
436
+ content_type=content_type,
437
+ tenant_id=tenant_id,
438
+ board_id=board_id,
439
+ )
440
+
441
+ logger.info(
442
+ "Audio stored successfully",
443
+ generation_id=generation_id,
444
+ storage_key=artifact_ref.storage_key,
445
+ storage_url=artifact_ref.storage_url,
446
+ )
447
+
448
+ # Return artifact with our permanent storage URL
449
+ return AudioArtifact(
450
+ generation_id=generation_id,
451
+ storage_url=artifact_ref.storage_url,
452
+ format=format,
453
+ duration=duration,
454
+ sample_rate=sample_rate,
455
+ channels=channels,
456
+ )
457
+
458
+
459
+ async def store_text_result(
460
+ storage_manager: StorageManager,
461
+ generation_id: str,
462
+ tenant_id: str,
463
+ board_id: str,
464
+ content: str,
465
+ format: str,
466
+ ) -> TextArtifact:
467
+ """
468
+ Store a text result by uploading to storage.
469
+
470
+ Args:
471
+ storage_manager: Storage manager instance
472
+ generation_id: ID of the generation
473
+ tenant_id: Tenant ID for storage isolation
474
+ board_id: Board ID for organization
475
+ content: Text content to store
476
+ format: Text format (plain, markdown, html, etc.)
477
+
478
+ Returns:
479
+ TextArtifact with permanent storage URL
480
+
481
+ Raises:
482
+ StorageException: If storage operation fails
483
+ httpx.HTTPError: If upload fails
484
+ """
485
+ logger.info(
486
+ "Storing text result",
487
+ generation_id=generation_id,
488
+ content=content,
489
+ format=format,
490
+ )
491
+
492
+ # Upload to storage system
493
+ artifact_ref = await storage_manager.store_artifact(
494
+ artifact_id=generation_id,
495
+ content=content.encode("utf-8"),
496
+ artifact_type="text",
497
+ content_type="text/plain",
498
+ tenant_id=tenant_id,
499
+ board_id=board_id,
500
+ )
501
+
502
+ logger.info(
503
+ "Text stored successfully",
504
+ generation_id=generation_id,
505
+ storage_key=artifact_ref.storage_key,
506
+ storage_url=artifact_ref.storage_url,
507
+ )
508
+
509
+ # Return artifact with our permanent storage URL
510
+ return TextArtifact(
511
+ generation_id=generation_id,
512
+ storage_url=artifact_ref.storage_url,
513
+ content=content,
514
+ format=format,
515
+ )
@@ -0,0 +1,34 @@
1
+ """Test helper generator class for loader unit tests (class-based)."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from ..base import BaseGenerator
6
+
7
+
8
+ class _Input(BaseModel):
9
+ text: str = "hello"
10
+
11
+
12
+ class _Output(BaseModel):
13
+ text: str
14
+
15
+
16
+ class ClassGen(BaseGenerator):
17
+ name = "class-gen"
18
+ artifact_type = "text"
19
+ description = "Test class-based generator"
20
+
21
+ def __init__(self, suffix: str | None = None):
22
+ self.suffix = suffix or "!"
23
+
24
+ def get_input_schema(self) -> type[_Input]:
25
+ return _Input
26
+
27
+ def get_output_schema(self) -> type[_Output]:
28
+ return _Output
29
+
30
+ async def generate(self, inputs: _Input, context) -> _Output: # type: ignore[override]
31
+ return _Output(text=f"{inputs.text}{self.suffix}")
32
+
33
+ async def estimate_cost(self, inputs: _Input) -> float: # type: ignore[override]
34
+ return 0.0
@@ -0,0 +1,35 @@
1
+ """Test helper module that registers on import (import-based)."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from ..base import BaseGenerator
6
+ from ..registry import registry
7
+
8
+
9
+ class _Input(BaseModel):
10
+ pass
11
+
12
+
13
+ class _Output(BaseModel):
14
+ pass
15
+
16
+
17
+ class ImportGen(BaseGenerator):
18
+ name = "import-gen"
19
+ artifact_type = "text"
20
+ description = "Test import-based generator"
21
+
22
+ def get_input_schema(self) -> type[_Input]:
23
+ return _Input
24
+
25
+ def get_output_schema(self) -> type[_Output]:
26
+ return _Output
27
+
28
+ async def generate(self, inputs: _Input, context) -> _Output: # type: ignore[override]
29
+ return _Output()
30
+
31
+ async def estimate_cost(self, inputs: _Input) -> float: # type: ignore[override]
32
+ return 0.0
33
+
34
+
35
+ registry.register(ImportGen())
@@ -0,0 +1,7 @@
1
+ """
2
+ GraphQL API for Boards backend
3
+ """
4
+
5
+ from .schema import schema
6
+
7
+ __all__ = ["schema"]