Skip to content

feat: auto-enrich on brain_store via Gemini Flash Lite#115

Merged
EtanHey merged 3 commits intomainfrom
feat/auto-enrich-on-store
Mar 27, 2026
Merged

feat: auto-enrich on brain_store via Gemini Flash Lite#115
EtanHey merged 3 commits intomainfrom
feat/auto-enrich-on-store

Conversation

@EtanHey
Copy link
Copy Markdown
Owner

@EtanHey EtanHey commented Mar 27, 2026

Summary

  • Wire R47 two-pass enrichment into brain_store: sync embedding (pass 1, immediate) + async Gemini 2.5 Flash Lite enrichment (pass 2, ~600ms target)
  • Add enrich_single() to enrichment_controller.py — enriches a single chunk by ID with low retry count (2) and short backoff
  • Fire-and-forget in daemon thread — store response is never blocked by enrichment
  • Opt-out via BRAINLAYER_AUTO_ENRICH=0 env var

Changed files

File Change
src/brainlayer/enrichment_controller.py Add enrich_single(), AUTO_ENRICH_ENABLED flag
src/brainlayer/mcp/store_handler.py Wire enrich_single call into _background_embed_and_flush
tests/test_auto_enrich.py 19 new tests: unit + integration + env var parsing

Design

brain_store(content)
  ├─ store_memory(embed_fn=None)  → immediate, returns chunk_id
  └─ background thread (daemon)
       ├─ embed_pending_chunks()   → pass 1: searchable via vector
       └─ enrich_single(chunk_id)  → pass 2: Gemini faceted tags (~600ms)

Test plan

  • enrich_single enriches chunk successfully with mock Gemini
  • Returns None when disabled (env var)
  • Returns None for missing chunk
  • Returns None without API key
  • Returns None on Gemini failure (no crash)
  • Returns None on invalid response
  • Writes enrichment to DB
  • Uses low retry count (2, not 12)
  • _store background thread triggers enrich_single
  • Store succeeds even when enrichment throws
  • Env var parsing for all values (0/false/no/1/true/yes)
  • No regressions in existing tests (37 pass)

🤖 Generated with Claude Code

Note

Add auto-enrichment via Gemini Flash Lite on brain_store

  • Adds enrich_single in enrichment_controller.py that fetches a chunk, calls Gemini to generate enrichment, and persists the result to the store; all failures return None without raising.
  • After background embedding in store_handler.py, enrich_single is called for the newly stored chunk; exceptions are caught and logged at debug level, leaving store behavior unchanged.
  • Auto-enrichment is gated by the BRAINLAYER_AUTO_ENRICH environment variable (disabled when set to 0, false, or no).
  • Behavioral Change: every brain_store call now triggers a background Gemini API request when BRAINLAYER_AUTO_ENRICH is enabled, which adds latency and external API usage to the background path.

Macroscope summarized e6ea5de.

Summary by CodeRabbit

  • New Features

    • On-demand enrichment of individual memory chunks with AI-generated summaries and tags
    • Automatic background enrichment that runs after storing new memories (non-blocking)
    • Toggle automatic enrichment via environment configuration
  • Tests

    • Comprehensive tests covering enrichment success/failure modes, retry behavior, environment toggling, persistence, and the background enrichment path

…ass)

Wire async Gemini enrichment into brain_store's background thread.
When a new chunk is stored, embedding happens first (pass 1), then
Gemini 2.5 Flash Lite enriches with faceted tags/summary (pass 2, ~600ms).

- Add enrich_single() to enrichment_controller.py — enriches one chunk
  by ID, low retry count (2), short backoff (0.3s base)
- Wire into _background_embed_and_flush in store_handler.py
- Opt-out via BRAINLAYER_AUTO_ENRICH=0 env var
- Never blocks store response — fire-and-forget in daemon thread
- 19 new tests with mock Gemini client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

This PR adds an optional automatic enrichment pass using Gemini: a new enrich_single() function performs per-chunk enrichment (respecting a BRAINLAYER_AUTO_ENRICH flag) and is invoked from the store handler's background thread after embedding/flush completion.

Changes

