Adding sanity to the recollection decider

This commit is contained in:
2026-04-21 19:09:25 +02:00
parent 128dd653e7
commit ccbb5b2d45
4 changed files with 158 additions and 90 deletions
+56 -30
View File
@@ -44,7 +44,7 @@ from .cue_scanner import scan_cues
from .recollection import build_recollection_block, inject_recollection
from .resolution_job import run_resolution_job, last_run_timestamp
from .tokenizer import tokenize
from .write_queue import enqueue_context_extract, enqueue_cue, start_worker, stop_worker
from .write_queue import enqueue_context_discover, enqueue_cue, start_worker, stop_worker
from .urd_writer import InsertRequest, insert_urd_edge
from .wordnet import import_wordnet, CITATION as WORDNET_CITATION
from .test_scenarios import SCENARIOS, seed_scenario, reset_scenario
@@ -484,6 +484,21 @@ def extract_prompt_text(body: dict, path: str) -> str:
return body.get("prompt", "")
def _last_user_message_text(body: dict, path: str) -> str:
"""
Extract only the last user message for the write path.
Agent responses, thinking traces, and system prompts are excluded —
they are noise for concept discovery.
"""
if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"):
messages = body.get("messages", [])
last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
if last_user:
return " ".join(_extract_text_strings(last_user.get("content", "")))
return ""
return body.get("prompt", "")
def inject_recollection_anthropic(body: dict, block: str) -> dict:
"""
Inject a recollection block into an Anthropic Messages API request.
@@ -512,69 +527,80 @@ async def process_prompt(body: dict, path: str, pool, cfg: dict) -> dict:
Returns a (possibly modified) body dict with the recollection block injected.
"""
read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
write_threshold = float(await get_config(pool, "saliency_write_threshold", "1.2"))
conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
recency_days = int(await get_config(pool, "recollection_recency_days", "90"))
prompt_text = extract_prompt_text(body, path)
if not prompt_text.strip():
# Extract only the last user message — agent responses and reasoning traces
# are noise for both cue scanning and concept discovery.
user_text = _last_user_message_text(body, path)
if not user_text.strip():
return body
# 1. Scan for explicit relationship cues (bypass threshold)
cues = scan_cues(prompt_text)
for cue in cues:
# 1. Scan user message for explicit relationship cues (fast, inline, bypasses LLM).
for cue in scan_cues(user_text):
await enqueue_cue(cue)
# 2. Tokenise + update saliency
# 2. Tokenise the recent context (last user + last assistant) for the read path.
# Novel words from the user turn are also collected as LLM candidates.
prompt_text = extract_prompt_text(body, path)
tokens = tokenize(prompt_text)
salient_for_read: list[int] = []
# Novel domain words found in this turn — not in the standard dictionary.
# Capped to avoid flooding on unexpectedly large turns.
MAX_NOVEL_PER_PROMPT = 8
novel_this_prompt: list[str] = []
# Candidate novel tokens from the USER message only — structural tokens
# (paths, versions, numbers) are filtered out. Capped to avoid flooding
# on very long messages.
MAX_NOVEL_PER_TURN = 8
novel_candidates: list[str] = []
# Only collect candidates from user-side tokens
user_tokens = set(tokenize(user_text))
for token in tokens:
soas_row = cache.soas_by_token.get(token)
if soas_row is None:
# Token absent from dictionary candidate novel domain word.
# Skip structural tokens (paths, versions, numbers).
if not _is_structural_token(token) and len(novel_this_prompt) < MAX_NOVEL_PER_PROMPT:
novel_this_prompt.append(token)
# Token absent from cache entirely candidate domain word.
# Restrict to user-side tokens so we don't mine agent responses.
if (
token in user_tokens
and not _is_structural_token(token)
and len(novel_candidates) < MAX_NOVEL_PER_TURN
):
novel_candidates.append(token)
continue
if soas_row.saliency == 0.0 and soas_row.novelty == 0.0:
# Common English word pre-seeded from the dictionary — not interesting.
# Common English word — skip.
continue
cache.record_encounter(soas_row.id)
# Only surface in recollection if saliency is above threshold.
# Unconfirmed novel words (saliency=NOVEL_INITIAL_SALIENCY=0.1) are
# deliberately kept below the threshold until the LLM confirms them.
if soas_row.saliency >= read_threshold:
salient_for_read.append(soas_row.id)
# Create SOAS entries for novel words and add them to the read list.
# Capture first-seen context so zero-hit recollection can include a hint.
for token in novel_this_prompt:
ctx = _sentence_containing(prompt_text, token)
soas_row = await create_novel_soas(pool, token, context=ctx)
salient_for_read.append(soas_row.id)
# Register novel candidates in SOAS at low saliency (below read threshold).
# They become recollection attractors only after the LLM confirms them.
for token in novel_candidates:
ctx = _sentence_containing(user_text, token)
await create_novel_soas(pool, token, context=ctx)
# Do NOT add to salient_for_read — no zero-hit recollection until confirmed.
# Enqueue context-aware LLM extraction for all novel words found this turn.
# The LLM reads the actual conversation text and extracts relationships from
# evidence — one call per turn, not one per concept.
if novel_this_prompt:
await enqueue_context_extract(novel_this_prompt, prompt_text)
# 3. Enqueue for LLM-driven discovery if there are candidates to evaluate.
if novel_candidates and len(user_text) >= 20:
await enqueue_context_discover(user_text, novel_candidates)
if not salient_for_read:
return body
# 3. Build recollection block
# 5. Build recollection block
block = build_recollection_block(salient_for_read, conf_floor, recency_days)
if not block:
return body
# 4. Inject into messages
# 6. Inject into messages
if path == "/api/chat" or path == "/v1/chat/completions":
body = dict(body)
body["messages"] = inject_recollection(body.get("messages", []), block)