11 KiB
Gap Analysis — graphiti-neo4j-finalize
Current State Investigation
Domain assets touched by this spec
| Asset | Path | Purpose |
|---|---|---|
| Compose stack | docker-compose.yml |
Single-service mirofish container; no Neo4j; no version: key already (good). |
| Env example | .env.example |
Read-blocked by pre_tool_env_guard.sh — confirmed via _env_guard.py. Need to update via Bash with sudo override or document content based on README and config.py. |
| Configuration | backend/app/config.py:20-81 |
Single Python Config class. Holds LLM_*, NEO4J_*, EMBEDDING_MODEL, ZEP_API_KEY (deprecated). Has a validate() classmethod. |
| Graphiti adapter | backend/app/services/graphiti_adapter.py |
Hard-coded GeminiClient + GeminiEmbedder at lines 95–105; _GeminiReranker no-op stub at 40–51; _GraphNamespace.search at 434–464 accepts but ignores reranker= kwarg. |
| Search caller | backend/app/services/zep_tools.py:504 |
Passes reranker="cross_encoder" (ignored). Two oasis_profile_generator.py calls at lines 324 and 349 pass reranker="rrf" — same ignored kwarg. |
| Startup recovery | backend/app/__init__.py:86–104 |
_recover_stuck_projects calls into Graphiti at boot; needs Neo4j reachable. |
| README env section | README-EN.md:154-167 |
Already lists the correct env vars; .env.example is the surface that drifted. |
Conventions extracted
- Config single-source-of-truth: All env vars live on
Configinbackend/app/config.py. Steering rule: “Configuration is a single Python file. Prefer extending it over scattering env-var reads through the codebase.” - Graphiti dependency direction: All graph reads/writes go through the
graphiti_adapter; feature code does not importgraphiti_coreor Neo4j drivers directly. This keeps the provider switch local. - Persistent event loop pattern: The adapter runs all async Graphiti calls on a dedicated
graphiti-event-loopthread; the singleton lives behind_get_graphiti()with a double-checked lock. The provider switch must stay inside_get_graphiti()so it’s evaluated once per process. - No enforced linter/formatter: Match the surrounding file style; existing files mix English and Chinese comments — preserve both.
- Docker Compose v2 syntax: Steering rule §11 requires no
version:key. The currentdocker-compose.ymlalready complies. - Backwards compatibility for env:
ZEP_API_KEYis intentionally kept onConfigas deprecated; new optional vars should follow the same fallback-to-existing pattern.
External dependency surface (verified against the installed package cache)
graphiti-core==0.11.6 is the resolved version (backend/uv.lock). Inspected ~/.cache/uv/archive-v0/.../graphiti_core/:
graphiti_core.llm_client.openai_client.OpenAIClient(config: LLMConfig)— acceptsapi_key,base_url,modelviaLLMConfig.graphiti_core.embedder.openai.OpenAIEmbedder(config: OpenAIEmbedderConfig)—OpenAIEmbedderConfighasapi_key,base_url,embedding_model.graphiti_core.cross_encoder.openai_reranker_client.OpenAIRerankerClient— Graphiti’s default cross-encoder whencross_encoder=Noneis passed toGraphiti(...)(verified ingraphiti.py:154). The default uses a hard-codedgpt-4.1-nanomodel andlogprobs, which is not interchangeable with Qwen/Dashscope endpoints.graphiti_core.cross_encoder.client.CrossEncoderClient— abstract base; the existing_GeminiRerankeralready extends it.
This is important for Requirement 7: simply omitting cross_encoder= would make Graphiti fall back to OpenAIRerankerClient() with no api_key/base_url — which would then 401 for Qwen users. Better to inject an explicit passthrough.
Requirement-to-Asset Map
| Req | Existing asset | Gap | Tag |
|---|---|---|---|
| R1 Dockerised Neo4j | docker-compose.yml (single service, no version: already) |
Need to add neo4j service + healthcheck + named volumes + depends_on + NEO4J_URI override on mirofish. |
Missing |
| R2 Host-mode no-regression | Config.NEO4J_URI defaults to bolt://localhost:7687 (already correct). |
Confirm Compose override is service-level only; don't mutate the default. | Constraint |
| R3 LLM provider switch | _get_graphiti() lines 95–99 |
Add Config.GRAPHITI_LLM_PROVIDER (default openai). Branch OpenAIClient vs GeminiClient. Raise on unknown value. |
Missing |
| R4 Embedder switch | _get_graphiti() lines 100–105 |
Branch OpenAIEmbedder (with OpenAIEmbedderConfig) vs GeminiEmbedder. |
Missing |
| R5 Decoupled embedding creds | Config.EMBEDDING_MODEL (single field) |
Add EMBEDDING_API_KEY, EMBEDDING_BASE_URL with fallbacks. Surface to embedder branch in adapter. |
Missing |
| R6 Env example refresh | .env.example (read-blocked) |
Update via Bash cat <<EOF > with the correct keys; mirror README-EN §"environment variables". |
Missing + Constraint (env guard) |
| R7 Reranker cleanup | _GeminiReranker stub; _GraphNamespace.search(reranker=); zep_tools.py:504; oasis_profile_generator.py:324,349 |
Drop/rename stub to _PassthroughReranker (no provider dep), keep injecting it explicitly so Graphiti doesn't fall back to OpenAIRerankerClient. Drop reranker= kwarg + caller usages. |
Missing + Constraint (default is OpenAI-only) |
| R8 Backwards compat (Gemini) | Existing Gemini code path. | Branch must preserve today's behaviour exactly when GRAPHITI_LLM_PROVIDER=gemini. No env-var removals. |
Constraint |
| R9 End-to-end Qwen smoke | None | Manual smoke test on a fresh checkout (no automated test exists today; pytest coverage is intentionally minimal per steering doc). | Research / manual |
Specific code locations to change
docker-compose.yml # add neo4j service + depends_on + NEO4J_URI override
.env.example # add NEO4J_*, EMBEDDING_MODEL, GRAPHITI_LLM_PROVIDER, EMBEDDING_*; drop ZEP_API_KEY
backend/app/config.py # add GRAPHITI_LLM_PROVIDER, EMBEDDING_API_KEY, EMBEDDING_BASE_URL
backend/app/services/graphiti_adapter.py
- lines 30-32 # imports: replace forced gemini imports with lazy / branched
- lines 40-51 # rename _GeminiReranker -> _PassthroughReranker, drop GeminiClient dep
- lines 89-119 # _get_graphiti(): branch on GRAPHITI_LLM_PROVIDER
- lines 434-464 # drop `reranker` kwarg from _GraphNamespace.search
backend/app/services/zep_tools.py:504 # remove reranker="cross_encoder" arg
backend/app/services/oasis_profile_generator.py:324, :349 # remove reranker="rrf" args (also ignored)
Implementation Approach Options
Option A — Extend in place (recommended)
All changes happen inside the existing files; no new module is introduced. The provider switch is a single if/elif/else block inside _get_graphiti(). Imports become lazy (inside the branch) so a missing optional dependency for one provider doesn't crash the other.
- Trade-offs:
- ✅ Smallest diff, minimal cognitive load, follows the "extend
Config, don't scatter" rule. - ✅ Keeps the singleton initialisation pattern intact.
- ❌
graphiti_adapter.pygrows by ~30 lines; still well under 600 LoC.
- ✅ Smallest diff, minimal cognitive load, follows the "extend
Option B — Extract a graphiti_factory.py
Move the LLM/embedder/reranker construction into a new backend/app/services/graphiti_factory.py and have _get_graphiti() call into it.
- Trade-offs:
- ✅ Cleaner separation of concerns.
- ❌ Yet another file. The factory is ~40 lines and only used once. Premature abstraction per the project's "don't refactor beyond what the task requires" rule. Steering says don't introduce abstractions unless needed.
Option C — Hybrid: factory module + tests
Add the factory + a unit test that exercises both branches with mocked clients.
- Trade-offs:
- ✅ Highest confidence the branch logic works.
- ❌ Adds a heavy test harness in a repo that intentionally has minimal pytest coverage. Steering rule: don't introduce that without discussion.
Recommended: Option A for the code change, paired with a manual smoke test (per Requirement 9). It matches established patterns and is the lowest-risk path.
Effort & Risk
-
Effort: S (1–3 days). Justification: All changes are localised to known files; Graphiti's OpenAI/Gemini classes already exist in 0.11.6; the Compose addition is mechanical; the
.env.examplerewrite is one block; no new tests demanded. -
Risk: Medium. Justification: Two soft spots:
- The
pre_tool_env_guard.shhook blocks reading and writing.env.examplefor Claude. The rewrite is small enough that I can write a fresh canonical file from the README, but the guard may also block the Write — needs verification at implementation time. - End-to-end validation requires real Qwen + OpenAI keys which the sandbox doesn't have. Acceptance for Requirement 9 will rely on a structural review (correct provider classes, correct env wiring) plus a Neo4j-only docker-compose smoke (boot Neo4j, hit
/health).
- The
Recommendations for Design Phase
- Preferred approach: Option A — extend in place; lazy-import provider-specific Graphiti classes.
- Key design decisions to nail down in
design.md:- Exact name of the new config knob (
GRAPHITI_LLM_PROVIDER) and the validation strategy (raise vs. log+fallback). - How the embedder credentials fall back when only
EMBEDDING_API_KEYis set withoutEMBEDDING_BASE_URL(recommend: each falls back independently). - Whether to drop
_GeminiRerankerentirely or rename to_PassthroughRerankerto keep Graphiti from defaulting toOpenAIRerankerClient. Recommend rename: dropping it makes the Qwen path silently 401 on every search. - How to handle the
.env.examplewrite under the env guard hook (likely allowed byWrite/Editsince the hook only blocksRead/Bash; verify on first implementation step).
- Exact name of the new config knob (
- Research items to carry forward:
- Confirm Compose Neo4j healthcheck syntax (use
cypher-shellvia container shell). Either the Neo4j container ships it or awget --no-verbose --tries=1 --spider http://localhost:7474works equivalently. Pick whichever is simpler. - Confirm how
_recover_stuck_projectsbehaves when Neo4j is not reachable at boot — it currently throws inside thetry/except(backend/app/__init__.py:91-104); this should be caught already, but worth a glance.
- Confirm Compose Neo4j healthcheck syntax (use
Output Checklist
- Requirement-to-Asset Map with gaps tagged
- Options A/B/C with rationale and trade-offs
- Effort (S) + Risk (Medium) with justifications
- Recommendations for design phase
- Research items flagged