content-foundry 1.0.0__py3-none-any.whl

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 (122) hide show
  1. content_foundry/__init__.py +4 -0
  2. content_foundry/agents/__init__.py +36 -0
  3. content_foundry/agents/brainstorm.py +136 -0
  4. content_foundry/agents/broll_director.py +93 -0
  5. content_foundry/agents/data_fetcher.py +114 -0
  6. content_foundry/agents/distill.py +108 -0
  7. content_foundry/agents/idea_miner.py +214 -0
  8. content_foundry/agents/instruction_planner.py +89 -0
  9. content_foundry/agents/judge.py +437 -0
  10. content_foundry/agents/judge_checks.py +337 -0
  11. content_foundry/agents/publisher.py +191 -0
  12. content_foundry/agents/renderer.py +146 -0
  13. content_foundry/agents/research.py +265 -0
  14. content_foundry/agents/scene_image_director.py +69 -0
  15. content_foundry/agents/script_generator.py +910 -0
  16. content_foundry/agents/thumbnail_director.py +80 -0
  17. content_foundry/agents/visuals.py +956 -0
  18. content_foundry/agents/voiceover.py +127 -0
  19. content_foundry/cli.py +524 -0
  20. content_foundry/config.py +797 -0
  21. content_foundry/data/sounds/apple_notification.mp3 +0 -0
  22. content_foundry/data/sounds/awww.mp3 +0 -0
  23. content_foundry/data/sounds/bell.mp3 +0 -0
  24. content_foundry/data/sounds/camera_flash.mp3 +0 -0
  25. content_foundry/data/sounds/camera_shutter.mp3 +0 -0
  26. content_foundry/data/sounds/cash_register.mp3 +0 -0
  27. content_foundry/data/sounds/click.mp3 +0 -0
  28. content_foundry/data/sounds/game_menu.mp3 +0 -0
  29. content_foundry/data/sounds/pop.mp3 +0 -0
  30. content_foundry/data/sounds/whoosh.mp3 +0 -0
  31. content_foundry/data/sounds/wrong_answer.mp3 +0 -0
  32. content_foundry/datasources/__init__.py +8 -0
  33. content_foundry/datasources/adzuna.py +98 -0
  34. content_foundry/datasources/base.py +22 -0
  35. content_foundry/datasources/bls.py +66 -0
  36. content_foundry/datasources/layoffs.py +73 -0
  37. content_foundry/datasources/news.py +70 -0
  38. content_foundry/datasources/registry.py +100 -0
  39. content_foundry/datasources/search.py +359 -0
  40. content_foundry/errors.py +61 -0
  41. content_foundry/logging.py +88 -0
  42. content_foundry/models/__init__.py +50 -0
  43. content_foundry/models/data_brief.py +44 -0
  44. content_foundry/models/ideas.py +53 -0
  45. content_foundry/models/judge_report.py +47 -0
  46. content_foundry/models/provenance.py +27 -0
  47. content_foundry/models/publish.py +30 -0
  48. content_foundry/models/research.py +42 -0
  49. content_foundry/models/run.py +62 -0
  50. content_foundry/models/script.py +48 -0
  51. content_foundry/models/signals.py +34 -0
  52. content_foundry/models/video.py +24 -0
  53. content_foundry/models/visuals.py +42 -0
  54. content_foundry/models/voiceover.py +35 -0
  55. content_foundry/notifications/__init__.py +16 -0
  56. content_foundry/notifications/base.py +20 -0
  57. content_foundry/notifications/credit_monitor.py +73 -0
  58. content_foundry/notifications/factory.py +42 -0
  59. content_foundry/notifications/telegram.py +22 -0
  60. content_foundry/persistence/__init__.py +9 -0
  61. content_foundry/persistence/db.py +50 -0
  62. content_foundry/persistence/repository.py +239 -0
  63. content_foundry/persistence/schema.py +142 -0
  64. content_foundry/pipeline/__init__.py +22 -0
  65. content_foundry/pipeline/artifacts.py +207 -0
  66. content_foundry/pipeline/orchestrator.py +1110 -0
  67. content_foundry/pipeline/package.py +96 -0
  68. content_foundry/pipeline/stages.py +32 -0
  69. content_foundry/pipeline/topic_relevance.py +85 -0
  70. content_foundry/production/__init__.py +1 -0
  71. content_foundry/production/affiliate.py +497 -0
  72. content_foundry/production/captions.py +98 -0
  73. content_foundry/production/end_screen.py +146 -0
  74. content_foundry/production/like_nudge.py +141 -0
  75. content_foundry/production/overlay.py +60 -0
  76. content_foundry/production/seo.py +244 -0
  77. content_foundry/production/sound_design.py +67 -0
  78. content_foundry/production/subscribe.py +129 -0
  79. content_foundry/production/timebox.py +48 -0
  80. content_foundry/production/timeline.py +48 -0
  81. content_foundry/prompts/__init__.py +30 -0
  82. content_foundry/prompts/affiliate_queries.system.txt +18 -0
  83. content_foundry/prompts/brainstorm.system.txt +26 -0
  84. content_foundry/prompts/broll_director.system.txt +69 -0
  85. content_foundry/prompts/instruction_planner.system.txt +45 -0
  86. content_foundry/prompts/judge.rubric.txt +53 -0
  87. content_foundry/prompts/judge.system.txt +62 -0
  88. content_foundry/prompts/research.system.txt +34 -0
  89. content_foundry/prompts/scene_image_director.system.txt +75 -0
  90. content_foundry/prompts/script_generator.system.txt +296 -0
  91. content_foundry/prompts/thumbnail_director.system.txt +9 -0
  92. content_foundry/providers/__init__.py +257 -0
  93. content_foundry/providers/anthropic_provider.py +64 -0
  94. content_foundry/providers/base.py +103 -0
  95. content_foundry/providers/broll.py +347 -0
  96. content_foundry/providers/faceid.py +181 -0
  97. content_foundry/providers/faceswap.py +261 -0
  98. content_foundry/providers/fallback.py +70 -0
  99. content_foundry/providers/google_provider.py +115 -0
  100. content_foundry/providers/image.py +193 -0
  101. content_foundry/providers/local_provider.py +72 -0
  102. content_foundry/providers/openai_provider.py +64 -0
  103. content_foundry/providers/render_backend.py +509 -0
  104. content_foundry/providers/sfx.py +107 -0
  105. content_foundry/providers/text_normalize.py +87 -0
  106. content_foundry/providers/tiering.py +26 -0
  107. content_foundry/providers/tts.py +519 -0
  108. content_foundry/providers/youtube.py +230 -0
  109. content_foundry/providers/youtube_data.py +183 -0
  110. content_foundry/py.typed +0 -0
  111. content_foundry/safeguards/__init__.py +29 -0
  112. content_foundry/safeguards/disclosure.py +65 -0
  113. content_foundry/safeguards/grounding.py +65 -0
  114. content_foundry/scheduler.py +40 -0
  115. content_foundry/templates/__init__.py +81 -0
  116. content_foundry/templates/definitions.py +108 -0
  117. content_foundry-1.0.0.dist-info/METADATA +176 -0
  118. content_foundry-1.0.0.dist-info/RECORD +122 -0
  119. content_foundry-1.0.0.dist-info/WHEEL +5 -0
  120. content_foundry-1.0.0.dist-info/entry_points.txt +2 -0
  121. content_foundry-1.0.0.dist-info/licenses/LICENSE +21 -0
  122. content_foundry-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ """Content Foundry — a resumable multi-agent pipeline that researches, writes, judges, narrates, and
2
+ renders short-form YouTube videos for any niche."""
3
+
4
+ __version__ = "1.0.0"
@@ -0,0 +1,36 @@
1
+ """The seven agents (each owns exactly one transformation; no agent imports another)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .brainstorm import Brainstormer
6
+ from .broll_director import BrollDirector
7
+ from .data_fetcher import DataFetcher
8
+ from .idea_miner import IdeaMiner
9
+ from .instruction_planner import InstructionPlanner
10
+ from .judge import Judge
11
+ from .publisher import Publisher
12
+ from .renderer import Renderer
13
+ from .research import Researcher
14
+ from .scene_image_director import SceneImageDirector
15
+ from .script_generator import ScriptGenerator
16
+ from .thumbnail_director import ThumbnailDirector
17
+ from .visuals import Visuals, build_image_prompt
18
+ from .voiceover import Voiceover
19
+
20
+ __all__ = [
21
+ "DataFetcher",
22
+ "Brainstormer",
23
+ "IdeaMiner",
24
+ "InstructionPlanner",
25
+ "Researcher",
26
+ "BrollDirector",
27
+ "ScriptGenerator",
28
+ "ThumbnailDirector",
29
+ "SceneImageDirector",
30
+ "Judge",
31
+ "Voiceover",
32
+ "Visuals",
33
+ "build_image_prompt",
34
+ "Renderer",
35
+ "Publisher",
36
+ ]
@@ -0,0 +1,136 @@
1
+ """Agent 0 — Brainstormer. Proposes several fresh, specific, helpful video ideas from the brief so
2
+ runs don't collapse onto the same topic. LLM-driven with a deterministic content-angle fallback."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ from ..errors import LLMError
9
+ from ..logging import get_logger
10
+ from ..models import DataBrief
11
+ from ..prompts import load_prompt, render_prompt
12
+ from ..providers.base import LLMProvider, extract_json
13
+ from ..providers.tiering import TaskTier, select_model
14
+
15
+
16
+ class Brainstormer:
17
+ def __init__(self, settings, llm_provider: LLMProvider):
18
+ self._settings = settings
19
+ self._llm = llm_provider
20
+ self._log = get_logger(component="brainstorm")
21
+
22
+ def propose(
23
+ self, brief: DataBrief, *, recent_ideas: list[str] | None = None, count: int = 5,
24
+ focus: str = "",
25
+ ) -> list[str]:
26
+ """Propose ``count`` distinct, specific video ideas. ``focus`` (e.g. the user's --idea) steers
27
+ every idea. The fetched data is used only as INSPIRATION / supporting stats, never the whole
28
+ basis. Falls back to deterministic content angles when the LLM is unavailable."""
29
+ recent = [r for r in (recent_ideas or []) if r]
30
+ fallback = self._fallback(brief, recent, focus, count)
31
+ try:
32
+ system = render_prompt(
33
+ load_prompt("brainstorm.system"),
34
+ niche=brief.niche,
35
+ count=str(count),
36
+ focus_line=(
37
+ f'The viewer specifically asked for: "{focus}". EVERY idea MUST be a concrete, '
38
+ "specific angle that delivers on this." if focus else ""
39
+ ),
40
+ facts_json=json.dumps([kf.statement for kf in brief.key_facts], ensure_ascii=False),
41
+ avoid_json=json.dumps(recent[:8], ensure_ascii=False),
42
+ )
43
+ resp = self._llm.complete(
44
+ "Return ONLY the JSON array now.",
45
+ system=system,
46
+ temperature=max(self._settings.llm_temperature, 0.8),
47
+ # A generous cap: reasoning models (e.g. Gemini flash) spend part of the budget
48
+ # THINKING, and a tight cap (700) left no room for the JSON -> empty/unusable reply.
49
+ max_tokens=self._settings.llm_max_tokens,
50
+ model=select_model(
51
+ self._settings, TaskTier.HEAVY, fallback=self._settings.generator_model
52
+ ),
53
+ )
54
+ ideas = _parse_ideas(resp.text)
55
+ if ideas:
56
+ self._log.info("brainstormed_ideas", count=len(ideas))
57
+ return ideas[:count]
58
+ except (json.JSONDecodeError, LLMError, KeyError, ValueError, AttributeError, TypeError) as exc:
59
+ self._log.warning("brainstorm_fallback", error=str(exc))
60
+ return fallback
61
+
62
+ def run(self, brief: DataBrief, *, recent_ideas: list[str] | None = None) -> str:
63
+ """Back-compat single-idea helper."""
64
+ ideas = self.propose(brief, recent_ideas=recent_ideas, count=1)
65
+ return ideas[0] if ideas else ""
66
+
67
+ def _fallback(self, brief: DataBrief, recent: list[str], focus: str, count: int) -> list[str]:
68
+ """Deterministic: clean focus-based angles (or the brief's content angles when there is no
69
+ focus), skipping recently-made ones."""
70
+ pool: list[str] = []
71
+ if focus:
72
+ f = focus.strip().rstrip(".")
73
+ low = (f[:1].lower() + f[1:]) if f else f
74
+ pool += [
75
+ f"{f}: a practical step-by-step guide",
76
+ f"{f}: the mistakes that keep you stuck",
77
+ f"{f}: what actually works, backed by the data",
78
+ f"The truth about {low}",
79
+ f"{f}: what the {brief.niche} numbers really say",
80
+ f"{f} in 30 days: a realistic plan",
81
+ f"{f}: the first moves to make right now",
82
+ ]
83
+ pool += [a.hook for a in brief.content_angles]
84
+ if not pool:
85
+ pool = [f"A specific, data-backed {brief.niche} explainer that solves one real problem"]
86
+ recent_l = " ".join(recent).lower()
87
+ fresh = [h for h in _dedup(pool) if h[:25].lower() not in recent_l]
88
+ return (fresh or _dedup(pool))[:count]
89
+
90
+
91
+ def _dedup(items) -> list[str]:
92
+ """Order-preserving de-dup + trim; coerces non-strings to str."""
93
+ seen: set[str] = set()
94
+ out: list[str] = []
95
+ for item in items:
96
+ text = (item if isinstance(item, str) else str(item)).strip()
97
+ if text and text.lower() not in seen:
98
+ seen.add(text.lower())
99
+ out.append(text)
100
+ return out
101
+
102
+
103
+ def _slice_array(text: str) -> str:
104
+ """Slice the first ``[`` to the last ``]`` (recovers a JSON array wrapped in prose)."""
105
+ start, end = text.find("["), text.rfind("]")
106
+ return text[start : end + 1] if start != -1 and end > start else ""
107
+
108
+
109
+ def _parse_ideas(text: str) -> list[str]:
110
+ """Robustly pull the list of idea strings from an LLM reply. Handles a bare JSON array, an object
111
+ ``{"ideas": [...]}``, an array of objects, or an array wrapped in prose. NOTE: a plain
112
+ ``extract_json`` MANGLES a top-level array (it slices first ``{`` to last ``}``), so we try a
113
+ direct parse and an array-slice too."""
114
+ raw = (text or "").strip()
115
+ data = None
116
+ for candidate in (raw, extract_json(raw), _slice_array(raw)):
117
+ if not candidate:
118
+ continue
119
+ try:
120
+ data = json.loads(candidate)
121
+ break
122
+ except (json.JSONDecodeError, ValueError):
123
+ continue
124
+ if isinstance(data, dict):
125
+ data = data.get("ideas") or data.get("items") or data.get("titles") or []
126
+ if not isinstance(data, list):
127
+ return []
128
+ out: list[str] = []
129
+ for item in data:
130
+ if isinstance(item, str):
131
+ out.append(item)
132
+ elif isinstance(item, dict): # array of objects -> use the most title-like field
133
+ val = item.get("title") or item.get("idea") or item.get("angle") or item.get("text")
134
+ if isinstance(val, str):
135
+ out.append(val)
136
+ return _dedup(out)
@@ -0,0 +1,93 @@
1
+ """Agent 5.5 — B-roll Director. Leverages the LLM's whole-script context (e.g. Gemini) to rewrite each
2
+ scene's B-roll search queries so the stock footage is both RELEVANT to what the narrator says AND
3
+ visually DIVERSE across the whole video (no repeated shots). Runs in the visuals stage, gated by
4
+ BROLL_DIRECTOR_ENABLED. Best-effort: any failure keeps the script generator's original keywords.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from ..errors import LLMError
12
+ from ..logging import get_logger
13
+ from ..models import Script
14
+ from ..prompts import load_prompt, render_prompt
15
+ from ..providers.base import LLMProvider, extract_json
16
+ from ..providers.tiering import TaskTier, select_model
17
+
18
+
19
+ class BrollDirector:
20
+ def __init__(self, settings, llm_provider: LLMProvider):
21
+ self._settings = settings
22
+ self._llm = llm_provider
23
+ self._log = get_logger(component="broll_director")
24
+
25
+ def run(self, script: Script) -> Script:
26
+ """Rewrite each scene's ``b_roll_keywords`` in place with relevant, diverse queries. A no-op
27
+ when disabled, the script has no scenes, or the LLM output is unusable."""
28
+ if not self._settings.broll_director_enabled or not script.scenes:
29
+ return script
30
+ try:
31
+ queries = self._direct(script)
32
+ except (json.JSONDecodeError, LLMError, ValueError, AttributeError, TypeError) as exc:
33
+ self._log.warning("broll_director_failed", error=str(exc))
34
+ return script
35
+ applied = 0
36
+ for scene in script.scenes:
37
+ directed = queries.get(scene.index)
38
+ if directed:
39
+ scene.b_roll_keywords = directed
40
+ applied += 1
41
+ if applied:
42
+ self._log.info("broll_directed", scenes=applied)
43
+ return script
44
+
45
+ def _direct(self, script: Script) -> dict[int, list[str]]:
46
+ # Hand the model the WHOLE script (every scene's narration, the on-screen stat/callout, and
47
+ # the generator's first-draft keywords) so it can keep each shot relevant to what is said AND
48
+ # deliberately distinct across scenes.
49
+ scenes_json = json.dumps(
50
+ [
51
+ {
52
+ "index": s.index,
53
+ "narration": s.narration,
54
+ "on_screen": s.on_screen_text or "",
55
+ "current_keywords": s.b_roll_keywords,
56
+ }
57
+ for s in script.scenes
58
+ ],
59
+ ensure_ascii=False,
60
+ )
61
+ model = select_model(
62
+ self._settings, TaskTier.LIGHT, fallback=self._settings.generator_model
63
+ )
64
+ system = render_prompt(
65
+ load_prompt("broll_director.system"),
66
+ max_queries=str(self._settings.broll_director_max_queries),
67
+ scenes_json=scenes_json,
68
+ )
69
+ resp = self._llm.complete(
70
+ "Return ONLY the JSON now.",
71
+ system=system,
72
+ temperature=0.4,
73
+ max_tokens=self._settings.llm_max_tokens,
74
+ model=model,
75
+ )
76
+ # The model may return a bare array or a {"scenes": [...]} object; extract_json only recovers
77
+ # objects, so try a direct parse first, then fall back to it.
78
+ try:
79
+ data = json.loads(resp.text.strip())
80
+ except json.JSONDecodeError:
81
+ data = json.loads(extract_json(resp.text))
82
+ items = data.get("scenes") if isinstance(data, dict) else data
83
+ out: dict[int, list[str]] = {}
84
+ for item in items or []:
85
+ if not isinstance(item, dict):
86
+ continue
87
+ idx = item.get("index")
88
+ queries = [
89
+ q.strip() for q in (item.get("queries") or []) if isinstance(q, str) and q.strip()
90
+ ]
91
+ if idx is not None and queries:
92
+ out[int(idx)] = queries[: self._settings.broll_director_max_queries]
93
+ return out
@@ -0,0 +1,114 @@
1
+ """Agent 1 — Data Fetcher. Fetch + rank signals, then deterministically distill (Ch. 7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+
8
+ from ..datasources import DataSource, build_sources
9
+ from ..errors import DataSourceError, InsufficientDataError, NoDataError
10
+ from ..logging import get_logger
11
+ from ..models import DataBrief, NormalizedSignal, Provenance
12
+ from . import distill
13
+
14
+
15
+ class DataFetcher:
16
+ """Orchestrates source fetching (with cache) and deterministic distillation. No LLM."""
17
+
18
+ def __init__(self, settings, repository=None, sources: Sequence[DataSource] | None = None):
19
+ self._settings = settings
20
+ self._repo = repository
21
+ self._sources = sources
22
+ self._log = get_logger(component="data_fetcher")
23
+
24
+ def run(self, run_id: str, *, niche: str, topic_seed: str | None = None) -> DataBrief:
25
+ sources = list(self._sources) if self._sources is not None else build_sources(
26
+ self._settings, niche=niche, topic_seed=topic_seed
27
+ )
28
+ if not sources:
29
+ raise NoDataError("No data sources are enabled/configured.")
30
+
31
+ coverage: dict[str, bool] = {}
32
+ gaps: list[str] = []
33
+ all_signals: list[NormalizedSignal] = []
34
+
35
+ for source in sources:
36
+ try:
37
+ signals = self._fetch_with_cache(source)
38
+ except DataSourceError as exc:
39
+ self._log.warning("source_failed", source=source.name, error=str(exc))
40
+ coverage[source.name] = False
41
+ gaps.append(f"{source.name}: {exc}")
42
+ continue
43
+ coverage[source.name] = bool(signals)
44
+ if not signals:
45
+ gaps.append(f"{source.name}: no signals returned")
46
+ all_signals.extend(signals)
47
+
48
+ if not all_signals:
49
+ raise NoDataError("All data sources failed or returned nothing.")
50
+
51
+ ranked = self._rank(self._dedup(all_signals), niche=niche, topic_seed=topic_seed)
52
+ key_facts = distill.build_key_facts(ranked, limit=self._settings.max_facts)
53
+ if len(key_facts) < self._settings.min_facts:
54
+ raise InsufficientDataError(
55
+ f"Only {len(key_facts)} grounded facts (< MIN_FACTS={self._settings.min_facts})."
56
+ )
57
+ angles = distill.build_angles(ranked)
58
+
59
+ return DataBrief(
60
+ run_id=run_id,
61
+ niche=niche,
62
+ topic_seed=topic_seed,
63
+ key_facts=key_facts,
64
+ content_angles=angles,
65
+ coverage=coverage,
66
+ gaps=gaps,
67
+ provenance=Provenance(
68
+ produced_by="data_fetcher", model=None, config_hash=self._settings.config_hash
69
+ ),
70
+ )
71
+
72
+ # ------------------------------------------------------------------ internals
73
+ def _fetch_with_cache(self, source: DataSource) -> list[NormalizedSignal]:
74
+ ttl = self._settings.signal_cache_ttl_min
75
+ if self._repo is not None:
76
+ cached = self._repo.get_cached_signals(source.name, ttl)
77
+ if cached is not None:
78
+ self._log.info("cache_hit", source=source.name, count=len(cached))
79
+ return [NormalizedSignal.model_validate(c) for c in cached]
80
+
81
+ signals = source.fetch()
82
+ if self._repo is not None and signals:
83
+ self._repo.put_cached_signals(
84
+ source.name, [s.model_dump(mode="json") for s in signals]
85
+ )
86
+ return signals
87
+
88
+ @staticmethod
89
+ def _dedup(signals: Sequence[NormalizedSignal]) -> list[NormalizedSignal]:
90
+ seen: set[tuple] = set()
91
+ out: list[NormalizedSignal] = []
92
+ for s in signals:
93
+ key = (s.source, s.kind, s.title, s.value)
94
+ if key in seen:
95
+ continue
96
+ seen.add(key)
97
+ out.append(s)
98
+ return out
99
+
100
+ @staticmethod
101
+ def _rank(
102
+ signals: Sequence[NormalizedSignal], *, niche: str, topic_seed: str | None
103
+ ) -> list[NormalizedSignal]:
104
+ terms = [
105
+ w
106
+ for w in re.split(r"\W+", f"{niche} {topic_seed or ''}".lower())
107
+ if len(w) > 2
108
+ ]
109
+
110
+ def relevance(sig: NormalizedSignal) -> int:
111
+ haystack = f"{sig.title} {sig.value or ''}".lower()
112
+ return sum(term in haystack for term in terms)
113
+
114
+ return sorted(signals, key=lambda s: (relevance(s), s.observed_at), reverse=True)
@@ -0,0 +1,108 @@
1
+ """Deterministic distillation: NormalizedSignal -> KeyFact / ContentAngle (Ch. 7.3).
2
+
3
+ NO LLM. Every field is copied from real signal data, so invented numbers are impossible.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from collections.abc import Sequence
10
+
11
+ from ..models import Citation, ContentAngle, KeyFact, NormalizedSignal
12
+
13
+ # Per-kind statement templates (rendered purely from structured signal fields).
14
+ _STATEMENT_TEMPLATES = {
15
+ "salary": "{title} pays a median of {value} {unit}.",
16
+ "posting_trend": "{title}: {value} {unit}.",
17
+ "layoff": "{title}",
18
+ "news": "{title}",
19
+ "outlook": "{title} stands at {value} {unit}.",
20
+ }
21
+
22
+ # Per-kind content-angle hooks, filled with concrete numbers.
23
+ _ANGLE_TEMPLATES = {
24
+ "salary": (
25
+ "What {title} really pays now ({value}) — and who is actually getting it.",
26
+ "Most salary advice ignores the real {value} number for {title}.",
27
+ ),
28
+ "posting_trend": (
29
+ "The hiring signal nobody mentions: {value} {unit}.",
30
+ "Why '{title}' ({value} {unit}) changes your job-search math.",
31
+ ),
32
+ "layoff": (
33
+ "{title} — what these layoffs actually signal for your field.",
34
+ "The layoff headline hides the real career move to make.",
35
+ ),
36
+ "news": (
37
+ "{title} — the part that affects your paycheck.",
38
+ "Behind the headline '{title}': the non-obvious takeaway.",
39
+ ),
40
+ "outlook": (
41
+ "{title} is {value} {unit} — here is what that means for you.",
42
+ "The outlook number ({value} {unit}) most channels get wrong.",
43
+ ),
44
+ }
45
+
46
+
47
+ def _clean(value: str | None) -> str:
48
+ return (value or "").strip()
49
+
50
+
51
+ def _snippet(signal: NormalizedSignal) -> str:
52
+ raw_snippet = signal.raw.get("snippet") if isinstance(signal.raw, dict) else None
53
+ if raw_snippet:
54
+ return str(raw_snippet)
55
+ parts = [signal.title]
56
+ if signal.value:
57
+ parts.append(f"{signal.value}{(' ' + signal.unit) if signal.unit else ''}")
58
+ return ": ".join(p for p in parts if p)
59
+
60
+
61
+ def build_key_fact(signal: NormalizedSignal) -> KeyFact:
62
+ template = _STATEMENT_TEMPLATES.get(signal.kind, "{title}")
63
+ statement = template.format(
64
+ title=_clean(signal.title), value=_clean(signal.value), unit=_clean(signal.unit)
65
+ ).strip()
66
+ statement = re.sub(r"\s+", " ", statement).strip(": ").strip() + ("" if statement.endswith(".") else "")
67
+ return KeyFact(
68
+ statement=statement,
69
+ metric=signal.kind,
70
+ value=signal.value,
71
+ citation=Citation(
72
+ source=signal.source,
73
+ url=signal.url,
74
+ observed_at=signal.observed_at,
75
+ snippet=_snippet(signal),
76
+ ),
77
+ )
78
+
79
+
80
+ def build_key_facts(signals: Sequence[NormalizedSignal], *, limit: int = 8) -> list[KeyFact]:
81
+ return [build_key_fact(s) for s in signals[:limit]]
82
+
83
+
84
+ def build_angles(
85
+ signals: Sequence[NormalizedSignal], *, limit: int = 3
86
+ ) -> list[ContentAngle]:
87
+ angles: list[ContentAngle] = []
88
+ for idx, signal in enumerate(signals):
89
+ if len(angles) >= limit:
90
+ break
91
+ templates = _ANGLE_TEMPLATES.get(signal.kind)
92
+ if not templates:
93
+ continue
94
+ hook = templates[idx % len(templates)].format(
95
+ title=_clean(signal.title), value=_clean(signal.value), unit=_clean(signal.unit)
96
+ )
97
+ hook = re.sub(r"\s+", " ", hook).strip()
98
+ angles.append(
99
+ ContentAngle(
100
+ hook=hook,
101
+ supporting_fact_ids=[idx],
102
+ why_nonobvious=(
103
+ "Built directly from fetched data, not generic advice; "
104
+ f"cites a concrete {signal.kind} signal from {signal.source}."
105
+ ),
106
+ )
107
+ )
108
+ return angles