feat: add pgvector semantic memory, PgBouncer pooling, and Alembic migrations
Build & Redeploy to Portainer (local images) / build-and-redeploy (push) Failing after 1m48s Details

pgvector — semantic memory retrieval:
- Postgres Dockerfile builds pgvector v0.7.4 from source (Alpine)
- memories table gains embedding vector(384) column + IVFFlat cosine index
- EmbeddingService: lazy-loads all-MiniLM-L6-v2 (384 dims) via
  sentence-transformers; embed_text() / embed_batch() with graceful
  degradation if model unavailable
- DBManager: asyncpg vector codec registration (text-cast fallback),
  upsert_memory_embedding(), get_semantically_relevant_memories()
  retrieves by cosine similarity rather than importance score

PgBouncer — connection pooling:
- Transaction-mode pooling: 200 max clients → 20 Postgres connections
- Sits between all app services (FastAPI + 4 Celery workers) and Postgres
- DATABASE_URL in .env now routes through westworld-pgbouncer:5432
- Added to docker-compose.yml and CI/CD pipeline (build-deploy.yaml)

Alembic — versioned schema migrations:
- alembic.ini configured for async SQLAlchemy + asyncpg
- env.py uses async_engine_from_config; reads DATABASE_URL from env
- Initial migration 0001_initial_schema.py: creates vector extension,
  all 13 tables (including embedding column), all 14 indexes
- Downgrade drops tables in correct FK dependency order

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rohit Arora 2026-03-23 10:57:45 -04:00
parent 74e81b69cf
commit 9e8a9fd6e9
19 changed files with 617 additions and 4 deletions

3
.env
View File

@ -1,5 +1,6 @@
# ─── Database ─────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://admin:Westworld@2026@westworld-postgres:5432/westworld
# Routes through PgBouncer (connection pooler) → westworld-postgres
DATABASE_URL=postgresql://admin:Westworld@2026@westworld-pgbouncer:5432/westworld
POSTGRES_USER=admin
POSTGRES_PASSWORD=Westworld@2026
POSTGRES_DB=westworld

View File

@ -81,4 +81,18 @@ jobs:
--load \
--no-cache \
-f ./services/redis/Dockerfile \
./services/redis
./services/redis
- name: Build pgbouncer
run: |
SHA="${{ gitea.sha }}"
BUILD_DATE="$(date +%Y%m%d)"
docker buildx build \
--tag westworld-pgbouncer:latest \
--tag westworld-pgbouncer:sha-${SHA} \
--tag westworld-pgbouncer:build-${BUILD_DATE} \
--load \
--no-cache \
-f ./services/pgbouncer/Dockerfile \
./services/pgbouncer

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
# ---> Pycharm
.idea/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/

View File

@ -80,6 +80,7 @@ services:
- .env
volumes:
- /Users/rohit89/Containers/westworld/volumes/postgres/data:/var/lib/postgresql/data
- ./services/postgres/config/init:/docker-entrypoint-initdb.d:ro
networks:
- rainbow-net
@ -184,6 +185,16 @@ services:
networks:
- rainbow-net
westworld-pgbouncer:
image: westworld-pgbouncer:latest
container_name: westworld-pgbouncer
hostname: westworld-pgbouncer
restart: unless-stopped
depends_on:
- westworld-postgres
networks:
- rainbow-net
networks:
rainbow-net:
name: rainbow-net

View File

