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:
@@ -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, 600–900 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 5–8 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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user