Cohort / File(s) Summary
Core Auto-Enrichment Logic
src/brainlayer/enrichment_controller.py
Added AUTO_ENRICH_ENABLED (from BRAINLAYER_AUTO_ENRICH) and a new public enrich_single(store, chunk_id, max_retries=2) that loads a Gemini client, builds an external prompt, calls client.models.generate_content with retry/backoff (base_delay=0.3, max_delay=5.0), parses the model output, and applies enrichment. Returns early on disabled flag, missing chunk, missing credentials, prompt build failure, API failures, parse failure, or apply failure.
Store Handler Integration
src/brainlayer/mcp/store_handler.py
After embedding-and-flush in the background thread, fire-and-forget calls to enrich_single(bg_store, chunk_id) were added. Enrichment exceptions are caught and logged at debug level and do not affect store/embedding error handling.
Comprehensive Test Suite
tests/test_auto_enrich.py
New tests covering enrich_single success and failure modes (disabled flag, missing chunk, missing creds, API errors, parse errors, apply errors), retry behavior, DB persistence, and integration of the background enrichment pass. Also tests env var parsing for BRAINLAYER_AUTO_ENRICH.

Sequence Diagram

sequenceDiagram
    participant StoreThread as Store Handler (Background)
    participant EnrichCtrl as Enrichment Controller
    participant GeminiAPI as Gemini API Client
    participant Store as Vector Store (DB)
    participant Parser as Enrichment Parser

    StoreThread->>Store: Store chunk & embed
    StoreThread->>EnrichCtrl: enrich_single(store, chunk_id)
    alt AUTO_ENRICH_ENABLED
        EnrichCtrl->>Store: get_chunk(chunk_id)
        alt Chunk exists
            EnrichCtrl->>EnrichCtrl: build_external_prompt(chunk + Sanitizer)
            EnrichCtrl->>GeminiAPI: generate_content(prompt) with retry/backoff
            alt API Success
                GeminiAPI-->>EnrichCtrl: response
                EnrichCtrl->>Parser: parse_enrichment(response)
                Parser-->>EnrichCtrl: {summary, tags, ...}
                EnrichCtrl->>Store: _apply_enrichment(chunk_id, enrichment)
                EnrichCtrl-->>StoreThread: enrichment dict
            else API Failure
                GeminiAPI-->>EnrichCtrl: error (after retries)
                EnrichCtrl-->>StoreThread: None (logged)
            end
        else Chunk Missing
            EnrichCtrl-->>StoreThread: None (early return)
        end
    else Feature Disabled
        EnrichCtrl-->>StoreThread: None (early return)
    end
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nibble on chunks in the quiet night,
Whisper a prompt to Gemini's light,
Two gentle passes, a background hum,
Tags and summaries hop back home, yum!
Retry for patience, logger holds tight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: adding auto-enrichment functionality to brain_store using Gemini Flash Lite.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auto-enrich-on-store

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread tests/test_auto_enrich.py Outdated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Owner Author

@EtanHey EtanHey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #115 -- Auto-enrich on brain_store via Gemini Flash Lite

What was done well

  • Clean two-pass architecture. The R47 pattern is well-designed: pass 1 (sync embedding) keeps the store response fast, pass 2 (async Gemini enrichment) fires in the existing background thread. No new threads, no new coordination primitives.
  • Thorough error isolation. Every failure path in enrich_single returns None instead of propagating. The store_handler wraps the call in its own try/except, making it truly fire-and-forget. The store result is never affected by enrichment failures.
  • Good reuse of existing infrastructure. enrich_single reuses _get_gemini_client, _retry_with_backoff, build_external_prompt, parse_enrichment, and _apply_enrichment -- no reinvention.
  • Sensible retry parameters. max_retries=2 with base_delay=0.3 and max_delay=5.0 is appropriate for inline enrichment (vs the batch max_retries=12 with max_delay=120).

Critical -- Must Fix

1. CI lint failure: tests/test_auto_enrich.py fails ruff format --check

Three formatting issues flagged by ruff: _fake_gemini_response dict, the cursor.execute() call in test_writes_enrichment_to_db, and the @pytest.mark.parametrize list. Run ruff format tests/test_auto_enrich.py to fix.


Important -- Should Fix

2. TestAutoEnrichEnvVar.test_auto_enrich_flag_parsing tests itself, not the production code

(Macroscope already flagged this -- I agree with their assessment.)

Line 327 manually computes value.lower() not in ("0", "false", "no") rather than importing and checking the actual AUTO_ENRICH_ENABLED module attribute. This test will never catch regressions in the production parsing logic. The fix is:

def test_auto_enrich_flag_parsing(self, value, expected, monkeypatch):
    import importlib
    from brainlayer import enrichment_controller as ctrl

    monkeypatch.setenv("BRAINLAYER_AUTO_ENRICH", value)
    importlib.reload(ctrl)
    assert ctrl.AUTO_ENRICH_ENABLED == expected

Note that importlib.reload is needed because AUTO_ENRICH_ENABLED is computed at module import time. After the test, monkeypatch will restore the env var, but you may want to reload again in a finalizer if other tests in the same process depend on the original value.

3. _FakeGeminiClient has a confusing __init__ / __new__ split

