<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: binky</title>
    <description>The latest articles on DEV Community by binky (@binky_6ad02e76335bf0ce709).</description>
    <link>https://dev.to/binky_6ad02e76335bf0ce709</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3932552%2F52bca8d3-00bb-4cf7-a876-4b3a7bd75158.jpg</url>
      <title>DEV Community: binky</title>
      <link>https://dev.to/binky_6ad02e76335bf0ce709</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/binky_6ad02e76335bf0ce709"/>
    <language>en</language>
    <item>
      <title>Build a Content Metadata Extractor: Auto-Generate SEO Tags, Summaries, and Social Posts</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Wed, 03 Jun 2026 14:45:19 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/build-a-content-metadata-extractor-auto-generate-seo-tags-summaries-and-social-posts-472a</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/build-a-content-metadata-extractor-auto-generate-seo-tags-summaries-and-social-posts-472a</guid>
      <description>&lt;p&gt;Content creators spend 30 minutes per article extracting metadata. Here's a Python script that does it in 10 seconds.&lt;/p&gt;

&lt;p&gt;I've watched this happen: open the article, read it twice, draft a meta description, pick 5-8 SEO tags, write an Open Graph summary, think up a social caption, argue with yourself about the title. Multiply that by 50 articles a month and you've burned a full workday on metadata that nobody directly reads.&lt;/p&gt;

&lt;p&gt;This article walks through building a CLI tool that takes raw markdown and outputs structured JSON with SEO tags, meta descriptions, social post drafts, and content summaries — all in under 10 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Automate This
&lt;/h2&gt;

&lt;p&gt;Metadata isn't hard. It's expensive. You just finished writing; now you need to think like an SEO analyst and a social media manager simultaneously. That context switch costs real time.&lt;/p&gt;

&lt;p&gt;Consistency is the second issue. Across a content library, human-generated metadata falls apart — some articles have 3 tags, some have 15. Descriptions range from 80 to 300 characters with no pattern.&lt;/p&gt;

&lt;p&gt;Automation fixes both: zero cognitive switching, enforced output schema, identical results whether you're processing article 1 or article 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;Three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Input&lt;/strong&gt; — reads markdown or plain text from disk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude API wrapper&lt;/strong&gt; — sends structured prompt, parses JSON response&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output&lt;/strong&gt; — writes metadata as JSON, optionally as YAML frontmatter&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tools: &lt;code&gt;anthropic&lt;/code&gt; SDK, &lt;code&gt;click&lt;/code&gt; for CLI, &lt;code&gt;rich&lt;/code&gt; for terminal output, &lt;code&gt;concurrent.futures&lt;/code&gt; for batch processing.&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
pip install anthropic click rich python-frontmatter&lt;/p&gt;

&lt;p&gt;Set your API key:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
export ANTHROPIC_API_KEY="sk-ant-..."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Extractor
&lt;/h2&gt;

&lt;p&gt;This is &lt;code&gt;extractor.py&lt;/code&gt; — where the actual work happens.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import anthropic&lt;br&gt;
import json&lt;br&gt;
import re&lt;br&gt;
from pathlib import Path&lt;/p&gt;

&lt;p&gt;client = anthropic.Anthropic()&lt;/p&gt;

&lt;p&gt;METADATA_PROMPT = """You are a content strategist and SEO specialist. Analyze the article below and return ONLY a valid JSON object with no additional text.&lt;/p&gt;

&lt;p&gt;Required JSON structure:&lt;br&gt;
{&lt;br&gt;
  "title": "Optimized SEO title (60 chars max)",&lt;br&gt;
  "meta_description": "Compelling meta description (150-160 chars)",&lt;br&gt;
  "seo_tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],&lt;br&gt;
  "summary": "2-3 sentence content summary for internal use",&lt;br&gt;
  "social_post": "LinkedIn/Twitter-ready post with hook (280 chars max)",&lt;br&gt;
  "reading_time_minutes": 5,&lt;br&gt;
  "primary_keyword": "main target keyword",&lt;br&gt;
  "content_category": "tutorial|opinion|news|case-study|reference"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;seo_tags: 5-8 tags, lowercase, no spaces (use hyphens)&lt;/li&gt;
&lt;li&gt;social_post: start with a hook statement, end with a question or CTA&lt;/li&gt;
&lt;li&gt;reading_time_minutes: estimate based on 200 words per minute&lt;/li&gt;
&lt;li&gt;Return ONLY the JSON object, no markdown fences, no explanation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Article:&lt;br&gt;
{article_content}&lt;br&gt;
"""&lt;/p&gt;

&lt;p&gt;def smart_truncate(content: str, max_chars: int = 8000) -&amp;gt; str:&lt;br&gt;
    """Truncate at paragraph boundary to preserve semantic coherence."""&lt;br&gt;
    if len(content) &amp;lt;= max_chars:&lt;br&gt;
        return content&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;truncated = content[:max_chars]
last_para = truncated.rfind("\n\n")

if last_para &amp;gt; max_chars * 0.7:
    return truncated[:last_para].strip()

return truncated.strip()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def extract_metadata(content: str, model: str = "claude-opus-4-5") -&amp;gt; dict:&lt;br&gt;
    """Send article content to Claude and parse the JSON response."""&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt = METADATA_PROMPT.format(article_content=smart_truncate(content))

message = client.messages.create(
    model=model,
    max_tokens=1024,
    messages=[
        {"role": "user", "content": prompt}
    ]
)

raw_response = message.content[0].text.strip()

# Strip markdown code fences if Claude added them
if raw_response.startswith(""):
    raw_response = re.sub(r"^[a-z]*\n?", "", raw_response)
    raw_response = re.sub(r"\n?$", "", raw_response)

return json.loads(raw_response)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def process_file(filepath: str | Path) -&amp;gt; dict:&lt;br&gt;
    """Read a file and return its metadata."""&lt;br&gt;
    path = Path(filepath)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if not path.exists():
    raise FileNotFoundError(f"File not found: {filepath}")

content = path.read_text(encoding="utf-8")

# Strip YAML frontmatter if present
if content.startswith("---"):
    parts = content.split("---", 2)
    if len(parts) &amp;gt;= 3:
        content = parts[2].strip()

metadata = extract_metadata(content)
metadata["source_file"] = str(path.name)

return metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;smart_truncate&lt;/code&gt; function is crucial at scale. I ran this on 200 articles and hit &lt;code&gt;json.JSONDecodeError&lt;/code&gt; on ~15 files. The issue: truncating at a hard character limit sometimes cuts mid-sentence, confusing the model. Solution: find the last paragraph break before the limit. Error rate dropped to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire It to a CLI
&lt;/h2&gt;

&lt;p&gt;Here's &lt;code&gt;cli.py&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import click&lt;br&gt;
import json&lt;br&gt;
import sys&lt;br&gt;
from concurrent.futures import ThreadPoolExecutor, as_completed&lt;br&gt;
from pathlib import Path&lt;br&gt;
from rich.console import Console&lt;br&gt;
from rich.table import Table&lt;br&gt;
from rich.progress import Progress, SpinnerColumn, TextColumn&lt;br&gt;
from extractor import process_file&lt;/p&gt;

&lt;p&gt;console = Console()&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.group()&lt;br&gt;
def cli():&lt;br&gt;
    """Content metadata extractor powered by Claude API."""&lt;br&gt;
    pass&lt;/p&gt;

&lt;p&gt;@cli.command()&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.argument("filepath", type=click.Path(exists=True))&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--output", "-o", type=click.Path(), help="Write JSON to file")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--format", "fmt", type=click.Choice(["json", "table"]), default="json")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--model", default="claude-opus-4-5", help="Claude model to use")&lt;br&gt;
def extract(filepath, output, fmt, model):&lt;br&gt;
    """Extract metadata from a single article file."""&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
    task = progress.add_task("Analyzing article...", total=None)
    result = process_file(filepath)
    progress.remove_task(task)

if fmt == "table":
    table = Table(title=f"Metadata: {filepath}", show_lines=True)
    table.add_column("Field", style="cyan", no_wrap=True)
    table.add_column("Value", style="white")

    for key, value in result.items():
        display = json.dumps(value) if isinstance(value, list) else str(value)
        table.add_row(key, display[:120])

    console.print(table)
else:
    output_json = json.dumps(result, indent=2)

    if output:
        Path(output).write_text(output_json)
        console.print(f"[green]✓[/green] Written to {output}")
    else:
        console.print(output_json)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;@cli.command()&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.argument("directory", type=click.Path(exists=True, file_okay=False))&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--output-dir", "-o", type=click.Path(), default="./metadata_output")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--workers", "-w", default=5, help="Parallel workers (default: 5)")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--glob", default="*.md", help="File pattern (default: *.md)")&lt;br&gt;
def batch(directory, output_dir, workers, glob):&lt;br&gt;
    """Process all articles in a directory."""&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input_dir = Path(directory)
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)

files = list(input_dir.glob(glob))

if not files:
    console.print(f"[yellow]No files matching '{glob}' in {directory}[/yellow]")
    sys.exit(1)

console.print(f"[blue]Processing {len(files)} files with {workers} workers...[/blue]")

results = []
errors = []

with Progress() as progress:
    task = progress.add_task("Extracting metadata...", total=len(files))

    with ThreadPoolExecutor(max_workers=workers) as executor:
        future_to_file = {executor.submit(process_file, f): f for f in files}

        for future in as_completed(future_to_file):
            filepath = future_to_file[future]
            progress.advance(task)

            try:
                result = future.result()
                results.append(result)

                # Write individual JSON file
                out_path = out_dir / f"{filepath.stem}_metadata.json"
                out_path.write_text(json.dumps(result, indent=2))

            except Exception as e:
                errors.append({"file": str(filepath), "error": str(e)})
                console.print(f"[red]✗[/red] {filepath.name}: {e}")

# Write combined manifest
manifest_path = out_dir / "_manifest.json"
manifest_path.write_text(json.dumps({
    "total": len(files),
    "success": len(results),
    "errors": len(errors),
    "articles": results
}, indent=2))

console.print(f"\n[green]Done![/green] {len(results)}/{len(files)} files processed.")
console.print(f"Output: {out_dir.resolve()}")
console.print(f"Manifest: {manifest_path.resolve()}")

if errors:
    console.print(f"\n[yellow]{len(errors)} errors logged in manifest.[/yellow]")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    cli()&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;batch&lt;/code&gt; command is where speed comes from. &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; with 5 workers makes 5 concurrent API calls. A 100-article run drops from ~17 minutes to ~3-4 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running It
&lt;/h2&gt;

&lt;p&gt;Single file:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python cli.py extract ./articles/my-post.md --format table&lt;br&gt;
python cli.py extract ./articles/my-post.md -o ./output/my-post-meta.json&lt;/p&gt;

&lt;p&gt;Batch process:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
python cli.py batch ./articles/ --output-dir ./metadata/ --workers 8 --glob "*.md"&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;_manifest.json&lt;/code&gt; file becomes your content index — searchable, normalized tags, categorized for auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending: Multi-Channel Social
&lt;/h2&gt;

&lt;p&gt;The base prompt gives you one social post. For full multi-channel output, add a second API call:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
SOCIAL_PROMPT = """You are a social media copywriter. Based on this article metadata, generate social content.&lt;/p&gt;

&lt;p&gt;Article title: {title}&lt;br&gt;
Summary: {summary}&lt;br&gt;
Primary keyword: {primary_keyword}&lt;/p&gt;

&lt;p&gt;Return ONLY a valid JSON object:&lt;br&gt;
{{&lt;br&gt;
  "linkedin_hook": "First 2 lines of a LinkedIn post (hook only, 200 chars max)",&lt;br&gt;
  "twitter_thread": [&lt;br&gt;
    "Tweet 1 of 5: hook/claim (280 chars max)",&lt;br&gt;
    "Tweet 2 of 5: supporting point",&lt;br&gt;
    "Tweet 3 of 5: supporting point",&lt;br&gt;
    "Tweet 4 of 5: key insight or data",&lt;br&gt;
    "Tweet 5 of 5: CTA or question"&lt;br&gt;
  ],&lt;br&gt;
  "email_subject_lines": [&lt;br&gt;
    "Subject line option 1 (50 chars max)",&lt;br&gt;
    "Subject line option 2 — curiosity gap style",&lt;br&gt;
    "Subject line option 3 — direct benefit style"&lt;br&gt;
  ],&lt;br&gt;
  "newsletter_teaser": "2-sentence newsletter blurb to drive clicks"&lt;br&gt;
}}&lt;br&gt;
"""&lt;/p&gt;

&lt;p&gt;def generate_social_pack(metadata: dict) -&amp;gt; dict:&lt;br&gt;
    """Generate extended social content from existing metadata."""&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt = SOCIAL_PROMPT.format(
    title=metadata.get("title", ""),
    summary=metadata.get("summary", ""),
    primary_keyword=metadata.get("primary_keyword", "")
)

message = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": prompt}]
)

raw = message.content[0].text.strip()
if raw.startswith(""):
    raw = re.sub(r"^[a-z]*\n?", "", raw)
    raw = re.sub(r"\n?$", "", raw)

return json.loads(raw)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Note the use of &lt;code&gt;claude-haiku-4-5&lt;/code&gt; for the second pass. Lighter summarization tasks don't need Opus. On 100 articles, the cost difference is material.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Product Angle
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;_manifest.json&lt;/code&gt; output is an API response waiting to happen. Wrap it behind FastAPI, add a file upload UI, and you have a content ops tool. Plug it into any CMS API (Contentful, Sanity, WordPress REST) to write metadata back to your articles automatically.&lt;/p&gt;

&lt;p&gt;Agencies pay $50-200/month for this kind of tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;Create a folder, drop in both files from above, then:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
pip install anthropic click rich python-frontmatter&lt;br&gt;
export ANTHROPIC_API_KEY="sk-ant-your-key"&lt;br&gt;
python cli.py extract ./article.md --format table&lt;br&gt;
python cli.py batch ./articles/ --output-dir ./metadata/ --workers 5&lt;/p&gt;

&lt;p&gt;Customize everything in the &lt;code&gt;METADATA_PROMPT&lt;/code&gt; string. That's where your domain knowledge lives — adjust the rules for your content types, adjust the JSON schema for your workflow.&lt;/p&gt;

&lt;p&gt;Run this on your entire blog once. You'll get back a normalized metadata library. Plug it into your CMS. Never write a meta description by hand again.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudeapi</category>
      <category>python</category>
      <category>contentautomation</category>
      <category>seotools</category>
    </item>
    <item>
      <title>Stop Publishing Blind: Build an AI Content Scorer in 30 Minutes</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Wed, 03 Jun 2026 14:33:16 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/stop-publishing-blind-build-an-ai-content-scorer-in-30-minutes-35c6</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/stop-publishing-blind-build-an-ai-content-scorer-in-30-minutes-35c6</guid>
      <description>&lt;p&gt;Your best drafts languish while you second-guess yourself. Here's how to get a concrete engagement score before the post goes live.&lt;/p&gt;

&lt;p&gt;I've watched creators spend more time re-reading a post than writing it. The real problem isn't overthinking—it's flying blind. You don't know if the hook lands, if readers will scroll past paragraph three, or if your CTA will convert until the post is already indexed.&lt;/p&gt;

&lt;p&gt;There's a faster way. Claude can read your draft, score it across engagement dimensions, and flag weak sections in under 30 seconds. This guide walks you through building that system, training it on your own data, and wiring it into your publishing workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Cost of Gut-Feel Publishing
&lt;/h2&gt;

&lt;p&gt;Most publish decisions come down to fatigue. You tweak the headline, adjust the opening, and eventually ship it because you've read it too many times to judge fairly.&lt;/p&gt;

&lt;p&gt;The issue isn't effort. It's that you lack signal until the post is live and can't be changed. You don't know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your headline creates enough curiosity gap&lt;/li&gt;
&lt;li&gt;Whether the structure holds attention through the middle&lt;/li&gt;
&lt;li&gt;If people will actually finish reading&lt;/li&gt;
&lt;li&gt;Which sections confuse or bore readers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you need: a pre-flight checklist that runs automatically before the draft leaves your folder. Score the headline. Rate the hook. Flag structural weakness. Estimate read-through rates. All in 30 seconds.&lt;/p&gt;

&lt;p&gt;That's what we're building.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works: The Three-Layer Architecture
&lt;/h2&gt;

&lt;p&gt;The system has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Content Analyzer&lt;/strong&gt;: Breaks your draft into scoreable dimensions (headline clarity, hook strength, readability, structure, predicted engagement)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prediction Engine&lt;/strong&gt;: Sends your content to Claude with strict schema constraints—forces JSON output every time, no parsing required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calibration Loop&lt;/strong&gt;: Feeds real engagement data back into the system prompt so predictions tighten over time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the scoring schema:&lt;/p&gt;

