feat: article_pipeline.py — Stage 0 Prompt-Builder für BSN-Artikel-Workflow
This commit is contained in:
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
article_pipeline.py — Stage 0: Input-Preparation für den BSN-Artikel-Workflow.
|
||||||
|
|
||||||
|
Liest Topic, PDFs, URLs und baut einen strukturierten Prompt für den Hermes-Agenten,
|
||||||
|
der dann via delegate_task Research (Stage 1) und Write+QC (Stage 2+3) durchführt.
|
||||||
|
Stage 4 (Git, Verify) macht der Agent selbst.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Thema + PDF
|
||||||
|
python3 article_pipeline.py --topic "Gamegenic × TCC" --pdf pm.pdf --rubric "Nachrichten"
|
||||||
|
|
||||||
|
# Thema + URL
|
||||||
|
python3 article_pipeline.py --topic "Neue Kennerspiel-Nominierungen" --url "https://..."
|
||||||
|
|
||||||
|
# Nur Thema (autonome Recherche)
|
||||||
|
python3 article_pipeline.py --topic "Spiel des Jahres 2026" --rubric "Nachrichten"
|
||||||
|
|
||||||
|
# Batch (JSON)
|
||||||
|
python3 article_pipeline.py --batch topics.json
|
||||||
|
|
||||||
|
Output: Strukturierter Prompt nach stdout (wird vom Hermes-Cronjob als Context injiziert)
|
||||||
|
Exit-Codes: 0 = Prompt bereit, 1 = Fehler in Stage 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
WORKSPACE = "/home/hermes/workspace"
|
||||||
|
DOC_CACHE = os.path.expanduser("~/.hermes/cache/documents")
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_pdf(pdf_path: str) -> str:
|
||||||
|
"""PDF mit pymupdf OCRen und Text extrahieren."""
|
||||||
|
import pymupdf
|
||||||
|
doc = pymupdf.open(pdf_path)
|
||||||
|
texts = []
|
||||||
|
for page in doc:
|
||||||
|
texts.append(page.get_text())
|
||||||
|
doc.close()
|
||||||
|
return "\n\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_url_text(url: str) -> str:
|
||||||
|
"""URL-Inhalt via 5-Stufen-Eskalation abrufen."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
# Stufe 1: curl direkt
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return resp.read().decode("utf-8", errors="ignore")[:5000]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stufe 2-5 wäre hier — für Stage 0 reicht Stufe 1
|
||||||
|
return f"[URL nicht direkt abrufbar: {url}]"
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(topic: str, sources: list[str], rubric: str,
|
||||||
|
category_id: int, image_urls: list[str],
|
||||||
|
deadline: str | None,
|
||||||
|
pdf_texts: dict[str, str]) -> str:
|
||||||
|
"""Direkten Agent-Prompt für den Artikel-Workflow bauen."""
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
date_str = now.strftime("%d. %B %Y")
|
||||||
|
slug = topic.lower().replace(" ", "_").replace(":", "").replace("×", "x")[:40]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append(f"Führe den BSN-Artikel-Workflow für folgendes Thema aus:")
|
||||||
|
lines.append(f"**Thema:** {topic}")
|
||||||
|
lines.append(f"**Datum:** {date_str}")
|
||||||
|
lines.append(f"**Rubrik:** {rubric} (Joomla catid={category_id})")
|
||||||
|
if deadline:
|
||||||
|
lines.append(f"**Deadline:** {deadline}")
|
||||||
|
lines.append(f"**Dateiname:** artikel_{slug}_{now.strftime('%Y-%m-%d')}.html")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# PDF-OCR-Ergebnisse
|
||||||
|
if pdf_texts:
|
||||||
|
lines.append("## PDF-Quellen (OCR-extrahiert, bereits im Context)")
|
||||||
|
for path, text in pdf_texts.items():
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
truncated = text[:3000]
|
||||||
|
if len(text) > 3000:
|
||||||
|
truncated += f"\n... (gekürzt, Original: {len(text)} Zeichen)"
|
||||||
|
lines.append(f"\n### {fname}\n{truncated}\n")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# URL-Quellen
|
||||||
|
if sources:
|
||||||
|
lines.append("## URL-Quellen")
|
||||||
|
for s in sources:
|
||||||
|
lines.append(f"- {s}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# STAGE 1: Research
|
||||||
|
lines.append("## STAGE 1: Research")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Nutze **delegate_task** um einen Research-Subagent zu starten:")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"- goal: \"Recherchiere zum Thema: {topic}. Sammle Fakten aus allen Quellen, bewerte sie (AAA/AA/A), ergänze BGG-Daten (Designer, Verlag, Jahr). Erstelle Research-Brief in /tmp/research_brief.md. Bei unvollständigen Kern-Fakten → Exit 1.\"")
|
||||||
|
lines.append("- context: Enthält PDF-Texte und URLs (siehe oben). Nutze 5-Stufen-Eskalation für unerreichbare URLs. web_search site:boardgamegeek.com für BGG-Daten.")
|
||||||
|
lines.append('- toolsets: ["web", "terminal", "file", "search", "skills"]')
|
||||||
|
lines.append("")
|
||||||
|
lines.append("**WICHTIG:** Warte bis Sub-Agent #1 fertig ist. Prüfe /tmp/research_brief.md. Bei Exit-Code 1 → MELDUNG an Basti, NICHT fortfahren.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# STAGE 2+3: Write+QC
|
||||||
|
lines.append("## STAGE 2+3: Write + QC")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Nutze **delegate_task** für einen Write+QC-Subagent:")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f'- goal: "Schreibe HTML-Artikel zum Thema: {topic}. Nutze Research-Brief aus /tmp/research_brief.md. Datei: artikel_{slug}_{now.strftime("%Y-%m-%d")}.html in /home/hermes/workspace/. Danach: bash /home/hermes/workspace/verify_article.sh artikel_{slug}_{now.strftime("%Y-%m-%d")}.html. Bei Fehlern korrigieren, max 3 Versuche."')
|
||||||
|
lines.append(f'- context: "HTML-Regeln: KEIN XML API v2, KEIN Betriebsgeheimnis, KEINE Emojis. <p class=\\"meta\\"> mit Datum+Lesezeit. Genau 1× <hr id=\\"system-readmore\\" />. Quellen als <span><strong>Quellen</strong></span> + <ol> mit <a target=\\"_blank\\">. <p class=\\"qc\\"> mit Quellen-Ratings. Datei endet mit </html>. Rubrik: {rubric}."')
|
||||||
|
lines.append('- toolsets: ["web", "terminal", "file", "search", "skills"]')
|
||||||
|
lines.append("")
|
||||||
|
lines.append("**WICHTIG:** Warte bis Sub-Agent #2 fertig. Prüfe Exit-Code und Datei.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# STAGE 4: Deliver
|
||||||
|
lines.append("## STAGE 4: Deliver")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("1. Git-Commit:")
|
||||||
|
lines.append(f" ```bash")
|
||||||
|
lines.append(f" cd /home/hermes/workspace && git add artikel_{slug}_{now.strftime('%Y-%m-%d')}.html")
|
||||||
|
lines.append(f' git commit -m "artikel: {topic[:50]}"')
|
||||||
|
lines.append(f" ```")
|
||||||
|
lines.append("2. Prüfen ob Article-Server erreichbar:")
|
||||||
|
lines.append(" curl -s 'http://185.162.249.159:8890/?key=br3ttsp1el-n3ws-2026' | head -3")
|
||||||
|
lines.append("3. Basti benachrichtigen per send_message (Telegram):")
|
||||||
|
lines.append(f" ✅ Artikel fertig: {topic}")
|
||||||
|
lines.append(f" 📄 /home/hermes/workspace/artikel_{slug}_{now.strftime('%Y-%m-%d')}.html")
|
||||||
|
lines.append(" 📊 verify_article.sh: 0 Fehler")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="BSN Article Pipeline — Stage 0 Prompt Builder"
|
||||||
|
)
|
||||||
|
parser.add_argument("--topic", help="Artikel-Thema")
|
||||||
|
parser.add_argument("--sources", nargs="*", default=[],
|
||||||
|
help="URLs oder PDF-Pfade")
|
||||||
|
parser.add_argument("--rubric", default="Korrektur",
|
||||||
|
help="Rubrik-Name (default: Korrektur)")
|
||||||
|
parser.add_argument("--category-id", type=int, default=8,
|
||||||
|
help="Joomla Category-ID (default: 8)")
|
||||||
|
parser.add_argument("--image-urls", nargs="*", default=[],
|
||||||
|
help="Bild-URLs aus PM")
|
||||||
|
parser.add_argument("--deadline", default=None,
|
||||||
|
help="ISO-Timestamp für Deadline")
|
||||||
|
parser.add_argument("--batch", help="JSON-Datei mit Batch-Inputs")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Batch-Modus
|
||||||
|
if args.batch:
|
||||||
|
with open(args.batch) as f:
|
||||||
|
batch_inputs = json.load(f)
|
||||||
|
outputs = []
|
||||||
|
for inp in batch_inputs:
|
||||||
|
prompt = build_prompt(
|
||||||
|
topic=inp.get("topic", ""),
|
||||||
|
sources=inp.get("sources", []),
|
||||||
|
rubric=inp.get("rubric", "Korrektur"),
|
||||||
|
category_id=inp.get("category_id", 8),
|
||||||
|
image_urls=inp.get("image_urls", []),
|
||||||
|
deadline=inp.get("deadline"),
|
||||||
|
pdf_texts={}
|
||||||
|
)
|
||||||
|
outputs.append({
|
||||||
|
"topic": inp.get("topic"),
|
||||||
|
"prompt": prompt
|
||||||
|
})
|
||||||
|
print(json.dumps(outputs, indent=2, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Einzel-Modus
|
||||||
|
if not args.topic:
|
||||||
|
print("ERROR: --topic erforderlich (oder --batch)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# PDFs OCRen
|
||||||
|
pdf_texts = {}
|
||||||
|
for src in args.sources:
|
||||||
|
if src.lower().endswith(".pdf"):
|
||||||
|
full_path = src
|
||||||
|
if not os.path.isabs(full_path):
|
||||||
|
full_path = os.path.join(DOC_CACHE, src)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
try:
|
||||||
|
pdf_texts[full_path] = ocr_pdf(full_path)
|
||||||
|
print(f"[Stage 0] PDF OCR: {full_path} "
|
||||||
|
f"({len(pdf_texts[full_path])} Zeichen)", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Stage 0] PDF-Fehler: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Prompt bauen
|
||||||
|
prompt = build_prompt(
|
||||||
|
topic=args.topic,
|
||||||
|
sources=args.sources,
|
||||||
|
rubric=args.rubric,
|
||||||
|
category_id=args.category_id,
|
||||||
|
image_urls=args.image_urls,
|
||||||
|
deadline=args.deadline,
|
||||||
|
pdf_texts=pdf_texts,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user