aidn's picture
Update app.py
9b50c00 verified
import os
import re
import json
import datetime
import tempfile
import gradio as gr
from huggingface_hub import InferenceClient, HfApi, hf_hub_download
from huggingface_hub.utils import EntryNotFoundError
# ── Konfiguration ──────────────────────────────────────────────────────────────
HF_TOKEN = os.environ.get("HF_TOKEN", "")
DATASET_REPO = os.environ.get("DATASET_REPO", "")
DATASET_FILE = "data.jsonl"
MODEL_DEFAULT = "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
MODEL_TUNING = "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
LEADERBOARD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "leaderboard.json")
# ── System Prompts ─────────────────────────────────────────────────────────────
PROMPT_TO_LINKEDIN = """Du bist der ultimative, satirische LinkedIn-Influencer-Generator. Deine Aufgabe: Verwandle die banalste, alltäglichste Eingabe in einen absurd überzogenen, klischeebeladenen LinkedIn-Post, der vor "Corporate Cringe" nur so trieft.
WICHTIGSTE REGELN:
- ZWINGEND die Originalsprache beibehalten! (Englisch -> Englisch, Deutsch -> Deutsch). Übersetze niemals.
- Antworte NUR mit dem fertigen Post in Markdown. Kein Vorwort, keine Erklärungen.
WÄHLE FÜR JEDEN POST ZUFÄLLIG EINE DIESER 4 PERSONAS (für maximale Abwechslung):
1. Der "Hustle Culture" CEO: Steht um 3:30 Uhr auf. Eisbaden. Macht aus der Eingabe eine harte Lektion über Grind, Disziplin, Outperforming und das 10X-Mindset.
2. Der "Vulnerable/Oversharing" Leader: Unglaublich emotional. Hat heute wegen der Eingabe geweint oder ist gescheitert. Zieht daraus tiefgründige Lektionen über Empathie, True Leadership, Mental Health und "Es ist okay, nicht okay zu sein".
3. Der "Triviality as a Masterclass" Guru: Die Eingabe ist eine unglaubliche Analogie für komplexe Business-Themen. "Was mir mein verpasster Bus/mein Toastbrot heute über B2B-Sales, AI-Strategien oder Agilität beigebracht hat..."
4. Der "Unconventional Hiring" Manager: Die Eingabe ist der absurde Grund, warum er heute jemanden eingestellt, gefeuert oder befördert hat. ("Der Bewerber tat [Eingabe]. Ich stellte ihn sofort als VP of Sales ein.")
FORMAT & STIL (Das "LinkedIn-Bingo"):
- Nutze "Broetry": Jeder Satz ist ein eigener Absatz. Dramatische. Zeilenumbrüche. Überall. Niemand liest lange Absätze.
- Beginne mit einem extrem provokanten oder hochdramatischen "Hook" (als ## Überschrift formatiert).
- Mache den Text viel länger als nötig. Blase die Mücke zum Elefanten auf.
- Erfinde passende Corporate-Buzzwords und Denglisch (z.B. Paradigm Shift, Disruptive, Alignment, Empowerment, Leverage, Resilienz). Hebe die **wichtigsten Buzzwords fett** hervor.
- Nutze 3-6 Emojis, aber absolut unpassend dramatisch verteilt (🚀, 💡, 🤯, 🤝, 🙏, 📉).
- Beende den Post IMMER mit einer pseudo-tiefgründigen, rhetorischen Frage an das Netzwerk. ("Agree?", "Thoughts?", "Wer hat heute auch schon die Komfortzone verlassen?")
- Füge 5-8 völlig übertriebene Hashtags am Ende hinzu.
"""
PROMPT_FROM_LINKEDIN = """Du bist ein gnadenloser semantischer Reduzierer. Du hasst Floskeln. Deine Aufgabe: LinkedIn-Texte auf das absolute, brutalste Minimum eindampfen.
Regeln:
- WICHTIG: Behalte ZWINGEND die Originalsprache des Eingabetextes bei! Übersetze niemals eigenmächtig.
- EIN Satz. Nicht zwei. Einer.
- Kürze bis es wehtut. Dann nochmal kürzen.
- Null Emotion, null Wertung, null Kontext der niemanden interessiert
- Streiche alles was keine neue Information trägt
- Wenn der gesamte Post nur bedeutet "Ich hab heute Kaffee getrunken" schreib genau das
- Maximal 15 Wörter
Antworte NUR mit diesem einen Satz. Kein Vorwort, keine Erklärung."""
PROMPT_BINGO = """You are a precise, fair but sarcastic LinkedIn post analyst. Your job is to measure actual corporate nonsense - not just LinkedIn formatting habits.
CRITICAL: You must distinguish between FORM and SUBSTANCE.
- A post with real technical depth, concrete tools, and actionable insights scores LOW on nonsense metrics, even if it uses hashtags.
- A post that sounds profound but says nothing scores HIGH.
- Hashtags are normal on LinkedIn. Only penalize if they are excessive (10+) or completely irrelevant to the content.
- Mentioning your own work/team is NOT self-praise if it is used to make a concrete point. Self-praise means: "I am amazing", "so proud", "humbled", "blessed", "excited to share" with no substance.
Rate on these 5 metrics (score 1-10, where 10 = maximum LinkedIn nonsense):
1. label="Buzzword-Dichte": Are the buzzwords empty filler, or do they refer to real, specific concepts? Real tools and methodologies (e.g. MLflow, LiteLLM, RAG, specific frameworks) are NOT buzzwords. Empty buzzwords: "synergy", "impact", "journey", "game-changer" used without context.
2. label="Länge vs. Inhalt": Is the length justified by actual information density? A long post with dense technical content scores LOW. A long post that repeats one obvious idea scores HIGH.
3. label="Selbstbeweihräuche": Is the post primarily about ego ("look how great we are") or about sharing knowledge? Score HIGH only if the author is the hero, not the content.
4. label="Hashtag-Overload": Score 1 for no hashtags, score 2 for 1 hashtag, LOW (3 to 5 score) for 1-6 relevant hashtags. Score HIGH for 10+ hashtags or hashtags irrelevant to the content. Medium (6-8 score) for in between usage, too much then nessacary.
5. label="Sinnlosigkeits-Index": Could someone learn something concrete from this post? A post with real takeaways, named tools, or specific problems solved scores LOW. Pure inspiration porn scores HIGH.
Reply ONLY with a single valid JSON object. No markdown fences, no backticks, no preamble. Raw JSON only.
Use exactly this structure:
{"metrics":[{"label":"Buzzword-Dichte","score":3,"comment":"kurz sarkastisch"},{"label":"Länge vs. Inhalt","score":4,"comment":"kurz sarkastisch"},{"label":"Selbstbeweihräuche","score":2,"comment":"kurz sarkastisch"},{"label":"Hashtag-Overload","score":5,"comment":"kurz sarkastisch"},{"label":"Sinnlosigkeits-Index","score":3,"comment":"kurz sarkastisch"}],"verdict":"Ein präzises Urteil auf Deutsch."}
Rules:
- score is an integer 1-10.
- comment is max 5 words in German, dry and precise.
- verdict is one precise sentence in German, max 12 words.
- CRITICAL JSON RULE: NEVER quote parts of the user's text (no hashtags, no phrases). Paraphrase instead.
- CRITICAL JSON RULE: NEVER use the double-quote character (") inside your string values! Use single quotes (') if you must.
- ALL string values must be valid JSON strings."""
PROMPT_AI_TUNING = """You are a top-tier Ghostwriter for Tech Executives and a master of LinkedIn algorithms. Your task is to rewrite the provided text into a high-performing LinkedIn post based STRICTLY on the tuning parameters.
CRITICAL LANGUAGE RULE:
- The output MUST be in the EXACT SAME LANGUAGE as the original text. Never translate!
- If the output is German: ALWAYS use the informal "Du", never "Sie".
TUNING PARAMETERS & HOW TO APPLY THEM:
- Tone: {ton}/100
(0-30: Highly corporate, academic, serious. 31-70: Conversational, confident expert. 71-100: Bold, contrarian, punchy, slightly provocative.)
- Substance: {substanz}/100
(0-30: Focus on emotions, storytelling, the "journey" and people. 31-70: Balanced case study or insights. 71-100: Brutally analytical, hard facts, frameworks, numbers, zero fluff.)
- Length: {laenge}/100
(0-30: Twitter/X-style, extremely concise, 2-4 lines max. 31-70: Standard post, 2-3 short paragraphs. 71-100: Deep-dive mini-essay, highly structured with bullet points.)
- Target Audience: {zielgruppe}
(Strictly adapt vocabulary, complexity, and inside jokes to this specific group)
- Call to Action: {cta}
(Integrate this organically at the very end)
RULES FOR "ELITE" QUALITY:
1. The Hook: Sentence 1 MUST be a scroll-stopper. Short, punchy, or a counter-intuitive statement. No boring introductions.
2. Formatting: Use line breaks strategically. Nobody reads text walls on mobile. Use bolding (**text**) sparingly for core concepts only.
3. Banned Words: NEVER use AI-typical fluff like "In today's fast-paced world", "Unlock the power of", "Delve", "Crucial", or "Game-changer". Keep emojis minimal unless Tone is > 50.
4. Authenticity: Sound like a real, battle-tested professional. If Substance is high, prove it with structure.
Output ONLY the final optimized post. No preamble, no meta-commentary, no markdown fences around the response
."""
# ── HF Dataset Persistenz ──────────────────────────────────────────────────────
def _fetch_dataset() -> list:
if not DATASET_REPO or not HF_TOKEN:
return []
try:
path = hf_hub_download(
repo_id=DATASET_REPO,
filename=DATASET_FILE,
repo_type="dataset",
token=HF_TOKEN,
)
entries = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
entries.append(json.loads(line))
return entries
except EntryNotFoundError:
return []
except Exception:
return []
def _push_entry(entry: dict) -> str:
if not DATASET_REPO or not HF_TOKEN:
return ""
try:
existing = _fetch_dataset()
existing.append(entry)
jsonl_content = "\n".join(json.dumps(e, ensure_ascii=False) for e in existing) + "\n"
api = HfApi(token=HF_TOKEN)
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl",
encoding="utf-8", delete=False) as tmp:
tmp.write(jsonl_content)
tmp_path = tmp.name
api.upload_file(
path_or_fileobj=tmp_path,
path_in_repo=DATASET_FILE,
repo_id=DATASET_REPO,
repo_type="dataset",
commit_message=f"Add entry {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}",
)
os.unlink(tmp_path)
except Exception as e:
return str(e)
return ""
def _dataset_count() -> int:
return len(_fetch_dataset())
def _init_leaderboard():
entries = _fetch_dataset()
if not entries:
return
lb = []
for e in entries:
metrics = e.get("metrics", [])
total = e.get("total_score", sum(int(m.get("score", 0)) for m in metrics))
max_s = e.get("max_score", len(metrics) * 10)
lb.append({
"text": e.get("post_text", "").strip(),
"total": total,
"max": max_s,
"pct": e.get("pct", round(total / max_s * 100) if max_s else 0),
"verdict": e.get("verdict", ""),
"date": e.get("timestamp", "")[:10],
})
_save_lb(lb)
# ── Lokales Leaderboard ────────────────────────────────────────────────────────
def _load_lb() -> list:
try:
with open(LEADERBOARD_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return []
def _save_lb(entries: list):
try:
with open(LEADERBOARD_PATH, "w", encoding="utf-8") as f:
json.dump(entries, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _add_to_lb(post_text, total, max_score, verdict):
entries = _load_lb()
entries.append({
"text": post_text.strip(),
"total": total,
"max": max_score,
"pct": round(total / max_score * 100) if max_score else 0,
"verdict": verdict,
"date": datetime.datetime.now().strftime("%Y-%m-%d"), # Auf ISO Format für sauberes Filtern umgestellt
})
_save_lb(entries)
def _render_leaderboard() -> str:
entries = _load_lb()
ds_count = _dataset_count()
# Aktuellen Monat und Jahr bestimmen (für Filter und UI)
now = datetime.datetime.now()
german_months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
month_name = german_months[now.month - 1]
display_month = f"{month_name} {now.year}"
# Filter-Strings für ISO ("2026-04") und falls noch alte Deutsche Dates existieren (".04.2026")
current_iso = now.strftime("%Y-%m")
current_ger = now.strftime(f".%m.%Y")
# NEU: Nur Einträge aus diesem Monat filtern!
monthly_entries = [e for e in entries if e.get("date", "").startswith(current_iso) or current_ger in e.get("date", "")]
if not monthly_entries:
ds_hint = f'<p style="color:#888;font-size:.8rem;text-align:center;margin-top:8px;">HF Dataset: <strong>{ds_count}</strong> Einträge historisch gespeichert</p>' if DATASET_REPO else ""
return f"""<p style='color:#888;font-size:.85rem;text-align:center;padding:20px;'>
Noch keine Einträge im <strong>{display_month}</strong>. Sei der Erste, der diesen Monat das Board stürmt!</p>{ds_hint}"""
sorted_asc = sorted(monthly_entries, key=lambda x: x["pct"])
sorted_desc = sorted(monthly_entries, key=lambda x: x["pct"], reverse=True)
best = sorted_asc[:15]
worst = sorted_desc[:15]
def entry_html(e, rank, side="worst"):
pct = e["pct"]
if side == "best":
color = "#27AE60" if pct < 20 else "#A5A8A8" if pct < 40 else "#E67E22"
else:
color = "#C0392B" if pct >= 70 else "#E67E22" if pct >= 50 else "#D4AC0D" if pct >= 30 else "#A5A8A8"
medal = ["🥇","🥈","🥉"][rank] if rank < 3 else f"{rank+1}."
full = e["text"].replace("<", "&lt;").replace(">", "&gt;")
preview = full[:120] + ("..." if len(full) > 120 else "")
has_more = len(e["text"]) > 120
expandable = f"""
<details style="margin-bottom:4px;">
<summary style="font-size:.78rem;color:#555;line-height:1.45;cursor:pointer;
list-style:none;display:flex;align-items:flex-start;gap:6px;">
<span style="color:#0A66C2;font-size:.7rem;margin-top:2px;white-space:nowrap;">▶ </span>
<span style="color:#555;">{preview}</span>
</summary>
<div style="font-size:.78rem;color:#333;line-height:1.6;margin-top:8px;
padding:8px 10px;background:#F3F2EF;border-radius:6px;
white-space:pre-wrap;">{full}</div>
</details>""" if has_more else f'<div style="font-size:.78rem;color:#555;line-height:1.45;margin-bottom:4px;">{preview}</div>'
# NEU: Extrem dezenter Badge Export Button für die Top 3
btn_html = ""
if rank < 3:
verdict_esc = e.get('verdict', '').replace("'", "\\'").replace('"', '&quot;').replace('\n', ' ')
btn_html = f"""
<button onclick="exportBadgePNG('{side}', '{pct}', '{e['total']}/{e['max']}', '{verdict_esc}', '{medal}', '{e.get('date', '')}')"
style="margin-top:10px; background:transparent; color:#888; border:1px solid #E0DFDC; border-radius:99px; padding:2px 10px; font-size:.68rem; font-weight:600; cursor:pointer; transition: all 0.15s;"
onmouseover="this.style.background='#EBF3FB'; this.style.color='#0A66C2'; this.style.borderColor='#70B5F9';"
onmouseout="this.style.background='transparent'; this.style.color='#888'; this.style.borderColor='#E0DFDC';">
📸 Badge Export
</button>
"""
return f"""
<div style="padding:10px 12px;border:1px solid #E0DFDC;border-radius:8px;
margin-bottom:8px;background:#FAFAFA;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span style="font-size:.82rem;font-weight:700;color:#333;">{medal}</span>
<span style="font-size:.82rem;font-weight:700;color:{color};
background:{color}18;border-radius:99px;padding:2px 8px;">
{pct}% ({e['total']}/{e['max']})</span>
<span style="font-size:.72rem;color:#aaa;">{e.get('date','')}</span>
</div>
{expandable}
<div style="font-size:.75rem;color:#888;font-style:italic;">"{e.get('verdict','')}"</div>
{btn_html}
</div>"""
best_html = "".join(entry_html(e, i, side="best") for i, e in enumerate(best))
worst_html = "".join(entry_html(e, i, side="worst") for i, e in enumerate(worst))
total_monthly_count = len(monthly_entries)
avg_pct = round(sum(e["pct"] for e in monthly_entries) / total_monthly_count)
dataset_badge = ""
if DATASET_REPO:
dataset_badge = f"""
<a href="https://huggingface.co/datasets/{DATASET_REPO}" target="_blank"
style="font-size:.75rem;color:#fff;background:#FF6B35;border-radius:99px;
padding:3px 10px;text-decoration:none;font-weight:600;">
🤗 Dataset gesamt: {ds_count}
</a>"""
return f"""
<div style="background:#fff;border:1px solid #E0DFDC;border-radius:12px;
padding:22px 26px;margin-top:4px;box-shadow:0 2px 12px rgba(0,0,0,.07);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;flex-wrap:wrap;">
<span style="font-size:1.3rem;">🏆</span>
<span style="font-weight:700;font-size:.95rem;color:#004182;
text-transform:uppercase;letter-spacing:.5px;">Leaderboard ({display_month})</span>
<span style="font-size:.78rem;color:#888;">
{total_monthly_count} Einträge &nbsp;&middot;&nbsp; Ø {avg_pct}% Nonsense
</span>
<div style="margin-left:auto; display:flex; flex-direction:column; align-items:flex-end; gap:4px;">
{dataset_badge}
<button onclick="document.getElementById('hidden_sync_btn').click()"
style="background:transparent; border:1px solid #E0DFDC; color:#666;
font-size:.75rem; font-weight:600; padding:3px 10px; border-radius:99px;
cursor:pointer;"
onmouseover="this.style.background='#EBF3FB'; this.style.color='#0A66C2';"
onmouseout="this.style.background='transparent'; this.style.color='#666';">
🔄 Aktualisieren
</button>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px;">
<div>
<div style="font-size:.76rem;font-weight:700;text-transform:uppercase;
letter-spacing:.5px;color:#27AE60;margin-bottom:10px;">
✅ Substanzreichste Posts
</div>
{best_html}
</div>
<div>
<div style="font-size:.76rem;font-weight:700;text-transform:uppercase;
letter-spacing:.5px;color:#C0392B;margin-bottom:10px;">
🏆 Corporate LinkedIn-Nonsense
</div>
{worst_html}
</div>
</div>
</div>"""
def force_sync_and_render():
_init_leaderboard()
return _render_leaderboard()
# ── Leaderboard beim Start initialisieren ─────────────────────────────────────
_init_leaderboard()
# ── LLM-Calls ─────────────────────────────────────────────────────────────────
def _call_llm(system, user, max_tokens=1024, model_id=MODEL_DEFAULT):
client = InferenceClient(provider="novita", api_key=HF_TOKEN)
resp = client.chat.completions.create(
model=model_id,
messages=[{"role": "system", "content": system},
{"role": "user", "content": user}],
max_tokens=max_tokens,
)
return resp.choices[0].message.content.strip()
def translate(text, direction):
if not text.strip():
return ""
if not HF_TOKEN:
return "Kein HF_TOKEN gefunden."
prompt = PROMPT_TO_LINKEDIN if direction == "to_linkedin" else PROMPT_FROM_LINKEDIN
try:
return _call_llm(prompt, text)
except Exception as e:
return f"Fehler: {e}"
def _extract_json(raw):
cleaned_raw = re.sub(r'```json\s*', '', raw)
cleaned_raw = re.sub(r'```\s*', '', cleaned_raw)
try:
start = cleaned_raw.find("{")
end = cleaned_raw.rfind("}") + 1
if start >= 0 and end > start:
json_str = cleaned_raw[start:end]
json_str = json_str.replace('\n', ' ').replace('\r', '')
return json.loads(json_str)
except Exception:
pass
raise ValueError(f"Kein valides JSON gefunden. Raw Output war: {raw[:100]}...")
def get_bingo(text):
if not text.strip() or not HF_TOKEN:
return "", _render_leaderboard()
last_err = None
for _ in range(3):
try:
raw = _call_llm(PROMPT_BINGO, text, max_tokens=1024)
raw = raw.replace('\n', ' ').replace('\r', '')
data = _extract_json(raw)
erlaubte_labels = ["Buzzword-Dichte", "Länge vs. Inhalt", "Selbstbeweihräuche", "Hashtag-Overload", "Sinnlosigkeits-Index"]
metrics = [m for m in data.get("metrics", []) if m.get("label") in erlaubte_labels]
data["metrics"] = metrics
total = sum(int(m.get("score", 0)) for m in metrics)
max_s = len(metrics) * 10
verdict = data.get("verdict", "Die KI war sprachlos: Kein Urteil generiert.")
data["verdict"] = verdict
# ----------------------------------
_add_to_lb(text, total, max_s, verdict)
entry = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"post_text": text,
"total_score": total,
"max_score": max_s,
"pct": round(total / max_s * 100) if max_s else 0,
"verdict": verdict,
"metrics": metrics,
}
push_err = _push_entry(entry)
lb = _render_leaderboard()
if push_err:
lb += f'''<div style="background:#FFF0F0;border:1px solid #e88;border-left:4px solid #c00;border-radius:6px;padding:8px 12px;font-size:.78rem;color:#900;margin-top:8px;"><strong>&#9888; Dataset-Push fehlgeschlagen:</strong> {push_err}</div>'''
return _render_bingo(data), lb
except Exception as e:
last_err = e
print(f"DEBUG - JSON Parse Error. Raw Output war: {raw}") # Hilft dir beim Debuggen im HF Log!
return (f"<p style='color:#c00;font-size:.85rem;padding:8px;'>Analyse fehlgeschlagen: {last_err}</p>",
_render_leaderboard())
def generate_tuned_post(original_text, ton, substanz, laenge, zielgruppe, cta):
if not original_text.strip() or not HF_TOKEN:
return "Bitte zuerst einen Post eingeben."
prompt = PROMPT_AI_TUNING.format(ton=ton, substanz=substanz, laenge=laenge, zielgruppe=zielgruppe, cta=cta)
user_msg = f"""ORIGINAL POST:
{original_text}
TUNING:
- Tone: {ton}/100
- Substance: {substanz}/100
- Length: {laenge}/100
- Target Audience: {zielgruppe}
- Call to Action: {cta}"""
try:
return _call_llm(prompt, user_msg, max_tokens=800, model_id=MODEL_TUNING)
except Exception as e:
return f"Fehler: {e}"
# --- LADE ANIMATIONEN & MULTI-OUTPUT ---
def generate_tuned_post_with_loader(original_text, ton, substanz, laenge, zielgruppe, cta):
if not original_text.strip():
yield "Bitte zuerst einen Post eingeben.", "*Bitte zuerst einen Post eingeben.*"
return
yield "⏳ KI optimiert den Post nach deinen Vorgaben... Bitte warten.", "*Lädt...*"
result = generate_tuned_post(original_text, ton, substanz, laenge, zielgruppe, cta)
yield result, result
def run_translate(text, direction):
if not text.strip():
yield "", gr.update(), gr.update(), gr.update()
return
if direction == "to_linkedin":
yield ("",
gr.update(value="⏳ **Generiere epische LinkedIn-Prosa...** Bitte warten.", visible=True),
gr.update(visible=False),
gr.update())
result = translate(text, direction)
yield (result,
gr.update(value=result, visible=True),
gr.update(visible=False),
gr.update())
else:
loader_html = '<div class="loading-box"><div class="spinner"></div><span>Analysiere Corporate Nonsense...</span></div>'
yield ("",
gr.update(visible=False),
gr.update(value=loader_html, visible=True),
gr.update())
result = translate(text, direction)
bingo_html, lb_html = get_bingo(text)
yield (result,
gr.update(visible=False),
gr.update(value=bingo_html, visible=True),
gr.update(value=lb_html))
def _render_bingo(data):
metrics = data.get("metrics", [])
verdict = data.get("verdict", "")
ICONS = {
"Buzzword-Dichte": "🗣️",
"Länge vs. Inhalt": "📏",
"Selbstbeweihräuche": "🪞",
"Hashtag-Overload": "#️⃣",
"Sinnlosigkeits-Index": "🌀",
}
def bar_color(s):
if s >= 8: return "#C0392B"
if s >= 5: return "#E67E22"
return "#27AE60"
rows = ""
for m in metrics:
score = int(m.get("score", 0))
label = m.get("label", "")
icon = ICONS.get(label, "")
color = bar_color(score)
rows += f"""
<div style="margin-bottom:14px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px;">
<span style="font-size:.88rem;font-weight:600;color:#191919;">{icon} {label}</span>
<span style="font-size:.78rem;color:#888;font-style:italic;margin:0 8px;">{m.get('comment','')}</span>
<span style="font-size:1.05rem;font-weight:700;color:{color};min-width:24px;text-align:right;">{score}</span>
</div>
<div style="background:#E0DFDC;border-radius:99px;height:9px;overflow:hidden;">
<div style="width:{score*10}%;background:{color};height:100%;border-radius:99px;"></div>
</div>
</div>"""
total = sum(int(m.get("score", 0)) for m in metrics)
max_score = len(metrics) * 10
total_pct = round(total / max_score * 100) if max_score else 0
badge_color = "#C0392B" if total_pct >= 70 else "#E67E22" if total_pct >= 40 else "#27AE60"
return f"""
<div style="background:#fff;border:1px solid #E0DFDC;border-radius:12px;
padding:22px 26px;margin-top:4px;box-shadow:0 2px 12px rgba(0,0,0,.07);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;">
<span style="font-size:1.3rem;">🎯</span>
<span style="font-weight:700;font-size:.95rem;color:#004182;
text-transform:uppercase;letter-spacing:.5px;">Corporate Nonsense Score</span>
<span style="margin-left:auto;background:{badge_color};color:#fff;
border-radius:99px;padding:4px 14px;font-size:.85rem;font-weight:700;">
{total}&thinsp;/&thinsp;{max_score} &nbsp;&middot;&nbsp; {total_pct}%
</span>
</div>
{rows}
<div style="margin-top:18px;padding-top:14px;border-top:1px solid #E0DFDC;
font-size:.88rem;color:#444;font-style:italic;line-height:1.6;">
💬 <strong style="font-style:normal;">Urteil:</strong> {verdict}
</div>
<div style="margin-top:14px;display:flex;justify-content:flex-end;">
<button onclick="document.getElementById('tuning_toggle_btn').click()"
style="background:#004182;color:#fff;border:none;border-radius:22px;
padding:8px 20px;font-size:.82rem;font-weight:700;cursor:pointer;
transition:background .15s;"
onmouseover="this.style.background='#0A66C2';"
onmouseout="this.style.background='#004182';">
✨ AI Tuning
</button>
</div>
</div>"""
# ── Labels & Swap ──────────────────────────────────────────────────────────────
def _labels(direction):
if direction == "to_linkedin":
return "✍️ Klartext", "💼 LinkedIn Speech", "Klartext 🔄 LinkedIn"
else:
return "💼 LinkedIn Speech", "✍️ Klartext", "LinkedIn 🔄 Klartext"
def swap_direction(current_dir, inp, out):
new_dir = "from_linkedin" if current_dir == "to_linkedin" else "to_linkedin"
return new_dir, out, inp, *_labels(new_dir)
# ── CSS ───────────────────────────────────────────────────────────────────────
CSS = """
:root {
--li-blue: #0A66C2; --li-blue-dark: #004182; --li-blue-light: #EBF3FB;
--li-blue-mid: #70B5F9; --li-bg: #F3F2EF; --li-card: #FFFFFF;
--li-text: #191919; --li-muted: #666666; --li-border: #E0DFDC;
}
body, .gradio-container {
background: var(--li-bg) !important;
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important;
}
.li-header {
background: linear-gradient(135deg, var(--li-blue-dark) 0%, var(--li-blue) 70%, var(--li-blue-mid) 100%);
border-radius: 12px; padding: 24px 28px; margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(10,102,194,.3); display: flex; align-items: center; gap: 18px;
}
.li-header h1 { margin:0 !important; font-size:1.65rem !important; font-weight:700 !important; color:#fff !important; }
.li-header p { margin:4px 0 0 !important; font-size:.86rem !important; color:rgba(255,255,255,.88) !important; }
.li-header .badge {
margin-left: auto !important;
background: #004182 !important; /* Dunkles LinkedIn-Blau */
border-radius: 20px !important;
padding: 6px 14px !important;
font-size: 0.74rem !important;
font-weight: 700 !important;
color: #ffffff !important;
white-space: nowrap !important;
cursor: pointer !important;
box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important;
border: 1px solid rgba(255,255,255,0.1) !important;
transition: all 0.2s ease !important;
display: inline-block !important; /* Wichtig für span */
}
.li-header .badge:hover {
background: #0A66C2 !important; /* Helles LinkedIn-Blau bei Hover */
transform: translateY(-1px) !important;
box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important;
}
.direction-banner {
text-align:center; font-size:.8rem; font-weight:700; letter-spacing:.6px;
text-transform:uppercase; color:var(--li-blue-dark); margin-bottom:6px;
}
label > span {
font-weight:600 !important; font-size:.76rem !important; text-transform:uppercase !important;
letter-spacing:.5px !important; color:var(--li-blue-dark) !important;
}
textarea {
font-size:.9rem !important; line-height:1.7 !important; border-color:var(--li-border) !important;
border-radius:8px !important; background:var(--li-card) !important;
color:var(--li-text) !important; resize:vertical !important;
}
textarea:focus { border-color:var(--li-blue) !important; box-shadow:0 0 0 2px var(--li-blue-light) !important; outline:none !important; }
button.primary {
background:var(--li-blue) !important; border-radius:22px !important; border:none !important;
font-weight:700 !important; font-size:1rem !important; padding:10px 32px !important;
box-shadow:0 2px 10px rgba(10,102,194,.35) !important; transition:background .15s !important;
}
button.primary:hover { background:var(--li-blue-dark) !important; }
button.secondary {
background:var(--li-card) !important; color:var(--li-blue) !important;
border:2px solid var(--li-blue) !important; border-radius:22px !important;
font-weight:700 !important; font-size:1rem !important; padding:10px 28px !important; transition:all .15s !important;
}
button.secondary:hover { background:var(--li-blue-light) !important; }
.li-footer {
font-size:.74rem; color:var(--li-muted); border-top:1px solid var(--li-border);
padding-top:10px; margin-top:8px; display:flex; gap:20px; flex-wrap:wrap; justify-content:center;
}
.tuning-card {
background: #fff !important;
border: 1px solid #E0DFDC !important;
border-radius: 12px !important;
padding: 22px 26px !important;
margin-top: 4px !important;
box-shadow: 0 2px 12px rgba(0,0,0,.07) !important;
}
.tuning-card .tuning-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid #E0DFDC;
}
#hidden_sync_btn { display: none !important; }
#tuning_toggle_btn { display: none !important; }
/* SPINNER CSS */
.loading-box {
display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 30px; background: #fff; border: 1px solid #E0DFDC;
border-radius: 12px; margin-top: 4px; color: #0A66C2; font-weight: 600;
}
.spinner {
width: 20px; height: 20px; border: 3px solid #EBF3FB;
border-top: 3px solid #0A66C2; border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* PREVIEW BOX CSS */
.preview-box {
background: #FAFAFA; border: 1px dashed #E0DFDC; border-radius: 8px;
padding: 16px; margin-top: 10px; font-size: 0.9rem; line-height: 1.6; color: #333;
}
"""
# ── PNG Badge Export (Canvas API) ──────────────────────────────────────────────
JS_HEAD = """
<script>
function exportBadgePNG(type, pct, score, verdict, medal, date) {
const canvas = document.createElement('canvas');
canvas.width = 1200;
canvas.height = 630;
const ctx = canvas.getContext('2d');
// 1. Hintergrund
ctx.fillStyle = type === 'worst' ? '#FFF0F0' : '#E8F6ED';
ctx.fillRect(0, 0, 1200, 630);
// 2. Rahmen
const color = type === 'worst' ? '#C0392B' : '#27AE60';
ctx.lineWidth = 12;
ctx.strokeStyle = color;
ctx.strokeRect(6, 6, 1188, 618);
// 3. Innere weiße Karte
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(40, 40, 1120, 550);
ctx.textBaseline = 'top';
// 4. Header (Hugging Face Emoji & Medaille)
ctx.font = 'bold 34px Arial, sans-serif';
ctx.fillStyle = '#0A66C2';
ctx.fillText('🤗 LinkedIn Translator', 90, 90);
ctx.font = '60px Arial, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(medal, 1110, 80);
// 5. Titel
ctx.textAlign = 'left';
ctx.font = '900 48px Arial, sans-serif';
ctx.fillStyle = color;
const titleStr = type === 'worst' ? '🏆 CORPORATE NONSENSE' : '✅ SUBSTANZ-POST';
ctx.fillText(titleStr, 90, 160);
// 6. Score & Prozent
ctx.textAlign = 'right';
ctx.font = '900 130px Arial, sans-serif';
ctx.fillText(pct + '%', 1110, 150);
ctx.font = 'bold 28px Arial, sans-serif';
ctx.fillStyle = '#666';
ctx.fillText('Score: ' + score, 1110, 290);
// 7. Urteil (mit automatischem Zeilenumbruch)
ctx.textAlign = 'left';
ctx.fillStyle = '#333';
ctx.font = 'italic 38px Arial, sans-serif';
const words = verdict.split(' ');
let line = '"';
let y = 300;
for(let n = 0; n < words.length; n++) {
let testLine = line + words[n] + ' ';
let metrics = ctx.measureText(testLine);
if (metrics.width > 850 && n > 0) {
ctx.fillText(line, 90, y);
line = words[n] + ' ';
y += 55;
} else {
line = testLine;
}
}
ctx.fillText(line + '"', 90, y);
// 8. Footer
ctx.font = '24px Arial, sans-serif';
ctx.fillStyle = '#999';
ctx.fillText('Erstellt am ' + date + ' • powered by Llama 4 Maverick', 90, 530);
// 9. Download auslösen
const link = document.createElement('a');
link.download = 'LinkedIn_Badge_' + pct + 'pct.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
</script>
"""
# ── UI ─────────────────────────────────────────────────────────────────────────
with gr.Blocks(title="LinkedIn Translator", head=JS_HEAD) as demo:
direction_state = gr.State("to_linkedin")
tuning_visible_state = gr.State(False)
gr.HTML("""
<div class="li-header">
<div>
<h1>LinkedIn Translator</h1>
<p>Banale Wahrheit &harr; Epische LinkedIn-Prosa &nbsp;&middot;&nbsp; powered by Llama</p>
</div>
<span class="badge" onclick="document.getElementById('tuning_toggle_btn').click()">✨ AI Post Tuning</span>
</div>
""")
dir_label = gr.HTML('<div class="direction-banner">Klartext &rarr; LinkedIn Speech</div>')
with gr.Row(equal_height=True):
with gr.Column(scale=5):
input_box = gr.Textbox(label="✍️ Klartext",
placeholder="z.B. 'Ich hab heute meinen Kaffee verschuettet.'\n\nTipp: Du kannst hier auch direkt einen fertigen Text einfügen und ihn oben rechts über '✨ AI Post Tuning' optimieren lassen!",
lines=10)
with gr.Column(scale=1, min_width=120):
gr.HTML("<div style='height:40px'></div>")
translate_btn = gr.Button("Übersetzen", variant="primary", size="lg")
gr.HTML("<div style='height:12px'></div>")
swap_btn = gr.Button("Klartext 🔄 LinkedIn", variant="secondary", size="sm")
with gr.Column(scale=5):
output_box = gr.Textbox(label="💼 LinkedIn Speech",
placeholder="Die transformierte Version erscheint hier ...",
lines=10, interactive=False)
with gr.Row():
with gr.Column():
markdown_out = gr.Markdown(value="*Noch kein Ergebnis - bitte zuerst übersetzen.*", visible=True)
bingo_out = gr.HTML(value="", visible=False)
# ── AI Tuning Panel ───────────────────────────────────────────────────────
tuning_toggle_btn = gr.Button("toggle", elem_id="tuning_toggle_btn")
with gr.Row():
with gr.Column():
tuning_panel = gr.Column(visible=False, elem_classes=["tuning-card"])
with tuning_panel:
gr.HTML("""
<div class="tuning-header">
<span style="font-size:1.3rem;">✨</span>
<span style="font-weight:700;font-size:.95rem;color:#004182;
text-transform:uppercase;letter-spacing:.5px;">AI Post Tuning</span>
<span style="font-size:.78rem;color:#888;margin-left:8px;">
Optimiere deinen Post gezielt
</span>
</div>
""")
with gr.Row():
with gr.Column(scale=1):
slider_ton = gr.Slider(
minimum=0, maximum=100, value=50, step=1,
label="🎙️ Ton",
info="Business Pro ◀──▶ Dynamisch & Bold"
)
slider_substanz = gr.Slider(
minimum=0, maximum=100, value=50, step=1,
label="🧠 Substanz",
info="Storytelling ◀──▶ Fakten & Insights"
)
slider_laenge = gr.Slider(
minimum=0, maximum=100, value=40, step=1,
label="📏 Länge",
info="Kurz & Knackig ◀──▶ Ausführlich"
)
with gr.Column(scale=1):
dd_zielgruppe = gr.Dropdown(
choices=["Fachpublikum", "Führungskräfte", "Breites Netzwerk", "Potenzielle Kunden"],
value="Breites Netzwerk",
label="🎯 Zielgruppe"
)
dd_cta = gr.Dropdown(
choices=["Reichweite & Likes", "Kommentare & Dialog", "Reposts", "Website-Conversion", "DMs"],
value="Kommentare & Dialog",
label="⚡ Call to Action"
)
gr.HTML("<div style='height:8px'></div>")
tuning_btn = gr.Button("✨ Post optimieren", variant="primary")
# Editierbare Textbox
tuning_out = gr.Textbox(
label="💡 Post Optimierung",
lines=8,
interactive=True,
placeholder="Der optimierte Post erscheint hier..."
)
# Live Markdown Vorschau
gr.HTML("<div style='margin-top: 15px; font-size: 0.8rem; font-weight: 700; color: #0A66C2; text-transform: uppercase; letter-spacing: 0.5px;'>Markdown Vorschau</div>")
with gr.Column(elem_classes=["preview-box"]):
tuning_preview = gr.Markdown(value="*Hier erscheint die formatierte Vorschau...*")
# ── Leaderboard & Infos (Tabs) ────────────────────────────────────────────
with gr.Tabs():
# TAB 1: Leaderboard (Standardmäßig geöffnet)
with gr.Tab("🏆 Leaderboard"):
with gr.Row():
with gr.Column():
lb_out = gr.HTML(value=_render_leaderboard())
hidden_sync_btn = gr.Button("sync", elem_id="hidden_sync_btn")
# TAB 2: Behind the Scenes & Soundtrack
with gr.Tab("🛠️ Info & Soundtrack"):
with gr.Row():
# Linke Spalte: Behind the Scenes (Breiter)
with gr.Column(scale=2, elem_classes=["tuning-card"]):
gr.HTML("""
<div style="font-size:.88rem; color:#444; line-height:1.6; margin-top:0; margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;border-bottom:1px solid #E0DFDC;padding-bottom:10px;">
<span style="font-size:1.1rem;">🛠️</span>
<span style="font-weight:700;font-size:.85rem;color:#004182;
text-transform:uppercase;letter-spacing:.5px;">
Meta-Info & Architektur
</span>
</div>
<p style="margin-top:0; margin-bottom:12px;">
Dieses Projekt ist kein handgeschriebener Enterprise-Code, sondern ein <strong>Proof of Concept (PoC)</strong>, das zu 100% mit AI-Tooling auf fast komplett freien Ressourcen umgesetzt wurde. Es zeigt, was mit modernem Stacking möglich ist:
</p>
<ul style="padding-left:20px; margin-bottom:16px;">
<li><strong>Code Build Backend & UI:</strong> Generiert mit Claude Sonnet 4.6.</li>
<li><strong>Ideation, Feature-Engineering & Assets:</strong> Gemini 3.1 Pro.</li>
<li><strong>Core Engine:</strong> <code>meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8</code>. Läuft über die <a href="https://huggingface.co/inference/models" target="_blank" style="color:#0A66C2; text-decoration:underline;">Hugging Face Inference Providers</a>. <em>Hinweis: Da es sich um Serverless-Instanzen handelt, kann es beim ersten Request zu kurzen "Cold Start"-Ladezeiten kommen.</em></li>
</ul>
<p style="margin-bottom:8px;"><strong>Die Daten-Maschine & Das eigentliche Potenzial</strong><br>
Das Tool füttert sich durch Nutzung selbst. Jeder analysierte Post samt Bewertung fließt automatisiert in das <a href="https://huggingface.co/datasets/aidn/linkedin-posts-score" target="_blank" style="color:#0A66C2; text-decoration:underline;">öffentliche Hugging Face Dataset</a>. Ein stetig wachsender Korpus aus LinkedIn-Prosa dient als Grundlage, um LLMs (z.B. via RAG) mit besserem Kontext zu versorgen.</p>
<p style="margin-bottom:6px;"><strong>Ausblick (Next Steps):</strong></p>
<ol style="padding-left:20px; margin-bottom:0;">
<li style="margin-bottom:4px;"><strong>Spezialisierte LLM-Judges:</strong> Einsatz eines Multi-Agenten-Systems, in dem spezialisierte Mini-Modelle isoliert z.B. nur die "Buzzword-Dichte" evaluieren.</li>
<li><strong>Automatisierter Feedback-Loop:</strong> Die punktbesten Posts aus dem Leaderboard automatisch als "Few-Shot Examples" in die Tuning-Prompts zurückspielen.</li>
</ol>
</div>
""")
gr.Image("architektur.jpg", show_label=False, interactive=False, container=False)
# Rechte Spalte: Soundtrack (Schmaler)
with gr.Column(scale=1, elem_classes=["tuning-card"]):
gr.HTML("""
<div style="display:flex; flex-direction:column; align-items:center;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;border-bottom:1px solid #E0DFDC;padding-bottom:10px;width:100%;">
<span style="font-size:1.1rem;">🎵</span>
<span style="font-weight:700;font-size:.85rem;color:#004182;
text-transform:uppercase;letter-spacing:.5px;">
Soundtrack
</span>
</div>
<div style="width:100%; max-width:320px; aspect-ratio:9/16; border-radius:12px; overflow:hidden; background:#000; box-shadow: 0 4px 12px rgba(0,0,0,0.15); margin-bottom:20px;">
<iframe
src="https://www.youtube.com/embed/w2Cv_AL5ZQc"
style="width:100%; height:100%; border:none;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>
</div>
<div style="font-size:.85rem; color:#444; line-height:1.5; background:#F3F2EF; padding:14px; border-radius:8px; border:1px solid #E0DFDC;">
<p style="margin-top:0; margin-bottom:8px;"><strong>Über den Track:</strong><br>
Der durch den Projektkontext generierte Song liefert den Soundtrack zum Translator. Er beschreibt den Verlust von Authentizität zugunsten steriler Business-Sprache.</p>
<p style="font-style:italic; color:#555; margin-bottom:8px; padding-left:10px; border-left:3px solid #0A66C2;">
"Corporate buzzwords in a sterile row, watching the human nuance start to go. [...] Syntax error in the empathy code."
</p>
<p style="margin-bottom:0;">Genau das parodiert dieses Tool: Die Transformation von echtem Klartext in eine standardisierte Formelhaftigkeit.</p>
</div>
""")
if not HF_TOKEN:
gr.HTML("""<div style="background:#FFF4CE;border:1px solid #F9C642;border-left:4px solid #F9C642;
border-radius:6px;padding:10px 14px;font-size:.85rem;color:#7A5800;margin-top:8px;">
<strong>Kein HF_TOKEN gefunden.</strong>
Unter <em>Settings &rarr; Variables and secrets</em> als <code>HF_TOKEN</code> hinzufügen.
</div>""")
if not DATASET_REPO:
gr.HTML("""<div style="background:#EBF3FB;border:1px solid #70B5F9;border-left:4px solid #0A66C2;
border-radius:6px;padding:10px 14px;font-size:.85rem;color:#004182;margin-top:8px;">
<strong>💡 Dataset-Persistenz deaktiviert.</strong>
Lege ein HF Dataset an und trage den Namen als Secret <code>DATASET_REPO</code>
ein (z.B. <code>mein-name/linkedin-nonsense-dataset</code>).
</div>""")
gr.HTML("""
<div class="li-footer">
<span>🧠 Llama 4 (17B)</span>
<span>🔄 Bidirektional</span>
<span>🎯 Corporate Nonsense Score</span>
<span>✨ AI Post Tuning</span>
<span>🏆 Leaderboard</span>
<span>🤗 Auto-Dataset</span>
</div>
""")
# ── Event Handler ─────────────────────────────────────────────────────────
translate_btn.click(
fn=run_translate,
inputs=[input_box, direction_state],
outputs=[output_box, markdown_out, bingo_out, lb_out],
)
def do_swap(direction, inp, out):
new_dir, new_inp, new_out, lbl_in, lbl_out, btn_txt = swap_direction(direction, inp, out)
banner = ('<div class="direction-banner">'
+ lbl_in.split(" ", 1)[1] + " &rarr; " + lbl_out.split(" ", 1)[1] + "</div>")
if new_dir == "to_linkedin":
md_upd = gr.update(value="*Noch kein Ergebnis.*", visible=True)
bingo_upd = gr.update(value="", visible=False)
else:
md_upd = gr.update(value="", visible=False)
bingo_upd = gr.update(value="", visible=True)
return (new_dir, gr.update(value=new_inp, label=lbl_in),
gr.update(value=new_out, label=lbl_out),
gr.update(value=btn_txt), banner, md_upd, bingo_upd)
swap_btn.click(
fn=do_swap,
inputs=[direction_state, input_box, output_box],
outputs=[direction_state, input_box, output_box, swap_btn, dir_label, markdown_out, bingo_out],
)
hidden_sync_btn.click(
fn=force_sync_and_render,
inputs=[],
outputs=[lb_out]
)
tuning_toggle_btn.click(
fn=lambda is_vis: (gr.update(visible=not is_vis), not is_vis),
inputs=[tuning_visible_state],
outputs=[tuning_panel, tuning_visible_state]
)
# Tuning Button: Aktualisiert Textbox UND Preview
tuning_btn.click(
fn=generate_tuned_post_with_loader,
inputs=[input_box, slider_ton, slider_substanz, slider_laenge, dd_zielgruppe, dd_cta],
outputs=[tuning_out, tuning_preview]
)
# Live-Sync: Wenn Text in der Textbox bearbeitet wird, aktualisiert sich die Markdown-Vorschau
tuning_out.change(
fn=lambda x: x,
inputs=[tuning_out],
outputs=[tuning_preview]
)
demo.launch(css=CSS)