This commit is contained in:
2026-04-21 17:52:27 +02:00
parent ce82678d14
commit 2656951bb9
5 changed files with 93 additions and 24 deletions
+5
View File
@@ -17,6 +17,7 @@ class SoasRow:
encounter_count: int = 0 encounter_count: int = 0
saliency: float = 0.0 saliency: float = 0.0
novelty: float = 0.0 novelty: float = 0.0
first_seen_context: str = ""
@dataclass @dataclass
@@ -48,6 +49,10 @@ urd_by_concept: dict[int, list[UrdEdge]] = {}
# Mirrors Postgres UNIQUE index on (id, dim_id) exactly # Mirrors Postgres UNIQUE index on (id, dim_id) exactly
urd_by_concept_dim: dict[tuple[int, int], UrdEdge] = {} urd_by_concept_dim: dict[tuple[int, int], UrdEdge] = {}
# URD — reverse lookup: parent_id → list of edges where this id is a parent
# Used for bidirectional recollection (surface "gnommoweb is in omega13" when omega13 is mentioned)
urd_by_parent: dict[int, list[UrdEdge]] = {}
# Concepts with pending items in resolution_queue # Concepts with pending items in resolution_queue
pending_conflicts: set[int] = set() pending_conflicts: set[int] = set()
+19 -7
View File
@@ -56,6 +56,10 @@ async def init_schema(pool: asyncpg.Pool) -> None:
await conn.execute( await conn.execute(
"ALTER TABLE models ADD COLUMN IF NOT EXISTS base_url TEXT NOT NULL DEFAULT ''" "ALTER TABLE models ADD COLUMN IF NOT EXISTS base_url TEXT NOT NULL DEFAULT ''"
) )
# Migration: first_seen_context stores the sentence where a novel word first appeared
await conn.execute(
"ALTER TABLE soas ADD COLUMN IF NOT EXISTS first_seen_context TEXT NOT NULL DEFAULT ''"
)
log.info("schema applied") log.info("schema applied")
@@ -121,7 +125,7 @@ async def warm_cache(pool: asyncpg.Pool) -> None:
"""Load all SOAS and URD rows into the in-memory cache.""" """Load all SOAS and URD rows into the in-memory cache."""
async with pool.acquire() as conn: async with pool.acquire() as conn:
soas_rows = await conn.fetch( soas_rows = await conn.fetch(
"SELECT id, token, encounter_count, saliency, novelty FROM soas" "SELECT id, token, encounter_count, saliency, novelty, first_seen_context FROM soas"
) )
for r in soas_rows: for r in soas_rows:
row = SoasRow( row = SoasRow(
@@ -130,6 +134,7 @@ async def warm_cache(pool: asyncpg.Pool) -> None:
encounter_count=r["encounter_count"], encounter_count=r["encounter_count"],
saliency=r["saliency"], saliency=r["saliency"],
novelty=r["novelty"], novelty=r["novelty"],
first_seen_context=r["first_seen_context"] or "",
) )
cache.soas_by_token[r["token"]] = row cache.soas_by_token[r["token"]] = row
cache.soas_by_id[r["id"]] = r["token"] cache.soas_by_id[r["id"]] = r["token"]
@@ -158,6 +163,7 @@ async def warm_cache(pool: asyncpg.Pool) -> None:
) )
cache.urd_by_concept.setdefault(r["id"], []).append(edge) cache.urd_by_concept.setdefault(r["id"], []).append(edge)
cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge
cache.urd_by_parent.setdefault(r["parent_id"], []).append(edge)
pending = await conn.fetch( pending = await conn.fetch(
"SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'" "SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'"
@@ -176,6 +182,7 @@ async def reload_urd_cache(pool: asyncpg.Pool) -> None:
"""Rebuild only URD cache (called after nightly resolution job).""" """Rebuild only URD cache (called after nightly resolution job)."""
cache.urd_by_concept.clear() cache.urd_by_concept.clear()
cache.urd_by_concept_dim.clear() cache.urd_by_concept_dim.clear()
cache.urd_by_parent.clear()
async with pool.acquire() as conn: async with pool.acquire() as conn:
urd_rows = await conn.fetch( urd_rows = await conn.fetch(
""" """
@@ -200,6 +207,7 @@ async def reload_urd_cache(pool: asyncpg.Pool) -> None:
) )
cache.urd_by_concept.setdefault(r["id"], []).append(edge) cache.urd_by_concept.setdefault(r["id"], []).append(edge)
cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge
cache.urd_by_parent.setdefault(r["parent_id"], []).append(edge)
pending = await conn.fetch( pending = await conn.fetch(
"SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'" "SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'"
@@ -221,7 +229,7 @@ async def get_or_create_soas(pool: asyncpg.Pool, token: str) -> SoasRow:
row = await conn.fetchrow( row = await conn.fetchrow(
"INSERT INTO soas (token) VALUES ($1) " "INSERT INTO soas (token) VALUES ($1) "
"ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token " "ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token "
"RETURNING id, token, encounter_count, saliency, novelty", "RETURNING id, token, encounter_count, saliency, novelty, first_seen_context",
token, token,
) )
@@ -231,6 +239,7 @@ async def get_or_create_soas(pool: asyncpg.Pool, token: str) -> SoasRow:
encounter_count=row["encounter_count"], encounter_count=row["encounter_count"],
saliency=row["saliency"], saliency=row["saliency"],
novelty=row["novelty"], novelty=row["novelty"],
first_seen_context=row["first_seen_context"] or "",
) )
cache.soas_by_token[token] = soas_row cache.soas_by_token[token] = soas_row
cache.soas_by_id[row["id"]] = token cache.soas_by_id[row["id"]] = token
@@ -247,24 +256,25 @@ async def get_or_create_soas(pool: asyncpg.Pool, token: str) -> SoasRow:
NOVEL_INITIAL_SALIENCY = 2.0 NOVEL_INITIAL_SALIENCY = 2.0
async def create_novel_soas(pool: asyncpg.Pool, token: str) -> SoasRow: async def create_novel_soas(pool: asyncpg.Pool, token: str, context: str = "") -> SoasRow:
""" """
Insert a domain-specific (non-dictionary) token with an initial saliency Insert a domain-specific (non-dictionary) token with an initial saliency
high enough to trigger recollection on the very first encounter. high enough to trigger recollection on the very first encounter.
novelty=1.0 distinguishes these rows from common-English seeds. novelty=1.0 distinguishes these rows from common-English seeds.
context is the sentence fragment where the word was first encountered.
Idempotent — returns the existing row if already present. Idempotent — returns the existing row if already present.
""" """
async with pool.acquire() as conn: async with pool.acquire() as conn:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
INSERT INTO soas (token, saliency, novelty, encounter_count) INSERT INTO soas (token, saliency, novelty, encounter_count, first_seen_context)
VALUES ($1, $2, 1.0, 1) VALUES ($1, $2, 1.0, 1, $3)
ON CONFLICT (token) DO UPDATE ON CONFLICT (token) DO UPDATE
SET encounter_count = soas.encounter_count + 1, SET encounter_count = soas.encounter_count + 1,
last_seen = now() last_seen = now()
RETURNING id, token, encounter_count, saliency, novelty RETURNING id, token, encounter_count, saliency, novelty, first_seen_context
""", """,
token, NOVEL_INITIAL_SALIENCY, token, NOVEL_INITIAL_SALIENCY, context,
) )
soas_row = SoasRow( soas_row = SoasRow(
id=row["id"], id=row["id"],
@@ -272,6 +282,7 @@ async def create_novel_soas(pool: asyncpg.Pool, token: str) -> SoasRow:
encounter_count=row["encounter_count"], encounter_count=row["encounter_count"],
saliency=row["saliency"], saliency=row["saliency"],
novelty=row["novelty"], novelty=row["novelty"],
first_seen_context=row["first_seen_context"] or "",
) )
cache.soas_by_token[token] = soas_row cache.soas_by_token[token] = soas_row
cache.soas_by_id[row["id"]] = token cache.soas_by_id[row["id"]] = token
@@ -314,6 +325,7 @@ async def reset_graph(pool: asyncpg.Pool) -> dict:
# Clear in-memory state # Clear in-memory state
cache.urd_by_concept.clear() cache.urd_by_concept.clear()
cache.urd_by_concept_dim.clear() cache.urd_by_concept_dim.clear()
cache.urd_by_parent.clear()
cache.pending_conflicts.clear() cache.pending_conflicts.clear()
# Remove domain words from SOAS cache (keep novelty=0 entries) # Remove domain words from SOAS cache (keep novelty=0 entries)
domain_tokens = [t for t, r in list(cache.soas_by_token.items()) if r.novelty > 0] domain_tokens = [t for t, r in list(cache.soas_by_token.items()) if r.novelty > 0]
+46 -9
View File
@@ -20,6 +20,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os import os
import re
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -435,15 +436,49 @@ def _extract_text_strings(content) -> list[str]:
return [] return []
# Tokens that look structural/technical — skip novel-word detection for these.
# Matches: paths (foo/bar), emails (a@b), file extensions (foo.py), dotted names (1.2.3),
# pure numbers, hex literals/colours.
_STRUCTURAL_RE = re.compile(
r"[/@]" # URL-like separator or email @
r"|\.\w" # dotted extension or namespace (e.g. foo.py, omega.13)
r"|^\d+$" # pure digits
r"|^\d[\d.]+\d$" # version string like 1.2 or 3.4.5
r"|^#[0-9a-f]{3,6}$" # hex colour
r"|^0x[0-9a-f]+$", # hex literal
re.IGNORECASE,
)
def _is_structural_token(token: str) -> bool:
"""Return True if token looks like a path, version, number, or URL fragment."""
return bool(_STRUCTURAL_RE.search(token))
def _sentence_containing(text: str, token: str, max_chars: int = 80) -> str:
"""Return a short excerpt of the first sentence containing token (case-insensitive)."""
for sentence in re.split(r"(?<=[.!?])\s+|\n+", text):
if token.lower() in sentence.lower():
return sentence.strip()[:max_chars]
return ""
def extract_prompt_text(body: dict, path: str) -> str: def extract_prompt_text(body: dict, path: str) -> str:
"""Extract a flat string from a request body for saliency processing.""" """
Extract text from the most recent turns only (last user + last assistant message).
Scanning full conversation history on every request is noisy and redundant —
the system prompt in particular is large and stable, so its novel words were
already registered on the first request.
"""
if path in ("/api/chat", "/v1/chat/completions", "/v1/messages"): 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)
last_assistant = next(
(m for m in reversed(messages) if m.get("role") == "assistant"), None
)
parts: list[str] = [] parts: list[str] = []
# system can be a plain string OR a list of content-block dicts (Anthropic format) for m in filter(None, [last_user, last_assistant]):
system = body.get("system")
if system:
parts.extend(_extract_text_strings(system))
for m in body.get("messages", []):
parts.extend(_extract_text_strings(m.get("content", ""))) parts.extend(_extract_text_strings(m.get("content", "")))
return " ".join(parts) return " ".join(parts)
return body.get("prompt", "") return body.get("prompt", "")
@@ -505,8 +540,8 @@ async def process_prompt(body: dict, path: str, pool, cfg: dict) -> dict:
if soas_row is None: if soas_row is None:
# Token absent from dictionary → candidate novel domain word. # Token absent from dictionary → candidate novel domain word.
# Collect for batch processing; apply a per-prompt cap. # Skip structural tokens (paths, versions, numbers) and apply a per-prompt cap.
if len(novel_this_prompt) < MAX_NOVEL_PER_PROMPT: if not _is_structural_token(token) and len(novel_this_prompt) < MAX_NOVEL_PER_PROMPT:
novel_this_prompt.append(token) novel_this_prompt.append(token)
continue continue
@@ -530,8 +565,10 @@ async def process_prompt(body: dict, path: str, pool, cfg: dict) -> dict:
salient_for_write.append(token) salient_for_write.append(token)
# Create SOAS entries for novel words and add them to the read list. # 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: for token in novel_this_prompt:
soas_row = await create_novel_soas(pool, token) ctx = _sentence_containing(prompt_text, token)
soas_row = await create_novel_soas(pool, token, context=ctx)
salient_for_read.append(soas_row.id) salient_for_read.append(soas_row.id)
for token in salient_for_write: for token in salient_for_write:
+22 -8
View File
@@ -17,7 +17,7 @@ from .cache import SoasRow, UrdEdge
log = logging.getLogger("festinger.recollection") log = logging.getLogger("festinger.recollection")
ZERO_HIT_TEMPLATE = "You can't remember what {concept} is. Ask." ZERO_HIT_TEMPLATE = "You have no memory of {concept}. Try: memory_load query='{concept}' or gutask context {concept}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -53,24 +53,36 @@ def query_edges(
# Renderers # Renderers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def render_hit(concept_token: str, edges: list[UrdEdge], concept_id: int) -> str: def render_hit(
concept_token: str,
edges: list[UrdEdge],
concept_id: int,
reverse_edges: list[UrdEdge] | None = None,
) -> str:
""" """
Render one concept's recollection line: Render one concept's recollection line:
gnommoweb: [type] repo [membership] glitch_university gnommoweb: [type] repo [membership] glitch_university
omega13: [membership←] gnommoweb (reverse: gnommoweb is a member of omega13)
Pending-conflict dimensions get a '?' suffix on the dim token. Pending-conflict dimensions get a '?' suffix on the dim token.
""" """
has_pending = concept_id in cache.pending_conflicts has_pending = concept_id in cache.pending_conflicts
parts = [] parts = []
for e in edges: for e in edges:
dim_label = e.dim_token dim_label = e.dim_token + ("?" if has_pending else "")
if has_pending:
dim_label += "?"
parts.append(f"[{dim_label}] {e.parent_token}") parts.append(f"[{dim_label}] {e.parent_token}")
# Bidirectional: surface concepts where this concept appears as a parent
for e in (reverse_edges or [])[:3]:
child_token = cache.soas_by_id.get(e.concept_id, str(e.concept_id))
parts.append(f"[{e.dim_token}←] {child_token}")
return f"{concept_token}: {' '.join(parts)}" return f"{concept_token}: {' '.join(parts)}"
def render_zero_hit(concept_token: str) -> str: def render_zero_hit(concept_token: str) -> str:
return ZERO_HIT_TEMPLATE.format(concept=concept_token) line = ZERO_HIT_TEMPLATE.format(concept=concept_token)
soas_row = cache.soas_by_token.get(concept_token)
if soas_row and soas_row.first_seen_context:
line += f" [first seen: '{soas_row.first_seen_context}']"
return line
def build_recollection_block( def build_recollection_block(
@@ -98,9 +110,11 @@ def build_recollection_block(
token = cache.soas_by_id.get(cid, str(cid)) token = cache.soas_by_id.get(cid, str(cid))
edges = query_edges(cid, confidence_floor, recency_days) edges = query_edges(cid, confidence_floor, recency_days)
# Bidirectional: find edges where this concept appears as a parent
reverse_edges = cache.urd_by_parent.get(cid, [])
if edges: if edges or reverse_edges:
hit_lines.append(render_hit(token, edges, cid)) hit_lines.append(render_hit(token, edges, cid, reverse_edges))
else: else:
zero_lines.append(render_zero_hit(token)) zero_lines.append(render_zero_hit(token))
@@ -132,6 +132,7 @@ async def insert_urd_edge(
) )
cache.urd_by_concept.setdefault(req.concept_id, []).append(edge) cache.urd_by_concept.setdefault(req.concept_id, []).append(edge)
cache.urd_by_concept_dim[key] = edge cache.urd_by_concept_dim[key] = edge
cache.urd_by_parent.setdefault(req.parent_id, []).append(edge)
log.info( log.info(
"urd insert concept=%d parent=%d dim=%d is_isa=%s source=%s", "urd insert concept=%d parent=%d dim=%d is_isa=%s source=%s",
req.concept_id, req.parent_id, req.dim_id, req.is_isa, req.source, req.concept_id, req.parent_id, req.dim_id, req.is_isa, req.source,