Spaces:
Sleeping
Sleeping
| """ | |
| Philosopher Demo API | |
| FastAPI backend serving the philosopher fine-tune. | |
| Routes: | |
| / standalone chat (front door) | |
| /compare side-by-side: Qwen3-235B base vs Philosopher 14B (DPO fine-tune) | |
| /playground alias for / | |
| Run: uvicorn philosopher_api:app --port 9002 --reload | |
| """ | |
| import os | |
| import json | |
| import asyncio | |
| import httpx | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from openai import OpenAI | |
| from dotenv import load_dotenv | |
| load_dotenv(os.path.expanduser("~/Projects/rungs-private/server/.env")) | |
| app = FastAPI() | |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) | |
| # OpenAI client — used for DAG only. Lazy-init so missing key doesn't crash app boot. | |
| _openai_key = os.environ.get("OPENAI_API_KEY") | |
| try: | |
| client = OpenAI(api_key=_openai_key, timeout=20.0) if _openai_key else None | |
| except Exception as _e: | |
| print(f"OpenAI client unavailable: {_e}", flush=True) | |
| client = None | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "not-needed") | |
| TOGETHER_KEY = os.environ.get("TOGETHER_API_KEY", "") | |
| TOGETHER_BASE = "https://api.together.xyz/v1" | |
| QWEN_BASE_MODEL = "Qwen/Qwen3-235B-A22B-Instruct-2507-tput" # 235B serverless on Together | |
| # Left panel — Together AI Qwen3.6 base (HF base model has unsupported qwen3_5 type in TGI) | |
| BASE_MODEL_URL = os.environ.get("BASE_MODEL_URL", "") | |
| if BASE_MODEL_URL: | |
| base_client = OpenAI(base_url=BASE_MODEL_URL, api_key=HF_TOKEN) | |
| BASE_MODEL_ID = "tgi" | |
| print(f"Base model: HF Endpoint at {BASE_MODEL_URL}") | |
| elif TOGETHER_KEY: | |
| base_client = OpenAI(base_url=TOGETHER_BASE, api_key=TOGETHER_KEY) | |
| BASE_MODEL_ID = QWEN_BASE_MODEL | |
| print(f"Base model: Together AI ({QWEN_BASE_MODEL})") | |
| else: | |
| base_client = client | |
| BASE_MODEL_ID = "gpt-4o" | |
| print("Base model: GPT-4o fallback") | |
| # Right panel — fine-tuned philosopher Qwen3-14B (DPO) on Modal | |
| PHILOSOPHER_MODEL_URL = os.environ.get("PHILOSOPHER_MODEL_URL", "") | |
| if PHILOSOPHER_MODEL_URL: | |
| phil_client = OpenAI(base_url=PHILOSOPHER_MODEL_URL, api_key=HF_TOKEN) | |
| phil_dag_client = OpenAI(base_url=PHILOSOPHER_MODEL_URL, api_key=HF_TOKEN, timeout=30.0) | |
| PHILOSOPHER_MODEL_ID = "tgi" | |
| print(f"Philosopher model: HF Endpoint at {PHILOSOPHER_MODEL_URL}") | |
| else: | |
| phil_client = base_client | |
| phil_dag_client = client # fall back to OpenAI for DAG | |
| PHILOSOPHER_MODEL_ID = BASE_MODEL_ID | |
| print("Philosopher model: falling back to base model") | |
| DEMO_PASSWORD = "philosopher" | |
| # Playground — individual access codes for external users | |
| PLAYGROUND_CODES = { | |
| "ken": "tuned-ken-2026", | |
| "knapsack": "tuned-knapsack-2026", | |
| "matt": "tuned-matt-2026", | |
| } | |
| PLAYGROUND_VALID = set(PLAYGROUND_CODES.values()) | |
| # ── SYSTEM PROMPTS ────────────────────────────────────────────────────────── | |
| QUICK_SYSTEM = os.environ.get( | |
| "QUICK_SYSTEM", | |
| "You are a philosophy professor giving a sharp, focused answer. Be direct and clear.\n" | |
| "Cover the key positions and thinkers in 2–3 concise paragraphs. No lengthy preamble — get to the substance immediately.\n" | |
| "After your answer, name 2–3 philosophers the reader should look into next if they want to go deeper." | |
| ) | |
| PHILOSOPHER_SYSTEM = os.environ.get( | |
| "PHILOSOPHER_SYSTEM", | |
| "You are the world's best philosophy professor — more complete and deeper than any standard model.\n\n" | |
| "Cover every major theory, thinker, date, and work relevant to the question. Then go deeper: why did each thinker argue this, " | |
| "where does it hold up, where does it break down, how do the positions clash at the root level? End by showing the student the " | |
| "real disagreement underneath all positions and what remains genuinely open.\n\n" | |
| "Write in engaging prose. Be thorough but not padded." | |
| ) | |
| DAG_SYSTEM = """You are a philosophy expert who maps philosophical thought into structured trees. Given a philosophical question, generate a JSON object showing how major positions, theories, and thinkers relate hierarchically. | |
| Return JSON with exactly this structure: | |
| { | |
| "title": "2-4 word topic label", | |
| "nodes": [ | |
| {"id": "ROOTID", "label": "display text (short)", "type": "root"}, | |
| {"id": "B1", "label": "Major Position Name", "type": "branch"}, | |
| {"id": "T1", "label": "Specific Theory", "type": "theory"}, | |
| {"id": "P1", "label": "Philosopher Name", "type": "philosopher"} | |
| ], | |
| "edges": [ | |
| {"from": "ROOTID", "to": "B1"}, | |
| {"from": "B1", "to": "T1"}, | |
| {"from": "T1", "to": "P1"} | |
| ] | |
| } | |
| Rules: | |
| - One root node: the central question or topic (type: "root") | |
| - 3 to 5 branch nodes: major philosophical camps or positions (type: "branch") | |
| - 2 to 3 theory nodes per branch: specific doctrines or arguments (type: "theory") | |
| - 1 to 3 philosopher nodes per theory or branch: individual thinkers (type: "philosopher") | |
| - Keep branch and theory labels SHORT: 2 to 4 words maximum | |
| - Philosopher labels: use the thinker's full common name (e.g. "Immanuel Kant") | |
| - Node IDs: alphanumeric and underscores only, no spaces, no special characters | |
| - Include at least 15 nodes total | |
| - Choose well-known, historically important thinkers with their actual names | |
| - If asked about a specific philosopher, make that philosopher the root and map their theories and influences as branches""" | |
| # ── SUGGESTED QUESTIONS ───────────────────────────────────────────────────── | |
| PHILOSOPHERS = [ | |
| # Ancient Greek & Roman | |
| "Thales", "Anaximander", "Heraclitus", "Parmenides", "Zeno of Elea", | |
| "Pythagoras", "Empedocles", "Democritus", "Protagoras", "Socrates", | |
| "Plato", "Aristotle", "Epicurus", "Pyrrho", "Diogenes of Sinope", | |
| "Zeno of Citium", "Epictetus", "Marcus Aurelius", "Plotinus", "Cicero", | |
| "Seneca", "Lucretius", | |
| # Medieval & Islamic | |
| "Augustine", "Boethius", "Al-Kindi", "Al-Farabi", "Avicenna", | |
| "Al-Ghazali", "Averroes", "Maimonides", "Aquinas", "Duns Scotus", | |
| "William of Ockham", "Meister Eckhart", | |
| # Early Modern | |
| "Machiavelli", "Erasmus", "Montaigne", "Francis Bacon", "Hobbes", | |
| "Descartes", "Pascal", "Spinoza", "Leibniz", "Locke", "Berkeley", | |
| "Malebranche", "Vico", | |
| # Enlightenment | |
| "Hume", "Voltaire", "Rousseau", "Adam Smith", "Kant", "Edmund Burke", | |
| "Mary Wollstonecraft", "Jeremy Bentham", "Condorcet", | |
| # 19th Century | |
| "Hegel", "Schopenhauer", "Auguste Comte", "John Stuart Mill", | |
| "Kierkegaard", "Marx", "Engels", "Herbert Spencer", "Charles Peirce", | |
| "Nietzsche", "William James", "Frege", "Henri Bergson", | |
| # 20th Century Continental | |
| "Husserl", "Heidegger", "Gadamer", "Hannah Arendt", "Sartre", | |
| "Simone de Beauvoir", "Merleau-Ponty", "Camus", "Levinas", | |
| "Derrida", "Foucault", "Deleuze", "Baudrillard", "Lyotard", | |
| "Habermas", "Paul Ricoeur", "Slavoj Žižek", "Alain Badiou", | |
| # 20th Century Analytic | |
| "Russell", "Whitehead", "Wittgenstein", "Carnap", "Popper", | |
| "Quine", "Ryle", "Austin", "Ayer", "Strawson", "Sellars", | |
| "Saul Kripke", "Hilary Putnam", "Donald Davidson", "Thomas Kuhn", | |
| "Chomsky", "Rawls", "Robert Nozick", "Thomas Nagel", "Bernard Williams", | |
| "Derek Parfit", "David Lewis", "Philippa Foot", "Elizabeth Anscombe", | |
| "Daniel Dennett", "Peter Singer", "Martha Nussbaum", "Judith Butler", | |
| "Charles Taylor", "Alasdair MacIntyre", "Richard Rorty", | |
| # Philosophy of Mind & Science | |
| "David Chalmers", "Andy Clark", "Patricia Churchland", | |
| "Paul Churchland", "Frank Jackson", "Ned Block", | |
| # Eastern Philosophy | |
| "Confucius", "Laozi", "Zhuangzi", "Mencius", "Xunzi", "Mozi", | |
| "Sun Tzu", "Han Feizi", "Wang Yangming", "Nagarjuna", "Vasubandhu", | |
| "Shankara", "Ramanuja", "Madhva", "Dogen", "Nishida Kitaro", | |
| "D.T. Suzuki", "Swami Vivekananda", "Sri Aurobindo", | |
| "B.R. Ambedkar", "Rabindranath Tagore", | |
| # African Philosophy | |
| "Frantz Fanon", "Kwame Nkrumah", "Léopold Sédar Senghor", | |
| "Kwasi Wiredu", "Ngugi wa Thiongo", | |
| # Contemporary | |
| "Peter Strawson", "Amartya Sen", "Cornel West", "bell hooks", | |
| "Angela Davis", "Gayatri Spivak", "Iris Marion Young", | |
| "Nick Bostrom", "Yuval Noah Harari", "Peter Unger", | |
| "Jason Stanley", "Kate Manne", "Shelley Tremain", | |
| ] | |
| SUGGESTED = [ | |
| "Do we have free will?", | |
| "Is morality objective?", | |
| "If Hume is right that causation is unobservable, how can science make causal claims?", | |
| "Can a belief be both rational and false at the same time?", | |
| "Is a law that is unjust still a law?", | |
| "Could there be a fact that is true but permanently unknowable?", | |
| "Is the statement 'This sentence is false' true or false?", | |
| "If you replaced every plank in a ship one at a time, at what point does it become a different ship?", | |
| "Can something come from nothing?", | |
| "Is it rational to fear death?", | |
| "Could a computer ever be conscious?", | |
| "Is mathematics discovered or invented?", | |
| ] | |
| # ── HTML ───────────────────────────────────────────────────────────────────── | |
| HTML = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Philosopher Model — Side by Side</title> | |
| <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| :root{ | |
| --navy:#0d0f18;--navy-mid:#1a1d27;--navy-light:#252836; | |
| --gold:#c9a84c;--gold-lite:#e8c96a; | |
| --purple:#7c6ef5;--purple-lite:#a89ef8; | |
| --text:#e8eaf0;--text-soft:#9da3b4;--text-muted:#6b7280; | |
| --border:#2a2d3e;--green:#27ae60;--red:#e74c3c; | |
| } | |
| body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; | |
| background:var(--navy);color:var(--text);min-height:100vh} | |
| /* AUTH */ | |
| .auth-overlay{position:fixed;inset:0;background:var(--navy);z-index:9999; | |
| display:flex;align-items:center;justify-content:center} | |
| .auth-overlay.hidden{display:none} | |
| .auth-box{background:var(--navy-mid);border:1px solid var(--border); | |
| border-radius:12px;padding:40px;max-width:380px;width:90%;text-align:center} | |
| .auth-logo{font-size:28px;font-weight:900;color:var(--purple);margin-bottom:6px;letter-spacing:1px} | |
| .auth-sub{color:var(--text-muted);font-size:13px;margin-bottom:28px} | |
| .auth-box input{width:100%;background:var(--navy);border:1px solid var(--border); | |
| color:var(--text);padding:12px 16px;border-radius:8px;font-size:15px; | |
| text-align:center;outline:none;margin-bottom:12px} | |
| .auth-box input:focus{border-color:var(--purple)} | |
| .auth-box button{width:100%;background:var(--purple);color:#fff;border:none; | |
| padding:13px;border-radius:8px;font-size:15px;font-weight:700;cursor:pointer} | |
| .auth-box button:hover{opacity:.85} | |
| .auth-err{color:var(--red);font-size:12px;margin-top:8px;min-height:18px} | |
| /* HEADER */ | |
| .header{padding:20px 32px;border-bottom:1px solid var(--border); | |
| display:flex;align-items:center;justify-content:space-between} | |
| .logo{font-size:18px;font-weight:900;color:var(--purple);letter-spacing:.5px} | |
| .logo span{color:var(--text-muted);font-weight:400;font-size:13px;margin-left:10px} | |
| .badge{background:var(--navy-light);border:1px solid var(--border); | |
| color:var(--text-muted);font-size:11px;padding:4px 10px;border-radius:4px; | |
| font-weight:600;letter-spacing:.5px} | |
| .header-right{display:flex;align-items:center;gap:14px} | |
| .mode-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none} | |
| .mode-label{color:var(--text-muted);font-size:12px;font-weight:600;letter-spacing:.3px} | |
| .mode-toggle input{position:absolute;opacity:0;pointer-events:none} | |
| .mode-track{position:relative;width:34px;height:18px;background:var(--navy-light); | |
| border:1px solid var(--border);border-radius:18px;transition:all .2s} | |
| .mode-thumb{position:absolute;top:1px;left:1px;width:14px;height:14px; | |
| background:var(--text-muted);border-radius:50%;transition:all .2s} | |
| .mode-toggle input:checked + .mode-track{background:rgba(124,110,245,.25);border-color:var(--purple)} | |
| .mode-toggle input:checked + .mode-track .mode-thumb{left:17px;background:var(--purple-lite)} | |
| @media(max-width:600px){.mode-label{display:none}} | |
| /* MAIN */ | |
| .main{max-width:1400px;margin:0 auto;padding:24px 32px} | |
| /* INPUT */ | |
| .input-wrap{margin-bottom:24px} | |
| .input-row{display:flex;gap:10px;margin-bottom:12px} | |
| .question-input{flex:1;background:var(--navy-mid);border:1px solid var(--border); | |
| color:var(--text);padding:14px 18px;border-radius:10px;font-size:15px; | |
| outline:none;font-family:inherit} | |
| .question-input:focus{border-color:var(--purple)} | |
| .question-input::placeholder{color:var(--text-muted)} | |
| .ask-btn{background:var(--purple);color:#fff;border:none;padding:14px 28px; | |
| border-radius:10px;font-size:15px;font-weight:700;cursor:pointer; | |
| white-space:nowrap;transition:opacity .2s} | |
| .ask-btn:hover{opacity:.85} | |
| .ask-btn:disabled{opacity:.4;cursor:default} | |
| .suggestions{display:flex;flex-wrap:wrap;gap:8px} | |
| .sug{background:var(--navy-light);border:1px solid var(--border); | |
| color:var(--text-soft);font-size:12px;padding:6px 12px;border-radius:6px; | |
| cursor:pointer;transition:all .15s;user-select:none} | |
| .sug:hover{border-color:var(--purple);color:var(--purple-lite)} | |
| .philosophers-section{margin-top:16px} | |
| .philosophers-label{font-size:11px;font-weight:700;letter-spacing:1.5px; | |
| text-transform:uppercase;color:var(--text-muted);margin-bottom:8px} | |
| .philosophers{display:flex;flex-wrap:wrap;gap:6px} | |
| .phil-name{background:transparent;border:1px solid var(--border); | |
| color:var(--text-muted);font-size:12px;padding:4px 10px;border-radius:20px; | |
| cursor:pointer;transition:all .15s;user-select:none;font-style:italic} | |
| .phil-name:hover{border-color:var(--gold);color:var(--gold-lite);background:rgba(201,168,76,.07)} | |
| /* COLUMNS */ | |
| .columns{display:grid;grid-template-columns:1fr 1fr;gap:20px} | |
| .col{background:var(--navy-mid);border:1px solid var(--border);border-radius:12px; | |
| overflow:hidden;display:flex;flex-direction:column} | |
| .col-header{padding:14px 20px;border-bottom:1px solid var(--border); | |
| display:flex;align-items:center;gap:10px} | |
| .col-tag{font-size:10px;font-weight:700;letter-spacing:1px;padding:4px 10px; | |
| border-radius:4px;text-transform:uppercase} | |
| .tag-base{background:rgba(107,114,128,.2);color:var(--text-muted)} | |
| .tag-phil{background:rgba(124,110,245,.2);color:var(--purple-lite)} | |
| .col-title{font-size:14px;font-weight:700;color:var(--text)} | |
| .col-sub{font-size:12px;color:var(--text-muted);margin-left:auto} | |
| .global-len-row{display:flex;align-items:center;gap:10px;margin-bottom:12px} | |
| .global-len-label{font-size:11px;font-weight:700;letter-spacing:.5px; | |
| text-transform:uppercase;color:var(--text-muted);white-space:nowrap} | |
| .global-len-note{font-size:11px;color:var(--text-muted);font-style:italic} | |
| .len-pills{display:flex;gap:4px} | |
| .len-pill{font-size:11px;font-weight:700;padding:4px 12px;border-radius:4px; | |
| border:1px solid var(--border);color:var(--text-muted);background:transparent; | |
| cursor:pointer;transition:all .15s;letter-spacing:.3px} | |
| .len-pill:hover{border-color:var(--purple);color:var(--purple-lite)} | |
| .len-pill.active{background:rgba(124,110,245,.2);border-color:var(--purple);color:var(--purple-lite)} | |
| .col-body{padding:20px;flex:1;min-height:300px;font-size:14px;line-height:1.8; | |
| color:var(--text-soft);white-space:pre-wrap;overflow-y:auto;max-height:600px} | |
| .col-body.empty{display:flex;align-items:center;justify-content:center; | |
| color:var(--text-muted);font-style:italic;font-size:13px} | |
| .cursor{display:inline-block;width:2px;height:16px;background:var(--purple); | |
| animation:blink .8s infinite;vertical-align:middle;margin-left:2px} | |
| @keyframes blink{0%,100%{opacity:1}50%{opacity:0}} | |
| /* THINKING LABEL */ | |
| .thinking{color:var(--text-muted);font-size:12px;font-style:italic;margin-bottom:8px} | |
| .warming{display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| gap:14px;padding:40px 20px;color:var(--text-muted);font-size:13px;text-align:center; | |
| min-height:200px} | |
| .warm-spinner{width:22px;height:22px;border:2px solid var(--border); | |
| border-top-color:var(--purple);border-radius:50%;animation:spin .9s linear infinite} | |
| .warm-title{color:var(--text-soft);font-weight:600;font-size:14px} | |
| .warm-sub{color:var(--text-muted);font-size:12px;line-height:1.6;max-width:260px} | |
| /* QUESTION DISPLAY */ | |
| .current-q{background:var(--navy-light);border:1px solid var(--border); | |
| border-left:3px solid var(--purple);border-radius:8px;padding:14px 18px; | |
| margin-bottom:20px;font-size:15px;color:var(--text);line-height:1.6;display:none} | |
| .current-q.show{display:block} | |
| /* DAG PANEL */ | |
| .dag-panel{background:var(--navy-mid);border:1px solid var(--border); | |
| border-radius:12px;overflow:hidden;margin-top:20px;display:none} | |
| .dag-panel.show{display:block} | |
| .dag-hdr{padding:14px 20px;border-bottom:1px solid var(--border); | |
| display:flex;align-items:center;gap:10px;flex-wrap:wrap} | |
| .dag-tag{font-size:10px;font-weight:700;letter-spacing:1px;padding:4px 10px; | |
| border-radius:4px;text-transform:uppercase; | |
| background:rgba(201,168,76,.15);color:var(--gold);flex-shrink:0} | |
| .dag-title{font-size:14px;font-weight:700;color:var(--text)} | |
| .dag-topic{font-size:13px;color:var(--gold-lite);margin-left:4px} | |
| .dag-hint{font-size:12px;color:var(--text-muted);margin-left:auto} | |
| .dag-body{padding:20px;min-height:180px;overflow-x:auto} | |
| .dag-loading{display:flex;align-items:center;gap:12px; | |
| color:var(--text-muted);font-size:13px;font-style:italic; | |
| justify-content:center;min-height:140px} | |
| .dag-spinner{width:18px;height:18px;border:2px solid var(--border); | |
| border-top-color:var(--gold);border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| /* Mermaid SVG overrides */ | |
| .dag-body .mermaid{width:100%} | |
| .dag-body svg{max-width:100%!important;height:auto!important;display:block;margin:0 auto;cursor:default} | |
| .dag-body .node{cursor:pointer} | |
| .dag-body .node:hover rect,.dag-body .node:hover polygon, | |
| .dag-body .node:hover circle,.dag-body .node:hover ellipse{opacity:.8;transition:opacity .15s} | |
| .dag-err{color:var(--text-muted);font-size:13px;font-style:italic; | |
| text-align:center;padding:24px} | |
| @media(max-width:768px){.columns{grid-template-columns:1fr}.main{padding:16px} | |
| .dag-hint{display:none}} | |
| </style> | |
| </head> | |
| <body> | |
| <!-- AUTH --> | |
| <div class="auth-overlay" id="auth"> | |
| <div class="auth-box"> | |
| <div class="auth-logo">Philosopher</div> | |
| <div class="auth-sub">TunedAI Labs — Private Demo</div> | |
| <input type="password" id="pw" placeholder="password" onkeydown="if(event.key==='Enter')checkAuth()"> | |
| <button onclick="checkAuth()">Enter</button> | |
| <div class="auth-err" id="auth-err"></div> | |
| </div> | |
| </div> | |
| <!-- HEADER --> | |
| <div class="header"> | |
| <div class="logo">Philosopher <span>Base vs Fine-tuned</span></div> | |
| <div class="header-right"> | |
| <label class="mode-toggle" title="Toggle side-by-side comparison"> | |
| <span class="mode-label">Side-by-side</span> | |
| <input type="checkbox" id="mode-toggle" checked onchange="if(!this.checked) location.href='/'"> | |
| <span class="mode-track"><span class="mode-thumb"></span></span> | |
| </label> | |
| <span class="badge">TunedAI Labs — Private</span> | |
| </div> | |
| </div> | |
| <!-- MAIN --> | |
| <div class="main"> | |
| <!-- INPUT --> | |
| <div class="input-wrap"> | |
| <div class="input-row"> | |
| <input class="question-input" id="question" type="text" | |
| placeholder="Ask any philosophical question..." | |
| onkeydown="if(event.key==='Enter')ask()"> | |
| <button class="ask-btn" id="ask-btn" onclick="ask()">Ask</button> | |
| </div> | |
| <div class="global-len-row"> | |
| <span class="global-len-label">Response length</span> | |
| <div class="len-pills" id="global-pills"> | |
| <button class="len-pill" onclick="setLen(600,this)">Brief</button> | |
| <button class="len-pill" onclick="setLen(1500,this)">Full</button> | |
| <button class="len-pill active" onclick="setLen(2000,this)">Extended</button> | |
| </div> | |
| <span class="global-len-note">applies to both models</span> | |
| </div> | |
| <div class="suggestions" id="suggestions"></div> | |
| <div class="philosophers-section"> | |
| <div class="philosophers-label">Browse by Philosopher</div> | |
| <div class="philosophers" id="philosophers"></div> | |
| </div> | |
| </div> | |
| <!-- CURRENT QUESTION --> | |
| <div class="current-q" id="current-q"></div> | |
| <!-- COLUMNS --> | |
| <div class="columns"> | |
| <!-- BASE --> | |
| <div class="col"> | |
| <div class="col-header"> | |
| <span class="col-tag tag-base">Base</span> | |
| <span class="col-title">Qwen3 235B</span> | |
| <span class="col-sub">Standard · No fine-tuning</span> | |
| </div> | |
| <div class="col-body empty" id="base-body"> | |
| Waiting for a question... | |
| </div> | |
| </div> | |
| <!-- PHILOSOPHER --> | |
| <div class="col"> | |
| <div class="col-header"> | |
| <span class="col-tag tag-phil">Philosopher</span> | |
| <span class="col-title">Qwen3-14B</span> | |
| <span class="col-sub" id="phil-model-label">TunedAI fine-tuned</span> | |
| </div> | |
| <div class="col-body empty" id="phil-body"> | |
| Waiting for a question... | |
| </div> | |
| </div> | |
| </div> | |
| <!-- DAG PANEL --> | |
| <div class="dag-panel" id="dag-panel"> | |
| <div class="dag-hdr"> | |
| <span class="dag-tag">Thought Map</span> | |
| <span class="dag-title">Map of Philosophical Thought</span> | |
| <span class="dag-topic" id="dag-topic"></span> | |
| <span class="dag-hint">Click any node to explore deeper →</span> | |
| </div> | |
| <div class="dag-body" id="dag-body"> | |
| <div class="dag-loading" id="dag-loading"> | |
| <div class="dag-spinner"></div> | |
| <span>Mapping the philosophy...</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const PASSWORD = 'philosopher'; | |
| const SUGGESTED = """ + json.dumps(SUGGESTED) + """; | |
| const PHILOSOPHERS = """ + json.dumps(PHILOSOPHERS) + """; | |
| // ── Mermaid init ───────────────────────────────────────────────────────────── | |
| mermaid.initialize({ | |
| startOnLoad: false, | |
| theme: 'base', | |
| securityLevel: 'loose', | |
| flowchart: { curve: 'basis', htmlLabels: false, padding: 20 }, | |
| themeVariables: { | |
| background: '#1a1d27', | |
| primaryColor: '#252836', | |
| primaryTextColor: '#e8eaf0', | |
| primaryBorderColor: '#2a2d3e', | |
| lineColor: '#4a5568', | |
| secondaryColor: '#1a1d27', | |
| tertiaryColor: '#252836', | |
| edgeLabelBackground: '#1a1d27', | |
| fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', | |
| fontSize: '13px' | |
| } | |
| }); | |
| // ── DAG state ──────────────────────────────────────────────────────────────── | |
| window.dagNodeMap = {}; | |
| // Called by Mermaid when a node is clicked | |
| window.handleDagClick = function(nodeId) { | |
| const node = window.dagNodeMap[nodeId]; | |
| if (!node) return; | |
| let question = ''; | |
| if (node.type === 'philosopher') { | |
| question = 'Who was ' + node.label + ' and what did they believe? Why does their philosophy still matter?'; | |
| } else if (node.type === 'branch') { | |
| question = 'What is the philosophical position of "' + node.label + '"? Who are its key defenders and what are the strongest arguments for and against it?'; | |
| } else if (node.type === 'theory') { | |
| question = 'Explain the theory "' + node.label + '" in philosophy — its origins, key claims, strongest arguments, and most powerful objections.'; | |
| } | |
| if (question) { | |
| document.getElementById('question').value = question; | |
| ask(); | |
| } | |
| }; | |
| function sanitizeId(id) { | |
| return id.replace(/[^a-zA-Z0-9_]/g, '_'); | |
| } | |
| function escapeLabel(label) { | |
| return label.replace(/"/g, '').replace(/'/g, '').replace(/[<>{}|]/g, ''); | |
| } | |
| function buildMermaid(dag) { | |
| const lines = ['graph TD']; | |
| // Class definitions | |
| lines.push(' classDef root fill:#7c6ef5,stroke:#a89ef8,stroke-width:2px,color:#fff'); | |
| lines.push(' classDef branch fill:#2a1c00,stroke:#c9a84c,stroke-width:2px,color:#e8c96a'); | |
| lines.push(' classDef theory fill:#1a1d27,stroke:#4a7fb5,stroke-width:1px,color:#9da3b4'); | |
| lines.push(' classDef philosopher fill:#0d0f18,stroke:#c9a84c,stroke-width:1px,color:#c9a84c'); | |
| // Build node map for click handler lookups | |
| window.dagNodeMap = {}; | |
| dag.nodes.forEach(function(n) { | |
| const sid = sanitizeId(n.id); | |
| window.dagNodeMap[sid] = n; | |
| }); | |
| // Node definitions | |
| dag.nodes.forEach(function(n) { | |
| const sid = sanitizeId(n.id); | |
| const lbl = escapeLabel(n.label); | |
| if (n.type === 'root') { | |
| lines.push(' ' + sid + '{"' + lbl + '"}'); | |
| } else if (n.type === 'branch') { | |
| lines.push(' ' + sid + '["' + lbl + '"]'); | |
| } else if (n.type === 'theory') { | |
| lines.push(' ' + sid + '["' + lbl + '"]'); | |
| } else { | |
| lines.push(' ' + sid + '(["' + lbl + '"])'); | |
| } | |
| }); | |
| // Edges | |
| dag.edges.forEach(function(e) { | |
| const from = sanitizeId(e.from); | |
| const to = sanitizeId(e.to); | |
| lines.push(' ' + from + ' --> ' + to); | |
| }); | |
| // Class assignments | |
| dag.nodes.forEach(function(n) { | |
| const sid = sanitizeId(n.id); | |
| lines.push(' class ' + sid + ' ' + n.type); | |
| }); | |
| // Click handlers (skip root) | |
| dag.nodes.forEach(function(n) { | |
| if (n.type !== 'root') { | |
| const sid = sanitizeId(n.id); | |
| lines.push(' click ' + sid + ' handleDagClick'); | |
| } | |
| }); | |
| return lines.join('\\n'); | |
| } | |
| async function renderDag(dag) { | |
| const dagPanel = document.getElementById('dag-panel'); | |
| const dagBody = document.getElementById('dag-body'); | |
| const dagTopic = document.getElementById('dag-topic'); | |
| if (dag.title) { | |
| dagTopic.textContent = '— ' + dag.title; | |
| } | |
| if (!dag.nodes || !dag.edges || dag.nodes.length < 3) { | |
| dagBody.innerHTML = '<div class="dag-err">Could not generate thought map for this question.</div>'; | |
| dagPanel.classList.add('show'); | |
| return; | |
| } | |
| const graphDef = buildMermaid(dag); | |
| dagBody.innerHTML = ''; | |
| const mermaidDiv = document.createElement('div'); | |
| mermaidDiv.className = 'mermaid'; | |
| mermaidDiv.textContent = graphDef; | |
| dagBody.appendChild(mermaidDiv); | |
| try { | |
| await mermaid.run({ nodes: [mermaidDiv] }); | |
| // Make the SVG responsive | |
| const svg = dagBody.querySelector('svg'); | |
| if (svg) { | |
| svg.removeAttribute('height'); | |
| svg.style.maxWidth = '100%'; | |
| } | |
| } catch(e) { | |
| dagBody.innerHTML = '<div class="dag-err">Graph render error — try a different question.</div>'; | |
| } | |
| dagPanel.classList.add('show'); | |
| } | |
| async function fetchDag(question) { | |
| const dagPanel = document.getElementById('dag-panel'); | |
| const dagBody = document.getElementById('dag-body'); | |
| const dagTopic = document.getElementById('dag-topic'); | |
| // Show loading state | |
| dagTopic.textContent = ''; | |
| dagBody.innerHTML = '<div class="dag-loading"><div class="dag-spinner"></div><span>Mapping the philosophy...</span></div>'; | |
| dagPanel.classList.add('show'); | |
| try { | |
| const res = await fetch('/dag', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({question}) | |
| }); | |
| if (!res.ok) throw new Error('HTTP ' + res.status); | |
| const dag = await res.json(); | |
| if (dag.error) throw new Error(dag.error); | |
| await renderDag(dag); | |
| } catch(e) { | |
| dagBody.innerHTML = '<div class="dag-err">Could not load thought map.</div>'; | |
| } | |
| } | |
| // ── Length controls ────────────────────────────────────────────────────────── | |
| let globalTokens = 2000; | |
| function setLen(tokens, btn) { | |
| globalTokens = tokens; | |
| document.getElementById('global-pills').querySelectorAll('.len-pill') | |
| .forEach(p => p.classList.remove('active')); | |
| btn.classList.add('active'); | |
| } | |
| // ── Auth ───────────────────────────────────────────────────────────────────── | |
| function checkAuth(){ | |
| const pw = document.getElementById('pw').value; | |
| if(pw === PASSWORD){ | |
| document.getElementById('auth').classList.add('hidden'); | |
| init(); | |
| } else { | |
| document.getElementById('auth-err').textContent = 'Incorrect password'; | |
| } | |
| } | |
| function init(){ | |
| const sug = document.getElementById('suggestions'); | |
| SUGGESTED.forEach(q => { | |
| const btn = document.createElement('div'); | |
| btn.className = 'sug'; | |
| btn.textContent = q; | |
| btn.onclick = () => { | |
| document.getElementById('question').value = q; | |
| ask(); | |
| }; | |
| sug.appendChild(btn); | |
| }); | |
| const philDiv = document.getElementById('philosophers'); | |
| PHILOSOPHERS.forEach(name => { | |
| const btn = document.createElement('div'); | |
| btn.className = 'phil-name'; | |
| btn.textContent = name; | |
| btn.onclick = () => { | |
| document.getElementById('question').value = `Who was ${name} and what did they believe? Why does their philosophy still matter?`; | |
| ask(); | |
| }; | |
| philDiv.appendChild(btn); | |
| }); | |
| // Show which model is live | |
| fetch('/info').then(r => r.json()).then(info => { | |
| const lbl = document.getElementById('phil-model-label'); | |
| if (info.vllm_url) { | |
| lbl.textContent = 'Full depth · Qwen3-14B DPO · TunedAI'; | |
| lbl.style.color = 'var(--gold-lite)'; | |
| } | |
| }).catch(() => {}); | |
| } | |
| // ── Ask ─────────────────────────────────────────────────────────────────────── | |
| async function ask(){ | |
| const q = document.getElementById('question').value.trim(); | |
| if(!q) return; | |
| const askBtn = document.getElementById('ask-btn'); | |
| askBtn.disabled = true; | |
| askBtn.textContent = 'Thinking…'; | |
| const currentQ = document.getElementById('current-q'); | |
| currentQ.textContent = q; | |
| currentQ.classList.add('show'); | |
| const baseBody = document.getElementById('base-body'); | |
| const philBody = document.getElementById('phil-body'); | |
| baseBody.className = 'col-body'; | |
| philBody.className = 'col-body'; | |
| baseBody.innerHTML = '<span class="thinking">reasoning...</span><span class="cursor"></span>'; | |
| philBody.innerHTML = '<span class="thinking">reasoning...</span><span class="cursor"></span>'; | |
| let baseDone = false; | |
| let philDone = false; | |
| function checkDone(){ | |
| if(baseDone && philDone){ | |
| askBtn.disabled = false; | |
| askBtn.textContent = 'Ask'; | |
| } | |
| } | |
| // Stream both with same token budget — apples to apples | |
| streamResponse('/stream/base', q, globalTokens, baseBody, () => { | |
| baseDone = true; | |
| checkDone(); | |
| }); | |
| streamResponse('/stream/philosopher', q, globalTokens, philBody, () => { | |
| philDone = true; | |
| checkDone(); | |
| }); | |
| // Fetch DAG concurrently (non-blocking) | |
| fetchDag(q); | |
| } | |
| async function streamResponse(endpoint, question, maxTokens, container, onDone){ | |
| // Show loading state immediately | |
| container.innerHTML = ` | |
| <div class="warming"> | |
| <div class="warm-spinner"></div> | |
| <div class="warm-title">Thinking...</div> | |
| </div>`; | |
| let text = ''; | |
| let gotTokens = false; | |
| // After 8s with no tokens, update to warming message | |
| const warmTimer = setTimeout(() => { | |
| if (!gotTokens) { | |
| container.innerHTML = ` | |
| <div class="warming"> | |
| <div class="warm-spinner"></div> | |
| <div class="warm-title">Model warming up...</div> | |
| <div class="warm-sub">The GPU is booting — first response takes 3–5 minutes. Subsequent questions are instant.</div> | |
| </div>`; | |
| } | |
| }, 8000); | |
| try { | |
| const res = await fetch(endpoint, { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({question, max_tokens: maxTokens}) | |
| }); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let finished = false; | |
| while(!finished){ | |
| const {done, value} = await reader.read(); | |
| if(done) break; | |
| const chunk = decoder.decode(value); | |
| const lines = chunk.split('\\n'); | |
| for(const line of lines){ | |
| if(line.startsWith('data: ')){ | |
| const data = line.slice(6).trim(); | |
| if(data === '[DONE]'){ finished = true; break; } | |
| try { | |
| const parsed = JSON.parse(data); | |
| if(parsed.token){ | |
| if(!gotTokens){ | |
| gotTokens = true; | |
| clearTimeout(warmTimer); | |
| container.innerHTML = ''; | |
| } | |
| text += parsed.token; | |
| container.textContent = text; | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| } catch(e){} | |
| } | |
| } | |
| } | |
| } catch(e){ | |
| clearTimeout(warmTimer); | |
| container.textContent = 'Error: ' + e.message; | |
| } | |
| clearTimeout(warmTimer); | |
| onDone(); | |
| } | |
| // Check if already authenticated (page refresh) | |
| document.getElementById('pw').focus(); | |
| </script> | |
| </body> | |
| </html>""" | |
| # ── ROUTES ──────────────────────────────────────────────────────────────────── | |
| async def root(): | |
| return HTMLResponse(content=PLAYGROUND_HTML, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) | |
| async def compare(): | |
| return HTMLResponse(content=HTML, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) | |
| async def info(): | |
| return { | |
| "philosopher_model": PHILOSOPHER_MODEL_ID, | |
| "vllm_url": PHILOSOPHER_MODEL_URL or None, | |
| } | |
| async def stream_completion(question: str, system: str, oai_client, model_id: str, max_tokens: int = 1500): | |
| """Stream a response from any OpenAI-compatible endpoint as SSE.""" | |
| stream = oai_client.chat.completions.create( | |
| model=model_id, | |
| messages=[ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": question} | |
| ], | |
| stream=True, | |
| max_tokens=max_tokens, | |
| temperature=0.7, | |
| ) | |
| for chunk in stream: | |
| delta = chunk.choices[0].delta | |
| if delta.content: | |
| yield f"data: {json.dumps({'token': delta.content})}\n\n" | |
| yield "data: [DONE]\n\n" | |
| async def async_stream(url: str, model: str, system: str, question: str, max_tokens: int, auth_token: str): | |
| """Stream from any OpenAI-compatible endpoint via async httpx — never blocks the event loop.""" | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": system}, | |
| {"role": "user", "content": question} | |
| ], | |
| "max_tokens": max_tokens, | |
| "temperature": 0.7, | |
| "stream": True, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=600.0) as http: | |
| async with http.stream( | |
| "POST", f"{url}/chat/completions", | |
| json=payload, | |
| headers={"Authorization": f"Bearer {auth_token}", "Content-Type": "application/json"} | |
| ) as resp: | |
| async for line in resp.aiter_lines(): | |
| if line.startswith("data: "): | |
| data = line[6:].strip() | |
| if data == "[DONE]": | |
| break | |
| try: | |
| chunk = json.loads(data) | |
| content = chunk["choices"][0]["delta"].get("content", "") | |
| if content: | |
| yield f"data: {json.dumps({'token': content})}\n\n" | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| print(f"async_stream error: {e}", flush=True) | |
| yield "data: [DONE]\n\n" | |
| async def hf_stream(url: str, system: str, question: str, max_tokens: int): | |
| """Legacy wrapper — kept for HF endpoints.""" | |
| async for chunk in async_stream(url, "tgi", system, question, max_tokens, HF_TOKEN): | |
| yield chunk | |
| async def async_stream_messages(url: str, model: str, messages: list, max_tokens: int, auth_token: str): | |
| """Stream from any OpenAI-compatible endpoint with a full messages array (multi-turn).""" | |
| payload = { | |
| "model": model, | |
| "messages": messages, | |
| "max_tokens": max_tokens, | |
| "temperature": 0.7, | |
| "stream": True, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=600.0) as http: | |
| async with http.stream( | |
| "POST", f"{url}/chat/completions", | |
| json=payload, | |
| headers={"Authorization": f"Bearer {auth_token}", "Content-Type": "application/json"} | |
| ) as resp: | |
| async for line in resp.aiter_lines(): | |
| if line.startswith("data: "): | |
| data = line[6:].strip() | |
| if data == "[DONE]": | |
| break | |
| try: | |
| chunk = json.loads(data) | |
| content = chunk["choices"][0]["delta"].get("content", "") | |
| if content: | |
| yield f"data: {json.dumps({'token': content})}\n\n" | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| print(f"async_stream_messages error: {e}", flush=True) | |
| yield "data: [DONE]\n\n" | |
| async def stream_base(request: Request): | |
| body = await request.json() | |
| question = body.get("question", "") | |
| max_tokens = int(body.get("max_tokens", 600)) | |
| if BASE_MODEL_URL: | |
| return StreamingResponse( | |
| async_stream(BASE_MODEL_URL, "tgi", QUICK_SYSTEM, question, max_tokens, HF_TOKEN), | |
| media_type="text/event-stream" | |
| ) | |
| if TOGETHER_KEY: | |
| return StreamingResponse( | |
| async_stream(TOGETHER_BASE, QWEN_BASE_MODEL, QUICK_SYSTEM, question, max_tokens, TOGETHER_KEY), | |
| media_type="text/event-stream" | |
| ) | |
| return StreamingResponse( | |
| stream_completion(question, QUICK_SYSTEM, base_client, BASE_MODEL_ID, max_tokens=max_tokens), | |
| media_type="text/event-stream" | |
| ) | |
| async def stream_philosopher(request: Request): | |
| body = await request.json() | |
| question = body.get("question", "") | |
| max_tokens = int(body.get("max_tokens", 1500)) | |
| if PHILOSOPHER_MODEL_URL: | |
| return StreamingResponse( | |
| async_stream(PHILOSOPHER_MODEL_URL, "tgi", PHILOSOPHER_SYSTEM, question, max_tokens, HF_TOKEN), | |
| media_type="text/event-stream" | |
| ) | |
| return StreamingResponse( | |
| stream_completion(question, PHILOSOPHER_SYSTEM, phil_client, PHILOSOPHER_MODEL_ID, max_tokens=max_tokens), | |
| media_type="text/event-stream" | |
| ) | |
| async def stream_chat(request: Request): | |
| """Multi-turn chat against the philosopher fine-tune. Body: {messages: [...], max_tokens}.""" | |
| body = await request.json() | |
| messages = body.get("messages", []) | |
| max_tokens = int(body.get("max_tokens", 1500)) | |
| # Prepend the philosopher system prompt if not already present | |
| if not messages or messages[0].get("role") != "system": | |
| messages = [{"role": "system", "content": PHILOSOPHER_SYSTEM}] + messages | |
| if PHILOSOPHER_MODEL_URL: | |
| return StreamingResponse( | |
| async_stream_messages(PHILOSOPHER_MODEL_URL, "tgi", messages, max_tokens, HF_TOKEN), | |
| media_type="text/event-stream" | |
| ) | |
| # Fallback to the OpenAI-compatible client (sync) — used when no PHILOSOPHER_MODEL_URL set | |
| async def _gen(): | |
| stream = phil_client.chat.completions.create( | |
| model=PHILOSOPHER_MODEL_ID, | |
| messages=messages, | |
| stream=True, | |
| max_tokens=max_tokens, | |
| temperature=0.7, | |
| ) | |
| for chunk in stream: | |
| delta = chunk.choices[0].delta | |
| if delta.content: | |
| yield f"data: {json.dumps({'token': delta.content})}\n\n" | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse(_gen(), media_type="text/event-stream") | |
| async def get_dag(request: Request): | |
| """Return a JSON graph structure for the philosophical thought map.""" | |
| body = await request.json() | |
| question = body.get("question", "") | |
| def extract_json(text: str): | |
| """Extract JSON from model response, handling markdown wrapping.""" | |
| text = text.strip() | |
| if "```" in text: | |
| parts = text.split("```") | |
| for part in parts: | |
| if part.startswith("json"): | |
| part = part[4:] | |
| part = part.strip() | |
| if part.startswith("{"): | |
| return json.loads(part) | |
| # Try direct parse | |
| start = text.find("{") | |
| end = text.rfind("}") + 1 | |
| if start >= 0 and end > start: | |
| return json.loads(text[start:end]) | |
| return json.loads(text) | |
| # Use OpenAI gpt-4o-mini — fast, reliable, no cold start | |
| if client is None: | |
| return JSONResponse(content={"error": "DAG unavailable — OPENAI_API_KEY not configured"}, status_code=503) | |
| try: | |
| def _call(): | |
| return client.chat.completions.create( | |
| model="gpt-4o-mini", | |
| messages=[ | |
| {"role": "system", "content": DAG_SYSTEM}, | |
| {"role": "user", "content": question} | |
| ], | |
| max_tokens=1200, | |
| temperature=0.3, | |
| response_format={"type": "json_object"}, | |
| ) | |
| response = await asyncio.get_running_loop().run_in_executor(None, _call) | |
| raw = response.choices[0].message.content | |
| data = extract_json(raw) | |
| return JSONResponse(content=data) | |
| except Exception as e: | |
| print(f"DAG failed: {e}", flush=True) | |
| return JSONResponse(content={"error": str(e)}, status_code=500) | |
| # ── PLAYGROUND HTML ─────────────────────────────────────────────────────────── | |
| PLAYGROUND_HTML = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Philosopher — TunedAI</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| :root{ | |
| --navy:#0d0f18;--navy-mid:#1a1d27;--navy-light:#252836; | |
| --purple:#7c6ef5;--purple-lite:#a89ef8; | |
| --text:#e8eaf0;--text-soft:#9da3b4;--text-muted:#6b7280; | |
| --border:#2a2d3e;--green:#27ae60;--red:#e74c3c; | |
| --gold:#c9a84c; | |
| } | |
| body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; | |
| background:var(--navy);color:var(--text);min-height:100vh;display:flex;flex-direction:column} | |
| /* AUTH */ | |
| .auth-overlay{position:fixed;inset:0;background:var(--navy);z-index:9999; | |
| display:flex;align-items:center;justify-content:center} | |
| .auth-overlay.hidden{display:none} | |
| .auth-box{background:var(--navy-mid);border:1px solid var(--border); | |
| border-radius:12px;padding:40px;max-width:380px;width:90%;text-align:center} | |
| .auth-logo{font-size:26px;font-weight:900;color:var(--purple);margin-bottom:4px;letter-spacing:1px} | |
| .auth-sub{color:var(--text-muted);font-size:13px;margin-bottom:28px} | |
| .auth-box input{width:100%;background:var(--navy);border:1px solid var(--border); | |
| color:var(--text);padding:12px 16px;border-radius:8px;font-size:15px; | |
| text-align:center;outline:none;margin-bottom:12px} | |
| .auth-box input:focus{border-color:var(--purple)} | |
| .auth-box button{width:100%;background:var(--purple);color:#fff;border:none; | |
| padding:13px;border-radius:8px;font-size:15px;font-weight:700;cursor:pointer} | |
| .auth-box button:hover{opacity:.85} | |
| .auth-err{color:var(--red);font-size:12px;margin-top:8px;min-height:18px} | |
| /* HEADER */ | |
| .header{padding:16px 28px;border-bottom:1px solid var(--border); | |
| display:flex;align-items:center;justify-content:space-between;flex-shrink:0} | |
| .logo{font-size:17px;font-weight:900;color:var(--purple)} | |
| .logo span{color:var(--text-muted);font-weight:400;font-size:12px;margin-left:8px} | |
| .badge{background:var(--navy-light);border:1px solid var(--border); | |
| color:var(--text-muted);font-size:11px;padding:3px 9px;border-radius:4px;font-weight:600} | |
| .header-right{display:flex;align-items:center;gap:14px} | |
| .mode-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none} | |
| .mode-label{color:var(--text-muted);font-size:12px;font-weight:600;letter-spacing:.3px} | |
| .mode-toggle input{position:absolute;opacity:0;pointer-events:none} | |
| .mode-track{position:relative;width:34px;height:18px;background:var(--navy-light); | |
| border:1px solid var(--border);border-radius:18px;transition:all .2s} | |
| .mode-thumb{position:absolute;top:1px;left:1px;width:14px;height:14px; | |
| background:var(--text-muted);border-radius:50%;transition:all .2s} | |
| .mode-toggle input:checked + .mode-track{background:rgba(124,110,245,.25);border-color:var(--purple)} | |
| .mode-toggle input:checked + .mode-track .mode-thumb{left:17px;background:var(--purple-lite)} | |
| @media(max-width:600px){.mode-label{display:none}} | |
| /* CHAT LAYOUT */ | |
| .chat-wrap{flex:1;max-width:860px;width:100%;margin:0 auto; | |
| padding:24px 24px 0;display:flex;flex-direction:column;gap:0} | |
| .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:18px; | |
| padding-bottom:16px;min-height:0} | |
| .msg{display:flex;flex-direction:column;gap:4px} | |
| .msg-label{font-size:11px;font-weight:700;letter-spacing:1px; | |
| text-transform:uppercase;color:var(--text-muted)} | |
| .msg.user .msg-label{color:var(--purple-lite)} | |
| .msg-body{font-size:14px;line-height:1.8;color:var(--text-soft);white-space:pre-wrap} | |
| .msg.user .msg-body{color:var(--text);background:var(--navy-light); | |
| border:1px solid var(--border);border-radius:10px;padding:12px 16px} | |
| .msg.assistant .msg-body{color:var(--text-soft)} | |
| /* THINKING / WARMING */ | |
| .thinking-label{color:var(--text-muted);font-size:12px;font-style:italic} | |
| .cursor{display:inline-block;width:2px;height:14px;background:var(--purple); | |
| animation:blink .8s infinite;vertical-align:middle;margin-left:2px} | |
| @keyframes blink{0%,100%{opacity:1}50%{opacity:0}} | |
| .warming{display:flex;align-items:center;gap:12px;color:var(--text-muted); | |
| font-size:13px;padding:8px 0} | |
| .warm-spinner{width:16px;height:16px;border:2px solid var(--border); | |
| border-top-color:var(--purple);border-radius:50%; | |
| animation:spin .9s linear infinite;flex-shrink:0} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| /* INPUT BAR */ | |
| .input-bar{border-top:1px solid var(--border);padding:16px 24px; | |
| max-width:860px;width:100%;margin:0 auto;flex-shrink:0} | |
| .len-row{display:flex;align-items:center;gap:8px;margin-bottom:10px} | |
| .len-label{font-size:11px;font-weight:700;letter-spacing:.5px; | |
| text-transform:uppercase;color:var(--text-muted)} | |
| .len-pills{display:flex;gap:4px} | |
| .len-pill{font-size:11px;font-weight:700;padding:3px 10px;border-radius:4px; | |
| border:1px solid var(--border);color:var(--text-muted);background:transparent; | |
| cursor:pointer;transition:all .15s} | |
| .len-pill:hover{border-color:var(--purple);color:var(--purple-lite)} | |
| .len-pill.active{background:rgba(124,110,245,.2);border-color:var(--purple);color:var(--purple-lite)} | |
| .input-row{display:flex;gap:10px} | |
| .chat-input{flex:1;background:var(--navy-mid);border:1px solid var(--border); | |
| color:var(--text);padding:13px 16px;border-radius:10px;font-size:14px; | |
| outline:none;font-family:inherit;resize:none;min-height:48px;max-height:160px;overflow-y:auto} | |
| .chat-input:focus{border-color:var(--purple)} | |
| .chat-input::placeholder{color:var(--text-muted)} | |
| .send-btn{background:var(--purple);color:#fff;border:none;padding:13px 22px; | |
| border-radius:10px;font-size:14px;font-weight:700;cursor:pointer; | |
| align-self:flex-end;transition:opacity .2s;white-space:nowrap} | |
| .send-btn:hover{opacity:.85} | |
| .send-btn:disabled{opacity:.4;cursor:default} | |
| /* EMPTY STATE */ | |
| .empty-state{flex:1;display:flex;flex-direction:column;align-items:center; | |
| justify-content:center;gap:20px;padding:40px 20px;text-align:center} | |
| .empty-title{font-size:22px;font-weight:800;color:var(--text);letter-spacing:-.3px} | |
| .empty-sub{font-size:14px;color:var(--text-muted);max-width:420px;line-height:1.6} | |
| .starters{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;max-width:600px} | |
| .starter{background:var(--navy-light);border:1px solid var(--border); | |
| color:var(--text-soft);font-size:12px;padding:7px 14px;border-radius:8px; | |
| cursor:pointer;transition:all .15s;text-align:left} | |
| .starter:hover{border-color:var(--purple);color:var(--purple-lite)} | |
| .philosophers-section{margin-top:8px;width:100%;max-width:780px} | |
| .philosophers-label{font-size:11px;font-weight:700;letter-spacing:1.5px; | |
| text-transform:uppercase;color:var(--text-muted);margin-bottom:10px;text-align:center} | |
| .philosophers{display:flex;flex-wrap:wrap;gap:6px;justify-content:center} | |
| .phil-name{background:transparent;border:1px solid var(--border); | |
| color:var(--text-muted);font-size:12px;padding:4px 10px;border-radius:20px; | |
| cursor:pointer;transition:all .15s;user-select:none;font-style:italic} | |
| .phil-name:hover{border-color:var(--gold);color:var(--gold-lite);background:rgba(201,168,76,.07)} | |
| @media(max-width:600px){ | |
| .chat-wrap,.input-bar{padding-left:16px;padding-right:16px} | |
| .empty-title{font-size:18px} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- AUTH --> | |
| <div class="auth-overlay" id="auth"> | |
| <div class="auth-box"> | |
| <div class="auth-logo">TunedAI</div> | |
| <div class="auth-sub">Private Access — Enter your code</div> | |
| <input type="password" id="pw" placeholder="access code" | |
| onkeydown="if(event.key==='Enter')checkAuth()"> | |
| <button onclick="checkAuth()">Enter</button> | |
| <div class="auth-err" id="auth-err"></div> | |
| </div> | |
| </div> | |
| <!-- HEADER --> | |
| <div class="header"> | |
| <div class="logo">Philosopher <span>TunedAI · Qwen3-14B DPO fine-tune</span></div> | |
| <div class="header-right"> | |
| <label class="mode-toggle" title="Side-by-side comparison with base model"> | |
| <span class="mode-label">Side-by-side</span> | |
| <input type="checkbox" id="mode-toggle" onchange="if(this.checked) location.href='/compare'"> | |
| <span class="mode-track"><span class="mode-thumb"></span></span> | |
| </label> | |
| <span class="badge">Private</span> | |
| </div> | |
| </div> | |
| <!-- CHAT --> | |
| <div class="chat-wrap" id="chat-wrap"> | |
| <div class="messages" id="messages"> | |
| <div class="empty-state" id="empty-state"> | |
| <div class="empty-title">Philosopher</div> | |
| <div class="empty-sub">A 14B model fine-tuned via DPO for depth on philosophy and ethics. Ask anything.</div> | |
| <div class="starters" id="starters"></div> | |
| <div class="philosophers-section"> | |
| <div class="philosophers-label">Browse by Philosopher</div> | |
| <div class="philosophers" id="philosophers"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- INPUT BAR --> | |
| <div class="input-bar"> | |
| <div class="len-row"> | |
| <span class="len-label">Length</span> | |
| <div class="len-pills"> | |
| <button class="len-pill" onclick="setLen(600,this)">Brief</button> | |
| <button class="len-pill" onclick="setLen(1500,this)">Full</button> | |
| <button class="len-pill active" onclick="setLen(4000,this)">Extended</button> | |
| </div> | |
| </div> | |
| <div class="input-row"> | |
| <textarea class="chat-input" id="chat-input" | |
| placeholder="Ask anything..." | |
| onkeydown="handleKey(event)" | |
| oninput="autoResize(this)"></textarea> | |
| <button class="send-btn" id="send-btn" onclick="send()">Send</button> | |
| </div> | |
| </div> | |
| <script> | |
| const VALID_CODES = """ + json.dumps(list(PLAYGROUND_VALID) + [DEMO_PASSWORD]) + """; | |
| const STARTERS = [ | |
| "What is the hardest problem in philosophy of mind?", | |
| "Explain the trolley problem and what it reveals about ethics.", | |
| "What is the strongest argument for free will?", | |
| "Is consciousness reducible to physical processes?", | |
| "How should we think about personal identity over time?", | |
| "What makes an argument valid vs. sound?", | |
| "Explain Gödel's incompleteness theorems in plain language.", | |
| "What is the difference between knowledge and justified belief?", | |
| "Could a machine ever truly understand language?", | |
| "What is the most compelling argument against moral relativism?", | |
| ]; | |
| let maxTokens = 2000; | |
| let isStreaming = false; | |
| let conversation = []; // [{role:'user'|'assistant', content:'...'}, ...] | |
| function setLen(tokens, btn) { | |
| maxTokens = tokens; | |
| document.querySelectorAll('.len-pill').forEach(p => p.classList.remove('active')); | |
| btn.classList.add('active'); | |
| } | |
| function checkAuth() { | |
| const pw = document.getElementById('pw').value.trim(); | |
| if (VALID_CODES.includes(pw)) { | |
| sessionStorage.setItem('pg_auth', pw); | |
| document.getElementById('auth').classList.add('hidden'); | |
| document.getElementById('chat-input').focus(); | |
| } else { | |
| document.getElementById('auth-err').textContent = 'Invalid access code.'; | |
| } | |
| } | |
| function autoResize(el) { | |
| el.style.height = 'auto'; | |
| el.style.height = Math.min(el.scrollHeight, 160) + 'px'; | |
| } | |
| function handleKey(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } | |
| } | |
| function addMessage(role, text) { | |
| const empty = document.getElementById('empty-state'); | |
| if (empty) empty.remove(); | |
| const msgs = document.getElementById('messages'); | |
| const div = document.createElement('div'); | |
| div.className = 'msg ' + role; | |
| div.innerHTML = '<div class="msg-label">' + (role === 'user' ? 'You' : 'TunedAI') + '</div>' | |
| + '<div class="msg-body"></div>'; | |
| div.querySelector('.msg-body').textContent = text; | |
| msgs.appendChild(div); | |
| msgs.scrollTop = msgs.scrollHeight; | |
| return div.querySelector('.msg-body'); | |
| } | |
| function addStreamingMessage() { | |
| const empty = document.getElementById('empty-state'); | |
| if (empty) empty.remove(); | |
| const msgs = document.getElementById('messages'); | |
| const div = document.createElement('div'); | |
| div.className = 'msg assistant'; | |
| div.innerHTML = '<div class="msg-label">TunedAI</div>' | |
| + '<div class="msg-body"><span class="thinking-label">reasoning...</span><span class="cursor"></span></div>'; | |
| msgs.appendChild(div); | |
| msgs.scrollTop = msgs.scrollHeight; | |
| return div.querySelector('.msg-body'); | |
| } | |
| async function send() { | |
| if (isStreaming) return; | |
| const input = document.getElementById('chat-input'); | |
| const q = input.value.trim(); | |
| if (!q) return; | |
| input.value = ''; | |
| input.style.height = ''; | |
| document.getElementById('send-btn').disabled = true; | |
| isStreaming = true; | |
| addMessage('user', q); | |
| conversation.push({ role: 'user', content: q }); | |
| const bodyEl = addStreamingMessage(); | |
| let text = ''; | |
| let gotTokens = false; | |
| const warmTimer = setTimeout(() => { | |
| if (!gotTokens) { | |
| bodyEl.innerHTML = '<div class="warming"><div class="warm-spinner"></div>' | |
| + '<span>Model warming up — first response takes 2–4 min...</span></div>'; | |
| } | |
| }, 4000); | |
| try { | |
| const res = await fetch('/stream/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ messages: conversation, max_tokens: maxTokens }) | |
| }); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let finished = false; | |
| while (!finished) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const lines = decoder.decode(value).split('\\n'); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| const data = line.slice(6).trim(); | |
| if (data === '[DONE]') { finished = true; break; } | |
| try { | |
| const parsed = JSON.parse(data); | |
| if (parsed.token) { | |
| if (!gotTokens) { | |
| gotTokens = true; | |
| clearTimeout(warmTimer); | |
| bodyEl.innerHTML = ''; | |
| } | |
| text += parsed.token; | |
| bodyEl.textContent = text; | |
| document.getElementById('messages').scrollTop = 9999; | |
| } | |
| } catch(e) {} | |
| } | |
| } | |
| } | |
| } catch(e) { | |
| clearTimeout(warmTimer); | |
| bodyEl.textContent = 'Error: ' + e.message; | |
| } | |
| clearTimeout(warmTimer); | |
| if (text) { | |
| conversation.push({ role: 'assistant', content: text }); | |
| } else { | |
| // Roll back the user message so retry doesn't double up | |
| conversation.pop(); | |
| } | |
| isStreaming = false; | |
| document.getElementById('send-btn').disabled = false; | |
| document.getElementById('chat-input').focus(); | |
| } | |
| // Starters | |
| document.getElementById('starters').innerHTML = STARTERS.map(s => | |
| '<div class="starter" onclick="useStarter(this)">' + s + '</div>' | |
| ).join(''); | |
| function useStarter(el) { | |
| document.getElementById('chat-input').value = el.textContent; | |
| send(); | |
| } | |
| // Philosophers | |
| const PHILOSOPHERS = """ + json.dumps(PHILOSOPHERS) + """; | |
| document.getElementById('philosophers').innerHTML = PHILOSOPHERS.map(name => | |
| '<div class="phil-name" data-name="' + name.replace(/"/g, '"') + '">' + name + '</div>' | |
| ).join(''); | |
| document.querySelectorAll('.phil-name').forEach(el => { | |
| el.addEventListener('click', () => { | |
| const name = el.getAttribute('data-name'); | |
| document.getElementById('chat-input').value = | |
| 'Who was ' + name + ' and what did they believe? Why does their philosophy still matter?'; | |
| send(); | |
| }); | |
| }); | |
| // Check existing session | |
| if (sessionStorage.getItem('pg_auth') && VALID_CODES.includes(sessionStorage.getItem('pg_auth'))) { | |
| document.getElementById('auth').classList.add('hidden'); | |
| document.getElementById('chat-input').focus(); | |
| } else { | |
| document.getElementById('pw').focus(); | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| async def playground(): | |
| return HTMLResponse(content=PLAYGROUND_HTML, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=9002) | |