This commit is contained in:
Mikan
2026-06-19 19:14:27 +03:00
parent 2167493887
commit 32575e217e
23 changed files with 1191 additions and 92 deletions

View File

@@ -72,21 +72,39 @@ async def run_iteration(
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
# Save the player's action as a message
next_seq = await _next_seq(db, session_id)
player_msg = Message(
session_id=session_id,
seq=next_seq,
role="user",
kind="player_action",
content=action_text,
payload={},
is_pinned=True,
hidden=False,
# Save the player's action as a message — UNLESS this is a retry of the
# previous action (frontend re-sent the same action_text after an error).
# In that case we reuse the existing player_action row so the chat
# history doesn't fill up with duplicates.
last_msg_result = await db.execute(
select(Message)
.where(Message.session_id == session_id)
.order_by(Message.seq.desc())
.limit(1)
)
db.add(player_msg)
await db.commit()
await db.refresh(player_msg)
last_msg = last_msg_result.scalars().first()
is_retry = (
last_msg is not None
and last_msg.kind == "player_action"
and last_msg.content == action_text
)
if is_retry:
player_msg = last_msg
else:
next_seq = await _next_seq(db, session_id)
player_msg = Message(
session_id=session_id,
seq=next_seq,
role="user",
kind="player_action",
content=action_text,
payload={},
is_pinned=True,
hidden=False,
)
db.add(player_msg)
await db.commit()
await db.refresh(player_msg)
yield {"type": "status", "data": {"message": "planning"}}
@@ -476,3 +494,122 @@ def _safe_parse_json(s: str) -> Any:
return json.loads(s) if s else {}
except Exception:
return s
async def generate_intro_scene(
db: AsyncSession,
user_id: uuid.UUID,
session_id: uuid.UUID,
) -> AsyncIterator[Dict[str, Any]]:
"""Generate the opening cinematic scene for a freshly-created session.
Yields the same SSE event stream shape as `run_iteration` so the
frontend can consume it identically. Saves a `narrative_step` message
of kind `intro_scene` (still kind=narrative_step for compatibility,
but with payload.kind=intro so the UI can style it differently if
desired).
"""
result = await db.execute(select(Session).where(Session.id == session_id))
session = result.scalars().first()
if not session:
yield {"type": "error", "data": {"message": "session_not_found"}}
return
result = await db.execute(select(World).where(World.id == session.world_id))
world = result.scalars().first()
if not world:
yield {"type": "error", "data": {"message": "world_not_found"}}
return
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
yield {"type": "status", "data": {"message": "writing_scene"}}
import json as _json
defn = world.definition or {}
player_state = world.state.get("player", {}) if world.state else {}
system_prompt = get_prompt("intro_scene", world.language).format(
setting_description=defn.get("setting_description", "")[:1200],
current_time=world.current_time or "",
player_state=_json.dumps(player_state, ensure_ascii=False)[:800],
plot_rails=_json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:600],
world_language=world.language or "en",
)
step_resp = await llm.chat(
messages=[{"role": "system", "content": system_prompt}],
tools=STEP_WRITER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
max_tokens=1500,
purpose="intro_scene",
user_id=user_id,
session_id=session_id,
db=db,
)
step_text = step_resp.text or ""
step_options: List[str] = []
for tc in (step_resp.tool_calls or []):
if tc.get("function", {}).get("name") == "submit_scene":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
scene_data = _json.loads(args_str) if args_str else {}
if scene_data.get("narrative"):
step_text = scene_data["narrative"]
if scene_data.get("options") and isinstance(scene_data["options"], list):
step_options = [str(o) for o in scene_data["options"]][:5]
except _json.JSONDecodeError:
log.warning("intro_scene_invalid_json", args=args_str[:200])
break
else:
# Fallback: extract JSON from text.
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "")
if json_match:
try:
step_data = _json.loads(json_match.group(0))
if "narrative" in step_data:
step_text = step_data["narrative"]
if "options" in step_data and isinstance(step_data["options"], list):
step_options = [str(o) for o in step_data["options"]][:5]
except _json.JSONDecodeError:
pass
# Save as a narrative_step message flagged as intro in payload.
step_seq = await _next_seq(db, session_id)
step_msg = Message(
session_id=session_id,
seq=step_seq,
role="assistant",
kind="narrative_step",
content=step_text,
payload={
"kind": "intro",
"options": step_options,
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
},
is_pinned=True,
hidden=False,
)
db.add(step_msg)
session.last_played_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(step_msg)
yield {
"type": "step_complete",
"data": {
"message_id": str(step_msg.id),
"seq": step_msg.seq,
"narrative": step_text,
"options": step_options,
"state": world.state,
"world_time": world.current_time,
"player_state": world.state.get("player", {}),
"fired_triggers": [],
"is_intro": True,
},
}
yield {"type": "done", "data": {}}