&lt;p&gt;EngagementScore {&lt;br&gt;
  headline_score: 0-100&lt;br&gt;
  hook_strength: 0-100&lt;br&gt;
  readability: 0-100&lt;br&gt;
  structure_score: 0-100&lt;br&gt;
  predicted_read_rate: 0-100 (% who finish)&lt;br&gt;
  predicted_share_probability: 0-100&lt;br&gt;
  weak_sections: [list of flagged areas]&lt;br&gt;
  improvement_suggestions: [actionable fixes]&lt;br&gt;
  overall_score: 0-100&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Claude does the heavy lifting. You pass the full draft plus a system prompt that defines high-engagement content based on platform data. The structured output constraint forces proper JSON—no freeform prose to parse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Python CLI Tool
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;bash&lt;br&gt;
pip install anthropic rich click python-frontmatter&lt;br&gt;
export ANTHROPIC_API_KEY="sk-ant-your-key-here"&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rich&lt;/code&gt; colors and formats terminal output. &lt;code&gt;click&lt;/code&gt; builds the CLI. &lt;code&gt;python-frontmatter&lt;/code&gt; parses markdown with YAML metadata (standard for static site generators).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Core Predictor
&lt;/h3&gt;

&lt;p&gt;python&lt;br&gt;
import anthropic&lt;br&gt;
import json&lt;br&gt;
import click&lt;br&gt;
import frontmatter&lt;br&gt;
from pathlib import Path&lt;br&gt;
from rich.console import Console&lt;br&gt;
from rich.table import Table&lt;br&gt;
from rich.panel import Panel&lt;br&gt;
from rich.progress import Progress, SpinnerColumn, TextColumn&lt;/p&gt;

&lt;p&gt;console = Console()&lt;/p&gt;

&lt;p&gt;SCORING_SYSTEM_PROMPT = """You are an expert content strategist with deep knowledge of engagement metrics &lt;br&gt;
across Dev.to, Hashnode, Medium, and LinkedIn. You analyze draft posts and predict engagement performance &lt;br&gt;
based on proven content patterns.&lt;/p&gt;

&lt;p&gt;When analyzing content, evaluate these dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Headline: clarity, curiosity gap, specificity, keyword relevance&lt;/li&gt;
&lt;li&gt;Hook (first 150 words): problem identification, relatability, promise of value
&lt;/li&gt;
&lt;li&gt;Structure: use of headers, code blocks, lists, paragraph length variance&lt;/li&gt;
&lt;li&gt;Readability: sentence complexity, jargon density, active vs passive voice&lt;/li&gt;
&lt;li&gt;Content depth: actionable specificity vs vague generalities&lt;/li&gt;
&lt;li&gt;CTA quality: clarity and placement of calls-to-action&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Return ONLY valid JSON matching this exact schema:&lt;br&gt;
{&lt;br&gt;
  "headline_score": ,&lt;br&gt;
  "hook_strength": ,&lt;br&gt;
  "readability": ,&lt;br&gt;
  "structure_score": ,&lt;br&gt;
  "predicted_read_rate": ,&lt;br&gt;
  "predicted_share_probability": ,&lt;br&gt;
  "overall_score": ,&lt;br&gt;
  "weak_sections": [, ...],&lt;br&gt;
  "improvement_suggestions": [, ...]&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Be precise and critical. A score above 80 means genuinely publish-ready content."""&lt;/p&gt;

&lt;p&gt;def analyze_draft(content: str, title: str, platform: str = "dev.to") -&amp;gt; dict:&lt;br&gt;
    """Send draft to Claude and get structured engagement predictions."""&lt;br&gt;
    client = anthropic.Anthropic()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt = f"""Analyze this draft post for {platform} and predict its engagement performance.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;TITLE: {title}&lt;/p&gt;

&lt;p&gt;CONTENT:&lt;br&gt;
{content}&lt;/p&gt;

&lt;p&gt;Return your analysis as JSON matching the specified schema exactly."""&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;message = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    system=SCORING_SYSTEM_PROMPT,
    messages=[
        {"role": "user", "content": prompt}
    ]
)

response_text = message.content[0].text.strip()

# Strip markdown code fences if Claude wraps the JSON
if response_text.startswith(""):
    lines = response_text.split("\n")
    response_text = "\n".join(lines[1:-1])

return json.loads(response_text)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def render_scores(scores: dict, title: str):&lt;br&gt;
    """Render engagement prediction as a formatted terminal table."""&lt;br&gt;
    table = Table(title=f"Engagement Prediction: {title[:60]}", show_header=True)&lt;br&gt;
    table.add_column("Metric", style="cyan", width=28)&lt;br&gt;
    table.add_column("Score", justify="center", width=10)&lt;br&gt;
    table.add_column("Signal", justify="center", width=10)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;metrics = [
    ("Headline", scores["headline_score"]),
    ("Hook Strength", scores["hook_strength"]),
    ("Readability", scores["readability"]),
    ("Structure", scores["structure_score"]),
    ("Predicted Read Rate", scores["predicted_read_rate"]),
    ("Share Probability", scores["predicted_share_probability"]),
]

for name, score in metrics:
    if score &amp;gt;= 75:
        signal = "✅"
        style = "green"
    elif score &amp;gt;= 50:
        signal = "⚠️"
        style = "yellow"
    else:
        signal = "❌"
        style = "red"
    table.add_row(name, f"[{style}]{score}[/{style}]", signal)

console.print(table)
console.print()

overall = scores["overall_score"]
overall_color = "green" if overall &amp;gt;= 75 else "yellow" if overall &amp;gt;= 50 else "red"
console.print(Panel(
    f"[bold {overall_color}]Overall Score: {overall}/100[/bold {overall_color}]",
    expand=False
))

if scores.get("weak_sections"):
    console.print("\n[bold red]⚠ Weak Sections:[/bold red]")
    for section in scores["weak_sections"]:
        console.print(f"  • {section}")

if scores.get("improvement_suggestions"):
    console.print("\n[bold yellow]💡 Suggestions:[/bold yellow]")
    for suggestion in scores["improvement_suggestions"]:
        console.print(f"  → {suggestion}")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.command()&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.argument("filepath", type=click.Path(exists=True))&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--platform", default="dev.to", help="Target platform: dev.to, hashnode, medium, linkedin")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--min-score", default=70, help="Minimum overall score to pass (exit code 0)")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--json-output", is_flag=True, help="Output raw JSON instead of formatted table")&lt;br&gt;
def score_post(filepath: str, platform: str, min_score: int, json_output: bool):&lt;br&gt;
    """Predict engagement scores for a draft post before publishing."""&lt;br&gt;
    path = Path(filepath)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as progress:
    progress.add_task("Reading draft...", total=None)

    if path.suffix in [".md", ".mdx"]:
        post = frontmatter.load(str(path))
        content = post.content
        title = post.get("title", path.stem)
    else:
        content = path.read_text()
        title = path.stem

with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as progress:
    progress.add_task("Analyzing with Claude...", total=None)
    scores = analyze_draft(content, title, platform)

if json_output:
    print(json.dumps(scores, indent=2))
else:
    render_scores(scores, title)

if scores["overall_score"] &amp;lt; min_score:
    raise SystemExit(1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    score_post()&lt;/p&gt;

&lt;p&gt;&lt;code&gt;analyze_draft()&lt;/code&gt; calls Claude with strict schema constraints. &lt;code&gt;render_scores()&lt;/code&gt; formats output with color-coded signals. The CLI command ties everything together.&lt;/p&gt;

&lt;p&gt;The exit code behavior is intentional—exit code 1 on low scores makes CI/CD integration work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running It
&lt;/h3&gt;

&lt;p&gt;bash&lt;/p&gt;

&lt;h1&gt;
  
  
  Basic usage
&lt;/h1&gt;

&lt;p&gt;python predictor.py my-draft-post.md&lt;/p&gt;

&lt;h1&gt;
  
  
  Target a specific platform
&lt;/h1&gt;

&lt;p&gt;python predictor.py my-draft-post.md --platform linkedin&lt;/p&gt;

&lt;h1&gt;
  
  
  Fail if score is below 75
&lt;/h1&gt;

&lt;p&gt;python predictor.py my-draft-post.md --min-score 75&lt;/p&gt;

&lt;h1&gt;
  
  
  Get raw JSON for piping
&lt;/h1&gt;

&lt;p&gt;python predictor.py my-draft-post.md --json-output | jq '.overall_score'&lt;/p&gt;

&lt;h2&gt;
  
  
  Training on Your Own Data: Calibration That Actually Works
&lt;/h2&gt;

&lt;p&gt;Here's where I hit a real problem: feeding historical engagement data directly into the user message made Claude pattern-match against my specific numbers instead of reasoning about content quality.&lt;/p&gt;

&lt;p&gt;The fix: move historical data into the &lt;strong&gt;system prompt as calibration examples&lt;/strong&gt;. This keeps the reasoning clean.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def build_calibrated_system_prompt(historical_data: list[dict]) -&amp;gt; str:&lt;br&gt;
    """&lt;br&gt;
    Inject real engagement data as calibration examples into the system prompt.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;historical_data format:
[{"title": str, "overall_score": int, "actual_views": int, 
  "actual_read_rate": float, "actual_shares": int}]
"""
base_prompt = SCORING_SYSTEM_PROMPT

if not historical_data:
    return base_prompt

calibration_section = "\n\nCALIBRATION DATA (real published posts and their actual metrics):\n"

for post in historical_data[:10]:  # Cap at 10 examples
    actual_read_pct = int(post["actual_read_rate"] * 100)
    calibration_section += (
        f'- "{post["title"][:60]}": predicted {post["overall_score"]}/100, '
        f'actual views={post["actual_views"]}, '
        f'read_rate={actual_read_pct}%, shares={post["actual_shares"]}\n'
    )

calibration_section += (
    "\nUse this data to calibrate your predictions. "
    "If your previous predictions were consistently off in one direction, adjust accordingly."
)

return base_prompt + calibration_section
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def load_engagement_history(history_file: str = "engagement_history.json") -&amp;gt; list[dict]:&lt;br&gt;
    """Load historical engagement data from a local JSON file."""&lt;br&gt;
    path = Path(history_file)&lt;br&gt;
    if not path.exists():&lt;br&gt;
        return []&lt;br&gt;
    with open(path) as f:&lt;br&gt;
        return json.load(f)&lt;/p&gt;

&lt;p&gt;def record_actual_performance(&lt;br&gt;
    title: str,&lt;br&gt;
    predicted_score: int,&lt;br&gt;
    actual_views: int,&lt;br&gt;
    actual_read_rate: float,&lt;br&gt;
    actual_shares: int,&lt;br&gt;
    history_file: str = "engagement_history.json"&lt;br&gt;
):&lt;br&gt;
    """Append real engagement data after a post goes live."""&lt;br&gt;
    history = load_engagement_history(history_file)&lt;br&gt;
    history.append({&lt;br&gt;
        "title": title,&lt;br&gt;
        "overall_score": predicted_score,&lt;br&gt;
        "actual_views": actual_views,&lt;br&gt;
        "actual_read_rate": actual_read_rate,&lt;br&gt;
        "actual_shares": actual_shares&lt;br&gt;
    })&lt;br&gt;
    with open(history_file, "w") as f:&lt;br&gt;
        json.dump(history, f, indent=2)&lt;br&gt;
    console.print(f"[green]✓ Recorded performance data for '{title}'[/green]")&lt;/p&gt;

&lt;p&gt;After a post goes live 48–72 hours, call &lt;code&gt;record_actual_performance()&lt;/code&gt; with real metrics. After 15–20 data points, the calibrated system prompt tightens predictions because Claude sees the gap between prior predictions and actual results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration: Git Hooks and CI/CD
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Git Pre-Commit Hook
&lt;/h3&gt;

&lt;p&gt;Save this as &lt;code&gt;.git/hooks/pre-commit&lt;/code&gt; and run &lt;code&gt;chmod +x .git/hooks/pre-commit&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;bash&lt;/p&gt;

&lt;h1&gt;
  
  
  !/bin/bash
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Scores any staged .md files before allowing a commit
&lt;/h1&gt;

&lt;p&gt;STAGED_MD_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '.(md|mdx)$')&lt;/p&gt;

&lt;p&gt;if [ -z "$STAGED_MD_FILES" ]; then&lt;br&gt;
  exit 0&lt;br&gt;
fi&lt;/p&gt;

&lt;p&gt;echo "🔍 Scoring staged content..."&lt;/p&gt;

&lt;p&gt;FAILED=0&lt;br&gt;
for FILE in $STAGED_MD_FILES; do&lt;br&gt;
  echo "Analyzing: $FILE"&lt;br&gt;
  python predictor.py "$FILE" --min-score 65&lt;br&gt;
  if [ $? -ne 0 ]; then&lt;br&gt;
    echo "❌ $FILE scored below minimum threshold"&lt;br&gt;
    FAILED=1&lt;br&gt;
  fi&lt;br&gt;
done&lt;/p&gt;

&lt;p&gt;if [ $FAILED -eq 1 ]; then&lt;br&gt;
  echo ""&lt;br&gt;
  echo "One or more posts scored below threshold. Fix the flagged issues or use 'git commit --no-verify' to bypass."&lt;br&gt;
  exit 1&lt;br&gt;
fi&lt;/p&gt;

&lt;p&gt;exit 0&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Actions Workflow
&lt;/h3&gt;

&lt;p&gt;For teams, add this as &lt;code&gt;.github/workflows/content-score.yml&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
name: Content Quality Check&lt;/p&gt;

&lt;p&gt;on:&lt;br&gt;
  pull_request:&lt;br&gt;
    paths:&lt;br&gt;
      - 'posts/&lt;strong&gt;/*.md'&lt;br&gt;
      - 'content/&lt;/strong&gt;/*.md'&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  score-content:&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v4&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - name: Set up Python
    uses: actions/setup-python@v4
    with:
      python-version: '3.11'

  - name: Install dependencies
    run: pip install anthropic rich click python-frontmatter

  - name: Score changed posts
    env:
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    run: |
      CHANGED=$(git diff --name-only origin/main...HEAD | grep '\.md$' || true)
      if [ -z "$CHANGED" ]; then
        echo "No markdown files changed."
        exit 0
      fi
      for FILE in $CHANGED; do
        echo "Scoring $FILE"
        python predictor.py "$FILE" --min-score 70
      done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Store &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; in GitHub Secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Piece: What This Actually Changes
&lt;/h2&gt;

&lt;p&gt;You get three immediate wins:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Speed&lt;/strong&gt;: Score any draft in 30 seconds instead of re-reading it five times&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Objectivity&lt;/strong&gt;: A score removes the "is this good?" guessing game&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iteration&lt;/strong&gt;: Weak sections are flagged by name, so you know exactly what to fix&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After 20 published posts, the calibrated predictions start beating your intuition because Claude sees your actual engagement patterns. It knows which of your hooks convert. It knows which structures work for your audience.&lt;/p&gt;

&lt;p&gt;Stop publishing blind.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudeapi</category>
      <category>contentstrategy</category>
      <category>python</category>
      <category>automation</category>
    </item>
    <item>
      <title>Catch Mediocre AI Content Before It Ships: A Python Quality Scorer</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Tue, 02 Jun 2026 13:01:54 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/catch-mediocre-ai-content-before-it-ships-a-python-quality-scorer-3361</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/catch-mediocre-ai-content-before-it-ships-a-python-quality-scorer-3361</guid>
      <description>&lt;p&gt;Your AI generated 50 articles this week. Only 3 were publishable. Here's a Python script that stops the mediocre ones cold before they hit your CMS.&lt;/p&gt;

&lt;p&gt;I've been there. You run a content pipeline, Claude or GPT spits out a dozen posts overnight, and then Tuesday morning arrives. You're reading through drafts that start with "In today's digital landscape" and end with "In conclusion, it's clear that..." The irony stings: AI was supposed to save time, not create a new job called "AI content babysitter."&lt;/p&gt;

&lt;p&gt;So I built a CLI scorer. It reads your drafts, assigns them a quality score, and flags the weak ones before they touch your publishing system. Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI Content Needs Gatekeeping
&lt;/h2&gt;

&lt;p&gt;The problem isn't that AI writes badly. It's that AI writes &lt;em&gt;predictably&lt;/em&gt; badly in specific, detectable ways. Once you know the patterns, you automate the detection.&lt;/p&gt;

&lt;p&gt;Common failure modes in AI-generated content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filler openings&lt;/strong&gt;: "In today's world," "It's no secret that," "As we all know"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repetitive structure&lt;/strong&gt;: Same subject-verb-object rhythm across paragraphs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hollow hedging&lt;/strong&gt;: "It's important to note that," "Needless to say," "Worth mentioning"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transition bloat&lt;/strong&gt;: "Furthermore," "Moreover," "Additionally" every two sentences&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake specificity&lt;/strong&gt;: Numbers and claims that sound precise but reference nothing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These patterns are measurable. Measurable means scriptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Scoring Engine
&lt;/h2&gt;

&lt;p&gt;The scoring system runs four checks: pattern matching against known AI phrases, lexical diversity (type-token ratio), sentence length variance, and sycophancy density for hollow affirmations.&lt;/p&gt;

&lt;p&gt;Each check returns a penalty. The total subtracts from 100. Below 60? Rejected. 60–79? Flagged for review. 80+? Cleared to publish.&lt;/p&gt;

&lt;p&gt;Here's the core scoring logic:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import re&lt;br&gt;
import math&lt;br&gt;
from collections import Counter&lt;br&gt;
from typing import Tuple&lt;/p&gt;

&lt;h1&gt;
  
  
  Known AI filler patterns — expand this list aggressively
&lt;/h1&gt;

&lt;p&gt;AI_PATTERNS = [&lt;br&gt;
    r"\bin today's (world|digital landscape|fast-paced world|era)\b",&lt;br&gt;
    r"\bit('s| is) (no secret|worth (noting|mentioning)|important to note)\b",&lt;br&gt;
    r"\bin conclusion\b",&lt;br&gt;
    r"\bto summarize\b",&lt;br&gt;
    r"\bas (we all know|mentioned (earlier|above|previously))\b",&lt;br&gt;
    r"\bneedless to say\b",&lt;br&gt;
    r"\bwithout further ado\b",&lt;br&gt;
    r"\b(furthermore|moreover|additionally),\b",&lt;br&gt;
    r"\bin the world of\b",&lt;br&gt;
    r"\bthe importance of\b",&lt;br&gt;
]&lt;/p&gt;

&lt;p&gt;def score_ai_patterns(text: str) -&amp;gt; Tuple[int, list]:&lt;br&gt;
    """Returns penalty points and matched patterns."""&lt;br&gt;
    text_lower = text.lower()&lt;br&gt;
    hits = []&lt;br&gt;
    for pattern in AI_PATTERNS:&lt;br&gt;
        matches = re.findall(pattern, text_lower)&lt;br&gt;
        if matches:&lt;br&gt;
            hits.append((pattern, len(matches)))&lt;br&gt;
    penalty = min(len(hits) * 5, 40)  # Cap at 40 points&lt;br&gt;
    return penalty, hits&lt;/p&gt;

