feat: 重构 memory 系统,支持 user memory 和 work memory 分离

This commit is contained in:
qzl
2026-03-23 14:25:47 +08:00
parent 3aacc756db
commit 6be616f108
70 changed files with 7031 additions and 431 deletions
@@ -0,0 +1,38 @@
"""drop source column from memories
Revision ID: 202603230001
Revises: 202603200001
Create Date: 2026-03-23 18:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "202603230001"
down_revision: Union[str, Sequence[str], None] = "202603200001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns("memories")}
if "source" in columns:
op.drop_column("memories", "source")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns("memories")}
if "source" not in columns:
op.add_column(
"memories",
sa.Column(
"source", sa.String(length=20), nullable=False, server_default="agent"
),
)
op.alter_column("memories", "source", server_default=None)
@@ -0,0 +1,34 @@
"""drop title column from memories
Revision ID: 202603230002
Revises: 202603230001
Create Date: 2026-03-23 21:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "202603230002"
down_revision: Union[str, Sequence[str], None] = "202603230001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns("memories")}
if "title" in columns:
op.drop_column("memories", "title")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns("memories")}
if "title" not in columns:
op.add_column(
"memories", sa.Column("title", sa.String(length=255), nullable=True)
)
@@ -0,0 +1,355 @@
"""add bootstrap key and unique indexes for registration bootstrap
Revision ID: 202603230003
Revises: 202603230002
Create Date: 2026-03-23 23:10:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "202603230003"
down_revision: Union[str, Sequence[str], None] = "202603230002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
automation_columns = {
column["name"] for column in inspector.get_columns("automation_jobs")
}
if "bootstrap_key" not in automation_columns:
op.add_column(
"automation_jobs",
sa.Column("bootstrap_key", sa.String(length=64), nullable=True),
)
op.execute("DROP INDEX IF EXISTS ux_automation_jobs_owner_memory_active")
op.execute(
"""
UPDATE public.automation_jobs
SET bootstrap_key = 'memory_extraction'
WHERE bootstrap_key IS NULL
AND (
config->>'agent_type' = 'memory'
OR (
created_by = owner_id
AND title = 'Memory Agent'
AND coalesce(config->'enabled_tools', '[]'::jsonb) @> '["memory.write", "memory.forget"]'::jsonb
AND jsonb_array_length(coalesce(config->'enabled_tools', '[]'::jsonb)) = 2
AND coalesce(config->'context', '{}'::jsonb) @> jsonb_build_object(
'source', 'latest_chat',
'window_mode', 'day',
'window_count', 2
)
)
)
"""
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS public.memories_dedup_backup_202603230003
(LIKE public.memories INCLUDING ALL)
"""
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS public.automation_jobs_dedup_backup_202603230003 (
id UUID PRIMARY KEY
)
"""
)
op.execute(
"""
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY owner_id, bootstrap_key
ORDER BY updated_at DESC, created_at DESC, id DESC
) AS rn
FROM public.automation_jobs
WHERE deleted_at IS NULL
AND bootstrap_key IS NOT NULL
)
INSERT INTO public.automation_jobs_dedup_backup_202603230003(id)
SELECT id
FROM ranked
WHERE rn > 1
ON CONFLICT (id) DO NOTHING
"""
)
op.execute(
"""
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY owner_id, memory_type
ORDER BY updated_at DESC, created_at DESC, id DESC
) AS rn
FROM public.memories
)
INSERT INTO public.memories_dedup_backup_202603230003
SELECT m.*
FROM public.memories m
JOIN ranked r ON r.id = m.id
WHERE r.rn > 1
ON CONFLICT (id) DO NOTHING
"""
)
op.execute(
"""
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY owner_id, bootstrap_key
ORDER BY updated_at DESC, created_at DESC, id DESC
) AS rn
FROM public.automation_jobs
WHERE deleted_at IS NULL
AND bootstrap_key IS NOT NULL
)
UPDATE public.automation_jobs aj
SET deleted_at = now()
FROM ranked r
WHERE aj.id = r.id
AND r.rn > 1
"""
)
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS ux_automation_jobs_owner_bootstrap_key_active
ON public.automation_jobs(owner_id, bootstrap_key)
WHERE deleted_at IS NULL
AND bootstrap_key IS NOT NULL
"""
)
op.execute(
"""
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY owner_id, memory_type
ORDER BY updated_at DESC, created_at DESC, id DESC
) AS rn
FROM public.memories
)
DELETE FROM public.memories m
USING ranked r
WHERE m.id = r.id
AND r.rn > 1
"""
)
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS ux_memories_owner_memory_type
ON public.memories(owner_id, memory_type)
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.create_profile_for_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
base_seed TEXT;
candidate_username TEXT;
attempt INT := 0;
BEGIN
base_seed := coalesce(NEW.phone, NEW.id::text);
LOOP
candidate_username := 'user_' || public.generate_profile_username_suffix(base_seed || ':' || attempt::text);
EXIT WHEN NOT EXISTS (
SELECT 1 FROM public.profiles p WHERE p.username = candidate_username
);
attempt := attempt + 1;
IF attempt >= 50 THEN
candidate_username := 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 6);
EXIT;
END IF;
END LOOP;
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
VALUES (
NEW.id,
candidate_username,
NULL,
NULL,
'{}'::jsonb,
now(),
now()
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ux_memories_owner_memory_type")
op.execute("DROP INDEX IF EXISTS ux_automation_jobs_owner_bootstrap_key_active")
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names(schema="public"))
if "automation_jobs_dedup_backup_202603230003" in tables:
op.execute(
"""
UPDATE public.automation_jobs aj
SET deleted_at = NULL
FROM public.automation_jobs_dedup_backup_202603230003 b
WHERE aj.id = b.id
"""
)
op.execute(
"DROP TABLE IF EXISTS public.automation_jobs_dedup_backup_202603230003"
)
if "memories_dedup_backup_202603230003" in tables:
op.execute(
"""
INSERT INTO public.memories
SELECT b.*
FROM public.memories_dedup_backup_202603230003 b
LEFT JOIN public.memories m ON m.id = b.id
WHERE m.id IS NULL
ON CONFLICT (id) DO NOTHING
"""
)
op.execute("DROP TABLE IF EXISTS public.memories_dedup_backup_202603230003")
automation_columns = {
column["name"] for column in inspector.get_columns("automation_jobs")
}
if "bootstrap_key" in automation_columns:
op.drop_column("automation_jobs", "bootstrap_key")
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS ux_automation_jobs_owner_memory_active
ON public.automation_jobs(owner_id)
WHERE deleted_at IS NULL
AND config->>'agent_type' = 'memory'
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.create_profile_for_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
base_seed TEXT;
candidate_username TEXT;
attempt INT := 0;
BEGIN
base_seed := coalesce(NEW.phone, NEW.id::text);
LOOP
candidate_username := 'user_' || public.generate_profile_username_suffix(base_seed || ':' || attempt::text);
EXIT WHEN NOT EXISTS (
SELECT 1 FROM public.profiles p WHERE p.username = candidate_username
);
attempt := attempt + 1;
IF attempt >= 50 THEN
candidate_username := 'user_' || substr(replace(NEW.id::text, '-', ''), 1, 6);
EXIT;
END IF;
END LOOP;
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
VALUES (
NEW.id,
candidate_username,
NULL,
NULL,
'{}'::jsonb,
now(),
now()
)
ON CONFLICT (id) DO NOTHING;
BEGIN
IF NOT EXISTS (
SELECT 1
FROM public.automation_jobs aj
WHERE aj.owner_id = NEW.id
AND aj.deleted_at IS NULL
AND aj.config->>'agent_type' = 'memory'
) THEN
INSERT INTO public.automation_jobs (
id,
owner_id,
title,
config,
schedule_type,
run_at,
next_run_at,
timezone,
status,
created_by,
created_at,
updated_at
) VALUES (
gen_random_uuid(),
NEW.id,
'Memory Agent',
jsonb_build_object(
'agent_type', 'memory',
'model_code', 'qwen3.5-flash',
'enabled_tools', jsonb_build_array('calendar.read', 'user.lookup'),
'input_template', '请基于最近聊天上下文生成一段可执行的记忆总结与建议。',
'context', jsonb_build_object(
'source', 'latest_chat',
'window_mode', 'day',
'window_count', 2
)
),
'daily',
now(),
now() + interval '1 day',
'UTC',
'active',
NEW.id,
now(),
now()
);
END IF;
EXCEPTION WHEN unique_violation THEN
NULL;
END;
RETURN NEW;
END;
$$;
"""
)