# Design — graphiti-neo4j-finalize ## Overview **Purpose**: This feature finalises the Zep → Graphiti/Neo4j migration so that a fresh checkout following the README works end-to-end with the documented default LLM provider (Qwen via Dashscope), without regressing the existing Gemini path. It closes two functional gaps left by commit `6264828`: (a) `docker compose up -d` cannot bring up the stack because Neo4j is missing from `docker-compose.yml`, and (b) Step 1 of the pipeline cannot succeed for Qwen because the Graphiti adapter is hard-wired to Gemini for both the LLM client and the embedder. **Users**: Developers cloning the repo for the first time (Docker path), operators running with Qwen/Dashscope (the documented default), and existing Gemini operators who must keep working unchanged after a one-line `.env` opt-in. **Impact**: Adds a `neo4j` service to Compose; introduces a `GRAPHITI_LLM_PROVIDER` switch in `Config`; decouples embedder credentials from chat credentials; replaces the no-op Gemini-flavoured reranker with a provider-agnostic passthrough; refreshes `.env.example` to mirror the README. ### Goals - A fresh checkout configured with a Qwen `LLM_API_KEY` completes Step 1 (Graph Build) end-to-end via Docker. - `docker compose up -d` boots Neo4j + the Flask app together with no manual Neo4j install. - Existing Gemini operators keep working with a single `GRAPHITI_LLM_PROVIDER=gemini` opt-in. - The reranker code path is honest: either does work or doesn't claim to. - `.env.example` matches what the code reads; the README is unchanged (already correct). ### Non-Goals - Implementing a real per-provider reranker (deferred to a follow-up). - Pagination cleanup of `_NodeNamespace.get_by_graph_id` / `_EdgeNamespace.get_by_graph_id` (low priority, deferred). - Renaming `zep_*` files (tracked separately). - Migrating data from existing Zep Cloud deployments (project is local-only by design now). - Adding automated tests (steering rule: minimal pytest coverage by design; smoke test is manual). ## Boundary Commitments ### This Spec Owns - The Compose definition for the `neo4j` service and its wiring with `mirofish` (`docker-compose.yml`). - The Graphiti provider switch (`Config.GRAPHITI_LLM_PROVIDER`) and its default value. - The optional embedding-credentials fallback (`EMBEDDING_API_KEY`, `EMBEDDING_BASE_URL`). - The `_get_graphiti()` factory body in `backend/app/services/graphiti_adapter.py`. - The reranker layer (replace stub; remove ignored `reranker=` kwarg + caller usages). - `.env.example` content (best-effort under env-guard hook). ### Out of Boundary - Per-project `group_id` isolation logic (preserved unchanged). - The `Task` background-task model and `_recover_stuck_projects` recovery logic (verified to still work behind a healthchecked Neo4j; no changes). - Frontend code (no changes). - Simulation IPC (`services/simulation_ipc.py`) and CAMEL-OASIS subprocesses. - Retry / pagination logic outside the touched files. ### Allowed Dependencies - `graphiti-core>=0.3` (resolved 0.11.6) — already declared in `backend/pyproject.toml`. - `openai` SDK (already present, used by `LLMClient`). - Neo4j 5.x Community image — declared in `docker-compose.yml`. - `python-dotenv` — already used by `Config`. ### Revalidation Triggers - New `LLM_*` env var: forces `Config` and `.env.example` to update together. - Graphiti major version bump: forces re-verification of `OpenAIClient`/`OpenAIEmbedder`/`OpenAIRerankerClient` signatures. - Switching the default reranker: forces re-checking every search call site. ## Architecture ### Existing Architecture Analysis The system already has a clean adapter boundary: all graph reads/writes go through `backend/app/services/graphiti_adapter.py`. Feature code does not import `graphiti_core` or call Neo4j drivers directly (steering rule). The adapter is a singleton initialised on first use via `_get_graphiti()` (`graphiti_adapter.py:89-119`), with all async work running on a dedicated `graphiti-event-loop` thread to keep the Neo4j driver bound to one event loop. `Config` (`backend/app/config.py`) is the single source of truth for env-var reads. Graph isolation is enforced via `group_id` on every read and write. This design extends those patterns rather than introducing new ones. ### Architecture Pattern & Boundary Map ```mermaid graph LR subgraph "User-facing" ENV[".env / .env.example"] DC["docker-compose.yml"] end subgraph "Backend (Python)" CFG["Config
(config.py)"] ADP["GraphitiAdapter
(graphiti_adapter.py)"] TOOLS["zep_tools.py
oasis_profile_generator.py"] end subgraph "Graphiti (graphiti_core 0.11.6)" GC["Graphiti instance"] OAI_LLM["OpenAIClient (lazy)"] OAI_EMB["OpenAIEmbedder (lazy)"] GEM_LLM["GeminiClient (lazy)"] GEM_EMB["GeminiEmbedder (lazy)"] PASS["_PassthroughReranker"] end subgraph "External" NEO[("Neo4j 5
(Docker service)")] QWEN["Qwen / OpenAI / GLM
(OpenAI-compatible)"] GEM["Google Gemini API"] end DC -->|env_file| ENV ENV --> CFG CFG --> ADP TOOLS --> ADP ADP -->|"_get_graphiti()
branch on GRAPHITI_LLM_PROVIDER"| GC GC -.->|provider=openai| OAI_LLM GC -.->|provider=openai| OAI_EMB GC -.->|provider=gemini| GEM_LLM GC -.->|provider=gemini| GEM_EMB GC --> PASS GC --> NEO OAI_LLM --> QWEN OAI_EMB --> QWEN GEM_LLM --> GEM GEM_EMB --> GEM ``` **Architecture Integration**: - **Selected pattern**: Single-adapter façade with provider-strategy switch, encapsulated inside the existing `_get_graphiti()` factory. - **Domain/feature boundaries**: Adapter (`graphiti_adapter.py`) owns Graphiti construction; `Config` owns env-var reads; Compose owns runtime wiring; no domain bleed. - **Existing patterns preserved**: Singleton + persistent event loop, `group_id` isolation, single-source-of-truth `Config`, env-driven provider selection (matches the steering rule "new providers are integrated by changing `LLM_BASE_URL`/`LLM_MODEL_NAME`, not by adding a second SDK"). - **New components rationale**: `_PassthroughReranker` is a renamed replacement for `_GeminiReranker` — same shape, no provider dependency. No other new components. - **Steering compliance**: Single Python config file; lazy imports keep optional dependencies optional; backwards-compatible env vars. ### Technology Stack | Layer | Choice / Version | Role in Feature | Notes | |-------|------------------|-----------------|-------| | Frontend / CLI | n/a | not touched | | | Backend / Services | Python 3.11+, Flask 3.0 | Hosts `Config` and `GraphitiAdapter` | No new Python deps | | Data / Storage | Neo4j 5-community via `graphiti-core` 0.11.6 | Knowledge graph backend | Now containerised via Compose | | Messaging / Events | n/a | | | | Infrastructure / Runtime | Docker Compose v2 | Adds `neo4j` service + healthcheck + named volumes | Drops `version:` key (already absent today; we keep it absent) | ## File Structure Plan ### Directory Structure ``` . ├── docker-compose.yml # MODIFIED: add neo4j service + depends_on + NEO4J_URI override ├── .env.example # MODIFIED (best-effort): add NEO4J_*, EMBEDDING_*, GRAPHITI_LLM_PROVIDER └── backend/ └── app/ ├── config.py # MODIFIED: + GRAPHITI_LLM_PROVIDER, EMBEDDING_API_KEY, EMBEDDING_BASE_URL └── services/ ├── graphiti_adapter.py # MODIFIED: provider switch, passthrough reranker, drop ignored kwarg ├── zep_tools.py # MODIFIED: drop reranker="cross_encoder" arg (line 504) └── oasis_profile_generator.py # MODIFIED: drop reranker="rrf" args (lines 324, 349) ``` ### Modified Files - `docker-compose.yml` — add `neo4j:5-community` service with auth, healthcheck, named volumes, port 7474/7687; wire `mirofish` with `depends_on: { neo4j: { condition: service_healthy } }` and `environment: NEO4J_URI=bolt://neo4j:7687`. - `.env.example` — drop `ZEP_API_KEY` (or comment as deprecated); add `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`, `EMBEDDING_MODEL`, optional `GRAPHITI_LLM_PROVIDER`, optional `EMBEDDING_API_KEY`, optional `EMBEDDING_BASE_URL`. **Best-effort under `pre_tool_env_guard.sh`; if blocked, README documentation is the canonical surface (acceptable per Requirement 6 fallback).** - `backend/app/config.py` — add three new class attributes (`GRAPHITI_LLM_PROVIDER`, `EMBEDDING_API_KEY`, `EMBEDDING_BASE_URL`). Keep `ZEP_API_KEY` as today. - `backend/app/services/graphiti_adapter.py` — replace `_GeminiReranker` with `_PassthroughReranker` (no GeminiClient dep); branch in `_get_graphiti()` on `Config.GRAPHITI_LLM_PROVIDER`; lazy-import provider-specific Graphiti classes inside their branch; drop `reranker` kwarg from `_GraphNamespace.search`. Preserve persistent-event-loop and singleton patterns exactly. - `backend/app/services/zep_tools.py:504` — remove `reranker="cross_encoder"` from the `client.graph.search(...)` call. - `backend/app/services/oasis_profile_generator.py:324, :349` — remove `reranker="rrf"` from the two `client.graph.search(...)` calls. ## System Flows ### Flow 1: Adapter initialisation with provider switch ```mermaid flowchart TD Start([first .client access]) --> Lock["acquire _graphiti_lock"] Lock --> Cache{"_graphiti_instance
already set?"} Cache -->|yes| Return([return cached]) Cache -->|no| ReadCfg["read Config.GRAPHITI_LLM_PROVIDER
(default openai)"] ReadCfg --> Branch{"provider?"} Branch -->|openai| BuildOAI["lazy import OpenAIClient,
OpenAIEmbedder, OpenAIEmbedderConfig
build with LLM_* and EMBEDDING_* fallbacks"] Branch -->|gemini| BuildGem["lazy import GeminiClient,
GeminiEmbedder, GeminiEmbedderConfig
build with LLM_API_KEY + EMBEDDING_MODEL"] Branch -->|other| Raise["raise ValueError
'unknown GRAPHITI_LLM_PROVIDER=...'"] BuildOAI --> Build["construct Graphiti(...,
cross_encoder=_PassthroughReranker())"] BuildGem --> Build Build --> Indices["_run(g.build_indices_and_constraints())"] Indices --> Cache2["cache singleton; release lock"] Cache2 --> Return Raise --> Fail([startup error]) ``` **Decision points**: The provider read is one-shot at construction time. Re-reading mid-process is unnecessary because changing provider mid-run requires Neo4j re-init anyway. The passthrough reranker is always injected explicitly (never let Graphiti default to `OpenAIRerankerClient`). ### Flow 2: Compose boot order ```mermaid sequenceDiagram participant U as User participant C as docker compose participant N as neo4j container participant A as mirofish container U->>C: docker compose up -d C->>N: start neo4j service N->>N: cypher-shell healthcheck
(start_period 30s, retries 10) N-->>C: healthy C->>A: start mirofish (depends_on healthy) A->>A: _recover_stuck_projects()
connects to bolt://neo4j:7687 A-->>U: /health -> 200 ``` ## Requirements Traceability | Requirement | Summary | Components | Interfaces | Flows | |-------------|---------|------------|------------|-------| | 1.1 | `neo4j` service named, image `neo4j:5-community` | `docker-compose.yml` | Compose service | Flow 2 | | 1.2 | Expose 7474 + 7687 | `docker-compose.yml` | Compose ports | Flow 2 | | 1.3 | `NEO4J_AUTH` from `${NEO4J_PASSWORD:-mirofish123}` | `docker-compose.yml`, `.env.example` | Compose env | Flow 2 | | 1.4 | Named volumes for `/data` + `/logs` | `docker-compose.yml` | Compose volumes | Flow 2 | | 1.5 | Healthcheck via `cypher-shell` | `docker-compose.yml` | Compose healthcheck | Flow 2 | | 1.6 | `mirofish.depends_on.neo4j.condition: service_healthy` | `docker-compose.yml` | Compose depends_on | Flow 2 | | 1.7 | In-stack `NEO4J_URI=bolt://neo4j:7687` | `docker-compose.yml` | Compose environment override | — | | 1.8 | No top-level `version:` key | `docker-compose.yml` | — | — | | 1.9 | E2E graph build inside Docker | end-to-end smoke | — | manual | | 2.1 | Default `bolt://localhost:7687` | `Config.NEO4J_URI` | env var | — | | 2.2 | `npm run dev` regression-free | host-mode dev | — | manual | | 2.3 | Docker override scoped to compose only | `docker-compose.yml` | env var | — | | 3.1 | `Config.GRAPHITI_LLM_PROVIDER` exists | `Config` | class attr | — | | 3.2 | Default `openai` | `Config.GRAPHITI_LLM_PROVIDER` | class attr | — | | 3.3 | Branch builds OpenAI client | `_get_graphiti()` | Python | Flow 1 | | 3.4 | Branch builds Gemini client | `_get_graphiti()` | Python | Flow 1 | | 3.5 | Raise on unknown value | `_get_graphiti()` | Python | Flow 1 | | 3.6 | Qwen E2E pipeline | end-to-end smoke | — | manual | | 4.1 | OpenAI embedder branch | `_get_graphiti()` | Python | Flow 1 | | 4.2 | Gemini embedder branch | `_get_graphiti()` | Python | Flow 1 | | 4.3 | Lazy import per branch | `_get_graphiti()` | Python | Flow 1 | | 5.1 | `EMBEDDING_API_KEY` / `EMBEDDING_BASE_URL` exist | `Config` | class attr | — | | 5.2 | Fallback `LLM_API_KEY` | `Config.embedding_credentials()` helper | Python | — | | 5.3 | Fallback `LLM_BASE_URL` | `Config.embedding_credentials()` helper | Python | — | | 5.4 | Embedder uses embedding key | `_get_graphiti()` openai branch | Python | Flow 1 | | 5.5 | `EMBEDDING_MODEL` independent | `Config` (already true) | class attr | — | | 6.1 | `.env.example` has Neo4j vars | `.env.example` | doc | — | | 6.2 | `.env.example` has `EMBEDDING_MODEL` | `.env.example` | doc | — | | 6.3 | `.env.example` has new optional vars | `.env.example` | doc | — | | 6.4 | Embedder note for Qwen users | `.env.example` | doc | — | | 6.5 | Drop or comment `ZEP_API_KEY` | `.env.example` | doc | — | | 6.6 | No real secrets in example | `.env.example` | doc | — | | 6.7 | README and `.env.example` agree | both | doc | — | | 7.1 | Drop `_GeminiReranker` (rename) | `graphiti_adapter.py` | Python class | — | | 7.2 | Remove `reranker=` kwarg | `_GraphNamespace.search` | Python | — | | 7.3 | Drop `reranker="cross_encoder"` | `zep_tools.py:504` | Python | — | | 7.4 | All search paths return default-ranked | adapter + callers | Python | — | | 8.1 | Gemini path identical | `_get_graphiti()` gemini branch | Python | Flow 1 | | 8.2 | Default = openai | `Config.GRAPHITI_LLM_PROVIDER` | class attr | — | | 8.3 | No env vars removed | `Config` | class attr | — | | 9.1 | Qwen E2E completes | smoke | — | manual | | 9.2 | Graph data endpoint nonzero | smoke | — | manual | | 9.3 | Report tools nonzero | smoke | — | manual | | 9.4 | Provider error surfaces | adapter exception path | Python | Flow 1 | ## Components and Interfaces | Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts | |-----------|--------------|--------|--------------|--------------------------|-----------| | `docker-compose.yml` (extended) | Infra | Containerise full stack | 1.1–1.9, 2.3 | Neo4j 5-community (P0) | Service | | `Config` (extended) | Backend / config | Single source of truth for env reads | 3.1, 3.2, 5.1, 5.2, 5.3, 5.5, 8.3 | `python-dotenv` (P0) | State | | `_PassthroughReranker` | Backend / adapter | Provider-agnostic no-op cross-encoder | 7.1, 7.4 | `graphiti_core.cross_encoder.client.CrossEncoderClient` (P0) | Service | | `_get_graphiti()` (refactored) | Backend / adapter | Build singleton Graphiti from Config | 3.3, 3.4, 3.5, 4.1, 4.2, 4.3, 5.4, 8.1, 9.4 | `graphiti_core` (P0), `Config` (P0) | Service | | `_GraphNamespace.search` (cleaned) | Backend / adapter | Honest signature; no dead kwarg | 7.2 | `Graphiti.search`, `Graphiti.search_` (P0) | Service | | Search callers (zep_tools, oasis_profile_generator) | Backend / services | Drop ignored `reranker=` kwarg | 7.3 | `_GraphNamespace.search` (P0) | Service | ### Backend / Configuration #### `Config` (extended) | Field | Detail | |-------|--------| | Intent | Add three new env-var-driven class attributes; preserve all existing ones. | | Requirements | 3.1, 3.2, 5.1, 5.2, 5.3, 5.5, 8.3 | **Responsibilities & Constraints** - Reads env vars at module import time via `python-dotenv` (existing behaviour). - Provides typed defaults consistent with `README-EN.md` env section. - New attributes do not mutate or replace existing ones. **Dependencies** - Inbound: `graphiti_adapter._get_graphiti()` reads the new fields (P0). - External: `python-dotenv` (P0). **Contracts**: State [x] ##### State Management ```python # Additions to backend/app/config.py — Config class GRAPHITI_LLM_PROVIDER: str = os.environ.get("GRAPHITI_LLM_PROVIDER", "openai") """Provider for Graphiti's LLM client and embedder. Allowed: 'openai', 'gemini'.""" EMBEDDING_API_KEY: Optional[str] = os.environ.get("EMBEDDING_API_KEY") """Optional override for the embedder's API key. Falls back to LLM_API_KEY.""" EMBEDDING_BASE_URL: Optional[str] = os.environ.get("EMBEDDING_BASE_URL") """Optional override for the embedder's base URL. Falls back to LLM_BASE_URL.""" ``` - **State model**: Plain Python class attributes; immutable after import. Same pattern as the rest of `Config`. - **Persistence & consistency**: None — read-only. - **Concurrency strategy**: Read-only after import; safe. **Implementation Notes** - Integration: Adapter resolves `EMBEDDING_API_KEY or LLM_API_KEY`, `EMBEDDING_BASE_URL or LLM_BASE_URL` at the openai-branch construction site. - Validation: `_get_graphiti()` validates `GRAPHITI_LLM_PROVIDER` against `{"openai", "gemini"}` and raises `ValueError` on unknown. - Risks: Existing Gemini deployments default to `openai`. Documented in `.env.example` and PR description. ### Backend / Adapter #### `_PassthroughReranker` | Field | Detail | |-------|--------| | Intent | Replace `_GeminiReranker` with a provider-agnostic no-op so Graphiti doesn't fall back to its OpenAI-only default. | | Requirements | 7.1, 7.4 | **Responsibilities & Constraints** - Conform to `graphiti_core.cross_encoder.client.CrossEncoderClient`. - Return passages in the order Graphiti supplied them, with synthetic descending scores so any consumer that sorts on score still gets stable order. - Hold no state; no provider dependency; thread-safe. **Dependencies** - External: `graphiti_core.cross_encoder.client.CrossEncoderClient` (P0). **Contracts**: Service [x] ##### Service Interface ```python class _PassthroughReranker(CrossEncoderClient): """No-op reranker — preserves input order and avoids provider lock-in. Graphiti's default cross-encoder is `OpenAIRerankerClient` with a hard-coded `gpt-4.1-nano` model that requires logprobs from OpenAI directly. That default 401s for Qwen / Dashscope users, so we inject this explicit passthrough. """ async def rank(self, query: str, passages: list[str]) -> list[tuple[str, float]]: ... ``` - Preconditions: `passages` is a (possibly empty) list of strings. - Postconditions: returns `[(passages[i], 1.0 - 0.01 * i) for i in range(len(passages))]`. - Invariants: order preservation; deterministic. **Implementation Notes** - Integration: Always injected by `_get_graphiti()` regardless of provider. - Validation: None. - Risks: Search results are still un-reranked. Same behaviour as today; future ticket may introduce a real per-provider reranker. #### `_get_graphiti()` (refactored) | Field | Detail | |-------|--------| | Intent | Construct the singleton `Graphiti` instance using the configured provider. | | Requirements | 3.3, 3.4, 3.5, 4.1, 4.2, 4.3, 5.4, 8.1, 9.4 | **Responsibilities & Constraints** - Singleton via double-checked locking (`_graphiti_lock`) — preserved exactly. - Read provider from `Config.GRAPHITI_LLM_PROVIDER` once. - Lazy-import provider-specific classes inside their branch. - Always inject `_PassthroughReranker()` for `cross_encoder`. - Run `g.build_indices_and_constraints()` on the persistent loop — preserved exactly. **Dependencies** - External: `graphiti_core.Graphiti`, `graphiti_core.llm_client.config.LLMConfig` (P0). - External (openai branch): `graphiti_core.llm_client.openai_client.OpenAIClient`, `graphiti_core.embedder.openai.OpenAIEmbedder`, `graphiti_core.embedder.openai.OpenAIEmbedderConfig` (P0). - External (gemini branch): `graphiti_core.llm_client.gemini_client.GeminiClient`, `graphiti_core.embedder.gemini.GeminiEmbedder`, `graphiti_core.embedder.gemini.GeminiEmbedderConfig` (P0). - Internal: `Config` (P0), `_PassthroughReranker` (P0). **Contracts**: Service [x] ##### Service Interface ```python def _get_graphiti() -> Graphiti: """Lazily construct the singleton Graphiti instance. Reads Config.GRAPHITI_LLM_PROVIDER and branches between OpenAI- compatible and Gemini implementations. Provider-specific classes are imported inside their branch so a missing optional dep on one provider does not break the other. Raises: ValueError: if GRAPHITI_LLM_PROVIDER is not one of {'openai', 'gemini'}. """ ``` - **Preconditions**: `Config.LLM_API_KEY`, `Config.NEO4J_*` set; `Config.GRAPHITI_LLM_PROVIDER` ∈ `{"openai", "gemini"}`. - **Postconditions**: returns a built and indexed `Graphiti` instance bound to the persistent event loop. - **Invariants**: Idempotent; one Graphiti per process; thread-safe under double-checked lock. **Branch logic (illustrative)**: ```python provider = (Config.GRAPHITI_LLM_PROVIDER or "openai").lower() if provider == "openai": from graphiti_core.llm_client.openai_client import OpenAIClient from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig llm_client = OpenAIClient( config=LLMConfig( api_key=Config.LLM_API_KEY, base_url=Config.LLM_BASE_URL, model=Config.LLM_MODEL_NAME, ) ) embedder = OpenAIEmbedder( config=OpenAIEmbedderConfig( api_key=Config.EMBEDDING_API_KEY or Config.LLM_API_KEY, base_url=Config.EMBEDDING_BASE_URL or Config.LLM_BASE_URL, embedding_model=Config.EMBEDDING_MODEL, ) ) elif provider == "gemini": from graphiti_core.llm_client.gemini_client import GeminiClient from graphiti_core.embedder.gemini import GeminiEmbedder, GeminiEmbedderConfig llm_client = GeminiClient( config=LLMConfig(api_key=Config.LLM_API_KEY, model=Config.LLM_MODEL_NAME) ) embedder = GeminiEmbedder( config=GeminiEmbedderConfig( api_key=Config.LLM_API_KEY, embedding_model=Config.EMBEDDING_MODEL, ) ) else: raise ValueError( f"Unknown GRAPHITI_LLM_PROVIDER={provider!r}; allowed: 'openai', 'gemini'" ) g = Graphiti( Config.NEO4J_URI, Config.NEO4J_USER, Config.NEO4J_PASSWORD, llm_client=llm_client, embedder=embedder, cross_encoder=_PassthroughReranker(), ) ``` **Implementation Notes** - Integration: No behavioural change for existing Gemini deployments that opt in via `GRAPHITI_LLM_PROVIDER=gemini`. Default flips to OpenAI-compatible, matching documented default provider. - Validation: Single `if/elif/else` raise; the error includes the offending value and the allowed set (Requirement 3.5, 9.4). - Risks: Lazy imports rely on `graphiti_core` shipping both client modules. Confirmed in 0.11.6. #### `_GraphNamespace.search` (cleaned) | Field | Detail | |-------|--------| | Intent | Drop the misleading `reranker` kwarg the adapter accepted but ignored. | | Requirements | 7.2 | **Responsibilities & Constraints** - Same query-dispatch logic as today. - New signature drops `reranker: Optional[str] = None`. **Dependencies** - Inbound: `zep_tools.py`, `oasis_profile_generator.py`, `graph_builder.py`, `report_agent` tools. - External: `graphiti_core.Graphiti.search`, `Graphiti.search_` (P0). **Contracts**: Service [x] ##### Service Interface ```python def search( self, graph_id: str, query: str, limit: int = 10, scope: str = "edges", ) -> _SearchResults: """Semantic search over the per-project graph. scope='edges'|'nodes'|'both'.""" ``` - Preconditions: caller no longer passes `reranker=...`. **Breaking change** for the three known call sites; they are updated in this spec. - Postconditions: identical behaviour to today (the dropped kwarg was already ignored). - Invariants: `group_id` isolation enforced by Graphiti via `group_ids=[graph_id]`. **Implementation Notes** - Integration: Three call sites updated in this PR (`zep_tools.py:504`, `oasis_profile_generator.py:324, :349`). A `Grep` for `\.graph\.search\(` will confirm no other callers slipped in. - Validation: Caller breakage will surface immediately as `TypeError: unexpected keyword argument 'reranker'` — caught at first request. - Risks: External callers that import this adapter would also need updating. None known; the adapter is internal. ### Infrastructure #### `docker-compose.yml` (extended) | Field | Detail | |-------|--------| | Intent | Bring up Neo4j + the app together with healthchecked startup ordering. | | Requirements | 1.1–1.9, 2.3 | **Compose definition (illustrative)**: ```yaml services: neo4j: image: neo4j:5-community container_name: mirofish-neo4j environment: NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-mirofish123} ports: - "7474:7474" - "7687:7687" volumes: - neo4j-data:/data - neo4j-logs:/logs healthcheck: test: ["CMD-SHELL", "cypher-shell -u neo4j -p ${NEO4J_PASSWORD:-mirofish123} 'RETURN 1' || exit 1"] interval: 10s timeout: 5s retries: 10 start_period: 30s restart: unless-stopped mirofish: image: ghcr.io/666ghj/mirofish:latest container_name: mirofish env_file: - .env environment: NEO4J_URI: bolt://neo4j:7687 depends_on: neo4j: condition: service_healthy ports: - "3000:3000" - "5001:5001" restart: unless-stopped volumes: - ./backend/uploads:/app/backend/uploads volumes: neo4j-data: neo4j-logs: ``` - Preconditions: `.env` exists at repo root; `NEO4J_PASSWORD` set there or default substitution applies. - Postconditions: `docker compose up -d` exits with both services healthy; `mirofish` reaches Bolt at `bolt://neo4j:7687` once `neo4j` is healthy. - Invariants: No top-level `version:` key (Compose v2). Bolt healthcheck → no race against driver init. **Implementation Notes** - Integration: Existing kept-alive image reference and volumes preserved. Comment block above `mirofish` is preserved in Chinese (steering rule: don't translate existing Chinese comments). - Validation: `docker compose config` must parse cleanly. `docker compose up -d` then `docker compose ps` shows both `running` (and `neo4j` `healthy`). - Risks: Default password (`mirofish123`) is intentionally weak for local dev. README already calls this out. Production deployments override via `.env`. ### Documentation #### `.env.example` (best-effort) | Field | Detail | |-------|--------| | Intent | Mirror the README env section so a `cp .env.example .env` produces a working config. | | Requirements | 6.1–6.7 | **Target content (compact)**: ```env # LLM (OpenAI-SDK-compatible — Qwen via Dashscope is the default) LLM_API_KEY= LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_MODEL_NAME=qwen-plus # Optional: pick the Graphiti provider explicitly. 'openai' is the # default and works for any OpenAI-compatible endpoint (Qwen, GLM, # OpenAI itself). Set to 'gemini' to use Google Gemini directly. # GRAPHITI_LLM_PROVIDER=openai # Optional: dedicated embedder credentials. Default to LLM_* values. # Useful when chat is Dashscope/Qwen (no OpenAI-compatible embeddings) # but you want to point the embedder at OpenAI directly. # EMBEDDING_API_KEY= # EMBEDDING_BASE_URL= EMBEDDING_MODEL=text-embedding-3-small # Knowledge graph — Neo4j (default works for both Docker and host modes) NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=mirofish123 # Optional: accelerated LLM for high-volume calls (omit if not used) # LLM_BOOST_API_KEY= # LLM_BOOST_BASE_URL= # LLM_BOOST_MODEL_NAME= # Deprecated — kept for backwards compatibility only ZEP_API_KEY= ``` - Preconditions: file is writeable. (`pre_tool_env_guard.sh` may block.) - Postconditions: `cp .env.example .env` + filling in `LLM_API_KEY` produces a working stack. - Invariants: No real secret values. New vars are commented when optional, uncommented when required. **Implementation Notes** - Integration: README is already correct (`README-EN.md:154-167`); `.env.example` was the surface that drifted. - Validation: Diff against README. If env-guard blocks the Write, fall back to documenting in README only and note in PR description. - Risks: Hook block. Mitigation in `research.md` Decision: ".env.example fallback path". ## Data Models No data-model changes. The `Entity` / `RELATES_TO` Cypher schema, the `_NodeResult`/`_EdgeResult`/`_SearchResults` adapter dataclasses, and the in-memory `Project` / `Task` models are all preserved. ## Error Handling ### Error Strategy - **Misconfiguration (boot)**: `_get_graphiti()` raises `ValueError` immediately if `GRAPHITI_LLM_PROVIDER` is unrecognised. The adapter singleton is built lazily on first use, so the error propagates to the first request and is logged via the existing `mirofish.graphiti_adapter` logger. - **Provider auth failure (runtime)**: Errors from the OpenAI / Gemini clients propagate up the existing `_run(coro)` path; the existing `try/except` in `_GraphNamespace.search` (`graphiti_adapter.py:462-464`) downgrades to an empty `_SearchResults` and logs at `WARNING`. Same behaviour as today — no change. - **Neo4j unreachable (boot)**: `Graphiti(...)` driver init fails; the existing `_recover_stuck_projects` startup hook already wraps this in `try/except` (`backend/app/__init__.py:91-104`). No change required. ### Error Categories and Responses - **User Errors**: Wrong `GRAPHITI_LLM_PROVIDER` → `ValueError` with offending value and allowed set. Wrong API key → 401 from provider, surfaced at first call. - **System Errors**: Neo4j down → driver error caught at startup; per-request errors flow through existing handlers. - **Business Logic Errors**: None new. ### Monitoring - Existing `mirofish.graphiti_adapter` logger covers init + search paths. No new metrics; matches steering's "minimal observability" posture. ## Testing Strategy Per steering rule "pytest coverage is intentionally minimal," no new automated tests are added. Acceptance is structural review + manual smoke: ### Manual smoke checklist (Requirement 9) 1. **Compose boot (no LLM keys needed)**: - `docker compose up -d` - Wait for `docker compose ps` to show both services as running and `neo4j` healthy. - `curl localhost:5001/health` → `{"status":"ok"}`. 2. **Neo4j connectivity**: - `docker compose exec neo4j cypher-shell -u neo4j -p mirofish123 'RETURN 1'` returns `1`. 3. **Provider switch unit-style sanity**: - With `GRAPHITI_LLM_PROVIDER=invalid` and an LLM key set, hit `/api/graph/build` and expect a `ValueError` in logs naming the offending value. 4. **Qwen pipeline (with real keys, run by ticket reviewer)**: - Set `LLM_API_KEY=`, `EMBEDDING_API_KEY=` in `.env`. - Upload a ~1 KB `.txt`, run ontology generation, run graph build. Confirm completion and >0 nodes/edges. 5. **Gemini regression (with Gemini key)**: - Set `GRAPHITI_LLM_PROVIDER=gemini`, `LLM_API_KEY=`, `EMBEDDING_MODEL=text-embedding-004`. - Same upload+build flow. Confirm completion. ### Verification of "no regression" - Grep for `reranker=` after edits — must return zero hits in `backend/app/services/`. - Grep for `_GeminiReranker` — zero hits after rename. - `docker compose config` parses cleanly. ## Migration Strategy ```mermaid flowchart LR Start([existing deployment]) --> Type{provider?} Type -->|"Gemini (was working)"| AddVar["add GRAPHITI_LLM_PROVIDER=gemini
to .env"] Type -->|"Qwen / OpenAI / GLM
(was broken)"| Default["accept new default
(GRAPHITI_LLM_PROVIDER=openai)"] Type -->|"Qwen + OpenAI embedder"| Both["add GRAPHITI_LLM_PROVIDER=openai
+ EMBEDDING_API_KEY/BASE_URL"] AddVar --> Restart["restart backend"] Default --> Restart Both --> Restart Restart --> Verify["smoke: /api/graph/build"] ``` - **Phase**: One-step migration; no schema migration; no data movement. - **Rollback trigger**: If a Gemini deployment fails to migrate, revert `.env` (remove `GRAPHITI_LLM_PROVIDER`) — but it would default to `openai`, which is the bug being fixed for everyone else. The cleaner rollback is to pin to the previous git SHA. Documented in PR description. - **Validation checkpoint**: `/api/graph/build` against a small `.txt` is the canonical smoke. ## Supporting References (Optional) Detailed investigation notes (Graphiti class signatures, env-guard hook scope, healthcheck choice) live in `research.md`. Trade-off rationale for "passthrough vs. drop-the-stub" is captured under `research.md` → "Design Decisions → Replace `_GeminiReranker` with `_PassthroughReranker`".