This commit is contained in:
Mikan
2026-06-19 11:28:04 +03:00
commit 53c89829a8
80 changed files with 12482 additions and 0 deletions

View File

243
backend/app/core/llm.py Normal file
View File

@@ -0,0 +1,243 @@
"""OpenAI-compatible LLM client with tool calling, streaming, and logging."""
from __future__ import annotations
import json
import time
import uuid
from typing import Any, AsyncIterator, Dict, List, Optional
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.settings_service import get_all_settings, cast_setting
from app.logging_setup import get_logger
from app.models import LlmCallLog
log = get_logger("llm")
class LlmResponse:
"""Non-streaming response wrapper."""
def __init__(self, text: str, tool_calls: List[Dict[str, Any]], usage: Optional[Dict[str, int]]):
self.text = text
self.tool_calls = tool_calls
self.usage = usage or {}
class LlmClient:
"""Lightweight OpenAI-compatible chat-completions client."""
def __init__(self, settings_map: Dict[str, Any]):
self.base_url: str = str(settings_map.get("llm.base_url", "")).rstrip("/")
self.api_key: str = str(settings_map.get("llm.api_key", "dummy"))
self.model: str = str(settings_map.get("llm.model", "local-model"))
self.temperature: float = float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7)))
self.max_tokens: int = int(cast_setting("llm.max_tokens", settings_map.get("llm.max_tokens", 1024)))
self.timeout: int = int(cast_setting("llm.request_timeout", settings_map.get("llm.request_timeout", 120)))
self.streaming: bool = bool(cast_setting("llm.streaming", settings_map.get("llm.streaming", True)))
@classmethod
async def from_db(cls, db: AsyncSession) -> "LlmClient":
s = await get_all_settings(db)
return cls(s)
def _headers(self) -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if self.api_key and self.api_key != "dummy":
h["Authorization"] = f"Bearer {self.api_key}"
return h
async def chat(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
purpose: str = "orchestrator",
user_id: Optional[uuid.UUID] = None,
session_id: Optional[uuid.UUID] = None,
db: Optional[AsyncSession] = None,
) -> LlmResponse:
"""Non-streaming chat completion with tool support."""
url = f"{self.base_url}/chat/completions"
payload: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature if temperature is not None else self.temperature,
"max_tokens": max_tokens or self.max_tokens,
"stream": False,
}
if tools:
payload["tools"] = tools
if tool_choice is not None:
payload["tool_choice"] = tool_choice
started = time.monotonic()
err: Optional[str] = None
text = ""
tool_calls: List[Dict[str, Any]] = []
usage: Dict[str, int] = {}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=payload, headers=self._headers())
resp.raise_for_status()
data = resp.json()
choice = (data.get("choices") or [{}])[0]
msg = choice.get("message", {})
text = msg.get("content") or ""
tool_calls = msg.get("tool_calls") or []
usage = data.get("usage") or {}
except Exception as e:
err = f"{type(e).__name__}: {e}"
log.error("llm_call_failed", purpose=purpose, error=err)
raise
finally:
latency_ms = int((time.monotonic() - started) * 1000)
if db is not None:
db.add(LlmCallLog(
user_id=user_id,
session_id=session_id,
purpose=purpose,
model=self.model,
base_url=self.base_url,
prompt_messages=messages,
tools=tools,
response_text=text,
tool_calls=tool_calls,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
total_tokens=usage.get("total_tokens"),
latency_ms=latency_ms,
error=err,
))
try:
await db.commit()
except Exception:
await db.rollback()
return LlmResponse(text=text, tool_calls=tool_calls, usage=usage)
async def stream_chat(
self,
messages: List[Dict[str, Any]],
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Any = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
purpose: str = "orchestrator",
user_id: Optional[uuid.UUID] = None,
session_id: Optional[uuid.UUID] = None,
db: Optional[AsyncSession] = None,
) -> AsyncIterator[Dict[str, Any]]:
"""Streaming chat completion. Yields incremental deltas.
Yields dicts of the form:
{"type": "delta", "content": "..."} - text delta
{"type": "tool_calls", "tool_calls": [...]} - final tool calls (if any)
{"type": "done", "usage": {...}}
{"type": "error", "error": "..."}
"""
url = f"{self.base_url}/chat/completions"
payload: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature if temperature is not None else self.temperature,
"max_tokens": max_tokens or self.max_tokens,
"stream": True,
}
if tools:
payload["tools"] = tools
if tool_choice is not None:
payload["tool_choice"] = tool_choice
started = time.monotonic()
full_text_parts: List[str] = []
tool_call_accum: Dict[int, Dict[str, Any]] = {}
usage: Dict[str, int] = {}
err: Optional[str] = None
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
async with client.stream("POST", url, json=payload, headers=self._headers()) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line or not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") or []
if not choices:
if chunk.get("usage"):
usage = chunk["usage"]
continue
delta = choices[0].get("delta", {})
if delta.get("content"):
full_text_parts.append(delta["content"])
yield {"type": "delta", "content": delta["content"]}
if delta.get("tool_calls"):
for tc in delta["tool_calls"]:
idx = tc.get("index", 0)
acc = tool_call_accum.setdefault(idx, {
"id": tc.get("id", ""),
"type": "function",
"function": {"name": "", "arguments": ""},
})
if tc.get("id"):
acc["id"] = tc["id"]
if tc.get("function", {}).get("name"):
acc["function"]["name"] += tc["function"]["name"]
if tc.get("function", {}).get("arguments"):
acc["function"]["arguments"] += tc["function"]["arguments"]
if chunk.get("usage"):
usage = chunk["usage"]
except Exception as e:
err = f"{type(e).__name__}: {e}"
log.error("llm_stream_failed", purpose=purpose, error=err)
yield {"type": "error", "error": err}
return
full_text = "".join(full_text_parts)
final_tool_calls = [tool_call_accum[i] for i in sorted(tool_call_accum.keys())]
if final_tool_calls:
yield {"type": "tool_calls", "tool_calls": final_tool_calls}
yield {"type": "done", "usage": usage, "full_text": full_text}
latency_ms = int((time.monotonic() - started) * 1000)
if db is not None:
db.add(LlmCallLog(
user_id=user_id,
session_id=session_id,
purpose=purpose,
model=self.model,
base_url=self.base_url,
prompt_messages=messages,
tools=tools,
response_text=full_text,
tool_calls=final_tool_calls,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
total_tokens=usage.get("total_tokens"),
latency_ms=latency_ms,
error=err,
))
try:
await db.commit()
except Exception:
await db.rollback()
def build_tool_schema(name: str, description: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""Helper to build an OpenAI-style tool schema."""
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": params,
},
}

463
backend/app/core/rag.py Normal file
View File

@@ -0,0 +1,463 @@
"""Qdrant RAG client: glossary / facts / history indexing and retrieval.
Embeddings are configurable via admin settings (see `embedding.*` keys):
* `embedding.provider = "hash"` — deterministic offline fallback (no semantic quality).
* `embedding.provider = "openai"` — calls the OpenAI-compatible `/embeddings`
endpoint of `embedding.base_url` (falls back to `llm.base_url` if empty).
Vector dimension (`embedding.dim`) is normally auto-probed from the endpoint on
first use (set it to 0). When the configured dimension changes, the Qdrant
collections are dropped and recreated — already-indexed points are lost, but
they will be repopulated on the next RAG upsert from the engine.
"""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional
import httpx
from qdrant_client import AsyncQdrantClient
from qdrant_client.http import models as qm
from app.config import settings
from app.core.settings_service import cast_setting
from app.logging_setup import get_logger
log = get_logger("rag")
COLLECTION_GLOSSARY = "glossary"
COLLECTION_HISTORY = "history"
ALL_COLLECTIONS = (COLLECTION_GLOSSARY, COLLECTION_HISTORY)
# Fallback dimension for the hash embedder (kept stable across restarts).
HASH_EMBED_DIM = 384
# ---------------------------------------------------------------------------
# Embedders
# ---------------------------------------------------------------------------
class _HashEmbedder:
"""Deterministic lightweight embedder used as an offline fallback.
Not semantically rich, but provides stable vectors for retrieval by keyword
overlap (bag-of-tokens hashed into a fixed-dim vector, L2-normalized).
"""
def __init__(self, dim: int = HASH_EMBED_DIM):
self.dim = dim
async def embed(self, text: str) -> List[float]:
vec = [0.0] * self.dim
tokens = [t for t in text.lower().split() if t]
if not tokens:
return vec
for tok in tokens:
h = abs(hash(tok)) % self.dim
vec[h] += 1.0
h2 = abs(hash(tok + "_b")) % self.dim
vec[h2] += 0.5
norm = sum(v * v for v in vec) ** 0.5
if norm > 0:
vec = [v / norm for v in vec]
return vec
async def probe_dim(self) -> int:
return self.dim
class OpenAIEmbedder:
"""Real embeddings via OpenAI-compatible `/embeddings` endpoint.
Falls back to `_HashEmbedder` per-call if the endpoint is unreachable or
returns an error — so RAG keeps working even if the embeddings server is
temporarily down.
"""
def __init__(
self,
base_url: str,
api_key: str,
model: str,
timeout: int = 60,
fallback_dim: int = HASH_EMBED_DIM,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model or "text-embedding-3-small"
self.timeout = timeout
self._fallback = _HashEmbedder(fallback_dim)
def _headers(self) -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if self.api_key and self.api_key != "dummy":
h["Authorization"] = f"Bearer {self.api_key}"
return h
async def _raw_embed(self, text: str) -> Optional[List[float]]:
url = f"{self.base_url}/embeddings"
payload = {"model": self.model, "input": text}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=payload, headers=self._headers())
resp.raise_for_status()
data = resp.json()
arr = (data.get("data") or [{}])[0].get("embedding") or []
if not arr:
return None
return [float(x) for x in arr]
except Exception as e:
log.warning("openai_embed_failed", model=self.model, error=f"{type(e).__name__}: {e}")
return None
async def embed(self, text: str) -> List[float]:
vec = await self._raw_embed(text)
if vec:
return vec
# Network/endpoint failure — degrade gracefully to hash fallback
return await self._fallback.embed(text)
async def probe_dim(self) -> int:
"""Probe the endpoint with a short text and return the vector dimension.
Returns HASH_EMBED_DIM if the endpoint is unreachable so the system
keeps working (with degraded retrieval quality).
"""
vec = await self._raw_embed("dimension probe")
if vec:
return len(vec)
log.warning("embed_probe_failed_using_hash_dim", dim=HASH_EMBED_DIM)
return HASH_EMBED_DIM
# ---------------------------------------------------------------------------
# RAG client
# ---------------------------------------------------------------------------
class RagClient:
"""Qdrant-backed RAG client with configurable embeddings."""
def __init__(self, url: str | None = None):
url = url or settings.qdrant_url
self.client = AsyncQdrantClient(url=url)
# Cache of {collection_name: configured_dim}. Populated by ensure_collections.
self._collection_dims: Dict[str, int] = {}
# Lazily constructed embedder + its config signature (so we rebuild on settings change).
self._embedder: Optional[Any] = None
self._embedder_sig: Optional[str] = None
self._configured_dim: Optional[int] = None # resolved dim (after probe)
@staticmethod
def _resolve_embedder_config(settings_map: Dict[str, Any]) -> Dict[str, Any]:
provider = str(settings_map.get("embedding.provider", "hash")).lower().strip() or "hash"
base_url = str(settings_map.get("embedding.base_url", "") or "").strip()
if not base_url:
base_url = str(settings_map.get("llm.base_url", "") or "").strip()
api_key = str(settings_map.get("embedding.api_key", "") or "").strip()
if not api_key:
api_key = str(settings_map.get("llm.api_key", "") or "").strip()
model = str(settings_map.get("embedding.model", "text-embedding-3-small") or "text-embedding-3-small")
dim = int(cast_setting("embedding.dim", settings_map.get("embedding.dim", 0)) or 0)
timeout = int(cast_setting("embedding.request_timeout", settings_map.get("embedding.request_timeout", 60)) or 60)
return {
"provider": provider,
"base_url": base_url,
"api_key": api_key,
"model": model,
"dim": dim,
"timeout": timeout,
}
@staticmethod
def _build_embedder(cfg: Dict[str, Any]) -> Any:
if cfg["provider"] == "openai" and cfg["base_url"]:
return OpenAIEmbedder(
base_url=cfg["base_url"],
api_key=cfg["api_key"],
model=cfg["model"],
timeout=cfg["timeout"],
fallback_dim=HASH_EMBED_DIM,
)
return _HashEmbedder(HASH_EMBED_DIM)
def _embedder_signature(self, cfg: Dict[str, Any]) -> str:
# Only fields that affect the produced vector — `dim` is resolved via probe.
return f"{cfg['provider']}|{cfg['base_url']}|{cfg['model']}"
async def get_embedder(self, settings_map: Optional[Dict[str, Any]] = None) -> Any:
"""Return the current embedder, rebuilding it if settings changed.
If `settings_map` is provided and the provider/base_url/model changed,
the embedder is rebuilt and Qdrant collections are reconfigured.
"""
if settings_map is None:
# Caller has no DB context — return whatever is cached.
if self._embedder is None:
self._embedder = _HashEmbedder(HASH_EMBED_DIM)
self._embedder_sig = "hash||"
return self._embedder
cfg = RagClient._resolve_embedder_config(settings_map)
sig = self._embedder_signature(cfg)
if self._embedder is None or sig != self._embedder_sig:
self._embedder = RagClient._build_embedder(cfg)
self._embedder_sig = sig
self._configured_dim = None # force re-probe on next ensure_collections
await self.ensure_collections(settings_map)
return self._embedder
async def _resolve_dim(self, embedder: Any, cfg: Dict[str, Any]) -> int:
if cfg["dim"] and cfg["dim"] > 0:
return cfg["dim"]
if self._configured_dim is not None:
return self._configured_dim
# Auto-probe from the endpoint (or fallback to HASH_EMBED_DIM).
dim = await embedder.probe_dim()
self._configured_dim = dim
log.info("rag_dim_probed", dim=dim, provider=cfg["provider"])
return dim
async def ensure_collections(self, settings_map: Optional[Dict[str, Any]] = None) -> None:
"""Create Qdrant collections if missing; recreate if dim changed.
Recreating drops all points — they will be repopulated by subsequent
upserts from the engine (glossary tool, history indexing).
"""
cfg = RagClient._resolve_embedder_config(settings_map or {})
embedder = await self.get_embedder(settings_map)
desired_dim = await self._resolve_dim(embedder, cfg)
for name in ALL_COLLECTIONS:
existing_dim = await self._get_collection_dim(name)
if existing_dim is None:
try:
await self.client.create_collection(
collection_name=name,
vectors_config=qm.VectorParams(size=desired_dim, distance=qm.Distance.COSINE),
)
self._collection_dims[name] = desired_dim
log.info("rag_collection_created", name=name, dim=desired_dim)
except Exception as e:
log.warning("rag_collection_create_failed", name=name, error=str(e))
elif existing_dim != desired_dim:
log.warning(
"rag_collection_dim_mismatch_recreate",
name=name,
old=existing_dim,
new=desired_dim,
)
try:
await self.client.delete_collection(collection_name=name)
except Exception:
pass
try:
await self.client.create_collection(
collection_name=name,
vectors_config=qm.VectorParams(size=desired_dim, distance=qm.Distance.COSINE),
)
self._collection_dims[name] = desired_dim
except Exception as e:
log.warning("rag_collection_recreate_failed", name=name, error=str(e))
else:
self._collection_dims[name] = existing_dim
async def _get_collection_dim(self, name: str) -> Optional[int]:
try:
info = await self.client.get_collection(collection_name=name)
cfg = info.config.params.vectors
# Qdrant returns either a single VectorParams or a NamedVectors dict
if isinstance(cfg, qm.VectorParams):
return cfg.size
# NamedVectors: take first vector config
if hasattr(cfg, "size") and isinstance(cfg.size, int):
return cfg.size
if isinstance(cfg, dict):
for v in cfg.values():
if hasattr(v, "size") and isinstance(v.size, int):
return v.size
except Exception:
return None
return None
async def embed(self, text: str, settings_map: Optional[Dict[str, Any]] = None) -> List[float]:
embedder = await self.get_embedder(settings_map)
return await embedder.embed(text)
async def upsert_glossary(
self,
world_id: uuid.UUID,
entry_id: uuid.UUID,
kind: str,
name: str,
description: str,
payload: Dict[str, Any],
settings_map: Optional[Dict[str, Any]] = None,
) -> None:
text = f"{kind}: {name}. {description}"
vector = await self.embed(text, settings_map)
await self.client.upsert(
collection_name=COLLECTION_GLOSSARY,
points=[
qm.PointStruct(
id=str(entry_id),
vector=vector,
payload={
"world_id": str(world_id),
"entry_id": str(entry_id),
"kind": kind,
"name": name,
"description": description,
"text": text,
**payload,
},
)
],
)
async def upsert_history(
self,
session_id: uuid.UUID,
message_id: uuid.UUID,
seq: int,
text: str,
kind: str,
settings_map: Optional[Dict[str, Any]] = None,
) -> None:
vector = await self.embed(text, settings_map)
await self.client.upsert(
collection_name=COLLECTION_HISTORY,
points=[
qm.PointStruct(
id=str(message_id),
vector=vector,
payload={
"session_id": str(session_id),
"message_id": str(message_id),
"seq": seq,
"kind": kind,
"text": text,
},
)
],
)
async def search_glossary(
self,
world_id: uuid.UUID,
query: str,
limit: int = 5,
settings_map: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
try:
vector = await self.embed(query, settings_map)
res = await self.client.search(
collection_name=COLLECTION_GLOSSARY,
query_vector=vector,
query_filter=qm.Filter(
must=[qm.FieldCondition(key="world_id", match=qm.MatchValue(value=str(world_id)))]
),
limit=limit,
with_payload=True,
)
return [r.payload for r in res]
except Exception as e:
log.warning("rag_search_glossary_failed", error=str(e))
return []
async def search_history(
self,
session_id: uuid.UUID,
query: str,
limit: int = 5,
settings_map: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
try:
vector = await self.embed(query, settings_map)
res = await self.client.search(
collection_name=COLLECTION_HISTORY,
query_vector=vector,
query_filter=qm.Filter(
must=[qm.FieldCondition(key="session_id", match=qm.MatchValue(value=str(session_id)))]
),
limit=limit,
with_payload=True,
)
return [r.payload for r in res]
except Exception as e:
log.warning("rag_search_history_failed", error=str(e))
return []
async def delete_history(self, session_id: uuid.UUID) -> None:
try:
await self.client.delete(
collection_name=COLLECTION_HISTORY,
points_selector=qm.FilterSelector(
filter=qm.Filter(must=[qm.FieldCondition(key="session_id", match=qm.MatchValue(value=str(session_id)))])
),
)
except Exception:
pass
# ---------------------------------------------------------------------------
# Singleton + cache invalidation
# ---------------------------------------------------------------------------
_rag: Optional[RagClient] = None
async def get_rag(settings_map: Optional[Dict[str, Any]] = None) -> RagClient:
"""Get the shared RagClient, ensuring collections are configured for the
current embedding settings.
Pass `settings_map` from DB on the first call (or whenever settings may
have changed) so the client can rebuild its embedder and reconfigure
Qdrant collections if `embedding.provider` / `embedding.base_url` /
`embedding.model` / `embedding.dim` changed.
"""
global _rag
if _rag is None:
_rag = RagClient()
await _rag.ensure_collections(settings_map)
elif settings_map is not None:
# Re-check embedder signature; ensure_collections runs only if changed.
await _rag.get_embedder(settings_map)
return _rag
async def reset_rag() -> None:
"""Drop the cached RAG client so the next `get_rag()` rebuilds it from
current settings. Call this after admin updates embedding.* settings.
"""
global _rag
_rag = None
async def probe_embeddings(settings_map: Dict[str, Any]) -> Dict[str, Any]:
"""Standalone probe used by the admin "Test embeddings" button.
Returns dict with: ok, provider, base_url, model, dim, sample_norm, error.
Does not touch the shared singleton or Qdrant.
"""
cfg = RagClient._resolve_embedder_config(settings_map)
embedder = RagClient._build_embedder(cfg)
try:
vec = await embedder.embed("RAG embedding probe: a brave adventurer enters a tavern.")
if not vec:
return {"ok": False, "provider": cfg["provider"], "error": "empty_vector"}
norm = sum(v * v for v in vec) ** 0.5
return {
"ok": True,
"provider": cfg["provider"],
"base_url": cfg["base_url"],
"model": cfg["model"],
"dim": len(vec),
"sample_norm": round(norm, 4),
}
except Exception as e:
return {
"ok": False,
"provider": cfg["provider"],
"base_url": cfg["base_url"],
"model": cfg["model"],
"error": f"{type(e).__name__}: {e}",
}

View File

@@ -0,0 +1,44 @@
"""Security: password hashing + JWT."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return _pwd_ctx.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
try:
return _pwd_ctx.verify(plain, hashed)
except Exception:
return False
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"type": "access",
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any] | None:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return payload
except JWTError:
return None

