Files
dreamweaver/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md
torin b8d3cb4644
Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions
wip: snapshot full local workspace state
2026-04-17 18:58:11 +08:00

13 KiB

孩子档案数据模型

概述

孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。


一、数据库模型

1.1 主表: child_profiles

CREATE TABLE child_profiles (
    -- 主键
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- 外键: 所属用户
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- 基础信息
    name VARCHAR(50) NOT NULL,
    avatar_url VARCHAR(500),
    birth_date DATE,
    gender VARCHAR(10) CHECK (gender IN ('male', 'female', 'other')),

    -- 显式偏好 (家长填写)
    interests JSONB DEFAULT '[]',
    -- 示例: ["恐龙", "太空", "公主", "动物"]

    growth_themes JSONB DEFAULT '[]',
    -- 示例: ["勇气", "分享"]

    -- 隐式偏好 (系统学习)
    reading_preferences JSONB DEFAULT '{}',
    -- 示例: {
    --   "preferred_length": "medium",  -- short/medium/long
    --   "preferred_style": "adventure", -- adventure/fairy/educational
    --   "tag_weights": {"恐龙": 5, "公主": 2, "太空": 3}
    -- }

    -- 统计数据
    stories_count INTEGER DEFAULT 0,
    total_reading_time INTEGER DEFAULT 0,  -- 秒

    -- 时间戳
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    -- 约束
    CONSTRAINT unique_child_per_user UNIQUE (user_id, name)
);

-- 索引
CREATE INDEX idx_child_profiles_user_id ON child_profiles(user_id);

1.2 兴趣标签枚举

预定义的兴趣标签,前端展示用:

INTEREST_TAGS = {
    "animals": {
        "zh": "动物",
        "icon": "🐾",
        "subtags": ["恐龙", "猫咪", "狗狗", "兔子", "海洋动物"]
    },
    "fantasy": {
        "zh": "奇幻",
        "icon": "✨",
        "subtags": ["公主", "王子", "魔法", "精灵", "龙"]
    },
    "adventure": {
        "zh": "冒险",
        "icon": "🗺️",
        "subtags": ["太空", "海盗", "探险", "寻宝"]
    },
    "vehicles": {
        "zh": "交通工具",
        "icon": "🚗",
        "subtags": ["汽车", "火车", "飞机", "火箭"]
    },
    "nature": {
        "zh": "自然",
        "icon": "🌳",
        "subtags": ["森林", "海洋", "山川", "四季"]
    }
}

1.3 成长主题枚举

GROWTH_THEMES = [
    {"key": "courage", "zh": "勇气", "description": "克服恐惧,勇敢面对"},
    {"key": "sharing", "zh": "分享", "description": "学会与他人分享"},
    {"key": "friendship", "zh": "友谊", "description": "交朋友,珍惜友情"},
    {"key": "honesty", "zh": "诚实", "description": "说真话,不撒谎"},
    {"key": "independence", "zh": "独立", "description": "自己的事情自己做"},
    {"key": "kindness", "zh": "善良", "description": "帮助他人,关爱弱小"},
    {"key": "patience", "zh": "耐心", "description": "学会等待,不急躁"},
    {"key": "curiosity", "zh": "好奇", "description": "探索未知,爱问为什么"}
]

二、SQLAlchemy 模型

# backend/app/db/models.py

from sqlalchemy import Column, String, Date, Integer, ForeignKey, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
import uuid

class ChildProfile(Base):
    __tablename__ = "child_profiles"

    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)

    # 基础信息
    name = Column(String(50), nullable=False)
    avatar_url = Column(String(500))
    birth_date = Column(Date)
    gender = Column(String(10))

    # 偏好
    interests = Column(JSON, default=list)
    growth_themes = Column(JSON, default=list)
    reading_preferences = Column(JSON, default=dict)

    # 统计
    stories_count = Column(Integer, default=0)
    total_reading_time = Column(Integer, default=0)

    # 时间戳
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

    # 关系
    user = relationship("User", back_populates="child_profiles")
    story_universes = relationship("StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan")

    @property
    def age(self) -> int | None:
        """计算年龄"""
        if not self.birth_date:
            return None
        today = date.today()
        return today.year - self.birth_date.year - (
            (today.month, today.day) < (self.birth_date.month, self.birth_date.day)
        )

三、Pydantic Schema

# backend/app/schemas/child_profile.py

from pydantic import BaseModel, Field
from datetime import date
from uuid import UUID

class ChildProfileCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=50)
    birth_date: date | None = None
    gender: str | None = Field(None, pattern="^(male|female|other)$")
    interests: list[str] = Field(default_factory=list)
    growth_themes: list[str] = Field(default_factory=list)

class ChildProfileUpdate(BaseModel):
    name: str | None = Field(None, min_length=1, max_length=50)
    birth_date: date | None = None
    gender: str | None = Field(None, pattern="^(male|female|other)$")
    interests: list[str] | None = None
    growth_themes: list[str] | None = None
    avatar_url: str | None = None

class ChildProfileResponse(BaseModel):
    id: UUID
    name: str
    avatar_url: str | None
    birth_date: date | None
    gender: str | None
    age: int | None
    interests: list[str]
    growth_themes: list[str]
    stories_count: int
    total_reading_time: int

    class Config:
        from_attributes = True

class ChildProfileListResponse(BaseModel):
    profiles: list[ChildProfileResponse]
    total: int

四、API 实现

# backend/app/api/profiles.py

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID

router = APIRouter(prefix="/api/profiles", tags=["profiles"])

