fix
This commit is contained in:
@@ -58,7 +58,10 @@ async def build_orchestrator_messages(
|
||||
|
||||
recent = visible_msgs[-recent_n:] if visible_msgs else []
|
||||
|
||||
# Build orchestrator system prompt with current state
|
||||
# Build orchestrator system prompt with current state.
|
||||
# NOTE: prompts are always English (system content convention). The LLM
|
||||
# produces player-facing text in world.language when relevant (the
|
||||
# step-writer prompt interpolates world_language explicitly).
|
||||
defn = world.definition or {}
|
||||
system_prompt_template = get_prompt("orchestrator", world.language)
|
||||
player_state = world.state.get("player", {}) if world.state else {}
|
||||
@@ -69,14 +72,14 @@ async def build_orchestrator_messages(
|
||||
current_time=world.current_time or "",
|
||||
player_state=json.dumps(player_state, ensure_ascii=False)[:600],
|
||||
plot_rails=json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:400],
|
||||
summary=summary_text or "(нет сводки)",
|
||||
summary=summary_text or "(no summary yet)",
|
||||
)
|
||||
|
||||
messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
# Add summary as a system note if present
|
||||
if summary_text:
|
||||
messages.append({"role": "system", "content": f"Сводка прошлого:\n{summary_text}"})
|
||||
messages.append({"role": "system", "content": f"Past summary:\n{summary_text}"})
|
||||
|
||||
# Add recent visible messages
|
||||
for m in recent:
|
||||
@@ -86,7 +89,7 @@ async def build_orchestrator_messages(
|
||||
messages.append({"role": "assistant", "content": m.content})
|
||||
|
||||
# The current action
|
||||
messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'})
|
||||
messages.append({"role": "user", "content": f'Player action: "{action_text}"'})
|
||||
|
||||
return messages, settings_map
|
||||
|
||||
@@ -100,7 +103,11 @@ async def _maybe_compress(
|
||||
world: World,
|
||||
settings_map: Dict[str, Any],
|
||||
) -> None:
|
||||
"""If history exceeds threshold, summarize older messages into a single summary message."""
|
||||
"""If history exceeds threshold, summarize older messages into a single summary message.
|
||||
|
||||
Uses the `submit_summary` tool (tool-calling-first design) to get structured
|
||||
output from the summarizer LLM.
|
||||
"""
|
||||
visible = [m for m in all_msgs if not m.hidden]
|
||||
if len(visible) <= recent_n + summary_n:
|
||||
return
|
||||
@@ -110,45 +117,61 @@ async def _maybe_compress(
|
||||
if not to_summarize:
|
||||
return
|
||||
|
||||
# Build summarization input
|
||||
# Build summarization input (English labels — system content convention)
|
||||
summary_input_lines = []
|
||||
for m in to_summarize:
|
||||
prefix = {
|
||||
"player_action": "Игрок",
|
||||
"narrative_step": "Сцена",
|
||||
"summary": "Сводка",
|
||||
"player_action": "Player",
|
||||
"narrative_step": "Scene",
|
||||
"summary": "Summary",
|
||||
"orchestrator_plan": "GM",
|
||||
"technical_offscreen": "За кадром",
|
||||
"technical_offscreen": "Offscreen",
|
||||
}.get(m.kind, m.kind)
|
||||
summary_input_lines.append(f"{prefix}: {m.content[:300]}")
|
||||
summary_input = "\n\n".join(summary_input_lines)
|
||||
|
||||
llm = LlmClient(settings_map)
|
||||
system_prompt = get_prompt("summarizer", world.language)
|
||||
from app.engine.tools.tools import SUMMARIZER_TOOL_SCHEMAS
|
||||
response = await llm.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": summary_input[:4000]},
|
||||
],
|
||||
tools=SUMMARIZER_TOOL_SCHEMAS,
|
||||
temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
|
||||
max_tokens=300,
|
||||
max_tokens=400,
|
||||
purpose="summary",
|
||||
session_id=session_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Parse summary response
|
||||
summary_text = response.text
|
||||
# Extract from submit_summary tool call; fall back to text parse.
|
||||
summary_text = response.text or ""
|
||||
facts: List[Dict[str, Any]] = []
|
||||
import re as _re
|
||||
json_match = _re.search(r"\{[\s\S]*\}", response.text)
|
||||
if json_match:
|
||||
try:
|
||||
data = json.loads(json_match.group(0))
|
||||
summary_text = data.get("summary", response.text)
|
||||
facts = data.get("facts", [])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
extracted = False
|
||||
for tc in (response.tool_calls or []):
|
||||
if tc.get("function", {}).get("name") == "submit_summary":
|
||||
args_str = tc.get("function", {}).get("arguments", "{}")
|
||||
try:
|
||||
data = json.loads(args_str) if args_str else {}
|
||||
summary_text = data.get("summary", response.text or "")
|
||||
facts = data.get("facts", []) or []
|
||||
extracted = True
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
break
|
||||
if not extracted:
|
||||
# Fallback: extract JSON from text response (older models).
|
||||
import re as _re
|
||||
json_match = _re.search(r"\{[\s\S]*\}", response.text or "")
|
||||
if json_match:
|
||||
try:
|
||||
data = json.loads(json_match.group(0))
|
||||
summary_text = data.get("summary", response.text or "")
|
||||
facts = data.get("facts", []) or []
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Create summary message
|
||||
next_seq = (max((m.seq for m in all_msgs), default=0)) + 1
|
||||
@@ -207,7 +230,11 @@ async def build_step_writer_messages(
|
||||
outcome: str,
|
||||
narrative_prompt: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build messages for the step writer LLM call."""
|
||||
"""Build messages for the step writer LLM call.
|
||||
|
||||
The step-writer prompt is in English (system content convention) but
|
||||
instructs the LLM to produce the narrative in world.language.
|
||||
"""
|
||||
defn = world.definition or {}
|
||||
player_state = world.state.get("player", {}) if world.state else {}
|
||||
system_prompt = get_prompt("step_writer", world.language).format(
|
||||
@@ -216,6 +243,7 @@ async def build_step_writer_messages(
|
||||
player_state=json.dumps(player_state, ensure_ascii=False)[:400],
|
||||
outcome=outcome,
|
||||
narrative_prompt=narrative_prompt[:600],
|
||||
world_language=world.language or "en",
|
||||
)
|
||||
return [{"role": "system", "content": system_prompt}]
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step."""
|
||||
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step.
|
||||
|
||||
Design (v2 — tool-calling-first):
|
||||
- The orchestrator LLM is given a set of game tools (dice_roll, update_state,
|
||||
rag_query, rag_add, schedule_trigger, advance_time, run_subagent) PLUS a
|
||||
`submit_plan` tool. The LLM calls game tools to execute its plan, then
|
||||
calls `submit_plan` to terminate the loop with structured data.
|
||||
- The step-writer LLM is given only a `submit_scene` tool. It calls this
|
||||
to return the narrative + options; its text response is ignored.
|
||||
- This replaces the old "return JSON in your text response" pattern which
|
||||
conflicted with tool use and caused the model to dump raw JSON into chat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -11,15 +22,20 @@ 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.core.triggers import advance_world_time, fire_due_triggers
|
||||
from app.engine.context import (
|
||||
build_orchestrator_messages,
|
||||
build_step_writer_messages,
|
||||
build_subagent_messages,
|
||||
)
|
||||
from app.engine.tools.tools import ALL_TOOL_SCHEMAS, ToolContext, handle_tool_call
|
||||
from app.engine.tools.tools import (
|
||||
ALL_TOOL_SCHEMAS,
|
||||
STEP_WRITER_TOOL_SCHEMAS,
|
||||
ToolContext,
|
||||
handle_tool_call,
|
||||
)
|
||||
from app.logging_setup import get_logger
|
||||
from app.models import DeferredTrigger, Message, Session, World
|
||||
from app.prompts.templates import get_prompt
|
||||
from app.models import Message, Session, World
|
||||
|
||||
log = get_logger("orchestrator")
|
||||
|
||||
@@ -88,19 +104,22 @@ async def run_iteration(
|
||||
)
|
||||
return resp.text
|
||||
|
||||
ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map)
|
||||
ctx = ToolContext(
|
||||
db=db,
|
||||
world=world,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
subagent_runner=_subagent,
|
||||
settings_map=settings_map,
|
||||
)
|
||||
|
||||
# === Phase 1: Orchestrator with tool calls (max 5 iterations) ===
|
||||
orchestrator_messages, _ = await build_orchestrator_messages(db, world, session_id, action_text)
|
||||
# Add a final user instruction forcing JSON output
|
||||
orchestrator_messages.append({
|
||||
"role": "user",
|
||||
"content": "Используй инструменты при необходимости, затем верни финальный JSON-ответ с assessment, outcome, state_patch, time_advance, narrative_prompt, next_options, triggers, rails_update, rag_facts.",
|
||||
})
|
||||
|
||||
max_iters = 5
|
||||
final_assistant_text: Optional[str] = None
|
||||
final_tool_calls: List[Dict[str, Any]] = []
|
||||
parsed: Dict[str, Any] = {}
|
||||
plan_tool_calls_log: List[Dict[str, Any]] = []
|
||||
orchestrator_text_log: str = ""
|
||||
|
||||
for i in range(max_iters):
|
||||
yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}}
|
||||
@@ -114,48 +133,112 @@ async def run_iteration(
|
||||
db=db,
|
||||
)
|
||||
|
||||
if response.tool_calls:
|
||||
# Append assistant message with tool_calls
|
||||
orchestrator_messages.append({
|
||||
"role": "assistant",
|
||||
"content": response.text or "",
|
||||
"tool_calls": response.tool_calls,
|
||||
})
|
||||
# Execute each tool call
|
||||
for tc in response.tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
args_str = fn.get("arguments", "{}")
|
||||
try:
|
||||
args = json.loads(args_str) if args_str else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
yield {"type": "tool_call", "data": {"name": name, "args": args}}
|
||||
result_dict = await handle_tool_call(name, args, ctx)
|
||||
yield {"type": "tool_result", "data": {"name": name, "result": result_dict}}
|
||||
# Append tool result message
|
||||
orchestrator_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"name": name,
|
||||
"content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800],
|
||||
})
|
||||
await db.commit()
|
||||
continue # Let orchestrator continue with tool results
|
||||
else:
|
||||
# No tool calls - this is the final answer
|
||||
final_assistant_text = response.text
|
||||
if not response.tool_calls:
|
||||
# No tool calls — model gave up or errored. Treat its text as the
|
||||
# outcome directly so the player still sees SOMETHING.
|
||||
log.warning("orchestrator_no_tool_calls", iteration=i, text_len=len(response.text or ""))
|
||||
orchestrator_text_log = response.text or ""
|
||||
parsed = {
|
||||
"assessment": "(no plan submitted)",
|
||||
"outcome": response.text or "",
|
||||
"narrative_prompt": "",
|
||||
"next_options": [],
|
||||
"state_patch": {},
|
||||
"time_advance": None,
|
||||
"rag_facts": [],
|
||||
"rails_update": None,
|
||||
}
|
||||
break
|
||||
|
||||
if final_assistant_text is None:
|
||||
# Ran out of iterations - use last text
|
||||
final_assistant_text = response.text or "{}"
|
||||
# Append assistant message with tool_calls
|
||||
orchestrator_messages.append({
|
||||
"role": "assistant",
|
||||
"content": response.text or "",
|
||||
"tool_calls": response.tool_calls,
|
||||
})
|
||||
|
||||
# Check for submit_plan — if present, extract plan and break
|
||||
submit_plan_call = None
|
||||
for tc in response.tool_calls:
|
||||
if tc.get("function", {}).get("name") == "submit_plan":
|
||||
submit_plan_call = tc
|
||||
break
|
||||
|
||||
if submit_plan_call:
|
||||
# Extract plan from the submit_plan tool call
|
||||
args_str = submit_plan_call.get("function", {}).get("arguments", "{}")
|
||||
try:
|
||||
parsed = json.loads(args_str) if args_str else {}
|
||||
except json.JSONDecodeError:
|
||||
log.warning("submit_plan_invalid_json", args=args_str[:200])
|
||||
parsed = {}
|
||||
# Make sure required keys exist
|
||||
parsed.setdefault("assessment", "")
|
||||
parsed.setdefault("outcome", "")
|
||||
parsed.setdefault("narrative_prompt", "")
|
||||
parsed.setdefault("next_options", [])
|
||||
parsed.setdefault("state_patch", {})
|
||||
parsed.setdefault("time_advance", None)
|
||||
parsed.setdefault("rag_facts", [])
|
||||
parsed.setdefault("rails_update", None)
|
||||
# Acknowledge the tool call so the model's history is consistent
|
||||
orchestrator_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": submit_plan_call.get("id", ""),
|
||||
"name": "submit_plan",
|
||||
"content": json.dumps({"ok": True}),
|
||||
})
|
||||
# Log OTHER tool calls made this iteration (for debugging)
|
||||
for tc in response.tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
if fn.get("name") != "submit_plan":
|
||||
plan_tool_calls_log.append({
|
||||
"name": fn.get("name"),
|
||||
"args": _safe_parse_json(fn.get("arguments", "{}")),
|
||||
})
|
||||
break
|
||||
|
||||
# Otherwise: execute all tool calls and continue
|
||||
for tc in response.tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
args_str = fn.get("arguments", "{}")
|
||||
try:
|
||||
args = json.loads(args_str) if args_str else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
yield {"type": "tool_call", "data": {"name": name, "args": args}}
|
||||
try:
|
||||
result_dict = await handle_tool_call(name, args, ctx)
|
||||
except Exception as e:
|
||||
result_dict = {"error": f"{type(e).__name__}: {e}"}
|
||||
log.error("tool_call_failed", name=name, error=str(e))
|
||||
yield {"type": "tool_result", "data": {"name": name, "result": result_dict}}
|
||||
plan_tool_calls_log.append({"name": name, "args": args, "result": result_dict})
|
||||
# Append tool result message
|
||||
orchestrator_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", ""),
|
||||
"name": name,
|
||||
"content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800],
|
||||
})
|
||||
await db.commit()
|
||||
else:
|
||||
# Ran out of iterations without submit_plan — use a minimal fallback.
|
||||
log.warning("orchestrator_exhausted_iterations")
|
||||
parsed = parsed or {
|
||||
"assessment": "(iteration limit reached)",
|
||||
"outcome": orchestrator_text_log or action_text,
|
||||
"narrative_prompt": "",
|
||||
"next_options": [],
|
||||
"state_patch": {},
|
||||
"time_advance": None,
|
||||
"rag_facts": [],
|
||||
"rails_update": None,
|
||||
}
|
||||
|
||||
yield {"type": "status", "data": {"message": "writing_scene"}}
|
||||
|
||||
# === Parse orchestrator final response ===
|
||||
parsed = _parse_orchestrator_response(final_assistant_text)
|
||||
|
||||
# Apply final state patch (if any)
|
||||
if parsed.get("state_patch"):
|
||||
from app.core.state_validator import apply_patch, validate_state
|
||||
@@ -170,7 +253,7 @@ async def run_iteration(
|
||||
# Advance time
|
||||
time_advance = parsed.get("time_advance")
|
||||
if time_advance and isinstance(time_advance, dict):
|
||||
new_time = _advance_world_time(world.current_time, time_advance, world)
|
||||
new_time, _total, _delta = advance_world_time(world.current_time, time_advance, world)
|
||||
world.current_time = new_time
|
||||
|
||||
# Save orchestrator plan as hidden message
|
||||
@@ -180,31 +263,40 @@ async def run_iteration(
|
||||
seq=plan_seq,
|
||||
role="assistant",
|
||||
kind="orchestrator_plan",
|
||||
content=final_assistant_text[:2000],
|
||||
content=(parsed.get("assessment", "") + " | " + parsed.get("outcome", ""))[:2000],
|
||||
payload={
|
||||
"assessment": parsed.get("assessment", ""),
|
||||
"outcome": parsed.get("outcome", ""),
|
||||
"state_patch": parsed.get("state_patch", {}),
|
||||
"time_advance": time_advance,
|
||||
"tool_calls_made": [tc for tc in final_tool_calls],
|
||||
"tool_calls_made": plan_tool_calls_log,
|
||||
"scheduled_triggers": ctx.scheduled_triggers,
|
||||
"rag_added": ctx.rag_added,
|
||||
"narrative_prompt": parsed.get("narrative_prompt", ""),
|
||||
"next_options": parsed.get("next_options", []),
|
||||
},
|
||||
is_pinned=False,
|
||||
hidden=True,
|
||||
)
|
||||
db.add(plan_msg)
|
||||
|
||||
# === Phase 2: Step writer (narrative scene) ===
|
||||
# === Phase 2: Step writer (narrative scene) — uses submit_scene tool ===
|
||||
narrative_prompt_parts = [parsed.get("narrative_prompt", "")]
|
||||
# Add RAG context if relevant
|
||||
if parsed.get("outcome"):
|
||||
try:
|
||||
from app.core.rag import get_rag
|
||||
rag = await get_rag(settings_map)
|
||||
rag_results = await rag.search_glossary(world.id, parsed.get("outcome", ""), limit=3, settings_map=settings_map)
|
||||
rag_results = await rag.search_glossary(
|
||||
world.id,
|
||||
parsed.get("outcome", ""),
|
||||
limit=3,
|
||||
settings_map=settings_map,
|
||||
)
|
||||
if rag_results:
|
||||
rag_text = "\n".join(f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results)
|
||||
rag_text = "\n".join(
|
||||
f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results
|
||||
)
|
||||
narrative_prompt_parts.append(f"Relevant facts from glossary:\n{rag_text}")
|
||||
except Exception as e:
|
||||
log.warning("rag_lookup_failed", error=str(e))
|
||||
@@ -219,28 +311,46 @@ async def run_iteration(
|
||||
|
||||
step_resp = await llm.chat(
|
||||
messages=step_messages,
|
||||
tools=STEP_WRITER_TOOL_SCHEMAS,
|
||||
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
|
||||
max_tokens=800,
|
||||
max_tokens=1200,
|
||||
purpose="step",
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
step_text = step_resp.text
|
||||
# Extract scene from submit_scene tool call (if present); fall back to text.
|
||||
step_text = step_resp.text or ""
|
||||
step_options: List[str] = parsed.get("next_options", []) or []
|
||||
# Try to extract structured step from JSON
|
||||
import re as _re
|
||||
json_match = _re.search(r"\{[\s\S]*\}", step_resp.text)
|
||||
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
|
||||
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("submit_scene_invalid_json", args=args_str[:200])
|
||||
break
|
||||
else:
|
||||
# No submit_scene call — try to extract JSON from text as a last resort.
|
||||
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
|
||||
# If still no narrative, use the orchestrator's outcome as fallback.
|
||||
if not step_text.strip():
|
||||
step_text = parsed.get("outcome", action_text)
|
||||
|
||||
# Save narrative step message
|
||||
step_seq = await _next_seq(db, session_id)
|
||||
@@ -276,7 +386,6 @@ async def run_iteration(
|
||||
completed = set(rails.get("completed_subgoals", []))
|
||||
completed.update(rails_update["completed_subgoals"])
|
||||
rails["completed_subgoals"] = list(completed)
|
||||
# Remove completed from subgoals
|
||||
rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed]
|
||||
defn["plot_rails"] = rails
|
||||
world.definition = defn
|
||||
@@ -316,8 +425,27 @@ async def run_iteration(
|
||||
await db.commit()
|
||||
await db.refresh(step_msg)
|
||||
|
||||
# Check for triggers that should fire immediately (fire_at <= current_time)
|
||||
fired_now = await _check_due_triggers(db, session_id, world.current_time or "")
|
||||
# Check for triggers that should fire now (fire_at <= current world time).
|
||||
# Triggers fire on in-game time changes, not real-time polling — see
|
||||
# app.core.triggers. We do this AFTER committing the narrative step so the
|
||||
# player sees the main scene first, then any trigger consequences.
|
||||
triggers_enabled = bool(cast_setting(
|
||||
"triggers.enabled",
|
||||
settings_map.get("triggers.enabled", True),
|
||||
))
|
||||
fired_now: List[Dict[str, Any]] = []
|
||||
if triggers_enabled:
|
||||
try:
|
||||
await db.refresh(world)
|
||||
fired_now = await fire_due_triggers(
|
||||
db=db,
|
||||
session_id=session_id,
|
||||
world=world,
|
||||
settings_map=settings_map,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("trigger_fire_failed_in_iteration", error=f"{type(e).__name__}: {e}")
|
||||
|
||||
yield {
|
||||
"type": "step_complete",
|
||||
@@ -335,21 +463,6 @@ async def run_iteration(
|
||||
yield {"type": "done", "data": {}}
|
||||
|
||||
|
||||
def _parse_orchestrator_response(text: str) -> Dict[str, Any]:
|
||||
"""Extract the JSON object from the orchestrator's final response."""
|
||||
if not text:
|
||||
return {}
|
||||
import re as _re
|
||||
m = _re.search(r"\{[\s\S]*\}", text)
|
||||
if not m:
|
||||
return {"outcome": text, "narrative_prompt": text, "next_options": []}
|
||||
try:
|
||||
data = json.loads(m.group(0))
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
return {"outcome": text, "narrative_prompt": text, "next_options": []}
|
||||
|
||||
|
||||
async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
|
||||
result = await db.execute(
|
||||
select(Message.seq).where(Message.session_id == session_id).order_by(Message.seq.desc()).limit(1)
|
||||
@@ -358,86 +471,8 @@ async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
|
||||
return (row[0] + 1) if row else 1
|
||||
|
||||
|
||||
def _advance_world_time(current_time: Optional[str], advance: Dict[str, int], world: World) -> str:
|
||||
"""Advance world time string. Supports format like 'day_N_hour_H' or ISO datetime."""
|
||||
if not current_time:
|
||||
# Try to use the world_state's world_time field
|
||||
wt = (world.state or {}).get("world_time", {})
|
||||
if wt:
|
||||
day = int(wt.get("day", 1))
|
||||
hour = int(wt.get("hour", 8))
|
||||
else:
|
||||
day, hour = 1, 8
|
||||
else:
|
||||
# Parse 'day_N_hour_H' or fall back to numbers
|
||||
import re as _re
|
||||
m = _re.match(r"day_(\d+)_hour_(\d+)", current_time)
|
||||
if m:
|
||||
day, hour = int(m.group(1)), int(m.group(2))
|
||||
else:
|
||||
# Try ISO format
|
||||
try:
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
dt = _dt.fromisoformat(current_time)
|
||||
dt = dt + _td(
|
||||
days=int(advance.get("days", 0)),
|
||||
hours=int(advance.get("hours", 0)),
|
||||
minutes=int(advance.get("minutes", 0)),
|
||||
)
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
day, hour = 1, 8
|
||||
|
||||
total_minutes = day * 24 * 60 + hour * 60
|
||||
total_minutes += int(advance.get("days", 0)) * 24 * 60
|
||||
total_minutes += int(advance.get("hours", 0)) * 60
|
||||
total_minutes += int(advance.get("minutes", 0))
|
||||
new_day = total_minutes // (24 * 60)
|
||||
new_hour = (total_minutes % (24 * 60)) // 60
|
||||
new_time = f"day_{new_day}_hour_{new_hour}"
|
||||
|
||||
# Also update world_time in state if present
|
||||
if world.state and "world_time" in world.state:
|
||||
world.state["world_time"] = {
|
||||
**world.state["world_time"],
|
||||
"day": new_day,
|
||||
"hour": new_hour,
|
||||
}
|
||||
|
||||
return new_time
|
||||
|
||||
|
||||
async def _check_due_triggers(db: AsyncSession, session_id: uuid.UUID, current_time: str) -> List[Dict[str, Any]]:
|
||||
"""Mark triggers as fired if their fire_at <= current_time. Returns list of fired triggers."""
|
||||
import re as _re
|
||||
def _parse(t: str):
|
||||
m = _re.match(r"day_(\d+)_hour_(\d+)", t or "")
|
||||
if m:
|
||||
return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
dt = _dt.fromisoformat(t)
|
||||
return int(dt.timestamp() // 60)
|
||||
except Exception:
|
||||
return 0
|
||||
cur = _parse(current_time)
|
||||
result = await db.execute(
|
||||
select(DeferredTrigger).where(
|
||||
DeferredTrigger.session_id == session_id,
|
||||
DeferredTrigger.fired.is_(False),
|
||||
)
|
||||
)
|
||||
triggers = list(result.scalars().all())
|
||||
fired: List[Dict[str, Any]] = []
|
||||
for t in triggers:
|
||||
if _parse(t.fire_at) <= cur:
|
||||
t.fired = True
|
||||
fired.append({
|
||||
"id": str(t.id),
|
||||
"fire_at": t.fire_at,
|
||||
"description": t.description,
|
||||
"payload": t.payload,
|
||||
})
|
||||
if fired:
|
||||
await db.commit()
|
||||
return fired
|
||||
def _safe_parse_json(s: str) -> Any:
|
||||
try:
|
||||
return json.loads(s) if s else {}
|
||||
except Exception:
|
||||
return s
|
||||
|
||||
@@ -91,13 +91,29 @@ RAG_ADD_SCHEMA = build_tool_schema(
|
||||
|
||||
SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
|
||||
name="schedule_trigger",
|
||||
description="Schedule a deferred event tied to world time. When world time reaches fire_at, the system will fire it.",
|
||||
description=(
|
||||
"Schedule a deferred event tied to in-world time. When the world's "
|
||||
"internal clock reaches fire_at, the engine fires the event (calls the "
|
||||
"LLM with the description to produce a narrative beat and optional "
|
||||
"state patch). fire_at must use the same format as world.current_time "
|
||||
"('day_N_hour_H' or 'day_N_hour_H_min_M'). The world's calendar "
|
||||
"(hours_per_day, minutes_per_hour) is honored when comparing times."
|
||||
),
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"},
|
||||
"description": {"type": "string", "description": "What should happen"},
|
||||
"payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"},
|
||||
"fire_at": {
|
||||
"type": "string",
|
||||
"description": "In-world time when the trigger fires, e.g. 'day_3_hour_14' or 'day_3_hour_14_min_30'.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What should happen when the trigger fires. Be specific — this is fed to the LLM at fire time.",
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "Optional structured payload (e.g. who, conditions, parameters).",
|
||||
},
|
||||
},
|
||||
"required": ["fire_at", "description"],
|
||||
},
|
||||
@@ -106,14 +122,22 @@ SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
|
||||
|
||||
ADVANCE_TIME_SCHEMA = build_tool_schema(
|
||||
name="advance_time",
|
||||
description="Advance the world's internal clock by days/hours/minutes. Use this when the action takes time.",
|
||||
description=(
|
||||
"Advance the world's internal clock by days / hours / minutes. Use "
|
||||
"this when the player's action takes measurable in-world time (travel, "
|
||||
"sleep, crafting, long rest). The world's calendar (hours_per_day, "
|
||||
"minutes_per_hour) is honored. After time advances, any scheduled "
|
||||
"triggers whose fire_at is now <= the new time will fire "
|
||||
"automatically — so this is also how you 'run out the clock' on a "
|
||||
"scheduled event."
|
||||
),
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {"type": "integer", "default": 0},
|
||||
"hours": {"type": "integer", "default": 0},
|
||||
"minutes": {"type": "integer", "default": 0},
|
||||
"reason": {"type": "string", "description": "Why time advances"},
|
||||
"reason": {"type": "string", "description": "Why time advances (logged for debugging)."},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -133,6 +157,219 @@ RUN_SUBAGENT_SCHEMA = build_tool_schema(
|
||||
)
|
||||
|
||||
|
||||
# === Submission tools (how the LLM returns structured results) ===
|
||||
# These replace the old "return JSON in your text response" pattern, which
|
||||
# conflicted with tool use and caused the model to dump raw JSON into chat.
|
||||
|
||||
SUBMIT_PLAN_SCHEMA = build_tool_schema(
|
||||
name="submit_plan",
|
||||
description=(
|
||||
"Submit the orchestrator's final plan for this iteration. This MUST be "
|
||||
"the last tool you call. After you call it, the iteration ends and the "
|
||||
"step-writer takes over to produce the cinematic scene."
|
||||
),
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"assessment": {
|
||||
"type": "string",
|
||||
"description": "Brief assessment of the player's action (1-2 sentences, English).",
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"description": "What concretely happened (1-3 sentences, English). Fed to the step-writer as the raw outcome.",
|
||||
},
|
||||
"state_patch": {
|
||||
"type": "object",
|
||||
"description": "JSON-patch for world state. Keys: set, unset, append, increment, remove. Empty object if no change.",
|
||||
"properties": {
|
||||
"set": {"type": "object"},
|
||||
"unset": {"type": "array", "items": {"type": "string"}},
|
||||
"append": {"type": "object"},
|
||||
"increment": {"type": "object"},
|
||||
"remove": {"type": "object"},
|
||||
},
|
||||
},
|
||||
"time_advance": {
|
||||
"type": "object",
|
||||
"description": "How much in-world time advances. null/omitted if no time passes.",
|
||||
"properties": {
|
||||
"days": {"type": "integer", "default": 0},
|
||||
"hours": {"type": "integer", "default": 0},
|
||||
"minutes": {"type": "integer", "default": 0},
|
||||
},
|
||||
},
|
||||
"narrative_prompt": {
|
||||
"type": "string",
|
||||
"description": "Facts the step-writer should know to write the scene (English). Max ~100 words.",
|
||||
},
|
||||
"next_options": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "3 suggested next actions for the player (short, 5-12 words each).",
|
||||
},
|
||||
"rails_update": {
|
||||
"type": "object",
|
||||
"description": "Optional update to plot rails. Omit if no change.",
|
||||
"properties": {
|
||||
"main_goal": {"type": "string"},
|
||||
"new_subgoals": {"type": "array", "items": {"type": "string"}},
|
||||
"completed_subgoals": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
"rag_facts": {
|
||||
"type": "array",
|
||||
"description": "New persistent facts to add to the glossary. Empty array if none.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["kind", "name", "description"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["assessment", "outcome", "narrative_prompt", "next_options"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
SUBMIT_SCENE_SCHEMA = build_tool_schema(
|
||||
name="submit_scene",
|
||||
description=(
|
||||
"Submit the narrative scene for this step. This is the ONLY way to "
|
||||
"return the scene — your text response is ignored. The narrative "
|
||||
"should be 200-400 words, cinematic, second-person ('You...'), in the "
|
||||
"world's player-facing language."
|
||||
),
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"narrative": {
|
||||
"type": "string",
|
||||
"description": "200-400 words of cinematic prose describing the scene. Second-person ('You...').",
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Exactly 3 short (5-12 words) options for the player's next action.",
|
||||
},
|
||||
},
|
||||
"required": ["narrative", "options"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
SUBMIT_WORLD_DEFINITION_SCHEMA = build_tool_schema(
|
||||
name="submit_world_definition",
|
||||
description=(
|
||||
"Submit a proposed world definition. Call this when you have enough "
|
||||
"information to build the world. Your text response will be shown to "
|
||||
"the player as your conversational reply (use it to summarize the "
|
||||
"proposed world in 2-4 sentences)."
|
||||
),
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"setting_description": {"type": "string", "description": "Expanded setting, 1-2 paragraphs."},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"description": "Object with keys like stats, combat, magic, time, inventory, death (whichever apply).",
|
||||
},
|
||||
"world_schema": {
|
||||
"type": "object",
|
||||
"description": "JSON Schema describing the shape of the world state.",
|
||||
},
|
||||
"plot_rails": {
|
||||
"type": "object",
|
||||
"description": "{main_goal, subgoals, hooks}.",
|
||||
"properties": {
|
||||
"main_goal": {"type": "string"},
|
||||
"subgoals": {"type": "array", "items": {"type": "string"}},
|
||||
"hooks": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
"initial_state": {
|
||||
"type": "object",
|
||||
"description": "Initial world state matching world_schema.",
|
||||
},
|
||||
"initial_time": {
|
||||
"type": "string",
|
||||
"description": "World time string e.g. 'day_1_hour_8'.",
|
||||
},
|
||||
"calendar": {
|
||||
"type": "object",
|
||||
"description": "Optional. Custom calendar. Include only if non-standard.",
|
||||
"properties": {
|
||||
"hours_per_day": {"type": "integer"},
|
||||
"minutes_per_hour": {"type": "integer"},
|
||||
"days_per_week": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"is_final": {
|
||||
"type": "boolean",
|
||||
"description": "True ONLY when the player has explicitly accepted the world.",
|
||||
},
|
||||
},
|
||||
"required": ["setting_description", "rules", "world_schema", "initial_state", "initial_time"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
SUBMIT_SUMMARY_SCHEMA = build_tool_schema(
|
||||
name="submit_summary",
|
||||
description="Submit the compressed summary of older session messages.",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string", "description": "3-6 sentences, max 150 words."},
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["kind", "name", "description"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["summary", "facts"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
SUBMIT_TRIGGER_RESULT_SCHEMA = build_tool_schema(
|
||||
name="submit_trigger_result",
|
||||
description="Submit the result of firing a deferred trigger.",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"outcome": {"type": "string", "description": "1-2 sentences, English. For logs."},
|
||||
"state_patch": {
|
||||
"type": "object",
|
||||
"description": "JSON-patch for world state. Empty object if no change.",
|
||||
"properties": {
|
||||
"set": {"type": "object"},
|
||||
"unset": {"type": "array", "items": {"type": "string"}},
|
||||
"append": {"type": "object"},
|
||||
"increment": {"type": "object"},
|
||||
"remove": {"type": "object"},
|
||||
},
|
||||
},
|
||||
"narrative": {"type": "string", "description": "1-paragraph scene description for the player, in world.language. Empty string if offscreen."},
|
||||
"should_notify_player": {"type": "boolean", "description": "True if the player should see the narrative."},
|
||||
},
|
||||
"required": ["outcome", "narrative", "should_notify_player"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Tools available to the orchestrator (game-loop tools + submit_plan)
|
||||
ALL_TOOL_SCHEMAS = [
|
||||
DICE_ROLL_SCHEMA,
|
||||
UPDATE_STATE_SCHEMA,
|
||||
@@ -141,8 +378,21 @@ ALL_TOOL_SCHEMAS = [
|
||||
SCHEDULE_TRIGGER_SCHEMA,
|
||||
ADVANCE_TIME_SCHEMA,
|
||||
RUN_SUBAGENT_SCHEMA,
|
||||
SUBMIT_PLAN_SCHEMA,
|
||||
]
|
||||
|
||||
# Tools for the step writer (only submit_scene)
|
||||
STEP_WRITER_TOOL_SCHEMAS = [SUBMIT_SCENE_SCHEMA]
|
||||
|
||||
# Tools for the world builder (only submit_world_definition)
|
||||
WORLD_BUILDER_TOOL_SCHEMAS = [SUBMIT_WORLD_DEFINITION_SCHEMA]
|
||||
|
||||
# Tools for the summarizer
|
||||
SUMMARIZER_TOOL_SCHEMAS = [SUBMIT_SUMMARY_SCHEMA]
|
||||
|
||||
# Tools for the trigger runner
|
||||
TRIGGER_RUNNER_TOOL_SCHEMAS = [SUBMIT_TRIGGER_RESULT_SCHEMA]
|
||||
|
||||
|
||||
# === Tool handlers ===
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
|
||||
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition.
|
||||
|
||||
Design (v2 — tool-calling-first):
|
||||
The world-builder LLM is given a single tool, `submit_world_definition`,
|
||||
which it calls when it has enough information to propose a world. The LLM's
|
||||
text response is the conversational reply shown to the player (in
|
||||
world.language). This replaces the old "return JSON in your text response"
|
||||
pattern which conflicted with tool use and caused raw JSON to leak into
|
||||
the chat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -14,6 +23,7 @@ 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
|
||||
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
|
||||
|
||||
log = get_logger("world_builder")
|
||||
|
||||
@@ -36,7 +46,8 @@ async def start_world_builder(
|
||||
) -> WorldBuilderReply:
|
||||
"""Kick off a new world-builder dialogue. Returns the first AI reply."""
|
||||
session_id = uuid.uuid4()
|
||||
llm = await LlmClient.from_db(db)
|
||||
settings_map = await get_all_settings(db)
|
||||
llm = LlmClient(settings_map)
|
||||
|
||||
preset_payload: Optional[Dict[str, Any]] = None
|
||||
if preset_id:
|
||||
@@ -55,7 +66,9 @@ async def start_world_builder(
|
||||
language=language,
|
||||
)
|
||||
|
||||
system_prompt = get_prompt("world_builder", language)
|
||||
# System prompt is English (system content convention). The LLM is told to
|
||||
# produce player-facing text in `language` (interpolated as world_language).
|
||||
system_prompt = get_prompt("world_builder", language).format(world_language=language)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_brief},
|
||||
@@ -63,20 +76,27 @@ async def start_world_builder(
|
||||
|
||||
response = await llm.chat(
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
tools=WORLD_BUILDER_TOOL_SCHEMAS,
|
||||
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
|
||||
purpose="world_builder",
|
||||
user_id=user.id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
|
||||
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
|
||||
|
||||
_DIALOGUES[session_id] = {
|
||||
"user_id": user.id,
|
||||
"world_name": world_name,
|
||||
"language": language,
|
||||
"preset_id": preset_id,
|
||||
"messages": messages + [{"role": "assistant", "content": response.text}],
|
||||
"messages": messages + [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": response.text or "",
|
||||
"tool_calls": response.tool_calls or None,
|
||||
},
|
||||
],
|
||||
"turn": 1,
|
||||
"last_proposed": proposed.model_dump() if proposed else None,
|
||||
}
|
||||
@@ -87,7 +107,7 @@ async def start_world_builder(
|
||||
ai_message=ai_text,
|
||||
proposed_definition=proposed,
|
||||
is_final=is_final,
|
||||
followup_questions=followups,
|
||||
followup_questions=[],
|
||||
)
|
||||
|
||||
|
||||
@@ -104,20 +124,26 @@ async def continue_world_builder(
|
||||
if dialogue["user_id"] != user.id:
|
||||
raise ValueError("forbidden")
|
||||
|
||||
llm = await LlmClient.from_db(db)
|
||||
settings_map = await get_all_settings(db)
|
||||
llm = LlmClient(settings_map)
|
||||
dialogue["messages"].append({"role": "user", "content": user_message})
|
||||
dialogue["turn"] += 1
|
||||
|
||||
response = await llm.chat(
|
||||
messages=dialogue["messages"],
|
||||
temperature=0.7,
|
||||
tools=WORLD_BUILDER_TOOL_SCHEMAS,
|
||||
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
|
||||
purpose="world_builder",
|
||||
user_id=user.id,
|
||||
db=db,
|
||||
)
|
||||
dialogue["messages"].append({"role": "assistant", "content": response.text})
|
||||
dialogue["messages"].append({
|
||||
"role": "assistant",
|
||||
"content": response.text or "",
|
||||
"tool_calls": response.tool_calls or None,
|
||||
})
|
||||
|
||||
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
|
||||
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
|
||||
if proposed:
|
||||
dialogue["last_proposed"] = proposed.model_dump()
|
||||
|
||||
@@ -127,7 +153,7 @@ async def continue_world_builder(
|
||||
ai_message=ai_text,
|
||||
proposed_definition=proposed,
|
||||
is_final=is_final,
|
||||
followup_questions=followups,
|
||||
followup_questions=[],
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +177,7 @@ async def commit_world_builder(
|
||||
world = World(
|
||||
owner_id=user.id,
|
||||
name=name or dialogue.get("world_name") or "New World",
|
||||
language=dialogue.get("language", "ru"),
|
||||
language=dialogue.get("language", "en"),
|
||||
definition=definition.model_dump(),
|
||||
state=definition.initial_state or {},
|
||||
current_time=definition.initial_time,
|
||||
@@ -176,7 +202,8 @@ def _build_user_brief(
|
||||
preset_payload: Optional[Dict[str, Any]],
|
||||
language: str,
|
||||
) -> str:
|
||||
parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
|
||||
parts = [f"=== WORLD BRIEF ==="]
|
||||
parts.append(f"Player-facing language: {language}")
|
||||
parts.append(f"Name: {world_name}")
|
||||
if preset_payload:
|
||||
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
|
||||
@@ -189,57 +216,78 @@ def _build_user_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.")
|
||||
parts.append("\nAsk 2-4 clarifying questions OR call submit_world_definition with a proposed world.")
|
||||
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
|
||||
def _extract_world_definition(
|
||||
text: str,
|
||||
tool_calls: Optional[List[Dict[str, Any]]],
|
||||
) -> tuple[str, Optional[WorldDefinition], bool]:
|
||||
"""Extract AI message text, proposed definition (if any), and is_final flag.
|
||||
|
||||
Looks for a `submit_world_definition` tool call first. Falls back to
|
||||
JSON-in-text parse for older models that don't honor the tool.
|
||||
"""
|
||||
proposed: Optional[WorldDefinition] = None
|
||||
is_final = False
|
||||
followups: List[str] = []
|
||||
ai_text = text or ""
|
||||
|
||||
# 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
|
||||
# 1) Prefer the submit_world_definition tool call (the proper way).
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
if tc.get("function", {}).get("name") == "submit_world_definition":
|
||||
args_str = tc.get("function", {}).get("arguments", "{}")
|
||||
try:
|
||||
data = json.loads(args_str) if args_str else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
proposed = _try_build_definition(data)
|
||||
is_final = bool(data.get("is_final", False))
|
||||
break
|
||||
|
||||
# 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"]):
|
||||
# 2) Fallback: parse a JSON block from the text (older models).
|
||||
if proposed is None:
|
||||
json_str = _extract_json_block(text)
|
||||
if json_str:
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
if isinstance(data, dict):
|
||||
if "proposed_definition" in data and isinstance(data["proposed_definition"], dict):
|
||||
proposed = _try_build_definition(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"]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 3) Heuristic: if a definition was proposed and the text mentions "ready",
|
||||
# mark as final.
|
||||
if proposed is not None and not is_final:
|
||||
low = ai_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
|
||||
return ai_text, proposed, is_final
|
||||
|
||||
|
||||
def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]:
|
||||
try:
|
||||
return WorldDefinition.model_validate(data)
|
||||
except Exception as e:
|
||||
log.warning("world_definition_invalid", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_json_block(text: str) -> Optional[str]:
|
||||
"""Find the first JSON object/array block in text."""
|
||||
"""Find the first JSON object/array block in text (fallback path only)."""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user