Spaces:
Runtime error
Runtime error
File size: 11,400 Bytes
55d0eca | 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 | """The plan of a themed report, made before any of it is written.
One model call (PlanReportBlueprint) sees the themes with their weights and the reader's request, never the posts, and plans the
whole report: a storyline, the sections in order (what each is about, how deep, what it must cover), and which section bridges which
themes. Code then checks the plan against the weights, because a plan that forgets a major theme or gives a footnote the room of a
main theme is wrong however well it reads: every major or asked-for theme gets a section, depth follows the theme's tier, a body
theme belongs to one section, and what other sections own is worked out here, not left to the model."""
import logging
import time
import uuid
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from app.llm import run
from app.llm.provider import report_plan_lm
from app.llm.signatures import PlanReportBlueprint
from app.models import Job
from app.pipeline.report_themes import Theme
log = logging.getLogger(__name__)
MAX_SECTIONS = 8
MAX_CONNECTIONS = 6
ROLES = ("intro", "body", "synthesis", "summary")
MORE_HEADING = "More to know" # where the themes no section took are gathered
class BlueprintUnavailable(Exception):
"""The plan was not usable: the caller reads the posts as before."""
@dataclass
class Section:
heading: str
role: str
brief: str
theme_ids: list[str]
depth: str = "standard"
must_cover: list[str] = field(default_factory=list)
embed: dict | None = None
covered_elsewhere: list[str] = field(default_factory=list)
@dataclass
class Blueprint:
title: str
storyline: str
sections: list[Section]
connections: list[dict] # {"theme_ids": [...], "relation": str, "section": heading}
def theme_lines(themes: list[Theme]) -> list[str]:
"""The themes as the planner reads them: id | name | tier | weight | posts | asked for: summary, then a few of their ideas."""
return [f"{t.id} | {t.name} | {t.tier} | {t.weight:.2f} | {len(t.doc_ids)} posts | "
f"{'asked for' if t.pinned else 'not asked for'}: {t.summary} (e.g. {'; '.join(i.label for i in t.ideas[:3])})"
for t in themes]
def relation_lines(relations: list[dict]) -> list[str]:
return [f"{r['a']} <-> {r['b']}: {r['relation']}" for r in relations]
def derived_depth(section: Section, by_id: dict[str, Theme]) -> str:
"""How deep a section goes follows its heaviest theme, whatever the model wrote: the weights are the plan's reason to exist."""
if section.role in ("intro", "summary"):
return "brief"
tiers = {by_id[i].tier + ("!" if by_id[i].pinned else "") for i in section.theme_ids if i in by_id}
if any(t.startswith("major") or t.endswith("!") for t in tiers):
return "deep"
if section.role == "synthesis" or any(t.startswith("medium") for t in tiers):
return "standard"
return "brief"
def _first_index(sections: list[Section], role: str) -> int | None:
return next((n for n, s in enumerate(sections) if s.role == role), None)
def _in_reading_order(sections: list[Section]) -> list[Section]:
"""The intro first, the summary last, whatever order the model gave them in. One of each."""
intro = [s for s in sections if s.role == "intro"][:1]
summary = [s for s in sections if s.role == "summary"][-1:]
return intro + [s for s in sections if s.role in ("body", "synthesis")] + summary
def repair(bp: Blueprint, themes: list[Theme]) -> Blueprint:
"""Make the plan agree with the weights. Raises BlueprintUnavailable when fewer than two sections remain."""
by_id = {t.id: t for t in themes}
sections = [s for s in bp.sections if s.heading.strip()]
planned = len(sections)
# A body theme is the subject of one section; unknown ids go.
seen: set[str] = set()
for s in sections:
s.theme_ids = [i for n, i in enumerate(s.theme_ids) if i in by_id and i not in s.theme_ids[:n]]
if s.role == "body":
s.theme_ids = [i for i in s.theme_ids if i not in seen]
seen.update(s.theme_ids)
before = len(sections)
sections = [s for s in sections if s.role != "body" or s.theme_ids]
if len(sections) < before:
log.info("report blueprint: dropped %d body sections with no theme of their own", before - len(sections))
sections = _in_reading_order(sections)
def insert(section: Section) -> None:
at = _first_index(sections, "summary")
sections.insert(len(sections) if at is None else at, section)
# Every major or asked-for theme has a section; whatever else no section took is gathered into one.
for t in sorted(themes, key=lambda t: -t.weight):
if t.id not in seen and (t.tier == "major" or t.pinned):
log.info("report blueprint: no section for %s theme %s (%s), added one", "asked-for" if t.pinned else t.tier, t.id,
t.name)
insert(Section(heading=t.name, role="body", brief=t.summary, theme_ids=[t.id], depth="deep"))
seen.add(t.id)
rest = [t.id for t in sorted(themes, key=lambda t: -t.weight) if t.id not in seen]
if rest:
log.info("report blueprint: themes in no section %s go to %r", rest, MORE_HEADING)
more = next((s for s in sections if s.role == "body" and s.heading == MORE_HEADING), None)
if more:
more.theme_ids += rest
else:
insert(Section(heading=MORE_HEADING, role="body", theme_ids=rest, depth="brief",
brief="The smaller themes in the posts, in a line or two each."))
seen.update(rest)
# Too many sections: the lightest body sections (never one holding a major or asked-for theme) are gathered into one brief
# section, so a footnote never shares a section with a main theme.
def weight(s: Section) -> float:
return sum(by_id[i].weight for i in s.theme_ids)
def protected(s: Section) -> bool:
return any(by_id[i].tier == "major" or by_id[i].pinned for i in s.theme_ids)
while len(sections) > MAX_SECTIONS:
more = next((s for s in sections if s.role == "body" and s.heading == MORE_HEADING), None)
candidates = [s for s in sections if s.role == "body" and not protected(s) and s is not more]
if not candidates:
break
lightest = min(candidates, key=weight)
log.info("report blueprint: %d sections, over the %d allowed: merging %r (themes %s) into %r", len(sections),
MAX_SECTIONS, lightest.heading, lightest.theme_ids, MORE_HEADING)
if more:
more.theme_ids += lightest.theme_ids
sections.remove(lightest)
else:
lightest.heading, lightest.must_cover = MORE_HEADING, []
lightest.brief = "The smaller themes in the posts, in a line or two each."
# intro first, summary last, depth from the weights, the summary restates the main themes.
sections = _in_reading_order(sections)
for s in [s for s in sections if s.role == "summary"]:
s.theme_ids = [t.id for t in themes if t.tier == "major" or t.pinned] or s.theme_ids
for s in sections:
s.depth = derived_depth(s, by_id)
if len(sections) < 2:
log.warning("report blueprint unusable: sections=%d planned=%d", len(sections), planned)
raise BlueprintUnavailable("the plan has fewer than two sections")
# What the other body sections own, so no section writes another's points. The intro and summary may touch everything.
for s in sections:
s.covered_elsewhere = [] if s.role in ("intro", "summary") else [
f"{o.heading}: {'; '.join(o.must_cover) or o.brief}"[:240] for o in sections
if o is not s and o.role in ("body", "synthesis")]
headings = {s.heading.lower(): s.heading for s in sections}
connections = []
for c in bp.connections:
ids = [i for i in c.get("theme_ids") or [] if i in by_id]
relation = (c.get("relation") or "").strip()
if len(ids) < 2 or not relation:
continue
home = headings.get(str(c.get("section", "")).strip().lower()) or next(
(s.heading for s in sections if s.role in ("body", "synthesis") and set(ids) & set(s.theme_ids)), None)
if home:
connections.append({"theme_ids": ids, "relation": relation[:200], "section": home})
log.info("report blueprint checked: planned=%d final=%d connections=%d/%d", planned, len(sections), len(connections),
len(bp.connections))
for n, s in enumerate(sections, start=1):
log.info("report section %d: heading=%r role=%s depth=%s themes=%s must_cover=%d embed=%s", n, s.heading, s.role, s.depth,
s.theme_ids, len(s.must_cover), (s.embed or {}).get("kind"))
return Blueprint(title=bp.title, storyline=bp.storyline, sections=sections, connections=connections[:MAX_CONNECTIONS])
def plan_blueprint(db: Session, job: Job | None, workspace_id: uuid.UUID, title: str, request: str,
themes: list[Theme], relations: list[dict], kinds: list[str], language: str) -> Blueprint:
"""The checked plan for these themes. Raises BlueprintUnavailable (or whatever the model call raised) when there is none."""
log.info("report blueprint: asking the model workspace=%s themes=%d relations=%d allowed_visuals=%s", workspace_id, len(themes),
len(relations), kinds)
started = time.monotonic()
out = run.predict(PlanReportBlueprint, db=db, workspace_id=workspace_id, job=job, lm=report_plan_lm(), title=title,
request=request, themes=theme_lines(themes), relations=relation_lines(relations), allowed_kinds=kinds,
language=language, max_sections=MAX_SECTIONS)
sections = []
for s in out.get("sections") or []:
heading = (s.get("heading") or "").strip()
if heading:
sections.append(Section(
heading=heading, role=s.get("role") if s.get("role") in ROLES else "body", brief=(s.get("brief") or "").strip(),
theme_ids=[str(i) for i in s.get("theme_ids") or []], depth=s.get("depth") or "standard",
must_cover=[str(p).strip() for p in s.get("must_cover") or [] if str(p).strip()][:4], embed=s.get("embed")))
log.info("report blueprint: model answered sections=%d connections=%d seconds=%.1f", len(sections),
len(out.get("connections") or []), time.monotonic() - started)
bp = Blueprint(title=(out.get("report_title") or title).strip(), storyline=(out.get("storyline") or "").strip(),
sections=sections, connections=out.get("connections") or [])
return repair(bp, themes)
def outline(bp: Blueprint) -> list[str]:
"""Every section as every writer sees it: number, heading, role, what it says."""
return [f"{n}. {s.heading} ({s.role}): {s.brief}" for n, s in enumerate(bp.sections, start=1)]
def connection_lines(bp: Blueprint, themes: list[Theme], heading: str | None = None) -> list[str]:
"""The connections (all of them, or the ones one section bridges) as 'Pricing + Hiring: how they connect (section)'."""
names = {t.id: t.name for t in themes}
return [f"{' + '.join(names[i] for i in c['theme_ids'])}: {c['relation']} (bridged in: {c['section']})"
for c in bp.connections if heading is None or c["section"] == heading]
|