This commit is contained in:
Mikan
2026-06-20 19:13:05 +03:00
parent 32575e217e
commit 8514c63ec6
193 changed files with 22105 additions and 11660 deletions

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Empty package marker."""

153
app/core/embeddings.py Normal file
View File

@@ -0,0 +1,153 @@
"""Embedders for RAG.
Two implementations:
- `HashEmbedder`: offline, deterministic bag-of-words + hash projection. Used for dev/test.
- `OpenAIEmbedder`: calls an OpenAI-compatible embeddings API at runtime.
The active embedder is chosen via `settings.embeddings.provider`.
"""
from __future__ import annotations
import hashlib
import math
import re
from collections import Counter
from typing import Protocol, runtime_checkable
import httpx
from app.core.logging import get_logger
_logger = get_logger(__name__)
_WORD_RE = re.compile(r"\w+", re.UNICODE)
def _tokenize(text: str) -> list[str]:
return [w.lower() for w in _WORD_RE.findall(text)]
@runtime_checkable
class Embedder(Protocol):
async def embed(self, texts: list[str]) -> list[list[float]]: ...
@property
def dimension(self) -> int: ...
class HashEmbedder:
"""Offline bag-of-words embedder with hash projection.
Not semantically meaningful, but deterministic and fast — sufficient for
integration tests and local dev. Cosine similarity is non-zero only when
texts share tokens.
"""
def __init__(self, dimension: int = 256):
if dimension <= 0:
raise ValueError("dimension must be positive")
self._dim = dimension
@property
def dimension(self) -> int:
return self._dim
async def embed(self, texts: list[str]) -> list[list[float]]:
out: list[list[float]] = []
for text in texts:
out.append(self._hash_project(text))
return out
def _hash_project(self, text: str) -> list[float]:
vec = [0.0] * self._dim
tokens = _tokenize(text)
if not tokens:
return vec
counts = Counter(tokens)
for token, count in counts.items():
h = hashlib.md5(token.encode("utf-8")).digest()
# Use first 4 bytes for index, next 4 bytes for sign
idx = int.from_bytes(h[:4], "little") % self._dim
sign = 1.0 if (h[4] & 1) == 0 else -1.0
vec[idx] += sign * math.sqrt(count)
# L2 normalize
norm = math.sqrt(sum(v * v for v in vec))
if norm > 0:
vec = [v / norm for v in vec]
return vec
class OpenAIEmbedder:
"""OpenAI-compatible embeddings API client."""
def __init__(
self,
api_url: str,
api_key: str,
model: str,
dimension: int,
timeout: float = 30.0,
batch_size: int = 32,
):
self._api_url = api_url.rstrip("/")
self._api_key = api_key
self._model = model
self._dim = dimension
self._timeout = timeout
self._batch_size = batch_size
@property
def dimension(self) -> int:
return self._dim
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
out: list[list[float]] = []
async with httpx.AsyncClient(timeout=self._timeout) as client:
for i in range(0, len(texts), self._batch_size):
batch = texts[i : i + self._batch_size]
resp = await client.post(
f"{self._api_url}/embeddings",
headers={"Authorization": f"Bearer {self._api_key}"},
json={"model": self._model, "input": batch},
)
resp.raise_for_status()
data = resp.json()
# Sort by index to preserve order
sorted_data = sorted(data["data"], key=lambda x: x["index"])
out.extend(d["embedding"] for d in sorted_data)
return out
async def probe_dimension(self, sample_text: str = "hello world") -> int:
"""Make a single embedding call and return the dimension of the result.
Useful for the "auto-probe dimension" admin button.
"""
result = await self.embed([sample_text])
if not result:
raise RuntimeError("Empty embeddings response")
return len(result[0])
def build_hash_embedder(dimension: int) -> HashEmbedder:
return HashEmbedder(dimension=dimension)
def build_openai_embedder(
api_url: str,
api_key: str,
model: str,
dimension: int,
timeout: float = 30.0,
batch_size: int = 32,
) -> OpenAIEmbedder:
return OpenAIEmbedder(
api_url=api_url,
api_key=api_key,
model=model,
dimension=dimension,
timeout=timeout,
batch_size=batch_size,
)

462
app/core/llm.py Normal file
View File

@@ -0,0 +1,462 @@
"""LLM client — OpenAI-compatible API wrapper with retry, logging, and streaming.
Usage:
client = LlmClient.from_settings(settings_dict)
resp = await client.complete(
stage="orchestrator_phase1",
messages=[{"role": "system", "content": "..."}, ...],
tools=[...], # optional
temperature=0.7,
max_tokens=2048,
stream=False, # if True, returns an async iterator of deltas
user_id=..., world_id=..., step_id=...,
)
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models import LlmCallLog
_logger = get_logger(__name__)
class LLMError(Exception):
"""Base LLM error."""
def __init__(self, code: str, message: str, status: str = "api_error"):
super().__init__(message)
self.code = code
self.status = status
class LLMTimeoutError(LLMError):
def __init__(self, message: str = "LLM call timed out"):
super().__init__("llm_timeout", message, status="timeout")
class LLMUnavailableError(LLMError):
def __init__(self, message: str = "LLM provider unavailable"):
super().__init__("llm_unavailable", message, status="api_error")
class LLMResponseError(LLMError):
def __init__(self, message: str, code: str = "parse_error"):
super().__init__(code, message, status="parse_error")
class LlmClient:
"""OpenAI-compatible LLM client with retry, logging, and streaming."""
def __init__(
self,
api_url: str,
api_key: str,
model: str,
timeout: float = 60.0,
max_retries: int = 3,
):
self._api_url = api_url.rstrip("/")
self._api_key = api_key
self._model = model
self._timeout = timeout
self._max_retries = max_retries
# ------------------------------------------------------------------ #
# Construction
# ------------------------------------------------------------------ #
@classmethod
def from_settings(cls, settings: dict[str, Any]) -> "LlmClient":
return cls(
api_url=settings.get("llm.api_url", "http://localhost:11434/v1"),
api_key=settings.get("llm.api_key", ""),
model=settings.get("llm.model", "qwen2.5-7b-instruct"),
timeout=float(settings.get("llm.timeout_seconds", 60)),
)
# ------------------------------------------------------------------ #
# Non-streaming call
# ------------------------------------------------------------------ #
async def complete(
self,
*,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None = None,
tool_choice: Any = None,
temperature: float = 0.7,
top_p: float = 0.9,
max_tokens: int = 2048,
user_id: uuid.UUID | None = None,
world_id: uuid.UUID | None = None,
step_id: uuid.UUID | None = None,
session: AsyncSession | None = None,
stream: bool = False,
) -> dict[str, Any]:
"""Make a non-streaming chat completion call.
Returns a dict with keys:
- `message`: assistant message (with `content` and optional `tool_calls`)
- `finish_reason`: stop | length | tool_calls
- `prompt_tokens`, `completion_tokens`, `latency_ms`
- `log_id`: id of the LlmCallLog row if `session` provided
"""
if stream:
raise ValueError("Use stream_complete() for streaming calls")
payload: dict[str, Any] = {
"model": self._model,
"messages": messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
start = time.monotonic()
last_exc: Exception | None = None
for attempt in range(self._max_retries):
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
f"{self._api_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json=payload,
)
if resp.status_code >= 500:
raise LLMUnavailableError(
f"LLM provider returned {resp.status_code}: {resp.text[:200]}"
)
if resp.status_code == 429:
raise LLMUnavailableError("LLM provider rate-limited (429)")
if resp.status_code >= 400:
raise LLMResponseError(
f"LLM provider returned {resp.status_code}: {resp.text[:500]}",
code="api_error",
)
data = resp.json()
break
except (httpx.TimeoutException, asyncio.TimeoutError) as e:
last_exc = LLMTimeoutError(str(e))
_logger.warning(
"llm_timeout", stage=stage, attempt=attempt + 1, error=str(e)
)
except (httpx.ConnectError, httpx.NetworkError) as e:
last_exc = LLMUnavailableError(str(e))
_logger.warning(
"llm_connection_error", stage=stage, attempt=attempt + 1, error=str(e)
)
except LLMError as e:
last_exc = e
_logger.warning(
"llm_error", stage=stage, attempt=attempt + 1, error=str(e)
)
# exponential backoff
await asyncio.sleep(min(2**attempt, 4))
else:
# All retries exhausted
if session is not None:
await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message={},
tool_calls=None,
prompt_tokens=None,
completion_tokens=None,
latency_ms=int((time.monotonic() - start) * 1000),
temperature=temperature,
status=last_exc.status if isinstance(last_exc, LLMError) else "api_error",
error_message=str(last_exc) if last_exc else "unknown",
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
assert last_exc is not None
raise last_exc
latency_ms = int((time.monotonic() - start) * 1000)
choice = data["choices"][0]
msg = choice.get("message", {})
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
log_id: uuid.UUID | None = None
if session is not None:
log_id = await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message=msg,
tool_calls=msg.get("tool_calls"),
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency_ms,
temperature=temperature,
status="ok",
error_message=None,
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
return {
"message": msg,
"finish_reason": finish_reason,
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
"latency_ms": latency_ms,
"log_id": log_id,
}
# ------------------------------------------------------------------ #
# Streaming call
# ------------------------------------------------------------------ #
async def stream_complete(
self,
*,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None = None,
tool_choice: Any = None,
temperature: float = 0.85,
top_p: float = 0.95,
max_tokens: int = 2048,
user_id: uuid.UUID | None = None,
world_id: uuid.UUID | None = None,
step_id: uuid.UUID | None = None,
session: AsyncSession | None = None,
) -> AsyncIterator[dict[str, Any]]:
"""Stream chat completion. Yields dicts with keys:
- `delta`: {content?, tool_calls?}
- `finish_reason`: present only on the final chunk
After the iterator is exhausted, the call is logged to `llm_call_logs`.
"""
payload: dict[str, Any] = {
"model": self._model,
"messages": messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
start = time.monotonic()
full_content_parts: list[str] = []
full_tool_calls: list[dict] = []
finish_reason: str | None = None
usage: dict[str, Any] = {}
status = "ok"
error_message: str | None = None
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
async with client.stream(
"POST",
f"{self._api_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json=payload,
) as resp:
if resp.status_code >= 400:
body = await resp.aread()
raise LLMResponseError(
f"LLM provider returned {resp.status_code}: {body.decode('utf-8', 'ignore')[:500]}",
code="api_error",
)
async for line in resp.aiter_lines():
if not line:
continue
if line.startswith("data: "):
line = line[6:]
if line.strip() == "[DONE]":
break
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
if not chunk.get("choices"):
if chunk.get("usage"):
usage = chunk["usage"]
continue
choice = chunk["choices"][0]
delta = choice.get("delta", {})
if delta.get("content"):
full_content_parts.append(delta["content"])
if delta.get("tool_calls"):
full_tool_calls.extend(delta["tool_calls"])
if choice.get("finish_reason"):
finish_reason = choice["finish_reason"]
yield {"delta": delta, "finish_reason": finish_reason}
except Exception as e:
status = "api_error" if not isinstance(e, LLMTimeoutError) else "timeout"
error_message = str(e)
_logger.warning("llm_stream_error", stage=stage, error=error_message)
raise
finally:
latency_ms = int((time.monotonic() - start) * 1000)
if session is not None:
full_content = "".join(full_content_parts)
await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message={
"role": "assistant",
"content": full_content,
"tool_calls": full_tool_calls or None,
},
tool_calls=full_tool_calls or None,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency_ms,
temperature=temperature,
status=status,
error_message=error_message,
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
# ------------------------------------------------------------------ #
# Safe logging (separate transaction)
# ------------------------------------------------------------------ #
async def _write_log_safely(
self,
*,
session: AsyncSession,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None,
response_message: dict[str, Any],
tool_calls: list | None,
prompt_tokens: int | None,
completion_tokens: int | None,
latency_ms: int,
temperature: float,
status: str,
error_message: str | None,
user_id: uuid.UUID | None,
world_id: uuid.UUID | None,
step_id: uuid.UUID | None,
) -> uuid.UUID | None:
"""Insert an LlmCallLog row in a nested transaction so it survives rollback.
Errors here are logged but never raised — logging is best-effort.
"""
try:
async with session.begin_nested():
log = LlmCallLog(
user_id=user_id,
world_id=world_id,
step_id=step_id,
stage=stage,
model=self._model,
request_messages=messages,
request_tools=tools,
response_message=response_message,
tool_calls=tool_calls,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
latency_ms=latency_ms,
temperature=temperature,
status=status,
error_message=error_message,
)
session.add(log)
await session.flush()
log_id = log.id
await session.commit()
return log_id
except Exception as e: # noqa: BLE001
_logger.error("llm_log_write_failed", stage=stage, error=str(e))
try:
await session.rollback()
except Exception:
pass
return None
# --------------------------------------------------------------------------- #
# Mock LLM client (for tests)
# --------------------------------------------------------------------------- #
class MockLlmClient:
"""Replay-based mock LLM client. Returns pre-recorded responses per stage."""
def __init__(self, replay_data: dict[str, list[dict]] | None = None):
self._replay = replay_data or {}
self._call_counts: dict[str, int] = {}
# Allow recording mode
self.recorded_calls: list[dict[str, Any]] = []
def set_replay(self, stage: str, responses: list[dict]) -> None:
self._replay[stage] = responses
self._call_counts.pop(stage, None)
async def complete(self, *, stage: str, messages=None, tools=None, **kwargs) -> dict[str, Any]:
idx = self._call_counts.get(stage, 0)
responses = self._replay.get(stage, [])
if idx >= len(responses):
raise LLMResponseError(
f"Replay exhausted for stage {stage} (call #{idx + 1})",
code="replay_exhausted",
)
resp = responses[idx]
self._call_counts[stage] = idx + 1
self.recorded_calls.append({"stage": stage, "messages": messages, "tools": tools})
# Mimic the real client's return shape
return {
"message": resp.get("message", {"role": "assistant", "content": resp.get("content", "")}),
"finish_reason": resp.get("finish_reason", "stop"),
"prompt_tokens": resp.get("prompt_tokens", 0),
"completion_tokens": resp.get("completion_tokens", 0),
"latency_ms": 0,
"log_id": None,
}
async def stream_complete(self, *, stage: str, messages=None, tools=None, **kwargs):
idx = self._call_counts.get(stage, 0)
responses = self._replay.get(stage, [])
if idx >= len(responses):
raise LLMResponseError(
f"Replay exhausted for stage {stage} (call #{idx + 1})",
code="replay_exhausted",
)
resp = responses[idx]
self._call_counts[stage] = idx + 1
content = resp.get("message", {}).get("content", resp.get("content", ""))
# Yield content in 3 chunks for streaming tests
chunk_size = max(1, len(content) // 3)
for i in range(0, len(content), chunk_size):
yield {"delta": {"content": content[i : i + chunk_size]}, "finish_reason": None}
yield {"delta": {}, "finish_reason": "stop"}
def get_mock_client() -> MockLlmClient:
"""Convenience factory — used in tests and as a fallback in dev when no LLM configured."""
return MockLlmClient()

48
app/core/logging.py Normal file
View File

@@ -0,0 +1,48 @@
"""Application logging setup using structlog."""
from __future__ import annotations
import logging
import sys
import structlog
from app.config import get_settings
def configure_logging() -> None:
"""Configure structlog + stdlib logging once at startup."""
cfg = get_settings()
level = getattr(logging, cfg.log_level.upper(), logging.INFO)
# stdlib root logger
logging.basicConfig(
level=level,
format="%(message)s",
stream=sys.stdout,
)
# structlog processors — JSON output in prod, pretty console in dev
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
if cfg.debug:
renderer = structlog.dev.ConsoleRenderer(colors=True)
else:
renderer = structlog.processors.JSONRenderer()
structlog.configure(
processors=shared_processors + [renderer],
wrapper_class=structlog.make_filtering_bound_logger(level),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
"""Return a structlog logger bound to `name`."""
return structlog.get_logger(name) # type: ignore[return-value]

130
app/core/qdrant_client.py Normal file
View File

@@ -0,0 +1,130 @@
"""Qdrant client wrapper (singleton) with health check."""
from __future__ import annotations
from typing import Any
from qdrant_client import AsyncQdrantClient
from qdrant_client.http.models import (
Distance,
PayloadSchemaType,
VectorParams,
)
from app.config import get_settings
from app.core.logging import get_logger
_logger = get_logger(__name__)
_client: AsyncQdrantClient | None = None
def get_qdrant_client() -> AsyncQdrantClient:
"""Return the singleton AsyncQdrantClient."""
global _client
if _client is None:
cfg = get_settings()
_client = AsyncQdrantClient(
url=cfg.qdrant_url,
api_key=cfg.qdrant_api_key or None,
timeout=cfg.qdrant_timeout,
)
return _client
async def dispose_qdrant_client() -> None:
"""Close the Qdrant client (on shutdown)."""
global _client
if _client is not None:
try:
await _client.close()
except Exception as e: # noqa: BLE001
_logger.warning("qdrant_close_failed", error=str(e))
_client = None
async def ping_qdrant() -> bool:
"""Health-check: returns True if Qdrant responds."""
try:
client = get_qdrant_client()
await client.get_collections()
return True
except Exception as e: # noqa: BLE001
_logger.warning("qdrant_ping_failed", error=str(e))
return False
async def init_qdrant_collections(dimension: int) -> dict[str, Any]:
"""Create collections `entities` and `story_entries` if missing.
Returns a dict with the list of created collection names and the dimension used.
"""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
client = get_qdrant_client()
existing = {c.name for c in (await client.get_collections()).collections}
created: list[str] = []
collections_config = {
f"{prefix}entities": [
("world_id", PayloadSchemaType.KEYWORD),
("entity_type", PayloadSchemaType.KEYWORD),
("deleted", PayloadSchemaType.BOOL),
],
f"{prefix}story_entries": [
("world_id", PayloadSchemaType.KEYWORD),
("entry_type", PayloadSchemaType.KEYWORD),
("created_at", PayloadSchemaType.INTEGER),
],
}
for name, indexes in collections_config.items():
if name in existing:
continue
await client.create_collection(
collection_name=name,
vectors_config=VectorParams(size=dimension, distance=Distance.COSINE),
)
for field, schema_type in indexes:
await client.create_payload_index(name, field, schema_type)
created.append(name)
_logger.info("qdrant_collection_created", name=name, dimension=dimension)
return {"created": created, "dimension": dimension, "existing": sorted(existing)}
async def cleanup_world_points(world_id: str) -> None:
"""Best-effort delete of all Qdrant points for a given world_id."""
from qdrant_client.http.models import (
FieldCondition,
Filter,
FilterSelector,
MatchValue,
)
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
client = get_qdrant_client()
for collection in (f"{prefix}entities", f"{prefix}story_entries"):
try:
await client.delete(
collection_name=collection,
points_selector=FilterSelector(
filter=Filter(
must=[
FieldCondition(
key="world_id",
match=MatchValue(value=str(world_id)),
)
]
)
),
)
except Exception as e: # noqa: BLE001
_logger.error(
"qdrant_cleanup_failed",
collection=collection,
world_id=str(world_id),
error=str(e),
)

326
app/core/rag.py Normal file
View File

@@ -0,0 +1,326 @@
"""RAG — retrieval-augmented generation through Qdrant + PostgreSQL.
Two-stage retrieval:
1. Vector search in Qdrant (filtered by world_id).
2. Hydrate full entity/story-entry data from PostgreSQL by IDs.
"""
from __future__ import annotations
import time
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.embeddings import (
HashEmbedder,
OpenAIEmbedder,
build_hash_embedder,
build_openai_embedder,
)
from app.core.logging import get_logger
from app.core.qdrant_client import get_qdrant_client
from app.models import Entity, StoryEntry
_logger = get_logger(__name__)
_embedder_cache: dict[str, Any] = {}
async def get_embedder():
"""Return the active Embedder based on settings.
Falls back to HashEmbedder if the OpenAI embedder cannot be built.
"""
from app.core.settings_service import get_all_settings
# We can't take a DB session here — use a module-level cache.
# On settings change the admin should hit "test embeddings" which clears the cache.
if "active" in _embedder_cache:
return _embedder_cache["active"]
cfg = get_settings()
provider = cfg.embeddings_provider
if provider == "offline_hash":
emb = build_hash_embedder(cfg.embeddings_dimension)
elif provider == "openai":
api_url = cfg.embeddings_api_url or cfg.llm_api_url
api_key = cfg.embeddings_api_key or cfg.llm_api_key
if not api_url:
_logger.warning("embeddings_openai_no_url_fallback_hash")
emb = build_hash_embedder(cfg.embeddings_dimension)
else:
emb = build_openai_embedder(
api_url=api_url,
api_key=api_key,
model=cfg.embeddings_model,
dimension=cfg.embeddings_dimension,
timeout=float(cfg.embeddings_timeout_seconds),
batch_size=cfg.embeddings_batch_size,
)
else:
_logger.warning("embeddings_unknown_provider_fallback_hash", provider=provider)
emb = build_hash_embedder(cfg.embeddings_dimension)
_embedder_cache["active"] = emb
return emb
def reset_embedder_cache() -> None:
"""Clear the cached embedder (used by admin test endpoints after settings change)."""
_embedder_cache.clear()
async def rag_query(
*,
db: AsyncSession,
world_id: uuid.UUID,
query: str,
limit: int = 5,
filter_type: str = "all",
min_score: float = 0.0,
) -> list[dict[str, Any]]:
"""Semantic search over entities + story_entries via Qdrant."""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
embedder = await get_embedder()
try:
vecs = await embedder.embed([query[: cfg.embeddings_max_text_chars]])
if not vecs:
return []
query_vec = vecs[0]
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_embed_failed", error=str(e))
return []
client = get_qdrant_client()
from qdrant_client.http.models import (
FieldCondition,
Filter,
MatchValue,
)
world_filter = FieldCondition(
key="world_id", match=MatchValue(value=str(world_id))
)
raw_results: list[dict[str, Any]] = []
if filter_type in ("all", "entities"):
try:
ents = await client.search(
collection_name=f"{prefix}entities",
query_vector=query_vec,
query_filter=Filter(
must=[
world_filter,
FieldCondition(
key="deleted", match=MatchValue(value=False)
),
]
),
limit=limit,
score_threshold=min_score,
with_payload=True,
)
for p in ents:
raw_results.append({
"type": "entity",
"id": p.payload.get("entity_id"),
"score": float(p.score),
"name": p.payload.get("name"),
"entity_type": p.payload.get("entity_type"),
})
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_entities_failed", error=str(e))
if filter_type in ("all", "story_entries"):
try:
sts = await client.search(
collection_name=f"{prefix}story_entries",
query_vector=query_vec,
query_filter=Filter(must=[world_filter]),
limit=limit,
score_threshold=min_score,
with_payload=True,
)
for p in sts:
raw_results.append({
"type": "story_entry",
"id": p.payload.get("entry_id"),
"score": float(p.score),
"entry_type": p.payload.get("entry_type"),
})
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_stories_failed", error=str(e))
# Sort and truncate
raw_results.sort(key=lambda r: r["score"], reverse=True)
top = raw_results[:limit]
return await _hydrate(db, top, world_id)
async def _hydrate(
db: AsyncSession, items: list[dict[str, Any]], world_id: uuid.UUID
) -> list[dict[str, Any]]:
"""Stage 2: pull full records from PostgreSQL by IDs."""
entity_ids = [uuid.UUID(i["id"]) for i in items if i["type"] == "entity"]
story_ids = [uuid.UUID(i["id"]) for i in items if i["type"] == "story_entry"]
ents_map: dict[uuid.UUID, Entity] = {}
stories_map: dict[uuid.UUID, StoryEntry] = {}
if entity_ids:
rows = (
await db.execute(
select(Entity).where(
Entity.id.in_(entity_ids), Entity.world_id == world_id
)
)
).scalars().all()
ents_map = {r.id: r for r in rows}
if story_ids:
rows = (
await db.execute(
select(StoryEntry).where(
StoryEntry.id.in_(story_ids), StoryEntry.world_id == world_id
)
)
).scalars().all()
stories_map = {r.id: r for r in rows}
out: list[dict[str, Any]] = []
for i in items:
if i["type"] == "entity":
ent = ents_map.get(uuid.UUID(i["id"]))
if ent and ent.deleted_at is None:
out.append({
**i,
"content": {
"entity_type": ent.entity_type,
"name": ent.name,
"data": ent.data,
},
})
else:
se = stories_map.get(uuid.UUID(i["id"]))
if se:
out.append({
**i,
"content": {
"text": se.content,
"entry_type": se.entry_type,
"metadata": se.metadata_,
},
})
return out
async def rag_add(
*,
db: AsyncSession,
world_id: uuid.UUID,
content: str,
entry_type: str,
metadata: dict | None = None,
step_id: uuid.UUID | None = None,
) -> StoryEntry:
"""Add a story entry and index it in Qdrant (best-effort)."""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
entry = StoryEntry(
world_id=world_id,
content=content,
entry_type=entry_type,
metadata_=metadata or {},
embedding_status="pending",
)
db.add(entry)
await db.flush()
try:
embedder = await get_embedder()
vecs = await embedder.embed([content[: cfg.embeddings_max_text_chars]])
if vecs:
point_id = str(entry.id)
from qdrant_client.http.models import PointStruct
await get_qdrant_client().upsert(
collection_name=f"{prefix}story_entries",
points=[
PointStruct(
id=point_id,
vector=vecs[0],
payload={
"world_id": str(world_id),
"entry_id": point_id,
"entry_type": entry_type,
"step_id": str(step_id) if step_id else None,
"created_at": int(time.time()),
},
)
],
)
entry.qdrant_point_id = point_id
entry.embedding_status = "indexed"
except Exception as e: # noqa: BLE001
_logger.warning("rag_add_embed_failed", entry_id=str(entry.id), error=str(e))
entry.embedding_status = "failed"
await db.flush()
return entry
async def index_entity(
*,
db: AsyncSession,
entity: Entity,
) -> None:
"""Index (or re-index) an entity's vector in Qdrant."""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
text = entity.name + " " + _stringify(entity.data)
try:
embedder = await get_embedder()
vecs = await embedder.embed([text[: cfg.embeddings_max_text_chars]])
if not vecs:
return
point_id = str(entity.id)
from qdrant_client.http.models import PointStruct
await get_qdrant_client().upsert(
collection_name=f"{prefix}entities",
points=[
PointStruct(
id=point_id,
vector=vecs[0],
payload={
"world_id": str(entity.world_id),
"entity_id": point_id,
"entity_type": entity.entity_type,
"name": entity.name,
"deleted": entity.deleted_at is not None,
},
)
],
)
entity.qdrant_point_id = point_id
entity.embedding_status = "indexed"
except Exception as e: # noqa: BLE001
_logger.warning("entity_index_failed", entity_id=str(entity.id), error=str(e))
entity.embedding_status = "failed"
await db.flush()
def _stringify(obj: Any) -> str:
import json
try:
return json.dumps(obj, ensure_ascii=False, default=str)
except Exception: # noqa: BLE001
return str(obj)

85
app/core/security.py Normal file
View File

@@ -0,0 +1,85 @@
"""Security: JWT creation/verification and password hashing."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""Hash a password using bcrypt."""
return _pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Verify a password against its bcrypt hash."""
try:
return _pwd_context.verify(plain, hashed)
except (ValueError, TypeError):
return False
def create_access_token(
subject: str | uuid.UUID,
extra_claims: dict[str, Any] | None = None,
expires_in_minutes: int | None = None,
) -> str:
"""Create a signed JWT access token."""
cfg = get_settings()
minutes = expires_in_minutes or cfg.access_token_expire_minutes
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": str(subject),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=minutes)).timestamp()),
"type": "access",
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm)
def create_refresh_token(
subject: str | uuid.UUID, expires_in_minutes: int | None = None
) -> str:
"""Create a signed JWT refresh token."""
cfg = get_settings()
minutes = expires_in_minutes or cfg.refresh_token_expire_minutes
now = datetime.now(timezone.utc)
payload = {
"sub": str(subject),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=minutes)).timestamp()),
"type": "refresh",
}
return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and verify a JWT. Raises JWTError on failure."""
cfg = get_settings()
return jwt.decode(token, cfg.secret_key, algorithms=[cfg.jwt_algorithm])
def validate_password_strength(password: str) -> list[str]:
"""Return a list of validation errors (empty list = valid password)."""
errors: list[str] = []
if len(password) < 8:
errors.append("Password must be at least 8 characters long")
if not any(c.isalpha() for c in password):
errors.append("Password must contain at least one letter")
if not any(c.isdigit() for c in password):
errors.append("Password must contain at least one digit")
# Tiny blacklist of trivial passwords
blacklist = {"password", "12345678", "qwerty12", "password1", "abcdefgh"}
if password.lower() in blacklist:
errors.append("Password is too common")
return errors

View File

@@ -0,0 +1,187 @@
"""Settings service — runtime overrides from the `settings` table.
Layered:
1. App config (env vars / .env) — `app.config.get_settings()`
2. DB overrides — `settings` table
3. `get_setting(key)` merges them with DB taking precedence.
"""
from __future__ import annotations
import secrets
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Setting
# Default settings written to DB on first run.
# These match the seed list in `docs/AI-RPG_TZ_TDD.md` §5.2.2.
DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
"llm.api_url": {"value": None, "description": "OpenAI-compatible endpoint URL"},
"llm.api_key": {"value": "", "description": "API key for LLM (stored as string)"},
"llm.model": {"value": "qwen2.5-7b-instruct", "description": "Chat model name"},
"llm.temperature_orchestrator": {"value": 0.7, "description": "Phase 1 temperature"},
"llm.temperature_writer": {"value": 0.85, "description": "Phase 2 temperature"},
"llm.max_tokens": {"value": 2048, "description": "Max completion tokens"},
"llm.timeout_seconds": {"value": 60, "description": "LLM call timeout"},
"embeddings.provider": {
"value": "offline_hash",
"description": "offline_hash | openai",
},
"embeddings.api_url": {"value": "", "description": "OpenAI-compatible embeddings URL"},
"embeddings.api_key": {"value": "", "description": "API key for embeddings"},
"embeddings.model": {"value": "text-embedding-3-small", "description": "Embedding model"},
"embeddings.dimension": {"value": 256, "description": "Embedding dimension"},
"embeddings.timeout_seconds": {"value": 30, "description": "Embeddings API timeout"},
"embeddings.batch_size": {"value": 32, "description": "Batch size for embeddings API"},
"embeddings.cache_ttl_seconds": {"value": 300, "description": "LRU cache TTL"},
"embeddings.max_text_chars": {"value": 4000, "description": "Text truncation before embedding"},
"context.guaranteed_messages": {"value": 10, "description": "Always-in-context messages"},
"context.compression_threshold_messages": {"value": 20, "description": "Compression threshold"},
"context.compression_threshold_tokens": {"value": 6000, "description": "Token-based threshold"},
"context.scene_text_truncate_tokens": {"value": 500, "description": "scene_text truncation"},
"context.auto_rag_on_entity_mention": {"value": False, "description": "Auto RAG on entity mention"},
"context.safety_margin_tokens": {"value": 500, "description": "Safety margin from edge"},
"qdrant.url": {"value": "http://qdrant:6333", "description": "Qdrant URL"},
"qdrant.api_key": {"value": "", "description": "Qdrant API key"},
"qdrant.collection_prefix": {"value": "", "description": "Collection prefix"},
"game.deferred_triggers_enabled": {"value": True, "description": "Enable deferred triggers"},
"game.max_substeps_per_iteration": {"value": 8, "description": "Max Phase 1 substeps"},
"game.max_suggested_actions": {"value": 3, "description": "Max suggested actions"},
"ui.page_title": {"value": "AI-RPG", "description": "Browser tab title"},
"ui.favicon_url": {"value": "/icon.png", "description": "Favicon URL"},
"ui.logo_url": {"value": "/icon.png", "description": "Logo URL"},
"ui.og_image_url": {"value": "", "description": "OpenGraph image URL"},
"admin.setup_token": {"value": "", "description": "Admin setup token"},
}
# Keys whose values should never be returned to the client in plaintext.
SECRET_KEYS = {"llm.api_key", "embeddings.api_key", "qdrant.api_key", "admin.setup_token"}
# Map: setting key -> (env-var attribute on Settings, default value)
ENV_OVERRIDE_MAP = {
"llm.api_url": ("llm_api_url", None),
"llm.api_key": ("llm_api_key", None),
"llm.model": ("llm_model", None),
"llm.timeout_seconds": ("llm_timeout_seconds", None),
"embeddings.provider": ("embeddings_provider", None),
"embeddings.api_url": ("embeddings_api_url", None),
"embeddings.api_key": ("embeddings_api_key", None),
"embeddings.model": ("embeddings_model", None),
"embeddings.dimension": ("embeddings_dimension", None),
"qdrant.url": ("qdrant_url", None),
"qdrant.api_key": ("qdrant_api_key", None),
"qdrant.collection_prefix": ("qdrant_collection_prefix", None),
"ui.page_title": ("ui_page_title", None),
"ui.favicon_url": ("ui_favicon_url", None),
"ui.logo_url": ("ui_logo_url", None),
}
async def seed_default_settings(session: AsyncSession) -> None:
"""Upsert all DEFAULT_SETTINGS rows. Called on application startup."""
existing = (
await session.execute(select(Setting).where(Setting.key.in_(DEFAULT_SETTINGS.keys())))
).scalars().all()
existing_keys = {row.key for row in existing}
cfg = get_settings()
for key, spec in DEFAULT_SETTINGS.items():
if key in existing_keys:
continue
value = spec["value"]
# Apply env-var override on first seed (so docker-compose env wins).
env_attr = ENV_OVERRIDE_MAP.get(key)
if env_attr is not None and env_attr[1] is None:
env_val = getattr(cfg, env_attr[0], None)
if env_val not in (None, ""):
value = env_val
# Special: admin.setup_token — generate random if env not set
if key == "admin.setup_token" and not value:
env_token = cfg.admin_setup_token
value = env_token if env_token else secrets.token_urlsafe(16)
session.add(
Setting(key=key, value=value, description=spec["description"])
)
await session.commit()
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
"""Return all settings as a dict (with env overrides applied for missing keys)."""
rows = (await session.execute(select(Setting))).scalars().all()
cfg = get_settings()
out: dict[str, Any] = {}
for key, spec in DEFAULT_SETTINGS.items():
row = next((r for r in rows if r.key == key), None)
if row is not None:
out[key] = row.value
else:
# Fall back to env-var if present, otherwise spec default
env_attr = ENV_OVERRIDE_MAP.get(key)
env_val = (
getattr(cfg, env_attr[0], None)
if env_attr and env_attr[1] is None
else None
)
out[key] = env_val if env_val not in (None, "") else spec["value"]
return out
async def get_setting(session: AsyncSession, key: str) -> Any:
"""Get a single setting by key, with env override fallback."""
row = (
await session.execute(select(Setting).where(Setting.key == key))
).scalar_one_or_none()
if row is not None:
return row.value
# Env-var fallback
env_attr = ENV_OVERRIDE_MAP.get(key)
if env_attr and env_attr[1] is None:
env_val = getattr(get_settings(), env_attr[0], None)
if env_val not in (None, ""):
return env_val
return DEFAULT_SETTINGS.get(key, {}).get("value")
async def set_setting(session: AsyncSession, key: str, value: Any) -> Any:
"""Upsert a setting value. Returns the new value."""
if key not in DEFAULT_SETTINGS:
# Allow ad-hoc keys but warn in logs
import logging
logging.getLogger(__name__).warning("creating_unregistered_setting", extra={"key": key})
row = (
await session.execute(select(Setting).where(Setting.key == key))
).scalar_one_or_none()
if row is None:
row = Setting(
key=key,
value=value,
description=DEFAULT_SETTINGS.get(key, {}).get("description"),
)
session.add(row)
else:
row.value = value
await session.commit()
return value
def mask_secret(key: str, value: Any) -> Any:
"""Mask secret values for safe display in admin UI."""
if key in SECRET_KEYS and isinstance(value, str) and value:
if len(value) <= 4:
return "****"
return value[:2] + "" + "*" * (min(len(value) - 4, 8)) + value[-2:]
return value
async def get_admin_setup_token(session: AsyncSession) -> str:
"""Return the current admin setup token (generating one if absent)."""
token = await get_setting(session, "admin.setup_token")
if not token:
token = secrets.token_urlsafe(16)
await set_setting(session, "admin.setup_token", token)
return token

307
app/core/state_validator.py Normal file
View File

@@ -0,0 +1,307 @@
"""State validator for environment / entity.data / world schema.
All mutations of `world.environment` and `entity.data` go through this module.
The orchestrator's `env_update` and `entity_update` tools use `apply_patch`.
Validation rules:
- Required fields must be present (per `environment_schema` and entity `schemas`).
- Field types must match the declared type.
- Numeric ranges enforced when `max`/`min` provided.
- Nested `object` / `array` schemas are validated recursively.
"""
from __future__ import annotations
import re
from typing import Any
# Supported primitive JSON-schema type names
_PRIMITIVES = {"string", "integer", "number", "boolean"}
_PATCH_OPS = {"set", "inc", "dec", "append", "remove"}
def validate_state(state: dict[str, Any], schema_fields: list[dict]) -> tuple[bool, list[str]]:
"""Validate `state` against a list of field definitions.
Each field definition has the shape:
{
"name": "player",
"type": "object" | "array" | "string" | ...,
"required": bool,
"properties": [ ... ], # for type=object
"items": { ... }, # for type=array
"min": int, "max": int, # for numeric types
"default": <any>
}
"""
errors: list[str] = []
for field in schema_fields:
name = field.get("name")
if not name:
errors.append("Schema field missing 'name'")
continue
if name not in state:
if field.get("required"):
errors.append(f"Missing required field: {name}")
continue
_validate_value(state[name], field, path=name, errors=errors)
return (len(errors) == 0, errors)
def _validate_value(
value: Any, field_schema: dict, path: str, errors: list[str]
) -> None:
ftype = field_schema.get("type", "string")
if ftype in _PRIMITIVES:
_validate_primitive(value, ftype, field_schema, path, errors)
elif ftype == "object":
if not isinstance(value, dict):
errors.append(f"{path} must be object")
return
props = field_schema.get("properties", [])
# validate child fields
ok, child_errors = validate_state(value, props)
if not ok:
errors.extend(child_errors)
elif ftype == "array":
if not isinstance(value, list):
errors.append(f"{path} must be array")
return
items_schema = field_schema.get("items")
if items_schema:
for i, item in enumerate(value):
_validate_value(item, items_schema, f"{path}[{i}]", errors)
else:
errors.append(f"{path}: unknown type {ftype!r}")
def _validate_primitive(
value: Any, ftype: str, field_schema: dict, path: str, errors: list[str]
) -> None:
if ftype == "string":
if not isinstance(value, str):
errors.append(f"{path} must be string")
return
elif ftype == "integer":
if isinstance(value, bool) or not isinstance(value, int):
errors.append(f"{path} must be integer")
return
elif ftype == "number":
if isinstance(value, bool) or not isinstance(value, (int, float)):
errors.append(f"{path} must be number")
return
elif ftype == "boolean":
if not isinstance(value, bool):
errors.append(f"{path} must be boolean")
return
# Range checks
if ftype in ("integer", "number"):
mn = field_schema.get("min")
mx = field_schema.get("max")
if mn is not None and value < mn:
errors.append(f"{path} must be >= {mn}, got {value}")
if mx is not None and value > mx:
errors.append(f"{path} must be <= {mx}, got {value}")
# ---------------------------------------------------------------------------
# Patch application
# ---------------------------------------------------------------------------
_PATH_TOKEN_RE = re.compile(r"\.?([^\.\[\]]+)|\[(\d+)\]")
def _split_path(path: str) -> list[tuple[str, int | None]]:
"""Split a dotted path into tokens. Supports `arr[0].field` syntax."""
tokens: list[tuple[str, int | None]] = []
for m in _PATH_TOKEN_RE.finditer(path):
if m.group(1) is not None and m.group(1) != "":
tokens.append((m.group(1), None))
elif m.group(2) is not None:
tokens.append(("", int(m.group(2))))
return tokens
def _navigate(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, Any, str]:
"""Walk into state along tokens. Returns (ok, value, error)."""
cur = state
for i, (key, idx) in enumerate(tokens):
if idx is not None:
if not isinstance(cur, list):
return False, None, f"cannot index into non-list at {'.'.join(t[0] for t in tokens[:i])}"
if idx >= len(cur):
return False, None, f"index {idx} out of range"
cur = cur[idx]
else:
if not isinstance(cur, dict):
return False, None, f"cannot key into non-object at {'.'.join(t[0] for t in tokens[:i])}"
if key not in cur:
return False, None, f"key {key!r} not found"
cur = cur[key]
return True, cur, ""
def _set_path(state: Any, tokens: list[tuple[str, int | None]], value: Any) -> tuple[bool, str]:
"""Set value at path, creating intermediate dicts as needed."""
if not tokens:
return False, "empty path"
cur = state
for i, (key, idx) in enumerate(tokens[:-1]):
nxt_key, nxt_idx = tokens[i + 1]
if idx is not None:
# current is list — descend by index
if not isinstance(cur, list):
return False, "cannot index non-list"
while len(cur) <= idx:
cur.append({})
cur = cur[idx]
else:
if not isinstance(cur, dict):
return False, "cannot key non-object"
if key not in cur:
cur[key] = [] if nxt_idx is not None else {}
cur = cur[key]
# last token
last_key, last_idx = tokens[-1]
if last_idx is not None:
if not isinstance(cur, list):
return False, "cannot index non-list"
while len(cur) <= last_idx:
cur.append(None)
cur[last_idx] = value
else:
if not isinstance(cur, dict):
return False, "cannot key non-object"
cur[last_key] = value
return True, ""
def _remove_path(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, str]:
"""Remove the value at path."""
if not tokens:
return False, "empty path"
parent_tokens = tokens[:-1]
ok, parent, err = _navigate(state, parent_tokens)
if not ok:
return False, err
last_key, last_idx = tokens[-1]
if last_idx is not None:
if not isinstance(parent, list):
return False, "cannot index non-list"
if last_idx >= len(parent):
return False, "index out of range"
parent.pop(last_idx)
else:
if not isinstance(parent, dict):
return False, "cannot key non-object"
if last_key not in parent:
return False, f"key {last_key!r} not found"
del parent[last_key]
return True, ""
def apply_patch(state: dict[str, Any], patch: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""Apply a patch to `state`. Returns (new_state, errors).
Patch format: `{field_path: new_value | {op: ..., by: N | value: V}}`.
Supported ops: `set` (default), `inc`, `dec`, `append`, `remove`.
The state is mutated in place — pass a deepcopy if you need to preserve the original.
"""
import copy
state = copy.deepcopy(state)
errors: list[str] = []
for path, op_spec in patch.items():
tokens = _split_path(path)
if not tokens:
errors.append(f"invalid path: {path!r}")
continue
# Determine if this is an op-dict or a direct value
if isinstance(op_spec, dict) and "op" in op_spec and op_spec["op"] in _PATCH_OPS:
op = op_spec["op"]
if op == "set":
ok, err = _set_path(state, tokens, op_spec.get("value"))
if not ok:
errors.append(f"{path}: {err}")
elif op in ("inc", "dec"):
by = op_spec.get("by", 1)
if op == "dec":
by = -by
ok, cur, err = _navigate(state, tokens)
if not ok:
# create with the delta value
ok2, err2 = _set_path(state, tokens, by)
if not ok2:
errors.append(f"{path}: {err2}")
else:
if isinstance(cur, bool) or not isinstance(cur, (int, float)):
errors.append(f"{path}: cannot {op} non-number")
else:
ok2, err2 = _set_path(state, tokens, cur + by)
if not ok2:
errors.append(f"{path}: {err2}")
elif op == "append":
value = op_spec.get("value")
ok, cur, err = _navigate(state, tokens)
if not ok:
# create empty list, then append
ok2, err2 = _set_path(state, tokens, [value])
if not ok2:
errors.append(f"{path}: {err2}")
else:
if not isinstance(cur, list):
errors.append(f"{path}: cannot append to non-list")
else:
cur.append(value)
elif op == "remove":
ok, err = _remove_path(state, tokens)
if not ok:
errors.append(f"{path}: {err}")
else:
# Direct value assignment
ok, err = _set_path(state, tokens, op_spec)
if not ok:
errors.append(f"{path}: {err}")
return state, errors
def validate_world(world_dict: dict[str, Any]) -> tuple[bool, list[str]]:
"""Top-level validation of a World dict.
Checks: presence of required keys, types of basic fields, and that
`environment` validates against `environment_schema`.
"""
errors: list[str] = []
required_top = ["name", "language", "schemas", "environment_schema", "environment"]
for k in required_top:
if k not in world_dict:
errors.append(f"Missing required world field: {k}")
# environment must validate against environment_schema
env_schema = world_dict.get("environment_schema", [])
env = world_dict.get("environment", {})
if env_schema and env:
ok, env_errors = validate_state(env, env_schema)
if not ok:
errors.extend(env_errors)
# plot_rails structure
pr = world_dict.get("plot_rails") or {}
for k in ("hooks", "current_goals", "completed_goals"):
if k not in pr:
errors.append(f"plot_rails missing key: {k}")
elif not isinstance(pr[k], list):
errors.append(f"plot_rails.{k} must be list")
# current_time format
ct = world_dict.get("current_time")
if ct:
from app.core.time_utils import GameTime
try:
GameTime.parse(ct)
except ValueError as e:
errors.append(str(e))
return (len(errors) == 0, errors)

148
app/core/time_utils.py Normal file
View File

@@ -0,0 +1,148 @@
"""Helpers for parsing/advancing in-game time strings.
Time format: `day_D_hour_H[_min_M]` (optionally with `year_Y_` prefix).
Examples:
- `day_1_hour_8` -> (1, 8, 0)
- `day_3_hour_14_min_30` -> (3, 14, 30)
- `year_2_day_5_hour_12` -> (2, 5, 12, 0)
Delta format: `[year_Y][days_D][hours_H][min_M]`
Examples: `hours_2_min_30`, `days_1`, `min_15`, `days_3_hours_2`
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Iterable
_TIME_RE = re.compile(
r"^(?:year_(\d+)_)?day_(\d+)_hour_(\d+)(?:_min_(\d+))?$"
)
_DELTA_RE = re.compile(
r"^(?:(?:year_(\d+)_)?(?:days_(\d+)_)?(?:hours_(\d+)_)?(?:min_(\d+))?)$"
)
@dataclass(frozen=True)
class GameTime:
year: int = 1
day: int = 1
hour: int = 0
minute: int = 0
def __post_init__(self):
if self.year < 1 or self.day < 1 or self.hour < 0 or self.minute < 0:
raise ValueError(f"Invalid GameTime: {self}")
if self.hour > 23:
raise ValueError(f"Hour out of range: {self.hour}")
if self.minute > 59:
raise ValueError(f"Minute out of range: {self.minute}")
@classmethod
def parse(cls, s: str) -> "GameTime":
m = _TIME_RE.match(s.strip())
if not m:
raise ValueError(f"Invalid time string: {s!r}")
year = int(m.group(1)) if m.group(1) else 1
day = int(m.group(2))
hour = int(m.group(3))
minute = int(m.group(4)) if m.group(4) else 0
return cls(year=year, day=day, hour=hour, minute=minute)
def to_string(self) -> str:
parts = []
if self.year != 1:
parts.append(f"year_{self.year}")
parts.append(f"day_{self.day}")
parts.append(f"hour_{self.hour}")
if self.minute:
parts.append(f"min_{self.minute}")
return "_".join(parts)
def total_minutes(self, hours_in_day: int = 24) -> int:
"""Total minutes since the start of year 1, day 1, hour 0."""
return (
(self.year - 1) * 365 * hours_in_day * 60
+ (self.day - 1) * hours_in_day * 60
+ self.hour * 60
+ self.minute
)
@classmethod
def from_total_minutes(cls, total: int, hours_in_day: int = 24) -> "GameTime":
year_len = 365 * hours_in_day * 60
day_len = hours_in_day * 60
year = total // year_len + 1
rem = total % year_len
day = rem // day_len + 1
rem = rem % day_len
hour = rem // 60
minute = rem % 60
return cls(year=year, day=day, hour=hour, minute=minute)
def parse_delta(delta: str) -> tuple[int, int, int, int]:
"""Parse a delta string, return (years, days, hours, minutes).
Accepted formats:
- `hours_2`, `min_30`, `days_1`, `year_2`
- `hours_2_min_30`, `days_3_hours_4`, `year_1_days_5_hours_2_min_15`
- `hours_2min_30` (no separator between components — also accepted)
"""
s = delta.strip()
if not s:
raise ValueError("Empty delta string")
parts: dict[str, int] = {"year": 0, "days": 0, "hours": 0, "min": 0}
# Use finditer to walk the string and ensure full coverage
pos = 0
matches = list(re.finditer(r"(year|days|hours|min)_(\d+)", s))
if not matches:
raise ValueError(f"Invalid delta string: {delta!r}")
for m in matches:
# Between matches, only underscores are allowed
gap = s[pos:m.start()]
if any(c != "_" for c in gap):
raise ValueError(f"Invalid delta string: {delta!r}")
parts[m.group(1)] += int(m.group(2))
pos = m.end()
# Trailing chars must also be underscores only
trailing = s[pos:]
if any(c != "_" for c in trailing):
raise ValueError(f"Invalid delta string: {delta!r}")
return (parts["year"], parts["days"], parts["hours"], parts["min"])
def advance_time(current: str, delta: str, time_schema: dict | None = None) -> str:
"""Advance `current` time string by `delta`. Returns new time string."""
schema = time_schema or {"hours_in_day": 24}
hours_in_day = int(schema.get("hours_in_day", 24))
gt = GameTime.parse(current)
y, d, h, mn = parse_delta(delta)
total = gt.total_minutes(hours_in_day) + (
y * 365 * hours_in_day * 60 + d * hours_in_day * 60 + h * 60 + mn
)
new_gt = GameTime.from_total_minutes(total, hours_in_day)
return new_gt.to_string()
def time_le(a: str, b: str) -> bool:
"""Return True if time `a` <= time `b`."""
ga, gb = GameTime.parse(a), GameTime.parse(b)
return ga.total_minutes() <= gb.total_minutes()
def summarize_schemas(schemas: Iterable[dict]) -> str:
"""Render a compact human-readable summary of entity schemas for LLM prompts."""
lines: list[str] = []
for s in schemas:
type_name = s.get("type", "?")
verbose = s.get("verbose", type_name)
props = s.get("properties", [])
prop_str = ", ".join(
f"{p.get('name')}:{p.get('type')}" + ("*" if p.get("required") else "")
for p in props
)
lines.append(f"- {verbose} ({type_name}): {prop_str}")
return "\n".join(lines) if lines else "(no schemas)"