rickveloper's picture
Update app.py
a18f2cf verified
Raw
History Blame Contribute Delete
23.7 kB
import hashlib
import random
from datetime import date
from html import escape
from urllib.parse import quote
import gradio as gr
import requests
import spaces
WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php"
CHALLENGES = [
("Motorcycle", "Black hole"),
("Coffee", "Moon"),
("Rubber duck", "Artificial intelligence"),
("Pizza", "Quantum mechanics"),
("Harley-Davidson", "Computer science"),
("Volcano", "Internet"),
("Shark", "Electricity"),
("Chess", "Space exploration"),
("New York City", "Dinosaur"),
("Heavy metal music", "Ancient Egypt"),
("Video game", "Solar System"),
("Submarine", "World Wide Web"),
]
@spaces.GPU(duration=1)
def zerogpu_compatibility():
"""Allows this lightweight app to start on ZeroGPU hardware."""
return True
def wikipedia_request(params: dict) -> dict:
response = requests.get(
WIKIPEDIA_API,
params={
**params,
"format": "json",
"formatversion": 2,
"origin": "*",
},
timeout=20,
headers={
"User-Agent": "WikiQuest/1.0 (Hugging Face educational game)"
},
)
response.raise_for_status()
return response.json()
def normalize_title(title: str) -> str:
return " ".join(title.strip().split())
def resolve_article(title: str) -> str:
data = wikipedia_request(
{
"action": "query",
"titles": title,
"redirects": 1,
}
)
pages = data.get("query", {}).get("pages", [])
if not pages or pages[0].get("missing"):
raise ValueError(f'Wikipedia could not find an article named "{title}".')
return pages[0].get("title", title)
def get_article_data(title: str, link_limit: int = 120) -> tuple[str, str, list[str]]:
data = wikipedia_request(
{
"action": "query",
"prop": "extracts|links",
"titles": title,
"redirects": 1,
"exintro": 1,
"explaintext": 1,
"plnamespace": 0,
"pllimit": link_limit,
}
)
pages = data.get("query", {}).get("pages", [])
if not pages or pages[0].get("missing"):
raise ValueError(f'Wikipedia could not load "{title}".')
page = pages[0]
resolved_title = page.get("title", title)
summary = page.get("extract") or "No introductory summary was available."
links = []
blocked_prefixes = (
"List of",
"Outline of",
"Index of",
"Glossary of",
"Timeline of",
)
for item in page.get("links", []):
link_title = item.get("title", "").strip()
if not link_title:
continue
if link_title.startswith(blocked_prefixes):
continue
if link_title.casefold() == resolved_title.casefold():
continue
links.append(link_title)
return resolved_title, summary[:900], sorted(set(links))
def get_link_titles(title: str, limit: int = 100) -> set[str]:
try:
_, _, links = get_article_data(title, limit)
return {item.casefold() for item in links}
except Exception:
return set()
def title_words(title: str) -> set[str]:
ignored = {
"a",
"an",
"and",
"the",
"of",
"in",
"on",
"to",
"for",
"with",
"by",
}
cleaned = "".join(
character.lower() if character.isalnum() else " "
for character in title
)
return {
word
for word in cleaned.split()
if len(word) > 2 and word not in ignored
}
def connection_score(
article: str,
target: str,
article_links: set[str] | None = None,
) -> float:
if article.casefold() == target.casefold():
return 100.0
article_words = title_words(article)
target_words = title_words(target)
word_overlap = len(article_words & target_words)
score = word_overlap * 16.0
article_lower = article.casefold()
target_lower = target.casefold()
if article_lower in target_lower or target_lower in article_lower:
score += 25.0
if article_links and target_lower in article_links:
score += 55.0
return min(score, 95.0)
def select_destinations(
links: list[str],
target: str,
amount: int = 12,
) -> list[str]:
if not links:
return []
target_lower = target.casefold()
target_terms = title_words(target)
def rank(link: str) -> tuple[int, int, int]:
link_terms = title_words(link)
exact_target = int(link.casefold() == target_lower)
shared_terms = len(link_terms & target_terms)
readable_length = -abs(len(link) - 18)
return exact_target, shared_terms, readable_length
ranked = sorted(links, key=rank, reverse=True)
priority = ranked[:5]
remaining = [link for link in links if link not in priority]
seed_text = f"{target}|{'|'.join(links[:20])}"
seed = int(hashlib.sha256(seed_text.encode()).hexdigest()[:12], 16)
local_random = random.Random(seed)
local_random.shuffle(remaining)
selected = priority + remaining[: max(0, amount - len(priority))]
if target in links and target not in selected:
selected[-1] = target
return selected[:amount]
def daily_challenge() -> tuple[str, str]:
today_number = date.today().toordinal()
return CHALLENGES[today_number % len(CHALLENGES)]
def random_challenge() -> tuple[str, str]:
return random.choice(CHALLENGES)
def make_header(
current: str,
target: str,
clicks: int,
temperature: str,
) -> str:
return f"""
<div class="mission-grid">
<div class="mission-card current-card">
<span class="eyebrow">CURRENT LOCATION</span>
<h2>{escape(current)}</h2>
</div>
<div class="mission-arrow">
<span>➜</span>
</div>
<div class="mission-card target-card">
<span class="eyebrow">TARGET DESTINATION</span>
<h2>{escape(target)}</h2>
</div>
</div>
<div class="game-stats">
<div class="stat-pill">
<span class="stat-label">MOVES</span>
<strong>{clicks}</strong>
</div>
<div class="stat-pill heat-pill">
<span class="stat-label">SIGNAL</span>
<strong>{escape(temperature)}</strong>
</div>
</div>
"""
def make_article_card(title: str, summary: str) -> str:
article_url = (
"https://en.wikipedia.org/wiki/"
+ quote(title.replace(" ", "_"), safe="")
)
return f"""
<section class="article-panel">
<div class="article-heading">
<span class="location-pin">◉</span>
<div>
<span class="eyebrow">YOU HAVE ARRIVED AT</span>
<h1>{escape(title)}</h1>
</div>
</div>
<p>{escape(summary)}</p>
<a
class="wiki-link"
href="{article_url}"
target="_blank"
rel="noopener noreferrer"
>
Read the full Wikipedia article ↗
</a>
</section>
"""
def make_path(path: list[str]) -> str:
if not path:
return """
<div class="empty-path">
Your journey will appear here.
</div>
"""
nodes = []
for index, title in enumerate(path):
node_class = "path-node"
if index == 0:
node_class += " start-node"
elif index == len(path) - 1:
node_class += " active-node"
nodes.append(
f"""
<div class="{node_class}">
<span>{index}</span>
<strong>{escape(title)}</strong>
</div>
"""
)
if index < len(path) - 1:
nodes.append('<div class="path-line"></div>')
return f"""
<div class="path-wrapper">
{''.join(nodes)}
</div>
"""
def temperature_message(
previous_score: float | None,
new_score: float,
won: bool,
) -> str:
if won:
return "TARGET REACHED"
if previous_score is None:
return "SEARCHING"
difference = new_score - previous_score
if new_score >= 65:
return "BURNING HOT"
if difference >= 18:
return "MUCH WARMER"
if difference >= 4:
return "GETTING WARMER"
if difference <= -18:
return "ICE COLD"
if difference <= -4:
return "GETTING COLDER"
return "UNCHANGED"
def victory_screen(path: list[str], target: str) -> str:
clicks = max(len(path) - 1, 0)
return f"""
<section class="victory-panel">
<div class="victory-icon">◆</div>
<span class="eyebrow">MISSION COMPLETE</span>
<h1>You found {escape(target)}!</h1>
<p>
You crossed Wikipedia in
<strong>{clicks} move{"s" if clicks != 1 else ""}</strong>.
</p>
<div class="victory-path">
{escape(" → ".join(path))}
</div>
</section>
"""
def error_outputs(message: str):
return (
f"""
<section class="error-panel">
<h2>Unable to start this mission</h2>
<p>{escape(message)}</p>
</section>
""",
"",
gr.update(choices=[], value=None, interactive=False),
"",
[],
"",
"",
0.0,
)
def load_location(
article: str,
target: str,
path: list[str],
previous_score: float | None,
):
resolved_title, summary, links = get_article_data(article)
target = resolve_article(target)
if path:
path[-1] = resolved_title
else:
path = [resolved_title]
link_set = {link.casefold() for link in links}
score = connection_score(resolved_title, target, link_set)
won = resolved_title.casefold() == target.casefold()
temperature = temperature_message(previous_score, score, won)
header = make_header(
resolved_title,
target,
max(len(path) - 1, 0),
temperature,
)
if won:
content = victory_screen(path, target)
destinations = gr.update(
choices=[],
value=None,
interactive=False,
label="Mission complete",
)
else:
content = make_article_card(resolved_title, summary)
choices = select_destinations(links, target)
destinations = gr.update(
choices=choices,
value=None,
interactive=True,
label="Choose your next destination",
)
return (
header,
content,
destinations,
make_path(path),
path,
resolved_title,
target,
score,
)
def start_game(start: str, target: str):
start = normalize_title(start)
target = normalize_title(target)
if not start or not target:
return error_outputs("Enter both a starting article and a target.")
try:
resolved_start = resolve_article(start)
resolved_target = resolve_article(target)
return load_location(
resolved_start,
resolved_target,
[resolved_start],
None,
)
except (requests.RequestException, ValueError) as error:
return error_outputs(str(error))
def travel(
destination: str,
target: str,
path: list[str],
previous_score: float,
):
if not destination:
return (
gr.update(),
gr.update(),
gr.update(),
gr.update(),
path,
path[-1] if path else "",
target,
previous_score,
)
new_path = list(path)
new_path.append(destination)
try:
return load_location(
destination,
target,
new_path,
previous_score,
)
except (requests.RequestException, ValueError) as error:
return error_outputs(str(error))
def undo_move(
target: str,
path: list[str],
):
if len(path) <= 1:
current = path[0] if path else ""
if not current:
return error_outputs("Start a mission first.")
return load_location(
current,
target,
path,
None,
)
new_path = path[:-1]
previous_article = new_path[-1]
return load_location(
previous_article,
target,
new_path,
None,
)
def load_daily():
start, target = daily_challenge()
return start_game(start, target)
def load_random():
start, target = random_challenge()
return start_game(start, target)
CSS = """
:root {
--background: #070b14;
--panel: #111827;
--panel-light: #182235;
--border: rgba(255, 255, 255, 0.09);
--text: #f7f9fc;
--muted: #9aa8bd;
--accent: #7c5cff;
--accent-bright: #9c88ff;
--cyan: #4dd7ff;
--success: #48e3a5;
}
.gradio-container {
max-width: 1180px !important;
margin: 0 auto !important;
padding: 20px !important;
background:
radial-gradient(circle at 50% -20%, rgba(124, 92, 255, 0.24), transparent 40%),
var(--background) !important;
}
body,
.gradio-container {
font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
}
footer {
display: none !important;
}
.hero {
text-align: center;
padding: 38px 10px 24px;
}
.hero-badge {
display: inline-block;
color: var(--cyan);
border: 1px solid rgba(77, 215, 255, 0.32);
background: rgba(77, 215, 255, 0.08);
border-radius: 999px;
padding: 7px 13px;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.16em;
}
.hero h1 {
margin: 16px 0 6px;
color: var(--text);
font-size: clamp(2.8rem, 7vw, 5.1rem);
line-height: 0.95;
letter-spacing: -0.06em;
}
.hero h1 span {
color: var(--accent-bright);
}
.hero p {
color: var(--muted);
font-size: 1.05rem;
margin: 15px auto 0;
max-width: 630px;
}
.setup-panel,
.control-panel {
background: rgba(17, 24, 39, 0.9);
border: 1px solid var(--border);
border-radius: 22px;
padding: 20px;
box-shadow: 0 20px 55px rgba(0, 0, 0, 0.28);
}
.setup-panel label span,
.destination-grid label span {
color: var(--text) !important;
font-weight: 800 !important;
}
.mission-grid {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 14px;
align-items: stretch;
margin: 22px 0 14px;
}
.mission-card {
min-height: 125px;
padding: 22px;
border-radius: 20px;
background: var(--panel);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: center;
}
.current-card {
border-color: rgba(77, 215, 255, 0.35);
}
.target-card {
border-color: rgba(156, 136, 255, 0.4);
}
.mission-card h2 {
color: var(--text);
margin: 6px 0 0;
font-size: 1.55rem;
}
.mission-arrow {
display: flex;
align-items: center;
color: var(--accent-bright);
font-size: 2rem;
}
.eyebrow {
color: var(--muted);
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.16em;
}
.game-stats {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.stat-pill {
flex: 1;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 15px;
padding: 13px 17px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-pill strong {
color: var(--text);
}
.stat-label {
color: var(--muted);
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.12em;
}
.heat-pill strong {
color: var(--cyan);
}
.article-panel,
.victory-panel,
.error-panel {
background:
linear-gradient(135deg, rgba(124, 92, 255, 0.09), transparent 45%),
var(--panel);
border: 1px solid var(--border);
border-radius: 22px;
padding: clamp(22px, 5vw, 38px);
margin: 14px 0;
color: var(--text);
}
.article-heading {
display: flex;
gap: 14px;
align-items: center;
}
.location-pin {
color: var(--cyan);
font-size: 1.7rem;
}
.article-panel h1,
.victory-panel h1 {
color: var(--text);
margin: 4px 0 0;
font-size: clamp(1.8rem, 5vw, 2.7rem);
}
.article-panel p {
color: #c7d0df;
line-height: 1.75;
font-size: 1rem;
}
.wiki-link {
display: inline-block;
color: var(--cyan) !important;
font-weight: 750;
text-decoration: none !important;
margin-top: 8px;
}
.destination-grid {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 22px;
padding: 18px !important;
margin-top: 14px;
}
.destination-grid .wrap {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 10px !important;
}
.destination-grid label {
min-height: 62px !important;
border: 1px solid var(--border) !important;
border-radius: 14px !important;
background: var(--panel-light) !important;
color: var(--text) !important;
padding: 14px !important;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease !important;
}
.destination-grid label:hover {
transform: translateY(-2px);
border-color: rgba(124, 92, 255, 0.8) !important;
background: rgba(124, 92, 255, 0.16) !important;
}
.primary-button {
min-height: 52px !important;
border-radius: 14px !important;
font-weight: 850 !important;
}
.path-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 22px;
padding: 22px;
margin-top: 14px;
}
.path-title {
color: var(--text);
font-size: 0.78rem;
letter-spacing: 0.13em;
font-weight: 900;
margin-bottom: 18px;
}
.path-wrapper {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
}
.path-node {
min-width: max-content;
background: var(--panel-light);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text);
padding: 8px 13px 8px 8px;
display: flex;
align-items: center;
gap: 8px;
}
.path-node span {
width: 27px;
height: 27px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: var(--muted);
font-size: 0.75rem;
}
.active-node {
border-color: rgba(77, 215, 255, 0.55);
}
.active-node span {
background: var(--cyan);
color: #07101a;
}
.start-node span {
background: var(--accent);
color: white;
}
.path-line {
width: 24px;
min-width: 24px;
height: 2px;
background: rgba(255, 255, 255, 0.14);
}
.victory-panel {
text-align: center;
border-color: rgba(72, 227, 165, 0.45);
background:
radial-gradient(circle at 50% 0%, rgba(72, 227, 165, 0.19), transparent 48%),
var(--panel);
}
.victory-icon {
color: var(--success);
font-size: 3rem;
margin-bottom: 10px;
}
.victory-panel p {
color: var(--muted);
font-size: 1.05rem;
}
.victory-path {
color: var(--success);
background: rgba(72, 227, 165, 0.08);
border: 1px solid rgba(72, 227, 165, 0.2);
border-radius: 14px;
padding: 15px;
margin-top: 18px;
overflow-wrap: anywhere;
}
.empty-path {
color: var(--muted);
}
.error-panel {
border-color: rgba(255, 100, 120, 0.38);
}
@media (max-width: 760px) {
.gradio-container {
padding: 12px !important;
}
.mission-grid {
grid-template-columns: 1fr;
}
.mission-arrow {
justify-content: center;
transform: rotate(90deg);
height: 25px;
}
.destination-grid .wrap {
grid-template-columns: 1fr !important;
}
.game-stats {
flex-direction: column;
}
}
"""
with gr.Blocks(title="WikiQuest") as demo:
path_state = gr.State([])
current_state = gr.State("")
target_state = gr.State("")
score_state = gr.State(0.0)
gr.HTML(
"""
<header class="hero">
<span class="hero-badge">THE INTERNET RABBIT HOLE</span>
<h1>WIKI<span>QUEST</span></h1>
<p>
Travel between two completely different ideas using only
the links hidden inside Wikipedia.
</p>
</header>
"""
)
with gr.Group(elem_classes=["setup-panel"]):
with gr.Row():
start_input = gr.Textbox(
label="Starting article",
value="Motorcycle",
placeholder="Example: Motorcycle",
)
target_input = gr.Textbox(
label="Target article",
value="Black hole",
placeholder="Example: Black hole",
)
with gr.Row():
start_button = gr.Button(
"Start Custom Mission",
variant="primary",
elem_classes=["primary-button"],
)
daily_button = gr.Button(
"Play Daily Challenge",
elem_classes=["primary-button"],
)
random_button = gr.Button(
"Generate Random Mission",
elem_classes=["primary-button"],
)
mission_header = gr.HTML()
article_display = gr.HTML(
"""
<section class="article-panel">
<span class="eyebrow">READY FOR DEPARTURE</span>
<h1>Choose a mission above</h1>
<p>
Every destination shown during the game is a real link
from your current Wikipedia article.
</p>
</section>
"""
)
destination_picker = gr.Radio(
label="Choose your next destination",
choices=[],
interactive=True,
elem_classes=["destination-grid"],
)
with gr.Row():
travel_button = gr.Button(
"Travel to Selected Article",
variant="primary",
elem_classes=["primary-button"],
)
undo_button = gr.Button(
"Undo Last Move",
elem_classes=["primary-button"],
)
gr.HTML('<section class="path-section"><div class="path-title">YOUR JOURNEY</div>')
path_display = gr.HTML(
'<div class="empty-path">Your journey will appear here.</div>'
)
gr.HTML("</section>")
game_outputs = [
mission_header,
article_display,
destination_picker,
path_display,
path_state,
current_state,
target_state,
score_state,
]
start_button.click(
fn=start_game,
inputs=[start_input, target_input],
outputs=game_outputs,
)
daily_button.click(
fn=load_daily,
outputs=game_outputs,
)
random_button.click(
fn=load_random,
outputs=game_outputs,
)
travel_button.click(
fn=travel,
inputs=[
destination_picker,
target_state,
path_state,
score_state,
],
outputs=game_outputs,
)
destination_picker.change(
fn=travel,
inputs=[
destination_picker,
target_state,
path_state,
score_state,
],
outputs=game_outputs,
)
undo_button.click(
fn=undo_move,
inputs=[
target_state,
path_state,
],
outputs=game_outputs,
)
demo.launch(
theme=gr.themes.Base(),
css=CSS,
)