@ -0,0 +1,41 @@
[alembic]
script_location = app/migrations
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://admin:Westworld@2026@westworld-pgbouncer:5432/westworld
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -37,6 +37,17 @@ async def _init_conn(conn):
schema="pg_catalog",
format="text",
)
# Register pgvector type codec — gracefully degrades if pgvector is not installed yet.
try:
await conn.set_type_codec(
"vector",
encoder=lambda v: "[" + ",".join(str(x) for x in v) + "]",
decoder=lambda s: [float(x) for x in s.strip("[]").split(",")],
schema="public",
format="text",
)
except Exception:
pass # pgvector not installed yet — queries using vector columns will still work via text cast
class DBManager:
@ -785,3 +796,56 @@ class DBManager:
async def is_empty(self) -> bool:
count = await self._fetchval("SELECT COUNT(*) FROM characters")
return count == 0
# ------------------------------------------------------------------
# Semantic memory search (pgvector)
# ------------------------------------------------------------------
async def upsert_memory_embedding(self, memory_id: int, embedding: list[float]) -> None:
"""Store a vector embedding for a memory."""
await self._pool.execute(
"UPDATE memories SET embedding = $1 WHERE id = $2",
embedding, memory_id,
)
async def get_semantically_relevant_memories(
self,
char_name: str,
query_embedding: list[float],
limit: int = 10,
user_id: Optional[str] = None,
) -> list[dict]:
"""
Retrieve memories by cosine similarity to a query embedding.
Falls back to importance-based retrieval if no embeddings exist.
"""
if user_id:
rows = await self._pool.fetch(
"""
SELECT id, content, mem_type, emotional_tone, importance, memory_ts,
memory_tier, user_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memories
WHERE char_name = $2
AND embedding IS NOT NULL
AND (user_id = $3 OR user_id IS NULL)
ORDER BY embedding <=> $1::vector
LIMIT $4
""",
query_embedding, char_name, user_id, limit,
)
else:
rows = await self._pool.fetch(
"""
SELECT id, content, mem_type, emotional_tone, importance, memory_ts,
memory_tier, user_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memories
WHERE char_name = $2
AND embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT $3
""",
query_embedding, char_name, limit,
)
return [dict(r) for r in rows]

View File

@ -117,7 +117,8 @@ TABLES = [
memory_ts TEXT NOT NULL,
created_at TEXT NOT NULL,
user_id TEXT,
memory_tier INTEGER NOT NULL DEFAULT 2
memory_tier INTEGER NOT NULL DEFAULT 2,
embedding vector(384) -- sentence-transformers/all-MiniLM-L6-v2 embedding
)
""",
"""
@ -234,4 +235,8 @@ INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_travel_char ON character_travel (char_name)",
"CREATE INDEX IF NOT EXISTS idx_user_rel_physio ON user_relationship_physiology (char_name, user_id)",
"CREATE INDEX IF NOT EXISTS idx_behavior_escalation ON user_behavior_escalation (char_name, user_id)",
# pgvector: approximate nearest-neighbor search on memory embeddings.
# Requires pgvector extension and at least one vector row to be useful.
# IF NOT EXISTS makes this safe to run on every startup even when the table is empty.
"CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)",
]

View File

