245 lines
9.5 KiB
Python
245 lines
9.5 KiB
Python
#!/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.
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
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 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]
|
||
date_slug = now.strftime("%Y-%m-%d")
|
||
filename = f"artikel_{slug}_{date_slug}.html"
|
||
|
||
# --- WRITE+QC CONTEXT (most complex part) ---
|
||
write_context = (
|
||
"STRUKTUR (exakt einhalten, nummeriert):\n"
|
||
"1. <h1>TITEL</h1>\n"
|
||
'2. DACHZEILE: <div style="font-size:1.1rem;color:#555;margin-bottom:1.5rem;'
|
||
'border-bottom:2px solid #e0e0e0;padding-bottom:1rem;">'
|
||
'Zusammenfassung in 1-2 Sätzen</div>\n'
|
||
'3. Einleitungs-Absatz <p>\n'
|
||
'4. INFOBOX (PFLICHT bei Unternehmen/Produkt-News): '
|
||
'<div style="background:#f0f4f8;border:1px solid #d0d8e0;border-radius:6px;'
|
||
'padding:1.2rem 1.5rem;margin:2rem 0;">'
|
||
'<h3 style="margin-top:0;color:#20228A;font-family:Arial,sans-serif;font-size:1.15rem;">'
|
||
'NAME — Fakten</h3><table style="width:100%;border-collapse:collapse;">'
|
||
'<tr><td style="padding:0.3rem 0.5rem;font-weight:bold;color:#444;width:160px;">'
|
||
'Label</td><td style="padding:0.3rem 0.5rem;">Wert</td></tr></table></div>\n'
|
||
'5. ALLE Inhalts-Abschnitte (<h2> + <p>)\n'
|
||
'6. <hr id="system-readmore" /> <-- NACH allen Inhalten, VOR Quellen!\n'
|
||
'7. Quellen-Überschrift: <span style="font-size:1.17em;font-weight:bold;'
|
||
'display:block;margin:1em 0;"><strong>Quellen</strong></span>\n'
|
||
'8. JEDE Quelle als: <li><a href="URL" target="_blank" rel="noopener" '
|
||
'style="color:#0075FF;">Name</a> — Beschreibung</li>\n'
|
||
'9. <p class="qc"><!-- QC: Quelle1=AAA|URL1, Quelle2=AA|URL2 --></p>\n'
|
||
'10. <p style="margin-top:2.5rem;padding-top:1rem;border-top:1px solid #ddd;'
|
||
'font-size:0.9rem;color:#777;"><em>Recherchestand: DATUM. '
|
||
'Basierend auf QUELLEN.</em></p>\n'
|
||
'\n'
|
||
'KRITISCHE REGELN (QC-Fail bei Verstoß):\n'
|
||
'- Read-More NACH allen Inhalten, VOR Quellen (NICHT nach Absatz 1!)\n'
|
||
'- JEDE Quelle als <a href> mit target="_blank" — KEINE unverlinkten Quellen!\n'
|
||
'- Dachzeile + Infobox sind PFLICHT\n'
|
||
'- Quellen-Überschrift: font-size:1.17em (NICHT 0.9rem)\n'
|
||
'- Kein "XML API v2", kein "Betriebsgeheimnis", keine Emojis\n'
|
||
'- Datei endet mit </html>\n'
|
||
f'- Rubrik: {rubric}'
|
||
)
|
||
|
||
# --- BUILD OUTPUT ---
|
||
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:** {filename}")
|
||
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}. '
|
||
f'Sammle Fakten aus allen Quellen, bewerte sie (AAA/AA/A), '
|
||
f'ergänze BGG-Daten (Designer, Verlag, Jahr). '
|
||
f'Erstelle Research-Brief in /tmp/research_brief.md. '
|
||
f'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}. '
|
||
f'Nutze Research-Brief aus /tmp/research_brief.md. '
|
||
f'Datei: /home/hermes/workspace/{filename}. '
|
||
f'Danach: bash /home/hermes/workspace/verify_article.sh '
|
||
f'/home/hermes/workspace/{filename}. '
|
||
f'Bei Fehlern korrigieren, max 3 Versuche."'
|
||
)
|
||
lines.append(f'- context: """\n{write_context}\n"""')
|
||
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" cd /home/hermes/workspace && git add {filename}")
|
||
lines.append(f' git commit -m "artikel: {topic[:50]}"')
|
||
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/{filename}")
|
||
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()
|