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

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