@ -0,0 +1,69 @@
"""
Alembic migration environment.
Configured for async PostgreSQL (asyncpg) via SQLAlchemy async engine.
Run migrations with:
docker exec westworld-backend alembic upgrade head
docker exec westworld-backend alembic revision --autogenerate -m "description"
"""
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Override DB URL from environment if set
db_url = os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url"))
# Convert asyncpg URL to use asyncpg driver explicitly
if db_url and db_url.startswith("postgresql://"):
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
config.set_main_option("sqlalchemy.url", db_url)
target_metadata = None
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,282 @@
"""Initial schema — all tables and indexes.
Revision ID: 0001
Revises:
Create Date: 2026-03-23
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Enable pgvector extension
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
# ── users ──────────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
intro_completed BOOLEAN NOT NULL DEFAULT FALSE,
tier TEXT NOT NULL DEFAULT 'free',
paused_until TEXT,
created_at TEXT NOT NULL
)
""")
# ── user_meta ──────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS user_meta (
user_id TEXT NOT NULL REFERENCES users(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (user_id, key)
)
""")
# ── characters ─────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS characters (
name TEXT PRIMARY KEY,
identity_core TEXT NOT NULL DEFAULT '',
birth_date TEXT NOT NULL DEFAULT '',
personality_traits JSONB NOT NULL DEFAULT '{}',
speech_style TEXT NOT NULL DEFAULT '',
values JSONB NOT NULL DEFAULT '[]',
fears JSONB NOT NULL DEFAULT '[]',
quirks JSONB NOT NULL DEFAULT '[]',
profession TEXT NOT NULL DEFAULT '',
hobbies JSONB NOT NULL DEFAULT '[]',
goals_long JSONB NOT NULL DEFAULT '[]',
goals_mid JSONB NOT NULL DEFAULT '[]',
goals_short JSONB NOT NULL DEFAULT '[]',
backstory TEXT NOT NULL DEFAULT '',
full_profile JSONB NOT NULL DEFAULT '{}',
attractiveness_score REAL NOT NULL DEFAULT 5.0,
attractiveness_reasons JSONB NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'awake',
energy INTEGER NOT NULL DEFAULT 100,
mood TEXT NOT NULL DEFAULT 'neutral',
stress INTEGER NOT NULL DEFAULT 20,
happiness INTEGER NOT NULL DEFAULT 70,
existential_crisis BOOLEAN NOT NULL DEFAULT FALSE,
narrative_summary TEXT NOT NULL DEFAULT '',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
retired_reason TEXT NOT NULL DEFAULT '',
introduced_at TEXT NOT NULL DEFAULT '',
retired_at TEXT,
last_event_ts TEXT NOT NULL DEFAULT '',
last_active_ts TEXT NOT NULL DEFAULT '',
world_status TEXT NOT NULL DEFAULT 'active',
home_city TEXT NOT NULL DEFAULT 'NYC',
gender TEXT NOT NULL DEFAULT 'female'
)
""")
# ── character_physiology ───────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS character_physiology (
char_name TEXT PRIMARY KEY REFERENCES characters(name),
heart_rate_bpm REAL NOT NULL DEFAULT 72,
blood_pressure_systolic REAL NOT NULL DEFAULT 120,
blood_pressure_diastolic REAL NOT NULL DEFAULT 80,
respiratory_rate REAL NOT NULL DEFAULT 14,
body_temperature_c REAL NOT NULL DEFAULT 36.6,
blood_oxygen_spo2 REAL NOT NULL DEFAULT 98,
cortisol_ng_ml REAL NOT NULL DEFAULT 12.0,
oxytocin_pg_ml REAL NOT NULL DEFAULT 100,
serotonin_ng_ml REAL NOT NULL DEFAULT 160,
dopamine_pg_ml REAL NOT NULL DEFAULT 35,
testosterone_ng_dl REAL NOT NULL DEFAULT 0,
estradiol_pg_ml REAL NOT NULL DEFAULT 0,
melatonin_pg_ml REAL NOT NULL DEFAULT 10,
adrenaline_pg_ml REAL NOT NULL DEFAULT 40,
insulin_uiu_ml REAL NOT NULL DEFAULT 8,
blood_glucose_mg_dl REAL NOT NULL DEFAULT 95,
hydration_pct REAL NOT NULL DEFAULT 60,
sweat_rate_ml_hr REAL NOT NULL DEFAULT 100,
pupil_dilation_mm REAL NOT NULL DEFAULT 4.0,
skin_conductance_us REAL NOT NULL DEFAULT 4.0,
muscle_tension_pct REAL NOT NULL DEFAULT 20,
updated_at TEXT NOT NULL DEFAULT ''
)
""")
# ── character_self_metrics ─────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS character_self_metrics (
id SERIAL PRIMARY KEY,
char_name TEXT NOT NULL REFERENCES characters(name),
metric_date TEXT NOT NULL,
weight_kg REAL,
weight_note TEXT,
bmi REAL,
body_fat_pct REAL,
sleep_quality INTEGER,
mood_journal TEXT,
workout_type TEXT,
daily_caffeine_mg REAL,
daily_water_glasses INTEGER,
steps_today INTEGER,
screen_time_hrs REAL,
custom_metrics JSONB NOT NULL DEFAULT '{}'
)
""")
# ── memories ───────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS memories (
id SERIAL PRIMARY KEY,
char_name TEXT NOT NULL REFERENCES characters(name),
mem_type TEXT NOT NULL,
content TEXT NOT NULL,
emotional_tone TEXT NOT NULL DEFAULT 'neutral',
importance INTEGER NOT NULL DEFAULT 5,
is_vivid BOOLEAN NOT NULL DEFAULT FALSE,
memory_ts TEXT NOT NULL,
created_at TEXT NOT NULL,
user_id TEXT,
memory_tier INTEGER NOT NULL DEFAULT 2,
embedding vector(384)
)
""")
# ── events ─────────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
char_name TEXT NOT NULL REFERENCES characters(name),
description TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT 'daily_life',
ts TEXT NOT NULL
)
""")
# ── relationships ──────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS relationships (
id SERIAL PRIMARY KEY,
char_a TEXT NOT NULL,
char_b TEXT NOT NULL,
entity_type_b TEXT NOT NULL DEFAULT 'character',
relationship_type TEXT NOT NULL DEFAULT 'acquaintance',
commitment_level REAL NOT NULL DEFAULT 0.0,
trust REAL NOT NULL DEFAULT 0.5,
romantic_interest REAL NOT NULL DEFAULT 0.0,
friendliness REAL NOT NULL DEFAULT 0.5,
admiration REAL NOT NULL DEFAULT 0.5,
protectiveness REAL NOT NULL DEFAULT 0.0,
rivalry REAL NOT NULL DEFAULT 0.0,
last_interaction_ts TEXT,
notes TEXT NOT NULL DEFAULT '',
UNIQUE(char_a, char_b)
)
""")
# ── conversations ──────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
char_name TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
ts TEXT NOT NULL
)
""")
# ── world_state ────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS world_state (
id SERIAL PRIMARY KEY,
event_description TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT 'social',
affects_all BOOLEAN NOT NULL DEFAULT TRUE,
active BOOLEAN NOT NULL DEFAULT TRUE,
started_at TEXT NOT NULL,
ended_at TEXT
)
""")
# ── character_travel ───────────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS character_travel (
id SERIAL PRIMARY KEY,
char_name TEXT NOT NULL REFERENCES characters(name),
destination TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'vacation',
departure_date TEXT NOT NULL,
return_date TEXT,
status TEXT NOT NULL DEFAULT 'planned',
travel_context JSONB NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
)
""")
# ── user_relationship_physiology ───────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS user_relationship_physiology (
id SERIAL PRIMARY KEY,
char_name TEXT NOT NULL REFERENCES characters(name),
user_id TEXT NOT NULL REFERENCES users(id),
oxytocin_modifier REAL NOT NULL DEFAULT 0.0,
serotonin_modifier REAL NOT NULL DEFAULT 0.0,
cortisol_modifier REAL NOT NULL DEFAULT 0.0,
dopamine_modifier REAL NOT NULL DEFAULT 0.0,
updated_at TEXT NOT NULL,
UNIQUE(char_name, user_id)
)
""")
# ── user_behavior_escalation ───────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS user_behavior_escalation (
id SERIAL PRIMARY KEY,
char_name TEXT NOT NULL REFERENCES characters(name),
user_id TEXT NOT NULL REFERENCES users(id),
trigger_type TEXT NOT NULL,
stage INTEGER NOT NULL DEFAULT 1,
last_triggered TEXT NOT NULL,
UNIQUE(char_name, user_id, trigger_type)
)
""")
# ── indexes ────────────────────────────────────────────────────────────────
op.execute("CREATE INDEX IF NOT EXISTS idx_memories_char_type ON memories (char_name, mem_type)")
op.execute("CREATE INDEX IF NOT EXISTS idx_memories_char_vivid ON memories (char_name, is_vivid)")
op.execute("CREATE INDEX IF NOT EXISTS idx_conversations_char_user ON conversations (char_name, user_id)")
op.execute("CREATE INDEX IF NOT EXISTS idx_events_char ON events (char_name)")
op.execute("CREATE INDEX IF NOT EXISTS idx_relationships_char_b ON relationships (char_b, entity_type_b)")
op.execute("CREATE INDEX IF NOT EXISTS idx_characters_active ON characters (is_active)")
op.execute("CREATE INDEX IF NOT EXISTS idx_world_state_active ON world_state (active)")
op.execute("CREATE INDEX IF NOT EXISTS idx_users_paused ON users (paused_until) WHERE paused_until IS NOT NULL")
op.execute("CREATE INDEX IF NOT EXISTS idx_memories_user ON memories (char_name, user_id)")
op.execute("CREATE INDEX IF NOT EXISTS idx_memories_tier ON memories (char_name, memory_tier)")
op.execute("CREATE INDEX IF NOT EXISTS idx_travel_char ON character_travel (char_name)")
op.execute("CREATE INDEX IF NOT EXISTS idx_user_rel_physio ON user_relationship_physiology (char_name, user_id)")
op.execute("CREATE INDEX IF NOT EXISTS idx_behavior_escalation ON user_behavior_escalation (char_name, user_id)")
op.execute("CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)")
def downgrade() -> None:
# Drop in reverse dependency order
op.execute("DROP TABLE IF EXISTS user_behavior_escalation CASCADE")
op.execute("DROP TABLE IF EXISTS user_relationship_physiology CASCADE")
op.execute("DROP TABLE IF EXISTS character_travel CASCADE")
op.execute("DROP TABLE IF EXISTS world_state CASCADE")
op.execute("DROP TABLE IF EXISTS conversations CASCADE")
op.execute("DROP TABLE IF EXISTS relationships CASCADE")
op.execute("DROP TABLE IF EXISTS events CASCADE")
op.execute("DROP TABLE IF EXISTS memories CASCADE")
op.execute("DROP TABLE IF EXISTS character_self_metrics CASCADE")
op.execute("DROP TABLE IF EXISTS character_physiology CASCADE")
op.execute("DROP TABLE IF EXISTS characters CASCADE")
op.execute("DROP TABLE IF EXISTS user_meta CASCADE")
op.execute("DROP TABLE IF EXISTS users CASCADE")
op.execute("DROP EXTENSION IF EXISTS vector")

