File size: 26,994 Bytes
66de9c8
0649f1b
00a4363
 
 
0649f1b
34b0226
 
00a4363
 
e1222dc
29c052e
 
89250ee
3c705c5
66de9c8
34b0226
00a4363
c0b9f7d
34b0226
00a4363
34b0226
fa8df4c
4a3d0a5
 
fa8df4c
4a3d0a5
fa8df4c
00a4363
66de9c8
 
00a4363
 
29c052e
fa8df4c
0649f1b
 
fa8df4c
 
00a4363
 
 
070c931
00a4363
 
 
34b0226
29c052e
34b0226
 
3c705c5
34b0226
fa8df4c
 
34b0226
e1222dc
 
 
070c931
 
34b0226
 
 
 
b74c6ae
 
 
0649f1b
 
 
 
 
 
 
 
66de9c8
 
fa8df4c
 
 
0649f1b
 
 
 
 
34b0226
00a4363
e1222dc
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
00a4363
34b0226
 
00a4363
 
 
 
 
 
34b0226
 
 
 
 
 
94f4eb8
 
 
ba40d17
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
3c705c5
 
34b0226
 
070c931
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
0649f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
fa8df4c
 
34b0226
66de9c8
fa8df4c
 
 
 
 
 
 
34b0226
b74c6ae
 
 
 
 
0649f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
 
0649f1b
fa8df4c
 
 
 
 
 
0649f1b
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdd1640
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00a4363
 
 
 
34b0226
66de9c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0649f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a3d0a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
bdd1640
 
34b0226
 
bdd1640
 
 
34b0226
 
 
 
 
 
 
 
070c931
 
 
 
 
34b0226
bdd1640
 
 
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
0649f1b
 
34b0226
 
 
 
 
 
00a4363
 
 
7299acc
34b0226
 
 
00a4363
34b0226
 
 
00a4363
34b0226
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
import logging
import re
import uuid
from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select

from app.auth import Ctx, get_ctx
from app.config import settings
from app.infographics.image import ImageUnavailable, render_png
from app.infographics.themes import theme_id
from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
from app.routers.notebooks import ensure_archive_notebook
from app.services import artifact_feedback
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
from app.services.jobs import create_job, serialize_job
from app.services.plans import effective_plan, plan_limit_error
from app.services.renderer import COMPOSITIONS
from app.services.storage import storage
from app.services.usage import check_limit
from app.slides import export as slide_export
from app.slides.build import BadEdit, restructure
from app.slides.build import slots as slide_slots
from app.slides.build import stored as stored_deck
from app.slides.build import view as slide_pages
from app.slides.themes import theme_id as slide_theme_id

log = logging.getLogger(__name__)

router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])

ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
                       "flashcards", "report", "infographic", "slide_deck"]


# Made from posts or chats picked in the dialog
SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic", "slide_deck", "audio_overview")


class RenderIn(BaseModel):
    composition: Literal["AudiogramSquare", "QuoteCard", "CarouselSlide"]
    props: dict


class GenerateIn(BaseModel):
    type: ArtifactType
    notebook_id: uuid.UUID | None = None
    document_id: uuid.UUID | None = None
    archive: bool = False  # no notebook or post picked: use the "All posts" notebook
    # audio_overview
    format: Literal["deep_dive", "brief", "critique", "debate"] = "deep_dive"
    hosts: Literal[1, 2] = 2  # audio_overview: a single narrator or a two host conversation
    minutes: int = Field(6, ge=1, le=30)
    # the two hosts' voices, from the workspace's voices (none: the defaults)
    host_a: str | None = Field(None, max_length=100)
    host_b: str | None = Field(None, max_length=100)
    # video: only audiograms here; other videos are made by blog2video (/api/videos)
    style: str = "audiogram"
    audio_artifact_id: uuid.UUID | None = None
    # carousel
    slides: list[dict] | None = None
    parent_id: uuid.UUID | None = None
    # mind_map: the posts to chart (none picked means every post in the notebook) and what to centre on
    document_ids: list[uuid.UUID] | None = None
    focus: str | None = Field(None, max_length=500)
    topic: str | None = Field(None, max_length=500)  # quiz: what to ask about (none: the material as a whole)
    # quiz: from a notebook's posts (notebook_id and document_ids) or from notebook chats (chat_ids)
    chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=5)
    count: Literal["fewer", "standard", "more"] = "standard"
    difficulty: Literal["easy", "medium", "hard"] = "medium"
    question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
        default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
    language: str | None = Field(None, max_length=40)
    theme: str | None = Field(None, max_length=30)  # infographic: one of the looks (app.infographics.design.LOOKS); slide_deck: app.slides.themes
    ig_style: str | None = Field(None, max_length=20)  # infographic: the layout to lean towards (design.STYLES), auto when empty
    # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
    deck_format: Literal["detailed", "presenter"] = "detailed"
    deck_length: Literal["short", "default", "long"] = "default"
    # report: a written "document" or an "interactive" one, the template it started from, and the instructions the
    # writer is given (the dialog's editable text; empty means the template's own)
    report_format: Literal["document", "interactive"] = "document"
    template_id: str | None = Field(None, max_length=40)
    instructions: str | None = Field(None, max_length=4000)


