fix: bump llm_chat timeout 60s→600s for qwen3.8 long-form (was timing out on article writing)
This commit is contained in:
@@ -16,7 +16,8 @@ import requests
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
DB_PATH = BASE_DIR / "core" / "publisher.db"
|
DB_PATH = BASE_DIR / "core" / "publisher.db"
|
||||||
OLLAMA_MACBOOK = "http://localhost:11434"
|
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
|
# Load API keys from Hermes env if not already set
|
||||||
_hermes_env = Path.home() / ".hermes" / ".env"
|
_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]}")
|
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,
|
system: str = "", temperature: float = 0.7, max_tokens: int = 4096,
|
||||||
retries: int = 3) -> str:
|
retries: int = 3) -> str:
|
||||||
"""Call LLM with DeepSeek cloud → Ollama fallback, with retries."""
|
"""Call LLM with DeepSeek cloud → Ollama fallback, with retries."""
|
||||||
@@ -188,18 +189,19 @@ def llm_chat(prompt: str, model: str = "qwen3.5:4b-mlx", host: str = OLLAMA_MACB
|
|||||||
log.warning(f"DeepSeek failed, trying local Ollama: {e}")
|
log.warning(f"DeepSeek failed, trying local Ollama: {e}")
|
||||||
payload = {
|
payload = {
|
||||||
"model": model, "messages": [], "stream": False,
|
"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:
|
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})
|
||||||
|
|
||||||
# Try Ollama hosts first
|
# 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 attempt in range(retries):
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
try:
|
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})
|
proxies={"http": None, "https": None})
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
result = r.json()
|
result = r.json()
|
||||||
@@ -235,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}")
|
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.",
|
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 LLM and parse JSON response."""
|
"""Call LLM and parse JSON response."""
|
||||||
@@ -252,11 +254,11 @@ def dual_llm_research(prompt: str, system: str = "") -> tuple[str, dict]:
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
|
||||||
def call_ornith():
|
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)
|
system=system, temperature=0.3, max_tokens=4096)
|
||||||
|
|
||||||
def call_qwen():
|
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)
|
system=system, temperature=0.3, max_tokens=2048)
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
@@ -452,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."""
|
Respond with a JSON array of strings, each a compelling article title."""
|
||||||
|
|
||||||
try:
|
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):
|
if isinstance(result, list):
|
||||||
return result
|
return result
|
||||||
return list(result.values())[0] if result else []
|
return list(result.values())[0] if result else []
|
||||||
@@ -641,11 +643,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 = 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.")
|
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 = 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.")
|
system="You are an expert research analyst. Be accurate and honest.")
|
||||||
|
|
||||||
# Store knowledge package
|
# Store knowledge package
|
||||||
@@ -831,8 +833,8 @@ Dark background matching the site's aesthetic. Abstract but relevant to the topi
|
|||||||
verify = llm_chat(
|
verify = llm_chat(
|
||||||
f"""Examine this image and verify it's appropriate for an article titled "{title}" on a {vertical} website.
|
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>".""",
|
Is the image relevant, coherent, and free of inappropriate content? Respond ONLY with "PASS" or "FAIL: <reason>".""",
|
||||||
model="minicpm-v4.6:1b",
|
model="qwen3.8:latest",
|
||||||
host=OLLAMA_MACBOOK,
|
host=OLLAMA_SHADOW,
|
||||||
system="You are an image quality reviewer. Be strict but fair.",
|
system="You are an image quality reviewer. Be strict but fair.",
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=50,
|
max_tokens=50,
|
||||||
@@ -875,7 +877,7 @@ Generate an outline appropriate for this format.
|
|||||||
Respond with JSON:
|
Respond with JSON:
|
||||||
{{"sections": [{{"heading": "...", "subsections": ["..."]}}, ...], "faq_questions": ["..."], "cta": "..."}}"""
|
{{"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
|
# Agent 2: Draft with format guidance
|
||||||
draft_prompt = f"""Write a {fmt['name']} format article.
|
draft_prompt = f"""Write a {fmt['name']} format article.
|
||||||
@@ -902,7 +904,7 @@ Requirements:
|
|||||||
|
|
||||||
Respond with the FULL Markdown article. No JSON wrapper."""
|
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.",
|
system="You are an expert writer. Write clear, accurate, engaging content. No AI clichés. No fluff.",
|
||||||
temperature=0.75, max_tokens=8192)
|
temperature=0.75, max_tokens=8192)
|
||||||
|
|
||||||
@@ -914,14 +916,14 @@ ARTICLE:
|
|||||||
{draft}
|
{draft}
|
||||||
|
|
||||||
Return the edited article in full Markdown. No JSON wrapper.""",
|
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
|
# Agent 4: SEO
|
||||||
seo = llm_json(f"""Optimize this article for SEO.
|
seo = llm_json(f"""Optimize this article for SEO.
|
||||||
TITLE: {topic_title}
|
TITLE: {topic_title}
|
||||||
FIRST 500 CHARS: {edited[:500]}
|
FIRST 500 CHARS: {edited[:500]}
|
||||||
Respond with JSON: {{"seo_title": "...", "seo_description": "...", "keywords": ["..."]}}""",
|
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)
|
# Agent 5: Real Fact Check (web-verified)
|
||||||
factcheck = real_fact_check(edited, topic_title)
|
factcheck = real_fact_check(edited, topic_title)
|
||||||
@@ -942,7 +944,7 @@ ARTICLE:
|
|||||||
{edited}
|
{edited}
|
||||||
|
|
||||||
Return the expanded article in full Markdown. No JSON wrapper.""",
|
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)
|
passed, issues = quality_gate(edited, topic_title, vertical)
|
||||||
|
|
||||||
if not passed:
|
if not passed:
|
||||||
|
|||||||
Reference in New Issue
Block a user