下一轮第 3 波的契约骨架:工作流版本加表的结构版本;定时的非工作流目标按种类交给各自的模块(评测题集 app/eval/scheduled、数据源维护 app/data/source_maintenance,先放照实记「还不能发起」的占位)、record_fire 共用;分享链接、数据源变化记录、口径账本按答案出具与对账的路由先登记好权限

This commit is contained in:
ThearchyHelios
2026-10-16 15:51:33 +08:00
parent c1c423da05
commit 91cc150f17
9 changed files with 117 additions and 2 deletions
+3
View File
@@ -26,6 +26,9 @@ router = APIRouter(prefix="/api/ledger", tags=["ledger"])
#: 口径账本里按运行读的那几个(一个答案里每个数的口径标签等,docs/design/next-round/02-口径账本.md):挂在
#: /api/runs/{run_id}/ledger… 下,和证据接口同一道门(main.py 挂 evidence_scope:使用者只读、而且要能读这次运行)
runs_router = APIRouter(prefix="/api/runs", tags=["ledger"])
#: 按口径正式出具、口径对账这类要写的(docs/design/next-round/02-口径账本.md 做法 5、6):使用者也能发起(总方案拍板
#: 账本-L5:对账都可以,正式出具只限有权使用的应用固定着的口径版本),所以 main.py 只挂登录即可,接口里按运行和应用授权再判
answers_router = APIRouter(prefix="/api/ledger/answers", tags=["ledger"])
@router.get("/workflow-versions/{workflow_id}/{version}")
+10
View File
@@ -0,0 +1,10 @@
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2)。
读分享页的接口不用登录,只认请求头里的分享 key(中间件把 /api/shares/ 当公开前缀,第 3 波 W3-d 加);建、撤销分享在
/api/apps/{id}/shares 下,要应用的 manage 权限。第 3 波 W3-d 填。
"""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/api/shares", tags=["shares"])
+9
View File
@@ -0,0 +1,9 @@
"""数据源的变化记录(docs/design/next-round/03-数据接入与目录维护.md B3):结构变化、取值变化,查看与处理。
挂在 /api/datasources/{id}/changes 下,要在 datasources 的通配 /{source_id} 之前登记(main.py)。第 3 波 W3-a 填。
"""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/api/datasources", tags=["source-changes"])
+21
View File
@@ -0,0 +1,21 @@
"""数据源的定时维护:探查 → 剖析需要的表 → 取值观察(docs/design/next-round/03-数据接入与目录维护.md B4)。
scheduler/service.py 按目标种类导入本模块(_TARGET_MODULES):fire 到点发起一次,check_target 建任务时核对目标。
第 3 波 W3-a 填;填之前照实记「当前版本还不能按时发起这类目标」,不发起。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
async def fire(*, schedule_id: str, fire_at: datetime, kind: str, actor: str | None, target_id: str, name: str,
tz: str, cron_expr: str) -> Any:
from app.scheduler import service
reason = service.TARGET_UNSUPPORTED.format(kind=service.TARGET_LABEL["source_maintenance"])
return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", reason)
async def check_target(session: Any, target_id: str) -> str | None:
return "当前版本还不能按时发起「数据源维护」这类目标"
+2
View File
@@ -248,6 +248,8 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
# 第 2 波:服务账号(users.kind);剖析上传表格源时的快照
("users", "kind", "VARCHAR(20) DEFAULT 'person'"),
("catalog_profile_tasks", "snapshot_id", "VARCHAR(32)"),
# 第 3 波:发布时这一版引用的表的结构版本
("workflow_versions", "schema_versions", "JSON"),
]
#: 迁移补上的列要建的索引:(索引名, 表, 列或多列)。create_all 不碰已有的表,表上新列的索引也就不会建。
+3
View File
@@ -629,6 +629,9 @@ class WorkflowVersion(Base, TimestampMixin):
retire_note: Mapped[str | None] = mapped_column(Text, default=None)
# 发布时这一版用到的能力的治理版本:{能力名: version}。正式运行发起时有漂移只提醒、不拦
capability_versions: Mapped[dict[str, Any] | None] = mapped_column(default=None)
# 发布时这一版直接引用的表的结构版本 {源名: {表名: 结构版本}}(next-round 03 B1):正式运行发起时比对,变了只提醒、
# 不拦(schema.drift),写法照 catalog_versions。加这一列之前发布的版本为空
schema_versions: Mapped[dict[str, Any] | None] = mapped_column(default=None)
workflow: Mapped[Workflow] = relationship(back_populates="versions")
+21
View File
@@ -0,0 +1,21 @@
"""评测题集的定时(docs/design/next-round/01-效果度量与回归评测.md 做法 15)。
scheduler/service.py 按目标种类导入本模块(_TARGET_MODULES):fire 到点发起一次,check_target 建任务时核对目标。
第 3 波 W3-c 填;填之前照实记「当前版本还不能按时发起这类目标」,不发起。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
async def fire(*, schedule_id: str, fire_at: datetime, kind: str, actor: str | None, target_id: str, name: str,
tz: str, cron_expr: str) -> Any:
from app.scheduler import service
reason = service.TARGET_UNSUPPORTED.format(kind=service.TARGET_LABEL["eval_suite"])
return await service.record_fire(schedule_id, fire_at, kind, "failed_to_start", reason)
async def check_target(session: Any, target_id: str) -> str | None:
return "当前版本还不能按时发起「评测题集」这类目标"
+8
View File
@@ -20,6 +20,8 @@ from app.api import (
)
# 下一轮第 2 波(docs/design/next-round):评测、访问密钥
from app.api import access_keys as access_keys_api, evals as evals_api
# 第 3 波:分享链接、数据源的变化记录
from app.api import shares as shares_api, source_changes as source_changes_api
# 普通用户建应用(docs/design/normal-user-apps.md §3.10):蓝图、模板、文件、个人应用。权限在接口里按蓝图的范围和拥有者判
from app.api import (
blueprints as blueprints_api, copilot_blueprint, files as files_api, personal as personal_api,
@@ -255,6 +257,8 @@ for router, guard in (
(table_imports.router, _BUILDER),
(source_versions.router, _BUILDER),
(catalog.router, _BUILDER),
# 数据源的变化记录(/api/datasources/{id}/changes…):在通配 /{source_id} 之前登记
(source_changes_api.router, _BUILDER),
(datasources.router, _BUILDER),
# 应用平台。登录、登出、OIDC、JWKS 匿名可用,设置在接口里要管理员;入站钩子只认签名
(auth_api.router, []),
@@ -276,10 +280,14 @@ for router, guard in (
(tasks_api.router, _USER),
(actions_api.router, _USER),
(hooks_api.router, []),
# 分享链接的读接口只认分享 key(不用登录,中间件放行 /api/shares/)
(shares_api.router, []),
(notify_api.router, _USER),
(ledger_api.router, _BUILDER),
# 下一轮第 2 波:评测只给搭建者;访问密钥登录即可,接口里再按本人 / 管理员判
(evals_api.router, _BUILDER),
# 口径账本里按答案出具、对账的(第 3 波):登录即可,接口里按运行和应用授权判
(ledger_api.answers_router, _USER),
(access_keys_api.router, _USER),
):
app.include_router(router, dependencies=guard)
+40 -2
View File
@@ -86,6 +86,40 @@ def target_of(schedule: Any) -> tuple[str, str]:
return kind, getattr(schedule, "target_id", None) or ""
#: 非工作流目标各由一个模块处理(next-round X4):模块里有
#: async def fire(*, schedule_id, fire_at, kind, actor, target_id, name, tz, cron_expr) -> ScheduleFire | None
#: 到点发起一次(持着 launch_lock 调用);触发记录用本模块的 record_fire 写,和工作流同一套去重、同一种状态词
#: async def check_target(session, target_id) -> str | None
#: 建任务、改目标时核对目标(存在、可用),不合规返回给人看的一句,合规返回 None
#: 按需导入(评测、数据源维护的模块要导入本模块,模块级互相导入会成环)
_TARGET_MODULES = {"eval_suite": "app.eval.scheduled", "source_maintenance": "app.data.source_maintenance"}
def target_handler(kind: str) -> Any | None:
"""处理这种目标的模块;不认识的种类为 None。"""
import importlib
module = _TARGET_MODULES.get(kind)
return importlib.import_module(module) if module else None
async def record_fire(schedule_id: str, fire_at: datetime, kind: str, status: str, reason: str | None,
inputs: dict[str, Any] | None = None, **columns: Any) -> ScheduleFire | None:
"""非工作流目标写触发记录(同 _record:同一次触发已经记过就交回已有的那一条)。columns 写额外的列,比如评测的
eval_run_id。"""
fire = await _record(schedule_id, fire_at, kind, status, reason, inputs or {})
if fire is not None and columns:
async with SessionLocal() as session:
row = await session.get(ScheduleFire, fire.id)
if row is not None:
for key, value in columns.items():
setattr(row, key, value)
await session.commit()
await session.refresh(row)
return row
return fire
def target_columns(kind: str, target_id: str) -> dict[str, str]:
"""写目标的几列:{target_kind, target_id, workflow_id}。workflow 时 target_id 同 workflow_id;别的种类 workflow_id 存空串
(老表的非空约束)。种类不认识、id 为空抛 ValueError。"""
@@ -325,8 +359,12 @@ async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: st
name, run_class = schedule.name, schedule.run_class
tz, cron_expr, template = schedule.timezone, schedule.cron, schedule.input_template
if target_kind != "workflow":
reason = TARGET_UNSUPPORTED.format(kind=TARGET_LABEL.get(target_kind, target_kind))
return await _record(schedule_id, fire_at, kind, "failed_to_start", reason, {})
handler = target_handler(target_kind)
if handler is None:
reason = TARGET_UNSUPPORTED.format(kind=TARGET_LABEL.get(target_kind, target_kind))
return await _record(schedule_id, fire_at, kind, "failed_to_start", reason, {})
return await handler.fire(schedule_id=schedule_id, fire_at=fire_at, kind=kind, actor=actor,
target_id=target_id, name=name, tz=tz, cron_expr=cron_expr)
return await _fire_workflow(schedule_id, fire_at, kind, actor=actor, name=name, workflow_id=target_id,
run_class=run_class, tz=tz, cron_expr=cron_expr, template=template)