View File

@@ -0,0 +1,279 @@
"""AI-assisted editor for an EXISTING world.
The player chats with the AI; each turn the AI returns:
- a short player-facing message describing what it changed / will change,
- an updated `definition` (the full new WorldDefinition).
The caller (API endpoint) decides whether to persist the new definition
to the World row. The editor itself is stateless aside from the in-memory
dialogue cache (keyed by world_id), so the player can iterate.
"""
from __future__ import annotations
import json
import uuid
from typing import Any, Dict, List, Optional, Tuple
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 User, World
from app.prompts.templates import get_prompt
from app.schemas import WorldDefinition
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
log = get_logger("world_editor")
# In-memory dialogue cache: world_id -> list of messages.
_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {}
async def edit_world_via_chat(
db: AsyncSession,
user: User,
world: World,
message: str,
) -> Tuple[str, Optional[Dict[str, Any]], bool]:
"""Run one turn of AI-assisted world editing.
Returns (ai_message, new_definition_dict_or_None, changed).
- ai_message: short prose reply for the player (in world.language).
- new_definition_dict: the full updated definition if the AI proposed
changes this turn, else None.
- changed: True if new_definition_dict is not None and differs from
the current world.definition.
"""
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
# Get / init dialogue state for this world.
dialogue = _DIALOGUES.get(world.id)
if not dialogue:
system_prompt = _build_system_prompt(world)
dialogue = {
"user_id": user.id,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": _build_seed_message(world)},
],
}
_DIALOGUES[world.id] = dialogue
# Authorization: only the owner (or admin) may continue an existing dialogue.
if dialogue["user_id"] != user.id and not user.is_admin:
raise ValueError("forbidden")
dialogue["messages"].append({"role": "user", "content": message})
response = await llm.chat(
messages=dialogue["messages"],
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="world_editor",
user_id=user.id,
db=db,
)
ai_message, new_defn, _is_final = _extract_world_definition(
response.text, response.tool_calls,
)
# If the model did not return a tool call but did emit JSON in text,
# _extract_world_definition handles it. If still None, just return the
# conversational message without changes.
if new_defn is None:
dialogue["messages"].append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
})
return ai_message, None, False
new_defn_dict = new_defn.model_dump()
changed = new_defn_dict != (world.definition or {})
dialogue["messages"].append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
})
return ai_message, new_defn_dict, changed
def reset_editor_dialogue(world_id: uuid.UUID) -> None:
"""Drop the cached editor dialogue for a world (e.g. after manual save)."""
_DIALOGUES.pop(world_id, None)
def _build_system_prompt(world: World) -> str:
"""System prompt for the world editor.
Reuses the world-builder prompt but overrides the workflow: instead of
designing from scratch, the AI is told to MODIFY the existing definition.
"""
base = get_prompt("world_builder", world.language)
override = (
"\n\nADDITIONAL CONTEXT — YOU ARE EDITING AN EXISTING WORLD:\n"
"The world already exists with the definition provided in the first "
"user message. The player will give you edit instructions in their "
"language ({world_language}). For EACH instruction:\n"
"1. Call `submit_world_definition` with the FULL updated definition "
"(not just the changed fields — the entire object, all required keys).\n"
"2. Your text response should briefly summarize what you changed in "
"the player's language. 2-4 sentences max.\n"
"3. NEVER set `is_final=true` — the player will commit changes "
"manually via the Save button.\n"
"4. Preserve `initial_state` consistency with `world_schema`. If you "
"change the schema, update the state accordingly.\n"
"5. Preserve `initial_time` and `calendar` unless the player asks to "
"change them.\n"
).format(world_language=world.language or "en")
return base + override
def _build_seed_message(world: World) -> str:
"""First user message: dumps the current world definition as context."""
defn = world.definition or {}
parts = [
"=== CURRENT WORLD DEFINITION ===",
f"Name: {world.name}",
f"Language: {world.language}",
f"Current time: {world.current_time or '(none)'}",
f"Definition JSON:\n```json\n{json.dumps(defn, ensure_ascii=False, indent=2)}\n```",
f"Live state JSON:\n```json\n{json.dumps(world.state or {}, ensure_ascii=False, indent=2)[:2000]}\n```",
"",
"The player will now give you edit instructions. Apply each one by "
"calling submit_world_definition with the FULL updated definition.",
]
return "\n".join(parts)
# === Output extraction (mirrors world_builder._extract_world_definition) ===
def _extract_world_definition(
text: str,
tool_calls: Optional[List[Dict[str, Any]]],
) -> Tuple[str, Optional[WorldDefinition], bool]:
import re as _re
proposed: Optional[WorldDefinition] = None
is_final = False
ai_text = _sanitize_model_text(text or "")
if tool_calls:
for tc in tool_calls:
if tc.get("function", {}).get("name") == "submit_world_definition":
args_str = tc.get("function", {}).get("arguments", "{}")
data = _safe_json_loads(args_str, {})
if data:
proposed = _try_build_definition(data)
is_final = bool(data.get("is_final", False))
break
if proposed is None:
json_str = _extract_json_block(ai_text)
if json_str:
data = _safe_json_loads(json_str, None)
if isinstance(data, dict):
target = data
if "proposed_definition" in data and isinstance(data["proposed_definition"], dict):
target = data["proposed_definition"]
if "is_final" in data:
is_final = bool(data["is_final"])
if "ai_message" in data and isinstance(data["ai_message"], str):
ai_text = data["ai_message"]
else:
if "is_final" in data:
is_final = bool(data["is_final"])
proposed = _try_build_definition(target)
if proposed is not None:
ai_text = _strip_json_blocks(ai_text).strip()
if not ai_text:
ai_text = "(definition updated)"
return ai_text, proposed, is_final
def _sanitize_model_text(text: str) -> str:
if not text:
return ""
import re as _re
cleaned = _re.sub(r"<\s*/?\s*[a-zA-Z]+\s*>", "", text)
cleaned = _re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned.strip()
def _strip_json_blocks(text: str) -> str:
if not text:
return ""
import re as _re
cleaned = _re.sub(r"```[a-zA-Z]*\s*[\s\S]*?```", "", text)
m = _re.search(r"\n\{[\s\S]*\}\s*$", cleaned)
if m:
cleaned = cleaned[: m.start()] + cleaned[m.end():]
return cleaned
def _safe_json_loads(s: str, default: Any) -> Any:
if not s:
return default
try:
return json.loads(s)
except json.JSONDecodeError:
pass
import re as _re
repaired = _re.sub(r",\s*([}\]])", r"\1", s)
try:
return json.loads(repaired)
except json.JSONDecodeError:
pass
try:
repaired2 = repaired.replace("'", '"')
return json.loads(repaired2)
except json.JSONDecodeError:
return default
def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]:
try:
return WorldDefinition.model_validate(data)
except Exception as e:
log.warning("world_editor_definition_invalid", error=str(e), keys=list(data.keys()))
return None
def _extract_json_block(text: str) -> Optional[str]:
if not text:
return None
import re as _re
m = _re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
if m:
return m.group(1)
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