File size: 5,764 Bytes
34b0226
 
 
bcfc897
 
34b0226
 
bcfc897
34b0226
 
 
 
 
 
bcfc897
34b0226
 
bcfc897
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bcfc897
 
34b0226
bcfc897
 
 
 
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
"""Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
the post's own claims."""

import logging

from sqlalchemy.orm import Session

from app.config import settings
from app.corpus import INDEX, Corpus
from app.llm import run
from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
from app.models import Artifact, Document, Job
from app.pipeline.generate import NothingToDo
from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
from app.services import renderer
from app.services.jobs import create_job, update_job

log = logging.getLogger(__name__)

PLATFORMS = {
    "x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
                 "No hashtags except at most one at the end. The last post links to the article with {url}."),
    "linkedin": ("LinkedIn", "One post, 900 to 1,500 characters. Short paragraphs of 1 to 2 lines. Open with a "
                 "concrete line, not a question. End with the link {url} and one question for comments."),
    "substack_notes": ("Substack Notes", "3 separate notes, each 1 to 4 sentences, conversational, each able "
                       "to stand alone. One of them links to {url}."),
    "bluesky": ("Bluesky", "A thread of 3 to 6 posts, each under 290 characters. Plain, human, no hashtags. "
                "The last post links to {url}."),
}


def build_launch_kit(db: Session, job: Job, artifact: Artifact) -> dict:
    doc = db.get(Document, artifact.document_id) if artifact.document_id else None
    if not doc or not doc.path:
        raise NothingToDo("Pick a post for the Launch Kit.")
    ws = artifact.workspace_id
    corpus = Corpus(ws)
    passages = passages_for(corpus, [doc], budget_chars=50_000)
    tools = tools_for(corpus, [doc])
    voice = voice_text(db, ws)
    url = doc.url if doc.url.startswith("http") else ""

    def step(progress: float, message: str) -> None:
        update_job(db, job, progress=progress, message=message)

    step(0.08, "Pulling out the key claims")
    claims_out = run.predict(ExtractClaims, db=db, workspace_id=ws, job=job, passages=passages)
    claims = []
    for c in claims_out.get("claims") or []:
        refs = verify_refs(tools, c.get("sources") or [])
        if refs and c.get("claim"):
            claims.append({"claim": c["claim"], "sources": refs})
    if not claims:
        raise RuntimeError("No verifiable claims found in this post")
    claim_inputs = [{"claim": c["claim"], "sources": [{k: s[k] for k in ("path", "line_start", "line_end")}
                                                      for s in c["sources"]]} for c in claims]

    step(0.2, "Writing hooks")
    hooks = run.predict(HookGenerator, db=db, workspace_id=ws, job=job, passages=passages,
                        voice_profile=voice, n=8).get("hooks") or []
    hooks = sorted(hooks, key=lambda h: -(h.get("strength") or 0))

    posts: dict[str, list[str]] = {}
    for i, (platform, (label, rules)) in enumerate(PLATFORMS.items()):
        step(0.3 + 0.1 * i, f"Adapting for {label}")
        out = run.predict(PlatformAdapter, db=db, workspace_id=ws, job=job, claims=claim_inputs, voice_profile=voice,
                          platform=platform, platform_rules=rules.format(url=url or "the post"))
        posts[platform] = [p for p in (out.get("posts") or []) if p.strip()]

    step(0.72, "Building the SEO pack")
    related = []
    if corpus.exists(INDEX):
        related = [ln for ln in corpus.read_lines(INDEX) if "|" in ln and doc.path not in ln][:150]
    seo = run.predict(SeoPack, db=db, workspace_id=ws, job=job, title=doc.title, passages=passages,
                      related_posts="\n".join(related)).get("seo") or {}

    step(0.82, "Designing the carousel")
    slides = run.predict(CarouselSlides, db=db, workspace_id=ws, job=job, passages=passages,
                         voice_profile=voice).get("slides") or []

    step(0.9, "Choosing quotes")
    quotes = []
    for pick in run.predict(PickQuotes, db=db, workspace_id=ws, job=job, passages=passages, n=3).get("quotes") or []:
        refs = verify_refs(tools, [pick.get("source") or {}])
        if refs and pick.get("quote"):
            quotes.append({"quote": pick["quote"][:380], "source": refs[0]})

    artifact.content_json = {
        **(artifact.content_json or {}),
        "title": f"Launch Kit: {doc.title}",
        "post_title": doc.title,
        "post_url": doc.url,
        "claims": claims,
        "hooks": hooks,
        "posts": posts,
        "seo": seo,
        "carousel": slides,
        "quotes": quotes,
    }
    artifact.status = "ready"
    db.commit()

    # Quote cards render as their own artifacts, so a missing renderer does not sink the kit. With the renderer
    # down they would only fail, so skip them: the kit keeps its quotes as text either way.
    card_ids = []
    if quotes and not renderer.available():
        log.warning("Renderer unreachable at %s: launch kit %s made without quote cards",
                    settings.renderer_url, artifact.id)
        quotes = []
    for q in quotes:
        card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
                        content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
        db.add(card)
        db.flush()
        create_job(db, ws, "quote_card", {"artifact_id": str(card.id)}, artifact_id=card.id, max_attempts=1)
        card_ids.append(str(card.id))
    artifact.content_json = {**artifact.content_json, "quote_card_ids": card_ids}
    db.commit()
    return {"artifact_id": str(artifact.id), "claims": len(claims)}