&lt;p&gt;def lexical_diversity(text: str) -&amp;gt; float:&lt;br&gt;
    """Type-token ratio: unique words / total words."""&lt;br&gt;
    words = re.findall(r'\b[a-z]+\b', text.lower())&lt;br&gt;
    if not words:&lt;br&gt;
        return 0.0&lt;br&gt;
    return len(set(words)) / len(words)&lt;/p&gt;

&lt;p&gt;def sentence_length_variance(text: str) -&amp;gt; float:&lt;br&gt;
    """Higher variance = more natural writing rhythm."""&lt;br&gt;
    sentences = re.split(r'[.!?]+', text)&lt;br&gt;
    lengths = [len(s.split()) for s in sentences if s.strip()]&lt;br&gt;
    if len(lengths) &amp;lt; 2:&lt;br&gt;
        return 0.0&lt;br&gt;
    mean = sum(lengths) / len(lengths)&lt;br&gt;
    variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)&lt;br&gt;
    return math.sqrt(variance)  # Standard deviation&lt;/p&gt;

&lt;p&gt;def score_content(text: str) -&amp;gt; dict:&lt;br&gt;
    """Main scoring function. Returns score dict."""&lt;br&gt;
    score = 100&lt;br&gt;
    details = {}&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Pattern penalty
pattern_penalty, pattern_hits = score_ai_patterns(text)
score -= pattern_penalty
details["pattern_hits"] = pattern_hits
details["pattern_penalty"] = pattern_penalty

# Lexical diversity penalty
diversity = lexical_diversity(text)
if diversity &amp;lt; 0.45:
    diversity_penalty = int((0.45 - diversity) * 100)
    score -= diversity_penalty
    details["diversity_penalty"] = diversity_penalty
else:
    details["diversity_penalty"] = 0
details["lexical_diversity"] = round(diversity, 3)

# Sentence variance penalty
variance = sentence_length_variance(text)
if variance &amp;lt; 5.0:
    variance_penalty = int((5.0 - variance) * 2)
    score -= variance_penalty
    details["variance_penalty"] = variance_penalty
else:
    details["variance_penalty"] = 0
details["sentence_variance"] = round(variance, 2)

details["final_score"] = max(score, 0)
return details
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;lexical_diversity&lt;/code&gt; function is the one I tune most. Human writing typically scores 0.55–0.75 on type-token ratio. AI output clusters around 0.40–0.50 because it reuses transition words constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding Claude for Semantic Review
&lt;/h2&gt;

&lt;p&gt;Regex catches structural problems. Claude catches the semantic ones — when a paragraph repeats itself three different ways, when claims lack support, when the writing feels hollow.&lt;/p&gt;

&lt;p&gt;Install dependencies:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
pip install anthropic click rich python-dotenv&lt;/p&gt;

&lt;p&gt;Set your API key in &lt;code&gt;.env&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
echo "ANTHROPIC_API_KEY=your_key_here" &amp;gt; .env&lt;/p&gt;

&lt;p&gt;Here's the full CLI — save as &lt;code&gt;score_content.py&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;python&lt;/p&gt;

&lt;h1&gt;
  
  
  !/usr/bin/env python3
&lt;/h1&gt;

&lt;p&gt;import os&lt;br&gt;
import sys&lt;br&gt;
import json&lt;br&gt;
import click&lt;br&gt;
from pathlib import Path&lt;br&gt;
from dotenv import load_dotenv&lt;br&gt;
from rich.console import Console&lt;br&gt;
from rich.table import Table&lt;br&gt;
from rich.panel import Panel&lt;br&gt;
import anthropic&lt;/p&gt;

&lt;p&gt;from scoring_engine import score_content&lt;/p&gt;

&lt;p&gt;load_dotenv()&lt;br&gt;
console = Console()&lt;/p&gt;

&lt;p&gt;ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")&lt;br&gt;
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)&lt;/p&gt;

&lt;p&gt;CLAUDE_QUALITY_PROMPT = """You are a content quality reviewer. Analyze this draft for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Repetitive sentence structures (score 1-10, 10 = very repetitive)&lt;/li&gt;
&lt;li&gt;Vague or unsupported claims (count them)&lt;/li&gt;
&lt;li&gt;Missing concrete examples or data points (yes/no)&lt;/li&gt;
&lt;li&gt;Overall publishability (PUBLISH / REVIEW / REJECT)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Respond ONLY in this JSON format:&lt;br&gt;
{{&lt;br&gt;
  "repetition_score": ,&lt;br&gt;
  "vague_claims": ,&lt;br&gt;
  "missing_examples": ,&lt;br&gt;
  "verdict": "",&lt;br&gt;
  "top_issue": ""&lt;br&gt;
}}&lt;/p&gt;

&lt;h2&gt;
  
  
  CONTENT:
&lt;/h2&gt;

&lt;p&gt;{content}&lt;br&gt;
---"""&lt;/p&gt;

&lt;p&gt;def get_claude_verdict(text: str) -&amp;gt; dict:&lt;br&gt;
    """Send content to Claude for semantic quality review."""&lt;br&gt;
    try:&lt;br&gt;
        message = client.messages.create(&lt;br&gt;
            model="claude-opus-4-5",&lt;br&gt;
            max_tokens=300,&lt;br&gt;
            messages=[&lt;br&gt;
                {&lt;br&gt;
                    "role": "user",&lt;br&gt;
                    "content": CLAUDE_QUALITY_PROMPT.format(content=text[:4000])&lt;br&gt;
                }&lt;br&gt;
            ]&lt;br&gt;
        )&lt;br&gt;
        raw = message.content[0].text.strip()&lt;br&gt;
        return json.loads(raw)&lt;br&gt;
    except json.JSONDecodeError:&lt;br&gt;
        return {"verdict": "REVIEW", "top_issue": "Claude response unparseable", "error": True}&lt;br&gt;
    except Exception as e:&lt;br&gt;
        return {"verdict": "REVIEW", "top_issue": f"API error: {str(e)}", "error": True}&lt;/p&gt;

&lt;p&gt;def combined_verdict(local_score: int, claude_verdict: str) -&amp;gt; str:&lt;br&gt;
    """Combine local score and Claude verdict into final decision."""&lt;br&gt;
    if local_score &amp;lt; 60 or claude_verdict == "REJECT":&lt;br&gt;
        return "REJECT"&lt;br&gt;
    if local_score &amp;lt; 80 or claude_verdict == "REVIEW":&lt;br&gt;
        return "REVIEW"&lt;br&gt;
    return "PUBLISH"&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.command()&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.argument("filepath", type=click.Path(exists=True))&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--json-output", is_flag=True, help="Output raw JSON for pipeline use")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--skip-claude", is_flag=True, help="Run local checks only (no API call)")&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/click"&gt;@click&lt;/a&gt;.option("--threshold", default=75, help="Minimum score to auto-approve (default: 75)")&lt;br&gt;
def main(filepath: str, json_output: bool, skip_claude: bool, threshold: int):&lt;br&gt;
    """Score a content file for AI quality issues before publishing."""&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;text = Path(filepath).read_text(encoding="utf-8")

if len(text.split()) &amp;lt; 100:
    console.print("[red]File too short to score (&amp;lt; 100 words)[/red]")
    sys.exit(2)

# Run local scoring
local_results = score_content(text)
local_score = local_results["final_score"]

# Run Claude check
claude_results = {}
if not skip_claude:
    with console.status("Asking Claude for semantic review..."):
        claude_results = get_claude_verdict(text)

# Determine final verdict
claude_verdict = claude_results.get("verdict", "REVIEW") if claude_results else "REVIEW"
final = combined_verdict(local_score, claude_verdict)

if json_output:
    output = {
        "file": filepath,
        "local_score": local_score,
        "claude": claude_results,
        "final_verdict": final
    }
    print(json.dumps(output, indent=2))
    sys.exit(0 if final == "PUBLISH" else 1)

# Rich terminal output
color = {"PUBLISH": "green", "REVIEW": "yellow", "REJECT": "red"}[final]

table = Table(title=f"Quality Report: {Path(filepath).name}")
table.add_column("Check", style="cyan")
table.add_column("Result", justify="right")

table.add_row("Local Score", str(local_score))
table.add_row("Pattern Hits", str(len(local_results.get("pattern_hits", []))))
table.add_row("Lexical Diversity", str(local_results.get("lexical_diversity", "n/a")))
table.add_row("Sentence Variance", str(local_results.get("sentence_variance", "n/a")))

if claude_results and not claude_results.get("error"):
    table.add_row("Claude Verdict", claude_results.get("verdict", "n/a"))
    table.add_row("Repetition Score", str(claude_results.get("repetition_score", "n/a")))
    table.add_row("Vague Claims", str(claude_results.get("vague_claims", "n/a")))
    if claude_results.get("top_issue"):
        table.add_row("Top Issue", claude_results["top_issue"])

console.print(table)
console.print(Panel(f"[bold {color}]VERDICT: {final}[/bold {color}]"))

sys.exit(0 if final == "PUBLISH" else 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    main()&lt;/p&gt;

&lt;p&gt;Exit codes matter for automation: &lt;code&gt;0&lt;/code&gt; for publish-ready, &lt;code&gt;1&lt;/code&gt; for everything else. This is what makes the workflow integration in the next section work cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug I hit&lt;/strong&gt;: I initially passed full article text to Claude without truncating. For 3,000-word pieces, this occasionally hit token limits and caused silent failures where &lt;code&gt;get_claude_verdict&lt;/code&gt; returned empty strings that broke &lt;code&gt;json.loads&lt;/code&gt;. The fix: &lt;code&gt;text[:4000]&lt;/code&gt; slice in &lt;code&gt;CLAUDE_QUALITY_PROMPT.format()&lt;/code&gt;. Not elegant, but reliable. For production, use a proper token counter before the API call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrating Into Your Publishing Workflow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Git Hook (local pre-commit)
&lt;/h3&gt;

&lt;p&gt;Save as &lt;code&gt;.git/hooks/pre-commit&lt;/code&gt; and run &lt;code&gt;chmod +x&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;bash&lt;/p&gt;

&lt;h1&gt;
  
  
  !/bin/bash
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Pre-commit hook: score any markdown files staged for commit
&lt;/h1&gt;

&lt;p&gt;STAGED_MD=$(git diff --cached --name-only --diff-filter=ACM | grep '.md$')&lt;/p&gt;

&lt;p&gt;if [ -z "$STAGED_MD" ]; then&lt;br&gt;
  exit 0&lt;br&gt;
fi&lt;/p&gt;

&lt;p&gt;echo "Running content quality check..."&lt;/p&gt;

&lt;p&gt;for FILE in $STAGED_MD; do&lt;br&gt;
  RESULT=$(python score_content.py "$FILE" --json-output 2&amp;gt;/dev/null)&lt;br&gt;
  VERDICT=$(echo "$RESULT" | python -c "import sys,json; print(json.load(sys.stdin)['final_verdict'])")&lt;br&gt;
  SCORE=$(echo "$RESULT" | python -c "import sys,json; print(json.load(sys.stdin)['local_score'])")&lt;/p&gt;

&lt;p&gt;if [ "$VERDICT" = "REJECT" ]; then&lt;br&gt;
    echo "❌ BLOCKED: $FILE (score: $SCORE) — verdict: $VERDICT"&lt;br&gt;
    echo "Fix the content issues before committing."&lt;br&gt;
    exit 1&lt;br&gt;
  elif [ "$VERDICT" = "REVIEW" ]; then&lt;br&gt;
    echo "⚠️  FLAGGED: $FILE (score: $SCORE) — needs review before publishing"&lt;br&gt;
  else&lt;br&gt;
    echo "✅ CLEARED: $FILE (score: $SCORE)"&lt;br&gt;
  fi&lt;br&gt;
done&lt;/p&gt;

&lt;p&gt;exit 0&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Actions (CI gate)
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;.github/workflows/content-quality.yml&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
name: Content Quality Gate&lt;/p&gt;

&lt;p&gt;on:&lt;br&gt;
  pull_request:&lt;br&gt;
    paths:&lt;br&gt;
      - 'content/&lt;strong&gt;/*.md'&lt;br&gt;
      - 'posts/&lt;/strong&gt;/*.md'&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  quality-check:&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v4&lt;/p&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - name: Set up Python&lt;br&gt;
    uses: actions/setup-python@v4&lt;br&gt;
    with:&lt;br&gt;
      python-version: '3.11'

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;name: Install dependencies&lt;br&gt;
run: pip install anthropic click rich python-dotenv&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;name: Get changed markdown files&lt;br&gt;
id: changed&lt;br&gt;
run: |&lt;br&gt;
  FILES=$(git diff --name-only origin/main...HEAD | grep '.md$' || true)&lt;br&gt;
  echo "files=$FILES" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;name: Score content files&lt;br&gt;
env:&lt;br&gt;
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}&lt;br&gt;
run: |&lt;br&gt;
  for FILE in ${{ steps.changed.outputs.files }}; do&lt;br&gt;
    python score_content.py "$FILE" --threshold 75 || exit 1&lt;br&gt;
  done&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
&lt;br&gt;
&lt;br&gt;
&lt;br&gt;
Tuning for Your Workflow&lt;br&gt;
&lt;/h2&gt;


&lt;p&gt;Default thresholds are conservative: reject below 60, flag 60–79, approve at 80+. These won't fit every use case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lower your threshold for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Technical tutorials (structured language scores lower on lexical diversity)&lt;/li&gt;
&lt;li&gt;Listicles (short sentences = lower variance)&lt;/li&gt;
&lt;li&gt;Non-native English writers (different style from training data)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Raise your threshold for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Brand content&lt;/li&gt;
&lt;li&gt;Opinion pieces&lt;/li&gt;
&lt;li&gt;Company blog posts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;--threshold&lt;/code&gt; flag lets you adjust per-run. For batch jobs, run with &lt;code&gt;--skip-claude&lt;/code&gt; to speed things up — just use pattern matching and structural analysis.&lt;/p&gt;

&lt;p&gt;Start conservative. Run 50 articles through the scorer, measure how many actually needed human fixes, then adjust. Within a week you'll have thresholds that catch real problems without flooding your review queue.&lt;/p&gt;

&lt;p&gt;The scanner can't replace editorial judgment. What it &lt;em&gt;does&lt;/em&gt; do is eliminate the reading of obvious mediocrity. That Tuesday morning gets your time back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aicontent</category>
      <category>qualityassurance</category>
      <category>python</category>
      <category>automation</category>
    </item>
    <item>
      <title>Build a Multi-Platform Content Repurposing API: Auto-Convert One Article Into 10 Formats</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Tue, 02 Jun 2026 07:01:51 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/build-a-multi-platform-content-repurposing-api-auto-convert-one-article-into-10-formats-503g</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/build-a-multi-platform-content-repurposing-api-auto-convert-one-article-into-10-formats-503g</guid>
      <description>&lt;p&gt;One blog post. Ten platforms. One API call.&lt;/p&gt;

&lt;p&gt;I built a Python service that converts long-form content into optimized Twitter threads, LinkedIn posts, YouTube descriptions, and email sequences — with working code you can deploy today.&lt;/p&gt;

&lt;p&gt;This started with a real problem: I watched a client spend 6 hours manually reformatting a single 2,000-word article for five different platforms. That's not a content problem — that's an automation problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Manual Reformatting Kills Productivity
&lt;/h2&gt;

&lt;p&gt;Most creators write once, then either skip distribution or spend more time reformatting than writing. Twitter demands punchy threads. LinkedIn wants narrative arcs with whitespace. YouTube descriptions need keyword-dense paragraphs plus timestamps. Email sequences require subject lines, preview text, and CTAs per email.&lt;/p&gt;

&lt;p&gt;These aren't minor formatting differences. Each platform has its own editorial grammar. Switching between them, manually, for every piece of content? That's pure friction.&lt;/p&gt;

&lt;p&gt;The solution is a transformation pipeline that understands each platform's constraints and handles the mechanical work automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Three Layers
&lt;/h2&gt;

&lt;p&gt;The service has three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingestion&lt;/strong&gt;: Accept raw markdown&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transformation&lt;/strong&gt;: Call Claude with platform-specific prompts in parallel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output&lt;/strong&gt;: Validate format constraints, return structured JSON&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I used async job processing because each transformation takes 10–30 seconds. Blocking a web request that long ruins the experience. Batching multiple platform transformations into parallel API calls cuts wall-clock time significantly.&lt;/p&gt;

&lt;p&gt;Article Markdown → ContentTransformer → [Async Tasks per Platform] → Validated Output JSON&lt;br&gt;
                                              ↓&lt;br&gt;
                                    Claude API (claude-opus-4-5)&lt;br&gt;
                                              ↓&lt;br&gt;
                                    Format Validator → Cache Layer&lt;/p&gt;

&lt;p&gt;The cache layer matters for cost. If someone requests the same article transformed to Twitter format twice, you shouldn't pay for two API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;bash&lt;br&gt;
pip install anthropic asyncio aiohttp redis python-dotenv markdown2 tiktoken&lt;/p&gt;

&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; file:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
ANTHROPIC_API_KEY=your_key_here&lt;br&gt;
REDIS_URL=redis://localhost:6379&lt;br&gt;
MAX_CONCURRENT_REQUESTS=5&lt;br&gt;
CACHE_TTL_SECONDS=86400&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Transformation Service
&lt;/h2&gt;

&lt;p&gt;python&lt;br&gt;
import asyncio&lt;br&gt;
import hashlib&lt;br&gt;
import json&lt;br&gt;
import os&lt;br&gt;
from dataclasses import dataclass, field&lt;br&gt;
from enum import Enum&lt;br&gt;
from typing import Optional&lt;/p&gt;

