fix:文明
This commit is contained in:
@@ -7,13 +7,13 @@ APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
|
||||
# 数据库(dev 指向 mindfulness_dev;prod 指向 mindfulness)
|
||||
DATABASE_URL=mysql+aiomysql://<用户名>:<密码>@<MYSQL_HOST>: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://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||
REDIS_URL=redis://dev_damer:damer@43.163.242.87:6379/0
|
||||
|
||||
# Celery(默认不启用结果存储,避免 Redis 内存压力)
|
||||
CELERY_BROKER_URL=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||
CELERY_BROKER_URL=redis://dev_damer:damer@43.163.242.87:6379/0
|
||||
# CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||
|
||||
# 推送(Expo)
|
||||
20
server/.env.prod
Normal file
20
server/.env.prod
Normal file
@@ -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://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||
|
||||
# 推送(Expo)
|
||||
# EXPO_ACCESS_TOKEN=
|
||||
39
server/alembic.ini
Normal file
39
server/alembic.ini
Normal file
@@ -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
|
||||
|
||||
40
server/alembic/README.md
Normal file
40
server/alembic/README.md
Normal file
@@ -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`(创建推荐系统最小内容表与画像表)。
|
||||
|
||||
BIN
server/alembic/__pycache__/env.cpython-313.pyc
Normal file
BIN
server/alembic/__pycache__/env.cpython-313.pyc
Normal file
Binary file not shown.
137
server/alembic/env.py
Normal file
137
server/alembic/env.py
Normal file
@@ -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())
|
||||
|
||||
27
server/alembic/script.py.mako
Normal file
27
server/alembic/script.py.mako
Normal file
@@ -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"}
|
||||
|
||||
147
server/alembic/versions/0001_init_content_tables.py
Normal file
147
server/alembic/versions/0001_init_content_tables.py
Normal file
@@ -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")
|
||||
|
||||
Binary file not shown.
BIN
server/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
server/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
6
server/app/api/__init__.py
Normal file
6
server/app/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API 路由入口
|
||||
|
||||
说明:按 FastAPI 常见工程结构拆分 api/v1/* 路由模块。
|
||||
"""
|
||||
|
||||
4
server/app/api/v1/__init__.py
Normal file
4
server/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
V1 API 路由集合
|
||||
"""
|
||||
|
||||
26
server/app/api/v1/user_profile_scoring.py
Normal file
26
server/app/api/v1/user_profile_scoring.py
Normal file
@@ -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,
|
||||
)
|
||||
|
||||
BIN
server/app/core/__pycache__/config.cpython-313.pyc
Normal file
BIN
server/app/core/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
server/app/db/__pycache__/base.cpython-313.pyc
Normal file
BIN
server/app/db/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
14
server/app/db/models/__init__.py
Normal file
14
server/app/db/models/__init__.py
Normal file
@@ -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"]
|
||||
|
||||
BIN
server/app/db/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
server/app/db/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
server/app/db/models/__pycache__/content.cpython-313.pyc
Normal file
BIN
server/app/db/models/__pycache__/content.cpython-313.pyc
Normal file
Binary file not shown.
BIN
server/app/db/models/__pycache__/content_profile.cpython-313.pyc
Normal file
BIN
server/app/db/models/__pycache__/content_profile.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
70
server/app/db/models/content.py
Normal file
70
server/app/db/models/content.py
Normal file
@@ -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="更新时间",
|
||||
)
|
||||
|
||||
95
server/app/db/models/content_profile.py
Normal file
95
server/app/db/models/content_profile.py
Normal file
@@ -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="画像更新时间",
|
||||
)
|
||||
|
||||
51
server/app/db/models/content_risk_flag.py
Normal file
51
server/app/db/models/content_risk_flag.py
Normal file
@@ -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="创建时间",
|
||||
)
|
||||
|
||||
9
server/app/features/user_profile_scoring/__init__.py
Normal file
9
server/app/features/user_profile_scoring/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
User Profile Scoring(用户画像打分)V1.2
|
||||
|
||||
说明:
|
||||
- 提供“问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)”的服务端实现
|
||||
- 规则以 `spec_kit/User Profile Scoring/spec.md`(V1.2)与
|
||||
`设计说明文档/客戶端問卷打分規則.md`(V1.2)为准
|
||||
"""
|
||||
|
||||
194
server/app/features/user_profile_scoring/scoring.py
Normal file
194
server/app/features/user_profile_scoring/scoring.py
Normal file
@@ -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,
|
||||
)
|
||||
|
||||
89
server/app/features/user_profile_scoring/types.py
Normal file
89
server/app/features/user_profile_scoring/types.py
Normal file
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -4,6 +4,7 @@ uvicorn[standard]>=0.27
|
||||
# 数据库(SQLAlchemy 2.x 异步 + MySQL)
|
||||
SQLAlchemy>=2.0
|
||||
aiomysql>=0.2
|
||||
greenlet>=3.0
|
||||
|
||||
# 配置
|
||||
pydantic>=2.6
|
||||
|
||||
Reference in New Issue
Block a user