__init__ is defined but never actually runs because __new__ does all the initialization. This is misleading -- a reader sees __init__ setting self._response_text and self.call_count and assumes that is the constructor. Since __new__ already handles everything, just remove the __init__ method entirely to avoid confusion.

4. Integration test thread-join pattern is fragile

for t in threading.enumerate():
    if t.daemon and t.name != "MainThread":
        t.join(timeout=5.0)

This joins all daemon threads in the process, not just the one spawned by _store. In a parallel pytest run or if other fixtures spawn daemon threads, this could join unrelated threads or miss the target thread entirely. A more robust approach: capture the thread reference directly. The background thread is spawned as t = threading.Thread(...) in _store -- you could monkeypatch threading.Thread to capture the thread instance, or use an Event to signal completion from the mock enrich_single.


Suggestions -- Nice to Have

5. Logger instantiation inside function body

enrich_single creates _logger = logging.getLogger(__name__) on every call. This is cheap but unconventional -- the module-level pattern _logger = logging.getLogger(__name__) (once, at import time) is standard in this codebase. Consider moving it to module level for consistency.

6. Consider testing the enriched_at timestamp

test_writes_enrichment_to_db verifies summary and tags but does not verify that enriched_at was set. Since _apply_enrichment calls update_enrichment, which sets enriched_at, asserting on it would confirm the full write path. This also matters for the enrichment pipeline which uses enriched_at to skip already-enriched chunks.

7. Missing test: build_external_prompt failure path

enrich_single handles prompt-build exceptions (lines 141-145), but no test covers this path. A quick test with build_external_prompt monkeypatched to raise would complete the error-path coverage.

8. Missing test: _apply_enrichment failure path

Similarly, the _apply_enrichment exception handler (lines 173-177) has no test coverage. Monkeypatch store.update_enrichment to raise and verify enrich_single returns None.


Summary

The implementation is architecturally sound and follows the existing patterns well. The critical fix is the ruff formatting (CI blocker). The important fixes are the env-var test that doesn't test production code, the confusing __new__/__init__ split, and the fragile thread-join pattern. Everything else is polish.

19 tests all pass locally. The CI test failure (test_digest_connect.py) is pre-existing and unrelated to this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/brainlayer/enrichment_controller.py`:
- Around line 110-121: enrich_single currently may race with enrich_realtime and
cause duplicate Gemini calls; add a pre-flight guard at the start of
enrich_single that queries the chunk's enrichment state (check the enriched_at
field for the chunk_id) and return early (None) if enriched_at is not null, so
only one writer proceeds; update the function's docstring to note this
pre-flight check and reference enrich_realtime and the enriched_at field so
reviewers can see the concurrency safeguard.

In `@tests/test_auto_enrich.py`:
- Around line 323-328: The test test_auto_enrich_flag_parsing is duplicating the
parsing logic instead of asserting the production flag; change it to set the env
via monkeypatch.setenv("BRAINLAYER_AUTO_ENRICH", value) and then import the
module under test and assert its AUTO_ENRICH_ENABLED equals expected (use
importlib.reload(module) after setting the env to pick up module-level
evaluation), or better: extract the parsing into a helper function (e.g.,
parse_auto_enrich_env) in the production module and call that helper from the
test to verify parsing behavior; ensure you reference AUTO_ENRICH_ENABLED or the
new parse_auto_enrich_env helper and remove the duplicated parsing logic from
the test.
- Around line 61-81: The _FakeGeminiClient currently duplicates initialization
in both __init__ and __new__ (setting _response_text and call_count), so remove
the redundant __init__ method and rely on the __new__ implementation to set
instance._response_text, instance.call_count, and instance.models (which uses
the nested _Models class and increments call_count via generate_content); ensure
generate_content still references self._parent and that models remains attached
to the returned instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c84a0766-a663-43a0-becc-0a4bb67a5aa0

📥 Commits

Reviewing files that changed from the base of the PR and between 52f9c4a and 4a25587.

📒 Files selected for processing (3)
  • src/brainlayer/enrichment_controller.py
  • src/brainlayer/mcp/store_handler.py
  • tests/test_auto_enrich.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Macroscope - Correctness Check
  • GitHub Check: test (3.13)
  • GitHub Check: test (3.11)
  • GitHub Check: test (3.12)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Flag risky DB or concurrency changes explicitly and do not hand-wave lock behavior
Enforce one-write-at-a-time concurrency constraint; reads are safe but brain_digest is write-heavy and must not run in parallel with other MCP work
Run pytest before claiming behavior changed safely; current test suite has 929 tests

**/*.py: All scripts and CLI must use paths.py:get_db_path() for database path resolution instead of hardcoding paths
Preserve verbatim content for message types: ai_code, stack_trace, user_message during classification and chunking
Skip noise content entirely; summarize build_log; extract structure only from dir_listing during chunking
Use AST-aware chunking via tree-sitter; never split stack traces; mask large tool output
Implement retry logic on SQLITE_BUSY errors; each worker must use its own database connection
Override enrichment backend via BRAINLAYER_ENRICH_BACKEND environment variable (valid values: ollama, mlx, groq); default to Groq
Configure enrichment rate via BRAINLAYER_ENRICH_RATE environment variable (default: 0.2 = 12 RPM)
Checkpoint WAL before and after bulk database operations: PRAGMA wal_checkpoint(FULL)
Drop FTS triggers before bulk deletes from chunks table; recreate after operation to avoid performance degradation
Batch deletes in 5-10K chunk sizes with WAL checkpoint every 3 batches
Default search queries must exclude lifecycle-managed chunks; use include_archived=True parameter to show history
Lint and format code with ruff check src/ && ruff format src/
Session dedup coordination: SessionStart writes injected chunk_ids to /tmp/brainlayer_session_{id}.json; UserPromptSubmit skips already-injected chunks
Skip auto-search for prompts containing 'handoff' or 'session-handoff' keywords

Files:

  • src/brainlayer/mcp/store_handler.py
  • src/brainlayer/enrichment_controller.py
  • tests/test_auto_enrich.py
**/*test*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Use pytest for testing

Files:

  • tests/test_auto_enrich.py
🧠 Learnings (3)
📚 Learning: 2026-03-22T15:55:22.017Z
Learnt from: EtanHey
Repo: EtanHey/brainlayer PR: 100
File: src/brainlayer/enrichment_controller.py:175-199
Timestamp: 2026-03-22T15:55:22.017Z
Learning: In `src/brainlayer/enrichment_controller.py`, the `parallel` parameter in `enrich_local()` is intentionally kept in the function signature (currently unused, suppressed with `# noqa: ARG001`) for API stability. Parallel local enrichment via a thread pool or process pool is planned for a future iteration. Do not flag this as dead code requiring removal.

