Skip to content

feat: implement cached prompt entity detection#213

Merged
EtanHey merged 3 commits intomainfrom
feat/detect-entities-impl
Apr 6, 2026
Merged

feat: implement cached prompt entity detection#213
EtanHey merged 3 commits intomainfrom
feat/detect-entities-impl

Conversation

@EtanHey
Copy link
Copy Markdown
Owner

@EtanHey EtanHey commented Apr 6, 2026

Summary

  • replace prompt-time SQL-per-candidate entity detection with a module-level KG cache
  • match case-insensitive multi-word entity names via longest-first token span lookup
  • keep alias-based fallback support and add tests for caching, no-hit behavior, and db-path loading

Test plan

  • pytest tests/test_conditional_hooks.py tests/test_hebrew_alias.py tests/test_adaptive_injection.py -q
  • pytest -x

Notes

  • Full repo pytest stops in tests/test_eval_baselines.py::TestEntityRouting::test_avi_simon_entity because the embedding model loader tries to reach Hugging Face in this restricted environment.

Note

Add cached entity detection with longest-span and phonetic fallback to prompt search

  • Rewrites detect_entities_in_prompt in brainlayer-prompt-search.py to use an in-memory per-DB cache of entities and aliases, built once via _load_entity_cache and keyed by DB file path.
  • Adds longest-span tokenization via _iter_prompt_tokens and _match_entity_spans, so multi-word entity names are preferred over shorter overlapping matches.
  • Supports operation without an explicit DB connection by opening it via paths.get_db_path().
  • When a connection is provided, also runs a phonetic fallback query for Hebrew candidates even when exact matches are already found.
  • Behavioral Change: entity detection now returns multiword matches instead of individual tokens, and phonetic Hebrew matches are always attempted alongside exact matches.
📊 Macroscope summarized aac9bba. 3 files reviewed, 1 issue evaluated, 0 issues filtered, 1 comment posted

🗂️ Filtered Issues

Summary by CodeRabbit

  • Bug Fixes

    • Improved entity detection accuracy for multi-word matches and Hebrew/phonetic aliases.
  • Tests

    • Added comprehensive test coverage for entity detection and caching behavior.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

The pull request refactors entity detection in brainlayer-prompt-search.py from per-prompt SQL generation to a cached, preloaded lookup approach using span-based token matching. It makes the database connection optional and adds comprehensive unit tests validating cache behavior, longest-match preference, error recovery, and phonetic fallback paths.

Changes

Cohort / File(s) Summary
Entity Detection Refactoring
hooks/brainlayer-prompt-search.py
Replaced per-prompt SQL candidate generation with a cached, preloaded entity/alias lookup. Added module-level tokenization (ENTITY_TOKEN_RE), global cache state (_ENTITY_CACHE, _ENTITY_CACHE_DB_PATH), cache loader (_load_entity_cache), span-based matching helpers (_iter_prompt_tokens, _match_entity_spans), and connection cache-key resolution (_get_connection_cache_key). Updated detect_entities_in_prompt signature to accept optional conn parameter (default None) and perform phonetic scoring only when connection is provided.
Cache Behavior Tests
tests/test_conditional_hooks.py
Added 126 lines of unit tests verifying empty-list returns for no matches, cache-driven deterministic lookup with longest-match preference, DB path resolution from configured filesystem paths when no explicit connection is provided, and recovery from transient SQLite OperationalError via cache reset and rebuild. Tests also validate filtering of kg_entities/kg_entity_aliases to only supported injected entity/alias types.
Phonetic Fallback Tests
tests/test_hebrew_alias.py
Added test verifying that phonetic alias scoring runs alongside exact matching, validating detection of both exact-match and phonetic-only entities in mixed-language prompts.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A cache of entities, swift and bright,
Span-matched tokens dancing in the night,
No more SQL per prompt's refrain—
Just preloaded wisdom in RAM's domain,
Phonetic Hebrew whispers still take flight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% 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 'feat: implement cached prompt entity detection' clearly and concisely describes the main change: replacing per-prompt SQL queries with a cached entity detection approach using module-level caches.
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/detect-entities-impl

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.

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.

