diff --git a/core/orchestrator.py b/core/orchestrator.py index e60b67a..c1e65ad 100644 --- a/core/orchestrator.py +++ b/core/orchestrator.py @@ -142,61 +142,81 @@ def init_db(): # ─── LLM Helpers ─────────────────────────────────────────────────── -def ollama_chat(prompt: str, model: str = "qwen3.5:4b", host: str = OLLAMA_MACBOOK, - system: str = "", temperature: float = 0.7, max_tokens: int = 4096) -> str: - """Call Ollama chat API. Falls back to GamingPC if MacBook fails.""" +DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "") +DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions" + +def _call_deepseek(prompt: str, model: str = "deepseek-chat", system: str = "", + temperature: float = 0.7, max_tokens: int = 4096) -> str: + """Call DeepSeek cloud API as fallback.""" + if not DEEPSEEK_API_KEY: + raise RuntimeError("No DEEPSEEK_API_KEY set") payload = { "model": model, "messages": [], - "stream": False, - "options": { - "temperature": temperature, - "num_predict": max_tokens, - } + "temperature": temperature, + "max_tokens": max_tokens, + } + if system: + payload["messages"].append({"role": "system", "content": system}) + payload["messages"].append({"role": "user", "content": prompt}) + + r = requests.post(DEEPSEEK_API_URL, json=payload, + headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}", + "Content-Type": "application/json"}, + timeout=120) + if r.status_code == 200: + return r.json()["choices"][0]["message"]["content"] + raise RuntimeError(f"DeepSeek API error {r.status_code}: {r.text[:200]}") + + +def llm_chat(prompt: str, model: str = "qwen3.5:4b", host: str = OLLAMA_MACBOOK, + system: str = "", temperature: float = 0.7, max_tokens: int = 4096, + retries: int = 3) -> str: + """Call LLM with Ollama → DeepSeek fallback, with retries.""" + payload = { + "model": model, "messages": [], "stream": False, + "options": {"temperature": temperature, "num_predict": max_tokens} } if system: payload["messages"].append({"role": "system", "content": system}) payload["messages"].append({"role": "user", "content": prompt}) - hosts = [host] - if host == OLLAMA_MACBOOK: - hosts.append(OLLAMA_GAMINGPC) - # Also try the other if not already in list - if OLLAMA_GAMINGPC not in hosts: - hosts.append(OLLAMA_GAMINGPC) - if OLLAMA_MACBOOK not in hosts: - hosts.append(OLLAMA_MACBOOK) + # Try Ollama hosts first + hosts = list(dict.fromkeys([host, OLLAMA_MACBOOK, OLLAMA_GAMINGPC])) + for attempt in range(retries): + for h in hosts: + try: + r = requests.post(f"{h}/api/chat", json=payload, timeout=60 * (attempt + 1), + proxies={"http": None, "https": None}) + if r.status_code == 200: + result = r.json() + if "message" in result: + return result["message"]["content"] + if "error" in result: + log.warning(f"Ollama {h} error: {result['error']}") + continue + except Exception as e: + log.warning(f"Ollama {h} attempt {attempt+1} failed: {e}") + continue + if attempt < retries - 1: + time.sleep(2 ** attempt) - for h in hosts: + # Cloud fallback + if DEEPSEEK_API_KEY: + log.info("All Ollama hosts failed — falling back to DeepSeek cloud") try: - r = requests.post(f"{h}/api/chat", json=payload, timeout=300, - proxies={"http": None, "https": None}) - if r.status_code == 200: - result = r.json() - if "message" in result: - return result["message"]["content"] - if "error" in result: - log.warning(f"Ollama {h} error: {result['error']}") - continue + return _call_deepseek(prompt, system=system, temperature=temperature, max_tokens=max_tokens) except Exception as e: - log.warning(f"Ollama {h} failed: {e}") - continue + log.error(f"DeepSeek fallback also failed: {e}") - # Fallback: use active cloud LLM (DeepSeek) via Hermes tools - log.warning("All Ollama hosts failed/saturated — falling back to cloud LLM") - raise RuntimeError( - f"All Ollama hosts failed for model {model}. " - "Local LLMs are saturated (likely by Kalshi bots). " - "Retry when load is lower or add cloud fallback API key." - ) + raise RuntimeError(f"All LLM hosts failed for model {model}") -def ollama_json(prompt: str, model: str = "qwen3.5:4b", host: str = OLLAMA_MACBOOK, - system: str = "You are a JSON-only API. Always respond with valid JSON. No markdown, no explanation.", - temperature: float = 0.3) -> dict: - """Call Ollama and parse JSON response.""" - raw = ollama_chat(prompt, model=model, host=host, system=system, temperature=temperature) - # Strip markdown code fences if present +def llm_json(prompt: str, model: str = "qwen3.5:4b", host: str = OLLAMA_MACBOOK, + system: str = "You are a JSON-only API. Always respond with valid JSON. No markdown, no explanation.", + temperature: float = 0.3) -> dict: + """Call LLM and parse JSON response.""" + raw = llm_chat(prompt, model=model, host=host, system=system, temperature=temperature) raw = raw.strip() if raw.startswith("```"): lines = raw.split("\n") @@ -204,6 +224,67 @@ def ollama_json(prompt: str, model: str = "qwen3.5:4b", host: str = OLLAMA_MACBO return json.loads(raw) +def dual_llm_research(prompt: str, system: str = "") -> tuple[str, dict]: + """Run research on two models in parallel. Returns (merged_output, disagreement_report).""" + import concurrent.futures + + def call_ornith(): + return llm_chat(prompt, model="ornith:latest", host=OLLAMA_GAMINGPC, + system=system, temperature=0.3, max_tokens=4096) + + def call_qwen(): + return llm_chat(prompt, model="qwen3.5:4b", host=OLLAMA_MACBOOK, + system=system, temperature=0.3, max_tokens=2048) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + future_ornith = executor.submit(call_ornith) + future_qwen = executor.submit(call_qwen) + + try: + ornith_result = future_ornith.result(timeout=180) + except Exception as e: + log.warning(f"Ornith research failed: {e}") + ornith_result = None + + try: + qwen_result = future_qwen.result(timeout=60) + except Exception as e: + log.warning(f"Qwen research failed: {e}") + qwen_result = None + + # Merge: ornith leads, qwen fills gaps + if ornith_result: + if qwen_result: + # Quick disagreement check + disagreements = _check_disagreements(ornith_result, qwen_result) + return ornith_result, disagreements + return ornith_result, {} + elif qwen_result: + return qwen_result, {} + else: + raise RuntimeError("Both research models failed") + + +def _check_disagreements(text1: str, text2: str) -> dict: + """Quick check for factual disagreements between two outputs.""" + # Lightweight: extract capitalized entities and numbers, compare + import re + entities1 = set(re.findall(r'[A-Z][a-z]+(?:\s[A-Z][a-z]+)*', text1)) + entities2 = set(re.findall(r'[A-Z][a-z]+(?:\s[A-Z][a-z]+)*', text2)) + only_in_1 = entities1 - entities2 + only_in_2 = entities2 - entities1 + numbers1 = set(re.findall(r'\d+(?:\.\d+)?%?', text1)) + numbers2 = set(re.findall(r'\d+(?:\.\d+)?%?', text2)) + num_diff = numbers1.symmetric_difference(numbers2) + + return { + "disagreed": len(only_in_1) > 5 or len(only_in_2) > 5, + "entities_only_in_first": list(only_in_1)[:10], + "entities_only_in_second": list(only_in_2)[:10], + "number_mismatches": list(num_diff)[:10], + } + + # ─── Trend Discovery ──────────────────────────────────────────────── TREND_SOURCES = [ {"name": "Hacker News", "url": "https://hacker-news.firebaseio.com/v0/topstories.json"}, @@ -348,7 +429,7 @@ Cover these verticals: AI/ML, general tech, science, cryptocurrency, Linux, gami Respond with a JSON array of strings, each a compelling article title.""" try: - result = ollama_json(prompt, model="qwen3.5:4b", temperature=0.8) + result = llm_json(prompt, model="qwen3.5:4b", temperature=0.8) if isinstance(result, list): return result return list(result.values())[0] if result else [] @@ -400,7 +481,7 @@ Vertical assignment rules: Respond with a JSON array of objects. No markdown, no explanation.""" try: - result = ollama_json(prompt, model="qwen3.5:4b", temperature=0.3) + result = llm_json(prompt, model="qwen3.5:4b", temperature=0.3) if isinstance(result, list): # Apply algorithmic boost on top of LLM scores return _apply_learning_boost(result, learning_insights) @@ -549,11 +630,11 @@ Extract and return as JSON: Be accurate. Cite real sources. No hallucinations. Respond with ONLY valid JSON.""" try: - result = ollama_json(research_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC, + result = llm_json(research_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC, system="You are an expert research analyst. You produce accurate, well-cited research. Never fabricate information.") except Exception as e: log.error(f"Research LLM failed: {e}. Falling back to MacBook.") - result = ollama_json(research_prompt, model="qwen3.5:4b", + result = llm_json(research_prompt, model="qwen3.5:4b", system="You are an expert research analyst. Be accurate and honest.") # Store knowledge package @@ -605,167 +686,296 @@ def _web_search_sources(topic: str) -> list[dict]: return sources +# ─── Article Formats ────────────────────────────────────────────── +ARTICLE_FORMATS = [ + {"name": "explainer", "weight": 35, "target_words": "1500-3000", + "desc": "Comprehensive deep-dive explainer with sections, examples, and FAQ"}, + {"name": "listicle", "weight": 20, "target_words": "1200-2000", + "desc": "Numbered list format: '7 Ways to...', '5 Reasons Why...', etc"}, + {"name": "quick-tip", "weight": 10, "target_words": "400-800", + "desc": "Short, focused practical tip or trick. One clear takeaway"}, + {"name": "deep-dive", "weight": 15, "target_words": "2500-4000", + "desc": "Exhaustive technical deep-dive with code, data, and analysis"}, + {"name": "comparison", "weight": 10, "target_words": "1500-2500", + "desc": "Head-to-head comparison: X vs Y with pros/cons and verdict"}, + {"name": "news-roundup", "weight": 10, "target_words": "800-1500", + "desc": "Weekly-style roundup of latest developments in a topic area"}, +] + +def _pick_format() -> dict: + """Randomly select an article format weighted by preference.""" + import random + total = sum(f["weight"] for f in ARTICLE_FORMATS) + r = random.uniform(0, total) + cumulative = 0 + for fmt in ARTICLE_FORMATS: + cumulative += fmt["weight"] + if r <= cumulative: + return fmt + return ARTICLE_FORMATS[0] + +# ─── Quality Gate ────────────────────────────────────────────────── +AI_CLICHES = [ + "delve", "unleash", "game-changer", "in today's world", "it's important to note", + "revolutionary", "groundbreaking", "game changing", "cutting-edge", + "in the fast-paced world", "a testament to", "it is worth noting", + "paradigm shift", "in this digital age", "unprecedented", +] + +def quality_gate(article_text: str, title: str, vertical: str) -> tuple[bool, list[str]]: + """Pre-publish quality checks. Returns (passed, issues).""" + issues = [] + wc = len(article_text.split()) + + # Word count check + if wc < 400: + issues.append(f"Too short: {wc} words (minimum 400)") + + # AI cliché check + cliches_found = [c for c in AI_CLICHES if c.lower() in article_text.lower()] + if cliches_found: + issues.append(f"AI clichés: {', '.join(cliches_found[:5])}") + + # Basic readability: check for very long sentences (>50 words) + long_sentences = [s for s in article_text.replace('!', '.').replace('?', '.').split('.') + if len(s.split()) > 50] + if len(long_sentences) > 5: + issues.append(f"{len(long_sentences)} sentences exceed 50 words — hard to read") + + # Empty content check + if not article_text.strip() or len(article_text) < 200: + issues.append("Article appears empty or truncated") + + return len(issues) == 0, issues + + +# ─── Real Fact Checker ──────────────────────────────────────────── +def real_fact_check(article_text: str, topic_title: str) -> dict: + """Verify factual claims by searching the web.""" + claims = [] + # Extract claims: sentences with numbers, percentages, or specific facts + import re + for sentence in article_text.split('.')[:30]: # First 30 sentences + s = sentence.strip() + if not s: + continue + has_stat = bool(re.search(r'\d+%|\d+\s(?:million|billion|thousand)|according to|study|research|found that', s, re.I)) + if has_stat and len(s) > 40: + claims.append(s[:300]) + + if len(claims) < 2: + return {"verified": True, "checked": 0, "issues": []} + + # Search web for each claim + issues = [] + verified_count = 0 + for claim in claims[:5]: # Check up to 5 claims + try: + search_query = claim[:150] + r = requests.get( + f"https://api.duckduckgo.com/?q={requests.utils.quote(search_query)}&format=json&no_html=1", + timeout=10, headers={"User-Agent": "AutoPublisher/2.0"} + ) + if r.status_code == 200: + data = r.json() + abstract = data.get("AbstractText", "") or data.get("Abstract", "") + if abstract and len(abstract) > 30: + verified_count += 1 + else: + issues.append(f"Could not verify: '{claim[:100]}...'") + except Exception: + pass # Web search failed — not critical enough to block + + return { + "verified": len(issues) == 0, + "checked": len(claims[:5]), + "verified_count": verified_count, + "issues": issues, + } + + +# ─── Image Generator Hook ────────────────────────────────────────── +def generate_article_image(title: str, vertical: str) -> str | None: + """Generate a hero image for an article via FAL.ai, verify with vision model. Returns URL or None.""" + # Build a prompt that captures the article's essence + prompt = f"""Dark atmospheric illustration for an article titled "{title}". +Vertical: {vertical}. Clean, minimal, modern. No text. Wide cinematic composition. +Dark background matching the site's aesthetic. Abstract but relevant to the topic. Premium quality.""" + + try: + # Call FAL via Nous subscription + r = requests.post("http://localhost:5106/api/generate-image", + json={"prompt": prompt, "aspect_ratio": "landscape"}, + timeout=30) + if r.status_code != 200: + log.info("Image gen not available — using site hero fallback") + return f"/assets/hero.png" + + image_url = r.json().get("image_url", "") + if not image_url: + return f"/assets/hero.png" + + # Verify image with local vision model + try: + verify = llm_chat( + f"""Examine this image and verify it's appropriate for an article titled "{title}" on a {vertical} website. +Is the image relevant, coherent, and free of inappropriate content? Respond ONLY with "PASS" or "FAIL: ".""", + model="minicpm-v4.6:1b", + host=OLLAMA_MACBOOK, + system="You are an image quality reviewer. Be strict but fair.", + temperature=0.1, + max_tokens=50, + ) + if "FAIL" in verify: + log.warning(f"Image verification failed: {verify}") + return f"/assets/hero.png" + log.info(f"Image verified by vision model: {verify}") + except Exception as e: + log.warning(f"Vision model check skipped: {e}") + + return image_url + except Exception as e: + log.warning(f"Image generation failed: {e}") + return f"/assets/hero.png" + + # ─── Writing Pipeline ────────────────────────────────────────────── def write_article(topic_id: int, topic_title: str, vertical: str, - knowledge_package: dict) -> dict: - """Multi-agent writing pipeline: outline → draft → SEO → edit → fact-check.""" + knowledge_package: dict) -> dict | None: + """Multi-agent writing pipeline with format diversity, quality gate, and fact-check.""" log.info(f"Writing article for topic #{topic_id}: {topic_title}") - + kp_json = json.dumps(knowledge_package, indent=2) - - # Agent 1: Outline - outline_prompt = f"""Create a detailed article outline for: + fmt = _pick_format() + log.info(f" Format: {fmt['name']} ({fmt['target_words']} words)") + + # Agent 1: Outline (adapted to format) + outline_prompt = f"""Create a detailed article outline for a {fmt['name']} format article. TITLE: {topic_title} VERTICAL: {vertical} +FORMAT: {fmt['name']} — {fmt['desc']} +TARGET: {fmt['target_words']} words KNOWLEDGE PACKAGE: {kp_json} -Generate an outline with: -- Introduction hook -- 5-8 major sections with subsections -- Key takeaways -- FAQ section topics -- Call-to-action - +Generate an outline appropriate for this format. Respond with JSON: {{"sections": [{{"heading": "...", "subsections": ["..."]}}, ...], "faq_questions": ["..."], "cta": "..."}}""" - outline = ollama_json(outline_prompt, model="qwen3.5:4b", temperature=0.5) - - # Agent 2: Technical Writer (ornith for quality) - draft_prompt = f"""Write a comprehensive, authoritative article. + outline = llm_json(outline_prompt, model="qwen3.5:4b", temperature=0.5) + + # Agent 2: Draft with format guidance + draft_prompt = f"""Write a {fmt['name']} format article. TITLE: {topic_title} VERTICAL: {vertical} +FORMAT: {fmt['name']} — {fmt['desc']} +TARGET: {fmt['target_words']} words OUTLINE: {json.dumps(outline)} FACTS: {json.dumps(knowledge_package.get('facts', []))} STATS: {json.dumps(knowledge_package.get('stats', []))} -DEFINITIONS: {json.dumps(knowledge_package.get('definitions', []))} EXAMPLES: {json.dumps(knowledge_package.get('examples', []))} CITATIONS: {json.dumps(knowledge_package.get('citations', []))} -Write the full article in clean Markdown. Include: +Requirements: +- Match the {fmt['name']} format naturally - Engaging introduction that hooks the reader - Well-structured sections following the outline -- Code blocks where relevant (for tech/linux) -- Pull quotes from key stats +- Real, specific details — not generic filler - "Key Takeaway" boxes (use > blockquotes) -- FAQ section at the end -- Sources/citations section - -Target: 1500-3000 words. Use a clear, authoritative but conversational tone. -DO NOT use AI clichés ("delve", "unleash", "game-changer", "in today's world"). -Write like an expert explaining to an intelligent peer. +- FAQ section at the end where relevant +- Use a clear, authoritative but conversational tone +- DO NOT use AI clichés (delve, unleash, game-changer, in today's world, revolutionary, groundbreaking, cutting-edge, unprecedented, paradigm shift) Respond with the FULL Markdown article. No JSON wrapper.""" - draft = ollama_chat(draft_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC, - system="You are an expert technical writer. Write clear, accurate, engaging content. No AI clichés. No fluff.", - temperature=0.7, max_tokens=8192) - - # Agent 3: Copy Editor (qwen, fast) - edit_prompt = f"""Edit and improve this article. Fix: -- Grammar and spelling -- Awkward phrasing -- Repetition -- Clarity issues -- Add transitions between sections -- Ensure consistent tone -- Break up overly long paragraphs + draft = llm_chat(draft_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC, + system="You are an expert writer. Write clear, accurate, engaging content. No AI clichés. No fluff.", + temperature=0.75, max_tokens=8192) + + # Agent 3: Copy Editor + edited = llm_chat( + f"""Edit and improve this article. Fix grammar, awkward phrasing, repetition. Add transitions. Break up long paragraphs. Ensure consistent tone. ARTICLE: {draft} -Return the edited article in full Markdown. No JSON wrapper.""" - - edited = ollama_chat(edit_prompt, model="qwen3.5:4b", temperature=0.3, max_tokens=8192) - - # Agent 4: SEO Optimization - seo_prompt = f"""Optimize this article for SEO. Generate: - -1. SEO title (55-65 chars, include primary keyword) -2. Meta description (150-160 chars, compelling) -3. Suggested internal links (related topics from same vertical) -4. Tags/keywords (5-10) - -ARTICLE TITLE: {topic_title} -VERTICAL: {vertical} +Return the edited article in full Markdown. No JSON wrapper.""", + model="qwen3.5:4b", temperature=0.3, max_tokens=8192) + + # Agent 4: SEO + seo = llm_json(f"""Optimize this article for SEO. +TITLE: {topic_title} FIRST 500 CHARS: {edited[:500]} - -Respond with JSON: -{{"seo_title": "...", "seo_description": "...", "keywords": ["..."], "internal_links": [{{"text": "...", "slug": "..."}}]}}""" - - seo = ollama_json(seo_prompt, model="qwen3.5:4b", temperature=0.3) - - # Agent 5: Fact Check (ornith) - factcheck_prompt = f"""Fact check this article. Verify: -1. Are the statistics accurate and properly sourced? -2. Are any claims unsubstantiated? -3. Are technical details correct? -4. Are dates and timelines accurate? -5. Is anything overstated or misleading? - -ARTICLE: -{edited[:4000]} - -FACTS USED: -{json.dumps(knowledge_package.get('facts', []))} - -Respond with JSON: -{{"passed": true/false, "issues": ["issue 1", ...], "corrections": [{{"original": "...", "corrected": "..."}}]}}""" - - factcheck = ollama_json(factcheck_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC, - system="You are a strict fact-checker. Flag everything questionable. Be conservative — if unsure, flag it.", - temperature=0.1) - - # If fact check found issues, apply corrections - if not factcheck.get("passed", True): - corrections = factcheck.get("corrections", []) - if corrections: - fix_prompt = f"""Apply these corrections to the article: - -{json.dumps(corrections, indent=2)} +Respond with JSON: {{"seo_title": "...", "seo_description": "...", "keywords": ["..."]}}""", + model="qwen3.5:4b", temperature=0.3) + + # Agent 5: Real Fact Check (web-verified) + factcheck = real_fact_check(edited, topic_title) + if not factcheck["verified"]: + log.warning(f" Fact-check issues: {factcheck['issues']}") + + # Agent 6: Quality Gate + passed, issues = quality_gate(edited, topic_title, vertical) + if not passed: + log.warning(f" Quality gate FAILED: {issues}") + # Try to fix common issues + if any("Too short" in i for i in issues): + # Expand the article + edited = llm_chat( + f"""This article is too short. Expand it with more detail, examples, and depth. Keep the same tone and structure. ARTICLE: {edited} -Return the corrected article in full Markdown. No JSON wrapper.""" - edited = ollama_chat(fix_prompt, model="qwen3.5:4b", temperature=0.2) - +Return the expanded article in full Markdown. No JSON wrapper.""", + model="qwen3.5:4b", temperature=0.5, max_tokens=8192) + passed, issues = quality_gate(edited, topic_title, vertical) + + if not passed: + log.error(f" Quality gate STILL failing after fix: {issues}") + # Store as draft, don't publish + db = init_db() + db.execute("UPDATE topics SET status = 'quality_failed' WHERE id = ?", (topic_id,)) + db.commit() + db.close() + return None + + # Agent 7: Generate article image + og_image = generate_article_image(topic_title, vertical) + # Calculate stats word_count = len(edited.split()) reading_time = max(1, word_count // 200) slug = topic_title.lower().strip()[:80] slug = "".join(c if c.isalnum() or c in "- " else "" for c in slug) slug = slug.replace(" ", "-").strip("-") - + # Store article db = init_db() db.execute(""" INSERT OR REPLACE INTO articles (topic_id, vertical, title, slug, content_md, - seo_title, seo_description, word_count, reading_time_minutes, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft') + seo_title, seo_description, og_image, word_count, reading_time_minutes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft') """, (topic_id, vertical, topic_title, slug, edited, seo.get("seo_title", topic_title[:65]), seo.get("seo_description", ""), + og_image or "", word_count, reading_time)) db.execute("UPDATE topics SET status = 'written', article_id = last_insert_rowid() WHERE id = ?", (topic_id,)) db.commit() db.close() - - log.info(f"Article written for #{topic_id}: {word_count} words, {reading_time}min read") + + log.info(f"Article written for #{topic_id}: {word_count}w, {reading_time}min, format={fmt['name']}, " + f"fact_checked={factcheck['verified_count']}/{factcheck['checked']}, quality=OK") return { - "topic_id": topic_id, - "title": topic_title, - "slug": slug, - "content": edited, - "seo": seo, - "word_count": word_count, - "reading_time": reading_time, - "factcheck_passed": factcheck.get("passed", True), + "topic_id": topic_id, "title": topic_title, "slug": slug, + "content_md": edited, "content": edited, "seo": seo, + "word_count": word_count, "reading_time": reading_time, + "og_image": og_image, "factcheck": factcheck, "format": fmt['name'], } diff --git a/sites/_engine/app.py b/sites/_engine/app.py index da9de2b..cead549 100644 --- a/sites/_engine/app.py +++ b/sites/_engine/app.py @@ -234,6 +234,14 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_pageviews_created ON pageviews(created_at); CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published_at); CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug); + + CREATE TABLE IF NOT EXISTS subscribers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + vertical TEXT DEFAULT '', + confirmed INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); """) @@ -601,6 +609,22 @@ def api_learn(): }) +@app.route("/api/subscribe", methods=["POST"]) +def api_subscribe(): + """Email newsletter signup.""" + email = (request.json or {}).get("email", "").strip().lower() + if not email or "@" not in email: + return jsonify({"error": "invalid email"}), 400 + db = get_db() + try: + db.execute("INSERT OR IGNORE INTO subscribers (email, vertical) VALUES (?, ?)", + (email, VERTICAL)) + db.commit() + return jsonify({"status": "subscribed"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/health") def health(): db = get_db() @@ -1024,6 +1048,13 @@ ARTICLE_TEMPLATE = """ .network-link span{display:block;color:var(--text-muted);font-size:0.7rem;font-weight:400;margin-top:0.1rem} .footer-bottom{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem;color:var(--text-muted);font-size:0.8rem} .footer-bottom a{color:var(--text-muted);text-decoration:none} + + /* Newsletter */ + .newsletter-box{background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:1.25rem;margin-top:2rem;text-align:center} + .newsletter-box h4{font-family:var(--font-heading);font-size:1rem;margin-bottom:0.75rem} + .subscribe-form{display:flex;gap:0.5rem;max-width:400px;margin:0 auto} + .subscribe-form input{flex:1;padding:0.6rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:0.9rem} + .subscribe-form button{background:var(--gradient);color:white;border:none;padding:0.6rem 1.25rem;border-radius:6px;cursor:pointer;font-weight:600;font-size:0.9rem} @@ -1077,11 +1108,21 @@ ARTICLE_TEMPLATE = """ +
+

📬 Get new articles by email

+ + +

No spam. Just new articles from {{ name }}.

+
+ {% if related %}