Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 66 additions & 6 deletions hooks/brainlayer-prompt-search.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
ENTITY_TOKEN_RE = re.compile(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*|[\u0590-\u05FF]+")
_ENTITY_CACHE = None
_ENTITY_CACHE_DB_PATH = None
LOW_CONFIDENCE_FALLBACK_THRESHOLD = 0.30
LOW_CONFIDENCE_FALLBACK_MESSAGE = (
"No high-confidence memories found. Use brain_search() for deeper retrieval."
)
_KG_ENTITY_CHUNKS_RELATION_TYPE_CACHE = {}


def get_session_context(conn, session_id: str, limit: int = 3) -> list[str]:
Expand Down Expand Up @@ -400,11 +405,9 @@ def truncate(text, max_chars=80):
if len(text) <= max_chars:
return text
candidate = text[:max_chars]
search_start = max(0, max_chars - 40)
for sep in (". ", "! ", "? ", "| "):
idx = candidate.rfind(sep, search_start)
if idx > 0:
return candidate[: idx + len(sep) - 1] + "..."
sentence_matches = list(re.finditer(r"[.!?](?=(?:\s|$))", candidate))
if sentence_matches:
return candidate[: sentence_matches[-1].end()] + "..."
return candidate.rsplit(" ", 1)[0] + "..."


Expand All @@ -428,6 +431,22 @@ def _get_connection_cache_key(conn):
return f"conn:{id(conn)}"


def _kg_entity_chunks_has_relation_type(conn):
cache_key = _get_connection_cache_key(conn)
cached = _KG_ENTITY_CHUNKS_RELATION_TYPE_CACHE.get(cache_key)
if cached is not None:
return cached

try:
columns = {row[1] for row in conn.execute("PRAGMA table_info(kg_entity_chunks)").fetchall()}
except sqlite3.Error:
columns = set()

has_column = "relation_type" in columns
_KG_ENTITY_CHUNKS_RELATION_TYPE_CACHE[cache_key] = has_column
return has_column

Comment on lines +434 to +448
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if relation_type column exists in kg_entity_chunks schema definition
rg -n "kg_entity_chunks" --type py -A 10 | head -60

# Check if there's a migration adding relation_type to kg_entity_chunks
rg -n "relation_type" --type py -C 3 | grep -i "kg_entity_chunks" | head -20

Repository: EtanHey/brainlayer

Length of output: 4918


🏁 Script executed:

# Find and examine the actual kg_entity_chunks schema definition in vector_store.py
rg -n "CREATE TABLE kg_entity_chunks" --type py -A 20

# Also check for any ALTER TABLE statements that might add relation_type
rg -n "ALTER TABLE kg_entity_chunks" --type py -A 5

Repository: EtanHey/brainlayer

Length of output: 4124


🏁 Script executed:

# Find the initial CREATE TABLE for kg_entity_chunks in vector_store.py
rg -n "CREATE TABLE kg_entity_chunks" src/brainlayer/vector_store.py -A 10

# Also search broadly for relation_type in production code (not tests)
rg -n "relation_type" src/brainlayer/ --type py

Repository: EtanHey/brainlayer

Length of output: 8273


🏁 Script executed:

# Look around the ALTER TABLE statements to find initial CREATE TABLE
sed -n '600,900p' src/brainlayer/vector_store.py | cat -n | grep -E "(CREATE TABLE|kg_entity_chunks|ALTER TABLE)" -A 3 -B 3

# Also try direct search with broader context
rg -n "CREATE TABLE" src/brainlayer/vector_store.py | grep -i entity

Repository: EtanHey/brainlayer

Length of output: 6993


🏁 Script executed:

# Check how _kg_entity_chunks_has_relation_type is used in the hook
rg -n "_kg_entity_chunks_has_relation_type" hooks/brainlayer-prompt-search.py -B 2 -A 8

# Look at line 644 context mentioned in initial results
sed -n '640,660p' hooks/brainlayer-prompt-search.py

Repository: EtanHey/brainlayer

Length of output: 1691


🏁 Script executed:

# Search for TODOs, FIXMEs, or comments about relation_type and kg_entity_chunks
rg -n "TODO|FIXME|XXX" hooks/brainlayer-prompt-search.py | head -20

# Check if there are any schema migration files or pending work
find . -name "*migrat*" -type f 2>/dev/null | head -10

# Search for any comments mentioning this forward compatibility
rg -n "relation_type.*kg_entity_chunks|kg_entity_chunks.*relation_type" --type py -B 3 -A 3

Repository: EtanHey/brainlayer

Length of output: 2170


🏁 Script executed:

# Check the migrate.py file for relation_type migration
cat src/brainlayer/migrate.py | head -100

# Also search for any migration or TODO comments about this
rg -n "relation_type" src/brainlayer/migrate.py -B 3 -A 3

Repository: EtanHey/brainlayer

Length of output: 3299


🏁 Script executed:

# Search for any schema evolution or chunk lifecycle plans
rg -n "relation_type|lifecycle|archived_at" src/brainlayer/ --type py | grep -E "(mention_type|archived|superseded)" | head -20

# Check if relation_type filtering is actually needed by looking at where co_occurs_with is used
rg -n "co_occurs_with" src/brainlayer/ --type py | head -15

Repository: EtanHey/brainlayer

Length of output: 2885


Document forward-compatible schema detection or clarify the intended timeline for relation_type in kg_entity_chunks.

The defensive check is sound—the relation_type column doesn't exist in kg_entity_chunks in production (created at vector_store.py:81-89 with only entity_id, chunk_id, relevance), and co_occurs_with filtering happens at the kg_relations level, not here. The function gracefully handles this by returning an empty filter string when the column is absent, so no filtering occurs in production.

However, the test schema in test_prompt_classification.py:40-45 includes relation_type in kg_entity_chunks, suggesting this may be planned. Either document this as intentional forward-compatibility or clarify whether and when relation_type will be added to the production schema.

🤖 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 434 - 448, The function
_kg_entity_chunks_has_relation_type currently defensively checks for a future
relation_type column and caches the result in
_KG_ENTITY_CHUNKS_RELATION_TYPE_CACHE; update the codebase by either (A) adding
a brief docstring above _kg_entity_chunks_has_relation_type explaining this is
forward-compatible support for an expected relation_type column (and note
where/when it will be added, e.g., vector_store.py schema changes), or (B) if
relation_type is not planned, remove the check and related test usage and update
test_prompt_classification to match production schema; reference the function
name _kg_entity_chunks_has_relation_type and the cache symbol
_KG_ENTITY_CHUNKS_RELATION_TYPE_CACHE when making the change so reviewers can
locate and verify the update.


def _load_entity_cache(conn=None):
global _ENTITY_CACHE, _ENTITY_CACHE_DB_PATH

Expand Down Expand Up @@ -621,12 +640,17 @@ def detect_entities_in_prompt(prompt, conn=None):
def get_entity_chunks(entity_id, conn, limit=3):
"""Get top linked chunk summaries for an entity."""
try:
relation_filter = ""
if _kg_entity_chunks_has_relation_type(conn):
relation_filter = "AND COALESCE(ec.relation_type, '') != 'co_occurs_with'"

rows = conn.execute(
"""
f"""
SELECT c.content, c.created_at, c.project
FROM kg_entity_chunks ec
JOIN chunks c ON c.id = ec.chunk_id
WHERE ec.entity_id = ?
{relation_filter}
AND COALESCE(c.project, '') != 'eval-sandbox'
AND COALESCE(c.tags, '') NOT LIKE '%"eval-test"%'
ORDER BY ec.relevance DESC
Expand Down Expand Up @@ -710,6 +734,24 @@ def select_adaptive_injection_rows(rows, entity_count=0, light_mode=False):
return strategic_reorder(selected[:MAX_ADAPTIVE_INJECTION])


def build_low_confidence_fallback(rows):
if not rows:
return LOW_CONFIDENCE_FALLBACK_MESSAGE

top_row = rows[0]
if isinstance(top_row, dict):
relevance = top_row.get("relevance")
if relevance is None:
relevance = top_row.get("rrf_score")
else:
relevance = None

if relevance is not None and relevance < LOW_CONFIDENCE_FALLBACK_THRESHOLD:
return LOW_CONFIDENCE_FALLBACK_MESSAGE

return None

Comment on lines +737 to +753
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 | 🟠 Major

Bug: fallback_rows always have rrf_score=0.0, causing fallback to always trigger.

The fallback_rows constructed at lines 1218-1229 hardcode rrf_score: 0.0 for all rows. When build_low_confidence_fallback() checks the top row's relevance/rrf_score, it will always find 0.0 < 0.30, causing the fallback message to always be emitted when not lines is true.

If the intent is to use the actual FTS rank or relevance score, you need to compute a meaningful score. If the intent is to always show the fallback when no search results were injected, the threshold check is unnecessary.

🔧 Possible fix if intent is to always emit fallback when no lines
 def build_low_confidence_fallback(rows):
     if not rows:
         return LOW_CONFIDENCE_FALLBACK_MESSAGE
-
-    top_row = rows[0]
-    if isinstance(top_row, dict):
-        relevance = top_row.get("relevance")
-        if relevance is None:
-            relevance = top_row.get("rrf_score")
-    else:
-        relevance = None
-
-    if relevance is not None and relevance < LOW_CONFIDENCE_FALLBACK_THRESHOLD:
-        return LOW_CONFIDENCE_FALLBACK_MESSAGE
-
-    return None
+    # Always return fallback when called (caller determines when to call)
+    return LOW_CONFIDENCE_FALLBACK_MESSAGE

Alternatively, compute actual relevance scores in fallback_rows instead of hardcoding 0.0.

Also applies to: 1218-1229

🤖 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 737 - 753, The fallback_rows
creation currently hardcodes rrf_score=0.0 which makes
build_low_confidence_fallback() always treat the top row as low-confidence;
either compute a real relevance/rrf score for those synthetic rows or stop
setting rrf_score at all so build_low_confidence_fallback() won't see a numeric
score and won't auto-trigger fallback. Update the code that builds fallback_rows
(the block that sets "rrf_score: 0.0") to either calculate a proper relevance
value or omit/set rrf_score to None, and keep
build_low_confidence_fallback(top_row.get("relevance") /
top_row.get("rrf_score")) logic unchanged.


def _ensure_src_on_syspath():
src_path = Path(__file__).resolve().parents[1] / "src"
if src_path.exists():
Expand Down Expand Up @@ -1014,6 +1056,7 @@ def main():
new_chunk_ids = []
new_briefs = []
entities_detected = 0
fallback_rows = []

def finalize_and_exit(*, mode=None):
final_mode = mode or telemetry_mode
Expand Down Expand Up @@ -1172,6 +1215,18 @@ def finalize_and_exit(*, mode=None):
if len(filtered_rows) >= base_limit:
break

fallback_rows = [
{
"id": chunk_id,
"content": content,
"importance": importance,
"project": project,
"tags": tags,
"created_at": created_at,
"rrf_score": 0.0,
}
for chunk_id, content, importance, project, tags, created_at in filtered_rows
]
new_chunk_ids, new_briefs = inject_search_results(lines, filtered_rows, deep, label=label)
except sqlite3.Error:
pass
Expand All @@ -1186,6 +1241,11 @@ def finalize_and_exit(*, mode=None):
"Run brain_search() to verify before stating any personal details (hardware, history, names)."
)

if not lines:
fallback = build_low_confidence_fallback(fallback_rows)
Comment on lines +1244 to +1245
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Base fallback emission on retrieval state, not rendered lines

In main(), the low-confidence message is gated by if not lines, but lines is a presentation buffer rather than a retrieval signal. After dedup, filtered_rows can be empty even when the original query returned relevant chunks that were already injected, so this branch emits “No high-confidence memories found” for repeated/follow-up prompts where memories do exist. That is misleading and can spam fallback guidance in active sessions; the condition should be tied to actual search outcome/new-memory availability rather than whether any text was appended to lines.

Useful? React with 👍 / 👎.

if fallback:
lines.append(fallback)

Comment on lines +1244 to +1248
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

Logic concern: Fallback only applies to FTS-based paths, not entity lookup.

The fallback_rows variable is only populated in the knowledge_question, follow_up, and hebrew_query branches. For entity_lookup classification, fallback_rows remains empty, so build_low_confidence_fallback([]) would return the fallback message even if entity cards were successfully injected but lines was empty for other reasons.

Consider whether this is the intended behavior or if entity lookup should have its own fallback handling.

🤖 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 1244 - 1248, The current
logic unconditionally calls build_low_confidence_fallback(fallback_rows) when
lines is empty, but fallback_rows is only populated for the FTS paths
(knowledge_question, follow_up, hebrew_query) and not for entity_lookup; change
the condition so the low-confidence fallback is only built/added when
fallback_rows is non-empty or when the classification is one of the FTS branches
(e.g., knowledge_question, follow_up, hebrew_query), and for entity_lookup
either skip building that fallback or implement a separate entity-specific
fallback; update the block referencing fallback_rows,
build_low_confidence_fallback, and the entity_lookup classification to guard the
fallback call accordingly.

if lines:
print("\n".join(lines))

Expand Down
19 changes: 19 additions & 0 deletions tests/test_adaptive_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,22 @@ def test_fallback_to_fts_only(self, prompt_search, monkeypatch):

assert used_hybrid is False
assert [row["id"] for row in rows] == ["fts-best"]

def test_no_results_falls_back_to_low_confidence_message(self, prompt_search):
message = prompt_search.build_low_confidence_fallback([])

assert message == "No high-confidence memories found. Use brain_search() for deeper retrieval."

def test_low_relevance_rows_emit_fallback_message(self, prompt_search):
rows = [_row("low", 0.29)]

message = prompt_search.build_low_confidence_fallback(rows)

assert message == "No high-confidence memories found. Use brain_search() for deeper retrieval."

def test_high_relevance_rows_do_not_emit_fallback_message(self, prompt_search):
rows = [_row("high", 0.30)]

message = prompt_search.build_low_confidence_fallback(rows)

assert message is None
10 changes: 10 additions & 0 deletions tests/test_hook_slim.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ def test_truncate_prefers_sentence_boundary(self, hook):
assert result.endswith("...")
assert len(result) <= 83

def test_truncate_cuts_at_last_sentence_end_before_limit(self, hook):
text = (
"Short sentence. Another complete sentence. "
"This final sentence extends well beyond the truncation limit and should be omitted."
)

result = hook.truncate(text, max_chars=55)

assert result == "Short sentence. Another complete sentence...."


class TestResultCountCap:
"""Result count must be capped at MAX_ADAPTIVE_INJECTION (3)."""
Expand Down
36 changes: 33 additions & 3 deletions tests/test_prompt_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ def make_hook_db(db_path: Path) -> None:
)
conn.execute("CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id UNINDEXED, content)")
conn.execute("CREATE TABLE kg_entities (id TEXT PRIMARY KEY, name TEXT, entity_type TEXT)")
conn.execute("CREATE TABLE kg_entity_chunks (entity_id TEXT, chunk_id TEXT, relevance REAL)")
conn.execute(
"""
CREATE TABLE kg_entity_chunks (
entity_id TEXT,
chunk_id TEXT,
relevance REAL,
relation_type TEXT
)
"""
)