@EtanHey
Copy link
Copy Markdown
Owner Author

EtanHey commented Apr 6, 2026

@codex review

@EtanHey
Copy link
Copy Markdown
Owner Author

EtanHey commented Apr 6, 2026

@cursor @BugBot review

@EtanHey
Copy link
Copy Markdown
Owner Author

EtanHey commented Apr 6, 2026

@coderabbitai review

@cursor
Copy link
Copy Markdown

cursor Bot commented Apr 6, 2026

You need to increase your spend limit or enable usage-based billing to run background agents. Go to Cursor

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8a0987f73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hooks/brainlayer-prompt-search.py Outdated
Comment on lines +522 to +523
if matched:
return matched
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Continue phonetic scan after exact entity matches

Returning immediately when any exact-name/alias match is found skips the phonetic branch entirely, so mixed prompts (for example, one directly named entity plus a Hebrew token that only resolves via alias_type='phonetic') now lose the phonetic entity. Before this change, phonetic fallback was evaluated per candidate and could return both entities; this regression reduces entity-context coverage and can misclassify bilingual prompts.

Useful? React with 👍 / 👎.

Comment on lines 453 to +460
except sqlite3.Error:
pass
_ENTITY_CACHE = {
"entities_by_name": {},
"aliases_by_name": {},
"max_name_tokens": 1,
"max_alias_tokens": 1,
}
finally:
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 hooks/brainlayer-prompt-search.py:453

When _load_entity_cache() fails to load from database "B", it sets _ENTITY_CACHE = {} but leaves _ENTITY_CACHE_DB_PATH unchanged. If database "A" was previously cached, the stale _ENTITY_CACHE_DB_PATH still holds "A", causing a subsequent call for "A" to pass the cache check at lines 393-394 and return the empty dict instead of the original cached data. Consider clearing _ENTITY_CACHE_DB_PATH in the error handler to invalidate the cache.

    except sqlite3.Error:
        _ENTITY_CACHE = {
            "entities_by_name": {},
            "aliases_by_name": {},
            "max_name_tokens": 1,
            "max_alias_tokens": 1,
        }
+        _ENTITY_CACHE_DB_PATH = None
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file hooks/brainlayer-prompt-search.py around lines 453-460:

When `_load_entity_cache()` fails to load from database "B", it sets `_ENTITY_CACHE = {}` but leaves `_ENTITY_CACHE_DB_PATH` unchanged. If database "A" was previously cached, the stale `_ENTITY_CACHE_DB_PATH` still holds "A", causing a subsequent call for "A" to pass the cache check at lines 393-394 and return the empty dict instead of the original cached data. Consider clearing `_ENTITY_CACHE_DB_PATH` in the error handler to invalidate the cache.

Evidence trail:
hooks/brainlayer-prompt-search.py lines 387-465 (REVIEWED_COMMIT): The `_load_entity_cache` function at line 387 shows: (1) Cache check at lines 391-392 uses both `_ENTITY_CACHE is not None` and `_ENTITY_CACHE_DB_PATH == cache_key`. (2) Success path at lines 447-448 sets both `_ENTITY_CACHE` and `_ENTITY_CACHE_DB_PATH = cache_key`. (3) Error handler at lines 449-456 only sets `_ENTITY_CACHE` to empty structure, does NOT update `_ENTITY_CACHE_DB_PATH`.

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 `@hooks/brainlayer-prompt-search.py`:
- Around line 530-531: Update the function's docstring to explicitly state that
phonetic fallback matching is skipped when the conn parameter is None and that
callers must pass a valid connection to enable phonetic matching; reference the
early-return check "if conn is None: return matched" and describe that the
docstring currently only documents cache/span matching and must also explain the
opt-out behavior for phonetic matching when conn is not provided.
- Around line 40-41: The module-level globals _ENTITY_CACHE and
_ENTITY_CACHE_DB_PATH are mutated without synchronization; either document the
single-threaded assumption or protect access with a threading.Lock to avoid race
conditions. Update the module to declare a lock (e.g., _ENTITY_CACHE_LOCK) and
wrap all reads/writes of _ENTITY_CACHE and _ENTITY_CACHE_DB_PATH inside that
lock within the _load_entity_cache function and any other helpers that touch
these globals; alternatively, add a clear top-of-file comment stating the module
is not thread-safe and must only be used in single-threaded contexts. Ensure you
reference and use the existing _load_entity_cache function and the
_ENTITY_CACHE/_ENTITY_CACHE_DB_PATH symbols when making the change.

