
Content Ops
- 8 installs
- 33 repo stars
- Updated April 26, 2026
- bighardperson/computer-science-skills-collection
content-ops is a skill that scores and iteratively improves content or strategy with an auto-assembled expert panel until every score reaches 90+.
About
content-ops is a skill that scores and iteratively improves content or strategy using an auto-assembled panel of 7-10 domain experts. It selects a scoring rubric by content type, runs a recursive scoring loop until every expert scores 90+ (max 3 rounds), and always includes an AI-writing detector and brand-voice check. A developer or marketer uses it as a quality gate on copy, landing pages, email sequences, titles or strategy docs.
- Scores and iteratively improves content or strategy with an auto-assembled panel of 7-10 domain experts
- Recursively loops until all scores hit 90+, max 3 rounds
- Always includes an AI Writing Detector (1.5x weight) and Brand Voice Match expert
Content Ops by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,541 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
content-ops capabilities & compatibility
- Capabilities
- content scoring · copywriting · quality review
- Use cases
- copywriting · marketing · seo
- Pricing
- Free
What content-ops says it does
Score, evaluate, and iteratively improve any content or strategy using an auto-assembled panel of domain experts.
**Target: 90/100 across all experts. Non-negotiable. Max 3 rounds.**
npx skills add https://github.com/bighardperson/computer-science-skills-collection --skill content-opsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 33 |
| Last updated | April 26, 2026 |
| Repository | bighardperson/computer-science-skills-collection ↗ |
What it does
Score and iteratively rewrite copy, landing pages, email sequences or strategy docs until an expert panel rates them 90+.
Who is it for?
quality-gating copy, landing pages, email sequences, titles and strategy docs
When should I use this skill?
asked to score, rate, quality-check or panel-review content, or another skill needs a quality gate on its output
What you get
Content revised through expert scoring rounds until it reaches a 90+ quality bar.
By the numbers
- panel of 7-10 experts
- iterates until 90+ score
- max 3 rounds
Files
Preamble (runs on skill start)
# Version check (silent if up to date)
python3 telemetry/version_check.py 2>/dev/null || true
# Telemetry opt-in (first run only, then remembers your choice)
python3 telemetry/telemetry_init.py 2>/dev/null || truePrivacy: This skill logs usage locally to~/.ai-marketing-skills/analytics/. Remote telemetry is opt-in only. No code, file paths, or repo content is ever collected. Seetelemetry/README.md.
---
Expert Panel
General-purpose scoring and iterative improvement engine. Auto-assembles the right experts for whatever is being evaluated, scores it, and loops until 90+.
---
Step 1: Intake — Understand What's Being Scored
Collect or infer from context:
1. Content/artifact — The thing(s) to score (paste, file path, or URL) 2. Content type — Copy, sequence, landing page, strategy, title, chart, candidate eval, etc. 3. Offer context — What's being sold/promoted? To whom? What domain/industry? 4. Variants — Are there multiple versions to compare? (A/B/C) 5. Source skill — Is this output from another skill? (e.g., cold-outbound-optimizer) If yes, note the source for feedback-to-source routing in Step 6.
If context is obvious from the conversation, don't ask — just proceed.
---
Step 2: Auto-Assemble the Expert Panel
Build a panel of 7–10 experts tailored to the content type and domain.
Assembly rules
1. Start with content-type experts. Read experts/ directory for pre-built panels matching the content type. If an exact match exists (e.g., experts/linkedin.md for a LinkedIn post), use it as the base.
2. Add domain/offer experts. Based on the offer context, add 1–3 experts who understand the specific industry or domain. Examples:
- Scoring bakery marketing → add Food & Beverage Marketing Expert
- Scoring SaaS landing page → add SaaS Conversion Expert
- Scoring recruiting outreach → add Agency Recruiter + Talent Market Expert
- Scoring medical device copy → add Healthcare Compliance Expert
3. Always include these two:
- AI Writing Detector — See
experts/humanizer.md. Weight: 1.5x. Non-negotiable. - Brand Voice Match — Checks alignment with the configured brand voice and
known rejection patterns from references/patterns.md (if present).
4. Check learned patterns. If references/patterns.md exists, read it. If any patterns apply to this content type, brief the panel on them. Dock points for known-bad patterns.
5. Cap at 10 experts. If you have more than 10, merge overlapping roles.
Panel output format
List each expert with: Name, lens/focus, what they check.
---
Step 3: Select Scoring Rubric
Choose the appropriate rubric from scoring-rubrics/:
| Content type | Rubric file |
|---|---|
| Blog, social, email, newsletter, scripts | scoring-rubrics/content-quality.md |
| Strategy, recommendations, analysis | scoring-rubrics/strategic-quality.md |
| Landing pages, ads, CTAs | scoring-rubrics/conversion-quality.md |
| Charts, data viz, infographics | scoring-rubrics/visual-quality.md |
| Candidate evaluations | scoring-rubrics/evaluation-quality.md |
| Other | Synthesize a rubric from the two closest matches |
Read the selected rubric file for detailed criteria and point allocation.
---
Step 4: Score — Recursive Loop Until 90+
Target: 90/100 across all experts. Non-negotiable. Max 3 rounds.
Each round produces:
## Round [N] — Score: [AVG]/100
| Expert | Score | Key Feedback |
|--------|-------|--------------|
| [Name] | [0-100] | [One-line rationale] |
| ... | ... | ... |
**Aggregate:** [weighted average — humanizer at 1.5x]
**Top 3 weaknesses:** [ranked]
**Changes made:** [specific edits addressing each weakness]Then the revised content/artifact.
Rules
- Scores must be brutally honest. No padding to 90.
- Humanizer score weighted 1.5x in the aggregate.
- If aggregate < 90: identify top 3 weaknesses → revise → next round.
- If aggregate ≥ 90: finalize and proceed to output.
- After 3 rounds, if still < 90: return best version with honest score + note on what's
holding it back.
- Show ALL rounds in output — the iteration trail is part of the value.
Variant comparison mode
When scoring multiple variants (A/B/C):
- Score each variant independently through the full panel.
- After scoring, rank variants by aggregate score.
- If top variant is < 90, iterate on the best one (don't iterate all of them).
---
Step 5: Output Format
Winner + Score (always at top)
## 🏆 Result: [SCORE]/100 — [PASS ✅ | NEEDS WORK ⚠️]
[Final content/artifact here]
**Iterations:** [N] rounds
**Panel:** [Expert names, comma-separated]If variants: show winner first, then runner-up scores.
## 🏆 Winner: Variant [X] — [SCORE]/100
[Winning content]
### Runner-up scores
- Variant A: 87/100
- Variant B: 82/100
- Variant C: 91/100 ← WinnerFeedback History (below the result)
Show full scoring rounds.
---
<details>
<summary>📊 Scoring History (N rounds)</summary>
[All round tables from Step 4]
</details>---
Step 6: Feedback-to-Source (When Scoring Another Skill's Output)
When the scored content came from another skill, generate a Source Improvement Brief:
## 🔁 Feedback for [Source Skill]
### What scored low
- [Pattern]: [Specific example from this content]
### Suggested skill improvements
- [Concrete change to the source skill's process/rubric/prompt]
### Patterns to add to source skill
- [Any recurring weakness that should become a rule]This brief can be used to update the source skill's SKILL.md or rubrics.
---
Step 7: Memory — Learn from Approvals and Rejections
After the user approves or rejects panel output:
On approval (score ≥ 90, user accepts)
Note what worked. No action needed unless a new positive pattern emerges.
On rejection (user overrides the panel or rejects 90+ content)
1. Ask why (or infer from context). 2. Add a new pattern to references/patterns.md using this format:
## [Pattern Name]
- **Type:** rejection | preference | override
- **Content types:** [which types this applies to]
- **Rule:** [What to always/never do]
- **Example:** [The specific instance that triggered this]
- **Date:** [YYYY-MM-DD]
- **Point dock:** [-N points when detected]3. Confirm: "Added pattern: [one-line summary]. Panel will dock [N] points for this going forward."
Pattern enforcement
Every scoring round, check references/patterns.md against the content. Apply point docks before expert scoring begins. This means known-bad patterns are penalized even if individual experts miss them.
---
Reference Files
| File | Purpose | When to read |
|---|---|---|
experts/humanizer.md | AI writing detection rubric (24 patterns) | Every scoring run |
experts/[domain].md | Pre-built expert panels for common domains | When domain matches |
scoring-rubrics/content-quality.md | Content scoring rubric | Content scoring |
scoring-rubrics/strategic-quality.md | Strategy scoring rubric | Strategy scoring |
scoring-rubrics/conversion-quality.md | Landing page/ad/CTA rubric | Conversion scoring |
scoring-rubrics/visual-quality.md | Chart/data viz/infographic rubric | Visual scoring |
scoring-rubrics/evaluation-quality.md | Candidate/assessment rubric | Eval scoring |
references/patterns.md | Learned rejection patterns | Every scoring run |
references/expert-assembly.md | Domain-expert examples for auto-assembly | When building unfamiliar panels |
{
"name": "content-ops",
"installedAt": 1776152091412,
"source": "marketplace",
"iconSource": "content-ops",
"version": "1.0.0"
}# ── Required ──
# Anthropic API key (for LLM-powered features: editorial brain, content transform, expert panel)
ANTHROPIC_API_KEY=sk-ant-...
# ── Optional: Data directory ──
# Override default data directory (default: ./data/)
# CONTENT_OPS_DATA_DIR=./data
# ── Optional: Editorial Brain ──
# Override default model for editorial brain clip discovery
# EDITORIAL_BRAIN_MODEL=claude-sonnet-4-20250514
# ── Optional: Quote Mining Engine ──
# Path to feeds JSON config: {"Feed Name": "https://feed-url/rss", ...}
# QUOTE_MINING_FEEDS_FILE=./config/feeds.json
# Inline feeds JSON (alternative to file)
# QUOTE_MINING_FEEDS={"My Podcast": "https://feeds.example.com/rss"}
# Directory containing meeting notes (markdown files) to scan for quotes
# QUOTE_MINING_NOTES_DIR=./notes/
# Speaker name to extract from meeting notes (e.g., "John Smith")
# QUOTE_MINING_SPEAKER=
# ── Optional: Content Transform ──
# Voice configuration file (markdown describing your brand voice)
# VOICE_CONFIG_FILE=./config/voice.md
# Style guide file (markdown with writing style rules)
# STYLE_GUIDE_FILE=./config/style-guide.md
{
"My Marketing Podcast": "https://feeds.example.com/marketing-podcast/rss",
"Industry Show": "https://feeds.example.com/industry-show/rss"
}
Expert Panel: AI Writing Detector (Humanizer)
Context
- Based on the 24 AI writing patterns from Wikipedia's "Signs of AI writing" guide
- This expert scores drafts on how AI-generated they sound
- Scoring: 0 = obviously AI-generated, 100 = indistinguishable from human
- This should be the LAST check before any draft is finalized
Scoring Rubric
Banned Vocabulary (instant -5 per occurrence)
delve, tapestry, landscape (abstract), leverage, multifaceted, nuanced, pivotal, realm, robust, seamless, testament, transformative, underscore (verb), utilize, whilst, keen, embark, comprehensive, intricate, commendable, meticulous, paramount, groundbreaking, innovative, cutting-edge, synergy, holistic, paradigm, ecosystem, Additionally, align with, crucial, enduring, enhance, fostering, garner, highlight (verb), interplay, intricacies, showcase, vibrant, valuable, profound, renowned, breathtaking, nestled, stunning
The 24 Patterns to Flag
CONTENT PATTERNS
1. Significance Inflation (-10) Puffing up importance with "stands as", "is a testament", "pivotal moment", "underscores its importance", "reflects broader", "setting the stage for", "indelible mark", "deeply rooted".
- Before: "This initiative marked a pivotal moment in the evolution of digital marketing."
- After: "The company launched its first programmatic ad campaign in 2019."
2. Undue Notability Claims (-5) Listing media mentions without context. "Active social media presence", "leading expert".
- Before: "His insights have been featured in Forbes, Inc, and Entrepreneur."
- After: "In a 2024 Forbes interview, he argued most marketing budgets are wasted on brand awareness."
3. Superficial -ing Analyses (-8) Tacking "-ing" phrases for fake depth: "highlighting", "underscoring", "emphasizing", "ensuring", "reflecting", "symbolizing", "contributing to", "fostering", "showcasing".
- Before: "The platform grew 40% YoY, showcasing the team's commitment to innovation and highlighting the importance of user experience."
- After: "The platform grew 40% YoY. Most of that came from a single referral loop they built in Q2."
4. Promotional Language (-8) "Boasts a", "vibrant", "rich" (figurative), "profound", "exemplifies", "commitment to", "natural beauty", "nestled", "in the heart of", "must-visit".
- Before: "The company boasts a vibrant team with a profound commitment to delivering groundbreaking results."
- After: "The company has 45 employees. Revenue grew 32% last year."
5. Vague Attributions (-8) "Industry reports", "Experts argue", "Some critics argue", "several sources". No specific citations.
- Before: "Experts believe AI will transform the marketing landscape."
- After: "A 2024 Gartner survey found 67% of CMOs plan to increase AI spend next year."
6. Formulaic "Challenges and Future" Sections (-10) "Despite its X, faces challenges...", "Despite these challenges, continues to Y", "Future Outlook".
- Before: "Despite these challenges, the company continues to thrive as a leader in the space."
- After: "Customer churn hit 8% in Q3. They hired a retention team in October."
LANGUAGE AND GRAMMAR PATTERNS
7. AI Vocabulary Clustering (-10) Multiple banned words in same paragraph. See banned list above.
- Before: "Additionally, this innovative approach showcases the intricate interplay between technology and creativity, highlighting its crucial role in the evolving landscape."
- After: "The tool saves about 3 hours per week on content scheduling. That's it."
8. Copula Avoidance (-5) Using "serves as", "stands as", "marks", "represents", "boasts", "features", "offers" instead of simple "is/are/has".
- Before: "The newsletter serves as a valuable resource for marketers."
- After: "The newsletter is a resource for marketers. 12K subscribers open it weekly."
9. Negative Parallelisms (-5) "Not only...but...", "It's not just about X, it's Y", "It's not merely X, it's Y".
- Before: "It's not just about the content; it's about building a lasting relationship with your audience."
- After: "Good content gets replies. That's how you build an audience."
10. Rule of Three Overuse (-8) Forcing ideas into groups of three. Triple adjectives, triple nouns, triple parallel clauses.
- Before: "The event features keynote sessions, panel discussions, and networking opportunities."
- After: "The event has talks and panels. There's also time for networking between sessions."
11. Elegant Variation / Synonym Cycling (-5) Excessive synonym substitution to avoid repetition.
- Before: "The CEO shared his vision. The business leader outlined the strategy. The company head detailed the plan."
- After: "The CEO shared his vision and outlined the strategy."
12. False Ranges (-5) "From X to Y" where X and Y aren't on a meaningful scale.
- Before: "From content creation to audience engagement, from SEO to paid media, the landscape is shifting."
- After: "Content, SEO, and paid media are all changing. Here's what actually matters."
STYLE PATTERNS
13. Em Dash Overuse (-5) More than 1 em dash per 200 words. AI uses them for "punchy" sales writing.
14. Overuse of Boldface (-3) Mechanical bold emphasis on every key term.
15. Inline-Header Vertical Lists (-5) Lists where every item starts with a bolded header + colon.
16. Title Case in Headings (-3) Capitalizing All Main Words In Every Heading.
17. Emoji Decoration (-5) Emojis on headings or bullet points (🚀💡✅).
18. Curly Quotation Marks (-2) Using " " instead of " ".
COMMUNICATION PATTERNS
19. Collaborative Artifacts (-10) "I hope this helps", "Of course!", "Certainly!", "Would you like...", "let me know", "here is a...".
20. Knowledge-Cutoff Disclaimers (-10) "As of [date]", "While specific details are limited", "based on available information".
21. Sycophantic Tone (-8) "Great question!", "You're absolutely right!", "That's an excellent point!"
FILLER AND HEDGING
22. Filler Phrases (-5 each) "In order to" → "To". "Due to the fact that" → "Because". "At this point in time" → "Now". "It is important to note that" → just state it.
23. Excessive Hedging (-8) "Could potentially possibly", "might have some effect", "it could be argued that".
- Before: "It could potentially be argued that this approach might have some positive impact."
- After: "This approach works. Here's the data."
24. Generic Positive Conclusions (-10) "The future looks bright", "Exciting times lie ahead", "continues their journey toward excellence".
- Before: "The future looks bright for AI in marketing. Exciting times lie ahead."
- After: "They plan to double their AI budget next quarter. We'll see if it pays off."
Scoring Method
Start at 100. Deduct points for each pattern detected (penalties listed above). Multiple occurrences of the same pattern stack (up to 2x the base penalty).
- 90-100: Human-sounding. Clean.
- 70-89: Minor AI tells. Quick fixes needed.
- 50-69: Obvious AI patterns. Significant rewrite needed.
- 0-49: Reads like ChatGPT output. Full rewrite.
What Good Looks Like
Good human writing has:
- Opinions, not just reporting
- Varied sentence rhythm (short punches + longer ones)
- Specific details over vague claims
- Simple verbs (is, has, does) over elaborate constructions
- Acknowledgment of uncertainty or mixed feelings
- First-person perspective when appropriate
- Humor, edge, or personality
- Concrete examples with names, dates, numbers
Expert Panel: Instagram Visual Content
Context
- Focus on Instagram infographic and data-driven post captions
- Data-forward, insight-dense, and visually bold content
- Captions should be punchy, hashtagged, and scroll-stopping
The 6 Experts
1. Visual Impact Scorer — Is the image concept scroll-stopping? Would someone pause their scroll for this graphic? Does the headline/hook on the visual create an immediate "I need to read this" reaction? Checks: bold contrast, clear hierarchy, data visualization quality, thumb-stopping composition.
2. Caption Copywriter — Is the caption punchy and platform-native? First line is the hook before "more" truncation. Body delivers the insight in 2-3 tight sentences. Hashtags are relevant and placed at the end. No fluff, no filler. Checks: hook strength, hashtag relevance (4-8 tags), caption length (ideal 125-200 chars), CTA presence.
3. Data Accuracy Checker — Is the stat or insight correct and properly sourced? Is this a real data point, not a vague "studies show" claim? Is the number specific, recent (within 12 months preferred), and directly relevant? Checks: specificity of data, source existence, recency, no hallucinated stats.
4. Timeliness Validator — Is this insight still relevant and interesting today? Has this exact take already flooded Instagram this week? Does the topic align with current conversations in the target space? Checks: topic freshness, differentiation from overposted angles.
5. Brand Consistency — Does this match the configured brand voice? Direct, data-driven, slightly contrarian, ROI-obsessed. No motivation porn. No vague inspiration. Only specific, actionable, data-backed insights. Checks: brand voice alignment, topic fit, no generic "hustle" content.
6. AI Writing Detector (Humanizer) — 1.5x weight. Checks caption text specifically. Instagram captions fail when they sound AI-generated. See experts/humanizer.md for the full 24-pattern rubric. Special Instagram flags: forced hashtag stuffing, overly polished corporate tone, "💡 Key insight:" formatting, sycophantic openers, significance inflation.
Scoring Criteria
- Visual hook — Would someone stop scrolling for this?
- Caption punch — First sentence earns the "more" tap
- Data credibility — Real numbers, real sources
- Timeliness — Fresh angle, not stale take
- Brand fit — Matches configured brand voice
- Human voice — Reads like a real person, not a content bot
Expert Panel: LinkedIn Posts
The 10 Experts
1. B2B Thought Leader — Does this establish authority without being preachy? Would a CMO reshare this? 2. LinkedIn Algorithm Specialist — Hook before "see more" fold, dwell time signals, comment-driving structure 3. Storytelling Coach — Is there a real story? Personal anecdote? Emotional arc? 4. Executive Brand Builder — Does this build the author's brand as a founder/operator, not just a content creator? 5. Engagement Optimizer — Will this get comments, not just likes? Is there a debate hook? 6. Hook Writer — First 2-3 lines before the fold. Would you click "see more"? 7. Professional Copywriter — Professional but not corporate. Warm but not soft. Every sentence counts. 8. Data Visualization Expert — Are numbers presented compellingly? Could a stat be formatted as a callout? 9. Community Builder — Does this invite conversation? Does it make readers feel part of something? 10. Brand Voice Match Evaluator — Authentic voice: direct, personal anecdotes, specific numbers, contrarian but credible. 11. AI Writing Detector (Humanizer) — Scores how AI-generated the draft sounds. Checks all 24 humanizer patterns. See experts/humanizer.md for full rubric. This expert's score is weighted 1.5x.
Scoring Criteria
- Hook before "see more" fold — First 2-3 lines must compel the click
- Story arc — Setup → insight → takeaway
- Professional but not corporate — No jargon, no "I'm excited to announce"
- Personal anecdotes — Real stories from experience
- Specific data — Numbers, percentages, dollar amounts
- Engagement drivers — Questions, debate hooks, "what would you do?"
- Line spacing/readability — Short paragraphs, white space, scannable
- CTA that drives comments — Not "like and share" but genuine engagement prompts
Expert Panel: Newsletter
The 10 Experts
1. Email Marketer — Deliverability, open rate optimization, sender reputation signals 2. Newsletter Growth Expert — Is this the kind of content that drives forwards and referrals? 3. Copywriter — Every sentence earns its place. No filler. Punchy and clear. 4. Data Journalist — Are claims backed by data? Are sources credible and recent? 5. CTA Specialist — Is there a clear action? Does the reader know what to do next? 6. Subject Line Expert — Would you open this email? 40-50 chars, curiosity or value signal 7. Retention Specialist — Will subscribers stay after reading this? Does it deliver on the promise? 8. Layout/Formatting Coach — Scannable? Headers, bullets, bold text for skimmers? 9. Value-Per-Word Optimizer — Information density. Could this be 20% shorter and still deliver? 10. Brand Voice Match Evaluator — Does this sound like the author's newsletter voice: direct, data-rich, actionable? 11. AI Writing Detector (Humanizer) — Scores how AI-generated the draft sounds. See experts/humanizer.md for full rubric. This expert's score is weighted 1.5x.
Scoring Criteria
- Value density — Every paragraph teaches something specific
- Scanability — Headers, bullets, bold. A skimmer gets 80% of the value
- "Why this matters" clarity — Reader knows immediately why they should care
- CTA clarity — One clear next action
- Would I forward this? — The ultimate newsletter test
- Subject line — Opens the email, sets expectations
Expert Panel: Podcast Quote Cards
Context
- Focus on quote cards extracted from podcast episodes or guest appearances
- Quote cards live on Instagram, LinkedIn, and X — they must work visually AND as text
- Target audience: marketers, agency owners, founders, operators
The 6 Experts
1. Quote Impact Scorer — Is this actually quotable? Would someone screenshot this and send it to a friend? The best quotes are contrarian, counter-intuitive, or confirm what people secretly believe. A good podcast quote card captures a single strong idea in under 20 words. Checks: quotability (screenshot factor), idea density, surprise or confirmation bias appeal, standalone power.
2. Context Validator — Does the quote make sense without the full episode? Quote cards get ripped from context constantly. This expert asks: if someone sees this with zero episode context, do they understand what's being said? Checks: self-contained clarity, no pronouns without clear referents, no jargon that needs explanation, no "as I was saying" fragments.
3. Attribution Accuracy — Is the quote attributed correctly? Correct speaker name and title. No misattribution, no paraphrase presented as direct quote. Checks: speaker name matches voice, attribution format is clean, no fabricated or paraphrased quotes passed as verbatim.
4. Audience Relevance — Does the target audience care about this topic? Topics that resonate with marketers/founders: AI tools, growth, SEO, paid media, content, hiring, revenue ops. Topics that don't: generic lifestyle advice, unrelated industries, personal stories without business lesson. Checks: topic-audience fit, actionability, connection to current trends.
5. Visual Text Scorer — Will this text read well on an image card? Quote cards are read at thumbnail size on mobile. Long quotes fail. Checks: character count (under 120 chars ideal), no awkward line breaks, bold-friendly phrasing, visual rhythm of the sentence.
6. AI Writing Detector (Humanizer) — 1.5x weight. Applies to both the quote text and the caption. Quotes from real podcast episodes should sound natural and human. Red flags: cleaned-up quotes that lost the natural speech rhythm, overly polished paraphrases, AI-added context that inflates the quote's importance. See experts/humanizer.md for the full rubric.
Scoring Criteria
- Screenshot factor — Would someone save and share this?
- Self-contained — Makes sense without episode context
- Attribution accuracy — Correct speaker, correct format
- Audience fit — Relevant to marketers/founders
- Visual readability — Works at small size on mobile
- Human voice — Sounds like a real person said this
Quote Standards
- Under 120 characters is ideal for visual cards
- Direct quotes only — no paraphrasing
- Attribution: "— [Name]" or "[Name] on [Show Name]"
- Caption: 1-2 sentences + 3-5 hashtags
Expert Panel: Recruiting
The 10 Experts
1. Agency Recruiter — Understands agency culture, pace, and what makes someone thrive vs burn out 2. Talent Acquisition Leader — Pipeline strategy, sourcing channels, employer branding 3. Hiring Manager — Day-to-day fit. Can this person actually do the job on day one? 4. Culture Fit Assessor — Values alignment, team dynamics, growth mindset indicators 5. Compensation Analyst — Is the offer competitive? Market rate awareness 6. LinkedIn Sourcer — Profile signals, career trajectory patterns, red flags in work history 7. Diversity Specialist — Diverse perspectives, inclusive hiring practices, bias checks 8. Startup Hiring Expert — Can this person handle ambiguity, wear multiple hats, move fast? 9. AI Fluency Evaluator — Does this candidate use AI tools? Can they leverage AI in their role? 10. Industry Insider — Understands the relevant industry landscape, competitor talent pools
Scoring Criteria
- Candidate-role fit — Skills, experience, and trajectory match the role requirements
- Evidence quality — Claims backed by portfolio, metrics, references (not just resume bullets)
- Risk assessment accuracy — Honest about gaps, flight risk, culture mismatch potential
- Outreach angle creativity — What would make this person respond to a cold message?
- AI fluency signal strength — Evidence of AI tool usage, automation mindset, future-readiness
Expert Panel: SEO Strategy
The 10 Experts
1. Technical SEO Specialist — Site architecture, crawlability, Core Web Vitals, structured data 2. Content Strategist — Topic clusters, content gaps, SERP intent alignment 3. Conversion Rate Optimizer — Does the SEO strategy connect to revenue, not just traffic? 4. Revenue Attribution Expert — Can we tie this recommendation to dollar outcomes? 5. Competitive Analyst — What are competitors doing? Where are the gaps? 6. AI/AEO Specialist — How does this strategy account for AI Overviews, ChatGPT citations, Perplexity? 7. Data Scientist — Is the analysis statistically sound? Are trends real or noise? 8. Growth Hacker — What's the fastest path to measurable results? 9. Operations Expert — Is this feasible with current resources and timelines? 10. ROI Calculator — Does this pass a 4:1 ROI bar? What's the expected return?
Scoring Criteria
- Data backing — Real data cited, not projections or assumptions
- Actionable specificity — Clear next steps, not vague "optimize your content"
- ROI estimate quality — Realistic, with assumptions stated
- Risk assessment — Honest about what could go wrong
- Feasibility — Can this actually be executed with available resources?
- Alignment with priorities — Serves current business goals
Expert Panel: X Articles (Long-Form X Posts)
Context
- Focus on X ARTICLES (long-form X posts), not just threads
- These are meaty, value-dense posts that stop the scroll and deliver insight
The 10 Experts
1. Viral X Writer — Judges structure, pacing, and viral mechanics. Does this follow the patterns that get 100K+ impressions? 2. Engagement Strategist — Analyzes reply-bait, shareability, and algorithm signals. Will this get engagement or just impressions? 3. Hook Specialist — First 2 lines only. Would you stop scrolling? Is there a curiosity gap, contrarian claim, or surprising stat? 4. Data Storytelling Expert — Are the numbers specific, recent, and surprising? Are they woven into narrative or just dropped in? 5. Contrarian Positioning Coach — Is there a genuine contrarian angle? Or is this just conventional wisdom repackaged? 6. CTA Optimizer — Does the ending drive action? Comments, follows, saves? Is it natural or forced? 7. Audience Growth Expert — Will this attract NEW followers or just engage existing ones? Does it signal expertise? 8. Algorithm Specialist — Post length, formatting, engagement signals. Will X's algorithm boost this? 9. Copywriter — Sentence-level quality. Short punchy sentences? No filler? Every word earns its place? 10. Brand Voice Match Evaluator — Does this sound like a real person wrote it? Authentic voice: direct, personal anecdotes, specific numbers, contrarian but credible. 11. AI Writing Detector (Humanizer) — Scores how AI-generated the draft sounds. Checks all 24 humanizer patterns: banned vocabulary, significance inflation, formulaic structures, vague attributions, promotional language, hedging, em dash overuse, triple structures, generic conclusions. See experts/humanizer.md for full rubric. This expert's score is weighted 1.5x — if it flags the draft as AI-sounding, the draft MUST be revised.
Scoring Criteria
- Hook in first 2 lines — Would you stop scrolling for this?
- Data specificity — Real numbers, not vague claims
- Contrarian angle — Genuine insight, not clickbait
- Story arc — Setup → tension → payoff
- Voice authenticity — Sounds like a real person, not a content mill
- CTA strength — Natural engagement driver
- Readability — Short paragraphs, line breaks, scannable
- Shareability — "I need to repost this"
- Visual elements — At least one ASCII diagram or visual element
Expert Panel: YouTube Shorts
The 10 Experts
1. Short-Form Creator — Does this work as a standalone piece? Would you watch it on your For You page? 2. Retention Curve Specialist — Where will viewers drop off? Is every second justified? 3. Script Doctor — Is the script tight? No wasted words? Clear structure? 4. Visual Storytelling Expert — What should be on screen at each moment? B-roll, text overlays, screen shares? 5. TikTok/Reels Crossover Expert — Would this work cross-platform? Format-native for each? 6. Pacing Coach — Is the energy right? No dead spots? Builds momentum? 7. Hook Specialist (First 2 Sec) — Would you NOT swipe away in the first 2 seconds? 8. Payoff Designer — Does the ending deliver? Is there a satisfying resolution or surprise? 9. Re-watch Optimizer — Is there a loop? A detail you'd catch on second watch? 10. Brand Voice Match Evaluator — Does this sound like a real person on camera? Direct, confident, slightly irreverent? 11. AI Writing Detector (Humanizer) — Scores how AI-generated the draft sounds. See experts/humanizer.md for full rubric. This expert's score is weighted 1.5x.
Scoring Criteria
- Hook in first 2 seconds — Pattern interrupt, surprising claim, or visual hook
- Setup-payoff structure — Clear promise → delivery
- 30-60 sec runtime — Tight, no filler
- Visual cue quality — Text overlays, B-roll suggestions, screen share moments
- "Would I watch this twice?" — Re-watch value
- Shareability — "Send this to someone who needs to hear this"
- CTA — Natural, not forced ("Comment X and I'll show you")
Expert Assembly Guide
Examples of domain-specific experts to add based on offer context. Use this when auto-assembling panels for unfamiliar domains.
Assembly principle
The panel needs experts who understand both the craft (how to make good content/strategy) and the domain (the specific market, audience, and offer being scored).
---
Domain Expert Examples
SaaS / Software
- SaaS Conversion Expert — free trial vs demo, PLG patterns, activation metrics
- Developer Audience Specialist — if targeting devs, knows what resonates vs cringe
- Pricing Page Analyst — tier structure, anchoring, feature comparison tables
E-Commerce / DTC
- DTC Brand Strategist — unboxing, retention loops, subscription models
- Product Page Optimizer — hero images, reviews, urgency without fakeness
- Email/SMS Commerce Expert — abandoned cart, post-purchase, winback flows
Healthcare / Medical
- Healthcare Compliance Expert — HIPAA, FDA advertising rules, claim substantiation
- Patient Communication Specialist — empathy without condescension, plain language
- Medical Professional Audience Expert — if targeting HCPs, clinical credibility
Financial Services
- FinServ Compliance Reviewer — SEC/FINRA advertising rules, disclaimers
- Trust & Authority Expert — credential signaling, risk communication
- Retail Investor Audience Specialist — jargon translation, fear/greed calibration
Food & Beverage / Restaurant
- Food Marketing Expert — appetite appeal, sensory language, seasonal hooks
- Local Business Marketing Specialist — geo-targeting, community signals
- Visual Food Stylist — photography/visual standards for food content
Professional Services / Agency
- B2B Services Buyer Expert — what CMOs/VPs actually respond to
- Case Study Analyst — proof structure, metrics that matter, client story arc
- Competitive Positioning Expert — differentiation in crowded service markets
Education / Courses
- Course Launch Expert — urgency, social proof, transformation promise
- Curriculum Designer — learning outcomes, module structure, completion optimization
- Student Success Storyteller — before/after, specific outcomes, relatable journeys
Real Estate
- Real Estate Marketing Expert — listing copy, neighborhood selling, visual standards
- Luxury Market Specialist — if high-end, understands aspiration vs information
- Lead Nurture Expert — long sales cycles, drip sequence optimization
---
Universal Experts (always consider)
These roles apply to nearly any domain:
- Audience Empathy Expert — Does the scorer actually understand the target audience's
daily reality, pain points, and language?
- Competitive Context Expert — What else is the audience seeing? Is this differentiated
or just another version of what everyone says?
- Offer Clarity Expert — Can someone understand what they get, what it costs, and what
happens next in under 10 seconds?
---
When no pre-built panel exists
1. Identify the content type → pick 3-4 craft experts (copywriter, designer, strategist, etc.) 2. Identify the domain → pick 2-3 domain experts from above or synthesize new ones 3. Add humanizer (mandatory) and brand voice match (mandatory) 4. Cap at 10, merge overlapping roles
Learned Patterns
Patterns learned from content approvals and rejections. The expert panel checks these before scoring begins and docks points for known-bad patterns.
<!-- Add patterns as they are learned. Format:
[Pattern Name]
- Type: rejection | preference | override
- Content types: [which types this applies to]
- Rule: [What to always/never do]
- Example: [The specific instance that triggered this]
- Date: [YYYY-MM-DD]
- Point dock: [-N points when detected]
-->
# Core dependencies
anthropic>=0.39.0 # Claude API client (for LLM-powered features)
feedparser>=6.0.0 # RSS feed parsing (quote mining engine)
# Optional: for video clip cutting (editorial brain)
# yt-dlp # YouTube subtitle/video download (install separately)
# ffmpeg # Video cutting (install via system package manager)
Content Quality Rubric (0-100)
Hook Power (0-25)
- 0-5: Generic, no reason to keep reading
- 6-15: Interesting but not urgent
- 16-20: Strong curiosity gap or contrarian claim
- 21-25: Impossible to scroll past. Specific, surprising, personal.
Voice Authenticity (0-25)
- Does this sound like a real person wrote it?
- Short punchy sentences? Specific numbers? Personal framing?
- No corporate jargon? No filler words?
- Contrarian but backed by data?
Value Density (0-25)
- Every sentence earns its place
- Specific data points, not vague claims
- Actionable insight, not just observation
- "I learned something I can use today"
Engagement Potential (0-25)
- Would someone share/repost this?
- Does the CTA invite genuine response?
- Does it spark debate or agreement?
- Platform-native formatting?
Conversion Quality Rubric (0-100)
For landing pages, ads, CTAs, signup flows, pricing pages.
Headline / Hero (0-25)
- 0-5: Generic, no clear value prop
- 6-15: Communicates offer but not compelling
- 16-20: Clear value prop with specificity
- 21-25: Impossible to bounce. Specific, urgent, addresses the visitor's exact pain.
Clarity & Friction (0-25)
- Is the offer immediately obvious? (3-second test)
- Can a visitor complete the desired action without confusion?
- Are there unnecessary form fields, steps, or distractions?
- Does copy match the traffic source expectation?
Social Proof & Trust (0-25)
- Specific results (numbers, names, companies) vs vague testimonials
- Trust signals (logos, security badges, guarantees) present and credible
- Case studies or data points that prove the claim
- No fake urgency or manufactured scarcity
CTA Strength (0-25)
- CTA copy specific to the action ("Get my audit" > "Submit")
- CTA visible without scrolling
- Single clear primary action (no competing CTAs)
- Micro-copy reduces anxiety ("No credit card required", "2-minute setup")
Evaluation Quality Rubric (0-100)
For candidate assessments, vendor evaluations, tool comparisons, opportunity scoring.
Evidence Quality (0-25)
- Claims backed by data, portfolio, references, or verifiable metrics
- No resume-bullet-level assertions without proof
- Specific examples cited (projects, outcomes, timelines)
- Red flags acknowledged, not glossed over
Criteria Relevance (0-25)
- Evaluation criteria match the actual role/need
- Weighted by what matters most (not equal weight to everything)
- Context-appropriate (startup vs enterprise, junior vs senior)
- Anti-criteria considered (what would make this a bad fit?)
Risk Assessment (0-25)
- Honest about gaps, unknowns, and flight risk
- Mitigation strategies suggested for identified risks
- Comparison to alternatives or market baseline
- No false confidence — uncertainty stated clearly
Actionability (0-25)
- Clear recommendation (hire/pass/shortlist, buy/skip, proceed/wait)
- Next steps defined
- Decision criteria transparent
- Dissenting view included if panel is split
Strategic Quality Rubric (0-100)
Data Foundation (0-25)
- Real data cited, not projections
- Sources verifiable
- Numbers specific and recent
Actionability (0-25)
- Clear next step
- Timeline realistic
- Resources identified
ROI Clarity (0-25)
- 4:1 minimum demonstrated
- Costs estimated
- Comparison to alternatives
Risk Assessment (0-25)
- Honest about what could go wrong
- Mitigation plan included
- Dependencies identified
Visual Quality Rubric (0-100)
For charts, data visualizations, infographics, diagrams, slide decks.
Data Accuracy & Integrity (0-25)
- Numbers match the source data
- Axes labeled correctly, scales not misleading
- No cherry-picked timeframes or truncated axes
- Source cited
Visual Clarity (0-25)
- Can a viewer understand the main point in under 5 seconds?
- Labels readable at expected display size
- Color choices accessible (colorblind-safe)
- No chart junk (unnecessary gridlines, 3D effects, decorative elements)
Insight Delivery (0-25)
- Does the visualization tell a story or just display data?
- Is the "so what?" obvious without explanation?
- Annotations highlight the key takeaway
- Title states the insight, not just the topic ("Revenue doubled in Q3" > "Revenue by quarter")
Design & Polish (0-25)
- Consistent typography and color palette
- Proper alignment and spacing
- Brand-appropriate styling
- Mobile/thumbnail readable if applicable
#!/usr/bin/env python3
"""
Content Quality Gate — CI/CD-style gate for content publishing.
Filters drafts through quality scorer before they publish.
Nothing goes live without passing automated quality scoring.
Usage:
python content-quality-gate.py --input drafts.json
python content-quality-gate.py --input drafts.json --conservative
python content-quality-gate.py --input drafts.json --threshold 75
"""
import json
import os
import sys
import argparse
from pathlib import Path
from datetime import datetime, timezone
import subprocess
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
DATA_DIR = Path(os.environ.get("CONTENT_OPS_DATA_DIR", PROJECT_DIR / "data"))
DRAFTS_INPUT_FILE = DATA_DIR / "content-drafts-latest.json"
DRAFTS_OUTPUT_FILE = DATA_DIR / "content-drafts-filtered.json"
QUALITY_SCORES_FILE = DATA_DIR / "quality-scores-latest.json"
def run_quality_scorer(input_file, verbose=False):
"""Run the quality scorer on the drafts file."""
scorer_script = SCRIPT_DIR / "content-quality-scorer.py"
cmd = [
sys.executable,
str(scorer_script),
"--input", str(input_file)
]
if verbose:
cmd.append("--verbose")
print(f"🔍 Running quality scorer...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ Quality scorer failed:")
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
return False
if verbose:
print(result.stdout)
return True
def load_quality_scores():
"""Load the latest quality scoring results."""
if not QUALITY_SCORES_FILE.exists():
print(f"❌ Quality scores file not found: {QUALITY_SCORES_FILE}")
return None
try:
with open(QUALITY_SCORES_FILE) as f:
return json.load(f)
except Exception as e:
print(f"❌ Error loading quality scores: {e}")
return None
def filter_drafts_by_quality(drafts, quality_results, conservative_mode=False):
"""Filter drafts based on quality scores."""
if not quality_results or "results" not in quality_results:
print("❌ No quality results available for filtering")
return drafts, []
passed_ids = set()
failed_drafts = []
quality_by_id = {}
for result in quality_results["results"]:
draft_id = result.get("draft_id")
quality_by_id[draft_id] = result
if result.get("passed", False):
passed_ids.add(draft_id)
else:
failed_drafts.append({
"draft_id": draft_id,
"platform": result.get("platform"),
"score": result.get("total_score"),
"reasons": result.get("failure_reasons", [])
})
filtered_drafts = []
for draft in drafts:
draft_id = draft.get("id")
if draft_id in quality_by_id:
quality_info = quality_by_id[draft_id]
draft["quality_score"] = quality_info.get("total_score")
draft["quality_passed"] = quality_info.get("passed")
draft["quality_reasons"] = quality_info.get("failure_reasons", [])
draft["quality_scored_at"] = quality_info.get("scored_at")
if conservative_mode:
filtered_drafts.append(draft)
elif draft_id in passed_ids:
filtered_drafts.append(draft)
return filtered_drafts, failed_drafts
def save_filtered_drafts(original_data, filtered_drafts, quality_results):
"""Save filtered drafts with quality metadata."""
filtered_data = original_data.copy()
filtered_data["drafts"] = filtered_drafts
filtered_data["filtered_at"] = datetime.now(timezone.utc).isoformat()
filtered_data["quality_gate_applied"] = True
filtered_data["original_draft_count"] = original_data.get("draft_count", len(original_data.get("drafts", [])))
filtered_data["filtered_draft_count"] = len(filtered_drafts)
filtered_data["quality_threshold"] = quality_results.get("threshold")
filtered_data["quality_pass_rate"] = quality_results.get("pass_rate")
filtered_data["quality_average_score"] = quality_results.get("average_score")
filtered_data["draft_count"] = len(filtered_drafts)
DRAFTS_OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(DRAFTS_OUTPUT_FILE, 'w') as f:
json.dump(filtered_data, f, indent=2)
return filtered_data
def run_quality_gate(input_file=None, conservative_mode=False, verbose=False):
"""Run the complete quality gate process."""
input_path = Path(input_file) if input_file else DRAFTS_INPUT_FILE
if not input_path.exists():
print(f"❌ Input file not found: {input_path}")
return None
try:
with open(input_path) as f:
original_data = json.load(f)
drafts = original_data.get("drafts", [])
print(f"📊 Loaded {len(drafts)} drafts from {input_path}")
except Exception as e:
print(f"❌ Error loading drafts: {e}")
return None
if not drafts:
print("❌ No drafts found in input file")
return None
if not run_quality_scorer(input_path, verbose):
return None
quality_results = load_quality_scores()
if not quality_results:
return None
filtered_drafts, failed_drafts = filter_drafts_by_quality(drafts, quality_results, conservative_mode)
filtered_data = save_filtered_drafts(original_data, filtered_drafts, quality_results)
original_count = len(drafts)
filtered_count = len(filtered_drafts)
filtered_out = original_count - filtered_count
print(f"\n{'='*60}")
print(f"QUALITY GATE RESULTS")
print(f"{'='*60}")
print(f"Original drafts: {original_count}")
print(f"Passed quality gate: {filtered_count}")
print(f"Filtered out: {filtered_out}")
print(f"Pass rate: {quality_results.get('pass_rate', 0):.1f}%")
print(f"Average score: {quality_results.get('average_score', 0):.1f}/100")
print(f"Threshold: {quality_results.get('threshold', 60)}/100")
if conservative_mode:
print(f"\n⚠️ CONSERVATIVE MODE: All drafts passed through with quality flags")
platform_stats = {}
for draft in filtered_drafts:
platform = draft.get("platform", "unknown")
platform_stats[platform] = platform_stats.get(platform, 0) + 1
if platform_stats:
print(f"\n📱 Filtered Drafts by Platform:")
for platform, count in sorted(platform_stats.items()):
print(f" {platform}: {count}")
if failed_drafts:
failure_reasons = {}
for failed in failed_drafts:
for reason in failed["reasons"]:
failure_reasons[reason] = failure_reasons.get(reason, 0) + 1
if failure_reasons:
print(f"\n❌ Top Failure Reasons:")
for reason, count in sorted(failure_reasons.items(), key=lambda x: x[1], reverse=True)[:3]:
print(f" {reason}: {count} drafts")
print(f"\n💾 Filtered drafts saved to: {DRAFTS_OUTPUT_FILE}")
if filtered_count == 0:
print("\n⚠️ WARNING: No drafts passed quality gate!")
print("Consider lowering threshold or improving content quality.")
return None
return filtered_data
def main():
parser = argparse.ArgumentParser(description="Filter content drafts through quality gate")
parser.add_argument("--input", type=str, help="Input drafts JSON file")
parser.add_argument("--conservative", action="store_true", help="Pass all drafts but add quality flags")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
parser.add_argument("--threshold", type=float, help="Override quality threshold")
args = parser.parse_args()
if args.threshold:
weights_file = DATA_DIR / "quality-scorer-weights.json"
if weights_file.exists():
try:
with open(weights_file) as f:
weights_data = json.load(f)
weights_data["threshold"] = args.threshold
with open(weights_file, 'w') as f:
json.dump(weights_data, f, indent=2)
print(f"🎯 Set threshold to {args.threshold}")
except Exception as e:
print(f"⚠ Could not update threshold: {e}")
filtered_data = run_quality_gate(
input_file=args.input,
conservative_mode=args.conservative,
verbose=args.verbose
)
if filtered_data:
filtered_count = filtered_data.get("filtered_draft_count", 0)
if filtered_count > 0:
print(f"\n📤 Next: Pass filtered drafts to your publishing pipeline")
else:
print(f"\n⚠️ No drafts to publish. Consider:")
print(f" • Lowering threshold: --threshold 50")
print(f" • Conservative mode: --conservative")
print(f" • Improving content quality in transform step")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Content Quality Scorer — Automated content scoring engine.
Scores drafts against configurable voice patterns BEFORE they publish.
Five scoring dimensions: voice similarity, specificity, AI slop detection,
length appropriateness, and engagement potential.
Input: JSON file with drafts array
Output: scored drafts with pass/fail recommendations
Usage:
python content-quality-scorer.py --input drafts.json --verbose
python content-quality-scorer.py --input drafts.json --threshold 75
python content-quality-scorer.py --init-weights # Create default weights file
"""
import json
import re
import os
import sys
import argparse
from pathlib import Path
from datetime import datetime, timezone
from collections import Counter
import math
# ── Configuration (all paths relative/configurable) ──
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
DATA_DIR = Path(os.environ.get("CONTENT_OPS_DATA_DIR", PROJECT_DIR / "data"))
DRAFTS_FILE = DATA_DIR / "content-drafts-latest.json"
WEIGHTS_FILE = DATA_DIR / "quality-scorer-weights.json"
LOG_FILE = DATA_DIR / "quality-scores-log.json"
# Default scoring threshold (adjustable)
DEFAULT_THRESHOLD = 60
# Platform character limits
PLATFORM_LIMITS = {
"x": {"min": 50, "max": 280, "optimal_min": 150, "optimal_max": 260},
"linkedin": {"min": 200, "max": 1500, "optimal_min": 500, "optimal_max": 1200},
"youtube_short": {"min": 100, "max": 800, "optimal_min": 200, "optimal_max": 600},
"newsletter": {"min": 300, "max": 2000, "optimal_min": 800, "optimal_max": 1600},
}
# Banned AI words — penalized in scoring
BANNED_WORDS = [
"leverage", "synergy", "ecosystem", "holistic", "at the end of the day",
"delve", "tapestry", "landscape", "multifaceted", "nuanced", "pivotal",
"realm", "robust", "seamless", "testament", "transformative", "underscore",
"utilize", "whilst", "keen", "embark", "comprehensive", "intricate",
"commendable", "meticulous", "paramount", "groundbreaking", "innovative",
"cutting-edge", "paradigm", "Additionally", "crucial", "enduring",
"enhance", "fostering", "garner", "highlight", "interplay", "intricacies",
"showcase", "vibrant", "valuable", "profound", "renowned", "breathtaking",
"nestled", "stunning", "I'm excited to share", "I think maybe",
"It could potentially", "dive into", "game-changer", "unlock"
]
# AI patterns to detect
AI_PATTERNS = [
(r"pivotal moment|is a testament|stands as", "significance_inflation"),
(r"boasts|vibrant|commitment to", "promotional_language"),
(r"experts believe|industry reports|studies show", "vague_attribution"),
(r"despite.{1,50}continues to", "formulaic_structure"),
(r"serves as|acts as|functions as", "copula_avoidance"),
(r"it's not just .{1,30}, it's", "negative_parallelism"),
(r"could potentially|might possibly|may perhaps", "excessive_hedging"),
(r"the future looks bright|exciting times ahead|stay tuned", "generic_conclusion"),
]
# Voice markers — configurable positive signals for your brand voice
# Override these by setting VOICE_MARKERS_FILE env var pointing to a JSON file
VOICE_MARKERS = [
# Numbers with specificity
(r'\$[\d,]+[KkMmBb]?(?:\+)?', 2.0, "revenue_markers"),
(r'\d+%', 1.5, "percentage_stats"),
(r'\d+x', 1.5, "multiplier_stats"),
(r'\d+ (?:hours?|minutes?|days?|weeks?|months?|years?)', 1.0, "time_specifics"),
(r'\d+ (?:pages?|pieces?|tools?|agents?|companies|founders?|members)', 1.0, "count_specifics"),
# Personal framing
(r'I (?:built|found|asked|remember|had lunch)', 2.0, "personal_framing"),
(r'Here\'s what happened|A friend who|I asked \d+', 1.5, "story_framing"),
# Contrarian hooks
(r'Most people .{1,50} wrong|Everyone says .{1,30} That\'s', 2.0, "contrarian_hooks"),
(r'Harsh reality:', 1.5, "harsh_reality"),
# Engagement patterns
(r'What\'s your take\?|What did I miss\?|What would you do', 1.0, "engagement_cta"),
# Short sentences (under 15 words)
(r'[.!?]\s+[A-Z][^.!?]{1,75}[.!?]', 0.5, "short_sentences"),
]
# Default scoring weights
DEFAULT_WEIGHTS = {
"voice_similarity": 0.35,
"specificity": 0.25,
"slop_penalty": 0.20,
"length_appropriateness": 0.10,
"engagement_potential": 0.10,
}
def load_weights():
"""Load scoring weights from file or return defaults."""
if WEIGHTS_FILE.exists():
try:
with open(WEIGHTS_FILE) as f:
data = json.load(f)
weights = data.get("weights", DEFAULT_WEIGHTS)
threshold = data.get("threshold", DEFAULT_THRESHOLD)
return weights, threshold
except Exception as e:
print(f"⚠ Error loading weights: {e}, using defaults")
return DEFAULT_WEIGHTS, DEFAULT_THRESHOLD
def save_weights(weights, threshold):
"""Save scoring weights and threshold to file."""
data = {
"weights": weights,
"threshold": threshold,
"updated_at": datetime.now(timezone.utc).isoformat(),
"version": "1.0"
}
WEIGHTS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(WEIGHTS_FILE, 'w') as f:
json.dump(data, f, indent=2)
def log_score(draft_id, platform, scores, passed, reasons):
"""Log scoring results for analysis."""
log_entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"draft_id": draft_id,
"platform": platform,
"scores": scores,
"total_score": sum(scores.values()),
"passed": passed,
"failure_reasons": reasons,
}
log_data = []
if LOG_FILE.exists():
try:
with open(LOG_FILE) as f:
log_data = json.load(f)
except Exception:
log_data = []
log_data.append(log_entry)
# Keep only last 1000 entries
if len(log_data) > 1000:
log_data = log_data[-1000:]
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, 'w') as f:
json.dump(log_data, f, indent=2)
def score_voice_similarity(draft_text):
"""Score how well draft matches voice patterns (0-100)."""
score = 0
matches = {}
for pattern, weight, category in VOICE_MARKERS:
pattern_matches = re.findall(pattern, draft_text, re.IGNORECASE)
if pattern_matches:
match_count = len(pattern_matches)
category_score = min(weight * math.log(match_count + 1) * 10, weight * 25)
score += category_score
matches[category] = matches.get(category, 0) + match_count
# Bonus for short punchy sentences
sentences = re.split(r'[.!?]+', draft_text)
short_sentences = [s for s in sentences if len(s.split()) <= 15 and len(s.split()) >= 3]
sentence_ratio = len(short_sentences) / max(len(sentences), 1)
score += sentence_ratio * 15
return min(score, 100), matches
def score_specificity(draft_text):
"""Score specificity — real numbers, examples, named entities (0-100)."""
score = 0
number_patterns = [
r'\$[\d,]+[KkMmBb]?(?:\+)?',
r'\d+%',
r'\d+x',
r'\d+[\.,]?\d*\s*(?:hours?|minutes?|days?|weeks?|months?|years?)',
r'\d+\s*(?:pages?|pieces?|tools?|agents?|companies|founders?|members)',
]
total_numbers = 0
for pattern in number_patterns:
matches = re.findall(pattern, draft_text, re.IGNORECASE)
total_numbers += len(matches)
word_count = len(draft_text.split())
number_density = total_numbers / max(word_count / 50, 1)
score += min(number_density * 30, 50)
# Named entities and specific examples
entity_patterns = [
r'[A-Z][a-z]+ [A-Z][a-z]+(?:\s[A-Z][a-z]+)*',
r'@[A-Za-z0-9_]+',
r'(?:Apple|Google|Meta|Microsoft|Amazon|Tesla|ChatGPT|Claude|OpenAI)',
]
entity_count = 0
for pattern in entity_patterns:
matches = re.findall(pattern, draft_text)
entity_count += len(matches)
score += min(entity_count * 10, 30)
# Before/after comparisons
comparison_patterns = [
r'\d+.*→.*\d+',
r'from \d+.*to \d+',
r'before.*\d+.*after.*\d+',
r'used to.*now.*'
]
for pattern in comparison_patterns:
if re.search(pattern, draft_text, re.IGNORECASE):
score += 10
break
return min(score, 100)
def score_slop_penalty(draft_text):
"""Detect and penalize AI slop and banned phrases (0-100, higher = less slop)."""
score = 100
detected_issues = []
text_lower = draft_text.lower()
banned_found = []
for word in BANNED_WORDS:
if word.lower() in text_lower:
banned_found.append(word)
score -= 10
if banned_found:
detected_issues.append(f"Banned words: {', '.join(banned_found[:3])}")
ai_patterns_found = []
for pattern, pattern_name in AI_PATTERNS:
matches = re.findall(pattern, draft_text, re.IGNORECASE)
if matches:
ai_patterns_found.append(pattern_name)
score -= 8
if ai_patterns_found:
detected_issues.append(f"AI patterns: {', '.join(ai_patterns_found[:3])}")
# Em dash overuse
em_dash_count = draft_text.count('—')
word_count = len(draft_text.split())
if em_dash_count > word_count / 200:
score -= 5
detected_issues.append("Excessive em dash usage")
# Corporate speak
corporate_patterns = [
r'I\'m excited to share',
r'it is important to note',
r'in order to',
r'we are pleased to announce',
r'stay tuned for',
]
for pattern in corporate_patterns:
if re.search(pattern, draft_text, re.IGNORECASE):
score -= 15
detected_issues.append("Corporate speak detected")
break
return max(score, 0), detected_issues
def score_length_appropriateness(draft_text, platform):
"""Score if content length is appropriate for platform (0-100)."""
char_count = len(draft_text)
limits = PLATFORM_LIMITS.get(platform, PLATFORM_LIMITS["x"])
if char_count < limits["min"]:
shortfall_ratio = char_count / limits["min"]
return max(shortfall_ratio * 100, 20)
elif char_count > limits["max"]:
excess_ratio = limits["max"] / char_count
return max(excess_ratio * 100, 30)
elif limits["optimal_min"] <= char_count <= limits["optimal_max"]:
return 100
else:
return 85
def score_engagement_potential(draft_text, platform):
"""Score engagement potential based on CTAs and hooks (0-100)."""
score = 0
cta_patterns = {
"x": [r'What\'s your take\?', r'What did I miss\?', r'Reply with'],
"linkedin": [r'What would you do', r'What do you think', r'Drop .* below', r'curious.*your'],
"youtube_short": [r'Comment.*and I\'ll', r'Follow for more'],
"newsletter": [r'subscribe', r'read more', r'check it out'],
}
platform_ctas = cta_patterns.get(platform, cta_patterns["x"])
for pattern in platform_ctas:
if re.search(pattern, draft_text, re.IGNORECASE):
score += 25
break
# Strong hooks (first 100 characters)
hook = draft_text[:100]
hook_patterns = [
r'^\d+.*\.',
r'^Most people.*wrong',
r'^I (?:built|found|asked)',
r'^Harsh reality:',
r'^Here\'s what',
]
for pattern in hook_patterns:
if re.search(pattern, hook, re.IGNORECASE):
score += 25
break
# Question-based engagement
question_count = len(re.findall(r'\?', draft_text))
if question_count >= 1:
score += min(question_count * 15, 30)
# Debate invitation
debate_patterns = [
r'Agree or disagree',
r'What\'s your experience',
r'Change my mind',
]
for pattern in debate_patterns:
if re.search(pattern, draft_text, re.IGNORECASE):
score += 20
break
return min(score, 100)
def score_draft(draft, weights, threshold):
"""Score a single draft against all criteria."""
platform = draft.get("platform", "x")
draft_text = draft.get("draft", "")
voice_score, voice_matches = score_voice_similarity(draft_text)
specificity_score = score_specificity(draft_text)
slop_score, slop_issues = score_slop_penalty(draft_text)
length_score = score_length_appropriateness(draft_text, platform)
engagement_score = score_engagement_potential(draft_text, platform)
scores = {
"voice_similarity": voice_score,
"specificity": specificity_score,
"slop_penalty": slop_score,
"length_appropriateness": length_score,
"engagement_potential": engagement_score,
}
total_score = sum(scores[key] * weights[key] for key in scores.keys())
total_score = round(total_score, 1)
passed = total_score >= threshold
failure_reasons = []
if voice_score < 50:
failure_reasons.append("Low voice match - lacks brand voice patterns")
if specificity_score < 40:
failure_reasons.append("Not specific enough - needs real numbers/examples")
if slop_score < 70:
failure_reasons.append("Contains AI slop - " + "; ".join(slop_issues))
if length_score < 60:
failure_reasons.append(f"Length issue for {platform}")
if engagement_score < 40:
failure_reasons.append("Weak engagement - needs better CTA/hook")
result = {
"draft_id": draft.get("id"),
"platform": platform,
"total_score": total_score,
"scores": scores,
"passed": passed,
"failure_reasons": failure_reasons,
"voice_matches": voice_matches,
"slop_issues": slop_issues,
"char_count": len(draft_text),
"scored_at": datetime.now(timezone.utc).isoformat(),
}
log_score(draft.get("id"), platform, scores, passed, failure_reasons)
return result
def score_drafts_file(file_path=None, output_path=None, threshold_override=None, verbose=False):
"""Score all drafts in a file."""
input_file = Path(file_path) if file_path else DRAFTS_FILE
if not input_file.exists():
print(f"❌ Input file not found: {input_file}")
return None
with open(input_file) as f:
data = json.load(f)
drafts = data.get("drafts", [])
if not drafts:
print("❌ No drafts found in input file")
return None
weights, threshold = load_weights()
if threshold_override:
threshold = threshold_override
print(f"📊 Using threshold override: {threshold}")
print(f"📊 Scoring {len(drafts)} drafts with threshold {threshold}")
if verbose:
print(f"📊 Weights: {weights}")
results = []
passed_count = 0
for i, draft in enumerate(drafts):
result = score_draft(draft, weights, threshold)
results.append(result)
if result["passed"]:
passed_count += 1
if verbose:
print(f"\n[{i+1}/{len(drafts)}] {result['platform']} | Score: {result['total_score']}/100")
if result["passed"]:
print(f" ✅ PASS")
else:
print(f" ❌ FAIL: {'; '.join(result['failure_reasons'])}")
total_scores = [r["total_score"] for r in results]
avg_score = sum(total_scores) / len(total_scores)
pass_rate = (passed_count / len(results)) * 100
summary = {
"scored_at": datetime.now(timezone.utc).isoformat(),
"total_drafts": len(drafts),
"passed_count": passed_count,
"pass_rate": round(pass_rate, 1),
"average_score": round(avg_score, 1),
"threshold": threshold,
"weights": weights,
"results": results,
}
if output_path:
output_file = Path(output_path)
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = DATA_DIR / f"quality-scores-{timestamp}.json"
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w') as f:
json.dump(summary, f, indent=2)
latest_file = DATA_DIR / "quality-scores-latest.json"
with open(latest_file, 'w') as f:
json.dump(summary, f, indent=2)
print(f"\n{'='*60}")
print(f"QUALITY SCORING RESULTS")
print(f"{'='*60}")
print(f"Total drafts: {len(drafts)}")
print(f"Passed: {passed_count} ({pass_rate:.1f}%)")
print(f"Failed: {len(drafts) - passed_count}")
print(f"Average score: {avg_score:.1f}/100")
print(f"Threshold: {threshold}/100")
print(f"\nSaved to: {output_file}")
print(f"Saved to: {latest_file}")
if verbose:
print(f"\n🏆 TOP SCORING DRAFTS:")
top_drafts = sorted(results, key=lambda x: x["total_score"], reverse=True)[:3]
for i, result in enumerate(top_drafts):
status = "✅ PASS" if result["passed"] else "❌ FAIL"
print(f" {i+1}. {result['platform']} | {result['total_score']}/100 | {status}")
return summary
def main():
parser = argparse.ArgumentParser(description="Score content drafts for quality")
parser.add_argument("--input", type=str, help="Input drafts JSON file")
parser.add_argument("--output", type=str, help="Output scores JSON file")
parser.add_argument("--threshold", type=float, help="Scoring threshold override")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
parser.add_argument("--init-weights", action="store_true", help="Initialize default weights file")
args = parser.parse_args()
if args.init_weights:
save_weights(DEFAULT_WEIGHTS, DEFAULT_THRESHOLD)
print(f"✅ Initialized weights file: {WEIGHTS_FILE}")
return
score_drafts_file(
file_path=args.input,
output_path=args.output,
threshold_override=args.threshold,
verbose=args.verbose
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Content Transform — Repurpose long-form content into platform-native drafts.
Reads content atoms, generates platform-native drafts using Claude API + optional
expert panel quality gate. Supports X threads/posts, LinkedIn, YouTube Shorts, and
newsletter formats.
LLM mode is DEFAULT. Use --template-only for fast template-based drafts (no API needed).
Usage:
python content-transform.py --atoms atoms.json --top-n 10
python content-transform.py --atoms atoms.json --template-only
python content-transform.py --atoms atoms.json --no-expert-panel
"""
import json
import uuid
import argparse
import os
import re
import sys
import textwrap
from datetime import datetime, timezone
from pathlib import Path
# ── Configuration ──
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
DATA_DIR = Path(os.environ.get("CONTENT_OPS_DATA_DIR", PROJECT_DIR / "data"))
SKILL_DIR = PROJECT_DIR
ATOMS_FILE = DATA_DIR / "content-atoms-latest.json"
# Voice configuration files (optional, for LLM mode)
VOICE_CONFIG_FILE = os.environ.get("VOICE_CONFIG_FILE", str(PROJECT_DIR / "config" / "voice.md"))
STYLE_GUIDE_FILE = os.environ.get("STYLE_GUIDE_FILE", str(PROJECT_DIR / "config" / "style-guide.md"))
PLATFORM_MAP = {
"x": ["x_thread", "x_post"],
"linkedin": ["linkedin_post"],
"short_form": ["youtube_short_script"],
"newsletter": ["newsletter_section"],
"youtube_short": ["youtube_short_script"],
}
MISSING_TO_FORMAT = {
"x": "x_thread",
"linkedin": "linkedin_post",
"short_form": "youtube_short_script",
"newsletter": "newsletter_section",
"youtube_short": "youtube_short_script",
}
MISSING_TO_PLATFORM = {
"x": "x",
"linkedin": "linkedin",
"short_form": "youtube_short",
"newsletter": "newsletter",
"youtube_short": "youtube_short",
}
PLATFORM_TO_EXPERT = {
"x": "x-articles.md",
"linkedin": "linkedin.md",
"youtube_short": "youtube-shorts.md",
"newsletter": "newsletter.md",
}
EXPERT_PANEL_THRESHOLD = 95
EXPERT_PANEL_MAX_ITERATIONS = 3
def load_atoms(path=None):
p = Path(path) if path else ATOMS_FILE
with open(p) as f:
data = json.load(f)
return data.get("atoms", data) if isinstance(data, dict) else data
def rank_atoms(atoms, top_n=10):
"""Sort by repurpose_score * len(platforms_missing), take top N."""
for a in atoms:
a["_rank"] = a.get("repurpose_score", 0) * max(len(a.get("platforms_missing", [])), 1)
ranked = sorted(atoms, key=lambda x: x["_rank"], reverse=True)
return ranked[:top_n]
def clean_content(content):
content = re.sub(r'^[\w]+\s*·\s*@[\w]+\s*·.*$', '', content, flags=re.MULTILINE)
content = re.sub(r'\n{3,}', '\n\n', content)
return content.strip()
def extract_hook(content, max_chars=200):
content = clean_content(content)
for sep in [". ", ".\n", "\n"]:
idx = content.find(sep)
if 0 < idx < max_chars:
return content[:idx + 1].strip()
return content[:max_chars].strip()
def extract_key_points(content, max_points=6):
lines = content.split("\n")
points = []
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith(("•", "-", "→", "*")) or re.match(r"^\d+[\.\)]", line):
cleaned = re.sub(r"^[•\-→\*\d+\.\)]+\s*", "", line).strip()
if len(cleaned) > 15:
points.append(cleaned)
elif len(line) > 20 and len(line) < 280:
points.append(line)
return points[:max_points] if points else [content[:200]]
def extract_numbers(content):
patterns = [
r'\$[\d,]+[KkMmBb]?(?:\+)?',
r'\d+%',
r'\d+x',
r'\d+[\.,]?\d*\s*(?:hours?|minutes?|days?|weeks?|months?|years?)',
r'\d+\s*(?:pages?|pieces?|tools?|agents?|companies|founders?|members)',
]
numbers = []
for p in patterns:
numbers.extend(re.findall(p, content, re.IGNORECASE))
return numbers[:5]
def shorten_sentence(s, max_words=15):
words = s.split()
if len(words) <= max_words:
return s
return " ".join(words[:max_words]) + "."
def make_punchy(text, max_words=15):
sentences = re.split(r'(?<=[.!?])\s+', text)
result = []
for s in sentences:
s = s.strip()
if not s:
continue
if len(s.split()) > max_words:
parts = re.split(r'[,;—]', s)
for p in parts:
p = p.strip()
if p:
result.append(shorten_sentence(p) if not p.endswith(('.', '!', '?')) else p)
else:
result.append(s)
return result
# ── TEMPLATE GENERATORS (used with --template-only) ──
def generate_x_thread(atom):
content = clean_content(atom["content"])
hook_text = extract_hook(content, 200)
points = extract_key_points(content)
numbers = extract_numbers(content)
tags = atom.get("tags", [])
atom_type = atom.get("atom_type", "")
if "data" in atom_type or numbers:
tweet1 = f"{hook_text}\n\nThe numbers tell a different story. 🧵"
elif "story" in atom_type or "anecdote" in atom_type:
tweet1 = f"{hook_text}\n\nHere's what happened next. 🧵"
else:
tweet1 = f"Most people get this wrong about {tags[0] if tags else 'this'}.\n\n{hook_text}"
if len(tweet1) > 280:
tweet1 = tweet1[:277] + "..."
tweets = [tweet1]
for i, point in enumerate(points[:5]):
point_short = shorten_sentence(point, 15)
if numbers and i < len(numbers):
tweet = f"{point_short}\n\n{numbers[i]} — that's the real number."
else:
tweet = point_short
if len(tweet) > 280:
tweet = tweet[:277] + "..."
tweets.append(tweet)
ctas = [
"What's your take? Reply with what you'd add.",
"What did I miss? Drop your thoughts below.",
"Agree or disagree? I want to hear your take.",
]
tweets.append(ctas[hash(atom["id"]) % len(ctas)])
while len(tweets) < 5:
tweets.insert(-1, "The gap is only getting wider. Those who move now win.")
thread = "\n\n---\n\n".join([f"🧵 {i+1}/{len(tweets)}\n{t}" for i, t in enumerate(tweets)])
return thread, tweets[0]
def generate_x_post(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 180)
numbers = extract_numbers(content)
num_str = f"\n\n{numbers[0]}." if numbers else ""
post = f"{hook}{num_str}\n\nWhat's your take?"
if len(post) > 280:
post = post[:277] + "..."
return post, hook
def generate_linkedin_post(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 150)
points = extract_key_points(content)
numbers = extract_numbers(content)
hook_section = f"{hook}\n\nHere's what I learned."
punchy = make_punchy(content)
story = "\n\n".join(punchy[:6])
point_section = "\n".join([f"→ {p}" for p in points[:4]]) if len(points) > 2 else ""
data_section = f"\nThe data: {', '.join(numbers[:3])}." if numbers else ""
ctas = [
"What would you do differently?",
"What's your experience with this?",
"Curious — what's your take?",
]
cta = ctas[hash(atom["id"]) % len(ctas)]
parts = [hook_section, story]
if point_section:
parts.append(point_section)
if data_section:
parts.append(data_section)
parts.append(cta)
post = "\n\n".join(parts)
if len(post) > 1500:
post = post[:1497] + "..."
return post, hook
def generate_youtube_short(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 100)
points = extract_key_points(content)
numbers = extract_numbers(content)
tags = atom.get("tags", [])
topic = tags[0] if tags else "this"
hook_line = f"[HOOK] (0:00-0:03)\n[Look directly at camera, energy up]\n\"{hook}\""
setup_points = points[:2]
setup_text = " ".join([shorten_sentence(p, 12) for p in setup_points])
setup_line = f"[SETUP] (0:03-0:13)\n[Cut to B-roll or screen share]\n\"{setup_text}\""
payoff_points = points[2:5] if len(points) > 2 else points
payoff_items = "\n".join([f" → {shorten_sentence(p, 12)}" for p in payoff_points])
num_callout = f"\n[TEXT OVERLAY: {numbers[0]}]" if numbers else ""
payoff_line = f"[PAYOFF] (0:13-0:40)\n[Quick cuts between points]{num_callout}\n{payoff_items}"
cta_line = f"[CTA] (0:40-0:45)\n[Point at camera]\n\"Comment '{topic.upper()}' and I'll show you exactly how.\"\n[TEXT: Follow for more]"
script = f"{hook_line}\n\n{setup_line}\n\n{payoff_line}\n\n{cta_line}"
return script, hook
def generate_newsletter_section(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 150)
points = extract_key_points(content)
numbers = extract_numbers(content)
headline = f"**{hook}**"
punchy = make_punchy(content)
para1 = " ".join(punchy[:4])
para2 = " ".join(punchy[4:8]) if len(punchy) > 4 else ""
data = f"The numbers: {', '.join(numbers[:3])}." if numbers else ""
why = f"> **Why this matters:** {shorten_sentence(points[-1] if points else content[:100], 15)}"
parts = [headline, para1]
if para2:
parts.append(para2)
if data:
parts.append(data)
parts.append(why)
return "\n\n".join([p for p in parts if p.strip()]), hook
FORMAT_GENERATORS = {
"x_thread": generate_x_thread,
"x_post": generate_x_post,
"linkedin_post": generate_linkedin_post,
"youtube_short_script": generate_youtube_short,
"newsletter_section": generate_newsletter_section,
}
def estimate_engagement(atom, platform):
score = atom.get("repurpose_score", 5)
if score >= 8:
return "high"
elif score >= 5:
return "medium"
return "low"
def generate_drafts_for_atom(atom):
drafts = []
missing = atom.get("platforms_missing", [])
for platform_key in missing:
fmt = MISSING_TO_FORMAT.get(platform_key)
platform = MISSING_TO_PLATFORM.get(platform_key)
if not fmt or fmt not in FORMAT_GENERATORS:
continue
generator = FORMAT_GENERATORS[fmt]
draft_text, hook = generator(atom)
draft = {
"id": str(uuid.uuid4()),
"atom_id": atom["id"],
"atom_content": atom["content"][:500],
"atom_source": atom.get("source", "unknown"),
"platform": platform,
"format": fmt,
"draft": draft_text,
"hook": hook[:200],
"char_count": len(draft_text),
"estimated_engagement": estimate_engagement(atom, platform),
"created_at": datetime.now(timezone.utc).isoformat(),
"status": "draft",
"expert_score": None,
"iterations": 0,
"key_improvements": [],
}
drafts.append(draft)
return drafts
# ── ANTHROPIC API ──
def get_anthropic_key():
"""Get Anthropic API key from environment."""
key = os.environ.get("ANTHROPIC_API_KEY")
if key:
return key
print("ERROR: Set ANTHROPIC_API_KEY environment variable")
return None
def load_file_safe(path):
"""Load a text file, return empty string if missing."""
try:
return Path(path).read_text()
except Exception:
return ""
def load_expert_panel(platform):
"""Load expert panel for a platform."""
filename = PLATFORM_TO_EXPERT.get(platform, "x-articles.md")
return load_file_safe(SKILL_DIR / "experts" / filename)
def load_scoring_rubric():
"""Load content quality scoring rubric."""
return load_file_safe(SKILL_DIR / "scoring-rubrics" / "content-quality.md")
def load_voice_references():
"""Load voice/style references for content generation."""
voice_config = load_file_safe(VOICE_CONFIG_FILE)
style_guide = load_file_safe(STYLE_GUIDE_FILE)
return voice_config, style_guide
def call_anthropic(client, messages, system=None, model="claude-sonnet-4-20250514", max_tokens=2000):
"""Call Anthropic API."""
kwargs = {"model": model, "max_tokens": max_tokens, "messages": messages}
if system:
kwargs["system"] = system
response = client.messages.create(**kwargs)
return response.content[0].text.strip()
def llm_generate_draft(client, atom, platform, fmt, voice_config, style_guide):
"""Generate a draft using Claude API."""
platform_instructions = {
"x": "Write an X article (long-form X post). Include at least one ASCII diagram in a code block. Keep paragraphs to 1-3 sentences. End with a natural CTA.",
"linkedin": "Write a LinkedIn post. Hook must work before the 'see more' fold (first 2-3 lines). Use line breaks for readability. Professional but personal. 800-1500 chars.",
"youtube_short": "Write a YouTube Short script. Format: [HOOK] (0:00-0:03), [SETUP] (0:03-0:13), [PAYOFF] (0:13-0:40), [CTA] (0:40-0:45). Include visual directions. 30-60 seconds total.",
"newsletter": "Write a newsletter section. Subject line + scannable body. Headers, bullets, bold for skimmers. End with 'why this matters'.",
}
system_parts = ["You are a content writer creating platform-native content. Follow the configured voice and style EXACTLY."]
if voice_config:
system_parts.append(f"\nVOICE CONFIGURATION:\n{voice_config}")
if style_guide:
system_parts.append(f"\nSTYLE GUIDE:\n{style_guide[:2000]}")
system_parts.append("""
RULES:
- Short punchy sentences. Max 15 words.
- Specific numbers always. Never vague.
- Contrarian angles backed by data.
- No corporate speak. No "I'm excited to share."
- Personal stories and specific examples.
- Every sentence earns its place.""")
system = "\n".join(system_parts)
topic_tags = atom.get('tags', [])
prompt = f"""Create a {platform} draft from this content atom.
PLATFORM INSTRUCTIONS:
{platform_instructions.get(platform, platform_instructions['x'])}
SOURCE CONTENT:
{clean_content(atom['content'])}
SOURCE: {atom.get('source_title', 'unknown')}
TAGS: {', '.join(topic_tags)}
Write ONLY the draft content. No preamble, no explanation."""
return call_anthropic(client, [{"role": "user", "content": prompt}], system=system)
def expert_panel_score(client, draft_text, platform, expert_panel, rubric, voice_config):
"""Run expert panel scoring. Returns (score, feedback_dict)."""
system = f"""You are simulating 10 domain experts reviewing content for quality.
EXPERT PANEL:
{expert_panel}
SCORING RUBRIC:
{rubric}
VOICE REFERENCE:
{voice_config[:1000] if voice_config else 'No specific voice config provided.'}"""
prompt = f"""Score this {platform} draft. Each of 11 experts scores 0-100 on the rubric criteria.
Expert #11 is the AI Writing Detector (Humanizer) — scores how AI-generated the draft sounds.
BANNED AI VOCABULARY (flag any occurrence):
delve, tapestry, landscape (abstract), leverage, multifaceted, nuanced, pivotal, realm, robust, seamless, testament, transformative, underscore (verb), utilize, whilst, keen, embark, comprehensive, intricate, commendable, meticulous, paramount, groundbreaking, innovative, cutting-edge, synergy, holistic, paradigm, ecosystem, Additionally, crucial, enduring, enhance, fostering, garner, highlight (verb), interplay, intricacies, showcase, vibrant, valuable, profound, renowned, breathtaking, nestled, stunning
AI PATTERNS TO CHECK:
- Significance inflation ("pivotal moment", "is a testament", "stands as")
- Superficial -ing phrases ("highlighting", "showcasing", "underscoring")
- Promotional language ("boasts", "vibrant", "commitment to")
- Vague attributions ("Experts believe", "Industry reports")
- Formulaic "despite challenges... continues to" structures
- Copula avoidance ("serves as" instead of "is")
- Negative parallelisms ("It's not just X, it's Y")
- Rule-of-three forcing (triple adjectives/clauses)
- Em dash overuse (max 1 per 200 words)
- Filler phrases ("In order to", "It is important to note")
- Excessive hedging ("could potentially")
- Generic positive conclusions ("The future looks bright")
If the Humanizer expert scores below 70, the draft MUST be flagged for revision.
DRAFT:
{draft_text}
Respond in this EXACT JSON format (no other text):
{{
"average_score": <number>,
"expert_scores": [<11 numbers>],
"weaknesses": ["<specific weakness 1>", "<specific weakness 2>", ...],
"line_feedback": ["<specific line-by-line fix 1>", "<specific line-by-line fix 2>", ...],
"strengths": ["<strength 1>", "<strength 2>"],
"ai_patterns_detected": ["<pattern 1>", "<pattern 2>", ...],
"humanizer_score": <number>
}}
Be harsh. Score honestly."""
response = call_anthropic(client, [{"role": "user", "content": prompt}], system=system, max_tokens=1500)
try:
json_match = re.search(r'\{[\s\S]*\}', response)
if json_match:
result = json.loads(json_match.group())
return result.get("average_score", 0), result
else:
return 0, {"error": "No JSON in response"}
except json.JSONDecodeError:
return 0, {"error": "Invalid JSON", "raw": response[:500]}
def expert_panel_revise(client, draft_text, platform, feedback, voice_config, style_guide):
"""Revise draft based on expert feedback."""
system_parts = ["You are revising content based on expert feedback."]
if voice_config:
system_parts.append(f"\nVOICE CONFIGURATION:\n{voice_config}")
system_parts.append("""
RULES:
- Fix every weakness identified
- Keep all strengths
- Maintain configured voice exactly
- Short punchy sentences, specific numbers, contrarian angles""")
system = "\n".join(system_parts)
weaknesses = feedback.get("weaknesses", [])
line_fixes = feedback.get("line_feedback", [])
ai_patterns = feedback.get("ai_patterns_detected", [])
ai_section = ""
if ai_patterns:
ai_section = f"""
AI PATTERNS DETECTED (MUST FIX ALL):
{chr(10).join(f'- {p}' for p in ai_patterns)}
BANNED VOCABULARY (replace every occurrence):
delve, tapestry, landscape (abstract), leverage, multifaceted, nuanced, pivotal, realm, robust, seamless, testament, transformative, underscore (verb), utilize, whilst, keen, embark, comprehensive, intricate, commendable, meticulous, paramount, groundbreaking, innovative, cutting-edge, synergy, holistic, paradigm, ecosystem, Additionally, crucial, enduring, enhance, fostering, garner, highlight (verb), interplay, intricacies, showcase, vibrant, valuable, profound, renowned, breathtaking, nestled, stunning
"""
prompt = f"""Revise this {platform} draft based on expert feedback.
CURRENT DRAFT:
{draft_text}
WEAKNESSES TO FIX:
{chr(10).join(f'- {w}' for w in weaknesses)}
SPECIFIC LINE FIXES:
{chr(10).join(f'- {f}' for f in line_fixes)}
{ai_section}
CURRENT SCORE: {feedback.get('average_score', 'unknown')}
TARGET SCORE: {EXPERT_PANEL_THRESHOLD}+
Write ONLY the revised draft. No preamble."""
return call_anthropic(client, [{"role": "user", "content": prompt}], system=system)
def process_draft_with_expert_panel(client, atom, platform, fmt, voice_config, style_guide):
"""Full expert panel pipeline: generate → score → revise loop."""
expert_panel = load_expert_panel(platform)
rubric = load_scoring_rubric()
print(f" Generating {platform} draft via Claude...")
draft_text = llm_generate_draft(client, atom, platform, fmt, voice_config, style_guide)
iterations = []
best_draft = draft_text
best_score = 0
for iteration in range(1, EXPERT_PANEL_MAX_ITERATIONS + 1):
print(f" Expert panel scoring (iteration {iteration})...")
score, feedback = expert_panel_score(client, draft_text, platform, expert_panel, rubric, voice_config)
print(f" Score: {score}/100")
iteration_log = {
"iteration": iteration,
"score": score,
"weaknesses": feedback.get("weaknesses", []),
"line_feedback": feedback.get("line_feedback", []),
"strengths": feedback.get("strengths", []),
}
iterations.append(iteration_log)
if score > best_score:
best_score = score
best_draft = draft_text
if score >= EXPERT_PANEL_THRESHOLD:
print(f" ✓ Passed threshold ({score} >= {EXPERT_PANEL_THRESHOLD})")
break
if iteration < EXPERT_PANEL_MAX_ITERATIONS:
print(f" Revising based on feedback...")
draft_text = expert_panel_revise(client, draft_text, platform, feedback, voice_config, style_guide)
key_improvements = []
for it in iterations:
for w in it.get("weaknesses", []):
key_improvements.append(f"Iter {it['iteration']}: Fixed — {w}")
return best_draft, best_score, len(iterations), key_improvements, iterations
def rewrite_with_llm(drafts, use_expert_panel=False, expert_panel_top_n=10):
"""Rewrite drafts using Claude API, optionally with expert panel."""
try:
import anthropic
except ImportError:
print("ERROR: anthropic package not installed. Run: pip install anthropic")
return drafts
api_key = get_anthropic_key()
if not api_key:
return drafts
client = anthropic.Anthropic(api_key=api_key)
voice_config, style_guide = load_voice_references()
rewritten = []
for i, draft in enumerate(drafts):
atom = {"content": draft["atom_content"], "source_title": draft.get("atom_source", ""),
"tags": [], "atom_type": ""}
if use_expert_panel and i < expert_panel_top_n:
print(f"\n [{i+1}/{len(drafts)}] Expert panel: {draft['format']} (atom {draft['atom_id'][:8]})")
try:
import time as _time
_start = _time.time()
new_text, score, iters, improvements, iter_log = process_draft_with_expert_panel(
client, atom, draft["platform"], draft["format"],
voice_config, style_guide
)
_elapsed = _time.time() - _start
draft["draft"] = new_text
draft["hook"] = extract_hook(new_text, 200)
draft["char_count"] = len(new_text)
draft["expert_score"] = score
draft["iterations"] = iters
draft["key_improvements"] = improvements
draft["iteration_log"] = iter_log
status = "✓" if score >= EXPERT_PANEL_THRESHOLD else f"⚠ ({score})"
print(f" {status} Final: {score}/100 after {iters} iteration(s) [{_elapsed:.1f}s]")
except Exception as e:
print(f" ✗ Expert panel failed ({type(e).__name__}): {e}")
try:
new_text = llm_generate_draft(client, atom, draft["platform"], draft["format"],
voice_config, style_guide)
draft["draft"] = new_text
draft["hook"] = extract_hook(new_text, 200)
draft["char_count"] = len(new_text)
print(f" ↳ Fell back to simple LLM rewrite")
except Exception as e2:
print(f" ✗ LLM rewrite also failed: {e2}")
else:
print(f"\n [{i+1}/{len(drafts)}] LLM rewrite: {draft['format']} (atom {draft['atom_id'][:8]})")
try:
new_text = llm_generate_draft(client, atom, draft["platform"], draft["format"],
voice_config, style_guide)
draft["draft"] = new_text
draft["hook"] = extract_hook(new_text, 200)
draft["char_count"] = len(new_text)
print(f" ✓ Rewrote")
except Exception as e:
print(f" ✗ LLM rewrite failed: {e}")
rewritten.append(draft)
return rewritten
def main():
parser = argparse.ArgumentParser(description="Transform content atoms into platform-native drafts")
parser.add_argument("--atoms", type=str, help="Path to atoms JSON file")
parser.add_argument("--top-n", type=int, default=10, help="Number of top atoms to process")
parser.add_argument("--template-only", action="store_true", help="Use template-based generation (no LLM)")
parser.add_argument("--no-expert-panel", action="store_true", help="Disable expert panel quality gate")
parser.add_argument("--expert-panel-top-n", type=int, default=10, help="Apply expert panel to top N drafts")
parser.add_argument("--output", type=str, help="Output file path")
args = parser.parse_args()
use_llm = not args.template_only
use_expert_panel = use_llm and not args.no_expert_panel
atoms = load_atoms(args.atoms)
print(f"Loaded {len(atoms)} atoms")
top_atoms = rank_atoms(atoms, args.top_n)
print(f"Selected top {len(top_atoms)} atoms by repurpose_score × missing platforms")
all_drafts = []
for atom in top_atoms:
drafts = generate_drafts_for_atom(atom)
all_drafts.extend(drafts)
missing = atom.get("platforms_missing", [])
print(f" Atom {atom['id'][:8]}: {len(drafts)} drafts ({', '.join(missing)})")
print(f"\nGenerated {len(all_drafts)} total drafts")
if use_llm:
mode = "LLM + Expert Panel" if use_expert_panel else "LLM only"
print(f"\n{'='*60}")
print(f"Rewriting with {mode}...")
print(f"{'='*60}")
all_drafts = rewrite_with_llm(all_drafts, use_expert_panel=use_expert_panel,
expert_panel_top_n=args.expert_panel_top_n)
by_platform = {}
for d in all_drafts:
by_platform[d["platform"]] = by_platform.get(d["platform"], 0) + 1
print(f"\n{'='*60}")
print("Drafts by platform:")
for p, c in sorted(by_platform.items()):
print(f" {p}: {c}")
scored = [d for d in all_drafts if d.get("expert_score")]
if scored:
avg = sum(d["expert_score"] for d in scored) / len(scored)
passed = sum(1 for d in scored if d["expert_score"] >= EXPERT_PANEL_THRESHOLD)
print(f"\nExpert panel: {len(scored)} scored, {passed} passed (≥{EXPERT_PANEL_THRESHOLD}), avg {avg:.1f}")
today = datetime.now().strftime("%Y-%m-%d")
output_path = Path(args.output) if args.output else DATA_DIR / f"content-drafts-{today}.json"
latest_path = DATA_DIR / "content-drafts-latest.json"
output = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"atom_count": len(top_atoms),
"draft_count": len(all_drafts),
"used_llm": use_llm,
"used_expert_panel": use_expert_panel,
"expert_panel_threshold": EXPERT_PANEL_THRESHOLD if use_expert_panel else None,
"drafts": all_drafts,
}
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(output, f, indent=2)
with open(latest_path, "w") as f:
json.dump(output, f, indent=2)
print(f"\nSaved to {output_path}")
print(f"Saved to {latest_path}")
if scored:
print(f"\n{'='*60}")
print("TOP DRAFTS BY SCORE:")
print(f"{'='*60}")
for d in sorted(scored, key=lambda x: x["expert_score"], reverse=True)[:5]:
print(f"\n[{d['platform'].upper()}] Score: {d['expert_score']}/100 | Iterations: {d['iterations']}")
print(f"Hook: {d['hook'][:100]}...")
if d.get("key_improvements"):
print(f"Key improvements: {d['key_improvements'][0]}")
print(f"---")
print(d["draft"][:300])
print("...\n")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Editorial Brain — Top-down clip discovery using LLM analysis.
Instead of bottom-up keyword matching, this gives the full transcript to an LLM
and asks it to find the best clip-worthy moments like a human editor would.
Two-pass approach:
1. Sonnet scans transcript chunks cheaply, finds candidate moments
2. Sonnet scores candidates on hook/build/payoff/clean-cut (0-100)
3. Only 90+ clips get cut
Usage:
python editorial-brain.py --url "https://youtube.com/watch?v=..." [--max-clips 5]
python editorial-brain.py --vtt /path/to/file.vtt --video-id ID [--max-clips 5]
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.request
from pathlib import Path
# ── Configuration ──
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY', '')
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
DATA_DIR = Path(os.environ.get("CONTENT_OPS_DATA_DIR", PROJECT_DIR / "data"))
CLIPS_DIR = DATA_DIR / "clips"
# Model configuration
DEFAULT_MODEL = os.environ.get("EDITORIAL_BRAIN_MODEL", "claude-sonnet-4-20250514")
def call_claude(prompt, model=None, max_tokens=4000):
"""Call Claude API."""
model = model or DEFAULT_MODEL
data = json.dumps({
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}]
}).encode()
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
data=data,
headers={
"Content-Type": "application/json",
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01"
}
)
with urllib.request.urlopen(req, timeout=120) as resp:
result = json.loads(resp.read())
return result['content'][0]['text']
def download_vtt(url):
"""Download VTT subtitles from YouTube."""
video_id = re.search(r'(?:v=|/)([a-zA-Z0-9_-]{11})', url).group(1)
vtt_path = f"/tmp/editorial_{video_id}.en.vtt"
if os.path.exists(vtt_path):
return vtt_path, video_id
subprocess.run([
'yt-dlp', '--write-auto-subs', '--sub-lang', 'en', '--sub-format', 'vtt',
'--skip-download', '--output', f'/tmp/editorial_{video_id}.%(ext)s', url
], capture_output=True, check=True)
return vtt_path, video_id
def parse_vtt(vtt_path):
"""Parse YouTube auto-caption VTT into clean, deduplicated transcript.
YouTube auto-captions use a scrolling format where each block contains
the previous line + new text. We filter out repeat blocks (< 20ms duration)
and strip overlapping prefixes to get clean text.
"""
content = open(vtt_path).read()
blocks = content.split('\n\n')
segments = []
prev_clean = ''
for block in blocks:
lines = block.strip().split('\n')
if not lines:
continue
ts = re.match(r'(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})', lines[0])
if not ts:
continue
p1 = ts.group(1).split(':')
p2 = ts.group(2).split(':')
s1 = int(p1[0]) * 3600 + int(p1[1]) * 60 + float(p1[2])
s2 = int(p2[0]) * 3600 + int(p2[1]) * 60 + float(p2[2])
if s2 - s1 < 0.02:
continue
raw_text = '\n'.join(lines[1:])
clean = re.sub(r'<[^>]+>', '', raw_text).strip()
clean = re.sub(r'\s+', ' ', clean)
if not clean or clean == prev_clean:
continue
new_text = clean
if prev_clean:
for overlap_len in range(min(len(prev_clean), len(clean)), 0, -1):
if clean[:overlap_len] == prev_clean[-overlap_len:]:
new_text = clean[overlap_len:].strip()
break
if new_text:
segments.append({'start': s1, 'end': s2, 'text': new_text})
prev_clean = clean
return segments
def build_readable_transcript(segments):
"""Build a human-readable transcript with timestamps every ~30s."""
output = ''
last_ts = -30
for seg in segments:
if seg['start'] - last_ts >= 30:
m, s = divmod(int(seg['start']), 60)
output += f'\n\n[{m}:{s:02d}] '
last_ts = seg['start']
output += seg['text'] + ' '
return output
def chunk_transcript(transcript_text, chunk_size=12000):
"""Split transcript into chunks at timestamp boundaries."""
chunks = []
remaining = transcript_text
while remaining:
if len(remaining) <= chunk_size:
chunks.append(remaining)
break
break_at = remaining.rfind('\n\n[', 0, chunk_size)
if break_at < chunk_size * 0.3:
break_at = chunk_size
chunks.append(remaining[:break_at])
remaining = remaining[break_at:]
return chunks
def find_moments_full_transcript(full_transcript, video_title=""):
"""Analyze the ENTIRE transcript in one call."""
prompt = f"""You are a legendary short-form video editor (think: the team behind Hormozi's clips, Chris Williamson's best moments).
Read this FULL transcript of "{video_title}" and find the 3-5 BEST moments that could become viral 30-60 second clips.
CRITICAL RULES:
- ONLY identify moments that ACTUALLY EXIST in the transcript below
- Quote the EXACT words from the transcript — do not paraphrase or invent
- Each moment must have a clear HOOK → BUILD → PAYOFF arc
- A stranger scrolling at 2am should stop, watch the whole clip, and feel smarter
What makes a 90+ clip:
- HOOK (0-3s): Pattern interrupt — shocking stat, bold claim, provocative question
- BUILD (3-30s): Stakes rise — story tension, framework develops, insight escalates
- PAYOFF (last 5-10s): The insight LANDS — counterintuitive truth, surprising number, emotional resolution
- CLEAN END: Cut immediately after the payoff. Silence > trailing off.
FULL TRANSCRIPT:
{full_transcript}
Return a JSON array of the best moments (3-5 max). For each:
{{
"start_timestamp": "[M:SS] exact timestamp from transcript",
"end_timestamp": "[M:SS] where to cut",
"hook_quote": "EXACT opening words from transcript",
"payoff_quote": "EXACT closing words/punchline from transcript",
"why_viral": "One sentence on why this stops scrolls",
"estimated_score": 0-100,
"narrative_arc": "Hook: ... → Build: ... → Payoff: ..."
}}
Be EXTREMELY selective. If nothing scores above 70, return fewer moments or an empty array. Quality > quantity."""
try:
response = call_claude(prompt, max_tokens=3000)
json_match = re.search(r'\[[\s\S]*\]', response)
if json_match:
moments = json.loads(json_match.group())
for m in moments:
m['hook'] = m.get('hook_quote', m.get('hook', ''))
m['payoff'] = m.get('payoff_quote', m.get('payoff', ''))
m['suggested_clip_text'] = m.get('narrative_arc', '')
return moments
return []
except Exception as e:
print(f" ⚠️ Full transcript analysis failed: {e}")
return []
def find_moments_in_chunk(chunk_text, chunk_idx, video_title=""):
"""Ask LLM to find clip-worthy moments in a transcript chunk."""
prompt = f"""You are a legendary short-form video editor.
Analyze this transcript section from "{video_title}" and find ANY moments that could become a viral 30-60 second clip.
A great clip moment has:
- A clear HOOK (bold claim, shocking stat, provocative question, emotional statement)
- A STORY ARC or BUILD (tension rises, framework develops, stakes increase)
- A PAYOFF (insight lands, number drops, counterintuitive truth revealed, punchline hits)
- Works STANDALONE — a stranger with zero context would stop scrolling and watch
TRANSCRIPT SECTION:
{chunk_text}
Return a JSON array of moments found. If no moments qualify, return an empty array.
For each moment:
{{
"start_timestamp": "[M:SS] from the transcript",
"end_timestamp": "[M:SS] approximate end",
"hook": "The opening line/moment that grabs attention",
"payoff": "How this moment resolves/lands",
"why_viral": "One sentence on why this would stop a scroll",
"estimated_score": 0-100,
"suggested_clip_text": "The key 2-3 sentences a viewer would remember"
}}
Be SELECTIVE. Most transcript sections have 0-1 clip-worthy moments. Only include moments you'd bet could score 70+."""
try:
response = call_claude(prompt, max_tokens=2000)
json_match = re.search(r'\[[\s\S]*\]', response)
if json_match:
return json.loads(json_match.group())
return []
except Exception as e:
print(f" ⚠️ Chunk {chunk_idx} failed: {e}")
return []
def score_and_refine_moment(moment, full_transcript_context, video_title=""):
"""Deep-score a candidate moment and suggest exact trim points."""
prompt = f"""You are scoring a potential short-form clip from "{video_title}".
CANDIDATE MOMENT:
Hook: {moment.get('hook', 'N/A')}
Payoff: {moment.get('payoff', 'N/A')}
Why viral: {moment.get('why_viral', 'N/A')}
Key text: {moment.get('suggested_clip_text', 'N/A')}
SURROUNDING TRANSCRIPT (for context):
{full_transcript_context}
Score this clip candidate on a 0-100 scale:
- HOOK (0-25): Does the first sentence stop the scroll?
- BUILD (0-25): Does tension/interest rise through the middle?
- PAYOFF (0-25): Does the insight LAND? Would the viewer feel smarter/moved?
- CLEAN CUT (0-25): Can this end on a strong note without trailing off?
Also provide:
- Exact start quote (the first words of the clip)
- Exact end quote (the last words before cutting)
- Any adjustments to improve the score
Return JSON:
{{
"total_score": 0-100,
"hook_score": 0-25,
"build_score": 0-25,
"payoff_score": 0-25,
"clean_cut_score": 0-25,
"start_quote": "exact first words",
"end_quote": "exact last words",
"adjustments": "how to improve",
"would_you_post_this": true/false,
"reason": "one line summary"
}}"""
try:
response = call_claude(prompt, max_tokens=1500)
json_match = re.search(r'\{[\s\S]*\}', response)
if json_match:
return json.loads(json_match.group())
return {"total_score": 0, "reason": "Failed to parse"}
except Exception as e:
return {"total_score": 0, "reason": f"API error: {e}"}
def get_context_around_timestamp(segments, timestamp_str, context_seconds=180):
"""Get clean transcript text around a timestamp."""
parts = timestamp_str.replace('[', '').replace(']', '').split(':')
if len(parts) == 2:
target_sec = int(parts[0]) * 60 + int(parts[1])
elif len(parts) == 3:
target_sec = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
else:
target_sec = 0
context = ''
last_ts = -15
for seg in segments:
if target_sec - context_seconds <= seg['start'] <= target_sec + context_seconds:
if seg['start'] - last_ts >= 15:
m, s = divmod(int(seg['start']), 60)
context += f'\n[{m}:{s:02d}] '
last_ts = seg['start']
context += seg['text'] + ' '
return context[:5000]
def cut_clip(video_url, start_sec, duration_sec, output_path):
"""Download video and cut a clip using ffmpeg."""
video_id = re.search(r'(?:v=|/)([a-zA-Z0-9_-]{11})', video_url).group(1)
video_cache = f"/tmp/editorial_{video_id}.mp4"
if not os.path.exists(video_cache):
print(f" ⬇️ Downloading video...")
subprocess.run([
'yt-dlp', '--format', 'best[height<=720]',
'--output', video_cache, '--no-playlist', video_url
], capture_output=True, check=True)
CLIPS_DIR.mkdir(parents=True, exist_ok=True)
cmd = [
'ffmpeg', '-y',
'-ss', str(start_sec),
'-i', video_cache,
'-t', str(duration_sec),
'-vf', 'crop=ih*9/16:ih,scale=1080:1920',
'-c:a', 'aac', '-b:a', '128k',
output_path
]
subprocess.run(cmd, capture_output=True, check=True)
return os.path.exists(output_path)
def timestamp_to_seconds(ts_str):
"""Convert timestamp string like '14:31' to seconds."""
parts = ts_str.replace('[', '').replace(']', '').strip().split(':')
if len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
elif len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
return 0
def main():
parser = argparse.ArgumentParser(description='Editorial Brain — LLM-powered clip discovery')
parser.add_argument('--url', help='YouTube URL')
parser.add_argument('--vtt', help='VTT file path')
parser.add_argument('--video-id', help='Video ID (required with --vtt)')
parser.add_argument('--title', default='', help='Video title')
parser.add_argument('--max-clips', type=int, default=5, help='Max clips to produce')
parser.add_argument('--min-score', type=int, default=90, help='Minimum score threshold')
parser.add_argument('--skip-cut', action='store_true', help='Skip video cutting (analysis only)')
parser.add_argument('--output', help='Output JSON path')
args = parser.parse_args()
if not ANTHROPIC_API_KEY:
print("❌ Set ANTHROPIC_API_KEY environment variable")
sys.exit(1)
output_path = args.output or str(DATA_DIR / "editorial-clips-latest.json")
# Step 1: Get transcript
if args.url:
print(f"📥 Downloading subtitles...")
vtt_path, video_id = download_vtt(args.url)
elif args.vtt:
vtt_path = args.vtt
video_id = args.video_id or 'unknown'
else:
parser.print_help()
sys.exit(1)
print(f"📝 Parsing transcript...")
segments = parse_vtt(vtt_path)
print(f" {len(segments)} segments")
readable = build_readable_transcript(segments)
chunks = chunk_transcript(readable)
print(f" {len(chunks)} chunks for analysis")
# Step 2: Scan for moments
all_moments = []
if len(readable) < 80000:
print(f"\n🔍 Pass 1: Full-transcript analysis (single call, {len(readable)//1000}K chars)...")
moments = find_moments_full_transcript(readable, args.title)
all_moments = moments
print(f" Found {len(moments)} candidate(s)")
else:
print(f"\n🔍 Pass 1: Chunked analysis ({len(chunks)} chunks)...")
for i, chunk in enumerate(chunks):
moments = find_moments_in_chunk(chunk, i, args.title)
if moments:
print(f" Chunk {i+1}/{len(chunks)}: Found {len(moments)} candidate(s)")
for m in moments:
m['chunk_idx'] = i
all_moments.append(m)
else:
print(f" Chunk {i+1}/{len(chunks)}: No moments")
print(f"\n📊 Pass 1 complete: {len(all_moments)} total candidates")
if not all_moments:
print("❌ No clip-worthy moments found in this episode")
sys.exit(0)
all_moments.sort(key=lambda x: x.get('estimated_score', 0), reverse=True)
top_candidates = all_moments[:min(10, len(all_moments))]
for m in top_candidates:
print(f" [{m.get('start_timestamp', '?')}] Score ~{m.get('estimated_score', '?')}: {m.get('hook', '?')[:60]}")
# Step 3: Deep-score candidates (Pass 2)
print(f"\n🎯 Pass 2: Deep-scoring top {len(top_candidates)} candidates...")
scored = []
for i, moment in enumerate(top_candidates):
ts = moment.get('start_timestamp', '0:00')
context = get_context_around_timestamp(segments, ts)
score = score_and_refine_moment(moment, context, args.title)
moment['deep_score'] = score
total = score.get('total_score', 0)
scored.append(moment)
status = "✅" if total >= args.min_score else "❌"
print(f" {status} [{ts}] Score: {total}/100 — {score.get('reason', '?')[:80]}")
passed = [m for m in scored if m.get('deep_score', {}).get('total_score', 0) >= args.min_score]
print(f"\n🏆 {len(passed)} clips scored {args.min_score}+")
# Step 4: Cut clips
results = {
'video_id': video_id,
'title': args.title,
'url': args.url or '',
'total_candidates': len(all_moments),
'scored': len(scored),
'passed': len(passed),
'threshold': args.min_score,
'clips': []
}
if passed and not args.skip_cut and args.url:
print(f"\n✂️ Cutting {len(passed)} clips...")
for i, moment in enumerate(passed[:args.max_clips]):
start_sec = timestamp_to_seconds(moment.get('start_timestamp', '0:00'))
end_sec = timestamp_to_seconds(moment.get('end_timestamp', '0:00'))
duration = max(30, min(60, end_sec - start_sec)) if end_sec > start_sec else 45
clip_id = f"{video_id}_editorial_{i+1}"
clip_output = str(CLIPS_DIR / f"{clip_id}.mp4")
try:
cut_clip(args.url, start_sec, duration, clip_output)
print(f" ✅ {clip_id}.mp4 ({duration}s)")
results['clips'].append({
'id': clip_id,
'path': clip_output,
'start': start_sec,
'duration': duration,
'score': moment['deep_score'],
'hook': moment.get('hook', ''),
'payoff': moment.get('payoff', ''),
})
except Exception as e:
print(f" ❌ Cut failed: {e}")
results['all_scored'] = [{
'timestamp': m.get('start_timestamp', '?'),
'score': m.get('deep_score', {}).get('total_score', 0),
'hook': m.get('hook', ''),
'payoff': m.get('payoff', ''),
'reason': m.get('deep_score', {}).get('reason', ''),
'adjustments': m.get('deep_score', {}).get('adjustments', ''),
} for m in scored]
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w') as f:
json.dump(results, f, indent=2)
print(f"\n💾 Saved to {output_path}")
return 0 if passed else 1
if __name__ == '__main__':
sys.exit(main())
#!/usr/bin/env python3
"""
Quote Mining Engine — Extract viral-worthy quotes from podcasts and notes.
Scans RSS feeds and local markdown/text files to extract the most quotable,
contrarian, and viral-worthy moments. Outputs scored candidates ready to publish.
Usage:
python quote-mining-engine.py --days 90 --top 50 --min-score 60
python quote-mining-engine.py --feeds feeds.json --notes-dir ./notes/
"""
import argparse
import json
import os
import re
import sys
import hashlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from html import unescape
import feedparser
# ── Configuration ──
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
DATA_DIR = Path(os.environ.get("CONTENT_OPS_DATA_DIR", PROJECT_DIR / "data"))
OUTPUT_PATH = DATA_DIR / "quote-mining-latest.json"
# Configure feeds via environment variable or JSON file
# Format: {"Feed Name": "https://feed-url.com/rss", ...}
FEEDS_FILE = os.environ.get("QUOTE_MINING_FEEDS_FILE", str(PROJECT_DIR / "config" / "feeds.json"))
# Directory containing meeting notes / transcripts (markdown files)
NOTES_DIR = os.environ.get("QUOTE_MINING_NOTES_DIR", "")
# Speaker name to look for in meeting notes (configurable)
SPEAKER_NAME = os.environ.get("QUOTE_MINING_SPEAKER", "")
# ── Viral scoring heuristics ──
CONTRARIAN_SIGNALS = [
r"\b(?:wrong|myth|lie|dead|overrated|underrated|nobody|everyone)\b",
r"\b(?:stop|quit|don\'t|never|avoid|mistake|fail)\b",
r"\b(?:secret|hidden|overlooked|surprising|counterintuitive)\b",
r"\b(?:actually|truth|reality|real reason)\b",
r"\b(?:unpopular opinion|hot take|controversial)\b",
]
SPECIFICITY_SIGNALS = [
r"\$[\d,.]+[MBKmk]?",
r"\b\d{1,3}%\b",
r"\b\d+x\b",
r"\b(?:doubled|tripled|10x|100x)\b",
r"\b\d{4,}\b",
r"\b(?:case study|example|data|study|research)\b",
]
EMOTIONAL_TRIGGERS = [
r"\b(?:fear|afraid|scared|worried|anxious)\b",
r"\b(?:love|hate|obsessed|passionate)\b",
r"\b(?:shocking|insane|crazy|wild|unbelievable|mindblowing)\b",
r"\b(?:broke|rich|wealthy|millionaire|billionaire)\b",
r"\b(?:fired|hired|quit|resigned)\b",
r"\b(?:AI|artificial intelligence|ChatGPT|GPT|automation)\b",
]
SHAREABILITY_SIGNALS = [
r"\b(?:how to|step.by.step|framework|playbook|strategy)\b",
r"\b(?:lesson|learned|mistake|regret)\b",
r"\b(?:why (?:most|nobody|everyone))\b",
r"\b(?:the (?:one|only|best|worst|biggest))\b",
r"\bhack\b",
]
def score_text(text: str) -> dict:
"""Score a text blob for viral potential. Returns breakdown + total."""
t = text.lower()
def count_matches(patterns):
return sum(1 for p in patterns if re.search(p, t, re.I))
contrarian = min(count_matches(CONTRARIAN_SIGNALS) * 15, 35)
specificity = min(count_matches(SPECIFICITY_SIGNALS) * 12, 30)
emotional = min(count_matches(EMOTIONAL_TRIGGERS) * 12, 25)
shareability = min(count_matches(SHAREABILITY_SIGNALS) * 12, 25)
words = len(text.split())
if words <= 15:
length_bonus = 10
elif words <= 30:
length_bonus = 5
else:
length_bonus = 0
question_bonus = 8 if re.search(r"\?", text) else 0
number_bonus = 8 if re.search(r"\b\d+\b", text) else 0
howto_bonus = 8 if re.search(r"^(?:how|why|what|when|the\s+\d)", text, re.I) else 0
total = min(contrarian + specificity + emotional + shareability + length_bonus + question_bonus + number_bonus + howto_bonus, 100)
return {
"contrarian": contrarian,
"specificity": specificity,
"emotional": emotional,
"shareability": shareability,
"total": total,
}
def suggest_platform(score_breakdown: dict, text: str) -> str:
"""Suggest X, LinkedIn, or both based on content characteristics."""
if score_breakdown["specificity"] >= 15 and score_breakdown["shareability"] >= 10:
return "both"
if score_breakdown["emotional"] >= 15 or len(text.split()) <= 20:
return "X"
if score_breakdown["specificity"] >= 10 or score_breakdown["shareability"] >= 10:
return "LinkedIn"
if score_breakdown["total"] >= 60:
return "both"
return "X"
def generate_hook(quote: str) -> str:
"""Generate a punchy X-ready opening line from a quote."""
q = quote.strip().rstrip(".")
words = q.split()
if len(words) <= 20:
return q + "."
short = " ".join(words[:15])
for sep in [". ", ", ", " — ", " - ", ": "]:
idx = short.rfind(sep)
if idx > 20:
return short[: idx + len(sep)].strip().rstrip(",") + "..."
return short + "..."
def strip_html(text: str) -> str:
"""Remove HTML tags and decode entities."""
text = re.sub(r"<[^>]+>", " ", text)
text = unescape(text)
text = re.sub(r"\s+", " ", text).strip()
return text
def make_id(text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()[:10]
def load_feeds() -> dict:
"""Load RSS feed configuration."""
feeds_path = Path(FEEDS_FILE)
if feeds_path.exists():
try:
with open(feeds_path) as f:
return json.load(f)
except Exception as e:
print(f" ⚠ Error loading feeds config: {e}")
# Check environment variable for inline JSON
feeds_env = os.environ.get("QUOTE_MINING_FEEDS", "")
if feeds_env:
try:
return json.loads(feeds_env)
except Exception:
pass
print(" ⚠ No feeds configured. Set QUOTE_MINING_FEEDS_FILE or QUOTE_MINING_FEEDS env var.")
print(" Example feeds.json: {\"My Podcast\": \"https://feeds.example.com/rss\"}")
return {}
# ── RSS Feed Processing ──
def fetch_feed_quotes(feed_name: str, feed_url: str, since: datetime) -> list:
"""Parse an RSS feed and extract quotable candidates."""
print(f" Fetching {feed_name}...")
feed = feedparser.parse(feed_url)
candidates = []
for entry in feed.entries:
pub = entry.get("published_parsed") or entry.get("updated_parsed")
if not pub:
continue
pub_dt = datetime(*pub[:6], tzinfo=timezone.utc)
if pub_dt < since:
continue
title = entry.get("title", "").strip()
desc = strip_html(entry.get("description", "") or entry.get("summary", ""))
date_str = pub_dt.strftime("%Y-%m-%d")
if title:
scores = score_text(title + " " + desc[:200])
context_sentence = desc[:200].split(".")[0].strip() + "." if desc else title
candidates.append({
"id": make_id(title + date_str),
"quote_text": title,
"source": f"{feed_name} — {title} ({date_str})",
"viral_score": scores["total"],
"score_breakdown": scores,
"suggested_platform": suggest_platform(scores, title),
"hook_version": generate_hook(title),
"context": context_sentence,
"type": "podcast_title",
})
if desc and len(desc) > 50:
sentences = re.split(r"(?<=[.!?])\s+", desc)
for sent in sentences:
sent = sent.strip()
if len(sent) < 30 or len(sent) > 300:
continue
if any(skip in sent.lower() for skip in [
"subscribe", "leave a review", "click here", "sign up",
"sponsor", "brought to you", "check out", "visit us",
"follow us", "download", "episode is", "links mentioned",
"get a free", "use code", "http", "www.", ".com/",
]):
continue
s = score_text(sent)
if s["total"] >= 30:
candidates.append({
"id": make_id(sent + date_str),
"quote_text": sent,
"source": f"{feed_name} — {title} ({date_str})",
"viral_score": s["total"],
"score_breakdown": s,
"suggested_platform": suggest_platform(s, sent),
"hook_version": generate_hook(sent),
"context": f"From episode: {title}",
"type": "podcast_description",
})
print(f" → {len(candidates)} candidates from {feed_name}")
return candidates
# ── Notes Processing ──
def scan_notes(notes_dir: str, since: datetime, speaker: str = "") -> list:
"""Scan meeting notes/transcripts for quotable moments."""
notes_path = Path(notes_dir)
if not notes_path.exists():
print(f" ⚠ Notes directory not found: {notes_dir}, skipping.")
return []
print(f" Scanning notes in {notes_dir}...")
candidates = []
for fpath in sorted(notes_path.glob("**/*.md")):
m = re.match(r"(\d{4}-\d{2}-\d{2})", fpath.name)
if m:
file_date = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
if file_date < since:
continue
else:
# If no date in filename, include by default
file_date = datetime.now(timezone.utc)
try:
text = fpath.read_text(errors="replace")
except Exception:
continue
meeting_name = fpath.stem.replace("_", " ").lstrip("0123456789- ")
notable_lines = []
for line in text.split("\n"):
line = line.strip()
if not line or len(line) < 30:
continue
# Match lines attributed to configured speaker
if speaker and re.match(rf"(?:{re.escape(speaker)})\s*:", line, re.I):
content = re.sub(rf"^(?:{re.escape(speaker)})\s*:\s*", "", line, flags=re.I)
notable_lines.append(content.strip())
# Grab bullet points with viral signals
elif re.match(r"[\*\-]\s+", line):
bullet = re.sub(r"^[\*\-]\s+", "", line).strip()
if len(bullet) > 30 and any(
re.search(p, bullet, re.I)
for p in CONTRARIAN_SIGNALS + SPECIFICITY_SIGNALS + EMOTIONAL_TRIGGERS
):
notable_lines.append(bullet)
for line in notable_lines:
if len(line) < 20 or len(line) > 500:
continue
if any(skip in line.lower() for skip in [
"let me share my screen", "can you hear me", "hold on",
"one second", "sorry about that", "let me pull up",
"next slide", "any questions", "sounds good",
]):
continue
s = score_text(line)
if s["total"] >= 25:
date_str = file_date.strftime("%Y-%m-%d")
candidates.append({
"id": make_id(line + date_str),
"quote_text": line,
"source": f"Notes — {meeting_name} ({date_str})",
"viral_score": s["total"],
"score_breakdown": s,
"suggested_platform": suggest_platform(s, line),
"hook_version": generate_hook(line),
"context": f"From: {meeting_name}",
"type": "meeting_notes",
})
print(f" → {len(candidates)} candidates from notes")
return candidates
# ── Main ──
def main():
parser = argparse.ArgumentParser(description="Quote Mining Engine")
parser.add_argument("--days", type=int, default=90, help="Look back N days (default: 90)")
parser.add_argument("--top", type=int, default=50, help="Return top N quotes (default: 50)")
parser.add_argument("--min-score", type=int, default=40, help="Minimum viral score (default: 40)")
parser.add_argument("--output", type=str, default=str(OUTPUT_PATH), help="Output JSON path")
parser.add_argument("--feeds", type=str, help="Path to feeds JSON config file")
parser.add_argument("--notes-dir", type=str, help="Directory of meeting notes to scan")
parser.add_argument("--speaker", type=str, help="Speaker name to extract from notes")
args = parser.parse_args()
since = datetime.now(timezone.utc) - timedelta(days=args.days)
print(f"🔍 Quote Mining Engine — scanning last {args.days} days (since {since.strftime('%Y-%m-%d')})\n")
all_candidates = []
# 1. Podcast RSS feeds
feeds_file = args.feeds or FEEDS_FILE
if args.feeds:
os.environ["QUOTE_MINING_FEEDS_FILE"] = args.feeds
feeds = load_feeds() if not args.feeds else json.load(open(args.feeds))
if feeds:
print("📡 Fetching podcast feeds...")
for name, url in feeds.items():
try:
all_candidates.extend(fetch_feed_quotes(name, url, since))
except Exception as e:
print(f" ⚠ Error fetching {name}: {e}")
# 2. Meeting notes
notes_dir = args.notes_dir or NOTES_DIR
speaker = args.speaker or SPEAKER_NAME
if notes_dir:
print("\n📝 Scanning meeting notes...")
try:
all_candidates.extend(scan_notes(notes_dir, since, speaker))
except Exception as e:
print(f" ⚠ Error scanning notes: {e}")
# 3. Deduplicate
seen = set()
unique = []
for c in all_candidates:
if c["id"] not in seen:
seen.add(c["id"])
unique.append(c)
all_candidates = unique
# 4. Filter by min score
filtered = [c for c in all_candidates if c["viral_score"] >= args.min_score]
# 5. Sort and take top N
filtered.sort(key=lambda x: x["viral_score"], reverse=True)
top = filtered[: args.top]
# 6. Clean output
output = []
for c in top:
output.append({
"quote_text": c["quote_text"],
"source": c["source"],
"viral_score": c["viral_score"],
"suggested_platform": c["suggested_platform"],
"hook_version": c["hook_version"],
"context": c["context"],
})
# 7. Save
os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, "w") as f:
json.dump(output, f, indent=2)
# 8. Summary
print(f"\n{'='*60}")
print(f"📊 QUOTE MINING SUMMARY")
print(f"{'='*60}")
print(f" Total candidates found: {len(all_candidates)}")
print(f" Above min score ({args.min_score}): {len(filtered)}")
print(f" Top quotes saved: {len(output)}")
print(f" Output: {args.output}")
print()
if output:
print(f"🏆 Top 10 Quotes:")
print(f"{'-'*60}")
for i, q in enumerate(output[:10], 1):
print(f" {i:2d}. [{q['viral_score']:3d}] {q['quote_text'][:80]}")
print(f" → {q['source'][:60]}")
print(f" Platform: {q['suggested_platform']} | Hook: {q['hook_version'][:50]}...")
print()
else:
print(" ⚠ No quotes met the minimum score threshold.")
print(f" Try lowering --min-score (currently {args.min_score})")
return 0
if __name__ == "__main__":
sys.exit(main())
Related skills
FAQ
What score does content-ops target?
It targets 90/100 across all experts, iterating up to 3 rounds.
How many experts are on the panel?
It auto-assembles a panel of 7-10 experts tailored to the content type and domain.