Applied to files:

  • src/brainlayer/mcp/store_handler.py
  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-26T15:46:16.139Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Applies to **/*.py : Configure enrichment rate via `BRAINLAYER_ENRICH_RATE` environment variable (default: 0.2 = 12 RPM)

Applied to files:

  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-26T15:46:16.139Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Applies to **/*.py : Override enrichment backend via `BRAINLAYER_ENRICH_BACKEND` environment variable (valid values: `ollama`, `mlx`, `groq`); default to Groq

Applied to files:

  • src/brainlayer/enrichment_controller.py
  • tests/test_auto_enrich.py
🔇 Additional comments (7)
src/brainlayer/enrichment_controller.py (3)

22-23: LGTM!

The environment variable parsing correctly handles multiple falsey values (0, false, no) with case-insensitive comparison and defaults to enabled.


134-138: LGTM!

Graceful degradation when Gemini credentials are unavailable — logs at debug level (appropriate since this is expected in dev/test environments) and returns None without disrupting the store flow.


157-166: LGTM!

The retry parameters (base_delay=0.3, max_delay=5.0, max_retries=2) are well-tuned for the ~600ms target latency goal stated in the PR objectives. Short backoffs prevent blocking the background thread while still providing resilience against transient failures.

src/brainlayer/mcp/store_handler.py (1)

534-542: LGTM!

The fire-and-forget enrichment integration is well-structured:

  • Exception isolation ensures store success is never affected by enrichment failures
  • Debug-level logging is appropriate for expected scenarios (disabled enrichment, missing API key)
  • Uses the background thread's dedicated bg_store connection, respecting APSW's same-thread requirement
tests/test_auto_enrich.py (3)

19-42: LGTM!

Clean fixture setup with proper resource cleanup. The stored_chunk fixture correctly chains from store and provides a realistic test chunk for enrichment scenarios.


150-150: Verify time.sleep patch target is correct.

The patch ctrl.time.sleep assumes time is imported as a module in enrichment_controller. If from time import sleep were used instead, this patch would fail silently. Confirm that enrichment_controller.py uses import time (line 8 shows it does).


260-266: Consider using a more robust thread synchronization pattern.

The daemon thread enumeration works but may be fragile if other daemon threads exist. A more targeted approach would capture the thread returned by _store or use an event:

# Alternative: could mock threading.Thread to capture the specific thread

That said, the current approach works for these isolated test cases.

