Files
AgentLab/backend/app/db/models_process.py
ThearchyHelios 77fc0efa10 口径召回后端(W4-a):发布新版本后反查用过旧版的答案、按参数分组在同一份数据上重算两版之差,数真变了才开 recall 待办
为什么:口径升版是被迫处置的事件(02 做法 7、拍板 L6),已交付的答案以前没人追;目录事实被驳回、表结构变了也没有地方标。

- ledger/uses.py(新):反查旧版本被谁用过——比对工件里等价已证明的问数回答(含轮次、对话主人)、W 的正式运行(定时任务、
  应用各取最近 4 期)、固定了它的工作流版本发起的正式运行、固定这一版的应用、导出审计(导出人也是主人);预告、召回、
  版本用量共用这一份
- ledger/recall.py(新):计划按(旧版本、新版本、input_key)去重成组,从已有召回运行重算(幂等、不建任务表);每组一次
  召回运行(origin_kind=recall,探索运行),派生图经 derive.recall_graph(新旧两版取数和口径卡带前缀、召回卡里算两版之差);
  不能重算照实记原因(数据部分不能复用、新版没这张卡、入口参数不齐、图里有人工节点或写入);每次发布最多 recall_limit 组,
  超出的手动接着算;收尾时按渲染的旧值新值判数变没变:变了开 recall 待办(开登录按主人各一条、关登录派给任何人)、推
  ledger.recall,没变在原答案上记 ledger.recalled;待办「按新版重新出具」走 issue.check_target 同一套判法、「知道了」
- 目录事实被驳回或修改(审阅、人工编辑、保存提案、批量审阅之后):冻结目录里看得到这一项的探索运行记 ledger.basis_changed,
  按新目录多出错误级 SQL 检查问题的才开待办;表结构变动消费 source_changes(游标、处理时改了目录的当场交过去),标依据的
  表结构已变,受影响的口径试算一次,算不出来才通知发布人
- 发布钩子在 commit_publish 提交之后排队(自动的几样和调度循环同一个开关),GET /api/workflows/{id}/recall-preview 给发布
  弹窗预告;GET/POST /api/ledger/recall/{wf}/{v}[/continue] 给版本历史看批次、接着算;版本用量加问数回答、导出、召回
- ledger.recall 打开订阅(全局、个人渠道),同一渠道只推一次,消息不放数;新事件 ledger.recalled、ledger.basis_changed
  登记为封存后追加的事件,可验证报告离线核对页认得;E4 每次发布召回了几组、数变了的比例
- runner 收尾时召回运行在后台收尾,启动时接着做没发完的批次
2026-10-16 19:52:03 +08:00

