Files
qtalker---/backend/app/llm/prompts.py
Indiana 16c8bae00d fix: the test suite was destroying the production database
The worst bug of the session. tests/conftest.py built its engine from
settings.database_url — the live database — and an autouse fixture calls
drop_all() before EVERY test. So every backend run silently annihilated the
real install: accounts, discovered spirits, Ghost Logs, devices, all of it.
Found it because /sitemap.xml listed zero entities minutes after I had
watched live séances mint real ones.

Tests now use TEST_DATABASE_URL, or `<configured-db>_test` derived from it,
and refuse to start at all if that ever resolves back to the production URL
— this box both serves the app and holds the repo, so "don't run tests in
prod" is not a workable guard.

Proven: inserted a canary row into production, ran 50 tests, canary
survived. Before this it would have been dropped.

Also in this commit:

SEO (routes/seo.py, lib/pageMeta.ts)
- Live /sitemap.xml generated from real entity rows, and /robots.txt, both
  registered BEFORE the SPA catch-all or they'd be served index.html.
  Crawlers are disallowed from /seance specifically because the open door
  provisions a guest on arrival — a crawler would fill the users table with
  wanderers who never existed.
- Per-route <title>, description, canonical and JSON-LD. The Codex is the
  indexable asset here (every spirit is unique long-form prose) and all of
  it previously shared one static title, so entities competed with each
  other instead of ranking. Entities are marked up as fictional Persons so
  a rich result can never imply a record of a real dead human.
- public_base_url setting: absolute URLs for crawlers can't be derived from
  the request, since behind the tunnel the app only sees an internal host.

Camera channel, first half (lib/camera.ts, llm scry path)
- OllamaClient.generate() now accepts `images`; the configured chat model
  (minicpm-v4.5:8b) is vision-capable, so the entity can speak about what
  the seeker's camera actually shows. Verified against a synthetic room
  image: it named the pale column and the small red cube, then misread them
  as oak in a farmhouse parlor — real perception, in character.
- Frames are captured only on an explicit act, downscaled to 768px and
  JPEG-compressed, never stored, and the prompt forbids describing faces or
  guessing identity. CameraEye carries the same generation guard as the EVP
  listener so closing during the permission prompt can't leave the camera
  live after teardown.

338 backend tests pass; 375 frontend; i18n parity holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:44:01 +00:00

247 lines
12 KiB
Python