In `@tests/test_hebrew_alias.py`:
- Around line 151-163: The test
test_hook_runs_phonetic_fallback_even_with_exact_match relies on a fresh entity
cache but doesn't explicitly reset module cache state; before creating the
VectorStore or calling prompt_search.detect_entities_in_prompt, clear
prompt_search._ENTITY_CACHE and prompt_search._ENTITY_CACHE_DB_PATH by setting
them to None so the test does not depend on tmp_path uniqueness and matches the
reset behavior used in test_conditional_hooks.py.
🪄 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: 1b2931d9-47fd-47bd-957b-99c14be1a04b

📥 Commits

Reviewing files that changed from the base of the PR and between db39b3f and 69b84ad.

📒 Files selected for processing (3)
  • hooks/brainlayer-prompt-search.py
  • tests/test_conditional_hooks.py
  • tests/test_hebrew_alias.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.11)
  • GitHub Check: test (3.13)
  • 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

Files:

  • tests/test_hebrew_alias.py
  • tests/test_conditional_hooks.py
  • hooks/brainlayer-prompt-search.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

Use pytest for testing

Files:

  • tests/test_hebrew_alias.py
  • tests/test_conditional_hooks.py
🧠 Learnings (3)
📓 Common learnings
Learnt from: EtanHey
Repo: EtanHey/brainlayer PR: 198
File: hooks/brainlayer-prompt-search.py:241-259
Timestamp: 2026-04-04T15:21:59.853Z
Learning: In `hooks/brainlayer-prompt-search.py` (Python), `record_injection_event()` is explicitly best-effort telemetry: silent `except sqlite3.Error: pass` is intentional — table non-existence or lock failures are acceptable silent failures. `sqlite3.connect(timeout=2)` is the file-open timeout; `PRAGMA busy_timeout` governs per-statement lock-wait. The `DEADLINE_MS` (450ms) guard applies only to the FTS search phase, not to this side-channel write.
Learnt from: EtanHey
Repo: EtanHey/brainlayer PR: 198
File: hooks/brainlayer-prompt-search.py:169-169
Timestamp: 2026-04-04T15:21:36.497Z
Learning: In EtanHey/brainlayer, `hooks/brainlayer-prompt-search.py` reads `entity_type` directly from existing rows in `kg_entities` (read-only). `contracts/entity-types.yaml` defines the write-side schema only and is not authoritative for what `entity_type` values exist in the DB. The DB already stores `technology` (72 entities), `project` (24), and `tool` (1) as valid `entity_type` values, so `INJECT_TYPES` in the hook should match these DB values, not the contract file.
📚 Learning: 2026-04-04T15:21:36.497Z
Learnt from: EtanHey
Repo: EtanHey/brainlayer PR: 198
File: hooks/brainlayer-prompt-search.py:169-169
Timestamp: 2026-04-04T15:21:36.497Z
Learning: In EtanHey/brainlayer, `hooks/brainlayer-prompt-search.py` reads `entity_type` directly from existing rows in `kg_entities` (read-only). `contracts/entity-types.yaml` defines the write-side schema only and is not authoritative for what `entity_type` values exist in the DB. The DB already stores `technology` (72 entities), `project` (24), and `tool` (1) as valid `entity_type` values, so `INJECT_TYPES` in the hook should match these DB values, not the contract file.

Applied to files:

  • tests/test_hebrew_alias.py
  • tests/test_conditional_hooks.py
  • hooks/brainlayer-prompt-search.py