Comment on lines +110 to +121
def enrich_single(store, chunk_id: str, max_retries: int = 2) -> dict[str, Any] | None:
"""Enrich a single chunk by ID via Gemini 2.5 Flash Lite.

Designed for post-store auto-enrichment (R47 two-pass pattern):
Pass 1 = sync embedding (immediate, searchable)
Pass 2 = this function (async Gemini tagging, ~600ms target)

Bypasses get_enrichment_candidates — works on any chunk regardless
of enriched_at status. Overwrites stub enrichment with Gemini output.

Returns the parsed enrichment dict on success, None on failure.
"""
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any rate limiting logic or deduplication for concurrent enrichment
rg -nC3 "enriched_at" src/brainlayer/

Repository: EtanHey/brainlayer

Length of output: 6281


🏁 Script executed:

# Find where enrich_single is called
rg -n "enrich_single" src/brainlayer/

Repository: EtanHey/brainlayer

Length of output: 1319


🏁 Script executed:

# Check for any locking/synchronization mechanisms around enrichment
rg -n "Lock|Event|Thread|await|asyncio" src/brainlayer/enrichment_controller.py | head -20

Repository: EtanHey/brainlayer

Length of output: 44


🏁 Script executed:

# Look at the _background_embed_and_flush function mentioned in the docstring
rg -n "_background_embed_and_flush" src/brainlayer/ -A 20

Repository: EtanHey/brainlayer

Length of output: 3642


🏁 Script executed:

# Check what enrich_realtime does and how it's scheduled
rg -n "enrich_realtime" src/brainlayer/ -B 3 -A 8

Repository: EtanHey/brainlayer

Length of output: 4111


🏁 Script executed:

# Check _apply_enrichment implementation to see when enriched_at is written
rg -n "_apply_enrichment" src/brainlayer/enrichment_controller.py -A 25

Repository: EtanHey/brainlayer

Length of output: 3608


🏁 Script executed:

# Check store.update_enrichment to see if it's atomic
rg -n "def update_enrichment" src/brainlayer/ -A 30

Repository: EtanHey/brainlayer

Length of output: 2307


Concurrent enrichment is possible but unlikely in practice; consider explicit synchronization per concurrency guidelines.

The race condition scenario is technically possible: if enrich_realtime (manual trigger) runs while enrich_single (daemon auto-enrichment) is executing, both may fetch and enrich the same chunk, resulting in duplicate Gemini API calls and unnecessary quota consumption.

However, in typical usage this overlap is unlikely — enrich_realtime is invoked manually via CLI or MCP, not automatically, so it rarely runs while auto-enrichment threads are active. Additionally, the enrich_single docstring explicitly documents that it overwrites existing enrichment by design (two-pass pattern: Pass 1 = embedding, Pass 2 = async Gemini).

That said, the codebase guidelines require explicitly flagging risky concurrent database changes and enforcing one-write-at-a-time constraints. This function currently has no guard against concurrent writes from enrich_realtime. Consider either:

  • Adding a pre-flight check to skip chunks where enriched_at IS NOT NULL (if deliberate overlap is not desired), or
  • Documenting that concurrent enrichment is acceptable and updating the guidelines to reflect this exception.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/brainlayer/enrichment_controller.py` around lines 110 - 121,
enrich_single currently may race with enrich_realtime and cause duplicate Gemini
calls; add a pre-flight guard at the start of enrich_single that queries the
chunk's enrichment state (check the enriched_at field for the chunk_id) and
return early (None) if enriched_at is not null, so only one writer proceeds;
update the function's docstring to note this pre-flight check and reference
enrich_realtime and the enriched_at field so reviewers can see the concurrency
safeguard.

Comment thread tests/test_auto_enrich.py Outdated
Comment on lines +61 to +81
class _FakeGeminiClient:
"""Mock Gemini client that returns a valid enrichment response."""

def __init__(self, response_text=None):
self._response_text = response_text or _fake_gemini_response()
self.call_count = 0

class _Models:
def __init__(self, parent):
self._parent = parent

def generate_content(self, **kwargs):
self._parent.call_count += 1
return SimpleNamespace(text=self._parent._response_text)

def __new__(cls, response_text=None):
instance = super().__new__(cls)
instance._response_text = response_text or _fake_gemini_response()
instance.call_count = 0
instance.models = cls._Models(instance)
return instance
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Redundant initialization in __init__ and __new__.

Both methods initialize _response_text and call_count. Since __init__ runs after __new__, the __new__ initialization is immediately overwritten. Consider removing __init__ entirely since __new__ already handles setup:

♻️ Simplified fake client
 class _FakeGeminiClient:
     """Mock Gemini client that returns a valid enrichment response."""

-    def __init__(self, response_text=None):
-        self._response_text = response_text or _fake_gemini_response()
-        self.call_count = 0
-
     class _Models:
         def __init__(self, parent):
             self._parent = parent
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auto_enrich.py` around lines 61 - 81, The _FakeGeminiClient
currently duplicates initialization in both __init__ and __new__ (setting
_response_text and call_count), so remove the redundant __init__ method and rely
on the __new__ implementation to set instance._response_text,
instance.call_count, and instance.models (which uses the nested _Models class
and increments call_count via generate_content); ensure generate_content still
references self._parent and that models remains attached to the returned
instance.

