File size: 11,282 Bytes
fa8df4c
34b0226
fa8df4c
34b0226
 
 
 
 
 
 
 
fa8df4c
34b0226
 
fa8df4c
34b0226
fa8df4c
34b0226
 
66de9c8
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f74c934
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
fa8df4c
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
e1222dc
 
f74c934
 
fa8df4c
 
f74c934
 
 
 
 
 
 
 
 
 
fa8df4c
34b0226
 
 
f74c934
34b0226
fa8df4c
 
34b0226
 
 
 
 
 
 
 
fa8df4c
 
 
 
 
34b0226
 
fa8df4c
34b0226
 
 
 
 
 
 
070c931
34b0226
 
 
070c931
34b0226
070c931
34b0226
 
 
 
 
 
 
 
 
 
 
 
66de9c8
34b0226
 
 
 
 
 
070c931
34b0226
 
 
 
 
 
 
 
 
66de9c8
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
66de9c8
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
"""Audio overviews (one or two hosts, ElevenLabs), audiograms and stills (Remotion renderer)."""

import re
import uuid

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.config import settings
from app.corpus import Corpus
from app.llm import run
from app.llm.signatures import PickQuotes, WriteAudioScript
from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
from app.pipeline.generate import NothingToDo
from app.pipeline.material import load_material
from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
from app.pipeline.report import SAME_LANGUAGE
from app.services import tts
from app.services.jobs import record_usage, update_job
from app.services.renderer import PermanentJobError, request_render
from app.services.storage import keys, storage


def artifact_docs(db: Session, artifact: Artifact) -> list[Document]:
    if artifact.document_id:
        doc = db.get(Document, artifact.document_id)
        return [doc] if doc and doc.path else []
    if artifact.notebook_id:
        return notebook_docs(db, artifact.notebook_id)
    return []


def host_voices(db: Session, workspace_id: uuid.UUID) -> tuple[str, str]:
    vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
    voices = (vp.host_voices if vp else None) or {}
    return voices.get("host_a") or settings.elevenlabs_voice_a, voices.get("host_b") or settings.elevenlabs_voice_b


def host_settings(db: Session, workspace_id: uuid.UUID) -> dict[str, tts.VoiceSettings]:
    """Per host delivery settings (stability, similarity, style, speed) saved on the Voice page."""
    vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
    saved = ((vp.host_voices if vp else None) or {}).get("settings") or {}
    return {h: tts.VoiceSettings.from_dict(saved.get(h)) for h in ("host_a", "host_b")}


def _require_tts() -> None:
    if not settings.elevenlabs_api_key:
        raise PermanentJobError("Audio needs ELEVENLABS_API_KEY in .env")


# Audio overview


STYLES = {
    "deep_dive": "Deep Dive: a lively, curious conversation that unpacks the ideas and connects them to each other",
    "brief": "Brief: a short, bite sized overview that gets the core ideas across quickly",
    "critique": "Critique: an expert review of the material, with honest, constructive feedback on how to improve it",
    "debate": "Debate: two hosts take different sides on the material's main questions and argue them respectfully",
}
WORDS_PER_MINUTE = 150
_MARKUP = re.compile(r"(\*\*|__|`|#{1,6}\s|^\s*[-*•]\s+|\[\d+\])", re.M)


def script_turns(turns: list[dict], hosts: int) -> list[dict]:
    """The model's turns ready to voice: empty ones dropped and markup removed. With two hosts, consecutive turns by
    one host are joined and the speakers then strictly alternate, host A first, so the conversation always goes A, B,
    A, B. With one host, every turn is host A's."""
    clean = []
    for t in turns or []:
        text = re.sub(r"\s+", " ", _MARKUP.sub("", str((t or {}).get("text") or ""))).replace("\u2014", ", ").strip()
        if text:
            clean.append({"speaker": (t or {}).get("speaker") or "host_a", "text": text,
                          "sources": list((t or {}).get("sources") or [])})
    if hosts == 1:
        return [{**t, "speaker": "host_a"} for t in clean]
    merged: list[dict] = []
    for t in clean:
        if merged and merged[-1]["speaker"] == t["speaker"]:
            merged[-1]["text"] += " " + t["text"]
            merged[-1]["sources"] += t["sources"]
        else:
            merged.append(dict(t))
    return [{**t, "speaker": "host_a" if i % 2 == 0 else "host_b"} for i, t in enumerate(merged)]