View File

@ -0,0 +1,59 @@
"""
Embedding service for semantic memory search.
Uses sentence-transformers (all-MiniLM-L6-v2, 384 dimensions) to embed
memory content and conversation context for pgvector similarity search.
The model is loaded once on first use and cached in memory.
384-dimensional embeddings are compact, fast, and sufficient for this use case.
"""
import logging
from typing import Optional
logger = logging.getLogger(__name__)
_model = None
def get_model():
"""Lazy-load the embedding model — only on first call."""
global _model
if _model is None:
try:
from sentence_transformers import SentenceTransformer
logger.info("Loading sentence-transformers model (all-MiniLM-L6-v2)...")
_model = SentenceTransformer("all-MiniLM-L6-v2")
logger.info("Embedding model loaded.")
except ImportError:
logger.warning("sentence-transformers not installed — semantic search unavailable.")
return _model
def embed_text(text: str) -> Optional[list[float]]:
"""
Embed a single text string. Returns None if the model is unavailable.
Truncates to 512 tokens internally (model limit).
"""
model = get_model()
if model is None:
return None
try:
vec = model.encode(text, normalize_embeddings=True)
return vec.tolist()
except Exception as e:
logger.warning(f"Embedding failed: {e}")
return None
def embed_batch(texts: list[str]) -> list[Optional[list[float]]]:
"""Embed multiple texts in one forward pass (more efficient than one-by-one)."""
model = get_model()
if model is None:
return [None] * len(texts)
try:
vecs = model.encode(texts, normalize_embeddings=True, batch_size=32)
return [v.tolist() for v in vecs]
except Exception as e:
logger.warning(f"Batch embedding failed: {e}")
return [None] * len(texts)

