v3: Self-learning loop — nightly /api/learn, keyword-based scoring boost, performance feedback into topic selection

This commit is contained in:
drjones
2026-08-03 22:15:34 -07:00
parent 33f76cdc3b
commit 066cf076aa
2 changed files with 154 additions and 4 deletions

View File

@@ -358,14 +358,21 @@ Respond with a JSON array of strings, each a compelling article title."""
def _score_and_assign(raw_topics: list[str]) -> list[dict]:
"""Score topics and assign to verticals using LLM."""
"""Score topics and assign to verticals using LLM, boosted by learning data."""
if not raw_topics:
return []
# Phase 0: Get learning insights from live sites
learning_insights = _get_learning_insights()
# Deduplicate first
unique = list(dict.fromkeys(raw_topics))[:50]
prompt = f"""You are a content strategist. Score and categorize these {len(unique)} topics.
insights_text = ""
if learning_insights:
insights_text = f"\n\nLEARNING DATA — content that performs well on our sites:\n{json.dumps(learning_insights, indent=2)}\n\nUse this to boost composite_score for topics similar to what our audience already reads. Topics matching high-performing patterns get +10 to composite_score."
prompt = f"""You are a content strategist. Score and categorize these {len(unique)} topics.{insights_text}
Topics:
{json.dumps(unique)}
@@ -378,7 +385,7 @@ For each topic, return:
- "competition_score": 0-100 (how many competing articles exist)
- "freshness_score": 0-100 (how new/urgent)
- "evergreen_score": 0-100 (will this be relevant in 5 years)
- "composite_score": overall value score 0-100 (higher = publish now)
- "composite_score": overall value score 0-100 (higher = publish now) — apply learning boosts here
Vertical assignment rules:
- AI/ML topics → ai
@@ -395,13 +402,100 @@ Respond with a JSON array of objects. No markdown, no explanation."""
try:
result = ollama_json(prompt, model="qwen3.5:4b", temperature=0.3)
if isinstance(result, list):
return result
# Apply algorithmic boost on top of LLM scores
return _apply_learning_boost(result, learning_insights)
return []
except Exception as e:
log.warning(f"Topic scoring failed: {e}")
return []
def _get_learning_insights() -> dict:
"""Query all 8 live sites for their top-performing content patterns."""
insights = {}
for vertical, vinfo in VERTICALS.items():
ct_ip = vinfo.get("ip")
if not ct_ip:
continue
try:
r = requests.get(f"http://{ct_ip}:5000/api/stats", timeout=5)
if r.status_code == 200:
data = r.json()
popular = data.get("popular", [])
if popular:
# Extract keyword patterns from popular articles
all_keywords = []
for art in popular:
kw_str = art.get("keywords", "[]")
try:
kws = json.loads(kw_str) if isinstance(kw_str, str) else kw_str
all_keywords.extend(kws)
except (json.JSONDecodeError, TypeError):
pass
# Most frequent keywords = winning topics
from collections import Counter
kw_counts = Counter(all_keywords)
insights[vertical] = {
"top_keywords": [kw for kw, _ in kw_counts.most_common(8)],
"top_articles": [a.get("title", "")[:80] for a in popular[:3]],
"avg_word_count": sum(a.get("word_count", 0) for a in popular) // max(len(popular), 1),
"total_articles": data.get("total_articles", 0),
}
except Exception:
pass
# Also check orchestrator's own performance_learning DB
try:
db = sqlite3.connect(str(DB_PATH))
db.row_factory = sqlite3.Row
for vertical in VERTICALS:
row = db.execute(
"SELECT * FROM performance_learning WHERE vertical=? ORDER BY updated_at DESC LIMIT 1",
(vertical,)
).fetchone()
if row:
if vertical not in insights:
insights[vertical] = {}
try:
insights[vertical]["stored_patterns"] = json.loads(row["top_patterns"])
except (json.JSONDecodeError, TypeError):
pass
db.close()
except Exception:
pass
return insights if insights else {}
def _apply_learning_boost(scored: list[dict], insights: dict) -> list[dict]:
"""Boost composite scores for topics matching winning patterns."""
if not insights:
return scored
for topic in scored:
vertical = topic.get("vertical", "")
title = topic.get("title", "").lower()
vin = insights.get(vertical, {})
top_kws = vin.get("top_keywords", [])
# Count keyword matches between topic title and winning keywords
matches = sum(1 for kw in top_kws if kw.lower() in title)
boost = min(matches * 8, 25) # Up to 25-point boost
# Boost for matching the optimal word count range (signals topic depth fits)
if vin.get("avg_word_count", 0) > 0:
boost += 3 # Minor boost for having any data
if boost > 0:
old_score = topic.get("composite_score", 50)
topic["composite_score"] = min(old_score + boost, 100)
topic["learning_boost"] = boost
return scored
def _deduplicate_and_rank(scored: list[dict]) -> list[dict]:
"""Remove near-duplicates and rank by composite score."""
seen_titles = set()