def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
    _require_tts()
    params = dict(job.params)
    if artifact.document_id and not params.get("document_ids") and not params.get("chat_ids"):
        params["document_ids"] = [str(artifact.document_id)]  # made from one post
    fmt = params.get("format") if params.get("format") in STYLES else "deep_dive"
    hosts = 1 if params.get("hosts") == 1 else 2  # older jobs had no choice: two hosts
    minutes = int(params.get("minutes", 6))
    focus = (params.get("instructions") or "").strip()
    material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="audio overview")
    update_job(db, job, progress=0.15, message="Writing the conversation" if hosts == 2 else "Writing the narration")
    out = run.predict(WriteAudioScript, db=db, workspace_id=artifact.workspace_id, job=job, title=material.title,
                      material=material.text, style=STYLES[fmt], hosts=hosts, target_words=minutes * WORDS_PER_MINUTE,
                      focus=focus or "(none)", language=(params.get("language") or "").strip() or SAME_LANGUAGE)
    lines = script_turns(out.get("turns") or [], hosts)
    if not lines:
        raise RuntimeError("The script came back empty")
    default_a, default_b = host_voices(db, artifact.workspace_id)
    voice_a, voice_b = params.get("host_a") or default_a, params.get("host_b") or default_b  # picked when made
    delivery = host_settings(db, artifact.workspace_id)
    script = [
        tts.Line(text=ln["text"], voice_id=voice_b if ln["speaker"] == "host_b" else voice_a,
                 settings=delivery[ln["speaker"]])
        for ln in lines
    ]
    update_job(db, job, progress=0.2, message=f"Recording {len(script)} lines")

    def progress(done: int) -> None:
        if done % 3 == 0 or done == len(script):
            update_job(db, job, progress=0.2 + 0.75 * done / len(script),
                       message=f"Recorded {done} of {len(script)} lines")

    clips = tts.synthesize_many(artifact.workspace_id, script, on_done=progress)
    # Every clip is the same MP3 format (mp3_44100_128), so the clips join into one file in order.
    audio = bytearray()
    segments = []
    t = 0.0
    for line, clip in zip(lines, clips, strict=True):
        seconds = tts.mp3_seconds(clip)
        segments.append({"speaker": line["speaker"], "text": line["text"],
                         "start": round(t, 2), "end": round(t + seconds, 2), "sources": material.refs(line["sources"])})
        audio += clip
        t += seconds
    key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
    storage.put_bytes(key, bytes(audio), "audio/mpeg")
    artifact.storage_key = key
    artifact.status = "ready"
    artifact.content_json = {
        **(artifact.content_json or {}),
        "format": fmt,
        "hosts": hosts,
        "focus": focus,
        "language": params.get("language") or "",
        "source": material.source,
        "duration_s": round(t, 1),
        "segments": segments,
        "voices": {"host_a": voice_a, **({"host_b": voice_b} if hosts == 2 else {})},
    }
    db.commit()
    record_usage(db, workspace_id=artifact.workspace_id, kind="tts", provider="elevenlabs",
                 quantity=round(t, 1), unit="seconds", job=job)
    return {"artifact_id": str(artifact.id), "duration_s": round(t, 1)}


# Video: blog2video makes videos now (app/routers/videos.py). The renderer still makes audiograms.


def make_video(db: Session, job: Job, artifact: Artifact) -> None:
    if job.params.get("style") == "audiogram":
        return make_audiogram(db, job, artifact)
    raise PermanentJobError("Videos are now made in the Videos editor.")


def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
    source = db.get(Artifact, uuid.UUID(job.params["audio_artifact_id"]))
    if not source or source.type != "audio_overview" or not source.storage_key:
        raise PermanentJobError("Pick a finished audio overview for the audiogram.")
    content = source.content_json or {}
    duration = float(content.get("duration_s") or 30)
    segments = [{"speaker": s["speaker"], "text": s["text"], "startMs": int(s["start"] * 1000),
                 "endMs": int(s["end"] * 1000)} for s in content.get("segments", [])]
    props = {"title": (content.get("title") or "Audio overview")[:140],
             "audioUrl": storage.presign_get(source.storage_key, ttl=6 * 3600),
             "durationS": round(min(duration, 600), 2), "segments": segments }
    artifact.content_json = {**(artifact.content_json or {}), "style": "audiogram", "composition": "AudiogramSquare",
                             "audio_artifact_id": str(source.id), "duration_s": props["durationS"]}
    artifact.status = "rendering"
    db.commit()
    update_job(db, job, progress=0.1, message="Handing off to the renderer")
    request_render(job, artifact, "AudiogramSquare", props)
    record_usage(db, workspace_id=artifact.workspace_id, kind="render", provider="remotion",
                 quantity=props["durationS"], unit="seconds", job=job)


# Stills


def author_line(db: Session, workspace_id: uuid.UUID) -> str:
    ws = db.get(Workspace, workspace_id)
    owner = db.get(User, ws.owner_id) if ws else None
    return (owner.name if owner and owner.name else "") or "Notestack"


def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
    content = artifact.content_json or {}
    if not content.get("quote"):
        docs = artifact_docs(db, artifact)
        if not docs:
            raise NothingToDo("Pick a post or notebook for the quote card.")
        corpus = Corpus(artifact.workspace_id)
        out = run.predict(PickQuotes, db=db, workspace_id=artifact.workspace_id, job=job,
                          passages=passages_for(corpus, docs, budget_chars=40_000), n=3)
        tools = tools_for(corpus, docs)
        for pick in out.get("quotes") or []:
            refs = verify_refs(tools, [pick.get("source") or {}])
            if refs and pick.get("quote"):
                content = {**content, "quote": pick["quote"][:380], "source": refs[0]}
                break
        else:
            raise RuntimeError("No verifiable quote found")
    source = content.get("source") or {}
    doc = db.scalar(select(Document).where(Document.workspace_id == artifact.workspace_id,
                                           Document.path == source.get("path")))
    publication = None
    if doc and doc.source_id:
        src = db.get(Source, doc.source_id)
        publication = src.title if src else None
    props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id)}
    if publication or (doc and doc.title):
        props["publication"] = (publication or doc.title)[:120]
    artifact.content_json = {**content, "composition": "QuoteCard"}
    artifact.status = "rendering"
    db.commit()
    request_render(job, artifact, "QuoteCard", props)


def render_carousel(db: Session, job: Job, artifact: Artifact) -> None:
    slides = (artifact.content_json or {}).get("slides") or []
    if not slides:
        raise PermanentJobError("This carousel has no slides.")
    stills = [{"heading": s.get("heading", "")[:120], "body": s.get("body", "")[:500], "index": i + 1,
               "total": len(slides)} for i, s in enumerate(slides)]
    artifact.status = "rendering"
    db.commit()
    request_render(job, artifact, "CarouselSlide", stills=stills)