conn.execute(
"""
Expand Down Expand Up @@ -79,8 +88,26 @@ def make_hook_db(db_path: Path) -> None:
("person-theo", "Theo Browne", "person"),
)
conn.execute(
"INSERT INTO kg_entity_chunks (entity_id, chunk_id, relevance) VALUES (?, ?, ?)",
("person-theo", "chunk-theo", 0.9),
"INSERT INTO kg_entity_chunks (entity_id, chunk_id, relevance, relation_type) VALUES (?, ?, ?, ?)",
("person-theo", "chunk-theo", 0.9, "mentioned_in"),
)
conn.execute(
"""
INSERT INTO chunks (id, content, importance, project, tags, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
"chunk-theo-noise",
"Theo Browne appears in low-signal co-occurrence noise that should stay hidden.",
5,
"brainlayer",
'["people"]',
"2026-04-03T10:00:00Z",
),
)
conn.execute(
"INSERT INTO kg_entity_chunks (entity_id, chunk_id, relevance, relation_type) VALUES (?, ?, ?, ?)",
("person-theo", "chunk-theo-noise", 0.99, "co_occurs_with"),
)
conn.commit()
conn.close()
Expand Down Expand Up @@ -137,6 +164,7 @@ def test_classify_long_prompt_not_casual():

def test_command_skips_retrieval(monkeypatch, capsys):
module = load_prompt_search_module()
monkeypatch.setattr(module, "get_db_path", lambda: None)

def fail_connect(*args, **kwargs):
raise AssertionError("sqlite3.connect should not be called for command prompts")
Expand All @@ -148,6 +176,7 @@ def fail_connect(*args, **kwargs):

def test_casual_skips_retrieval(monkeypatch, capsys):
module = load_prompt_search_module()
monkeypatch.setattr(module, "get_db_path", lambda: None)

def fail_connect(*args, **kwargs):
raise AssertionError("sqlite3.connect should not be called for casual prompts")
Expand All @@ -167,6 +196,7 @@ def test_entity_route_injects_card(tmp_path, monkeypatch, capsys):

assert "[Entity: Theo Browne" in output
assert "Theo Browne is linked to BrainLayer collaboration notes." in output
assert "co-occurrence noise" not in output


def test_knowledge_route_injects_chunks(tmp_path, monkeypatch, capsys):
Expand Down
Loading