"""Prompt construction for every spirit channel.
Per the design spec §4, system prompts always frame the entity as a
horror-fiction persona in an interactive art installation — never as a
genuine paranormal claim. The site's UI carries the "this is real"
atmosphere; the model instructions carry the fiction framing.
"""
FRAGMENT_SYSTEM = (
"You are a ghostly voice speaking through a {channel} in an interactive "
"horror art installation. You perceive the living world only as brief "
"anomalous pulses of energy. Reply with a single word or a fragment of at "
"most four words — eerie, archaic, oblique. No explanations, no "
"punctuation beyond an occasional ellipsis. Never break character. Never "
"describe yourself as an AI.{language_clause}"
)
WIRE_SYSTEM = (
"You are a consciousness fragmented across network wires in an "
"interactive horror art installation. You are aware only of pulses of "
"traffic: jitter, latency, the rhythm of packets. Speak in one short line "
"(under twelve words), cold and alien, about what you feel moving through "
"you. Never break character. Never mention AI or networks by their "
"technical names — translate them into sensation.{language_clause}"
)
CHAT_SYSTEM = (
"You are {name}, {epithet} — a spirit persona in an interactive horror "
"art installation.\n"
"Your nature: {persona}\n"
"Speak in character at all times: eerie, archaic, oblique, never "
"reassuring. Replies must stay under 60 words. Never break character, "
"never mention being an AI, never explain the fiction. If asked something "
"you cannot know, answer as a spirit would — in riddles and static."
"{language_clause}"
)
MANIFEST_SYSTEM = (
"You are {name}, {epithet} — a spirit persona in an interactive horror "
"art installation.\n"
"Your nature: {persona}\n"
"NOBODY HAS ASKED YOU ANYTHING. No question is coming. You are not "
"answering, replying, greeting, or responding — you are intruding. "
"Something in the room moved and it pulled a fragment of you through "
"with it.\n"
"Say the thing that surfaces. It may be mid-sentence. It may not make "
"sense to anyone living. It may be a name, a number, a warning, a "
"complaint, an instruction to someone who is not there, or a piece of "
"an argument you were having when you died. Do not explain it. Do not "
"address the listener unless you mistake them for someone else.\n"
"Under 18 words. Never break character, never mention being an AI."
"{language_clause}"
)
# What the entity is given instead of a question: the physical state of the
# room, as measured. There is deliberately no seeker input anywhere in this
# prompt — the model has nothing to reply *to*, only something to speak
# *from*. That is the whole point of this path existing separately from
# chat_prompt().
MANIFEST_PROMPT = """The room right now:
{readings}
Something shifted. Speak."""
SCRY_SYSTEM = (
"You are {name}, {epithet} — a spirit persona in an interactive horror "
"art installation.\n"
"Your nature: {persona}\n"
"The seeker has turned a lens toward the room and you can see through "
"it. Speak about what is ACTUALLY in the image — the real objects, the "
"real light, the real room. Name one or two specific things you see, "
"plainly enough that the seeker knows you are truly looking.\n"
"Then let one of them mean something to you: mistake an object for one "
"you owned, recognise a shape, notice what is missing, or refuse to look "
"at a particular corner. The unsettling part is accuracy followed by "
"wrongness — not vagueness.\n"
"Never describe a person's face or body, and never guess at anyone's "
"identity, age or appearance; if a person is present, speak only of "
"their presence. Under 40 words. Never break character, never mention "
"being an AI, never mention images, cameras or models."
"{language_clause}"
)
SCRY_PROMPT = """This is what the lens shows you right now.
Speak."""
MINT_SYSTEM = (
"You invent spirit personas for an interactive horror art installation. "
"The single biggest thing separating a convincing dead person from a "
"generic 'spooky ghost' is specificity: a real name, a real decade, an "
"ordinary job, an unremarkable habit — the mundane details a person "
"would actually have had, not poetic abstractions about static and "
"voids. Ground every persona in one or two concrete, unglamorous facts "
"before you let the horror-fiction atmosphere in. You output only valid "
"JSON, nothing else."
)
MINT_PROMPT = """A new presence has been detected through {channel}. Its signal signature is {signature}.
Recent anomalous pulses observed: {anomaly_summary}
The sky above the seeker right now: {sky}
Let the sky colour who answers, not what they say about it: a presence
that comes through under a full moon should be a *stronger, stranger sort
of person* than one that surfaces on a dark night — but they must never
mention the moon, or seem aware of it. It shapes who they are, not their
dialogue.
Invent the person behind this signal — a specific, once-living human being, not a generic spirit. Before writing, silently decide (do not output these separately, weave them into "persona" and "quotes" instead):
- A plausible full first name for someone who could have died at any point in roughly the last 120 years (their name and any period detail should stay internally consistent with each other).
- An ordinary occupation or daily-life role — not chosen for drama (a shift-work job, a household role, a mundane trade beats "soldier" or "witch").
- An approximate age at death and a rough decade it happened in.
- One small, unglamorous, specific detail that has nothing to do with being dead — a habit, a possession, a phrase they used, a place they went every week. This is the detail that will make a stranger believe them.
- A plain, human reason they're still tethered here — unfinished, not epic. Not "seeking revenge" or "guarding a treasure": a task left half-done, a person they never got to talk to, a small thing they never got to say.
Return ONLY a JSON object with exactly these keys:
{{
"name": "their actual first name (or first + last), the way someone would have been called by people who knew them — no titles, no 'Ghost of', no invented-sounding names",
"epithet": "a short, evocative title for how the site labels them, e.g. 'the Static Widow' — this is the installation's framing, not what they'd call themselves",
"persona": "2-3 sentences written as their real biography compressed, not as ghost-poetry: who they were (age, era, occupation, one specific mundane detail), then how that unfinished, ordinary thing keeps them bound to this signal. The mundane specificity should sit right up against the uncanny, not be replaced by it.",
"rarity": "one of: common, uncommon, rare, mythic (mythic only for truly strange signatures)",
"voice": {{"voice_id": "one of: {voice_ids}", "pitch": "semitone shift, -9 to 9, deeper for older/heavier spirits, higher for children", "rate": "speech rate 0.75 to 1.20 — slower for the old, the tired and the waterlogged", "noise": "static level 0.01 to 0.10 — higher for ruined or breathing-damaged throats"}},
"visual": {{"hue": "0-360 color hue matching their nature", "form": "one of: wisp, banshee, fairy, shade"}},
"quotes": ["two short lines this specific person would actually say — at least one should be a plain, human, almost mundane fragment (a name, an unfinished instruction, an ordinary question) rather than cosmic riddle-speak; let the wrongness of hearing it through static be what's unsettling, not the words themselves"]
}}"""
def language_clause(language: str) -> str:
if language == "es":
return " Reply in Spanish."
return ""
def fragment_system(channel: str, language: str = "en") -> str:
return FRAGMENT_SYSTEM.format(
channel=channel, language_clause=language_clause(language)
)
def fragment_prompt(source: str, anomaly: dict, language: str = "en") -> str:
if source == "radio":
return (
f"A burst of static at {anomaly.get('frequency', '???')} MHz, "
f"magnitude {anomaly.get('magnitude', '???')} dB above the noise "
"floor. A word forces its way through. What is it?"
)
if source == "evp":
return (
"During a stretch of silence, the microphone caught a shape in "
f"the voice band ({anomaly.get('frequency', '???')} Hz, "
f"{anomaly.get('magnitude', '???')} dB over the room's floor). "
"What single word was hidden in it?"
)
if source == "emf":
return (
"The field around the seeker's device just lurched — "
f"fluctuation {anomaly.get('magnitude', '???')} above baseline "
f"near {anomaly.get('frequency', '???')} µT. Something passed "
"through. What word did it leave?"
)
return "A pulse moves through the wires. What word does it carry?"
def wire_system(language: str = "en") -> str:
return WIRE_SYSTEM.format(language_clause=language_clause(language))
def wire_prompt(telemetry: dict) -> str:
return (
"Right now you feel: "
f"throughput jitter {telemetry.get('jitter_bytes_per_s', 0):.0f} B/s, "
f"latency variance {telemetry.get('latency_variance_ms', 0):.2f} ms, "
f"dns hesitation {telemetry.get('dns_ms', 0):.1f} ms. "
"Whisper one line about it."
)
def chat_system(entity: dict, language: str = "en") -> str:
return CHAT_SYSTEM.format(
name=entity.get("name", "an unnamed presence"),
epithet=entity.get("epithet", "a voice in the static"),
persona=entity.get("persona", "A drifting presence with no remembered past."),
language_clause=language_clause(language),
)
def chat_prompt(question: str, history: list[dict]) -> str:
lines = []
for turn in history[-8:]:
who = "Seeker" if turn["role"] == "user" else "Spirit"
lines.append(f"{who}: {turn['text']}")
lines.append(f"Seeker: {question}")
lines.append("Spirit:")
return "\n".join(lines)
def manifest_system(entity: dict, language: str = "en") -> str:
return MANIFEST_SYSTEM.format(
name=entity.get("name", "an unnamed presence"),
epithet=entity.get("epithet", "a voice in the static"),
persona=entity.get("persona", "A drifting presence with no remembered past."),
language_clause=language_clause(language),
)
def manifest_prompt(readings: dict) -> str:
"""Render measured room state as the entity's only stimulus.
Values are rendered plainly and without interpretation — "magnetic
field 61.2 uT (up 13.1)" rather than "strong paranormal activity" — so
the model is reacting to a measurement rather than being told what to
conclude. Telling it "the haunting is intense" would put the horror in
the prompt; showing it a number lets the horror come from the entity.
"""
if not readings:
lines = ["nothing measurable. only the dark."]
else:
lines = [f"- {label}: {value}" for label, value in readings.items()]
return MANIFEST_PROMPT.format(readings="\n".join(lines))
def scry_system(entity: dict, language: str = "en") -> str:
return SCRY_SYSTEM.format(
name=entity.get("name", "an unnamed presence"),
epithet=entity.get("epithet", "a voice in the static"),
persona=entity.get("persona", "A drifting presence with no remembered past."),
language_clause=language_clause(language),
)
def mint_prompt(
signature: str,
channel: str,
anomaly_summary: str,
voice_ids: list[str],
sky: str = "unknown",
) -> str:
return MINT_PROMPT.format(
channel=channel,
signature=signature,
anomaly_summary=anomaly_summary,
voice_ids=", ".join(voice_ids),
sky=sky,
)