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.
- content_foundry/__init__.py +4 -0
- content_foundry/agents/__init__.py +36 -0
- content_foundry/agents/brainstorm.py +136 -0
- content_foundry/agents/broll_director.py +93 -0
- content_foundry/agents/data_fetcher.py +114 -0
- content_foundry/agents/distill.py +108 -0
- content_foundry/agents/idea_miner.py +214 -0
- content_foundry/agents/instruction_planner.py +89 -0
- content_foundry/agents/judge.py +437 -0
- content_foundry/agents/judge_checks.py +337 -0
- content_foundry/agents/publisher.py +191 -0
- content_foundry/agents/renderer.py +146 -0
- content_foundry/agents/research.py +265 -0
- content_foundry/agents/scene_image_director.py +69 -0
- content_foundry/agents/script_generator.py +910 -0
- content_foundry/agents/thumbnail_director.py +80 -0
- content_foundry/agents/visuals.py +956 -0
- content_foundry/agents/voiceover.py +127 -0
- content_foundry/cli.py +524 -0
- content_foundry/config.py +797 -0
- content_foundry/data/sounds/apple_notification.mp3 +0 -0
- content_foundry/data/sounds/awww.mp3 +0 -0
- content_foundry/data/sounds/bell.mp3 +0 -0
- content_foundry/data/sounds/camera_flash.mp3 +0 -0
- content_foundry/data/sounds/camera_shutter.mp3 +0 -0
- content_foundry/data/sounds/cash_register.mp3 +0 -0
- content_foundry/data/sounds/click.mp3 +0 -0
- content_foundry/data/sounds/game_menu.mp3 +0 -0
- content_foundry/data/sounds/pop.mp3 +0 -0
- content_foundry/data/sounds/whoosh.mp3 +0 -0
- content_foundry/data/sounds/wrong_answer.mp3 +0 -0
- content_foundry/datasources/__init__.py +8 -0
- content_foundry/datasources/adzuna.py +98 -0
- content_foundry/datasources/base.py +22 -0
- content_foundry/datasources/bls.py +66 -0
- content_foundry/datasources/layoffs.py +73 -0
- content_foundry/datasources/news.py +70 -0
- content_foundry/datasources/registry.py +100 -0
- content_foundry/datasources/search.py +359 -0
- content_foundry/errors.py +61 -0
- content_foundry/logging.py +88 -0
- content_foundry/models/__init__.py +50 -0
- content_foundry/models/data_brief.py +44 -0
- content_foundry/models/ideas.py +53 -0
- content_foundry/models/judge_report.py +47 -0
- content_foundry/models/provenance.py +27 -0
- content_foundry/models/publish.py +30 -0
- content_foundry/models/research.py +42 -0
- content_foundry/models/run.py +62 -0
- content_foundry/models/script.py +48 -0
- content_foundry/models/signals.py +34 -0
- content_foundry/models/video.py +24 -0
- content_foundry/models/visuals.py +42 -0
- content_foundry/models/voiceover.py +35 -0
- content_foundry/notifications/__init__.py +16 -0
- content_foundry/notifications/base.py +20 -0
- content_foundry/notifications/credit_monitor.py +73 -0
- content_foundry/notifications/factory.py +42 -0
- content_foundry/notifications/telegram.py +22 -0
- content_foundry/persistence/__init__.py +9 -0
- content_foundry/persistence/db.py +50 -0
- content_foundry/persistence/repository.py +239 -0
- content_foundry/persistence/schema.py +142 -0
- content_foundry/pipeline/__init__.py +22 -0
- content_foundry/pipeline/artifacts.py +207 -0
- content_foundry/pipeline/orchestrator.py +1110 -0
- content_foundry/pipeline/package.py +96 -0
- content_foundry/pipeline/stages.py +32 -0
- content_foundry/pipeline/topic_relevance.py +85 -0
- content_foundry/production/__init__.py +1 -0
- content_foundry/production/affiliate.py +497 -0
- content_foundry/production/captions.py +98 -0
- content_foundry/production/end_screen.py +146 -0
- content_foundry/production/like_nudge.py +141 -0
- content_foundry/production/overlay.py +60 -0
- content_foundry/production/seo.py +244 -0
- content_foundry/production/sound_design.py +67 -0
- content_foundry/production/subscribe.py +129 -0
- content_foundry/production/timebox.py +48 -0
- content_foundry/production/timeline.py +48 -0
- content_foundry/prompts/__init__.py +30 -0
- content_foundry/prompts/affiliate_queries.system.txt +18 -0
- content_foundry/prompts/brainstorm.system.txt +26 -0
- content_foundry/prompts/broll_director.system.txt +69 -0
- content_foundry/prompts/instruction_planner.system.txt +45 -0
- content_foundry/prompts/judge.rubric.txt +53 -0
- content_foundry/prompts/judge.system.txt +62 -0
- content_foundry/prompts/research.system.txt +34 -0
- content_foundry/prompts/scene_image_director.system.txt +75 -0
- content_foundry/prompts/script_generator.system.txt +296 -0
- content_foundry/prompts/thumbnail_director.system.txt +9 -0
- content_foundry/providers/__init__.py +257 -0
- content_foundry/providers/anthropic_provider.py +64 -0
- content_foundry/providers/base.py +103 -0
- content_foundry/providers/broll.py +347 -0
- content_foundry/providers/faceid.py +181 -0
- content_foundry/providers/faceswap.py +261 -0
- content_foundry/providers/fallback.py +70 -0
- content_foundry/providers/google_provider.py +115 -0
- content_foundry/providers/image.py +193 -0
- content_foundry/providers/local_provider.py +72 -0
- content_foundry/providers/openai_provider.py +64 -0
- content_foundry/providers/render_backend.py +509 -0
- content_foundry/providers/sfx.py +107 -0
- content_foundry/providers/text_normalize.py +87 -0
- content_foundry/providers/tiering.py +26 -0
- content_foundry/providers/tts.py +519 -0
- content_foundry/providers/youtube.py +230 -0
- content_foundry/providers/youtube_data.py +183 -0
- content_foundry/py.typed +0 -0
- content_foundry/safeguards/__init__.py +29 -0
- content_foundry/safeguards/disclosure.py +65 -0
- content_foundry/safeguards/grounding.py +65 -0
- content_foundry/scheduler.py +40 -0
- content_foundry/templates/__init__.py +81 -0
- content_foundry/templates/definitions.py +108 -0
- content_foundry-1.0.0.dist-info/METADATA +176 -0
- content_foundry-1.0.0.dist-info/RECORD +122 -0
- content_foundry-1.0.0.dist-info/WHEEL +5 -0
- content_foundry-1.0.0.dist-info/entry_points.txt +2 -0
- content_foundry-1.0.0.dist-info/licenses/LICENSE +21 -0
- content_foundry-1.0.0.dist-info/top_level.txt +1 -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
|