Spaces:
Running
Running
| 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("<", "<").replace(">", ">") | |
| 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('"', '"').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 · Ø {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>⚠ 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} / {max_score} · {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 ↔ Epische LinkedIn-Prosa · 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 → 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 → 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] + " → " + 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) |