content-foundry 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- content_foundry-1.0.0/PKG-INFO +176 -0
- content_foundry-1.0.0/README.md +81 -0
- content_foundry-1.0.0/pyproject.toml +158 -0
- content_foundry-1.0.0/setup.cfg +4 -0
- content_foundry-1.0.0/src/content_foundry/__init__.py +4 -0
- content_foundry-1.0.0/src/content_foundry/agents/__init__.py +36 -0
- content_foundry-1.0.0/src/content_foundry/agents/brainstorm.py +136 -0
- content_foundry-1.0.0/src/content_foundry/agents/broll_director.py +93 -0
- content_foundry-1.0.0/src/content_foundry/agents/data_fetcher.py +114 -0
- content_foundry-1.0.0/src/content_foundry/agents/distill.py +108 -0
- content_foundry-1.0.0/src/content_foundry/agents/idea_miner.py +214 -0
- content_foundry-1.0.0/src/content_foundry/agents/instruction_planner.py +89 -0
- content_foundry-1.0.0/src/content_foundry/agents/judge.py +437 -0
- content_foundry-1.0.0/src/content_foundry/agents/judge_checks.py +337 -0
- content_foundry-1.0.0/src/content_foundry/agents/publisher.py +191 -0
- content_foundry-1.0.0/src/content_foundry/agents/renderer.py +146 -0
- content_foundry-1.0.0/src/content_foundry/agents/research.py +265 -0
- content_foundry-1.0.0/src/content_foundry/agents/scene_image_director.py +69 -0
- content_foundry-1.0.0/src/content_foundry/agents/script_generator.py +910 -0
- content_foundry-1.0.0/src/content_foundry/agents/thumbnail_director.py +80 -0
- content_foundry-1.0.0/src/content_foundry/agents/visuals.py +956 -0
- content_foundry-1.0.0/src/content_foundry/agents/voiceover.py +127 -0
- content_foundry-1.0.0/src/content_foundry/cli.py +524 -0
- content_foundry-1.0.0/src/content_foundry/config.py +797 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/apple_notification.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/awww.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/bell.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/camera_flash.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/camera_shutter.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/cash_register.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/click.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/game_menu.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/pop.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/whoosh.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/data/sounds/wrong_answer.mp3 +0 -0
- content_foundry-1.0.0/src/content_foundry/datasources/__init__.py +8 -0
- content_foundry-1.0.0/src/content_foundry/datasources/adzuna.py +98 -0
- content_foundry-1.0.0/src/content_foundry/datasources/base.py +22 -0
- content_foundry-1.0.0/src/content_foundry/datasources/bls.py +66 -0
- content_foundry-1.0.0/src/content_foundry/datasources/layoffs.py +73 -0
- content_foundry-1.0.0/src/content_foundry/datasources/news.py +70 -0
- content_foundry-1.0.0/src/content_foundry/datasources/registry.py +100 -0
- content_foundry-1.0.0/src/content_foundry/datasources/search.py +359 -0
- content_foundry-1.0.0/src/content_foundry/errors.py +61 -0
- content_foundry-1.0.0/src/content_foundry/logging.py +88 -0
- content_foundry-1.0.0/src/content_foundry/models/__init__.py +50 -0
- content_foundry-1.0.0/src/content_foundry/models/data_brief.py +44 -0
- content_foundry-1.0.0/src/content_foundry/models/ideas.py +53 -0
- content_foundry-1.0.0/src/content_foundry/models/judge_report.py +47 -0
- content_foundry-1.0.0/src/content_foundry/models/provenance.py +27 -0
- content_foundry-1.0.0/src/content_foundry/models/publish.py +30 -0
- content_foundry-1.0.0/src/content_foundry/models/research.py +42 -0
- content_foundry-1.0.0/src/content_foundry/models/run.py +62 -0
- content_foundry-1.0.0/src/content_foundry/models/script.py +48 -0
- content_foundry-1.0.0/src/content_foundry/models/signals.py +34 -0
- content_foundry-1.0.0/src/content_foundry/models/video.py +24 -0
- content_foundry-1.0.0/src/content_foundry/models/visuals.py +42 -0
- content_foundry-1.0.0/src/content_foundry/models/voiceover.py +35 -0
- content_foundry-1.0.0/src/content_foundry/notifications/__init__.py +16 -0
- content_foundry-1.0.0/src/content_foundry/notifications/base.py +20 -0
- content_foundry-1.0.0/src/content_foundry/notifications/credit_monitor.py +73 -0
- content_foundry-1.0.0/src/content_foundry/notifications/factory.py +42 -0
- content_foundry-1.0.0/src/content_foundry/notifications/telegram.py +22 -0
- content_foundry-1.0.0/src/content_foundry/persistence/__init__.py +9 -0
- content_foundry-1.0.0/src/content_foundry/persistence/db.py +50 -0
- content_foundry-1.0.0/src/content_foundry/persistence/repository.py +239 -0
- content_foundry-1.0.0/src/content_foundry/persistence/schema.py +142 -0
- content_foundry-1.0.0/src/content_foundry/pipeline/__init__.py +22 -0
- content_foundry-1.0.0/src/content_foundry/pipeline/artifacts.py +207 -0
- content_foundry-1.0.0/src/content_foundry/pipeline/orchestrator.py +1110 -0
- content_foundry-1.0.0/src/content_foundry/pipeline/package.py +96 -0
- content_foundry-1.0.0/src/content_foundry/pipeline/stages.py +32 -0
- content_foundry-1.0.0/src/content_foundry/pipeline/topic_relevance.py +85 -0
- content_foundry-1.0.0/src/content_foundry/production/__init__.py +1 -0
- content_foundry-1.0.0/src/content_foundry/production/affiliate.py +497 -0
- content_foundry-1.0.0/src/content_foundry/production/captions.py +98 -0
- content_foundry-1.0.0/src/content_foundry/production/end_screen.py +146 -0
- content_foundry-1.0.0/src/content_foundry/production/like_nudge.py +141 -0
- content_foundry-1.0.0/src/content_foundry/production/overlay.py +60 -0
- content_foundry-1.0.0/src/content_foundry/production/seo.py +244 -0
- content_foundry-1.0.0/src/content_foundry/production/sound_design.py +67 -0
- content_foundry-1.0.0/src/content_foundry/production/subscribe.py +129 -0
- content_foundry-1.0.0/src/content_foundry/production/timebox.py +48 -0
- content_foundry-1.0.0/src/content_foundry/production/timeline.py +48 -0
- content_foundry-1.0.0/src/content_foundry/prompts/__init__.py +30 -0
- content_foundry-1.0.0/src/content_foundry/prompts/affiliate_queries.system.txt +18 -0
- content_foundry-1.0.0/src/content_foundry/prompts/brainstorm.system.txt +26 -0
- content_foundry-1.0.0/src/content_foundry/prompts/broll_director.system.txt +69 -0
- content_foundry-1.0.0/src/content_foundry/prompts/instruction_planner.system.txt +45 -0
- content_foundry-1.0.0/src/content_foundry/prompts/judge.rubric.txt +53 -0
- content_foundry-1.0.0/src/content_foundry/prompts/judge.system.txt +62 -0
- content_foundry-1.0.0/src/content_foundry/prompts/research.system.txt +34 -0
- content_foundry-1.0.0/src/content_foundry/prompts/scene_image_director.system.txt +75 -0
- content_foundry-1.0.0/src/content_foundry/prompts/script_generator.system.txt +296 -0
- content_foundry-1.0.0/src/content_foundry/prompts/thumbnail_director.system.txt +9 -0
- content_foundry-1.0.0/src/content_foundry/providers/__init__.py +257 -0
- content_foundry-1.0.0/src/content_foundry/providers/anthropic_provider.py +64 -0
- content_foundry-1.0.0/src/content_foundry/providers/base.py +103 -0
- content_foundry-1.0.0/src/content_foundry/providers/broll.py +347 -0
- content_foundry-1.0.0/src/content_foundry/providers/faceid.py +181 -0
- content_foundry-1.0.0/src/content_foundry/providers/faceswap.py +261 -0
- content_foundry-1.0.0/src/content_foundry/providers/fallback.py +70 -0
- content_foundry-1.0.0/src/content_foundry/providers/google_provider.py +115 -0
- content_foundry-1.0.0/src/content_foundry/providers/image.py +193 -0
- content_foundry-1.0.0/src/content_foundry/providers/local_provider.py +72 -0
- content_foundry-1.0.0/src/content_foundry/providers/openai_provider.py +64 -0
- content_foundry-1.0.0/src/content_foundry/providers/render_backend.py +509 -0
- content_foundry-1.0.0/src/content_foundry/providers/sfx.py +107 -0
- content_foundry-1.0.0/src/content_foundry/providers/text_normalize.py +87 -0
- content_foundry-1.0.0/src/content_foundry/providers/tiering.py +26 -0
- content_foundry-1.0.0/src/content_foundry/providers/tts.py +519 -0
- content_foundry-1.0.0/src/content_foundry/providers/youtube.py +230 -0
- content_foundry-1.0.0/src/content_foundry/providers/youtube_data.py +183 -0
- content_foundry-1.0.0/src/content_foundry/py.typed +0 -0
- content_foundry-1.0.0/src/content_foundry/safeguards/__init__.py +29 -0
- content_foundry-1.0.0/src/content_foundry/safeguards/disclosure.py +65 -0
- content_foundry-1.0.0/src/content_foundry/safeguards/grounding.py +65 -0
- content_foundry-1.0.0/src/content_foundry/scheduler.py +40 -0
- content_foundry-1.0.0/src/content_foundry/templates/__init__.py +81 -0
- content_foundry-1.0.0/src/content_foundry/templates/definitions.py +108 -0
- content_foundry-1.0.0/src/content_foundry.egg-info/PKG-INFO +176 -0
- content_foundry-1.0.0/src/content_foundry.egg-info/SOURCES.txt +125 -0
- content_foundry-1.0.0/src/content_foundry.egg-info/dependency_links.txt +1 -0
- content_foundry-1.0.0/src/content_foundry.egg-info/entry_points.txt +2 -0
- content_foundry-1.0.0/src/content_foundry.egg-info/requires.txt +80 -0
- content_foundry-1.0.0/src/content_foundry.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranshu Mishra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: content-foundry
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A resumable multi-agent pipeline that researches, writes, judges, narrates, and renders short-form YouTube videos for any niche.
|
|
5
|
+
Author-email: Pranshu Mishra <pranshumshr.04@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pranshu97/content-foundry
|
|
8
|
+
Project-URL: Repository, https://github.com/pranshu97/content-foundry
|
|
9
|
+
Project-URL: Issues, https://github.com/pranshu97/content-foundry/issues
|
|
10
|
+
Keywords: youtube,video-generation,ai-agents,llm,gemini,content-automation,text-to-speech,ffmpeg,pipeline
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Multimedia :: Video
|
|
19
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: pydantic>=2.7
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
29
|
+
Requires-Dist: lxml>=5.2
|
|
30
|
+
Requires-Dist: ddgs>=6.0
|
|
31
|
+
Requires-Dist: SQLAlchemy>=2.0
|
|
32
|
+
Requires-Dist: typer>=0.12
|
|
33
|
+
Requires-Dist: APScheduler>=3.10
|
|
34
|
+
Requires-Dist: tenacity>=8.5
|
|
35
|
+
Requires-Dist: structlog>=24.1
|
|
36
|
+
Requires-Dist: rich>=13.7
|
|
37
|
+
Requires-Dist: python-dateutil>=2.9
|
|
38
|
+
Requires-Dist: python-ulid>=2.7
|
|
39
|
+
Requires-Dist: Pillow>=10.4
|
|
40
|
+
Requires-Dist: ffmpeg-python>=0.2
|
|
41
|
+
Requires-Dist: pydub>=0.25
|
|
42
|
+
Requires-Dist: edge-tts>=6.1
|
|
43
|
+
Requires-Dist: anthropic>=0.39
|
|
44
|
+
Requires-Dist: openai>=1.50
|
|
45
|
+
Provides-Extra: youtube
|
|
46
|
+
Requires-Dist: google-api-python-client>=2.140; extra == "youtube"
|
|
47
|
+
Requires-Dist: google-auth-oauthlib>=1.2; extra == "youtube"
|
|
48
|
+
Requires-Dist: google-auth-httplib2>=0.2; extra == "youtube"
|
|
49
|
+
Provides-Extra: tts
|
|
50
|
+
Requires-Dist: elevenlabs>=1.5; extra == "tts"
|
|
51
|
+
Requires-Dist: piper-tts>=1.2; extra == "tts"
|
|
52
|
+
Provides-Extra: image
|
|
53
|
+
Requires-Dist: stability-sdk>=0.8; extra == "image"
|
|
54
|
+
Provides-Extra: video
|
|
55
|
+
Requires-Dist: moviepy>=1.0.3; extra == "video"
|
|
56
|
+
Provides-Extra: whisper
|
|
57
|
+
Requires-Dist: faster-whisper>=1.0; extra == "whisper"
|
|
58
|
+
Provides-Extra: dashboard
|
|
59
|
+
Requires-Dist: streamlit>=1.37; extra == "dashboard"
|
|
60
|
+
Provides-Extra: avatar
|
|
61
|
+
Requires-Dist: rembg>=2.0; extra == "avatar"
|
|
62
|
+
Provides-Extra: clone
|
|
63
|
+
Requires-Dist: chatterbox-tts>=0.1; extra == "clone"
|
|
64
|
+
Provides-Extra: faceid
|
|
65
|
+
Requires-Dist: diffusers==0.29.0; extra == "faceid"
|
|
66
|
+
Requires-Dist: transformers==4.44.2; extra == "faceid"
|
|
67
|
+
Requires-Dist: numpy<2.0.0; extra == "faceid"
|
|
68
|
+
Requires-Dist: safetensors==0.5.3; extra == "faceid"
|
|
69
|
+
Requires-Dist: accelerate>=0.28; extra == "faceid"
|
|
70
|
+
Requires-Dist: insightface>=0.7; extra == "faceid"
|
|
71
|
+
Requires-Dist: onnxruntime-gpu<1.23,>=1.19; extra == "faceid"
|
|
72
|
+
Requires-Dist: opencv-python-headless>=4.9; extra == "faceid"
|
|
73
|
+
Provides-Extra: all
|
|
74
|
+
Requires-Dist: elevenlabs>=1.5; extra == "all"
|
|
75
|
+
Requires-Dist: piper-tts>=1.2; extra == "all"
|
|
76
|
+
Requires-Dist: stability-sdk>=0.8; extra == "all"
|
|
77
|
+
Requires-Dist: moviepy>=1.0.3; extra == "all"
|
|
78
|
+
Requires-Dist: faster-whisper>=1.0; extra == "all"
|
|
79
|
+
Requires-Dist: streamlit>=1.37; extra == "all"
|
|
80
|
+
Requires-Dist: rembg>=2.0; extra == "all"
|
|
81
|
+
Requires-Dist: google-api-python-client>=2.140; extra == "all"
|
|
82
|
+
Requires-Dist: google-auth-oauthlib>=1.2; extra == "all"
|
|
83
|
+
Requires-Dist: google-auth-httplib2>=0.2; extra == "all"
|
|
84
|
+
Provides-Extra: dev
|
|
85
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
86
|
+
Requires-Dist: pytest-mock>=3.14; extra == "dev"
|
|
87
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
88
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
89
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
90
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
91
|
+
Requires-Dist: pre-commit>=3.8; extra == "dev"
|
|
92
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
93
|
+
Requires-Dist: twine>=5.1; extra == "dev"
|
|
94
|
+
Dynamic: license-file
|
|
95
|
+
|
|
96
|
+
# Content Foundry
|
|
97
|
+
|
|
98
|
+
An autonomous, fully-resumable multi-agent pipeline that turns real labor-market data into a
|
|
99
|
+
published (Private/Unlisted draft) YouTube video — grounded in data, gated by a
|
|
100
|
+
strict quality rubric, and compliant with synthetic-content disclosure by default.
|
|
101
|
+
|
|
102
|
+
**Pipeline:** Data Fetcher → Script Generator → Judge → Voiceover → Visuals → Render → Publish
|
|
103
|
+
|
|
104
|
+
> The complete engineering specification (single source of truth) lives in [`spec/`](spec/README.md).
|
|
105
|
+
> A high-level architecture summary is in [`TECH_REPORT.md`](TECH_REPORT.md), and the operator guide
|
|
106
|
+
> in [`Tutorial.md`](Tutorial.md).
|
|
107
|
+
|
|
108
|
+
## Live channel
|
|
109
|
+
|
|
110
|
+
Watch the output live: **[youtube.com/@TheCrackedEng](https://www.youtube.com/@TheCrackedEng)**
|
|
111
|
+
|
|
112
|
+
> **Disclaimer:** This channel is 100% generated, voiced, and published autonomously by this repository.
|
|
113
|
+
|
|
114
|
+
## Quickstart
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# 1. Create & activate a Python 3.11+ environment, then install
|
|
118
|
+
pip install -r requirements.txt
|
|
119
|
+
pip install -e . # exposes the `content-foundry` CLI
|
|
120
|
+
|
|
121
|
+
# 2. Configure
|
|
122
|
+
cp .env.example .env # fill in your keys (see Human_Tasks.md)
|
|
123
|
+
|
|
124
|
+
# 3. Initialise the database
|
|
125
|
+
python scripts/init_db.py
|
|
126
|
+
|
|
127
|
+
# 4. Smoke test (no upload, stops at the Judge)
|
|
128
|
+
content-foundry run --niche "tech careers" --to-stage judge
|
|
129
|
+
|
|
130
|
+
# Or produce a vertical YouTube Short instead of a long video (one switch):
|
|
131
|
+
content-foundry run --niche "tech careers" --idea "your topic" --format short
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
See [`spec/23-deployment-instructions.md`](spec/23-deployment-instructions.md) for full deployment,
|
|
135
|
+
[`spec/17-cli-interface.md`](spec/17-cli-interface.md) for every command, and
|
|
136
|
+
[`Human_Tasks.md`](Human_Tasks.md) for the manual setup checklist (API keys, OAuth, Telegram bot).
|
|
137
|
+
|
|
138
|
+
## Project layout
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
src/content_foundry/ # the engine (models, agents, providers, pipeline, ...)
|
|
142
|
+
dashboard/ # Streamlit review dashboard
|
|
143
|
+
scripts/ # init_db, seed_demo
|
|
144
|
+
tests/ # unit / agent / integration / e2e (dry-run)
|
|
145
|
+
spec/ # the authoritative specification (25 chapters)
|
|
146
|
+
output/runs/<run_id>/ # per-run artifacts + media + package.md
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Cost discipline
|
|
150
|
+
|
|
151
|
+
Only **Agent 2 (Script Generator)** always calls an LLM. The Data Fetcher, most of the Judge, and
|
|
152
|
+
the Visuals prompt-builder are deterministic Python — free, fast, and hallucination-proof.
|
|
153
|
+
|
|
154
|
+
Cost levers (cheapest first):
|
|
155
|
+
- **Run the LLM locally** — `PRIMARY_PROVIDER=local` (Ollama / LM Studio / vLLM) makes generation free.
|
|
156
|
+
- **Free voice** — `TTS_PROVIDER=edge` (Microsoft neural, free, no key) or `piper` (fully offline), or `chatterbox` to **clone your own voice** free & locally (MIT-licensed, safe to monetize; GPU recommended). Paid: elevenlabs / openai. Voices auto-alternate male/female by run number.
|
|
157
|
+
- **Free visuals** — `IMAGE_PROVIDER=none` renders polished title cards; add free Pexels + Pixabay keys for real, moment-matched B-roll (a clip per narration beat), now held to a STRICT on-topic bar — a beat with no confidently-relevant clip falls back to a bespoke generated image (or a clean card when no image provider is set), never an off-topic clip.
|
|
158
|
+
- **Free research (default)** — `ENABLED_SOURCES=search` runs free DuckDuckGo web research on your run's topic (no key), so it works on **any** niche out of the box; the labor-market feeds (adzuna/layoffs/bls) are opt-in add-ons.
|
|
159
|
+
- **Free idea discovery** — `IDEA_MINING_ENABLED=true` + a free `YOUTUBE_API_KEY` mines *proven* outlier videos in your niche (views far above the channel's median) so each run builds a topic with demonstrated demand instead of a guess; best-effort, so it never blocks a run.
|
|
160
|
+
- **Free polish** — bundled sound effects (`SFX_ENABLED`), scene crossfades, a warm grade, and a Subscribe nudge are all local/ffmpeg (no paid services).
|
|
161
|
+
- **`--profile cheap`** — deterministic judge + Pillow cards (no image API) + a single revision.
|
|
162
|
+
- **Hard budget cap** — `ENFORCE_BUDGET_CAP=true` aborts a run once estimated month-to-date spend
|
|
163
|
+
reaches `MONTHLY_BUDGET_USD` (on by default; cost safety, not just an alert).
|
|
164
|
+
- **Resume reuses paid artifacts** — re-running a stage reuses existing voiceover/visuals instead of
|
|
165
|
+
paying again (use `--force` to regenerate).
|
|
166
|
+
- **`FAIL_FAST_SCORE`** (opt-in) — stop paying for revisions a hopeless script can't recover from.
|
|
167
|
+
|
|
168
|
+
Use `--profile quality` for publishing.
|
|
169
|
+
|
|
170
|
+
## Testing
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
pytest # unit + agent + integration + e2e dry-run, ≥85% coverage gate
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
All tests run offline — vendors are mocked behind their protocols; no real network or API calls.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Content Foundry
|
|
2
|
+
|
|
3
|
+
An autonomous, fully-resumable multi-agent pipeline that turns real labor-market data into a
|
|
4
|
+
published (Private/Unlisted draft) YouTube video — grounded in data, gated by a
|
|
5
|
+
strict quality rubric, and compliant with synthetic-content disclosure by default.
|
|
6
|
+
|
|
7
|
+
**Pipeline:** Data Fetcher → Script Generator → Judge → Voiceover → Visuals → Render → Publish
|
|
8
|
+
|
|
9
|
+
> The complete engineering specification (single source of truth) lives in [`spec/`](spec/README.md).
|
|
10
|
+
> A high-level architecture summary is in [`TECH_REPORT.md`](TECH_REPORT.md), and the operator guide
|
|
11
|
+
> in [`Tutorial.md`](Tutorial.md).
|
|
12
|
+
|
|
13
|
+
## Live channel
|
|
14
|
+
|
|
15
|
+
Watch the output live: **[youtube.com/@TheCrackedEng](https://www.youtube.com/@TheCrackedEng)**
|
|
16
|
+
|
|
17
|
+
> **Disclaimer:** This channel is 100% generated, voiced, and published autonomously by this repository.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 1. Create & activate a Python 3.11+ environment, then install
|
|
23
|
+
pip install -r requirements.txt
|
|
24
|
+
pip install -e . # exposes the `content-foundry` CLI
|
|
25
|
+
|
|
26
|
+
# 2. Configure
|
|
27
|
+
cp .env.example .env # fill in your keys (see Human_Tasks.md)
|
|
28
|
+
|
|
29
|
+
# 3. Initialise the database
|
|
30
|
+
python scripts/init_db.py
|
|
31
|
+
|
|
32
|
+
# 4. Smoke test (no upload, stops at the Judge)
|
|
33
|
+
content-foundry run --niche "tech careers" --to-stage judge
|
|
34
|
+
|
|
35
|
+
# Or produce a vertical YouTube Short instead of a long video (one switch):
|
|
36
|
+
content-foundry run --niche "tech careers" --idea "your topic" --format short
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
See [`spec/23-deployment-instructions.md`](spec/23-deployment-instructions.md) for full deployment,
|
|
40
|
+
[`spec/17-cli-interface.md`](spec/17-cli-interface.md) for every command, and
|
|
41
|
+
[`Human_Tasks.md`](Human_Tasks.md) for the manual setup checklist (API keys, OAuth, Telegram bot).
|
|
42
|
+
|
|
43
|
+
## Project layout
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
src/content_foundry/ # the engine (models, agents, providers, pipeline, ...)
|
|
47
|
+
dashboard/ # Streamlit review dashboard
|
|
48
|
+
scripts/ # init_db, seed_demo
|
|
49
|
+
tests/ # unit / agent / integration / e2e (dry-run)
|
|
50
|
+
spec/ # the authoritative specification (25 chapters)
|
|
51
|
+
output/runs/<run_id>/ # per-run artifacts + media + package.md
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Cost discipline
|
|
55
|
+
|
|
56
|
+
Only **Agent 2 (Script Generator)** always calls an LLM. The Data Fetcher, most of the Judge, and
|
|
57
|
+
the Visuals prompt-builder are deterministic Python — free, fast, and hallucination-proof.
|
|
58
|
+
|
|
59
|
+
Cost levers (cheapest first):
|
|
60
|
+
- **Run the LLM locally** — `PRIMARY_PROVIDER=local` (Ollama / LM Studio / vLLM) makes generation free.
|
|
61
|
+
- **Free voice** — `TTS_PROVIDER=edge` (Microsoft neural, free, no key) or `piper` (fully offline), or `chatterbox` to **clone your own voice** free & locally (MIT-licensed, safe to monetize; GPU recommended). Paid: elevenlabs / openai. Voices auto-alternate male/female by run number.
|
|
62
|
+
- **Free visuals** — `IMAGE_PROVIDER=none` renders polished title cards; add free Pexels + Pixabay keys for real, moment-matched B-roll (a clip per narration beat), now held to a STRICT on-topic bar — a beat with no confidently-relevant clip falls back to a bespoke generated image (or a clean card when no image provider is set), never an off-topic clip.
|
|
63
|
+
- **Free research (default)** — `ENABLED_SOURCES=search` runs free DuckDuckGo web research on your run's topic (no key), so it works on **any** niche out of the box; the labor-market feeds (adzuna/layoffs/bls) are opt-in add-ons.
|
|
64
|
+
- **Free idea discovery** — `IDEA_MINING_ENABLED=true` + a free `YOUTUBE_API_KEY` mines *proven* outlier videos in your niche (views far above the channel's median) so each run builds a topic with demonstrated demand instead of a guess; best-effort, so it never blocks a run.
|
|
65
|
+
- **Free polish** — bundled sound effects (`SFX_ENABLED`), scene crossfades, a warm grade, and a Subscribe nudge are all local/ffmpeg (no paid services).
|
|
66
|
+
- **`--profile cheap`** — deterministic judge + Pillow cards (no image API) + a single revision.
|
|
67
|
+
- **Hard budget cap** — `ENFORCE_BUDGET_CAP=true` aborts a run once estimated month-to-date spend
|
|
68
|
+
reaches `MONTHLY_BUDGET_USD` (on by default; cost safety, not just an alert).
|
|
69
|
+
- **Resume reuses paid artifacts** — re-running a stage reuses existing voiceover/visuals instead of
|
|
70
|
+
paying again (use `--force` to regenerate).
|
|
71
|
+
- **`FAIL_FAST_SCORE`** (opt-in) — stop paying for revisions a hopeless script can't recover from.
|
|
72
|
+
|
|
73
|
+
Use `--profile quality` for publishing.
|
|
74
|
+
|
|
75
|
+
## Testing
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pytest # unit + agent + integration + e2e dry-run, ≥85% coverage gate
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
All tests run offline — vendors are mocked behind their protocols; no real network or API calls.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "content-foundry"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A resumable multi-agent pipeline that researches, writes, judges, narrates, and renders short-form YouTube videos for any niche."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Pranshu Mishra", email = "pranshumshr.04@gmail.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"youtube", "video-generation", "ai-agents", "llm", "gemini",
|
|
15
|
+
"content-automation", "text-to-speech", "ffmpeg", "pipeline",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Multimedia :: Video",
|
|
26
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Lean core — everything the DEFAULT pipeline needs. Optional integrations live in the extras below
|
|
32
|
+
# (install on demand, e.g. `pip install content-foundry[all]`); every vendor SDK is imported lazily.
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pydantic>=2.7",
|
|
35
|
+
"pydantic-settings>=2.3",
|
|
36
|
+
"httpx>=0.27",
|
|
37
|
+
"beautifulsoup4>=4.12",
|
|
38
|
+
"lxml>=5.2",
|
|
39
|
+
"ddgs>=6.0",
|
|
40
|
+
"SQLAlchemy>=2.0",
|
|
41
|
+
"typer>=0.12",
|
|
42
|
+
"APScheduler>=3.10",
|
|
43
|
+
"tenacity>=8.5",
|
|
44
|
+
"structlog>=24.1",
|
|
45
|
+
"rich>=13.7",
|
|
46
|
+
"python-dateutil>=2.9",
|
|
47
|
+
"python-ulid>=2.7",
|
|
48
|
+
"Pillow>=10.4",
|
|
49
|
+
"ffmpeg-python>=0.2",
|
|
50
|
+
"pydub>=0.25",
|
|
51
|
+
"edge-tts>=6.1",
|
|
52
|
+
"anthropic>=0.39",
|
|
53
|
+
"openai>=1.50",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[project.optional-dependencies]
|
|
57
|
+
youtube = ["google-api-python-client>=2.140", "google-auth-oauthlib>=1.2", "google-auth-httplib2>=0.2"]
|
|
58
|
+
tts = ["elevenlabs>=1.5", "piper-tts>=1.2"]
|
|
59
|
+
image = ["stability-sdk>=0.8"]
|
|
60
|
+
video = ["moviepy>=1.0.3"]
|
|
61
|
+
whisper = ["faster-whisper>=1.0"]
|
|
62
|
+
dashboard = ["streamlit>=1.37"]
|
|
63
|
+
avatar = ["rembg>=2.0"]
|
|
64
|
+
clone = ["chatterbox-tts>=0.1"]
|
|
65
|
+
faceid = [
|
|
66
|
+
# Pinned to coexist with chatterbox-tts, which needs diffusers==0.29.0: a newer transformers or
|
|
67
|
+
# numpy>=2 breaks diffusers 0.29's Stable Diffusion import. See providers/faceid.py.
|
|
68
|
+
"diffusers==0.29.0", "transformers==4.44.2", "numpy<2.0.0", "safetensors==0.5.3",
|
|
69
|
+
# onnxruntime-gpu pinned to the CUDA 12 line (matches torch cu124); 1.23+ needs CUDA 13 libs this env lacks.
|
|
70
|
+
"accelerate>=0.28", "insightface>=0.7", "onnxruntime-gpu>=1.19,<1.23", "opencv-python-headless>=4.9",
|
|
71
|
+
]
|
|
72
|
+
all = [
|
|
73
|
+
"elevenlabs>=1.5", "piper-tts>=1.2", "stability-sdk>=0.8", "moviepy>=1.0.3",
|
|
74
|
+
"faster-whisper>=1.0", "streamlit>=1.37", "rembg>=2.0",
|
|
75
|
+
"google-api-python-client>=2.140", "google-auth-oauthlib>=1.2", "google-auth-httplib2>=0.2",
|
|
76
|
+
]
|
|
77
|
+
dev = [
|
|
78
|
+
"pytest>=8.3",
|
|
79
|
+
"pytest-mock>=3.14",
|
|
80
|
+
"pytest-cov>=5.0",
|
|
81
|
+
"respx>=0.21",
|
|
82
|
+
"ruff>=0.6",
|
|
83
|
+
"mypy>=1.11",
|
|
84
|
+
"pre-commit>=3.8",
|
|
85
|
+
"build>=1.2",
|
|
86
|
+
"twine>=5.1",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[project.urls]
|
|
90
|
+
Homepage = "https://github.com/pranshu97/content-foundry"
|
|
91
|
+
Repository = "https://github.com/pranshu97/content-foundry"
|
|
92
|
+
Issues = "https://github.com/pranshu97/content-foundry/issues"
|
|
93
|
+
|
|
94
|
+
[project.scripts]
|
|
95
|
+
content-foundry = "content_foundry.cli:app"
|
|
96
|
+
|
|
97
|
+
[tool.setuptools.dynamic]
|
|
98
|
+
version = { attr = "content_foundry.__version__" }
|
|
99
|
+
|
|
100
|
+
[tool.setuptools.packages.find]
|
|
101
|
+
where = ["src"]
|
|
102
|
+
|
|
103
|
+
[tool.setuptools.package-data]
|
|
104
|
+
content_foundry = ["prompts/*.txt", "data/sounds/*", "py.typed"]
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Tooling
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
[tool.ruff]
|
|
110
|
+
line-length = 100
|
|
111
|
+
target-version = "py311"
|
|
112
|
+
src = ["src", "tests"]
|
|
113
|
+
|
|
114
|
+
[tool.ruff.lint]
|
|
115
|
+
select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
|
|
116
|
+
ignore = ["E501", "UP042"]
|
|
117
|
+
|
|
118
|
+
[tool.mypy]
|
|
119
|
+
python_version = "3.11"
|
|
120
|
+
warn_unused_ignores = true
|
|
121
|
+
ignore_missing_imports = true
|
|
122
|
+
plugins = ["pydantic.mypy"]
|
|
123
|
+
packages = ["content_foundry"]
|
|
124
|
+
mypy_path = "src"
|
|
125
|
+
|
|
126
|
+
[tool.pytest.ini_options]
|
|
127
|
+
minversion = "8.0"
|
|
128
|
+
addopts = "-q --cov=content_foundry --cov-report=term-missing"
|
|
129
|
+
testpaths = ["tests"]
|
|
130
|
+
pythonpath = ["src"]
|
|
131
|
+
filterwarnings = ["ignore::DeprecationWarning"]
|
|
132
|
+
|
|
133
|
+
[tool.coverage.run]
|
|
134
|
+
source = ["content_foundry"]
|
|
135
|
+
branch = true
|
|
136
|
+
omit = [
|
|
137
|
+
# Thin vendor adapter glue (network/SDK side effects) — excluded per Ch. 22.5.
|
|
138
|
+
"*/providers/anthropic_provider.py",
|
|
139
|
+
"*/providers/openai_provider.py",
|
|
140
|
+
"*/providers/tts.py",
|
|
141
|
+
"*/providers/image.py",
|
|
142
|
+
"*/providers/broll.py",
|
|
143
|
+
"*/providers/render_backend.py",
|
|
144
|
+
"*/providers/youtube.py",
|
|
145
|
+
"*/notifications/telegram.py",
|
|
146
|
+
"*/scheduler.py",
|
|
147
|
+
"*/cli.py",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
[tool.coverage.report]
|
|
151
|
+
fail_under = 85
|
|
152
|
+
show_missing = true
|
|
153
|
+
exclude_lines = [
|
|
154
|
+
"pragma: no cover",
|
|
155
|
+
"raise NotImplementedError",
|
|
156
|
+
"if TYPE_CHECKING:",
|
|
157
|
+
"\\.\\.\\.",
|
|
158
|
+
]
|
|
@@ -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)
|