From 0793351ad2061c7e92409721a7e1432d221b0ae6 Mon Sep 17 00:00:00 2001 From: Alumno Date: Wed, 24 Jun 2026 13:05:18 -0400 Subject: [PATCH 1/2] more npc wiring --- AGENTS.md | 25 +- ARCHITECTURE.md | 174 --- README.md | 6 +- SYSTEM.md | 1198 ----------------- conftest.py | 9 + docs/1_product/_index.md | 18 + docs/1_product/epics.md | 38 + docs/1_product/ideal_state.md | 34 + docs/1_product/vision_and_modes.md | 38 + docs/2_architecture/_index.md | 23 + docs/2_architecture/layer1_data.md | 30 + docs/2_architecture/layer2_agents.md | 30 + docs/2_architecture/layer3_interface.md | 26 + docs/2_architecture/mcp_transport.md | 29 + .../the_proposed_change_pattern.md | 24 + docs/2_architecture/the_three_layers.md | 53 + docs/3_loops_and_systems/_index.md | 23 + docs/3_loops_and_systems/conversation_loop.md | 29 + docs/3_loops_and_systems/scene_loop.md | 32 + docs/3_loops_and_systems/story_loop.md | 32 + .../world_building_loop.md | 18 + docs/4_ontology/_index.md | 17 + docs/4_ontology/entity_types.md | 22 + docs/4_ontology/fact_canon_levels.md | 22 + docs/4_ontology/graph_relationships.md | 22 + docs/5_infrastructure/_index.md | 17 + docs/5_infrastructure/database_cluster.md | 23 + docs/5_infrastructure/lain_mcp_proxy.md | 30 + docs/5_infrastructure/observability.md | 23 + docs/README.md | 71 - docs/_index.md | 40 + docs/architecture/CONVERSATIONAL_LOOPS.md | 88 -- .../PLAY_UI_REACTIVE_REVAMP_PLAN.md | 210 +++ .../WORLD_FORGE_DIRECT_MANIPULATION_PLAN.md | 133 ++ .../monitor_agents/loops/conversation_loop.py | 75 +- .../src/monitor_agents/prompts/card_draft.py | 95 ++ .../monitor_agents/prompts/npc_profile_gen.py | 181 +++ .../agents/tests/test_conversation_loop.py | 168 +++ packages/agents/tests/test_npc_profile_gen.py | 104 ++ .../monitor_data/tools/neo4j_tools/core.py | 250 ++++ .../tools/neo4j_tools/entities.py | 26 +- .../test_tools/test_universe_split_merge.py | 165 +++ .../routers/character_conversation.py | 335 +++++ .../src/monitor_ui/routers/entities.py | 352 +++++ .../monitor_ui/routers/entities_schemas.py | 65 + .../tests/test_character_conversation.py | 208 +++ .../ui/backend/tests/test_entities_crud.py | 152 +++ .../tests/test_entities_crud_integration.py | 132 ++ .../ui/backend/tests/test_relationships.py | 91 ++ .../ui/frontend/src/app/characters/page.tsx | 470 +++++++ .../ui/frontend/src/app/explorer/page.tsx | 214 +++ packages/ui/frontend/src/app/worlds/page.tsx | 199 ++- .../ui/frontend/src/components/Sidebar.tsx | 2 + .../components/characters/CharacterChat.tsx | 233 ++++ packages/ui/frontend/src/lib/api.ts | 81 ++ .../frontend/src/lib/characterChatApi.test.ts | 68 + .../ui/frontend/src/lib/entitiesApi.test.ts | 93 ++ packages/ui/frontend/src/lib/query-keys.ts | 1 + packages/ui/frontend/src/lib/types.ts | 63 + scripts/claude-provider.fish | 250 ++++ scripts/live_roleplay_character.py | 599 +++++++++ 61 files changed, 5664 insertions(+), 1615 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 SYSTEM.md create mode 100644 docs/1_product/_index.md create mode 100644 docs/1_product/epics.md create mode 100644 docs/1_product/ideal_state.md create mode 100644 docs/1_product/vision_and_modes.md create mode 100644 docs/2_architecture/_index.md create mode 100644 docs/2_architecture/layer1_data.md create mode 100644 docs/2_architecture/layer2_agents.md create mode 100644 docs/2_architecture/layer3_interface.md create mode 100644 docs/2_architecture/mcp_transport.md create mode 100644 docs/2_architecture/the_proposed_change_pattern.md create mode 100644 docs/2_architecture/the_three_layers.md create mode 100644 docs/3_loops_and_systems/_index.md create mode 100644 docs/3_loops_and_systems/conversation_loop.md create mode 100644 docs/3_loops_and_systems/scene_loop.md create mode 100644 docs/3_loops_and_systems/story_loop.md create mode 100644 docs/3_loops_and_systems/world_building_loop.md create mode 100644 docs/4_ontology/_index.md create mode 100644 docs/4_ontology/entity_types.md create mode 100644 docs/4_ontology/fact_canon_levels.md create mode 100644 docs/4_ontology/graph_relationships.md create mode 100644 docs/5_infrastructure/_index.md create mode 100644 docs/5_infrastructure/database_cluster.md create mode 100644 docs/5_infrastructure/lain_mcp_proxy.md create mode 100644 docs/5_infrastructure/observability.md delete mode 100644 docs/README.md create mode 100644 docs/_index.md delete mode 100644 docs/architecture/CONVERSATIONAL_LOOPS.md create mode 100644 docs/architecture/PLAY_UI_REACTIVE_REVAMP_PLAN.md create mode 100644 docs/architecture/WORLD_FORGE_DIRECT_MANIPULATION_PLAN.md create mode 100644 packages/agents/src/monitor_agents/prompts/card_draft.py create mode 100644 packages/agents/src/monitor_agents/prompts/npc_profile_gen.py create mode 100644 packages/agents/tests/test_npc_profile_gen.py create mode 100644 packages/data-layer/tests/test_tools/test_universe_split_merge.py create mode 100644 packages/ui/backend/src/monitor_ui/routers/character_conversation.py create mode 100644 packages/ui/backend/tests/test_character_conversation.py create mode 100644 packages/ui/backend/tests/test_entities_crud.py create mode 100644 packages/ui/backend/tests/test_entities_crud_integration.py create mode 100644 packages/ui/backend/tests/test_relationships.py create mode 100644 packages/ui/frontend/src/app/characters/page.tsx create mode 100644 packages/ui/frontend/src/components/characters/CharacterChat.tsx create mode 100644 packages/ui/frontend/src/lib/characterChatApi.test.ts create mode 100644 packages/ui/frontend/src/lib/entitiesApi.test.ts create mode 100755 scripts/claude-provider.fish create mode 100644 scripts/live_roleplay_character.py diff --git a/AGENTS.md b/AGENTS.md index 1d3bef47..dfb494c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,18 +84,19 @@ FastAPI app at `packages/ui/backend/src/monitor_ui/`. Routers in `routers/`. The ## Documentation Map -| Topic | Source | -|-------|--------| -| Product vision & epics | [SYSTEM.md](SYSTEM.md) | -| Folder structure | [STRUCTURE.md](STRUCTURE.md) | -| Layer rules & diagrams | [ARCHITECTURE.md](ARCHITECTURE.md) | -| Use-case catalog | [docs/USE_CASES.md](docs/USE_CASES.md) | -| Quick implementation ref | [docs/AI_DOCS.md](docs/AI_DOCS.md) | -| MCP transport | [docs/architecture/MCP_TRANSPORT.md](docs/architecture/MCP_TRANSPORT.md) | -| Agent orchestration | [docs/architecture/AGENT_ORCHESTRATION.md](docs/architecture/AGENT_ORCHESTRATION.md) | -| Data model | [docs/ontology/ONTOLOGY.md](docs/ontology/ONTOLOGY.md) | -| Contributing workflow | [CONTRIBUTING.md](CONTRIBUTING.md) | -| DB integration | [docs/architecture/DATABASE_INTEGRATION.md](docs/architecture/DATABASE_INTEGRATION.md) | +The documentation for MONITOR has been restructured for agent-friendliness. + +**Start Here:** [docs/_index.md](docs/_index.md) + +Quick Links: +- Product & Epics: [docs/1_product/_index.md](docs/1_product/_index.md) +- Architecture & Layers: [docs/2_architecture/_index.md](docs/2_architecture/_index.md) +- Loops & Systems: [docs/3_loops_and_systems/_index.md](docs/3_loops_and_systems/_index.md) +- Ontology & Models: [docs/4_ontology/_index.md](docs/4_ontology/_index.md) +- Infrastructure: [docs/5_infrastructure/_index.md](docs/5_infrastructure/_index.md) +- Folder Structure: [STRUCTURE.md](STRUCTURE.md) +- Use-case catalog: [docs/USE_CASES.md](docs/USE_CASES.md) +- Contributing workflow: [CONTRIBUTING.md](CONTRIBUTING.md) ## Common Mistakes diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 34ea22e8..00000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,174 +0,0 @@ -# MONITOR Architecture - -> **Multi-Ontology Narrative Intelligence Through Omniversal Representation** - -This document defines the high-level architecture of MONITOR, its subsystems, agent coordination, and data flow patterns. - ---- - -## 1. High-Level Architecture: The "3-Layer Cake" - -MONITOR follows a strict layered architecture pattern. Dependencies only flow **downward**. - -```mermaid -graph TD - UI["User Interface (CLI / Web UI)"] - - subgraph Layer3["Layer 3: Interface Layer"] - CLI["monitor-cli"] - WebFrontend["monitor-ui-frontend"] - WebBackend["monitor-ui-backend"] - end - - subgraph Layer2["Layer 2: Agent Layer"] - Loops["LangGraph Loops"] - Agents["Specialized Agents"] - Logic["GameSystemRuntime"] - end - - subgraph Layer1["Layer 1: Data Layer"] - Tools["MCP Tools"] - Clients["DB Clients"] - Schemas["Pydantic Schemas"] - end - - subgraph Infrastructure - Neo4j["Neo4j (Canon)"] - MongoDB["MongoDB (State)"] - Qdrant["Qdrant (Vectors)"] - PG["PostgreSQL (Config)"] - MinIO["MinIO (Files)"] - end - - UI --> Layer3 - Layer3 --> Layer2 - Layer2 --> Layer1 - Layer1 --> Infrastructure -``` - -### Layer 1: Data Layer (`monitor-data-layer`) -The foundation of the system. It owns all database connections, data schemas (Pydantic), and the "Canonical Truth". -- **Responsibilities:** Database CRUD, authority enforcement, request validation, Model Context Protocol (MCP) tool exposure. -- **Rule:** Never imports from Layer 2 or 3. - -### Layer 2: Agent Layer (`monitor-agents`) -The "brain" of the system. It handles narrative intelligence, reasoning, and orchestration. -- **Responsibilities:** specialized agents (Narrator, Resolver, CanonKeeper, etc.), LangGraph state machine loops, DSPy modules. -- **Rule:** Imports from Layer 1; never imports from Layer 3. - -### Layer 3: Interface Layer (`monitor-cli`, `monitor-ui`) -The user-facing surfaces. -- **Responsibilities:** Command execution, interactive REPL, web-based chat, visual graph browsing. -- **Rule:** Imports from Layer 2; avoids direct Layer 1 imports (skip-layer protection). - ---- - -## 2. Subsystem Architecture - -### 2.1 Agent Architecture -MONITOR is a **multi-agent system** of specialized, stateless workers coordinated by state machines. - -| Agent | Responsibility | Write Authority | -|-------|----------------|-----------------| -| **ContextAssembly** | Gathers data from all DBs for a turn | Read-Only | -| **Narrator** | Generates prose and descriptions | MongoDB (Turns) | -| **Resolver** | Adjudicates rules and dice | MongoDB (Resolutions) | -| **CanonKeeper** | The "Gatekeeper" of truth | **Neo4j (Exclusive)** | -| **Indexer** | Embeds and indexes documents | Qdrant | -| **Analyzer** | Extracts knowledge from text | MongoDB (Packs) | -| **IngestionPipeline** | Orchestrates file processing | MinIO, Neo4j (Sources) | -| **WorldArchitect** | Guides world-building sessions | Neo4j (via CanonKeeper) | -| **NPCVoice** | Speaks as specific NPCs | MongoDB (Turns) | -| **RecapAgent** | Synthesizes story history | Read-Only | - -### 2.2 Orchestration via LangGraph -Instead of a monolithic "Orchestrator," we use LangGraph **StateGraph** loops. -- **SceneLoop:** Manages the turn-by-turn interactive scene. -- **StoryLoop:** Manages the high-level campaign progression and scene transitions. -- **ConversationLoop:** Specialized loop for deep NPC interactions. -- **WorldBuildingLoop:** Collaborative setting creation flow. - -### 2.3 Knowledge Architecture (The "Brain") -- **DSPy:** Used for creative reasoning chains (prose generation, knowledge extraction). -- **instructor:** Used for strict Pydantic output from LLMs (tool calls, structured responses). -- **LiteLLM:** Provider-agnostic abstraction for OpenAI, Anthropic, Gemini, etc. - ---- - -## 3. Data Flow Patterns - -### 3.1 The "Proposed Change" Pattern -To ensure the Neo4j Knowledge Graph remains clean and consistent, **no agent (except CanonKeeper) can write to Neo4j**. -1. **Agents** (Narrator, Resolver, Analyzer) produce `ProposedChange` documents in MongoDB. -2. **CanonKeeper** reviews these proposals against established policies. -3. **CanonKeeper** commits accepted proposals to Neo4j and marks them `accepted`. - -### 3.2 Context Retrieval Flow (RAG) -1. **User Action** → `ContextAssembly` agent. -2. `ContextAssembly` performs: - - **Semantic Search** in Qdrant (memories, lore). - - **Graph Traversal** in Neo4j (relationships, entities). - - **History Lookup** in MongoDB (recent turns). -3. Resulting **Context Package** is injected into the prompt for the `Narrator` or `Resolver`. - ---- - -## 4. Tech Stack - -| Component | Technology | -|-----------|------------| -| **Language** | Python 3.11+ | -| **Package Mgmt** | [uv](https://github.com/astral-sh/uv) | -| **Agent Framework** | [LangGraph](https://github.com/langchain-ai/langgraph) | -| **Prompt Engineering** | [DSPy](https://github.com/stanfordnlp/dspy) | -| **Schemas** | Pydantic v2 | -| **Interface** | Typer (CLI), FastAPI (Backend), Next.js (Frontend) | -| **Database: Graph** | Neo4j | -| **Database: Document** | MongoDB | -| **Database: Vector** | Qdrant | -| **Database: Relational** | PostgreSQL (Config/Management) | -| **Object Storage** | MinIO (S3 Compatible) | -| **Communication** | [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) | - ---- - -## 5. Communications & Interoperability (MCP) - -MONITOR uses the **Model Context Protocol (MCP)** as the standard interface between agents and data. - -- **Tools as Services:** Every database operation (Layer 1) is exposed as an MCP Tool. -- **Language Agnostic:** Agents can be written in any language that supports MCP clients, while the data layer remains a stable MCP server. -- **Transport:** Currently uses `stdio` for local execution and is ready for `SSE/HTTP` for distributed deployments. -- **Standardization:** All tool definitions follow the MCP schema, including descriptions, input parameters, and output formats. - ---- - -## 6. Deployment & Scalability -MONITOR is built to be **cloud-native** and **distributed**. -- Every database runs in Docker. -- Layers are package-separated for independent scaling. -- Agents are stateless, allowing for horizontal scaling of worker nodes. -- **Durability:** Every loop state is checkpointed to MongoDB via `MongoDBSaver`. - ---- - -## 7. Deep Dive Documentation - -For detailed specifications of individual subsystems, refer to the following documents: - -| Topic | Document | -|-------|----------| -| **Execution Loops** | [docs/architecture/CONVERSATIONAL_LOOPS.md](docs/architecture/CONVERSATIONAL_LOOPS.md) | -| **Data Flow** | [docs/architecture/DATA_FLOWS.md](docs/architecture/DATA_FLOWS.md) | -| **Memory & RAG** | [docs/architecture/RAG_AND_MEMORY.md](docs/architecture/RAG_AND_MEMORY.md) | -| **Prompt Engineering** | [docs/architecture/PROMPT_ENGINEERING.md](docs/architecture/PROMPT_ENGINEERING.md) | -| **Rules Engine** | [docs/architecture/RULES_ENGINE.md](docs/architecture/RULES_ENGINE.md) | -| **Lifecycle & Recovery** | [docs/architecture/LIFECYCLE_AND_RECOVERY.md](docs/architecture/LIFECYCLE_AND_RECOVERY.md) | -| **DB Integration** | [docs/architecture/DATABASE_INTEGRATION.md](docs/architecture/DATABASE_INTEGRATION.md) | - ---- - -## 8. Development Standards -- **Layer Integrity:** Strict import rules enforced by linting/tests. -- **Statelessness:** Agents must never store local state; use DBs or LangGraph state. -- **Traceability:** All LLM calls and tool executions are logged for auditing and debugging. diff --git a/README.md b/README.md index 084e1887..edbb80f8 100644 --- a/README.md +++ b/README.md @@ -115,10 +115,8 @@ Use the canonical docs below instead of hunting through overlapping summaries: | Canon doc | Purpose | |-----------|---------| | [`docs/STATUS.md`](docs/STATUS.md) | **Live verified status** — tests, gates, stack health | -| [`SYSTEM.md`](SYSTEM.md) | Product vision, goals, and operating modes | +| [`docs/_index.md`](docs/_index.md) | **Documentation Map** — entry point for architecture, product, loops, and ontology | | [`STRUCTURE.md`](STRUCTURE.md) | Repo layout and folder ownership | -| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Layer boundaries and dependency rules | -| [`docs/README.md`](docs/README.md) | Documentation index and placement rules | | [`docs/USE_CASES.md`](docs/USE_CASES.md) | Use-case catalog and workflow targets | | [`docs/AI_DOCS.md`](docs/AI_DOCS.md) | Contributor and agent quick navigation | | [`infra/README.md`](infra/README.md) | Local infrastructure setup and maintenance | @@ -131,7 +129,7 @@ For subsystem detail, see [`docs/architecture/`](docs/architecture/) and [`docs/ Before changing code or docs: -1. Read `SYSTEM.md`, `STRUCTURE.md`, and `ARCHITECTURE.md` +1. Read `docs/_index.md` and `STRUCTURE.md` 2. Respect layer boundaries (`cli → agents → data-layer`) 3. Add or update tests for behavior changes 4. Reference the relevant use-case ID in your PR or commit message diff --git a/SYSTEM.md b/SYSTEM.md deleted file mode 100644 index a89e601c..00000000 --- a/SYSTEM.md +++ /dev/null @@ -1,1198 +0,0 @@ -# MONITOR — System Description - -> **Multi-Ontology Narrative Intelligence Through Omniversal Representation** - ---- - -## One-Sentence Pitch - -A persistent narrative intelligence system that can build worlds, run solo RPGs, and assist human Game Masters by remembering everything that matters and reacting like a coherent storyteller. - ---- - -## 1. What It Is - -MONITOR is a **narrative intelligence system** that can create, understand, run, and assist tabletop role-playing games across one or multiple worlds. - -It operates in three distinct modes: - -| Mode | Description | -|------|-------------| -| **World Architect** | Builds and maintains fictional worlds and multiverses from structured and unstructured sources | -| **Autonomous Game Master** | Runs full solo RPG experiences with turn-by-turn narration and rules enforcement | -| **Game Master Assistant** | Supports human-led campaigns by recording, tracking, recalling, and analyzing sessions | - -The system treats **worlds, characters, events, and stories as persistent entities that evolve over time**. - ---- - -## 2. Core Objectives - -What success looks like: - -| ID | Objective | Description | -|----|-----------|-------------| -| **O1** | Persistent Fictional Worlds | Create and maintain consistent worlds that retain facts, history, entities, and causal continuity across sessions | -| **O2** | Playable Narrative Experiences | Deliver full solo RPG gameplay where the system narrates, adjudicates rules, and reacts meaningfully to player choices | -| **O3** | System-Agnostic Rules Handling | Support multiple RPG systems (dice, cards, custom mechanics) without hard-coding any single game | -| **O4** | Assisted Human GMing | Act as a reliable co-pilot for live or recorded sessions: remembering what happened, tracking consequences, and surfacing useful insights | -| **O5** | World Evolution Over Time | Allow worlds and characters to change permanently based on play, not reset between sessions | - ---- - -## 3. Epics - -### EPIC 1 — World & Multiverse Definition - -**Goal:** Allow users to create, expand, and modify fictional worlds with structured consistency. - -**Capabilities:** -- Define worlds, universes, and multiverses -- Store facts, locations, factions, rules of reality -- Track canonical vs optional or alternative truths - -**Key Use Cases:** -- Ingest a setting book or PDF and extract: - - Geography - - Cultures - - Magic/technology rules -- Manually add or edit world facts -- Fork timelines or alternate universes -- Ask factual questions about the world ("What gods exist in this region?") - ---- - -### EPIC 2 — Knowledge & Memory Ingestion - -**Goal:** Convert external information into usable world knowledge. - -**Capabilities:** -- Ingest: - - Written lore - - Session summaries - - Player notes - - Transcripts or recordings -- Distinguish: - - Facts - - Rumors - - Character beliefs - - Player knowledge vs character knowledge - -**Key Use Cases:** -- Upload campaign notes → world memory updates -- Record a live session → automatic event timeline -- Ask: "What did the party promise the Duke?" -- Detect contradictions or unresolved threads - ---- - -### EPIC 3 — Character Creation & Identity Management - -**Goal:** Support persistent player characters and NPCs across stories. - -**Capabilities:** -- Create player characters tied to: - - A world - - A rule system -- Maintain: - - Stats - - Inventory - - Relationships - - Psychological traits -- Allow characters to reappear as NPCs in other stories - -**Key Use Cases:** -- Create a solo character and start a campaign -- Import an existing character sheet -- Reuse a past PC as an NPC in a new story -- Ask: "How would this character realistically react?" - ---- - -### EPIC 4 — Autonomous Narrative Game Master - -**Goal:** Run a complete RPG session without a human GM. - -**Capabilities:** -- Scene-based narration -- Turn-by-turn interaction -- Player choice → world reaction -- Maintain tone, genre, and pacing -- Track unresolved consequences - -**Key Use Cases:** -- Play a solo campaign like an interactive novel -- Switch between: - - Freeform roleplay - - Structured turns -- Pause, rewind, or branch the story -- Ask the GM for clarification or summaries mid-session - ---- - -### EPIC 5 — Rules & Randomization Engine - -**Goal:** Apply RPG mechanics consistently and transparently. - -**Capabilities:** -- Support: - - Dice systems (d20, dice pools, percentiles) - - Card-based systems - - Custom probability rules -- Enforce: - - Success/failure logic - - Partial successes - - Narrative consequences - -**Key Use Cases:** -- Roll dice automatically when required -- Explain why an outcome happened -- Override or house-rule mechanics -- Ask: "What are my odds if I try this?" - ---- - -### EPIC 6 — Session Tracking & Timeline Management - -**Goal:** Treat gameplay as a sequence of meaningful events. - -**Capabilities:** -- Record: - - Scenes - - Actions - - Decisions - - Outcomes -- Maintain: - - World timelines - - Character timelines -- Enable querying past events - -**Key Use Cases:** -- Review last session summary -- Ask: "When did this NPC betray us?" -- Detect dangling plot threads -- Generate recaps for players - ---- - -### EPIC 7 — Human GM Assistant Mode - -**Goal:** Augment, not replace, a human Dungeon Master. - -**Capabilities:** -- Listen to or ingest live sessions -- Track: - - NPC names - - Improvised lore - - Player decisions -- Suggest: - - Plot hooks - - Consequences - - Continuations - -**Key Use Cases:** -- GM runs a live table; system records canon -- Ask mid-campaign: - - "What threads have I not resolved?" -- Generate prep notes for next session -- Detect inconsistencies introduced accidentally - ---- - -### EPIC 8 — Planning & Meta-Narrative Tools - -**Goal:** Help design stories without breaking immersion. - -**Capabilities:** -- Plan arcs without forcing outcomes -- Model factions, tensions, and goals -- Simulate "what if" scenarios - -**Key Use Cases:** -- Ask: "What happens if the kingdom collapses?" -- Design a mystery with multiple valid solutions -- Balance player agency with narrative pressure - ---- - -## 4. System Modes - -These rows describe the **target product workflow surface**. In the current repo, the main live play surface is the web chat UI, and the wired CLI command set is smaller (`state`, `rules`, `mechanics`, `ingest`, `playtest`). - -| Mode | Who Leads | System Role | Target Command Surface | Current Live Surface | -|------|-----------|-------------|------------------------|----------------------| -| **Solo Play** | Player | Full GM | `monitor play` | Web chat UI + `monitor playtest` | -| **Assisted GM** | Human GM | Memory + Analyst | `monitor copilot` | Web UI support flows | -| **World Design** | User | Architect | `monitor manage` | Web world-building flows + package APIs | -| **Post-Session Analysis** | User | Archivist | `monitor copilot`, `monitor query` | Query/use-case support remains mostly design-level | - -### Additional Product Commands - -| Command | Purpose | Use Cases | -|---------|---------|-----------| -| `monitor query` | Search and explore canon | Q-1 to Q-11 | -| `monitor ingest` | Upload and process documents | I-1 to I-13 | -| `monitor story` | Arc planning, faction modeling, what-if | ST-1 to ST-8 | -| `monitor rules` | Game system definition | RS-1 to RS-7 | - ---- - -## 5. Non-Goals - -The system does **NOT**: - -- Force stories toward predefined endings -- Replace player agency -- Require a single RPG system -- Assume combat-only gameplay - ---- - -## 6. Epic → Use Case Alignment - -> Cross-reference to `docs/USE_CASES.md` - -### Use Cases by Category - -| Category | Range | Description | -|----------|-------|-------------| -| **DATA LAYER** | `DL-1` to `DL-26` | Canonical data access and MCP interfaces | -| **PLAY** | `P-1` to `P-21` | Core gameplay loop — narration, scenes, actions | -| **MANAGE** | `M-1` to `M-35` | World administration — CRUD for all entities | -| **QUERY** | `Q-1` to `Q-11` | Canon exploration — search, browse, ask | -| **INGEST** | `I-1` to `I-12` | Knowledge import — documents, extraction, curation | -| **SYSTEM** | `SYS-1` to `SYS-12` | App lifecycle, config, session | -| **CO-PILOT** | `CF-1` to `CF-8` | Human GM assistant features | -| **STORY** | `ST-1` to `ST-8` | Planning & meta-narrative tools | -| **RULES** | `RS-1` to `RS-7` | Game system definition — stats, skills, mechanics | -| **PACKS** | `MP-1` to `MP-9` | Multiverse Pack composition and reuse | -| **DOCS** | `DOC-1` | Documentation publishing & governance | - -**Current catalog:** 165 use cases (see `docs/USE_CASES.md` for the live summary table) - -### Epic Mapping - -| Epic | Use Cases | Coverage | -|------|-----------|----------| -| **EPIC 0** — Data Layer Access | DL-1 to DL-14 (canonical data/MCP interfaces) | Defined | -| **EPIC 1** — World & Multiverse | M-1 to M-8 (hierarchy), M-23 to M-25 (axioms), M-30 (time) | Complete | -| **EPIC 2** — Knowledge Ingestion | I-1 to I-6 | Complete | -| **EPIC 3** — Character & Identity | M-12 to M-22 (entities, characters, memories) | Complete | -| **EPIC 4** — Autonomous GM | P-1 to P-8, P-11, P-12 (play loop) | Complete | -| **EPIC 5** — Rules & Randomization | P-4, P-9, P-10, **RS-1 to RS-4** (game systems) | Complete | -| **EPIC 6** — Session & Timeline | M-26 to M-30 (facts, scenes, time), Q-5 (timeline) | Complete | -| **EPIC 7** — Human GM Assistant | CF-1 to CF-5 (co-pilot features) | Complete | -| **EPIC 8** — Planning & Meta-Narrative | ST-1 to ST-5 (story planning) | Complete | -| **EPIC 9** — Documentation | DOC-1 | Defined | - -### Use Case Summary - -| Prefix | Name | Count | Epic | -|--------|------|-------|------| -| `DL-` | Data Layer | 14 | EPIC 0 | -| `P-` | Play | 12 | EPIC 4, 5 | -| `M-` | Manage | 30 | EPIC 1, 3, 6 | -| `Q-` | Query | 9 | EPIC 6 | -| `I-` | Ingest | 6 | EPIC 2 | -| `SYS-` | System | 10 | — | -| `CF-` | Co-Pilot | 5 | EPIC 7 | -| `ST-` | Story Planning | 5 | EPIC 8 | -| `RS-` | Rules | 4 | EPIC 5 | -| `DOC-` | Documentation | 1 | EPIC 9 | - -**Total: 96 use cases** - ---- - -## 7. Document Map - -| Document | Purpose | -|----------|---------| -| `SYSTEM.md` | **This file** — Product vision and epics | -| `ARCHITECTURE.md` | Technical layer architecture | -| `STRUCTURE.md` | Repository folder definitions | -| `docs/USE_CASES.md` | Detailed use case specifications | -| `docs/AI_DOCS.md` | Quick reference for implementation | -| `packages/*/IMPLEMENTATION.md` | Layer-specific task lists | - ---- - -## 8. Ideal State (System Modes) - -### Mode 1: World Architect - -### Purpose - -Build and maintain fictional worlds from structured and unstructured sources. The system should automatically extract entities, facts, relationships from documents and organize them into coherent world knowledge. - -### Ideal User Experience - -``` -User: I want to create a fantasy world for my D&D campaign. - -System: Let's build your world. What's the name? -User: Eldoria -System: Great! Eldoria is created. You can upload documents or add manual facts. -User: (Uploads "Eldoria_Campaign_Setting.pdf") -System: Extracting content from 150 pages... - Found 45 entities (characters, locations, factions) - Found 120 facts (lore, history, rules) - Found 80 relationships (alliances, rivalries) - Ready to review! - -User: Show me the factions. -System: (Lists factions with descriptions): - - The Silver Alliance (good-aligned kingdom) - - The Shadow Cabal (evil conspiracy) - - The Free Traders Guild (merchant alliance) - -User: Add a fact: The Silver Alliance is at war with the Shadow Cabal. -System: Fact added. This contradicts existing fact that they are uneasy allies. - Would you like to resolve the contradiction? -User: Retcon the old fact. -System: Done. Silver Alliance and Shadow Cabal are now at war. - -User: Create a character for the party. -System: What's the character's name and race? -User: Elara, High Elf Rogue -System: Elara created. Would you like to import from a character sheet? -User: No, let me add stats. STR 10, DEX 18, CON 12, INT 14, WIS 12, CHA 10. -System: Stats saved. Elara is ready for adventure! - -User: Apply the "Forgotten Ruins" knowledge pack to this world. -System: Applying pack... - Added 15 new locations (ruins, dungeons) - Added 30 new entities (ancient guardians, traps) - Added 20 new facts (lore about the ruins) - Pack applied successfully! -``` - -### What the System Must Do - -#### 1. Document Ingestion (I-1 to I-13) - -**User Action:** Upload a PDF document -**System Action:** -1. Upload document to MinIO storage -2. Extract text from PDF -3. Chunk text into 500-token snippets with 50-token overlap -4. Generate embeddings for each snippet -5. Store embeddings in Qdrant -6. Extract entities (names, locations, factions, items) -7. Extract facts (lore, history, rules) -8. Extract relationships (alliances, rivalries, family ties) -9. Store extracted data in MongoDB (for review) -10. Present extracted data to user for curation - -**Success Criteria:** -- All text extracted correctly -- Entities identified with ≥80% precision -- Facts identified with ≥70% precision -- Relationships identified with ≥60% precision -- User can review and curate extracted data -- User can accept/reject/approve extracted data - -#### 2. Knowledge Pack Curation (I-12) - -**User Action:** Curate extracted entities, facts, relationships into a knowledge pack -**System Action:** -1. User reviews extracted entities -2. User accepts/rejects/modifies entities -3. User reviews extracted facts -4. User accepts/rejects/modifies facts -5. User reviews extracted relationships -6. User accepts/rejects/modifies relationships -7. User creates knowledge pack -8. System stores pack with metadata (name, description, type) - -**Success Criteria:** -- User can review all extracted data -- User can modify extracted data -- User can create packs from curated data -- Packs are reusable across worlds - -#### 3. Knowledge Pack Application (MP-5 to MP-9) - -**User Action:** Apply knowledge pack to world -**System Action:** -1. User selects world -2. User selects pack -3. System applies pack: - - Create entities from archetypes - - Create facts from lore_facts - - Create relationships - - Handle conflicts (duplicate entities, contradictory facts) -4. System presents conflict resolution options -5. User resolves conflicts -6. System commits changes to canon (via CanonKeeper) - -**Success Criteria:** -- Pack applies successfully without errors -- Conflicts are detected and presented to user -- User can resolve conflicts (merge, skip, overwrite) -- Changes are committed to canon correctly -- World state is consistent after application - -#### 4. World State Persistence (O1, O5) - -**User Action:** Access world after session -**System Action:** -1. User opens world -2. System loads all entities, facts, relationships -3. System shows world state (entities, facts, timeline) -4. User can review history (what happened in previous sessions) -5. User can see current world state (who's alive, what factions exist, etc.) - -**Success Criteria:** -- World state persists across sessions -- Facts remain consistent -- Entities evolve over time (stats change, relationships change) -- Timeline is preserved -- User can review history - ---- - -### Mode 2: Autonomous Game Master (Solo Roleplay) - -### Purpose - -Run a complete RPG session without a human GM. The system should narrate, adjudicate rules, and react meaningfully to player choices. The system should act like a coherent storyteller who remembers everything and respects the rules. - -### Ideal User Experience - -``` -User: I want to play a solo D&D campaign. - -System: Let's set up your character. -User: Elara, High Elf Rogue, level 1. -System: Stats? AC? Hit points? -User: (Enters stats) -System: Character created. What's the starting adventure? -User: The party is hired to investigate disappearances in a village. -System: Scene starting... - [You arrive in the village of Millhaven at dusk. The streets are empty, - and an unnatural mist clings to the ground. A nervous innkeeper named - Barnaby greets you.] - -User: "What's going on with the mist?" -System: [Barnaby's eyes dart around nervously. "It started three nights ago. - People started disappearing. Then the mist came. Nobody goes out - after dark anymore. I'd suggest you find rooms quickly."] - -User: "I'll search the village for clues." -System: [Rolling Investigation...] - [Your investigation DC is 12.] - [Rolling 1d20 + 5 (Investigation) = 17] - [Success!] - [You find fresh drag marks leading from the town square toward the old - cemetery. The marks are only hours old.] - -User: "I follow the tracks to the cemetery." -System: [Scene transitioning to cemetery...] - [The cemetery is shrouded in mist. Crypts loom like silent sentinels. - The drag marks lead to a newly-dug grave that's been disturbed.] - -User: "Is there anything in the grave?" -System: [Oracle query: Is there anything in the grave?] - [Tension: High (7). Likelihood: Likely.] - [Rolling percentile... 42] - [Yes, but...] - [You peer into the grave. There's a body, but it's not what you expect. - It's a villager, but their skin is pale and their eyes glow faintly blue. - They're not dead... not yet.] - -User: "I draw my sword and prepare for a fight." -System: [Combat initiated!] - [Enemy: Possessed Villager (stats: AC 12, HP 15, attacks: claw +4)] - [Roll initiative... You: 18, Enemy: 12] - [You go first!] - -User: "I attack with my rapier." -System: [Rolling attack... 1d20 + 6 = 22] - [Hit!] - [Rolling damage... 1d8 + 3 = 7] - [The possessed villager staggers back. Your rapier glows with - magical energy as it cuts through the mist.] - -User: (Continues combat...) - -[Several turns later...] - -User: "I search the villager's body." -System: [You find a silver amulet with a strange blue gem. It pulses with - an otherworldly energy.] - [Item added: Amulet of Shadows] - -User: "I return to the inn and ask Barnaby if he recognizes this amulet." -System: [Barnaby's face goes pale. "That... that belonged to the village - elder, Magda. She disappeared first. We never found her body."] - -User: (Session ends) -System: Session summary: - - You investigated the village - - You found drag marks leading to the cemetery - - You fought and defeated a possessed villager - - You found an amulet belonging to the missing elder - - XP gained: 50 - - Unresolved threads: Where is Magda? What's causing the possessions? - -User: Start downtime. -System: You have 50 XP. Available options: - - Level up to level 2 (requires 300 XP) - - Train a skill (+1 to a skill, costs 10 XP) - - Learn a feat (requires 100 XP) -User: Train Investigation (+1 to Investigation, costs 10 XP). -System: Done! Your Investigation skill is now +6. - Remaining XP: 40. -``` - -### What the System Must Do - -#### 1. Turn Loop (P-3) - The Heart of the Game - -**User Action:** Declares action ("I attack", "I pick the lock", "I climb") -**System Action:** -1. Display context (location, present entities, recent turns) -2. Await user input -3. Parse input type: - - If starts with `/` → META_COMMAND - - If starts with `"` or contains "say" → DIALOGUE - - If contains `?` or starts with "what", "who", "where", "how" → QUESTION - - Otherwise → ACTION -4. Process through appropriate handler: - - ACTION → P-4 (Resolve Action) - - DIALOGUE → P-5 (Handle Dialogue) - - QUESTION → P-6 (Answer Question) - - META_COMMAND → P-7 (Execute Command) -5. Generate response (via Narrator agent) -6. Append turns to MongoDB (user turn, GM turn) -7. Check: Should scene end? -8. If yes → P-8 (End Scene) -9. If no → Continue loop - -**Success Criteria:** -- Turn loop executes smoothly (no crashes) -- Input parsing is accurate (≥95%) -- Response generation is coherent (≥80% user satisfaction) -- Turns are appended correctly to MongoDB -- Scene ends when appropriate -- Response time < 3s per turn - -#### 2. Resolve Action (P-4) - Dice and Outcomes - -**User Action:** Declares action ("I attack the goblin") -**System Action:** -1. Parse action intent -2. Identify target entities -3. Determine difficulty (DC) -4. Determine resolution type: - - **Dice**: Combat, skill checks, saves - - **Narrative**: Trivial actions (GM decides) - - **Auto-success**: Guaranteed success (impossible to fail) - - **Auto-fail**: Impossible actions (can't succeed) -5. If dice: - - Roll dice according to formula - - Calculate total - - Determine success level: - - critical_success: roll + 10 or nat 20 - - success: roll >= DC - - partial: DC - 5 <= roll < DC - - failure: DC - 10 <= roll < DC - 5 - - critical_failure: roll < DC - 10 or nat 1 -6. Create ProposedChanges (state changes, damage, etc.) -7. Narrator describes outcome -8. Return to turn loop - -**Success Criteria:** -- Difficulty calculation is correct (DC appropriate for action) -- Dice rolling is accurate (formula interpreted correctly) -- Success level determination is correct -- ProposedChanges are created for all state changes -- Narrative description is coherent and responsive -- Response time < 500ms (excluding narration) - -#### 3. AutoGM Oracle (P-18) - Probability Resolution - -**User Action:** Asks question about unknown environmental state ("Is the door locked?") -**System Action:** -1. Detect question about unknown fact -2. Determine tension_score (0-10, higher = more tension) -3. Determine likelihood based on tension/narrative context: - - High tension → unlikely answers (system is hostile) - - Low tension → likely answers (system is benign) -4. Roll percentile die (1d100) -5. Map to outcome: - - 1-5: "No, and..." (bad outcome) - - 6-45: "No" (answer is no) - - 46-55: "No, but..." (answer is no but something good happens) - - 56-95: "Yes" (answer is yes) - - 96-100: "Yes, and..." (good outcome) -6. Canonize oracle result as Fact -7. Narrator describes outcome, respecting rolled truth - -**Example:** -``` -Question: "Is the door locked?" -Tension: 7 (high tension) -Likelihood: Unlikely (system is hostile) -Roll: 25 -Outcome: "No" (door is not locked) -Narrator: [The door creaks open easily. It wasn't locked after all.] -Fact: "The door to the crypt is not locked." (canon_level=cards) -``` - -**Success Criteria:** -- Oracle questions are detected correctly -- Likelihood determination is based on tension/narrative -- Dice rolling is accurate -- Outcome mapping is correct -- Oracle result is canonized as Fact -- Narrator respects the rolled oracle truth -- Oracle resolves consistently (same question → same outcome) - -#### 4. Procedural Scene Population (P-19) - Auto-Generate Content - -**User Action:** Transitions to new/unexplored location -**System Action:** -1. Detect scene transition to new location -2. Check if location is unpopulated (no entities staged) -3. If unpopulated, trigger procedural generation: - - Pull Random Tables for location type (encounters, features, loot) - - Roll on each table - - Generate entities based on rolls: - - NPCs (from encounter table) - - Hazards (from hazard table) - - Loot (from loot table) - - Features (from feature table) - - Stage entities in scene (temporary or canonized) -4. Narrator describes procedurally generated elements in opening prompt - -**Example:** -``` -Location: "Dungeon Room 3" (type=cave) -Random Tables: -- Encounters: 1d6 goblins (rolled: 2) -- Hazards: 1d4 pits (rolled: 1) -- Loot: 1d3 treasure chests (rolled: 1) -- Features: 1d6 stalactites (rolled: 3) - -Generated entities: -- 2 Goblins (temporarily staged in scene) -- 1 Pit trap (temporarily staged) -- 1 Treasure chest (canonized: becomes permanent world entity) -- 3 Stalactites (temporary) - -Narrator: [You enter a dark cave. Two goblins are arguing over a treasure chest, - while a third goblin stands guard. Stalactites hang from the ceiling, - and there's a pit in the center of the room.] -``` - -**Success Criteria:** -- Scene transition triggers procedural generation -- Random tables are pulled correctly for location type -- Rolls are random and varied (not same every time) -- Generated entities are staged correctly -- Narrator describes procedurally generated elements -- Generation doesn't create contradictions (e.g., duplicate NPCs) - -#### 5. Forced Narrative Pushback (P-20) - GM Authority - -**User Action:** Declares forced narrative ("I instantly kill the boss with one hit") -**System Action:** -1. Parse action -2. Detect forced narrative declaration -3. Evaluate stakes: - - Low stakes: trivial actions (allow forced narrative) - - High stakes: combat, major plot points (require roll) -4. If stakes are high: - - Pause turn - - Prompt user: "This requires a roll. Do you want to roll?" - - User options: - - Accept pushback (convert to dice roll) - - Override with explicit GM mode command ("/gm override") -5. If user accepts: - - Convert action to dice roll - - Calculate DC - - Roll dice - - Determine outcome -6. Narrate outcome based on roll - -**Example:** -``` -User: "I instantly kill the dragon with one arrow!" -System: [The dragon is a powerful creature. This requires a roll.] - [Do you want to roll? (Y/N)] -User: Y -System: [Rolling attack... 1d20 + 7 = 15] - [Dragon AC: 18] - [Miss!] - [Your arrow bounces off the dragon's scales. The dragon roars!] -``` - -**Success Criteria:** -- Forced narrative declarations are detected correctly -- Stakes are evaluated correctly -- High-stakes actions prompt for roll -- Low-stakes actions allow forced narrative -- User can override with explicit GM mode command -- Pushback prevents trivialization of combat - -#### 6. Downtime & Progression (P-21) - Character Advancement - -**User Action:** Completes milestone or enters downtime scene -**System Action:** -1. Detect milestone completion or downtime scene -2. Present progression options: - - XP summary (how much XP gained) - - Available upgrades (level up, train skill, learn feat) - - Requirements (XP needed, prerequisites) -3. User selects progression option -4. Validate choice against game system rules: - - Check if enough XP - - Check if prerequisites met - - Check if choice is legal per game system -5. If valid: - - Commit changes to canonical entity properties in Neo4j - - Update character stats - - Reduce XP - - Confirm to user -6. If invalid: - - Explain why (not enough XP, prerequisites not met) - -**Example:** -``` -System: [Story arc complete!] - [XP gained: 300] - [Total XP: 500] - [Current level: 1] - [Available options:] - - Level up to level 2 (requires 300 XP) ✓ Available - - Train a skill (+1 to skill, costs 50 XP) ✓ Available - - Learn a feat (requires 100 XP) ✓ Available - -User: Level up to level 2. -System: [Leveling up to level 2...] - [Gaining: +1 proficiency bonus, new class feature] - [Remaining XP: 200] - [Character updated in canon!] - -User: Train Investigation (+1 to Investigation, costs 50 XP). -System: [Investigation skill increased from +5 to +6] - [Remaining XP: 150] - [Character updated in canon!] -``` - -**Success Criteria:** -- Milestone detection is accurate -- Progression options are presented correctly -- Validation against game system rules is correct -- Invalid choices are rejected with explanation -- Valid choices are committed to canon -- Character stats persist across sessions - ---- - -### Mode 3: Game Master Assistant (Co-Pilot) - -### Purpose - -Augment, not replace, a human Dungeon Master. The system should capture sessions, generate recaps, detect unresolved threads, suggest plot hooks, detect contradictions, and generate handouts. The human GM remains in control. - -### Ideal User Experience - -``` -GM: I'm running a D&D session tonight. I want the system to record it. - -System: Start recording session? -GM: Yes. The story is "The Shadow Conspiracy". -System: Recording started. Enter GM notes as the session progresses. - -[Session starts...] -GM: (typing notes) The party arrives at the village of Millhaven. They meet - Barnaby, the nervous innkeeper. -System: [Parsed: Scene started. Location: Millhaven. Entity: Barnaby (innkeeper). - Fact: Barnaby is nervous. Status: pending review] - -[Later in session...] -GM: (typing notes) The party investigates disappearances. They find drag marks - leading to the cemetery. They find a possessed villager. -System: [Parsed: Action: Investigate disappearances. Entity: Possessed Villager. - Fact: Villagers are being possessed. Status: pending review] - -[Session ends...] -GM: Stop recording. -System: Recording stopped. Session summary: - - Scenes: 2 (Millhaven inn, Cemetery) - - Turns: 25 - - Entities: 4 (Barnaby, Possessed Villager, Elara, Gandalf) - - Facts proposed: 7 - - Ready for review. - -GM: Generate a recap for my players. -System: Session Recap: - **What Happened:** - The party arrived in Millhaven at dusk and met Barnaby, the nervous - innkeeper. Barnaby told them about disappearances and strange mist. - - **Key Events:** - - Party investigates disappearances - - Party finds drag marks leading to cemetery - - Party fights and defeats a possessed villager - - Party finds amulet belonging to missing elder Magda - - **Decisions Made:** - - Party chose to investigate cemetery (risky choice) - - Party chose to fight possessed villager (combat decision) - - **NPCs Encountered:** - - Barnaby (innkeeper, nervous) - - Possessed Villager (hostile, defeated) - - **Unresolved Questions:** - - Where is Magda? - - What's causing the possessions? - - What's in the cemetery crypt? - -GM: Show me unresolved threads. -System: Unresolved Threads (ranked by priority): - 1. **High Priority:** Where is Magda? (asked at turn 20, never answered) - 2. **Medium Priority:** What's causing the possessions? (central mystery) - 3. **Low Priority:** What's in the crypt? (foreshadowed but not explored) - -GM: Suggest some plot hooks for next session. -System: Plot Hook Suggestions: - 1. **Immediate:** The possessed villager wakes up and reveals they were - forced by a dark ritual. They know where Magda is. - 2. **Near-term:** The amulet pulses with energy and leads the party to a - hidden cave where the possessions originate. - 3. **Long-term:** The Shadow Cabal is behind everything. They're - kidnapping villagers for a ritual to summon a dark entity. - -GM: Check for contradictions. -System: No contradictions found. Canon is consistent. - -GM: Generate a handout for my players. -System: What type of handout? - - Session recap - - Character dossier (what PC knows) - - Location guide (known facts about place) - - Quest log (active plot threads) -GM: Character dossier for Elara. -System: Elara's Knowledge: - **What Elara Knows:** - - Barnaby is the innkeeper at Millhaven - - Villagers are disappearing - - Strange mist appears at night - - Possessed villager fought and defeated - - Amulet belonged to missing elder Magda - - Drag marks led to cemetery - - **What Elara Doesn't Know:** - - Where Magda is - - What's causing the possessions - - What the Shadow Cabal is planning - -GM: Review and accept the proposed facts. -System: Proposed Facts: - 1. Barnaby is nervous (accept/reject/modify) - 2. Villagers are being possessed (accept/reject/modify) - 3. Amulet belonged to Magda (accept/reject/modify) - ... - -GM: (Reviews and accepts all facts) -System: Facts committed to canon via CanonKeeper. - Canon updated successfully! - -GM: Prepare for next session. -System: Next Session Prep: - - Current threads: 3 unresolved - - Active entities: 4 (Barnaby, Magda (missing), Shadow Cabal) - - Recent events: Possessed villager defeated, amulet found - - Suggested plot hooks: 3 (see above) -``` - -### What the System Must Do - -#### 1. Record Session (CF-1) - Capture Human-Led Sessions - -**User Action:** Starts recording session -**System Action:** -1. Initialize recording mode for story -2. Enter passive observation: - - GM types notes into assistant chat - - OR session is recorded/transcribed for later ingestion -3. Parse and categorize incoming material: - - Action - - Dialogue - - Lore - - Decision - - Consequence -4. Create/update draft story/scene documents in MongoDB -5. For each significant event: - - Append turns to draft scene transcript - - Create ProposedChange items tagged with timestamp, participants, location -6. GM can annotate in real time ("this is important", "NPC name: Varys") -7. Session ends → scene drafts and pending proposals ready for review - -**Success Criteria:** -- Recording starts and stops correctly -- Incoming material is parsed and categorized accurately (≥80%) -- Turns are appended to draft transcript -- ProposedChanges are created for significant events -- GM can annotate in real time -- Session can be reviewed after recording - -#### 2. Generate Recap (CF-2) - Summarize What Happened - -**User Action:** Requests recap of session -**System Action:** -1. Select session/scene to recap -2. Analyze: - - All turns in scene - - Accepted proposals - - Key decisions and outcomes -3. Generate structured recap: - - **Summary:** 2-3 paragraph overview - - **Key Events:** Bulleted list - - **Decisions Made:** Player choices and consequences - - **NPCs Encountered:** Names and roles - - **Threads Opened/Closed:** Plot progression - - **Loot/Rewards:** If applicable -4. Display recap -5. Option: Export as Markdown, share with players - -**Success Criteria:** -- Recap covers all major events (≥90%) -- Recap is readable and useful for players -- Recap is accurate (doesn't hallucinate events) -- Recap includes decisions, NPCs, threads -- Recap can be exported - -#### 3. Detect Unresolved Threads (CF-3) - Surface Plot Hooks - -**User Action:** Asks for unresolved threads -**System Action:** -1. Analyze story history: - - All scenes in current story - - All proposals and facts - - NPC statements and promises - - Player stated intentions -2. Identify unresolved items: - - **Open Questions:** Things players asked but weren't answered - - **Unfulfilled Promises:** NPCs promised something, not delivered - - **Dangling Hooks:** Clues planted but not followed up - - **Incomplete Quests:** Started but not finished - - **Missing Payoffs:** Setups without resolution -3. Rank by: - - Recency (older = more urgent) - - Importance (player interest level) - - Story relevance -4. Display prioritized list -5. GM can: dismiss, mark resolved, add notes - -**Success Criteria:** -- All unresolved threads are detected -- Threads are categorized correctly (questions, promises, hooks) -- Threads are ranked by priority -- GM can manage threads (dismiss, resolve, annotate) -- Thread detection is accurate (≥80%) - -#### 4. Suggest Plot Hooks (CF-4) - Generate Contextual Hooks - -**User Action:** Asks for plot hooks -**System Action:** -1. Analyze current context: - - Active story and recent events - - Present location and NPCs - - Unresolved threads (CF-3) - - Character goals and relationships - - Faction tensions -2. Generate hook suggestions: - - **Immediate:** Can happen right now - - **Near-term:** Next session material - - **Long-term:** Arc-level developments -3. For each hook, provide: - - Description - - Involved entities - - Potential outcomes - - Connection to existing threads -4. GM selects, modifies, or dismisses -5. Selected hooks optionally saved as plot_thread - -**Success Criteria:** -- Hooks are contextually appropriate (≥70%) -- Hooks connect to existing threads -- Hooks involve relevant entities -- GM can modify or dismiss hooks -- Hooks can be saved as plot_thread - -#### 5. Detect Contradictions (CF-5) - Find Conflicting Facts - -**User Action:** Runs contradiction detection -**System Action:** -1. Scope selection: - - Current scene only - - Current story - - Entire universe -2. Analyze all canonical facts for conflicts: - - **Direct contradictions:** "X is dead" vs "X spoke to party" - - **Timeline violations:** Event B before Event A (but B depends on A) - - **Location conflicts:** Entity in two places at same time - - **Relationship conflicts:** "X hates Y" vs "X is Y's ally" - - **Rule violations:** Actions that break established axioms -3. For each conflict: - - Show both facts with sources - - Suggest resolution options: - - Retcon older fact - - Retcon newer fact - - Mark as "apparent contradiction" (mystery) - - Create explanation fact -4. GM resolves each conflict -5. Update canon accordingly - -**Success Criteria:** -- All contradictions are detected -- Contradictions are categorized correctly -- Sources are shown (where facts came from) -- Resolution options are provided -- GM can resolve contradictions -- Canon remains consistent after resolution - -#### 6. Generate Player Handouts (CF-6) - Create Summaries for Players - -**User Action:** Requests player handout -**System Action:** -1. **Select Scope:** - - Specific character (what they know) - - Party (shared knowledge) - - Story so far (campaign summary) - - Location (travel guide) - - NPC (relationship summary) -2. **Configure Handout:** - - Perspective: In-character vs out-of-character - - Detail level: Brief, standard, detailed - - Include/exclude: Secrets, rumors, speculation - - Format: Prose, bullet points, table -3. **Generate Handout:** - - System gathers relevant facts, memories, scenes - - Filters by character knowledge (what PC witnessed) - - Excludes GM-only information - - Generates formatted output -4. **Review & Export:** - - GM reviews and edits - - Export as Markdown, PDF, or image - - Optionally save to story documents - -**Success Criteria:** -- Handout is filtered by character knowledge (what PC knows) -- GM-only information is excluded -- Handout is accurate and useful for players -- Handout can be exported in multiple formats -- GM can edit before export - ---- - -### Cross-Cutting Concerns - -### 1. Narrative Coherence - -**Requirement:** Narrator must generate coherent, contextually appropriate responses that respect world state, character knowledge, and previous turns. - -**What Must Happen:** -- Narrator has access to full context (entities, facts, events, recent turns) -- Narrator references previous events and character knowledge -- Narrator respects canon (doesn't contradict established facts) -- Narrator maintains tone and genre consistency -- Narrator is responsive to player choices (not generic) - -**Example:** -``` -Turn 1: Player defeats goblin -Turn 2: Player asks "What happened to the goblin?" -Narrator: "The goblin's body lies on the ground. You defeated it in combat." -(Not: "I don't see any goblin here.") -``` - -### 2. World State Consistency - -**Requirement:** World state must remain consistent across sessions and modes. Facts, entities, and relationships must evolve logically. - -**What Must Happen:** -- Facts persist across sessions -- Entities evolve over time (stats change, relationships change) -- Contradictions are detected and resolved -- Timeline is preserved (event A before event B) -- CanonKeeper ensures only canonical facts are written to Neo4j - -**Example:** -``` -Session 1: Character HP = 20/20 → takes 5 damage → HP = 15/20 -Session 2: Character HP = 15/20 → heals 5 → HP = 20/20 -(Not: Character HP = 20/20 in Session 2) -``` - -### 3. Performance - -**Requirement:** System must respond quickly to maintain immersion. - -**Performance Targets:** -- Turn loop (full cycle): < 3s -- Resolve action (excluding narration): < 500ms -- Semantic search: < 200ms -- Document processing (10MB PDF): < 30s -- Knowledge pack application (100 entities): < 30s - -### 4. Reliability - -**Requirement:** System must handle errors gracefully and not lose data. - -**What Must Happen:** -- LLM API failures → fallback to rule-based resolution -- Database connection losses → auto-reconnect with retry -- Invalid inputs → graceful error messages (not crashes) -- Concurrent operations → no data corruption -- Turn data is persisted before generating response - -### 5. Scalability - -**Requirement:** System must support multiple concurrent users and large worlds. - -**What Must Happen:** -- Multiple users can play simultaneously without interference -- Large worlds (1000+ entities) load in < 5s -- Large scenes (1000+ turns) load in < 10s -- Vector search remains fast with large embeddings (100K+) - ---- - -### Success Criteria - -### Mode 1: World Architect - -- ✅ Document ingestion extracts entities, facts, relationships correctly -- ✅ Knowledge packs can be curated and applied without conflicts -- ✅ World state persists across sessions -- ✅ Contradictions are detected and can be resolved -- ✅ World evolves over time (O5) - -### Mode 2: Autonomous GM (Solo Roleplay) - -- ✅ Turn loop executes smoothly (no crashes) -- ✅ Actions resolve correctly (dice, narrative, auto) -- ✅ Narrative is coherent and responsive -- ✅ Oracle resolves questions consistently -- ✅ Procedural generation creates varied content -- ✅ Forced narrative pushback prevents abuse -- ✅ Character progression works correctly -- ✅ World state updates correctly - -### Mode 3: GM Assistant (Co-Pilot) - -- ✅ Sessions are captured and parsed correctly -- ✅ Recaps are accurate and useful -- ✅ Unresolved threads are detected and ranked -- ✅ Plot hooks are contextually appropriate -- ✅ Contradictions are detected and can be resolved -- ✅ Handouts are filtered by character knowledge - ---- diff --git a/conftest.py b/conftest.py index 0c91a504..7a919ce2 100644 --- a/conftest.py +++ b/conftest.py @@ -77,6 +77,15 @@ def _wants_network(item: pytest.Item) -> bool: def pytest_runtest_setup(item: pytest.Item) -> None: + # Honor the gating promised by the marker docs (pytest.ini): integration/e2e + # tests hit real services, so skip them unless their env flag is set. + # Without this they run against the unroutable hermetic URIs above and fail + # with a DB/connection timeout instead of skipping cleanly. + if "e2e" in item.keywords and not os.getenv("RUN_E2E"): + pytest.skip("e2e test — set RUN_E2E=1 to run") + if "integration" in item.keywords and not _INTEGRATION_MODE: + pytest.skip("integration test — set RUN_INTEGRATION=1 (or RUN_E2E=1) to run") + if _INTEGRATION_MODE or _wants_network(item): enable_socket() else: diff --git a/docs/1_product/_index.md b/docs/1_product/_index.md new file mode 100644 index 00000000..e714f28a --- /dev/null +++ b/docs/1_product/_index.md @@ -0,0 +1,18 @@ +--- +description: "Index for Product definition: vision, modes, epics, and use cases." +tags: [product, index, vision] +layer: 0 +--- + +# 1. Product Definitions + +This directory contains the high-level definitions of what MONITOR is, what it aims to do, and the epics guiding its development. + +## Documents + +- **[Vision & Modes](./vision_and_modes.md)**: The core pitch, modes of operation, and non-goals. +- **[Epics](./epics.md)**: The 9 core development epics and their coverage. +- **[Ideal State](./ideal_state.md)**: Examples of how the three modes should ideally operate and detailed success criteria. + +## See Also +- [Root Index](../_index.md) diff --git a/docs/1_product/epics.md b/docs/1_product/epics.md new file mode 100644 index 00000000..b2b7c546 --- /dev/null +++ b/docs/1_product/epics.md @@ -0,0 +1,38 @@ +--- +description: "The 9 core development epics and their coverage." +tags: [product, epics, use-cases] +layer: 0 +--- + +# Epics & Use Case Alignment + +MONITOR's development is guided by 9 core Epics. + +## The Epics +- **EPIC 1 — World & Multiverse Definition**: Create, expand, modify fictional worlds. +- **EPIC 2 — Knowledge & Memory Ingestion**: Convert external information (PDFs, notes) into world knowledge. +- **EPIC 3 — Character Creation & Identity Management**: Support persistent PCs and NPCs across stories. +- **EPIC 4 — Autonomous Narrative Game Master**: Run a complete RPG session without a human GM. +- **EPIC 5 — Rules & Randomization Engine**: Apply RPG mechanics consistently and transparently. +- **EPIC 6 — Session Tracking & Timeline Management**: Treat gameplay as a sequence of meaningful events. +- **EPIC 7 — Human GM Assistant Mode**: Augment a human DM (listen, track, suggest). +- **EPIC 8 — Planning & Meta-Narrative Tools**: Help design stories without breaking immersion. + +## Epic to Use Case Mapping + +| Epic | Use Cases | Coverage | +|------|-----------|----------| +| **EPIC 0** — Data Layer Access | `DL-1` to `DL-14` | Defined | +| **EPIC 1** — World & Multiverse | `M-1` to `M-8`, `M-23` to `M-25`, `M-30` | Complete | +| **EPIC 2** — Knowledge Ingestion | `I-1` to `I-6` | Complete | +| **EPIC 3** — Character & Identity | `M-12` to `M-22` | Complete | +| **EPIC 4** — Autonomous GM | `P-1` to `P-8`, `P-11`, `P-12` | Complete | +| **EPIC 5** — Rules & Randomization | `P-4`, `P-9`, `P-10`, `RS-1` to `RS-4` | Complete | +| **EPIC 6** — Session & Timeline | `M-26` to `M-30`, `Q-5` | Complete | +| **EPIC 7** — Human GM Assistant | `CF-1` to `CF-5` | Complete | +| **EPIC 8** — Planning & Meta-Narrative | `ST-1` to `ST-5` | Complete | +| **EPIC 9** — Documentation | `DOC-1` | Defined | + +## See Also +- [Vision & Modes](./vision_and_modes.md) +- [USE_CASES.md](../../USE_CASES.md) diff --git a/docs/1_product/ideal_state.md b/docs/1_product/ideal_state.md new file mode 100644 index 00000000..ea7f823d --- /dev/null +++ b/docs/1_product/ideal_state.md @@ -0,0 +1,34 @@ +--- +description: "Examples of how the three main modes should ideally operate." +tags: [product, ideal-state, use-cases] +layer: 0 +--- + +# Ideal State & Target Use Cases + +This outlines what the system must do across its primary modes. + +## Mode 1: World Architect +**Goal:** Build and maintain fictional worlds from structured and unstructured sources. +- **Document Ingestion (`I-1` to `I-13`)**: Upload PDFs, chunk text, generate embeddings, extract entities, facts, and relationships. +- **Knowledge Pack Curation (`I-12`)**: Review and refine extracted data into reusable knowledge packs. +- **Knowledge Pack Application (`MP-5` to `MP-9`)**: Apply packs to a world, resolve conflicts, commit to canon. +- **World State Persistence**: Facts persist and remain consistent across sessions. + +## Mode 2: Autonomous GM (Solo Roleplay) +**Goal:** Run a complete RPG session without a human GM. +- **Turn Loop (`P-3`)**: The core interaction. Parses action intent (dialogue, question, action, command) and delegates. +- **Resolve Action (`P-4`)**: Computes DCs, rolls dice, evaluates success margins, and creates `ProposedChanges`. +- **AutoGM Oracle (`P-18`)**: Answers questions about unknown environmental states using tension-based probability. +- **Forced Narrative Pushback (`P-20`)**: Prevents players from declaring high-stakes results without rolling. + +## Mode 3: Game Master Assistant (Co-Pilot) +**Goal:** Augment a human DM by capturing sessions and suggesting hooks. +- **Record Session (`CF-1`)**: Parses GM notes/transcripts to draft scenes and propose facts. +- **Generate Recap (`CF-2`)**: Summarizes scenes, decisions, and threads. +- **Detect Unresolved Threads (`CF-3`)**: Finds open questions or dangling hooks. +- **Suggest Plot Hooks (`CF-4`)**: Generates contextual hooks based on unresolved threads and recent events. +- **Detect Contradictions (`CF-5`)**: Identifies conflicting facts and suggests resolutions. + +## See Also +- [Vision & Modes](./vision_and_modes.md) diff --git a/docs/1_product/vision_and_modes.md b/docs/1_product/vision_and_modes.md new file mode 100644 index 00000000..e67169d9 --- /dev/null +++ b/docs/1_product/vision_and_modes.md @@ -0,0 +1,38 @@ +--- +description: "The core pitch, modes of operation, and non-goals of MONITOR." +tags: [product, vision, modes] +layer: 0 +--- + +# Vision & Modes + +**MONITOR**: Multi-Ontology Narrative Intelligence Through Omniversal Representation + +## One-Sentence Pitch +A persistent narrative intelligence system that can build worlds, run solo RPGs, and assist human Game Masters by remembering everything that matters and reacting like a coherent storyteller. + +## Core Objectives (What success looks like) +- **O1** Persistent Fictional Worlds: Retain facts, history, entities across sessions. +- **O2** Playable Narrative Experiences: Full solo RPG gameplay, narration, rules enforcement. +- **O3** System-Agnostic Rules: Support multiple RPG systems (dice, cards, etc.). +- **O4** Assisted Human GMing: Co-pilot for tracking, recalling, and analyzing sessions. +- **O5** World Evolution Over Time: Permanent changes based on play. + +## System Modes +| Mode | Who Leads | System Role | Target Command Surface | +|------|-----------|-------------|------------------------| +| **Solo Play** | Player | Full GM | `monitor play` | +| **Assisted GM** | Human GM | Memory + Analyst | `monitor copilot` | +| **World Design** | User | Architect | `monitor manage` | +| **Post-Session Analysis** | User | Archivist | `monitor copilot`, `monitor query` | + +## Non-Goals +The system does **NOT**: +- Force stories toward predefined endings +- Replace player agency +- Require a single RPG system +- Assume combat-only gameplay + +## See Also +- [Epics](./epics.md) +- [Ideal State](./ideal_state.md) diff --git a/docs/2_architecture/_index.md b/docs/2_architecture/_index.md new file mode 100644 index 00000000..56166d69 --- /dev/null +++ b/docs/2_architecture/_index.md @@ -0,0 +1,23 @@ +--- +description: "Index for Architecture documentation." +tags: [architecture, index] +layer: 0 +--- + +# 2. Architecture + +This directory details the technical architecture of MONITOR, primarily organized around the 3-Layer Cake dependency pattern. + +## Documents + +- **[The Three Layers](./the_three_layers.md)**: Defines the boundaries and rules of the 3-Layer architecture. +- **[Layer 1: Data](./layer1_data.md)**: Database clients, Pydantic schemas, and MCP tools. +- **[Layer 2: Agents](./layer2_agents.md)**: LangGraph loops, BaseAgent, and DSPy modules. +- **[Layer 3: Interface](./layer3_interface.md)**: CLI and Web interfaces. +- **[MCP Transport](./mcp_transport.md)**: How agents interact with Layer 1 via Model Context Protocol. + +## The Proposed Change Pattern +To preserve the integrity of the Neo4j Knowledge Graph, **no agent (except CanonKeeper) can write to Neo4j**. Instead, agents create `ProposedChange` documents in MongoDB. The CanonKeeper reviews these against established policies and commits accepted proposals to Neo4j. + +## See Also +- [Root Index](../_index.md) diff --git a/docs/2_architecture/layer1_data.md b/docs/2_architecture/layer1_data.md new file mode 100644 index 00000000..ad692d06 --- /dev/null +++ b/docs/2_architecture/layer1_data.md @@ -0,0 +1,30 @@ +--- +description: "Details Layer 1: Data, Databases, and Schemas." +tags: [architecture, data, layer-1, databases] +layer: 1 +--- + +# Layer 1: Data Layer (`monitor-data-layer`) + +The foundation of the system. It owns all database connections, data schemas (Pydantic), and the "Canonical Truth". + +## Responsibilities +- **Database CRUD**: Direct interactions with underlying databases. +- **Authority Enforcement**: Gating operations based on user/agent permissions. +- **Schema Validation**: Defining and enforcing Pydantic v2 data models. +- **MCP Server**: Exposing data operations as Model Context Protocol tools. + +## Databases & Usage +- **Neo4j (Canon)**: Stores the canonical knowledge graph (Entities, Facts, Relationships). +- **MongoDB (State)**: Stores mutable state, turn history, and `ProposedChange` documents. +- **Qdrant (Vectors)**: Stores embeddings for semantic search. +- **PostgreSQL**: Stores configuration and management metadata. +- **MinIO**: Object storage for uploaded files and documents. + +## Strict Rules +- **Rule:** Never imports from Layer 2 or 3. +- **Rule:** All tools must be exposed via MCP. Agents should not bypass MCP to access databases. + +## See Also +- [The Three Layers](./the_three_layers.md) +- [MCP Transport](./mcp_transport.md) diff --git a/docs/2_architecture/layer2_agents.md b/docs/2_architecture/layer2_agents.md new file mode 100644 index 00000000..6240eee1 --- /dev/null +++ b/docs/2_architecture/layer2_agents.md @@ -0,0 +1,30 @@ +--- +description: "Details Layer 2: Agents, Loops, and Logic." +tags: [architecture, agents, layer-2, langgraph] +layer: 2 +--- + +# Layer 2: Agent Layer (`monitor-agents`) + +The "brain" of the system. It handles narrative intelligence, reasoning, and orchestration using stateless agents. + +## Core Components +- **Agents**: Specialized, stateless workers (e.g., `ContextAssembly`, `Narrator`, `Resolver`, `CanonKeeper`). +- **Loops**: LangGraph StateGraph loops that manage control flow. +- **DSPy**: Used for creative reasoning chains (e.g., generating prose, extracting knowledge). +- **Instructor**: Enforces strict Pydantic output from LLMs for tool calls. +- **LiteLLM**: Provider-agnostic abstraction for LLM calls. + +## Responsibilities +- **Orchestration**: Managing complex, multi-turn interactions. +- **Narrative Logic**: Applying rules, rolling dice, generating descriptions. +- **Knowledge Synthesis**: Assembling context from Layer 1 tools to feed to prompts. + +## Strict Rules +- **Rule:** Imports from Layer 1. +- **Rule:** Never imports from Layer 3. +- **Rule:** Agents must be stateless. All persistence is handled via LangGraph Checkpointers (to MongoDB) or MCP tool calls. + +## See Also +- [The Three Layers](./the_three_layers.md) +- [Loops & Systems Index](../3_loops_and_systems/_index.md) diff --git a/docs/2_architecture/layer3_interface.md b/docs/2_architecture/layer3_interface.md new file mode 100644 index 00000000..a7ea111b --- /dev/null +++ b/docs/2_architecture/layer3_interface.md @@ -0,0 +1,26 @@ +--- +description: "Details Layer 3: Interface Layer." +tags: [architecture, interface, layer-3] +layer: 3 +--- + +# Layer 3: Interface Layer + +This layer acts as the user-facing surface for the entire MONITOR system. + +## Components +- **monitor-cli**: A Typer-based CLI for running the system, entering REPLs, and executing specific offline commands. +- **monitor-ui-backend**: A FastAPI application that serves the frontend and handles WebSocket connections for live loops. +- **monitor-ui-frontend**: A Next.js web application for visual interaction, world building, and chat. + +## Responsibilities +- Taking user input and passing it to the appropriate Layer 2 loops. +- Displaying streaming output from agents. +- Formatting structured data for human consumption. + +## Strict Rules +- **Rule:** Imports from Layer 2. +- **Rule:** **Skip-Layer Protection:** Layer 3 must avoid importing directly from Layer 1. Operations requiring Layer 1 access must be routed through Layer 2 Agents or Loops. + +## See Also +- [The Three Layers](./the_three_layers.md) diff --git a/docs/2_architecture/mcp_transport.md b/docs/2_architecture/mcp_transport.md new file mode 100644 index 00000000..0e903aa7 --- /dev/null +++ b/docs/2_architecture/mcp_transport.md @@ -0,0 +1,29 @@ +--- +description: "How agents communicate with the data layer using the Model Context Protocol." +tags: [architecture, mcp, data-access] +layer: 1 +--- + +# MCP Transport + +MONITOR uses the **Model Context Protocol (MCP)** as the standard interface between Layer 2 (Agents) and Layer 1 (Data). + +## Why MCP? +- **Tools as Services**: Every database operation is exposed as an MCP Tool. +- **Language Agnostic**: Agents can be written in any language that supports MCP clients. The data layer remains a stable MCP server. +- **Standardization**: All tool definitions follow the MCP schema strictly (descriptions, input parameters, output formats). + +## Architecture +```mermaid +graph LR + Agent[Layer 2 Agent] -->|JSON-RPC via stdio| MCPServer[Layer 1 MCP Server] + MCPServer -->|CRUD| DB[(Databases)] +``` + +## Transport Modes +- Currently uses `stdio` for local execution. +- Designed to be easily upgraded to `SSE/HTTP` for distributed deployments. + +## See Also +- [Layer 1: Data](./layer1_data.md) +- [Layer 2: Agents](./layer2_agents.md) diff --git a/docs/2_architecture/the_proposed_change_pattern.md b/docs/2_architecture/the_proposed_change_pattern.md new file mode 100644 index 00000000..f0e6e039 --- /dev/null +++ b/docs/2_architecture/the_proposed_change_pattern.md @@ -0,0 +1,24 @@ +--- +description: "The core design pattern for safely mutating the canonical graph." +tags: [architecture, data-flow, pattern, canonkeeper] +layer: 1 +--- + +# The Proposed Change Pattern + +To ensure the Neo4j Knowledge Graph remains clean, consistent, and strictly canonical, MONITOR employs the **Proposed Change Pattern**. + +## The Core Rule +**No agent (except the CanonKeeper) can write directly to Neo4j.** + +## How It Works +1. **Agents Propose**: Agents (like the Narrator, Resolver, or Analyzer) generate structural mutations but write them to MongoDB as `ProposedChange` documents. +2. **Review**: The CanonKeeper agent evaluates these proposals against established policies (e.g., checking for contradictions, enforcing constraints). +3. **Commit**: The CanonKeeper commits accepted proposals to Neo4j and marks them `accepted` in MongoDB. + +## Why This Matters +- Prevents rogue agents or hallucinating LLMs from corrupting the core graph. +- Allows for Human-in-the-Loop review (in Co-Pilot mode, the GM can review `ProposedChange` documents before they are canonized). + +## See Also +- [Layer 1: Data](./layer1_data.md) diff --git a/docs/2_architecture/the_three_layers.md b/docs/2_architecture/the_three_layers.md new file mode 100644 index 00000000..c7bf0ab3 --- /dev/null +++ b/docs/2_architecture/the_three_layers.md @@ -0,0 +1,53 @@ +--- +description: "Defines the 3-Layer Cake dependency architecture of MONITOR." +tags: [architecture, layers, boundaries] +layer: 0 +--- + +# The Three Layers + +MONITOR follows a strict layered architecture pattern known as the **3-Layer Cake**. Dependencies only flow **downward**. + +```mermaid +graph TD + UI["User Interface (CLI / Web UI)"] + + subgraph Layer3["Layer 3: Interface Layer"] + CLI["monitor-cli"] + WebFrontend["monitor-ui-frontend"] + WebBackend["monitor-ui-backend"] + end + + subgraph Layer2["Layer 2: Agent Layer"] + Loops["LangGraph Loops"] + Agents["Specialized Agents"] + Logic["GameSystemRuntime"] + end + + subgraph Layer1["Layer 1: Data Layer"] + Tools["MCP Tools"] + Clients["DB Clients"] + Schemas["Pydantic Schemas"] + end + + subgraph Infrastructure + Neo4j["Neo4j (Canon)"] + MongoDB["MongoDB (State)"] + Qdrant["Qdrant (Vectors)"] + PG["PostgreSQL (Config)"] + MinIO["MinIO (Files)"] + end + + UI --> Layer3 + Layer3 --> Layer2 + Layer2 --> Layer1 + Layer1 --> Infrastructure +``` + +## Layer Summary +1. **[Layer 1: Data Layer](./layer1_data.md)**: Connects to databases. Validates schemas. Exposes MCP Tools. Never imports from Layer 2 or 3. +2. **[Layer 2: Agent Layer](./layer2_agents.md)**: AI logic, LangGraph loops, DSPy reasoning. Imports from Layer 1. Never imports from Layer 3. +3. **[Layer 3: Interface Layer](./layer3_interface.md)**: User surfaces. Imports from Layer 2. Avoids direct Layer 1 imports. + +## See Also +- [Architecture Index](./_index.md) diff --git a/docs/3_loops_and_systems/_index.md b/docs/3_loops_and_systems/_index.md new file mode 100644 index 00000000..694fd976 --- /dev/null +++ b/docs/3_loops_and_systems/_index.md @@ -0,0 +1,23 @@ +--- +description: "Index for all dynamic execution loops and state machines." +tags: [loops, index, langgraph] +layer: 2 +--- + +# 3. Loops & Systems + +This directory documents the dynamic behaviors of MONITOR. Instead of a monolithic "Orchestrator," the system uses **LangGraph StateGraph** state machines to handle complex, multi-turn interactions. + +## The Core Loops +- **[Scene Loop](./scene_loop.md)**: The primary unit of play. Manages turn-by-turn interaction. +- **[Story Loop](./story_loop.md)**: Manages high-level campaign progression and scene transitions. +- **[Conversation Loop](./conversation_loop.md)**: A specialized loop for deep, multi-turn NPC dialogue. +- **[World-Building Loop](./world_building_loop.md)**: A collaborative session for defining setting elements. + +## Durability & State +All major loops use LangGraph Checkpointers (e.g., `MongoDBSaver`). This ensures: +- **Crash Recovery**: Mid-turn crashes can be resumed exactly where they left off. +- **Time Travel**: Supports `/backtrack` commands by walking back through the graph history. + +## See Also +- [Layer 2: Agents](../2_architecture/layer2_agents.md) diff --git a/docs/3_loops_and_systems/conversation_loop.md b/docs/3_loops_and_systems/conversation_loop.md new file mode 100644 index 00000000..9ed16196 --- /dev/null +++ b/docs/3_loops_and_systems/conversation_loop.md @@ -0,0 +1,29 @@ +--- +description: "Details the specialized loop for deep, multi-turn NPC dialogue." +tags: [loop, langgraph, conversation, npc] +layer: 2 +--- + +# Conversation Loop + +**Intent:** Provide a dedicated flow for social interactions, focusing on dialogue flow and relationship shifts rather than physical action adjudication. + +## Flow Diagram +```mermaid +stateDiagram-v2 + [*] --> open_session + open_session --> player_turn + player_turn --> npc_responses + npc_responses --> player_turn: continue chat + npc_responses --> close_session: chat ends + close_session --> [*] +``` + +## Node Explanations +- **`open_session`**: Bootstraps the dialogue context (who is present, what is their disposition, recent memories). +- **`player_turn`**: Awaits user dialogue input. +- **`npc_responses`**: Calls the `NPCVoice` agent to generate in-character responses for one or more NPCs based on their specific personality profiles. +- **`close_session`**: Summarizes the conversation, extracts new facts, and stages relationship-update `ProposedChange` documents. + +## See Also +- [Loops Index](./_index.md) diff --git a/docs/3_loops_and_systems/scene_loop.md b/docs/3_loops_and_systems/scene_loop.md new file mode 100644 index 00000000..e8691196 --- /dev/null +++ b/docs/3_loops_and_systems/scene_loop.md @@ -0,0 +1,32 @@ +--- +description: "Details the execution steps of the LangGraph Scene Loop for turn-by-turn gameplay." +tags: [loop, langgraph, scene-loop] +layer: 2 +--- + +# Scene Loop (Core Play) + +**Intent:** Provide a durable, checkpointed state machine to handle a single turn of gameplay, ensuring the player's action is resolved, narrated, and safely persisted. + +## Flow Diagram +```mermaid +stateDiagram-v2 + [*] --> load_context + load_context --> resolve: S1→S3 + resolve --> narrate: S3→S4/S5 + narrate --> persist_turn_artifacts + persist_turn_artifacts --> canonize: scene_complete or max_turns + persist_turn_artifacts --> [*]: continue (await next run) + canonize --> [*]: scene finalized +``` + +## Node Explanations +- **`load_context`**: Calls `ContextAssembly` agent to gather entities, facts, and memories relevant to the scene and action. +- **`resolve`**: Calls the `Resolver` agent to adjudicate rules and dice. Outputs `ProposedChange` documents. +- **`narrate`**: Calls the `Narrator` agent to generate immersive GM prose based on the context and resolution. +- **`persist_turn_artifacts`**: Saves the generated turn and resolution state to MongoDB. +- **`canonize`**: (Runs at end of scene) Calls the `CanonKeeper` to evaluate all staged `ProposedChange` documents and commit accepted ones to Neo4j. + +## See Also +- [Loops Index](./_index.md) +- [The Proposed Change Pattern](../2_architecture/the_proposed_change_pattern.md) diff --git a/docs/3_loops_and_systems/story_loop.md b/docs/3_loops_and_systems/story_loop.md new file mode 100644 index 00000000..c3cb4f66 --- /dev/null +++ b/docs/3_loops_and_systems/story_loop.md @@ -0,0 +1,32 @@ +--- +description: "Details the execution steps of the LangGraph Story Loop." +tags: [loop, langgraph, story-loop] +layer: 2 +--- + +# Story Loop (Campaign Progression) + +**Intent:** Manage the lifecycle of a story arc, connecting multiple scenes and ensuring the world evolves "off-screen" between major events. + +## Flow Diagram +```mermaid +stateDiagram-v2 + [*] --> init_story + init_story --> run_scene + run_scene --> world_advance: scene complete + world_advance --> transition: story continues + world_advance --> finalize: story complete + transition --> run_scene + finalize --> [*] +``` + +## Node Explanations +- **`init_story`**: Establishes the arc parameters and initial world state. +- **`run_scene`**: Hands control over to the [Scene Loop](./scene_loop.md). +- **`world_advance`**: Runs the `Simulacrum Agent` after scenes to simulate faction moves, NPC actions, and environmental changes based on elapsed time. +- **`transition`**: Updates continuity and plot threads before starting the next scene. +- **`finalize`**: Wraps up the story arc, ensuring all final world states are canonized. + +## See Also +- [Loops Index](./_index.md) +- [Scene Loop](./scene_loop.md) diff --git a/docs/3_loops_and_systems/world_building_loop.md b/docs/3_loops_and_systems/world_building_loop.md new file mode 100644 index 00000000..d23101b9 --- /dev/null +++ b/docs/3_loops_and_systems/world_building_loop.md @@ -0,0 +1,18 @@ +--- +description: "Details the collaborative session flow for defining setting elements." +tags: [loop, langgraph, world-building] +layer: 2 +--- + +# World-Building Loop + +**Intent:** Provide a structured, collaborative session between the user and the `WorldArchitect` to define or expand a setting (entities, axioms, lore) before play begins. + +## Key Features +- Operates primarily in **Mode 1 (World Architect)**. +- Facilitates back-and-forth Q&A about world parameters. +- Stages large batches of `ProposedChange` documents for mass-canonization. + +## See Also +- [Loops Index](./_index.md) +- [Vision & Modes](../1_product/vision_and_modes.md) diff --git a/docs/4_ontology/_index.md b/docs/4_ontology/_index.md new file mode 100644 index 00000000..c3f7659b --- /dev/null +++ b/docs/4_ontology/_index.md @@ -0,0 +1,17 @@ +--- +description: "Index for Ontology and Data Models." +tags: [ontology, index, data-model] +layer: 1 +--- + +# 4. Ontology & Data Models + +This directory defines the structures, meaning, and taxonomies of data stored in MONITOR's databases (primarily Neo4j). + +## Core Concepts +- **[Fact Canon Levels](./fact_canon_levels.md)**: How truth is managed (e.g., rumor vs. hard canon). +- **[Entity Types](./entity_types.md)**: The difference between Archetypes (templates) and Instances (realized entities). +- **[Graph Relationships](./graph_relationships.md)**: How nodes in Neo4j connect to form the world graph. + +## See Also +- [Layer 1: Data](../2_architecture/layer1_data.md) diff --git a/docs/4_ontology/entity_types.md b/docs/4_ontology/entity_types.md new file mode 100644 index 00000000..47fba104 --- /dev/null +++ b/docs/4_ontology/entity_types.md @@ -0,0 +1,22 @@ +--- +description: "Differentiates between templates and actualized objects." +tags: [ontology, entities] +layer: 1 +--- + +# Entity Types (Archetypes vs. Instances) + +MONITOR cleanly separates definitions (what a thing *could* be) from actualized beings (what a thing *is*). + +## Archetypes +- **Definition**: A blueprint or template. +- **Example**: "Goblin (Monster Manual)", "Longsword (Standard Item)". +- **Usage**: Used heavily by Knowledge Packs. + +## Instances +- **Definition**: A specific, unique realization in the world. +- **Example**: "Droop the Goblin (currently at 3 HP)", "Elara's Longsword (chipped edge)". +- **Usage**: Used during live play. Instances inherit baseline traits from their Archetype but can mutate independently. + +## See Also +- [Ontology Index](./_index.md) diff --git a/docs/4_ontology/fact_canon_levels.md b/docs/4_ontology/fact_canon_levels.md new file mode 100644 index 00000000..94a0c809 --- /dev/null +++ b/docs/4_ontology/fact_canon_levels.md @@ -0,0 +1,22 @@ +--- +description: "Explains how truth and certainty are graded in the system." +tags: [ontology, facts, canon] +layer: 1 +--- + +# Fact Canon Levels + +To allow the world to contain rumors, lies, and subjective character beliefs without corrupting objective reality, facts in MONITOR are assigned a `CanonLevel`. + +## Levels of Canon +1. **Core / Axiom**: Unbreakable laws of the universe. +2. **Canon**: Verified, objective truth (established by the system or human GM). +3. **Derived**: Truth deduced by the system based on other facts. +4. **Rumor / Subjective**: What an entity *believes* to be true (may be false). +5. **Alternative / Proposed**: Used during what-if simulations or unreviewed `ProposedChange` drafts. + +## Resolving Contradictions +When a higher-level canon fact contradicts a lower-level one, the higher level wins. `CanonKeeper` is responsible for evaluating these prior to Neo4j commits. + +## See Also +- [Ontology Index](./_index.md) diff --git a/docs/4_ontology/graph_relationships.md b/docs/4_ontology/graph_relationships.md new file mode 100644 index 00000000..151a78ff --- /dev/null +++ b/docs/4_ontology/graph_relationships.md @@ -0,0 +1,22 @@ +--- +description: "Standard edge labels used in the Neo4j Knowledge Graph." +tags: [ontology, neo4j, relationships] +layer: 1 +--- + +# Graph Relationships + +The Neo4j database uses standard edge (relationship) labels to construct the Knowledge Graph. + +## Core Relationships +- `LOCATED_IN`: Spatial hierarchy (e.g., Tavern -> City -> Region). +- `OWNS` / `HAS_INVENTORY`: Possession. +- `KNOWS` / `ALLIED_WITH` / `ENEMIES_WITH`: Social topology. +- `BELIEVES`: Connects an Entity to a Subjective Fact. +- `INSTANCE_OF`: Connects an Instance to its Archetype. + +By traversing these edges, the `ContextAssembly` agent builds the contextual package needed for a scene. + +## See Also +- [Ontology Index](./_index.md) +- [Layer 1: Data](../2_architecture/layer1_data.md) diff --git a/docs/5_infrastructure/_index.md b/docs/5_infrastructure/_index.md new file mode 100644 index 00000000..0c9070ab --- /dev/null +++ b/docs/5_infrastructure/_index.md @@ -0,0 +1,17 @@ +--- +description: "Index for DevOps, infrastructure, and environments." +tags: [infrastructure, index, devops] +layer: 0 +--- + +# 5. Infrastructure + +This directory outlines how MONITOR runs, how its databases are orchestrated, and how we monitor system health. + +## Documents +- **[Database Cluster](./database_cluster.md)**: The Docker Compose setup for Neo4j, MongoDB, and others. +- **[Observability](./observability.md)**: Logging standards using `structlog`. +- **[Lain MCP Proxy](./lain_mcp_proxy.md)**: How the Lain architecture assistant is integrated. + +## See Also +- [Root Index](../_index.md) diff --git a/docs/5_infrastructure/database_cluster.md b/docs/5_infrastructure/database_cluster.md new file mode 100644 index 00000000..7fa93a21 --- /dev/null +++ b/docs/5_infrastructure/database_cluster.md @@ -0,0 +1,23 @@ +--- +description: "Details the docker-compose environment." +tags: [infrastructure, docker, databases] +layer: 0 +--- + +# Database Cluster + +MONITOR relies heavily on containerized infrastructure for local development and eventual cloud deployment. + +## The Docker Compose Stack +Running `./dev.sh` spins up `infra/docker-compose.yml`, which includes: +- **Neo4j** (Port 7687) +- **MongoDB** (Port 27017) +- **Qdrant** (Port 6333) +- **MinIO** (Port 9000, 9001) + +## Environment Variables +The `.env` file (copied from `env.example`) dictates credentials for these databases. The Data Layer dynamically loads these upon initialization. + +## See Also +- [Infrastructure Index](./_index.md) +- [Layer 1: Data](../2_architecture/layer1_data.md) diff --git a/docs/5_infrastructure/lain_mcp_proxy.md b/docs/5_infrastructure/lain_mcp_proxy.md new file mode 100644 index 00000000..49a675af --- /dev/null +++ b/docs/5_infrastructure/lain_mcp_proxy.md @@ -0,0 +1,30 @@ +--- +description: "Details the Lain MCP integration for architecture tools." +tags: [infrastructure, mcp, lain] +layer: 0 +--- + +# Lain MCP Proxy + +Lain is an external architecture analysis tool integrated into the workspace as an MCP server. + +## Configuration +Located in `.vscode/settings.json`, Lain runs via a proxy script: `scripts/lain-mcp-proxy.sh`. +It operates on port `9999`. + +## Capabilities +Agents can call Lain tools (via standard MCP clients) for: +- Blast radius analysis (`get_blast_radius`) +- Dependency traces (`trace_dependency`, `get_call_chain`) +- Semantic code search (`semantic_search`) + +## Health Checks +You can curl the proxy to verify it's running: +```bash +curl -s -X POST http://localhost:9999/mcp -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_health","arguments":{}},"id":1}' +``` + +## See Also +- [Infrastructure Index](./_index.md) +- [MCP Transport](../2_architecture/mcp_transport.md) diff --git a/docs/5_infrastructure/observability.md b/docs/5_infrastructure/observability.md new file mode 100644 index 00000000..c4ad1577 --- /dev/null +++ b/docs/5_infrastructure/observability.md @@ -0,0 +1,23 @@ +--- +description: "Logging standards and tracing." +tags: [infrastructure, logging, structlog] +layer: 0 +--- + +# Observability + +## Logging with Structlog +MONITOR forbids the use of `print()` for system logs. All modules must use `structlog` to ensure logs are structured, parseable (JSON in production), and context-aware. + +```python +import structlog + +log = structlog.get_logger() +log.info("scene_resolved", scene_id="123", outcome="success") +``` + +## Traceability +Every LLM call and MCP tool execution is logged. LangGraph also provides native trace states that are persisted via the `MongoDBSaver`. + +## See Also +- [Infrastructure Index](./_index.md) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 8ea47142..00000000 --- a/docs/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# MONITOR Documentation Map - -This directory is organized around a **small set of canonical sources** plus supporting design references. - ---- - -## Canonical Sources of Truth - -| Document | Owns | -|----------|------| -| [`../SYSTEM.md`](../SYSTEM.md) | Product vision, modes, objectives, epics | -| [`../STRUCTURE.md`](../STRUCTURE.md) | Repo layout and folder ownership | -| [`../ARCHITECTURE.md`](../ARCHITECTURE.md) | Layer boundaries and dependency rules | -| [`USE_CASES.md`](USE_CASES.md) | Use-case catalog and workflow targets | -| [`AI_DOCS.md`](AI_DOCS.md) | Contributor and agent quick navigation | - -> Prefer linking to these documents instead of repeating the same explanation in multiple places. - ---- - -## `docs/` Layout - -| Area | Purpose | Status | -|------|---------|--------| -| [`architecture/`](architecture/) | Current design references and subsystem contracts | Active | -| [`architecture/futures/`](architecture/futures/) | RAG research plans (MiA-RAG, SitEmb, CatRAG) | Active — Phase 1 partially implemented | -| [`superpowers/`](superpowers/) | Implementation plans and design specs for major features | Active | -| [`ontology/`](ontology/) | Data model and taxonomy definitions | Active | -| [`use-cases/`](use-cases/) | Structured YAML use-case definitions used by automation | Supporting source | -| [`gameplay-examples/`](gameplay-examples/) | Example play patterns and inspiration | Reference only | -| [`archive/`](archive/) | Historical plans, audits, and superseded writeups | Non-canonical | - ---- - -## Where New Docs Should Go - -| If the document is about... | Put it in... | -|-----------------------------|--------------| -| Product goals or modes | `SYSTEM.md` | -| Repo/folder ownership | `STRUCTURE.md` | -| Cross-layer rules or runtime boundaries | `ARCHITECTURE.md` or `docs/architecture/` | -| Future RAG/retrieval research plans | `docs/architecture/futures/` | -| Implementation plans and design specs | `docs/superpowers/plans/` and `docs/superpowers/specs/` | -| Data model or schema meaning | `docs/ontology/` | -| Use-case definitions for humans | `docs/USE_CASES.md` | -| Structured use-case metadata for automation | `docs/use-cases/` | -| Historical planning or audits | `docs/archive/` | - ---- - -## Useful Standalone References - -| Document | Purpose | -|----------|---------| -| [`data-layer-details.md`](use-cases/data-layer-details.md) | Storage-focused DL appendix | -| [`GM_CRAFT.md`](GM_CRAFT.md) | Play-surface tone, pacing, and GM-style guidance | -| [`architecture/futures/HYBRID_MINDSCAPE_AND_TRAVERSAL_PLAN.md`](architecture/futures/HYBRID_MINDSCAPE_AND_TRAVERSAL_PLAN.md) | Master plan combining three RAG retrieval strategies | -| [`architecture/futures/INGESTION_FIRST_CONTEXTUAL_RETRIEVAL_PLAN.md`](architecture/futures/INGESTION_FIRST_CONTEXTUAL_RETRIEVAL_PLAN.md) | Recommended rollout order for contextual retrieval | -| [`architecture/futures/MINDSCAPE_AWARE_INGESTION_IMPLEMENTATION_PLAN.md`](architecture/futures/MINDSCAPE_AWARE_INGESTION_IMPLEMENTATION_PLAN.md) | Mindscape-aware long-text ingestion (✅ partially implemented) | -| [`architecture/futures/SITUATED_CONVERSATIONAL_RETRIEVAL_IMPLEMENTATION_PLAN.md`](architecture/futures/SITUATED_CONVERSATIONAL_RETRIEVAL_IMPLEMENTATION_PLAN.md) | Situated conversational and transcript retrieval (not started) | -| [`architecture/futures/QUERY_AWARE_TRAVERSAL_IMPLEMENTATION_PLAN.md`](architecture/futures/QUERY_AWARE_TRAVERSAL_IMPLEMENTATION_PLAN.md) | Typed, query-aware graph traversal (not started) | - ---- - -## Documentation Hygiene Rules - -- Keep **runtime reality** separate from **target UX**. -- Prefer **short summaries + links** over duplicate long explanations. -- If a doc becomes obsolete but still has historical value, move it to `docs/archive/`. -- If a file is redundant and adds no value, delete it. -- Update this index when the documentation structure changes. diff --git a/docs/_index.md b/docs/_index.md new file mode 100644 index 00000000..78bd6123 --- /dev/null +++ b/docs/_index.md @@ -0,0 +1,40 @@ +--- +description: "The root index for all MONITOR agent documentation." +tags: [index, root, map] +layer: 0 +--- + +# MONITOR Documentation Map + +Welcome to the MONITOR Agent Documentation. This structure is designed to be highly modular and dense to maximize context efficiency. + +## 1. Product (What are we building?) +- [Vision & Modes](./1_product/vision_and_modes.md): The core pitch, modes of operation, and non-goals. +- [Epics](./1_product/epics.md): The 9 core development epics and their coverage. +- [Ideal State & Use Cases](./1_product/ideal_state.md): Examples of how the three modes should ideally operate. + +## 2. Architecture (How is it built?) +- [The Three Layers](./2_architecture/the_three_layers.md): The strict `3-Layer Cake` dependency rules. +- [Layer 1: Data](./2_architecture/layer1_data.md): Databases and tools. +- [Layer 2: Agents](./2_architecture/layer2_agents.md): Specialized workers and DSPy. +- [Layer 3: Interface](./2_architecture/layer3_interface.md): CLI and UI. +- [MCP Transport](./2_architecture/mcp_transport.md): How agents communicate with data. + +## 3. Loops & Systems (Dynamic Behaviors) +- [Scene Loop](./3_loops_and_systems/scene_loop.md): Turn-by-turn resolution. +- [Story Loop](./3_loops_and_systems/story_loop.md): Campaign progression. +- [Conversation Loop](./3_loops_and_systems/conversation_loop.md): NPC dialogue logic. +- [World Building Loop](./3_loops_and_systems/world_building_loop.md): Collaborative creation. + +## 4. Ontology (Data Models) +- [Fact Canon Levels](./4_ontology/fact_canon_levels.md): How truth is managed. +- [Entity Types](./4_ontology/entity_types.md): Archetypes vs Instances. +- [Graph Relationships](./4_ontology/graph_relationships.md): How Neo4j nodes connect. + +## 5. Infrastructure (DevOps & Env) +- [Database Cluster](./5_infrastructure/database_cluster.md): The docker-compose setup. +- [Observability](./5_infrastructure/observability.md): Structlog and tracing. +- [Lain MCP Proxy](./5_infrastructure/lain_mcp_proxy.md): How Lain integrates. + +## See Also +- [AGENTS.md](../AGENTS.md) - The primary system instructions for agents. diff --git a/docs/architecture/CONVERSATIONAL_LOOPS.md b/docs/architecture/CONVERSATIONAL_LOOPS.md deleted file mode 100644 index e4778410..00000000 --- a/docs/architecture/CONVERSATIONAL_LOOPS.md +++ /dev/null @@ -1,88 +0,0 @@ -# Conversational Loops - -MONITOR uses **LangGraph StateGraph** state machines to orchestrate complex, multi-turn interactions. This replaces the traditional monolithic "Orchestrator" pattern with a modular, checkpointed, and traceable graph-based flow. - ---- - -## 1. Hierarchy of Loops - -The system operates using four nested or specialized loops: - -1. **Story Loop:** Manages high-level campaign progression, world-advancement (simulation), and scene transitions. -2. **Scene Loop:** The primary unit of play. Manages turn-by-turn interaction within a specific narrative context. -3. **Conversation Loop:** A specialized loop for deep, multi-turn dialogue sessions with one or more NPCs. -4. **World-Building Loop:** A collaborative session for defining setting elements (entities, axioms, lore). - ---- - -## 2. Scene Loop (Core Play) - -The Scene Loop implements the logic for a single interactive scene from start to canonization. - -### Flow Diagram -```mermaid -stateDiagram-v2 - [*] --> load_context - load_context --> resolve: S1→S3 - resolve --> narrate: S3→S4/S5 - narrate --> persist_turn_artifacts - persist_turn_artifacts --> canonize: scene_complete or max_turns - persist_turn_artifacts --> [*]: continue (await next run) - canonize --> [*]: scene finalized -``` - -### Nodes -- **`load_context`:** Calls the `ContextAssembly` agent to gather entities, facts, and memories relevant to the current scene and player action. -- **`resolve`:** Calls the `Resolver` agent to adjudicate the player's action against the game system rules (dice rolls, stat checks). Produces structured outcomes and `ProposedChange` documents. -- **`narrate`:** Calls the `Narrator` agent to generate immersive GM prose based on the context and resolution. -- **`persist_turn_artifacts`:** Saves the generated turn, resolution, and working state to MongoDB. -- **`canonize`:** Calls the `CanonKeeper` agent to evaluate all `ProposedChange` documents staged during the scene and commit accepted ones to the Neo4j Knowledge Graph. - ---- - -## 3. Story Loop (Campaign Progression) - -The Story Loop manages the lifecycle of a story arc, connecting multiple scenes and ensuring the world evolves "off-screen." - -### Flow Diagram -```mermaid -stateDiagram-v2 - [*] --> init_story - init_story --> run_scene - run_scene --> world_advance: scene complete - world_advance --> transition: story continues - world_advance --> finalize: story complete - transition --> run_scene - finalize --> [*] -``` - -### Key Features -- **Simulation (World Advance):** Runs the `Simulacrum Agent` after every scene to simulate faction moves and environmental changes based on the time passed. -- **Continuity:** Ensures that plot threads are tracked and updated as scenes progress. - ---- - -## 4. Conversation Loop (NPC Dialogue) - -A specialized loop for dedicated social interactions. Unlike the Scene Loop, it focuses on dialogue flow and relationship shifts. - -### Nodes -- **`open_session`:** Bootstraps the dialogue context. -- **`player_turn`:** Awaits user input. -- **`npc_responses`:** Calls the `NPCVoice` agent for one or more NPCs. -- **`close_session`:** Summarizes the conversation and stages relationship-update proposals. - ---- - -## 5. Durability & State Management - -### Checkpointing -Both **Scene Loop** and **Story Loop** use LangGraph's `MongoDBSaver` checkpointer. -- **Survives Restarts:** If the process crashes mid-turn, the system can resume from the exact node it was in. -- **Time Travel:** Enables the `/backtrack` command by allowing the system to revert to a previous checkpoint in the graph history. - -### Thread Management -Every loop instance is tied to a `thread_id` (usually the `scene_id` or `story_id`). This ensures that multiple concurrent players or stories never bleed state into each other. - -### Authority Enforcement -Loop execution is stateless; agents called by nodes perform database operations via MCP tools. These tool calls are gated by the **Authority Middleware** in the Data Layer, ensuring agents only write to their allowed collections/nodes. diff --git a/docs/architecture/PLAY_UI_REACTIVE_REVAMP_PLAN.md b/docs/architecture/PLAY_UI_REACTIVE_REVAMP_PLAN.md new file mode 100644 index 00000000..85e98c80 --- /dev/null +++ b/docs/architecture/PLAY_UI_REACTIVE_REVAMP_PLAN.md @@ -0,0 +1,210 @@ +# Play UI — Reactive Turn-State Revamp Plan + +> **Status:** proposed +> **Author:** Play Console UX assessment (frontend revamp follow-up) +> **Scope:** Make the play surface feel alive and legible — an explicit +> turn-state machine that shows *when the player can talk* and *when the GM is +> thinking*, a **queue-one** answer to message accumulation, soft (non-flicker) +> live updates, a decluttered inspector, and reading-tuned typography. Keep the +> existing neon identity; do **not** do a ground-up redesign. + +## Context + +The play surface today (`packages/ui/frontend/src/components/play/PlayConsole.tsx`, +~1968 lines) drives all roleplay. Its turn lifecycle is encoded as four +independent React booleans — `isTyping`, `streamingMsg`, `sendFailure`, +`pendingDiceRequest` (lines 1143–1149) — with no enforced invariants between +them. Concrete consequences: + +- **No input lock, no queue.** `handleSend` (line 1355) only checks for empty + text. The textarea, Send button and Enter handler stay live while the GM is + mid-turn, so a player can fire N turns; each resets the watchdog and clobbers + `lastSentRef`. This is the unhandled "messages accumulate with no response" + case. +- **Scroll fights the reader.** `scrollIntoView({behavior:"smooth"})` fires on + every `messages`/`streamingMsg`/`isTyping` change (line 1294) — janky during + token streaming, and hostile when scrolling up to re-read. +- **Hard updates.** On `done` the handler invalidates + refetches three query + caches (lines 1193–1195); the whole right rail pops in one step. Optimistic + echo is a manual `setQueryData` with a random UUID that gets replaced on + refetch → flicker. +- **Binary "thinking."** Three blinking dots (`TypingIndicator`, line 103). The + backend computes the *full* reply then fake-streams it word-by-word + (`packages/ui/backend/src/monitor_ui/routers/chat_ws.py:222`, + `word + " "`), so the real wait is **dead air before `start`** with no signal. +- **Overloaded rail.** The right `aside` (lines 1771–1960) stacks nine + always-visible cards (Character, Working State, Combat, Story, Canon, Session + Context, Audit, Social, Benchmark) — information without prioritization. +- **No primitive layer.** Button/Card/Badge/Dropdown are re-implemented inline; + the tone selector is a `group-hover` div (not keyboard-accessible). Narration + renders as low-contrast `slate-400` italic over a glowing dot-grid + scan-line + — fine for a dashboard, tiring for long-form reading. + +WebSocket contract today (`chat_ws.py`, `chat.py`): server → client emits +`start` / `token` / `done` (`end`) / `error`; client → server sends +`{type:"message"|"dice_result"|"ping"}`. There are **no** per-turn phase events +and **no** cancel path. + +**Decisions locked with the user:** accumulation → **queue one** turn; +visual scope → **tune the existing theme** (keep neon chrome, fix legibility); +deliver this plan before any code. + +## Architectural rules honored + +- Layers: `data-layer (1) → agents (2) → cli/ui (3)`; dependencies flow down only. + All UI work is layer 3; the only layer-2/backend change (phase events, cancel) + is additive to the existing WS protocol. +- **Only `CanonKeeper` writes Neo4j** — unchanged; this revamp touches no graph + writes. +- Every change references a use-case ID (`UI-1`..`UI-6`, new for this plan). +- Every change ships tests: Vitest unit tests for the state machine + lib + helpers, Playwright e2e for the turn lifecycle; backend phase events get a + contract test. Integration/e2e gated by existing `RUN_E2E=1`. +- `structlog`, never `print()`, on the backend phase-event change. + +## Path conventions + +- Frontend code under `packages/ui/frontend/src`. New play components live in + `src/components/play/`; shared primitives in a new `src/components/ui/`. +- Chat WS handled by `src/hooks/use-chat-websocket.ts`; the new turn machine is a + sibling hook `src/hooks/use-turn-machine.ts`. +- Backend chat WS at `packages/ui/backend/src/monitor_ui/routers/chat_ws.py` + (and `chat.py` for the REST/streaming twin). + +## Tooling adopted (slots into Next 15 / React 19 / Tailwind 3 / framer / lucide) + +| Tool | Use | Notes | +|------|-----|-------| +| `xstate` v5 + `@xstate/react` | Turn lifecycle state machine | single source of truth for talk/think/queue/error | +| `react-virtuoso` | Message transcript | auto stick-to-bottom + "N new below" + virtualization in one | +| `sonner` | Soft toasts | "scene saved / canon updated / reconnected" — replaces inline banners | +| `@formkit/auto-animate` | List motion | cheap enter/exit for rail + session list | +| `cmdk` | ⌘K command palette | (Phase 6, optional) cross-app navigation/actions | + +Existing `@radix-ui`-style primitives are introduced via lightweight local +components (shadcn-pattern) rather than a full shadcn install, to avoid churn: +`Button`, `Card`, `Badge`, `Tabs`, `Popover`, `Tooltip`, `Dialog`. + +--- + +## Phase 1 — Turn-state machine + composer lock/queue + Stop (`UI-1`) + +**Goal:** One machine owns who can talk, whether the composer is locked, what the +indicator shows, and how a single queued turn drains. Replaces the four booleans. + +States: `idle → submitting → thinking → streaming → yourTurn`, plus +`awaitingChoice` (dice/consequence), `error`. Context holds: `queuedTurn` +(at most one), `lastSent`, `currentPhase`, `elapsedMs`. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 1.1 | `useTurnMachine` (XState) modeling the states + a single queued turn; exposes `canTalk`, `isBusy`, `indicator`, `submit`, `enqueue`, `cancelQueued`, `stop` | 3 (fe) | `src/hooks/use-turn-machine.ts` | +| 1.2 | Wire `handleWsMessage` (`start`/`token`/`done`/`error`) into machine transitions; retire `isTyping`/`streamingMsg`/`sendFailure` flags | 3 (fe) | `PlayConsole.tsx` | +| 1.3 | Composer: while `isBusy`, keep textarea live but route Enter → `enqueue` (max 1); render an editable/cancelable "Queued" chip above the input | 3 (fe) | `src/components/play/Composer.tsx` (extracted) | +| 1.4 | Send button becomes **Stop** while busy → `stop()` → WS `{type:"cancel"}`; on `done`, auto-drain `queuedTurn` | 3 (fe) | `Composer.tsx`, `PlayConsole.tsx` | +| 1.5 | Positive "Your move" affordance in `yourTurn` (composer border + label) | 3 (fe) | `Composer.tsx` | +| 1.6 | Unit tests: machine transitions incl. queue-one, stop, error→retry, session-switch reset | test | `src/hooks/use-turn-machine.test.ts` | + +**Done when:** sending while the GM is thinking queues exactly one turn (visible, +cancelable, auto-sent on completion); Stop interrupts without waiting on the +4-minute watchdog; the composer clearly signals when it is the player's move. + +## Phase 2 — Rich "GM is thinking" via phase events (`UI-2`) + +**Goal:** Turn dead-air-before-`start` into an honest sense of a mind at work. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 2.1 | Emit additive `{"type":"phase","phase":...}` events from the loop before `start` (`assembling_context`/`classifying_intent`/`resolving_dice`/`narrating`/`reviewing_canon`); never block the turn if emission fails | 2/3 (be) | `chat_ws.py`, `chat.py`, agent loop hook | +| 2.2 | Machine consumes `phase`; map to copy ("Recalling the world…", "Reading your move…", "Rolling…", "Writing…", "Updating canon…") | 3 (fe) | `use-turn-machine.ts` | +| 2.3 | `ThinkingIndicator` shows current phase + elapsed-time hint after ~8s ("still composing…"); reuses `PhaseChip` vocabulary | 3 (fe) | `src/components/play/ThinkingIndicator.tsx` | +| 2.4 | Backend contract test: phase events precede `start`, turn still completes if a phase emit is skipped | test | `packages/ui/backend/tests/test_chat_ws_phases.py` | + +**Done when:** the indicator names what the GM is doing through at least 3 honest +stages; a turn with no phase events still streams normally (back-compat). + +## Phase 3 — Virtualized transcript with smart auto-scroll (`UI-3`) + +**Goal:** Stop scroll-fighting; stay pinned to bottom only when already at bottom; +handle long sessions. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 3.1 | Replace the `space-y-4` map (lines 1590–1655) with `react-virtuoso` `followOutput="smooth"` (auto-stick only when at bottom) | 3 (fe) | `src/components/play/Transcript.tsx` (extracted) | +| 3.2 | "↓ N new" pill when scrolled up during streaming; click → jump to bottom | 3 (fe) | `Transcript.tsx` | +| 3.3 | Remove the `scrollIntoView`-on-every-change effect (line 1294) | 3 (fe) | `PlayConsole.tsx` | +| 3.4 | E2e: scroll up mid-stream stays put; pill appears; new turn while at bottom auto-follows | test | `e2e/play-transcript.spec.ts` | + +**Done when:** reading history during a stream is not interrupted, and the bottom +auto-follows only when the player is already there. + +## Phase 4 — Soft live updates (`UI-4`) + +**Goal:** Replace invalidate-and-refetch pops with append + targeted reconcile. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 4.1 | Treat the transcript as append-only local state fed by WS; reconcile with server only on reconnect/session-switch (drop the 3× invalidate on `done`, lines 1193–1195) | 3 (fe) | `PlayConsole.tsx` | +| 4.2 | Optimistic player echo via `useMutation` w/ stable id + onError rollback (replace manual `setQueryData` + random UUID) | 3 (fe) | `PlayConsole.tsx`, `src/lib/query-keys.ts` | +| 4.3 | `sonner` toasts for out-of-band events (canon updated, scene saved, reconnected) replacing inline banners | 3 (fe) | `src/app/layout.tsx`, `PlayConsole.tsx` | +| 4.4 | `@formkit/auto-animate` on rail cards + session list | 3 (fe) | rail components, `SessionList` | +| 4.5 | Unit test: echo→reconcile produces no duplicate/flicker (stable ids) | test | `src/components/play/transcript-reconcile.test.ts` | + +**Done when:** a completed turn updates the transcript and rail without a +full-list refetch flash; player echoes never double-render. + +## Phase 5 — Tabbed inspector + persistent turn-status bar (`UI-5`) + +**Goal:** Declutter the nine-card rail; surface connection/turn/phase calmly. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 5.1 | `Tabs` inspector (`Scene · Character · Mechanics · Canon`) wrapping existing cards; auto-focus Mechanics on roll resolve, Canon on pending review | 3 (fe) | `src/components/play/Inspector.tsx` | +| 5.2 | Persistent `TurnStatusBar` atop the chat column: connection (always present, calm), whose turn, current phase, elapsed/token count — replaces the buried connection chip (line 1722) | 3 (fe) | `src/components/play/TurnStatusBar.tsx` | +| 5.3 | Move "End scene" into a scene/beat header; keep behavior | 3 (fe) | `PlayConsole.tsx` | +| 5.4 | Skeletons replace "Loading…" text on home + session list + rail | 3 (fe) | `src/components/ui/Skeleton.tsx`, `src/app/page.tsx`, `SessionList` | + +**Done when:** the rail shows one relevant tab at a time; connection/turn state +is readable at a glance without opening the composer area. + +## Phase 6 — Primitive layer, reading typography, ⌘K (`UI-6`) + +**Goal:** Consistency + legibility polish; split the monolith. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 6.1 | Extract `Button`/`Card`/`Badge`/`Popover`/`Tooltip`/`Dialog` primitives; replace inline re-impls (incl. keyboard-accessible tone selector) | 3 (fe) | `src/components/ui/*` | +| 6.2 | Reading mode for transcript: contrast `slate-300` narration, ~68ch measure, no dot-grid/scan-line behind the message column, optional serif toggle | 3 (fe) | `globals.css`, `ProseBubble`, `Transcript.tsx` | +| 6.3 | Add spacing/typography design tokens (today only colors exist) | 3 (fe) | `tailwind.config.ts`, `globals.css` | +| 6.4 | `cmdk` ⌘K palette: switch session, ask Oracle, roll, change tone, jump to world/entity | 3 (fe) | `src/components/CommandPalette.tsx`, `layout.tsx` | +| 6.5 | Split `PlayConsole.tsx` into `Composer / Transcript / TurnStatusBar / Inspector` shells (enabled by 6.1) | 3 (fe) | `src/components/play/*` | + +**Done when:** play UI uses shared primitives, prose is comfortably readable, and +⌘K provides cross-app navigation. + +--- + +## Test strategy ("real usage") + +- **Vitest unit:** turn machine (all transitions, queue-one, stop, error→retry, + session reset), transcript reconcile (no duplicate echo), phase→copy mapping. +- **Playwright e2e** (`RUN_E2E=1`): send while busy → one queued chip → auto-send + on done; Stop interrupts; scroll-up-mid-stream stays put + "N new" pill; + reconnect path shows toast and reconciles. +- **Backend contract:** phase events precede `start`; turn completes if phase + emission is skipped (back-compat). +- Manual: long session (200+ messages) stays smooth (virtualization); flaky + connection shows calm status, not silent failure. + +## Rollout order + +1. **Phase 1** (turn machine + queue-one + Stop) — biggest UX win, frontend-only. +2. **Phase 2** (phase events) — small backend + indicator; unlocks the "thinking" story. +3. **Phase 3** (virtualized transcript) — fixes scroll + perf. +4. **Phase 4** (soft updates) — removes flicker/pops. +5. **Phase 5** (tabbed inspector + status bar) — declutter, use the surface. +6. **Phase 6** (primitives + reading mode + ⌘K) — consistency + polish. + +Each phase ships independently and leaves the app working; Phases 1–4 are the +core of "reactive, updates softly, clear turn states." Phases 5–6 are surface and +consistency. diff --git a/docs/architecture/WORLD_FORGE_DIRECT_MANIPULATION_PLAN.md b/docs/architecture/WORLD_FORGE_DIRECT_MANIPULATION_PLAN.md new file mode 100644 index 00000000..ae53b391 --- /dev/null +++ b/docs/architecture/WORLD_FORGE_DIRECT_MANIPULATION_PLAN.md @@ -0,0 +1,133 @@ +# World Forge — Direct Manipulation & Generative Rules Plan + +> **Status:** in progress +> **Author:** World Architect assessment follow-up +> **Scope:** Close the gap between MONITOR's strong *generative* world-building +> brain and its thin *direct-manipulation* surface, plus extend the Architect to +> generate **system rules**, not just lore. + +## Context + +The world-creation stack today: + +- **Generation** is good: `WorldArchitect` (conversational, gap-steering) + + `QuickWorldBuilder` (one-seed → playable world). Both auto-canonize via + `CanonKeeper`. +- **Visualization** is good: React Flow graph in `explorer/page.tsx`, + `worlds/page.tsx`, and the Architect mini-graph — colour-coded, ego-graph + drill-down, filters, search. +- **Manipulation** is thin: read + delete + fork + snapshots only. The graph + inspector advertises "inspect and **edit**" but is read-only. The data layer + already has `neo4j_update_entity`, `neo4j_create_character_relationship`, + `neo4j_create_entity`, and `neo4j_fork_universe` — they are simply **not wired + into the graph UI**, and there is **no single-entity REST endpoint**. +- **Rules** are narrative only: the Architect extracts `axiom`s (prose truths) + but never mechanical rules, even though `neo4j_create_resolution_mechanic` / + `neo4j_create_ability_system` exist. + +## Architectural rules honored + +- Layers: `data-layer (1) → agents (2) → cli/ui (3)`; dependencies flow down only. +- **Only `CanonKeeper` writes Neo4j.** All graph mutations from the UI go through + the backend → a `CanonKeeper`-authority path (the World Architect already + auto-commits this way for user-deliberate edits). +- Every change references a use-case ID (`M-36`..`M-41`). +- Every change ships unit + integration tests; integration/e2e tests are marked + and gated by `RUN_INTEGRATION=1` / `RUN_E2E=1`. +- `structlog`, never `print()`. `async` on agent methods that touch MCP tools. + +## Path conventions + +- `entities` router is mounted at `/api/entities` and defines `/entities/...` + paths → public path is `/api/entities/entities/{id}` (doubled, like universes). +- `universes` router → `/api/universes/universes/...`. + +--- + +## Gap 1 — Edit entities directly from the graph (`M-36`) + +**Goal:** Click a node → edit name / description / tags / properties → save → +graph reflects canon. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 1.1 | `GET /entities/{id}` returning full entity (wrap `neo4j_get_entity`) | 3 (backend) | `routers/entities.py` | +| 1.2 | `PATCH /entities/{id}` (wrap `neo4j_update_entity`; tags via `neo4j_update_state_tags`) under CanonKeeper authority | 3 (backend) | `routers/entities.py` | +| 1.3 | `entitiesApi.getEntity` + `entitiesApi.updateEntity` | 3 (frontend) | `lib/api.ts` | +| 1.4 | Editable `InspectorPanel` (name/description/tags) with save + optimistic refetch | 3 (frontend) | `worlds/page.tsx`, `explorer/page.tsx` | +| 1.5 | Contract tests (mocked tools) | test | `tests/test_entities_crud.py` | +| 1.6 | Integration test (real Neo4j: create→update→read round-trip) | test | `tests/test_entities_crud_integration.py` | +| 1.7 | Frontend lib test for `updateEntity` payload shaping | test | `lib/entitiesApi.test.ts` | + +**Done when:** editing a node persists to Neo4j and survives reload. + +## Gap 2 — Create relationships inline on the graph (`M-37`) + +**Goal:** Drag from a node handle to another node → pick a relation type → an edge +is persisted between the two entities. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 2.1 | Generalize `POST /entities/relationships` to any entity pair + rel type + properties; return created edge | 3 (backend) | `routers/entities.py` | +| 2.2 | `GET /entities/{id}/relationships` (list edges for inspector) | 3 (backend) | `routers/entities.py` | +| 2.3 | `entitiesApi.createRelationship` + `listRelationships` | 3 (frontend) | `lib/api.ts` | +| 2.4 | React Flow `onConnect` → rel-type modal → persist → add edge | 3 (frontend) | `explorer/page.tsx` | +| 2.5 | Contract + integration tests | test | `tests/test_relationships.py` (+ integration) | + +## Gap 3 — Create nodes on the graph canvas (`M-38`) + +**Goal:** "+ Add node" affordance on the canvas → type/name → entity created in the +selected universe and added to the graph. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 3.1 | `POST /entities/entities` single-entity create (wrap `neo4j_create_entity`, CanonKeeper authority) | 3 (backend) | `routers/entities.py` | +| 3.2 | `entitiesApi.createEntity` | 3 (frontend) | `lib/api.ts` | +| 3.3 | Add-node UI (mini-form / palette) on explorer canvas | 3 (frontend) | `explorer/page.tsx` | +| 3.4 | Contract + integration tests | test | `tests/test_entities_crud*.py` | + +## Gap 4 — Universe split & merge (`M-39`, `M-40`) + +Built on the proven `neo4j_fork_universe` template (deep-clone + `id_map` + +relationship remap + `alt_world_type`/`parent_universe_id`). + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 4.1 | `neo4j_split_universe(source, name, entity_ids)` → clone the selected subset + induced relationships into a new universe (`alt_world_type='split'`) | 1 (data) | `neo4j_tools/core.py` | +| 4.2 | `neo4j_merge_universes(source_ids, name, dedupe_by='name')` → union canon into a new universe, dedupe entities by name, remap relationships (`alt_world_type='merge'`) | 1 (data) | `neo4j_tools/core.py` | +| 4.3 | `POST /universes/{id}/split` and `POST /universes/merge` | 3 (backend) | `routers/universes.py` | +| 4.4 | `universesApi.splitUniverse` + `mergeUniverses` + UI (multiselect in explorer → split; tree multi-select → merge) | 3 (frontend) | `lib/api.ts`, `worlds`/`explorer` | +| 4.5 | Data-layer integration tests (real Neo4j round-trips) + backend contract tests | test | `data-layer tests/`, `backend tests/` | + +## Gap 5 — Architect generates system rules (`M-41`) + +**Goal:** The Architect can author *mechanical* rules (resolution mechanics, +ability systems), not just lore axioms. + +| # | Task | Layer | Files | +|---|------|-------|-------| +| 5.1 | Add `resolution_mechanic` + `ability_system` to the DSPy extraction vocabulary | 2 (agents) | `prompts/world_architect.py` | +| 5.2 | Parse those proposal types in `_parse_proposals` | 2 (agents) | `world_architect.py` | +| 5.3 | Commit via `neo4j_create_resolution_mechanic` / `neo4j_create_ability_system` in `_commit_proposals` | 2 (agents) | `world_architect.py` | +| 5.4 | Agent unit tests (parse + commit, tools mocked) | test | `agents tests/` | + +--- + +## Test strategy ("real usage") + +- **Unit / contract:** FastAPI `TestClient` with the underlying `neo4j_*` tool + `patch`ed (the established `test_universes.py` pattern). Fast, no DB. +- **Integration (`@pytest.mark.integration`, `RUN_INTEGRATION=1`):** hit a real + Neo4j. Each test creates its own multiverse/universe, performs the real + round-trip (create → edit → relate → split/merge → read back), and tears down. +- **Frontend:** Vitest lib tests for API payload shaping (the existing + `*.test.ts` pattern under `lib/`), plus the Playwright e2e harness for the + edit-on-graph flow where practical. + +## Rollout order + +1. **Gap 1** (edit) — unlocks "direct manipulation" with zero new data-layer work. +2. **Gap 2** (relate) — makes the graph feel like a tool. +3. **Gap 3** (create) — completes node CRUD on the canvas. +4. **Gap 4** (split/merge) — the headline multiverse capability. +5. **Gap 5** (rules) — deepens generation. diff --git a/packages/agents/src/monitor_agents/loops/conversation_loop.py b/packages/agents/src/monitor_agents/loops/conversation_loop.py index 902f1635..dee18738 100644 --- a/packages/agents/src/monitor_agents/loops/conversation_loop.py +++ b/packages/agents/src/monitor_agents/loops/conversation_loop.py @@ -109,9 +109,28 @@ class ConversationState(BaseModel): # ============================================================================= +def _extract_conversation_id(result: Any) -> Optional[UUID]: + """Pull the persisted conversation_id out of a create-conversation response.""" + raw: Any = None + if isinstance(result, dict): + raw = result.get("conversation_id") + elif result is not None and hasattr(result, "conversation_id"): + raw = getattr(result, "conversation_id") + if not raw: + return None + try: + return UUID(str(raw)) + except (ValueError, TypeError): + return None + + async def open_session(state: ConversationState) -> Dict[str, Any]: """ - Create the ConversationSession document in MongoDB and return its ID. + Create the ConversationSession document in MongoDB and adopt its ID. + + The create tool always mints its own conversation_id, so we read it back + and propagate it into state — otherwise every later append/persist/close + would target a non-existent session. Write: ConversationSession → MongoDB. """ @@ -119,20 +138,25 @@ async def open_session(state: ConversationState) -> Dict[str, Any]: from monitor_agents.npc_voice import NPCVoice agent = NPCVoice() - await agent.call_tool( + result = await agent.call_tool( "mongodb_create_conversation", { - "universe_id": str(state.universe_id), - "mode": state.mode.value, - "npc_ids": [str(nid) for nid in state.npc_ids], - "scene_id": str(state.scene_id) if state.scene_id else None, - "story_id": str(state.story_id) if state.story_id else None, - "player_entity_id": ( - str(state.player_entity_id) if state.player_entity_id else None - ), - "metadata": {}, + "params": { + "universe_id": str(state.universe_id), + "mode": state.mode.value, + "npc_ids": [str(nid) for nid in state.npc_ids], + "scene_id": str(state.scene_id) if state.scene_id else None, + "story_id": str(state.story_id) if state.story_id else None, + "player_entity_id": ( + str(state.player_entity_id) if state.player_entity_id else None + ), + "metadata": {}, + }, }, ) + conversation_id = _extract_conversation_id(result) + if conversation_id is not None: + return {"conversation_id": conversation_id} except Exception: # noqa: BLE001 import logging @@ -465,6 +489,12 @@ def __init__( player_entity_id=player_entity_id, ) self._graph = build_conversation_graph().compile() + self._closed = False + + def _apply(self, update: Dict[str, Any]) -> None: + """Merge a node's partial-state update back into self.state.""" + if update: + self.state = ConversationState(**{**self.state.model_dump(), **update}) @classmethod async def start( @@ -496,6 +526,11 @@ async def step(self, player_input: str) -> List[Dict[str, Any]]: """ Process one player input → return list of NPC responses. + Drives the mid-session nodes directly. The compiled graph has a single + fixed entry point (open_session), so re-invoking it per turn would only + re-run the setup flow and never reach response generation — hence we + run process_player_turn → generate_npc_responses explicitly here. + Args: player_input: What the player said / asked. @@ -504,11 +539,14 @@ async def step(self, player_input: str) -> List[Dict[str, Any]]: """ self.state.current_player_input = player_input - result = await self._graph.ainvoke( - {**self.state.model_dump(), "current_player_input": player_input}, - # Use process_player_turn as entry for mid-session steps - ) - self.state = ConversationState(**{**self.state.model_dump(), **result}) + self._apply(await process_player_turn(self.state)) + self._apply(await generate_npc_responses(self.state)) + + # Honor the loop's own ceiling (max_turns / explicit completion). + if route_after_npc_response(self.state) == "close" and not self._closed: + self._apply(await close_session(self.state)) + self._closed = True + return self.state.current_npc_responses async def finish(self) -> List[Dict[str, Any]]: @@ -519,6 +557,7 @@ async def finish(self) -> List[Dict[str, Any]]: Staged proposals list (for caller to display or confirm). """ self.state.is_complete = True - result = await self._graph.ainvoke({**self.state.model_dump(), "is_complete": True}) - self.state = ConversationState(**{**self.state.model_dump(), **result}) + if not self._closed: + self._apply(await close_session(self.state)) + self._closed = True return self.state.pending_proposals diff --git a/packages/agents/src/monitor_agents/prompts/card_draft.py b/packages/agents/src/monitor_agents/prompts/card_draft.py new file mode 100644 index 00000000..d0f479cb --- /dev/null +++ b/packages/agents/src/monitor_agents/prompts/card_draft.py @@ -0,0 +1,95 @@ +""" +DSPy Signature + Module for LLM-assisted character-card drafting. + +LAYER: 2 (agents) + +Given a short concept (and optionally a name and/or partial fields), draft the +"light card" fields a user would otherwise type by hand: description, +personality, an in-character first message, and author/GM notes. This is the +"ask for aid to fill the card" path — the output is a plain card, which can +then be expanded into a full MONITOR NPCProfile by npc_profile_gen. + +Uses discrete OutputFields (not a JSON blob) so there is no brittle parsing — +DSPy populates each field directly. +""" + +from __future__ import annotations + +import logging +from typing import Dict, Optional + +import dspy + +from monitor_agents.dspy_runtime import dspy_context_for +from monitor_data.schemas.llm_config import ModelRole + +logger = logging.getLogger(__name__) + + +class CardDraftSignature(dspy.Signature): + """ + Draft a roleplay character card from a concept. + + You are a character designer. Expand the concept into a vivid but concise + character others can chat with. Respect any fields the user already filled + (echo/refine them; do not contradict them). Write in the card's own world — + no meta commentary. + """ + + concept: str = dspy.InputField(desc="Short free-text concept / premise for the character") + given_name: str = dspy.InputField(desc="Name the user already chose, or empty to invent one") + existing_description: str = dspy.InputField(desc="Any description the user already wrote (may be empty)") + existing_personality: str = dspy.InputField(desc="Any personality notes already written (may be empty)") + + name: str = dspy.OutputField(desc="The character's name (1-4 words)") + description: str = dspy.OutputField( + desc="2-4 sentence description: who they are, appearance, situation" + ) + personality: str = dspy.OutputField( + desc="2-4 sentence personality: temperament, manner, how they treat strangers" + ) + first_message: str = dspy.OutputField( + desc="An in-character opening line the character says when a chat begins (1-3 sentences)" + ) + gm_notes: str = dspy.OutputField( + desc="Author/GM notes: hidden motivations, secrets, or behavioral guidance (1-3 sentences)" + ) + + +class CardDrafter(dspy.Module): + """ChainOfThought card drafter. Run under ModelRole.STANDARD.""" + + def __init__(self) -> None: + super().__init__() + self.draft = dspy.ChainOfThought(CardDraftSignature) + + def forward( + self, + concept: str, + given_name: str = "", + existing_description: str = "", + existing_personality: str = "", + role: Optional[ModelRole] = None, + ) -> Dict[str, str]: + with dspy_context_for("card_draft", role or ModelRole.STANDARD): + pred = self.draft( + concept=concept or "", + given_name=given_name or "", + existing_description=existing_description or "", + existing_personality=existing_personality or "", + ) + + def _clean(value: object, limit: int) -> str: + return str(value or "").strip()[:limit] + + # Never return an empty name — fall back to the user's input or concept. + name = _clean(getattr(pred, "name", ""), 200) or given_name.strip() or "New Character" + return { + "name": name[:200], + "description": _clean(getattr(pred, "description", ""), 2000) + or existing_description.strip(), + "personality": _clean(getattr(pred, "personality", ""), 2000) + or existing_personality.strip(), + "first_message": _clean(getattr(pred, "first_message", ""), 2000), + "gm_notes": _clean(getattr(pred, "gm_notes", ""), 5000), + } diff --git a/packages/agents/src/monitor_agents/prompts/npc_profile_gen.py b/packages/agents/src/monitor_agents/prompts/npc_profile_gen.py new file mode 100644 index 00000000..3bd1d267 --- /dev/null +++ b/packages/agents/src/monitor_agents/prompts/npc_profile_gen.py @@ -0,0 +1,181 @@ +""" +DSPy Signature + Module for generating a rich NPCProfile from a light card. + +LAYER: 2 (agents) +IMPORTS FROM: dspy, monitor_agents, monitor_data schemas + +Turns a character.ai / SillyTavern-style "card" (name + free-text description + +personality notes) into the structured MONITOR psychological backbone: +traits, values, fears, desires, speech style, catchphrases, and behavioral +triggers. This is what lets an imported light card be *expanded* into a true +MONITOR-backed character that NPCVoice can drive with memory and deltas. + +Parsing mirrors prompts/memory_extraction.py: tolerate prose-wrapped JSON and +fall back to a neutral-but-valid profile so character expansion never hard-fails. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, List, Optional + +import dspy + +from monitor_agents.dspy_runtime import dspy_context_for +from monitor_data.schemas.llm_config import ModelRole + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# SIGNATURE +# ============================================================================= + + +class NPCProfileGenSignature(dspy.Signature): + """ + Expand a character card into a structured psychological profile. + + You are a character designer. Given a character's name and free-text + description / personality notes, infer a consistent psychological profile. + Stay faithful to the source text — do not invent a different character. + Keep every field grounded in what the card implies. + """ + + name: str = dspy.InputField(desc="Character's name") + description: str = dspy.InputField(desc="Free-text description / backstory of the character") + personality: str = dspy.InputField(desc="Personality notes (may be empty)") + gm_notes: str = dspy.InputField(desc="Author/GM notes — hidden intent, secrets (may be empty)") + + profile_json: str = dspy.OutputField( + desc=( + "A single JSON object (no prose) with keys: " + "'traits' (object mapping 4-6 trait names to floats -1.0..1.0), " + "'values' (array of short strings), " + "'fears' (array of short strings), " + "'desires' (array of short strings), " + "'speech_style' (one short string), " + "'catchphrases' (array of 0-3 short strings), " + "'current_emotional_state' (one short word/phrase), " + "'triggers' (array of objects each with " + "'condition', 'reaction', 'intensity' (0.0..1.0), 'is_hidden' (bool))." + ) + ) + + +# ============================================================================= +# MODULE +# ============================================================================= + + +def _clamp(value: Any, lo: float, hi: float, default: float) -> float: + try: + return round(max(lo, min(hi, float(value))), 3) + except (TypeError, ValueError): + return default + + +def _neutral_profile(name: str) -> Dict[str, Any]: + """A valid, bland profile used when generation/parsing fails.""" + return { + "traits": {"openness": 0.0, "warmth": 0.0, "confidence": 0.0}, + "values": [], + "fears": [], + "desires": [], + "speech_style": "", + "catchphrases": [], + "current_emotional_state": "neutral", + "triggers": [], + } + + +def _coerce_profile(raw: Dict[str, Any]) -> Dict[str, Any]: + """Normalize an LLM-produced profile dict into NPCProfileCreate-compatible fields.""" + traits_raw = raw.get("traits") or {} + traits: Dict[str, float] = {} + if isinstance(traits_raw, dict): + for key, val in list(traits_raw.items())[:8]: + traits[str(key)] = _clamp(val, -1.0, 1.0, 0.0) + + def _str_list(key: str, cap: int) -> List[str]: + items = raw.get(key) or [] + if not isinstance(items, list): + return [] + return [str(x).strip() for x in items if str(x).strip()][:cap] + + triggers: List[Dict[str, Any]] = [] + for t in (raw.get("triggers") or [])[:6]: + if not isinstance(t, dict): + continue + condition = str(t.get("condition", "")).strip() + reaction = str(t.get("reaction", "")).strip() + if not condition or not reaction: + continue + triggers.append( + { + "condition": condition[:500], + "reaction": reaction[:500], + "intensity": _clamp(t.get("intensity", 0.7), 0.0, 1.0, 0.7), + "is_hidden": bool(t.get("is_hidden", True)), + } + ) + + return { + "traits": traits, + "values": _str_list("values", 6), + "fears": _str_list("fears", 6), + "desires": _str_list("desires", 6), + "speech_style": (str(raw.get("speech_style", "")).strip() or None), + "catchphrases": _str_list("catchphrases", 3), + "current_emotional_state": ( + str(raw.get("current_emotional_state", "")).strip() or "neutral" + ), + "triggers": triggers, + } + + +class NPCProfileGenerator(dspy.Module): + """ + ChainOfThought generator — reasons about character psychology before + emitting the structured profile. Run under ModelRole.STANDARD. + """ + + def __init__(self) -> None: + super().__init__() + self.generate = dspy.ChainOfThought(NPCProfileGenSignature) + + def forward( + self, + name: str, + description: str = "", + personality: str = "", + gm_notes: str = "", + role: Optional[ModelRole] = None, + ) -> Dict[str, Any]: + with dspy_context_for("npc_profile_gen", role or ModelRole.STANDARD): + prediction = self.generate( + name=name, + description=description or "", + personality=personality or "", + gm_notes=gm_notes or "", + ) + + raw_text = prediction.profile_json or "" + try: + if "```json" in raw_text: + raw_text = raw_text.split("```json")[1].split("```")[0].strip() + elif "```" in raw_text: + raw_text = raw_text.split("```")[1].split("```")[0].strip() + # Some models prepend prose — grab the outermost JSON object. + start = raw_text.find("{") + end = raw_text.rfind("}") + if start != -1 and end != -1 and end > start: + raw_text = raw_text[start : end + 1] + parsed = json.loads(raw_text) + if not isinstance(parsed, dict): + raise ValueError("profile_json was not a JSON object") + return _coerce_profile(parsed) + except (json.JSONDecodeError, IndexError, ValueError) as exc: + logger.warning("NPCProfileGenerator parse failed (%s); using neutral profile", exc) + return _neutral_profile(name) diff --git a/packages/agents/tests/test_conversation_loop.py b/packages/agents/tests/test_conversation_loop.py index 46026135..c0673673 100644 --- a/packages/agents/tests/test_conversation_loop.py +++ b/packages/agents/tests/test_conversation_loop.py @@ -15,11 +15,13 @@ from langgraph.graph import END from monitor_agents.loops.conversation_loop import ( + ConversationLoop, ConversationState, build_conversation_graph, close_session, generate_npc_responses, load_npc_context, + open_session, process_player_turn, route_after_npc_response, ) @@ -520,3 +522,169 @@ async def test_proposal_change_type_passed_to_create(self): assert payload["params"]["scene_id"] == str(scene_id) assert payload["params"]["story_id"] == str(story_id) assert payload["params"]["evidence"][0]["type"] == "snippet" + + +# =========================================================================== +# open_session — params wrapping + conversation_id adoption +# =========================================================================== + + +class TestOpenSession: + @pytest.mark.asyncio + async def test_wraps_create_args_in_params(self): + state = _state() + mock_agent = MagicMock() + mock_agent.call_tool = AsyncMock(return_value={"conversation_id": str(uuid4())}) + + with patch("monitor_agents.npc_voice.NPCVoice", return_value=mock_agent): + await open_session(state) + + call = mock_agent.call_tool.call_args + assert call[0][0] == "mongodb_create_conversation" + # The tool takes a single `params` model — args must be wrapped. + assert "params" in call[0][1] + assert call[0][1]["params"]["mode"] == state.mode.value + + @pytest.mark.asyncio + async def test_adopts_persisted_conversation_id(self): + persisted = uuid4() + state = _state(conversation_id=uuid4()) + mock_agent = MagicMock() + mock_agent.call_tool = AsyncMock(return_value={"conversation_id": str(persisted)}) + + with patch("monitor_agents.npc_voice.NPCVoice", return_value=mock_agent): + result = await open_session(state) + + # The create tool mints its own id; the loop must adopt it so later + # append/persist/close target the real session. + assert result == {"conversation_id": persisted} + + @pytest.mark.asyncio + async def test_no_persisted_id_returns_empty(self): + state = _state() + mock_agent = MagicMock() + mock_agent.call_tool = AsyncMock(return_value={}) + + with patch("monitor_agents.npc_voice.NPCVoice", return_value=mock_agent): + result = await open_session(state) + + assert result == {} + + +# =========================================================================== +# ConversationLoop.step / finish — mid-session orchestration +# +# Regression coverage: the compiled graph has a single fixed entry point +# (open_session), so re-invoking it per turn never reached response +# generation. step()/finish() must drive the mid-session nodes directly. +# =========================================================================== + + +def _loop(**overrides) -> ConversationLoop: + return ConversationLoop( + conversation_id=overrides.get("conversation_id", uuid4()), + universe_id=overrides.get("universe_id", uuid4()), + mode=overrides.get("mode", ConversationMode.DIRECT), + npc_ids=overrides.get("npc_ids", [uuid4()]), + ) + + +class TestConversationLoopStep: + @pytest.mark.asyncio + async def test_step_returns_generated_responses(self): + loop = _loop() + responses = [{"npc_id": "x", "npc_name": "Maeve", "text": "We don't get trouble here."}] + + async def fake_process(state): # noqa: ARG001 + return {} + + async def fake_generate(state): + return { + "current_npc_responses": responses, + "turns_count": state.turns_count + 1, + "current_player_input": None, + } + + with ( + patch( + "monitor_agents.loops.conversation_loop.process_player_turn", new=fake_process + ), + patch( + "monitor_agents.loops.conversation_loop.generate_npc_responses", + new=fake_generate, + ), + ): + out = await loop.step("Evening.") + + assert out == responses + assert loop.state.turns_count == 1 + + @pytest.mark.asyncio + async def test_step_does_not_auto_close_before_max_turns(self): + loop = _loop() + closed = {"called": False} + + async def fake_process(state): # noqa: ARG001 + return {} + + async def fake_generate(state): + return {"current_npc_responses": [], "turns_count": 1, "current_player_input": None} + + async def fake_close(state): # noqa: ARG001 + closed["called"] = True + return {} + + with ( + patch( + "monitor_agents.loops.conversation_loop.process_player_turn", new=fake_process + ), + patch( + "monitor_agents.loops.conversation_loop.generate_npc_responses", + new=fake_generate, + ), + patch("monitor_agents.loops.conversation_loop.close_session", new=fake_close), + ): + await loop.step("hi") + + assert closed["called"] is False + + +class TestConversationLoopFinish: + @pytest.mark.asyncio + async def test_finish_closes_session_and_returns_proposals(self): + loop = _loop() + loop.state = ConversationState( + **{ + **loop.state.model_dump(), + "pending_proposals": [{"change_type": "fact", "content": {}}], + } + ) + closed = {"called": False} + + async def fake_close(state): # noqa: ARG001 + closed["called"] = True + return {} + + with patch("monitor_agents.loops.conversation_loop.close_session", new=fake_close): + props = await loop.finish() + + assert closed["called"] is True + assert loop.state.is_complete is True + assert len(props) == 1 + + @pytest.mark.asyncio + async def test_finish_is_idempotent_after_step_close(self): + loop = _loop() + close_count = {"n": 0} + + async def fake_close(state): # noqa: ARG001 + close_count["n"] += 1 + return {} + + # Simulate the loop already having closed during step(). + loop._closed = True + + with patch("monitor_agents.loops.conversation_loop.close_session", new=fake_close): + await loop.finish() + + assert close_count["n"] == 0 diff --git a/packages/agents/tests/test_npc_profile_gen.py b/packages/agents/tests/test_npc_profile_gen.py new file mode 100644 index 00000000..3fb0cefc --- /dev/null +++ b/packages/agents/tests/test_npc_profile_gen.py @@ -0,0 +1,104 @@ +""" +Unit tests for the NPCProfileGenerator (card → structured profile). + +No live LM/DB — the DSPy context and the predictor are mocked so we exercise +the parsing + coercion + fallback logic only. +""" + +from __future__ import annotations + +import contextlib +from types import SimpleNamespace +from unittest.mock import patch + +from monitor_agents.prompts import npc_profile_gen as gen +from monitor_agents.prompts.npc_profile_gen import ( + NPCProfileGenerator, + _coerce_profile, + _neutral_profile, +) + + +# --------------------------------------------------------------------------- +# Pure coercion helpers +# --------------------------------------------------------------------------- + + +class TestCoerceProfile: + def test_clamps_traits_and_drops_bad_values(self): + out = _coerce_profile({"traits": {"a": 2.0, "b": -5.0, "c": "nope"}}) + assert out["traits"] == {"a": 1.0, "b": -1.0, "c": 0.0} + + def test_filters_empty_list_items_and_caps(self): + out = _coerce_profile({"values": ["honor", "", " ", "family"]}) + assert out["values"] == ["honor", "family"] + + def test_triggers_require_condition_and_reaction(self): + out = _coerce_profile( + { + "triggers": [ + {"condition": "asked about X", "reaction": "deflects", "intensity": 9}, + {"condition": "", "reaction": "noop"}, # dropped + {"reaction": "noop"}, # dropped + ] + } + ) + assert len(out["triggers"]) == 1 + t = out["triggers"][0] + assert t["condition"] == "asked about X" + assert t["intensity"] == 1.0 # clamped + assert t["is_hidden"] is True # default + + def test_speech_style_empty_becomes_none(self): + assert _coerce_profile({"speech_style": " "})["speech_style"] is None + + def test_emotional_state_defaults_to_neutral(self): + assert _coerce_profile({})["current_emotional_state"] == "neutral" + + def test_neutral_profile_is_valid_shape(self): + n = _neutral_profile("Anyone") + assert set(n) >= {"traits", "values", "fears", "desires", "triggers"} + assert n["current_emotional_state"] == "neutral" + + +# --------------------------------------------------------------------------- +# forward() parsing — predictor + context mocked +# --------------------------------------------------------------------------- + + +def _run_forward_with_output(profile_json: str) -> dict: + generator = NPCProfileGenerator() + + def fake_generate(**_kwargs): + return SimpleNamespace(profile_json=profile_json) + + with ( + patch.object(gen, "dspy_context_for", return_value=contextlib.nullcontext()), + patch.object(generator, "generate", side_effect=fake_generate), + ): + return generator.forward(name="Maeve", description="a wary tavern keeper") + + +class TestForwardParsing: + def test_parses_clean_json(self): + out = _run_forward_with_output( + '{"traits": {"wariness": 0.8}, "values": ["self-preservation"], ' + '"current_emotional_state": "guarded"}' + ) + assert out["traits"] == {"wariness": 0.8} + assert out["values"] == ["self-preservation"] + assert out["current_emotional_state"] == "guarded" + + def test_parses_json_in_code_fence(self): + out = _run_forward_with_output('```json\n{"values": ["honor"]}\n```') + assert out["values"] == ["honor"] + + def test_parses_json_with_surrounding_prose(self): + out = _run_forward_with_output( + 'Here is the profile you asked for:\n{"fears": ["betrayal"]}\nHope that helps!' + ) + assert out["fears"] == ["betrayal"] + + def test_falls_back_to_neutral_on_garbage(self): + out = _run_forward_with_output("not json at all") + assert out == _neutral_profile("Maeve") diff --git a/packages/data-layer/src/monitor_data/tools/neo4j_tools/core.py b/packages/data-layer/src/monitor_data/tools/neo4j_tools/core.py index b03a4a23..2eaf2b52 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools/core.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools/core.py @@ -1073,3 +1073,253 @@ def neo4j_fork_universe( "entities_cloned": entities_cloned, "relationships_cloned": relationships_cloned, } + + +# --------------------------------------------------------------------------- +# Universe split & merge (M-39 / M-40) — built on the fork clone pattern. +# --------------------------------------------------------------------------- + + +def _clone_entity_into_universe(client, new_uid, old_data, now_iso) -> str: + """Deep-clone one ``:Entity`` (fresh id, re-homed) into ``new_uid``. + + Copies every property of the source node, then overrides identity fields so + the clone is an independent entity in the target universe. Matches the + canonical model written by ``neo4j_create_entity``: + ``(:Universe)-[:HAS_ENTITY]->(e:Entity {universe_id})``. + """ + new_entity_id = str(uuid4()) + props = dict(old_data) + props["id"] = new_entity_id + props["universe_id"] = new_uid + props["updated_at"] = now_iso + client.execute_write( + """ + MATCH (u:Universe {id: $new_uid}) + CREATE (e:Entity) + SET e = $props + CREATE (u)-[:HAS_ENTITY]->(e) + """, + {"new_uid": new_uid, "props": props}, + ) + return new_entity_id + + +def _clone_induced_relationships(client, source_uid, id_map, seen=None) -> int: + """Clone edges whose *both* endpoints were cloned (present in ``id_map``). + + ``seen`` — an optional set of ``(from_new, rel_type, to_new)`` keys — lets a + caller dedupe identical edges across multiple source universes (merge). + """ + if not id_map: + return 0 + rels = client.execute_read( + """ + MATCH (:Universe {id: $source_uid})-[:HAS_ENTITY]->(e1:Entity) + MATCH (:Universe {id: $source_uid})-[:HAS_ENTITY]->(e2:Entity) + MATCH (e1)-[r]->(e2) + WHERE type(r) <> 'HAS_ENTITY' + RETURN type(r) as rel_type, e1.id as from_id, e2.id as to_id, properties(r) as props + """, + {"source_uid": source_uid}, + ) + cloned = 0 + for rel in rels: + from_new = id_map.get(rel["from_id"]) + to_new = id_map.get(rel["to_id"]) + if not (from_new and to_new): + continue + key = (from_new, rel["rel_type"], to_new) + if seen is not None: + if key in seen: + continue + seen.add(key) + client.execute_write( + f""" + MATCH (e1 {{id: $from_id}}) + MATCH (e2 {{id: $to_id}}) + CREATE (e1)-[:`{rel["rel_type"]}` $props]->(e2) + """, + {"from_id": from_new, "to_id": to_new, "props": rel["props"] or {}}, + ) + cloned += 1 + return cloned + + +def neo4j_split_universe( + source_universe_id: UUID, + name: str, + entity_ids: list, + description: str = "", +) -> Dict[str, Any]: + """ + Split a *subset* of a universe's entities into a new universe. + + The selected entities (and the relationships induced between them) are + deep-cloned with fresh IDs into a new universe that shares the source's + multiverse and is tagged ``alt_world_type='split'``. + + Authority: CanonKeeper only + Use Case: M-39 + """ + client = get_neo4j_client() + source_uid = str(source_universe_id) + wanted = [str(e) for e in entity_ids] + if not wanted: + raise ValueError("entity_ids must not be empty") + + source = neo4j_get_universe(source_universe_id) + if not source: + raise ValueError(f"Universe {source_universe_id} not found") + + new_universe_id = uuid4() + new_uid = str(new_universe_id) + now = datetime.now(timezone.utc) + now_iso = now.isoformat() + + client.execute_write( + """ + MATCH (source:Universe {id: $source_uid})-[:IN_MULTIVERSE]->(mv:Multiverse) + CREATE (new:Universe { + id: $new_uid, name: $name, description: $description, + genre: source.genre, tone: source.tone, tech_level: source.tech_level, + is_template: false, parent_universe_id: $source_uid, alt_world_type: 'split', + canon_level: source.canon_level, confidence: source.confidence, + authority: source.authority, created_at: datetime($now) + }) + CREATE (new)-[:IN_MULTIVERSE]->(mv) + RETURN new + """, + { + "source_uid": source_uid, + "new_uid": new_uid, + "name": name, + "description": description or f"Split of {source.name}", + "now": now_iso, + }, + ) + + entities = client.execute_read( + """ + MATCH (:Universe {id: $source_uid})-[:HAS_ENTITY]->(e:Entity) + WHERE e.id IN $ids + RETURN e + """, + {"source_uid": source_uid, "ids": wanted}, + ) + + id_map: Dict[str, str] = {} + for record in entities: + old_data = dict(record["e"]) + new_id = _clone_entity_into_universe(client, new_uid, old_data, now_iso) + id_map[old_data.get("id", "")] = new_id + + relationships_cloned = _clone_induced_relationships(client, source_uid, id_map) + + return { + "new_universe_id": new_uid, + "entities_cloned": len(id_map), + "relationships_cloned": relationships_cloned, + } + + +def neo4j_merge_universes( + source_universe_ids: list, + name: str, + description: str = "", + dedupe_by_name: bool = True, +) -> Dict[str, Any]: + """ + Merge two or more universes' canon into a single new universe. + + Entities from every source are deep-cloned into the new universe. With + ``dedupe_by_name`` (default), entities that share a name collapse into one + node and their relationships are re-pointed at the survivor; identical edges + are de-duplicated. The new universe lives in the first source's multiverse + and is tagged ``alt_world_type='merge'``. + + Authority: CanonKeeper only + Use Case: M-40 + """ + client = get_neo4j_client() + if len(source_universe_ids) < 2: + raise ValueError("merge requires at least two source universes") + + sources = [] + for sid in source_universe_ids: + s = neo4j_get_universe(sid if isinstance(sid, UUID) else UUID(str(sid))) + if not s: + raise ValueError(f"Universe {sid} not found") + sources.append(s) + + primary_uid = str(source_universe_ids[0]) + new_universe_id = uuid4() + new_uid = str(new_universe_id) + now = datetime.now(timezone.utc) + now_iso = now.isoformat() + + created = client.execute_write( + """ + MATCH (primary:Universe {id: $primary_uid})-[:IN_MULTIVERSE]->(mv:Multiverse) + CREATE (new:Universe { + id: $new_uid, name: $name, description: $description, + genre: primary.genre, tone: primary.tone, tech_level: primary.tech_level, + is_template: false, parent_universe_id: $primary_uid, alt_world_type: 'merge', + canon_level: primary.canon_level, confidence: primary.confidence, + authority: primary.authority, created_at: datetime($now) + }) + CREATE (new)-[:IN_MULTIVERSE]->(mv) + RETURN new + """, + { + "primary_uid": primary_uid, + "new_uid": new_uid, + "name": name, + "description": description or f"Merge of {len(sources)} universes", + "now": now_iso, + }, + ) + if not created: + raise ValueError("Could not create merged universe (is the primary in a multiverse?)") + + id_map: Dict[str, str] = {} + name_to_new: Dict[str, str] = {} + entities_cloned = 0 + duplicates_merged = 0 + + for sid in source_universe_ids: + suid = str(sid) + entities = client.execute_read( + """ + MATCH (:Universe {id: $source_uid})-[:HAS_ENTITY]->(e:Entity) + RETURN e + """, + {"source_uid": suid}, + ) + for record in entities: + old_data = dict(record["e"]) + old_id = old_data.get("id", "") + nm = old_data.get("name", "Unknown") + if dedupe_by_name and nm in name_to_new: + id_map[old_id] = name_to_new[nm] + duplicates_merged += 1 + continue + new_id = _clone_entity_into_universe( + client, new_uid, old_data, now_iso + ) + id_map[old_id] = new_id + name_to_new[nm] = new_id + entities_cloned += 1 + + seen: set = set() + relationships_cloned = 0 + for sid in source_universe_ids: + relationships_cloned += _clone_induced_relationships(client, str(sid), id_map, seen) + + return { + "new_universe_id": new_uid, + "entities_cloned": entities_cloned, + "relationships_cloned": relationships_cloned, + "sources_merged": len(sources), + "duplicates_merged": duplicates_merged, + } diff --git a/packages/data-layer/src/monitor_data/tools/neo4j_tools/entities.py b/packages/data-layer/src/monitor_data/tools/neo4j_tools/entities.py index 0710301a..f74f1902 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools/entities.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools/entities.py @@ -519,16 +519,22 @@ def neo4j_set_state_tags(entity_id: UUID, params: StateTagsUpdate) -> EntityResp "updated_at": datetime.now(timezone.utc).isoformat(), } - if params.remove_tags: - update_parts.append("e.state_tags = [tag IN e.state_tags WHERE NOT tag IN $remove_tags]") - update_params["remove_tags"] = params.remove_tags - - if params.add_tags: - # Add tags, avoiding duplicates - update_parts.append( - "e.state_tags = e.state_tags + [tag IN $add_tags WHERE NOT tag IN e.state_tags]" - ) - update_params["add_tags"] = params.add_tags + # Compose removal and addition into a SINGLE assignment. Two separate + # ``SET e.state_tags = ...`` clauses both read the *original* list, so an + # add clause would clobber a preceding remove when both are supplied. + if params.remove_tags or params.add_tags: + kept_expr = "e.state_tags" + if params.remove_tags: + kept_expr = "[tag IN e.state_tags WHERE NOT tag IN $remove_tags]" + update_params["remove_tags"] = params.remove_tags + if params.add_tags: + update_params["add_tags"] = params.add_tags + tag_expr = ( + f"{kept_expr} + [tag IN $add_tags WHERE NOT tag IN {kept_expr}]" + ) + else: + tag_expr = kept_expr + update_parts.append(f"e.state_tags = {tag_expr}") if not update_parts: # No changes, return current state diff --git a/packages/data-layer/tests/test_tools/test_universe_split_merge.py b/packages/data-layer/tests/test_tools/test_universe_split_merge.py new file mode 100644 index 00000000..1dede58d --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_universe_split_merge.py @@ -0,0 +1,165 @@ +"""Real-Neo4j integration tests for universe split & merge (M-39 / M-40). + +Each test provisions an isolated multiverse, builds a small canon graph, runs +the split/merge tool, and asserts the resulting universe. Gated by +``RUN_INTEGRATION=1``. +""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +pytestmark = pytest.mark.integration + + +def _make_entity(universe_id: UUID, name: str, etype="character"): + from monitor_data.schemas.base import Authority, CanonLevel, EntityType + from monitor_data.schemas.entities import EntityCreate + from monitor_data.tools.neo4j_tools import neo4j_create_entity + + return neo4j_create_entity( + EntityCreate( + universe_id=universe_id, + name=name, + entity_type=EntityType(etype), + is_archetype=False, + description=f"{name} desc", + properties={}, + authority=Authority.GM, + canon_level=CanonLevel.CANON, + confidence=1.0, + ) + ) + + +def _relate(a: UUID, b: UUID, rel_type="MEMBER_OF"): + from monitor_data.schemas.relationships import ( + RelationshipCategory, + RelationshipCreate, + RelationshipType, + ) + from monitor_data.tools.neo4j_tools import neo4j_create_relationship + + neo4j_create_relationship( + RelationshipCreate( + from_entity_id=a, + to_entity_id=b, + rel_type=RelationshipType(rel_type), + category=RelationshipCategory.MEMBERSHIP, + ) + ) + + +def _count_entities(universe_id: str) -> int: + from monitor_data.schemas.entities import EntityFilter + from monitor_data.tools.neo4j_tools import neo4j_list_entities + + res = neo4j_list_entities(EntityFilter(universe_id=UUID(universe_id), limit=100)) + return len(res.entities) + + +@pytest.fixture() +def multiverse(): + """An isolated multiverse, cascade-deleted on teardown.""" + from monitor_data.schemas.universe import MultiverseCreate + from monitor_data.tools.neo4j_tools import ( + neo4j_create_multiverse, + neo4j_delete_multiverse, + neo4j_ensure_omniverse, + ) + + omni = neo4j_ensure_omniverse() + mv = neo4j_create_multiverse( + MultiverseCreate( + omniverse_id=UUID(omni["omniverse_id"]), + name="Split/Merge Test MV", + system_name="generic", + description="ephemeral", + is_template=False, + source_document_id=None, + parent_multiverse_id=None, + ) + ) + yield mv.id + try: + neo4j_delete_multiverse(mv.id) + except Exception: # noqa: BLE001 + pass + + +def _new_universe(multiverse_id: UUID, name: str) -> UUID: + from monitor_data.schemas.universe import UniverseCreate + from monitor_data.tools.neo4j_tools import neo4j_create_universe + + u = neo4j_create_universe( + UniverseCreate(multiverse_id=multiverse_id, name=name, description="x") + ) + return u.id + + +def test_split_universe_clones_subset_and_induced_edges(multiverse): + from monitor_data.tools.neo4j_tools import neo4j_split_universe + + uni = _new_universe(multiverse, "Source") + a = _make_entity(uni, "Aldric") + b = _make_entity(uni, "Iron Brotherhood", "faction") + _make_entity(uni, "Caldera", "location") # C — excluded from the split + _relate(a.id, b.id) # A -MEMBER_OF-> B (induced; both in subset) + + result = neo4j_split_universe( + source_universe_id=uni, + name="Splinter", + entity_ids=[a.id, b.id], + ) + + assert result["entities_cloned"] == 2 + assert result["relationships_cloned"] == 1 + new_uid = result["new_universe_id"] + assert _count_entities(new_uid) == 2 # C did not come along + # source is untouched (still 3) + assert _count_entities(str(uni)) == 3 + + +def test_split_rejects_empty_selection(multiverse): + from monitor_data.tools.neo4j_tools import neo4j_split_universe + + uni = _new_universe(multiverse, "Source2") + with pytest.raises(ValueError): + neo4j_split_universe(source_universe_id=uni, name="x", entity_ids=[]) + + +def test_merge_universes_dedupes_by_name(multiverse): + from monitor_data.tools.neo4j_tools import neo4j_merge_universes + + u1 = _new_universe(multiverse, "Left") + _make_entity(u1, "Aldric") + b1 = _make_entity(u1, "Shared Guild", "faction") + + u2 = _new_universe(multiverse, "Right") + _make_entity(u2, "Shared Guild", "faction") # same name → dedupes + c2 = _make_entity(u2, "Caldera", "location") + _relate(c2.id, _make_entity(u2, "Region", "location").id, "LOCATED_IN") + + result = neo4j_merge_universes( + source_universe_ids=[u1, u2], + name="Unified", + dedupe_by_name=True, + ) + + # A, Shared Guild (deduped), Caldera, Region = 4 unique; 1 duplicate collapsed + assert result["entities_cloned"] == 4 + assert result["duplicates_merged"] == 1 + assert result["sources_merged"] == 2 + assert _count_entities(result["new_universe_id"]) == 4 + # silence unused-var lint on b1 (kept for graph realism) + assert b1 is not None + + +def test_merge_requires_two_sources(multiverse): + from monitor_data.tools.neo4j_tools import neo4j_merge_universes + + uni = _new_universe(multiverse, "Solo") + with pytest.raises(ValueError): + neo4j_merge_universes(source_universe_ids=[uni], name="x") diff --git a/packages/ui/backend/src/monitor_ui/routers/character_conversation.py b/packages/ui/backend/src/monitor_ui/routers/character_conversation.py new file mode 100644 index 00000000..518c3cee --- /dev/null +++ b/packages/ui/backend/src/monitor_ui/routers/character_conversation.py @@ -0,0 +1,335 @@ +""" +Character conversatory — story-less MONITOR-backed chat with a roster character. + +LAYER: 3 (UI backend) + +A standalone "character" (see character_storage) is, by default, a light card +(name + free-text personality). To talk to it through the MONITOR engine +(NPCVoice: memory, triggers, emotional state, relationship deltas) it must be +backed by a real EntityInstance (Neo4j) + NPCProfile (Mongo). + +This module: + * find-or-creates a hidden "Conversatory" universe to host standalone + characters' entities (so ConversationLoop, which requires a universe_id, + works without any story); + * expands a light card into a full NPCProfile via NPCProfileGenerator and + provisions the backing entity (ensure_character_backed) — idempotent; + * starts / steps / ends story-less ConversationLoop DIRECT sessions and + caches the live loops, mirroring chat_loops._CONVERSATION_LOOPS. + +Reuses the provisioning pattern from entities._persist_generated_entity. +""" + +from __future__ import annotations + +import asyncio +import uuid +from collections import OrderedDict +from typing import Any, Optional + +import structlog + +from .character_storage import get_character, update_character + +log = structlog.get_logger() + +# Sentinel names for the hidden host universe. +_CONVERSATORY_MULTIVERSE_NAME = "__MONITOR_CONVERSATORY__" +_CONVERSATORY_UNIVERSE_NAME = "Conversatory" + +# Stable sentinel "player" entity so NPCVoice keys relationship_states and +# accumulates trust/fear/affinity deltas across conversatory sessions. Without +# a player_entity_id, NPCVoice skips relationship tracking entirely. +_CONVERSATORY_PLAYER_ID = uuid.UUID("c0a7e7a7-0000-4000-8000-000000000001") + +# Cached host-universe id (resolved once per process). +_conversatory_universe_id: Optional[str] = None + +# Live ConversationLoop cache, keyed by conversation_id (string). +_LOOPS: "OrderedDict[str, Any]" = OrderedDict() +_LOOPS_MAX = 64 + + +# --------------------------------------------------------------------------- +# Conversatory host universe +# --------------------------------------------------------------------------- + + +async def ensure_conversatory_universe() -> str: + """Find-or-create the hidden universe that hosts standalone characters.""" + global _conversatory_universe_id + if _conversatory_universe_id: + return _conversatory_universe_id + + from monitor_data.schemas.universe import UniverseFilter + from monitor_data.tools.neo4j_tools.core import neo4j_list_universes + + try: + universes = await asyncio.to_thread( + neo4j_list_universes, UniverseFilter(limit=1000) + ) + for u in universes: + if getattr(u, "name", None) == _CONVERSATORY_UNIVERSE_NAME: + _conversatory_universe_id = str(u.id) + return _conversatory_universe_id + except Exception as exc: # noqa: BLE001 + log.warning("conversatory_universe_lookup_failed", error=str(exc)) + + # Not found — create the multiverse → universe chain via CanonKeeper. + from monitor_agents.canonkeeper import CanonKeeper + + keeper = CanonKeeper() + mv = await keeper.create_multiverse( + { + "name": _CONVERSATORY_MULTIVERSE_NAME, + "system_name": "Freeform", + "description": "Hidden host for standalone roleplay characters.", + } + ) + if "id" not in mv: + raise RuntimeError(f"Failed to create conversatory multiverse: {mv}") + + u = await keeper.create_universe( + { + "multiverse_id": str(mv["id"]), + "name": _CONVERSATORY_UNIVERSE_NAME, + "genre": "Freeform", + "description": "Standalone roleplay characters live here.", + "tone": "neutral", + } + ) + if "id" not in u: + raise RuntimeError(f"Failed to create conversatory universe: {u}") + + _conversatory_universe_id = str(u["id"]) + log.info("conversatory_universe_created", universe_id=_conversatory_universe_id) + return _conversatory_universe_id + + +# --------------------------------------------------------------------------- +# LLM-assisted card drafting ("fill the card for me") +# --------------------------------------------------------------------------- + + +async def draft_card( + concept: str, + name: str = "", + description: str = "", + personality: str = "", +) -> dict[str, Any]: + """Draft the light-card fields from a concept. Does not persist anything.""" + from monitor_agents.prompts.card_draft import CardDrafter + + drafter = CardDrafter() + return await asyncio.to_thread( + drafter.forward, + concept, + name, + description, + personality, + ) + + +# --------------------------------------------------------------------------- +# Expansion: light card → MONITOR-backed entity + NPCProfile +# --------------------------------------------------------------------------- + + +def _provision_entity_and_profile( + universe_id: str, char: dict[str, Any], fields: dict[str, Any] +) -> str: + """Create the EntityInstance + NPCProfile. Returns the new entity id (str). + + Mirrors entities._persist_generated_entity; synchronous (Neo4j/Mongo tools). + """ + from monitor_data.schemas.base import Authority, CanonLevel, EntityType + from monitor_data.schemas.entities import EntityCreate + from monitor_data.schemas.npc_profiles import BehavioralTrigger, NPCProfileCreate + from monitor_data.tools.mongodb_tools import mongodb_create_npc_profile + from monitor_data.tools.neo4j_tools.entities import neo4j_create_entity + + entity = neo4j_create_entity( + EntityCreate( + universe_id=uuid.UUID(universe_id), + name=char["name"], + entity_type=EntityType.CHARACTER, + sub_type="npc", + is_archetype=False, + description=char.get("description", "") or "", + properties={ + "role": "character", + "standalone_character_id": char["id"], + "generation_source": "character_card_expansion", + }, + authority=Authority.SYSTEM, + canon_level=CanonLevel.CANON, + confidence=1.0, + ) + ) + + triggers = [BehavioralTrigger(**t) for t in fields.get("triggers", [])] + mongodb_create_npc_profile( + NPCProfileCreate( + entity_id=entity.id, + traits=fields.get("traits", {}), + values=fields.get("values", []), + fears=fields.get("fears", []), + desires=fields.get("desires", []), + speech_style=fields.get("speech_style"), + catchphrases=fields.get("catchphrases", []), + triggers=triggers, + gm_notes=char.get("gm_notes") or "", + current_emotional_state=fields.get("current_emotional_state", "neutral"), + ) + ) + return str(entity.id) + + +async def ensure_character_backed(character_id: str) -> dict[str, Any]: + """Ensure the character has an EntityInstance + NPCProfile. + + Idempotent: returns immediately if already backed. Otherwise generates a + profile from the card and provisions the backing entity in the character's + own universe (if universe-linked) or the hidden Conversatory universe. + + Returns {"entity_id": str, "universe_id": str}. + """ + char = get_character(character_id) + if not char: + raise ValueError(f"Character {character_id} not found") + + universe_id = char.get("source_universe_id") or await ensure_conversatory_universe() + + if char.get("entity_id"): + return {"entity_id": str(char["entity_id"]), "universe_id": str(universe_id)} + + # Generate a structured profile from the light card. + from monitor_agents.prompts.npc_profile_gen import NPCProfileGenerator + + generator = NPCProfileGenerator() + fields = await asyncio.to_thread( + generator.forward, + char["name"], + char.get("description", "") or "", + char.get("personality", "") or "", + char.get("gm_notes", "") or "", + ) + + entity_id = await asyncio.to_thread( + _provision_entity_and_profile, str(universe_id), char, fields + ) + update_character( + character_id, {"entity_id": entity_id, "source_universe_id": str(universe_id)} + ) + log.info( + "character_expanded", + character_id=character_id, + entity_id=entity_id, + universe_id=str(universe_id), + ) + return {"entity_id": entity_id, "universe_id": str(universe_id)} + + +# --------------------------------------------------------------------------- +# Conversation lifecycle +# --------------------------------------------------------------------------- + + +def _cache_loop(conversation_id: str, loop: Any) -> None: + _LOOPS[conversation_id] = loop + _LOOPS.move_to_end(conversation_id) + while len(_LOOPS) > _LOOPS_MAX: + _LOOPS.popitem(last=False) + + +def get_loop(conversation_id: str) -> Any | None: + return _LOOPS.get(conversation_id) + + +def pop_loop(conversation_id: str) -> None: + _LOOPS.pop(conversation_id, None) + + +async def start_conversation(character_id: str) -> dict[str, Any]: + """Expand-if-needed, open a DIRECT ConversationLoop, return opening + id.""" + from monitor_agents.loops.conversation_loop import ConversationLoop, ConversationMode + + char = get_character(character_id) + if not char: + raise ValueError(f"Character {character_id} not found") + + backing = await ensure_character_backed(character_id) + + loop = await ConversationLoop.start( + universe_id=uuid.UUID(backing["universe_id"]), + mode=ConversationMode.DIRECT, + npc_ids=[uuid.UUID(backing["entity_id"])], + story_id=None, + scene_id=None, + player_entity_id=_CONVERSATORY_PLAYER_ID, + ) + conversation_id = str(loop.state.conversation_id) + _cache_loop(conversation_id, loop) + + opening = char.get("first_message") or f"{char['name']} turns to face you." + return { + "conversation_id": conversation_id, + "character_id": character_id, + "entity_id": backing["entity_id"], + "opening": opening, + } + + +async def send_message(conversation_id: str, text: str) -> dict[str, Any]: + """Step the loop once; return the NPC reply + emotional/relationship read.""" + loop = get_loop(conversation_id) + if loop is None: + raise KeyError(conversation_id) + + responses = await loop.step(text) + reply = responses[0] if responses else {} + return { + "text": reply.get("text", ""), + "emotional_state": reply.get("emotional_state"), + "relationship_snapshot": reply.get("relationship_snapshot", {}), + } + + +async def end_conversation(conversation_id: str) -> dict[str, Any]: + """Finish the loop (persist + stage). Drops it from the cache.""" + loop = get_loop(conversation_id) + if loop is None: + return {"ended": True, "proposals": 0} + try: + proposals = await loop.finish() + finally: + pop_loop(conversation_id) + return {"ended": True, "proposals": len(proposals or [])} + + +def list_conversations(entity_id: str, limit: int = 20) -> list[dict[str, Any]]: + """Past ConversationSessions for this character's entity, newest first.""" + from monitor_data.db.mongodb import get_mongodb_client + + coll = get_mongodb_client().get_collection("conversations") + cursor = ( + coll.find({"npc_ids": entity_id}) + .sort("updated_at", -1) + .limit(max(1, min(int(limit), 100))) + ) + out: list[dict[str, Any]] = [] + for doc in cursor: + out.append( + { + "conversation_id": doc.get("conversation_id"), + "status": doc.get("status"), + "turn_count": len(doc.get("turns", [])), + "created_at": doc["created_at"].isoformat() + if hasattr(doc.get("created_at"), "isoformat") + else str(doc.get("created_at", "")), + "updated_at": doc["updated_at"].isoformat() + if hasattr(doc.get("updated_at"), "isoformat") + else str(doc.get("updated_at", "")), + } + ) + return out diff --git a/packages/ui/backend/src/monitor_ui/routers/entities.py b/packages/ui/backend/src/monitor_ui/routers/entities.py index 892d427e..8a4ac34e 100644 --- a/packages/ui/backend/src/monitor_ui/routers/entities.py +++ b/packages/ui/backend/src/monitor_ui/routers/entities.py @@ -39,9 +39,16 @@ AttributeInfo, Character, CharacterCreate, + CardDraftRequest, + CardDraftResponse, CharacterDetail, + CharacterExpandResponse, CharacterImportRequest, CharacterUpdate, + ConversationReply, + ConversationSendRequest, + ConversationStartResponse, + ConversationSummary, CoreMechanicInfo, GenerateEntityRequest, PaginatedNPCs, @@ -1053,6 +1060,113 @@ async def clear_character_memories(character_id: str) -> None: pass +# --------------------------------------------------------------------------- +# Conversatory — MONITOR-backed chat with a roster character +# --------------------------------------------------------------------------- + + +@router.post("/characters/draft", response_model=CardDraftResponse) +async def draft_character_card(body: CardDraftRequest) -> CardDraftResponse: + """LLM-assisted: draft card fields from a concept (does not persist).""" + from . import character_conversation as cc + + try: + draft = await cc.draft_card( + concept=body.concept, + name=body.name, + description=body.description, + personality=body.personality, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"Draft failed: {exc}") + return CardDraftResponse(**draft) + + +@router.post("/characters/{character_id}/expand", response_model=CharacterExpandResponse) +async def expand_character(character_id: str) -> CharacterExpandResponse: + """Promote a light card into a MONITOR-backed character (entity + NPCProfile).""" + from . import character_conversation as cc + + if not _get_character_doc(character_id): + raise HTTPException(status_code=404, detail="Character not found") + try: + backing = await cc.ensure_character_backed(character_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"Expansion failed: {exc}") + return CharacterExpandResponse( + character_id=character_id, + entity_id=backing["entity_id"], + universe_id=backing["universe_id"], + ) + + +@router.post( + "/characters/{character_id}/conversations", response_model=ConversationStartResponse +) +async def start_character_conversation(character_id: str) -> ConversationStartResponse: + """Open a story-less conversatory session with the character.""" + from . import character_conversation as cc + + if not _get_character_doc(character_id): + raise HTTPException(status_code=404, detail="Character not found") + try: + result = await cc.start_conversation(character_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"Could not start conversation: {exc}") + return ConversationStartResponse(**result) + + +@router.post( + "/characters/{character_id}/conversations/{conversation_id}/send", + response_model=ConversationReply, +) +async def send_character_message( + character_id: str, conversation_id: str, body: ConversationSendRequest +) -> ConversationReply: + """Send one line; return the character's reply + emotional/relationship read.""" + from . import character_conversation as cc + + try: + reply = await cc.send_message(conversation_id, body.text) + except KeyError: + raise HTTPException( + status_code=409, + detail="Conversation is no longer active. Start a new one.", + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"Reply failed: {exc}") + return ConversationReply(**reply) + + +@router.post("/characters/{character_id}/conversations/{conversation_id}/end") +async def end_character_conversation(character_id: str, conversation_id: str) -> dict: + """Close a conversatory session (persist working state + stage proposals).""" + from . import character_conversation as cc + + return await cc.end_conversation(conversation_id) + + +@router.get( + "/characters/{character_id}/conversations", + response_model=list[ConversationSummary], +) +async def list_character_conversations( + character_id: str, + limit: int = Query(default=20, ge=1, le=100), +) -> list[ConversationSummary]: + """List past conversatory sessions for this character (newest first).""" + from . import character_conversation as cc + + char = _get_character_doc(character_id) + if not char: + raise HTTPException(status_code=404, detail="Character not found") + entity_id = char.get("entity_id") + if not entity_id: + return [] + sessions = await asyncio.to_thread(cc.list_conversations, str(entity_id), limit) + return [ConversationSummary(**s) for s in sessions] + + # --------------------------------------------------------------------------- # Entity Template Cloning (M-31) # --------------------------------------------------------------------------- @@ -1089,6 +1203,144 @@ async def link_entity_to_archetype(entity_id: UUID, archetype_id: UUID) -> dict: raise HTTPException(status_code=404, detail=str(exc)) from exc +# --------------------------------------------------------------------------- +# Single-entity CRUD — direct graph manipulation (M-36 / M-38) +# --------------------------------------------------------------------------- + + +class EntityCreateRequest(BaseModel): + """Create a single entity on the graph canvas (M-38).""" + + universe_id: UUID + name: str + entity_type: str = "concept" + description: str = "" + properties: Optional[dict] = None + + +class EntityPatchRequest(BaseModel): + """Partial update of an entity from the graph inspector (M-36). + + ``tags`` is the *desired* full set of state tags; the diff against the + entity's current tags is computed server-side so the client never has to + reason about add/remove. + """ + + name: Optional[str] = None + description: Optional[str] = None + properties: Optional[dict] = None + tags: Optional[list[str]] = None + + +@router.post("/entities", status_code=201) +async def create_entity(body: EntityCreateRequest) -> dict: + """Create a single canon entity in a universe (M-38). + + Writes through the data layer's CanonKeeper-authority entity tool so the + graph stays the single source of truth. + """ + from monitor_data.schemas.base import Authority, CanonLevel, EntityType + from monitor_data.schemas.entities import EntityCreate + from monitor_data.tools.neo4j_tools.entities import neo4j_create_entity + + try: + entity_type = EntityType(body.entity_type) + except ValueError as exc: + raise HTTPException(422, f"Invalid entity_type: {body.entity_type}") from exc + + try: + created = neo4j_create_entity( + EntityCreate( + universe_id=body.universe_id, + name=body.name, + entity_type=entity_type, + is_archetype=False, + description=body.description, + properties=body.properties or {}, + authority=Authority.GM, + canon_level=CanonLevel.CANON, + confidence=1.0, + ) + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(503, f"Create failed: {exc}") from exc + + return created.model_dump(mode="json") + + +@router.get("/entities/{entity_id}") +async def get_entity(entity_id: str) -> dict: + """Fetch a single entity by ID for the graph inspector (M-36).""" + from monitor_data.tools.neo4j_tools.entities import neo4j_get_entity + + uid = validate_uuid(entity_id) + try: + entity = neo4j_get_entity(uid) + except Exception as exc: # noqa: BLE001 + raise HTTPException(503, f"Lookup failed: {exc}") from exc + + if not entity: + raise HTTPException(404, "Entity not found") + return entity.model_dump(mode="json") + + +@router.patch("/entities/{entity_id}") +async def update_entity(entity_id: str, body: EntityPatchRequest) -> dict: + """Update a single entity from the graph inspector (M-36). + + Mutable fields (``name``/``description``/``properties``) go through + ``neo4j_update_entity``; ``tags`` are diffed against current state and + applied atomically via ``neo4j_set_state_tags``. Both tools carry + CanonKeeper authority at the data layer. + """ + from monitor_data.schemas.entities import EntityUpdate, StateTagsUpdate + from monitor_data.tools.neo4j_tools.entities import ( + neo4j_get_entity, + neo4j_set_state_tags, + neo4j_update_entity, + ) + + uid = validate_uuid(entity_id) + + existing = neo4j_get_entity(uid) + if not existing: + raise HTTPException(404, "Entity not found") + + result = existing + has_field_update = any( + v is not None for v in (body.name, body.description, body.properties) + ) + + try: + if has_field_update: + result = neo4j_update_entity( + uid, + EntityUpdate( + name=body.name, + description=body.description, + properties=body.properties, + ), + ) + + if body.tags is not None and not existing.is_archetype: + desired = set(body.tags) + current = set(existing.state_tags or []) + add = sorted(desired - current) + remove = sorted(current - desired) + if add or remove: + result = neo4j_set_state_tags( + uid, StateTagsUpdate(add_tags=add, remove_tags=remove) + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(503, f"Update failed: {exc}") from exc + + return result.model_dump(mode="json") + + # --------------------------------------------------------------------------- # Character Relationships (GAP-F) # --------------------------------------------------------------------------- @@ -1118,6 +1370,106 @@ async def create_character_relationship( raise HTTPException(status_code=400, detail=str(exc)) from exc +# --------------------------------------------------------------------------- +# Graph edges — inline relationship creation between any entities (M-37) +# --------------------------------------------------------------------------- + +# Default category for each relationship type, so the graph UI only has to send +# a rel_type when the user draws an edge. +_REL_TYPE_CATEGORY = { + "KNOWS": "social", + "ALLIED_WITH": "social", + "HOSTILE_TO": "social", + "MEMBER_OF": "membership", + "PART_OF": "membership", + "SUBGROUP_OF": "membership", + "WORKS_FOR": "membership", + "OWNS": "ownership", + "LOCATED_IN": "spatial", + "CONTAINS": "spatial", + "PARTICIPATES_IN": "temporal", + "SUBTYPE_OF": "taxonomic", + "INSTANCE_OF": "taxonomic", + "DERIVES_FROM": "taxonomic", + "LEADS": "power", + "CONTROLS": "power", + "CONTROLLED_BY": "power", + "REVERES": "power", + "RELATED_TO": "generic", + "AFFILIATED_WITH": "generic", +} + + +class EdgeCreateRequest(BaseModel): + """Create a typed relationship between any two entities (M-37).""" + + from_id: UUID + to_id: UUID + rel_type: str = "RELATED_TO" + category: Optional[str] = None + properties: Optional[dict] = None + + +@router.post("/entities/edges", status_code=201) +async def create_edge(body: EdgeCreateRequest) -> dict: + """Create a relationship edge between two canon entities (M-37). + + Drawn by dragging between nodes on the graph; ``category`` is inferred from + ``rel_type`` when omitted. Writes via the CanonKeeper-authority data tool. + """ + from monitor_data.schemas.relationships import ( + RelationshipCategory, + RelationshipCreate, + RelationshipType, + ) + from monitor_data.tools.neo4j_tools.relationships import neo4j_create_relationship + + try: + rel_type = RelationshipType(body.rel_type) + except ValueError as exc: + raise HTTPException(422, f"Invalid rel_type: {body.rel_type}") from exc + + category_value = body.category or _REL_TYPE_CATEGORY.get(rel_type.value, "generic") + try: + category = RelationshipCategory(category_value) + except ValueError as exc: + raise HTTPException(422, f"Invalid category: {category_value}") from exc + + try: + rel = neo4j_create_relationship( + RelationshipCreate( + from_entity_id=body.from_id, + to_entity_id=body.to_id, + rel_type=rel_type, + category=category, + properties=body.properties or {}, + ) + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(503, f"Edge creation failed: {exc}") from exc + + return rel.model_dump(mode="json") + + +@router.get("/entities/{entity_id}/edges") +async def list_edges(entity_id: str) -> dict: + """List relationships touching an entity, both directions (M-37).""" + from monitor_data.schemas.relationships import Direction, RelationshipFilter + from monitor_data.tools.neo4j_tools.relationships import neo4j_list_relationships + + uid = validate_uuid(entity_id) + try: + result = neo4j_list_relationships( + RelationshipFilter(entity_id=uid, direction=Direction.BOTH, limit=200) + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(503, f"Edge lookup failed: {exc}") from exc + + return result.model_dump(mode="json") + + # --------------------------------------------------------------------------- # Batch Entity Operations (Phase 3.1) # --------------------------------------------------------------------------- diff --git a/packages/ui/backend/src/monitor_ui/routers/entities_schemas.py b/packages/ui/backend/src/monitor_ui/routers/entities_schemas.py index c202b116..e83878d8 100644 --- a/packages/ui/backend/src/monitor_ui/routers/entities_schemas.py +++ b/packages/ui/backend/src/monitor_ui/routers/entities_schemas.py @@ -211,3 +211,68 @@ class CharacterImportRequest(BaseModel): default=False, description="If True, import without universe/memory context", ) + + +# --------------------------------------------------------------------------- +# Conversatory (MONITOR-backed chat with a roster character) +# --------------------------------------------------------------------------- + + +class CharacterExpandResponse(BaseModel): + """Result of expanding a light card into a MONITOR-backed character.""" + + character_id: str + entity_id: str + universe_id: str + + +class CardDraftRequest(BaseModel): + """Ask the LLM to draft/fill a character card from a concept.""" + + concept: str = Field(..., min_length=1, description="Short premise for the character") + name: str = Field(default="", description="Name the user already chose (optional)") + description: str = Field(default="", description="Partial description to refine (optional)") + personality: str = Field(default="", description="Partial personality to refine (optional)") + + +class CardDraftResponse(BaseModel): + """A drafted card — not persisted; the UI shows it for review/edit.""" + + name: str + description: str = "" + personality: str = "" + first_message: str = "" + gm_notes: str = "" + + +class ConversationStartResponse(BaseModel): + """A freshly opened conversatory session.""" + + conversation_id: str + character_id: str + entity_id: str + opening: str + + +class ConversationSendRequest(BaseModel): + """A single player line in a conversatory session.""" + + text: str = Field(..., min_length=1) + + +class ConversationReply(BaseModel): + """The character's response plus its live emotional/relationship read.""" + + text: str + emotional_state: str | None = None + relationship_snapshot: dict[str, Any] = Field(default_factory=dict) + + +class ConversationSummary(BaseModel): + """A past conversatory session (for resume/history lists).""" + + conversation_id: str | None = None + status: str | None = None + turn_count: int = 0 + created_at: str = "" + updated_at: str = "" diff --git a/packages/ui/backend/tests/test_character_conversation.py b/packages/ui/backend/tests/test_character_conversation.py new file mode 100644 index 00000000..f7bcd64c --- /dev/null +++ b/packages/ui/backend/tests/test_character_conversation.py @@ -0,0 +1,208 @@ +""" +Unit tests for the character conversatory service (character_conversation.py). + +No live DB / LM / ConversationLoop — all collaborators are mocked. Async +functions are driven with asyncio.run() since the backend test suite does not +enable pytest-asyncio auto mode. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from monitor_ui.routers import character_conversation as cc + + +# --------------------------------------------------------------------------- +# ensure_character_backed +# --------------------------------------------------------------------------- + + +class TestEnsureCharacterBacked: + def test_returns_existing_backing_without_provisioning(self): + card = { + "id": "char-1", + "name": "Maeve", + "entity_id": "ent-1", + "source_universe_id": "uni-1", + } + with ( + patch.object(cc, "get_character", return_value=card), + patch.object(cc, "_provision_entity_and_profile") as mock_provision, + patch.object(cc, "update_character") as mock_update, + ): + out = asyncio.run(cc.ensure_character_backed("char-1")) + + assert out == {"entity_id": "ent-1", "universe_id": "uni-1"} + mock_provision.assert_not_called() + mock_update.assert_not_called() + + def test_expands_light_card_and_persists_entity_id(self): + card = {"id": "char-2", "name": "Maeve", "entity_id": None, "description": "wary"} + + fake_gen = MagicMock() + fake_gen.forward.return_value = {"traits": {"wariness": 0.8}, "triggers": []} + + with ( + patch.object(cc, "get_character", return_value=card), + patch.object( + cc, "ensure_conversatory_universe", new=AsyncMock(return_value="uni-conv") + ), + patch( + "monitor_agents.prompts.npc_profile_gen.NPCProfileGenerator", + return_value=fake_gen, + ), + patch.object(cc, "_provision_entity_and_profile", return_value="ent-new"), + patch.object(cc, "update_character") as mock_update, + ): + out = asyncio.run(cc.ensure_character_backed("char-2")) + + assert out == {"entity_id": "ent-new", "universe_id": "uni-conv"} + mock_update.assert_called_once_with( + "char-2", {"entity_id": "ent-new", "source_universe_id": "uni-conv"} + ) + fake_gen.forward.assert_called_once() + + def test_uses_linked_universe_when_present(self): + card = { + "id": "char-3", + "name": "Aldric", + "entity_id": None, + "source_universe_id": "uni-real", + } + fake_gen = MagicMock() + fake_gen.forward.return_value = {"triggers": []} + + with ( + patch.object(cc, "get_character", return_value=card), + patch.object(cc, "ensure_conversatory_universe", new=AsyncMock()) as mock_conv, + patch( + "monitor_agents.prompts.npc_profile_gen.NPCProfileGenerator", + return_value=fake_gen, + ), + patch.object(cc, "_provision_entity_and_profile", return_value="ent-x") as prov, + patch.object(cc, "update_character"), + ): + out = asyncio.run(cc.ensure_character_backed("char-3")) + + assert out["universe_id"] == "uni-real" + mock_conv.assert_not_awaited() # linked universe short-circuits the host lookup + assert prov.call_args[0][0] == "uni-real" + + def test_missing_character_raises(self): + with patch.object(cc, "get_character", return_value=None): + with pytest.raises(ValueError): + asyncio.run(cc.ensure_character_backed("nope")) + + +# --------------------------------------------------------------------------- +# start / send / end +# --------------------------------------------------------------------------- + + +class TestConversationLifecycle: + def setup_method(self): + cc._LOOPS.clear() + + def test_start_conversation_caches_loop_and_opening(self): + from uuid import uuid4 + + card = {"id": "char-1", "name": "Maeve", "first_message": "Well met."} + fake_loop = SimpleNamespace(state=SimpleNamespace(conversation_id="conv-1")) + entity_id, universe_id = str(uuid4()), str(uuid4()) + + with ( + patch.object(cc, "get_character", return_value=card), + patch.object( + cc, + "ensure_character_backed", + new=AsyncMock( + return_value={"entity_id": entity_id, "universe_id": universe_id} + ), + ), + patch( + "monitor_agents.loops.conversation_loop.ConversationLoop.start", + new=AsyncMock(return_value=fake_loop), + ), + ): + out = asyncio.run(cc.start_conversation("char-1")) + + assert out["conversation_id"] == "conv-1" + assert out["opening"] == "Well met." + assert cc.get_loop("conv-1") is fake_loop + + def test_send_message_maps_reply_fields(self): + fake_loop = MagicMock() + fake_loop.step = AsyncMock( + return_value=[ + { + "text": "We don't get trouble here.", + "emotional_state": "guarded", + "relationship_snapshot": {"stance": "guarded", "trust": -0.1}, + } + ] + ) + cc._cache_loop("conv-2", fake_loop) + + reply = asyncio.run(cc.send_message("conv-2", "hi")) + assert reply["text"] == "We don't get trouble here." + assert reply["emotional_state"] == "guarded" + assert reply["relationship_snapshot"]["stance"] == "guarded" + + def test_send_message_missing_loop_raises_keyerror(self): + with pytest.raises(KeyError): + asyncio.run(cc.send_message("does-not-exist", "hi")) + + def test_end_conversation_finishes_and_evicts(self): + fake_loop = MagicMock() + fake_loop.finish = AsyncMock(return_value=[{"change_type": "fact"}]) + cc._cache_loop("conv-3", fake_loop) + + out = asyncio.run(cc.end_conversation("conv-3")) + assert out == {"ended": True, "proposals": 1} + assert cc.get_loop("conv-3") is None + + def test_end_conversation_unknown_is_noop(self): + out = asyncio.run(cc.end_conversation("ghost")) + assert out == {"ended": True, "proposals": 0} + + +# --------------------------------------------------------------------------- +# list_conversations +# --------------------------------------------------------------------------- + + +class TestListConversations: + def test_maps_mongo_docs(self): + from datetime import datetime, timezone + + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + docs = [ + { + "conversation_id": "c1", + "status": "completed", + "turns": [{}, {}], + "created_at": now, + "updated_at": now, + } + ] + + cursor = MagicMock() + cursor.sort.return_value = cursor + cursor.limit.return_value = docs + coll = MagicMock() + coll.find.return_value = cursor + client = MagicMock() + client.get_collection.return_value = coll + + with patch("monitor_data.db.mongodb.get_mongodb_client", return_value=client): + out = cc.list_conversations("ent-1", limit=10) + + assert len(out) == 1 + assert out[0]["conversation_id"] == "c1" + assert out[0]["turn_count"] == 2 + coll.find.assert_called_once_with({"npc_ids": "ent-1"}) diff --git a/packages/ui/backend/tests/test_entities_crud.py b/packages/ui/backend/tests/test_entities_crud.py new file mode 100644 index 00000000..f26380be --- /dev/null +++ b/packages/ui/backend/tests/test_entities_crud.py @@ -0,0 +1,152 @@ +"""Contract tests for single-entity CRUD on the graph (M-36 / M-38). + +These exercise the FastAPI surface with the underlying Neo4j tools patched, +mirroring the ``test_universes.py`` style. A companion integration suite +(``test_entities_crud_integration.py``) drives the same endpoints against a +real Neo4j instance. +""" + +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from monitor_ui.main import app + +client = TestClient(app) + +ENTITIES_TOOLS = "monitor_data.tools.neo4j_tools.entities" + + +def _entity(**over): + """A stand-in EntityResponse exposing ``model_dump`` like the real schema.""" + data = { + "id": str(uuid4()), + "universe_id": str(uuid4()), + "name": "Mira", + "entity_type": "character", + "is_archetype": False, + "description": "An elven scout.", + "properties": {}, + "state_tags": [], + "canon_level": "canon", + "confidence": 1.0, + "authority": "gm", + } + data.update(over) + return SimpleNamespace( + is_archetype=data["is_archetype"], + state_tags=data["state_tags"], + model_dump=lambda mode=None: data, + ) + + +# ── create (M-38) ──────────────────────────────────────────────── + + +def test_create_entity_returns_201(): + uni = str(uuid4()) + with patch(f"{ENTITIES_TOOLS}.neo4j_create_entity") as mock_create: + mock_create.return_value = _entity(universe_id=uni, name="Iron Brotherhood", + entity_type="faction") + resp = client.post( + "/api/entities/entities", + json={"universe_id": uni, "name": "Iron Brotherhood", + "entity_type": "faction", "description": "A mercenary order."}, + ) + assert resp.status_code == 201 + assert resp.json()["name"] == "Iron Brotherhood" + assert mock_create.called + + +def test_create_entity_rejects_bad_type(): + resp = client.post( + "/api/entities/entities", + json={"universe_id": str(uuid4()), "name": "X", "entity_type": "wizardish"}, + ) + assert resp.status_code == 422 + + +# ── read (M-36) ────────────────────────────────────────────────── + + +def test_get_entity_found(): + eid = str(uuid4()) + with patch(f"{ENTITIES_TOOLS}.neo4j_get_entity") as mock_get: + mock_get.return_value = _entity(id=eid) + resp = client.get(f"/api/entities/entities/{eid}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Mira" + + +def test_get_entity_404(): + with patch(f"{ENTITIES_TOOLS}.neo4j_get_entity") as mock_get: + mock_get.return_value = None + resp = client.get(f"/api/entities/entities/{uuid4()}") + assert resp.status_code == 404 + + +def test_get_entity_bad_uuid(): + resp = client.get("/api/entities/entities/not-a-uuid") + assert resp.status_code == 400 + + +# ── update (M-36) ──────────────────────────────────────────────── + + +def test_patch_entity_fields(): + eid = str(uuid4()) + with patch(f"{ENTITIES_TOOLS}.neo4j_get_entity") as mock_get, patch( + f"{ENTITIES_TOOLS}.neo4j_update_entity" + ) as mock_update: + mock_get.return_value = _entity(id=eid) + mock_update.return_value = _entity(id=eid, description="A grizzled veteran scout.") + resp = client.patch( + f"/api/entities/entities/{eid}", + json={"description": "A grizzled veteran scout."}, + ) + assert resp.status_code == 200 + assert resp.json()["description"] == "A grizzled veteran scout." + # Only the field-update tool should fire when no tags are supplied. + mock_update.assert_called_once() + + +def test_patch_entity_tags_diffs_add_and_remove(): + eid = str(uuid4()) + with patch(f"{ENTITIES_TOOLS}.neo4j_get_entity") as mock_get, patch( + f"{ENTITIES_TOOLS}.neo4j_set_state_tags" + ) as mock_tags: + mock_get.return_value = _entity(id=eid, state_tags=["wounded", "hidden"]) + mock_tags.return_value = _entity(id=eid, state_tags=["hidden", "blessed"]) + resp = client.patch( + f"/api/entities/entities/{eid}", + json={"tags": ["hidden", "blessed"]}, + ) + assert resp.status_code == 200 + # diff of {hidden,blessed} vs {wounded,hidden} → add blessed, remove wounded + _eid_arg, params = mock_tags.call_args.args + assert params.add_tags == ["blessed"] + assert params.remove_tags == ["wounded"] + + +def test_patch_entity_404(): + with patch(f"{ENTITIES_TOOLS}.neo4j_get_entity") as mock_get: + mock_get.return_value = None + resp = client.patch( + f"/api/entities/entities/{uuid4()}", json={"name": "Nope"} + ) + assert resp.status_code == 404 + + +def test_patch_entity_noop_when_tags_unchanged(): + eid = str(uuid4()) + with patch(f"{ENTITIES_TOOLS}.neo4j_get_entity") as mock_get, patch( + f"{ENTITIES_TOOLS}.neo4j_set_state_tags" + ) as mock_tags: + mock_get.return_value = _entity(id=eid, state_tags=["hidden"]) + resp = client.patch( + f"/api/entities/entities/{eid}", json={"tags": ["hidden"]} + ) + assert resp.status_code == 200 + mock_tags.assert_not_called() diff --git a/packages/ui/backend/tests/test_entities_crud_integration.py b/packages/ui/backend/tests/test_entities_crud_integration.py new file mode 100644 index 00000000..acb3e4a5 --- /dev/null +++ b/packages/ui/backend/tests/test_entities_crud_integration.py @@ -0,0 +1,132 @@ +"""Real-usage integration tests for graph entity CRUD (M-36 / M-38). + +Drives the live FastAPI endpoints against a **real Neo4j** — no mocks. Each run +provisions its own multiverse/universe via the data-layer tools, performs the +create → read → edit (fields + tags) → read-back round-trip through HTTP, then +tears everything down. + +Gated behind ``RUN_INTEGRATION=1`` (see ``pytest.ini`` / root ``conftest.py``). +""" + +from __future__ import annotations + +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from monitor_ui.main import app + +pytestmark = [pytest.mark.integration, pytest.mark.e2e] + +client = TestClient(app) + + +@pytest.fixture() +def universe_id(): + """Provision an isolated universe in Neo4j and tear it down after.""" + from monitor_data.schemas.universe import MultiverseCreate, UniverseCreate + from monitor_data.tools.neo4j_tools import ( + neo4j_create_multiverse, + neo4j_create_universe, + neo4j_delete_multiverse, + neo4j_ensure_omniverse, + ) + + omni = neo4j_ensure_omniverse() + mv = neo4j_create_multiverse( + MultiverseCreate( + omniverse_id=UUID(omni["omniverse_id"]), + name="CRUD Test Multiverse", + system_name="generic", + description="ephemeral test fixture", + is_template=False, + source_document_id=None, + parent_multiverse_id=None, + ) + ) + universe = neo4j_create_universe( + UniverseCreate( + multiverse_id=mv.id, + name="CRUD Test Universe", + description="ephemeral", + ) + ) + yield str(universe.id) + + # Cascade delete the multiverse (removes universe + entities). + try: + neo4j_delete_multiverse(mv.id) + except Exception: # noqa: BLE001 — best-effort teardown + pass + + +def test_entity_crud_roundtrip(universe_id): + # ── create (M-38) ─────────────────────────────────────────── + create = client.post( + "/api/entities/entities", + json={ + "universe_id": universe_id, + "name": "Mira", + "entity_type": "character", + "description": "An elven scout.", + }, + ) + assert create.status_code == 201, create.text + eid = create.json()["id"] + + # ── read (M-36) ───────────────────────────────────────────── + got = client.get(f"/api/entities/entities/{eid}") + assert got.status_code == 200 + assert got.json()["name"] == "Mira" + + # ── update fields (M-36) ──────────────────────────────────── + patched = client.patch( + f"/api/entities/entities/{eid}", + json={"description": "A grizzled veteran scout.", + "properties": {"allegiance": "Iron Brotherhood"}}, + ) + assert patched.status_code == 200, patched.text + + # ── update tags (M-36) ────────────────────────────────────── + tagged = client.patch( + f"/api/entities/entities/{eid}", json={"tags": ["wounded", "hidden"]} + ) + assert tagged.status_code == 200, tagged.text + + # ── read-back: edits persisted to canon ───────────────────── + final = client.get(f"/api/entities/entities/{eid}").json() + assert final["description"] == "A grizzled veteran scout." + assert final["properties"].get("allegiance") == "Iron Brotherhood" + assert set(final["state_tags"]) == {"wounded", "hidden"} + + # ── tag diff: drop "wounded", add "blessed" ───────────────── + client.patch(f"/api/entities/entities/{eid}", json={"tags": ["hidden", "blessed"]}) + after = client.get(f"/api/entities/entities/{eid}").json() + assert set(after["state_tags"]) == {"hidden", "blessed"} + + +def test_edge_roundtrip(universe_id): + """M-37: draw a relationship between two real entities and read it back.""" + a = client.post( + "/api/entities/entities", + json={"universe_id": universe_id, "name": "Mira", "entity_type": "character"}, + ).json()["id"] + b = client.post( + "/api/entities/entities", + json={"universe_id": universe_id, "name": "Iron Brotherhood", + "entity_type": "faction"}, + ).json()["id"] + + edge = client.post( + "/api/entities/entities/edges", + json={"from_id": a, "to_id": b, "rel_type": "MEMBER_OF"}, + ) + assert edge.status_code == 201, edge.text + assert edge.json()["rel_type"] == "MEMBER_OF" + assert edge.json()["category"] == "membership" + + listed = client.get(f"/api/entities/entities/{a}/edges") + assert listed.status_code == 200 + rels = listed.json()["relationships"] + assert any(r["to_entity_id"] == b and r["rel_type"] == "MEMBER_OF" for r in rels) diff --git a/packages/ui/backend/tests/test_relationships.py b/packages/ui/backend/tests/test_relationships.py new file mode 100644 index 00000000..0d50680a --- /dev/null +++ b/packages/ui/backend/tests/test_relationships.py @@ -0,0 +1,91 @@ +"""Contract tests for inline graph-edge creation (M-37). + +FastAPI surface with the Neo4j relationship tools patched. A companion +integration test drives the same endpoints against real Neo4j. +""" + +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from monitor_ui.main import app + +client = TestClient(app) + +REL_TOOLS = "monitor_data.tools.neo4j_tools.relationships" + + +def _rel(**over): + data = { + "relationship_id": "5:abc:1", + "from_entity_id": str(uuid4()), + "to_entity_id": str(uuid4()), + "rel_type": "ALLIED_WITH", + "category": "social", + "subcategory": None, + "properties": {}, + "tags": [], + "created_at": None, + } + data.update(over) + return SimpleNamespace(model_dump=lambda mode=None: data) + + +def test_create_edge_infers_category_from_rel_type(): + a, b = str(uuid4()), str(uuid4()) + with patch(f"{REL_TOOLS}.neo4j_create_relationship") as mock_create: + mock_create.return_value = _rel(from_entity_id=a, to_entity_id=b) + resp = client.post( + "/api/entities/entities/edges", + json={"from_id": a, "to_id": b, "rel_type": "ALLIED_WITH"}, + ) + assert resp.status_code == 201, resp.text + # category inferred → "social" for ALLIED_WITH + sent = mock_create.call_args.args[0] + assert sent.category.value == "social" + assert sent.rel_type.value == "ALLIED_WITH" + + +def test_create_edge_rejects_unknown_rel_type(): + resp = client.post( + "/api/entities/entities/edges", + json={"from_id": str(uuid4()), "to_id": str(uuid4()), "rel_type": "BEFRIENDS"}, + ) + assert resp.status_code == 422 + + +def test_create_edge_propagates_missing_entity_as_400(): + with patch(f"{REL_TOOLS}.neo4j_create_relationship") as mock_create: + mock_create.side_effect = ValueError("Entity not found") + resp = client.post( + "/api/entities/entities/edges", + json={"from_id": str(uuid4()), "to_id": str(uuid4()), "rel_type": "KNOWS"}, + ) + assert resp.status_code == 400 + + +def test_list_edges_returns_relationships(): + eid = str(uuid4()) + with patch(f"{REL_TOOLS}.neo4j_list_relationships") as mock_list: + mock_list.return_value = SimpleNamespace( + model_dump=lambda mode=None: { + "relationships": [_rel().model_dump()], + "total": 1, + "limit": 200, + "offset": 0, + } + ) + resp = client.get(f"/api/entities/entities/{eid}/edges") + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + # entity filter wired through, both directions + sent = mock_list.call_args.args[0] + assert str(sent.entity_id) == eid + assert sent.direction.value == "both" + + +def test_list_edges_bad_uuid(): + resp = client.get("/api/entities/entities/not-a-uuid/edges") + assert resp.status_code == 400 diff --git a/packages/ui/frontend/src/app/characters/page.tsx b/packages/ui/frontend/src/app/characters/page.tsx new file mode 100644 index 00000000..f3fba341 --- /dev/null +++ b/packages/ui/frontend/src/app/characters/page.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { + Users, + Plus, + Upload, + MessageCircle, + Sparkles, + Trash2, + Save, + Download, + Brain, + Loader2, +} from "lucide-react"; +import { entitiesApi } from "@/lib/api"; +import type { StandaloneCharacter } from "@/lib/types"; +import { ENTITY_KEYS } from "@/lib/query-keys"; +import { DialogShell, DialogFooter } from "@/components/DialogShell"; +import { useNotify } from "@/components/NotificationProvider"; +import { CharacterChat } from "@/components/characters/CharacterChat"; +import { cn } from "@/lib/utils"; + +export default function CharactersPage() { + const qc = useQueryClient(); + const { notify } = useNotify(); + const [selectedId, setSelectedId] = useState(null); + const [chatting, setChatting] = useState(false); + const [creating, setCreating] = useState(false); + + const { data: characters = [], isLoading } = useQuery({ + queryKey: ENTITY_KEYS.standaloneCharacters(), + queryFn: () => entitiesApi.listStandaloneCharacters({ limit: 100 }), + }); + + const selected = characters.find((c) => c.id === selectedId) ?? null; + + const importCard = useMutation({ + mutationFn: (file: File) => entitiesApi.importCharacterCard(file), + onSuccess: (ch) => { + qc.invalidateQueries({ queryKey: ENTITY_KEYS.standaloneCharacters() }); + setSelectedId(ch.id); + notify("success", `Imported ${ch.name}`); + }, + onError: (e: any) => notify("error", `Import failed: ${e.message ?? e}`), + }); + + if (chatting && selected) { + return ( +
+ setChatting(false)} /> +
+ ); + } + + return ( +
+ {/* Roster */} +
+
+ +
+

Characters

+

Roster & conversatory — chat with MONITOR-backed characters

+
+ + +
+ +
+ {isLoading ? ( +
+ Loading roster… +
+ ) : characters.length === 0 ? ( + setCreating(true)} /> + ) : ( +
+ {characters.map((c) => ( + setSelectedId(c.id)} + onChat={() => { + setSelectedId(c.id); + setChatting(true); + }} + /> + ))} +
+ )} +
+
+ + {/* Detail */} + {selected && ( + setChatting(true)} + onClose={() => setSelectedId(null)} + /> + )} + + {creating && ( + setCreating(false)} + onCreated={(ch) => { + setCreating(false); + setSelectedId(ch.id); + }} + /> + )} +
+ ); +} + +function RosterCard({ + character, + active, + onSelect, + onChat, +}: { + character: StandaloneCharacter; + active: boolean; + onSelect: () => void; + onChat: () => void; +}) { + return ( + +
+
+ {character.name.slice(0, 2).toUpperCase()} +
+
+
{character.name}
+
{character.description || "No description"}
+
+
+
+ + {character.entity_id ? "MONITOR" : "Light card"} + + +
+
+ ); +} + +function CharacterDetail({ + character, + onChat, + onClose, +}: { + character: StandaloneCharacter; + onChat: () => void; + onClose: () => void; +}) { + const qc = useQueryClient(); + const { notify } = useNotify(); + const [form, setForm] = useState({ + name: character.name, + description: character.description, + personality: character.personality, + gm_notes: character.gm_notes, + first_message: character.first_message, + }); + const dirty = + form.name !== character.name || + form.description !== character.description || + form.personality !== character.personality || + form.gm_notes !== character.gm_notes || + form.first_message !== character.first_message; + + const save = useMutation({ + mutationFn: () => entitiesApi.updateStandaloneCharacter(character.id, form), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ENTITY_KEYS.standaloneCharacters() }); + notify("success", "Saved"); + }, + onError: (e: any) => notify("error", `Save failed: ${e.message ?? e}`), + }); + + const expand = useMutation({ + mutationFn: () => entitiesApi.expandCharacter(character.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ENTITY_KEYS.standaloneCharacters() }); + notify("success", "Expanded into a MONITOR profile"); + }, + onError: (e: any) => notify("error", `Expansion failed: ${e.message ?? e}`), + }); + + const del = useMutation({ + mutationFn: () => entitiesApi.deleteStandaloneCharacter(character.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ENTITY_KEYS.standaloneCharacters() }); + notify("success", "Deleted"); + onClose(); + }, + onError: (e: any) => notify("error", `Delete failed: ${e.message ?? e}`), + }); + + return ( +