mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +00:00
1b31c49a00
类的 id 以前按种子问题的原文算:种子是一类里最新的那一句,时间相同的几句读出来先后不定、来了一句新问法种子就换了, 两次读取 id 就变,认证队列里点过的「不是口径」随之失效。 - app/chat/questions:Question.key 是稳定的 id(问数据的那一轮 turn:…、别的探索运行 run:…);QuestionCluster.id 是成员的 id 排好之后取的哈希,同一批问题两次读取一样;recent_questions 同一时刻问的几句按 id 排出先后,不靠库里读出来的先后。 - ledger/certify:问题聚类那一项的 id 用类的 id;「不是口径」时服务端按同一批问题重新聚一次,记下这一类现在的成员(不收 客户端传的成员,找不到、取向量出错就只记 id);之后这一类过半的成员是某一次记下的,就照样不进队——来了几句新问法 id 会变,按成员认不失效。以前按种子原文算的 id 记的照样认;记的成员读不懂的照实忽略。 - api/ledger:dismiss 把当前身份交给 certify(重新聚类要按这个人读得到的问题)。 测试:tests/test_ledger_certify.py 新增三条——同一批问题换个先后读,种子不同、类的 id 一样;点过「不是口径」之后换个说法 再问一句,成员变了照样不进队,读不懂的老记录不报错也不藏;按旧的种子原文 id 记的照样生效。把 id 改回按种子原文、把按 成员认去掉再跑,各自的用例失败。
259 lines
16 KiB
Python
259 lines
16 KiB
Python
"""账本(BE-3,只读):工作流版本被哪些应用钉住、生效计数;生效记录查询。设计文档 §2.9 L4。
|
||
|
||
不另建存储:ActionRecord 本身带着工作流、版本、graph_hash、能力版本、payload_hash、回执;「口径 → 应用」就是固定这一版的
|
||
应用版本。用在发布弹窗、退役确认、版本列表三处。
|
||
|
||
口径账本(docs/design/next-round/02-口径账本.md)在这里加的接口:
|
||
- GET /api/runs/{run_id}/ledger(runs_router,和证据同一道门):一次运行里每个数的口径标签、整题判断、每条查询的比对、能做的
|
||
动作、出具过的正式运行、对过的账(app/ledger/view.py);
|
||
- GET /api/ledger/stats(router,搭建者):账本自身给出的统计 E1–E4,从比对工件、运行、审计现算(app/ledger/stats.py);
|
||
- POST /api/ledger/answers/{run_id}/issue(answers_router,登录即可,第 3 波):按口径正式出具(app/ledger/issue.py,拍板
|
||
L1、L5:参数只从比对工件取,使用者只能按他有权使用的应用固定着的版本出具);
|
||
- POST /api/ledger/answers/{run_id}/reconcile(同上):口径对账,点开才跑一次确定性派生的只读图(app/ledger/reconcile.py);
|
||
- POST /api/ledger/answers/{run_id}/reconcile/{reconcile_id}/feedback(同上,第 4 波):对账结果上的「说明有用 / 看不懂」
|
||
(app/ledger/feedback.py,统计 E2);
|
||
- GET /api/ledger/certify-queue、POST /api/ledger/certify-queue/dismiss(router,搭建者,第 3 波):可沉淀的问题、「不是口径」
|
||
(app/ledger/certify.py)。
|
||
- GET /api/ledger/recall/{workflow_id}/{version}、POST …/continue(router,搭建者,第 4 波):口径升到这一版之后的召回批次——
|
||
每组的结论、「另有 M 组未重算」、手动接着算(app/ledger/recall.py)。版本用量(workflow-versions)另加问数回答、导出、召回。
|
||
这几个新接口都不放给访问密钥(auth/keys.KEY_ROUTES 不登记)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from datetime import datetime
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.db.base import SessionLocal, get_session
|
||
from app.db.models import Workflow
|
||
from app.db.models_process import ActionRecord
|
||
|
||
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}")
|
||
async def workflow_version(workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""一个工作流版本被哪些应用固定、生效记录数、正式运行数;另加口径账本的用量(next-round 02 做法 8):被几次问数回答用过
|
||
(answers:比对里等价已证明的探索运行)、导出过几次(exports:这些答案和这一版的正式运行上的报告导出审计)、定时任务几个、
|
||
召回(recall:以这一版为新版本召回过几组、其中数变了几组,作为旧版本被召回过几组)。"""
|
||
from app.apps.versions import usage
|
||
from app.ledger.uses import version_usage
|
||
|
||
if await session.get(Workflow, workflow_id) is None:
|
||
raise HTTPException(404, "工作流不存在,可能已被删除")
|
||
base = await usage(session, workflow_id, version)
|
||
extra = await version_usage(session, workflow_id, version)
|
||
return {**base, "answers": extra["answers"], "exports": extra["exports"], "schedules": extra["schedules"],
|
||
"recall": extra["recall"]}
|
||
|
||
|
||
def effect_row(r: ActionRecord, *, receipt: bool = True) -> dict[str, Any]:
|
||
return {"id": r.id, "run_id": r.run_id, "node_id": r.node_id, "app_id": r.app_id, "workflow_id": r.workflow_id,
|
||
"workflow_version": r.workflow_version, "graph_hash": r.graph_hash, "capability": r.capability,
|
||
"capability_version": r.capability_version, "environment": r.environment, "state": r.state,
|
||
"attempt": r.attempt, "payload_hash": r.payload_hash, "preview": r.preview,
|
||
"receipt": r.receipt if receipt else None, "error": r.error, "resolution": r.resolution,
|
||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||
"updated_at": r.updated_at.isoformat() if r.updated_at else None}
|
||
|
||
|
||
@router.get("/effects")
|
||
async def effects(workflow_id: str | None = None, version: int | None = None, app_id: str | None = None,
|
||
state: str | None = None, capability: str | None = None,
|
||
limit: int = Query(default=100, ge=1, le=500), session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""生效记录,新的在前。演练(simulated)照样列出,状态写明是演练。"""
|
||
query = select(ActionRecord)
|
||
if workflow_id:
|
||
query = query.where(ActionRecord.workflow_id == workflow_id)
|
||
if version is not None:
|
||
query = query.where(ActionRecord.workflow_version == version)
|
||
if app_id:
|
||
query = query.where(ActionRecord.app_id == app_id)
|
||
if state:
|
||
query = query.where(ActionRecord.state == state)
|
||
if capability:
|
||
query = query.where(ActionRecord.capability == capability)
|
||
rows = (await session.execute(query.order_by(ActionRecord.created_at.desc()).limit(limit))).scalars()
|
||
return {"items": [effect_row(r) for r in rows]}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 口径账本(next-round 02)
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@runs_router.get("/{run_id}/ledger")
|
||
async def run_ledger(run_id: str) -> dict[str, Any]:
|
||
"""一次运行的口径账本:每个数来自哪一份口径(等价已证明 / 助手选用)、相关口径差在哪、这次的推理、没比对的原因;整题判断;
|
||
每条查询和哪几份口径比过。比对在封存之后做(note 写明不在封存范围内);没比对过、能比的运行这时排上按需比对,答复
|
||
state=pending。绑定的值是这次运行查到过的遮罩值时不给值,差异条目里的 SQL 片段按同一套遮罩换掉。"""
|
||
from app.ledger.view import run_view
|
||
|
||
return await run_view(run_id)
|
||
|
||
|
||
@router.get("/stats")
|
||
async def ledger_stats(since: datetime | None = Query(default=None, description="只算这之后发起的运行(ISO 时间)"),
|
||
until: datetime | None = Query(default=None, description="只算这之前发起的运行"),
|
||
origin: str | None = Query(default=None, description="逗号分隔的运行来源,缺省是探索运行的几种"),
|
||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""账本自身给出的统计 E1–E4(app/ledger/stats.compute_stats):口径命中、对账、重复度、召回。全部现算,不另存;评测页
|
||
「口径命中与重复度」直接调同一个函数。"""
|
||
from app.api.deps import current_principal
|
||
from app.ledger.stats import compute_stats
|
||
|
||
origins = [o.strip() for o in (origin or "").split(",") if o.strip()] or None
|
||
return await compute_stats(session, since=since, until=until, origins=origins, principal=current_principal())
|
||
|
||
|
||
class IssueIn(BaseModel):
|
||
"""按哪份口径出具:只能选口径,参数由服务端从比对工件里取(不收客户端传的参数)。"""
|
||
|
||
workflow_id: str = Field(min_length=1, max_length=32)
|
||
version: int = Field(ge=1)
|
||
|
||
|
||
@answers_router.post("/{run_id}/issue")
|
||
async def issue_answer(run_id: str, payload: IssueIn) -> dict[str, Any]:
|
||
"""按口径正式出具这次问数据的答案(app/ledger/issue.py):经 launch_run 发起正式运行,来源 ledger_issue。同一运行、同一
|
||
口径、同一组参数只发起一次,再点交回已经发起的那一次(created 为假)。不能出具时回 409(整题不等价、版本退役、有人工
|
||
节点或写入、缺入口字段……)、403(权限不够:使用者只能按他有权使用的应用固定着的版本出具)、429(超过每日上限),
|
||
detail 写明原因。"""
|
||
from app.api.coded import CodedHTTPException
|
||
from app.api.deps import current_principal
|
||
from app.ledger import issue as ledger_issue
|
||
|
||
try:
|
||
run, created = await ledger_issue.issue(run_id, payload.workflow_id, payload.version,
|
||
principal=current_principal())
|
||
except ledger_issue.IssueRefused as e:
|
||
raise CodedHTTPException(e.status_code, e.message, e.code) from e
|
||
return {"run_id": run.id, "created": created, "issued": ledger_issue.issued_view(run)}
|
||
|
||
|
||
class ReconcileIn(BaseModel):
|
||
"""对哪条查询、和哪份相关口径对账。inputs + confirm:参数要核对时(动作里 needs_check)人核对、改过的参数;pairs:认不出
|
||
哪一列对哪个指标时人选的 [{ours: 这次的列, metric: 口径卡上的指标 id}](动作里写明要不要选)。"""
|
||
|
||
query: str = Field(min_length=1, max_length=64)
|
||
workflow_id: str = Field(min_length=1, max_length=32)
|
||
version: int = Field(ge=1)
|
||
inputs: dict[str, Any] | None = None
|
||
confirm: bool = False
|
||
pairs: list[dict[str, Any]] | None = None
|
||
|
||
|
||
@answers_router.post("/{run_id}/reconcile", status_code=201)
|
||
async def reconcile_answer(run_id: str, payload: ReconcileIn) -> dict[str, Any]:
|
||
"""口径对账(app/ledger/reconcile.py):按那一版口径确定性派生只读图,发起一次探索运行(来源 reconcile),差值在对账卡
|
||
里算。参数是推断出来的、不全的要先核对(409,code ledger_reconcile_check_inputs);认不出列怎么配的要人选(409,
|
||
ledger_reconcile_needs_pairs);多行时维度是遮罩列、键不唯一照实拒绝(409)。"""
|
||
from app.api.coded import CodedHTTPException
|
||
from app.api.deps import current_principal
|
||
from app.ledger import reconcile as ledger_reconcile
|
||
|
||
try:
|
||
run = await ledger_reconcile.reconcile(run_id, ledger_reconcile.Request(
|
||
query=payload.query, workflow_id=payload.workflow_id, version=payload.version, inputs=payload.inputs,
|
||
confirm=payload.confirm, pairs=payload.pairs), principal=current_principal())
|
||
except ledger_reconcile.ReconcileRefused as e:
|
||
raise CodedHTTPException(e.status_code, e.message, e.code, extra=e.extra) from e
|
||
return {"run_id": run.id, "reconciled": ledger_reconcile.reconciled_view(run)}
|
||
|
||
|
||
class FeedbackIn(BaseModel):
|
||
"""对账结果上的反馈:说明有用(useful)还是看不懂(unclear)。"""
|
||
|
||
verdict: str = Field(min_length=1, max_length=20)
|
||
|
||
|
||
@answers_router.post("/{run_id}/reconcile/{reconcile_id}/feedback")
|
||
async def reconcile_feedback(run_id: str, reconcile_id: str, payload: FeedbackIn) -> dict[str, Any]:
|
||
"""对账结果上的「说明有用 / 看不懂」(app/ledger/feedback.py,统计 E2):记成对账运行上的封存后事件,同一个人改主意只算
|
||
最后一次。交回这次对账现在的反馈 {mine, useful, unclear}。不认的反馈 422;不是这个答案的对账 404;读不了这个答案 403;
|
||
对账还没跑完 409。"""
|
||
from app.api.coded import CodedHTTPException
|
||
from app.api.deps import current_principal
|
||
from app.ledger import feedback
|
||
|
||
try:
|
||
out = await feedback.give(run_id, reconcile_id, payload.verdict, principal=current_principal())
|
||
except feedback.FeedbackRefused as e:
|
||
raise CodedHTTPException(e.status_code, e.message, e.code) from e
|
||
return {"run_id": reconcile_id, "feedback": out}
|
||
|
||
|
||
@router.get("/recall/{workflow_id}/{version}")
|
||
async def recall_batches(workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""口径升到这一版之后的召回(app/ledger/recall.batches):每组(旧版本、参数)的结论——数变了 / 没变 / 无法重算:原因 /
|
||
重算失败 / 还在算 / 未重算——和「另有 M 组未重算」、能不能接着算。计划从已有的召回运行现算,不另存。"""
|
||
from app.ledger import recall
|
||
|
||
if await session.get(Workflow, workflow_id) is None:
|
||
raise HTTPException(404, "工作流不存在,可能已被删除")
|
||
return await recall.batches(session, workflow_id, version)
|
||
|
||
|
||
@router.post("/recall/{workflow_id}/{version}/continue")
|
||
async def recall_continue(workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""接着算:把还没重算的组再发起一批(每批 recall_limit 组,上限是 0 时按缺省的组数),交回发起了的召回运行和新的批次
|
||
情况。都算过了的、这一版已经不是当前发布版的(之后又发布了更新的版本)回 409,detail 写为什么。"""
|
||
from app.api.coded import CodedHTTPException
|
||
from app.api.deps import current_principal
|
||
from app.ledger import recall
|
||
|
||
if await session.get(Workflow, workflow_id) is None:
|
||
raise HTTPException(404, "工作流不存在,可能已被删除")
|
||
current = await recall.batches(session, workflow_id, version)
|
||
if not current["can_continue"]:
|
||
raise CodedHTTPException(409, current.get("continue_reason") or "没有还没重算的组", recall.REFUSED)
|
||
launched = await recall.launch(workflow_id, version, batch="manual", actor=current_principal().username)
|
||
async with SessionLocal() as fresh:
|
||
view = await recall.batches(fresh, workflow_id, version)
|
||
return {"launched": launched, **view}
|
||
|
||
|
||
@router.get("/certify-queue")
|
||
async def certify_queue(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""可沉淀的问题(认证队列,app/ledger/certify.py):确定性重复的 SQL 形状、问题聚类、被反复用的答案。每项给代表问题、
|
||
轮次、(形状的)参数候选和相关口径;点过「不是口径」的不再列。固化走 POST /api/copilot/from-run(freeze: true)。"""
|
||
from app.api.deps import current_principal
|
||
from app.ledger import certify
|
||
|
||
return await certify.queue(session, principal=current_principal())
|
||
|
||
|
||
class DismissIn(BaseModel):
|
||
item_id: str = Field(min_length=1, max_length=64)
|
||
kind: str | None = Field(default=None, max_length=20)
|
||
note: str | None = Field(default=None, max_length=500)
|
||
|
||
|
||
@router.post("/certify-queue/dismiss")
|
||
async def certify_dismiss(payload: DismissIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""「不是口径」:记一条审计(ledger.certify.dismiss),这一项之后不再进队。"""
|
||
from app.ledger import certify
|
||
|
||
from app.api.deps import current_principal
|
||
|
||
if payload.kind is not None and payload.kind not in certify.KINDS:
|
||
raise HTTPException(422, "可沉淀的问题只有三种:确定性重复、问题聚类、被反复用的答案")
|
||
await certify.dismiss(session, payload.item_id, kind=payload.kind, note=payload.note,
|
||
principal=current_principal())
|
||
return {"ok": True, "item_id": payload.item_id}
|