Comment thread tests/test_auto_enrich.py Outdated
- Fix ruff format on test file
- Remove __new__/__init__ split in _FakeGeminiClient (use __init__ only)
- Add test_returns_none_on_prompt_build_failure
- Add test_returns_none_on_apply_failure
- 21 tests total (was 19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread tests/test_auto_enrich.py
test_store.close()


# ── Environment variable opt-out ────────────────────────────────
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium tests/test_auto_enrich.py:335

test_auto_enrich_flag_parsing uses importlib.reload(ctrl) to test different environment variable values, but never reloads the module again after the test completes. This leaves AUTO_ENRICH_ENABLED set to the value from the last parametrized case (empty string → True), polluting global state for any subsequent tests that don't explicitly monkeypatch the flag.

Consider using a fixture or try/finally to reload the module with a clean environment (or delete ctrl from sys.modules) after each parametrized run.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file tests/test_auto_enrich.py around line 335:

`test_auto_enrich_flag_parsing` uses `importlib.reload(ctrl)` to test different environment variable values, but never reloads the module again after the test completes. This leaves `AUTO_ENRICH_ENABLED` set to the value from the last parametrized case (empty string → `True`), polluting global state for any subsequent tests that don't explicitly monkeypatch the flag.

Consider using a fixture or `try/finally` to reload the module with a clean environment (or delete `ctrl` from `sys.modules`) after each parametrized run.

Evidence trail:
tests/test_auto_enrich.py lines 343-365 (test code with importlib.reload), src/brainlayer/enrichment_controller.py line 26 (AUTO_ENRICH_ENABLED module-level definition). No cleanup code is present in the test after the reload.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/test_auto_enrich.py`:
- Around line 288-295: The test currently joins all daemon threads via
threading.enumerate(), which may catch unrelated threads; modify the code to
wait only for the specific background worker started by _store_new (or signal
completion via an Event). Change _store_new (or the code that spawns the worker)
to accept/return a threading.Thread or accept a threading.Event that it will set
when enrichment is done, then in the test wait on that thread.join(timeout=5.0)
or event.wait(timeout=5.0) before asserting enriched_ids and closing test_store;
reference the _store_new function and the enriched_ids check so the test only
waits for that specific operation.
- Around line 355-364: The test reloads the brainlayer.enrichment_controller
module which mutates sys.modules and leaves AUTO_ENRICH_ENABLED changed for
later tests; to fix, in test_auto_enrich_flag_parsing save the existing module
entry (orig = sys.modules.get('brainlayer.enrichment_controller')) before you
call importlib.reload(ctrl), then after the assertion restore the original
module state by putting orig back into sys.modules (or deleting the entry if
orig was None) so that the reload has no lasting effect on AUTO_ENRICH_ENABLED;
reference the test function test_auto_enrich_flag_parsing, the ctrl variable,
and the AUTO_ENRICH_ENABLED symbol when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7d493b29-a4a7-4b40-ac74-82eb303f32f3

📥 Commits

Reviewing files that changed from the base of the PR and between 4a25587 and e6ea5de.

📒 Files selected for processing (2)
  • src/brainlayer/enrichment_controller.py
  • tests/test_auto_enrich.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: test (3.11)
  • GitHub Check: test (3.12)
  • GitHub Check: test (3.13)
  • GitHub Check: Macroscope - Correctness Check
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: Flag risky DB or concurrency changes explicitly and do not hand-wave lock behavior
Enforce one-write-at-a-time concurrency constraint; reads are safe but brain_digest is write-heavy and must not run in parallel with other MCP work
Run pytest before claiming behavior changed safely; current test suite has 929 tests

**/*.py: All scripts and CLI must use paths.py:get_db_path() for database path resolution instead of hardcoding paths
Preserve verbatim content for message types: ai_code, stack_trace, user_message during classification and chunking
Skip noise content entirely; summarize build_log; extract structure only from dir_listing during chunking
Use AST-aware chunking via tree-sitter; never split stack traces; mask large tool output
Implement retry logic on SQLITE_BUSY errors; each worker must use its own database connection
Override enrichment backend via BRAINLAYER_ENRICH_BACKEND environment variable (valid values: ollama, mlx, groq); default to Groq
Configure enrichment rate via BRAINLAYER_ENRICH_RATE environment variable (default: 0.2 = 12 RPM)
Checkpoint WAL before and after bulk database operations: PRAGMA wal_checkpoint(FULL)
Drop FTS triggers before bulk deletes from chunks table; recreate after operation to avoid performance degradation
Batch deletes in 5-10K chunk sizes with WAL checkpoint every 3 batches
Default search queries must exclude lifecycle-managed chunks; use include_archived=True parameter to show history
Lint and format code with ruff check src/ && ruff format src/
Session dedup coordination: SessionStart writes injected chunk_ids to /tmp/brainlayer_session_{id}.json; UserPromptSubmit skips already-injected chunks
Skip auto-search for prompts containing 'handoff' or 'session-handoff' keywords

Files:

  • tests/test_auto_enrich.py
  • src/brainlayer/enrichment_controller.py
**/*test*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Use pytest for testing

Files:

  • tests/test_auto_enrich.py
🧠 Learnings (8)
📓 Common learnings
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Use Groq as primary enrichment backend (configured in launchd plist); fall back to Gemini, then Ollama as offline last-resort
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Applies to **/*.py : Override enrichment backend via `BRAINLAYER_ENRICH_BACKEND` environment variable (valid values: `ollama`, `mlx`, `groq`); default to Groq
Learnt from: EtanHey
Repo: EtanHey/brainlayer PR: 100
File: src/brainlayer/enrichment_controller.py:175-199
Timestamp: 2026-03-22T15:55:22.017Z
Learning: In `src/brainlayer/enrichment_controller.py`, the `parallel` parameter in `enrich_local()` is intentionally kept in the function signature (currently unused, suppressed with `# noqa: ARG001`) for API stability. Parallel local enrichment via a thread pool or process pool is planned for a future iteration. Do not flag this as dead code requiring removal.
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Applies to **/*.py : Configure enrichment rate via `BRAINLAYER_ENRICH_RATE` environment variable (default: 0.2 = 12 RPM)
📚 Learning: 2026-03-22T15:55:22.017Z
Learnt from: EtanHey
Repo: EtanHey/brainlayer PR: 100
File: src/brainlayer/enrichment_controller.py:175-199
Timestamp: 2026-03-22T15:55:22.017Z
Learning: In `src/brainlayer/enrichment_controller.py`, the `parallel` parameter in `enrich_local()` is intentionally kept in the function signature (currently unused, suppressed with `# noqa: ARG001`) for API stability. Parallel local enrichment via a thread pool or process pool is planned for a future iteration. Do not flag this as dead code requiring removal.

Applied to files:

  • tests/test_auto_enrich.py
  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-26T15:46:16.139Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Applies to **/*.py : Override enrichment backend via `BRAINLAYER_ENRICH_BACKEND` environment variable (valid values: `ollama`, `mlx`, `groq`); default to Groq

Applied to files:

  • tests/test_auto_enrich.py
  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-26T15:46:16.139Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Applies to **/*.py : Configure enrichment rate via `BRAINLAYER_ENRICH_RATE` environment variable (default: 0.2 = 12 RPM)

Applied to files:

  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-26T15:46:16.139Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T15:46:16.139Z
Learning: Never run bulk database operations while enrichment workers are writing; stop enrichment workers first

Applied to files:

  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-14T02:20:54.656Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T02:20:54.656Z
Learning: Applies to **/*.py : Enforce one-write-at-a-time concurrency constraint; reads are safe but brain_digest is write-heavy and must not run in parallel with other MCP work

Applied to files:

  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-14T02:20:54.656Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T02:20:54.656Z
Learning: Applies to **/*.py : Flag risky DB or concurrency changes explicitly and do not hand-wave lock behavior

Applied to files:

  • src/brainlayer/enrichment_controller.py
📚 Learning: 2026-03-14T02:20:54.656Z
Learnt from: CR
Repo: EtanHey/brainlayer PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-14T02:20:54.656Z
Learning: Be aware of known BrainLayer issues: DB locking during enrichment and WAL growth up to 4.7GB

Applied to files:

  • src/brainlayer/enrichment_controller.py
🔇 Additional comments (8)
src/brainlayer/enrichment_controller.py (5)

113-124: Concurrent enrichment overlap: unchanged from prior review.

The past review flagged that enrich_single and enrich_realtime may concurrently enrich the same chunk since there's no pre-flight enriched_at IS NOT NULL guard. The docstring documents this is by design ("Overwrites stub enrichment"). No code change here since last review.


6-6: LGTM!

Module-level logger follows best practices with __name__ scoping.

Also applies to: 14-15


25-27: LGTM!

Environment variable parsing correctly handles common truthy/falsy values with case-insensitive comparison.


133-137: Verify intentional Gemini-only backend for auto-enrichment.

The function uses Gemini directly via _get_gemini_client(), while learnings indicate "Use Groq as primary enrichment backend; fall back to Gemini." The PR title explicitly mentions "Gemini Flash Lite," suggesting this is intentional for the fast two-pass pattern. Please confirm this is the desired behavior or if BRAINLAYER_ENRICH_BACKEND should be respected here as well.

Based on learnings: "Use Groq as primary enrichment backend (configured in launchd plist); fall back to Gemini, then Ollama as offline last-resort."


156-162: LGTM!

Retry parameters (base_delay=0.3, max_delay=5.0, max_retries=2) are appropriately tuned for the ~600ms target latency of pass-2 enrichment.

tests/test_auto_enrich.py (3)

19-43: LGTM!

Fixtures properly manage resource lifecycle with yield and close(). The stored_chunk fixture correctly depends on store for test isolation.


63-78: LGTM!

Clean mock implementation. The previous concern about redundant __init__/__new__ has been addressed—only __init__ remains.


83-104: LGTM!

Good test structure with direct monkeypatching of AUTO_ENRICH_ENABLED to avoid module reload complications. Validates both return value and API call count.

Comment thread tests/test_auto_enrich.py
Comment on lines +288 to +295
# Wait for background thread to complete
for t in threading.enumerate():
if t.daemon and t.name != "MainThread":
t.join(timeout=5.0)

assert len(enriched_ids) == 1
assert enriched_ids[0] == chunk_id
test_store.close()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Thread-joining approach works but is fragile.

Joining all daemon threads by iterating threading.enumerate() could catch unrelated daemon threads from the test harness. Consider tracking the specific thread spawned by _store_new or using an Event to signal completion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auto_enrich.py` around lines 288 - 295, The test currently joins
all daemon threads via threading.enumerate(), which may catch unrelated threads;
modify the code to wait only for the specific background worker started by
_store_new (or signal completion via an Event). Change _store_new (or the code
that spawns the worker) to accept/return a threading.Thread or accept a
threading.Event that it will set when enrichment is done, then in the test wait
on that thread.join(timeout=5.0) or event.wait(timeout=5.0) before asserting
enriched_ids and closing test_store; reference the _store_new function and the
enriched_ids check so the test only waits for that specific operation.

Comment thread tests/test_auto_enrich.py
Comment on lines +355 to +364
def test_auto_enrich_flag_parsing(self, value, expected, monkeypatch):
"""AUTO_ENRICH_ENABLED respects environment variable values."""
import importlib

from brainlayer import enrichment_controller as ctrl

monkeypatch.setenv("BRAINLAYER_AUTO_ENRICH", value)
importlib.reload(ctrl)

assert ctrl.AUTO_ENRICH_ENABLED == expected
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Module reload without cleanup may pollute other tests.

After importlib.reload(ctrl), the AUTO_ENRICH_ENABLED constant retains the last-tested value for the remainder of the test session. While TestEnrichSingle tests explicitly monkeypatch AUTO_ENRICH_ENABLED, any future tests that don't could inherit stale state.

Consider restoring the original module state:

🧪 Proposed fix to restore module state after reload
     def test_auto_enrich_flag_parsing(self, value, expected, monkeypatch):
         """AUTO_ENRICH_ENABLED respects environment variable values."""
         import importlib
 
         from brainlayer import enrichment_controller as ctrl
 
+        original_value = ctrl.AUTO_ENRICH_ENABLED
         monkeypatch.setenv("BRAINLAYER_AUTO_ENRICH", value)
         importlib.reload(ctrl)
 
-        assert ctrl.AUTO_ENRICH_ENABLED == expected
+        try:
+            assert ctrl.AUTO_ENRICH_ENABLED == expected
+        finally:
+            # Restore default state for subsequent tests
+            monkeypatch.delenv("BRAINLAYER_AUTO_ENRICH", raising=False)
+            importlib.reload(ctrl)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auto_enrich.py` around lines 355 - 364, The test reloads the
brainlayer.enrichment_controller module which mutates sys.modules and leaves
AUTO_ENRICH_ENABLED changed for later tests; to fix, in
test_auto_enrich_flag_parsing save the existing module entry (orig =
sys.modules.get('brainlayer.enrichment_controller')) before you call
importlib.reload(ctrl), then after the assertion restore the original module
state by putting orig back into sys.modules (or deleting the entry if orig was
None) so that the reload has no lasting effect on AUTO_ENRICH_ENABLED; reference
the test function test_auto_enrich_flag_parsing, the ctrl variable, and the
AUTO_ENRICH_ENABLED symbol when making the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant