rebase
This commit is contained in:
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Empty package marker."""
|
||||
153
app/core/embeddings.py
Normal file
153
app/core/embeddings.py
Normal 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
462
app/core/llm.py
Normal 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
48
app/core/logging.py
Normal 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
130
app/core/qdrant_client.py
Normal 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
326
app/core/rag.py
Normal 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
85
app/core/security.py
Normal 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
|
||||
187
app/core/settings_service.py
Normal file
187
app/core/settings_service.py
Normal 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
307
app/core/state_validator.py
Normal 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
148
app/core/time_utils.py
Normal 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)"
|
||||
Reference in New Issue
Block a user