Compare commits

...

2 Commits

Author SHA1 Message Date
drjones
ec35333926 fix: bump llm_chat timeout 60s→600s for qwen3.8 long-form (was timing out on article writing) 2026-08-15 09:28:07 -07:00
drjones
8ce68fa779 fix: markdown→HTML rendering in engine + harden thinking fallback
- Engine now converts content_md to HTML at render time (was dumping raw markdown,
  causing articles to show literal #/**/- symbols and collapse into wall of text)
- /api/publish accepts 'content' key and converts markdown→HTML for API consumers
- Added md Jinja filter + md_to_html helper (markdown lib, extra+sane_lists)
- orchestrator: log warning when falling back to 'thinking' field (CoT, not prose)
- content_pipeline now generates formatted articles via LLM instead of raw scraped HTML
2026-08-14 20:09:34 -07:00
2 changed files with 54 additions and 22 deletions

View File

@@ -16,7 +16,8 @@ import requests
BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = BASE_DIR / "core" / "publisher.db"
OLLAMA_MACBOOK = "http://localhost:11434"
OLLAMA_GAMINGPC = "http://10.30.20.186:11434" # RTX 3070, ornith:latest
OLLAMA_GAMINGPC = "http://10.30.20.186:11434" # RTX 3070, ornith:latest (fallback)
OLLAMA_SHADOW = "http://10.30.20.128:11434" # RTX 4080 SUPER, qwen3.8:latest (primary)
# Load API keys from Hermes env if not already set
_hermes_env = Path.home() / ".hermes" / ".env"
@@ -176,7 +177,7 @@ def _call_deepseek(prompt: str, model: str = "deepseek-chat", system: str = "",
raise RuntimeError(f"DeepSeek API error {r.status_code}: {r.text[:200]}")
def llm_chat(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACBOOK,
def llm_chat(prompt: str, model: str = "qwen3.8:latest", host: str = OLLAMA_SHADOW,
system: str = "", temperature: float = 0.7, max_tokens: int = 4096,
retries: int = 3) -> str:
"""Call LLM with DeepSeek cloud → Ollama fallback, with retries."""
@@ -188,26 +189,32 @@ def llm_chat(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACB
log.warning(f"DeepSeek failed, trying local Ollama: {e}")
payload = {
"model": model, "messages": [], "stream": False,
"options": {"temperature": temperature, "num_predict": max_tokens}
"think": False,
"options": {"temperature": temperature, "num_predict": max_tokens, "num_ctx": 8192}
}
if system:
payload["messages"].append({"role": "system", "content": system})
payload["messages"].append({"role": "user", "content": prompt})
# Try Ollama hosts first
hosts = list(dict.fromkeys([host, OLLAMA_MACBOOK, OLLAMA_GAMINGPC]))
hosts = list(dict.fromkeys([host, OLLAMA_SHADOW, 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),
r = requests.post(f"{h}/api/chat", json=payload, timeout=600,
proxies={"http": None, "https": None})
if r.status_code == 200:
result = r.json()
if "message" in result:
content = result["message"].get("content", "")
# ornith puts output in 'thinking' when content is empty
# ornith puts output in 'thinking' when content is empty.
# WARNING: 'thinking' is chain-of-thought reasoning, NOT article text.
# Only fall back to it for JSON/short tasks, never long-form prose.
if not content:
content = result["message"].get("thinking", "")
if content:
log.warning(f"LLM {model} returned empty content — fell back to 'thinking' field ({len(content)} chars). "
f"Verify this is real output, not chain-of-thought.")
if content:
return content
if "error" in result:
@@ -230,7 +237,7 @@ def llm_chat(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACB
raise RuntimeError(f"All LLM hosts failed for model {model}")
def llm_json(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACBOOK,
def llm_json(prompt: str, model: str = "qwen3.8:latest", host: str = OLLAMA_SHADOW,
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."""
@@ -247,11 +254,11 @@ def dual_llm_research(prompt: str, system: str = "") -> tuple[str, dict]:
import concurrent.futures
def call_ornith():
return llm_chat(prompt, model="ornith:latest", host=OLLAMA_GAMINGPC,
return llm_chat(prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system=system, temperature=0.3, max_tokens=4096)
def call_qwen():
return llm_chat(prompt, model="qwen3.5:4b-mlx", host=OLLAMA_MACBOOK,
return llm_chat(prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system=system, temperature=0.3, max_tokens=2048)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
@@ -447,7 +454,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 = llm_json(prompt, model="minicpm-v4.5:8b", host=OLLAMA_GAMINGPC, temperature=0.8)
result = llm_json(prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.8)
if isinstance(result, list):
return result
return list(result.values())[0] if result else []
@@ -636,11 +643,11 @@ Extract and return as JSON:
Be accurate. Cite real sources. No hallucinations. Respond with ONLY valid JSON."""
try:
result = llm_json(research_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC,
result = llm_json(research_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
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 = llm_json(research_prompt, model="minicpm-v4.5:8b", host=OLLAMA_GAMINGPC,
result = llm_json(research_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system="You are an expert research analyst. Be accurate and honest.")
# Store knowledge package
@@ -826,8 +833,8 @@ Dark background matching the site's aesthetic. Abstract but relevant to the topi
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,
model="qwen3.8:latest",
host=OLLAMA_SHADOW,
system="You are an image quality reviewer. Be strict but fair.",
temperature=0.1,
max_tokens=50,
@@ -870,7 +877,7 @@ Generate an outline appropriate for this format.
Respond with JSON:
{{"sections": [{{"heading": "...", "subsections": ["..."]}}, ...], "faq_questions": ["..."], "cta": "..."}}"""
outline = llm_json(outline_prompt, model="minicpm-v4.5:8b", host=OLLAMA_GAMINGPC, temperature=0.5)
outline = llm_json(outline_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.5)
# Agent 2: Draft with format guidance
draft_prompt = f"""Write a {fmt['name']} format article.
@@ -897,7 +904,7 @@ Requirements:
Respond with the FULL Markdown article. No JSON wrapper."""
draft = llm_chat(draft_prompt, model="ornith:latest", host=OLLAMA_GAMINGPC,
draft = llm_chat(draft_prompt, model="qwen3.8:latest", host=OLLAMA_SHADOW,
system="You are an expert writer. Write clear, accurate, engaging content. No AI clichés. No fluff.",
temperature=0.75, max_tokens=8192)
@@ -909,14 +916,14 @@ ARTICLE:
{draft}
Return the edited article in full Markdown. No JSON wrapper.""",
model="minicpm-v4.5:8b", host=OLLAMA_GAMINGPC, temperature=0.3, max_tokens=8192)
model="qwen3.8:latest", host=OLLAMA_SHADOW, 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": ["..."]}}""",
model="minicpm-v4.5:8b", host=OLLAMA_GAMINGPC, temperature=0.3)
model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.3)
# Agent 5: Real Fact Check (web-verified)
factcheck = real_fact_check(edited, topic_title)
@@ -937,7 +944,7 @@ ARTICLE:
{edited}
Return the expanded article in full Markdown. No JSON wrapper.""",
model="minicpm-v4.5:8b", host=OLLAMA_GAMINGPC, temperature=0.5, max_tokens=8192)
model="qwen3.8:latest", host=OLLAMA_SHADOW, temperature=0.5, max_tokens=8192)
passed, issues = quality_gate(edited, topic_title, vertical)
if not passed:

View File

@@ -10,6 +10,24 @@ from pathlib import Path
from datetime import datetime
from flask import Flask, request, jsonify, render_template_string, g, abort, Response
try:
import markdown as _md
except ImportError:
_md = None
def md_to_html(text):
"""Convert Markdown to HTML for article rendering."""
if not text:
return ""
if _md is not None:
return _md.markdown(text, extensions=["extra", "sane_lists"])
# Minimal fallback (markdown lib not installed)
import re as _re
out = _re.sub(r"^#{1,6}\s+(.+)$", r"<h3>\1</h3>", text, flags=_re.M)
out = _re.sub(r"^\*\*(.+?)\*\*$", r"<strong>\1</strong>", out, flags=_re.M)
return "<p>" + out.replace("\n\n", "</p><p>").replace("\n", "<br>") + "</p>"
# ─── Config ────────────────────────────────────────────────────────
VERTICAL = os.environ.get("PUBLISHER_VERTICAL", "guides")
DOMAIN = f"{VERTICAL}.thetempleofdoom.com"
@@ -520,8 +538,10 @@ def api_publish():
slug = data.get("slug", "")
title = data.get("title", "")
content_html = data.get("content_html", data.get("content_md", ""))
content_md = data.get("content_md", "")
content_md = data.get("content_md") or data.get("content") or ""
content_html = data.get("content_html", "")
if not content_html and content_md:
content_html = md_to_html(content_md)
excerpt = data.get("excerpt", data.get("seo_description", ""))
seo_title = data.get("seo_title", title)
seo_description = data.get("seo_description", "")
@@ -1113,7 +1133,7 @@ ARTICLE_TEMPLATE = """<!DOCTYPE html>
</div>
<div class="article-content">
{{ article.content_html|safe }}
{{ (article.content_md or article.content_html)|md|safe }}
</div>
<footer class="article-footer">
@@ -1392,6 +1412,11 @@ def from_json_filter(s):
return []
@app.template_filter("md")
def md_filter(s):
return md_to_html(s or "")
# ─── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse