Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a7ea375
feat(models): implement SQLModel BaseModel and Memory data models
SparkZou Feb 9, 2026
c3eac38
fix(models): resolve all critical issues and test failures
SparkZou Feb 9, 2026
8db1fd6
test(memory): verify actual index configuration instead of attribute …
SparkZou Feb 9, 2026
a9e721c
fix(models): address remaining Copilot review feedback
SparkZou Feb 9, 2026
fdfd3da
refactor(models): remove redundant index and add timezone to happened_at
SparkZou Feb 9, 2026
61c8ac2
chore(alembic): add migration to create memories table
SparkZou Feb 9, 2026
2403a7f
fix(alembic): ensure pgvector extension before creating memories table
SparkZou Feb 9, 2026
6d39b94
fix(tests): correct docstring to match list[Any] type for links field
SparkZou Feb 9, 2026
3834ab1
docs(alembic): document pgvector extension prerequisite and downgrade…
SparkZou Feb 9, 2026
c007586
refactor(models): extract EMBEDDING_DIM constant and fix type: ignore…
SparkZou Feb 9, 2026
58bfc8d
refactor(tests): assert indexes via SQLAlchemy table metadata and fix…
SparkZou Feb 10, 2026
e13f562
fix(tests): validate KSUID as hex string instead of alphanumeric
SparkZou Feb 10, 2026
d0e6d62
refactor: delegate memory models and table creation to memu-py
SparkZou Feb 15, 2026
9b068af
fix: configure deptry and mypy to scan config/ directory
SparkZou Feb 15, 2026
9cee540
refactor: wire main.py to Settings + create_memory_service factory
SparkZou Feb 15, 2026
521eddb
fix: address Copilot review - URL-encode creds, remove dead code, har…
SparkZou Feb 15, 2026
0c54bd3
fix: CI test failure and add design rationale comments
SparkZou Feb 15, 2026
b3a3164
fix: make OPENAI_API_KEY and EMBEDDING_API_KEY required fields
SparkZou Feb 15, 2026
582e793
fix: use empty defaults for API keys, add config/ to coverage
SparkZou Feb 15, 2026
5cab98c
fix: align defaults with docker-compose, harden settings and endpoints
SparkZou Feb 16, 2026
c299008
fix: replace traceback.print_exc with logger.exception
SparkZou Feb 16, 2026
622db30
fix: document alembic metadata, harden tests, improve dependency comm…
SparkZou Feb 16, 2026
136e872
fix: correct Settings docstring precedence, include config in wheel
SparkZou Feb 16, 2026
3de8aae
fix: deduplicate lifespan error message string
SparkZou Feb 16, 2026
086935d
refactor: reuse Settings in alembic/env.py for consistent env vars
SparkZou Feb 16, 2026
1c972b9
fix: preserve RFC 3986 sub-delimiters in DATABASE_URL to avoid config…
SparkZou Feb 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ jobs:
run: |
uv lock --locked
uv run pre-commit run --all-files
uv run mypy app
uv run deptry app
uv run mypy app config
uv run deptry app config

- name: Run tests with coverage
run: uv run pytest --cov=app --cov-report=xml --cov-report=term
run: uv run pytest --cov=app --cov=config --cov-report=xml --cov-report=term

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ check:
@echo "🚀 Running pre-commit checks"
@uv run pre-commit run -a
@echo "🚀 Running static type checks (mypy)"
@uv run mypy app
@uv run mypy app config
@echo "🚀 Checking for obsolete dependencies (deptry)"
@uv run deptry app
@uv run deptry app config

test:
@echo "🚀 Running tests with coverage"
Expand Down
104 changes: 34 additions & 70 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -1,87 +1,38 @@
"""Alembic migration environment configuration."""
"""Alembic migration environment configuration.

import os
Note: Memory-related tables are managed by memu-py (via ``ddl_mode: "create"``).
Alembic is kept here for any future server-specific schema migrations.
Comment thread
SparkZou marked this conversation as resolved.

Configuration note:
This env.py reuses the application's ``Settings`` class so that Alembic
reads the same ``POSTGRES_*`` / ``DATABASE_URL`` environment variables as
the running server. ``Settings.DATABASE_URL`` is already normalised to
``postgresql+psycopg://`` by the ``assemble_db_url`` validator, which is
the synchronous driver required by Alembic.
"""

