improvements async

This commit is contained in:
Nicolò Boschi 2025-10-30 19:40:39 +01:00
parent bc7d9fe07f
commit 8c698d6dfb
7 changed files with 2771 additions and 3171 deletions

File diff suppressed because it is too large Load diff

View file

@ -154,7 +154,7 @@ async def answer_question(memory: TemporalSemanticMemory, agent_id: str, questio
try: try:
client = AsyncOpenAI() client = AsyncOpenAI()
response = await client.beta.chat.completions.parse( response = await client.beta.chat.completions.parse(
model="gpt-4o-mini", model="gpt-5",
messages=[ messages=[
{ {
"role": "system", "role": "system",
@ -165,8 +165,7 @@ async def answer_question(memory: TemporalSemanticMemory, agent_id: str, questio
"content": f"Context:\n{context}\n\nQuestion: {question}\n\nAnswer:" "content": f"Context:\n{context}\n\nQuestion: {question}\n\nAnswer:"
} }
], ],
temperature=0,
max_tokens=8000,
response_format=QuestionAnswer response_format=QuestionAnswer
) )
answer = response.choices[0].message.parsed answer = response.choices[0].message.parsed

View file

@ -5,8 +5,10 @@ Uses spaCy for entity extraction and implements resolution logic
to disambiguate entities across memory units. to disambiguate entities across memory units.
""" """
import spacy import spacy
import asyncpg
from typing import List, Dict, Optional, Set from typing import List, Dict, Optional, Set
from difflib import SequenceMatcher from difflib import SequenceMatcher
from datetime import datetime, timezone
# Load spaCy model (singleton) # Load spaCy model (singleton)
@ -90,21 +92,22 @@ class EntityResolver:
Resolves entities to canonical IDs with disambiguation. Resolves entities to canonical IDs with disambiguation.
""" """
def __init__(self, db_conn): def __init__(self, pool: asyncpg.Pool):
""" """
Initialize entity resolver. Initialize entity resolver.
Args: Args:
db_conn: psycopg2 database connection pool: asyncpg connection pool
""" """
self.conn = db_conn self.pool = pool
def resolve_entities_batch( async def resolve_entities_batch(
self, self,
agent_id: str, agent_id: str,
entities_data: List[Dict], entities_data: List[Dict],
context: str, context: str,
unit_event_date, unit_event_date,
conn=None,
) -> List[str]: ) -> List[str]:
""" """
Resolve multiple entities in batch (MUCH faster than sequential). Resolve multiple entities in batch (MUCH faster than sequential).
@ -117,6 +120,7 @@ class EntityResolver:
entities_data: List of dicts with 'text', 'type', 'nearby_entities' entities_data: List of dicts with 'text', 'type', 'nearby_entities'
context: Context where entities appear context: Context where entities appear
unit_event_date: When this unit was created unit_event_date: When this unit was created
conn: Optional connection to use (if None, acquires from pool)
Returns: Returns:
List of entity IDs in same order as input List of entity IDs in same order as input
@ -124,9 +128,13 @@ class EntityResolver:
if not entities_data: if not entities_data:
return [] return []
cursor = self.conn.cursor() if conn is None:
async with self.pool.acquire() as conn:
return await self._resolve_entities_batch_impl(conn, agent_id, entities_data, context, unit_event_date)
else:
return await self._resolve_entities_batch_impl(conn, agent_id, entities_data, context, unit_event_date)
try: async def _resolve_entities_batch_impl(self, conn, agent_id: str, entities_data: List[Dict], context: str, unit_event_date) -> List[str]:
import time import time
start = time.time() start = time.time()
@ -145,22 +153,25 @@ class EntityResolver:
entity_texts = list(set(e[1]['text'] for e in entities_list)) entity_texts = list(set(e[1]['text'] for e in entities_list))
# Query candidates for all texts at once # Query candidates for all texts at once
from psycopg2.extras import execute_values type_candidates = await conn.fetch(
cursor.execute(
""" """
SELECT canonical_name, id, metadata, last_seen, mention_count SELECT canonical_name, id, metadata, last_seen, mention_count
FROM entities FROM entities
WHERE agent_id = %s AND entity_type = %s WHERE agent_id = $1 AND entity_type = $2
""", """,
(agent_id, entity_type) agent_id, entity_type
) )
type_candidates = cursor.fetchall()
# Filter candidates in memory (faster than complex SQL for small datasets) # Filter candidates in memory (faster than complex SQL for small datasets)
for entity_text in entity_texts: for entity_text in entity_texts:
matching = [] matching = []
entity_text_lower = entity_text.lower() entity_text_lower = entity_text.lower()
for canonical_name, ent_id, metadata, last_seen, mention_count in type_candidates: for row in type_candidates:
canonical_name = row['canonical_name']
ent_id = row['id']
metadata = row['metadata']
last_seen = row['last_seen']
mention_count = row['mention_count']
canonical_lower = canonical_name.lower() canonical_lower = canonical_name.lower()
# Same matching logic as before # Same matching logic as before
if (entity_text_lower == canonical_lower or if (entity_text_lower == canonical_lower or
@ -227,15 +238,12 @@ class EntityResolver:
# Batch update existing entities # Batch update existing entities
if entities_to_update: if entities_to_update:
from psycopg2.extras import execute_values await conn.executemany(
execute_values(
cursor,
""" """
UPDATE entities SET UPDATE entities SET
mention_count = mention_count + 1, mention_count = mention_count + 1,
last_seen = data.last_seen last_seen = $2
FROM (VALUES %s) AS data(id, last_seen) WHERE id = $1::uuid
WHERE entities.id = data.id::uuid
""", """,
entities_to_update entities_to_update
) )
@ -243,18 +251,15 @@ class EntityResolver:
# Batch create new entities # Batch create new entities
if entities_to_create: if entities_to_create:
for idx, entity_data in entities_to_create: for idx, entity_data in entities_to_create:
entity_id = self._create_entity( entity_id = await self._create_entity(
cursor, agent_id, entity_data['text'], conn, agent_id, entity_data['text'],
entity_data['type'], unit_event_date entity_data['type'], unit_event_date
) )
entity_ids[idx] = entity_id entity_ids[idx] = entity_id
return entity_ids return entity_ids
finally: async def resolve_entity(
cursor.close()
def resolve_entity(
self, self,
agent_id: str, agent_id: str,
entity_text: str, entity_text: str,
@ -277,32 +282,28 @@ class EntityResolver:
Returns: Returns:
Entity ID (creates new entity if needed) Entity ID (creates new entity if needed)
""" """
cursor = self.conn.cursor() async with self.pool.acquire() as conn:
try:
# Find candidate entities with same type and similar name # Find candidate entities with same type and similar name
cursor.execute( candidates = await conn.fetch(
""" """
SELECT id, canonical_name, metadata, last_seen SELECT id, canonical_name, metadata, last_seen
FROM entities FROM entities
WHERE agent_id = %s WHERE agent_id = $1
AND entity_type = %s AND entity_type = $2
AND ( AND (
canonical_name ILIKE %s canonical_name ILIKE $3
OR canonical_name ILIKE %s OR canonical_name ILIKE $4
OR %s ILIKE canonical_name || '%%' OR $3 ILIKE canonical_name || '%%'
) )
ORDER BY mention_count DESC ORDER BY mention_count DESC
""", """,
(agent_id, entity_type, entity_text, f"%{entity_text}%", entity_text) agent_id, entity_type, entity_text, f"%{entity_text}%"
) )
candidates = cursor.fetchall()
if not candidates: if not candidates:
# New entity - create it # New entity - create it
return self._create_entity( return await self._create_entity(
cursor, agent_id, entity_text, entity_type, unit_event_date conn, agent_id, entity_text, entity_type, unit_event_date
) )
# Score candidates based on: # Score candidates based on:
@ -317,7 +318,11 @@ class EntityResolver:
nearby_entity_set = {e['text'].lower() for e in nearby_entities if e['text'] != entity_text} nearby_entity_set = {e['text'].lower() for e in nearby_entities if e['text'] != entity_text}
for candidate_id, canonical_name, metadata, last_seen in candidates: for row in candidates:
candidate_id = row['id']
canonical_name = row['canonical_name']
metadata = row['metadata']
last_seen = row['last_seen']
score = 0.0 score = 0.0
# 1. Name similarity (0-1) # 1. Name similarity (0-1)
@ -331,21 +336,21 @@ class EntityResolver:
# 2. Co-occurring entities (0-0.5) # 2. Co-occurring entities (0-0.5)
# Get entities that co-occurred with this candidate before # Get entities that co-occurred with this candidate before
# Use the materialized co-occurrence cache for fast lookup # Use the materialized co-occurrence cache for fast lookup
cursor.execute( co_entity_rows = await conn.fetch(
""" """
SELECT e.canonical_name, ec.cooccurrence_count SELECT e.canonical_name, ec.cooccurrence_count
FROM entity_cooccurrences ec FROM entity_cooccurrences ec
JOIN entities e ON ( JOIN entities e ON (
CASE CASE
WHEN ec.entity_id_1 = %s THEN ec.entity_id_2 WHEN ec.entity_id_1 = $1 THEN ec.entity_id_2
WHEN ec.entity_id_2 = %s THEN ec.entity_id_1 WHEN ec.entity_id_2 = $1 THEN ec.entity_id_1
END = e.id END = e.id
) )
WHERE ec.entity_id_1 = %s OR ec.entity_id_2 = %s WHERE ec.entity_id_1 = $1 OR ec.entity_id_2 = $1
""", """,
(candidate_id, candidate_id, candidate_id, candidate_id) candidate_id
) )
co_entities = {row[0].lower() for row in cursor.fetchall()} co_entities = {r['canonical_name'].lower() for r in co_entity_rows}
# Check overlap with nearby entities # Check overlap with nearby entities
overlap = len(nearby_entity_set & co_entities) overlap = len(nearby_entity_set & co_entities)
@ -371,28 +376,25 @@ class EntityResolver:
if best_score > threshold: if best_score > threshold:
# Update entity # Update entity
cursor.execute( await conn.execute(
""" """
UPDATE entities UPDATE entities
SET mention_count = mention_count + 1, SET mention_count = mention_count + 1,
last_seen = %s last_seen = $1
WHERE id = %s WHERE id = $2
""", """,
(unit_event_date, best_candidate) unit_event_date, best_candidate
) )
return best_candidate return best_candidate
else: else:
# Not confident - create new entity # Not confident - create new entity
return self._create_entity( return await self._create_entity(
cursor, agent_id, entity_text, entity_type, unit_event_date conn, agent_id, entity_text, entity_type, unit_event_date
) )
finally: async def _create_entity(
cursor.close()
def _create_entity(
self, self,
cursor, conn,
agent_id: str, agent_id: str,
entity_text: str, entity_text: str,
entity_type: str, entity_type: str,
@ -402,7 +404,7 @@ class EntityResolver:
Create a new entity. Create a new entity.
Args: Args:
cursor: Database cursor conn: Database connection
agent_id: Agent ID agent_id: Agent ID
entity_text: Entity text entity_text: Entity text
entity_type: Entity type entity_type: Entity type
@ -411,18 +413,17 @@ class EntityResolver:
Returns: Returns:
Entity ID Entity ID
""" """
cursor.execute( entity_id = await conn.fetchval(
""" """
INSERT INTO entities (agent_id, canonical_name, entity_type, first_seen, last_seen, mention_count) INSERT INTO entities (agent_id, canonical_name, entity_type, first_seen, last_seen, mention_count)
VALUES (%s, %s, %s, %s, %s, 1) VALUES ($1, $2, $3, $4, $5, 1)
RETURNING id RETURNING id
""", """,
(agent_id, entity_text, entity_type, event_date, event_date) agent_id, entity_text, entity_type, event_date, event_date
) )
entity_id = cursor.fetchone()[0]
return entity_id return entity_id
def link_unit_to_entity(self, unit_id: str, entity_id: str): async def link_unit_to_entity(self, unit_id: str, entity_id: str):
""" """
Link a memory unit to an entity. Link a memory unit to an entity.
Also updates co-occurrence cache with other entities in the same unit. Also updates co-occurrence cache with other entities in the same unit.
@ -431,45 +432,41 @@ class EntityResolver:
unit_id: Memory unit ID unit_id: Memory unit ID
entity_id: Entity ID entity_id: Entity ID
""" """
cursor = self.conn.cursor() async with self.pool.acquire() as conn:
try:
# Insert unit-entity link # Insert unit-entity link
cursor.execute( await conn.execute(
""" """
INSERT INTO unit_entities (unit_id, entity_id) INSERT INTO unit_entities (unit_id, entity_id)
VALUES (%s, %s) VALUES ($1, $2)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
""", """,
(unit_id, entity_id) unit_id, entity_id
) )
# Update co-occurrence cache: find other entities in this unit # Update co-occurrence cache: find other entities in this unit
cursor.execute( rows = await conn.fetch(
""" """
SELECT entity_id SELECT entity_id
FROM unit_entities FROM unit_entities
WHERE unit_id = %s AND entity_id != %s WHERE unit_id = $1 AND entity_id != $2
""", """,
(unit_id, entity_id) unit_id, entity_id
) )
other_entities = [row[0] for row in cursor.fetchall()] other_entities = [row['entity_id'] for row in rows]
# Update co-occurrences for each pair # Update co-occurrences for each pair
for other_entity_id in other_entities: for other_entity_id in other_entities:
self._update_cooccurrence(cursor, entity_id, other_entity_id) await self._update_cooccurrence(conn, entity_id, other_entity_id)
finally: async def _update_cooccurrence(self, conn, entity_id_1: str, entity_id_2: str):
cursor.close()
def _update_cooccurrence(self, cursor, entity_id_1: str, entity_id_2: str):
""" """
Update the co-occurrence cache for two entities. Update the co-occurrence cache for two entities.
Uses CHECK constraint ordering (entity_id_1 < entity_id_2) to avoid duplicates. Uses CHECK constraint ordering (entity_id_1 < entity_id_2) to avoid duplicates.
Args: Args:
cursor: Database cursor conn: Database connection
entity_id_1: First entity ID entity_id_1: First entity ID
entity_id_2: Second entity ID entity_id_2: Second entity ID
""" """
@ -477,19 +474,19 @@ class EntityResolver:
if entity_id_1 > entity_id_2: if entity_id_1 > entity_id_2:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1 entity_id_1, entity_id_2 = entity_id_2, entity_id_1
cursor.execute( await conn.execute(
""" """
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred) INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES (%s, %s, 1, NOW()) VALUES ($1, $2, 1, NOW())
ON CONFLICT (entity_id_1, entity_id_2) ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET DO UPDATE SET
cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1, cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1,
last_cooccurred = NOW() last_cooccurred = NOW()
""", """,
(entity_id_1, entity_id_2) entity_id_1, entity_id_2
) )
def link_units_to_entities_batch(self, unit_entity_pairs: List[tuple[str, str]]): async def link_units_to_entities_batch(self, unit_entity_pairs: List[tuple[str, str]], conn=None):
""" """
Link multiple memory units to entities in batch (MUCH faster than sequential). Link multiple memory units to entities in batch (MUCH faster than sequential).
@ -497,19 +494,23 @@ class EntityResolver:
Args: Args:
unit_entity_pairs: List of (unit_id, entity_id) tuples unit_entity_pairs: List of (unit_id, entity_id) tuples
conn: Optional connection to use (if None, acquires from pool)
""" """
if not unit_entity_pairs: if not unit_entity_pairs:
return return
cursor = self.conn.cursor() if conn is None:
try: async with self.pool.acquire() as conn:
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
else:
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: List[tuple[str, str]]):
# Batch insert all unit-entity links # Batch insert all unit-entity links
from psycopg2.extras import execute_values await conn.executemany(
execute_values(
cursor,
""" """
INSERT INTO unit_entities (unit_id, entity_id) INSERT INTO unit_entities (unit_id, entity_id)
VALUES %s VALUES ($1, $2)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
""", """,
unit_entity_pairs unit_entity_pairs
@ -540,13 +541,11 @@ class EntityResolver:
# Batch update co-occurrences # Batch update co-occurrences
if cooccurrence_pairs: if cooccurrence_pairs:
from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
execute_values( await conn.executemany(
cursor,
""" """
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred) INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES %s VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id_1, entity_id_2) ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET DO UPDATE SET
cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1, cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1,
@ -555,10 +554,7 @@ class EntityResolver:
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs] [(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs]
) )
finally: async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> List[str]:
cursor.close()
def get_units_by_entity(self, entity_id: str, limit: int = 100) -> List[str]:
""" """
Get all units that mention an entity. Get all units that mention an entity.
@ -569,23 +565,20 @@ class EntityResolver:
Returns: Returns:
List of unit IDs List of unit IDs
""" """
cursor = self.conn.cursor() async with self.pool.acquire() as conn:
try: rows = await conn.fetch(
cursor.execute(
""" """
SELECT unit_id SELECT unit_id
FROM unit_entities FROM unit_entities
WHERE entity_id = %s WHERE entity_id = $1
ORDER BY unit_id ORDER BY unit_id
LIMIT %s LIMIT $2
""", """,
(entity_id, limit) entity_id, limit
) )
return [row[0] for row in cursor.fetchall()] return [row['unit_id'] for row in rows]
finally:
cursor.close()
def get_entity_by_text( async def get_entity_by_text(
self, self,
agent_id: str, agent_id: str,
entity_text: str, entity_text: str,
@ -602,33 +595,29 @@ class EntityResolver:
Returns: Returns:
Entity ID if found, None otherwise Entity ID if found, None otherwise
""" """
cursor = self.conn.cursor() async with self.pool.acquire() as conn:
try:
if entity_type: if entity_type:
cursor.execute( row = await conn.fetchrow(
""" """
SELECT id FROM entities SELECT id FROM entities
WHERE agent_id = %s WHERE agent_id = $1
AND entity_type = %s AND entity_type = $2
AND canonical_name ILIKE %s AND canonical_name ILIKE $3
ORDER BY mention_count DESC ORDER BY mention_count DESC
LIMIT 1 LIMIT 1
""", """,
(agent_id, entity_type, entity_text) agent_id, entity_type, entity_text
) )
else: else:
cursor.execute( row = await conn.fetchrow(
""" """
SELECT id FROM entities SELECT id FROM entities
WHERE agent_id = %s WHERE agent_id = $1
AND canonical_name ILIKE %s AND canonical_name ILIKE $2
ORDER BY mention_count DESC ORDER BY mention_count DESC
LIMIT 1 LIMIT 1
""", """,
(agent_id, entity_text) agent_id, entity_text
) )
row = cursor.fetchone() return row['id'] if row else None
return row[0] if row else None
finally:
cursor.close()

View file

@ -11,9 +11,7 @@ This implements a sophisticated memory architecture that combines:
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import psycopg2 import asyncpg
from psycopg2.extras import RealDictCursor, execute_values
from pgvector.psycopg2 import register_vector
from sentence_transformers import SentenceTransformer from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv from dotenv import load_dotenv
import asyncio import asyncio
@ -92,7 +90,7 @@ class TemporalSemanticMemory:
""" """
load_dotenv() load_dotenv()
# Initialize PostgreSQL connection # Initialize PostgreSQL connection URL
self.db_url = db_url or os.getenv("DATABASE_URL") self.db_url = db_url or os.getenv("DATABASE_URL")
if not self.db_url: if not self.db_url:
raise ValueError( raise ValueError(
@ -100,21 +98,40 @@ class TemporalSemanticMemory:
"Set DATABASE_URL environment variable." "Set DATABASE_URL environment variable."
) )
self.conn = psycopg2.connect(self.db_url) # Connection pool (created lazily on first use)
register_vector(self.conn) self._pool = None
self._pool_lock = asyncio.Lock()
# Initialize entity resolver # Initialize entity resolver (will be created with pool)
self.entity_resolver = EntityResolver(self.conn) self.entity_resolver = None
# Initialize local embedding model (384 dimensions) # Initialize local embedding model (384 dimensions)
print(f"Loading embedding model: {embedding_model}...") print(f"Loading embedding model: {embedding_model}...")
self.embedding_model = SentenceTransformer(embedding_model) self.embedding_model = SentenceTransformer(embedding_model)
print(f"✓ Model loaded (embedding dim: {self.embedding_model.get_sentence_embedding_dimension()})") print(f"✓ Model loaded (embedding dim: {self.embedding_model.get_sentence_embedding_dimension()})")
def __del__(self): async def _get_pool(self) -> asyncpg.Pool:
"""Clean up database connection.""" """Get or create the connection pool (lazy initialization)."""
if hasattr(self, 'conn') and self.conn: if self._pool is None:
self.conn.close() async with self._pool_lock:
if self._pool is None:
self._pool = await asyncpg.create_pool(
self.db_url,
min_size=2,
max_size=10,
command_timeout=60,
statement_cache_size=0 # Disable prepared statement cache
)
# Initialize entity resolver with pool
if self.entity_resolver is None:
self.entity_resolver = EntityResolver(self._pool)
return self._pool
async def close(self):
"""Close the connection pool."""
if self._pool is not None:
await self._pool.close()
self._pool = None
def _generate_embedding(self, text: str) -> List[float]: def _generate_embedding(self, text: str) -> List[float]:
""" """
@ -161,9 +178,9 @@ class TemporalSemanticMemory:
except Exception as e: except Exception as e:
raise Exception(f"Failed to generate batch embeddings: {str(e)}") raise Exception(f"Failed to generate batch embeddings: {str(e)}")
def _find_duplicate_facts_batch( async def _find_duplicate_facts_batch(
self, self,
cursor, conn,
agent_id: str, agent_id: str,
texts: List[str], texts: List[str],
embeddings: List[List[float]], embeddings: List[List[float]],
@ -178,7 +195,7 @@ class TemporalSemanticMemory:
within the time window. Uses pgvector cosine similarity for efficiency. within the time window. Uses pgvector cosine similarity for efficiency.
Args: Args:
cursor: Database cursor conn: Database connection
agent_id: Agent identifier agent_id: Agent identifier
texts: List of fact texts to check texts: List of fact texts to check
embeddings: Corresponding embeddings embeddings: Corresponding embeddings
@ -196,20 +213,21 @@ class TemporalSemanticMemory:
for text, embedding in zip(texts, embeddings): for text, embedding in zip(texts, embeddings):
# Query for similar facts within time window # Query for similar facts within time window
cursor.execute( # Convert embedding list to string for asyncpg vector type
embedding_str = str(embedding)
result = await conn.fetchrow(
""" """
SELECT id, text, 1 - (embedding <=> %s::vector) AS similarity SELECT id, text, 1 - (embedding <=> $1::vector) AS similarity
FROM memory_units FROM memory_units
WHERE agent_id = %s WHERE agent_id = $2
AND event_date BETWEEN %s AND %s AND event_date BETWEEN $3 AND $4
AND 1 - (embedding <=> %s::vector) > %s AND 1 - (embedding <=> $1::vector) > $5
ORDER BY similarity DESC ORDER BY similarity DESC
LIMIT 1 LIMIT 1
""", """,
(embedding, agent_id, time_lower, time_upper, embedding, similarity_threshold) embedding_str, agent_id, time_lower, time_upper, similarity_threshold
) )
result = cursor.fetchone()
if result: if result:
is_duplicate.append(True) is_duplicate.append(True)
else: else:
@ -369,14 +387,16 @@ class TemporalSemanticMemory:
print(f"[2] Generate embeddings (parallel): {len(all_embeddings)} embeddings in {time.time() - step_start:.3f}s") print(f"[2] Generate embeddings (parallel): {len(all_embeddings)} embeddings in {time.time() - step_start:.3f}s")
# Step 3: Process everything in ONE database transaction # Step 3: Process everything in ONE database transaction
cursor = self.conn.cursor() pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
try: try:
# Deduplication check for all facts # Deduplication check for all facts
step_start = time.time() step_start = time.time()
all_is_duplicate = [] all_is_duplicate = []
for sentence, embedding, fact_date in zip(all_fact_texts, all_embeddings, all_fact_dates): for sentence, embedding, fact_date in zip(all_fact_texts, all_embeddings, all_fact_dates):
dup_flags = self._find_duplicate_facts_batch( dup_flags = await self._find_duplicate_facts_batch(
cursor, agent_id, [sentence], [embedding], fact_date conn, agent_id, [sentence], [embedding], fact_date
) )
all_is_duplicate.extend(dup_flags) all_is_duplicate.extend(dup_flags)
@ -396,54 +416,50 @@ class TemporalSemanticMemory:
# Batch insert ALL units # Batch insert ALL units
step_start = time.time() step_start = time.time()
from psycopg2.extras import execute_values # Convert embeddings to strings for asyncpg vector type
unit_data = [ filtered_embeddings_str = [str(emb) for emb in filtered_embeddings]
(agent_id, sentence, context, embedding, date, 0) # access_count starts at 0 results = await conn.fetch(
for sentence, context, embedding, date in zip(
filtered_sentences, filtered_contexts, filtered_embeddings, filtered_dates
)
]
results = execute_values(
cursor,
""" """
INSERT INTO memory_units (agent_id, text, context, embedding, event_date, access_count) INSERT INTO memory_units (agent_id, text, context, embedding, event_date, access_count)
VALUES %s SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::vector[], $5::timestamptz[], $6::integer[])
RETURNING id RETURNING id
""", """,
unit_data, [agent_id] * len(filtered_sentences),
fetch=True filtered_sentences,
filtered_contexts,
filtered_embeddings_str,
filtered_dates,
[0] * len(filtered_sentences)
) )
created_unit_ids = [str(row[0]) for row in results] created_unit_ids = [str(row['id']) for row in results]
print(f"[5] Batch insert units: {len(created_unit_ids)} units in {time.time() - step_start:.3f}s") print(f"[5] Batch insert units: {len(created_unit_ids)} units in {time.time() - step_start:.3f}s")
# Process entities for ALL units # Process entities for ALL units
step_start = time.time() step_start = time.time()
all_entity_links = self._extract_entities_batch_optimized( all_entity_links = await self._extract_entities_batch_optimized(
cursor, agent_id, created_unit_ids, filtered_sentences, "", filtered_dates conn, agent_id, created_unit_ids, filtered_sentences, "", filtered_dates
) )
print(f"[6] Extract entities (batched): {time.time() - step_start:.3f}s") print(f"[6] Extract entities (batched): {time.time() - step_start:.3f}s")
# Create temporal links # Create temporal links
step_start = time.time() step_start = time.time()
self._create_temporal_links_batch_per_fact(cursor, agent_id, created_unit_ids) await self._create_temporal_links_batch_per_fact(conn, agent_id, created_unit_ids)
print(f"[7] Batch create temporal links: {time.time() - step_start:.3f}s") print(f"[7] Batch create temporal links: {time.time() - step_start:.3f}s")
# Create semantic links # Create semantic links
step_start = time.time() step_start = time.time()
self._create_semantic_links_batch(cursor, agent_id, created_unit_ids, filtered_embeddings) await self._create_semantic_links_batch(conn, agent_id, created_unit_ids, filtered_embeddings)
print(f"[8] Batch create semantic links: {time.time() - step_start:.3f}s") print(f"[8] Batch create semantic links: {time.time() - step_start:.3f}s")
# Insert entity links # Insert entity links
step_start = time.time() step_start = time.time()
if all_entity_links: if all_entity_links:
self._insert_entity_links_batch(cursor, all_entity_links) await self._insert_entity_links_batch(conn, all_entity_links)
print(f"[9] Batch insert entity links: {time.time() - step_start:.3f}s") print(f"[9] Batch insert entity links: {time.time() - step_start:.3f}s")
# Commit everything # Transaction auto-commits on success
commit_start = time.time() commit_start = time.time()
self.conn.commit()
print(f"[10] Commit: {time.time() - commit_start:.3f}s") print(f"[10] Commit: {time.time() - commit_start:.3f}s")
# Map created unit IDs back to original content items # Map created unit IDs back to original content items
@ -467,137 +483,10 @@ class TemporalSemanticMemory:
return result_unit_ids return result_unit_ids
except Exception as e: except Exception as e:
self.conn.rollback() # Transaction auto-rolls back on exception
import traceback
traceback.print_exc()
raise Exception(f"Failed to store batch memory: {str(e)}") raise Exception(f"Failed to store batch memory: {str(e)}")
finally:
cursor.close()
def _create_temporal_links(
self,
cursor,
agent_id: str,
unit_id: str,
event_date: datetime,
time_window_hours: int = 24,
):
"""
Create temporal links to recent memories.
Links this unit to other units that occurred within a time window.
Args:
cursor: Database cursor
agent_id: Agent ID
unit_id: ID of the current unit
event_date: When this event occurred
time_window_hours: Size of the temporal window
"""
try:
# Get recent units within time window
cursor.execute(
"""
SELECT id, event_date
FROM memory_units
WHERE agent_id = %s
AND id != %s
AND event_date >= %s
ORDER BY event_date DESC
LIMIT 10
""",
(agent_id, unit_id, event_date - timedelta(hours=time_window_hours))
)
recent_units = cursor.fetchall()
# Create links to recent units
links = []
for recent_id, recent_event_date in recent_units:
# Calculate temporal proximity weight
time_diff_hours = abs((event_date - recent_event_date).total_seconds() / 3600)
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
links.append((unit_id, recent_id, 'temporal', weight, None))
if links:
execute_values(
cursor,
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES %s
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
links
)
except Exception as e:
print(f"ERROR: Failed to create temporal links: {str(e)}")
import traceback
traceback.print_exc()
# Re-raise to trigger rollback at put_async level
raise
def _create_semantic_links(
self,
cursor,
agent_id: str,
unit_id: str,
embedding: List[float],
top_k: int = 5,
threshold: float = 0.7,
):
"""
Create semantic links to similar memories.
Links this unit to other units with similar meaning.
Args:
cursor: Database cursor
agent_id: Agent ID
unit_id: ID of the current unit
embedding: Embedding of the current unit
top_k: Number of similar units to link to
threshold: Minimum similarity threshold
"""
try:
# Find similar units using vector similarity
cursor.execute(
"""
SELECT id, 1 - (embedding <=> %s::vector) AS similarity
FROM memory_units
WHERE agent_id = %s
AND id != %s
AND embedding IS NOT NULL
AND (1 - (embedding <=> %s::vector)) >= %s
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(embedding, agent_id, unit_id, embedding, threshold, embedding, top_k)
)
similar_units = cursor.fetchall()
# Create links to similar units
links = []
for similar_id, similarity in similar_units:
links.append((unit_id, similar_id, 'semantic', float(similarity), None))
if links:
execute_values(
cursor,
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES %s
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
links
)
except Exception as e:
print(f"ERROR: Failed to create semantic links: {str(e)}")
import traceback
traceback.print_exc()
# Re-raise to trigger rollback at put_async level
raise
def search( def search(
self, self,
@ -653,8 +542,8 @@ class TemporalSemanticMemory:
Returns: Returns:
List of memory units with their weights, sorted by relevance List of memory units with their weights, sorted by relevance
""" """
cursor = self.conn.cursor(cursor_factory=RealDictCursor) pool = await self._get_pool()
async with pool.acquire() as conn:
search_start = time.time() search_start = time.time()
print(f"\n[SEARCH] Starting search for query: '{query[:50]}...' (thinking_budget={thinking_budget}, top_k={top_k})") print(f"\n[SEARCH] Starting search for query: '{query[:50]}...' (thinking_budget={thinking_budget}, top_k={top_k})")
@ -666,21 +555,22 @@ class TemporalSemanticMemory:
# Step 2: Find entry points # Step 2: Find entry points
step_start = time.time() step_start = time.time()
cursor.execute( # Convert embedding to string for asyncpg
query_embedding_str = str(query_embedding)
entry_points = await conn.fetch(
""" """
SELECT id, text, context, event_date, access_count, embedding, SELECT id, text, context, event_date, access_count,
1 - (embedding <=> %s::vector) AS similarity 1 - (embedding <=> $1::vector) AS similarity
FROM memory_units FROM memory_units
WHERE agent_id = %s WHERE agent_id = $2
AND embedding IS NOT NULL AND embedding IS NOT NULL
AND (1 - (embedding <=> %s::vector)) >= 0.5 AND (1 - (embedding <=> $1::vector)) >= 0.5
ORDER BY embedding <=> %s::vector ORDER BY embedding <=> $1::vector
LIMIT 3 LIMIT 3
""", """,
(query_embedding, agent_id, query_embedding, query_embedding) query_embedding_str, agent_id
) )
entry_points = cursor.fetchall()
print(f" [2] Find entry points: {len(entry_points)} found in {time.time() - step_start:.3f}s") print(f" [2] Find entry points: {len(entry_points)} found in {time.time() - step_start:.3f}s")
if not entry_points: if not entry_points:
@ -722,27 +612,26 @@ class TemporalSemanticMemory:
# Update access counts for batch # Update access counts for batch
substep_start = time.time() substep_start = time.time()
node_ids = [str(node[0]["id"]) for node in nodes_to_process] node_ids = [str(node[0]["id"]) for node in nodes_to_process]
cursor.execute( await conn.execute(
"UPDATE memory_units SET access_count = access_count + 1 WHERE id::text = ANY(%s)", "UPDATE memory_units SET access_count = access_count + 1 WHERE id::text = ANY($1)",
(node_ids,) node_ids
) )
update_access_time += time.time() - substep_start update_access_time += time.time() - substep_start
# Query neighbors for ALL nodes in batch at once # Query neighbors for ALL nodes in batch at once
substep_start = time.time() substep_start = time.time()
cursor.execute( all_neighbors = await conn.fetch(
""" """
SELECT ml.from_unit_id, ml.to_unit_id, ml.weight, SELECT ml.from_unit_id, ml.to_unit_id, ml.weight,
mu.text, mu.context, mu.event_date, mu.access_count, mu.embedding mu.text, mu.context, mu.event_date, mu.access_count
FROM memory_links ml FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id JOIN memory_units mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id::text = ANY(%s) WHERE ml.from_unit_id::text = ANY($1)
AND ml.weight >= 0.1 AND ml.weight >= 0.1
ORDER BY ml.from_unit_id, ml.weight DESC ORDER BY ml.from_unit_id, ml.weight DESC
""", """,
(node_ids,) node_ids
) )
all_neighbors = cursor.fetchall()
query_neighbors_time += time.time() - substep_start query_neighbors_time += time.time() - substep_start
# Group neighbors by from_unit_id # Group neighbors by from_unit_id
@ -841,27 +730,20 @@ class TemporalSemanticMemory:
print(f" [3.3] Query neighbors: {query_neighbors_time:.3f}s ({num_batches} batched queries)") print(f" [3.3] Query neighbors: {query_neighbors_time:.3f}s ({num_batches} batched queries)")
print(f" [3.4] Process neighbors: {process_neighbors_time:.3f}s") print(f" [3.4] Process neighbors: {process_neighbors_time:.3f}s")
step_start = time.time()
self.conn.commit()
print(f" [4] Commit: {time.time() - step_start:.3f}s")
# Step 4: Sort by final weight and return top results # Step 4: Sort by final weight and return top results
step_start = time.time() step_start = time.time()
results.sort(key=lambda x: x["weight"], reverse=True) results.sort(key=lambda x: x["weight"], reverse=True)
top_results = results[:top_k] top_results = results[:top_k]
print(f" [5] Sort and return top {top_k}: {time.time() - step_start:.3f}s") print(f" [4] Sort and return top {top_k}: {time.time() - step_start:.3f}s")
print(f"[SEARCH] Complete: {len(top_results)} results in {time.time() - search_start:.3f}s\n") print(f"[SEARCH] Complete: {len(top_results)} results in {time.time() - search_start:.3f}s\n")
return top_results return top_results
except Exception as e: except Exception as e:
print(f"[SEARCH] ERROR after {time.time() - search_start:.3f}s: {str(e)}") print(f"[SEARCH] ERROR after {time.time() - search_start:.3f}s: {str(e)}")
self.conn.rollback()
raise Exception(f"Failed to search memories: {str(e)}") raise Exception(f"Failed to search memories: {str(e)}")
finally:
cursor.close()
def delete_agent(self, agent_id: str) -> Dict[str, int]: async def delete_agent(self, agent_id: str) -> Dict[str, int]:
""" """
Delete all data for a specific agent (multi-tenant cleanup). Delete all data for a specific agent (multi-tenant cleanup).
@ -879,23 +761,19 @@ class TemporalSemanticMemory:
Returns: Returns:
Dictionary with counts of deleted items Dictionary with counts of deleted items
""" """
cursor = self.conn.cursor() pool = await self._get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
try: try:
# Count before deletion for reporting # Count before deletion for reporting
cursor.execute("SELECT COUNT(*) FROM memory_units WHERE agent_id = %s", (agent_id,)) units_count = await conn.fetchval("SELECT COUNT(*) FROM memory_units WHERE agent_id = $1", agent_id)
units_count = cursor.fetchone()[0] entities_count = await conn.fetchval("SELECT COUNT(*) FROM entities WHERE agent_id = $1", agent_id)
cursor.execute("SELECT COUNT(*) FROM entities WHERE agent_id = %s", (agent_id,))
entities_count = cursor.fetchone()[0]
# Delete memory units (cascades to unit_entities, memory_links) # Delete memory units (cascades to unit_entities, memory_links)
cursor.execute("DELETE FROM memory_units WHERE agent_id = %s", (agent_id,)) await conn.execute("DELETE FROM memory_units WHERE agent_id = $1", agent_id)
# Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id) # Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id)
cursor.execute("DELETE FROM entities WHERE agent_id = %s", (agent_id,)) await conn.execute("DELETE FROM entities WHERE agent_id = $1", agent_id)
self.conn.commit()
return { return {
"memory_units_deleted": units_count, "memory_units_deleted": units_count,
@ -903,64 +781,11 @@ class TemporalSemanticMemory:
} }
except Exception as e: except Exception as e:
self.conn.rollback()
raise Exception(f"Failed to delete agent data: {str(e)}") raise Exception(f"Failed to delete agent data: {str(e)}")
finally:
cursor.close()
def get_memory_graph_data(self, agent_id: str = None) -> Tuple[List[Dict], List[Dict]]: async def _extract_entities_batch_optimized(
"""
Get memory graph data for visualization.
Args:
agent_id: Optional agent ID (if None, returns all data)
Returns:
Tuple of (units, links) for visualization
"""
cursor = self.conn.cursor(cursor_factory=RealDictCursor)
try:
# Get all units (optionally filtered by agent)
if agent_id:
cursor.execute(
"SELECT id, text, context, event_date, access_count FROM memory_units WHERE agent_id = %s",
(agent_id,)
)
else:
cursor.execute(
"SELECT id, text, context, event_date, access_count FROM memory_units"
)
units = [dict(row) for row in cursor.fetchall()]
# Get all links (optionally filtered by agent)
if agent_id:
cursor.execute(
"""
SELECT ml.from_unit_id, ml.to_unit_id, ml.link_type, ml.weight
FROM memory_links ml
JOIN memory_units mu1 ON ml.from_unit_id = mu1.id
JOIN memory_units mu2 ON ml.to_unit_id = mu2.id
WHERE mu1.agent_id = %s
""",
(agent_id,)
)
else:
cursor.execute(
"SELECT from_unit_id, to_unit_id, link_type, weight FROM memory_links"
)
links = [dict(row) for row in cursor.fetchall()]
return units, links
except Exception as e:
raise Exception(f"Failed to get memory graph data: {str(e)}")
finally:
cursor.close()
def _extract_entities_batch_optimized(
self, self,
cursor, conn,
agent_id: str, agent_id: str,
unit_ids: List[str], unit_ids: List[str],
sentences: List[str], sentences: List[str],
@ -1024,11 +849,12 @@ class TemporalSemanticMemory:
indices = [idx for idx, _ in entities_group] indices = [idx for idx, _ in entities_group]
entities_data = [entity_data for _, entity_data in entities_group] entities_data = [entity_data for _, entity_data in entities_group]
batch_resolved = self.entity_resolver.resolve_entities_batch( batch_resolved = await self.entity_resolver.resolve_entities_batch(
agent_id=agent_id, agent_id=agent_id,
entities_data=entities_data, entities_data=entities_data,
context=context, context=context,
unit_event_date=fact_date unit_event_date=fact_date,
conn=conn
) )
for idx, entity_id in zip(indices, batch_resolved): for idx, entity_id in zip(indices, batch_resolved):
@ -1049,7 +875,7 @@ class TemporalSemanticMemory:
unit_entity_pairs.append((unit_id, entity_id)) unit_entity_pairs.append((unit_id, entity_id))
# Batch insert all unit-entity links (MUCH faster!) # Batch insert all unit-entity links (MUCH faster!)
self.entity_resolver.link_units_to_entities_batch(unit_entity_pairs) await self.entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
print(f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s") print(f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s")
print(f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s") print(f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s")
@ -1067,15 +893,15 @@ class TemporalSemanticMemory:
# For each entity, find all units that reference it (one query per entity) # For each entity, find all units that reference it (one query per entity)
entity_to_units = {} entity_to_units = {}
for entity_id in all_entity_ids: for entity_id in all_entity_ids:
cursor.execute( rows = await conn.fetch(
""" """
SELECT unit_id SELECT unit_id
FROM unit_entities FROM unit_entities
WHERE entity_id = %s WHERE entity_id = $1
""", """,
(entity_id,) entity_id
) )
entity_to_units[entity_id] = [row[0] for row in cursor.fetchall()] entity_to_units[entity_id] = [row['unit_id'] for row in rows]
# Create bidirectional links between units that share entities # Create bidirectional links between units that share entities
links = [] links = []
@ -1098,71 +924,9 @@ class TemporalSemanticMemory:
# Re-raise to trigger rollback at put_async level # Re-raise to trigger rollback at put_async level
raise raise
def _create_temporal_links_batch( async def _create_temporal_links_batch_per_fact(
self, self,
cursor, conn,
agent_id: str,
unit_ids: List[str],
event_date: datetime,
time_window_hours: int = 24,
):
"""
Create temporal links for multiple units in one batch query.
Uses a single query to find all relevant temporal connections.
"""
if not unit_ids:
return
try:
from psycopg2.extras import execute_values
# Get ALL recent units within time window (single query)
# Cast string IDs to UUIDs for comparison
cursor.execute(
"""
SELECT id, event_date
FROM memory_units
WHERE agent_id = %s
AND id::text != ALL(%s)
AND event_date >= %s
ORDER BY event_date DESC
""",
(agent_id, unit_ids, event_date - timedelta(hours=time_window_hours))
)
recent_units = cursor.fetchall()
# Create links from each new unit to all recent units
links = []
for unit_id in unit_ids:
for recent_id, recent_event_date in recent_units:
# Calculate temporal proximity weight
time_diff_hours = abs((event_date - recent_event_date).total_seconds() / 3600)
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
links.append((unit_id, recent_id, 'temporal', weight, None))
if links:
execute_values(
cursor,
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES %s
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
links
)
except Exception as e:
print(f"ERROR: Failed to create temporal links: {str(e)}")
import traceback
traceback.print_exc()
# Re-raise to trigger rollback at put_async level
raise
def _create_temporal_links_batch_per_fact(
self,
cursor,
agent_id: str, agent_id: str,
unit_ids: List[str], unit_ids: List[str],
time_window_hours: int = 24, time_window_hours: int = 24,
@ -1177,55 +941,50 @@ class TemporalSemanticMemory:
return return
try: try:
from psycopg2.extras import execute_values
# Get the event_date for each new unit # Get the event_date for each new unit
cursor.execute( rows = await conn.fetch(
""" """
SELECT id, event_date SELECT id, event_date
FROM memory_units FROM memory_units
WHERE id::text = ANY(%s) WHERE id::text = ANY($1)
""", """,
(unit_ids,) unit_ids
) )
new_units = {str(row[0]): row[1] for row in cursor.fetchall()} new_units = {str(row['id']): row['event_date'] for row in rows}
# Create links based on each unit's individual event_date # Create links based on each unit's individual event_date
links = [] links = []
for unit_id, unit_event_date in new_units.items(): for unit_id, unit_event_date in new_units.items():
# Find units within the time window of THIS specific unit # Find units within the time window of THIS specific unit
cursor.execute( recent_units = await conn.fetch(
""" """
SELECT id, event_date SELECT id, event_date
FROM memory_units FROM memory_units
WHERE agent_id = %s WHERE agent_id = $1
AND id != %s AND id != $2
AND event_date BETWEEN %s AND %s AND event_date BETWEEN $3 AND $4
ORDER BY event_date DESC ORDER BY event_date DESC
LIMIT 10 LIMIT 10
""", """,
(
agent_id, agent_id,
unit_id, unit_id,
unit_event_date - timedelta(hours=time_window_hours), unit_event_date - timedelta(hours=time_window_hours),
unit_event_date + timedelta(hours=time_window_hours) unit_event_date + timedelta(hours=time_window_hours)
) )
)
recent_units = cursor.fetchall() for recent_row in recent_units:
recent_id = recent_row['id']
for recent_id, recent_event_date in recent_units: recent_event_date = recent_row['event_date']
# Calculate temporal proximity weight # Calculate temporal proximity weight
time_diff_hours = abs((unit_event_date - recent_event_date).total_seconds() / 3600) time_diff_hours = abs((unit_event_date - recent_event_date).total_seconds() / 3600)
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours)) weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
links.append((unit_id, recent_id, 'temporal', weight, None)) links.append((unit_id, str(recent_id), 'temporal', weight, None))
if links: if links:
execute_values( await conn.executemany(
cursor,
""" """
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id) INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES %s VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""", """,
links links
@ -1238,9 +997,9 @@ class TemporalSemanticMemory:
# Re-raise to trigger rollback at put_async level # Re-raise to trigger rollback at put_async level
raise raise
def _create_semantic_links_batch( async def _create_semantic_links_batch(
self, self,
cursor, conn,
agent_id: str, agent_id: str,
unit_ids: List[str], unit_ids: List[str],
embeddings: List[List[float]], embeddings: List[List[float]],
@ -1256,37 +1015,36 @@ class TemporalSemanticMemory:
return return
try: try:
from psycopg2.extras import execute_values
all_links = [] all_links = []
for unit_id, embedding in zip(unit_ids, embeddings): for unit_id, embedding in zip(unit_ids, embeddings):
# Find similar units using vector similarity # Find similar units using vector similarity
cursor.execute( # Convert embedding to string for asyncpg
embedding_str = str(embedding)
similar_units = await conn.fetch(
""" """
SELECT id, 1 - (embedding <=> %s::vector) AS similarity SELECT id, 1 - (embedding <=> $1::vector) AS similarity
FROM memory_units FROM memory_units
WHERE agent_id = %s WHERE agent_id = $2
AND id != %s AND id != $3
AND embedding IS NOT NULL AND embedding IS NOT NULL
AND (1 - (embedding <=> %s::vector)) >= %s AND (1 - (embedding <=> $1::vector)) >= $4
ORDER BY embedding <=> %s::vector ORDER BY embedding <=> $1::vector
LIMIT %s LIMIT $5
""", """,
(embedding, agent_id, unit_id, embedding, threshold, embedding, top_k) embedding_str, agent_id, unit_id, threshold, top_k
) )
similar_units = cursor.fetchall() for row in similar_units:
similar_id = row['id']
for similar_id, similarity in similar_units: similarity = row['similarity']
all_links.append((unit_id, similar_id, 'semantic', float(similarity), None)) all_links.append((unit_id, str(similar_id), 'semantic', float(similarity), None))
if all_links: if all_links:
execute_values( await conn.executemany(
cursor,
""" """
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id) INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES %s VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""", """,
all_links all_links
@ -1299,18 +1057,16 @@ class TemporalSemanticMemory:
# Re-raise to trigger rollback at put_async level # Re-raise to trigger rollback at put_async level
raise raise
def _insert_entity_links_batch(self, cursor, links: List[tuple]): async def _insert_entity_links_batch(self, conn, links: List[tuple]):
"""Insert all entity links in a single batch.""" """Insert all entity links in a single batch."""
if not links: if not links:
return return
try: try:
from psycopg2.extras import execute_values await conn.executemany(
execute_values(
cursor,
""" """
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id) INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES %s VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""", """,
links links

View file

@ -5,8 +5,7 @@ description = "Temporal + Semantic + Entity Memory System for AI agents using Po
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"psycopg2-binary>=2.9.0", "asyncpg>=0.29.0",
"pgvector>=0.2.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"openai>=1.0.0", "openai>=1.0.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",

View file

@ -3,9 +3,10 @@ Pytest configuration and shared fixtures.
""" """
import pytest import pytest
import os import os
import asyncio
from dotenv import load_dotenv from dotenv import load_dotenv
from memory import TemporalSemanticMemory from memory import TemporalSemanticMemory
import psycopg2 import asyncpg
load_dotenv() load_dotenv()
@ -29,19 +30,19 @@ def clean_agent(memory):
agent_id = "test" agent_id = "test"
# Clean up before test # Clean up before test
memory.delete_agent(agent_id) asyncio.run(memory.delete_agent(agent_id))
yield agent_id yield agent_id
# Clean up after test # Clean up after test
memory.delete_agent(agent_id) asyncio.run(memory.delete_agent(agent_id))
@pytest.fixture @pytest.fixture
def db_connection(): async def db_connection():
""" """
Provide a database connection for direct DB queries in tests. Provide a database connection for direct DB queries in tests.
""" """
conn = psycopg2.connect(os.getenv('DATABASE_URL')) conn = await asyncpg.connect(os.getenv('DATABASE_URL'))
yield conn yield conn
conn.close() await conn.close()

102
uv.lock
View file

@ -29,6 +29,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097 }, { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097 },
] ]
[[package]]
name = "asyncpg"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/0e/f5d708add0d0b97446c402db7e8dd4c4183c13edaabe8a8500b411e7b495/asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a", size = 674506 },
{ url = "https://files.pythonhosted.org/packages/6a/a0/67ec9a75cb24a1d99f97b8437c8d56da40e6f6bd23b04e2f4ea5d5ad82ac/asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed", size = 645922 },
{ url = "https://files.pythonhosted.org/packages/5c/d9/a7584f24174bd86ff1053b14bb841f9e714380c672f61c906eb01d8ec433/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a", size = 3079565 },
{ url = "https://files.pythonhosted.org/packages/a0/d7/a4c0f9660e333114bdb04d1a9ac70db690dd4ae003f34f691139a5cbdae3/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956", size = 3109962 },
{ url = "https://files.pythonhosted.org/packages/3c/21/199fd16b5a981b1575923cbb5d9cf916fdc936b377e0423099f209e7e73d/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056", size = 3064791 },
{ url = "https://files.pythonhosted.org/packages/77/52/0004809b3427534a0c9139c08c87b515f1c77a8376a50ae29f001e53962f/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454", size = 3188696 },
{ url = "https://files.pythonhosted.org/packages/52/cb/fbad941cd466117be58b774a3f1cc9ecc659af625f028b163b1e646a55fe/asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d", size = 567358 },
{ url = "https://files.pythonhosted.org/packages/3c/0a/0a32307cf166d50e1ad120d9b81a33a948a1a5463ebfa5a96cc5606c0863/asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f", size = 629375 },
{ url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162 },
{ url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025 },
{ url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243 },
{ url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059 },
{ url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596 },
{ url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632 },
{ url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186 },
{ url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064 },
{ url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373 },
{ url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745 },
{ url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103 },
{ url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471 },
{ url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253 },
{ url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720 },
{ url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404 },
{ url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623 },
]
[[package]] [[package]]
name = "blis" name = "blis"
version = "1.3.0" version = "1.3.0"
@ -1009,13 +1041,12 @@ name = "memory-poc"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" },
{ name = "langchain-text-splitters" }, { name = "langchain-text-splitters" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "networkx" }, { name = "networkx" },
{ name = "nltk" }, { name = "nltk" },
{ name = "openai" }, { name = "openai" },
{ name = "pgvector" },
{ name = "psycopg2-binary" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
@ -1028,13 +1059,12 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "langchain-text-splitters", specifier = ">=0.3.0" }, { name = "langchain-text-splitters", specifier = ">=0.3.0" },
{ name = "matplotlib", specifier = ">=3.7.0" }, { name = "matplotlib", specifier = ">=3.7.0" },
{ name = "networkx", specifier = ">=3.0" }, { name = "networkx", specifier = ">=3.0" },
{ name = "nltk", specifier = ">=3.8.0" }, { name = "nltk", specifier = ">=3.8.0" },
{ name = "openai", specifier = ">=1.0.0" }, { name = "openai", specifier = ">=1.0.0" },
{ name = "pgvector", specifier = ">=0.2.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.0" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },
{ name = "pytest", specifier = ">=7.0.0" }, { name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" },
@ -1418,18 +1448,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
] ]
[[package]]
name = "pgvector"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/43/9a0fb552ab4fd980680c2037962e331820f67585df740bedc4a2b50faf20/pgvector-0.4.1.tar.gz", hash = "sha256:83d3a1c044ff0c2f1e95d13dfb625beb0b65506cfec0941bfe81fd0ad44f4003", size = 30646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/21/b5735d5982892c878ff3d01bb06e018c43fc204428361ee9fc25a1b2125c/pgvector-0.4.1-py3-none-any.whl", hash = "sha256:34bb4e99e1b13d08a2fe82dda9f860f15ddcd0166fbb25bffe15821cbfeb7362", size = 27086 },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.0.0" version = "12.0.0"
@ -1559,58 +1577,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/8c/d3e30f80b2ef21f267f09f0b7d18995adccc928ede5b73ea3fe54e1303f4/preshed-3.0.10-cp313-cp313-win_amd64.whl", hash = "sha256:97e0e2edfd25a7dfba799b49b3c5cc248ad0318a76edd9d5fd2c82aa3d5c64ed", size = 115769 }, { url = "https://files.pythonhosted.org/packages/fa/8c/d3e30f80b2ef21f267f09f0b7d18995adccc928ede5b73ea3fe54e1303f4/preshed-3.0.10-cp313-cp313-win_amd64.whl", hash = "sha256:97e0e2edfd25a7dfba799b49b3c5cc248ad0318a76edd9d5fd2c82aa3d5c64ed", size = 115769 },
] ]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452 },
{ url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957 },
{ url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955 },
{ url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007 },
{ url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012 },
{ url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881 },
{ url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985 },
{ url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039 },
{ url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477 },
{ url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842 },
{ url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894 },
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603 },
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509 },
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159 },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234 },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236 },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083 },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281 },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010 },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641 },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940 },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147 },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572 },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529 },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242 },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258 },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295 },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133 },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383 },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168 },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712 },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549 },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215 },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567 },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755 },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646 },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701 },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293 },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184 },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650 },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663 },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737 },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643 },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913 },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.3" version = "2.12.3"