我製作了一款提升學習能力的遊戲
I made a game that boosts my learning skills
作者製作了 Ghost Tutor,一款以 Python 編寫的遊戲,可把用戶的學習材料轉成帶有心理恐怖氛圍的考試式問題。遊戲支援 PDF、圖片、Word、Excel、PowerPoint、影片及 YouTube 連結,並以生命值和分數設計答題機制;程式使用 gemma-4-31b-it 和 gemini-2.5-flash。
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine
What I Built
Studying from old notes is boring, so I let a ghost haunt them.
Ghost Tutor is a Python game that reads your own study material and quizzes you inside a haunted library. Feed it a PDF, a scanned PDF, photos of a textbook, a Word, Excel or PowerPoint file, a video file, or a YouTube link, and the ghost turns it into exam-style questions wrapped in psychological horror. The dusty notes and forgotten textbooks you never reread come back to haunt you. That's my take on "Ghost in the Machine".
How it plays:
-
You choose the number of levels. Short notes give up to 10 levels, and a big book (10+ pages) gives up to 50. Refuse to choose, and the ghost chooses for you. If you type
0, it whispers something creepy and picks a number that matches the content. - Souls and points. A wrong answer costs a soul and 5 points. If your points drain to zero, the ghost claims you.
- You can't skip. Try, and the ghost blocks you with a different spooky line every time.
- Trapdoor questions. Some questions end in "All of the above" or "None of the above", and that last option is always the right one.
- Every option has its own reason. Whatever you pick, the game explains in plain facts why it is right or wrong.
- Two moods. The text alternates between Gothic & Eerie (candles, fog, locked library doors) and Dark & Ominous (trapdoors, executioners, the price of cowardice).
Demo
Code
import os
import re
import sys
import json
import time
import random
import google.generativeai as genai
from concurrent.futures import ThreadPoolExecutor
Paste your API key between the quotes. Keep this file private: never post it or share it with the key inside.
API_KEY = "PASTE_YOUR_NEW_API_KEY_HERE"
if API_KEY.startswith("PASTE_YOUR"): # not pasted? try the PowerShell variable instead
API_KEY = os.environ.get("GEMINI_API_KEY", "")
if not API_KEY:
sys.exit("No API key found. Paste it into the API_KEY line near the top of ghost_game.py.")
genai.configure(api_key=API_KEY)
MODELS = ["gemma-4-31b-it", "gemini-2.5-flash"] # text: main model, then backup
MEDIA_MODEL = "gemini-2.5-flash" # photos, scans and videos
SOULS = 3
LOW_MAX = 10 # most questions for short material
HIGH_MAX = 50 # most questions for big material
HIGH_PAGES = 10 # this many pages counts as big material
CHARS_PER_PAGE = 3000
CHARS_PER_QUESTION = 150 # short material: about 25 words per question
MIN_CHARS = 80 # less text than this is too thin to quiz
PER_CALL = 4 # questions requested in one text call
MEDIA_PER_CALL = 8 # questions requested in one photo/scan/video call
WORKERS = 3 # model calls running at the same time
CHUNK_SIZE = 3000
MAX_IMAGES = 30
MAX_FILE_MB = 200 # bigger files are refused
TYPE_DELAY = 0.004 # typing effect speed (smaller = faster)
POINT_PENALTY = 5 # points lost for every wrong answer
START_POINTS = 0 # points you begin with (set it to e.g. 20 for a pool that wrong answers drain)
IMAGE_TYPES = (".jpg", ".jpeg", ".png", ".webp")
VIDEO_TYPES = (".mp4", ".mov", ".mkv", ".webm", ".avi")
REQUIRED = ["question", "options", "answer"]
TONES = ("gothic", "dark")
LAST_ERROR = {"msg": ""} # remembers why the last question failed
Messages by tone: "gothic" = Gothic & Eerie, "dark" = Dark & Ominous
MESSAGES = {
"correct": {
"gothic": [
"The spirits whisper... you are correct.",
"A chill runs down your spine as the truth reveals itself.",
"The shadows recede, satisfied with your answer.",
"You have appeased the ancient ones... for now.",
"The dust settles on the tomb. You spoke the true word.",
"The candle flames bow to your answer.",
"A distant bell tolls once... in approval.",
"The old library creaks, as if nodding in agreement.",
"Moonlight spills across the page. The truth is yours.",
"The mist parts, and the path forward appears.",
"A raven watches from the rafters, and lowers its gaze in respect.",
"The cobwebs tremble. The ghost is pleased.",
],
"dark": [
"You survive to face the next question.",
"The executioner lowers his axe... you live.",
"Correct. You narrowly avoided a grim fate.",
"Your soul remains your own. Next.",
"The countdown pauses. You have bought yourself a little more time.",
"Right again. But the dark is still watching.",
"You live... but the night is far from over.",
"The reaper pauses, then lowers his scythe. For now.",
"Correct. The noose loosens, just a little.",
"The hunger in the dark is denied... this time.",
"Your answer holds. The dungeon door stays shut.",
"The entity retreats into the shadows, but it will return.",
],
},
"wrong": {
"gothic": [
"A sudden wind blows out your candle... incorrect.",
"The fog thickens. You are lost in the dark.",
"Something just laughed behind you. That was wrong.",
"The spirits shake their heads in disappointment.",
"The portrait on the wall watches you fail, its eyes narrowing.",
"The candle gutters and dies. That was not the answer.",
"A raven cries from the rafters. You have erred.",
"The old clock strikes thirteen. Something is very wrong.",
"Cold mist curls around your ankles. Wrong.",
"The pages rustle with a mocking whisper.",
"The mirror shows a face behind you, shaking its head.",
"A bell tolls in the distance... for your mistake.",
],
"dark": [
"That mistake will haunt you forever.",
"The trapdoor creaks open beneath your feet.",
"Incorrect. The shadows draw a little closer.",
"You hear chains rattling... your time is running out.",
"The entity grows hungry. Your mistake feeds it.",
"A soul slips away into the dark. Incorrect.",
"The grave opens a little wider.",
"The reaper marks your name. Wrong.",
"The noose tightens. That answer was your mistake.",
"The dungeon door groans open, hungry for you.",
"The shadows lean in, tasting your failure.",
"Your fate darkens with every wrong word.",
],
},
"skip": {
"gothic": [
"You cannot flee the archives; the doors are locked from the outside.",
"The fog blocks your path. You must face what lies ahead.",
"Turning your back on the spirits only makes them angry.",
"The pages turn themselves back. You cannot run from this.",
"A cold hand grips your shoulder, forcing you to turn around.",
"The corridor stretches on forever. There is no way to skip ahead.",
"The candles gutter. The ghost will not let you pass.",
"The tome slams shut, then opens again to the same question.",
"Mist seals the staircase. Only an answer will clear it.",
"The portraits turn their eyes upon you. Answer.",
"The clock refuses to move until you answer.",
],
"dark": [
"Cowardice carries a much steeper price than failure.",
"There are no exits in the dark. Choose your fate.",
"You can try to run, but the entity is already waiting at the end of the hall.",
"The blade drops lower every time you hesitate. Answer.",
"Skipping will only bring the horror closer. Make your choice.",
"The reaper does not allow skipping. Answer, or pay.",
"The dungeon door bolts itself. You are not leaving.",
"Every step you take back, the dark takes two forward.",
"The entity whispers: you will answer... one way or another.",
"The shadows swallow the exit. Choose, mortal.",
"Hesitation is a gift to the dead. Answer.",
],
},
}
SKIP_WORDS = ("skip", "s", "pass", "next")
def say(kind, tone):
return random.choice(MESSAGES[kind][tone])
RECEIVED_MESSAGES = [
# gothic and eerie
"The leaves rustle as your pages turn in the dark.",
"We have received your manuscript. The spirits are already reading over your shoulder.",
"A cold wind blows through the server... your file has arrived safely.",
"Your words have been etched into the obsidian stones of our library.",
"The ink bleeds into the screen. Your document is accepted.",
# dark and ominous
"There is no turning back now. Your file belongs to the abyss.",
"We have locked your data in the cellar. Pray nothing finds it.",
"You have handed us the key. The entity is pleased.",
"Correctly received. But remember... once uploaded, it can never truly be forgotten.",
"A trapdoor closes behind your file. It is ours now.",
]
LOADING_MESSAGES = [
"The entity is tearing your file apart to read the pieces...",
"Shhh... the walls are listening to your data.",
"Chains rattle in the server room as your file ascends.",
"Bleeding through the wires... almost there.",
"The darkness is stretching to accommodate your offering.",
]
UPLOAD_SUCCESS_MESSAGES = [
"It is finished. Your sacrifice has been accepted into the vault.",
"The archives have swallowed it whole. It rests in peace now.",
"We've locked it away where the light can never reach it.",
"The whispers have ceased. Your text is now part of the gloom.",
"The ink has dried. The pact is sealed.",
]
UPLOAD_FAIL_MESSAGES = [
"The abyss rejected your offering. Try again... if you dare.",
"Something in the shadows bit the wire. Upload severed.",
"Your file died on the way down. Resurrect it and try again.",
"The spirits are displeased with this format. Give them something else.",
"It slipped into the cracks between the servers. It's gone forever. Retry.",
]
TOO_LARGE_MESSAGES = [
"This is too heavy for the ghosts to carry. Shrink your offering.",
"The entity refuses to swallow a beast of this size.",
]
WRONG_FORMAT_MESSAGES = [
"A foul curse has corrupted this file type. Send a .txt or .pdf instead.",
"The skeletons cannot read these runes. Change the format.",
]
VOID_MESSAGES = [ # short material, but the player asked for more than 10
"The void cannot stretch that far, mortal. These pages hold too little darkness for so many questions.",
"You ask for more than the void can give... it groans, and cracks spread across the walls.",
"Greedy soul. There is nothing more in these pages to feed you. The void will choose for you.",
"The shadows stretch thin, and tear. The void cannot give what you demand.",
"The abyss laughs: 'There is nothing left to ask.' I will decide the number myself.",
]
HESITATION_MESSAGES = [ # short material, and the player gave no number
"You hesitate... the ghost smells your fear. Very well, I will choose for you.",
"Silence. The candles lean toward you, waiting. Your indecision has been noted.",
"A cold breath on your neck: 'Cannot decide, mortal? Then I will decide.'",
"Every second you wait, the shadows lean closer. I take the choice from your trembling hands.",
"Hesitation is the first step into the dark. I will choose, and you will obey.",
]
MOCKING_MESSAGES = [ # big material, and the player gave no number
"Hahaha... you cannot even choose? This tome is vast, and you are not in control. FIFTY.",
"Such a small, trembling mortal. You have no say here. Fifty questions await you.",
"Powerless and indecisive... delicious. The abyss decrees: fifty levels.",
"You think you hold the pen? The entity writes your fate now: fifty questions.",
"Pathetic. The dark takes control, as it always does. Fifty levels. No mercy.",
]
ABYSS_LIMIT_MESSAGES = [ # big material, and the player asked for more than 50
"Even the abyss has limits, mortal. Fifty is all it can swallow.",
"More than fifty? The tome slams shut on your fingers. Fifty questions, no more.",
]
POINTS_ZERO_MESSAGES = [ # dark and ominous: the points ran out
"Your points drain away... one by one... until nothing is left. The entity feeds, and you are no more.",
"Zero. The number glows blood-red. The trapdoor opens beneath you, and the dark swallows you whole.",
"The last of your points slips through your fingers like ash. The executioner raises his axe... and this time, he does not stop.",
"Nothing left. The hall seals itself, the candles die, and something hungry steps out of the shadows.",
"Your score has bled out. There is nothing left to pay for your mistakes... except you.",
"Empty-handed, you hear the chains rattle... closer... and closer... Then the darkness takes you.",
]
ZERO_MESSAGES = [ # the player typed 0 for the number of levels
"Zero...?",
"The doors slam shut behind you. The candles die, one by one.",
"'You think you can walk away from me, mortal?'",
"Something cold breathes on the back of your neck. You do not dare to turn around.",
"Very well. If you will not choose your fate, I will choose it for you.",
]
TEXT_WAIT_MESSAGES = [
"(The entity is carving your questions into the walls... this takes only a moment.)",
"(Something in the dark is writing your questions. Do not look behind you...)",
"(The ink of the damned is drying... your questions are almost ready.)",
"(The shadows are whispering your questions to the ghost... wait.)",
"(Your questions are being scratched into the coffin lid... just a few seconds.)",
]
MEDIA_WAIT_MESSAGES = [
"(The ghost is reading your pages by candlelight... this can take a minute.)",
"(Cold fingers turn your pages in the dark... this can take a minute.)",
"(The entity is devouring every page, one by one... this can take a minute.)",
]
VIDEO_WAIT_MESSAGES = [
"(No captions? The ghost watches the video itself, unblinking... this can take a minute.)",
"(The entity stares into your video through the dark... this can take a minute.)",
]
BIG_TOME_MESSAGES = [
"(A tome this vast takes time to devour. Be patient, mortal...)",
"(The abyss reads slowly, page after endless page... be patient, mortal.)",
]
GAME_OVER_ART = r"""
.-.
(o o) BOO!
| O \
\ \
`~~~'
"""
---------- reading the material ----------
class SpookyError(Exception):
"""An error whose message is already written in the ghost's voice."""
def read_pdf(path):
from pypdf import PdfReader
reader = PdfReader(path)
return "\n".join((page.extract_text() or "") for page in reader.pages)
def read_docx(path):
from docx import Document
doc = Document(path)
parts = [p.text for p in doc.paragraphs]
for table in doc.tables:
for row in table.rows:
parts.append(" | ".join(cell.text for cell in row.cells))
return "\n".join(parts)
def read_xlsx(path):
from openpyxl import load_workbook
wb = load_workbook(path, data_only=True, read_only=True)
parts = []
for sheet in wb.worksheets:
parts.append(f"Sheet: {sheet.title}")
for row in sheet.iter_rows(values_only=True):
cells = [str(c) for c in row if c is not None]
if cells:
parts.append(" | ".join(cells))
return "\n".join(parts)
def read_pptx(path):
from pptx import Presentation
parts = []
for i, slide in enumerate(Presentation(path).slides, start=1):
parts.append(f"Slide {i}")
for shape in slide.shapes:
if shape.has_text_frame:
parts.append(shape.text_frame.text)
if getattr(shape, "has_table", False) and shape.has_table:
for row in shape.table.rows:
parts.append(" | ".join(cell.text for cell in row.cells))
return "\n".join(parts)
def read_text(path):
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def youtube_id(url):
m = re.search(r"(?:v=|youtu.be/|shorts/|embed/)([A-Za-z0-9_-]{11})", url)
return m.group(1) if m else None
def read_youtube(url):
from youtube_transcript_api import YouTubeTranscriptApi
vid = youtube_id(url)
if not vid:
raise ValueError("I could not find a video ID in that link.")
langs = ["en", "hi"]
try:
items = YouTubeTranscriptApi().fetch(vid, languages=langs) # newer versions
return " ".join(item.text for item in items)
except AttributeError:
items = YouTubeTranscriptApi.get_transcript(vid, languages=langs) # older
return " ".join(item["text"] for item in items)
READERS = {
".docx": read_docx, ".xlsx": read_xlsx, ".pptx": read_pptx,
".txt": read_text, ".md": read_text,
}
def pages_from_text(text):
return max(1, round(len(text) / CHARS_PER_PAGE))
def pdf_pages(path):
try:
from pypdf import PdfReader
return max(1, len(PdfReader(path).pages))
except Exception:
return 1
def load_material(source):
"""Returns (kind, material, pages): kind is 'text', 'media' or 'youtube'."""
source = source.strip().strip('"').strip("'")
if source.lower().startswith("http") and ("youtube.com" in source or "youtu.be" in source):
if not youtube_id(source):
raise SpookyError("The ghost finds no video in that link. Check it and try again.")
try:
text = read_youtube(source)
return "text", text, pages_from_text(text)
except Exception:
return "youtube", source, 1 # no captions: let Gemini watch the video itself
if os.path.isdir(source): # a folder of textbook photos
photos = sorted(os.path.join(source, f) for f in os.listdir(source)
if f.lower().endswith(IMAGE_TYPES))[:MAX_IMAGES]
if not photos:
raise SpookyError(random.choice(WRONG_FORMAT_MESSAGES) + " (That folder has no jpg, png or webp photos.)")
return "media", photos, len(photos)
if not os.path.isfile(source):
raise SpookyError("The ghost cannot find that. Check the path, or paste a YouTube link.")
if os.path.getsize(source) > MAX_FILE_MB * 1_000_000:
raise SpookyError(random.choice(TOO_LARGE_MESSAGES) + f" (The limit is {MAX_FILE_MB} MB.)")
ext = os.path.splitext(source)[1].lower()
if ext in IMAGE_TYPES:
return "media", [source], 1
if ext in VIDEO_TYPES:
return "media", [source], 4 # a video cannot be measured cheaply, so assume a medium one
if ext == ".pdf":
text = read_pdf(source)
pages = pdf_pages(source)
if len(re.sub(r"\s+", "", text)) < 300 * pages // 3 + 100: # almost no text: a scanned PDF
return "media", [source], pages
return "text", text, pages
if ext in READERS:
text = READERS[ext](source)
return "text", text, pages_from_text(text)
raise SpookyError(random.choice(WRONG_FORMAT_MESSAGES)
+ " (I read PDF, DOCX, XLSX, PPTX, TXT, photos, videos and YouTube links.)")
---------- talking to the models ----------
def slow_print(text, delay=TYPE_DELAY):
for ch in text:
print(ch, end="", flush=True)
time.sleep(delay)
print()
def ask_model(model_name, prompt):
model = genai.GenerativeModel(model_name)
for attempt in range(3):
try:
return model.generate_content(prompt).text
except Exception as e:
LAST_ERROR["msg"] = f"{model_name}: {str(e)[:150]}"
time.sleep(2 * (attempt + 1)) # slow and steady: wait, then retry
return None
def is_valid(q):
return (isinstance(q, dict) and all(k in q for k in REQUIRED)
and isinstance(q["options"], list) and len(q["options"]) == 4
and str(q["answer"]).strip().upper()[:1] in list("ABCD"))
TRAP_RE = re.compile(r"^\s*(?:all|none)\s+of\s+(?:the\s+above|these)", re.I)
LETTER_RE = re.compile(r"\b(?:Both|Option|Choice)\s+[A-D]\b")
def clean(q):
"""Tidy a question: align the reasons, shuffle the options (but keep a trap option last)."""
options = [re.sub(r"^\s*[A-Da-d][).:]\s+", "", str(o)).strip() for o in q["options"]]
answer = "ABCD".index(str(q["answer"]).strip().upper()[0])
tone = q.get("tone") if q.get("tone") in TONES else random.choice(TONES)
raw = q.get("reasons")
if not (isinstance(raw, list) and len(raw) == 4):
raw = [""] * 4
reasons = []
for i in range(4):
text = str(raw[i]).strip()
if len(text.split()) < 5: # missing or too thin to explain anything
text = f"The truth is: {options[i]}." if i == answer else say("wrong", tone)
reasons.append(text)
pairs = list(zip(options, reasons))
joined = " ".join(options)
if TRAP_RE.match(options[3]):
# "All of the above" / "None of the above" stays last and must be the answer
if answer != 3:
return None
front = pairs[:3]
random.shuffle(front)
pairs = front + pairs[3:]
elif "of the above" in joined.lower() or LETTER_RE.search(joined):
pass # the options point at each other, so keep their order
else:
order = list(range(4))
random.shuffle(order)
pairs = [pairs[i] for i in order]
answer = order.index(answer)
q["options"] = [p[0] for p in pairs]
q["reasons"] = [p[1] for p in pairs]
q["answer"] = "ABCD"[answer]
q["tone"] = tone
q["skip"] = str(q.get("skip") or "").strip() or say("skip", tone)
return q
def parse_many(text, plan=None):
items = []
# Gemma may explain first, so start at the list of objects
m = re.search(r"[\s*{", text)
end = text.rfind("]")
if m and end > m.start():
try:
items = json.loads(text[m.start():end + 1])
except json.JSONDecodeError:
items = []
if not items: # fall back to any separate JSON objects in the reply
for candidate in re.findall(r"{[^{}]*}", text, re.S):
try:
items.append(json.loads(candidate))
except json.JSONDecodeError:
pass
questions = []
for i, q in enumerate(items):
if not is_valid(q):
continue
if plan and i < len(plan):
q["tone"] = plan[i]["tone"]
q = clean(q)
if q:
questions.append(q)
return questions
STYLE_GUIDE = """Tone styles:
- Gothic & Eerie: whispering spirits, receding shadows, candles blowing out, thickening fog, disembodied laughter, locked library doors, cold hands gripping shoulders.
- Dark & Ominous: surviving executioners, narrow escapes, mistakes haunting you forever, creaking trapdoors, feeding the hungry entity, inescapable halls, the price of cowardice."""
FORMAT = """Each question is a JSON object with these keys:
"question": one short line of psychological horror in the assigned tone, then a clear, short exam question,
"options": a list of exactly 4 short answer strings (no letters in front),
"answer": the correct letter, one of "A", "B", "C", "D",
"reasons": a list of exactly 4 sentences, one for each option in the same order as "options", shown when the player picks that option. Each reason must EXPLAIN, using facts from the material, why that option is correct or why it is wrong (for the correct option, explain why it is correct). You may open with a few spooky words in the assigned tone, but the real explanation must be the main part of the sentence. Examples: "The dark approves... a queue removes the oldest item first, so it is first in, first out." or "Wrong. A stack is last in, first out, which is the opposite order.",
"skip": one short sentence in the assigned tone, shown if the player tries to skip this question,
"tone": "gothic" or "dark"."""
def question_plan(k):
"""Decides the tone and the format of each question in a batch."""
start = random.randint(0, 1)
plan = []
for i in range(k):
kind = random.choices(["normal", "all", "none"], weights=[8, 1, 1])[0] # traps are sprinkled in
plan.append({"tone": TONES[(start + i) % 2], "kind": kind})
if k >= 3 and all(p["kind"] == "normal" for p in plan):
plan[random.randrange(k)]["kind"] = random.choice(["all", "none"])
return plan
def plan_text(plan):
lines = []
for i, p in enumerate(plan, start=1):
tone = "Gothic & Eerie" if p["tone"] == "gothic" else "Dark & Ominous"
if p["kind"] == "all":
fmt = ('ALL-OF-THE-ABOVE trap: options A, B and C are three true statements about the material '
'and option D is exactly "All of the above", which is the correct answer (answer "D")')
elif p["kind"] == "none":
fmt = ('NONE-OF-THE-ABOVE trap: options A, B and C are three false, cursed fabrications '
'and option D is exactly "None of the above", which is the correct answer (answer "D")')
else:
fmt = ('normal: exactly one correct option and three wrong ones; never use "all of the above", '
'"none of the above" or "both A and B"')
lines.append(f"Question {i}: tone = {tone}; format = {fmt}.")
return "\n".join(lines)
def questions_from_passage(passage, k):
plan = question_plan(k)
prompt = f"""You are a ghost tutor who wraps exam questions in psychological horror.
Using ONLY the passage below, write {k} different multiple-choice questions that test understanding of its ideas.
Do not ask about page numbers, file names or formatting. Cover different parts of the passage.
{STYLE_GUIDE}
Follow this plan, question by question:
{plan_text(plan)}
{FORMAT}
Reply with ONLY a JSON list of {k} such objects, no other text.
PASSAGE:
{passage}"""
for model_name in MODELS:
for attempt in range(2):
text = ask_model(model_name, prompt)
qs = parse_many(text, plan) if text else []
if qs:
return qs[:k]
if text:
LAST_ERROR["msg"] = f"{model_name} gave an unusable reply: {text[:150]!r}"
return []
def split_chunks(text, size=CHUNK_SIZE):
text = re.sub(r"\s+", " ", text).strip()
chunks = [text[i:i + size] for i in range(0, len(text), size)]
return [c for c in chunks if len(c) > 200] or ([text] if text else [])
def split_sizes(total, parts):
return [total // parts + (1 if i < total % parts else 0) for i in range(parts)]
def merge(existing, batches):
"""Add new questions to the list, skipping repeats."""
questions = list(existing)
seen = {re.sub(r"\W+", " ", q["question"].lower()).strip() for q in questions}
for batch in batches:
for q in batch:
key = re.sub(r"\W+", " ", q["question"].lower()).strip()
if key not in seen:
seen.add(key)
questions.append(q)
return questions
def text_round(chunks, n):
batches = max(1, min(len(chunks), -(-n // PER_CALL)))
passages = []
for i in range(batches):
part = chunks[i::batches]
if len(part) > 3:
part = random.sample(part, 3)
passages.append(" ".join(part))
# several model calls at the same time, so the wait is about one call long
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
return list(pool.map(lambda args: questions_from_passage(*args),
zip(passages, split_sizes(n, batches))))
def questions_from_text(text, levels):
chunks = split_chunks(text)
if not chunks:
return []
questions = merge([], text_round(chunks, levels))
if len(questions) < levels: # one more round for the missing ones
questions = merge(questions, text_round(chunks, levels - len(questions)))
return questions[:levels]
def prepare_media(paths):
"""Turn photos into images and upload PDFs/videos so Gemini can look at them."""
parts = []
for path in paths:
if path.lower().endswith(IMAGE_TYPES):
from PIL import Image
img = Image.open(path)
img.thumbnail((1600, 1600))
parts.append(img.convert("RGB"))
else:
f = genai.upload_file(path)
while f.state.name == "PROCESSING":
time.sleep(3)
f = genai.get_file(f.name)
if f.state.name != "ACTIVE":
raise RuntimeError("The upload failed.")
parts.append(f)
return parts
def media_batch(parts, k, number, count):
plan = question_plan(k)
portion = ""
if count > 1:
start, end = round(100 * number / count), round(100 * (number + 1) / count)
portion = f"Write the questions about the part of the material from about {start}% to {end}% of the way through it.\n"
prompt = f"""You are a ghost tutor who wraps exam questions in psychological horror.
Read or watch the material provided (it may be photos of textbook pages, a scanned document or a video).
Write {k} different multiple-choice questions about its content.
{portion}{STYLE_GUIDE}
Follow this plan, question by question:
{plan_text(plan)}
{FORMAT}
Reply with ONLY a JSON list of {k} such objects, no other text."""
model = genai.GenerativeModel(MEDIA_MODEL)
for attempt in range(3):
try:
return parse_many(model.generate_content(parts + [prompt]).text, plan)[:k]
except Exception as e:
LAST_ERROR["msg"] = f"{MEDIA_MODEL}: {str(e)[:150]}"
time.sleep(2 * (attempt + 1))
return []
def questions_from_media(paths, n):
try:
parts = prepare_media(paths)
except Exception as e:
LAST_ERROR["msg"] = f"upload: {str(e)[:150]}"
return []
count = max(1, -(-n // MEDIA_PER_CALL))
sizes = split_sizes(n, count)
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
results = list(pool.map(lambda args: media_batch(parts, *args),
[(sizes[i], i, count) for i in range(count)]))
return merge([], results)[:n]
def questions_from_youtube(url, n):
"""For videos with no captions. Needs: pip install google-genai"""
try:
from google import genai as new_genai
from google.genai import types
except ImportError:
slow_print("\nTo watch captionless videos I need one more package: pip install google-genai")
return []
client = new_genai.Client(api_key=API_KEY)
plan = question_plan(n)
prompt = f"""You are a ghost tutor who wraps exam questions in psychological horror.
Watch this video and write {n} different multiple-choice questions about its content.
{STYLE_GUIDE}
Follow this plan, question by question:
{plan_text(plan)}
{FORMAT}
Reply with ONLY a JSON list of {n} such objects, no other text."""
for attempt in range(3):
try:
response = client.models.generate_content(
model=MEDIA_MODEL,
contents=types.Content(parts=[
types.Part(file_data=types.FileData(file_uri=url)),
types.Part(text=prompt),
]),
)
return parse_many(response.text, plan)[:n]
except Exception as e:
LAST_ERROR["msg"] = f"video: {str(e)[:150]}"
time.sleep(2 * (attempt + 1))
return []
---------- the game ----------
def route_for(kind, material, pages):
"""Returns ('low' or 'high', the most levels this material can have)."""
if pages >= HIGH_PAGES:
return "high", HIGH_MAX
if kind == "text":
chars = len(re.sub(r"\s+", " ", material).strip())
if chars < MIN_CHARS:
return "low", 0
return "low", max(1, min(LOW_MAX, chars // CHARS_PER_QUESTION))
if kind == "media":
return "low", max(1, min(LOW_MAX, 3 * pages))
return "low", LOW_MAX # a video with no captions: cannot measure it
REFUSE_WORDS = ("", "skip", "s", "pass", "no", "none", "nah", "later", "idk",
"whatever", "any", "auto", "you choose", "dont know", "don't know")
def ask_levels(route, cap):
if route == "high":
slow_print(f"\nThis tome is vast. The abyss may ask you up to {cap} questions.")
else:
slow_print(f"\nThis material holds enough secrets for up to {cap} level(s).")
while True:
raw = input(f"How many levels dare you play with me, mortal? (1-{cap}) ").strip().lower()
if raw in REFUSE_WORDS: # no number given
if route == "high":
slow_print(random.choice(MOCKING_MESSAGES))
return HIGH_MAX
slow_print(random.choice(HESITATION_MESSAGES))
slow_print(f"(The ghost chooses {cap} level(s).)")
return cap
if not raw.isdigit():
slow_print("The ghost does not understand... speak a number, mortal.")
continue
n = int(raw)
if n == 0: # the player refuses: the ghost picks a number to match the content
zero_levels()
n = random.randint(max(1, (cap + 1) // 2), cap)
slow_print(f"(The ghost chooses {n} level(s).)")
return n
if route == "high":
if n > HIGH_MAX:
slow_print(random.choice(ABYSS_LIMIT_MESSAGES))
return HIGH_MAX
return n # fewer than 50: exactly what was asked
if n > LOW_MAX:
slow_print(random.choice(VOID_MESSAGES))
slow_print(f"(The ghost chooses {cap} level(s).)")
return cap
if n > cap:
slow_print(f"This material only holds {cap} secret(s), mortal. We will play {cap}.")
return cap
return n
def zero_levels():
print()
for line in ZERO_MESSAGES:
slow_print(line, delay=0.03)
time.sleep(0.6)
def souls_for(total):
"""A longer game gives more souls."""
if total <= 3:
return total
return SOULS + (total - 1) // 10 if total > 10 else SOULS
def get_choice(q, level, total, souls, start_souls, score):
souls_left = "* " * souls + ". " * (start_souls - souls)
slow_print(f"\n--- Level {level} of {total} --- Souls: {souls_left} Points: {score}")
print(f"\n{q['question']}")
for letter, option in zip("ABCD", q["options"]):
print(f" {letter}) {option}")
choice = ""
while choice not in list("ABCD"):
choice = input("\nYour answer (A/B/C/D): ").strip().upper()
if choice.lower() in SKIP_WORDS:
slow_print(q["skip"])
choice = ""
elif choice not in list("ABCD"):
print(" Choose A, B, C or D.")
return choice
def get_questions(kind, material, levels):
slow_print("\n" + random.choice(LOADING_MESSAGES))
if levels > 12:
slow_print(random.choice(BIG_TOME_MESSAGES))
if kind == "youtube":
slow_print(random.choice(VIDEO_WAIT_MESSAGES))
return questions_from_youtube(material, levels)
if kind == "media":
slow_print(random.choice(MEDIA_WAIT_MESSAGES))
return questions_from_media(material, levels)
slow_print(random.choice(TEXT_WAIT_MESSAGES))
return questions_from_text(material, levels)
def play(kind, material, levels):
questions = get_questions(kind, material, levels)
if not questions:
slow_print("\n" + random.choice(UPLOAD_FAIL_MESSAGES))
slow_print("(No questions came back. Try clearer photos, a shorter video or another file.)")
if LAST_ERROR["msg"]:
print(f" (why: {LAST_ERROR['msg']})")
return None
slow_print("\n" + random.choice(UPLOAD_SUCCESS_MESSAGES))
total = len(questions)
if total < levels:
slow_print(f"The spirits could only conjure {total} questions this time.")
slow_print(f"(Each wrong answer costs a soul and {POINT_PENALTY} points. If your points drain to zero, the ghost claims you.)")
start_souls = souls_for(total)
souls, score, streak = start_souls, START_POINTS, 0
drained = False
for level, q in enumerate(questions, start=1):
choice = get_choice(q, level, total, souls, start_souls, score)
chosen = "ABCD".index(choice)
if choice == q["answer"]:
points = 10 + min(5 * streak, 40)
streak += 1
score += points
slow_print(f"\n{say('correct', q['tone'])} (+{points} points)")
slow_print(f"Reason: {q['reasons'][chosen]}")
else:
streak = 0
souls -= 1
lost = min(POINT_PENALTY, score)
score -= lost
note = f"-{lost} points" if lost else "no points left to lose"
slow_print("\n" + say("wrong", q["tone"]) + f" ({note})")
slow_print(f"Reason: {q['reasons'][chosen]}")
right = "ABCD".index(q["answer"])
slow_print(f"(The answer was {q['answer']}: {q['options'][right]})")
slow_print(f"Why it is correct: {q['reasons'][right]}")
if lost > 0 and score == 0: # the points have drained away
drained = True
break
if souls == 0:
break
if drained:
print(GAME_OVER_ART)
slow_print(random.choice(POINTS_ZERO_MESSAGES), delay=0.02)
slow_print("GAME OVER. Your points have drained to nothing...")
slow_print("Revise the material and dare to return!")
return "lost"
if souls == 0:
print(GAME_OVER_ART)
slow_print("GAME OVER. The ghost has claimed your last soul...")
slow_print(f"You scored {score} points. Revise the material and dare to return!")
return "lost"
slow_print(f"\nYou escaped the haunted library with {souls} soul(s) and {score} points!")
slow_print("The ghost fades away... for now.")
return "won"
def main():
slow_print("\n*** WELCOME TO THE GHOST TUTOR HAUNTED LIBRARY ***")
slow_print("Feed me a PDF, scanned PDF, textbook photos (or a folder of them),")
slow_print("a Word/Excel/PowerPoint file, a video file or a YouTube link.")
slow_print(f"Answer my questions. You have {SOULS} souls or more. Lose them all, and you stay...")
source = " ".join(sys.argv[1:])
while True:
if not source:
source = input("\nFile, folder or YouTube link (or 'quit'): ").strip()
if source.lower() in ("quit", "exit", "q"):
slow_print("Rest well, mortal...")
break
try:
kind, material, pages = load_material(source)
except SpookyError as e:
slow_print("\n" + str(e))
source = ""
continue
except Exception as e:
slow_print("\n" + random.choice(UPLOAD_FAIL_MESSAGES))
print(f" (details: {e})")
source = ""
continue
slow_print("\n" + random.choice(RECEIVED_MESSAGES))
route, cap = route_for(kind, material, pages)
if cap == 0:
slow_print("\nThere is almost nothing here... the ghost cannot feed on this. Give me more to read!")
source = ""
continue
while True:
levels = ask_levels(route, cap)
play(kind, material, levels)
again = input("\nPlay again with the same material? (y/n): ").strip().lower()
if again != "y":
break
source = ""
if name == "main":
main()
then run in powershell python ghost_game.py <paste your file path>
How I Built It
-
Language and models: Python. Text goes through Gemma (
gemma-4-31b-it) via Google's API, withgemini-2.5-flashas a backup. Photos, scanned PDFs, video files and YouTube videos without captions go through Gemini 2.5 Flash. -
Reading material:
pypdf,python-docx,openpyxl,python-pptxandyoutube-transcript-api. A PDF with almost no text counts as scanned, and the page count decides which route the material takes. - Writing the questions: For each batch, the game first makes a plan: the tone of each question and whether it is a normal question, an "All of the above" trap or a "None of the above" trap. Gemma returns JSON with the question, four options, the answer, one reason per option and a skip line.
- Checking the answers: The game shuffles the options and their reasons together so each reason stays attached to its option. A trap option always stays last, and a trap question whose answer isn't that last option is thrown away.
- Speed: Instead of one slow call per question, several calls run at the same time (3 workers, 4 questions each). A 50-question book takes a handful of parallel batches, and one extra round fills in any gaps.
- Slow and steady: Gemma sometimes thinks out loud before answering, so the parser finds the JSON list inside the reply. Failed calls retry with a short wait, then fall back to the backup model.
Why Does Open Innovation Matter?
Gemma is open-weight, so I could test again and again while building this without worrying about a locked-down tool. The same idea could run fully on my own laptop, so my study notes would never leave it, which matters when the material is private.
I want to be honest about where I am today: the text part uses the Gemma API, and the photo, scan and video parts use Gemini 2.5 Flash, so those files are uploaded to Google. I couldn't confirm that Gemma could read images through the API. My next step is replacing those parts with local open models.
The Lazy Payoff
I used Ghost Tutor to revise for a coding test. Answering questions from my own notes stuck far better than rereading them.
Prize Categories
Best Use of Gemma
來源:DEV Community · dev.to





