Files
AgentLab/backend/app/data/capability_review.py
T
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

337 lines
18 KiB
Python
Raw 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.
"""能力字段语义的审阅:字段语义怎么写进来、待审阅的项怎么排、批量审阅(D-i,§2.6 A6 第 1 条)。
**为什么和数据目录用同一套。** 能力登记的 fields 按 JSON 指针记返回字段的语义:{"/amount": {"label": 项, "unit": 项}},
「项」的形状、状态、审阅规矩都沿用数据目录(data/catalog.py):{value, source, status, note, reason, updated_at},状态
proposed(推断)/ verified(有确证)/ confirmed(已确认)/ rejected(驳回)。一个返回字段就当数据目录里的一列:能写的项
同列级的项(中文名、含义、单位、度量类型、码值),取值规矩、公式检查、人工编辑的语义都直接用 catalog 的实现(把 fields
当成 {"columns": fields} 交给它),说法另换成「返回字段」。一种说法(P5):助手查看能力时读的中文名、审阅队列、界面
上的状态,和数据目录是同一回事。
- **写进来**(normalize_fields,能力治理 PUT /api/capabilities/{name}/governance 调):同数据目录的人工编辑
(catalog.apply_human_edit)——新填、值改过的项记为人工填写、已确认;值没动的保持原来的来源和状态(提交里把状态改成
确认、驳回、来源的初始状态的照改,等同单项审阅);提交里没有的项删掉。客户端不能冒充有确证的来源。只多一条:提交里
明确标成推断(status=proposed,来源是模型起草或命名推断)的新项、改过的项,按推断收下、进审阅队列——起草、导入的
结果走这条;把一项标成推断只会降低它的可信度,冒充不了什么。
- **队列**(review_queue,GET /api/capabilities/review-queue):只列推断的项。priority = 使用次数权重 × 效果权重 ×
风险权重 × 来源权重,从高到低;同分按能力名、指针、项排,结果稳定。
- 使用次数:用到这项能力的运行有几次——运行发起时把用到的能力的治理快照记进 Run.options["capabilities"](A6 第 4
条),按它数。权重同数据目录(catalog_review.usage_weight):按对数增长。
- 效果权重(EFFECT_WEIGHT):写入能力最高(字段的含义错了,确认卡、生效记录的预览就跟着错,而写出去的收不回来),
视参数而定次之,只读最低。
- 风险权重、来源权重同数据目录的列级项(catalog_review.FIELD_GROUP、SOURCE_WEIGHT):度量类型(能不能加总)最高,
码值次之,中文名、含义、单位最低;模型起草、命名推断比别的来源优先。
- 来源已经删掉的能力(missing)不列:确认了也没有用。
- **批量审阅**(review_batch,POST /api/capabilities/review/batch):逐个能力按 version 乐观锁(能力治理同一个版本号,
改一次加一,运行固定的也是它),同一个能力的几条在一次写入里完成,写审计;一个能力冲突、写不进去不影响别的能力;
指针、项不对逐条报(results 和请求一一对应)。审阅的规则同数据目录的单项审阅(catalog.review_item)。
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import catalog
from app.data.catalog_review import (
FIELD_GROUP, REASON_RISK, REASON_SOURCE, RISK_WEIGHT, SOURCE_LABEL, SOURCE_WEIGHT, USED_RUNS, display,
usage_weight,
)
#: 一个返回字段下能写的项:同数据目录列级的项
FIELD_KEYS = catalog.COLUMN_FIELDS
FIELD_KEY_LABEL = catalog.COLUMN_FIELD_LABEL
#: 队列的筛选:全部、影响数字的项(度量类型)、名称和说明(中文名、含义、单位、码值)。同数据目录
KINDS = ("all", "facts", "names")
QUEUE_LIMIT_MAX = 200
BATCH_MAX = 200
#: 效果权重:写入能力的字段含义错了,确认卡、生效记录的预览跟着错
EFFECT_WEIGHT = {"write": 2.0, "dynamic": 1.5, "read": 1.0}
#: reasons 里效果那一条的说法(只读能力不写)
REASON_EFFECT = {"write": "写入能力,确认卡和生效记录按它展示", "dynamic": "视参数而定的能力,可能写入外部系统"}
#: 提交里能按推断收下的来源:起草、导入写进来的。有确证的来源(外键约束、数据剖析)只能由系统写,客户端不能冒充
PROPOSAL_SOURCES = ("llm", "name")
#: 批量审阅逐条结果的机读码
CODE_CONFLICT = "capability_conflict"
CODE_PATH = "capability_path_invalid"
CODE_MISSING = "capability_missing"
class FieldsInvalid(ValueError):
"""fields 的结构或取值不合规。message 是给人看的一句(多条用分号连起来)。"""
def __init__(self, problems: list[str]) -> None:
super().__init__(";".join(problems[:5]) + ("…" if len(problems) > 5 else ""))
self.problems = problems
# ==========================================================================
# 说法:数据目录的「列」在这里是「返回字段」
# ==========================================================================
def where(pointer: str, key: str) -> str:
"""一项给人看的位置:「返回字段 /amount 的中文名」。"""
return f"返回字段 {pointer} 的{FIELD_KEY_LABEL.get(key, key)}"
_COLUMN_HEAD = re.compile(r"^列 ")
def _words(problem: str) -> str:
"""数据目录的校验说法换成能力字段的:「列 /amount 的…」→「返回字段 /amount 的…」。"""
out = _COLUMN_HEAD.sub("返回字段 ", problem)
return out.replace("数据目录只记数据事实", "字段语义只记数据事实").replace("目录项", "字段语义的项")
# ==========================================================================
# 写进来
# ==========================================================================
def _shape(submitted: Any) -> dict[str, dict[str, Any]]:
"""提交的 fields → {指针: {项名: 项}}。一项可以只写值({"label": "金额"}),等同 {"label": {"value": "金额"}}。"""
if not isinstance(submitted, Mapping):
raise FieldsInvalid(["字段语义需要写成按 JSON 指针分项的对象"])
problems: list[str] = []
out: dict[str, dict[str, Any]] = {}
for pointer, items in submitted.items():
if not isinstance(pointer, str) or len(pointer) < 2 or not pointer.startswith("/") or pointer != pointer.strip():
problems.append(f"字段语义的键需要是返回里某个字段的位置(JSON 指针,以 / 开头),当前为「{pointer}」")
continue
if not isinstance(items, Mapping):
problems.append(f"返回字段 {pointer} 的语义需要写成按项分开的对象(中文名、含义、单位……)")
continue
out[pointer] = {k: (dict(v) if isinstance(v, Mapping) and "value" in v else {"value": v})
for k, v in items.items()}
if problems:
raise FieldsInvalid(problems)
return out
def normalize_fields(existing: Mapping[str, Any] | None, submitted: Any, *, at: str | None = None) -> dict[str, Any]:
"""能力治理提交的 fields → 要写入的 fields(不改入参)。规则见模块说明;不合规抛 FieldsInvalid。"""
shaped = _shape(submitted)
at = at or catalog.now_iso()
try:
notes = catalog.apply_human_edit({"columns": dict(existing or {})}, {"columns": shaped}, table="", at=at)
except catalog.CatalogInvalid as e:
raise FieldsInvalid([_words(p) for p in e.problems]) from e
old = catalog._slots({"columns": dict(existing or {})})
slots = catalog._slots(notes)
for pointer, items in shaped.items():
for key, item in items.items():
slot = ("c", pointer, key)
if slot not in slots or item.get("status") != "proposed" or item.get("source") not in PROPOSAL_SOURCES:
continue
prev = old.get(slot)
if prev is not None and catalog._edit_value(slot, slots[slot]) == catalog._edit_value(slot, prev):
continue # 值没动的,按人工编辑的规矩已经处理过
note = item.get("note") if isinstance(item.get("note"), str) else None
slots[slot] = catalog.make_item(slots[slot]["value"], item["source"], "proposed", note=note, at=at,
complete=catalog.codes_complete(item))
return catalog._assemble(slots).get("columns", {})
# ==========================================================================
# 用得多不多
# ==========================================================================
async def run_counts(session: AsyncSession) -> dict[str, int]:
"""每项能力被几次运行用到:{能力名: 运行次数},没用过的不出现。按运行发起时记下的能力治理快照数
(Run.options["capabilities"],一次运行里一项能力只记一次)。一次查询:两种库都用 json_each(PostgreSQL 的碰到不是
对象的值会报错,先按类型挑出来)。"""
from app.db.portable import dialect_of
if dialect_of(session) == "postgresql":
sql = ("SELECT j.key, COUNT(*) FROM runs CROSS JOIN LATERAL json_each(CASE WHEN "
"json_typeof(runs.options -> 'capabilities') = 'object' THEN runs.options -> 'capabilities' "
"ELSE '{}'::json END) AS j GROUP BY j.key")
else:
sql = ("SELECT j.key, COUNT(*) FROM runs, json_each(runs.options, '$.capabilities') AS j "
"WHERE json_valid(runs.options) GROUP BY j.key")
rows = await session.execute(text(sql))
return {str(name): int(count) for name, count in rows.all()}
# ==========================================================================
# 队列
# ==========================================================================
def priority(runs: int, effect: str, key: str, source: Any) -> float:
"""一项的优先级:使用次数权重 × 效果权重 × 风险权重 × 来源权重。"""
_, tier = FIELD_GROUP[("c", key)]
return (usage_weight(runs) * EFFECT_WEIGHT.get(effect, 1.0) * RISK_WEIGHT[tier]
* SOURCE_WEIGHT.get(str(source), 1.0))
def _reasons(runs: int, effect: str, key: str, item: Mapping[str, Any]) -> list[str]:
out: list[str] = []
if runs > 0:
out.append(USED_RUNS.format(runs=f"{runs:,}"))
if effect in REASON_EFFECT:
out.append(REASON_EFFECT[effect])
_, tier = FIELD_GROUP[("c", key)]
out.append(REASON_RISK[tier].format(label=FIELD_KEY_LABEL.get(key, key)))
source = str(item.get("source") or "")
out.append(REASON_SOURCE.format(source=SOURCE_LABEL.get(source, source)))
return out
async def review_queue(session: AsyncSession, *, kind: str = "all", capability: str | None = None,
limit: int = 50, offset: int = 0) -> dict[str, Any]:
"""审阅队列:{total, items}。每项:能力(名字、所属系统、标题、效果)、位置(指针、项、给人看的位置)、值和一行字、
来源、状态、备注、优先级、为什么排在这里、用到它的运行次数、能力的版本(批量审阅时带上)。"""
from app.capabilities.registry import effective_effect
from app.db.models import Capability
stmt = select(Capability).where(Capability.missing.is_(False))
if capability:
stmt = stmt.where(Capability.name == capability)
counts = await run_counts(session)
items: list[dict[str, Any]] = []
for row in (await session.execute(stmt)).scalars():
fields = row.fields if isinstance(row.fields, dict) else {}
if not fields:
continue
effect = effective_effect(row)
runs = counts.get(row.name, 0)
for (_, pointer, key), item in catalog._slots({"columns": fields}).items():
if item.get("status") != "proposed":
continue
group, _ = FIELD_GROUP[("c", key)]
if kind != "all" and group != kind:
continue
value = item.get("value")
items.append({
"capability": row.name,
"system": row.system,
"title": row.title or row.name,
"effect": effect,
"pointer": pointer,
"field": key,
"field_text": FIELD_KEY_LABEL.get(key, key),
"field_label": where(pointer, key),
"value": value,
"display": display(("c", pointer, key), value),
"source": item.get("source"),
"status": item.get("status"),
"note": item.get("note") if isinstance(item.get("note"), str) and item.get("note") else None,
"priority": round(priority(runs, effect, key, item.get("source")), 4),
"reasons": _reasons(runs, effect, key, item),
"runs": runs,
"version": row.version,
})
items.sort(key=lambda i: (-i["priority"], i["capability"], i["pointer"], FIELD_KEYS.index(i["field"])))
return {"total": len(items), "items": items[offset:offset + limit]}
# ==========================================================================
# 批量审阅
# ==========================================================================
@dataclass(frozen=True)
class BatchItem:
capability: str
pointer: str
field: str
action: str
if_version: int
def _result(item: BatchItem, *, ok: bool, version: int | None = None, error: str | None = None,
code: str | None = None) -> dict[str, Any]:
return {"capability": item.capability, "pointer": item.pointer, "field": item.field, "ok": ok,
"version": version if ok else None, "error": error, "code": code}
def _conflict(name: str) -> str:
return f"能力「{name}」的治理设置刚被修改过,请重新载入后再审阅"
def review_field(fields: Mapping[str, Any] | None, pointer: str, key: str, action: str, *,
at: str | None = None) -> dict[str, Any]:
"""对一项做审阅,返回新的 fields(不改入参)。规则同数据目录的单项审阅:确认、驳回;撤销审阅回到来源的初始状态
(人工填写的撤销就是删掉)。项不存在、操作不认识抛 catalog.CatalogPathError(说法已换成返回字段的)。"""
if action not in catalog.REVIEW_ACTIONS:
raise catalog.CatalogPathError("不支持这种审阅操作,只能确认、驳回或撤销审阅")
if key not in FIELD_KEYS or ("c", pointer, key) not in catalog._slots({"columns": dict(fields or {})}):
raise catalog.CatalogPathError(f"找不到{where(pointer, key)},可能已被修改,请重新载入")
notes = catalog.review_item({"columns": dict(fields or {})}, f"columns.{pointer}.{key}", action, at=at)
return notes.get("columns", {})
async def review_batch(session: AsyncSession, items: Iterable[BatchItem], *, actor: str | None,
verified: bool) -> list[dict[str, Any]]:
"""批量审阅,返回逐条结果(和请求一一对应)。
同一个能力的几条按请求里的顺序在当前 fields 上依次审阅,一次写入(条件 UPDATE:version 还是读到的那个才写,写成功
version 加一):版本不符(if_version 和库里的不一样)、位置不对的那几条不做,其余照做;写入时撞上别人刚改过,这个能力
的几条都记冲突。每个写成功的能力记一条审计(capability.review)。"""
from app.auth.audit import record
from app.db.models import Capability
items = list(items)
results: list[dict[str, Any] | None] = [None] * len(items)
groups: dict[str, list[int]] = {}
for i, item in enumerate(items):
groups.setdefault(item.capability, []).append(i)
at = catalog.now_iso()
wrote = False
for name, indexes in groups.items():
row = await session.get(Capability, name)
if row is None or row.missing:
for i in indexes:
results[i] = _result(items[i], ok=False, code=CODE_MISSING,
error=f"能力「{name}」不存在,或它的来源已被删除")
continue
current = row.version
fields: dict[str, Any] = dict(row.fields or {})
applied: list[int] = []
changes: list[dict[str, Any]] = []
for i in indexes:
item = items[i]
if item.if_version != current:
results[i] = _result(item, ok=False, code=CODE_CONFLICT, error=_conflict(name))
continue
before = ((fields.get(item.pointer) or {}).get(item.field) or {}).get("status") \
if isinstance(fields.get(item.pointer), dict) else None
try:
fields = review_field(fields, item.pointer, item.field, item.action, at=at)
except catalog.CatalogPathError as e:
results[i] = _result(item, ok=False, code=CODE_PATH, error=str(e))
continue
after = ((fields.get(item.pointer) or {}).get(item.field) or {}).get("status")
applied.append(i)
changes.append({"pointer": item.pointer, "field": item.field, "action": item.action,
"before": before, "after": after})
if not applied:
continue
done = await session.execute(
update(Capability).where(Capability.name == name, Capability.version == current)
.values(fields=fields, version=current + 1, updated_by=actor))
if done.rowcount == 0:
for i in applied:
results[i] = _result(items[i], ok=False, code=CODE_CONFLICT, error=_conflict(name))
continue
await record(session, "capability.review", "capability", name, actor=actor, verified=verified,
data={"version": current + 1, "items": changes})
wrote = True
for i in applied:
results[i] = _result(items[i], ok=True, version=current + 1)
if wrote:
await session.commit()
return [r for r in results if r is not None]
__all__ = ["BATCH_MAX", "BatchItem", "CODE_CONFLICT", "CODE_MISSING", "CODE_PATH", "EFFECT_WEIGHT", "FIELD_KEYS",
"FieldsInvalid", "KINDS", "PROPOSAL_SOURCES", "QUEUE_LIMIT_MAX", "normalize_fields", "priority",
"review_batch", "review_field", "review_queue", "run_counts", "where"]