v6: Cloud fallback, quality gate, format diversity, real fact-check, scroll depth, email capture, dual-model research, vision-verified images, network footer
This commit is contained in:
@@ -142,61 +142,81 @@ def init_db():
|
|||||||
|
|
||||||
|
|
||||||
# ─── LLM Helpers ───────────────────────────────────────────────────
|
# ─── LLM Helpers ───────────────────────────────────────────────────
|
||||||
def ollama_chat(prompt: str, model: str = "qwen3.5:4b", host: str = OLLAMA_MACBOOK,
|
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||||
system: str = "", temperature: float = 0.7, max_tokens: int = 4096) -> str:
|
DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"
|
||||||
"""Call Ollama chat API. Falls back to GamingPC if MacBook fails."""
|
|
||||||
|
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 = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [],
|
"messages": [],
|
||||||
"stream": False,
|
"temperature": temperature,
|
||||||
"options": {
|
"max_tokens": max_tokens,
|
||||||
"temperature": temperature,
|
}
|
||||||
"num_predict": 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:
|
if system:
|
||||||
payload["messages"].append({"role": "system", "content": system})
|
payload["messages"].append({"role": "system", "content": system})
|
||||||
payload["messages"].append({"role": "user", "content": prompt})
|
payload["messages"].append({"role": "user", "content": prompt})
|
||||||
|
|
||||||
hosts = [host]
|
# Try Ollama hosts first
|
||||||
if host == OLLAMA_MACBOOK:
|
hosts = list(dict.fromkeys([host, OLLAMA_MACBOOK, OLLAMA_GAMINGPC]))
|
||||||
hosts.append(OLLAMA_GAMINGPC)
|
for attempt in range(retries):
|
||||||
# Also try the other if not already in list
|
for h in hosts:
|
||||||
if OLLAMA_GAMINGPC not in hosts:
|
try:
|
||||||
hosts.append(OLLAMA_GAMINGPC)
|
r = requests.post(f"{h}/api/chat", json=payload, timeout=60 * (attempt + 1),
|
||||||
if OLLAMA_MACBOOK not in hosts:
|
proxies={"http": None, "https": None})
|
||||||
hosts.append(OLLAMA_MACBOOK)
|
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:
|
try:
|
||||||
r = requests.post(f"{h}/api/chat", json=payload, timeout=300,
|
return _call_deepseek(prompt, system=system, temperature=temperature, max_tokens=max_tokens)
|
||||||
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:
|
except Exception as e:
|
||||||
log.warning(f"Ollama {h} failed: {e}")
|
log.error(f"DeepSeek fallback also failed: {e}")
|
||||||
continue
|
|
||||||
|
|
||||||
# Fallback: use active cloud LLM (DeepSeek) via Hermes tools
|
raise RuntimeError(f"All LLM hosts failed for model {model}")
|
||||||
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."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ollama_json(prompt: str, model: str = "qwen3.5:4b", host: str = OLLAMA_MACBOOK,
|
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.",
|
system: str = "You are a JSON-only API. Always respond with valid JSON. No markdown, no explanation.",
|
||||||
temperature: float = 0.3) -> dict:
|
temperature: float = 0.3) -> dict:
|
||||||
"""Call Ollama and parse JSON response."""
|
"""Call LLM and parse JSON response."""
|
||||||
raw = ollama_chat(prompt, model=model, host=host, system=system, temperature=temperature)
|
raw = llm_chat(prompt, model=model, host=host, system=system, temperature=temperature)
|
||||||
# Strip markdown code fences if present
|
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
if raw.startswith("```"):
|
if raw.startswith("```"):
|
||||||
lines = raw.split("\n")
|
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)
|
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 Discovery ────────────────────────────────────────────────
|
||||||
TREND_SOURCES = [
|
TREND_SOURCES = [
|
||||||
{"name": "Hacker News", "url": "https://hacker-news.firebaseio.com/v0/topstories.json"},
|
{"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."""
|
Respond with a JSON array of strings, each a compelling article title."""
|
||||||
|
|
||||||
try:
|
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):
|
if isinstance(result, list):
|
||||||
return result
|
return result
|
||||||
return list(result.values())[0] if result else []
|
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."""
|
Respond with a JSON array of objects. No markdown, no explanation."""
|
||||||
|
|
||||||
try:
|
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):
|
if isinstance(result, list):
|
||||||
# Apply algorithmic boost on top of LLM scores
|
# Apply algorithmic boost on top of LLM scores
|
||||||
return _apply_learning_boost(result, learning_insights)
|
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."""
|
Be accurate. Cite real sources. No hallucinations. Respond with ONLY valid JSON."""
|
||||||
|
|
||||||
try:
|
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.")
|
system="You are an expert research analyst. You produce accurate, well-cited research. Never fabricate information.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Research LLM failed: {e}. Falling back to MacBook.")
|
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.")
|
system="You are an expert research analyst. Be accurate and honest.")
|
||||||
|
|
||||||
# Store knowledge package
|
# Store knowledge package
|
||||||
@@ -605,167 +686,296 @@ def _web_search_sources(topic: str) -> list[dict]:
|
|||||||
return sources
|
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: <reason>".""",
|
||||||
|
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 ──────────────────────────────────────────────
|
# ─── Writing Pipeline ──────────────────────────────────────────────
|
||||||
def write_article(topic_id: int, topic_title: str, vertical: str,
|
def write_article(topic_id: int, topic_title: str, vertical: str,
|
||||||
knowledge_package: dict) -> dict:
|
knowledge_package: dict) -> dict | None:
|
||||||
"""Multi-agent writing pipeline: outline → draft → SEO → edit → fact-check."""
|
"""Multi-agent writing pipeline with format diversity, quality gate, and fact-check."""
|
||||||
log.info(f"Writing article for topic #{topic_id}: {topic_title}")
|
log.info(f"Writing article for topic #{topic_id}: {topic_title}")
|
||||||
|
|
||||||
kp_json = json.dumps(knowledge_package, indent=2)
|
kp_json = json.dumps(knowledge_package, indent=2)
|
||||||
|
fmt = _pick_format()
|
||||||
# Agent 1: Outline
|
log.info(f" Format: {fmt['name']} ({fmt['target_words']} words)")
|
||||||
outline_prompt = f"""Create a detailed article outline for:
|
|
||||||
|
# Agent 1: Outline (adapted to format)
|
||||||
|
outline_prompt = f"""Create a detailed article outline for a {fmt['name']} format article.
|
||||||
|
|
||||||
TITLE: {topic_title}
|
TITLE: {topic_title}
|
||||||
VERTICAL: {vertical}
|
VERTICAL: {vertical}
|
||||||
|
FORMAT: {fmt['name']} — {fmt['desc']}
|
||||||
|
TARGET: {fmt['target_words']} words
|
||||||
|
|
||||||
KNOWLEDGE PACKAGE:
|
KNOWLEDGE PACKAGE:
|
||||||
{kp_json}
|
{kp_json}
|
||||||
|
|
||||||
Generate an outline with:
|
Generate an outline appropriate for this format.
|
||||||
- Introduction hook
|
|
||||||
- 5-8 major sections with subsections
|
|
||||||
- Key takeaways
|
|
||||||
- FAQ section topics
|
|
||||||
- Call-to-action
|
|
||||||
|
|
||||||
Respond with JSON:
|
Respond with JSON:
|
||||||
{{"sections": [{{"heading": "...", "subsections": ["..."]}}, ...], "faq_questions": ["..."], "cta": "..."}}"""
|
{{"sections": [{{"heading": "...", "subsections": ["..."]}}, ...], "faq_questions": ["..."], "cta": "..."}}"""
|
||||||
|
|
||||||
outline = ollama_json(outline_prompt, model="qwen3.5:4b", temperature=0.5)
|
outline = llm_json(outline_prompt, model="qwen3.5:4b", temperature=0.5)
|
||||||
|
|
||||||
# Agent 2: Technical Writer (ornith for quality)
|
# Agent 2: Draft with format guidance
|
||||||
draft_prompt = f"""Write a comprehensive, authoritative article.
|
draft_prompt = f"""Write a {fmt['name']} format article.
|
||||||
|
|
||||||
TITLE: {topic_title}
|
TITLE: {topic_title}
|
||||||
VERTICAL: {vertical}
|
VERTICAL: {vertical}
|
||||||
|
FORMAT: {fmt['name']} — {fmt['desc']}
|
||||||
|
TARGET: {fmt['target_words']} words
|
||||||
OUTLINE: {json.dumps(outline)}
|
OUTLINE: {json.dumps(outline)}
|
||||||
FACTS: {json.dumps(knowledge_package.get('facts', []))}
|
FACTS: {json.dumps(knowledge_package.get('facts', []))}
|
||||||
STATS: {json.dumps(knowledge_package.get('stats', []))}
|
STATS: {json.dumps(knowledge_package.get('stats', []))}
|
||||||
DEFINITIONS: {json.dumps(knowledge_package.get('definitions', []))}
|
|
||||||
EXAMPLES: {json.dumps(knowledge_package.get('examples', []))}
|
EXAMPLES: {json.dumps(knowledge_package.get('examples', []))}
|
||||||
CITATIONS: {json.dumps(knowledge_package.get('citations', []))}
|
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
|
- Engaging introduction that hooks the reader
|
||||||
- Well-structured sections following the outline
|
- Well-structured sections following the outline
|
||||||
- Code blocks where relevant (for tech/linux)
|
- Real, specific details — not generic filler
|
||||||
- Pull quotes from key stats
|
|
||||||
- "Key Takeaway" boxes (use > blockquotes)
|
- "Key Takeaway" boxes (use > blockquotes)
|
||||||
- FAQ section at the end
|
- FAQ section at the end where relevant
|
||||||
- Sources/citations section
|
- 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)
|
||||||
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.
|
|
||||||
|
|
||||||
Respond with the FULL Markdown article. No JSON wrapper."""
|
Respond with the FULL Markdown article. No JSON wrapper."""
|
||||||
|
|
||||||
draft = ollama_chat(draft_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC,
|
draft = llm_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.",
|
system="You are an expert writer. Write clear, accurate, engaging content. No AI clichés. No fluff.",
|
||||||
temperature=0.7, max_tokens=8192)
|
temperature=0.75, max_tokens=8192)
|
||||||
|
|
||||||
# Agent 3: Copy Editor (qwen, fast)
|
# Agent 3: Copy Editor
|
||||||
edit_prompt = f"""Edit and improve this article. Fix:
|
edited = llm_chat(
|
||||||
- Grammar and spelling
|
f"""Edit and improve this article. Fix grammar, awkward phrasing, repetition. Add transitions. Break up long paragraphs. Ensure consistent tone.
|
||||||
- Awkward phrasing
|
|
||||||
- Repetition
|
|
||||||
- Clarity issues
|
|
||||||
- Add transitions between sections
|
|
||||||
- Ensure consistent tone
|
|
||||||
- Break up overly long paragraphs
|
|
||||||
|
|
||||||
ARTICLE:
|
ARTICLE:
|
||||||
{draft}
|
{draft}
|
||||||
|
|
||||||
Return the edited article in full Markdown. No JSON wrapper."""
|
Return the edited article in full Markdown. No JSON wrapper.""",
|
||||||
|
model="qwen3.5:4b", temperature=0.3, max_tokens=8192)
|
||||||
edited = ollama_chat(edit_prompt, model="qwen3.5:4b", temperature=0.3, max_tokens=8192)
|
|
||||||
|
# Agent 4: SEO
|
||||||
# Agent 4: SEO Optimization
|
seo = llm_json(f"""Optimize this article for SEO.
|
||||||
seo_prompt = f"""Optimize this article for SEO. Generate:
|
TITLE: {topic_title}
|
||||||
|
|
||||||
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}
|
|
||||||
FIRST 500 CHARS: {edited[:500]}
|
FIRST 500 CHARS: {edited[:500]}
|
||||||
|
Respond with JSON: {{"seo_title": "...", "seo_description": "...", "keywords": ["..."]}}""",
|
||||||
Respond with JSON:
|
model="qwen3.5:4b", temperature=0.3)
|
||||||
{{"seo_title": "...", "seo_description": "...", "keywords": ["..."], "internal_links": [{{"text": "...", "slug": "..."}}]}}"""
|
|
||||||
|
# Agent 5: Real Fact Check (web-verified)
|
||||||
seo = ollama_json(seo_prompt, model="qwen3.5:4b", temperature=0.3)
|
factcheck = real_fact_check(edited, topic_title)
|
||||||
|
if not factcheck["verified"]:
|
||||||
# Agent 5: Fact Check (ornith)
|
log.warning(f" Fact-check issues: {factcheck['issues']}")
|
||||||
factcheck_prompt = f"""Fact check this article. Verify:
|
|
||||||
1. Are the statistics accurate and properly sourced?
|
# Agent 6: Quality Gate
|
||||||
2. Are any claims unsubstantiated?
|
passed, issues = quality_gate(edited, topic_title, vertical)
|
||||||
3. Are technical details correct?
|
if not passed:
|
||||||
4. Are dates and timelines accurate?
|
log.warning(f" Quality gate FAILED: {issues}")
|
||||||
5. Is anything overstated or misleading?
|
# Try to fix common issues
|
||||||
|
if any("Too short" in i for i in issues):
|
||||||
ARTICLE:
|
# Expand the article
|
||||||
{edited[:4000]}
|
edited = llm_chat(
|
||||||
|
f"""This article is too short. Expand it with more detail, examples, and depth. Keep the same tone and structure.
|
||||||
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)}
|
|
||||||
|
|
||||||
ARTICLE:
|
ARTICLE:
|
||||||
{edited}
|
{edited}
|
||||||
|
|
||||||
Return the corrected article in full Markdown. No JSON wrapper."""
|
Return the expanded article in full Markdown. No JSON wrapper.""",
|
||||||
edited = ollama_chat(fix_prompt, model="qwen3.5:4b", temperature=0.2)
|
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
|
# Calculate stats
|
||||||
word_count = len(edited.split())
|
word_count = len(edited.split())
|
||||||
reading_time = max(1, word_count // 200)
|
reading_time = max(1, word_count // 200)
|
||||||
slug = topic_title.lower().strip()[:80]
|
slug = topic_title.lower().strip()[:80]
|
||||||
slug = "".join(c if c.isalnum() or c in "- " else "" for c in slug)
|
slug = "".join(c if c.isalnum() or c in "- " else "" for c in slug)
|
||||||
slug = slug.replace(" ", "-").strip("-")
|
slug = slug.replace(" ", "-").strip("-")
|
||||||
|
|
||||||
# Store article
|
# Store article
|
||||||
db = init_db()
|
db = init_db()
|
||||||
db.execute("""
|
db.execute("""
|
||||||
INSERT OR REPLACE INTO articles (topic_id, vertical, title, slug, content_md,
|
INSERT OR REPLACE INTO articles (topic_id, vertical, title, slug, content_md,
|
||||||
seo_title, seo_description, word_count, reading_time_minutes, status)
|
seo_title, seo_description, og_image, word_count, reading_time_minutes, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
|
||||||
""", (topic_id, vertical, topic_title, slug, edited,
|
""", (topic_id, vertical, topic_title, slug, edited,
|
||||||
seo.get("seo_title", topic_title[:65]),
|
seo.get("seo_title", topic_title[:65]),
|
||||||
seo.get("seo_description", ""),
|
seo.get("seo_description", ""),
|
||||||
|
og_image or "",
|
||||||
word_count, reading_time))
|
word_count, reading_time))
|
||||||
db.execute("UPDATE topics SET status = 'written', article_id = last_insert_rowid() WHERE id = ?",
|
db.execute("UPDATE topics SET status = 'written', article_id = last_insert_rowid() WHERE id = ?",
|
||||||
(topic_id,))
|
(topic_id,))
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
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 {
|
return {
|
||||||
"topic_id": topic_id,
|
"topic_id": topic_id, "title": topic_title, "slug": slug,
|
||||||
"title": topic_title,
|
"content_md": edited, "content": edited, "seo": seo,
|
||||||
"slug": slug,
|
"word_count": word_count, "reading_time": reading_time,
|
||||||
"content": edited,
|
"og_image": og_image, "factcheck": factcheck, "format": fmt['name'],
|
||||||
"seo": seo,
|
|
||||||
"word_count": word_count,
|
|
||||||
"reading_time": reading_time,
|
|
||||||
"factcheck_passed": factcheck.get("passed", True),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_published ON articles(published_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
|
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")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
db = get_db()
|
db = get_db()
|
||||||
@@ -1024,6 +1048,13 @@ ARTICLE_TEMPLATE = """<!DOCTYPE html>
|
|||||||
.network-link span{display:block;color:var(--text-muted);font-size:0.7rem;font-weight:400;margin-top:0.1rem}
|
.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{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}
|
.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}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1077,11 +1108,21 @@ ARTICLE_TEMPLATE = """<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="newsletter-box">
|
||||||
|
<h4>📬 Get new articles by email</h4>
|
||||||
|
<form class="subscribe-form" onsubmit="subscribe(event)">
|
||||||
|
<input type="email" id="sub-email" placeholder="your@email.com" required>
|
||||||
|
<button type="submit">Subscribe</button>
|
||||||
|
</form>
|
||||||
|
<div id="sub-msg" style="margin-top:0.5rem;font-size:0.8rem;display:none"></div>
|
||||||
|
<p style="font-size:0.7rem;color:var(--text-muted);margin-top:0.5rem">No spam. Just new articles from {{ name }}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if related %}
|
{% if related %}
|
||||||
<section class="related">
|
<section class="related">
|
||||||
<h2>Continue Reading</h2>
|
<h2>Continue Reading</h2>
|
||||||
<div class="related-grid">
|
<div class="related-grid">
|
||||||
{% for r in related %}
|
{% for r in related[:3] %}
|
||||||
<a href="/articles/{{ r.slug }}" class="related-card">
|
<a href="/articles/{{ r.slug }}" class="related-card">
|
||||||
<h4>{{ r.title[:60] }}</h4>
|
<h4>{{ r.title[:60] }}</h4>
|
||||||
<div class="meta">{{ r.reading_time }} min · {{ r.published_at[:10] }}</div>
|
<div class="meta">{{ r.reading_time }} min · {{ r.published_at[:10] }}</div>
|
||||||
@@ -1139,6 +1180,36 @@ ARTICLE_TEMPLATE = """<!DOCTYPE html>
|
|||||||
toc.appendChild(li);
|
toc.appendChild(li);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Scroll depth tracking
|
||||||
|
(function(){
|
||||||
|
var fired = {25:false,50:false,75:false,100:false};
|
||||||
|
window.addEventListener('scroll', function(){
|
||||||
|
var pct = Math.round((window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100);
|
||||||
|
[25,50,75,100].forEach(function(threshold){
|
||||||
|
if(pct >= threshold && !fired[threshold]){
|
||||||
|
fired[threshold] = true;
|
||||||
|
new Image().src = '/a/ping?p=/articles/{{ article.slug }}&d=' + threshold;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Time on page ping every 30s
|
||||||
|
setInterval(function(){
|
||||||
|
new Image().src = '/a/ping?p=/articles/{{ article.slug }}&t=1';
|
||||||
|
}, 30000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function subscribe(e){
|
||||||
|
e.preventDefault();
|
||||||
|
var email = document.getElementById('sub-email').value;
|
||||||
|
var msg = document.getElementById('sub-msg');
|
||||||
|
fetch('/api/subscribe', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email})})
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
msg.style.display = 'block';
|
||||||
|
msg.textContent = d.status === 'subscribed' ? '✅ Subscribed! Welcome.' : '❌ ' + (d.error || 'Error');
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<img src="/a/ping?p=/articles/{{ article.slug }}" alt="" width="1" height="1" style="display:none">
|
<img src="/a/ping?p=/articles/{{ article.slug }}" alt="" width="1" height="1" style="display:none">
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user