This commit is contained in:
Mikan
2026-06-21 04:11:38 +03:00
parent bd85e186dc
commit 4dee4fb0a8
19 changed files with 1423 additions and 115 deletions

View File

@@ -217,8 +217,34 @@ async def stats(
}
# --------------------------------------------------------------------------- #
# Helpers for test endpoints
# --------------------------------------------------------------------------- #
def _is_masked(value: str | None) -> bool:
"""Detect masked secret values (contain '' or are exactly '****').
The admin GET /settings endpoint masks secret keys before sending them to
the client. If the client sends a masked value back to a test endpoint
(because it pre-filled the form from the masked settings response), we
must ignore it and fall back to the raw value from the DB.
"""
if not value:
return False
return "" in value or value == "****"
def _resolve(value: str | None, fallback: str) -> str:
"""Use `value` if it's a non-empty, non-masked string; otherwise use fallback."""
if value and not _is_masked(value):
return value
return fallback or ""
# --------------------------------------------------------------------------- #
# Test endpoints — LLM, embeddings, embeddings probe dimension
# All test endpoints accept query params AND fall back to DB-stored settings.
# Masked values (containing '…' or '****') are ignored — they come from the
# admin UI's pre-filled form which displays masked secrets.
# --------------------------------------------------------------------------- #
@router.post("/test/llm")
async def test_llm(
@@ -229,9 +255,9 @@ async def test_llm(
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
api_url = api_url or settings.get("llm.api_url", "")
api_key = api_key or settings.get("llm.api_key", "")
model = model or settings.get("llm.model", "")
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
model = _resolve(model, settings.get("llm.model", ""))
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0}
@@ -265,37 +291,49 @@ async def test_llm_tools(
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
api_url = api_url or settings.get("llm.api_url", "")
api_key = api_key or settings.get("llm.api_key", "")
model = model or settings.get("llm.model", "")
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
model = _resolve(model, settings.get("llm.model", ""))
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0, "has_tool_calls": False}
client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1)
client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=30.0, max_retries=1)
start = time.monotonic()
try:
tools = [{
"type": "function",
"function": {
"name": "calc",
"description": "Evaluate a math expression",
"description": "Evaluate a math expression. You MUST call this tool.",
"parameters": {
"type": "object",
"required": ["expression"],
"properties": {"expression": {"type": "string"}},
"properties": {"expression": {"type": "string", "description": "e.g. '2+2'"}},
},
},
}]
resp = await client.complete(
stage="test_llm_tools",
messages=[{"role": "user", "content": "What is 2+2? Use the calc tool."}],
tools=tools, temperature=0.0, max_tokens=100,
messages=[
{"role": "system", "content": "You must use the calc tool to answer math questions. Do not compute in your head."},
{"role": "user", "content": "What is 2+2? You MUST call the calc tool with expression '2+2'."},
],
tools=tools,
tool_choice="auto",
temperature=0.0, max_tokens=100,
session=db,
)
elapsed = int((time.monotonic() - start) * 1000)
tcs = resp["message"].get("tool_calls") or []
# Also try to parse tool calls from content (some models emit them as text)
if not tcs:
content = resp["message"].get("content", "") or ""
parsed_tcs = _parse_text_tool_calls(content)
if parsed_tcs:
tcs = parsed_tcs
return {
"ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed,
"raw_response": resp["message"],
}
except Exception as e: # noqa: BLE001
elapsed = int((time.monotonic() - start) * 1000)
@@ -303,6 +341,50 @@ async def test_llm_tools(
"elapsed_ms": elapsed, "has_tool_calls": False}
# Pattern: call:tool_name{args} or <tool_call>name{args}</tool_call> or name(args)
import re as _re
_TOOL_CALL_PATTERNS = [
# call:name{json_args}
_re.compile(r"call:(\w+)\s*\{([^}]*)\}"),
# <tool_call>name{args}</tool_call>
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
# name({"key": "value", ...})
_re.compile(r"(\w+)\s*\(\s*(\{[^}]*\})\s*\)"),
]
def _parse_text_tool_calls(content: str) -> list[dict]:
"""Parse tool calls emitted as text (some models don't use the OpenAI format).
Handles patterns like:
- call:calc{"expression": "2+2"}
- <tool_call>calc{"expression": "2+2"}</tool_call>
- calc({"expression": "2+2"})
"""
import json as _json
calls: list[dict] = []
for pattern in _TOOL_CALL_PATTERNS:
for match in pattern.finditer(content):
name = match.group(1)
args_str = match.group(2).strip()
try:
args = _json.loads(args_str)
except _json.JSONDecodeError:
# Try to fix common issues (single quotes, missing quotes on keys)
try:
fixed = args_str.replace("'", '"')
args = _json.loads(fixed)
except _json.JSONDecodeError:
args = {"_raw": args_str}
calls.append({
"id": f"parsed_{len(calls)}",
"type": "function",
"function": {"name": name, "arguments": _json.dumps(args)},
})
return calls
@router.post("/test/embeddings")
async def test_embeddings(
api_url: str | None = None,
@@ -324,9 +406,9 @@ async def test_embeddings(
"ok": True, "dimension": emb.dimension, "model": "offline_hash",
"first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed,
}
api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "")
api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "")
model = model or settings.get("embeddings.model", "")
api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", ""))
api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
model = _resolve(model, settings.get("embeddings.model", ""))
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"},
"elapsed_ms": 0}
@@ -366,9 +448,9 @@ async def probe_dimension(
"dimension": int(settings.get("embeddings.dimension", 256)),
"elapsed_ms": 0,
}
api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "")
api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "")
model = model or settings.get("embeddings.model", "")
api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", ""))
api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
model = _resolve(model, settings.get("embeddings.model", ""))
emb = build_openai_embedder(
api_url=api_url, api_key=api_key, model=model,
dimension=int(settings.get("embeddings.dimension", 1536)),

View File

@@ -260,6 +260,171 @@ async def iterate_stream(
)
# --------------------------------------------------------------------------- #
# World editor: accept/reject proposed changes, answer clarifications
# --------------------------------------------------------------------------- #
@router.post("/worlds/{world_id}/apply")
async def apply_proposed_changes(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
) -> dict:
"""Accept proposed changes from the world_editor stream."""
from app.engine.world_editor import submit_propose_changes_answer
world = await _load_world(db, world_id, user)
ok = submit_propose_changes_answer(world.id, True)
if not ok:
raise HTTPException(409, "no_pending_changes")
return {"ok": True}
@router.post("/worlds/{world_id}/discard")
async def discard_proposed_changes(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
) -> dict:
"""Reject proposed changes from the world_editor stream."""
from app.engine.world_editor import submit_propose_changes_answer
world = await _load_world(db, world_id, user)
ok = submit_propose_changes_answer(world.id, False)
if not ok:
raise HTTPException(409, "no_pending_changes")
return {"ok": True}
@router.post("/worlds/{world_id}/answer")
async def answer_clarification(
world_id: uuid.UUID,
body: AnswerRequest,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
) -> dict:
"""Answer an ask_user clarification from the world_editor stream."""
from app.engine.world_editor import submit_clarification_answer
world = await _load_world(db, world_id, user)
ok = submit_clarification_answer(world.id, body.text)
if not ok:
raise HTTPException(409, "no_pending_clarification")
return {"ok": True}
# --------------------------------------------------------------------------- #
# Generate / re-generate intro scene (for worlds stuck in draft)
# --------------------------------------------------------------------------- #
@router.post("/worlds/{world_id}/generate-intro", response_model=dict, status_code=status.HTTP_202_ACCEPTED)
async def generate_intro(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> dict:
"""Re-generate the intro scene for a draft world.
This runs the intro_scene stage of the world_builder flow without
regenerating schemas/entities. Useful when the world was created from
a preset (which provides schemas + entities) but the intro scene
generation failed.
"""
world = await _load_world(db, world_id, user)
if world.status == "archived":
raise HTTPException(422, "world_archived")
return {
"stream_url": f"/api/sessions/worlds/{world.id}/intro/stream",
}
@router.get("/worlds/{world_id}/intro/stream")
async def intro_stream(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> StreamingResponse:
world = await _load_world(db, world_id, user)
emitter = SseEmitter()
llm = _llm_factory(settings)
async def run_bg():
async with _session_scope() as bg_db:
from sqlalchemy import select as sa_select
from app.engine.world_builder import _run_tool_loop, _strip_code_fence
from app.engine.tools.base import ToolContext, get_registry
from app.models import Entity
from app.prompts.registry import get_prompt
from app.core.time_utils import advance_time, summarize_schemas
import json as _json
bg_world = (
await bg_db.execute(sa_select(World).where(World.id == world.id))
).scalar_one()
try:
await emitter.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
entities = (
await bg_db.execute(
sa_select(Entity).where(
Entity.world_id == bg_world.id, Entity.deleted_at.is_(None)
)
)
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities[:20]
) or "(no entities)"
intro_prompt = get_prompt("intro_scene", "en").format(
world_name=bg_world.name,
world_description=bg_world.description or "",
language=bg_world.language,
current_time=bg_world.current_time,
environment_json=_json.dumps(bg_world.environment or {}, ensure_ascii=False, indent=2),
plot_rails_json=_json.dumps(bg_world.plot_rails or {}, ensure_ascii=False, indent=2),
entities_summary=entities_summary,
)
scene_result = await _run_tool_loop(
db=bg_db, world=bg_world, llm=llm, sse=emitter,
stage="intro_scene",
system_prompt=intro_prompt,
terminal_tool="submit_step",
max_substeps=3,
settings={},
)
scene_text = ""
delta_time = "hours_1"
if scene_result and scene_result.get("ok"):
scene_text = scene_result.get("data", {}).get("scene_text", "")
delta_time = scene_result.get("data", {}).get("delta_time", "hours_1")
bg_world.intro_scene = scene_text
bg_world.current_time = advance_time(bg_world.current_time, delta_time, bg_world.time_schema)
bg_world.status = "ready"
await bg_db.commit()
await emitter.emit("intro_scene_complete", {
"text": scene_text, "delta_time": delta_time,
"current_time": bg_world.current_time,
})
await emitter.done({"world_id": str(bg_world.id), "status": "ready"})
except Exception as e:
await emitter.error("internal_error", str(e))
import asyncio
task = asyncio.create_task(run_bg())
async def gen():
try:
async for evt in emitter.stream():
yield _format_sse(evt)
finally:
await task
return StreamingResponse(
gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# --------------------------------------------------------------------------- #
# Retry / rollback
# --------------------------------------------------------------------------- #