diff --git a/server/.env.dev b/server/.env.devs similarity index 65% rename from server/.env.dev rename to server/.env.devs index 64f08bb..04a4fd8 100644 --- a/server/.env.dev +++ b/server/.env.devs @@ -7,13 +7,13 @@ APP_HOST=0.0.0.0 APP_PORT=8000 # 数据库(dev 指向 mindfulness_dev;prod 指向 mindfulness) -DATABASE_URL=mysql+aiomysql://<用户名>:<密码>@:3306/mindfulness_dev?charset=utf8mb4 +DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness_dev?charset=utf8mb4 # Redis(使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀) -REDIS_URL=redis://:@:6379/0 +REDIS_URL=redis://dev_damer:damer@43.163.242.87:6379/0 # Celery(默认不启用结果存储,避免 Redis 内存压力) -CELERY_BROKER_URL=redis://:@:6379/0 +CELERY_BROKER_URL=redis://dev_damer:damer@43.163.242.87:6379/0 # CELERY_RESULT_BACKEND=redis://:@:6379/0 # 推送(Expo) diff --git a/server/.env.prod b/server/.env.prod new file mode 100644 index 0000000..246e1c7 --- /dev/null +++ b/server/.env.prod @@ -0,0 +1,20 @@ +# 运行环境:dev 或 prod +APP_ENV=prod + +# Web 服务 +APP_NAME=mindfulness-server +APP_HOST=0.0.0.0 +APP_PORT=8000 + +# 数据库(dev 指向 mindfulness_dev;prod 指向 mindfulness) +DATABASE_URL=mysql+aiomysql://damer:damer@43.163.242.87:3306/mindfulness?charset=utf8mb4 + +# Redis(使用 ACL 用户;并确保应用侧 key 带 dev:/pro: 前缀) +REDIS_URL=redis://prod_damer:damer@43.163.242.87:6379/0 + +# Celery(默认不启用结果存储,避免 Redis 内存压力) +CELERY_BROKER_URL=redis://prod_damer:damer@43.163.242.87:6379/0 +# CELERY_RESULT_BACKEND=redis://:@:6379/0 + +# 推送(Expo) +# EXPO_ACCESS_TOKEN= diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..0b41af5 --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = alembic + +# 注意:实际连接串由 alembic/env.py 从环境变量 DATABASE_URL 注入 +sqlalchemy.url = driver://user:pass@localhost/dbname + +[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 + diff --git a/server/alembic/README.md b/server/alembic/README.md new file mode 100644 index 0000000..a70351e --- /dev/null +++ b/server/alembic/README.md @@ -0,0 +1,40 @@ +# Alembic(数据库迁移) + +## 1. 前置 + +- 在 `server/` 下准备 `.env.dev`(或系统环境变量),至少包含: + - `DATABASE_URL=mysql+aiomysql://...` + +> 注意:本仓库推荐使用 Python 虚拟环境(venv)。示例以 `server/.venv` 为准。 + +--- + +## 2. 安装依赖(一次性) + +在仓库根目录: + +```bash +python3 -m venv server/.venv +source server/.venv/bin/activate +pip install -r server/requirements.txt +``` + +--- + +## 3. 常用命令 + +在 `server/` 目录运行: + +```bash +source .venv/bin/activate +alembic -c alembic.ini history +alembic -c alembic.ini upgrade head +``` + +--- + +## 4. 说明 + +- 连接串由 `alembic/env.py` 从环境变量 `DATABASE_URL`(或 `app/core/config.py`)读取。 +- 初始迁移版本为:`0001_init_content_tables`(创建推荐系统最小内容表与画像表)。 + diff --git a/server/alembic/__pycache__/env.cpython-313.pyc b/server/alembic/__pycache__/env.cpython-313.pyc new file mode 100644 index 0000000..2e046b7 Binary files /dev/null and b/server/alembic/__pycache__/env.cpython-313.pyc differ diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..b190e08 --- /dev/null +++ b/server/alembic/env.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +# 让 alembic 在 `server/` 下运行时也能 import app.* +SERVER_DIR = Path(__file__).resolve().parents[1] # .../server/alembic -> .../server +sys.path.append(str(SERVER_DIR)) + +from app.db.base import Base # noqa: E402 +import app.db.models # noqa: F401,E402 # 确保模型被导入,metadata 完整 + +# Alembic Config 对象 +config = context.config + +# 配置日志 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 目标 metadata(autogenerate 依赖) +target_metadata = Base.metadata + + +def _read_env_kv(env_path: Path) -> dict[str, str]: + """ + 读取 .env 文件中的 KEY=VALUE。 + + 说明: + - 迁移阶段只需要 DATABASE_URL,不应因为 Redis/Celery 等配置缺失而失败 + - 这里不依赖 pydantic-settings 的 Settings 校验,避免“缺字段导致迁移不可用” + """ + + data: dict[str, str] = {} + if not env_path.exists(): + return data + for raw in env_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k: + data[k] = v + return data + + +def _get_database_url() -> str: + """ + 获取数据库连接串。 + + 约定: + - 优先读取环境变量 `DATABASE_URL` + - 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod` + + 注意:迁移阶段仅依赖 DATABASE_URL;不应强制要求 REDIS_URL / CELERY_BROKER_URL 等配置存在。 + """ + + # 允许在 alembic 命令时临时覆盖 + env_url = os.getenv("DATABASE_URL") + if env_url: + return env_url + + app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev" + env_file = SERVER_DIR / (".env.prod" if app_env == "prod" else ".env.dev") + kv = _read_env_kv(env_file) + url = kv.get("DATABASE_URL") + if url: + return url + + raise RuntimeError( + "缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。" + ) + + +def run_migrations_offline() -> None: + """离线模式:生成 SQL 脚本,不连接数据库。""" + + url = _get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """在线模式:在已有连接上执行迁移。""" + + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """在线模式:使用异步引擎执行迁移。""" + + url = _get_database_url() + config.set_main_option("sqlalchemy.url", url) + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section) or {}, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) + diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..026ce91 --- /dev/null +++ b/server/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} + diff --git a/server/alembic/versions/0001_init_content_tables.py b/server/alembic/versions/0001_init_content_tables.py new file mode 100644 index 0000000..ba9bb56 --- /dev/null +++ b/server/alembic/versions/0001_init_content_tables.py @@ -0,0 +1,147 @@ +"""init content tables + +Revision ID: 0001_init_content_tables +Revises: +Create Date: 2026-02-01 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +# revision identifiers, used by Alembic. +revision = "0001_init_content_tables" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "contents", + sa.Column( + "content_id", + mysql.BIGINT(unsigned=True), + primary_key=True, + autoincrement=True, + comment="文案唯一 ID(自增;文案微调时保持不变)", + ), + sa.Column("text_en", sa.Text(), nullable=True, comment="英文文案(可空;若为空则必须提供 text_tc)"), + sa.Column("text_tc", sa.Text(), nullable=True, comment="繁体中文文案(可空;若为空则必须提供 text_en)"), + sa.Column("author_id", sa.String(length=255), nullable=True, comment="作者/来源 ID(可空;用于多样性与频控)"), + sa.Column("template_id", sa.String(length=255), nullable=True, comment="模板 ID(可空;用于多样性与频控)"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"), + sa.CheckConstraint( + "(text_en IS NOT NULL) OR (text_tc IS NOT NULL)", + name="chk_contents_text_present", + ), + mysql_charset="utf8mb4", + ) + op.create_index("idx_contents_author_id", "contents", ["author_id"], unique=False) + op.create_index("idx_contents_template_id", "contents", ["template_id"], unique=False) + + op.create_table( + "content_profiles", + sa.Column( + "content_id", + mysql.BIGINT(unsigned=True), + sa.ForeignKey("contents.content_id", ondelete="CASCADE"), + primary_key=True, + comment="FK -> contents.content_id", + ), + sa.Column( + "stage", + sa.Enum("general", "expecting", "parenting", "unknown", name="content_stage"), + server_default="general", + nullable=False, + comment="母职阶段定位(general/expecting/parenting/unknown)", + ), + sa.Column("emotion_score", sa.Numeric(3, 2), nullable=True, comment="情绪调性 0~1;NULL 表示 general"), + sa.Column( + "context_suitability_json", + sa.JSON(), + nullable=False, + comment="各 context 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ), + sa.Column( + "need_suitability_json", + sa.JSON(), + nullable=False, + comment="各 need 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ), + sa.Column( + "personalization_power", + sa.SmallInteger(), + server_default="0", + nullable=False, + comment="个性化力度(约定只允许 0/5/10,分别映射 0/0.5/1)", + ), + sa.Column( + "review_confidence", + sa.Numeric(3, 2), + nullable=True, + comment="标注置信度 0~1;NULL 表示由推荐侧按 0.7 兜底", + ), + sa.Column( + "is_safe_pool", + sa.Boolean(), + server_default=sa.text("0"), + nullable=False, + comment="是否属于通用安全池(L3 兜底)", + ), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="画像更新时间"), + mysql_charset="utf8mb4", + ) + op.create_index("idx_profiles_is_safe_pool", "content_profiles", ["is_safe_pool"], unique=False) + op.create_index("idx_profiles_personalization_power", "content_profiles", ["personalization_power"], unique=False) + op.create_index("idx_profiles_stage", "content_profiles", ["stage"], unique=False) + + op.create_table( + "content_risk_flags", + sa.Column( + "id", + mysql.BIGINT(unsigned=True), + primary_key=True, + autoincrement=True, + comment="主键", + ), + sa.Column( + "content_id", + mysql.BIGINT(unsigned=True), + sa.ForeignKey("contents.content_id", ondelete="CASCADE"), + nullable=False, + comment="FK -> contents.content_id", + ), + sa.Column( + "flag", + sa.String(length=64), + nullable=False, + comment="风险标记(unsafe_for_* / block_* / soft_*)", + ), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"), + sa.UniqueConstraint("content_id", "flag", name="uniq_content_flag"), + mysql_charset="utf8mb4", + ) + op.create_index("idx_content_id", "content_risk_flags", ["content_id"], unique=False) + op.create_index("idx_flag", "content_risk_flags", ["flag"], unique=False) + + +def downgrade() -> None: + op.drop_index("idx_flag", table_name="content_risk_flags") + op.drop_index("idx_content_id", table_name="content_risk_flags") + op.drop_table("content_risk_flags") + + op.drop_index("idx_profiles_stage", table_name="content_profiles") + op.drop_index("idx_profiles_personalization_power", table_name="content_profiles") + op.drop_index("idx_profiles_is_safe_pool", table_name="content_profiles") + op.drop_table("content_profiles") + + op.drop_index("idx_contents_template_id", table_name="contents") + op.drop_index("idx_contents_author_id", table_name="contents") + op.drop_table("contents") + diff --git a/server/alembic/versions/__pycache__/0001_init_content_tables.cpython-313.pyc b/server/alembic/versions/__pycache__/0001_init_content_tables.cpython-313.pyc new file mode 100644 index 0000000..4ad3518 Binary files /dev/null and b/server/alembic/versions/__pycache__/0001_init_content_tables.cpython-313.pyc differ diff --git a/server/app/__pycache__/__init__.cpython-313.pyc b/server/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8667beb Binary files /dev/null and b/server/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/api/__init__.py b/server/app/api/__init__.py new file mode 100644 index 0000000..9bb6d12 --- /dev/null +++ b/server/app/api/__init__.py @@ -0,0 +1,6 @@ +""" +API 路由入口 + +说明:按 FastAPI 常见工程结构拆分 api/v1/* 路由模块。 +""" + diff --git a/server/app/api/v1/__init__.py b/server/app/api/v1/__init__.py new file mode 100644 index 0000000..df8483f --- /dev/null +++ b/server/app/api/v1/__init__.py @@ -0,0 +1,4 @@ +""" +V1 API 路由集合 +""" + diff --git a/server/app/api/v1/user_profile_scoring.py b/server/app/api/v1/user_profile_scoring.py new file mode 100644 index 0000000..d63128d --- /dev/null +++ b/server/app/api/v1/user_profile_scoring.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire +from app.features.user_profile_scoring.types import BuildUserProfileRequest, UserProfileV1_2_Extended + +router = APIRouter(prefix="/v1/user-profile", tags=["user-profile"]) + + +@router.post("/score", response_model=UserProfileV1_2_Extended) +async def score_user_profile(req: BuildUserProfileRequest) -> UserProfileV1_2_Extended: + """ + 根据问卷答案生成用户画像(V1.2) + + 说明: + - 问卷题目允许跳过 + - 允许注入 generated_at/now,用于回归测试或离线批处理 + """ + + return build_user_profile_from_questionnaire( + req.answers, + generated_at=req.generated_at, + now=req.now, + ) + diff --git a/server/app/core/__pycache__/config.cpython-313.pyc b/server/app/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..0abbd93 Binary files /dev/null and b/server/app/core/__pycache__/config.cpython-313.pyc differ diff --git a/server/app/db/__pycache__/base.cpython-313.pyc b/server/app/db/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..20caa2e Binary files /dev/null and b/server/app/db/__pycache__/base.cpython-313.pyc differ diff --git a/server/app/db/models/__init__.py b/server/app/db/models/__init__.py new file mode 100644 index 0000000..5c1a619 --- /dev/null +++ b/server/app/db/models/__init__.py @@ -0,0 +1,14 @@ +""" +数据库 ORM 模型集合。 + +说明: +- 该包用于集中定义 SQLAlchemy ORM models,供 Alembic autogenerate 扫描。 +- 需要在此处导入所有模型,确保 `Base.metadata` 完整。 +""" + +from app.db.models.content import Content +from app.db.models.content_profile import ContentProfile +from app.db.models.content_risk_flag import ContentRiskFlag + +__all__ = ["Content", "ContentProfile", "ContentRiskFlag"] + diff --git a/server/app/db/models/__pycache__/__init__.cpython-313.pyc b/server/app/db/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3d81433 Binary files /dev/null and b/server/app/db/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/server/app/db/models/__pycache__/content.cpython-313.pyc b/server/app/db/models/__pycache__/content.cpython-313.pyc new file mode 100644 index 0000000..dd275b2 Binary files /dev/null and b/server/app/db/models/__pycache__/content.cpython-313.pyc differ diff --git a/server/app/db/models/__pycache__/content_profile.cpython-313.pyc b/server/app/db/models/__pycache__/content_profile.cpython-313.pyc new file mode 100644 index 0000000..7e220eb Binary files /dev/null and b/server/app/db/models/__pycache__/content_profile.cpython-313.pyc differ diff --git a/server/app/db/models/__pycache__/content_risk_flag.cpython-313.pyc b/server/app/db/models/__pycache__/content_risk_flag.cpython-313.pyc new file mode 100644 index 0000000..83454c5 Binary files /dev/null and b/server/app/db/models/__pycache__/content_risk_flag.cpython-313.pyc differ diff --git a/server/app/db/models/content.py b/server/app/db/models/content.py new file mode 100644 index 0000000..bd6a346 --- /dev/null +++ b/server/app/db/models/content.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Index, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Content(Base): + """ + 文案主体表。 + + 多语言约束: + - 当前仅支持 EN / TC(繁体中文) + - 至少需要提供 `text_en` 或 `text_tc` 之一 + """ + + __tablename__ = "contents" + + __table_args__ = ( + CheckConstraint( + "(text_en IS NOT NULL) OR (text_tc IS NOT NULL)", + name="chk_contents_text_present", + ), + Index("idx_contents_author_id", "author_id"), + Index("idx_contents_template_id", "template_id"), + ) + + content_id: Mapped[int] = mapped_column( + primary_key=True, + autoincrement=True, + comment="文案唯一 ID(自增;文案微调时保持不变)", + ) + + text_en: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="英文文案(可空;若为空则必须提供 text_tc)", + ) + text_tc: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="繁体中文文案(可空;若为空则必须提供 text_en)", + ) + + author_id: Mapped[str | None] = mapped_column( + nullable=True, + comment="作者/来源 ID(可空;用于多样性与频控)", + ) + template_id: Mapped[str | None] = mapped_column( + nullable=True, + comment="模板 ID(可空;用于多样性与频控)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + server_onupdate=func.now(), + comment="更新时间", + ) + diff --git a/server/app/db/models/content_profile.py b/server/app/db/models/content_profile.py new file mode 100644 index 0000000..c0c26b3 --- /dev/null +++ b/server/app/db/models/content_profile.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Optional + +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + Enum, + ForeignKey, + Index, + Numeric, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +ContentStage = Literal["general", "expecting", "parenting", "unknown"] + + +class ContentProfile(Base): + """ + 内容画像表(Content Profile / Cᵢ)。 + + 字段语义必须严格对齐: + - `设计说明文档/句子文案打分規則.md` + """ + + __tablename__ = "content_profiles" + + __table_args__ = ( + Index("idx_profiles_stage", "stage"), + Index("idx_profiles_personalization_power", "personalization_power"), + Index("idx_profiles_is_safe_pool", "is_safe_pool"), + ) + + content_id: Mapped[int] = mapped_column( + ForeignKey("contents.content_id", ondelete="CASCADE"), + primary_key=True, + comment="FK -> contents.content_id", + ) + + stage: Mapped[ContentStage] = mapped_column( + Enum("general", "expecting", "parenting", "unknown", name="content_stage"), + nullable=False, + server_default="general", + comment="母职阶段定位(general/expecting/parenting/unknown)", + ) + + emotion_score: Mapped[Optional[float]] = mapped_column( + Numeric(3, 2), + nullable=True, + comment="情绪调性 0~1;NULL 表示 general", + ) + + context_suitability_json: Mapped[dict] = mapped_column( + JSON, + nullable=False, + comment="各 context 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ) + need_suitability_json: Mapped[dict] = mapped_column( + JSON, + nullable=False, + comment="各 need 的适配度(JSON:0/0.5/1;必须包含 5 个 key)", + ) + + personalization_power: Mapped[int] = mapped_column( + nullable=False, + server_default="0", + comment="个性化力度(约定只允许 0/5/10,分别映射 0/0.5/1)", + ) + + review_confidence: Mapped[Optional[float]] = mapped_column( + Numeric(3, 2), + nullable=True, + comment="标注置信度 0~1;NULL 表示由推荐侧按 0.7 兜底", + ) + + is_safe_pool: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + server_default="0", + comment="是否属于通用安全池(L3 兜底)", + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + server_onupdate=func.now(), + comment="画像更新时间", + ) + diff --git a/server/app/db/models/content_risk_flag.py b/server/app/db/models/content_risk_flag.py new file mode 100644 index 0000000..281e201 --- /dev/null +++ b/server/app/db/models/content_risk_flag.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ContentRiskFlag(Base): + """ + 内容风险标记(risk_flags)关联表。 + + 命名约束(语义来源:句子文案打分规则): + - 仅允许 `unsafe_for_*` / `block_*` / `soft_*` 前缀 + - 旧 flag(如 `block_stage_unknown`)需在写入/读取层做映射 + """ + + __tablename__ = "content_risk_flags" + + __table_args__ = ( + UniqueConstraint("content_id", "flag", name="uniq_content_flag"), + Index("idx_flag", "flag"), + Index("idx_content_id", "content_id"), + ) + + id: Mapped[int] = mapped_column( + primary_key=True, + autoincrement=True, + comment="主键", + ) + + content_id: Mapped[int] = mapped_column( + ForeignKey("contents.content_id", ondelete="CASCADE"), + nullable=False, + comment="FK -> contents.content_id", + ) + + flag: Mapped[str] = mapped_column( + nullable=False, + comment="风险标记(unsafe_for_* / block_* / soft_*)", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="创建时间", + ) + diff --git a/server/app/features/user_profile_scoring/__init__.py b/server/app/features/user_profile_scoring/__init__.py new file mode 100644 index 0000000..b8e6548 --- /dev/null +++ b/server/app/features/user_profile_scoring/__init__.py @@ -0,0 +1,9 @@ +""" +User Profile Scoring(用户画像打分)V1.2 + +说明: +- 提供“问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)”的服务端实现 +- 规则以 `spec_kit/User Profile Scoring/spec.md`(V1.2)与 + `设计说明文档/客戶端問卷打分規則.md`(V1.2)为准 +""" + diff --git a/server/app/features/user_profile_scoring/scoring.py b/server/app/features/user_profile_scoring/scoring.py new file mode 100644 index 0000000..b099ad2 --- /dev/null +++ b/server/app/features/user_profile_scoring/scoring.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from app.features.user_profile_scoring.types import ( + HardRules, + ProfileAnswered, + QuestionnaireAnswersV1_2, + UserProfileV1_2_Extended, + UserStageOneHot, +) + + +def _clamp(value: float, min_value: float, max_value: float) -> float: + if value != value: # NaN + return min_value + return max(min_value, min(max_value, value)) + + +def normalize_answers(raw: QuestionnaireAnswersV1_2) -> QuestionnaireAnswersV1_2: + """ + 归一化答案: + - Pydantic 已对枚举做了校验;此处仅统一 None/缺失的语义为“跳过” + """ + + # 直接返回一份拷贝,保持纯函数语义 + return QuestionnaireAnswersV1_2.model_validate(raw.model_dump()) + + +def compute_profile_answered(answers: QuestionnaireAnswersV1_2) -> ProfileAnswered: + return ProfileAnswered( + stage=answers.mom_stage is not None, + emotion=answers.emotion is not None, + context=answers.context is not None, + need=answers.need is not None, + ) + + +def compute_time_confidence(generated_at: datetime, now: datetime) -> float: + """ + 时间衰减置信度(conf_time) + - 0–7 天:1.0 + - 7–30 天:线性衰减到 0.7(含第 30 天) + - 30 天以上:0.5 + """ + + delta = (now - generated_at).total_seconds() + if delta <= 0: + return 1.0 + + days = delta / (24 * 60 * 60) + if days <= 7: + return 1.0 + if days <= 30: + t = (days - 7) / (30 - 7) # 0..1 + return 1.0 - 0.3 * t + return 0.5 + + +def compute_profile_confidence(conf_time: float, answered: ProfileAnswered) -> float: + """ + V1.2:profile_confidence(conf_U) + conf = clamp(conf_time * (0.5 + 0.5 * completion), 0.2, 1.0) + """ + + answered_count = sum( + [ + 1 if answered.stage else 0, + 1 if answered.emotion else 0, + 1 if answered.context else 0, + 1 if answered.need else 0, + ] + ) + completion = answered_count / 4 + completion_factor = 0.5 + 0.5 * completion + return _clamp(float(conf_time) * float(completion_factor), 0.2, 1.0) + + +def _build_stage_one_hot(mom_stage: Optional[str]) -> UserStageOneHot: + # V1.2:mom_stage 跳过按安全策略输出 unknown=1 + if mom_stage is None: + return UserStageOneHot(unknown=1) + + return UserStageOneHot( + expecting=1 if mom_stage == "expecting" else 0, + parenting=1 if mom_stage == "parenting" else 0, + unknown=1 if mom_stage == "unknown" else 0, + ) + + +def _map_emotion_score(emotion: Optional[str]) -> Optional[float]: + if emotion is None: + return None + mapping = { + "low": 0.0, + "overwhelmed": 0.2, + "tired": 0.4, + "neutral": 0.6, + "calm": 0.8, + "joyful": 1.0, + } + return mapping.get(emotion) + + +def _build_sparse_one_hot(value: Optional[str]) -> dict[str, int]: + if value is None: + return {} + return {value: 1} + + +@dataclass(frozen=True) +class _RuleOutput: + rule_hits: list[str] + hard_rules: HardRules + + +def _compute_rule_output(stage: UserStageOneHot, emotion_score: Optional[float]) -> _RuleOutput: + rule_hits: list[str] = [] + forbidden_risk_flags: list[str] = [] + + stage_unknown = stage.unknown == 1 + stage_parenting = stage.parenting == 1 + + if stage_unknown: + rule_hits.append("unsafe_for_stage_unknown") + forbidden_risk_flags.append("unsafe_for_stage_unknown") + + if stage_parenting: + rule_hits.append("unsafe_for_stage_parenting") + forbidden_risk_flags.append("unsafe_for_stage_parenting") + + if emotion_score is not None and emotion_score <= 0.2: + rule_hits.append("unsafe_for_emotion_low") + forbidden_risk_flags.append("unsafe_for_emotion_low") + + forbidden_content_predicates = [] + if stage_unknown: + forbidden_content_predicates.append( + { + "id": "unknown_block_parenting_pressure_personalized", + "when_user": {"stage_unknown": True}, + "forbid_content": {"need": "parenting_pressure", "personalization_power": 1}, + } + ) + + return _RuleOutput( + rule_hits=rule_hits, + hard_rules=HardRules( + forbidden_risk_flags=forbidden_risk_flags, + forbidden_content_predicates=forbidden_content_predicates, + ), + ) + + +def build_user_profile_from_questionnaire( + raw_answers: QuestionnaireAnswersV1_2, + *, + generated_at: Optional[datetime] = None, + now: Optional[datetime] = None, +) -> UserProfileV1_2_Extended: + """ + 主入口:问卷答案(可跳过)→ 用户画像(V1.2)+ 硬规则输出 + """ + + answers = normalize_answers(raw_answers) + answered = compute_profile_answered(answers) + + now_dt = now or datetime.now(tz=timezone.utc) + gen_dt = generated_at or now_dt + + conf_time = compute_time_confidence(gen_dt, now_dt) + conf_u = compute_profile_confidence(conf_time, answered) + + stage = _build_stage_one_hot(answers.mom_stage) + emotion_score = _map_emotion_score(answers.emotion) + context = _build_sparse_one_hot(answers.context) + need = _build_sparse_one_hot(answers.need) + + rule_out = _compute_rule_output(stage, emotion_score) + + return UserProfileV1_2_Extended( + profile_generated_at=gen_dt, + profile_confidence=conf_u, + profile_answered=answered, + stage=stage, + emotion_score=emotion_score, + context=context, # type: ignore[arg-type] + need=need, # type: ignore[arg-type] + rule_hits=rule_out.rule_hits, + hard_rules=rule_out.hard_rules, + ) + diff --git a/server/app/features/user_profile_scoring/types.py b/server/app/features/user_profile_scoring/types.py new file mode 100644 index 0000000..b4a55a2 --- /dev/null +++ b/server/app/features/user_profile_scoring/types.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +MomStageAnswer = Literal["expecting", "parenting", "unknown"] +EmotionAnswer = Literal["low", "overwhelmed", "tired", "neutral", "calm", "joyful"] +ContextAnswer = Literal["family", "work", "relationship", "friends", "health"] +NeedAnswer = Literal[ + "emotional_support", + "parenting_pressure", + "self_worth", + "anxiety_relief", + "rest_balance", +] + + +class QuestionnaireAnswersV1_2(BaseModel): + """ + V1.2:每题可跳过 + + 说明: + - `None` 表示题目被跳过/无值(与客户端的 `null` 对齐) + - 字段缺失(未传)也视为跳过 + """ + + mom_stage: Optional[MomStageAnswer] = None + emotion: Optional[EmotionAnswer] = None + context: Optional[ContextAnswer] = None + need: Optional[NeedAnswer] = None + + +class ProfileAnswered(BaseModel): + stage: bool + emotion: bool + context: bool + need: bool + + +class UserStageOneHot(BaseModel): + expecting: Optional[Literal[0, 1]] = None + parenting: Optional[Literal[0, 1]] = None + unknown: Literal[0, 1] + + +class ForbiddenContentPredicate(BaseModel): + """ + 用于表达“需要同时看用户与内容字段才能执行”的规则(跨维度规则)。 + """ + + id: str + when_user: dict[str, Any] = Field(default_factory=dict) + forbid_content: dict[str, Any] = Field(default_factory=dict) + + +class HardRules(BaseModel): + forbidden_risk_flags: list[str] = Field(default_factory=list) + forbidden_content_predicates: list[ForbiddenContentPredicate] = Field(default_factory=list) + + +class UserProfileV1_2(BaseModel): + profile_version: Literal["v1.2"] = "v1.2" + profile_source: Literal["questionnaire"] = "questionnaire" + profile_generated_at: datetime + profile_confidence: float + profile_answered: ProfileAnswered + stage: UserStageOneHot + emotion_score: Optional[float] = None + context: dict[str, Literal[1]] = Field(default_factory=dict) + need: dict[str, Literal[1]] = Field(default_factory=dict) + + +class UserProfileV1_2_Extended(UserProfileV1_2): + rule_hits: list[str] = Field(default_factory=list) + hard_rules: HardRules = Field(default_factory=HardRules) + + +class BuildUserProfileRequest(BaseModel): + """ + API 请求体:问卷答案 + 可选时间注入(便于回归测试/服务端批处理) + """ + + answers: QuestionnaireAnswersV1_2 = Field(default_factory=QuestionnaireAnswersV1_2) + generated_at: Optional[datetime] = None + now: Optional[datetime] = None + diff --git a/server/app/main.py b/server/app/main.py index f4bfaa2..988e867 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from app.core.config import get_settings +from app.api.v1.user_profile_scoring import router as user_profile_router def create_app() -> FastAPI: @@ -14,6 +15,9 @@ def create_app() -> FastAPI: app = FastAPI(title=settings.app_name) + # 业务路由 + app.include_router(user_profile_router) + @app.get("/healthz") async def healthz() -> dict: return {"status": "ok", "env": settings.app_env} diff --git a/server/requirements.txt b/server/requirements.txt index 5d1da86..ad0748f 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -4,6 +4,7 @@ uvicorn[standard]>=0.27 # 数据库(SQLAlchemy 2.x 异步 + MySQL) SQLAlchemy>=2.0 aiomysql>=0.2 +greenlet>=3.0 # 配置 pydantic>=2.6