initial
This commit is contained in:
0
backend/app/engine/__init__.py
Normal file
0
backend/app/engine/__init__.py
Normal file
230
backend/app/engine/context.py
Normal file
230
backend/app/engine/context.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Context manager: builds the LLM prompt context with guaranteed-recent + dynamic summarization."""
|
||||
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 Message, World
|
||||
from app.prompts.templates import get_prompt
|
||||
|
||||
log = get_logger("context")
|
||||
|
||||
|
||||
async def build_orchestrator_messages(
|
||||
db: AsyncSession,
|
||||
world: World,
|
||||
session_id: uuid.UUID,
|
||||
action_text: str,
|
||||
) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||||
"""Build the messages list for the orchestrator LLM call.
|
||||
|
||||
Returns (messages, settings_used).
|
||||
"""
|
||||
settings_map = await get_all_settings(db)
|
||||
recent_n = int(cast_setting("context.recent_messages", settings_map.get("context.recent_messages", 10)))
|
||||
threshold = int(cast_setting("context.compress_threshold", settings_map.get("context.compress_threshold", 20)))
|
||||
summary_n = int(cast_setting("context.summary_messages", settings_map.get("context.summary_messages", 10)))
|
||||
|
||||
# Load all messages ordered by seq
|
||||
result = await db.execute(
|
||||
select(Message).where(Message.session_id == session_id).order_by(Message.seq)
|
||||
)
|
||||
all_msgs: List[Message] = list(result.scalars().all())
|
||||
|
||||
# Check if we need to compress
|
||||
if len(all_msgs) >= threshold:
|
||||
await _maybe_compress(db, session_id, all_msgs, summary_n, recent_n, world, settings_map)
|
||||
# Reload after compression
|
||||
result = await db.execute(
|
||||
select(Message).where(Message.session_id == session_id).order_by(Message.seq)
|
||||
)
|
||||
all_msgs = list(result.scalars().all())
|
||||
|
||||
# Get summary message (the latest summary before the recent window)
|
||||
summary_text = ""
|
||||
visible_msgs = [m for m in all_msgs if not m.hidden]
|
||||
if len(visible_msgs) > recent_n:
|
||||
# Look for the latest summary
|
||||
summaries = [m for m in all_msgs if m.kind == "summary"]
|
||||
if summaries:
|
||||
summary_text = summaries[-1].content
|
||||
|
||||
recent = visible_msgs[-recent_n:] if visible_msgs else []
|
||||
|
||||
# Build orchestrator system prompt with current state
|
||||
defn = world.definition or {}
|
||||
system_prompt_template = get_prompt("orchestrator", world.language)
|
||||
player_state = world.state.get("player", {}) if world.state else {}
|
||||
system_prompt = system_prompt_template.format(
|
||||
world_name=world.name,
|
||||
setting_description=defn.get("setting_description", "")[:800],
|
||||
rules=json.dumps(defn.get("rules", {}), ensure_ascii=False)[:600],
|
||||
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 "(нет сводки)",
|
||||
)
|
||||
|
||||
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}"})
|
||||
|
||||
# Add recent visible messages
|
||||
for m in recent:
|
||||
if m.kind == "player_action":
|
||||
messages.append({"role": "user", "content": m.content})
|
||||
elif m.kind == "narrative_step":
|
||||
messages.append({"role": "assistant", "content": m.content})
|
||||
|
||||
# The current action
|
||||
messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'})
|
||||
|
||||
return messages, settings_map
|
||||
|
||||
|
||||
async def _maybe_compress(
|
||||
db: AsyncSession,
|
||||
session_id: uuid.UUID,
|
||||
all_msgs: List[Message],
|
||||
summary_n: int,
|
||||
recent_n: int,
|
||||
world: World,
|
||||
settings_map: Dict[str, Any],
|
||||
) -> None:
|
||||
"""If history exceeds threshold, summarize older messages into a single summary message."""
|
||||
visible = [m for m in all_msgs if not m.hidden]
|
||||
if len(visible) <= recent_n + summary_n:
|
||||
return
|
||||
|
||||
# Take the messages that will be summarized (everything before the recent window)
|
||||
to_summarize = visible[:-recent_n]
|
||||
if not to_summarize:
|
||||
return
|
||||
|
||||
# Build summarization input
|
||||
summary_input_lines = []
|
||||
for m in to_summarize:
|
||||
prefix = {
|
||||
"player_action": "Игрок",
|
||||
"narrative_step": "Сцена",
|
||||
"summary": "Сводка",
|
||||
"orchestrator_plan": "GM",
|
||||
"technical_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)
|
||||
response = await llm.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": summary_input[:4000]},
|
||||
],
|
||||
temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
|
||||
max_tokens=300,
|
||||
purpose="summary",
|
||||
session_id=session_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Parse summary response
|
||||
summary_text = response.text
|
||||
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
|
||||
|
||||
# Create summary message
|
||||
next_seq = (max((m.seq for m in all_msgs), default=0)) + 1
|
||||
summary_msg = Message(
|
||||
session_id=session_id,
|
||||
seq=next_seq,
|
||||
role="system",
|
||||
kind="summary",
|
||||
content=summary_text,
|
||||
payload={"summarized_count": len(to_summarize), "facts": facts},
|
||||
is_pinned=True,
|
||||
hidden=False,
|
||||
)
|
||||
db.add(summary_msg)
|
||||
|
||||
# Hide the summarized messages (but keep them in DB)
|
||||
for m in to_summarize:
|
||||
m.hidden = True
|
||||
|
||||
# Index facts into RAG glossary
|
||||
if facts:
|
||||
from app.core.rag import get_rag
|
||||
from app.models import GlossaryEntry
|
||||
rag = await get_rag(settings_map)
|
||||
for f in facts:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
entry = GlossaryEntry(
|
||||
world_id=world.id,
|
||||
session_id=session_id,
|
||||
kind=f.get("kind", "lore"),
|
||||
name=f.get("name", "unknown"),
|
||||
description=f.get("description", ""),
|
||||
payload={},
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
await rag.upsert_glossary(
|
||||
world_id=world.id,
|
||||
entry_id=entry.id,
|
||||
kind=entry.kind,
|
||||
name=entry.name,
|
||||
description=entry.description,
|
||||
payload={},
|
||||
settings_map=settings_map,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
log.info("context_compressed", session_id=str(session_id), summarized=len(to_summarize))
|
||||
|
||||
|
||||
async def build_step_writer_messages(
|
||||
db: AsyncSession,
|
||||
world: World,
|
||||
session_id: uuid.UUID,
|
||||
outcome: str,
|
||||
narrative_prompt: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build messages for the step writer LLM call."""
|
||||
defn = world.definition or {}
|
||||
player_state = world.state.get("player", {}) if world.state else {}
|
||||
system_prompt = get_prompt("step_writer", world.language).format(
|
||||
setting_description=defn.get("setting_description", "")[:600],
|
||||
current_time=world.current_time or "",
|
||||
player_state=json.dumps(player_state, ensure_ascii=False)[:400],
|
||||
outcome=outcome,
|
||||
narrative_prompt=narrative_prompt[:600],
|
||||
)
|
||||
return [{"role": "system", "content": system_prompt}]
|
||||
|
||||
|
||||
async def build_subagent_messages(
|
||||
world: World,
|
||||
task: str,
|
||||
context: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build messages for a clean-context sub-agent call."""
|
||||
system_prompt = get_prompt("subagent", world.language).format(task=task, context=context[:600])
|
||||
return [{"role": "system", "content": system_prompt}]
|
||||
443
backend/app/engine/orchestrator.py
Normal file
443
backend/app/engine/orchestrator.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, AsyncIterator, 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.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.logging_setup import get_logger
|
||||
from app.models import DeferredTrigger, Message, Session, World
|
||||
from app.prompts.templates import get_prompt
|
||||
|
||||
log = get_logger("orchestrator")
|
||||
|
||||
|
||||
async def run_iteration(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
session_id: uuid.UUID,
|
||||
action_text: str,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Run one full iteration: plan -> tools -> step -> technical side-effects.
|
||||
|
||||
Yields SSE-ready event dicts:
|
||||
{"type": "status", "data": {"message": "..."}}
|
||||
{"type": "plan", "data": {...}} # orchestrator plan with tool calls
|
||||
{"type": "tool_call", "data": {"name": ..., "args": ..., "result": ...}}
|
||||
{"type": "narrative_chunk", "data": {"content": "..."}}
|
||||
{"type": "step_complete", "data": {"message_id": ..., "options": [...], "state": ...}}
|
||||
{"type": "error", "data": {"message": "..."}}
|
||||
{"type": "done", "data": {}}
|
||||
"""
|
||||
# Load session + world
|
||||
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)
|
||||
|
||||
# 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,
|
||||
)
|
||||
db.add(player_msg)
|
||||
await db.commit()
|
||||
await db.refresh(player_msg)
|
||||
|
||||
yield {"type": "status", "data": {"message": "planning"}}
|
||||
|
||||
# Subagent runner
|
||||
async def _subagent(task: str, context: str) -> str:
|
||||
sub_messages = await build_subagent_messages(world, task, context)
|
||||
resp = await llm.chat(
|
||||
messages=sub_messages,
|
||||
temperature=0.7,
|
||||
max_tokens=300,
|
||||
purpose="subagent",
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
db=db,
|
||||
)
|
||||
return resp.text
|
||||
|
||||
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]] = []
|
||||
|
||||
for i in range(max_iters):
|
||||
yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}}
|
||||
response = await llm.chat(
|
||||
messages=orchestrator_messages,
|
||||
tools=ALL_TOOL_SCHEMAS,
|
||||
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
|
||||
purpose="orchestrator",
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
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
|
||||
break
|
||||
|
||||
if final_assistant_text is None:
|
||||
# Ran out of iterations - use last text
|
||||
final_assistant_text = response.text or "{}"
|
||||
|
||||
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
|
||||
new_state = apply_patch(world.state, parsed["state_patch"])
|
||||
schema = world.definition.get("world_schema", {})
|
||||
ok, errors = validate_state(new_state, schema)
|
||||
if ok:
|
||||
world.state = new_state
|
||||
else:
|
||||
log.warning("state_patch_invalid", errors=errors)
|
||||
|
||||
# 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)
|
||||
world.current_time = new_time
|
||||
|
||||
# Save orchestrator plan as hidden message
|
||||
plan_seq = await _next_seq(db, session_id)
|
||||
plan_msg = Message(
|
||||
session_id=session_id,
|
||||
seq=plan_seq,
|
||||
role="assistant",
|
||||
kind="orchestrator_plan",
|
||||
content=final_assistant_text[: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],
|
||||
"scheduled_triggers": ctx.scheduled_triggers,
|
||||
"rag_added": ctx.rag_added,
|
||||
},
|
||||
is_pinned=False,
|
||||
hidden=True,
|
||||
)
|
||||
db.add(plan_msg)
|
||||
|
||||
# === Phase 2: Step writer (narrative scene) ===
|
||||
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)
|
||||
if 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))
|
||||
|
||||
step_messages = await build_step_writer_messages(
|
||||
db=db,
|
||||
world=world,
|
||||
session_id=session_id,
|
||||
outcome=parsed.get("outcome", action_text),
|
||||
narrative_prompt="\n".join(p for p in narrative_prompt_parts if p),
|
||||
)
|
||||
|
||||
step_resp = await llm.chat(
|
||||
messages=step_messages,
|
||||
temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
|
||||
max_tokens=800,
|
||||
purpose="step",
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
step_text = step_resp.text
|
||||
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
|
||||
|
||||
# Save narrative step message
|
||||
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={
|
||||
"options": step_options,
|
||||
"outcome": parsed.get("outcome", ""),
|
||||
"world_time": world.current_time,
|
||||
"player_state": world.state.get("player", {}),
|
||||
},
|
||||
is_pinned=True,
|
||||
hidden=False,
|
||||
)
|
||||
db.add(step_msg)
|
||||
|
||||
# === Phase 3: Update plot rails (if any) ===
|
||||
rails_update = parsed.get("rails_update")
|
||||
if rails_update and isinstance(rails_update, dict):
|
||||
defn = dict(world.definition)
|
||||
rails = dict(defn.get("plot_rails", {}))
|
||||
if "main_goal" in rails_update:
|
||||
rails["main_goal"] = rails_update["main_goal"]
|
||||
if "new_subgoals" in rails_update:
|
||||
existing = list(rails.get("subgoals", []))
|
||||
existing.extend(rails_update["new_subgoals"])
|
||||
rails["subgoals"] = existing
|
||||
if "completed_subgoals" in rails_update:
|
||||
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
|
||||
|
||||
# Add RAG facts from orchestrator response
|
||||
rag_facts = parsed.get("rag_facts", []) or []
|
||||
if rag_facts:
|
||||
from app.core.rag import get_rag
|
||||
from app.models import GlossaryEntry
|
||||
rag = await get_rag(settings_map)
|
||||
for f in rag_facts:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
entry = GlossaryEntry(
|
||||
world_id=world.id,
|
||||
session_id=session_id,
|
||||
kind=f.get("kind", "lore"),
|
||||
name=f.get("name", "unknown"),
|
||||
description=f.get("description", ""),
|
||||
payload={},
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
await rag.upsert_glossary(
|
||||
world_id=world.id,
|
||||
entry_id=entry.id,
|
||||
kind=entry.kind,
|
||||
name=entry.name,
|
||||
description=entry.description,
|
||||
payload={},
|
||||
settings_map=settings_map,
|
||||
)
|
||||
|
||||
# Update session last_played_at
|
||||
session.last_played_at = datetime.now(timezone.utc)
|
||||
|
||||
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 "")
|
||||
|
||||
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": fired_now,
|
||||
},
|
||||
}
|
||||
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)
|
||||
)
|
||||
row = result.first()
|
||||
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
|
||||
0
backend/app/engine/tools/__init__.py
Normal file
0
backend/app/engine/tools/__init__.py
Normal file
295
backend/app/engine/tools/tools.py
Normal file
295
backend/app/engine/tools/tools.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Tool definitions and handlers for the orchestrator's tool-calling loop."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.llm import build_tool_schema
|
||||
from app.core.rag import get_rag
|
||||
from app.core.state_validator import apply_patch, validate_state
|
||||
from app.logging_setup import get_logger
|
||||
from app.models import DeferredTrigger, GlossaryEntry, World
|
||||
|
||||
log = get_logger("tools")
|
||||
|
||||
|
||||
# === Tool schemas (OpenAI function-calling format) ===
|
||||
|
||||
DICE_ROLL_SCHEMA = build_tool_schema(
|
||||
name="dice_roll",
|
||||
description="Roll dice. Use 'sides' (e.g. 20 for d20) and optional 'count' (default 1) and 'modifier'. Returns the rolls and total.",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sides": {"type": "integer", "description": "Number of sides on the die, e.g. 20 for d20"},
|
||||
"count": {"type": "integer", "description": "Number of dice to roll", "default": 1},
|
||||
"modifier": {"type": "integer", "description": "Modifier to add to total", "default": 0},
|
||||
"label": {"type": "string", "description": "What this roll represents, e.g. 'attack' or 'perception'"},
|
||||
},
|
||||
"required": ["sides"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
UPDATE_STATE_SCHEMA = build_tool_schema(
|
||||
name="update_state",
|
||||
description="Apply a patch to world state. Paths use dot notation. ops: set, unset, append, increment, remove.",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"patch": {
|
||||
"type": "object",
|
||||
"description": "JSON-patch object with optional keys: set, unset, append, increment, remove. Each is a dict of path->value (or list of paths for unset).",
|
||||
"properties": {
|
||||
"set": {"type": "object"},
|
||||
"unset": {"type": "array", "items": {"type": "string"}},
|
||||
"append": {"type": "object"},
|
||||
"increment": {"type": "object"},
|
||||
"remove": {"type": "object"},
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["patch"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
RAG_QUERY_SCHEMA = build_tool_schema(
|
||||
name="rag_query",
|
||||
description="Search the glossary (NPCs, locations, items, lore) for relevant facts.",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Free-text search query"},
|
||||
"limit": {"type": "integer", "description": "Max results", "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
RAG_ADD_SCHEMA = build_tool_schema(
|
||||
name="rag_add",
|
||||
description="Add a new entry to the glossary (NPC, location, item, lore, event).",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event", "rule"]},
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"payload": {"type": "object", "description": "Optional extra fields"},
|
||||
},
|
||||
"required": ["kind", "name", "description"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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.",
|
||||
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"},
|
||||
},
|
||||
"required": ["fire_at", "description"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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.",
|
||||
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"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
RUN_SUBAGENT_SCHEMA = build_tool_schema(
|
||||
name="run_subagent",
|
||||
description="Spawn a sub-agent with clean context for a focused sub-task (e.g. generate NPC backstory, room description).",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {"type": "string", "description": "The specific task for the sub-agent"},
|
||||
"context": {"type": "string", "description": "Minimal context needed (max 200 words)"},
|
||||
},
|
||||
"required": ["task"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
ALL_TOOL_SCHEMAS = [
|
||||
DICE_ROLL_SCHEMA,
|
||||
UPDATE_STATE_SCHEMA,
|
||||
RAG_QUERY_SCHEMA,
|
||||
RAG_ADD_SCHEMA,
|
||||
SCHEDULE_TRIGGER_SCHEMA,
|
||||
ADVANCE_TIME_SCHEMA,
|
||||
RUN_SUBAGENT_SCHEMA,
|
||||
]
|
||||
|
||||
|
||||
# === Tool handlers ===
|
||||
|
||||
class ToolContext:
|
||||
"""Holds everything tools need to execute."""
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
world: World,
|
||||
session_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
subagent_runner: Optional[Callable[[str, str], Awaitable[str]]] = None,
|
||||
settings_map: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.db = db
|
||||
self.world = world
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
self.subagent_runner = subagent_runner
|
||||
self.settings_map = settings_map or {}
|
||||
# Track time advancement during this iteration
|
||||
self.time_advance: Dict[str, int] = {"days": 0, "hours": 0, "minutes": 0}
|
||||
# Track scheduled triggers
|
||||
self.scheduled_triggers: List[Dict[str, Any]] = []
|
||||
# Track rag facts added
|
||||
self.rag_added: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
async def handle_tool_call(name: str, args: Dict[str, Any], ctx: ToolContext) -> Dict[str, Any]:
|
||||
if name == "dice_roll":
|
||||
sides = int(args.get("sides", 20))
|
||||
count = int(args.get("count", 1))
|
||||
modifier = int(args.get("modifier", 0))
|
||||
label = args.get("label", "")
|
||||
rolls = [random.randint(1, sides) for _ in range(max(1, count))]
|
||||
total = sum(rolls) + modifier
|
||||
return {"rolls": rolls, "modifier": modifier, "total": total, "label": label}
|
||||
|
||||
if name == "update_state":
|
||||
patch = args.get("patch", {})
|
||||
new_state = apply_patch(ctx.world.state, patch)
|
||||
schema = ctx.world.definition.get("world_schema", {})
|
||||
ok, errors = validate_state(new_state, schema)
|
||||
if not ok:
|
||||
return {"ok": False, "errors": errors, "state_unchanged": True}
|
||||
ctx.world.state = new_state
|
||||
return {"ok": True, "new_state_summary": _summarize_state(new_state)}
|
||||
|
||||
if name == "rag_query":
|
||||
query = args.get("query", "")
|
||||
limit = int(args.get("limit", 5))
|
||||
rag = await get_rag(ctx.settings_map)
|
||||
results = await rag.search_glossary(ctx.world.id, query, limit=limit, settings_map=ctx.settings_map)
|
||||
return {"results": results}
|
||||
|
||||
if name == "rag_add":
|
||||
kind = args.get("kind", "lore")
|
||||
entry_name = args.get("name", "")
|
||||
desc = args.get("description", "")
|
||||
extra = args.get("payload", {}) or {}
|
||||
entry = GlossaryEntry(
|
||||
world_id=ctx.world.id,
|
||||
session_id=ctx.session_id,
|
||||
kind=kind,
|
||||
name=entry_name,
|
||||
description=desc,
|
||||
payload=extra,
|
||||
)
|
||||
ctx.db.add(entry)
|
||||
await ctx.db.flush()
|
||||
rag = await get_rag(ctx.settings_map)
|
||||
await rag.upsert_glossary(
|
||||
world_id=ctx.world.id,
|
||||
entry_id=entry.id,
|
||||
kind=kind,
|
||||
name=entry_name,
|
||||
description=desc,
|
||||
payload=extra,
|
||||
settings_map=ctx.settings_map,
|
||||
)
|
||||
ctx.rag_added.append({"kind": kind, "name": entry_name, "description": desc})
|
||||
return {"ok": True, "entry_id": str(entry.id)}
|
||||
|
||||
if name == "schedule_trigger":
|
||||
fire_at = args.get("fire_at", "")
|
||||
description = args.get("description", "")
|
||||
payload = args.get("payload", {}) or {}
|
||||
trigger = DeferredTrigger(
|
||||
session_id=ctx.session_id,
|
||||
fire_at=fire_at,
|
||||
description=description,
|
||||
payload=payload,
|
||||
)
|
||||
ctx.db.add(trigger)
|
||||
await ctx.db.flush()
|
||||
ctx.scheduled_triggers.append({
|
||||
"id": str(trigger.id),
|
||||
"fire_at": fire_at,
|
||||
"description": description,
|
||||
})
|
||||
return {"ok": True, "trigger_id": str(trigger.id)}
|
||||
|
||||
if name == "advance_time":
|
||||
days = int(args.get("days", 0))
|
||||
hours = int(args.get("hours", 0))
|
||||
minutes = int(args.get("minutes", 0))
|
||||
ctx.time_advance["days"] += days
|
||||
ctx.time_advance["hours"] += hours
|
||||
ctx.time_advance["minutes"] += minutes
|
||||
return {
|
||||
"ok": True,
|
||||
"advance": {"days": days, "hours": hours, "minutes": minutes},
|
||||
"reason": args.get("reason", ""),
|
||||
}
|
||||
|
||||
if name == "run_subagent":
|
||||
if ctx.subagent_runner is None:
|
||||
return {"error": "subagent_runner_not_available"}
|
||||
task = args.get("task", "")
|
||||
context = args.get("context", "")
|
||||
try:
|
||||
result = await ctx.subagent_runner(task, context)
|
||||
return {"result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
return {"error": f"unknown_tool: {name}"}
|
||||
|
||||
|
||||
def _summarize_state(state: Dict[str, Any]) -> str:
|
||||
"""Quick human-readable summary of state for the LLM."""
|
||||
if not state:
|
||||
return "(empty)"
|
||||
parts: List[str] = []
|
||||
player = state.get("player", {})
|
||||
if player:
|
||||
name = player.get("name", "?")
|
||||
stats = player.get("stats", {})
|
||||
location = player.get("location", "?")
|
||||
hp = stats.get("health", "?")
|
||||
hp_max = stats.get("health_max", "?")
|
||||
mp = stats.get("mana", "?")
|
||||
parts.append(f"player={name} hp={hp}/{hp_max} mp={mp} loc={location}")
|
||||
inv = player.get("inventory", []) if isinstance(player, dict) else []
|
||||
if inv:
|
||||
parts.append("inv=" + ", ".join(f"{i.get('name','?')}x{i.get('qty',1)}" for i in inv[:8]))
|
||||
npcs = state.get("npcs", [])
|
||||
if npcs:
|
||||
parts.append(f"npcs={len(npcs)}")
|
||||
return " | ".join(parts)
|
||||
267
backend/app/engine/world_builder.py
Normal file
267
backend/app/engine/world_builder.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user