MicroFish/.kiro/specs/graphiti-neo4j-finalize/design.md

660 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<br/>(config.py)"]
ADP["GraphitiAdapter<br/>(graphiti_adapter.py)"]
TOOLS["zep_tools.py<br/>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<br/>(Docker service)")]
QWEN["Qwen / OpenAI / GLM<br/>(OpenAI-compatible)"]
GEM["Google Gemini API"]
end
DC -->|env_file| ENV
ENV --> CFG
CFG --> ADP
TOOLS --> ADP
ADP -->|"_get_graphiti()<br/>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<br/>already set?"}
Cache -->|yes| Return([return cached])
Cache -->|no| ReadCfg["read Config.GRAPHITI_LLM_PROVIDER<br/>(default openai)"]
ReadCfg --> Branch{"provider?"}
Branch -->|openai| BuildOAI["lazy import OpenAIClient,<br/>OpenAIEmbedder, OpenAIEmbedderConfig<br/>build with LLM_* and EMBEDDING_* fallbacks"]
Branch -->|gemini| BuildGem["lazy import GeminiClient,<br/>GeminiEmbedder, GeminiEmbedderConfig<br/>build with LLM_API_KEY + EMBEDDING_MODEL"]
Branch -->|other| Raise["raise ValueError<br/>'unknown GRAPHITI_LLM_PROVIDER=...'"]
BuildOAI --> Build["construct Graphiti(...,<br/>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<br/>(start_period 30s, retries 10)
N-->>C: healthy
C->>A: start mirofish (depends_on healthy)
A->>A: _recover_stuck_projects()<br/>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.11.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.11.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.16.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=<qwen>`, `EMBEDDING_API_KEY=<openai>` 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=<gemini>`, `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<br/>to .env"]
Type -->|"Qwen / OpenAI / GLM<br/>(was broken)"| Default["accept new default<br/>(GRAPHITI_LLM_PROVIDER=openai)"]
Type -->|"Qwen + OpenAI embedder"| Both["add GRAPHITI_LLM_PROVIDER=openai<br/>+ 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`".