📚 Learning: 2026-04-04T15:21:59.853Z
Learnt from: EtanHey
Repo: EtanHey/brainlayer PR: 198
File: hooks/brainlayer-prompt-search.py:241-259
Timestamp: 2026-04-04T15:21:59.853Z
Learning: In `hooks/brainlayer-prompt-search.py` (Python), `record_injection_event()` is explicitly best-effort telemetry: silent `except sqlite3.Error: pass` is intentional — table non-existence or lock failures are acceptable silent failures. `sqlite3.connect(timeout=2)` is the file-open timeout; `PRAGMA busy_timeout` governs per-statement lock-wait. The `DEADLINE_MS` (450ms) guard applies only to the FTS search phase, not to this side-channel write.

Applied to files:

  • tests/test_hebrew_alias.py
  • tests/test_conditional_hooks.py
  • hooks/brainlayer-prompt-search.py
🔇 Additional comments (10)
hooks/brainlayer-prompt-search.py (4)

372-385: LGTM!

The cache key resolution logic correctly handles all three cases: no connection (uses configured path), file-based connection (extracts path from PRAGMA), and in-memory connection (uses object id as fallback). Error handling is appropriate.


388-464: LGTM — error recovery design is sound.

The cache loading correctly:

  1. Opens its own connection when conn=None and properly closes it in finally
  2. On sqlite3.Error, sets an empty cache but leaves _ENTITY_CACHE_DB_PATH as None, allowing subsequent calls to retry loading (confirmed by test_load_entity_cache_retries_after_transient_sqlite_error)
  3. Filters entities by supported inject_types

467-468: LGTM!

Clean implementation that tokenizes the prompt in a single regex pass, preserving both original and lowercase forms for case-insensitive matching.


471-496: LGTM!

The longest-first span matching algorithm correctly prioritizes multi-word entity names (e.g., "BrainLayer MCP" over "BrainLayer"). The deduplication via seen_ids prevents the same entity from being returned multiple times even if matched via different spans.

tests/test_conditional_hooks.py (5)

205-211: LGTM!

Clean test for the no-match case, verifying that an empty result is returned when no entities match the prompt.


213-247: LGTM!

Comprehensive test that validates:

  1. Cache persistence (second call returns same results after DB is emptied)
  2. Case-insensitive matching ("EtAn HeYmAn" matches "Etan Heyman")
  3. Longest-first matching ("BrainLayer MCP" preferred over "BrainLayer")

The explicit cache state reset before testing ensures isolation.


249-266: LGTM!

Good test for the conn=None code path, verifying that the cache loading can open the database via paths.get_db_path() when no explicit connection is provided.


268-291: LGTM!

Effective test for transient error recovery. Verifies that:

  1. On sqlite3.OperationalError, the cache is set to empty but _ENTITY_CACHE_DB_PATH remains None
  2. Subsequent calls can successfully rebuild the cache

The ErrorConn stub correctly doesn't need a close() method since close_conn is only True when _load_entity_cache opens its own connection.


293-329: LGTM!

Thorough test verifying that unsupported entity types (like "library") are filtered out during cache loading, ensuring only supported inject types (project, person, etc.) are cached for both entities and aliases.

tests/test_hebrew_alias.py (1)

11-11: LGTM!

Import is needed for the new test at line 156.

Comment on lines +40 to +41
_ENTITY_CACHE = None
_ENTITY_CACHE_DB_PATH = None
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

Module-level cache lacks synchronization primitives.

The global _ENTITY_CACHE and _ENTITY_CACHE_DB_PATH are accessed and mutated without locking. This is acceptable for the current single-threaded CLI hook execution model, but if this module is ever imported into a multi-threaded context (e.g., a web server or worker pool), concurrent calls to _load_entity_cache could cause race conditions or cache corruption.

Consider adding a comment documenting the single-threaded assumption, or using threading.Lock if broader reuse is anticipated.

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

