Add KNIEPUNKT Assistant with multi-LLM editorial workflow

Six-step weekly workflow (research → sources → storyline → draft →
quality → publication) supporting Claude, ChatGPT, Gemini, and Mistral
in parallel for creative steps. Web search via Anthropic tool for news
research. Episode index built from 34 existing KNIEPUNKT episodes for
redundancy checks. Sessions persisted as JSON for mid-workflow resume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 23:54:23 +02:00
commit e08c484838
57 changed files with 3240 additions and 0 deletions
View File
+114
View File
@@ -0,0 +1,114 @@
"""Draft generation across all configured providers and author selection."""
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
console = Console()
_DRAFT_SYSTEM = """Du bist ein erfahrener Ghostwriter für die LinkedIn-Kolumne KNIEPUNKT von Dr. André Knie.
Tonvorgaben:
- Kolumnenhaft und glossenhaft: pointiert, amüsant, Widersprüche und Anekdoten nutzen
- Kulturell gebildet: griechische/römische Mythologie, Kanonliteratur und klassische deutsche Literatur willkommen
- Meinungsstark: die Meinung des Autors muss deutlich erkennbar sein
- Nicht zu technisch: Entscheider-Zielgruppe, kein Tech-Deep-Dive
- Lesernahe Interpretation: nicht nur Nachrichten referieren, sondern einordnen und bewerten
Format: LinkedIn Newsletter-Artikel, 600900 Wörter. Sprache: Deutsch.
Füge am Ende an: ## Quellen (nummeriert mit URL/Fundort)."""
_ENRICHMENT_SYSTEM = _DRAFT_SYSTEM + (
"\n\nDu erhältst einen bestehenden Entwurf und Überarbeitungshinweise. "
"Ändere nur, was explizit verlangt wird. Behalte Ton, Meinung und Aufhänger bei."
)
def _build_prompt(
storyline_text: str,
storyline_choice: int,
adjustments: str,
news_digest: str,
source_assessment: str,
author_input: dict,
) -> str:
adj = f"\nAnpassungen des Autors: {adjustments}" if adjustments else ""
return f"""Schreibe einen vollständigen KNIEPUNKT-Artikel.
Ausgewählte Storyline (Option {storyline_choice}):
{storyline_text}
{adj}
Verfügbare Nachrichten:
{news_digest}
Quellenbewertung (für Quellenauswahl beachten):
{source_assessment[:800]}
Persönliche Notizen des Autors:
{author_input['author_news'][:500]}
Schreibe jetzt den vollständigen Artikel im KNIEPUNKT-Stil.
Anhang: ## Quellen (nummeriert mit URL/Fundort)."""
def generate_drafts(
providers: list,
storyline_text: str,
storyline_choice: int,
adjustments: str,
news_digest: str,
source_assessment: str,
author_input: dict,
) -> dict[str, str]:
"""Run draft generation on all providers. Returns {provider_name: draft}."""
prompt = _build_prompt(storyline_text, storyline_choice, adjustments, news_digest, source_assessment, author_input)
messages = [{"role": "user", "content": prompt}]
results = {}
for provider in providers:
console.print(f"\n[yellow]Schreibe Entwurf mit {provider.name}...[/yellow]")
try:
results[provider.name] = provider.chat(messages, _DRAFT_SYSTEM, max_tokens=4096)
except Exception as e:
console.print(f"[red]{provider.name} übersprungen: {e}[/red]")
return results
def select_draft(results: dict[str, str]) -> tuple[str, str]:
"""Show all drafts, return (provider_name, selected_draft_text)."""
if not results:
raise RuntimeError("Kein Modell hat einen Entwurf geliefert. API-Keys und Verbindung prüfen.")
provider_names = list(results.keys())
for name, draft in results.items():
console.print(Panel(draft, title=f"Entwurf {name}", border_style="green"))
console.print()
if len(provider_names) > 1:
choice = Prompt.ask(
"Welchen Entwurf als Basis verwenden?",
choices=provider_names,
default=provider_names[0],
)
else:
choice = provider_names[0]
return choice, results[choice]
def enrich_draft(client, draft: str, author_feedback: str) -> str:
"""Refine the selected draft based on author feedback (Claude only)."""
from kniepunkt.llm import chat
console.print("\n[yellow]Überarbeite Entwurf...[/yellow]")
prompt = f"""Überarbeite diesen KNIEPUNKT-Entwurf basierend auf den folgenden Autorenhinweisen.
Aktueller Entwurf:
{draft}
Hinweise des Autors:
{author_feedback}"""
return chat(client, [{"role": "user", "content": prompt}], _ENRICHMENT_SYSTEM, max_tokens=4096)
+133
View File
@@ -0,0 +1,133 @@
"""Load and index existing KNIEPUNKT episodes for redundancy checks."""
import json
from pathlib import Path
EPISODES_DIR = Path(__file__).parent.parent / "KNIEPUNKTe"
CACHE_FILE = Path(__file__).parent.parent / "episodes_cache.json"
_SYSTEM = (
"Du bist ein erfahrener Redaktionsassistent für die LinkedIn-Kolumne KNIEPUNKT "
"von Dr. André Knie. Analysiere Episodentexte präzise und strukturiert."
)
def _read_pdf(path: Path) -> str:
from pypdf import PdfReader
reader = PdfReader(str(path))
return "\n".join(page.extract_text() or "" for page in reader.pages)
def _read_docx(path: Path) -> str:
from docx import Document
doc = Document(str(path))
return "\n".join(p.text for p in doc.paragraphs)
def _read_odt(path: Path) -> str:
from odf.opendocument import load
from odf.text import P
doc = load(str(path))
paragraphs = doc.getElementsByType(P)
return "\n".join(
"".join(node.data for node in p.childNodes if hasattr(node, "data"))
for p in paragraphs
)
def _read_episode(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".pdf":
return _read_pdf(path)
if suffix == ".docx":
return _read_docx(path)
if suffix == ".odt":
return _read_odt(path)
return ""
def _episode_files() -> list[Path]:
"""Return one canonical file per episode number, preferring DOCX/ODT over PDF."""
if not EPISODES_DIR.exists():
return []
by_number: dict[str, Path] = {}
for f in sorted(EPISODES_DIR.iterdir()):
if f.suffix.lower() not in (".pdf", ".docx", ".odt"):
continue
num = f.name.split("_")[0].split(" ")[0].strip()
try:
int(num)
except ValueError:
continue
existing = by_number.get(num)
if existing is None or f.suffix.lower() in (".docx", ".odt"):
by_number[num] = f
return [by_number[k] for k in sorted(by_number)]
def build_index(client, force: bool = False) -> list[dict]:
"""Build or load the episode summary index."""
if not force and CACHE_FILE.exists():
with open(CACHE_FILE) as f:
return json.load(f)
from kniepunkt.llm import chat
index = []
files = _episode_files()
for path in files:
text = _read_episode(path)
if not text.strip():
continue
prompt = f"""Analysiere diese KNIEPUNKT-Episode und antworte NUR mit einem JSON-Objekt:
{{
"nummer": "z.B. 001",
"titel": "Titel der Episode",
"hauptthema": "Hauptthema in 1-2 Sätzen",
"allegories": ["verwendete Allegorie oder Metapher", ...],
"kulturelle_referenzen": ["Mythos/Literatur-Referenz", ...],
"kernargument": "Hauptaussage des Autors in 1-2 Sätzen"
}}
Episodentext (gekürzt):
{text[:5000]}"""
response = chat(client, [{"role": "user", "content": prompt}], _SYSTEM, max_tokens=512)
try:
start = response.find("{")
end = response.rfind("}") + 1
data = json.loads(response[start:end])
data["file"] = str(path)
index.append(data)
except (json.JSONDecodeError, ValueError):
index.append({
"nummer": path.name.split("_")[0].strip(),
"titel": path.stem,
"hauptthema": "",
"allegories": [],
"kulturelle_referenzen": [],
"kernargument": "",
"file": str(path),
})
with open(CACHE_FILE, "w") as f:
json.dump(index, f, ensure_ascii=False, indent=2)
return index
def format_for_context(index: list[dict]) -> str:
"""Format the episode index as a compact context string for prompts."""
lines = []
for ep in index:
allegs = ", ".join(ep.get("allegories", [])) or ""
refs = ", ".join(ep.get("kulturelle_referenzen", [])) or ""
lines.append(
f"Ep.{ep.get('nummer', '?')} »{ep.get('titel', '?')}«: "
f"{ep.get('hauptthema', '')} | Allegorien: {allegs} | Referenzen: {refs}"
)
return "\n".join(lines)
+78
View File
@@ -0,0 +1,78 @@
"""Anthropic client and chat helpers."""
import os
from typing import Any
import anthropic
MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 8096
def get_client() -> anthropic.Anthropic:
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError("ANTHROPIC_API_KEY environment variable not set.")
return anthropic.Anthropic(api_key=api_key)
def chat(
client: anthropic.Anthropic,
messages: list[dict],
system: str,
max_tokens: int = MAX_TOKENS,
) -> str:
response = client.messages.create(
model=MODEL,
max_tokens=max_tokens,
system=system,
messages=messages,
)
return _extract_text(response.content)
_MAX_SEARCH_ITERATIONS = 10
def chat_with_search(
client: anthropic.Anthropic,
messages: list[dict],
system: str,
) -> str:
"""Run a conversation with web search enabled. Loops on client-side tool_use only."""
current_messages = list(messages)
for _ in range(_MAX_SEARCH_ITERATIONS):
response = client.messages.create(
model=MODEL,
max_tokens=MAX_TOKENS,
system=system,
tools=[{"type": "web_search_20250305", "name": "web_search"}],
messages=current_messages,
)
if response.stop_reason == "end_turn":
return _extract_text(response.content)
if response.stop_reason == "tool_use":
current_messages.append({"role": "assistant", "content": response.content})
# Only handle client-side tool_use blocks; server_tool_use results are
# already embedded in the response and must not be echoed back.
tool_results = [
{"type": "tool_result", "tool_use_id": block.id, "content": ""}
for block in response.content
if getattr(block, "type", None) == "tool_use"
]
if tool_results:
current_messages.append({"role": "user", "content": tool_results})
else:
return _extract_text(response.content)
return _extract_text(response.content)
def _extract_text(content: list[Any]) -> str:
parts = []
for block in content:
if hasattr(block, "text"):
parts.append(block.text)
return "\n".join(parts)
+109
View File
@@ -0,0 +1,109 @@
"""LLM provider abstraction. Each provider wraps one vendor SDK."""
import os
from rich.console import Console
_console = Console()
class Provider:
name: str
def chat(self, messages: list[dict], system: str, max_tokens: int = 4096) -> str:
raise NotImplementedError
class AnthropicProvider(Provider):
name = "Claude"
def __init__(self):
import anthropic
self._client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
def chat(self, messages: list[dict], system: str, max_tokens: int = 4096) -> str:
response = self._client.messages.create(
model="claude-sonnet-4-6",
max_tokens=max_tokens,
system=system,
messages=messages,
)
return response.content[0].text
class OpenAIProvider(Provider):
name = "ChatGPT"
def __init__(self):
from openai import OpenAI
self._client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def chat(self, messages: list[dict], system: str, max_tokens: int = 4096) -> str:
oai_messages = [{"role": "system", "content": system}] + messages
response = self._client.chat.completions.create(
model="gpt-4o",
max_tokens=max_tokens,
messages=oai_messages,
)
return response.choices[0].message.content
class GeminiProvider(Provider):
name = "Gemini"
def __init__(self):
import google.generativeai as genai
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
self._genai = genai
def chat(self, messages: list[dict], system: str, max_tokens: int = 4096) -> str:
model = self._genai.GenerativeModel(
model_name="gemini-2.0-flash",
system_instruction=system,
)
# Convert role "assistant" → "model" for Gemini
contents = [
{"role": "model" if m["role"] == "assistant" else "user", "parts": [m["content"]]}
for m in messages
]
response = model.generate_content(
contents,
generation_config=self._genai.types.GenerationConfig(max_output_tokens=max_tokens),
)
return response.text
class MistralProvider(Provider):
name = "Mistral"
def __init__(self):
from mistralai import Mistral
self._client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
def chat(self, messages: list[dict], system: str, max_tokens: int = 4096) -> str:
mistral_messages = [{"role": "system", "content": system}] + messages
response = self._client.chat.complete(
model="mistral-large-latest",
max_tokens=max_tokens,
messages=mistral_messages,
)
return response.choices[0].message.content
_REGISTRY = [
("ANTHROPIC_API_KEY", AnthropicProvider),
("OPENAI_API_KEY", OpenAIProvider),
("GOOGLE_API_KEY", GeminiProvider),
("MISTRAL_API_KEY", MistralProvider),
]
def get_configured() -> list[Provider]:
"""Return instantiated providers for every API key that is set. Skips on init error."""
result = []
for env_var, cls in _REGISTRY:
if os.environ.get(env_var):
try:
result.append(cls())
except Exception as e:
_console.print(f"[yellow][Warnung] {cls.__name__} konnte nicht initialisiert werden: {e}[/yellow]")
return result
+85
View File
@@ -0,0 +1,85 @@
"""Publication preparation: teasers, visual ideas, final package."""
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
console = Console()
_SYSTEM = """Du bist ein erfahrener Redaktionsassistent für KNIEPUNKT.
Erstelle prägnante, ansprechende LinkedIn-Texte und kreative Cover-Ideen für eine hochgebildete Zielgruppe."""
def generate_teasers(client, draft: str) -> str:
"""Generate 3 teaser/invitation text variants."""
from kniepunkt.llm import chat
console.print("\n[yellow]Erstelle Teaser-Varianten...[/yellow]")
prompt = f"""Erstelle 3 verschiedene LinkedIn-Einladungstexte (Teaser) für diese KNIEPUNKT-Episode.
Episodentext (Auszug):
{draft[:2000]}
**Variante 1 Neugier-Aufhänger:** stellt eine Frage oder weckt Neugier
**Variante 2 Pointierte These:** provokante Aussage oder klare Meinung
**Variante 3 Bild/Anekdote:** beginnt mit einem konkreten Bild oder einer kurzen Geschichte
Jeder Teaser: 3-5 Sätze, LinkedIn-gerecht, endet mit einer Leseeinladung und "Schönen Sonntag! 🤙" """
return chat(client, [{"role": "user", "content": prompt}], _SYSTEM, max_tokens=1024)
def generate_visual_ideas(client, draft: str) -> str:
"""Generate 3 cover/visual concept ideas."""
from kniepunkt.llm import chat
console.print("\n[yellow]Entwickle Cover-Ideen...[/yellow]")
prompt = f"""Entwickle 3 Cover-Ideen für diese KNIEPUNKT-Episode.
Episodentext (Auszug):
{draft[:1500]}
Für jede Idee:
- **Bildkonzept:** Was ist zu sehen? Konkrete Beschreibung des Motivs
- **Stil:** fotorealistisch / illustrativ / symbolisch / abstrakt
- **Verbindung zur Episode:** Wie spiegelt es das Thema wider?
- **Bildgenerierungs-Prompt (Englisch):** konkreter Prompt für DALL-E / Midjourney / Flux
Hinweis: KNIEPUNKT-Logo, Episodentitel und Datum werden vom Autor separat hinzugefügt."""
return chat(client, [{"role": "user", "content": prompt}], _SYSTEM, max_tokens=1024)
def save_package(session_date: str, draft: str, teasers: str, source_assessment: str, visual_ideas: str) -> Path:
"""Write the complete publication package to a markdown file."""
sessions_dir = Path(__file__).parent.parent / "sessions"
sessions_dir.mkdir(exist_ok=True)
out_path = sessions_dir / f"{session_date}_publikation.md"
content = f"""# KNIEPUNKT Publikationspaket {session_date}
## Artikel
{draft}
---
## Einladungstexte (Teaser-Varianten)
{teasers}
---
## Cover-Ideen
{visual_ideas}
---
## Quellenbewertung
{source_assessment}
"""
out_path.write_text(content, encoding="utf-8")
return out_path
+41
View File
@@ -0,0 +1,41 @@
"""Quality review: tone, opinion, sources, redundancy."""
from rich.console import Console
from rich.panel import Panel
console = Console()
_SYSTEM = """Du bist ein strenger Redaktions-Qualitätsprüfer für die KNIEPUNKT-Kolumne.
Flagge Probleme klar mit: [SCHWACHE QUELLE] [FEHLENDER BELEG] [ZU TECHNISCH] [FEHLENDE MEINUNG] [FEHLENDE EINORDNUNG] [REDUNDANZ] [TON-PROBLEM] [FAKTENPROBLEM]
Sei direkt und konstruktiv. Weise auch auf Stärken hin."""
def review(client, draft: str, source_assessment: str, episodes_context: str) -> str:
"""Full quality review of the draft."""
from kniepunkt.llm import chat
console.print("\n[yellow]Qualitätsprüfung...[/yellow]")
prompt = f"""Führe eine vollständige Qualitätsprüfung dieses KNIEPUNKT-Entwurfs durch.
Entwurf:
{draft}
Quellenbewertung:
{source_assessment[:800]}
Frühere Episoden (Redundanzcheck):
{episodes_context[:1500]}
Prüfe:
1. **Ton** Kolumnenhaft, amüsant, pointiert? Zielgruppengerecht?
2. **Meinung** Kommt die Autorenmeinung deutlich vor?
3. **Einordnung** Lesernahe Interpretation oder nur Nachrichtenzusammenfassung?
4. **Quellen** Belege ausreichend? Schwache oder fehlende Quellen?
5. **Redundanz** Allegorien, Referenzen oder Winkel aus früheren Episoden wiederholt?
6. **Technische Tiefe** Zu technisch für die Zielgruppe?
7. **Fakten** Ungesicherte oder widersprüchliche Aussagen?
8. **Stärken** Was funktioniert besonders gut?
Gesamtbewertung: 🟢 bereit zur Freigabe / 🟡 kleine Anpassungen nötig / 🔴 überarbeiten"""
return chat(client, [{"role": "user", "content": prompt}], _SYSTEM, max_tokens=2048)
+100
View File
@@ -0,0 +1,100 @@
"""News research: web search via Claude + author personal input."""
from rich.console import Console
from rich.panel import Panel
console = Console()
_RESEARCH_SYSTEM = """Du bist ein erfahrener Redaktionsassistent für die LinkedIn-Kolumne KNIEPUNKT von Dr. André Knie.
KNIEPUNKT richtet sich an CEOs, Führungskräfte des Mittelstands, Konzerne und öffentliche Verwaltung alle mit Interesse an moralischer KI.
Geeignete Quellen: The Decoder, Heise, ARD, ZDF, DLF, Handelsblatt, Wirtschaftswoche, Die Zeit, Der Spiegel, wissenschaftliche Publikationen, Reddit (wenn originaler Diskussionskontext), offizielle Unternehmens-Ankündigungen.
Ungeeignete Quellen: Boulevardzeitungen, unbekannte Medien, reine Aggregatoren ohne eigene Recherche.
Präsentiere Ergebnisse klar strukturiert auf Deutsch."""
_SOURCE_SYSTEM = """Du bist ein strenger Quellen-Prüfer für die KNIEPUNKT-Kolumne.
Bewerte jede Quelle nach: Primärquelle | KI-Fachjournalismus | etablierte Qualitätspresse | Erstdiskussionsquelle | schwache Quelle | ungeeignet.
Flags: [FEHLENDER BELEG] [WIDERSPRUCH] [UNKLARE FAKTENLAGE] [AUTOR-PRÜFUNG ERFORDERLICH]."""
def get_author_input() -> dict:
"""Collect the author's personal news input and optional initial storyline."""
console.print(Panel(
"[bold]Schritt 1: Ihre persönlichen Nachrichten und Notizen[/bold]\n\n"
"Bitte geben Sie Ihre KI-Nachrichten der Woche ein.\n"
"(URLs, Schlagzeilen, Beobachtungen eine Leerzeile zum Abschließen)",
title="KNIEPUNKT Wöchentliche Recherche",
border_style="cyan",
))
lines = []
while True:
line = input()
if line == "" and lines:
break
elif line:
lines.append(line)
console.print("\n[dim]Haben Sie bereits eine Storyline-Idee? (Enter zum Überspringen)[/dim]")
initial_storyline = input().strip() or None
return {"author_news": "\n".join(lines), "initial_storyline": initial_storyline}
def research_news(client, author_input: dict, episodes_context: str) -> str:
"""Use Claude with web search to research current AI news."""
from kniepunkt.llm import chat_with_search
import datetime
console.print("\n[yellow]Recherchiere aktuelle KI-Nachrichten...[/yellow]")
today = datetime.date.today().strftime("%d.%m.%Y")
prompt = f"""Recherchiere die wichtigsten KI-Nachrichten der aktuellen Woche (Stand: {today}) für die LinkedIn-Kolumne KNIEPUNKT.
Autoreneingabe (bereits bekannte Nachrichten und Notizen):
{author_input['author_news']}
Frühere KNIEPUNKT-Episoden (zur Orientierung, was bereits behandelt wurde):
{episodes_context[:2000]}
Aufgabe:
1. Suche nach 58 wichtigen KI-Entwicklungen dieser Woche
2. Priorisiere: Was sollten Entscheider mit Interesse an moralischer KI unbedingt wissen?
3. Kombiniere Recherche-Ergebnisse mit den Autoreneingaben (Dopplungen vermeiden)
Ausgabeformat für jeden Nachrichten-Kandidaten:
### [N]. [Titel]
**Zusammenfassung:** 2-3 Sätze
**Quelle(n):** Name URL
**Relevanz für KNIEPUNKT-Zielgruppe:** 1 Satz"""
return chat_with_search(
client,
[{"role": "user", "content": prompt}],
_RESEARCH_SYSTEM,
)
def assess_sources(client, news_digest: str) -> str:
"""Assess source quality for all news items in the digest."""
from kniepunkt.llm import chat
console.print("\n[yellow]Bewerte Quellen...[/yellow]")
prompt = f"""Bewerte alle Quellen in diesem Nachrichten-Digest für KNIEPUNKT:
{news_digest}
Für jede Quelle:
- Qualitätsstufe (Primärquelle / KI-Fachjournalismus / Qualitätspresse / Erstdiskussion / schwach / ungeeignet)
- Kurze Begründung (1 Satz)
- Relevante Flags
Falls Quellen fehlen oder schwach sind: schlage konkret bessere Alternativen vor."""
return chat(
client,
[{"role": "user", "content": prompt}],
_SOURCE_SYSTEM,
)
+38
View File
@@ -0,0 +1,38 @@
"""Session state: save and load the weekly editorial workflow state."""
import json
from datetime import datetime
from pathlib import Path
SESSIONS_DIR = Path(__file__).parent.parent / "sessions"
STEPS = ["research", "sources", "storyline", "draft", "quality", "publication", "done"]
def new_session() -> dict:
return {
"date": datetime.today().strftime("%Y-%m-%d"),
"step": "research",
"research": None,
"sources": None,
"storyline": None,
"draft": None,
"quality": None,
"publication": None,
}
def save(session: dict) -> None:
SESSIONS_DIR.mkdir(exist_ok=True)
path = SESSIONS_DIR / f"{session['date']}.json"
with open(path, "w") as f:
json.dump(session, f, ensure_ascii=False, indent=2)
def load_latest() -> dict | None:
if not SESSIONS_DIR.exists():
return None
files = sorted(SESSIONS_DIR.glob("*.json"), reverse=True)
if not files:
return None
with open(files[0]) as f:
return json.load(f)
+95
View File
@@ -0,0 +1,95 @@
"""Storyline generation across all configured providers and author selection."""
from rich.console import Console
from rich.panel import Panel
from rich.prompt import IntPrompt, Prompt
console = Console()
_SYSTEM = """Du bist ein erfahrener Redaktionsassistent für die LinkedIn-Kolumne KNIEPUNKT von Dr. André Knie.
KNIEPUNKT ist kolumnenhaft, glossenhaft, amüsant, kulturell gebildet und meinungsstark.
Erlaubt: griechische/römische Mythologie, Kanonliteratur, klassische deutsche Literatur.
Zielgruppe: hochgebildete Entscheider aus Mittelstand, Konzernen und öffentlicher Verwaltung mit Interesse an moralischer KI.
Eine Storyline verbindet ausgewählte Nachrichten zu einem kohärenten redaktionellen Blickwinkel kein neutrales Nachrichtenreferat."""
def _build_prompt(news_digest: str, source_assessment: str, author_input: dict, episodes_context: str) -> str:
initial = (
f"\nVorläufige Autorenstoryline: {author_input['initial_storyline']}"
if author_input.get("initial_storyline")
else ""
)
return f"""Entwickle 3 klar unterschiedliche Storyline-Optionen für die nächste KNIEPUNKT-Episode.
Nachrichten dieser Woche:
{news_digest}
Quellenbewertung (berücksichtigen):
{source_assessment[:800]}
Frühere Episoden Redundanzvermeidung:
{episodes_context[:2000]}
{initial}
Für jede Storyline:
**Option [N]: [Arbeitstitel]**
- **Kernwinkel:** Was ist der redaktionelle Blickwinkel? (2-3 Sätze)
- **Nachrichten-Auswahl:** Welche 2-3 Nachrichten werden verwendet und wie verknüpft?
- **Einstiegsidee:** Konkrete Idee für den ersten Satz oder Absatz
- **Allegorischer Rahmen:** Mythologie, Literatur oder kulturelle Referenz (falls passend)
- **Unterschied** zu den anderen Optionen
- **Stärken / Risiken**
Vermeide: Redundanzen zu früheren Episoden, reines Nachrichtenreferat ohne Meinung, zu technische Tiefe."""
def generate_storylines(
providers: list,
news_digest: str,
source_assessment: str,
author_input: dict,
episodes_context: str,
) -> dict[str, str]:
"""Run storyline generation on all providers. Returns {provider_name: result}."""
prompt = _build_prompt(news_digest, source_assessment, author_input, episodes_context)
messages = [{"role": "user", "content": prompt}]
results = {}
for provider in providers:
console.print(f"\n[yellow]Generiere Storylines mit {provider.name}...[/yellow]")
try:
results[provider.name] = provider.chat(messages, _SYSTEM, max_tokens=4096)
except Exception as e:
console.print(f"[red]{provider.name} übersprungen: {e}[/red]")
return results
def select_storyline(results: dict[str, str]) -> tuple[str, int, str, str]:
"""Show all provider results, return (provider_name, choice, selected_text, adjustments)."""
if not results:
raise RuntimeError("Kein Modell hat Storylines geliefert. API-Keys und Verbindung prüfen.")
provider_names = list(results.keys())
for name, text in results.items():
console.print(Panel(text, title=f"Storylines {name}", border_style="cyan"))
console.print()
if len(provider_names) > 1:
provider_choice = Prompt.ask(
"Von welchem Modell möchten Sie eine Storyline wählen?",
choices=provider_names,
default=provider_names[0],
)
else:
provider_choice = provider_names[0]
option_choice = IntPrompt.ask(
f"Welche Storyline von {provider_choice}? (1/2/3)",
choices=["1", "2", "3"],
default=1,
)
console.print("\n[dim]Anpassungen oder zusätzliche Hinweise zur gewählten Storyline? (Enter zum Überspringen)[/dim]")
adjustments = input().strip()
return provider_choice, option_choice, results[provider_choice], adjustments