chore(prep): shared groundwork for batch-4 (report pipeline)
第四批(T15 编排/T16 视觉诊断/T17 文案校验)协调提交: - 迁移 0006:rp_report(result JSONB 按阶段键承载中间态)/rp_input (derived_features 视觉中间态)/rp_share(V1 只建结构) - ORM 三模型;错误码 40420/40920(文案无价格字样) - 配置:report_pipeline_stages(dotted path 动态装配)+ vision/tier - packages/report/pipeline.py:Stage Protocol + ReportContext(三任务共同契约, 动态 import 的动机与代价写在 docstring) - 路由桩 reports.py 已注册
This commit is contained in:
@@ -152,3 +152,10 @@ ADMIN_TOKEN=
|
|||||||
RETRIEVAL_CACHE_ENABLED=true
|
RETRIEVAL_CACHE_ENABLED=true
|
||||||
RETRIEVAL_CACHE_TTL_SECONDS=60
|
RETRIEVAL_CACHE_TTL_SECONDS=60
|
||||||
RETRIEVAL_CACHE_MAX_ENTRIES=512
|
RETRIEVAL_CACHE_MAX_ENTRIES=512
|
||||||
|
|
||||||
|
# ── 美学报告流水线(T-P3,第四批)──────────────────────────────────────────
|
||||||
|
# 阶段 dotted path 列表(importlib 动态装配;缺失即 CONFIG_ERROR)
|
||||||
|
REPORT_PIPELINE_STAGES_JSON=["packages.report.vision_stage:VisionStage","packages.report.diagnosis_stage:DiagnosisStage","packages.report.copy_stage:CopyStage","packages.report.validation_stage:ValidationStage"]
|
||||||
|
# deterministic = V1 骨架假实现;真实 RTMPose/BiRefNet 为 Sprint 1 结论后的替换项
|
||||||
|
REPORT_VISION_PROVIDER=deterministic
|
||||||
|
REPORT_TIER=basic
|
||||||
|
|||||||
+13
-1
@@ -12,7 +12,18 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from apps.api.deps import build_llm_gateway
|
from apps.api.deps import build_llm_gateway
|
||||||
from apps.api.routers import agent, auth, auth_sms, events, health, kb, quota, tasks, uploads
|
from apps.api.routers import (
|
||||||
|
agent,
|
||||||
|
auth,
|
||||||
|
auth_sms,
|
||||||
|
events,
|
||||||
|
health,
|
||||||
|
kb,
|
||||||
|
quota,
|
||||||
|
reports,
|
||||||
|
tasks,
|
||||||
|
uploads,
|
||||||
|
)
|
||||||
from packages.shared.config import Settings, get_settings
|
from packages.shared.config import Settings, get_settings
|
||||||
from packages.shared.db import dispose_engine
|
from packages.shared.db import dispose_engine
|
||||||
from packages.shared.handlers import install_exception_handlers
|
from packages.shared.handlers import install_exception_handlers
|
||||||
@@ -78,6 +89,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
events.router,
|
events.router,
|
||||||
uploads.router,
|
uploads.router,
|
||||||
kb.router,
|
kb.router,
|
||||||
|
reports.router,
|
||||||
)
|
)
|
||||||
for router in routers:
|
for router in routers:
|
||||||
app.include_router(router, prefix=cfg.api_prefix)
|
app.include_router(router, prefix=cfg.api_prefix)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""报告路由(骨架,T15 填充实现)。
|
||||||
|
|
||||||
|
POST /reports、GET /reports、GET /reports/{id}、POST /reports/{id}/regenerate;
|
||||||
|
实现契约见 docs/开发任务包/T15-报告域骨架.md。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Report tables for the Sprint-3 (M3) parallel batch (架构文档 §6.2 report 域).
|
||||||
|
|
||||||
|
三张表一次落齐,T15(编排)不再写迁移:
|
||||||
|
rp_report — result JSONB 承载流水线中间态与最终产物(按 stage 键增量更新)
|
||||||
|
rp_input — questionnaire / photo_meta / derived_features(视觉中间态,T-P3-09)
|
||||||
|
rp_share — V1 只建结构(路由属后续任务)
|
||||||
|
|
||||||
|
Reversible: 是。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0006"
|
||||||
|
down_revision: str | None = "0005"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"rp_report",
|
||||||
|
sa.Column("report_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("tier", sa.String(length=8), nullable=False, server_default=sa.text("'basic'")),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("'pending'")),
|
||||||
|
sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("result", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("model_info", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("content_code", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("rendered_asset_key", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("cost_cents", sa.Numeric(14, 6), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("report_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_rp_report_user_created", "rp_report", ["user_id", "created_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"rp_input",
|
||||||
|
sa.Column("input_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("report_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("questionnaire", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("photo_meta", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("derived_features", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("input_id"),
|
||||||
|
sa.UniqueConstraint("report_id", name="uq_rp_input_report"),
|
||||||
|
sa.ForeignKeyConstraint(["report_id"], ["rp_report.report_id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"rp_share",
|
||||||
|
sa.Column("share_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("report_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("share_token", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expire_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("view_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint("share_id"),
|
||||||
|
sa.UniqueConstraint("share_token", name="uq_rp_share_token"),
|
||||||
|
sa.ForeignKeyConstraint(["report_id"], ["rp_report.report_id"], ondelete="CASCADE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("rp_share")
|
||||||
|
op.drop_table("rp_input")
|
||||||
|
op.drop_index("ix_rp_report_user_created", table_name="rp_report")
|
||||||
|
op.drop_table("rp_report")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""报告流水线的阶段契约(第四批 T15/T16/T17 的共同接口)。
|
||||||
|
|
||||||
|
本文件由协调提交只定 **接口**;编排实现(assemble_stages / run_pipeline)
|
||||||
|
归 T15。T16 提供 vision_stage / diagnosis_stage,T17 提供 copy_stage /
|
||||||
|
validation_stage——三个任务互不改对方文件,靠 Settings.report_pipeline_stages
|
||||||
|
里的 dotted path 动态装配。
|
||||||
|
|
||||||
|
设计动机(为什么动态 import):三个阶段模块由三个并行分支各自创建,
|
||||||
|
静态 import 会在任一分支缺文件时炸掉编排;按配置动态装配让每个分支
|
||||||
|
独立可测,合并后自动咬合。代价是 import-linter 看不到这条边——阶段模块
|
||||||
|
自身的依赖仍受契约约束,编排层注入的 session/gateway 都是既有合法依赖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from packages.llm.gateway import LLMGateway
|
||||||
|
from packages.shared.config import Settings
|
||||||
|
from packages.shared.models import RpInput, RpReport
|
||||||
|
|
||||||
|
|
||||||
|
class ReportContext:
|
||||||
|
"""一个阶段的执行上下文。
|
||||||
|
|
||||||
|
不是 frozen:阶段要写 ``ctx.report.result[stage.name]`` 与
|
||||||
|
``ctx.rp_input.derived_features``(中间态落库由编排层 flush)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
report: RpReport,
|
||||||
|
report_input: RpInput,
|
||||||
|
session: AsyncSession,
|
||||||
|
settings: Settings,
|
||||||
|
gateway: LLMGateway | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.report = report
|
||||||
|
self.report_input = report_input
|
||||||
|
self.session = session
|
||||||
|
self.settings = settings
|
||||||
|
self.gateway = gateway
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Stage(Protocol):
|
||||||
|
"""流水线阶段。name 同时是 result JSONB 里的产物键。
|
||||||
|
|
||||||
|
约定:阶段把自己的全部产物写进 ``ctx.report.result[self.name]``;
|
||||||
|
抛异常即整条流水线失败(编排层负责 tk_task→failed + refund)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
async def run(self, ctx: ReportContext) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
#: T15 的 assemble_stages 按此清单 importlib 动态导入;缺失即 CONFIG_ERROR。
|
||||||
|
DEFAULT_STAGE_PATHS: tuple[str, ...] = (
|
||||||
|
"packages.report.vision_stage:VisionStage",
|
||||||
|
"packages.report.diagnosis_stage:DiagnosisStage",
|
||||||
|
"packages.report.copy_stage:CopyStage",
|
||||||
|
"packages.report.validation_stage:ValidationStage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stage_names(result: dict[str, Any]) -> list[str]:
|
||||||
|
"""result JSONB 里已出现的阶段键(诊断/调试用的小工具)。"""
|
||||||
|
return [k for k in result if isinstance(result[k], dict)]
|
||||||
@@ -213,6 +213,24 @@ class Settings(BaseSettings):
|
|||||||
retrieval_cache_ttl_seconds: int = 60
|
retrieval_cache_ttl_seconds: int = 60
|
||||||
retrieval_cache_max_entries: int = 512
|
retrieval_cache_max_entries: int = 512
|
||||||
|
|
||||||
|
# --- 美学报告流水线(T-P3,第四批)---------------------------------------
|
||||||
|
# 阶段按 dotted path 动态装配(importlib):T15 编排、T16 供 vision/diagnosis、
|
||||||
|
# T17 供 copy/validation——三个任务互不改对方文件,靠这份清单衔接。
|
||||||
|
# 缺失模块/类在装配时报 CONFIG_ERROR 并列出缺失项,绝不静默跳过。
|
||||||
|
report_pipeline_stages: list[str] = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
"packages.report.vision_stage:VisionStage",
|
||||||
|
"packages.report.diagnosis_stage:DiagnosisStage",
|
||||||
|
"packages.report.copy_stage:CopyStage",
|
||||||
|
"packages.report.validation_stage:ValidationStage",
|
||||||
|
],
|
||||||
|
alias="REPORT_PIPELINE_STAGES_JSON",
|
||||||
|
)
|
||||||
|
# 视觉提供者:deterministic = V1 骨架的确定性假实现(真实 RTMPose/BiRefNet
|
||||||
|
# 是 Sprint 1 结论落地后的替换项,接口位见 packages/report/vision.py)。
|
||||||
|
report_vision_provider: str = "deterministic"
|
||||||
|
report_tier: str = "basic"
|
||||||
|
|
||||||
# --- Observability -----------------------------------------------------
|
# --- Observability -----------------------------------------------------
|
||||||
otel_enabled: bool = False
|
otel_enabled: bool = False
|
||||||
otel_service_name: str = "meida-api"
|
otel_service_name: str = "meida-api"
|
||||||
@@ -228,6 +246,7 @@ class Settings(BaseSettings):
|
|||||||
"task_bonus_rules",
|
"task_bonus_rules",
|
||||||
"upload_scenes",
|
"upload_scenes",
|
||||||
"retrieval_rerank_weights",
|
"retrieval_rerank_weights",
|
||||||
|
"report_pipeline_stages",
|
||||||
mode="before",
|
mode="before",
|
||||||
)
|
)
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ class ErrorCode(IntEnum):
|
|||||||
TASK_BONUS_CAP_REACHED = 40310
|
TASK_BONUS_CAP_REACHED = 40310
|
||||||
NOT_FOUND = 40400
|
NOT_FOUND = 40400
|
||||||
TASK_BONUS_UNKNOWN = 40410
|
TASK_BONUS_UNKNOWN = 40410
|
||||||
|
REPORT_NOT_FOUND = 40420
|
||||||
STATE_CONFLICT = 40900
|
STATE_CONFLICT = 40900
|
||||||
TASK_BONUS_ALREADY_CLAIMED = 40910
|
TASK_BONUS_ALREADY_CLAIMED = 40910
|
||||||
|
REPORT_ALREADY_EXISTS = 40920
|
||||||
RATE_LIMITED = 42900
|
RATE_LIMITED = 42900
|
||||||
SMS_SEND_TOO_FREQUENT = 42910
|
SMS_SEND_TOO_FREQUENT = 42910
|
||||||
SMS_DAILY_LIMIT = 42911
|
SMS_DAILY_LIMIT = 42911
|
||||||
@@ -100,10 +102,12 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
|
|||||||
),
|
),
|
||||||
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
|
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
|
||||||
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
|
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
|
||||||
|
ErrorCode.REPORT_NOT_FOUND: ErrorMeta(404, "报告不存在或已删除"),
|
||||||
ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"),
|
ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"),
|
||||||
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
|
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
|
||||||
409, "这个周期的任务奖励已领取", "下个周期再来"
|
409, "这个周期的任务奖励已领取", "下个周期再来"
|
||||||
),
|
),
|
||||||
|
ErrorCode.REPORT_ALREADY_EXISTS: ErrorMeta(409, "本期报告已生成过", "每 30 天可重新生成一次"),
|
||||||
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
|
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
|
||||||
ErrorCode.SMS_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"),
|
ErrorCode.SMS_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"),
|
||||||
ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
|
ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
|
||||||
|
|||||||
@@ -479,3 +479,65 @@ class KbGap(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (Index("ix_kb_gap_query", "query_text"),)
|
__table_args__ = (Index("ix_kb_gap_query", "query_text"),)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# report 域(美学报告,架构文档 §6.2 / §7.1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class RpReport(Base):
|
||||||
|
"""报告主表。result JSONB 同时承载流水线中间态与最终产物(按 stage 键)——
|
||||||
|
阶段逐段落库是 T-P3-09「失败不需重新上传」的结构基础。"""
|
||||||
|
|
||||||
|
__tablename__ = "rp_report"
|
||||||
|
|
||||||
|
report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
||||||
|
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
tier: Mapped[str] = mapped_column(String(8), nullable=False, default="basic")
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
result: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
model_info: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
content_code: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
rendered_asset_key: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
cost_cents: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 6), nullable=False, default=Decimal("0")
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_rp_report_user_created", "user_id", "created_at"),)
|
||||||
|
|
||||||
|
|
||||||
|
class RpInput(Base):
|
||||||
|
"""报告输入。derived_features 是视觉分析的中间态:重试/重新生成时
|
||||||
|
已存在则跳过视觉阶段(T-P3-09)。只存派生特征与元信息,不存原图(CR-02)。"""
|
||||||
|
|
||||||
|
__tablename__ = "rp_input"
|
||||||
|
|
||||||
|
input_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
||||||
|
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
questionnaire: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
photo_meta: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
derived_features: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("report_id", name="uq_rp_input_report"),)
|
||||||
|
|
||||||
|
|
||||||
|
class RpShare(Base):
|
||||||
|
"""分享链接:可失效、可撤回(V1 只建结构,路由后续任务)。"""
|
||||||
|
|
||||||
|
__tablename__ = "rp_share"
|
||||||
|
|
||||||
|
share_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
||||||
|
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
share_token: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expire_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
view_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("share_token", name="uq_rp_share_token"),)
|
||||||
|
|||||||
Reference in New Issue
Block a user