chore(prep): shared groundwork for the Sprint-2 parallel batch

第二批(T6~T11)协调提交:
- 迁移 0005:kb 七表(entity/relation/rule/chunk vector1024+tsv 生成列/
  asset/color/gap),HNSW+GIN 索引,FK 级联
- ORM:七个 Kb* 模型(pgvector Vector/TSVECTOR/Computed)
- 配置:检索与 embedding、华为 JWKS、短信全参数组
- 错误码:40110/40111/42910/42911/61000
- 骨架路由:kb.py + auth_sms.py(已注册);依赖:tencentcloud-sdk-python-sms
  + spike extra(onnxruntime/opencv);gitignore:models/ 与 spike 输出
This commit is contained in:
xsl
2026-08-14 17:01:43 +08:00
parent ddec3973ad
commit d5f366fa4c
11 changed files with 656 additions and 4 deletions
+36
View File
@@ -168,6 +168,41 @@ class Settings(BaseSettings):
alias="UPLOAD_SCENES_JSON",
)
# --- 知识库与检索(架构 §8.3,TD-08)--------------------------------------
kb_vector_dimensions: int = 1024
# 当前默认 = 确定性假实现;真实 BGE-M3 ONNX 是后续任务,届时只换这里。
kb_embedding_model: str = "deterministic-v1"
kb_embedding_device: str = "cpu"
retrieval_top_k: int = 50
# 重排权重(架构 §8.3 唯一口径):0.45 向量 + 0.2 关键词 + 0.25 规则 + 0.1 新鲜度。
retrieval_rerank_weights: dict[str, float] = Field(
default_factory=lambda: {"vector": 0.45, "keyword": 0.2, "rule": 0.25, "freshness": 0.1},
alias="RETRIEVAL_RERANK_WEIGHTS_JSON",
)
# --- 华为账号登录(FR-ACC-01;联调待 AGC 配置,见《前置开通清单》§3)------
huawei_jwks_url: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/certs"
huawei_audience: str = "" # = AGC App ID;空配置时验签必须拒绝而不是跳过
huawei_issuer: str = "https://accounts.huawei.com"
huawei_jwks_cache_seconds: int = 86400
# --- 手机号验证码登录(FR-ACC-04/14,TD-32)--------------------------------
# fake = 验证码打日志(dev);tencent = 真实发送(待签名审核通过后切换)
sms_provider: str = "fake"
tencent_sms_secret_id: str = ""
tencent_sms_secret_key: str = ""
tencent_sms_sdk_app_id: str = ""
tencent_sms_sign_name: str = ""
tencent_sms_template_id: str = ""
sms_code_ttl_seconds: int = 300
sms_code_length: int = 6
sms_verify_max_attempts: int = 5
sms_resend_cooldown_seconds: int = 60
sms_hourly_limit: int = 5
sms_daily_limit: int = 10
sms_ip_daily_limit: int = 20
sms_device_daily_limit: int = 10
# --- Observability -----------------------------------------------------
otel_enabled: bool = False
otel_service_name: str = "meida-api"
@@ -182,6 +217,7 @@ class Settings(BaseSettings):
"llm_budget_tiers",
"task_bonus_rules",
"upload_scenes",
"retrieval_rerank_weights",
mode="before",
)
@classmethod
+10
View File
@@ -32,6 +32,8 @@ class ErrorCode(IntEnum):
REFRESH_TOKEN_INVALID = 40103
REFRESH_TOKEN_EXPIRED = 40104
SESSION_REVOKED = 40105
SMS_CODE_INVALID = 40110
SMS_CODE_EXPIRED = 40111
FORBIDDEN = 40300
QUOTA_EXHAUSTED = 40301
CONSENT_REQUIRED = 40302
@@ -42,6 +44,8 @@ class ErrorCode(IntEnum):
STATE_CONFLICT = 40900
TASK_BONUS_ALREADY_CLAIMED = 40910
RATE_LIMITED = 42900
SMS_SEND_TOO_FREQUENT = 42910
SMS_DAILY_LIMIT = 42911
# --- 5xxxx server -----------------------------------------------------
INTERNAL_ERROR = 50000
@@ -58,6 +62,7 @@ class ErrorCode(IntEnum):
LLM_PROMPT_CACHE_VIOLATION = 60004
MODERATION_UNAVAILABLE = 60005
LLM_SCENE_DEGRADED = 60006
SMS_PROVIDER_FAILED = 61000
@dataclass(frozen=True, slots=True)
@@ -84,6 +89,8 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.REFRESH_TOKEN_INVALID: ErrorMeta(401, "登录状态无效", "请重新登录"),
ErrorCode.REFRESH_TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"),
ErrorCode.SESSION_REVOKED: ErrorMeta(401, "该会话已登出", "请重新登录"),
ErrorCode.SMS_CODE_INVALID: ErrorMeta(401, "验证码错误", "请检查后重试"),
ErrorCode.SMS_CODE_EXPIRED: ErrorMeta(401, "验证码已过期", "请重新获取"),
ErrorCode.FORBIDDEN: ErrorMeta(403, "没有访问权限"),
ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"),
ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"),
@@ -98,6 +105,8 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
409, "这个周期的任务奖励已领取", "下个周期再来"
),
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
ErrorCode.SMS_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"),
ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.TASK_STATE_INVALID: ErrorMeta(500, "任务状态异常", "请到任务中心重试"),
@@ -120,6 +129,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.LLM_SCENE_DEGRADED: ErrorMeta(
503, "该功能今日暂时关闭", "AI 服务今日已达服务上限,明天恢复"
),
ErrorCode.SMS_PROVIDER_FAILED: ErrorMeta(502, "短信服务暂时不可用", "请稍后重试"),
}
+176 -1
View File
@@ -18,9 +18,11 @@ from datetime import datetime
from decimal import Decimal
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
BigInteger,
Boolean,
Computed,
DateTime,
Index,
Integer,
@@ -31,8 +33,9 @@ from sqlalchemy import (
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -304,3 +307,175 @@ class ObEvent(Base):
Index("ix_ob_event_name_ts", "event_name", "server_ts"),
Index("ix_ob_event_user_ts", "user_id", "server_ts"),
)
# ---------------------------------------------------------------------------
# kb 域(知识库,架构文档 §6.2)
# ---------------------------------------------------------------------------
class KbEntity(Base):
"""12 类实体统一表:attrs JSONB 承载类目差异化字段(架构 §6.2).
状态机 draft→reviewing→published→archived 由 service 层校验;
published 实体不可直接改(version 递增需回 draft 重走评审)。
"""
__tablename__ = "kb_entity"
entity_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_type: Mapped[str] = mapped_column(String(32), nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
attrs: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
source: Mapped[str | None] = mapped_column(String(255))
license: Mapped[str | None] = mapped_column(String(64))
updated_by: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("entity_type", "name", name="uq_kb_entity_type_name"),
Index("ix_kb_entity_type_status", "entity_type", "status"),
)
class KbRelation(Base):
"""邻接表 + 递归 CTE 做 1~3 跳(TD-09:不上 Neo4j)."""
__tablename__ = "kb_relation"
relation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
from_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
rel_type: Mapped[str] = mapped_column(String(64), nullable=False)
to_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
weight: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1"))
confidence: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1"))
evidence: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("from_id", "rel_type", "to_id", name="uq_kb_relation_triple"),
Index("ix_kb_relation_to", "to_id"),
)
class KbRule(Base):
"""决策表(TD-10 自研规则引擎)。conditions/effects 为 JSONB DSL."""
__tablename__ = "kb_rule"
rule_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
rule_key: Mapped[str] = mapped_column(String(64), nullable=False)
category: Mapped[str] = mapped_column(String(32), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
conditions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
effects: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
is_hard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
confidence: Mapped[Decimal] = mapped_column(
Numeric(4, 3), nullable=False, default=Decimal("0.9")
)
evidence: Mapped[str | None] = mapped_column(String(255))
author: Mapped[str | None] = mapped_column(String(64))
reviewer: Mapped[str | None] = mapped_column(String(64))
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("rule_key", name="uq_kb_rule_key"),
Index("ix_kb_rule_status_priority", "status", "priority"),
)
class KbChunk(Base):
"""向量 + 全文双索引,支撑混合检索(架构 §8.3).
embedding_model/version 必填:没有版本号的向量不可重跑(T-P2-03)。
tsv 是生成列——写入时不要手工填。
"""
__tablename__ = "kb_chunk"
chunk_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
chunk_type: Mapped[str] = mapped_column(String(32), nullable=False, default="text")
text: Mapped[str] = mapped_column(Text, nullable=False)
embedding = mapped_column(Vector(1024), nullable=False)
embedding_model: Mapped[str] = mapped_column(String(64), nullable=False)
embedding_version: Mapped[str] = mapped_column(String(16), nullable=False)
tsv = mapped_column(
TSVECTOR, Computed("to_tsvector('simple', text)", persisted=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_kb_chunk_entity", "entity_id"),)
class KbAsset(Base):
"""图像资产。tryon_ready 是换装闭环的关键标志位(架构 §6.2)."""
__tablename__ = "kb_asset"
asset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
asset_type: Mapped[str] = mapped_column(String(32), nullable=False)
cos_key: Mapped[str] = mapped_column(String(255), nullable=False)
spec_check: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
tryon_ready: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
review_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
license: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_kb_asset_entity", "entity_id"),
Index("ix_kb_asset_tryon_ready", "tryon_ready", postgresql_where=text("tryon_ready")),
)
class KbColor(Base):
"""自建色板(TD-18:规避「四季色彩/CMB」商标)。Lab 用于 ΔE 数值匹配."""
__tablename__ = "kb_color"
color_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
season_code: Mapped[str] = mapped_column(String(16), nullable=False)
role: Mapped[str] = mapped_column(String(8), nullable=False)
lab_l: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
lab_a: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
lab_b: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
hex: Mapped[str] = mapped_column(String(7), nullable=False)
name_cn: Mapped[str] = mapped_column(String(64), nullable=False)
__table_args__ = (Index("ix_kb_color_season", "season_code"),)
class KbGap(Base):
"""知识缺口埋点:无检索结果的查询,驱动内容生产(T-P2-06)."""
__tablename__ = "kb_gap"
gap_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
query_text: Mapped[str] = mapped_column(Text, nullable=False)
filter_ctx: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
hit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_kb_gap_query", "query_text"),)