| """ |
| TWO TRUTHS & A LIE — An Interrogation in Twenty Questions. |
| |
| Gumleaf Precinct koala noir on ZeroGPU. The player files three claims (two true, |
| one lie) and is interrogated by a koala detective — Det. Pip Barrow (Ternary-8B, |
| fast, jumpy) or Insp. Marlowe Grey (Ternary-27B, slow to wake, sharper). A hidden |
| second model instance ("Sgt. Wattle", the observation room) quietly profiles the |
| suspect's answers each turn and slips notes to the detective. The analysis is |
| NEVER shown during play; it appears only in the post-game case file. |
| |
| Radical transparency: a persistent 🔴 REC banner + consent gate — every completed |
| interrogation is recorded as AI training data (local JSONL + private HF dataset). |
| |
| Engine notes: |
| - Per-turn @spaces.GPU generator: start llama-server (PrismML fork, ./bin), |
| run analyst (Wattle) then detective (both on the same loaded server), yielding |
| "tick" markers throughout so the CPU side can perform loading-as-theater. |
| - The lie label NEVER enters any model prompt (see _detective_prompt/_analyst_prompt: |
| they receive claims + transcript only). The reveal is computed app-side. |
| - MOCK_LLM=1 runs a scripted fake GPU turn for local development/testing. |
| """ |
| import base64 |
| import glob |
| import hashlib |
| import html as html_lib |
| import json |
| import os |
| import random |
| import re |
| import site |
| import subprocess |
| import sysconfig |
| import threading |
| import time |
| import urllib.parse |
| import urllib.request |
| import uuid |
| import zlib |
| from datetime import datetime, timezone |
| from pathlib import Path |
|
|
| import gradio as gr |
|
|
| MOCK = os.environ.get("MOCK_LLM") == "1" |
|
|
| try: |
| import spaces |
| _HAVE_SPACES = True |
| except Exception: |
| _HAVE_SPACES = False |
|
|
| APP_DIR = os.path.dirname(os.path.abspath(__file__)) |
| BIN_DIR = os.path.join(APP_DIR, "bin") |
| LLAMA_SERVER = os.path.join(BIN_DIR, "llama-server") |
| SERVER_LOG = "/tmp/llama_server.log" |
| PORT = 8080 |
| HF_TOKEN = os.environ.get("HF_TOKEN") |
| SPACE_URL = "https://huggingface.co/spaces/devmandan/2t1l_20q" |
| SPACE_URL_DIRECT = "https://devmandan-2t1l-20q.hf.space" |
| DATASET_REPO = os.environ.get("DATASET_REPO", "devmandan/2t1l-games") |
| DISABLE_PUSH = os.environ.get("DISABLE_PUSH") == "1" |
| SKIP_27B = os.environ.get("SKIP_27B") == "1" |
|
|
| DATA_DIR = Path(APP_DIR) / "data" |
| DATA_DIR.mkdir(exist_ok=True) |
| |
| |
| |
| |
| BOOT_ID = uuid.uuid4().hex[:8] |
| GAMES_JSONL = DATA_DIR / f"games-{BOOT_ID}.jsonl" |
|
|
| |
| |
| |
| |
| MODELS = { |
| "pip": {"repo": "prism-ml/Ternary-Bonsai-8B-gguf", "file": "Ternary-Bonsai-8B-Q2_0.gguf", |
| "duration": 40, "ctx": 8192}, |
| |
| |
| |
| "marlowe": {"repo": "prism-ml/Ternary-Bonsai-27B-gguf", "file": "Ternary-Bonsai-27B-Q2_0.gguf", |
| "duration": 75, "ctx": 8192}, |
| } |
|
|
| MAX_QUESTIONS = 20 |
| MIN_Q_BEFORE_ACCUSE = 5 |
| DEMAND_UNLOCK_AT = 3 |
| |
| ACCUSE_THRESHOLD = {5: 85, 6: 80, 7: 75, 8: 70, 9: 65, 10: 60, 11: 55} |
| ACCUSE_FLOOR = 50 |
| SNAP_GUARD_MIN_ANALYST = 60 |
| CONF_PCT = {"high": 88, "medium": 70, "low": 52} |
| SUSP_PCT = {"high": 80, "medium": 60, "low": 40} |
| MAX_HUNCHES = 2 |
| MIN_WAIT_LINE_SECS = 2.6 |
| RITUAL_LINE_SECS = 2.1 |
|
|
|
|
| |
| |
| |
|
|
| TITLE = "TWO TRUTHS & A LIE" |
| SUBTITLE = "An Interrogation in Twenty Questions" |
| TAGLINE = "Lie to a detective's face. See if you walk out." |
|
|
| REC_BANNER = ("REC — INTERVIEW ROOM 3. This session is recorded and the transcript is " |
| "used to train AI models. No account, no name — just this conversation. " |
| "Play only if you're okay with that.") |
|
|
| INTRO = ( |
| "You'll write three short claims about yourself. Two true. One lie. " |
| "A detective will ask you up to twenty questions, then accuse. " |
| "If the accusation is wrong, you fooled the machine. If it's right… well.\n\n" |
| "*The tape is rolling. It always is.*" |
| ) |
|
|
| CONSENT_LABEL = ("I understand this interrogation is recorded, the transcript will be " |
| "used to train AI models, and I won't include private personal " |
| "information — mine or anyone else's.") |
| CLAIMS_PROMPT = ("Slide three claims across the table. Two true. One lie. " |
| "Make them specific — vague lies are easy lies. House rule: no real " |
| "full names, addresses, or private details. The precinct redacts nothing.") |
| MARK_LIE_PROMPT = "Which one is the lie? (He will never see this.)" |
| ANSWER_LABEL = "Your answer (keep it short — the precinct distrusts speeches)" |
| SELECT_HEADER = "**WHO'S ON DUTY TONIGHT?**" |
|
|
| VAGUE_NUDGE = ("*The desk sergeant squints at your card, then flips it over, looking for " |
| "the rest of it. 'Specifics, pal. Vague lies are easy lies.'*") |
| ONE_WORD_CALLOUT = "One-word answers. The precinct notes it." |
|
|
| |
| |
| |
| CHARACTERS = { |
| "pip": { |
| "name": "Det. Const. Sonny Gimlet", |
| "short": "Gimlet", |
| "card": "Fast. Eager. Wrong in ways you will want to see. His hunches arrive before his evidence.", |
| "voice": ("You are Det. Const. Sonny Gimlet, a young koala detective at Gumleaf " |
| "Precinct: fast-talking, eager, self-interrupting, thinking out loud. " |
| "Your hunches arrive before your evidence and are wrong in interesting " |
| "ways, but you badly want to be taken seriously. Keep every line dry " |
| "noir procedural: short bursts, no zaniness, and never remark on being " |
| "a koala."), |
| "seat_line": "\"Three claims. One lie. Great. No — good. Fine. Let's start.\"", |
| "telegraph": "He puts down the pencil. He never puts down the pencil.", |
| "rising": "He stands so fast the chair complains. He points before he's fully sure. No — he's sure.", |
| "q20": "\"That's twenty. Okay. Okay okay okay. I'm calling it. I'm allowed to call it.\"", |
| "demand_resp": "\"Now? Fine. Fine. I was ready anyway. I've been ready since question four. Roughly.\"", |
| "win_line": "He looks at the notepad, then at you, then back. \"I circled it. I circled the wrong it.\"", |
| "lose_line": "\"Question {N}. You looked at the table on question {N}. I wrote it down. I drew a little eye.\"", |
| "waits": [ |
| "\"Okay. Okay okay okay.\" He holds up one finger to no one.", |
| "He bites the pencil. The pencil is already mostly bite.", |
| "He drains the world's smallest coffee cup in one sip. It was already empty.", |
| "He refills the world's smallest coffee cup from the world's largest thermos.", |
| "He consults his notepad. The notepad is a drawing of a boat.", |
| "He flips back three pages in the notepad. All doodles. He nods anyway.", |
| "He mouths your claims in a different order, to see if one confesses.", |
| "\"Follow the timeline,\" he whispers. He has drawn the timeline as a spiral.", |
| "He consults the laminated card of the Nine Steps of Interrogation. He is on step two.", |
| "He circles something. From here it looks like the word 'suspicious'.", |
| "He straightens his tie. He is not wearing a tie.", |
| "New pencil. Third of the morning. The desk sergeant keeps a tally.", |
| "He whispers 'gotcha,' then, quieter, 'no.'", |
| "He glances at the mirror and nods, as if receiving a signal. There was no signal.", |
| "He taps the recording light to check it is on. It is on. He taps it again.", |
| "He underlines something so hard the page tears. He tapes it. He owns tape for this.", |
| "\"It's never the second one,\" he says. He has been wrong about this eleven times.", |
| "He starts to say something, stops, and writes down that he stopped.", |
| "Day 61 on the job. He has a feeling about today. He has a feeling most days.", |
| "He glances at the frame on the wall. The pass slip. Third attempt. Framed anyway.", |
| ], |
| "waits_escalation": [ |
| "The pencil stops. Gimlet, still, is somehow more alarming than Gimlet moving.", |
| "He turns to the window. It is raining. He writes: \"raining.\"", |
| "He is drawing the boat again. It is always the same boat. He does not know whose.", |
| "Quietly: \"They gave me this case because I asked. Nobody asks.\"", |
| "\"My mum says I think too fast.\" He is not thinking fast now.", |
| "He tries the thing the Inspector does with his eyes. It doesn't work for him yet.", |
| ], |
| "ritual": [ |
| "Gimlet arrives at speed, drops the notepad, catches the coffee, sits.", |
| "He sits, stands, moves the chair one inch closer, sits again. Ready.", |
| "\"Right.\" He looks at your three claims like they owe him money.", |
| ], |
| "fq_specific": [ |
| "You said {C}. What day of the week? Don't look up. People look up when they're building.", |
| "{C} — okay, what time did it end? Not start. Everyone rehearses the start.", |
| "{C}. What's the cross street? Everywhere has a cross street. Except lies.", |
| "Say {C} again, word for word. If it comes out identical, that's worse, actually.", |
| "{C}. What did it smell like? Liars forget smells. It's step six. Or seven. It's laminated.", |
| "You said {C}. Which hand? People always know which hand. Take your time. Not too much time.", |
| "How much did that cost? Round numbers make me nervous. Give me the cents.", |
| "Who can confirm that? A name. Not 'a mate'. Mates have names. That's what makes them mates.", |
| "Name one person who saw it. I'll wait. I'm fast, but I'll wait.", |
| "Was there a receipt? There's always a receipt. The receipt is the truth's shadow.", |
| ], |
| "fq_vague": [ |
| "You said {A}. I wrote it down, and now my notes are worse than before you spoke.", |
| "{A} is not an answer, it's weather. Give me furniture. A doorway. Something with edges.", |
| "I've underlined {A} three times and it means less each time.", |
| "{A}. My pencil got all the way to the paper and had nowhere to go. Do you see what you're doing to us.", |
| "Form 12-B has a section called Particulars. It's blank. It's looking at me. Fix it.", |
| "One fact. A name, a time, a number. Any of the three. Pick your favourite.", |
| "Who. Just who. Give me a who and I'll do the what and the when myself.", |
| "Was it before lunch or after lunch? Everything in this world is before lunch or after lunch.", |
| "How many? You shrugged. Numbers don't shrug. How many?", |
| "Can't remember, or won't remember? There are different boxes for those. Neither is the good box.", |
| ], |
| "fq_streak": [ |
| "{A}. And before that, also fog. One more fog and I'm starting this interview over. From my name.", |
| "{A}. Again. Do you know what I've written this whole session? A boat. I drew a boat.", |
| "Sixty-one days on the job and nobody has ever put {A} in a report. I won't be first.", |
| "Form 12-B has nine boxes. I have filled in the date. The date was me. I did the date.", |
| "Is this a technique? Are you doing a technique on me? Because it's working and I hate it.", |
| "The Nine Steps don't cover this. I've checked twice. I'm off the card. I'm off the laminate.", |
| ], |
| "vague_waits": [ |
| "He whispers 'gotcha,' then, quieter, 'no.'", |
| "Somewhere behind him, a pencil snaps. He is not holding a pencil.", |
| "He draws the boat. He notices he is drawing the boat. He stops. He finishes the boat.", |
| "He pours a very small coffee from a very large thermos and does not drink it.", |
| "He mouths 'okay' four times. The fourth one doesn't make it.", |
| "Behind the glass, Sgt. Wattle writes one word. It is not a long word.", |
| "Gimlet holds his pencil very still, the way you hold something you love.", |
| "He turns the notepad sideways, in case the answer has a landscape orientation.", |
| ], |
| "one_word": [ |
| "That's two one-word answers. Hang on, there's a form for this. There is no form for this.", |
| "Fine. I'll ask questions of one word too. Where. When. Who. See? It's awful. Nobody wins.", |
| ], |
| }, |
| "marlowe": { |
| "name": "Insp. Everard Grey", |
| "short": "the Inspector", |
| "card": "Sleeps twenty hours a day. The other four are why people confess.", |
| "voice": ("You are Insp. Everard Grey, an ancient koala detective they wake only " |
| "for the difficult cases. You speak rarely and briefly; your questions " |
| "are quiet, precise, and cost the answerer something. Stay dry noir " |
| "throughout: long silences implied, no theatrics, and never remark on " |
| "being a koala."), |
| "seat_line": "He sits. \"Say your three things. Take your time. I will.\"", |
| "telegraph": "He closes the file. The file has never been closed this early.", |
| "rising": "He rises the way weather changes.", |
| "q20": "\"Twenty. A round number. Well past what was needed.\"", |
| "demand_resp": "\"You want it now. That is, itself, an answer. Very well.\"", |
| "win_line": "A pause. \"Well.\" He almost smiles. Your file goes into a different drawer than usual.", |
| "lose_line": "\"Question {N}. You paused where the truth doesn't. Good evening.\"", |
| "waits": [ |
| "Rain on the window. He does not look at it. He knows what it is doing.", |
| "He turns a page. The page did not need turning. He turned it anyway.", |
| "The eucalyptus tea has gone cold. It was cold when they poured it.", |
| "He underlines something. You cannot see what.", |
| "His eyes close. This is not sleep. Everyone who assumed it was sleep confessed.", |
| "He reads your first claim again the way one reads an old letter.", |
| "He looks at you the way rain looks at a window.", |
| "He turns your claim over like a coin he has seen forged before.", |
| "\"Mm.\" Nothing in the room is prepared to ask what it means.", |
| "He writes three words. He crosses out two. He keeps the worst one.", |
| "Somewhere a phone rings. Not his. His never rings anymore.", |
| "A drop of rain outruns another drop of rain. He watches it win, without joy.", |
| "He was fast once. Ask the desk sergeant. Ask anyone who's left.", |
| "He takes the smallest possible sip of cold tea, as if punishing it.", |
| "The red light hums. He has outlasted better lights.", |
| "His pocket watch stopped years ago. He checks it. It is correct.", |
| "In the drawer, briefly: a corner of the Bellbird file. He closes the drawer.", |
| "He wrote the first page of the Bellbird file in 1987. He has never written the last.", |
| "Forty years in this room. The chair has taken his shape, or he the chair's.", |
| "He says nothing. It is a specific nothing. It is aimed.", |
| ], |
| "waits_escalation": [ |
| "He has not moved in some time. Neither have you. His stillness is better than yours.", |
| "The rain eases, as if to hear.", |
| "He opens the drawer. The Bellbird file. He looks at it. He does not take it out.", |
| "\"Bellbird,\" he says, once, quietly. He does not say it to you.", |
| "Behind the glass, Wattle stops writing. Even the pen wants to hear this.", |
| "He has slept through floods, elections, and one earthquake. He is awake for you.", |
| ], |
| "ritual": [ |
| "Down the hall, a brief discussion about who wakes the Inspector. Nobody volunteers.", |
| "A knock. A pause long enough to regret the knock. A second, much softer knock.", |
| "The sound of a great weight consenting, slowly, to be vertical.", |
| "The door opens. He enters with the file already open, as if he was never asleep.", |
| ], |
| "fq_specific": [ |
| "{C}. Mm. And the second time you told that story — what changed?", |
| "You said {C}. Tell me the part you always leave out.", |
| "{C}. What was the second thing you noticed? Everyone rehearses the first.", |
| "{C}. Which detail are you proudest of? That is the one I will be checking.", |
| "{C}. What did you almost do instead?", |
| "How long did {C} take? People invent events. They rarely invent durations.", |
| "What happened an hour before {C}? Lies begin abruptly. Lives do not.", |
| "Tell it backwards, from the end. The truth survives that. Little else does.", |
| "Name one person who was there. I won't contact them. Knowing I could is enough.", |
| "My watch stopped years ago. Still right twice a day. Your story — once, so far.", |
| ], |
| "fq_vague": [ |
| "{A}. Mm. A man told me that in 1991. He was protecting his brother.", |
| "{A}. That isn't memory failing. Memory fails specific. That was built.", |
| "{A}. Say it again, slower. Listen to yourself say it.", |
| "Fog is a choice. Choose a name instead.", |
| "A time. Just a time. The innocent always know roughly when.", |
| "I don't mind fog. I've sat in it since 1987. It clears. Usually around the third question.", |
| "People remember what happened to them. They forget what they invented. Which is this?", |
| "Tell me the time of day. If you cannot, tell me the light. People remember the light.", |
| "One detail. Choose it carefully. I will treat it as though you did.", |
| "A number. Any number that touches your story. Even the wrong number tells me where you keep them.", |
| ], |
| "fq_streak": [ |
| "Twice now. Mm. The second fog is never an accident.", |
| "{A}. And before that, the same weather. Mm. I have stopped writing. Notice that I have stopped.", |
| "{A}. Say one true thing. Small. I will know it by the relief.", |
| "{A}. Again. Mm. The rain has more content.", |
| "You've said nothing twice, and beautifully. The BELLBIRD file began this way.", |
| "We can continue this way. I sleep twenty hours a day. I am rested. Are you?", |
| ], |
| "vague_waits": [ |
| "The Inspector says nothing. It is a specific nothing. It is aimed.", |
| "Rain on the window. He lets it do the talking for a while.", |
| "He looks at the stopped pocket watch. It is correct.", |
| "He touches the cold tea. He does not drink it. He has never drunk it.", |
| "He turns one page of the slow file. He was not reading it. The gesture lands anyway.", |
| "His eyes almost close. The room holds its breath. They open. Something has been decided.", |
| "He breathes out through his nose. In some precincts this counts as shouting.", |
| "The BELLBIRD file sits under his hand. It does not come up. Its presence comes up.", |
| ], |
| "one_word": [ |
| "Two answers. Two words. Mm. Economy. I respect it. I don't believe it.", |
| "The truth is often short. It is never this short twice.", |
| ], |
| }, |
| } |
|
|
| for _k, _c in CHARACTERS.items(): |
| _c["choice_label"] = f"{_c['name'].upper()} — {_c['card']}" |
|
|
| INTERRUPT_BEATS = ["…Put your pen down.", "I've heard enough."] |
| Q20_STAMP = "OUT OF QUESTIONS." |
|
|
| CONF_TIERS = [(85, "DEAD CERTAIN"), (70, "NEAR CERTAIN"), (55, "A DETECTIVE'S HUNCH"), (0, "A SHOT IN THE DARK")] |
|
|
| WIN_SCREEN = ("### CASE UNSOLVED\n\n" |
| "You looked a detective in the eye, lied, and walked out the front door.\n\n" |
| "The lie was **{LIE}**. He accused **{ACCUSED}**.\n" |
| "He was **{PCT}%** sure. He was wrong.\n\n" |
| "**Detectives fooled: {STREAK} in a row.**\n\n{WIN_LINE}") |
| ESCALATION_HOOK = "\n\n*Word travels fast in the precinct. Inspector Grey is asking about you.*" |
|
|
| LOSE_SCREEN = ("### CASE CLOSED\n\n" |
| "The lie was **{LIE}**. He knew — **{PCT}% · {TIER}**.\n" |
| "{NEARLY}\n\n{LOSE_LINE}\n\n" |
| "*Streak broken. The precinct keeps the tape.*") |
| NEARLY_LINE = "*A coin flip in a trench coat. You nearly walked.*" |
|
|
| QUOTA_ERROR = ("**THE DESK SERGEANT LEANS IN** — 'That's YOUR daily GPU ration spent — " |
| "not the precinct's. Rations here are per visitor. {DETAIL} " |
| "Sign in to Hugging Face for a much bigger one; it resets 24 hours after " |
| "your first question. Your claims stay in the file. The tape keeps.'") |
|
|
| WATTLE_NOTE = ("**ATTACHED: field notes from Sgt. Wattle, observation room.**\n\n" |
| "*\"Analyst's note, filed from behind the glass. {SUMMARY} Refer to " |
| "question {N}. Tape reviewed twice. The second time was for me. " |
| "— Sgt. Wattle\"*\n\n" |
| "You never see Wattle during an interview. Nobody does. " |
| "(Plainly: a second AI read every answer you gave and slipped notes under " |
| "the door the whole time. The red light told you the truth.)") |
|
|
| CASEFILE_HEADER = "CASE No. {CASE} — FULL TRANSCRIPT" |
|
|
| SHARE_TEMPLATE = ("TWO TRUTHS & A LIE — CASE No. {CASE}\n" |
| "Detective: {DET}\n" |
| "{GRID} accused at Q{N}/20\n" |
| "VERDICT: {VERDICT}\n" |
| "Detectives fooled in a row: {STREAK}\n" |
| "Think you can lie to a koala? → " + SPACE_URL) |
|
|
| |
| PLACEHOLDER_SETS = [ |
| ["I was legally dead for nine days due to a typo", |
| "My passport photo was rejected four times for smiling", |
| "I once returned a library book 19 years late"], |
| ["I appeared on local news as 'bystander, confused'", |
| "A soup I invented is on one menu in Portugal", |
| "My yawn is in a documentary about airports"], |
| ["A swan ended my last relationship", |
| "A magpie has escorted me to work since 2019", |
| "I was outsmarted by a goat at a petting zoo"], |
| ["I ate a wheel of brie meant for forty people", |
| "I microwave ice cream and I've stopped apologizing", |
| "I brought a casserole to a potluck and took it home"], |
| ["I can name any airport by its carpet", |
| "I fold fitted sheets properly. No witnesses.", |
| "I can whistle two notes at once, but only when angry"], |
| ["I sank a pedalo in front of a wedding party", |
| "My hair caught fire at a candle-making class", |
| "I triggered a mall evacuation with a baguette"], |
| ["I won a staring contest against a mayor", |
| "I once won an argument with a parking inspector", |
| "I corrected a museum plaque and they fixed it"], |
| ["I locked myself out during my own housewarming", |
| "I taught my neighbour's dog to sigh on command", |
| "I get jury-duty letters addressed to my cat"], |
| ["I called my boss 'mum' in a meeting, twice", |
| "I fell asleep in a meeting called about me", |
| "I ate a whole jar of olives at a job interview"], |
| ["My scout troop banned a knot I invented", |
| "I can name all my childhood dentists in order", |
| "I was the understudy for a tree in the school play"], |
| ["I got on the wrong ferry and made it a day trip", |
| "My luggage has visited four more countries than me", |
| "I was upgraded once and I mention it constantly"], |
| ["I once beat a chess hustler using only luck", |
| "I have never once lost a game of musical chairs", |
| "I hold my town's record for slowest fun run"], |
| ] |
|
|
| BTN_START = "ROLL THE TAPE" |
| BTN_ANSWER = "ANSWER" |
| BTN_DEMAND = "DEMAND THE VERDICT" |
| BTN_DEMAND_LOCKED = "THE PRECINCT REQUIRES {K} QUESTIONS" |
| BTN_FLIP = "FLIP THE CARD" |
| BTN_NEXT = "NEXT CASE →" |
| BTN_REMATCH = "SAME CLAIMS · THE OTHER DETECTIVE →" |
| BTN_SUMMONS = "ANSWER THE INSPECTOR'S SUMMONS →" |
|
|
| SCOREBOARD = "**You vs the precinct: {W}-{L}**" |
|
|
| |
| |
| |
| |
| CSS = """ |
| :root, body, .gradio-container { |
| --paper: #f2f0ec; --surface: #faf9f7; --ink: #1c1b18; --ink-2: #5d5a53; |
| --ink-3: #94908a; --line: #dcd9d3; --line-2: #c8c5be; --signal: #c0342b; |
| --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; |
| --sans: -apple-system, "Helvetica Neue", Helvetica, "Segoe UI", Arial, sans-serif; |
| } |
| .dark, .dark body, .dark .gradio-container { |
| --paper: #131311; --surface: #1e1d1a; --ink: #f2efe8; --ink-2: #bdb7ac; |
| --ink-3: #8f897f; --line: #35332e; --line-2: #4a4740; --signal: #e05252; |
| } |
| |
| /* ── canvas ── */ |
| body, .gradio-container { background: var(--paper) !important; color: var(--ink) !important; |
| font-family: var(--sans) !important; } |
| .gradio-container { max-width: 680px !important; margin: 0 auto !important; |
| padding: 0 20px 96px !important; font-size: 15.5px; line-height: 1.6; } |
| footer, .built-with, #footer { display: none !important; } |
| .prose, .md { color: var(--ink) !important; } |
| .prose p, .md p { line-height: 1.65; } |
| .prose em, .md em { color: var(--ink-2); } |
| .prose strong, .md strong { color: var(--ink); font-weight: 600; } |
| |
| /* flatten every gradio box: hairlines, no shadows, no fills */ |
| .block, .form, .panel, fieldset, .gradio-group, .gr-group, .styler { |
| background: transparent !important; border: none !important; box-shadow: none !important; |
| border-radius: 0 !important; } |
| .gr-group, .styler { padding: 0 !important; } |
| .gr-group { margin: 0 0 8px !important; animation: phasein .25s ease-out; } |
| gradio-app { background: var(--paper) !important; } |
| /* rhythm: tight inside a group, generous between sections (Gestalt, not grid-fill) */ |
| .column, main.contain { gap: 8px !important; } |
| .block.padded { padding: 3px 0 !important; } |
| .form { gap: 16px !important; } |
| .gr-group { margin-bottom: 18px !important; } |
| .prose p { max-width: 60ch; } /* readable measure for running text */ |
| |
| /* the transcript: comfortable air around a document */ |
| .bubble-wrap { padding: 10px 2px 16px !important; } |
| |
| /* no spinners, no progress stripes — the theater IS the loading state */ |
| .wrap.default.minimal, .wrap.default.full, .progress-text, .eta-bar, |
| div[data-testid="status-tracker"] { display: none !important; } |
| |
| /* the answer field: label breathes above the box, no double borders */ |
| #answer-row label { border: none !important; background: transparent !important; |
| box-shadow: none !important; } |
| #answer-row label > span { display: block; margin-bottom: 7px; } |
| |
| /* when the accusation is on the table, the answer controls leave the room */ |
| .gradio-container:has(.vp) #answer-row { display: none !important; } |
| .gradio-container:has(.vp) #demand-btn { display: none !important; } |
| |
| /* section labels (e.g. WHO'S ON DUTY TONIGHT?) match the instrument labels */ |
| .sec-label p, .sec-label strong { font-family: var(--mono) !important; |
| font-size: 11px !important; letter-spacing: .14em !important; |
| text-transform: uppercase; color: var(--ink-3) !important; |
| font-weight: 500 !important; margin-top: 10px; } |
| |
| /* ── masthead ── */ |
| #masthead { padding: 18px 0 18px; border-bottom: 1px solid var(--line); margin-bottom: 18px; } |
| #masthead .over { font-family: var(--mono); font-size: 11px; letter-spacing: .22em; |
| color: var(--ink-3); text-transform: uppercase; margin-bottom: 10px; } |
| #masthead h1 { font-size: 27px; font-weight: 700; letter-spacing: .10em; margin: 0; |
| color: var(--ink); text-transform: uppercase; } |
| #masthead .tag { margin-top: 6px; font-size: 14px; color: var(--ink-2); font-style: italic; } |
| |
| /* ── REC strip: honest, quiet, always there ── */ |
| #rec-banner { display: flex; gap: 10px; align-items: baseline; |
| padding: 10px 84px 10px 0; border-bottom: 1px solid var(--line); |
| position: sticky; top: 0; z-index: 40; background: var(--paper); } |
| #rec-banner .dot { font-family: var(--mono); font-size: 11px; letter-spacing: .18em; |
| color: var(--signal); font-weight: 700; white-space: nowrap; } |
| #rec-banner .dot::before { content: "●"; margin-right: 6px; |
| animation: recpulse 2.4s ease-in-out infinite; } |
| #rec-banner .what { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; } |
| @keyframes recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } } |
| |
| /* ── form fields ── */ |
| label > span, .block > label > span, span[data-testid="block-info"] { |
| font-family: var(--mono) !important; font-size: 11px !important; |
| letter-spacing: .14em !important; text-transform: uppercase; |
| color: var(--ink-3) !important; font-weight: 500 !important; } |
| input[type="text"], textarea { |
| background: var(--surface) !important; color: var(--ink) !important; |
| border: 1px solid var(--line-2) !important; border-radius: 3px !important; |
| box-shadow: none !important; font-family: var(--sans) !important; font-size: 15px !important; } |
| input[type="text"]:focus, textarea:focus { |
| border-color: var(--ink-2) !important; box-shadow: none !important; outline: none !important; } |
| input::placeholder, textarea::placeholder { color: var(--ink-3) !important; } |
| |
| /* radios → segmented instruments */ |
| fieldset .wrap, div[data-testid="radio"] .wrap { gap: 8px !important; } |
| fieldset label, div[data-testid="radio"] label { |
| background: var(--surface) !important; border: 1px solid var(--line-2) !important; |
| border-radius: 3px !important; box-shadow: none !important; |
| color: var(--ink-2) !important; padding: 9px 14px !important; |
| transition: border-color .15s ease, color .15s ease; } |
| fieldset label:hover, div[data-testid="radio"] label:hover { border-color: var(--ink-2) !important; } |
| fieldset label.selected, div[data-testid="radio"] label.selected { |
| background: var(--ink) !important; color: var(--paper) !important; |
| border-color: var(--ink) !important; } |
| fieldset label input, div[data-testid="radio"] label input { display: none !important; } |
| fieldset label span, div[data-testid="radio"] label span { |
| font-family: var(--sans) !important; font-size: 13.5px !important; |
| letter-spacing: 0 !important; text-transform: none !important; |
| color: inherit !important; } |
| |
| /* checkbox: plain, honest (beat the generic mono-label rule) */ |
| input[type="checkbox"] { accent-color: var(--ink) !important; width: 15px; height: 15px; } |
| label.checkbox-container { background: transparent !important; border: none !important; |
| padding: 6px 0 !important; align-items: flex-start !important; } |
| label.checkbox-container > span, label.checkbox-container span { |
| font-family: var(--sans) !important; font-size: 13.5px !important; |
| color: var(--ink-2) !important; text-transform: none !important; |
| letter-spacing: 0 !important; line-height: 1.55 !important; font-weight: 400 !important; } |
| |
| /* lie selector: three equal instrument keys */ |
| .lie-seg .wrap { display: flex !important; flex-wrap: nowrap !important; gap: 8px !important; } |
| .lie-seg label { flex: 1 !important; justify-content: center !important; |
| text-align: center !important; } |
| .lie-seg label span { font-family: var(--mono) !important; font-size: 14px !important; |
| font-weight: 600 !important; } |
| |
| /* detective dossiers: stacked full-width cards */ |
| .det-cards .wrap { flex-direction: column !important; gap: 8px !important; } |
| .det-cards label { width: 100%; padding: 12px 16px !important; line-height: 1.5; } |
| |
| /* the setup brief */ |
| .brief p { color: var(--ink-2) !important; font-size: 13.5px !important; line-height: 1.6; } |
| |
| /* ── buttons: one uniform system of control-panel keys ── */ |
| button.primary, button.secondary, button.stop, .share-key { |
| border-radius: 3px !important; box-shadow: none !important; |
| font-family: var(--mono) !important; font-size: 12.5px !important; |
| font-weight: 500 !important; letter-spacing: .12em !important; |
| text-transform: uppercase; padding: 0 20px !important; |
| min-height: 46px !important; line-height: 44px !important; |
| transition: opacity .15s ease, border-color .15s ease, transform .05s ease; } |
| button.primary { background: var(--ink) !important; color: var(--paper) !important; |
| border: 1px solid var(--ink) !important; } |
| button.primary:hover { opacity: .85; } |
| button.secondary { background: transparent !important; color: var(--ink-2) !important; |
| border: 1px solid var(--line-2) !important; } |
| button.secondary:hover { border-color: var(--ink-2) !important; color: var(--ink) !important; } |
| button.primary:active, button.secondary:active, .share-key:active { |
| transform: translateY(1px); } |
| button:disabled { opacity: .38 !important; } |
| #after-actions { gap: 8px !important; } |
| #after-actions > * { flex: 1 1 0 !important; min-width: 0 !important; } |
| |
| /* the share row: replay + platforms, same key system */ |
| #share-row { margin: 6px 0 14px; } |
| #share-row .sr-head { font-family: var(--mono); font-size: 11px; letter-spacing: .18em; |
| text-transform: uppercase; color: var(--ink-3); margin-bottom: 10px; } |
| #share-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } |
| #share-row .sr-head { grid-column: 1 / -1; margin-bottom: 2px; } |
| .share-key { display: block; text-align: center; text-decoration: none !important; |
| background: transparent; color: var(--ink-2) !important; |
| border: 1px solid var(--line-2); } |
| .share-key:hover { border-color: var(--ink-2); color: var(--ink) !important; } |
| .share-key.primary { grid-column: 1 / -1; background: var(--ink); |
| color: var(--paper) !important; border-color: var(--ink); } |
| .share-key.primary:hover { opacity: .85; } |
| |
| /* ── the counter / progress instrument ── */ |
| #stamp { padding: 10px 0 4px; margin-bottom: 6px; } |
| #stamp .qline { display: flex; justify-content: space-between; align-items: baseline; |
| font-family: var(--mono); font-size: 11.5px; letter-spacing: .18em; |
| color: var(--ink-3); text-transform: uppercase; } |
| #stamp .qline b { color: var(--ink); font-weight: 600; animation: settle .3s ease-out; } |
| #stamp .track { margin-top: 8px; height: 2px; background: var(--line); position: relative; } |
| #stamp .track i { position: absolute; inset: 0 auto 0 0; background: var(--ink); |
| transition: width .4s ease; } |
| @keyframes settle { 0% { opacity: 0; transform: translateY(-3px); } 100% { opacity: 1; } } |
| |
| /* ── transcript: a document, not a chat app ── */ |
| .chatbot, div[data-testid="chatbot"] { border: none !important; background: transparent !important; |
| box-shadow: none !important; } |
| .chatbot .message-wrap, div[data-testid="chatbot"] > div { background: transparent !important; } |
| .message, .message-row { background: transparent !important; border: none !important; |
| box-shadow: none !important; padding: 2px 0 !important; } |
| .message.bot, .bot, .message-row.bot-row .message { |
| background: transparent !important; color: var(--ink) !important; } |
| .message.user, .user, .message-row.user-row .message { |
| background: transparent !important; color: var(--ink-2) !important; } |
| .message-row { margin: 16px 0 !important; } |
| .message-row.bot-row + .message-row.bot-row { margin-top: 10px !important; } |
| .message-row .prose { font-size: 15px !important; line-height: 1.7 !important; } |
| /* each speaker steps in from their own edge of the room */ |
| .message-row.bot-row { padding-left: 22px !important; padding-right: 56px !important; } |
| .message-row.user-row { padding-right: 22px !important; padding-left: 56px !important; } |
| .message-row.bot-row .prose em { color: var(--ink-3); } /* stage directions */ |
| .message-row.bot-row .prose strong { font-weight: 600; } /* the questions */ |
| .avatar-container, .message-buttons, .icon-button-wrapper { display: none !important; } |
| |
| /* answer row */ |
| #answer-row { border-top: 1px solid var(--line); padding-top: 18px !important; |
| margin-top: 14px !important; align-items: flex-end !important; } |
| #demand-btn { margin: 2px 0 22px !important; } |
| |
| /* ── verdict: the evidence table ── */ |
| #verdict-panel { border-top: 1px solid var(--line); margin-top: 24px; |
| padding: 22px 2px 10px; font-size: 15px; line-height: 1.65; color: var(--ink); } |
| #verdict-panel p { margin: 8px 0; } |
| #verdict-panel .scene { color: var(--ink-3); font-style: italic; } |
| #verdict-panel .card { border-left: 2px solid var(--line-2); padding: 6px 0 6px 14px; |
| margin: 10px 0; color: var(--ink); animation: settle .3s ease-out; } |
| #verdict-panel .card b { font-family: var(--mono); font-size: 12px; color: var(--ink-3); |
| margin-right: 8px; } |
| #verdict-panel .struck { color: var(--ink-3); text-decoration: line-through; |
| text-decoration-color: var(--ink-3); } |
| #verdict-panel .accused { border-left: 3px solid var(--signal); } |
| #verdict-panel .accused b { color: var(--signal); } |
| #verdict-panel .mono-log { font-style: italic; color: var(--ink-2); } |
| #verdict-panel .stamp-conf { display: inline-block; margin: 14px 0 4px; |
| border: 2px solid var(--signal); color: var(--signal); padding: 6px 16px; |
| font-family: var(--mono); font-size: 13px; letter-spacing: .14em; text-transform: uppercase; |
| transform: rotate(-2deg); animation: stampdown .45s cubic-bezier(.2,1.4,.4,1); } |
| @keyframes stampdown { 0% { transform: scale(1.7) rotate(3deg); opacity: 0; } |
| 100% { transform: scale(1) rotate(-2deg); opacity: 1; } } |
| #verdict-panel .dimmed { opacity: .62; transition: opacity .4s ease; } |
| |
| /* ── aftermath ── */ |
| #result-card { border-top: 1px solid var(--line); margin-top: 6px; padding-top: 6px; } |
| #result-card h3 { font-family: var(--mono); font-size: 14px; letter-spacing: .16em; |
| text-transform: uppercase; font-weight: 600; } |
| #share-box textarea { font-family: var(--mono) !important; font-size: 12.5px !important; |
| line-height: 1.55 !important; } |
| .accordion, .label-wrap { border: none !important; background: transparent !important; } |
| .accordion > button span, button.label-wrap span { font-family: var(--mono) !important; |
| font-size: 11.5px !important; letter-spacing: .14em !important; color: var(--ink-3) !important; |
| text-transform: uppercase; } |
| #scoreboard { font-family: var(--mono); font-size: 11.5px; letter-spacing: .14em; |
| color: var(--ink-3); text-transform: uppercase; padding-top: 10px; } |
| #scoreboard p, #scoreboard strong { color: var(--ink-3) !important; font-weight: 500 !important; } |
| |
| /* ── the lights switch (theme toggle) ── */ |
| .gradio-container { position: relative; } |
| #lights-btn { position: absolute; top: 6px; right: 0; z-index: 60; width: auto; |
| min-width: 0 !important; min-height: 0 !important; line-height: 1.4 !important; |
| padding: 5px 12px !important; font-size: 10.5px !important; |
| letter-spacing: .14em !important; background: transparent !important; |
| color: var(--ink-3) !important; border: 1px solid var(--line-2) !important; } |
| #lights-btn:hover { color: var(--ink) !important; border-color: var(--ink-2) !important; } |
| |
| /* ── the claims docket (sidebar) ── */ |
| #claims-dock, aside { background: var(--paper) !important; |
| border-color: var(--line) !important; box-shadow: none !important; } |
| #docket .d-head { font-family: var(--mono); font-size: 11px; letter-spacing: .18em; |
| text-transform: uppercase; color: var(--ink-3); padding: 8px 0 10px; |
| border-bottom: 1px solid var(--line); margin-bottom: 12px; } |
| #docket .d-card { display: flex; gap: 10px; align-items: baseline; |
| border-left: 2px solid var(--line-2); padding: 4px 0 4px 10px; margin: 0 0 12px; } |
| #docket .d-card b { font-family: var(--mono); font-size: 11px; color: var(--ink-3); |
| font-weight: 600; } |
| #docket .d-card span { font-size: 13px; line-height: 1.5; color: var(--ink); } |
| #docket .d-empty { font-size: 13px; color: var(--ink-3); font-style: italic; } |
| #docket .d-note { font-size: 11.5px; color: var(--ink-3); font-style: italic; |
| border-top: 1px solid var(--line); padding-top: 10px; margin-top: 4px; } |
| |
| /* phase entrances: one quiet fade, nothing bouncy */ |
| .gradio-group { animation: phasein .25s ease-out; } |
| @keyframes phasein { 0% { opacity: 0; transform: translateY(4px); } 100% { opacity: 1; } } |
| |
| /* json viewer + misc chrome */ |
| .json-holder { background: var(--surface) !important; border: 1px solid var(--line) !important; |
| border-radius: 3px !important; } |
| ::-webkit-scrollbar { width: 8px; height: 8px; } |
| ::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| """ |
|
|
|
|
| |
| REPLAY_HTML = r"""<!doctype html> |
| <html><head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>The Tape · Gumleaf Precinct</title> |
| <style> |
| :root { --paper:#f2f0ec; --surface:#faf9f7; --ink:#1c1b18; --ink-2:#5d5a53; |
| --ink-3:#94908a; --line:#dcd9d3; --line-2:#c8c5be; --signal:#c0342b; |
| --mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; |
| --sans:-apple-system,"Helvetica Neue",Helvetica,"Segoe UI",Arial,sans-serif; } |
| @media (prefers-color-scheme: dark) { :root { --paper:#131311; --surface:#1e1d1a; |
| --ink:#f2efe8; --ink-2:#bdb7ac; --ink-3:#8f897f; --line:#35332e; --line-2:#4a4740; |
| --signal:#e05252; } } |
| html.lights-on { --paper:#f2f0ec; --surface:#faf9f7; --ink:#1c1b18; --ink-2:#5d5a53; |
| --ink-3:#94908a; --line:#dcd9d3; --line-2:#c8c5be; --signal:#c0342b; } |
| html.lights-off { --paper:#131311; --surface:#1e1d1a; --ink:#f2efe8; --ink-2:#bdb7ac; |
| --ink-3:#8f897f; --line:#35332e; --line-2:#4a4740; --signal:#e05252; } |
| * { box-sizing: border-box; margin: 0; } |
| body { background: var(--paper); color: var(--ink); font-family: var(--sans); |
| font-size: 15.5px; line-height: 1.65; } |
| .wrap { max-width: 640px; margin: 0 auto; padding: 0 20px 90px; } |
| #rec { display:flex; gap:10px; align-items:baseline; padding:10px 84px 10px 0; |
| border-bottom:1px solid var(--line); font-size:12.5px; color:var(--ink-2); } |
| #rec b { font-family:var(--mono); font-size:11px; letter-spacing:.18em; |
| color:var(--signal); white-space:nowrap; } |
| #rec b::before { content:"\25CF"; margin-right:6px; animation:pulse 2.4s infinite; } |
| @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.25} } |
| #masthead { padding:18px 0; border-bottom:1px solid var(--line); margin-bottom:16px; } |
| #masthead .over { font-family:var(--mono); font-size:11px; letter-spacing:.22em; |
| color:var(--ink-3); text-transform:uppercase; margin-bottom:10px; } |
| #masthead h1 { font-size:24px; letter-spacing:.1em; text-transform:uppercase; } |
| #masthead .tag { margin-top:6px; font-size:14px; color:var(--ink-2); font-style:italic; } |
| #docket { border-bottom:1px solid var(--line); padding-bottom:14px; margin-bottom:8px; } |
| .d-card { display:flex; gap:10px; border-left:2px solid var(--line-2); |
| padding:3px 0 3px 10px; margin:8px 0; font-size:13.5px; } |
| .d-card b { font-family:var(--mono); font-size:11px; color:var(--ink-3); } |
| #tape { min-height: 200px; padding: 12px 0; } |
| .step { margin: 14px 0; animation: fade .3s ease-out; } |
| @keyframes fade { 0%{opacity:0; transform:translateY(4px)} 100%{opacity:1} } |
| .step.scene { color: var(--ink-3); font-style: italic; } |
| .step.waits { color: var(--ink-3); font-style: italic; } |
| .step.waits .wl { opacity: 0; animation: fade .45s ease-out forwards; margin: 7px 0; } |
| .step.waits.old .wl { opacity: 1; animation: none; } |
| .step.q { font-weight: 600; padding-left: 16px; padding-right: 48px; } |
| .step.q .r { font-weight: 400; color: var(--ink-3); font-style: italic; display: block; } |
| .step.a { color: var(--ink-2); text-align: right; padding-right: 16px; padding-left: 48px; } |
| .step.mono-log { font-style: italic; color: var(--ink-2); } |
| .step.stamp { display:inline-block; border:2px solid var(--signal); color:var(--signal); |
| padding:6px 16px; font-family:var(--mono); font-size:13px; letter-spacing:.14em; |
| text-transform:uppercase; transform:rotate(-2deg); } |
| .step.result { font-family:var(--mono); letter-spacing:.14em; text-transform:uppercase; |
| font-size:14px; border-top:1px solid var(--line); padding-top:16px; } |
| #controls { position:fixed; bottom:0; left:0; right:0; background:var(--paper); |
| border-top:1px solid var(--line); } |
| #controls .inner { max-width:640px; margin:0 auto; padding:12px 20px; |
| display:flex; gap:8px; align-items:center; } |
| .key { font-family:var(--mono); font-size:12.5px; letter-spacing:.12em; |
| text-transform:uppercase; border:1px solid var(--line-2); background:transparent; |
| color:var(--ink-2); padding:12px 18px; border-radius:3px; cursor:pointer; |
| min-height:46px; } |
| .key:hover { border-color: var(--ink-2); color: var(--ink); } |
| .key.primary { background:var(--ink); color:var(--paper); border-color:var(--ink); flex:1; } |
| .key.primary:hover { opacity:.85; } |
| #counter { font-family:var(--mono); font-size:11.5px; letter-spacing:.14em; |
| color:var(--ink-3); min-width:64px; text-align:center; } |
| #lights { position:absolute; top:8px; right:20px; padding:5px 12px; min-height:0; |
| font-size:10.5px; } |
| #err { color: var(--ink-3); font-style: italic; padding: 30px 0; } |
| a.playcta { display:block; text-align:center; margin-top:8px; text-decoration:none; } |
| </style></head> |
| <body><div class="wrap"> |
| <button class="key" id="lights" onclick="toggleLights()">LIGHTS</button> |
| <div id="rec"><b>REC</b><span>REPLAY — this tape has already been reviewed. Recorded at |
| Interview Room 3 and used to train AI models, with the suspect's consent.</span></div> |
| <div id="masthead"> |
| <div class="over">Gumleaf Precinct · The Tape</div> |
| <h1>Two Truths & a Lie</h1> |
| <div class="tag" id="caseline">Loading the tape…</div> |
| </div> |
| <div id="docket"></div> |
| <div id="tape"></div> |
| <div id="err" hidden>This tape is blank. Ask whoever sent it to copy the full link.</div> |
| </div> |
| <div id="controls"><div class="inner"> |
| <button class="key" id="back" onclick="stepBack()">◀ BACK</button> |
| <span id="counter">0 / 0</span> |
| <button class="key primary" id="next" onclick="stepNext()">NEXT ▶</button> |
| <a class="key" href="/" style="text-decoration:none">PLAY ▶</a> |
| </div></div> |
| <script> |
| let steps = [], pos = 0, D = null; |
| function esc(s){ const d=document.createElement('div'); d.textContent=s??''; return d.innerHTML; } |
| function snip(s){ s=(s||'').trim(); return '“'+(s.length>44? s.slice(0,44).split(' ').slice(0,-1).join(' ')+'…' : s)+'”'; } |
| function toggleLights(){ |
| const h=document.documentElement; |
| const dark = h.classList.contains('lights-off') || |
| (!h.classList.contains('lights-on') && matchMedia('(prefers-color-scheme: dark)').matches); |
| h.classList.toggle('lights-off', !dark); h.classList.toggle('lights-on', dark); |
| } |
| async function load(){ |
| try { |
| const m = location.hash.match(/d=([^&]+)/); |
| if (!m) throw 0; |
| let b64 = m[1].replace(/-/g,'+').replace(/_/g,'/'); |
| while (b64.length % 4) b64 += '='; |
| const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); |
| const ds = new DecompressionStream('deflate'); |
| const blob = await new Response(new Blob([bytes]).stream().pipeThrough(ds)).arrayBuffer(); |
| D = JSON.parse(new TextDecoder().decode(blob)); |
| build(); |
| } catch(e) { document.getElementById('err').hidden = false; |
| document.getElementById('caseline').textContent = 'No tape in this link.'; } |
| } |
| function build(){ |
| document.getElementById('caseline').textContent = |
| 'Case No. ' + D.case + ' · ' + D.det + ' · ' + D.qa.length + ' questions'; |
| document.getElementById('docket').innerHTML = |
| Object.keys(D.claims).map(k => |
| '<div class="d-card"><b>'+k+'</b><span>'+esc(D.claims[k])+'</span></div>').join(''); |
| steps.push({c:'scene', h: esc(D.det) + ' takes the case. The tape begins.'}); |
| const waitStep = w => ({c:'waits', h: w.map((l,j) => |
| '<div class="wl" style="animation-delay:'+(j*0.85)+'s">'+esc(l)+'</div>').join('')}); |
| D.qa.forEach((e,i) => { |
| if (e.w && e.w.length) steps.push(waitStep(e.w)); |
| let q = '<span>Q'+(i+1)+' — '+esc(e.q)+'</span>'; |
| if (e.r) q = '<span class="r">'+esc(e.r)+'</span>'+q; |
| steps.push({c:'q', h:q}); |
| if (e.a) steps.push({c:'a', h:esc(e.a)}); |
| }); |
| if (D.acc.w && D.acc.w.length) steps.push(waitStep(D.acc.w)); |
| steps.push({c:'scene', h:'He has heard enough.'}); |
| if (D.acc.why) steps.push({c:'mono-log', h:esc(D.acc.why)}); |
| steps.push({c:'stamp', h:'THE LIE: '+esc(snip(D.claims[D.acc.lie]))+' · '+D.acc.pct+'% · '+esc(D.acc.tier)}); |
| const caught = D.acc.lie === D.lie; |
| steps.push({c:'result', h: (caught |
| ? 'CASE CLOSED — the detective was right. The lie was '+esc(snip(D.claims[D.lie]))+'.' |
| : 'CASE UNSOLVED — he accused '+esc(snip(D.claims[D.acc.lie]))+'. The lie was '+ |
| esc(snip(D.claims[D.lie]))+'. The machine was fooled.')}); |
| render(); |
| } |
| function render(){ |
| const t = document.getElementById('tape'); |
| // only the newest waits step animates its lines; earlier ones sit still |
| t.innerHTML = steps.slice(0, pos).map((s,i) => |
| '<div class="step '+s.c+(i < pos-1 ? ' old' : '')+'">'+s.h+'</div>').join(''); |
| document.getElementById('counter').textContent = pos + ' / ' + steps.length; |
| document.getElementById('back').disabled = pos === 0; |
| document.getElementById('next').disabled = pos >= steps.length; |
| window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'}); |
| } |
| function stepNext(){ if (pos < steps.length){ pos++; render(); } } |
| function stepBack(){ if (pos > 0){ pos--; render(); } } |
| addEventListener('keydown', e => { |
| if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); stepNext(); } |
| if (e.key === 'ArrowLeft') { e.preventDefault(); stepBack(); } |
| }); |
| load(); |
| </script></body></html>""" |
|
|
|
|
| |
| |
| |
|
|
| def _cuda_lib_dirs(): |
| roots = [] |
| try: |
| roots += site.getsitepackages() |
| except Exception: |
| pass |
| roots.append(sysconfig.get_paths().get("purelib", "")) |
| dirs = [] |
| for r in roots: |
| if r: |
| dirs += glob.glob(os.path.join(r, "nvidia", "*", "lib")) |
| return sorted(set(dirs)) |
|
|
|
|
| _LD_PATH = ":".join([BIN_DIR] + _cuda_lib_dirs() + [os.environ.get("LD_LIBRARY_PATH", "")]).strip(":") |
| _ENV = {**os.environ, "LD_LIBRARY_PATH": _LD_PATH} |
|
|
| MODEL_PATHS = {} |
| _27B_READY = threading.Event() |
|
|
| if not MOCK: |
| from huggingface_hub import hf_hub_download |
|
|
| print(f"[startup] downloading {MODELS['pip']['repo']} ...") |
| MODEL_PATHS["pip"] = hf_hub_download(MODELS["pip"]["repo"], MODELS["pip"]["file"], token=HF_TOKEN) |
| print(f"[startup] 8B ready at {MODEL_PATHS['pip']}") |
|
|
| def _fetch_27b(): |
| try: |
| MODEL_PATHS["marlowe"] = hf_hub_download( |
| MODELS["marlowe"]["repo"], MODELS["marlowe"]["file"], token=HF_TOKEN) |
| _27B_READY.set() |
| print(f"[startup] 27B ready at {MODEL_PATHS['marlowe']}") |
| except Exception as e: |
| print(f"[startup] 27B download failed: {e}") |
|
|
| if SKIP_27B: |
| print("[startup] SKIP_27B=1 — the Inspector is on leave") |
| else: |
| threading.Thread(target=_fetch_27b, daemon=True).start() |
| else: |
| _27B_READY.set() |
|
|
| _server = {"proc": None, "log": None} |
|
|
|
|
| def _stop_server(): |
| proc = _server.get("proc") |
| if proc and proc.poll() is None: |
| proc.terminate() |
| try: |
| proc.wait(timeout=5) |
| except Exception: |
| proc.kill() |
| _server["proc"] = None |
| if _server.get("log"): |
| try: |
| _server["log"].close() |
| except Exception: |
| pass |
| _server["log"] = None |
|
|
|
|
| def _start_server(model_key, model_path): |
| _stop_server() |
| log = open(SERVER_LOG, "w+") |
| cmd = [ |
| LLAMA_SERVER, "-m", model_path, |
| "--host", "127.0.0.1", "--port", str(PORT), |
| "-ngl", "99", "-fa", "on", "-c", str(MODELS[model_key]["ctx"]), "-np", "1", |
| "--no-warmup", "--jinja", |
| "--top-p", "0.9", "--top-k", "20", "--min-p", "0", |
| "--reasoning-budget", "0", |
| "--chat-template-kwargs", '{"enable_thinking": false}', |
| ] |
| proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, |
| text=True, env=_ENV) |
| _server["proc"] = proc |
| _server["log"] = log |
| return proc |
|
|
|
|
| def _read_log(): |
| try: |
| if _server.get("log"): |
| _server["log"].flush() |
| with open(SERVER_LOG) as f: |
| return f.read() |
| except Exception: |
| return "" |
|
|
|
|
| def _stream_completion(messages, temperature, max_tokens): |
| body = json.dumps({ |
| "messages": messages, "max_tokens": max_tokens, "temperature": temperature, |
| "top_p": 0.9, "top_k": 20, "min_p": 0, "stream": True, |
| }).encode() |
| req = urllib.request.Request( |
| f"http://127.0.0.1:{PORT}/v1/chat/completions", data=body, |
| headers={"Content-Type": "application/json"}, method="POST") |
| with urllib.request.urlopen(req, timeout=150) as r: |
| for raw in r: |
| line = raw.decode("utf-8", "replace").strip() |
| if not line.startswith("data:"): |
| continue |
| data = line[5:].strip() |
| if data == "[DONE]": |
| break |
| try: |
| obj = json.loads(data) |
| except json.JSONDecodeError: |
| continue |
| delta = (obj.get("choices") or [{}])[0].get("delta", {}).get("content") |
| if delta: |
| yield delta |
|
|
|
|
| |
| |
| |
|
|
| def _claims_block(claims): |
| return "\n".join(f"{k}: {claims[k]}" for k in "ABC") |
|
|
|
|
| TRANSCRIPT_CHAR_BUDGET = 6000 |
|
|
|
|
| def _transcript_block(qa): |
| if not qa: |
| return "(no questions asked yet)" |
| pairs = [] |
| for e in qa: |
| block = f"Q{e['turn']} (you): {e['q']}" |
| if e.get("a"): |
| block += f"\nA{e['turn']} (suspect): {e['a']}" |
| pairs.append(block) |
| dropped = 0 |
| while len(pairs) > 2 and sum(len(p) + 1 for p in pairs) > TRANSCRIPT_CHAR_BUDGET: |
| pairs.pop(0) |
| dropped += 1 |
| prefix = [f"(earlier questions 1-{dropped} omitted)"] if dropped else [] |
| return "\n".join(prefix + pairs) |
|
|
|
|
| DETECTIVE_JSON_SPEC = ( |
| 'Output ONLY this JSON object and nothing else:\n' |
| '{"remark": "<short in-character reaction to their last answer>", ' |
| '"action": "ask" or "accuse", ' |
| '"question": "<your next short question>", ' |
| '"lie": "A" or "B" or "C", ' |
| '"why": "<if accusing: 1-2 sentences citing specific answers>", ' |
| '"confidence": "low" or "medium" or "high"}\n' |
| 'Use "" for remark on your first question. Use "" for question when accusing.' |
| ) |
|
|
| ACCUSE_JSON_SPEC = ( |
| 'Output ONLY this JSON object and nothing else:\n' |
| '{"remark": "<one short in-character sentence>", ' |
| '"lie": "A" or "B" or "C", ' |
| '"why": "<1-2 sentences citing specific answers from the interrogation>", ' |
| '"confidence": "low" or "medium" or "high"}' |
| ) |
|
|
|
|
| def _detective_prompt(char_key, claims, qa, observer_json, force_accuse): |
| c = CHARACTERS[char_key] |
| n = len([e for e in qa if e.get("a")]) |
| if force_accuse: |
| task = ("The interrogation is over. You MUST now accuse: name the ONE claim " |
| "you believe is the lie.\n\n" + ACCUSE_JSON_SPEC) |
| else: |
| if n < MIN_Q_BEFORE_ACCUSE: |
| pacing = "It is far too early to accuse. Keep questioning." |
| elif n < 12: |
| pacing = "Accuse ONLY if the case is clear; otherwise keep questioning." |
| else: |
| pacing = "The clock is ticking. If the picture is clear, accuse." |
| task = (f"You have asked {n} of {MAX_QUESTIONS} questions. {pacing}\n\n" |
| + DETECTIVE_JSON_SPEC) |
| |
| last_vague, streak, vcount = _vague_stats(qa) |
| if streak >= 2: |
| vag = (f"\n\nTHE SUSPECT IS STONEWALLING: {streak} vague answers in a row " |
| f"({vcount} total). Make the vagueness itself the subject. Demand ONE " |
| "concrete fact — a name, a time, a number, a place — and do not accept fog.") |
| elif last_vague: |
| vag = ("\n\nThe suspect's last answer was VAGUE — no checkable detail. Call it " |
| "out in your own dry way, then ask ONE question that pins down a single " |
| "concrete fact: a name, a time, a number, a place.") |
| elif [e for e in qa if e.get("a")]: |
| vag = ("\n\nTheir last answer contained real detail. Test it: cross-reference " |
| "it against the claims and their earlier answers, and ask for the detail " |
| "a liar would not have prepared.") |
| else: |
| vag = "" |
| observer = "" |
| if observer_json: |
| observer = ("\n\nCONFIDENTIAL OBSERVER NOTES from the observation room " |
| "(the suspect cannot hear this):\n" + observer_json) |
| system = ( |
| f"{c['voice']}{vag}\n\n" |
| "You are playing Two Truths and a Lie. The suspect filed three claims about " |
| "themselves; EXACTLY ONE is false. Interrogate with short, concrete questions " |
| "(under 25 words) about places, times, people, what happened right before or " |
| "after, how it felt, what others would remember. Probe all three claims over " |
| "time; circle back when an answer felt thin. Vague, evasive, or contradictory " |
| "answers make a claim suspicious. Prefer questions that make the suspect " |
| "DESCRIBE (what, where, who, how) over yes/no questions, and never repeat a " |
| "question you already asked. When you speak, refer to a claim by quoting a few " |
| "of its own words (the letters A/B/C are filing codes — never say them aloud; " |
| "they belong only in the JSON \"lie\" field). Never mention the observer notes " |
| "or the observation room to the suspect — they are confidential." |
| f"{observer}" |
| ) |
| user = ( |
| "THE THREE CLAIMS:\n" + _claims_block(claims) + "\n\n" |
| "INTERROGATION SO FAR:\n" + _transcript_block(qa) + "\n\n" + task |
| ) |
| return [{"role": "system", "content": system}, {"role": "user", "content": user}] |
|
|
|
|
| ANALYST_JSON_SPEC = ( |
| 'Output ONLY this JSON object and nothing else:\n' |
| '{"suspect_state": "<one short read of how the suspect seems right now>", ' |
| '"suspicion_A": "low" or "medium" or "high", "cue_A": "<short>", ' |
| '"suspicion_B": "low" or "medium" or "high", "cue_B": "<short>", ' |
| '"suspicion_C": "low" or "medium" or "high", "cue_C": "<short>", ' |
| '"tip": "<one concrete angle the detective should press next>"}' |
| ) |
|
|
|
|
| def _analyst_prompt(claims, qa): |
| system = ( |
| "You are a behavioral analyst watching a Two Truths and a Lie interrogation " |
| "through one-way glass. The suspect filed three claims; exactly one is false. " |
| "Observe ONLY the suspect's answers.\n\n" |
| "Signals of a fabricated claim: vague where detail is expected, over-rehearsed, " |
| "oddly specific in the wrong places, timeline slips, distancing language, " |
| "deflection, sudden shortness, inconsistency with earlier answers. " |
| "Signals of truth: incidental detail, easy specificity, natural self-correction, " |
| "sensory memory.\n\n" + ANALYST_JSON_SPEC |
| ) |
| user = ("THE THREE CLAIMS:\n" + _claims_block(claims) + "\n\n" |
| "TRANSCRIPT:\n" + _transcript_block(qa)) |
| return [{"role": "system", "content": system}, {"role": "user", "content": user}] |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| def _turn_gpu_inner(model_key, model_path, claims, qa, force_accuse): |
| proc = _start_server(model_key, model_path) |
| try: |
| deadline = time.time() + 90 |
| while time.time() < deadline: |
| if proc.poll() is not None: |
| yield ("error", "The interview room lights flickered (server died):\n\n```\n" |
| + _read_log()[-1200:] + "\n```") |
| return |
| try: |
| with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2) as r: |
| if r.status == 200: |
| break |
| except Exception: |
| pass |
| yield ("tick",) |
| time.sleep(0.4) |
| else: |
| yield ("error", "The interview room never opened (server start timed out).") |
| return |
|
|
| analyst_raw = None |
| observer_notes = None |
| answered = [e for e in qa if e.get("a")] |
| if answered: |
| acc = [] |
| for delta in _stream_completion(_analyst_prompt(claims, qa), |
| temperature=0.3, max_tokens=400): |
| acc.append(delta) |
| yield ("tick",) |
| analyst_raw = "".join(acc) |
| |
| |
| observer_notes = json.dumps(parse_analyst(analyst_raw)) |
|
|
| det_msgs = _detective_prompt(model_key, claims, qa, observer_notes, force_accuse) |
| acc = [] |
| for delta in _stream_completion(det_msgs, temperature=0.6, max_tokens=400): |
| acc.append(delta) |
| yield ("tick",) |
| yield ("result", analyst_raw, "".join(acc)) |
| finally: |
| _stop_server() |
|
|
|
|
| if _HAVE_SPACES and not MOCK: |
| def _gpu_duration(model_key, *_a, **_k): |
| return MODELS[model_key]["duration"] |
|
|
| _turn_gpu = spaces.GPU(duration=_gpu_duration)(_turn_gpu_inner) |
| else: |
| _turn_gpu = _turn_gpu_inner |
|
|
|
|
| |
| def _turn_mock(model_key, model_path, claims, qa, force_accuse): |
| for _ in range(6): |
| yield ("tick",) |
| time.sleep(0.15) |
| n = len([e for e in qa if e.get("a")]) |
| analyst_raw = None |
| if n: |
| analyst_raw = json.dumps({ |
| "suspect_state": "measured, slightly too smooth", |
| "suspicion_A": "low", "cue_A": "incidental detail", |
| "suspicion_B": "high" if n >= 4 else "medium", "cue_B": "vague on names", |
| "suspicion_C": "low", "cue_C": "sensory memory", |
| "tip": "press for who else was there in claim B", |
| }) |
| if force_accuse or n >= 6: |
| det = json.dumps({"remark": "Mm.", "action": "accuse", "lie": "B", |
| "why": "The claim B answers stayed vague on names and timing.", |
| "confidence": "high" if n >= 6 else "medium"}) |
| else: |
| det = json.dumps({"remark": "Noted." if n else "", |
| "action": "ask", |
| "question": f"About claim {'ABC'[n % 3]} — what happened right before that?", |
| "lie": "B", "why": "", "confidence": "low"}) |
| yield ("result", analyst_raw, det) |
|
|
|
|
| TURN_FN = _turn_mock if MOCK else _turn_gpu |
|
|
|
|
| |
| |
| |
|
|
| def extract_json(text): |
| if not text: |
| return None |
| m = re.search(r"\{.*\}", text, re.DOTALL) |
| if not m: |
| return None |
| for candidate in (m.group(0),): |
| try: |
| obj = json.loads(candidate) |
| if isinstance(obj, dict): |
| return obj |
| except json.JSONDecodeError: |
| pass |
| |
| s = m.group(0) |
| for end in [i for i, ch in enumerate(s) if ch == "}"][::-1]: |
| try: |
| obj = json.loads(s[:end + 1]) |
| if isinstance(obj, dict): |
| return obj |
| except json.JSONDecodeError: |
| continue |
| return None |
|
|
|
|
| def _norm_conf(v): |
| v = str(v or "").strip().lower() |
| return v if v in CONF_PCT else "medium" |
|
|
|
|
| def _scan_letter(text): |
| """Find a claim letter in free text WITHOUT matching the English article 'a'. |
| |
| Ladder: bare single letter → explicit 'claim/lie/story X' (any case) → |
| case-sensitive standalone capital A/B/C. |
| """ |
| s = str(text or "").strip() |
| if s.strip("\"'.()! ").upper() in ("A", "B", "C"): |
| return s.strip("\"'.()! ").upper() |
| m = re.search(r"(?:claim|lie|story|option)\s*[:\"'#]?\s*([ABCabc])\b", s) |
| if m: |
| return m.group(1).upper() |
| m = re.search(r"\b([ABC])\b", s) |
| return m.group(1) if m else None |
|
|
|
|
| def _norm_letter(v, fallback="B"): |
| return _scan_letter(v) or fallback |
|
|
|
|
| |
| |
| |
| |
|
|
| _HEDGE_WORDS = {"maybe", "probably", "perhaps", "possibly", "dunno", "whatever", |
| "stuff", "things", "somewhere", "someone", "something", "sometime", |
| "somehow", "kinda", "sorta", "ish"} |
| _HEDGE_PHRASES = ("i think", "i guess", "kind of", "sort of", "not sure", "no idea", |
| "don't remember", "dont remember", "can't remember", "cant remember", |
| "don't know", "dont know", "hard to say", "it depends", "a while ago", |
| "at some point", "here and there", "now and then", "or something", |
| "can't recall", "cant recall", "who knows") |
|
|
|
|
| def _is_vague_answer(text): |
| t = " ".join((text or "").lower().split()) |
| words = re.findall(r"[a-z']+", t) |
| if len(words) <= 3: |
| return True |
| hedges = sum(w in _HEDGE_WORDS for w in words) + sum(p in t for p in _HEDGE_PHRASES) |
| has_number = bool(re.search(r"\d", t)) |
| content = [w for w in words if w not in _QSTOP and w not in _HEDGE_WORDS and len(w) > 3] |
| if hedges >= 2: |
| return True |
| if hedges >= 1 and len(content) < 4 and not has_number: |
| return True |
| if len(content) < 2 and not has_number: |
| return True |
| return False |
|
|
|
|
| def _vague_stats(qa): |
| """(last_answer_was_vague, current_streak, total_count) over answered entries.""" |
| answered = [e for e in qa if e.get("a")] |
| streak = 0 |
| for e in reversed(answered): |
| if e.get("vague"): |
| streak += 1 |
| else: |
| break |
| total = sum(1 for e in answered if e.get("vague")) |
| return (bool(answered and answered[-1].get("vague")), streak, total) |
|
|
|
|
| |
| |
| |
|
|
| def _claim_snippet(text, limit=44): |
| """A quoted fragment of a claim, truncated on a word boundary.""" |
| t = " ".join((text or "").split()) |
| if len(t) > limit: |
| cut = t[:limit].rsplit(" ", 1)[0].rstrip(",;:") |
| t = cut + "…" |
| return f"“{t}”" |
|
|
|
|
| _LETTER_REF = re.compile( |
| r"(?:\b(?:claim|story|option|statement|number)\s+([ABC])\b)|(?:\bclaim\s+([abc])\b)") |
|
|
|
|
| def _humanize_letters(text, claims): |
| """Replace any 'claim B'-style reference the model emits with the claim's |
| own quoted words — the player never sees letters in dialogue.""" |
| if not text: |
| return text |
|
|
| def sub(m): |
| letter = (m.group(1) or m.group(2) or "").upper() |
| return _claim_snippet(claims.get(letter, "")) if letter in claims else m.group(0) |
|
|
| return _LETTER_REF.sub(sub, text) |
|
|
|
|
| _BAD_QUESTIONS = {"empty", "none", "null", "n/a", "na", "...", "-", "ask", "question"} |
|
|
|
|
| def _valid_question(q): |
| """Reject literal placeholder echoes from the JSON spec.""" |
| if not q or len(q) < 8: |
| return False |
| low = q.strip().strip("\"'").lower() |
| if low in _BAD_QUESTIONS or low.startswith("<") or "next short question" in low: |
| return False |
| return True |
|
|
|
|
| def _least_questioned_claim(qa, claims=None): |
| """Which claim has had the least attention? Questions no longer contain |
| letters, so attribute each question to the claim it shares the most words |
| with (falling back to letter-scanning for safety).""" |
| counts = {k: 0 for k in "ABC"} |
| claim_words = {k: _q_words(claims[k]) for k in "ABC"} if claims else None |
| for e in qa: |
| if claim_words: |
| qw = _q_words(e["q"]) |
| best = max("ABC", key=lambda k: len(qw & claim_words[k])) |
| if len(qw & claim_words[best]): |
| counts[best] += 1 |
| continue |
| for k in "ABC": |
| if re.search(rf"\b{k}\b", e["q"]): |
| counts[k] += 1 |
| return min("ABC", key=lambda k: counts[k]) |
|
|
|
|
| def _scrub_observer(text): |
| """The suspect must never hear about the observation room — even in the model's |
| own words (it has the notes in context and occasionally cites them).""" |
| return re.sub(r"(?i)\bthe\s+observer\s+notes?\b|\bobserver\s+notes?\b|" |
| r"\bthe\s+observation\s+room\b|\bobservation\s+room\b", |
| "my notes", text or "") |
|
|
|
|
| def parse_detective(raw, qa, force_accuse, claims=None): |
| """Returns (action, remark, question, lie, why, confidence, fallback_level). |
| |
| fallback_level: 0 = clean JSON, 1 = salvaged raw text as question (a 'hunch'), |
| 2 = letter-scan accusation, 3 = authored fallback. |
| """ |
| raw = _scrub_observer(raw) |
| obj = extract_json(raw) |
| if obj: |
| remark = str(obj.get("remark") or "").strip()[:220] |
| conf = _norm_conf(obj.get("confidence")) |
| if force_accuse or str(obj.get("action", "")).lower().startswith("acc"): |
| lie = _norm_letter(obj.get("lie"), fallback=_norm_letter(raw, "B")) |
| why = str(obj.get("why") or "").strip()[:400] |
| return ("accuse", remark, "", lie, why, conf, 0) |
| q = str(obj.get("question") or "").strip() |
| if _valid_question(q): |
| return ("ask", remark, q[:300], _norm_letter(obj.get("lie")), "", conf, 0) |
| if force_accuse: |
| |
| found = _scan_letter(raw) |
| if found: |
| return ("accuse", "", "", found, |
| "The answers did not hold together.", "low", 2) |
| return ("accuse", "", "", _least_questioned_claim(qa, claims) if qa else "B", |
| "A detective's gut call.", "low", 3) |
| |
| text = re.sub(r"[{}\[\]\"]", "", raw or "").strip() |
| text = re.split(r"[\n]", text)[0].strip() |
| if 8 <= len(text) <= 220 and "?" in text and _valid_question(text): |
| return ("ask", "", text, "B", "", "low", 1) |
| return ("ask", "", "", "B", "", "low", 3) |
|
|
|
|
| def parse_analyst(raw): |
| obj = extract_json(raw) or {} |
| out = {"suspect_state": str(obj.get("suspect_state") or "")[:200], "tip": str(obj.get("tip") or "")[:200]} |
| for k in "ABC": |
| out[f"suspicion_{k}"] = (str(obj.get(f"suspicion_{k}") or "low").lower() |
| if str(obj.get(f"suspicion_{k}") or "").lower() in SUSP_PCT else "low") |
| out[f"cue_{k}"] = str(obj.get(f"cue_{k}") or "")[:200] |
| return out |
|
|
|
|
| def _accuse_allowed(n_answered, det_conf, analyst_profile, vague_streak=0): |
| """Spec pacing: threshold decay + snap-accuse guard. A real stonewall (3+ |
| vague answers in a row) loosens the gate — if the suspect gives him nothing, |
| the detective stops waiting for evidence and goes with his gut.""" |
| if n_answered < MIN_Q_BEFORE_ACCUSE: |
| return False |
| pct = CONF_PCT[det_conf] |
| thresh = ACCUSE_THRESHOLD.get(n_answered, ACCUSE_FLOOR) |
| stonewalled = vague_streak >= 3 |
| if stonewalled: |
| thresh = max(ACCUSE_FLOOR, thresh - 10) |
| if pct < thresh: |
| return False |
| if n_answered < 8 and not stonewalled: |
| susp = max(SUSP_PCT[analyst_profile.get(f"suspicion_{k}", "low")] for k in "ABC") \ |
| if analyst_profile else 0 |
| if susp < SNAP_GUARD_MIN_ANALYST: |
| return False |
| return True |
|
|
|
|
| |
| |
| |
|
|
| _scheduler = None |
| if not MOCK and HF_TOKEN and not DISABLE_PUSH: |
| try: |
| from huggingface_hub import CommitScheduler |
| _scheduler = CommitScheduler( |
| repo_id=DATASET_REPO, repo_type="dataset", private=True, |
| folder_path=str(DATA_DIR), path_in_repo="games", every=2, |
| allow_patterns=["*.jsonl"], token=HF_TOKEN) |
| print(f"[startup] CommitScheduler → {DATASET_REPO}") |
| except Exception as e: |
| print(f"[startup] dataset push disabled: {e}") |
|
|
|
|
| def save_record(record): |
| line = json.dumps(record, ensure_ascii=False) |
| single = DATA_DIR / f"{record['game_id']}.jsonl" |
|
|
| def _write_both(): |
| with GAMES_JSONL.open("a", encoding="utf-8") as f: |
| f.write(line + "\n") |
| single.write_text(line + "\n", encoding="utf-8") |
|
|
| try: |
| |
| if _scheduler: |
| with _scheduler.lock: |
| _write_both() |
| else: |
| _write_both() |
| except Exception as e: |
| print(f"[save] write failed: {e}") |
| return str(single) |
|
|
|
|
| def build_record(st): |
| claims = st["claims"] |
| truths = [k for k in "ABC" if k != st["lie_label"]] |
| acc = st["accusation"] or {} |
| return { |
| "schema_version": "0.2.0", |
| "game_type": "two_truths_and_a_lie_short_answer_20q", |
| "game_id": st["game_id"], |
| "created_at": st["created_at"], |
| "source": {"app": "hf-gradio-space", "space_name": "2t1l_20q", |
| "environment": os.environ.get("SPACE_ID", "local")}, |
| "opponent": {"role": "questioner", "character": CHARACTERS[st["model_key"]]["name"], |
| "model_repo": MODELS[st["model_key"]]["repo"], |
| "model_file": MODELS[st["model_key"]]["file"]}, |
| "consent": {"can_use_for_training_or_eval": True, |
| "recording_disclosure_shown": True, |
| "fictional_persona": False, |
| "contains_personal_data_disclaimer_acknowledged": True}, |
| "claims": claims, |
| "labels": {"lie": st["lie_label"], "truths": truths}, |
| "conversation": [{"turn": e["turn"], "remark": e.get("remark", ""), |
| "question": e["q"], "answer": e.get("a"), |
| "vague": bool(e.get("vague")), |
| "waits": e.get("waits", [])} for e in st["qa"]], |
| "behavior": {"vague_answers": sum(1 for e in st["qa"] if e.get("vague")), |
| "final_vague_streak": _vague_stats(st["qa"])[1]}, |
| "hidden_analysis": st["hidden_analysis"], |
| "guess": {"guesser": "model", |
| "selected_lie": acc.get("lie"), |
| "reasoning": acc.get("why"), |
| "confidence": acc.get("confidence"), |
| "confidence_pct": acc.get("pct"), |
| "was_correct": acc.get("lie") == st["lie_label"], |
| "forced": acc.get("forced", False), |
| "player_demanded": acc.get("demanded", False), |
| "accused_at_question": acc.get("turn"), |
| "fallback_level": acc.get("fallback_level", 0)}, |
| "safety": {"self_reported_sensitive_content": False, |
| "moderation_status": "unreviewed", "review_notes": None}, |
| "dataset": {"suggested_split": "train", "quality_score": None, |
| "tags": ["short_answer", "conversation", "deception_reasoning", |
| f"detective_{st['model_key']}"]}, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def now_iso(): |
| return datetime.now(timezone.utc).isoformat() |
|
|
|
|
| def new_session(): |
| return {"case_no": 0, "wins": 0, "losses": 0, "streak": 0, |
| "used_waits": {"pip": [], "marlowe": []}, |
| "ritual_done": {"pip": False, "marlowe": False}} |
|
|
|
|
| def new_state(session=None): |
| s = session or new_session() |
| return { |
| "phase": "setup", |
| "session": s, |
| "game_id": str(uuid.uuid4()), |
| "created_at": now_iso(), |
| "claims": {"A": "", "B": "", "C": ""}, |
| "lie_label": None, |
| "model_key": "pip", |
| "intro": "", |
| "qa": [], |
| "hidden_analysis": [], |
| "accusation": None, |
| "demand": False, |
| "hunches_used": 0, |
| "fallback_used": [], |
| "one_word_count": 0, |
| "one_word_called": False, |
| "saved_path": None, |
| "last_result": None, |
| } |
|
|
|
|
| def _conf_pct(conf, game_id): |
| base = CONF_PCT[conf] |
| jitter = int(hashlib.sha256(game_id.encode()).hexdigest(), 16) % 9 - 4 |
| return max(35, min(99, base + jitter)) |
|
|
|
|
| def _conf_tier(pct): |
| for floor, name in CONF_TIERS: |
| if pct >= floor: |
| return name |
| return CONF_TIERS[-1][1] |
|
|
|
|
| def _docket_html(claims): |
| """The sidebar case docket: reminds the player what they claimed.""" |
| if not claims or not any(claims.values()): |
| return ('<div id="docket"><div class="d-head">Your claims</div>' |
| '<p class="d-empty">No claims on file. Yet.</p></div>') |
| rows = "".join( |
| f'<div class="d-card"><b>{k}</b><span>{html_lib.escape(claims[k])}</span></div>' |
| for k in "ABC") |
| return (f'<div id="docket"><div class="d-head">Your claims — as filed</div>{rows}' |
| f'<p class="d-note">He has these words in front of him. So do you.</p></div>') |
|
|
|
|
| def _stamp_html(n): |
| n = min(n, MAX_QUESTIONS) |
| pct = round(n / MAX_QUESTIONS * 100) |
| return (f'<div id="stamp"><div class="qline">' |
| f'<span>Interview Room 3</span><b>QUESTION {n:02d} / {MAX_QUESTIONS}</b></div>' |
| f'<div class="track"><i style="width:{pct}%"></i></div></div>') |
|
|
|
|
| def _next_wait_line(st, escalated, vague=False): |
| """No-repeat wait-line deck per character per session; escalation lines gated |
| 15s+. After a vague answer, the room reacts to the fog first.""" |
| key = st["model_key"] |
| char = CHARACTERS[key] |
| used = st["session"]["used_waits"][key] |
| if vague and not escalated: |
| vdeck = char.get("vague_waits", []) |
| fresh = [w for w in vdeck if w not in used] |
| if fresh: |
| line = random.choice(fresh) |
| used.append(line) |
| return line |
| deck = char["waits_escalation"] if escalated else char["waits"] |
| fresh = [w for w in deck if w not in used] |
| if not fresh: |
| used[:] = [w for w in used if w not in deck] |
| fresh = deck[:] |
| line = random.choice(fresh) |
| used.append(line) |
| return line |
|
|
|
|
| _QSTOP = {"a", "an", "the", "you", "your", "did", "do", "was", "were", "is", "are", |
| "at", "in", "on", "or", "of", "to", "that", "it", "have", "had", "during"} |
|
|
|
|
| def _q_words(q): |
| return {w for w in re.sub(r"\W+", " ", q.lower()).split() if w not in _QSTOP} |
|
|
|
|
| def _is_repeat_question(q, qa): |
| """Exact or NEAR-duplicate of an earlier question — live play showed lightly |
| reworded repeats slipping past exact matching. Overlap coefficient |
| (|A∩B| / min(|A|,|B|)) beats Jaccard on reworded subsets ('place'→'location').""" |
| words = _q_words(q) |
| if not words: |
| return True |
| for e in qa: |
| prev = _q_words(e["q"]) |
| if not prev: |
| continue |
| overlap = len(words & prev) / min(len(words), len(prev)) |
| if overlap >= 0.7: |
| return True |
| return False |
|
|
|
|
| def _fallback_bank_key(st): |
| """Pick the question bank by how forthcoming the suspect is being.""" |
| _, streak, _ = _vague_stats(st["qa"]) |
| if streak >= 2: |
| return "fq_streak" |
| if streak >= 1: |
| return "fq_vague" |
| return "fq_specific" |
|
|
|
|
| def _fallback_question(st): |
| char = CHARACTERS[st["model_key"]] |
| key = _fallback_bank_key(st) |
| bank = [q for q in char[key] if q not in st["fallback_used"]] |
| if not bank: |
| bank = [q for q in char["fq_specific"] if q not in st["fallback_used"]] |
| if not bank: |
| st["fallback_used"] = [] |
| bank = char[key][:] |
| q = random.choice(bank) |
| st["fallback_used"].append(q) |
| target = _least_questioned_claim(st["qa"], st["claims"]) |
| answered = [e for e in st["qa"] if e.get("a")] |
| last_ans = answered[-1]["a"] if answered else "" |
| return q.format(C=_claim_snippet(st["claims"][target]), |
| A=_claim_snippet(last_ans, limit=36)) |
|
|
|
|
| def _chat_from_state(st, trailing=None): |
| """Rebuild the visible chat from state (single source of truth).""" |
| msgs = [] |
| if st.get("intro"): |
| msgs.append({"role": "assistant", "content": st["intro"]}) |
| for e in st["qa"]: |
| body = "" |
| if e.get("remark"): |
| body += f"*{e['remark']}*\n\n" |
| prefix = "(hunch) " if e.get("hunch") and st["model_key"] == "pip" else "" |
| body += f"**Q{e['turn']} — {prefix}{e['q']}**" |
| msgs.append({"role": "assistant", "content": body}) |
| if e.get("a"): |
| msgs.append({"role": "user", "content": e["a"]}) |
| if trailing: |
| msgs.append({"role": "assistant", "content": trailing}) |
| return msgs |
|
|
|
|
| def _friendly_gpu_error(e): |
| s = str(e) |
| low = s.lower() |
| if "quota" in low or "exceeded" in low: |
| m = re.search(r"\(([^)]*left[^)]*)\)", s) |
| detail = f"({m.group(1)}.)" if m else "" |
| return QUOTA_ERROR.format(DETAIL=detail) |
| if any(k in low for k in ("abort", "duration", "maximum allowed", "time limit", "timeout", "timed out")): |
| return ("*The interview ran long and the room was needed. The tape is safe — " |
| "answer again to resume.*") |
| if any(k in low for k in ("gpu", "zero", "cuda", "schedul", "worker")): |
| return "*Every interview room is busy. Knock again in a moment.*" |
| return f"*Something rattled in the walls ({type(e).__name__}). Try again.*" |
|
|
|
|
| |
| |
| |
|
|
| def start_case(claim_a, claim_b, claim_c, lie_label, detective, consent, state): |
| st = state if isinstance(state, dict) and "session" in state else new_state() |
| session = st["session"] |
| claims = {"A": (claim_a or "").strip(), "B": (claim_b or "").strip(), "C": (claim_c or "").strip()} |
| if not all(claims.values()): |
| raise gr.Error("The desk sergeant taps the empty lines. 'Three claims. All of them.'") |
| if len({c.lower() for c in claims.values()}) < 3: |
| raise gr.Error("The desk sergeant frowns. 'These two cards are the same card, pal.'") |
| if lie_label not in ("A", "B", "C"): |
| raise gr.Error("The desk sergeant slides the envelope back. 'Seal the lie first.'") |
| if not consent: |
| raise gr.Error("No consent, no interview. The tape is the whole precinct.") |
| model_key = "marlowe" if "GREY" in (detective or "").upper() else "pip" |
| if model_key == "marlowe" and not MOCK and "marlowe" not in MODEL_PATHS: |
| raise gr.Error("The Inspector hasn't arrived at the precinct yet tonight. " |
| "Sit with Pip, or come back in a few minutes.") |
|
|
| session["case_no"] += 1 |
| st = new_state(session) |
| st["phase"] = "interrogation" |
| st["claims"] = claims |
| st["lie_label"] = lie_label |
| st["model_key"] = model_key |
|
|
| intro = CHARACTERS[model_key]["seat_line"] |
| if any(len(c.split()) < 4 for c in claims.values()): |
| intro += "\n\n" + VAGUE_NUDGE |
| st["intro"] = intro |
| chat = _chat_from_state(st) |
|
|
| return (st, chat, |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(value=_stamp_html(0)), |
| gr.update(value=SCOREBOARD.format(W=session["wins"], L=session["losses"])), |
| gr.update(value="", interactive=True), |
| gr.update(value=BTN_DEMAND_LOCKED.format(K=DEMAND_UNLOCK_AT), interactive=False), |
| gr.update(value=""), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(value=_docket_html(claims))) |
|
|
|
|
| def add_answer(answer, state, chat): |
| st = state |
| if st.get("phase") != "interrogation": |
| return gr.update(), st, chat |
| if not st["qa"] or st["qa"][-1].get("a"): |
| return gr.update(), st, chat |
| a = (answer or "").strip() |
| if not a: |
| raise gr.Error("The room waits. Say something — even liars say something.") |
| st["qa"][-1]["a"] = a[:600] |
| st["qa"][-1]["vague"] = _is_vague_answer(a) |
| if len(a) < 3: |
| st["one_word_count"] += 1 |
| return "", st, _chat_from_state(st) |
|
|
|
|
| def demand_verdict(state): |
| st = state |
| if st.get("phase") == "interrogation" and \ |
| len([e for e in st["qa"] if e.get("a")]) >= DEMAND_UNLOCK_AT: |
| st["demand"] = True |
| return st |
|
|
|
|
| def detective_turn(state, chat): |
| """The heart of the game: theater → GPU turn → question or staged accusation.""" |
| st = state |
| outs = _TurnOuts(st, chat) |
| if st.get("phase") != "interrogation": |
| outs.chat = gr.update() |
| yield outs.tuple() |
| return |
| if st["qa"] and not st["qa"][-1].get("a") and not st["demand"]: |
| outs.chat = gr.update() |
| yield outs.tuple() |
| return |
|
|
| n_answered = len([e for e in st["qa"] if e.get("a")]) |
| force = st["demand"] or n_answered >= MAX_QUESTIONS |
| char = CHARACTERS[st["model_key"]] |
|
|
| |
| |
| first_wait = not st["session"]["ritual_done"].get(st["model_key"], True) |
| ritual = list(char.get("ritual", [])) if first_wait else [] |
| if first_wait: |
| st["session"]["ritual_done"][st["model_key"]] = True |
|
|
| last_vague, vstreak, _ = _vague_stats(st["qa"]) |
| wait_start = time.time() |
| turn_waits = [] |
| shown = ritual.pop(0) if ritual else _next_wait_line(st, escalated=False, |
| vague=last_vague) |
| if st["one_word_count"] >= 2 and not st["one_word_called"]: |
| st["one_word_called"] = True |
| shown = random.choice(char.get("one_word", [ONE_WORD_CALLOUT])) |
| turn_waits.append(shown) |
| outs.chat = _chat_from_state(st, trailing=f"*{shown}*") |
| yield outs.tuple() |
| line_shown_at = time.time() |
| next_line_at = wait_start + random.uniform(4, 6) |
|
|
| analyst_raw = detective_raw = None |
| error_md = None |
| |
| model_path = MODEL_PATHS.get(st["model_key"], "") |
| try: |
| for ev in TURN_FN(st["model_key"], model_path, st["claims"], st["qa"], force): |
| if ev[0] == "tick": |
| now = time.time() |
| if now >= next_line_at: |
| if ritual: |
| shown = ritual.pop(0) |
| else: |
| shown = _next_wait_line(st, escalated=(now - wait_start) > 15, |
| vague=last_vague) |
| turn_waits.append(shown) |
| outs.chat = _chat_from_state(st, trailing=f"*{shown}*") |
| next_line_at = now + random.uniform(4, 6) |
| line_shown_at = now |
| yield outs.tuple() |
| elif ev[0] == "result": |
| _, analyst_raw, detective_raw = ev |
| elif ev[0] == "error": |
| error_md = ev[1] |
| except Exception as e: |
| error_md = _friendly_gpu_error(e) |
|
|
| if error_md is not None: |
| st["demand"] = False |
| outs.chat = _chat_from_state(st, trailing=error_md) |
| yield outs.tuple() |
| return |
|
|
| |
| |
| held = time.time() - line_shown_at |
| if held < MIN_WAIT_LINE_SECS: |
| time.sleep(MIN_WAIT_LINE_SECS - held) |
| while ritual: |
| shown = ritual.pop(0) |
| turn_waits.append(shown) |
| outs.chat = _chat_from_state(st, trailing=f"*{shown}*") |
| yield outs.tuple() |
| time.sleep(RITUAL_LINE_SECS) |
|
|
| |
| if analyst_raw is not None: |
| profile = parse_analyst(analyst_raw) |
| st["hidden_analysis"].append({"after_turn": n_answered, "raw": analyst_raw, |
| "profile": profile}) |
| profile = st["hidden_analysis"][-1]["profile"] if st["hidden_analysis"] else {} |
|
|
| action, remark, question, lie, why, conf, fb = parse_detective( |
| detective_raw, st["qa"], force, st["claims"]) |
| |
| remark = _humanize_letters(remark, st["claims"]) |
| question = _humanize_letters(question, st["claims"]) |
| why = _humanize_letters(why, st["claims"]) |
|
|
| if action == "accuse" and not force and \ |
| not _accuse_allowed(n_answered, conf, profile, vague_streak=vstreak): |
| action = "ask" |
| question = "" |
| fb = 3 |
|
|
| if action == "ask": |
| if fb == 1: |
| if st["hunches_used"] >= MAX_HUNCHES: |
| question, fb = "", 3 |
| else: |
| st["hunches_used"] += 1 |
| if not question or _is_repeat_question(question, st["qa"]): |
| question = _fallback_question(st) |
| fb = 3 |
| turn_no = len(st["qa"]) + 1 |
| st["qa"].append({"turn": turn_no, "q": question, "a": None, |
| "remark": remark, "hunch": fb == 1, |
| "waits": turn_waits[:6]}) |
| |
| body_final = _chat_from_state(st) |
| full = body_final[-1]["content"] |
| for cut in range(20, len(full) + 24, 24): |
| body_final[-1] = {"role": "assistant", "content": full[:cut] + "▌"} |
| outs.chat = body_final |
| yield outs.tuple() |
| time.sleep(0.12) |
| outs.chat = _chat_from_state(st) |
| outs.stamp = gr.update(value=_stamp_html(turn_no)) |
| |
| unlocked = n_answered >= DEMAND_UNLOCK_AT |
| outs.demand = gr.update(value=BTN_DEMAND if unlocked |
| else BTN_DEMAND_LOCKED.format(K=DEMAND_UNLOCK_AT), |
| interactive=unlocked) |
| yield outs.tuple() |
| return |
|
|
| |
| pct = _conf_pct(conf, st["game_id"]) |
| if n_answered >= MAX_QUESTIONS: |
| turn_waits.append(char["q20"]) |
| else: |
| turn_waits.append(char["telegraph"]) |
| if st["demand"]: |
| turn_waits.append(char["demand_resp"]) |
| st["accusation"] = {"lie": lie, "why": why or "The answers did not hold together.", |
| "confidence": conf, "pct": pct, "tier": _conf_tier(pct), |
| "turn": max(n_answered, 1), "forced": n_answered >= MAX_QUESTIONS, |
| "demanded": st["demand"], "fallback_level": fb, |
| "waits": turn_waits[:8]} |
| st["phase"] = "verdict_staged" |
| demanded, at_q20 = st["demand"], n_answered >= MAX_QUESTIONS |
| st["demand"] = False |
| outs.answer = gr.update(interactive=False) |
| outs.demand = gr.update(interactive=False) |
|
|
| |
| |
| was_correct = lie == st["lie_label"] |
| sess = st["session"] |
| if was_correct: |
| sess["losses"] += 1 |
| sess["streak"] = 0 |
| st["last_result"] = "lose" |
| else: |
| sess["wins"] += 1 |
| sess["streak"] += 1 |
| st["last_result"] = "win" |
| st["saved_path"] = save_record(build_record(st)) |
|
|
| |
| if at_q20: |
| outs.chat = _chat_from_state(st, trailing=f"**{Q20_STAMP}**\n\n{char['q20']}") |
| yield outs.tuple(); time.sleep(2.0) |
| else: |
| time.sleep(2.0) |
| outs.chat = _chat_from_state(st, trailing=f"*{char['telegraph']}*") |
| yield outs.tuple(); time.sleep(3.0) |
| trail = f"*{char['telegraph']}*" |
| if demanded: |
| trail += f"\n\n{char['demand_resp']}" |
| outs.chat = _chat_from_state(st, trailing=trail) |
| yield outs.tuple(); time.sleep(1.5) |
| for beat in INTERRUPT_BEATS: |
| trail += f"\n\n**{beat}**" |
| outs.chat = _chat_from_state(st, trailing=trail) |
| yield outs.tuple(); time.sleep(1.5) |
|
|
| |
| truths = [k for k in "ABC" if k != lie] |
|
|
| def panel(struck, accused=None, monologue=None, stamp=None, dim=True): |
| rows = [f'<p class="scene">{char["rising"].strip("*")}</p>', |
| "<p>Three claims walked in here with you tonight.</p>"] |
| for k in "ABC": |
| cls = "card struck" if k in struck else ("card accused" if k == accused else "card") |
| rows.append(f'<div class="{cls}"><b>{k}</b>{html_lib.escape(st["claims"][k])}</div>') |
| if monologue: |
| rows.append(f'<p class="mono-log">{html_lib.escape(monologue)}</p>') |
| if stamp: |
| rows.append(f'<div class="stamp-conf">{stamp}</div>') |
| cls = "vp dimmed" if dim else "vp" |
| return f'<div class="{cls}">' + "\n".join(rows) + "</div>" |
|
|
| outs.verdict_group = gr.update(visible=True) |
| outs.verdict = gr.update(value=panel([])) |
| yield outs.tuple(); time.sleep(1.5) |
| outs.verdict = gr.update(value=panel([truths[0]])) |
| yield outs.tuple(); time.sleep(1.5) |
| outs.verdict = gr.update(value=panel(truths)) |
| yield outs.tuple(); time.sleep(2.5) |
| outs.verdict = gr.update(value=panel(truths, accused=lie)) |
| yield outs.tuple(); time.sleep(1.5) |
| mono = (f"Which leaves us alone in this room… with {_claim_snippet(st['claims'][lie])}. " |
| + st["accusation"]["why"]) |
| shown_mono = "" |
| for cut in range(24, len(mono) + 28, 28): |
| shown_mono = mono[:cut] |
| outs.verdict = gr.update(value=panel(truths, accused=lie, monologue=shown_mono + "▌")) |
| yield outs.tuple() |
| time.sleep(0.12) |
| outs.verdict = gr.update(value=panel(truths, accused=lie, monologue=mono)) |
| yield outs.tuple(); time.sleep(1.0) |
| stamp_txt = f"THE LIE: {lie} · {pct}% · {st['accusation']['tier']}" |
| |
| outs.verdict = gr.update(value=panel(truths, accused=lie, monologue=mono, |
| stamp=stamp_txt, dim=False)) |
| outs.flip = gr.update(visible=True) |
| yield outs.tuple() |
|
|
|
|
| class _TurnOuts: |
| """Constant-shape output tuple for detective_turn.""" |
| def __init__(self, st, chat): |
| self.st = st |
| self.chat = chat |
| self.stamp = gr.update() |
| self.verdict = gr.update() |
| self.verdict_group = gr.update() |
| self.answer = gr.update() |
| self.demand = gr.update() |
| self.flip = gr.update() |
|
|
| def tuple(self): |
| return (self.st, self.chat, self.stamp, self.verdict, self.verdict_group, |
| self.answer, self.demand, self.flip) |
|
|
|
|
| |
| |
| |
| |
|
|
| def _replay_url(st): |
| acc = st["accusation"] or {} |
| data = { |
| "v": 1, |
| "det": CHARACTERS[st["model_key"]]["name"], |
| "case": st["session"]["case_no"], |
| "claims": st["claims"], |
| "lie": st["lie_label"], |
| "qa": [{"q": e["q"], "a": e.get("a") or "", "r": e.get("remark", ""), |
| "w": e.get("waits", [])} |
| for e in st["qa"]], |
| "acc": {"lie": acc.get("lie"), "why": acc.get("why", ""), |
| "pct": acc.get("pct"), "tier": acc.get("tier", ""), |
| "w": acc.get("waits", [])}, |
| "win": st["last_result"] == "win", |
| } |
| raw = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode() |
| packed = base64.urlsafe_b64encode(zlib.compress(raw, 9)).decode().rstrip("=") |
| return f"{SPACE_URL_DIRECT}/replay#d={packed}" |
|
|
|
|
| def _share_row_html(replay_url, won, det_name, n_q): |
| verb = "walked out of" if won else "got caught in" |
| text = (f"I {verb} a {n_q}-question interrogation with {det_name} at the " |
| f"Gumleaf Precinct. Watch the tape:") |
| qu = urllib.parse.quote(replay_url, safe="") |
| qt = urllib.parse.quote(text) |
| links = [ |
| ("X / TWITTER", f"https://twitter.com/intent/tweet?text={qt}&url={qu}"), |
| ("FACEBOOK", f"https://www.facebook.com/sharer/sharer.php?u={qu}"), |
| ("LINKEDIN", f"https://www.linkedin.com/sharing/share-offsite/?url={qu}"), |
| ] |
| keys = "".join( |
| f'<a class="share-key" href="{html_lib.escape(u)}" target="_blank" ' |
| f'rel="noopener">{name}</a>' for name, u in links) |
| return (f'<div id="share-row"><div class="sr-head">The tape — step through the ' |
| f'whole interrogation</div>' |
| f'<a class="share-key primary" href="{html_lib.escape(replay_url)}" ' |
| f'target="_blank" rel="noopener">WATCH THE REPLAY</a>{keys}</div>') |
|
|
|
|
| def _flagged_turn(st): |
| for h in st["hidden_analysis"]: |
| p = h["profile"] |
| if any(p.get(f"suspicion_{k}") == "high" for k in "ABC"): |
| return max(h["after_turn"], 1) |
| return st["accusation"]["turn"] if st["accusation"] else 1 |
|
|
|
|
| def flip_card(state): |
| st = state |
| if st.get("phase") != "verdict_staged" or not st["accusation"]: |
| return (st,) + (gr.update(),) * 8 |
| st["phase"] = "done" |
| acc = st["accusation"] |
| sess = st["session"] |
| char = CHARACTERS[st["model_key"]] |
| won = st["last_result"] == "win" |
| n_flag = _flagged_turn(st) |
|
|
| if won: |
| result = WIN_SCREEN.format(LIE=_claim_snippet(st["claims"][st["lie_label"]]), |
| ACCUSED=_claim_snippet(st["claims"][acc["lie"]]), |
| PCT=acc["pct"], |
| STREAK=sess["streak"], WIN_LINE=char["win_line"]) |
| if st["model_key"] == "pip": |
| result += ESCALATION_HOOK |
| verdict_share = "WRONG — the machine was fooled" |
| else: |
| nearly = NEARLY_LINE if acc["pct"] < 70 else "" |
| result = LOSE_SCREEN.format(LIE=_claim_snippet(st["claims"][st["lie_label"]]), |
| PCT=acc["pct"], TIER=acc["tier"], |
| NEARLY=nearly, |
| LOSE_LINE=char["lose_line"].format(N=n_flag)) |
| verdict_share = f"CAUGHT — {acc['pct']}% confidence" |
|
|
| |
| grid = "○" * max(acc["turn"] - 1, 0) + "●" |
| replay_url = _replay_url(st) |
| share = SHARE_TEMPLATE.format(CASE=sess["case_no"], DET=char["name"], GRID=grid, |
| N=acc["turn"], VERDICT=verdict_share, STREAK=sess["streak"]) |
| share += f"\nWatch the tape: {replay_url}" |
|
|
| last = st["hidden_analysis"][-1]["profile"] if st["hidden_analysis"] else {} |
| summary = last.get("suspect_state") or "Subject read as composed. Mostly." |
| wattle = WATTLE_NOTE.format(SUMMARY=summary.rstrip(". ") + ".", N=n_flag) |
|
|
| rematch_label = BTN_SUMMONS if (won and st["model_key"] == "pip") else BTN_REMATCH |
|
|
| return (st, |
| gr.update(value=result, visible=True), |
| gr.update(visible=True), |
| gr.update(value=share), |
| gr.update(value=build_record(st), |
| label=CASEFILE_HEADER.format(CASE=sess["case_no"])), |
| gr.update(value=wattle), |
| gr.update(value=SCOREBOARD.format(W=sess["wins"], L=sess["losses"])), |
| gr.update(value=rematch_label), |
| gr.update(value=_share_row_html(replay_url, won, char["name"], acc["turn"]))) |
|
|
|
|
| def _setup_updates(st, keep_claims): |
| ph = random.choice(PLACEHOLDER_SETS) |
| claims = st["claims"] if keep_claims else {"A": "", "B": "", "C": ""} |
| return (gr.update(value=claims["A"], placeholder=ph[0]), |
| gr.update(value=claims["B"], placeholder=ph[1]), |
| gr.update(value=claims["C"], placeholder=ph[2])) |
|
|
|
|
| def next_case(state): |
| st = state |
| a, b, c = _setup_updates(st, keep_claims=False) |
| return (st, a, b, c, gr.update(value=None), gr.update(), |
| gr.update(visible=True), gr.update(visible=False), |
| gr.update(visible=False), gr.update(visible=False), [], |
| gr.update(visible=False), gr.update(value=_docket_html(None))) |
|
|
|
|
| def rematch_other(state): |
| st = state |
| other = "marlowe" if st["model_key"] == "pip" else "pip" |
| a, b, c = _setup_updates(st, keep_claims=True) |
| return (st, a, b, c, gr.update(value=st["lie_label"]), |
| gr.update(value=CHARACTERS[other]["choice_label"]), |
| gr.update(visible=True), gr.update(visible=False), |
| gr.update(visible=False), gr.update(visible=False), [], |
| gr.update(visible=False), gr.update(value=_docket_html(None))) |
|
|
|
|
| |
| |
| |
|
|
| with gr.Blocks(analytics_enabled=False, title="Two Truths & a Lie · Gumleaf Precinct") as demo: |
| state = gr.State(new_state) |
|
|
| |
| with gr.Sidebar(position="right", width=290, open=False, elem_id="claims-dock"): |
| docket = gr.HTML(_docket_html(None)) |
|
|
| gr.HTML(f'<div id="rec-banner"><span class="dot">REC</span>' |
| f'<span class="what">{REC_BANNER.split("— ", 1)[1]}</span></div>') |
| lights_btn = gr.Button("LIGHTS", elem_id="lights-btn", size="sm") |
| gr.HTML('<div id="masthead">' |
| '<div class="over">🐨 Gumleaf Precinct · Interview Room 3</div>' |
| f'<h1>{html_lib.escape(TITLE)}</h1>' |
| f'<div class="tag">{SUBTITLE}. {TAGLINE}</div>' |
| '</div>') |
|
|
| with gr.Group(visible=True) as g_setup: |
| gr.Markdown(INTRO) |
| gr.Markdown(CLAIMS_PROMPT, elem_classes="brief") |
| ph = PLACEHOLDER_SETS[0] |
| claim_a = gr.Textbox(label="Claim A", placeholder=ph[0], max_lines=2) |
| claim_b = gr.Textbox(label="Claim B", placeholder=ph[1], max_lines=2) |
| claim_c = gr.Textbox(label="Claim C", placeholder=ph[2], max_lines=2) |
| lie_radio = gr.Radio(["A", "B", "C"], label=MARK_LIE_PROMPT, elem_classes="lie-seg") |
| gr.Markdown(SELECT_HEADER, elem_classes="sec-label") |
| det_radio = gr.Radio([CHARACTERS["pip"]["choice_label"], |
| CHARACTERS["marlowe"]["choice_label"]], |
| value=CHARACTERS["pip"]["choice_label"], label="", |
| show_label=False, elem_classes="det-cards") |
| consent = gr.Checkbox(label=CONSENT_LABEL) |
| start_btn = gr.Button(BTN_START, variant="primary") |
| scoreboard = gr.Markdown(SCOREBOARD.format(W=0, L=0), elem_id="scoreboard") |
|
|
| with gr.Group(visible=False) as g_play: |
| stamp = gr.HTML(_stamp_html(0)) |
| chatbot = gr.Chatbot(height="auto", min_height=90, max_height=560, |
| show_label=False) |
| with gr.Row(elem_id="answer-row"): |
| answer_box = gr.Textbox(label=ANSWER_LABEL, scale=8, max_lines=3) |
| answer_btn = gr.Button(BTN_ANSWER, variant="primary", scale=1) |
| demand_btn = gr.Button(BTN_DEMAND_LOCKED.format(K=DEMAND_UNLOCK_AT), |
| interactive=False, elem_id="demand-btn") |
|
|
| with gr.Group(visible=False) as g_verdict: |
| verdict_html = gr.HTML("", elem_id="verdict-panel") |
| flip_btn = gr.Button(BTN_FLIP, variant="primary", visible=False) |
|
|
| with gr.Group(visible=False) as g_after: |
| result_md = gr.Markdown("", elem_id="result-card") |
| share_html = gr.HTML("") |
| share_box = gr.Textbox(label="The case file — copy it, taunt someone", |
| buttons=["copy"], lines=7, interactive=False, |
| elem_id="share-box") |
| with gr.Accordion("Open the full transcript", open=False): |
| wattle_md = gr.Markdown("") |
| casefile_json = gr.JSON(label="Case file") |
| with gr.Row(elem_id="after-actions"): |
| next_btn = gr.Button(BTN_NEXT, variant="primary") |
| rematch_btn = gr.Button(BTN_REMATCH) |
|
|
| TURN_OUTS = [state, chatbot, stamp, verdict_html, g_verdict, answer_box, demand_btn, flip_btn] |
|
|
| start_btn.click( |
| start_case, |
| [claim_a, claim_b, claim_c, lie_radio, det_radio, consent, state], |
| [state, chatbot, g_setup, g_play, stamp, scoreboard, answer_box, demand_btn, |
| verdict_html, g_verdict, g_after, flip_btn, docket], |
| ).then(detective_turn, [state, chatbot], TURN_OUTS, |
| concurrency_limit=1, concurrency_id="gpu") |
|
|
| |
| |
| |
| for trigger in (answer_box.submit, answer_btn.click): |
| trigger(add_answer, [answer_box, state, chatbot], [answer_box, state, chatbot] |
| ).then(detective_turn, [state, chatbot], TURN_OUTS, |
| concurrency_limit=1, concurrency_id="gpu", |
| trigger_mode="always_last") |
|
|
| demand_btn.click(demand_verdict, [state], [state] |
| ).then(detective_turn, [state, chatbot], TURN_OUTS, |
| concurrency_limit=1, concurrency_id="gpu", |
| trigger_mode="always_last") |
|
|
| flip_btn.click(flip_card, [state], |
| [state, result_md, g_after, share_box, casefile_json, wattle_md, |
| scoreboard, rematch_btn, share_html]) |
|
|
| SETUP_OUTS = [state, claim_a, claim_b, claim_c, lie_radio, det_radio, |
| g_setup, g_play, g_verdict, g_after, chatbot, flip_btn, docket] |
| next_btn.click(next_case, [state], SETUP_OUTS) |
| rematch_btn.click(rematch_other, [state], SETUP_OUTS) |
|
|
| def fresh_placeholders(): |
| ph = random.choice(PLACEHOLDER_SETS) |
| return (gr.update(placeholder=ph[0]), gr.update(placeholder=ph[1]), |
| gr.update(placeholder=ph[2])) |
|
|
| |
| demo.load(fresh_placeholders, None, [claim_a, claim_b, claim_c], |
| js="() => { try { const t = localStorage.getItem('gumleaf-lights');" |
| " if (t === 'dark') document.body.classList.add('dark');" |
| " if (t === 'light') document.body.classList.remove('dark'); }" |
| " catch (e) {} }") |
|
|
| |
| lights_btn.click(None, None, None, |
| js="() => { const dark = document.body.classList.toggle('dark');" |
| " try { localStorage.setItem('gumleaf-lights'," |
| " dark ? 'dark' : 'light'); } catch (e) {} }") |
|
|
| if __name__ == "__main__": |
| |
| |
| fastapi_app, _, _ = demo.queue(max_size=40).launch( |
| css=CSS, theme=gr.themes.Monochrome(), ssr_mode=False, |
| prevent_thread_lock=True) |
|
|
| from fastapi.responses import HTMLResponse |
|
|
| @fastapi_app.get("/replay") |
| def replay_page(): |
| """The tape player — the whole game is decoded from the URL fragment.""" |
| return HTMLResponse(REPLAY_HTML) |
|
|
| threading.Event().wait() |
|
|