268 lines
8.3 KiB
Python
268 lines
8.3 KiB
Python
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.llm import LlmClient
|
|
from app.core.settings_service import cast_setting, get_all_settings
|
|
from app.logging_setup import get_logger
|
|
from app.models import Preset, User, World
|
|
from app.prompts.templates import get_prompt
|
|
from app.schemas import WorldBuilderReply, WorldDefinition
|
|
|
|
log = get_logger("world_builder")
|
|
|
|
|
|
# In-memory store of world-builder dialogues (session_id -> dialogue state).
|
|
# For production scale, move this to Redis. For MVP single-instance it's fine.
|
|
_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {}
|
|
|
|
|
|
async def start_world_builder(
|
|
db: AsyncSession,
|
|
user: User,
|
|
world_name: str,
|
|
language: str,
|
|
preset_id: Optional[uuid.UUID],
|
|
setting_brief: str,
|
|
character_brief: str,
|
|
rules_brief: str,
|
|
notes: str,
|
|
) -> WorldBuilderReply:
|
|
"""Kick off a new world-builder dialogue. Returns the first AI reply."""
|
|
session_id = uuid.uuid4()
|
|
llm = await LlmClient.from_db(db)
|
|
|
|
preset_payload: Optional[Dict[str, Any]] = None
|
|
if preset_id:
|
|
result = await db.execute(select(Preset).where(Preset.id == preset_id))
|
|
preset = result.scalars().first()
|
|
if preset:
|
|
preset_payload = preset.payload
|
|
|
|
user_brief = _build_user_brief(
|
|
world_name=world_name,
|
|
setting_brief=setting_brief,
|
|
character_brief=character_brief,
|
|
rules_brief=rules_brief,
|
|
notes=notes,
|
|
preset_payload=preset_payload,
|
|
language=language,
|
|
)
|
|
|
|
system_prompt = get_prompt("world_builder", language)
|
|
messages = [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_brief},
|
|
]
|
|
|
|
response = await llm.chat(
|
|
messages=messages,
|
|
temperature=0.7,
|
|
purpose="world_builder",
|
|
user_id=user.id,
|
|
db=db,
|
|
)
|
|
|
|
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
|
|
|
|
_DIALOGUES[session_id] = {
|
|
"user_id": user.id,
|
|
"world_name": world_name,
|
|
"language": language,
|
|
"preset_id": preset_id,
|
|
"messages": messages + [{"role": "assistant", "content": response.text}],
|
|
"turn": 1,
|
|
"last_proposed": proposed.model_dump() if proposed else None,
|
|
}
|
|
|
|
return WorldBuilderReply(
|
|
session_id=session_id,
|
|
turn=1,
|
|
ai_message=ai_text,
|
|
proposed_definition=proposed,
|
|
is_final=is_final,
|
|
followup_questions=followups,
|
|
)
|
|
|
|
|
|
async def continue_world_builder(
|
|
db: AsyncSession,
|
|
user: User,
|
|
session_id: uuid.UUID,
|
|
user_message: str,
|
|
) -> WorldBuilderReply:
|
|
"""Continue an existing world-builder dialogue."""
|
|
dialogue = _DIALOGUES.get(session_id)
|
|
if not dialogue:
|
|
raise ValueError("dialogue_not_found")
|
|
if dialogue["user_id"] != user.id:
|
|
raise ValueError("forbidden")
|
|
|
|
llm = await LlmClient.from_db(db)
|
|
dialogue["messages"].append({"role": "user", "content": user_message})
|
|
dialogue["turn"] += 1
|
|
|
|
response = await llm.chat(
|
|
messages=dialogue["messages"],
|
|
temperature=0.7,
|
|
purpose="world_builder",
|
|
user_id=user.id,
|
|
db=db,
|
|
)
|
|
dialogue["messages"].append({"role": "assistant", "content": response.text})
|
|
|
|
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
|
|
if proposed:
|
|
dialogue["last_proposed"] = proposed.model_dump()
|
|
|
|
return WorldBuilderReply(
|
|
session_id=session_id,
|
|
turn=dialogue["turn"],
|
|
ai_message=ai_text,
|
|
proposed_definition=proposed,
|
|
is_final=is_final,
|
|
followup_questions=followups,
|
|
)
|
|
|
|
|
|
async def commit_world_builder(
|
|
db: AsyncSession,
|
|
user: User,
|
|
session_id: uuid.UUID,
|
|
name: Optional[str] = None,
|
|
) -> World:
|
|
"""Commit the proposed world definition into a real World row."""
|
|
dialogue = _DIALOGUES.get(session_id)
|
|
if not dialogue:
|
|
raise ValueError("dialogue_not_found")
|
|
if dialogue["user_id"] != user.id:
|
|
raise ValueError("forbidden")
|
|
proposed = dialogue.get("last_proposed")
|
|
if not proposed:
|
|
raise ValueError("no_proposed_definition")
|
|
|
|
definition = WorldDefinition.model_validate(proposed)
|
|
world = World(
|
|
owner_id=user.id,
|
|
name=name or dialogue.get("world_name") or "New World",
|
|
language=dialogue.get("language", "ru"),
|
|
definition=definition.model_dump(),
|
|
state=definition.initial_state or {},
|
|
current_time=definition.initial_time,
|
|
status="ready",
|
|
preset_id=dialogue.get("preset_id"),
|
|
)
|
|
db.add(world)
|
|
await db.commit()
|
|
await db.refresh(world)
|
|
|
|
# Clean up dialogue
|
|
_DIALOGUES.pop(session_id, None)
|
|
return world
|
|
|
|
|
|
def _build_user_brief(
|
|
world_name: str,
|
|
setting_brief: str,
|
|
character_brief: str,
|
|
rules_brief: str,
|
|
notes: str,
|
|
preset_payload: Optional[Dict[str, Any]],
|
|
language: str,
|
|
) -> str:
|
|
parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
|
|
parts.append(f"Name: {world_name}")
|
|
if preset_payload:
|
|
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
|
|
parts.append(f"Suggested rules: {json.dumps(preset_payload.get('rules', {}), ensure_ascii=False)[:400]}")
|
|
if setting_brief:
|
|
parts.append(f"Setting: {setting_brief}")
|
|
if character_brief:
|
|
parts.append(f"Character: {character_brief}")
|
|
if rules_brief:
|
|
parts.append(f"Rules: {rules_brief}")
|
|
if notes:
|
|
parts.append(f"Notes: {notes}")
|
|
parts.append("\nPlease ask 2-4 clarifying questions OR build a proposed world definition.")
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]:
|
|
"""Extract AI message text, proposed definition (if any), is_final flag, and followup questions."""
|
|
proposed = None
|
|
is_final = False
|
|
followups: List[str] = []
|
|
|
|
# Try to find a JSON block in the response
|
|
json_str = _extract_json_block(text)
|
|
if json_str:
|
|
try:
|
|
data = json.loads(json_str)
|
|
if isinstance(data, dict):
|
|
if "proposed_definition" in data:
|
|
pd = data["proposed_definition"]
|
|
if isinstance(pd, dict):
|
|
try:
|
|
proposed = WorldDefinition.model_validate(pd)
|
|
except Exception:
|
|
proposed = None
|
|
if "is_final" in data:
|
|
is_final = bool(data["is_final"])
|
|
if "followup_questions" in data and isinstance(data["followup_questions"], list):
|
|
followups = [str(q) for q in data["followup_questions"]]
|
|
if "ai_message" in data and isinstance(data["ai_message"], str):
|
|
text = data["ai_message"]
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final
|
|
if proposed is not None:
|
|
low = text.lower()
|
|
if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]):
|
|
is_final = True
|
|
|
|
return text, proposed, is_final, followups
|
|
|
|
|
|
def _extract_json_block(text: str) -> Optional[str]:
|
|
"""Find the first JSON object/array block in text."""
|
|
if not text:
|
|
return None
|
|
# Try fenced ```json ... ```
|
|
import re
|
|
m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
|
|
if m:
|
|
return m.group(1)
|
|
# Try raw {...} (greedy from first { to matching })
|
|
start = text.find("{")
|
|
if start == -1:
|
|
return None
|
|
depth = 0
|
|
in_str = False
|
|
esc = False
|
|
for i in range(start, len(text)):
|
|
c = text[i]
|
|
if in_str:
|
|
if esc:
|
|
esc = False
|
|
elif c == "\\":
|
|
esc = True
|
|
elif c == '"':
|
|
in_str = False
|
|
else:
|
|
if c == '"':
|
|
in_str = True
|
|
elif c == "{":
|
|
depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return text[start:i + 1]
|
|
return None
|