In `@hooks/brainlayer-prompt-search.py` around lines 40 - 41, The module-level
globals _ENTITY_CACHE and _ENTITY_CACHE_DB_PATH are mutated without
synchronization; either document the single-threaded assumption or protect
access with a threading.Lock to avoid race conditions. Update the module to
declare a lock (e.g., _ENTITY_CACHE_LOCK) and wrap all reads/writes of
_ENTITY_CACHE and _ENTITY_CACHE_DB_PATH inside that lock within the
_load_entity_cache function and any other helpers that touch these globals;
alternatively, add a clear top-of-file comment stating the module is not
thread-safe and must only be used in single-threaded contexts. Ensure you
reference and use the existing _load_entity_cache function and the
_ENTITY_CACHE/_ENTITY_CACHE_DB_PATH symbols when making the change.

Comment on lines +530 to +531
if conn is None:
return matched
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

Clarify the phonetic fallback behavior in docstring.

The early return when conn is None skips the phonetic fallback. This is intentional per the PR (allows callers to opt out of phonetic matching), but the docstring only describes the cache-based span matching. Consider documenting that phonetic fallback requires an explicit connection.

📝 Suggested docstring update
 def detect_entities_in_prompt(prompt, conn=None):
     """Detect known KG entity names in the prompt.
 
     Loads KG entity names into a module-level cache and performs
     case-insensitive hash-table lookups over contiguous prompt token spans.
     Multi-word names are matched longest-first.
+
+    When conn is provided, additionally runs phonetic fallback matching
+    for Hebrew tokens against phonetic aliases in the database.
     """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/brainlayer-prompt-search.py` around lines 530 - 531, Update the
function's docstring to explicitly state that phonetic fallback matching is
skipped when the conn parameter is None and that callers must pass a valid
connection to enable phonetic matching; reference the early-return check "if
conn is None: return matched" and describe that the docstring currently only
documents cache/span matching and must also explain the opt-out behavior for
phonetic matching when conn is not provided.

Comment on lines +151 to +163
def test_hook_runs_phonetic_fallback_even_with_exact_match(prompt_search, tmp_path):
db_path = tmp_path / "hook-mixed.db"
store = VectorStore(db_path)
person_id = _upsert_person(store)
project_id = store.upsert_entity("project-brainlayer", "project", "BrainLayer", metadata={})
store.add_entity_alias(phonetic_key("Etan"), person_id, alias_type="phonetic")
store.close()

conn = sqlite3.connect(db_path)
matches = prompt_search.detect_entities_in_prompt("Tell me about BrainLayer and what does איתן think?", conn)
conn.close()

assert {match["id"] for match in matches} == {person_id, project_id}
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

Consider resetting module cache state for test consistency.

Other tests in test_conditional_hooks.py explicitly reset prompt_search._ENTITY_CACHE = None and prompt_search._ENTITY_CACHE_DB_PATH = None before running. While this test works correctly due to tmp_path uniqueness (different cache key per test), adding the explicit reset would improve consistency and make the test behavior more explicit.

📝 Suggested addition for consistency
 def test_hook_runs_phonetic_fallback_even_with_exact_match(prompt_search, tmp_path):
     db_path = tmp_path / "hook-mixed.db"
     store = VectorStore(db_path)
     person_id = _upsert_person(store)
     project_id = store.upsert_entity("project-brainlayer", "project", "BrainLayer", metadata={})
     store.add_entity_alias(phonetic_key("Etan"), person_id, alias_type="phonetic")
     store.close()
 
+    prompt_search._ENTITY_CACHE = None
+    prompt_search._ENTITY_CACHE_DB_PATH = None
+
     conn = sqlite3.connect(db_path)
     matches = prompt_search.detect_entities_in_prompt("Tell me about BrainLayer and what does איתן think?", conn)
     conn.close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_hebrew_alias.py` around lines 151 - 163, The test
test_hook_runs_phonetic_fallback_even_with_exact_match relies on a fresh entity
cache but doesn't explicitly reset module cache state; before creating the
VectorStore or calling prompt_search.detect_entities_in_prompt, clear
prompt_search._ENTITY_CACHE and prompt_search._ENTITY_CACHE_DB_PATH by setting
them to None so the test does not depend on tmp_path uniqueness and matches the
reset behavior used in test_conditional_hooks.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@EtanHey EtanHey merged commit d6a54b7 into main Apr 6, 2026
6 checks passed
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