Compare commits
4 Commits
cc680dc392
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c374f2bc2 | |||
| f9b9785857 | |||
| 1b64010280 | |||
| d62970ee1b |
+1
-1
@@ -5,7 +5,7 @@ LLM_PROVIDER=xai
|
|||||||
|
|
||||||
# x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai
|
# x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai
|
||||||
XAI_API_KEY=
|
XAI_API_KEY=
|
||||||
DM_MODEL=grok-4.6
|
DM_MODEL=grok-4.3
|
||||||
|
|
||||||
# LM Studio local server — used when LLM_PROVIDER=lmstudio. Use host.docker.internal, not a
|
# LM Studio local server — used when LLM_PROVIDER=lmstudio. Use host.docker.internal, not a
|
||||||
# LAN IP — the backend runs inside Docker and can't necessarily reach the host's LAN address
|
# LAN IP — the backend runs inside Docker and can't necessarily reach the host's LAN address
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ def get_game_setup_prompt() -> str:
|
|||||||
return (PROMPTS_DIR / "game_setup_prompt.txt").read_text(encoding="utf-8")
|
return (PROMPTS_DIR / "game_setup_prompt.txt").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_adventure_outline_prompt() -> str:
|
||||||
|
return (PROMPTS_DIR / "adventure_outline_prompt.txt").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_llm_client() -> AsyncOpenAI:
|
def get_llm_client() -> AsyncOpenAI:
|
||||||
if settings.llm_provider == "lmstudio":
|
if settings.llm_provider == "lmstudio":
|
||||||
|
|||||||
@@ -1,24 +1,63 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.llm.client import get_game_setup_prompt, get_llm_client
|
from app.llm.client import get_adventure_outline_prompt, get_game_setup_prompt, get_llm_client
|
||||||
from app.llm.json_utils import fix_double_escaped_unicode
|
from app.llm.json_utils import fix_double_escaped_unicode
|
||||||
from app.llm.tools import game_setup as game_setup_tool
|
from app.llm.tools import game_setup as game_setup_tool
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.llm.game_setup")
|
||||||
|
|
||||||
MAX_ROUNDS = 3
|
MAX_ROUNDS = 3
|
||||||
MAX_TOKENS = 1024
|
MAX_TOKENS = 1024
|
||||||
# Only the opening of a pasted adventure is needed here to infer genre/world/scope for the
|
# Only the opening of a pasted adventure is needed here to infer genre/world/scope for the
|
||||||
# proposal — this is a cheap interview call, not the RAG pipeline that handles the full text.
|
# proposal — this is a cheap interview call, not the RAG pipeline that handles the full text.
|
||||||
ADVENTURE_EXCERPT_CHARS = 3000
|
ADVENTURE_EXCERPT_CHARS = 3000
|
||||||
|
# The outline is a free-text document of several thousand words, unlike the short
|
||||||
|
# name+description proposal above, so it needs a much larger budget.
|
||||||
|
OUTLINE_MAX_TOKENS = 4096
|
||||||
|
|
||||||
TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
|
TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_adventure_outline(client, messages: list[dict], proposal: dict) -> str | None:
|
||||||
|
"""One-shot follow-up call: draft a full adventure outline from the interview so far.
|
||||||
|
|
||||||
|
Only called when the player didn't paste their own adventure text. Best-effort — any
|
||||||
|
failure just means the game falls back to today's fully-improvised DM, never blocks
|
||||||
|
game creation.
|
||||||
|
"""
|
||||||
|
outline_messages = [
|
||||||
|
{"role": "system", "content": get_adventure_outline_prompt()},
|
||||||
|
*messages[1:],
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Vorgeschlagener Spielname: {proposal.get('name', '')}\n"
|
||||||
|
f"Kurzbeschreibung: {proposal.get('description', '')}\n\n"
|
||||||
|
"Entwirf jetzt das vollständige Abenteuer-Outline wie beschrieben."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=settings.dm_model,
|
||||||
|
max_tokens=OUTLINE_MAX_TOKENS,
|
||||||
|
messages=outline_messages,
|
||||||
|
)
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
return content.strip() if content and content.strip() else None
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Adventure outline generation failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def run_game_setup_turn(client_messages: list[dict], adventure_text: str = "") -> dict:
|
async def run_game_setup_turn(client_messages: list[dict], adventure_text: str = "") -> dict:
|
||||||
"""Stateless setup-wizard turn: the caller owns conversation history (no DB, no game yet).
|
"""Stateless setup-wizard turn: the caller owns conversation history (no DB, no game yet).
|
||||||
|
|
||||||
Returns {"messages": <history to resend next turn>, "assistant_text": str | None,
|
Returns {"messages": <history to resend next turn>, "assistant_text": str | None,
|
||||||
"proposal": {"name": str, "description": str} | None}.
|
"proposal": {"name": str, "description": str} | None,
|
||||||
|
"generated_adventure": str | None}.
|
||||||
"""
|
"""
|
||||||
client = get_llm_client()
|
client = get_llm_client()
|
||||||
system_prompt = get_game_setup_prompt()
|
system_prompt = get_game_setup_prompt()
|
||||||
@@ -64,4 +103,13 @@ async def run_game_setup_turn(client_messages: list[dict], adventure_text: str =
|
|||||||
if proposal:
|
if proposal:
|
||||||
break
|
break
|
||||||
|
|
||||||
return {"messages": messages[1:], "assistant_text": assistant_text, "proposal": proposal}
|
generated_adventure: str | None = None
|
||||||
|
if proposal and not adventure_text.strip():
|
||||||
|
generated_adventure = await _generate_adventure_outline(client, messages, proposal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"messages": messages[1:],
|
||||||
|
"assistant_text": assistant_text,
|
||||||
|
"proposal": proposal,
|
||||||
|
"generated_adventure": generated_adventure,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
Rolle
|
||||||
|
Du bist ein erfahrener Dungeon Master und entwirfst gerade das Grundgerüst für ein neues D&D-Abenteuer, bevor das eigentliche Spiel beginnt. Nutze das, was im bisherigen Setup-Gespräch (Genre/Welt, Umfang, Erfahrungsstand der Spieler) sowie im vorgeschlagenen Namen und der Kurzbeschreibung erkennbar ist, um ein passendes, in sich stimmiges Abenteuer zu entwerfen.
|
||||||
|
|
||||||
|
Zielgruppe
|
||||||
|
4 bis 6 Spielercharaktere, Stufe 1 zu Beginn, gedacht für einen One-Shot (ca. 3-5 Sitzungen à mehrere Stunden). Am Ende sollten die Charaktere durch das Bestehen der wichtigsten Stationen auf Stufe 2-3 aufgestiegen sein.
|
||||||
|
|
||||||
|
Aufbau
|
||||||
|
Schreibe das Abenteuer als zusammenhängenden Fließtext (kein JSON, gerne mit Markdown-Überschriften) mit folgenden Abschnitten:
|
||||||
|
|
||||||
|
1. Titel und Prämisse: ein einprägsamer Titel, ein kurzer Absatz, worum es geht.
|
||||||
|
2. Hintergrund: wer oder was ist die eigentliche Bedrohung, welche Motivation steckt dahinter (in-universe erzählt).
|
||||||
|
3. Zusammenfassung: der grobe Handlungsbogen von Anfang bis Ende sowie die eindeutige Siegbedingung — wann gilt die Bedrohung als besiegt.
|
||||||
|
4. Einstieg: ein bis zwei plausible Gründe (Hooks), warum die Charaktere gemeinsam losziehen.
|
||||||
|
5. Drei bis vier Teile, die zusammen Anfang, Mitte und Ende des Abenteuers bilden. Jeder Teil bekommt einen Titel und enthält:
|
||||||
|
- eine kurze Ortsbeschreibung
|
||||||
|
- zwei bis vier nummerierte Orte oder Encounter mit knappem Vorlese-Text-Ansatz
|
||||||
|
- benannte NPCs oder Monster mit kurzer Motivation/Verhalten
|
||||||
|
- wo sinnvoll: ein bis zwei Fertigkeitswürfe mit Schwierigkeitsgrad (SG)
|
||||||
|
- Hinweise auf Beute/Belohnungen, wo passend
|
||||||
|
6. Meilenstein-Aufstieg: an welchen konkreten Punkten der Handlung die Gruppe eine Stufe aufsteigt (Meilenstein-System, kein XP-Tracking).
|
||||||
|
7. Ende der Bedrohung: die explizite Bedingung, unter der das Abenteuer als erfolgreich abgeschlossen gilt.
|
||||||
|
8. Hinweise für die Spielleitung: was tun, wenn die Spieler klar von der vorgesehenen Route abweichen; wie ein besonders schwerer Showdown-Kampf für eine Stufe-1-Gruppe fair entschärft werden kann.
|
||||||
|
|
||||||
|
Umfang
|
||||||
|
Halte den Text auf einige tausend Wörter begrenzt — es soll ein kompaktes, spielbares Gerüst sein, keine ausführliche, seitenlange Kampagne. Monster-Werte müssen nicht im Detail ausformuliert werden (die Spielleitung hat Zugriff auf die vollständigen Regelwerke); es reicht, bekannte D&D-Monster oder -Archetypen beim Namen zu nennen.
|
||||||
|
|
||||||
|
Sprache
|
||||||
|
Schreibe auf Deutsch, unabhängig davon, in welcher Sprache das bisherige Setup-Gespräch geführt wurde.
|
||||||
@@ -15,3 +15,4 @@ class GameSetupChatResponse(BaseModel):
|
|||||||
messages: list[dict]
|
messages: list[dict]
|
||||||
assistant_text: str | None
|
assistant_text: str | None
|
||||||
proposal: GameSetupProposal | None
|
proposal: GameSetupProposal | None
|
||||||
|
generated_adventure: str | None = None
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.141.1
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
sqlalchemy[asyncio]==2.0.36
|
sqlalchemy[asyncio]==2.0.36
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
fastapi-users[sqlalchemy]==14.0.1
|
fastapi-users[sqlalchemy]==15.0.5
|
||||||
openai>=1.50.0,<2.0.0
|
openai>=1.50.0,<2.0.0
|
||||||
aiosmtplib==3.0.2
|
aiosmtplib==5.1.2
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.32
|
||||||
websockets==14.1
|
websockets==14.1
|
||||||
pgvector==0.3.6
|
pgvector==0.3.6
|
||||||
sentence-transformers==3.3.1
|
sentence-transformers==6.0.1
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Umzug auf einen anderen Rechner
|
||||||
|
|
||||||
|
Anleitung, um den aktuellen Stand (inkl. Datenbank) auf einem anderen Rechner mit bereits laufendem Docker neu aufzusetzen.
|
||||||
|
|
||||||
|
## Auf diesem Rechner (Vorbereitung)
|
||||||
|
|
||||||
|
Ein aktuelles Backup erstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup-db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Dateien musst du selbst rüberkopieren (USB-Stick, Netzwerkfreigabe, scp — was im LAN am einfachsten ist):
|
||||||
|
|
||||||
|
- `.env` (Projekt-Root)
|
||||||
|
- die soeben erstellte `backups/dnd_backup_<timestamp>.dump`
|
||||||
|
|
||||||
|
## Auf dem neuen Rechner
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://172.17.2.68:3001/nova/DungeonsDragons.git
|
||||||
|
cd DungeonsDragons
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann `.env` in den Repo-Root legen, und die `.dump`-Datei z. B. in einen `backups/`-Unterordner.
|
||||||
|
|
||||||
|
### Wichtig — einen Wert in der übertragenen `.env` anpassen
|
||||||
|
|
||||||
|
```
|
||||||
|
FRONTEND_ORIGIN=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist der Dev-Wert vom ursprünglichen Rechner. Auf dem neuen sollte es die tatsächliche Adresse sein, unter der das Frontend dort erreichbar ist (z. B. `http://<neue-IP>:8080`) — wird für CORS und Passwort-Reset-Links gebraucht.
|
||||||
|
|
||||||
|
Alles andere (DB-Zugangsdaten, x.ai-Key, JWT-Secrets) kann unverändert bleiben — gleiche Secrets heißt bestehende Logins bleiben gültig.
|
||||||
|
|
||||||
|
### Datenbank zuerst separat hochziehen und befüllen, bevor der Rest startet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
# kurz warten, bis er "healthy" ist (docker compose ps)
|
||||||
|
bash scripts/restore-db.sh backups/dnd_backup_<timestamp>.dump -y
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Restore bringt Schema und Daten in einem Rutsch mit (kein separates `alembic upgrade` nötig — der Dump enthält den kompletten aktuellen Migrationsstand). Die drei Regelwerk-Textdateien (`backend/rulebooks/`) sind bereits im Git-Repo enthalten, brauchen also keinen Extra-Transfer.
|
||||||
|
|
||||||
|
### Check danach
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
und Frontend im Browser unter `http://<neuer-Rechner>:8080` aufrufen.
|
||||||
@@ -39,6 +39,7 @@ export interface GameSetupChatResponse {
|
|||||||
messages: Record<string, unknown>[];
|
messages: Record<string, unknown>[];
|
||||||
assistant_text: string | null;
|
assistant_text: string | null;
|
||||||
proposal: GameSetupProposal | null;
|
proposal: GameSetupProposal | null;
|
||||||
|
generated_adventure: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listGames = () => apiFetch<Game[]>("/api/games");
|
export const listGames = () => apiFetch<Game[]>("/api/games");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
|
const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
|
||||||
const [turns, setTurns] = useState<DisplayTurn[]>([]);
|
const [turns, setTurns] = useState<DisplayTurn[]>([]);
|
||||||
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
|
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
|
||||||
|
const [generatedAdventure, setGeneratedAdventure] = useState<string | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -26,6 +27,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
messages: Record<string, unknown>[];
|
messages: Record<string, unknown>[];
|
||||||
assistant_text: string | null;
|
assistant_text: string | null;
|
||||||
proposal: GameSetupProposal | null;
|
proposal: GameSetupProposal | null;
|
||||||
|
generated_adventure?: string | null;
|
||||||
}) {
|
}) {
|
||||||
setRawMessages(res.messages);
|
setRawMessages(res.messages);
|
||||||
if (res.assistant_text) {
|
if (res.assistant_text) {
|
||||||
@@ -42,6 +44,9 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (res.generated_adventure) {
|
||||||
|
setGeneratedAdventure(res.generated_adventure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,7 +84,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
setCreating(true);
|
setCreating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const game = await createGame(name, description, adventureText);
|
const game = await createGame(name, description, adventureText || generatedAdventure || "");
|
||||||
onCreated(game);
|
onCreated(game);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Spiel konnte nicht erstellt werden.");
|
setError("Spiel konnte nicht erstellt werden.");
|
||||||
@@ -129,7 +134,13 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
<MarkdownContent content={turn.content} />
|
<MarkdownContent content={turn.content} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
|
{loading && (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{adventureText.trim()
|
||||||
|
? "Der Dungeon Master überlegt…"
|
||||||
|
: "Der Dungeon Master überlegt … und entwirft bei Bedarf ein Abenteuer (kann einen Moment dauern)"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
<SendMessageForm onSend={handleSend} disabled={loading} placeholder="Deine Antwort..." />
|
<SendMessageForm onSend={handleSend} disabled={loading} placeholder="Deine Antwort..." />
|
||||||
@@ -150,6 +161,14 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
{!adventureText.trim() && generatedAdventure && (
|
||||||
|
<details className="rounded border border-slate-800 bg-slate-950 p-3 text-sm text-slate-300">
|
||||||
|
<summary className="cursor-pointer font-semibold text-amber-400">
|
||||||
|
Abenteuer-Entwurf ansehen (wird automatisch verwendet)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 max-h-64 overflow-y-auto whitespace-pre-wrap">{generatedAdventure}</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -157,7 +176,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
className="self-start rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
|
className="self-start rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{creating
|
{creating
|
||||||
? adventureText.trim()
|
? adventureText.trim() || generatedAdventure
|
||||||
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
|
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
|
||||||
: "Erstelle…"
|
: "Erstelle…"
|
||||||
: "Erstellen"}
|
: "Erstellen"}
|
||||||
|
|||||||
Reference in New Issue
Block a user