def _hosts(ctx: Ctx, body: GenerateIn) -> dict:
    """The voices picked for an audio overview's two hosts: each one from the workspace's voices (or a default)."""
    picked = {k: v for k, v in (("host_a", body.host_a), ("host_b", body.host_b)) if v}
    if picked:
        ok = set(ctx.db.scalars(select(UserSavedVoice.voice_id).where(UserSavedVoice.workspace_id == ctx.workspace.id,
                                                                     UserSavedVoice.voice_id.in_(picked.values()))))
        ok |= {settings.elevenlabs_voice_a, settings.elevenlabs_voice_b}
        if any(v not in ok for v in picked.values()):
            raise HTTPException(400, "Pick the hosts' voices from your voices")
    return picked


class PatchIn(BaseModel):
    title: str | None = Field(None, max_length=300)
    content: dict | None = None


def get_artifact_or_404(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
    a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
    if not a:
        raise HTTPException(404, "Artifact not found")
    return a


def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUID | None) -> str:
    if document_id:
        doc = ctx.db.scalar(select(Document).where(Document.id == document_id,
                                                   Document.workspace_id == ctx.workspace.id))
        if not doc:
            raise HTTPException(404, "Post not found")
        if not doc.path:
            plan = effective_plan(ctx.db, ctx.workspace)
            raise plan_limit_error(plan, "indexed_posts",
                                   f"This post is not indexed. Your plan indexes your latest {plan.indexed_posts} "
                                   "posts; upgrade to use your whole archive.")
        return doc.title
    if notebook_id:
        nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
                                                  Notebook.workspace_id == ctx.workspace.id))
        if not nb:
            raise HTTPException(404, "Notebook not found")
        return nb.title
    raise HTTPException(400, "Pick a notebook or a post")


@router.post("/generate")
def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
    """One entry point for every generated artifact."""
    if body.archive and not body.notebook_id and not body.document_id:
        body.notebook_id = ensure_archive_notebook(ctx).id
    params: dict = {}
    content: dict = {}
    if body.type == "video" and body.style != "audiogram":
        raise HTTPException(410, "Videos moved to /app/videos")
    if body.type == "video":
        if not body.audio_artifact_id:
            raise HTTPException(400, "Pick an audio overview for the audiogram")
        source = get_artifact_or_404(ctx, body.audio_artifact_id)
        title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
        params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
        artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
                                       document_id=source.document_id, params=params, job_kind="video")
        return serialize_artifact(artifact, job)
    if body.type == "carousel":
        if not body.slides:
            raise HTTPException(400, "A carousel needs slides")
        content = {"slides": body.slides[:12], "parent_id": str(body.parent_id) if body.parent_id else None}
    if body.type in SOURCE_TYPES and body.chat_ids:
        chats = list(ctx.db.scalars(select(Chat).where(Chat.id.in_(set(body.chat_ids)),
                                                       Chat.workspace_id == ctx.workspace.id)
                                    .order_by(Chat.created_at)))
        if len(chats) != len(set(body.chat_ids)):
            raise HTTPException(404, "Chat not found")
        if not body.notebook_id and not body.document_id:
            body.notebook_id = chats[0].notebook_id  # listed with its chats' notebook
        target = chats[0].title or "Chat"
    else:
        if body.type in SOURCE_TYPES and not (body.notebook_id or body.document_id):
            raise HTTPException(400, "Pick posts or chats to make this from")
        if body.type in SOURCE_TYPES and body.notebook_id and not body.document_id and body.document_ids == []:
            raise HTTPException(400, "Pick at least one post or chat to make this from")
        target = _target_title(ctx, body.notebook_id, body.document_id)
    if body.type == "audio_overview":
        if body.format == "debate" and body.hosts == 1:
            raise HTTPException(400, "A debate needs two hosts")
        check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
        check_limit(ctx.db, ctx.workspace, "audio_overviews", 1)
        ids = body.document_ids or ([body.document_id] if body.document_id else [])
        hosts = _hosts(ctx, body)
        if body.hosts == 1:
            hosts.pop("host_b", None)
        params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
                  "format": body.format, "minutes": body.minutes, "hosts": body.hosts, "language": body.language,
                  "instructions": (body.instructions or "").strip(), **hosts}
        title = f"Audio overview: {target}"
    elif body.type == "mind_map":
        if not body.notebook_id:
            raise HTTPException(400, "A Mind Constellation is made from a notebook")
        params = {"document_ids": [str(i) for i in body.document_ids or []], "focus": (body.focus or "").strip()}
        title = f"Mind Constellation: {params['focus'][:60] or target}"
    elif body.type == "flashcards":
        ids = body.document_ids or ([body.document_id] if body.document_id else [])
        params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
                  "topic": (body.topic or "").strip(), "count": body.count, "difficulty": body.difficulty,
                  "language": body.language}
        title = f"Flashcards: {params['topic'][:60] or target}"
    elif body.type == "report":
        check_limit(ctx.db, ctx.workspace, "reports", 1)
        ids = body.document_ids or ([body.document_id] if body.document_id else [])
        params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
                  "topic": (body.topic or "").strip(), "language": body.language,
                  "report_format": body.report_format, "template_id": body.template_id,
                  "instructions": (body.instructions or "").strip()}
        title = f"Report: {params['topic'][:60] or target}"
    elif body.type == "infographic":
        check_limit(ctx.db, ctx.workspace, "infographics", 1)
        ids = body.document_ids or ([body.document_id] if body.document_id else [])
        params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
                  "theme": theme_id(body.theme), "style": body.ig_style or "auto",
                  "instructions": (body.instructions or "").strip()[:600]}
        title = f"Infographic: {params['instructions'][:60] or target}"
    elif body.type == "slide_deck":
        ids = body.document_ids or ([body.document_id] if body.document_id else [])
        params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
                  "theme": slide_theme_id(body.theme), "deck_format": body.deck_format, "deck_length": body.deck_length,
                  "language": body.language, "instructions": (body.instructions or "").strip()}
        title = f"Slide deck: {target}"
    elif body.type == "quiz":
        ids = body.document_ids or ([body.document_id] if body.document_id else [])
        params = {"document_ids": [str(i) for i in ids],
                  "chat_ids": [str(i) for i in body.chat_ids or []], "topic": (body.topic or "").strip(),
                  "count": body.count, "difficulty": body.difficulty,
                  "question_types": list(dict.fromkeys(body.question_types)), "language": body.language}
        title = f"Quiz: {params['topic'][:60] or target}"
    elif body.type == "launch_kit":
        if not body.document_id:
            raise HTTPException(400, "A Launch Kit is made from one post")
        check_limit(ctx.db, ctx.workspace, "launch_kits", 1)
        title = f"Launch Kit: {target}"
    else:
        title = {"summary": "Summary", "quote_card": "Quote", "carousel": "Carousel"}[body.type] + f": {target}"
    artifact, job = start_artifact(ctx.db, ctx.workspace.id, body.type, title=title, notebook_id=body.notebook_id,
                                   document_id=body.document_id, content=content, params=params,
                                   max_attempts=1 if body.type in {"quote_card", "carousel"} else 2)
    return serialize_artifact(artifact, job)


@router.get("")
def list_artifacts(
    ctx: Ctx = Depends(get_ctx),
    type: str | None = None,
    notebook_id: uuid.UUID | None = None,
    document_id: uuid.UUID | None = None,
    status: str | None = None,
    q: str = "",
    limit: int = 60,
    offset: int = 0,
):
    query = select(Artifact).where(Artifact.workspace_id == ctx.workspace.id)
    if type:
        query = query.where(Artifact.type.in_(type.split(",")))
    else:  # files uploaded to post live in the Launchpad's picker, not among the things Notestack made
        query = query.where(Artifact.type != "upload")
    if notebook_id:
        query = query.where(Artifact.notebook_id == notebook_id)
    if document_id:
        query = query.where(Artifact.document_id == document_id)
    if status:
        query = query.where(Artifact.status.in_(status.split(",")))
    if q.strip():
        # Titles live in JSON; match on the linked post or notebook title instead of JSON operators.
        like = f"%{q.strip()}%"
        docs = select(Document.id).where(Document.workspace_id == ctx.workspace.id, Document.title.ilike(like))
        nbs = select(Notebook.id).where(Notebook.workspace_id == ctx.workspace.id, Notebook.title.ilike(like))
        query = query.where(or_(Artifact.document_id.in_(docs), Artifact.notebook_id.in_(nbs)))
    total = ctx.db.scalar(select(func.count()).select_from(query.subquery()))
    rows = ctx.db.scalars(query.order_by(Artifact.created_at.desc()).offset(max(offset, 0)).limit(min(limit, 200)))
    rows = list(rows.all())
    jobs = latest_jobs(ctx.db, [a.id for a in rows])
    items = [serialize_artifact(a, jobs.get(a.id)) for a in rows]
    return {"total": total, "items": artifact_feedback.attach(ctx.db, ctx.user.id, items)}


@router.get("/feedback/export")
def export_artifact_feedback(ctx: Ctx = Depends(get_ctx), rating: Literal["up", "down"] | None = None, type: str | None = None,
                             limit: int = 200):
    """The ratings of generated reports, quizzes, flashcards and infographics, newest first, with what each one was, to see
    which kinds do well and which do not."""
    q = select(ArtifactFeedback).where(ArtifactFeedback.workspace_id == ctx.workspace.id)
    if rating:
        q = q.where(ArtifactFeedback.rating == (1 if rating == "up" else -1))
    if type:
        q = q.where(ArtifactFeedback.artifact_type == type)
    rows = ctx.db.scalars(q.order_by(ArtifactFeedback.updated_at.desc()).limit(min(limit, 1000))).all()
    log.info("feedback exported: rows=%d rating=%s type=%s user=%s workspace=%s", len(rows), rating, type, ctx.user.id,
             ctx.workspace.id)
    return [{"id": str(f.id), "artifact_id": str(f.artifact_id) if f.artifact_id else None, "type": f.artifact_type,
             "rating": "up" if f.rating > 0 else "down", "reasons": f.reasons, "comment": f.comment, "snapshot": f.snapshot,
             "rated_at": f.updated_at.isoformat() if f.updated_at else None} for f in rows]


@router.get("/{artifact_id}")
def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
    a = get_artifact_or_404(ctx, artifact_id)
    _offer_visuals_once(ctx, a)
    return artifact_feedback.attach(ctx.db, ctx.user.id, [serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))])[0]


def _offer_visuals_once(ctx: Ctx, a: Artifact) -> None:
    """An interactive report made before interactive reports always offered visuals gets them the first time it is opened (once: after
    that, the ones the reader has added or taken away stay as they are). A document report never offers any."""
    c = a.content_json or {}
    if a.type != "report" or a.status != "ready" or c.get("format") != "interactive" or c.get("suggestions_offered") \
            or not c.get("blocks"):
        return
    from app.pipeline.report import EMBED_KINDS, fill_suggestions, merge_suggestions

    has_posts = bool((c.get("source") or {}).get("document_ids"))
    kinds = [k for k in EMBED_KINDS if k != "mind_map" or has_posts]
    a.content_json = {**c, "suggestions": merge_suggestions(fill_suggestions(list(c.get("suggestions") or []), c["blocks"], kinds)),
                      "suggestions_offered": True}
    ctx.db.commit()


class FeedbackIn(BaseModel):
    rating: Literal["up", "down"] | None  # null takes the rating back
    reasons: list[str] = []
    comment: str | None = Field(None, max_length=1000)