View File

@ -14,4 +14,9 @@ flower==2.0.1
opentelemetry-api==1.27.0
opentelemetry-sdk==1.27.0
opentelemetry-instrumentation-fastapi==0.48b0
opentelemetry-exporter-otlp==1.27.0
opentelemetry-exporter-otlp==1.27.0
# Sentence embeddings for pgvector semantic memory search
sentence-transformers==3.3.1
# pgvector Python client
pgvector==0.3.6
alembic==1.14.0

View File

@ -0,0 +1,3 @@
FROM pgbouncer/pgbouncer:1.23.1
COPY config/pgbouncer.ini /etc/pgbouncer/pgbouncer.ini
COPY config/userlist.txt /etc/pgbouncer/userlist.txt

View File

@ -0,0 +1,19 @@
[databases]
westworld = host=westworld-postgres port=5432 dbname=westworld
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 5432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 200
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 600
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
stats_period = 60
admin_users = admin

View File

@ -0,0 +1 @@
"admin" "Westworld@2026"

View File

@ -1,5 +1,15 @@
FROM postgres:16.13-alpine3.23
# Install pgvector extension
RUN apk add --no-cache git build-base clang15 llvm15-dev && \
cd /tmp && \
git clone --branch v0.7.4 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install && \
cd / && rm -rf /tmp/pgvector && \
apk del git build-base clang15 llvm15-dev
# Copy custom config (optional most people only need init scripts)
COPY config/postgresql.conf /etc/postgresql/postgresql.conf

View File

@ -0,0 +1,2 @@
-- Enable pgvector extension for semantic memory search
CREATE EXTENSION IF NOT EXISTS vector;