Files
ai-rpg/CHANGELOG.md
Mikan e98559a587 fix
2026-06-21 06:12:28 +03:00

294 lines
31 KiB
Markdown

# Changelog
All notable changes to AI-RPG are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.0] — 2026-06-21
Major release: world builder rewritten to use tools (instead of JSON), resumable builder flow, admin recovery, LLM model list, text replacements, human-readable time.
### Backend — Critical: World Builder rewritten to use tools
- **`world_builder_schema` stage**: previously asked the LLM to output a JSON object with schemas, which frequently failed validation. Now the LLM calls `schema_add_type` tool for each entity type. Added `world_builder_schema` and `world_builder_env` to the `stages` set of all relevant tools (schema_add_type, env_update, entity_create, submit_plan, etc.).
- **`world_builder_env` stage**: previously asked for JSON. Now the LLM calls `env_update` tool to set player, current_location, and plot_rails.
- **Resumable builder**: each stage checks if the world already has the needed data and skips if so. If schemas exist → skip schema generation. If environment has current_location → skip env generation. If entities exist → skip entity generation. If intro_scene exists → skip intro generation. This allows re-running the builder after a failure at any stage without redoing earlier stages.
- **Validation warnings instead of failures**: if `validate_world` finds issues after env generation, the builder emits a `warning` SSE event but continues (instead of failing). The world may still be usable.
- **Better tool-loop prompt**: the user message now says "Use the available tools to accomplish the task. When done, call {terminal_tool}." to encourage tool use.
- **Updated prompts**: `world_builder_schema` and `world_builder_env` prompts now describe the tools to use and give examples of tool arguments.
### Backend — Critical: World Editor fixes
- **No extra LLM call after propose_changes**: after the user accepts or rejects proposed changes, the editor loop now breaks immediately. Previously it made another LLM call (which returned empty text), wasting API requests.
- **`'str' object has no attribute 'get'` fix**: already in v1.2.0, but now also handles cases where `function` is not a dict.
### Backend — New: Admin recovery
- **`POST /api/admin/recover`** (NO AUTH required) — creates a new admin user using the `admin.setup_token` (printed on every backend startup). Body: `{token, email, username, password}`. For disaster recovery when all existing admins lost access. Returns 403 `invalid_admin_token` if the token doesn't match.
### Backend — New: LLM model list
- **`POST /api/admin/llm/models`** (admin) — fetches the list of available models from an OpenAI-compatible API (`GET {api_url}/models`). Returns `{ok: true, models: [...], count: N}` or `{ok: false, error: {...}, models: []}`. Uses the same `_resolve` helper as test endpoints (ignores masked api_key values).
### Backend — New: Text replacements
- **New setting `llm.text_replacements`**: a JSON array of `{from: string, to: string}` pairs. Applied to all LLM scene_text output (both orchestrator Phase 2 and intro_scene). Use empty `to` to remove a word/phrase entirely.
- **`apply_text_replacements(session, text)`** helper in `settings_service.py`.
- Applied in `game_master.py` (Phase 2 writer) and `world_builder.py` (intro scene).
### Backend — New: Human-readable time
- **`format_time_human(time_str, language)`** in `time_utils.py` — converts `"day_1_hour_8"``"Day 1, 08:00"` (en) or `"День 1, 08:00"` (ru). Supports years, days, hours, minutes.
- **`GET /api/worlds`** now returns `current_time_human` alongside `current_time`.
- **`GET /api/sessions/worlds/{id}/state`** now returns `current_time_human` and `status` in the world object.
### Backend — New: world_id in LLM logs
- **`GET /api/admin/llm-logs`** now includes `world_id` (string UUID or null) on each log item. Useful for the admin UI to show which world a log belongs to, even when not filtering by world_id.
### Backend — Route fix
- **404 on generate-intro**: the frontend was calling `/api/worlds/{id}/generate-intro` but the route is at `/api/sessions/worlds/{id}/generate-intro`. Fixed the frontend API helper to use the correct path.
### Frontend — 14 files changed, 1 new
- **Admin recovery page**: new `/recover` route (public, no auth). Form with token/email/username/password. Link from LoginPage: "Lost admin access? Recover here".
- **LLM model list dropdown**: "Fetch models" button next to the model input in SettingsPanel. Fetches from `POST /api/admin/llm/models`. Shows a `<select>` dropdown on success, or a "Could not fetch" hint on failure. Cached in component state.
- **Text replacements UI**: new card in SettingsPanel with add/remove rows for `{from, to}` pairs. Saved to `llm.text_replacements` setting.
- **World column in LLM logs table**: shows first 8 chars of `world_id` (or "—"). Filter indicator in header. Full `world_id` in detail modal.
- **Human-readable time**: `current_time_human` used in WorldCard, PlayPage, WorldEditPage. Falls back to `current_time` if not available.
- **World builder skipped stages**: `skipping_*` step events shown in muted blue color.
- **IntroSceneGenerator visibility**: shown for draft worlds OR worlds with empty intro_scene (not just stuck worlds).
### Verification
- Backend: 68 unit tests pass, 52 routes.
- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (378 KB JS / 23 KB CSS, ~114 KB gzipped).
## [1.2.0] — 2026-06-21
This release fixes critical bugs that prevented world creation, world editing, and LLM tool-calling with local models (gemma, qwen, etc.).
### Backend — Critical fixes
- **Prompt templates**: fixed `KeyError: 'scene_text, delta_time'` that crashed world_builder during intro scene generation. The prompts contained literal `{scene_text, delta_time}` braces which `str.format()` interpreted as format fields. Rewrote `orchestrator_phase2` and `intro_scene` prompts to describe the tool arguments in prose instead of using brace notation. All 11 prompt templates now format correctly (verified with a test).
- **LLM tool-call parsing for local models**: many local models (gemma4, qwen, etc.) don't use the OpenAI function-calling format — they emit tool calls as text like `call:calc{"expression": "2+2"}` or `<tool_call>calc{...}</tool_call>`. Added `_parse_text_tool_calls()` to `app/core/llm.py` that detects these patterns and converts them to OpenAI-format `tool_calls`. The LLM client now automatically parses text-based tool calls when the model doesn't return them in the standard format. This fixes the issue where `request_tools` was `[]` (empty) in logs even though tools were sent — actually tools WERE sent, but the model returned calls as text and they were ignored.
- **Test endpoints — ascii codec error**: `POST /api/admin/test/llm-tools` failed with `'ascii' codec can't encode character '\u2026'` when the client sent a masked api_key (containing `…`) back as a query parameter. Added `_is_masked()` and `_resolve()` helpers that detect masked values (`…` or `****`) and fall back to the raw DB value. All 4 test endpoints (test/llm, test/llm-tools, test/embeddings, test/embeddings/probe-dimension) now use these helpers.
- **Test LLM tools — better prompt**: the test now sends a system message "You must use the calc tool" and a user message "You MUST call the calc tool with expression '2+2'" to encourage tool use. Also increased timeout from 15s to 30s. The response now includes `raw_response` (the full LLM message) for debugging.
- **World editor — `'str' object has no attribute 'get'`**: the world_editor crashed when tool_calls contained string entries instead of dicts (some models return non-standard formats). Added type normalization: each tool_call is checked with `isinstance(tc, dict)`, strings are parsed as JSON, non-dicts are skipped. Also handles cases where `function` is not a dict.
- **World editor — propose_changes now waits for user**: previously `propose_changes` was auto-accepted (simplified). Now the editor emits `change_proposed` and WAITS for the user to accept/reject via `POST /api/sessions/worlds/{id}/apply` or `/discard`. Implemented using `asyncio.Future` stored in `_pending_changes` dict keyed by world_id. 120s timeout.
- **World editor — ask_user now waits for answer**: similarly, `ask_user` now waits for `POST /api/sessions/worlds/{id}/answer` body `{text: "..."}`. 120s timeout.
- **World editor — apply_diff improved**: now supports paths `world.name`, `world.description`, `schemas` (full replace), and `environment.<field>` (via apply_patch). Previously only `environment.*` paths worked.
- **World editor — better empty-state handling**: when world has no schemas/entities/environment, the prompt now shows "(no schemas yet)", "(no entities yet)", and "(empty — world has no environment yet. Use env_update to add player, current_location, plot_rails.)" instead of empty strings, so the LLM understands the context.
### Backend — New endpoints
- `POST /api/sessions/worlds/{id}/apply` — accept proposed changes (resolves the pending Future)
- `POST /api/sessions/worlds/{id}/discard` — reject proposed changes
- `POST /api/sessions/worlds/{id}/answer` body `{text: "..."}` — answer a clarification
- `POST /api/worlds/{id}/generate-intro``{stream_url}` — re-generate intro scene for draft worlds
- `GET /api/sessions/worlds/{id}/intro/stream` (SSE) — runs the intro_scene stage, sets world.status to "ready" on success. Emits `step`, `intro_scene_complete`, `done` events.
### Frontend — Critical fixes (10 files changed, 1 new)
- **World Editor — Accept/Reject buttons**: `change_proposed` events now show a card with the diff (color-coded: green=add, red=remove, yellow=replace) and two buttons. Accept → `POST /apply`, Reject → `POST /discard`. After decision, the card collapses to a status line.
- **World Editor — Answer input**: `clarification` events show a card with either free-text input + Send button, or clickable option buttons (if `options` provided). Submit → `POST /answer`.
- **World Edit Page — Generate Intro Scene**: new `IntroSceneGenerator` component. If `world.status === 'draft'`, shows a "Generate Intro Scene" button. Clicking it opens the SSE stream, shows progress, displays the generated scene, and on `done` refreshes the world (status → "ready") + shows toast "World is ready!".
- **Settings panel — autocomplete="off"**: all inputs now have `autoComplete="off"`. API key fields use `type="text"` (browsers won't save them as passwords). Added a hidden decoy password input to absorb the password manager's attention.
- **Username/email autocomplete**: verified `LoginPage` uses `autoComplete="username"` for the login field, `RegisterPage` uses `autoComplete="email"` for email and `autoComplete="username"` for username.
- **Test LLM Tools — raw response display**: when `has_tool_calls=false`, shows a warning + collapsible `<details>` with the raw LLM response so the user can see what the model returned.
- **World Builder — Retry button**: on error, shows a "Retry" button that re-subscribes to the builder stream. Also fixed a stale-closure bug where `done`/`error` events didn't close the active SSE controller (used `useRef` to track the current controller).
- **World Card — draft state**: draft worlds show a "Continue setup" button (links to edit page) and a "Draft" badge. No "Play" button for drafts.
- **Play page — better not-ready toast**: "This world is not ready yet. Generate the intro scene first."
### Verification
- Backend: 68 unit tests pass, 50 routes.
- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (368 KB JS / 23 KB CSS, ~112 KB gzipped).
## [1.1.0] — 2026-06-21
This is a major bugfix release addressing 20+ issues found during user testing.
### Backend — Critical fixes
- **app/api/sessions.py**: fixed `TypeError: 'async_generator' object does not support the asynchronous context manager protocol` that broke ALL SSE streams (world builder, world editor, orchestrator). The `_session_scope()` function was an async generator (used `yield`) but was called with `async with`. Added `@asynccontextmanager` decorator. Without this fix, no LLM calls were ever made — the background task crashed immediately.
- **app/models/__init__.py**: made `WorldPreset.owner_id` nullable (`NOT NULL``NULL`, `ondelete=CASCADE``SET NULL`). Builtin presets are now system presets with `owner_id=NULL`. Previously, `seed_builtin_presets` crashed on first startup with a foreign key violation because no admin user existed yet.
- **app/main.py**: added `_apply_schema_fixups(engine)` that runs `ALTER TABLE world_presets ALTER COLUMN owner_id DROP NOT NULL` on every startup (idempotent, wrapped in try/except). This fixes existing databases that were created with the old NOT NULL constraint.
- **app/main.py**: admin setup URL is now ALWAYS printed on startup (even if admin already exists), per user request. The URL is blocked by the backend if an admin exists, but the token is visible for reference.
- **app/migrations/seed.py**: `seed_builtin_presets` now uses `owner_id=None` instead of looking for an admin user or a sentinel UUID.
- **requirements.txt**: pinned `bcrypt==4.0.1`. bcrypt 4.1+ removed the `__about__` module which breaks passlib 1.7.4's version detection. This caused the annoying `(trapped) error reading bcrypt version` warning on every password hash operation.
### Backend — API improvements
- **app/api/misc.py**: added `GET /api/settings/public` (no auth) — returns `{page_title, favicon_url, logo_url, og_image_url}`. The frontend uses this on app load to set the document title, favicon, and navbar logo.
- **app/api/worlds.py**: `GET /api/worlds` now excludes archived worlds by default. Pass `?status_filter=archived` to see only archived, or `?status_filter=all` to see everything.
- **app/api/admin.py**: `GET /api/admin/stats` now returns separate counts: `worlds` (active), `worlds_archived`, `worlds_total`. Previously it counted ALL worlds including archived.
- **app/api/admin.py**: added `DELETE /api/admin/worlds/{id}` — hard-delete a world (cascade deletes entities, steps, logs, triggers, story_entries). Also cleans up Qdrant points (best-effort). This gives admins a way to permanently remove archived worlds.
- **app/core/llm.py**: improved error messages for LLM API failures:
- Non-JSON responses now raise `LLMResponseError("LLM provider returned non-JSON response (check that api_url points to an OpenAI-compatible endpoint). First 200 chars: ...")` instead of a generic `parse_error`.
- HTTP 4xx/5xx errors now include the response body (or extracted `error.message` from JSON).
- All error messages now include the HTTP status code for easier debugging.
### Frontend — Critical fixes (19 files changed, 1 new)
- **Black page after settings change**: root cause was `AdminApi.updateSettings` returning `{updated: ...}` but the store expecting a different shape. Fixed the return type and removed the refetch-after-save that caused re-render loops.
- **[object Object] error in chat**: added `toErrorMessage()` helper that properly extracts `.message` from `ApiError` objects. All error toasts now show human-readable strings.
- **Play page access control**: `/worlds/:id/play` now redirects to `/worlds/:id/edit` with a toast if `world.status !== 'ready'`.
- **Retry/Rollback buttons**: disabled when `recentSteps.length === 0` with `opacity-50 cursor-not-allowed`.
- **Archived worlds**: hidden from the worlds list (both server-side and client-side filter). After deleting, removed from local state immediately.
- **World card archived state**: no Play button, "Archived" badge, "Restore" button (`PATCH` status→ready), admin-only "Delete permanently" button (`DELETE /api/admin/worlds/{id}`).
- **LLM logs detail modal**: now shows `request_messages`, `response_message`, `tool_calls`, `error_message`, `prompt_tokens`, `completion_tokens`, `latency_ms`, `temperature`, `model`, `stage`, `status` (color-coded), `created_at` — all in pretty-printed JSON `<pre>` blocks.
- **Dropdowns**: `embeddings.provider``<select>` with `offline_hash`/`openai`; boolean settings → `<select>` with `true`/`false`; LLM log stage filter → `<select>` with all 16 stage values; LLM log status filter → `<select>` with all 6 status values.
- **Numeric inputs**: all integer/float settings now use `<input type="number">` with `min`/`max` attributes. String-to-number conversion on save.
- **Test page pre-fills**: diagnostic forms now pre-fill from current settings (`llm.api_url`, `llm.api_key`, `llm.model`, `embeddings.api_url`, `embeddings.model`, `embeddings.provider`).
- **Embeddings test provider**: defaults to current `embeddings.provider` setting (was hardcoded to `openai`). Added hint: "If provider=openai and api_url is empty, falls back to llm.api_url".
- **Embeddings URL fallback hints**: added muted hint text next to `embeddings.api_url`, `embeddings.api_key`, `embeddings.model` fields.
- **UI settings applied**: new `uiSettingsStore` fetches `GET /api/settings/public` on app load. Sets `document.title`, favicon `<link>`, and navbar logo. Re-fetches after admin settings save.
- **Light theme fix**: rewrote CSS with CSS-variable-based theming. Light mode now uses `#fafafa`/`#1a1a1a`/`#ffffff` with high-contrast text. Dark mode unchanged.
- **Single Create button**: removed redundant "Create World" from navbar. Only one Create button remains (top of worlds list page).
- **Username validation**: client-side regex check + 422 error parsing with field-level inline messages. Specific toast: "Username can only contain letters, numbers, and underscores".
- **SSE error handling**: `sessionStore` now shows toast on SSE `error` events. One-shot "Connection lost. Retrying…" toast on unexpected stream closure.
- **Settings panel UX**: 6 grouped cards (LLM / Embeddings / Qdrant / Context / Game / UI), each with its own Save button. No page reload or full refetch after save — just a success toast.
- **Admin stats panel**: now shows 6 cards: Users, Active Worlds, Archived, Total Worlds, Steps, Avg LLM Latency.
### Verification
- Backend: 68 unit tests pass.
- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (354 KB JS / 23 KB CSS, ~109 KB gzipped).
## [1.0.5] — 2026-06-20
### Fixed
- **app/main.py**: added `create_all_tables(engine)` call at the start of `lifespan`. Without this, the backend started but crashed on every DB query with `relation "settings" does not exist` because tables were never created in PostgreSQL. Tables are now created idempotently on every startup via `Base.metadata.create_all` ( SQLAlchemy skips tables that already exist).
- **app/main.py**: also added `seed_builtin_presets(session)` call so the 2 builtin presets (Classic Fantasy, Deep Space Outpost) are seeded on first startup, not just settings.
- **app/migrations/versions/**: renamed `001_initial_schema.py``_001_initial_schema.py`. Python module names cannot start with a digit, so `from app.migrations.versions.001_initial_schema import create_all_tables` was a SyntaxError. The leading underscore is a conventional marker for "internal" modules.
- **README.md**: updated references to the renamed migration file.
### Why this happened
The lifespan handler was supposed to run DB migrations as its first step, but I forgot to wire it up. The `seed_default_settings()` call immediately tried to `SELECT FROM settings` against a fresh PostgreSQL database with no tables. SQLAlchemy 2.x's `create_all` is idempotent (skips existing tables), so this is safe to call on every startup — equivalent to `alembic upgrade head` for our single-migration MVP.
## [1.0.4] — 2026-06-20
### Fixed (proper fix for CORS env parsing)
- **app/config.py**: replaced the broken `Annotated[list[str], NoDecode]` approach with a simpler, more robust one:
- `cors_origins` is now declared as a plain `str` (comma-separated, e.g. `"http://a,http://b"` or `"*"`).
- Added a `cors_origins_list` property that splits the string into a `list[str]` on demand.
- This sidesteps the entire `EnvSettingsSource.decode_complex_value` / JSON-parsing codepath — pydantic-settings sees a `str` field, takes the env value as-is, no JSON parsing attempted.
- **app/main.py**: updated `CORSMiddleware(allow_origins=cfg.cors_origins_list)` to use the new property.
- **Why v1.0.3 didn't work**: in pydantic-settings 2.7.0, `NoDecode` is importable but `_annotation_is_complex()` doesn't actually check for it (only checks for `Json`). The marker was added to the package surface but the inner logic was only wired up in a later version. Switching to a plain `str` field is the most reliable fix and works across all pydantic-settings 2.x versions.
- All 68 unit tests still pass.
## [1.0.3] — 2026-06-20
### Fixed
- **app/config.py**: fixed `SettingsError: error parsing value for field "cors_origins" from source "EnvSettingsSource"` that crashed the backend on startup when `CORS_ORIGINS` was set as a comma-separated string (e.g. `http://localhost:8080,http://localhost:5173,http://localhost`).
- Root cause: pydantic-settings v2 by default tries to JSON-parse complex-typed env vars before applying field validators. The comma-separated string isn't valid JSON, so parsing failed before our `@field_validator(mode="before")` could split it.
- Fix: declared `cors_origins: Annotated[list[str], NoDecode]``NoDecode` is a pydantic-settings marker that disables JSON pre-parsing, so the raw string reaches our `_split_cors` validator unchanged.
- **requirements.txt**: bumped `pydantic` 2.7.1 → 2.9.2 and `pydantic-settings` 2.2.1 → 2.7.0. The `NoDecode` annotation was only introduced in pydantic-settings 2.6+, so the older versions couldn't support the fix above. All 68 unit tests still pass with the new versions.
## [1.0.2] — 2026-06-20
### Fixed
- **requirements.txt**: added missing `email-validator==2.2.0` dependency. Pydantic's `EmailStr` type (used in `RegisterRequest`, `AdminRegisterRequest`, `LoginRequest.user.email`, `UserPublic.email`, `TokenResponse.user.email`) requires this package at runtime, but it's not bundled with pydantic itself. Without it the backend crashed at startup with `ImportError: email-validator is not installed, run pip install pydantic[email]`. Also removed a duplicate `httpx==0.27.0` line.
## [1.0.1] — 2026-06-20
### Fixed
- **docker-compose.yml**: removed obsolete `version: "3.9"` (caused warning in modern Docker Compose).
- **docker-compose.yml**: fixed frontend build context — was `./frontend` (broke `COPY deploy/nginx.conf` and `COPY frontend/package*.json` in Dockerfile.frontend). Now correctly `.` (project root) with `dockerfile: deploy/Dockerfile.frontend`.
- **docker-compose.yml**: fixed frontend port mapping — was `5173:5173` but the frontend container is nginx on port 80. Now `8080:80` so the app is accessible at `http://localhost:8080`.
- **docker-compose.yml**: added `extra_hosts: ["host.docker.internal:host-gateway"]` to the backend service — enables `LLM_API_URL=http://host.docker.internal:11434/v1` to work on Linux hosts (not just Docker Desktop on Mac/Windows).
- **docker-compose.yml**: added `VITE_API_BASE_URL: /api` build arg for the frontend service — bakes the relative `/api` URL into the Vite bundle so the browser uses the same origin + nginx proxies `/api``backend:8000`.
- **deploy/Dockerfile.frontend**: added `ARG VITE_API_BASE_URL=/api` + `ENV` so the build arg actually gets baked into the Vite bundle.
- **deploy/nginx.conf**: extended SSE timeouts from 300s → 600s; added `proxy_send_timeout`; added `Upgrade`/`Connection` headers for future WebSocket support; added gzip for static assets.
- **.env.example**: clarified `VITE_API_BASE_URL` — only used by local `npm run dev`, ignored by docker build (which uses `/api` relative). Removed the misleading `http://localhost/api` default.
- **frontend/src/lib/api.ts**: now respects `VITE_API_BASE_URL` env var with fallback to relative `/api`. Works both for local dev (point at separate backend) and Docker (nginx proxy).
- **frontend/src/vite-env.d.ts**: added Vite env type declarations so TypeScript knows about `import.meta.env.VITE_API_BASE_URL`.
- **app/api/deps.py**: added `?access_token=<jwt>` query parameter fallback for SSE endpoints. Native `EventSource` cannot send `Authorization` headers, so the frontend SSE client passes the token via query string. Without this fix, all SSE endpoints (`/iterate/stream`, `/builder/stream`, `/editor/stream`) returned 401.
- **.dockerignore**: added at project root — excludes `node_modules/`, `__pycache__/`, `.venv/`, `data/`, `.git/`, etc. from Docker build contexts (faster builds, smaller context transfer).
## [1.0.0] — 2026-06-20
### Added — Sprint 1: Foundation
- docker-compose with 4 services: db (PostgreSQL 15), qdrant (1.9), backend (FastAPI/uvicorn), frontend (Vite dev / nginx prod).
- SQLAlchemy 2.x async models for all 10 tables: `users`, `settings`, `world_presets`, `worlds`, `entities`, `steps`, `step_tool_calls`, `deferred_triggers`, `story_entries`, `llm_call_logs`.
- Alembic-equivalent idempotent migration `001_initial_schema.py` (creates all tables, no pgvector).
- `init_qdrant.py` creates collections `entities` and `story_entries` with payload indexes on `world_id`, `entity_type`, `entry_type`, `deleted`, `created_at`.
- Settings seed: 33 default keys (llm.*, embeddings.*, context.*, qdrant.*, game.*, ui.*, admin.setup_token).
- 2 builtin presets: Classic Fantasy, Deep Space Outpost.
- `GET /api/health` returns `{status, db, qdrant, llm, embeddings, version}`.
### Added — Sprint 2: Auth + Admin
- `POST /api/register` (only if at least one admin exists), `POST /api/register/admin?token=`, `POST /api/auth/login` (email or username), `POST /api/auth/refresh`, `POST /api/auth/logout`, `GET /api/auth/me`.
- JWT (access + refresh) with python-jose, password hashing with passlib[bcrypt].
- Password strength validation: ≥8 chars, ≥1 letter, ≥1 digit, blacklist of trivial passwords.
- Anti-enumeration: same `401 invalid_credentials` for "user not found" and "wrong password".
- Admin setup token: auto-generated on first startup, printed in logs.
- `GET/PATCH /api/admin/settings` with secret masking.
- `GET /api/admin/llm-logs` with filters (world_id, stage, status, pagination) + detail view.
- `GET /api/admin/users`, `PATCH /api/admin/users/{id}` (admin/active toggle).
- `GET /api/admin/stats` (users, worlds, steps, avg LLM latency).
- Diagnostic endpoints: `POST /api/admin/test/llm`, `/test/llm-tools`, `/test/embeddings`, `/test/embeddings/probe-dimension`, `/embeddings/recreate-collections`.
- `POST /api/admin/upload-icon` (multipart, favicon/logo/og_image, ≤1MB, PNG/SVG/JPG/WebP/ICO).
### Added — Sprint 3: World Builder
- `LlmClient` — OpenAI-compatible chat completions client with retry (3 attempts, exp backoff), streaming, tool-calling, separate-transaction logging.
- `MockLlmClient` — replay-based mock for tests/dev when no LLM configured.
- `app/prompts/` — 11 stage prompts in English (world_builder_schema/env/entities, world_editor, orchestrator_phase1/2/3_summary/3_suggest, intro_scene, subagent, summary).
- `POST /api/worlds` creates draft world + returns SSE URL.
- `GET /api/sessions/worlds/{id}/builder/stream` runs the 4-step builder flow: schemas → environment → entities (tool-calling loop) → intro scene (with submit_step + suggest_actions).
- If preset_id provided, schemas/environment copied from preset (skip first LLM call).
### Added — Sprint 4: World Editor
- `POST /api/worlds/{id}/edit` body `{instruction}` → SSE URL.
- `GET /api/sessions/worlds/{id}/editor/stream?instruction=` runs LLM with world_editor tools.
- `propose_changes` tool returns diff, `ask_user` emits clarification event, `comment_to_user` for chat.
- Optimistic locking via `updated_at` on `PATCH /api/worlds/{id}` (409 state_conflict on mismatch).
- `PATCH /api/worlds/{id}` for direct JSON edits.
- `DELETE /api/worlds/{id}` soft-delete (status='archived').
### Added — Sprint 5: Orchestrator
- `POST /api/sessions/worlds/{id}/iterate` body `{action, action_source}` creates new step + returns SSE URL.
- `GET /api/sessions/worlds/{id}/iterate/stream?step_id=` runs the 3-phase orchestrator:
- **Phase 1**: tool-calling loop with `submit_plan` terminator, max 8 substeps (configurable).
- **Phase 2**: writer LLM call with `submit_step`, streams `scene_chunk` events.
- **Phase 3**: persist + deferred triggers + summary (if history > threshold) + suggest_actions.
- `POST /api/sessions/worlds/{id}/retry` soft-deletes last step + creates new one with same action.
- `POST /api/sessions/worlds/{id}/rollback` soft-deletes last step.
- 17 game tools: entity_create/get/list/update/delete, env_update/get, update_plot_rails, advance_time, schedule_trigger, calc (with dice), random_choice (deterministic), rag_query/add, run_subagent, submit_plan, submit_step, suggest_actions.
- 4 schema tools: schema_add_type/add_field/remove_field/modify_field.
### Added — Sprint 6: Frontend polish
- React 18 + Vite 5 + TypeScript 5 + Tailwind CSS 3 + zustand 4 + react-i18next 14.
- Dark theme by default (Tailwind `dark:` class on `<html>`).
- 8 pages: Login, Register, AdminRegister, WorldsList, WorldBuilder, WorldEdit, Play, Admin.
- 23 components: 9 UI primitives, 5 session, 3 worlds, 6 admin.
- 5 zustand stores: auth, ui, worlds, session, toast.
- i18n bundles in English + Russian (complete).
- Native EventSource SSE client with reconnect + Last-Event-ID.
- Mobile-first responsive layout.
- TypeScript strict mode, 0 type errors, `npm run build` succeeds (340 KB JS / 22 KB CSS).
### Added — Sprint 7: RAG + Triggers + Summary + Context
- `app/core/rag.py` — two-stage retrieval: Qdrant vector search → PostgreSQL hydration.
- `HashEmbedder` (offline, deterministic) and `OpenAIEmbedder` (OpenAI-compatible API) implementations.
- Embedder provider selection via `embeddings.provider` setting (`offline_hash` | `openai`).
- Auto-fallback: `embeddings.api_url` falls back to `llm.api_url` if empty (and same for api_key).
- Embedder cache with `reset_embedder_cache()` (called on settings update).
- `rag_query` returns empty list on embedder/Qdrant failure (non-blocking).
- `rag_add` saves story entry with `embedding_status='pending'` if embedding fails (background indexer can retry).
- `index_entity` helper for entity vectors.
- World isolation via Qdrant payload filter `world_id`.
- `_cleanup_qdrant(world_id)` deletes all points for a world on world delete (best-effort).
- Deferred triggers: Phase 3.1 fires triggers where `fire_at <= current_time`, appends summary to scene_text, marks `is_fired=true`.
- Summary: Phase 3.2 generates summary when `len(recent_steps) > compression_threshold_messages`, stores as StoryEntry with `metadata.type=summary`.
- Context manager: builds messages with system prompt + optional summary + last N guaranteed messages + current action.
### Added — Sprint 8: Production
- `README.md` with quickstart, architecture diagram, API overview, testing instructions.
- `pytest.ini` + 65+ unit tests across 8 test files.
- `CHANGELOG.md` (this file).
- `docker-compose.yml` with healthchecks for db and qdrant.
- `deploy/Dockerfile.backend`, `deploy/Dockerfile.frontend`, `deploy/nginx.conf`.
- `.env.example` with all 24 env vars documented.
- `.gitignore` for Python, Node, env, IDE, data dirs.
- Frontend production build verified (`npm run build` produces `dist/`).