@router.put("/{artifact_id}/feedback")
def rate_artifact(artifact_id: uuid.UUID, body: FeedbackIn, ctx: Ctx = Depends(get_ctx)):
    """Thumbs up or down on a finished report, quiz, flashcard set or infographic. Rating again replaces it; null removes it."""
    a = get_artifact_or_404(ctx, artifact_id)
    allowed = artifact_feedback.REASONS.get(a.type)
    if allowed is None or a.status != "ready":
        log.warning("feedback refused: artifact=%s type=%s status=%s user=%s", a.id, a.type, a.status, ctx.user.id)
        raise HTTPException(400, "Only a finished report, quiz, flashcard set or infographic can be rated")
    row = ctx.db.scalar(select(ArtifactFeedback).where(ArtifactFeedback.artifact_id == a.id, ArtifactFeedback.user_id == ctx.user.id))
    if body.rating is None:
        if row:
            ctx.db.delete(row)
            ctx.db.commit()
            log.info("feedback removed: artifact=%s type=%s user=%s workspace=%s", a.id, a.type, ctx.user.id, ctx.workspace.id)
        return {"feedback": None}
    if not row:
        row = ArtifactFeedback(workspace_id=ctx.workspace.id, artifact_id=a.id, user_id=ctx.user.id)
        ctx.db.add(row)
    row.artifact_type = a.type
    row.rating = 1 if body.rating == "up" else -1
    row.reasons = [r for r in dict.fromkeys(body.reasons) if r in allowed] if body.rating == "down" else []
    row.comment = (body.comment or "").strip() or None
    row.snapshot = artifact_feedback.snapshot(a)
    ctx.db.commit()
    # The comment's text stays out of the logs (it is the user's own words); its length and the reasons are enough.
    log.info("feedback saved: artifact=%s type=%s rating=%s reasons=%s comment_chars=%d user=%s workspace=%s",
             a.id, a.type, body.rating, row.reasons, len(row.comment or ""), ctx.user.id, ctx.workspace.id)
    return {"feedback": artifact_feedback.feedback_out(row)}


@router.get("/{artifact_id}/image")
def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "portrait"] = "landscape", ctx: Ctx = Depends(get_ctx)):
    """A finished infographic as a PNG, wide (the default) or tall, for its Download button."""
    a = get_artifact_or_404(ctx, artifact_id)
    shown = serialize_artifact(a) if a.type == "infographic" and a.status == "ready" else {}
    html = (shown.get("content") or {}).get("html_landscape" if layout == "landscape" else "html")
    if not html:
        raise HTTPException(404, "This infographic has no image to download.")
    try:
        png = render_png(html, layout)
    except ImageUnavailable as exc:
        raise HTTPException(503, str(exc)) from exc
    name = re.sub(r"[^\w\- ]+", "", shown.get("title") or "infographic").strip()[:80] or "infographic"
    return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})


@router.get("/{artifact_id}/slides.{ext}")
def slide_deck_file(artifact_id: uuid.UUID, ext: Literal["pdf", "pptx"], ctx: Ctx = Depends(get_ctx)):
    """A finished slide deck as a PDF (exactly as shown) or an editable PowerPoint."""
    a = get_artifact_or_404(ctx, artifact_id)
    got = stored_deck(a.content_json or {}) if a.type == "slide_deck" and a.status == "ready" else None
    if not got:
        raise HTTPException(404, "This slide deck has nothing to download yet.")
    deck, theme, fmt, seed = got
    try:
        data = (slide_export.render_pdf if ext == "pdf" else slide_export.build_pptx)(deck, theme, fmt, seed)
    except ImageUnavailable as exc:
        raise HTTPException(503, str(exc)) from exc
    name = re.sub(r"[^\w\- ]+", "", deck.get("title") or "Slide deck").strip()[:80] or "Slide deck"
    media = "application/pdf" if ext == "pdf" else \
        "application/vnd.openxmlformats-officedocument.presentationml.presentation"
    return Response(data, media_type=media, headers={"Content-Disposition": f'attachment; filename="{name}.{ext}"'})


class DeckIn(BaseModel):
    # The deck's slides as the editor has them, in the stored shape (app.slides.content.empty_slide); coerced on arrival
    slides: list[dict] = Field(min_length=1, max_length=60)