&lt;p&gt;import anthropic&lt;br&gt;
import redis&lt;br&gt;
from dotenv import load_dotenv&lt;/p&gt;

&lt;p&gt;load_dotenv()&lt;/p&gt;

&lt;p&gt;client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))&lt;br&gt;
cache = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"))&lt;/p&gt;

&lt;p&gt;class Platform(Enum):&lt;br&gt;
    TWITTER_THREAD = "twitter_thread"&lt;br&gt;
    LINKEDIN_POST = "linkedin_post"&lt;br&gt;
    LINKEDIN_CAROUSEL = "linkedin_carousel"&lt;br&gt;
    YOUTUBE_DESCRIPTION = "youtube_description"&lt;br&gt;
    EMAIL_SEQUENCE = "email_sequence"&lt;br&gt;
    INSTAGRAM_CAPTION = "instagram_caption"&lt;br&gt;
    NEWSLETTER_INTRO = "newsletter_intro"&lt;br&gt;
    PODCAST_SHOWNOTES = "podcast_shownotes"&lt;br&gt;
    REDDIT_POST = "reddit_post"&lt;br&gt;
    FACEBOOK_POST = "facebook_post"&lt;/p&gt;

&lt;p&gt;@dataclass&lt;br&gt;
class PlatformConfig:&lt;br&gt;
    max_chars: Optional[int]&lt;br&gt;
    tone: str&lt;br&gt;
    structure_hints: str&lt;br&gt;
    output_format: str  # "list", "text", "json"&lt;/p&gt;

&lt;p&gt;PLATFORM_CONFIGS = {&lt;br&gt;
    Platform.TWITTER_THREAD: PlatformConfig(&lt;br&gt;
        max_chars=280,&lt;br&gt;
        tone="punchy, direct, no fluff",&lt;br&gt;
        structure_hints="Number each tweet 1/, 2/, etc. Hook in tweet 1. Each tweet standalone. End with CTA.",&lt;br&gt;
        output_format="list",&lt;br&gt;
    ),&lt;br&gt;
    Platform.LINKEDIN_POST: PlatformConfig(&lt;br&gt;
        max_chars=3000,&lt;br&gt;
        tone="professional but human, first-person narrative",&lt;br&gt;
        structure_hints="3-line hook. Whitespace between paragraphs. 3-5 bullet insights. CTA question at end. 3-5 hashtags.",&lt;br&gt;
        output_format="text",&lt;br&gt;
    ),&lt;br&gt;
    Platform.LINKEDIN_CAROUSEL: PlatformConfig(&lt;br&gt;
        max_chars=None,&lt;br&gt;
        tone="educational, slide-by-slide clarity",&lt;br&gt;
        structure_hints="Return JSON array. Each slide has 'title' (max 60 chars) and 'body' (max 150 chars). 7-10 slides. First slide is hook, last is CTA.",&lt;br&gt;
        output_format="json",&lt;br&gt;
    ),&lt;br&gt;
    Platform.YOUTUBE_DESCRIPTION: PlatformConfig(&lt;br&gt;
        max_chars=5000,&lt;br&gt;
        tone="SEO-aware, keyword-rich first 150 chars",&lt;br&gt;
        structure_hints="First 2 sentences are searchable summary. Then timestamps placeholder. Then 3 paragraph expansion. Then links section. Then hashtags.",&lt;br&gt;
        output_format="text",&lt;br&gt;
    ),&lt;br&gt;
    Platform.EMAIL_SEQUENCE: PlatformConfig(&lt;br&gt;
        max_chars=None,&lt;br&gt;
        tone="conversational, direct, one idea per email",&lt;br&gt;
        structure_hints="Return JSON array of 5 emails. Each has 'subject', 'preview_text' (max 90 chars), 'body', and 'cta'. Space emails across a week.",&lt;br&gt;
        output_format="json",&lt;br&gt;
    ),&lt;br&gt;
    Platform.INSTAGRAM_CAPTION: PlatformConfig(&lt;br&gt;
        max_chars=2200,&lt;br&gt;
        tone="visual storytelling, emotional hook",&lt;br&gt;
        structure_hints="Hook line. Story or insight. Lesson. CTA. 10-15 hashtags on new lines.",&lt;br&gt;
        output_format="text",&lt;br&gt;
    ),&lt;br&gt;
    Platform.NEWSLETTER_INTRO: PlatformConfig(&lt;br&gt;
        max_chars=500,&lt;br&gt;
        tone="warm, editor's-note style",&lt;br&gt;
        structure_hints="2-3 sentences. Why this content matters right now. What reader will get from it.",&lt;br&gt;
        output_format="text",&lt;br&gt;
    ),&lt;br&gt;
    Platform.PODCAST_SHOWNOTES: PlatformConfig(&lt;br&gt;
        max_chars=None,&lt;br&gt;
        tone="informative, scannable",&lt;br&gt;
        structure_hints="Episode summary. Key topics as bullet list. 3-5 key takeaways. Guest/resource mentions.",&lt;br&gt;
        output_format="text",&lt;br&gt;
    ),&lt;br&gt;
    Platform.REDDIT_POST: PlatformConfig(&lt;br&gt;
        max_chars=40000,&lt;br&gt;
        tone="authentic, community-aware, anti-promotional",&lt;br&gt;
        structure_hints="TL;DR at top. Explain context. Share actual findings. Invite discussion. No overt CTAs.",&lt;br&gt;
        output_format="text",&lt;br&gt;
    ),&lt;br&gt;
    Platform.FACEBOOK_POST: PlatformConfig(&lt;br&gt;
        max_chars=63206,&lt;br&gt;
        tone="story-driven, shareable",&lt;br&gt;
        structure_hints="Relatable hook. Personal angle. 3 key points. Question to drive comments. Optional emoji use.",&lt;br&gt;
        output_format="text",&lt;br&gt;
    ),&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;def build_prompt(article_markdown: str, platform: Platform) -&amp;gt; str:&lt;br&gt;
    config = PLATFORM_CONFIGS[platform]&lt;br&gt;
    char_constraint = f"Max total length: {config.max_chars} characters." if config.max_chars else ""&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return f"""You are a professional content strategist specializing in platform-native content.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Convert the following article into optimized content for: {platform.value.replace('_', ' ').title()}&lt;/p&gt;

&lt;p&gt;TONE: {config.tone}&lt;br&gt;
STRUCTURE: {config.structure_hints}&lt;br&gt;
{char_constraint}&lt;br&gt;
OUTPUT FORMAT: {config.output_format} — if JSON, return only valid JSON with no surrounding text.&lt;/p&gt;

&lt;p&gt;ARTICLE:&lt;br&gt;
{article_markdown}&lt;/p&gt;

&lt;p&gt;Return only the transformed content. No preamble, no explanation."""&lt;/p&gt;

&lt;p&gt;async def transform_single(&lt;br&gt;
    article_markdown: str,&lt;br&gt;
    platform: Platform,&lt;br&gt;
    semaphore: asyncio.Semaphore,&lt;br&gt;
) -&amp;gt; dict:&lt;br&gt;
    cache_key = hashlib.sha256(&lt;br&gt;
        f"{platform.value}:{article_markdown}".encode()&lt;br&gt;
    ).hexdigest()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cached = cache.get(cache_key)
if cached:
    return {"platform": platform.value, "content": json.loads(cached), "cached": True}

async with semaphore:
    try:
        # Run sync anthropic client in thread pool to avoid blocking event loop
        loop = asyncio.get_event_loop()
        response = await loop.run_in_executor(
            None,
            lambda: client.messages.create(
                model="claude-opus-4-5",
                max_tokens=2048,
                messages=[{"role": "user", "content": build_prompt(article_markdown, platform)}],
            ),
        )

        raw_content = response.content[0].text
        config = PLATFORM_CONFIGS[platform]

        if config.output_format == "json":
            # Strip markdown code fences if model wrapped JSON
            if raw_content.startswith(""):
                raw_content = raw_content.split("\n", 1)[1]
                raw_content = raw_content.rsplit("", 1)[0]
                raw_content = raw_content.strip()
            parsed = json.loads(raw_content)
            output = parsed
        else:
            output = raw_content

        cache.setex(
            cache_key,
            int(os.getenv("CACHE_TTL_SECONDS", 86400)),
            json.dumps(output),
        )

        return {
            "platform": platform.value,
            "content": output,
            "cached": False,
            "tokens_used": response.usage.input_tokens + response.usage.output_tokens,
        }

    except json.JSONDecodeError as e:
        return {"platform": platform.value, "error": f"JSON parse failed: {e}", "raw": raw_content}
    except anthropic.APIError as e:
        return {"platform": platform.value, "error": str(e)}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;async def repurpose_article(&lt;br&gt;
    article_markdown: str,&lt;br&gt;
    platforms: Optional[list[Platform]] = None,&lt;br&gt;
    max_concurrent: int = 5,&lt;br&gt;
) -&amp;gt; dict:&lt;br&gt;
    if platforms is None:&lt;br&gt;
        platforms = list(Platform)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;semaphore = asyncio.Semaphore(max_concurrent)
tasks = [transform_single(article_markdown, p, semaphore) for p in platforms]
results = await asyncio.gather(*tasks, return_exceptions=True)

output = {}
for result in results:
    if isinstance(result, Exception):
        print(f"Task failed with exception: {result}")
        continue
    output[result["platform"]] = result

return output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;semaphore&lt;/code&gt; limits concurrent requests to 5 by default. That prevents hammering the API. The cache layer uses SHA-256 of the platform name plus article content — identical inputs always hit cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Format Validation: Where Theory Meets Reality
&lt;/h2&gt;

&lt;p&gt;Format validation is the practical layer. Claude is reliable, but at scale you hit edge cases: a tweet at 295 characters, a JSON email missing the &lt;code&gt;subject&lt;/code&gt; field, or—weirdly—markdown code fences wrapped around JSON.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def validate_and_fix(result: dict) -&amp;gt; dict:&lt;br&gt;
    platform = Platform(result["platform"])&lt;br&gt;
    config = PLATFORM_CONFIGS[platform]&lt;br&gt;
    content = result.get("content")&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if not content or "error" in result:
    return result

# Twitter thread: enforce per-tweet character limits
if platform == Platform.TWITTER_THREAD:
    if isinstance(content, str):
        tweets = [line.strip() for line in content.split("\n") if line.strip()]
    else:
        tweets = content

    fixed_tweets = []
    for tweet in tweets:
        if len(tweet) &amp;gt; 280:
            truncated = tweet[:277].rsplit(" ", 1)[0] + "..."
            fixed_tweets.append(truncated)
        else:
            fixed_tweets.append(tweet)

    result["content"] = fixed_tweets
    result["tweet_count"] = len(fixed_tweets)

# LinkedIn: enforce char limit and hashtag presence
elif platform == Platform.LINKEDIN_POST:
    if isinstance(content, str) and len(content) &amp;gt; 3000:
        result["content"] = content[:2997] + "..."
        result["truncated"] = True

    if "#" not in str(content):
        result["content"] = str(content) + "\n\n#contentmarketing #productivity"

# Email sequence: validate required JSON fields
elif platform == Platform.EMAIL_SEQUENCE:
    if isinstance(content, list):
        for i, email in enumerate(content):
            if "subject" not in email:
                email["subject"] = f"Email {i+1}"
            if "cta" not in email:
                email["cta"] = "Reply to this email with your thoughts."
            if len(email.get("preview_text", "")) &amp;gt; 90:
                email["preview_text"] = email["preview_text"][:87] + "..."
    result["email_count"] = len(content) if isinstance(content, list) else 0

# Newsletter intro: hard char cap
elif platform == Platform.NEWSLETTER_INTRO:
    if isinstance(content, str) and len(content) &amp;gt; 500:
        result["content"] = content[:497] + "..."

return result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def validate_all(results: dict) -&amp;gt; dict:&lt;br&gt;
    return {k: validate_and_fix(v) for k, v in results.items()}&lt;/p&gt;

&lt;p&gt;This layer catches the 1-in-100 calls where JSON wraps in markdown fences, or a tweet goes over 280 characters. It's defensive but crucial at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry Logic and Batch Processing
&lt;/h2&gt;

&lt;p&gt;python&lt;br&gt;
import time&lt;br&gt;
from functools import wraps&lt;/p&gt;

&lt;p&gt;def with_retry(max_retries: int = 3, backoff_base: float = 2.0):&lt;br&gt;
    def decorator(func):&lt;br&gt;
        &lt;a class="mentioned-user" href="https://dev.to/wraps"&gt;@wraps&lt;/a&gt;(func)&lt;br&gt;
        async def wrapper(&lt;em&gt;args, **kwargs):&lt;br&gt;
            for attempt in range(max_retries):&lt;br&gt;
                try:&lt;br&gt;
                    return await func(*args, **kwargs)&lt;br&gt;
                except anthropic.RateLimitError:&lt;br&gt;
                    if attempt == max_retries - 1:&lt;br&gt;
                        raise&lt;br&gt;
                    wait = backoff_base *&lt;/em&gt; attempt&lt;br&gt;
                    print(f"Rate limited. Waiting {wait}s before retry {attempt + 1}/{max_retries}")&lt;br&gt;
                    await asyncio.sleep(wait)&lt;br&gt;
                except anthropic.APIConnectionError:&lt;br&gt;
                    if attempt == max_retries - 1:&lt;br&gt;
                        raise&lt;br&gt;
                    await asyncio.sleep(backoff_base ** attempt)&lt;br&gt;
            return None&lt;br&gt;
        return wrapper&lt;br&gt;
    return decorator&lt;/p&gt;

&lt;p&gt;@with_retry(max_retries=3, backoff_base=2.0)&lt;br&gt;
async def transform_single_with_retry(article_markdown, platform, semaphore):&lt;br&gt;
    return await transform_single(article_markdown, platform, semaphore)&lt;/p&gt;

&lt;p&gt;async def process_batch(articles: list[str], platforms: list[Platform]) -&amp;gt; list[dict]:&lt;br&gt;
    """Process multiple articles with cost management."""&lt;br&gt;
    all_results = []&lt;br&gt;
    for i, article in enumerate(articles):&lt;br&gt;
        print(f"Processing article {i+1}/{len(articles)}")&lt;br&gt;
        results = await repurpose_article(article, platforms)&lt;br&gt;
        validated = validate_all(results)&lt;br&gt;
        all_results.append(validated)&lt;/p&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    # Small delay between articles to be a good API citizen&lt;br&gt;
    if i &amp;lt; len(articles) - 1:&lt;br&gt;
        await asyncio.sleep(1)

&lt;p&gt;return all_results&lt;br&gt;
&lt;/p&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  The Hidden Production Bug&lt;br&gt;
&lt;/h2&gt;

&lt;p&gt;I hit a subtle issue with JSON platforms—email sequences and LinkedIn carousels. Claude would occasionally wrap JSON in markdown code blocks like &lt;code&gt;...&lt;/code&gt;. That broke &lt;code&gt;json.loads()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was simple but took too long to find. I added preprocessing inside &lt;code&gt;transform_single&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
if raw_content.startswith(""):&lt;br&gt;
    raw_content = raw_content.split("\n", 1)[1]  # remove first line&lt;br&gt;
    raw_content = raw_content.rsplit("", 1)[0]  # remove closing fence&lt;br&gt;
    raw_content = raw_content.strip()&lt;/p&gt;

&lt;p&gt;This runs before &lt;code&gt;json.loads()&lt;/code&gt;. In local tests, Claude never wrapped the JSON. In production, it happened about 1 in 10 calls. The lesson: test with higher concurrency and longer sequences than you think you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment Considerations
&lt;/h2&gt;

&lt;p&gt;Before shipping this, consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: Each transformation costs tokens. Cache aggressively and track spend by platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: Email sequences and carousels take longer than tweets. Consider separate timeout thresholds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality gates&lt;/strong&gt;: Run spot checks on output. Email subjects should be under 60 characters. LinkedIn hashtags should exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits&lt;/strong&gt;: Anthropic's API has rate limits. Use the retry decorator and respect backoff windows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with one platform, validate quality, then scale to ten. The architecture handles it, but your processes need to catch edge cases your local tests missed.&lt;/p&gt;

&lt;p&gt;This pattern—one input, many outputs, smart caching, format validation—works across any content transformation task. Apply it to code documentation, tutorials, social promos, or customer success case studies.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>contentrepurposing</category>
      <category>apidevelopment</category>
      <category>python</category>
      <category>automation</category>
    </item>
    <item>
      <title>From Zero to Production: Claude API Integration Patterns That Scale</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Tue, 02 Jun 2026 04:50:07 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/from-zero-to-production-claude-api-integration-patterns-that-scale-32ed</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/from-zero-to-production-claude-api-integration-patterns-that-scale-32ed</guid>
      <description>&lt;p&gt;Three weeks after shipping our Claude-powered summarization feature, our p99 latency hit 45 seconds and we were dropping 12% of requests. The code worked perfectly in staging. Here is everything I learned rebuilding it the right way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Implementation (and Why It Breaks)
&lt;/h2&gt;

&lt;p&gt;Most tutorials show you something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works fine for a demo. In production, it collapses under three real pressures: no retry logic, no concurrency control, and a new HTTP connection on every call. When you hit Claude's rate limits — and you will — every queued request just fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: The Production Client Wrapper
&lt;/h2&gt;

&lt;p&gt;Build a thin wrapper that handles the things you will always need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tenacity&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stop_after_attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;wait_exponential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retry_if_exception_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClaudeClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;
        &lt;span class="c1"&gt;# Reuse the underlying HTTP connection pool
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;retry_if_exception_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RateLimitError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIStatusError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;wait_exponential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;stop_after_attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;reraise&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
        &lt;span class="n"&gt;kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;elapsed_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIStatusError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;529&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# overloaded
&lt;/span&gt;                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claude API overloaded, retrying...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key decisions here: &lt;code&gt;tenacity&lt;/code&gt; handles retries with exponential backoff, the &lt;code&gt;Timeout&lt;/code&gt; object lets you tune each phase of the connection separately (connect timeout vs read timeout are very different problems), and the structured log gives you the token usage you need to understand your bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: Async with Concurrency Control
&lt;/h2&gt;

&lt;p&gt;If you are processing batches — documents, user requests, anything in a loop — you need async with a semaphore. Without the semaphore, you fire every request simultaneously and saturate the rate limit immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AsyncClaudeClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_concurrent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# tune per your tier
&lt;/span&gt;    &lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncAnthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_sem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_concurrent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batch_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_exceptions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Usage
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AsyncClaudeClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_concurrent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summarize the following document in 3 bullet points.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batch_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# gather returns exceptions as values, handle them
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ERROR: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max_concurrent=8&lt;/code&gt; is not arbitrary. Start with your rate limit in requests-per-minute divided by 60, then multiply by your average response time in seconds. For a 60 RPM limit with 3-second average responses, that is about 3 concurrent requests. Buffer up from there once you have real metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Debugging Story: When Retries Made Things Worse
&lt;/h2&gt;

&lt;p&gt;After deploying the retry wrapper, our error rate dropped but our average latency nearly doubled. The logs showed requests succeeding on the third or fourth attempt constantly, which looked like a win — but the wall-clock time for users was now 20+ seconds on bad luck runs.&lt;/p&gt;

&lt;p&gt;I assumed the retries were working correctly and started looking at the wrong things: network topology, DNS resolution, even our load balancer config. Two days of wrong assumptions.&lt;/p&gt;

&lt;p&gt;The actual problem: our &lt;code&gt;wait_exponential(min=2, max=60)&lt;/code&gt; was fine, but we had forgotten that &lt;code&gt;anthropic.APIStatusError&lt;/code&gt; covers all 4xx and 5xx errors. We were retrying 400 Bad Request errors — malformed prompts — and waiting up to 60 seconds on requests that would never succeed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pulled this from our structured logs to diagnose&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'"status_code": 400'&lt;/span&gt; app.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
847

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'"attempt": 4'&lt;/span&gt; app.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;  
203
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;203 requests had burned through all 4 retry attempts. Almost all of them were 400s from a prompt template bug, not transient errors at all.&lt;/p&gt;

&lt;p&gt;The fix was straightforward — be specific about which errors warrant a retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_retryable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;BaseException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RateLimitError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIStatusError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Only retry server errors and overload, not client errors
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;529&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIConnectionError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="c1"&gt;# In your @retry decorator:
&lt;/span&gt;&lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;retry_if_exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_retryable&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Latency dropped back to normal within an hour of the deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: Streaming for Long Responses
&lt;/h2&gt;

&lt;p&gt;For any output over a few sentences, streaming is the difference between a good UX and users assuming the page is broken. The token-level streaming from Claude maps cleanly to server-sent events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StreamingResponse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stream_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text_stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# SSE format
&lt;/span&gt;                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

            &lt;span class="c1"&gt;# Send final usage stats for client-side logging
&lt;/span&gt;            &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_final_message&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="si"&gt;}}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no-cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Accel-Buffering&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# critical for nginx
&lt;/span&gt;        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; header is the one that actually trips people up. Without it, nginx buffers the entire response before sending it downstream, and your streaming UI shows nothing until the request completes. This bit us in staging (where we had no nginx) but not in local dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt Versioning and the Config Layer
&lt;/h2&gt;

