fix:文明

This commit is contained in:
吕新雨
2026-02-02 10:47:24 +08:00
parent 7e4074d457
commit 814b96edb6
28 changed files with 976 additions and 3 deletions

View File

@@ -7,13 +7,13 @@ APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
# 数据库dev 指向 mindfulness_devprod 指向 mindfulness # 数据库dev 指向 mindfulness_devprod 指向 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使用 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默认不启用结果存储避免 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 # CELERY_RESULT_BACKEND=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
# 推送Expo # 推送Expo

20
server/.env.prod Normal file
View 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_devprod 指向 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
View 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
View 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`(创建推荐系统最小内容表与画像表)。

Binary file not shown.

137
server/alembic/env.py Normal file
View 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)
# 目标 metadataautogenerate 依赖)
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())

View 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"}

View 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~1NULL 表示 general"),
sa.Column(
"context_suitability_json",
sa.JSON(),
nullable=False,
comment="各 context 的适配度JSON0/0.5/1必须包含 5 个 key",
),
sa.Column(
"need_suitability_json",
sa.JSON(),
nullable=False,
comment="各 need 的适配度JSON0/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~1NULL 表示由推荐侧按 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.

View File

@@ -0,0 +1,6 @@
"""
API 路由入口
说明:按 FastAPI 常见工程结构拆分 api/v1/* 路由模块。
"""

View File

@@ -0,0 +1,4 @@
"""
V1 API 路由集合
"""

View 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,
)

Binary file not shown.

Binary file not shown.

View 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"]

View 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="更新时间",
)

View 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~1NULL 表示 general",
)
context_suitability_json: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="各 context 的适配度JSON0/0.5/1必须包含 5 个 key",
)
need_suitability_json: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="各 need 的适配度JSON0/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~1NULL 表示由推荐侧按 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="画像更新时间",
)

View 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="创建时间",
)

View File

@@ -0,0 +1,9 @@
"""
User Profile Scoring用户画像打分V1.2
说明:
- 提供“问卷答案(可跳过)→ 用户画像(可计算、可观测、可版本化)”的服务端实现
- 规则以 `spec_kit/User Profile Scoring/spec.md`V1.2)与
`设计说明文档/客戶端問卷打分規則.md`V1.2)为准
"""

View 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
- 07 天1.0
- 730 天:线性衰减到 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.2profile_confidenceconf_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.2mom_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,
)

View 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

View File

@@ -1,6 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from app.core.config import get_settings from app.core.config import get_settings
from app.api.v1.user_profile_scoring import router as user_profile_router
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -14,6 +15,9 @@ def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name) app = FastAPI(title=settings.app_name)
# 业务路由
app.include_router(user_profile_router)
@app.get("/healthz") @app.get("/healthz")
async def healthz() -> dict: async def healthz() -> dict:
return {"status": "ok", "env": settings.app_env} return {"status": "ok", "env": settings.app_env}

View File

@@ -4,6 +4,7 @@ uvicorn[standard]>=0.27
# 数据库SQLAlchemy 2.x 异步 + MySQL # 数据库SQLAlchemy 2.x 异步 + MySQL
SQLAlchemy>=2.0 SQLAlchemy>=2.0
aiomysql>=0.2 aiomysql>=0.2
greenlet>=3.0
# 配置 # 配置
pydantic>=2.6 pydantic>=2.6