# pylint: disable=no-member
from logging.config import fileConfig
from urllib.parse import quote

from sqlalchemy import pool

from alembic import context

# Import Base metadata for autogenerate support
# Note: We import from app.models.base which is side-effect free
# (doesn't create database connections or read environment variables)
from app.models.base import Base
from config.settings import Settings


def get_sync_database_url() -> str:
"""
Get synchronous database URL for Alembic migrations.
"""Return a synchronous database URL for Alembic migrations.

Alembic uses synchronous database connections, so we need to convert
the async URL (postgresql+asyncpg://) to sync format (postgresql+psycopg://).
Delegates to ``Settings`` so that the same ``POSTGRES_*`` /
``DATABASE_URL`` environment variables used by the application are
honoured here as well. The ``assemble_db_url`` field-validator in
``Settings`` already normalises the URL to ``postgresql+psycopg://``.

Returns:
str: Synchronous database connection URL
Synchronous database connection URL suitable for SQLAlchemy.
"""
# First try DATABASE_URL from environment
database_url = os.getenv("DATABASE_URL")
if database_url:
# Normalize common PostgreSQL DSNs to use the psycopg (sync) driver.
# Handle bare postgres:// and postgresql:// URLs that don't specify a driver.
if database_url.startswith("postgres://"):
# postgres://user:pass@host/db -> postgresql+psycopg://user:pass@host/db
database_url = "postgresql+psycopg://" + database_url[len("postgres://") :]
elif database_url.startswith("postgresql://") and not database_url.startswith("postgresql+"):
# postgresql://user:pass@host/db -> postgresql+psycopg://user:pass@host/db
database_url = "postgresql+psycopg://" + database_url[len("postgresql://") :]

# Convert async driver to sync driver if needed
# postgresql+asyncpg:// -> postgresql+psycopg://
if database_url.startswith("postgresql+asyncpg://"):
database_url = "postgresql+psycopg://" + database_url[len("postgresql+asyncpg://") :]

return database_url

# Construct from individual variables
db_host = os.getenv("DATABASE_HOST")
db_port = os.getenv("DATABASE_PORT", "5432") # Default PostgreSQL port
db_user = os.getenv("DATABASE_USER")
db_pass = os.getenv("DATABASE_PASSWORD")
db_name = os.getenv("DATABASE_NAME")

# Validate required environment variables (consistent with app/database.py)
missing_vars = [
name
for name, value in [
("DATABASE_HOST", db_host),
("DATABASE_USER", db_user),
("DATABASE_PASSWORD", db_pass),
("DATABASE_NAME", db_name),
]
if not value
]

if missing_vars:
raise RuntimeError(
f"Database configuration is incomplete. Missing environment variables: {', '.join(missing_vars)}"
)

# At this point, we know these are not None
assert db_host is not None
assert db_user is not None
assert db_pass is not None
assert db_name is not None

# URL-encode username and password to handle special characters
# Use quote(..., safe="") instead of quote_plus() for URL userinfo section
db_user_encoded = quote(db_user, safe="")
db_pass_encoded = quote(db_pass, safe="")

# Use psycopg (sync) for Alembic migrations
return f"postgresql+psycopg://{db_user_encoded}:{db_pass_encoded}@{db_host}:{db_port}/{db_name}"
settings = Settings()
return settings.DATABASE_URL


# this is the Alembic Config object, which provides
Expand All @@ -93,7 +44,20 @@ def get_sync_database_url() -> str:
if config.config_file_name is not None:
fileConfig(config.config_file_name)

Comment thread
SparkZou marked this conversation as resolved.
target_metadata = Base.metadata
target_metadata = None
# NOTE: Alembic's autogenerate relies on `target_metadata` to discover the
# current schema from SQLAlchemy models. It is intentionally set to None
# because all current tables are managed externally by memu-py (see module
# docstring), so there is no server-specific SQLAlchemy metadata to inspect.
#
# Implications:
# * `alembic revision --autogenerate` will NOT detect any changes.
# * Any migrations must use explicit operations (e.g. op.create_table).
#
# When you introduce server-specific tables, define a SQLAlchemy
# MetaData / declarative Base and assign it here, for example:
# from myapp.models import Base
# target_metadata = Base.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
Expand Down
107 changes: 0 additions & 107 deletions app/database.py

This file was deleted.

75 changes: 42 additions & 33 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,79 @@
"""memU Server - FastAPI application entry point."""

import json
import os
import traceback
import logging
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from memu.app import MemoryService

from app.database import get_database_url
from app.services.memu import create_memory_service
from config.settings import Settings

Comment thread
SparkZou marked this conversation as resolved.
logger = logging.getLogger(__name__)

app = FastAPI(title="memU Server", version="0.1.0")
# Load settings from environment / .env
settings = Settings()

# Ensure required environment variables are set
openai_api_key = os.getenv("OPENAI_API_KEY")
if not openai_api_key:
raise RuntimeError(
if not settings.OPENAI_API_KEY.strip():
# EM101/EM102: extract message to variable to satisfy ruff errmsg rules
msg = (
"OPENAI_API_KEY environment variable is not set or is empty. "
"Set OPENAI_API_KEY to a valid OpenAI API key before starting the server."
)

# Get database URL using shared configuration utility
database_url = get_database_url()

service = MemoryService(
llm_profiles={
"default": {
"provider": "openai",
"api_key": openai_api_key,
"base_url": os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
"model": os.getenv("DEFAULT_LLM_MODEL", "gpt-4o-mini"),
}
},
database_config={"url": database_url},
)
raise RuntimeError(msg)
Comment thread
SparkZou marked this conversation as resolved.
Comment thread
SparkZou marked this conversation as resolved.
Comment thread
SparkZou marked this conversation as resolved.

# Storage directory for conversation files
# Support both new STORAGE_PATH and legacy MEMU_STORAGE_DIR for backward compatibility
storage_dir = Path(os.getenv("STORAGE_PATH") or os.getenv("MEMU_STORAGE_DIR") or "./data")
storage_dir.mkdir(parents=True, exist_ok=True)
storage_dir = Path(settings.STORAGE_PATH)


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Initialise MemoryService on startup (defers DB connection until the app runs)."""
try:
storage_dir.mkdir(parents=True, exist_ok=True)
_app.state.service = create_memory_service(settings)
except Exception as exc:
# Log full traceback for operators and wrap in a clearer startup error
msg = "Failed to initialize MemoryService during application startup"
logger.exception(msg)
raise RuntimeError(msg) from exc
yield


app = FastAPI(title="memU Server", version="0.1.0", lifespan=lifespan)


@app.post("/memorize")
async def memorize(payload: dict[str, Any]):
async def memorize(request: Request, payload: dict[str, Any]):
try:
service = request.app.state.service
file_path = storage_dir / f"conversation-{uuid.uuid4().hex}.json"
with file_path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
Comment thread
SparkZou marked this conversation as resolved.

result = await service.memorize(resource_url=str(file_path), modality="conversation")
return JSONResponse(content={"status": "success", "result": result})
except Exception as exc:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(exc)) from exc
logger.exception("Memorize request failed")
raise HTTPException(status_code=500, detail="Internal server error") from exc
Comment thread
SparkZou marked this conversation as resolved.
Comment thread
SparkZou marked this conversation as resolved.


@app.post("/retrieve")
async def retrieve(payload: dict[str, Any]):
async def retrieve(request: Request, payload: dict[str, Any]):
if "query" not in payload:
raise HTTPException(status_code=400, detail="Missing 'query' in request body")
try:
service = request.app.state.service
result = await service.retrieve([payload["query"]])
return JSONResponse(content={"status": "success", "result": result})
except Exception as exc:
Comment thread
SparkZou marked this conversation as resolved.
raise HTTPException(status_code=500, detail=str(exc)) from exc
logger.exception("Retrieve request failed")
raise HTTPException(status_code=500, detail="Internal server error") from exc


@app.get("/")
Expand Down
Empty file removed app/models/__init__.py
Empty file.
14 changes: 0 additions & 14 deletions app/models/base.py

This file was deleted.

Loading