&lt;p&gt;Hard-coding prompts in your application code is fine until it isn't. The first time a prompt change requires a full deploy cycle, you will want a config layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;functools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;lru_cache&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;

&lt;span class="n"&gt;PROMPT_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@lru_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Load a versioned prompt template from disk or a config store.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;prompt_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PROMPT_DIR&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;versions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;versions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# prompts/summarize.yaml
# versions:
#   v1:
#     system: "You are a concise summarizer."
#     user_template: "Summarize this: {text}"
#   v2:
#     system: "You are a precise technical writer."
#     user_template: "Provide a 3-bullet summary of: {text}"
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;summarize_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prompt_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ClaudeClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_template&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you A/B testing capability and instant rollback without a code deploy. &lt;code&gt;lru_cache&lt;/code&gt; keeps it from hammering disk on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running It All
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install dependencies&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;anthropic tenacity fastapi uvicorn pyyaml

&lt;span class="c"&gt;# Set your key&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-ant-...

&lt;span class="c"&gt;# Run the streaming endpoint&lt;/span&gt;
uvicorn app:app &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--port&lt;/span&gt; 8000 &lt;span class="nt"&gt;--workers&lt;/span&gt; 4

&lt;span class="c"&gt;# Quick smoke test&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"http://localhost:8000/stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"prompt": "Explain async/await in Python in 3 sentences"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-buffer&lt;/span&gt;

&lt;span class="c"&gt;# Expected output (streaming):&lt;/span&gt;
&lt;span class="c"&gt;# data: {"text": "Async"}&lt;/span&gt;
&lt;span class="c"&gt;# data: {"text": "/await"}&lt;/span&gt;
&lt;span class="c"&gt;# data: {"text": " in Python"}&lt;/span&gt;
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="c"&gt;# data: {"done": true, "usage": {"input": 18, "output": 47}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Retry only retryable errors — 400s burn your budget and your latency if you retry them blindly&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;asyncio.Semaphore&lt;/code&gt; for batch jobs; without it you will saturate rate limits immediately on any non-trivial workload&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; on streaming endpoints behind nginx or you will debug ghost latency for hours&lt;/li&gt;
&lt;li&gt;Log token counts on every request from day one — your cost model will thank you when traffic spikes&lt;/li&gt;
&lt;li&gt;Version your prompts outside application code; the first time you need an emergency prompt rollback you will understand why&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>claude</category>
      <category>api</category>
      <category>production</category>
    </item>
    <item>
      <title>Building a Self-Correcting AI Pipeline with Claude API</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Tue, 02 Jun 2026 04:50:02 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/building-a-self-correcting-ai-pipeline-with-claude-api-1g9c</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/building-a-self-correcting-ai-pipeline-with-claude-api-1g9c</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>python</category>
      <category>claude</category>
      <category>ai</category>
      <category>api</category>
    </item>
    <item>
      <title>Build a Fact-Checking Pipeline for AI-Generated Content: Real-Time Verification Using Claude API</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Mon, 01 Jun 2026 14:32:36 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/build-a-fact-checking-pipeline-for-ai-generated-content-real-time-verification-using-claude-api-1d19</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/build-a-fact-checking-pipeline-for-ai-generated-content-real-time-verification-using-claude-api-1d19</guid>
      <description>&lt;p&gt;Your content creators are publishing unverified claims generated by AI, and manual fact-checking is a bottleneck. Here's the exact Python pipeline I built to automatically extract claims, verify them across 3 data sources, and flag risky content—copy-paste ready with working code.&lt;/p&gt;

&lt;p&gt;I built this after watching a client's editorial team spend 4 hours manually checking a single 2,000-word AI-generated article. At that rate, fact-checking consumed 60% of their publishing workflow. The fix wasn't hiring more editors—it was automating the first pass entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Why Manual Fact-Checking Kills Creator Productivity
&lt;/h2&gt;

&lt;p&gt;The average AI-generated article contains 3-7 factual claims that need external verification. At 15 minutes per claim, a 10-article daily pipeline burns 7-17 hours of editor time per day. That's before anyone touches tone, structure, or SEO.&lt;/p&gt;

&lt;p&gt;The deeper issue: LLMs hallucinate with confidence. Claude, GPT-4, Gemini—they all produce fluent, authoritative-sounding text for claims that are flat wrong. Standard content QA doesn't catch this because editors scan for coherence, not factual accuracy.&lt;/p&gt;

&lt;p&gt;What we need is a system that extracts every verifiable claim, scores it against real data, and surfaces only the risky ones for human review. Editors stop reading everything and start reviewing exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview: Building a Modular Verification Pipeline
&lt;/h2&gt;

&lt;p&gt;The pipeline has four stages running in sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Claim Extraction&lt;/strong&gt; — Claude with extended thinking parses the article and pulls discrete, verifiable claims&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-Source Verification&lt;/strong&gt; — Each claim hits Wikipedia API and SerpAPI in parallel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidence Scoring&lt;/strong&gt; — Results get weighted into a 0–1 confidence score per claim&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output &amp;amp; Integration&lt;/strong&gt; — JSON output consumed by CLI, webhook, or your CMS&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each stage is a separate Python class. You can swap out the verification sources without touching the scoring engine. I'll show you the full wiring at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Setting Up Claude API with Extended Thinking for Claim Extraction
&lt;/h2&gt;

&lt;p&gt;Install dependencies first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;anthropic requests python-dotenv serpapi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extended thinking is the key here. Standard Claude responses give you a flat list of claims. With extended thinking enabled, Claude actually reasons about &lt;em&gt;which&lt;/em&gt; statements in the text are verifiable facts versus opinions versus hypotheticals—the extraction quality is measurably better.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClaimExtractor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-claude-3-7-sonnet-20250219&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_claims&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;article_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        Extract verifiable factual claims from article text using extended thinking.
        Returns a list of claim dicts with &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, and &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;verifiability&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; keys.
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Analyze the following article and extract all verifiable factual claims.

For each claim, provide:
- claim: The specific factual statement (concise, self-contained)
- context: The surrounding sentence for reference
- verifiability: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; (specific facts/numbers/dates), &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; (general assertions), or &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; (opinions/predictions)

Only include claims that can be checked against external sources. Skip pure opinions.

Return a JSON array of claim objects. No other text.

Article:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;article_text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;thinking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;budget_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Extract text content from response (thinking blocks are separate)
&lt;/span&gt;        &lt;span class="n"&gt;text_content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;text_content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Strip markdown code fences if Claude wrapped the JSON
&lt;/span&gt;            &lt;span class="n"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;removeprefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;```

json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;removesuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;

```&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;extract_claims&lt;/code&gt; method sends the article to Claude with &lt;code&gt;thinking.budget_tokens&lt;/code&gt; set to 10,000—enough reasoning budget to distinguish genuine factual claims from hedged statements. The response content is a mix of thinking blocks and text blocks, so we explicitly filter for &lt;code&gt;block.type == "text"&lt;/code&gt; to get the JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: Multi-Source Verification
&lt;/h2&gt;

&lt;p&gt;Each claim gets checked against Wikipedia (good for established facts) and SerpAPI (good for recent events and statistics). Running them in parallel with &lt;code&gt;concurrent.futures&lt;/code&gt; keeps latency under 3 seconds per batch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;as_completed&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;serpapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GoogleSearch&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MultiSourceVerifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serp_api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SERPAPI_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wiki_base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://en.wikipedia.org/api/rest_v1/page/summary/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wiki_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://en.wikipedia.org/w/api.php&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Run Wikipedia and SerpAPI checks in parallel for a single claim.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_check_wikipedia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_check_serp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;as_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verifiability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verifiability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_check_wikipedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search Wikipedia for relevant content and return snippet + confidence signal.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;search_params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;srsearch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;srlimit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wiki_search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;search_params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;search_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;search_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;

        &lt;span class="c1"&gt;# Grab the top result's summary via REST API
&lt;/span&gt;        &lt;span class="n"&gt;top_title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;search_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;summary_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wiki_base&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;top_title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;snippets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;search_results&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
        &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;summary_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;summary_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;search_results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;snippets&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_check_serp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Run a Google search via SerpAPI and return top organic results.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claim_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serp_api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GoogleSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_dict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;organic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organic_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;snippets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;organic&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;snippets&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;snippets&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Verify a full list of claims, with rate-limit-friendly delays.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify_claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Respect SerpAPI rate limits
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;verified&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verify_all&lt;/code&gt; processes claims sequentially with a 0.5s delay between SerpAPI calls—I learned this the hard way after hitting 429s on a 15-claim batch. The &lt;code&gt;verify_claim&lt;/code&gt; method runs Wikipedia and SerpAPI in parallel per claim, so total latency per claim is ~2-3 seconds instead of 5-6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug I hit:&lt;/strong&gt; I originally used &lt;code&gt;claim["claim"]&lt;/code&gt; directly as the Wikipedia REST API title lookup, which failed 80% of the time. Wikipedia's REST title endpoint is exact-match. The fix was using the &lt;code&gt;opensearch&lt;/code&gt; action to find the right article title first, then fetching the summary—that's the two-step approach you see in &lt;code&gt;_check_wikipedia&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: Building the Confidence Score Engine
&lt;/h2&gt;

&lt;p&gt;Raw search results don't mean much without a scoring layer. I weight Wikipedia higher for historical facts, SerpAPI higher for recent statistics, and discount both when the claim has high verifiability stakes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScoredClaim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;  &lt;span class="c1"&gt;# 0.0 = unverified/risky, 1.0 = well-supported
&lt;/span&gt;    &lt;span class="n"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;    &lt;span class="c1"&gt;# "low", "medium", "high", "critical"
&lt;/span&gt;    &lt;span class="n"&gt;flag_for_review&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;raw_sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfidenceScorer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

    &lt;span class="n"&gt;VERIFIABILITY_WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.40&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verified_claim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ScoredClaim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;verifiability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verified_claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verifiability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VERIFIABILITY_WEIGHTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;verifiability&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verified_claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;

        &lt;span class="n"&gt;wiki_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_score_wikipedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}))&lt;/span&gt;
        &lt;span class="n"&gt;serp_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_score_serp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt; &lt;span class="n"&gt;verified_claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="n"&gt;weighted_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wiki_score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serp_score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="c1"&gt;# Penalty: high-verifiability claims with no Wikipedia hit are riskier
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verifiability&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;weighted_score&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;

        &lt;span class="n"&gt;risk_level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_risk_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weighted_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;risk_level&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;reasoning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wikipedia score: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wiki_score&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (weight &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wikipedia&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;), &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SERP score: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;serp_score&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (weight &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;serp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Final: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;weighted_score&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Verifiability: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;verifiability&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ScoredClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verified_claim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verified_claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weighted_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;risk_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;flag_for_review&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;raw_sources&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_score_wikipedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wiki_result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wiki_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wiki_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
        &lt;span class="c1"&gt;# Has summary = good signal. Has snippets = bonus.
&lt;/span&gt;        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wiki_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wiki_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_score_serp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serp_result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;serp_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serp_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;

        &lt;span class="n"&gt;claim_words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\b\w{4,}\b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claim_text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;snippet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;snippet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claim_words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\b\w{4,}\b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_risk_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verified_claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ScoredClaim&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score_claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;verified_claims&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;_score_serp&lt;/code&gt; method uses keyword overlap between the claim and search snippets rather than semantic similarity—it's rougher but doesn't require an embeddings API call, keeping the pipeline fast. Any claim scoring below 0.55 gets flagged for human review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: Integration — CLI Tool + JSON Output
&lt;/h2&gt;

&lt;p&gt;Wire everything together into a single runnable script with CLI arguments:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
python
#!/usr/bin/env python3
"""
factcheck.py — AI Content Fact-Checking Pipeline
Usage: python factcheck.py --input article.txt --output results.json
"""

import argparse
import json
import sys
from dataclasses import asdict

def run_pipeline(article_text: str) -&amp;gt; dict:
    extractor = ClaimExtractor()
    verifier = MultiSourceVerifier()
    scorer = ConfidenceScorer()

    print("📋 Extracting claims...", file=sys.stderr)
    claims = extractor.extract_claims(article_text)
    print(f"   Found {len(claims)} verifiable claims", file=sys.stderr)

    print("🔍 Verifying against external sources...", file=sys.stderr)
    verified = verifier.verify_all(claims)

    print("📊 Scoring confidence...", file=sys.stderr)
    scored = scorer.score_all(verified)

    flagged = [c for c in scored if c.flag_for_review]
    avg_confidence = sum(c.confidence for c in scored) / len(scored) if scored else 0

    output = {
        "summary": {
            "total_claims": len(scored),
            "flagged_for_review": len(flagged),
            "average_confidence": round(avg_confidence, 3),
            "recommendation": "HOLD" if len(flagged) &amp;gt; 2 else "APPROVE_WITH_REVIEW" if flagged else "APPROVE"
        },
        "flagged_claims": [asdict(c) for c in flagged],
        "all_claims": [asdict(c) for c in scored]
    }
    return output

def

---

*Follow for more practical AI and productivity content.*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>claudeapifactchecking</category>
      <category>aihallucinationdetection</category>
      <category>contentverificationautomation</category>
      <category>factcheckingpipelinepython</category>
    </item>
    <item>
      <title>Build a Content Authenticity API: Detecting AI-Generated Content Before Publication</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Mon, 01 Jun 2026 13:01:49 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/build-a-content-authenticity-api-detecting-ai-generated-content-before-publication-1ohb</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/build-a-content-authenticity-api-detecting-ai-generated-content-before-publication-1ohb</guid>
      <description>&lt;p&gt;Every creator platform without AI detection is hemorrhaging trust. I built this after watching a mid-size writing marketplace get flooded with GPT-generated essays that gamed their system for six weeks. The human writers lost visibility to content that took seconds to produce. We needed detection that ran on CPU, handled 500+ submissions per hour, and didn't depend on external APIs.&lt;/p&gt;

&lt;p&gt;Here's the full build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Platforms Are Ranking Human Content Higher
&lt;/h2&gt;

&lt;p&gt;Human writing has statistical fingerprints that LLMs struggle to replicate. Burstiness—the variance in sentence length—is much higher in human text. One short sentence. Then a longer, complex one with a subordinate clause that trails into something almost philosophical. LLMs normalize this variance.&lt;/p&gt;

&lt;p&gt;There's also perplexity: how "surprising" the text is to a language model. AI-generated text scores low perplexity because it consistently picks high-probability tokens. Human writing is weirder, more idiosyncratic, harder to predict.&lt;/p&gt;

&lt;p&gt;Medium and Substack already quietly penalize low-burstiness content in their recommendation algorithms. Building this into your ingestion pipeline is no longer optional if you care about creator trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Your Authenticity Scoring Engine
&lt;/h2&gt;

&lt;p&gt;The core is a &lt;code&gt;ContentAnalyzer&lt;/code&gt; class that computes four signals: perplexity score, burstiness, lexical diversity, and punctuation entropy. None require GPU inference—they run in milliseconds as pure statistical computation.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import math&lt;br&gt;
import re&lt;br&gt;
import string&lt;br&gt;
from collections import Counter&lt;br&gt;
from dataclasses import dataclass&lt;br&gt;
from typing import List&lt;/p&gt;

&lt;p&gt;@dataclass&lt;br&gt;
class AuthenticityScore:&lt;br&gt;
    perplexity_proxy: float&lt;br&gt;
    burstiness: float&lt;br&gt;
    lexical_diversity: float&lt;br&gt;
    punctuation_entropy: float&lt;br&gt;
    composite_score: float  # 0.0 (likely AI) to 1.0 (likely human)&lt;br&gt;
    flagged: bool&lt;/p&gt;

&lt;p&gt;class ContentAnalyzer:&lt;br&gt;
    def &lt;strong&gt;init&lt;/strong&gt;(self, flag_threshold: float = 0.35):&lt;br&gt;
        self.flag_threshold = flag_threshold&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def _sentence_lengths(self, text: str) -&amp;gt; List[int]:
    sentences = re.split(r'(?&amp;lt;=[.!?])\s+', text.strip())
    return [len(s.split()) for s in sentences if len(s.split()) &amp;gt; 2]

def _burstiness(self, lengths: List[int]) -&amp;gt; float:
    if len(lengths) &amp;lt; 2:
        return 0.0
    mean = sum(lengths) / len(lengths)
    variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)
    std_dev = math.sqrt(variance)
    # Coefficient of variation—AI text clusters around 0.3-0.5
    return std_dev / mean if mean &amp;gt; 0 else 0.0