147 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""流程:待办、派给谁、生效记录、通知(应用平台 A4、A5,docs/design/app-platform-and-look.md §2.4、§2.5、§3.7、§3.8)。
一次中断对应一条 Approval(给引擎)和一条 Task(给人)。Task 下挂若干条 TaskAssignment:派给具体的人或组,
按 policy(any / all / quorum)汇总后才恢复运行。核实待办(kind=reconcile)没有 Approval,approval_id 为空。
ActionRecord 是写外部系统的权威记录:先写记录再发送;节点每次执行都先按 action_key 查,已生效的不再发,
结果未知的永不自动重发。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow
#: 待办的种类。人工节点三种 + 现有的几种中断 + 动作确认、核实、等外部系统 + 个人应用提交审核(review,
#: docs/design/normal-user-apps.md N7,app/process/tasks.REVIEW_KIND)+ 口径召回(recall,docs/design/next-round/02-口径账本.md
#: 做法 7:口径升版后答案的数变了、答案依据的目录或表结构变了;挂在召回运行或答案的运行上,没有 Approval,app/ledger/recall)
TASK_KINDS = ("approve", "input", "edit", "tool", "code", "dual_check", "action_confirm", "reconcile", "signal",
"review", "recall")
#: pending 等人;resolving 已凑齐、正在恢复运行(条件 UPDATE 抢到的那个请求负责);resolved / cancelled / expired 终态
TASK_STATUSES = ("pending", "resolving", "resolved", "cancelled", "expired")
#: 动作状态。simulated:探索运行里端点没有 test 环境时的演练,不发送
ACTION_STATES = ("pending", "sent", "confirmed", "failed", "unknown", "simulated")
class Task(Base, TimestampMixin):
__tablename__ = "tasks"
__table_args__ = (Index("ix_tasks_status_due", "status", "due_at"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
approval_id: Mapped[str | None] = mapped_column(String(32), default=None, unique=True)
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id", ondelete="CASCADE"), index=True)
node_id: Mapped[str | None] = mapped_column(String(64), default=None)
app_id: Mapped[str | None] = mapped_column(String(32), default=None, index=True)
app_version: Mapped[int | None] = mapped_column(Integer, default=None)
kind: Mapped[str] = mapped_column(String(20), default="approve")
title: Mapped[str] = mapped_column(String(300), default="")
# agentlab.form/1;没有表单的(批准 / 驳回)为空
form: Mapped[dict[str, Any] | None] = mapped_column(default=None)
# {"kind": "any"} | {"kind": "all"} | {"kind": "quorum", "m": 2}
policy: Mapped[dict[str, Any]] = mapped_column(default=lambda: {"kind": "any"})
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
due_at: Mapped[datetime | None] = mapped_column(default=None)
remind_at: Mapped[datetime | None] = mapped_column(default=None)
# {"action": "remind" | "escalate" | "reject", "every"?: ISO 时长, "to_slot"?: 岗位}。没有「自动批准」
on_timeout: Mapped[dict[str, Any] | None] = mapped_column(default=None)
escalation_level: Mapped[int] = mapped_column(Integer, default=0)
# 等外部系统(kind=signal)时,入站 signal 按它找到这条待办
correlation_key: Mapped[str | None] = mapped_column(String(200), default=None, index=True)
# 判定结果:{"decision": ..., "values": ..., "signoffs": [...]}
outcome: Mapped[dict[str, Any] | None] = mapped_column(default=None)
resolved_by: Mapped[str | None] = mapped_column(String(100), default=None)
resolved_at: Mapped[datetime | None] = mapped_column(default=None)
class TaskAssignment(Base):
__tablename__ = "task_assignments"
__table_args__ = (Index("ix_assign_principal", "principal_kind", "principal", "status"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
task_id: Mapped[str] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE"), index=True)
principal_kind: Mapped[str] = mapped_column(String(20)) # user | group | anyone | hook
principal: Mapped[str] = mapped_column(String(100), default="")
# 从哪来:slot:<岗位> | initiator | expression | escalation | reassign
source: Mapped[str] = mapped_column(String(40), default="")
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | responded | withdrawn
response: Mapped[dict[str, Any] | None] = mapped_column(default=None)
responded_by: Mapped[str | None] = mapped_column(String(100), default=None)
responded_at: Mapped[datetime | None] = mapped_column(default=None)
# 回复人是不是认证过的身份(off 模式下是自报署名,记 False)
verified: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class ActionRecord(Base, TimestampMixin):
__tablename__ = "action_records"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
# sha256(run_id, node_id, exec);同一次执行第 n 次发送的幂等键是 f"{action_key}:{attempt}"
action_key: Mapped[str] = mapped_column(String(64), index=True)
attempt: Mapped[int] = mapped_column(Integer, default=1)
idem_key: Mapped[str] = mapped_column(String(80), unique=True)
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id", ondelete="CASCADE"), index=True)
node_id: Mapped[str] = mapped_column(String(64))
exec_no: Mapped[int] = mapped_column(Integer, default=1)
app_id: Mapped[str | None] = mapped_column(String(32), default=None, index=True)
workflow_id: Mapped[str | None] = mapped_column(String(32), default=None, index=True)
workflow_version: Mapped[int | None] = mapped_column(Integer, default=None)
graph_hash: Mapped[str | None] = mapped_column(String(64), default=None)
capability: Mapped[str] = mapped_column(String(200))
capability_version: Mapped[int | None] = mapped_column(Integer, default=None)
endpoint_id: Mapped[str | None] = mapped_column(String(32), default=None)
environment: Mapped[str] = mapped_column(String(20), default="prod") # prod | test | simulated
payload_hash: Mapped[str] = mapped_column(String(64))
# 遮罩后的预览(确认卡上给人看的那一份)
preview: Mapped[dict[str, Any] | None] = mapped_column(default=None)
state: Mapped[str] = mapped_column(String(20), default="pending", index=True)
confirm_task_id: Mapped[str | None] = mapped_column(String(32), default=None)
request_at: Mapped[datetime | None] = mapped_column(default=None)
response_status: Mapped[int | None] = mapped_column(Integer, default=None)
# 回执:外部单号等,原文落工件,这里存 {artifact, summary}
receipt: Mapped[dict[str, Any] | None] = mapped_column(default=None)
error: Mapped[str | None] = mapped_column(Text, default=None)
lookup_attempts: Mapped[int] = mapped_column(Integer, default=0)
next_lookup_at: Mapped[datetime | None] = mapped_column(default=None)
# 核实结论:{"by": ..., "how": "lookup" | "manual", "outcome": "confirmed" | "not_applied" | "resend" | "abandon", "external_id"?}
resolution: Mapped[dict[str, Any] | None] = mapped_column(default=None)
class NotifyChannel(Base, TimestampMixin):
"""通知渠道。格式、签名、发送同定时任务的 Webhook(四种:generic / feishu / wecom / dingtalk)。"""
__tablename__ = "notify_channels"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100))
scope: Mapped[str] = mapped_column(String(20), default="global") # global | app | user
owner_id: Mapped[str | None] = mapped_column(String(32), default=None)
format: Mapped[str] = mapped_column(String(20), default="generic")
url: Mapped[str] = mapped_column(Text) # 密文
secret: Mapped[str | None] = mapped_column(Text, default=None) # 密文
events: Mapped[list[Any]] = mapped_column(default=list)
enabled: Mapped[bool] = mapped_column(default=True)
class NotifyDelivery(Base):
"""通用投递记录。定时任务的投递仍写 webhook_deliveries(schedule_id 非空,SQLite 改不了可空性)。"""
__tablename__ = "notify_deliveries"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
channel_id: Mapped[str] = mapped_column(ForeignKey("notify_channels.id", ondelete="CASCADE"), index=True)
subject_kind: Mapped[str] = mapped_column(String(20)) # task | run | action | channel(渠道的测试消息)
subject_id: Mapped[str] = mapped_column(String(32))
event: Mapped[str] = mapped_column(String(40))
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
task_id: Mapped[str | None] = mapped_column(String(32), default=None)
status: Mapped[str] = mapped_column(String(20))
http_status: Mapped[int | None] = mapped_column(Integer, default=None)
attempts: Mapped[int] = mapped_column(Integer, default=0)
error: Mapped[str | None] = mapped_column(Text, default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)