From 814b96edb68c6e4bf14fa8502d7afac82a0b4c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Mon, 2 Feb 2026 10:47:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E6=96=87=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/{.env.dev => .env.devs} | 6 +- server/.env.prod | 20 ++ server/alembic.ini | 39 ++++ server/alembic/README.md | 40 ++++ .../alembic/__pycache__/env.cpython-313.pyc | Bin 0 -> 5714 bytes server/alembic/env.py | 137 +++++++++++++ server/alembic/script.py.mako | 27 +++ .../versions/0001_init_content_tables.py | 147 +++++++++++++ .../0001_init_content_tables.cpython-313.pyc | Bin 0 -> 6907 bytes .../app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 293 bytes server/app/api/__init__.py | 6 + server/app/api/v1/__init__.py | 4 + server/app/api/v1/user_profile_scoring.py | 26 +++ .../core/__pycache__/config.cpython-313.pyc | Bin 0 -> 2707 bytes .../app/db/__pycache__/base.cpython-313.pyc | Bin 0 -> 488 bytes server/app/db/models/__init__.py | 14 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 631 bytes .../__pycache__/content.cpython-313.pyc | Bin 0 -> 2329 bytes .../content_profile.cpython-313.pyc | Bin 0 -> 3047 bytes .../content_risk_flag.cpython-313.pyc | Bin 0 -> 1868 bytes server/app/db/models/content.py | 70 +++++++ server/app/db/models/content_profile.py | 95 +++++++++ server/app/db/models/content_risk_flag.py | 51 +++++ .../features/user_profile_scoring/__init__.py | 9 + .../features/user_profile_scoring/scoring.py | 194 ++++++++++++++++++ .../features/user_profile_scoring/types.py | 89 ++++++++ server/app/main.py | 4 + server/requirements.txt | 1 + 28 files changed, 976 insertions(+), 3 deletions(-) rename server/{.env.dev => .env.devs} (65%) create mode 100644 server/.env.prod create mode 100644 server/alembic.ini create mode 100644 server/alembic/README.md create mode 100644 server/alembic/__pycache__/env.cpython-313.pyc create mode 100644 server/alembic/env.py create mode 100644 server/alembic/script.py.mako create mode 100644 server/alembic/versions/0001_init_content_tables.py create mode 100644 server/alembic/versions/__pycache__/0001_init_content_tables.cpython-313.pyc create mode 100644 server/app/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/api/__init__.py create mode 100644 server/app/api/v1/__init__.py create mode 100644 server/app/api/v1/user_profile_scoring.py create mode 100644 server/app/core/__pycache__/config.cpython-313.pyc create mode 100644 server/app/db/__pycache__/base.cpython-313.pyc create mode 100644 server/app/db/models/__init__.py create mode 100644 server/app/db/models/__pycache__/__init__.cpython-313.pyc create mode 100644 server/app/db/models/__pycache__/content.cpython-313.pyc create mode 100644 server/app/db/models/__pycache__/content_profile.cpython-313.pyc create mode 100644 server/app/db/models/__pycache__/content_risk_flag.cpython-313.pyc create mode 100644 server/app/db/models/content.py create mode 100644 server/app/db/models/content_profile.py create mode 100644 server/app/db/models/content_risk_flag.py create mode 100644 server/app/features/user_profile_scoring/__init__.py create mode 100644 server/app/features/user_profile_scoring/scoring.py create mode 100644 server/app/features/user_profile_scoring/types.py 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 0000000000000000000000000000000000000000..2e046b71bc47da32e728d2d7ef7004358de1e0e0 GIT binary patch literal 5714 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 RHJn*7npyP?vshm5{{UIls4D;f literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4ad3518ff64e175b31ec82d576086de1fda2d213 GIT binary patch literal 6907 zcmc&(Yg7|g9#6uM7Xf*xfUl`q6e=W$Xrb1sK`jqM%V?|XwsS%AzYlJ#IYa%e#4BssHS_l(iDA63n*lZ{nHY$L^Cvx zyZj7KF+9#Y+q@KKHn&p8XpZ(Xctd?1ZnN2{NF+uk#K?r0EJbm3W;;tcc?z$GM;*S| zws~uXZF7aK%509IS(ItTEGNVGc_$B$9K7oM0SEkK`ntGRykgN04d9A%5PufHPliUL zeeib^BIHP_tu;YassajYfuJK;vZhs^EY1Idgzktj`Q9oUBKa2o1&Is-s|BLrFO$&M zX8t6iK_OvTh=$(QYHAXdfM_I4wU|O)T}QJ*;-?U^(n7Q-5OdUYB(jMddDctP!plt? zFE1^;d<9;vntn+R6>|9x%e5|jxGvd~C?E=nrD@o6nPQCpo-WH5=>n->@dtddh$tpX zYIVd4Vr8uvSNea^Vrkj`S3x?kShX9Bo=;1N9kID3DjHB3BaD zngm~Qv)eJrC>v`I<+Ne+J}B zRpr(5#!dzA>QT1q5!U71X|2ou=}c4rnaGqzCbBE$s=n6N%WG``u_rCx{{O6v8&#u2 zN7#wIkUukIPc)?AiTjA1gih5{)IIT8khjmvdo*PY4$+v{gL0xdxf`Y32VgU46c~>7 zFqB)U1%0D$or+#MEnT=43HHDy_TJ6YbEmFKm*2a0b0pF9#l+m?Ytgqqi`;%U+J7<< zJSPnd-Me|FEJMr+u(Z#~c9ES_mzeDoct6d!Sjq<*WYOUA`{D(jI>D0^BRmD8&7HlD zP~|aUC~4r@?8j3u)ZEz*Bf%*sOQ+wFZuiD6-G~khM!p!q<<7hdji(chjNtX6jW?SO zSfU}(DC~l_+0Rc#Zv8eA{N2BRPceWmnqUGQewL)&v3~#{Fs1iWjECThlTLoGfVQ^M=@IG16j&to_NQQl9FAg-Q7q}E+MR-zXP1CToFKCh za3^}o9it^dTq-$3un`pOau*Pb z8~Q9d@ds&QICAS8xOW21nnJUtz^n-@lU+bN{ zaTP^i&lqm|brl2)G88^{X?*s|6rR+$NLC2~jT2}%bkbg$?;?+Ken!{>AkyILaSl%K z`0ViOv7T3Bz2~HLY2u93J08703S>7|Cwc;%0y>M$UbrE>HlU14u^0tH($z`j0OHyO zP3nB*FOZ4*b$-%cS(@u4+r3T?C+Z*_p?>ZS=(+-y=JnN)k`AI_4kO@wR=DiSDu;w+vI)&|F1fOVd2tJCXT_T18K(r7( zr`Ov6cRL>NIm2!+C*@>B45c|S_n@4j=FvaaJOKSP;!D0|zTY+6 zHJLHJW=oI<5r=l4Eb?qhq~c2cSn=iJ$#v6@ZT+$kM4r>`mPPC-kxf^0V_BE8KFGe= zcH8s0=XU4koi}`;7mkD|ZraEv$NEXBuZ<-$#*(mQdpK{?{mcxL`5U8VIsWa2!c6^l z<(f_tA*Bul!h$@NNQi-zQKl}UyL^ei~qD{+HyF&VtsgNNx1O&h0=r8E1Spa zF4v7aC-d&Cs}8zD&HS`cNR3{az&hI?_dasplnLguB+M7KtXtTANVUDCujP(qRd~f} z8RO7G4Z@Z+2&1vDaqyL)$L?5GhL;v2H5Uz3k`OxEe0RmF_qPpi8?7GMF5~T7h&TAd z*6Nxzc88a(`IgdFgJC+fDa%+xRrW8L(4K>#mt8ZMJ7t?ELv_uemiCat6AB13*s)Z@ z#!&h8;PD_Gw`Iy|D^tAXsyYA4g_wz zpK$4~jVor1D~6iKOGDclr;SaBk*Z=KWJ`*V^`X+b;Ln4O5OHV*JDiyHU}Fg1GI?yW z<4fTVwx4Z*;9qdZi}Is<$2$Qh?{w~X3Ah!i)h#%CsGmA970J~7FpQe`pj+YOsXq-= zxD`%wd_&rjMiohQP4$nUsfjSBscDvqr26>g`SIawW^u4}z#_W^95kutsYa8oiTpc8wH7PHgpWRe|V%!|*XXK@rMJz5R*|^>3 zcMD!>2ipd%=st`?2i|Wov|8x^{#~=<2FhUuhIe?6*48aUV4C#!TOr-@tF4xluea}~~f4XbR z^C>Hy_Ow22UHY_t@#BUjF0L2rw>+EC_kPc;=d;&6?d*NByY*TBgeUuEzg)NzB+@bW z>GJNUJ=0#UTJdb&w)cB_Kql>8`()3omkkr1tzG(J;nJ6TCp_6P>uJ}*XDb#yojV<5 zs-Gs~E%x~Ml>FrQ_>~NwK`y)HpdVVCT2!o`m7kTb?~+=aU6Nm*pHoq(pPpHgny82KczG$)vkyQXduYH#h~E$ Sz|6?V_=$;yk)? 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 0000000000000000000000000000000000000000..0abbd9328542e2650c0f197ed6755076fdaf7208 GIT binary patch literal 2707 zcmaJ@-E$My6~C)peXYLy3C5EIHNlZpz-lu?TiU`jg|@`bxYRq&WMXI5YiTVnh+XyW z%Ee5kql6G_D3)h1R0)L8&cp)`wQ0fx2ixIaz@rB=caoGQUVS_PW1iZVp1Uh4t{-wW zy7!!W&%Nh<{C?-`Mj*f=DBb^^RPMVF`j#s7;3<1G*_Apr?YAL?Z=6Q;7G&(X_b z){@yiuGfj3mdy3JdfnLF>%ksNclLREdCd3vun%;f;Oa!jIPC90&m+Ozi3HCuzmK&& z;lMB}_ysSu*k^Yk98B`jK$U7yhWI2^)eOl{G*t)Ho5C3}F2%=XP`IO#F&=dg_p6E_ zV<|;EzfV&`DFvPWUrV|y$cCY)Nu9LRr9+xJrX&w5aU;&u9MXpF2mZ{)*C1R%LyfT^ z2OXwmU#)MTc1w1zUDx+M05`g|2n?N|GJ}lZ*y}?GT9&o~*W*wfgN_03))`>a@TRhZ zYC*X|VOgCBmoA@OU;gad>^nSPS-f4!&pcZBfZrP)>4CwXggh}4j@6Vj))FJ(^`#4y zdsoaeAFnT8H1A$~wDOl~_5*WisXX`3%Dvh0@@@0#jq2$e<<(D0fBmd9_k}riD?Fmh zctXapk#PCSg1LGL);H(hDP6m2JBKCBf8=2vEM`uqGncQP&3%*ou(G&p<}UM9`h2H6^_`#cS`ythK!k;} z5RNMSQ8%WqL_DH6rle$1ByKG0T53Wj?zDtu)zAr>lnvsNPbxZ9bOT2@;+E2B5w3-J zMgeYOLcxTOOKL(%NQSHvCarU@Y#0hT6dQyy>#<|nF)emj){h%nI+i*)8A}3srPv8Y zO^jtys;uj=+TFm~vABk1s3>TA(vu`4CNp3`U0NH(JLprwUzecDqNgpWscr7a`9D7h zb*+WE3Zck@P;4y}yTz=9UdkSM7}}oy;aX@{v8f~fi?yZ~pE%hTSGM=D2YFj_!Rg@a zp9-Fx*_Xd|d#3)7dwu%#+#Azxd>Lw){qgyUsX);e+5jggPOEyU0iM~Ifu2if7-2?0 z*dZ{O?ShbjIf2Dafg5%S&InitF64P%>c5|^Jrz-B0=fX8>M%1K!VER9g6k&w@c^F*{G}Bo;P!M%*FT0*WWhZ zdw&xLuB@-#13%xfCf0!7NVqh0*(QXIV{ia7sW0q@EuPv|3ur?TKxkx7I6^TRc@wwM z!In)(*=r*Ox55DNP%Ns_3Hg-!M?Kch?i$y0L;2NCuwWI{u=~;)Hi#cUDp|PA;1uy< zIictx6`SL7O2(7oDAtb4n0EF5Cs2>ALxB7@Nqfx%SdvK@;;0lqE~^QGeoI_RQU%aP zS;AA4rm;MxoFu_G<%ce-x?(86DyMwXz|u_=4N}h0T?6NMQ9Pj~G8X^*qWH&*l&Xz{ zC@-;Yq!d+FHA+uWOlWaYwC)6GA#TbO&`X~i!;umG0j*x76^*mR4-Vv%Xfukmi=s}G zOI(x;11qB$AT7+^L(MyB)j_Lg>J?qpBHCH>#KAc|kNqgJC;Pkmffowg?qXYa_Q+Jc zz;zefp0%ZCi)|gY)bX&Ty}-3U47Hr@FK+KFaGk~7vHRSP{DA@&`*&+sv9-Op{h4BL z+Y>j}<~seV?VURQKeo|eVx_5fL4X7eDWwmokS+$GcWV(?f!kM0U*DIs4lwn!kpOE* z*__G@x$CP9GWF!4I))mlYS1A#_cOZ?B$G2>$ya9XK)7v?%3PW+uUv!(UZ;?G?s{c) zwfeWa=3Po8i1zC%dGpNF_mBj*Svmi4Jqt|J1Ti}gxxk)c6K6ALc}&mRPu#VPW>JZL zGBko^)JYtgPFxVjX~=d0kd?F*L9Hi_viN7P0%5IqUXQRky;+Fg78Hmd<^qcCuL^ts zhJFfv{pV0+(MB6`1|RbNTy#1*`$|4j;JY61yVv;L1%B@X{wKHEvxmM6J(t%Dp%=18 z)_tw{@ikxMJ{PebyFbcVShl(B!m^RUD(-@XqYiu!8hei+N|Lb*3<6;=-5c?aWz@K# zX(=63e9(ktjHi@QJEOUb$uxWdVH&70eO1z&o=iw81%2Zn9y&-L_q*G$4!4B~#Pds= zp9k?_Fs2_w`hKV$vkb$0g`PLj&aY6%ztFBSI{4Jp%rrgi-p075-g=6ldHfP`_%<9t Orh9`0=?Rsrlm8EcQ&aT- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..20caa2eb6555552bb1c7c96cf9694e8f8c9af4d2 GIT binary patch literal 488 zcmXv~Jxc>Y5Z%pPObBWaRDxBs5Mtq0cA_Y${D|mcCk&g*s;7H*$?Tn&PAtSfkRRb+ zIg@qa;YyS*GLvifLzm;cTx=CQfB$ND@9dYDM)?gb< zme!c`uK12u=&*F-x5^VoT0F0}bzl^#Gs>68x3_r;~vXvJM}mx#8u z_i54>=5Z))!c;2}g>mmXGqJW-knTaFQ2oB>bw!t&-0lz2E%VR0X{=)}?A>qT;{Sc8 z<>iZZU{o>Tc7hSQ%@&oelR{f3lvj6)5%+M3s9UF9Cim%a&Og}J*Bs~TulwJO!e7-d Dr4V~a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3d814331fa994711ccbb50b0b850b50f1393185b GIT binary patch literal 631 zcmZuv&ubGw6rSD9kFwb6#fu=$H9~hlJcx==6%UEO+Qsv9lbytE_eYrBh`C5ARt&~g z(15g2D6|qOO+56VCFo86hlO73&New!3ErLE#9QCt@y&bh`@T2B3@0aLz~je-C&sn_ zz(I8QAL1AcFLKZXDxg3WR52%HMJmDwOF1c;CprXWB-P0k85T}oxrm7km{ zht1F7s}9?H1MkhIDcJpiDD5?(=|_KX6Po1J+5?(nuuFM(k;w@7vWh5$b8cjqg$GD+#DCVd+Zq5X{L$sCotp-06&W! zxP-cB!C7$7P2w(kP8pextEgmnL_?Ng>&1#`6W2w2?h&Dg+aNuUMr$IB4cqWAR?1a> v;wmpzOmdA*acTa8fP03g-;y8*f5eoK_u(fQ6uZuZPM_jg|R zoO9>yQZUFM`24)%gVcx@p~pm6{&+0Lv2+BO&k%<=M+b^Iu!AMMv%?v4VyDTwI$SX~ zcE>!}6QeK{^I~s|#!dE-Mxe_0bXE z0udq(-7iRztO{yMmJ|@_q@apwDkDZbdPQ5mm>6gSK*d5zQgwPSfL#FUo^~lI4(jeM zaZuIWy_%GWICW~jkj;uoJ(w|{d_qoZ83~-a_XvuZu%cU(wjPe9ZU@-_y)LOs|zz+DDLMf zHiH2{;sHRyTqPhut_qMBoGeDUPz z!pw2w!ld!>G4{<)Hp+IjEzXY2-#Z4zg{j-%YjO4@=$1}=U%d0l!j)X<#xdjUxx#}{ zwp)c6;YF#NHAXJllA7pd3-?YHrgDr$u&O`h5Mc4xeSqo4T z&j9-BME?K`nYl5AH*K?6RA8HA<|F~;v^JF-e5xX7YQK#6R8sdnW$T_%QN>I)4O32( z{40bmtrd!9)vS175;AAo_d*FuC%!3O`*Lx1)P^2>Te|a+ap{M`!!f8;Vd`}8?B`IL zh)efpaVjI=A$~v{(t{9RPDu$YW*}YNn~*bv8TMO8=p8t1hjqr@8|0$B2GL8Wz9Ui0 zzCq~X*~$4^(-69J>Uv>n8u;SK7sZFi7shAo(wikw6C}Pt4J5+8dy;vboP2QN%wL!& zOrL?_Grs!K$lWYVomsf{iE-v8IMKF)tuZlfj6MASMsH~Wuquv>7H6iR7Ymo}MqGFe zEa@Rd#P5ojPl~;QmR7A&gzZu+zxqh!u?RzuYlBIjN-yAK>|;nvk}U3ZCyxVMR@+BvFz9_VRpE zP5=|+d6hKIQpDuSz+Q7VHoyzGPvLn?4mYkh^Wymx?{$F43l}#KvX+o)LTU-22=M{Y zU6TABZY1J*BG&M{LhgkG>_Ww<9!(WVj3&UA;D_|~=pop>5Wk8j)(e&U@ zv@fNKLNt?-lD%455)~zCRVfPBLNwVEg#w6aC2CK1OLho{z@JIT4wMGC6HcJAlk(6{ z!l~Z;+GL3wpxzMHw|08nvBH z#;y74x_oVYKHO3Ex*Mrn?3{lIxjj@lfSR}G=|H}+CLi7pSTmJ-doI{$fy@e!`zIJc z8+Vvy?U2MqD%U<&v3?1;z$cA1?=+D;fNZ969dnh<7Q&znFXriRzOpvIYR#p=PY2Hr z=fmsDKF$dM0qH@ZMj^XkqRumhvn|Pno; I9;TDO0Q6!I5C8xG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7e220eb338c78e73c870e81beda881584ef6bd4b GIT binary patch literal 3047 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 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