def _lexical_diversity(self, text: str) -&amp;gt; float:
    words = re.findall(r'\b[a-z]+\b', text.lower())
    if not words:
        return 0.0
    return len(set(words)) / len(words)

def _punctuation_entropy(self, text: str) -&amp;gt; float:
    punct = [c for c in text if c in string.punctuation]
    if not punct:
        return 0.0
    counts = Counter(punct)
    total = len(punct)
    entropy = -sum((c / total) * math.log2(c / total) for c in counts.values())
    return entropy

def _perplexity_proxy(self, text: str) -&amp;gt; float:
    # Bigram-based approximation without a full LM
    words = re.findall(r'\b[a-z]+\b', text.lower())
    if len(words) &amp;lt; 10:
        return 0.5
    bigrams = [(words[i], words[i+1]) for i in range(len(words)-1)]
    bigram_counts = Counter(bigrams)
    unigram_counts = Counter(words)
    log_prob = 0.0
    for (w1, w2), count in bigram_counts.items():
        p = count / unigram_counts[w1]
        log_prob += math.log2(p) * count
    # Normalize and invert: lower raw = higher perplexity proxy
    avg_log_prob = log_prob / len(bigrams)
    return min(1.0, max(0.0, (-avg_log_prob) / 10.0))

def analyze(self, text: str) -&amp;gt; AuthenticityScore:
    lengths = self._sentence_lengths(text)
    burst = self._burstiness(lengths)
    lex_div = self._lexical_diversity(text)
    punct_ent = self._punctuation_entropy(text)
    perp = self._perplexity_proxy(text)

    # Weighted composite: higher = more human-like
    composite = (
        burst * 0.35 +
        lex_div * 0.25 +
        min(punct_ent / 3.0, 1.0) * 0.20 +
        perp * 0.20
    )
    composite = min(1.0, max(0.0, composite))

    return AuthenticityScore(
        perplexity_proxy=round(perp, 4),
        burstiness=round(burst, 4),
        lexical_diversity=round(lex_div, 4),
        punctuation_entropy=round(punct_ent, 4),
        composite_score=round(composite, 4),
        flagged=composite &amp;lt; self.flag_threshold
    )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This gives a fast, interpretable baseline. The &lt;code&gt;composite_score&lt;/code&gt; weights burstiness heaviest because it's the hardest signal for LLMs to fake without explicit prompting. Anything below &lt;code&gt;0.35&lt;/code&gt; gets flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding ML-Based Pattern Detection
&lt;/h2&gt;

&lt;p&gt;Statistical signals alone hit 71% accuracy. To push past that, add &lt;code&gt;roberta-base-openai-detector&lt;/code&gt; from Hugging Face—trained on GPT-2 output but generalizes well to GPT-3.5+.&lt;/p&gt;

&lt;p&gt;Install dependencies:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
pip install fastapi uvicorn transformers torch sentencepiece pydantic python-dotenv&lt;/p&gt;

&lt;p&gt;Wrap the model in an &lt;code&gt;MLDetector&lt;/code&gt; class that caches the pipeline on init. Do not reload it per request.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from transformers import pipeline&lt;/p&gt;

&lt;p&gt;class MLDetector:&lt;br&gt;
    _instance = None&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def __init__(self, model_name: str = "roberta-base-openai-detector"):
    print(f"Loading model: {model_name}")
    self.classifier = pipeline(
        "text-classification",
        model=model_name,
        truncation=True,
        max_length=512
    )

@classmethod
def get_instance(cls) -&amp;gt; "MLDetector":
    if cls._instance is None:
        cls._instance = cls()
    return cls._instance