@router.get("", response_model=ChildProfileListResponse)
async def list_profiles(
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """获取当前用户的所有孩子档案"""
    profiles = await db.execute(
        select(ChildProfile)
        .where(ChildProfile.user_id == current_user.id)
        .order_by(ChildProfile.created_at)
    )
    profiles = profiles.scalars().all()
    return ChildProfileListResponse(profiles=profiles, total=len(profiles))

@router.post("", response_model=ChildProfileResponse, status_code=201)
async def create_profile(
    data: ChildProfileCreate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """创建孩子档案"""
    # 检查是否超过限制 (每用户最多5个孩子档案)
    count = await db.scalar(
        select(func.count(ChildProfile.id))
        .where(ChildProfile.user_id == current_user.id)
    )
    if count >= 5:
        raise HTTPException(400, "最多只能创建5个孩子档案")

    profile = ChildProfile(user_id=current_user.id, **data.model_dump())
    db.add(profile)
    await db.commit()
    await db.refresh(profile)
    return profile

@router.get("/{profile_id}", response_model=ChildProfileResponse)
async def get_profile(
    profile_id: UUID,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """获取单个孩子档案"""
    profile = await db.get(ChildProfile, profile_id)
    if not profile or profile.user_id != current_user.id:
        raise HTTPException(404, "档案不存在")
    return profile

@router.put("/{profile_id}", response_model=ChildProfileResponse)
async def update_profile(
    profile_id: UUID,
    data: ChildProfileUpdate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """更新孩子档案"""
    profile = await db.get(ChildProfile, profile_id)
    if not profile or profile.user_id != current_user.id:
        raise HTTPException(404, "档案不存在")

    for key, value in data.model_dump(exclude_unset=True).items():
        setattr(profile, key, value)

    await db.commit()
    await db.refresh(profile)
    return profile

@router.delete("/{profile_id}", status_code=204)
async def delete_profile(
    profile_id: UUID,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """删除孩子档案"""
    profile = await db.get(ChildProfile, profile_id)
    if not profile or profile.user_id != current_user.id:
        raise HTTPException(404, "档案不存在")

    await db.delete(profile)
    await db.commit()

五、隐式偏好学习

5.1 行为事件

class ReadingEvent(BaseModel):
    """阅读行为事件"""
    profile_id: UUID
    story_id: UUID
    event_type: Literal["started", "completed", "skipped", "replayed"]
    reading_time: int  # 秒
    timestamp: datetime

5.2 偏好更新算法

async def update_reading_preferences(
    db: AsyncSession,
    profile_id: UUID,
    story: Story,
    event: ReadingEvent
):
    """根据阅读行为更新隐式偏好"""
    profile = await db.get(ChildProfile, profile_id)
    prefs = profile.reading_preferences or {}
    tag_weights = prefs.get("tag_weights", {})

    # 权重调整
    weight_delta = {
        "completed": 1.0,    # 完整阅读,正向
        "replayed": 1.5,     # 重复播放,强正向
        "skipped": -0.5,     # 跳过,负向
        "started": 0.1       # 开始阅读,弱正向
    }

    delta = weight_delta.get(event.event_type, 0)

    for tag in story.tags:
        current = tag_weights.get(tag, 0)
        tag_weights[tag] = max(0, current + delta)  # 不低于0

    # 更新阅读长度偏好
    if event.event_type == "completed":
        word_count = len(story.content)
        if word_count < 300:
            length_pref = "short"
        elif word_count < 600:
            length_pref = "medium"
        else:
            length_pref = "long"

        # 简单的移动平均
        prefs["preferred_length"] = length_pref

    prefs["tag_weights"] = tag_weights
    profile.reading_preferences = prefs
    await db.commit()

六、数据迁移

# backend/alembic/versions/xxx_add_child_profiles.py

def upgrade():
    op.create_table(
        'child_profiles',
        sa.Column('id', sa.UUID(), nullable=False),
        sa.Column('user_id', sa.UUID(), nullable=False),
        sa.Column('name', sa.String(50), nullable=False),
        sa.Column('avatar_url', sa.String(500)),
        sa.Column('birth_date', sa.Date()),
        sa.Column('gender', sa.String(10)),
        sa.Column('interests', sa.JSON(), server_default='[]'),
        sa.Column('growth_themes', sa.JSON(), server_default='[]'),
        sa.Column('reading_preferences', sa.JSON(), server_default='{}'),
        sa.Column('stories_count', sa.Integer(), server_default='0'),
        sa.Column('total_reading_time', sa.Integer(), server_default='0'),
        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
        sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
        sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
        sa.PrimaryKeyConstraint('id')
    )
    op.create_index('idx_child_profiles_user_id', 'child_profiles', ['user_id'])

def downgrade():
    op.drop_index('idx_child_profiles_user_id')
    op.drop_table('child_profiles')

七、隐私与安全

7.1 数据加密

敏感字段(姓名、出生日期)在存储时加密:

from cryptography.fernet import Fernet

class EncryptedChildProfile:
    """加密存储的孩子档案"""

    @staticmethod
    def encrypt_name(name: str, key: bytes) -> str:
        f = Fernet(key)
        return f.encrypt(name.encode()).decode()

    @staticmethod
    def decrypt_name(encrypted: str, key: bytes) -> str:
        f = Fernet(key)
        return f.decrypt(encrypted.encode()).decode()

7.2 访问控制

  • 孩子档案只能被创建者访问
  • 删除用户时级联删除所有孩子档案
  • API 层强制校验 user_id 归属

7.3 数据保留

  • 用户可随时删除孩子档案
  • 删除后 30 天内可恢复(软删除)
  • 30 天后永久删除