Compare commits

...
1 Commits
Author SHA1 Message Date
Nicolò Boschi 7fc38e2ae3 chore: drop dead code 2026-01-27 12:33:34 +01:00
5 changed files with 7 additions and 602 deletions
@@ -141,7 +141,6 @@ from .entity_resolver import EntityResolver
from .llm_wrapper import LLMConfig
from .query_analyzer import QueryAnalyzer
from .reflect import run_reflect_agent
from .reflect.models import ObservationInput
from .reflect.tools import tool_expand, tool_recall, tool_search_mental_models, tool_search_observations
from .response_models import (
VALID_RECALL_FACT_TYPES,
@@ -535,7 +534,7 @@ class MemoryEngine(MemoryEngineInterface):
"""
Handler for consolidation tasks.
Consolidates new memories into learnings for a bank.
Consolidates new memories into mental models for a bank.
Args:
task_dict: Dict with 'bank_id'
@@ -1610,7 +1609,6 @@ class MemoryEngine(MemoryEngineInterface):
)
# Filter out 'opinion' - opinions are no longer returned from recall
# (learnings are now stored as mental models instead)
fact_type = [ft for ft in fact_type if ft != "opinion"]
if not fact_type:
# All requested types were opinions - return empty result
@@ -3519,7 +3517,7 @@ class MemoryEngine(MemoryEngineInterface):
ReflectResult containing:
- text: Plain text answer
- based_on: Empty dict (agent retrieves facts dynamically)
- new_opinions: Empty list (learnings stored as mental models)
- new_opinions: Empty list
- structured_output: None (not yet supported for agentic reflect)
"""
# Use cached LLM config
@@ -4362,314 +4360,6 @@ class MemoryEngine(MemoryEngineInterface):
)
return updated_count
# =========================================================================
# LEARNINGS CRUD
# =========================================================================
async def list_learnings(
self,
bank_id: str,
*,
tags: list[str] | None = None,
tags_match: str = "any",
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""List learnings for a bank.
Args:
bank_id: Bank identifier
tags: Optional tags to filter by
tags_match: How to match tags - 'any', 'all', or 'exact'
limit: Maximum number of results
offset: Offset for pagination
request_context: Request context for authentication
Returns:
List of learning dicts
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
# Build tag filter
tag_filter = ""
params: list[Any] = [bank_id, limit, offset]
if tags:
if tags_match == "all":
tag_filter = " AND tags @> $4::varchar[]"
elif tags_match == "exact":
tag_filter = " AND tags = $4::varchar[]"
else: # any
tag_filter = " AND tags && $4::varchar[]"
params.append(tags)
rows = await conn.fetch(
f"""
SELECT id, bank_id, text, proof_count, history, mission_context,
pre_mission_change, tags, created_at, updated_at
FROM {fq_table("learnings")}
WHERE bank_id = $1 {tag_filter}
ORDER BY proof_count DESC, updated_at DESC
LIMIT $2 OFFSET $3
""",
*params,
)
return [self._row_to_learning(row) for row in rows]
async def get_learning(
self,
bank_id: str,
learning_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""Get a single learning by ID.
Args:
bank_id: Bank identifier
learning_id: Learning UUID
request_context: Request context for authentication
Returns:
Learning dict or None if not found
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"""
SELECT id, bank_id, text, proof_count, history, mission_context,
pre_mission_change, tags, created_at, updated_at
FROM {fq_table("learnings")}
WHERE bank_id = $1 AND id = $2
""",
bank_id,
learning_id,
)
return self._row_to_learning(row) if row else None
async def create_learning(
self,
bank_id: str,
text: str,
*,
proof_count: int = 1,
tags: list[str] | None = None,
mission_context: str | None = None,
request_context: "RequestContext",
) -> dict[str, Any]:
"""Create a new learning.
Args:
bank_id: Bank identifier
text: The learning text
proof_count: Initial proof count (default 1)
tags: Optional tags for scoped visibility
mission_context: Hash of mission when created
request_context: Request context for authentication
Returns:
The created learning dict
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
# Generate embedding for the learning text
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
# Convert embedding to string for asyncpg vector type
embedding_str = str(embedding[0]) if embedding else None
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("learnings")}
(bank_id, text, proof_count, mission_context, embedding, tags)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, bank_id, text, proof_count, history, mission_context,
pre_mission_change, tags, created_at, updated_at
""",
bank_id,
text,
proof_count,
mission_context,
embedding_str,
tags or [],
)
logger.info(f"[LEARNINGS] Created learning for bank {bank_id}: {text[:50]}...")
return self._row_to_learning(row)
async def update_learning(
self,
bank_id: str,
learning_id: str,
*,
text: str | None = None,
increment_proof: bool = False,
add_history: dict[str, Any] | None = None,
mark_pre_mission_change: bool = False,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""Update a learning.
Args:
bank_id: Bank identifier
learning_id: Learning UUID
text: New text (if changing)
increment_proof: Whether to increment proof_count
add_history: History entry to append (for contradictions)
mark_pre_mission_change: Whether to mark as pre-mission-change
request_context: Request context for authentication
Returns:
Updated learning dict or None if not found
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
# Build dynamic update
updates = ["updated_at = NOW()"]
params: list[Any] = [bank_id, learning_id]
param_idx = 3
if text is not None:
updates.append(f"text = ${param_idx}")
params.append(text)
param_idx += 1
# Also update embedding (convert to string for asyncpg vector type)
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
if embedding:
updates.append(f"embedding = ${param_idx}")
params.append(str(embedding[0]))
param_idx += 1
if increment_proof:
updates.append("proof_count = proof_count + 1")
if add_history:
import json
updates.append(f"history = history || ${param_idx}::jsonb")
params.append(json.dumps([add_history]))
param_idx += 1
if mark_pre_mission_change:
updates.append("pre_mission_change = TRUE")
query = f"""
UPDATE {fq_table("learnings")}
SET {", ".join(updates)}
WHERE bank_id = $1 AND id = $2
RETURNING id, bank_id, text, proof_count, history, mission_context,
pre_mission_change, tags, created_at, updated_at
"""
row = await conn.fetchrow(query, *params)
return self._row_to_learning(row) if row else None
async def delete_learning(
self,
bank_id: str,
learning_id: str,
*,
request_context: "RequestContext",
) -> bool:
"""Delete a learning.
Args:
bank_id: Bank identifier
learning_id: Learning UUID
request_context: Request context for authentication
Returns:
True if deleted, False if not found
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
result = await conn.execute(
f"DELETE FROM {fq_table('learnings')} WHERE bank_id = $1 AND id = $2",
bank_id,
learning_id,
)
return result == "DELETE 1"
def _row_to_learning(self, row) -> dict[str, Any]:
"""Convert a database row to a learning dict."""
import json
# Parse history - asyncpg may return JSONB as string in some cases
history = row["history"]
if isinstance(history, str):
history = json.loads(history)
elif history is None:
history = []
return {
"id": str(row["id"]),
"bank_id": row["bank_id"],
"text": row["text"],
"proof_count": row["proof_count"],
"history": history,
"mission_context": row["mission_context"],
"pre_mission_change": row["pre_mission_change"],
"tags": row["tags"] or [],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
async def mark_learnings_pre_mission_change(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> int:
"""Mark all learnings as pre-mission-change when mission changes.
Args:
bank_id: Bank identifier
request_context: Request context for authentication
Returns:
Number of learnings marked
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
result = await conn.execute(
f"""
UPDATE {fq_table("learnings")}
SET pre_mission_change = TRUE, updated_at = NOW()
WHERE bank_id = $1 AND pre_mission_change = FALSE
""",
bank_id,
)
# Also update bank's mission_changed_at
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET mission_changed_at = NOW()
WHERE bank_id = $1
""",
bank_id,
)
count = int(result.split()[-1]) if result and "UPDATE" in result else 0
if count > 0:
logger.info(f"[LEARNINGS] Marked {count} learnings as pre-mission-change for bank {bank_id}")
return count
# =========================================================================
# MENTAL MODELS (CONSOLIDATED) - Read-only access to auto-consolidated mental models
# =========================================================================
@@ -4,17 +4,15 @@ Reflect agent module for agentic reflection with tools.
The reflect agent uses an iterative loop with tools to:
1. Lookup mental models (existing knowledge)
2. Recall facts (semantic + temporal search)
3. Learn new insights (create/update observations)
4. Expand memories (get chunk/document context)
3. Expand memories (get chunk/document context)
"""
from .agent import ReflectAgentResult, run_reflect_agent
from .models import ObservationInput, ReflectAction, ReflectActionBatch
from .models import ReflectAction, ReflectActionBatch
__all__ = [
"run_reflect_agent",
"ReflectAgentResult",
"ReflectAction",
"ReflectActionBatch",
"ObservationInput",
]
@@ -15,41 +15,18 @@ class ObservationSection(BaseModel):
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
class ObservationInput(BaseModel):
"""Input for the learn tool to create an observation placeholder.
The agent only specifies name and description - the actual content/sections
are generated during refresh, similar to pinned models.
"""
name: str = Field(description="Human-readable name for the observation")
description: str = Field(description="What to track - used as prompt for content generation during refresh")
entity_id: str | None = Field(default=None, description="Optional link to existing entity ID")
class AnswerSection(BaseModel):
"""A section of the answer with its supporting evidence (DEPRECATED)."""
title: str = Field(description="Section header/title")
text: str = Field(description="Section content")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
model_ids: list[str] = Field(default_factory=list, description="Mental model IDs supporting this section")
class ReflectAction(BaseModel):
"""Single action the reflect agent can take."""
tool: Literal["list_observations", "get_observation", "recall", "learn", "expand", "done"] = Field(
description="Tool to invoke: list_observations, get_observation, recall, learn, expand, or done"
tool: Literal["list_observations", "get_observation", "recall", "expand", "done"] = Field(
description="Tool to invoke: list_observations, get_observation, recall, expand, or done"
)
# Tool-specific parameters
observation_id: str | None = Field(default=None, description="Observation ID for get_observation")
query: str | None = Field(default=None, description="Search query for recall")
max_tokens: int | None = Field(default=None, description="Max tokens for recall results (default 2048)")
observation: ObservationInput | None = Field(default=None, description="Observation to create/update for learn")
memory_ids: list[str] | None = Field(default=None, description="Memory unit IDs for expand (batched)")
depth: Literal["chunk", "document"] | None = Field(default=None, description="Expansion depth for expand")
sections: list[AnswerSection] | None = Field(default=None, description="DEPRECATED: Use answer field instead")
observation_sections: list[ObservationSection] | None = Field(
default=None, description="Observation sections for done action (when output_mode=observations)"
)
@@ -73,7 +50,7 @@ class ReflectActionBatch(BaseModel):
class ToolCall(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
tool: str = Field(description="Tool name: lookup, recall, expand")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
@@ -1,134 +0,0 @@
"""
Scoring functions for memory search and retrieval.
Includes recency weighting, frequency weighting, temporal proximity,
and similarity calculations used in memory activation and ranking.
"""
from datetime import datetime
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
Args:
vec1: First vector
vec2: Second vector
Returns:
Similarity score between 0 and 1
"""
if len(vec1) != len(vec2):
raise ValueError("Vectors must have same dimension")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
"""
Calculate recency weight using logarithmic decay.
This provides much better differentiation over long time periods compared to
exponential decay. Uses a log-based decay where the half-life parameter controls
when memories reach 50% weight.
Examples:
- Today (0 days): 1.0
- 1 year (365 days): ~0.5 (with default half_life=365)
- 2 years (730 days): ~0.33
- 5 years (1825 days): ~0.17
- 10 years (3650 days): ~0.09
This ensures that 2-year-old and 5-year-old memories have meaningfully
different weights, unlike exponential decay which makes them both ~0.
Args:
days_since: Number of days since the memory was created
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
Returns:
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
return 1.0 / (1.0 + math.log1p(normalized_age))
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
"""
Calculate a single temporal anchor point from a temporal range.
Used for spreading activation - we need a single representative date
to calculate temporal proximity between facts. This simplifies the
range-to-range distance problem.
Strategy: Use midpoint of the range for balanced representation.
Args:
occurred_start: Start of temporal range
occurred_end: End of temporal range
Returns:
Single datetime representing the temporal anchor (midpoint)
Examples:
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
"""
# Calculate midpoint
time_delta = occurred_end - occurred_start
midpoint = occurred_start + (time_delta / 2)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
Used for spreading activation to determine how "close" two facts are
in time. Uses logarithmic decay so that temporal similarity doesn't
drop off too quickly.
Args:
anchor_a: Temporal anchor of first fact
anchor_b: Temporal anchor of second fact
half_life_days: Number of days for proximity to reach 0.5
(default: 30 days = 1 month)
Returns:
Proximity score in [0, 1] where:
- 1.0 = same day
- 0.5 = ~half_life days apart
- 0.0 = very distant in time
Examples:
- Same day: 1.0
- 1 week apart (half_life=30): ~0.7
- 1 month apart (half_life=30): ~0.5
- 1 year apart (half_life=30): ~0.2
"""
import math
days_apart = abs((anchor_a - anchor_b).days)
if days_apart == 0:
return 1.0
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
# Similar to calculate_recency_weight but for proximity between events
normalized_distance = days_apart / half_life_days
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
return proximity
-126
View File
@@ -65,129 +65,3 @@ async def extract_facts(
return [], chunks
return facts, chunks
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
Args:
vec1: First vector
vec2: Second vector
Returns:
Similarity score between 0 and 1
"""
if len(vec1) != len(vec2):
raise ValueError("Vectors must have same dimension")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
"""
Calculate recency weight using logarithmic decay.
This provides much better differentiation over long time periods compared to
exponential decay. Uses a log-based decay where the half-life parameter controls
when memories reach 50% weight.
Examples:
- Today (0 days): 1.0
- 1 year (365 days): ~0.5 (with default half_life=365)
- 2 years (730 days): ~0.33
- 5 years (1825 days): ~0.17
- 10 years (3650 days): ~0.09
This ensures that 2-year-old and 5-year-old memories have meaningfully
different weights, unlike exponential decay which makes them both ~0.
Args:
days_since: Number of days since the memory was created
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
Returns:
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
return 1.0 / (1.0 + math.log1p(normalized_age))
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
"""
Calculate a single temporal anchor point from a temporal range.
Used for spreading activation - we need a single representative date
to calculate temporal proximity between facts. This simplifies the
range-to-range distance problem.
Strategy: Use midpoint of the range for balanced representation.
Args:
occurred_start: Start of temporal range
occurred_end: End of temporal range
Returns:
Single datetime representing the temporal anchor (midpoint)
Examples:
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
"""
# Calculate midpoint
time_delta = occurred_end - occurred_start
midpoint = occurred_start + (time_delta / 2)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
Used for spreading activation to determine how "close" two facts are
in time. Uses logarithmic decay so that temporal similarity doesn't
drop off too quickly.
Args:
anchor_a: Temporal anchor of first fact
anchor_b: Temporal anchor of second fact
half_life_days: Number of days for proximity to reach 0.5
(default: 30 days = 1 month)
Returns:
Proximity score in [0, 1] where:
- 1.0 = same day
- 0.5 = ~half_life days apart
- 0.0 = very distant in time
Examples:
- Same day: 1.0
- 1 week apart (half_life=30): ~0.7
- 1 month apart (half_life=30): ~0.5
- 1 year apart (half_life=30): ~0.2
"""
import math
days_apart = abs((anchor_a - anchor_b).days)
if days_apart == 0:
return 1.0
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
# Similar to calculate_recency_weight but for proximity between events
normalized_distance = days_apart / half_life_days
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
return proximity