def _editable_deck(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
    a = get_artifact_or_404(ctx, artifact_id)
    if a.type != "slide_deck" or a.status != "ready":
        raise HTTPException(400, "Only a finished slide deck can be edited.")
    return a


@router.post("/{artifact_id}/deck-preview")
def preview_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
    """The editor's slides drawn, without saving: after a slide is added, removed, moved or changed to another layout,
    or a text box is added or removed. Returns the slides as they will be kept (variants given, text fitted)."""
    a = _editable_deck(ctx, artifact_id)
    try:
        deck = restructure(a.content_json or {}, body.slides, final=False)
    except BadEdit as exc:
        raise HTTPException(400, str(exc)) from exc
    content = {**(a.content_json or {}), "deck": deck}
    return {"slides": deck["slides"], "slides_html": slide_pages(content), "slide_slots": slide_slots(content)}


@router.put("/{artifact_id}/deck")
def save_deck(artifact_id: uuid.UUID, body: DeckIn, ctx: Ctx = Depends(get_ctx)):
    """The editor's slides kept: text, speaker notes, and which slides there are, in what order and layout. The deck is
    fitted again (with the Chrome check), so the slides, the PDF and the PowerPoint all show it."""
    a = _editable_deck(ctx, artifact_id)
    try:
        deck = restructure(a.content_json or {}, body.slides, final=True)
    except BadEdit as exc:
        raise HTTPException(400, str(exc)) from exc
    a.content_json = {**(a.content_json or {}), "deck": deck}
    ctx.db.commit()
    return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))


@router.patch("/{artifact_id}")
def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
    a = get_artifact_or_404(ctx, artifact_id)
    content = dict(a.content_json or {})
    if body.content is not None:
        protected = {"slide_keys", "quote_card_ids", "segments", "voices"}
        if a.type == "upload":  # what the file is, as posting reads it: only its title can change
            protected |= {"filename", "content_type", "media", "size_bytes", "duration_s"}
        content.update({k: v for k, v in body.content.items() if k not in protected})
    if body.title is not None:
        if not body.title.strip():
            raise HTTPException(400, "Give it a name.")
        content["title"] = body.title.strip()
    a.content_json = content
    ctx.db.commit()
    return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))


@router.delete("/{artifact_id}")
def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
    a = get_artifact_or_404(ctx, artifact_id)
    if a.type == "video" and (a.content_json or {}).get("provider") == "blog2video":
        from app.routers.videos import remove_video  # also removes it from blog2video

        remove_video(ctx, a)
        return {"ok": True}
    storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
    if a.type == "upload":  # a file from the user's computer: its upload record (and file) go too
        from app.services.uploads import remove_upload_artifact

        remove_upload_artifact(ctx.db, a)
    ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
    ctx.db.delete(a)
    ctx.db.commit()
    return {"ok": True}


@router.post("/{artifact_id}/retry")
def retry_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
    a = get_artifact_or_404(ctx, artifact_id)
    last = latest_jobs(ctx.db, [a.id]).get(a.id)
    if not last:
        raise HTTPException(400, "Nothing to retry")
    if last.status not in {"failed", "done"}:
        raise HTTPException(409, "Still running")
    if last.kind == "report_block":
        raise HTTPException(409, "Add it again from the report")
    a.status = "pending"
    content = dict(a.content_json or {})
    content.pop("error", None)
    a.content_json = content
    job = create_job(ctx.db, ctx.workspace.id, last.kind, last.params, artifact_id=a.id, max_attempts=last.max_attempts)
    return serialize_artifact(a, job)


@router.post("/{artifact_id}/render")
def render_artifact(artifact_id: uuid.UUID, body: RenderIn, ctx: Ctx = Depends(get_ctx)):
    a = get_artifact_or_404(ctx, artifact_id)
    if body.composition not in COMPOSITIONS:
        raise HTTPException(400, "Unknown composition")
    a.status = "rendering"
    job = create_job(ctx.db, ctx.workspace.id, "render",
                     {"artifact_id": str(a.id), "composition": body.composition, "props": body.props},
                     artifact_id=a.id, max_attempts=1)
    return serialize_job(job)


@router.get("/{artifact_id}/jobs")
def artifact_jobs(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
    a = get_artifact_or_404(ctx, artifact_id)
    jobs = ctx.db.scalars(select(Job).where(Job.artifact_id == a.id).order_by(Job.created_at.desc())).all()
    return [serialize_job(j) for j in jobs]