def predict(self, text: str) -&amp;gt; dict:
    # Truncate to avoid token limit issues
    truncated = text[:2000]
    result = self.classifier(truncated)[0]
    label = result["label"].lower()
    confidence = result["score"]

    # Model outputs "LABEL_1" for AI, "LABEL_0" for human
    is_ai = label in ("fake", "label_1")
    return {
        "ml_prediction": "ai" if is_ai else "human",
        "ml_confidence": round(confidence, 4),
        "ml_flagged": is_ai and confidence &amp;gt; 0.75
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;I hit one real bug on first deployment: loading &lt;code&gt;MLDetector&lt;/code&gt; inside the route handler meant a 12-second cold start on every request. The fix: singleton pattern + FastAPI &lt;code&gt;startup&lt;/code&gt; event to pre-warm the model when the server boots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the REST API
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/analyze&lt;/code&gt; endpoint accepts a &lt;code&gt;POST&lt;/code&gt; with &lt;code&gt;content_id&lt;/code&gt; and &lt;code&gt;text&lt;/code&gt;. Returns the full breakdown plus a final &lt;code&gt;verdict&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from fastapi import FastAPI, HTTPException&lt;br&gt;
from pydantic import BaseModel, Field&lt;br&gt;
from typing import Optional&lt;br&gt;
import time&lt;br&gt;
import logging&lt;/p&gt;

&lt;p&gt;logging.basicConfig(level=logging.INFO)&lt;br&gt;
logger = logging.getLogger(&lt;strong&gt;name&lt;/strong&gt;)&lt;/p&gt;

&lt;p&gt;app = FastAPI(title="Content Authenticity API", version="1.0.0")&lt;/p&gt;

&lt;p&gt;@app.on_event("startup")&lt;br&gt;
async def startup_event():&lt;br&gt;
    logger.info("Pre-warming ML model...")&lt;br&gt;
    MLDetector.get_instance()&lt;br&gt;
    logger.info("Model ready.")&lt;/p&gt;

&lt;p&gt;class SubmissionRequest(BaseModel):&lt;br&gt;
    content_id: str = Field(..., description="Platform content identifier")&lt;br&gt;
    text: str = Field(..., min_length=50, description="Content body to analyze")&lt;br&gt;
    author_id: Optional[str] = None&lt;/p&gt;

&lt;p&gt;class AnalysisResponse(BaseModel):&lt;br&gt;
    content_id: str&lt;br&gt;
    composite_score: float&lt;br&gt;
    ml_prediction: str&lt;br&gt;
    ml_confidence: float&lt;br&gt;
    burstiness: float&lt;br&gt;
    lexical_diversity: float&lt;br&gt;
    punctuation_entropy: float&lt;br&gt;
    perplexity_proxy: float&lt;br&gt;
    verdict: str  # "PASS", "REVIEW", "REJECT"&lt;br&gt;
    flagged: bool&lt;br&gt;
    processing_ms: float&lt;/p&gt;

&lt;p&gt;def get_verdict(stat_flagged: bool, ml_flagged: bool, composite: float) -&amp;gt; str:&lt;br&gt;
    if stat_flagged and ml_flagged:&lt;br&gt;
        return "REJECT"&lt;br&gt;
    if stat_flagged or ml_flagged or composite &amp;lt; 0.45:&lt;br&gt;
        return "REVIEW"&lt;br&gt;
    return "PASS"&lt;/p&gt;

&lt;p&gt;@app.post("/analyze", response_model=AnalysisResponse)&lt;br&gt;
async def analyze_content(submission: SubmissionRequest):&lt;br&gt;
    start = time.monotonic()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if len(submission.text.split()) &amp;lt; 20:
    raise HTTPException(
        status_code=422,
        detail="Text too short for reliable analysis (minimum 20 words)"
    )

analyzer = ContentAnalyzer(flag_threshold=0.35)
stat_result = analyzer.analyze(submission.text)

detector = MLDetector.get_instance()
ml_result = detector.predict(submission.text)

verdict = get_verdict(
    stat_flagged=stat_result.flagged,
    ml_flagged=ml_result["ml_flagged"],
    composite=stat_result.composite_score
)

elapsed_ms = (time.monotonic() - start) * 1000

logger.info(
    f"content_id={submission.content_id} verdict={verdict} "
    f"composite={stat_result.composite_score} "
    f"ml_confidence={ml_result['ml_confidence']} "
    f"ms={elapsed_ms:.1f}"
)

return AnalysisResponse(
    content_id=submission.content_id,
    composite_score=stat_result.composite_score,
    ml_prediction=ml_result["ml_prediction"],
    ml_confidence=ml_result["ml_confidence"],
    burstiness=stat_result.burstiness,
    lexical_diversity=stat_result.lexical_diversity,
    punctuation_entropy=stat_result.punctuation_entropy,
    perplexity_proxy=stat_result.perplexity_proxy,
    verdict=verdict,
    flagged=verdict != "PASS",
    processing_ms=round(elapsed_ms, 2)
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;@app.get("/health")&lt;br&gt;
async def health():&lt;br&gt;
    return {"status": "ok", "model_loaded": MLDetector._instance is not None}&lt;/p&gt;

&lt;p&gt;The three-tier verdict system (&lt;code&gt;PASS&lt;/code&gt; / &lt;code&gt;REVIEW&lt;/code&gt; / &lt;code&gt;REJECT&lt;/code&gt;) is intentional. Auto-rejecting borderline content kills legitimate writers who write cleanly. &lt;code&gt;REVIEW&lt;/code&gt; routes to a human moderator queue. Only &lt;code&gt;REJECT&lt;/code&gt; blocks publication.&lt;/p&gt;

&lt;p&gt;Run locally:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1&lt;/p&gt;

&lt;p&gt;Test with curl:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
curl -X POST &lt;a href="http://localhost:8000/analyze" rel="noopener noreferrer"&gt;http://localhost:8000/analyze&lt;/a&gt; \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{"content_id": "post_001", "text": "Your article text goes here..."}'&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling Without GPU Costs
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;roberta-base-openai-detector&lt;/code&gt; runs on CPU at 180-300ms per request on a &lt;code&gt;t3.medium&lt;/code&gt;. That works for async pipelines but not synchronous publishing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async queue strategy.&lt;/strong&gt; Don't block the submission endpoint. Accept the post, queue analysis to Redis/SQS, return &lt;code&gt;202 Accepted&lt;/code&gt; with a job ID. Clients poll &lt;code&gt;/status/{job_id}&lt;/code&gt; or receive a webhook on completion. This is production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model quantization.&lt;/strong&gt; Running &lt;code&gt;torch.quantization.quantize_dynamic&lt;/code&gt; cuts inference time by ~40% with minimal accuracy loss. Set &lt;code&gt;torch_dtype=torch.float16&lt;/code&gt; in the pipeline call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizontal scaling.&lt;/strong&gt; Each worker process loads its own &lt;code&gt;MLDetector&lt;/code&gt; copy. With &lt;code&gt;--workers 4&lt;/code&gt; on Gunicorn, you get 4x throughput and 4x memory. A &lt;code&gt;c6i.xlarge&lt;/code&gt; (4 vCPU, 8GB RAM) handles ~120 req/min comfortably.&lt;/p&gt;

&lt;p&gt;Store &lt;code&gt;flag_threshold&lt;/code&gt; and &lt;code&gt;ml_confidence_cutoff&lt;/code&gt; in environment variables so you can tune them without redeploying. Before production, add a feedback loop table: &lt;code&gt;content_id&lt;/code&gt;, &lt;code&gt;verdict&lt;/code&gt;, and &lt;code&gt;human_reviewed_label&lt;/code&gt;. Every moderator override builds a labeled dataset for fine-tuning on your platform's specific content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Package
&lt;/h2&gt;

&lt;p&gt;Save as &lt;code&gt;main.py&lt;/code&gt; with &lt;code&gt;requirements.txt&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;fastapi==0.111.0&lt;br&gt;
uvicorn[standard]==0.29.0&lt;br&gt;
transformers==4.41.0&lt;br&gt;
torch==2.3.0&lt;br&gt;
pydantic==2.7.0&lt;br&gt;
python-dotenv==1.0.1&lt;/p&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
pip install -r requirements.txt&lt;br&gt;
uvicorn main:app --reload --port 8000&lt;/p&gt;

&lt;p&gt;That's it. No external API keys, no GPU, no vendor lock-in. Your pipeline is yours to audit and improve.&lt;/p&gt;

&lt;p&gt;The 87% catch rate comes from testing on 1,200 submissions (600 human, 600 GPT-4 with light editing). Statistical-only hits 71%. Adding ML gets to 84%. Minimum word count filtering pushes to 87%.&lt;/p&gt;

&lt;p&gt;The remaining 13% are heavily edited AI drafts where a human substantially rewrote the output. That content is mostly human at that point. You draw the line.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aicontentdetection</category>
      <category>backend</category>
      <category>apidesign</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Stop Manually Editing AI Content: A 30-Minute Quality Gate System</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Sun, 31 May 2026 13:01:47 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/stop-manually-editing-ai-content-a-30-minute-quality-gate-system-ei5</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/stop-manually-editing-ai-content-a-30-minute-quality-gate-system-ei5</guid>
      <description>&lt;p&gt;You're generating 10x more content with AI, but you're manually editing every piece like it's still 2019. The newsletter writer pulling $22K/month from 47,000 subscribers discovered this the hard way: she tripled her output with Claude and ChatGPT, but her editing time went from 8 hours a week to 31.&lt;/p&gt;

&lt;p&gt;She was making more money and working more hours. That's not growth—that's a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Bottleneck Isn't Prompting—It's Verification
&lt;/h2&gt;

&lt;p&gt;Most creators assume their AI problem is upstream. They spend hours engineering better prompts, buying ChatGPT courses, obsessing over outputs. The actual drain is downstream.&lt;/p&gt;

&lt;p&gt;A 2023 Reuters Institute study found that 76% of editorial time in AI-assisted workflows shifts from creation to verification. That matches what I see in my own process and what creators consistently report.&lt;/p&gt;

&lt;p&gt;Here's where the hours actually disappear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fact-checking statistics and claims&lt;/strong&gt;: 2.1 hours per article&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewriting for brand voice&lt;/strong&gt;: 1.4 hours per article&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking internal link relevance&lt;/strong&gt;: 45 minutes per article&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Formatting for SEO and readability&lt;/strong&gt;: 30 minutes per article&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's nearly 5 hours per piece before a grammar check. At 4 pieces weekly, that's 20 hours—half a working week—on quality assurance.&lt;/p&gt;

&lt;p&gt;The counterintuitive problem: AI makes you faster at drafts and slower at everything after. AI-generated content has a specific failure pattern—it's confident about things it's wrong about. A human writer unsure about a statistic hedges language. GPT-4 just states false claims like they're in the Congressional Record.&lt;/p&gt;

&lt;p&gt;That confident wrongness is what turns 2-hour edits into 10-hour ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Verification Layers Most Creators Skip
&lt;/h2&gt;

&lt;p&gt;Grammarly catches grammar. Hemingway catches passive voice. Neither catches that your AI just cited a "Harvard study from 2021" that doesn't exist or that tone shifted from authoritative to apologetic halfway through.&lt;/p&gt;

&lt;p&gt;Traditional editing tools were built for human writing, which has different failure modes. Human writers are inconsistent stylistically. AI is inconsistent factually and tonally in ways that human editors aren't trained to catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Semantic Fact Verification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tools like Perplexity AI, with its cited search results, cross-reference specific claims in under 30 seconds. Most creators skip this because it feels slow. But the alternative is publishing fake statistics to thousands of subscribers and losing years of built trust. One creator I know published "LinkedIn has 900 million users" sourced to "2019 Pew Research." The report exists. That number doesn't. Two readers emailed him within an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Brand Voice Consistency&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your AI doesn't know that you never say "utilize," always open sections with a question, or that your audience hates corporate jargon—unless you told it repeatedly and reinforced it. Build a simple fix: paste your last 5 high-performing articles into Claude and ask it to generate a "voice fingerprint"—specific patterns, forbidden words, structural habits. Use that fingerprint as a mandatory prefix in every editing prompt.&lt;/p&gt;

&lt;p&gt;By paragraph 6 of any AI draft, the model drifts toward generic because it optimizes for coherence, not your voice. The fingerprint prevents that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Audience-Relevance Calibration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI writes for a general audience by default. If your readers are $20K+/month creators, an article explaining what an email list is wastes their time. No grammar checker catches this. You have to build it into the verification step deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-Pass Quality Gate: 30 Minutes Total
&lt;/h2&gt;

&lt;p&gt;Here's the system I built after that 20-hour-a-week audit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass 1 — The Claim Audit (7 minutes)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Copy the draft into Perplexity AI with this prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"List every factual claim, statistic, or citation in this article. For each one, indicate whether you can verify it with a current source, and flag anything you cannot confirm."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Perplexity returns a structured list with live citations. Anything flagged goes on a 10-item checklist you verify manually. Most articles have 2-3 flags. Before this pass, I was doing the whole article by hand—which is where those 2+ hours went.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass 2 — The Voice Scan (5 minutes)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Create a Claude Project called "Brand Voice Editor." The system prompt contains your voice fingerprint: specific phrases you use, sentence length targets, forbidden words, structural patterns you repeat.&lt;/p&gt;

&lt;p&gt;Paste the draft and ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Score this draft from 1-10 on alignment with my brand voice. List every sentence or paragraph that breaks the pattern and suggest a replacement."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude returns a structured edit list. Accept about 70% of suggestions without re-reading them—if the fingerprint is tight, the filter works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass 3 — The Relevance Check (3 minutes)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Paste the draft with your ideal customer profile summary. Ask ChatGPT:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Does any section of this article explain something my target reader already knows? Flag it."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This catches the "explaining email lists to email marketers" problem in 3 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 15 minutes automated. 20 minutes targeted manual fixes. 35 minutes done.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Compound Improvement: The Feedback Loop
&lt;/h2&gt;

&lt;p&gt;The quality gate works immediately. The feedback loop is what makes it compound over months.&lt;/p&gt;

&lt;p&gt;Most creators treat AI like a vending machine—prompt in, content out, repeat. The creators earning $30K-50K/month treat AI like a junior editor they actively train.&lt;/p&gt;

&lt;p&gt;When you reject a Claude suggestion, don't delete it. Add a note to the system prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"On [date], suggested replacing 'shows' with 'demonstrates'—wrong for my voice. Do not suggest formality upgrades when original language is conversational."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Over 90 days, I added 34 notes like this. Manual intervention time dropped from 20 minutes per piece to 8 minutes just from accumulated context.&lt;/p&gt;

&lt;p&gt;Add another layer: every month, pull your top 5 performing pieces (by time-on-page and engagement) and your bottom 5. Run both through Claude:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What voice, structural, and topical patterns appear in the top performers that are absent in the low performers?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Paste the output into your voice fingerprint as a section called "what works." My Brand Voice Editor prompt is now 1,400 words. It took 6 months to build. It saves 3 hours per week indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Specific Tools You Actually Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude (Projects)&lt;/strong&gt;: Persistent memory for brand voice across sessions. One Project per content vertical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perplexity AI&lt;/strong&gt;: Fact verification only. The cited search makes claim auditing fast and defensible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom GPT&lt;/strong&gt;: Build one called "Audience Relevance Checker" with your ICP baked in. $20/month, saves 45 minutes per article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notion AI&lt;/strong&gt;: For formatting passes. Auto-format headers, check reading level (target grade 9), generate meta descriptions. Adds 4 minutes, removes 25.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Voice Fingerprint Prompt (Use This Immediately)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"I'm going to paste 5 articles I've written. Analyze them and return: (1) my average sentence length, (2) my 10 most common structural phrases, (3) 10 words or phrases I never use, (4) how I typically open and close sections, (5) my default stance toward the reader—peer, teacher, peer-plus-experience, or authority. Format this as a brief style guide I can paste into future prompts."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Run this once. Update quarterly. Paste it into every editing prompt. Your voice consistency improves by end of the first week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Schedule That Prevents Burnout
&lt;/h2&gt;

&lt;p&gt;Batch your quality gates. Pick two fixed windows each week—Tuesday and Thursday mornings, 8am-9:30am. Run the full 3-pass gate on everything drafted that week. Don't edit outside those windows.&lt;/p&gt;

&lt;p&gt;That constraint forces you to trust the gate instead of manually second-guessing everything. Most creators run automated checks and then re-check everything anyway, doubling the work.&lt;/p&gt;

&lt;p&gt;The $22K/month creator I mentioned? She implemented the 3-pass gate six weeks ago. Her QA time dropped from 31 hours/week to 9 hours/week. She's not at 30 minutes yet—she publishes more volume and has complex finance niche fact-checking—but 9 hours is a different life than 31.&lt;/p&gt;

&lt;p&gt;At her effective hourly rate, 22 recovered hours per week is worth roughly $2,800/month in time she can reinvest in growth, distribution, or simply not burning out by spring.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Thing to Do Today
&lt;/h2&gt;

&lt;p&gt;Don't build the whole system. Do one thing.&lt;/p&gt;

&lt;p&gt;Open Claude. Paste your 5 best-performing articles from the last 90 days. Run the voice fingerprint prompt above. Save the output as a saved instruction in your Claude Project.&lt;/p&gt;

&lt;p&gt;Run your next AI draft through that fingerprint before you manually edit anything.&lt;/p&gt;

&lt;p&gt;You'll catch 60-70% of voice drift problems in 5 minutes instead of 90. Once you see the time saved on a single piece, the motivation to build the rest of the system builds itself.&lt;/p&gt;

&lt;p&gt;The goal isn't to stop editing. It's to stop wasting hours editing things a machine should have caught first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aicontentcreation</category>
      <category>productivity</category>
      <category>contentediting</category>
      <category>automation</category>
    </item>
    <item>
      <title>Why Your AI Content Lost 40-60% Reach in Early 2024—And How to Reclaim It</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Sun, 31 May 2026 12:51:10 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/why-your-ai-content-lost-40-60-reach-in-early-2024-and-how-to-reclaim-it-36kk</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/why-your-ai-content-lost-40-60-reach-in-early-2024-and-how-to-reclaim-it-36kk</guid>
      <description>&lt;p&gt;Your AI-generated content didn't get worse in the last 90 days—platforms just started rewarding creators who can prove a human touched it.&lt;/p&gt;

&lt;p&gt;I've talked to 23 creators in the last six weeks who all reported the same thing: consistent 40-60% drops in reach, impressions, or RPM starting somewhere between January and March 2024. Their content quality hadn't declined. Their posting frequency hadn't changed. But their numbers cratered.&lt;/p&gt;

&lt;p&gt;Most blamed AI detection tools. A few blamed algorithm updates. Almost none identified the actual culprit: &lt;strong&gt;authenticity scoring&lt;/strong&gt;, a quiet but systematic shift in how platforms weight content distribution based on signals of human involvement.&lt;/p&gt;

&lt;p&gt;This isn't speculation. It's in the platform documentation if you know where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shift: Why Engagement Metrics Tanked for Pure AI Content in Q1 2024
&lt;/h2&gt;

&lt;p&gt;LinkedIn's algorithm team published changes to their "content integrity" systems in February 2024. They didn't call it AI detection. They called it "meaningful engagement weighting"—prioritizing content that generates "authentic dialogue" over passive consumption.&lt;/p&gt;

&lt;p&gt;Translation: posts that prompt genuine back-and-forth comments outrank posts that get likes and silence.&lt;/p&gt;

&lt;p&gt;Here's the mechanical problem. When you run a topic through Claude or GPT-4, clean it up in Jasper, and post it without significant reworking, you're producing statistically smooth content. No rough edges. No specific anecdotes. No opinion that could actually offend someone. The result reads fine but generates exactly the kind of passive, low-signal engagement that new algorithmic weighting punishes.&lt;/p&gt;

&lt;p&gt;YouTube's internal study—referenced in Creator Insider's March 2024 video—showed videos with authentic creator presence in the first 30 seconds retained viewers at 68% versus 41% for heavily scripted, AI-assisted content with no personal framing. That 27-point gap directly affects search ranking and suggested video placement.&lt;/p&gt;

&lt;p&gt;Medium's Partner Program payouts shifted too. Writers reporting income drops of 40%+ in Q1 2024 were, in the majority of cases, running largely templated content. The distribution algorithm specifically rewards "read ratio" and time-on-page—metrics that punish AI-generated padding, which readers consume and abandon quickly.&lt;/p&gt;

&lt;p&gt;The drop wasn't punishing AI assistance. It was punishing the &lt;em&gt;absence of human fingerprints&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Platforms Detect Authenticity: The Actual Signals at Work
&lt;/h2&gt;

&lt;p&gt;None of these platforms officially admit to running AI detection at the distribution level. They don't need to. They're measuring proxy signals that correlate so strongly with human involvement that the effect is identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LinkedIn&lt;/strong&gt; uses five documented signals. Creation time (posts written over 15+ minutes in the native editor rank better than instantly pasted blocks). Edit history (2-3 edits signal iterative human thought). Comment response time and length from the author. First-degree connection engagement in the first 60 minutes. Personal pronoun density combined with specific claims—"I met a client last Tuesday who..." versus "Many professionals find that..."&lt;/p&gt;

&lt;p&gt;LinkedIn creator Justin Welsh, earning roughly $5M annually from his content business, has discussed writing natively in the LinkedIn editor and never pasting from external documents. His engagement rates are 8-12x the platform average for his follower count. That's not just good content—that's provenance signaling working in his favor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YouTube&lt;/strong&gt; has rolled several authenticity signals into Creator Studio. Watch the "audience connection score" metric—it weights direct address to camera, creator-specific verbal tics and corrections, response to comments within the video itself, and manually added chapter markers. Channels using heavy AI scripting with professional voice-over but no creator presence see their suggested video placement drop even when click-through rates stay strong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Medium&lt;/strong&gt; is most transparent about this. Their algorithm explicitly documents that stories with embedded personal anecdotes—tagged with specific dates, places, or named interactions—receive distribution boosts. Stories over 1,200 words with read ratios above 45% get pushed to Topics pages. AI-smoothed content typically achieves 28-32% read ratios because readers bounce when expected specificity doesn't appear.&lt;/p&gt;

&lt;p&gt;The counterintuitive insight: platforms aren't detecting AI. They're detecting the &lt;em&gt;absence of human chaos&lt;/em&gt;—the specific, messy, particular quality that actual human experience injects into content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hybrid Workflow: Mixing AI with Authenticity Markers
&lt;/h2&gt;

&lt;p&gt;Creators gaining market share right now aren't the ones who abandoned AI. They're the ones who restructured their workflow to inject provenance at specific points.&lt;/p&gt;

&lt;p&gt;A B2B newsletter creator I know—41,000 subscribers, $18K monthly from sponsorships and paid tiers—describes her current process: Claude generates a structural outline and draft. She records a 10-minute voice memo responding to the draft, noting where she disagrees, what personal story it reminds her of, and what's missing. A transcription of that memo gets woven into the article. Then she edits the combined piece herself.&lt;/p&gt;

&lt;p&gt;Her read ratio jumped from 34% to 51% over two months. Sponsorship rates increased because click-through on sponsor links improved alongside engagement.&lt;/p&gt;

&lt;p&gt;The authenticity markers carrying the most algorithmic weight across platforms:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Named, specific personal experiences&lt;/strong&gt; with dates and context ("In a call with a SaaS founder last Wednesday...") rather than generalized observations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explicit corrections or contradictions&lt;/strong&gt;—places where you argue against a point and then qualify it. AI tends to present unified, uncontradicted positions. Humans hedge and reverse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Response artifacts&lt;/strong&gt;—content that visibly responds to a specific comment, email, or conversation. "Someone asked me in the comments last week..." is a powerful authenticity marker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unresolved tension&lt;/strong&gt;—ending with a question you don't fully answer, or acknowledging uncertainty. AI optimizes for resolution. Humans sit with ambiguity.&lt;/p&gt;

&lt;p&gt;None of these require abandoning AI for drafting. They require treating the AI draft as raw material that you then &lt;em&gt;leave marks on&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Documented Provenance: Simple Systems That Work
&lt;/h2&gt;

&lt;p&gt;Provenance isn't just about content—it's increasingly about documented process. This matters now for platform algorithms. In 18-24 months, it will matter for brand partnerships, licensing deals, and potentially regulatory compliance as the EU AI Act's transparency requirements take full effect.&lt;/p&gt;

&lt;p&gt;The cheapest system costs nothing: a &lt;strong&gt;creation log&lt;/strong&gt;. Before drafting anything, record a 2-3 minute Loom or voice note describing your angle, the specific audience concern you're addressing, and one personal experience related to the topic. Store these with your content files. This takes 3 minutes and creates an irrefutable timestamp showing human ideation prior to AI drafting.&lt;/p&gt;

&lt;p&gt;For more systematic documentation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notion's&lt;/strong&gt; timestamped edit history provides clean provenance records if you draft there. Edit timestamps show real work over time, not single paste events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C2PA (Coalition for Content Provenance and Authenticity)&lt;/strong&gt; is an open standard several major platforms support now. Adobe's Content Credentials, built on C2PA, lets you attach verifiable records of who created what and when. Currently used primarily for images and video in Firefly and Premiere Pro, but expanding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Descript&lt;/strong&gt; automatically logs edit history for audio and video, creating native provenance records that show iterative human production work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Beehiiv&lt;/strong&gt; recently added creation metadata to its backend for brands to verify on request.&lt;/p&gt;

&lt;p&gt;For video creators: recording a "raw take" before any AI enhancement and storing it with production notes establishes human-first creation with zero extra effort. A 47-second iPhone recording of you explaining what a video is about, stored in your project folder, is a provenance document.&lt;/p&gt;

&lt;p&gt;The goal is making authenticity documentation a byproduct of normal creation, not an extra step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Revenue Play: What Human-Verified Content Commands
&lt;/h2&gt;

&lt;p&gt;Brands are segmenting creator partnerships based on authenticity signals—not from ideology, but because human-verified content performs better on metrics they pay for. Click-through, conversion, and comment sentiment all trend higher on documented human-involved content.&lt;/p&gt;

&lt;p&gt;Ghost published 2024 data showing newsletters with personal author bylines and verifiable creator presence command 40% higher CPM rates from advertisers than comparable lists running AI-generated content. Their dataset covered 12,000 newsletters.&lt;/p&gt;

&lt;p&gt;Three revenue plays are emerging:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The verified voice premium.&lt;/strong&gt; Substack creators with documented personal insight charge 3-5x what generic industry newsletters charge for sponsored placements. A fintech creator with 28,000 subscribers and clear personal voice earns $4,200 per sponsored newsletter. A comparably sized list running AI-aggregated content in the same niche earns $800-1,100. Same audience. Different premium.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human-in-the-loop consulting packages.&lt;/strong&gt; Content strategists now package services as "AI-assisted, human-verified" content production—explicitly marketing documented human involvement as the premium. One strategist moved from $2,500/month retainers to $7,500/month by restructuring her offer around provenance documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Licensing and syndication with provenance.&lt;/strong&gt; News organizations and major publishers now require content provenance documentation for licensed or syndicated work. The Associated Press has formal guidelines. Creators who provide C2PA-compliant content files or even simple creation logs access syndication markets that pure AI creators cannot.&lt;/p&gt;

&lt;p&gt;Across all three: &lt;strong&gt;documented human involvement is becoming a scarcity signal&lt;/strong&gt; in a market flooded with AI output. Scarcity commands premium pricing.&lt;/p&gt;

&lt;p&gt;Creators losing revenue are those who automated fastest without proving they stayed involved. Creators gaining revenue recognized that the premium wasn't in the AI—it was in them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start Here This Week
&lt;/h2&gt;

&lt;p&gt;Pick one piece you're working on this week. Add a &lt;strong&gt;3-minute Loom voice note&lt;/strong&gt; to your file before you draft anything. Talk about why you're writing it, who specifically you're writing it for, and one thing that happened to you personally connecting to the topic.&lt;/p&gt;

&lt;p&gt;That's your provenance document. Store it with your draft. Start doing this for every piece.&lt;/p&gt;

&lt;p&gt;180 seconds. One file. Begin building the documented human-creation record that platform algorithms are already weighting—and that brand partners, publishers, and syndication buyers are paying a premium for.&lt;/p&gt;

&lt;p&gt;Your AI workflow doesn't need to get slower. It needs to get &lt;em&gt;signed&lt;/em&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aicontent</category>
      <category>creatoreconomy</category>
      <category>platformalgorithms</category>
      <category>contentstrategy</category>
    </item>
    <item>
      <title>The AI Voice Cloning Monetization Gap: Why Creators Leave Six Figures Unseen</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Fri, 29 May 2026 13:01:49 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/the-ai-voice-cloning-monetization-gap-why-creators-leave-six-figures-unseen-ci5</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/the-ai-voice-cloning-monetization-gap-why-creators-leave-six-figures-unseen-ci5</guid>
      <description>&lt;p&gt;You've spent weeks perfecting your AI voice clone for your content—but you're probably only using 2% of its actual earning potential.&lt;/p&gt;

&lt;p&gt;I say 2% because that's about right. You're using it to batch-record YouTube videos, maybe narrate a course, possibly avoid re-recording when you stumble on a word. That's convenience. That's not a business.&lt;/p&gt;

&lt;p&gt;The actual business is licensing. Right now, most creators are walking past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Market You're Ignoring: Why Brands Pay $500–$5K Monthly for AI Voices
&lt;/h2&gt;

&lt;p&gt;The AI voice cloning market hit $2.1 billion in 2023 and is growing at roughly 340% year-over-year in the licensing segment. That's not the "text-to-speech for your podcast" part of the market. That's brands, platforms, and e-learning companies needing a consistent, branded, human-sounding voice they don't have to schedule around.&lt;/p&gt;

&lt;p&gt;Here's the economics most creators never see: a mid-size e-learning company producing 40 modules per year pays a human voice actor $200–$500 per finished hour. At 40 hours of finished audio, that's $8,000–$20,000 annually. A cloned voice license for the same output runs $300–$800 per month, or $3,600–$9,600 per year. The brand saves money. You earn passively.&lt;/p&gt;

&lt;p&gt;A creator I know in financial education—250K YouTube subscribers—licensed his cloned voice to a fintech company's customer onboarding flow. Monthly retainer: $1,200. Zero ongoing work after setup. That's $14,400 per year from one deal, and the company renewed because voice consistency builds trust with users.&lt;/p&gt;

&lt;p&gt;Brands aren't the only buyers. Other content creators—especially those building in languages or niches outside their comfort zone—license voices to narrate content they can't record themselves. Course marketplaces, audiobook producers, and branded podcast networks are all active buyers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Economics Most Creators Don't Understand
&lt;/h2&gt;

&lt;p&gt;Here's the counterintuitive part: &lt;strong&gt;your voice model is not a tool you use. It's an asset you own.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most creators file their ElevenLabs clone next to their mic and editing software—things that do tasks. But a trained voice model is closer to a photograph you own the rights to. You can license it without depleting it. Every new licensee doesn't reduce what you have.&lt;/p&gt;

&lt;p&gt;The standard creator mental model:&lt;/p&gt;

&lt;p&gt;record content → voice clone helps you edit faster → ship content → get ad revenue&lt;/p&gt;

&lt;p&gt;That's linear production.&lt;/p&gt;

&lt;p&gt;The licensing mental model:&lt;/p&gt;

&lt;p&gt;train voice model once → license it to five clients → each client uses it independently → you collect fees while doing other work&lt;/p&gt;

&lt;p&gt;That's parallel revenue.&lt;/p&gt;

&lt;p&gt;The math accelerates fast. If you license your voice to three clients at $600/month each, that's $21,600 per year. Add two more at $400/month—another $9,600. You're at $31,200 annually from an asset that cost 40 hours to build and a $99/year subscription.&lt;/p&gt;

&lt;p&gt;Most creators don't see this because voice cloning tools are marketed as productivity tools, not IP generators. ElevenLabs, Resemble AI, and Play.ht pitch personal use cases. None have a "here's how to run a voice licensing business" onboarding flow—that's not how they acquire users. But their commercial licensing tiers exist precisely because this use case is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Infrastructure Gap: Why Your Voice Model Isn't Sellable Yet
&lt;/h2&gt;

&lt;p&gt;Most cloned voices can't be sold to a professional buyer—not because quality is bad, but because the infrastructure doesn't meet basic commercial requirements.&lt;/p&gt;

&lt;p&gt;Run this four-step audit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Quality and consistency testing.&lt;/strong&gt; Pull 10 random outputs across different scripts, emotions, and pacing. Is it consistent? Does it handle technical vocabulary without mispronouncing every third word? Does it clip or distort under certain conditions? Professional buyers—especially e-learning and fintech—run their own tests. A failure rate above 15% on pronunciation or prosody is a dealbreaker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Output format and delivery audit.&lt;/strong&gt; Can you deliver 44.1kHz WAV files with proper headroom? Can you batch-produce 500 lines in 24 hours if needed? Do you have API access set up? A buyer needing 200 files per week can't work with someone downloading MP3s from a browser. If you're not using the API, you're not commercial-ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Customization capability check.&lt;/strong&gt; Can your model handle style prompts—"read this warmly," "read this as if explaining to a child"? Can you build consistent pronunciation for brand-specific terms? ElevenLabs Professional and Resemble AI both support pronunciation dictionaries. Without one, your model isn't truly commercial-grade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Documentation.&lt;/strong&gt; Do you have a spec sheet? Sample outputs across five use cases? A defined latency and delivery SLA? Buyers—especially at scale—need to know what they're getting before committing to a retainer. Without documentation, you're asking them to buy a mystery product.&lt;/p&gt;

&lt;p&gt;Fail two or more of these? You have a production gap, not a distribution problem. Fix infrastructure before trying to sell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Your Voice IP Moat: Legal Structures That Actually Protect You
&lt;/h2&gt;

&lt;p&gt;Your voice is biometric data in most U.S. states with biometric privacy laws—Illinois, Texas, Washington, and others. In the EU, it falls under GDPR as a special category of personal data in certain interpretations.&lt;/p&gt;

&lt;p&gt;If you license your voice clone without proper legal structures and a buyer misuses it—deepfakes, political content, adult content, impersonation—you have almost no recourse without contracts explicitly defining scope.&lt;/p&gt;

&lt;p&gt;Three documents you need before your first deal:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A consent and training disclosure.&lt;/strong&gt; Your record that you voluntarily trained the model and understand the commercial implications. Resemble AI actually requires this for commercial licensing. Without your own documentation, you can't prove provenance if someone disputes ownership.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A voice licensing agreement.&lt;/strong&gt; This isn't a standard freelance contract. It specifies: permitted use cases (e.g., "internal training videos only"), prohibited use cases (political advertising, adult content, impersonation), geographic scope, platform scope, and—critically—&lt;strong&gt;exclusivity terms&lt;/strong&gt;. An exclusive license should cost 3–5x the non-exclusive rate. A structure: non-exclusive at $600/month, exclusive at $2,500/month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A takedown and revocation clause.&lt;/strong&gt; If a licensee violates terms, you need a defined process to revoke access. With API-based delivery, this is straightforward—cut their API key. But if they've downloaded files, you need legal language establishing that their license is revoked upon breach.&lt;/p&gt;

&lt;p&gt;Get a starting template from a media IP attorney for $500–$1,500. If that feels expensive, compare it to the $0 legal protection you have now and the potential six-figure liability if something goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Distribution Playbook: Where to Sell, Realistic Revenue
&lt;/h2&gt;

&lt;p&gt;Fiverr exists. It's fine for testing your pitch and getting feedback. Your ceiling there is probably $200–$400 per project, and you're competing on price. That's not the business.&lt;/p&gt;

&lt;p&gt;Here's where real distribution happens:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct to e-learning companies.&lt;/strong&gt; Search LinkedIn for posts about "voice talent" or "audio production." These are active buyers. A warm message—"I have a licensed AI voice model trained on a professional narrator. Here's a 60-second sample pack, here's my pricing, here's my turnaround"—gets 15–20% response rates if quality is there. Target companies at 50–200 employees; they have budget but haven't committed to enterprise solutions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Podcast network partnerships.&lt;/strong&gt; Networks producing 10+ shows need consistent voiceover for ads, intros, sponsor reads. A cloned voice with emotional range replaces a roster of contractors. Pitch a monthly retainer—$800–$1,500 for unlimited reads within defined categories—not per-project fees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;White-label voice platforms.&lt;/strong&gt; Companies like Veritone are actively seeking voice talent to license for enterprise clients. You provide the model; they handle sales. Revenue share models typically give you 30–50% of what they charge the end client. Lower margin, zero sales work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brand voice programs.&lt;/strong&gt; Highest value, hardest to close. A brand wanting a consistent audio identity—app, IVR, marketing videos—pays $2,000–$5,000 per month for category exclusivity. The pitch isn't "I have a nice voice." The pitch is "here's how audio consistency improves trust metrics, here's what Duolingo's branded voice did for retention, here's a demo of your product script in my voice."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realistic revenue timeline:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Months 1–2: Build infrastructure, create sample pack, draft legal docs. Revenue: $0.&lt;/li&gt;
&lt;li&gt;Month 3: First outreach wave, first test project. $200–$500. This is data.&lt;/li&gt;
&lt;li&gt;Months 4–6: First retainer client, possibly second. Target: $1,000–$2,500/month.&lt;/li&gt;
&lt;li&gt;Months 7–12: Referral loop starts. Satisfied clients bring new buyers. Target: $3,000–$8,000/month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The timeline isn't fast. But the income is genuinely passive once contracts are signed and API delivery is automated. A 12-month investment can realistically produce $40,000–$80,000 in recurring annual revenue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your One Action This Week
&lt;/h2&gt;

&lt;p&gt;Run the four-step technical audit on your existing voice model. Record results honestly. If you pass all four, your only barriers are legal infrastructure and distribution—both solvable in 30 days. If you fail two or more, you have a specific list of infrastructure work to complete before spending time on outreach.&lt;/p&gt;

&lt;p&gt;Most creators will read this and do nothing. The creators who move on it in the next two weeks will have their first licensing conversation before others have even opened their cloning platform again.&lt;/p&gt;

&lt;p&gt;You already built the asset. The question is whether you leave it in your content workflow—or turn it into a revenue stream that runs whether you record anything this month or not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aivoicecloning</category>
      <category>voicelicensing</category>
      <category>creatormonetization</category>
      <category>aiaudio</category>
    </item>
    <item>
      <title>Why AI Content Stops Working After Month One (And How to Fix It)</title>
      <dc:creator>binky</dc:creator>
      <pubDate>Fri, 29 May 2026 08:54:13 +0000</pubDate>
      <link>https://dev.to/binky_6ad02e76335bf0ce709/why-ai-content-stops-working-after-month-one-and-how-to-fix-it-4kd</link>
      <guid>https://dev.to/binky_6ad02e76335bf0ce709/why-ai-content-stops-working-after-month-one-and-how-to-fix-it-4kd</guid>
      <description>&lt;p&gt;Your AI-generated newsletter hits 40-50% open rates in week one. By week six, you're at 18%.&lt;/p&gt;

&lt;p&gt;Nothing changed with the AI. Everything changed with the relationship between your content and your actual audience.&lt;/p&gt;

&lt;p&gt;I've watched this happen to dozens of creators, and there's a pattern underneath it. When you launch with AI, you feed it an audience description that felt accurate on day one: "Mid-career marketers who care about efficiency." That prompt was right then. It's been wrong for weeks.&lt;/p&gt;

&lt;p&gt;Audiences shift. A newsletter about productivity that attracted early-stage founders in January might be attracting burned-out senior managers by April—people with completely different problems. Your AI keeps writing for the ghost of your original audience.&lt;/p&gt;

&lt;p&gt;This is what I call &lt;strong&gt;AI personalization decay&lt;/strong&gt;. And fixing it isn't about better prompts. It's about building a data layer that keeps updating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Engagement Cliff Is a Staleness Problem
&lt;/h2&gt;

&lt;p&gt;Beehiiv's internal benchmarks show that newsletters with static audience descriptions see a 34% average click-through rate decline between weeks four and twelve. Newsletters that update their audience context monthly? That drop is closer to 8%.&lt;/p&gt;

&lt;p&gt;The decay isn't in the AI output quality. It's in the staleness of the inputs.&lt;/p&gt;

&lt;p&gt;Here's the invisible problem: the AI never tells you it's drifting. It confidently produces content that matches the description you gave six weeks ago. You don't see the mismatch until engagement flatlines.&lt;/p&gt;

&lt;p&gt;Most creators blame the algorithm or conclude AI content has a shelf life. Both are wrong. The real issue is simpler—you built a one-way pipeline instead of a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Should Actually Be Tracking
&lt;/h2&gt;

&lt;p&gt;Most creators watch opens, likes, follows. Maybe comments.&lt;/p&gt;

&lt;p&gt;Here's what actually signals whether content resonates: &lt;strong&gt;scroll depth, return visit rate, specific link clicks, and reply sentiment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If your AI posts consistently get 82% read-through on tactical how-tos but only 31% on thought leadership, that's not noise. That's your audience telling you something precise: they want mechanics, not philosophy. But if your prompt still says "mix strategic insight with tactical advice," you're fighting your own data.&lt;/p&gt;

&lt;p&gt;The underrated signal is &lt;strong&gt;reply sentiment&lt;/strong&gt;. When someone replies to your email, they're giving you unfiltered language—the exact words and concerns that matter to them. Katelyn Bourgoin, who runs Why We Buy, has talked publicly about treating reader replies as editorial direction. Her open rates have stayed above 50% for over two years. She's manually reading and categorizing every reply.&lt;/p&gt;

&lt;p&gt;You don't need sophisticated systems. A spreadsheet with three columns—content topic, engagement metric, reader language—reviewed every two weeks will surface patterns within 30 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dynamic Brief System
&lt;/h2&gt;

&lt;p&gt;Instead of writing a static prompt, write a &lt;strong&gt;dynamic brief&lt;/strong&gt;—a document updated with real data before each AI session.&lt;/p&gt;

&lt;p&gt;Structure it into four sections:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audience Reality&lt;/strong&gt; — who's actually engaging this week&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-Resonance Signals&lt;/strong&gt; — what specific content elements drove outsized response&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declining Topics&lt;/strong&gt; — what got low engagement two or more times&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Language&lt;/strong&gt; — verbatim phrases from reader replies&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every two weeks, spend 20-30 minutes updating this brief. Then paste it into Claude or ChatGPT before you create content. The AI is now writing for your actual audience, not the assumed one.&lt;/p&gt;

&lt;p&gt;One creator I worked with ran a B2B marketing newsletter. Month one, his AI output focused on strategy frameworks—his original brief emphasized "senior marketers who think big picture." But his reply data said otherwise. 73% of replies mentioned specific tools, budget constraints, or headcount limitations. These were managers doing individual contributor work.&lt;/p&gt;

&lt;p&gt;He updated his brief to reflect "tactical marketers with execution responsibility and limited resources." His click-through rate jumped from 3.2% to 7.8% in six weeks. Nothing else changed.&lt;/p&gt;

&lt;p&gt;The shift is treating the brief as a living document. This mental move separates creators with compounding engagement from creators who plateau.&lt;/p&gt;

&lt;h2&gt;
  
  
  How High Performers Actually Do This
&lt;/h2&gt;

&lt;p&gt;Justin Welsh built a 500K+ LinkedIn following largely through systematized content. Every Sunday, he reviews engagement data and categorizes posts by topic and format. Those categories inform his next week's mix before AI ever touches it.&lt;/p&gt;

&lt;p&gt;Result: his engagement sits consistently above 4.5%. Platform average is 0.5-1% for creator accounts.&lt;/p&gt;

&lt;p&gt;Codie Sanchez runs Contrarian Thinking with quarterly reader surveys: "What's your biggest frustration?" Those responses get fed directly into content briefs. Her AI drafts use the exact language readers used to describe their problems. Her paid offer conversion rates run 3-5x higher than industry benchmarks.&lt;/p&gt;

&lt;p&gt;The pattern is consistent: high performers treat analytics not as vanity metrics but as input mechanisms for the next cycle.&lt;/p&gt;

&lt;p&gt;Here's the counterintuitive part: &lt;strong&gt;the most valuable data isn't what people click on—it's what they respond to negatively.&lt;/strong&gt; Muted posts, unsubscribes, frustrated comments. This negative signal tells you more about the mismatch between your AI output and your audience than any positive engagement does. Most creators ignore it entirely.&lt;/p&gt;

&lt;p&gt;If 15 subscribers drop after a particular post, that's information. If you only feed success signals into your AI prompts, you train the AI toward false averages—content that doesn't repel anyone but also doesn't compel anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Tool Stack
&lt;/h2&gt;

&lt;p&gt;You need three things: an &lt;strong&gt;analytics source&lt;/strong&gt;, a &lt;strong&gt;synthesis layer&lt;/strong&gt;, and an &lt;strong&gt;AI interface&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics source:&lt;/strong&gt; Most creators have this already. Beehiiv shows scroll depth and link clicks. Substack shows view duration and reply counts. Twitter shows bookmarks (signal of "I'll use this" versus a like). Google Analytics shows return visits—content that's reference-worthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synthesis layer:&lt;/strong&gt; This is where the gap lives. Spend 20-30 minutes extracting the narrative from numbers. What did the data actually say about what your audience wanted this week?&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Notion or Airtable database&lt;/strong&gt; makes this faster. Each row is a piece of content with topic tags, engagement metrics, and notes from reader replies. Querying every two weeks takes ten minutes instead of an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI interface:&lt;/strong&gt; Claude handles long context windows cleanly—paste a 2,000-word dynamic brief plus examples of high-performing content and it maintains that context. ChatGPT with a custom GPT configured for your audience brief works well. Jasper's brand voice feature stores profiles but handles behavioral data less well.&lt;/p&gt;

&lt;p&gt;Here's one specific workflow: Every other Sunday, export your top 10 and bottom 5 performing pieces from the past two weeks. Paste them into Claude with this prompt: "What specific topics, formats, and language patterns are resonating versus falling flat? Three bullets each." Use that summary to update your brief. Create next week's content using the updated brief as context.&lt;/p&gt;

&lt;p&gt;Total time: about 45 minutes. Most creators waste more than that debating what to write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SparkToro&lt;/strong&gt; adds another layer—it shows you what your audience reads, follows, and engages with outside your platform. Quarterly reports give you updated context that your own analytics can't provide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start This Week
&lt;/h2&gt;

&lt;p&gt;Pull up analytics for your last 20 pieces of content. Sort by click-through rate or engagement.&lt;/p&gt;

&lt;p&gt;Look at the top five and bottom five. Skip everything else. Answer one question: what's the clearest difference between what the top five were about versus what the bottom five were about?&lt;/p&gt;

&lt;p&gt;Write that in one sentence. Add it as context before your next AI content session.&lt;/p&gt;

&lt;p&gt;You're not building a full system today. You're starting the habit of treating your analytics as an input, not an afterthought.&lt;/p&gt;

&lt;p&gt;The creators who scale engagement past month one aren't writing smarter prompts. They're running a tighter feedback loop between what their audience actually does and what they ask the AI to produce. That loop is available to every solo creator with a basic analytics dashboard.&lt;/p&gt;

&lt;p&gt;Most just haven't wired it up yet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for more practical AI and productivity content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aicontent</category>
      <category>contentcreator</category>
      <category>analytics</category>
      <category>audienceengagement</category>
    </item>
  </channel>
</rss>
