feat(internal): 启动确认窗 agent 兜底样本落盘(pricebot 回写)
- 新增内部端点 POST /internal/launch-confirm-sample(X-Internal-Secret 鉴权): pricebot 对没见过文案的启动确认窗用 LLM 兜底放行后,把样本(host 包 / 弹窗树 / LLM plan / 设备 locale 机型)回写落 launch_confirm_sample 表,供研发人工沉淀回 pricebot 规则 yaml - 配套: model LaunchConfirmSample + schema + repo(都上报不去重) + alembic 迁移 launch_confirm_sample_table(down_revision=cps_wx_user) + main 注册 router + models/__init__ 登记 - docs: 后端技术实现.md 加 §6.5 pricebot 回写内部端点说明 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
|
||||
|
||||
Revision ID: launch_confirm_sample_table
|
||||
Revises: cps_wx_user
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "launch_confirm_sample_table"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"launch_confirm_sample",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("host_package", sa.String(length=128), nullable=True),
|
||||
sa.Column("target_app", sa.String(length=128), nullable=True),
|
||||
sa.Column("system_locale", sa.String(length=32), nullable=True),
|
||||
sa.Column("exec_success", sa.Boolean(), nullable=False),
|
||||
sa.Column("dialog_title", sa.String(length=256), nullable=True),
|
||||
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
|
||||
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
|
||||
|
||||
op.drop_table("launch_confirm_sample")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。
|
||||
|
||||
pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App
|
||||
启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠
|
||||
server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置
|
||||
(默认空)时直接 503,避免裸奔写端点。
|
||||
|
||||
研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import launch_confirm_sample as repo
|
||||
from app.schemas.launch_confirm_sample import (
|
||||
LaunchConfirmSampleIn,
|
||||
LaunchConfirmSampleOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/launch-confirm-sample",
|
||||
response_model=LaunchConfirmSampleOut,
|
||||
summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)",
|
||||
)
|
||||
def report_launch_confirm_sample(
|
||||
payload: LaunchConfirmSampleIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> LaunchConfirmSampleOut:
|
||||
_check_secret(x_internal_secret)
|
||||
sid = repo.insert_sample(db, payload)
|
||||
logger.info(
|
||||
"launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s",
|
||||
sid, payload.host_package, payload.system_locale,
|
||||
payload.exec_success, payload.trace_id,
|
||||
)
|
||||
return LaunchConfirmSampleOut(id=sid)
|
||||
@@ -22,6 +22,7 @@ from app.api.v1.compare_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||
from app.api.internal.app_version import router as internal_app_version_router
|
||||
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
||||
from app.api.internal.price import router as internal_price_router
|
||||
from app.api.internal.store import router as internal_store_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
@@ -109,6 +110,7 @@ app.include_router(report_router)
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
app.include_router(internal_app_version_router)
|
||||
app.include_router(internal_launch_confirm_router)
|
||||
app.include_router(platform_router)
|
||||
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
|
||||
app.include_router(cps_redirect_router)
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。
|
||||
|
||||
pricebot 的 launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App
|
||||
启动确认窗(繁体 / 英文 / 小语种 / ROM 改版)时,把完整样本 server→server 落这里。研发定期
|
||||
**人工**把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES(静态快路径),
|
||||
让以后同款窗不必再调 LLM。
|
||||
|
||||
设计:
|
||||
- 与登录无关、不鉴权(server→server 共享密钥头 X-Internal-Secret)。
|
||||
- **都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale
|
||||
聚合后人工筛。
|
||||
- 大字段(弹窗树快照 + LLM plan + 设备信息)塞 payload(JSONB)兜底,免得加字段就迁移 schema。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class LaunchConfirmSample(Base):
|
||||
__tablename__ = "launch_confirm_sample"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
# pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
|
||||
# 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。
|
||||
host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
# 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。
|
||||
target_app: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。
|
||||
# ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。
|
||||
system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
|
||||
|
||||
# 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。
|
||||
exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。
|
||||
dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree,
|
||||
# device_info: {screen, locale, brand, model, android_version, rom_version}}
|
||||
payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<LaunchConfirmSample id={self.id} host={self.host_package!r} "
|
||||
f"locale={self.system_locale!r} success={self.exec_success}>"
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample
|
||||
from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn
|
||||
|
||||
logger = logging.getLogger("shagua.launch_confirm")
|
||||
|
||||
|
||||
def _trunc(s: Optional[str], n: int) -> Optional[str]:
|
||||
"""截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。"""
|
||||
return s[:n] if s else None
|
||||
|
||||
|
||||
def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
|
||||
"""写入一条样本,返回新行 id。"""
|
||||
row = LaunchConfirmSample(
|
||||
trace_id=_trunc(payload.trace_id, 64),
|
||||
device_id=_trunc(payload.device_id, 64),
|
||||
host_package=_trunc(payload.host_package, 128),
|
||||
target_app=_trunc(payload.target_app, 128),
|
||||
system_locale=_trunc(payload.system_locale, 32),
|
||||
exec_success=bool(payload.exec_success),
|
||||
dialog_title=_trunc(payload.dialog_title, 256),
|
||||
payload=payload.payload,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
@@ -0,0 +1,26 @@
|
||||
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LaunchConfirmSampleIn(BaseModel):
|
||||
"""一次 agent 兜底的完整样本。
|
||||
|
||||
字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)。
|
||||
"""
|
||||
|
||||
trace_id: str | None = None
|
||||
device_id: str | None = None
|
||||
host_package: str | None = None
|
||||
target_app: str | None = None
|
||||
system_locale: str | None = None
|
||||
exec_success: bool = False
|
||||
dialog_title: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
|
||||
class LaunchConfirmSampleOut(BaseModel):
|
||||
"""落库结果。"""
|
||||
|
||||
id: int
|
||||
@@ -222,6 +222,16 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
---
|
||||
|
||||
## 6.5 pricebot 回写:内部端点(server→server)
|
||||
|
||||
§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。
|
||||
|
||||
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
|
||||
|
||||
> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
|
||||
|
||||
Reference in New Issue
Block a user