View File

@@ -0,0 +1,92 @@
"""Admin settings service (DB-backed)."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Setting
# Settings that can be edited by admin via the admin panel
EDITABLE_SETTING_KEYS = {
"llm.base_url": str,
"llm.api_key": str,
"llm.model": str,
"llm.temperature": float,
"llm.step_temperature": float,
"llm.summary_temperature": float,
"llm.max_tokens": int,
"llm.request_timeout": int,
"llm.streaming": bool,
"context.recent_messages": int,
"context.compress_threshold": int,
"context.summary_messages": int,
"context.max_tokens_total": int,
"triggers.enabled": bool,
"triggers.check_interval": int,
# Embeddings / RAG
"embedding.provider": str, # "hash" | "openai"
"embedding.base_url": str, # OpenAI-compatible base URL (e.g. http://localhost:1234/v1)
"embedding.api_key": str, # API key (may be empty for local servers)
"embedding.model": str, # e.g. text-embedding-3-small, bge-m3, nomic-embed-text
"embedding.dim": int, # vector dimension; 0 = auto-probe from endpoint
"embedding.request_timeout": int, # request timeout, seconds
}
async def get_all_settings(db: AsyncSession) -> Dict[str, Any]:
result = await db.execute(select(Setting))
return {row.key: row.value for row in result.scalars().all()}
async def get_setting(db: AsyncSession, key: str, default: Any = None) -> Any:
result = await db.execute(select(Setting).where(Setting.key == key))
row = result.scalars().first()
return row.value if row else default
async def update_settings(db: AsyncSession, updates: Dict[str, Any]) -> Dict[str, Any]:
for key, value in updates.items():
if key not in EDITABLE_SETTING_KEYS:
continue
expected = EDITABLE_SETTING_KEYS[key]
try:
if expected is bool:
value = bool(value)
elif expected is int:
value = int(value)
elif expected is float:
value = float(value)
else:
value = str(value)
except (TypeError, ValueError):
continue
result = await db.execute(select(Setting).where(Setting.key == key))
row = result.scalars().first()
if row is None:
db.add(Setting(key=key, value=value))
else:
row.value = value
await db.commit()
return await get_all_settings(db)
def cast_setting(key: str, value: Any) -> Any:
"""Cast raw DB value to the expected type for use."""
if key not in EDITABLE_SETTING_KEYS:
return value
expected = EDITABLE_SETTING_KEYS[key]
try:
if expected is bool:
if isinstance(value, str):
return value.lower() in ("1", "true", "yes", "on")
return bool(value)
if expected is int:
return int(value)
if expected is float:
return float(value)
return str(value)
except (TypeError, ValueError):
return value

View File

@@ -0,0 +1,108 @@
"""World-state JSON schema validator (player/NPC stats, inventory, etc.)."""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from jsonschema import ValidationError, validate
from app.logging_setup import get_logger
log = get_logger("state_validator")
def validate_state(state: Dict[str, Any], schema: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""Validate state against world's JSON Schema. Returns (ok, errors)."""
if not schema:
return True, []
try:
validate(instance=state, schema=schema)
return True, []
except ValidationError as e:
return False, [f"{e.message} at path {list(e.absolute_path)}"]
except Exception as e:
return False, [f"schema_error: {e}"]
def apply_patch(state: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
"""Apply a JSON-patch-like update to state.
Patch format:
{"set": {"path.to.field": value, ...},
"unset": ["path.to.field", ...],
"append": {"path.to.list": value, ...},
"increment": {"path.to.number": delta, ...}}
Paths use dot notation. Creates intermediate dicts as needed.
"""
if not patch:
return state
new_state = _deep_copy(state)
for op, items in patch.items():
if op == "set":
for path, value in items.items():
_set_path(new_state, path, value)
elif op == "unset":
for path in items:
_unset_path(new_state, path)
elif op == "append":
for path, value in items.items():
lst = _get_path(new_state, path) or []
if not isinstance(lst, list):
lst = []
lst.append(value)
_set_path(new_state, path, lst)
elif op == "increment":
for path, delta in items.items():
cur = _get_path(new_state, path) or 0
try:
cur = float(cur)
except (TypeError, ValueError):
cur = 0
_set_path(new_state, path, cur + delta)
elif op == "remove":
for path, value in items.items():
lst = _get_path(new_state, path) or []
if isinstance(lst, list):
lst = [x for x in lst if x != value]
_set_path(new_state, path, lst)
return new_state
def _deep_copy(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _deep_copy(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_deep_copy(v) for v in obj]
return obj
def _get_path(obj: Any, path: str) -> Any:
cur = obj
for part in path.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return None
return cur
def _set_path(obj: Dict[str, Any], path: str, value: Any) -> None:
cur = obj
parts = path.split(".")
for part in parts[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[parts[-1]] = value
def _unset_path(obj: Dict[str, Any], path: str) -> None:
cur = obj
parts = path.split(".")
for part in parts[:-1]:
if not isinstance(cur, dict) or part not in cur:
return
cur = cur[part]
if isinstance(cur, dict):
cur.pop(parts[-1], None)