philosopher-demo / philosopher_api.py
Mark Gentry
Lazy-init OpenAI client; app boots without OPENAI_API_KEY (DAG returns 503)
158b7bf
"""
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 ────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def root():
return HTMLResponse(content=PLAYGROUND_HTML, headers={"Cache-Control": "no-store, no-cache, must-revalidate"})
@app.get("/compare", response_class=HTMLResponse)
async def compare():
return HTMLResponse(content=HTML, headers={"Cache-Control": "no-store, no-cache, must-revalidate"})
@app.get("/info")
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"
@app.post("/stream/base")
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"
)
@app.post("/stream/philosopher")
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"
)
@app.post("/stream/chat")
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")
@app.post("/dag")
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, '&quot;') + '">' + 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>"""
@app.get("/playground", response_class=HTMLResponse)
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)