Files
AgentLab/backend/app/engine/inquiry.py
T

841 lines
45 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.
"""审批质询:审批人针对待审报告里的一句话提问,一个只读 agent 当场查库作答,问答写进这次运行。
审批卡上只有报告正文和核对概况。审批人对某句话有疑问(「排除测试账号会怎样」「这个数含不含退款」),以前只能驳回重跑,
或者自己去查库。质询让一个只读 agent(engine/verify_agent.py)当场作答,报告本身一个字不改:
1. 开放条件(_open):运行停在审批上、问的是那张待处理的审批卡——问答落在以后的封存范围里,随运行结束一起封存;或者
运行已经结束封存、核对一致、是探索运行——问答追加在封存之后(post_seal),是批注不是证据。正式运行封存之后不再追加
(同按需裁判);设置里关了「审批质询 · 启用」一律不答。同一次运行同时只答一条:进程内登记(_ANSWERING),登记期间
这次运行的审批回复、继续运行也不受理(api/runs.py),免得运行往下走了、问答还在路上
2. 回答(_answer):交给 agent 被问的那句原文、它挂的证据摘录(照 judge.prepare 的办法取,数据源遮罩的列照旧不给,原报告
的编号标明「原报告」)、审批人选中的原文和问题;工具是这次运行用过的查询工具对应的数据源;上限按设置(每次的查询次数、
时长,全局每日金额)。回答先给结论,数字只能引用 agent 自己查询的格子,或者报告里已有的指标、运行输入;回答文档由
compose_doc 组装,裸数字被标出来
3. 遮罩:回答里引用到遮罩列的格子渲染成「已遮罩」(按遮罩过的快照组装,_masking_loader);回答正文、查询清单里出现的
遮罩列的值换成「已遮罩」(_Secrets)。哪些列要遮照证据面板的规则:查询当时记下的,加上数据源现在设的
4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的
_usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键
收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存),
append_events 不写,回答照样交回给提问的人,说明没有写进运行记录。触顶、失败照样记一条
5. 读取:列表读 approval.inquiry 事件(封存前后的都算,按先后);质询工件只认质询事件引用过的,遮罩照证据面板
不发 tool.start、llm.start 这类事件(run_verify_agent 不传 emit):界面从接口的返回取回答;runner 的 _stale_replay
只看 tool.start,质询不影响恢复后的重放。
"""
from __future__ import annotations
import contextlib
import json
import logging
import re
import time
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from app.core import artifact_store
from app.core.errors import describe_exception
from app.core.events import EventType
from app.data.provenance_types import DOC_PROVENANCE
from app.db.base import SessionLocal
from app.db.models import Approval, DataSource, Run, RunEvent
from app.engine import judge as judging
from app.engine.evidence import MARKER_RE, RenderError, compose_doc, iter_units, render_cell
from app.engine.expressions import numeric_text
from app.engine.merge_query import MERGE_SOURCE
from app.engine.nodes.challenge import mark_original, with_note
from app.engine.run_append import append_events
from app.engine.toolcalls import QUERY_PREFIX
from app.engine.verify_agent import RATIONALE, VerifyOutcome, run_verify_agent
logger = logging.getLogger(__name__)
#: 不受理时的机读码(前后端约定)。审批回复、继续运行在质询期间回的也是 INQUIRY_BUSY(api/runs.py)
INQUIRY_NOT_OPEN = "inquiry_not_open"
INQUIRY_BUSY = "inquiry_busy"
INQUIRY_DISABLED = "inquiry_disabled"
RUN_NOT_FOUND = "run_not_found"
ARTIFACT_NOT_FOUND = "inquiry_artifact_not_found"
ARTIFACT_TAMPERED = "snapshot_tampered"
#: 问题、选中的原文最长多少字
QUESTION_MAX = 1000
QUOTE_MAX = 500
#: 回答期间运行状态变了、问答没写进运行记录时给提问的人的说明
NOT_RECORDED = "运行状态已经变化,这次质询的问答没有写入运行记录"
BUSY = "这次运行另有一条质询正在回答,回答完再提问"
#: 质询正在回答时,这次运行的审批回复(批准 / 驳回)、继续运行回的话(api/runs.py)
BUSY_DECIDE = "正在回答质询,回答完再批准或驳回"
BUSY_CONTINUE = "正在回答质询,回答完再继续运行"
#: 触顶原因:前后端约定的四种。verify_agent 另有 max_steps(模型想了太多轮),轮数是按查询次数推出来的,算作查询次数到顶
LIMITS = ("max_queries", "timeout_s", "max_cost_usd", "daily_max_usd")
#: 事件、接口里的用量只给这几项(runner 计进运行用量的也是这几项,_INQUIRY_KEYS)
USAGE_KEYS = ("input_tokens", "output_tokens", "cost_usd", "calls", "unpriced_calls")
#: 查询清单每条给的几项:没有行,查询结果只在快照里(取的时候按遮罩遮好)
QUERY_KEYS = ("alias", "artifact", "sql", "row_count", "truncated", "source")
#: 回答文档存成工件时的种类
ANSWER_KIND = "inquiry_answer"
#: 没指明句子时交给 agent 的报告正文最多多少字
REPORT_CHARS = 4000
#: 列给 agent 的报告指标、运行输入最多几条
REFS_MAX = 40
#: 被问的是表格里的一格时,交给 agent 的表格最多几行(不含表头)
TABLE_ROWS = 20
#: 遮罩列的值短于这么多字就不在正文里换:一个字的值(「是」「男」)换掉,会把不相干的词也换得面目全非
SECRET_MIN = 2
#: 遮罩列的值最多收这么多个(一份快照最多上千行):再多就按已经收到的换
SECRETS_MAX = 20_000
#: 运行的终态。封存之后的追问只在这几种状态上
_TERMINAL = ("succeeded", "failed", "cancelled")
#: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截)
_WORD = re.compile(r"[0-9A-Za-z_]")
INQUIRY_SYSTEM = (
"你在帮人核对一份报告:提问人针对报告里的一句话提了问题,你查库作答。你不写报告,也不改报告。\n"
"- 问题是「如果……会怎样」(排除测试账号、换个时间范围、只看某个区域)时,写只读查询把它算出来,不要凭印象推测\n"
"- 回答直截了当:第一句直接给结论(是还是不是、是多少、差多少),再用一两句说依据;不要复述问题,不要客套\n"
"- 回答里的每个数字都写成引用:你自己查询结果里的一格写 [[v:Qn.rX.列名]],报告里已有的指标、运行输入照任务里列出的"
"写法引用;不要直接写数字,没有出处的数字会被标出来\n"
"- 数据源设了遮罩的列(手机号、姓名这类)不要在回答里写出原值,需要指明时说「该客户」「这一笔」\n"
"- 查不到、数据里没有判断所需的信息,就照实说查不到,不要编造"
)
INQUIRY_TASK_PROMPT = (
"报告「{report}」{where}\n\n"
"{evidence}"
"{refs}"
"可以查询的数据源:{sources}\n\n"
"{masked}"
"提问人的问题:\n{question}\n\n"
"按上面的做法作答,回答写在 rationale 里。"
)
#: 交给核查 agent 的结果结构:只有回答一项(verify_agent 的理由字段,沿用这里写的说明)
INQUIRY_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {RATIONALE: {
"type": "string",
"description": "回答:第一句直接给结论,再用一两句说依据;数字一律写成引用([[v:Qn.rX.列名]],"
"或任务里列出的报告指标、运行输入)",
}},
"required": [RATIONALE],
"additionalProperties": False,
}
# 不受理的说法
DISABLED_WORDS = "审批质询已关闭(设置中的「{label}」),如需质询,请在设置的「核查」一节开启"
STILL_RUNNING = "运行还在执行,停在人工审批时、或者运行结束封存之后才能质询"
NO_CARD = "运行没有停在人工审批上(可能是服务重启时中断的),没有可以质询的审批"
CARD_GONE = "这张审批卡已处理,或不是这次运行正在等待的审批,请刷新页面查看最新状态"
CARD_DONE = "这张审批卡已处理,运行已经结束,请刷新页面查看最新状态"
FORMAL_SEALED = "正式运行已结束并封存,问答不能再追加进运行记录;封存之后只有探索运行可以继续质询"
UNSEALED = "运行已结束,但还没有封存,暂时不能质询"
BROKEN_SEAL = "封存核对未通过:封存后有事件被修改、删除或插入,报告和证据已不可信,不能再质询"
NEED_CARD = "运行停在人工审批上,需要指明质询的是哪张审批卡"
BAD_BODY = ("请求格式有误:需要一个对象,写明报告(report,报告撰写节点的 ID)和问题(question),"
"可选句子编号(unit)、选中的原文(quote)、审批卡(approval_id)")
DOC_TAMPERED = "报告文档与哈希不一致,疑似被修改,不能据此质询"
DOC_MISSING = "无法读取报告文档"
NO_ANSWER = "模型没有给出回答内容"
class InquiryRefused(Exception):
"""质询不受理:status 是 HTTP 状态码,message 是给人看的话;409 带机读码 code,422 带出错的那一项 field。"""
def __init__(self, status: int, message: str, *, code: str | None = None, field: str | None = None) -> None:
super().__init__(message)
self.status, self.message, self.code, self.field = status, message, code, field
def _invalid(message: str, name: str | None) -> InquiryRefused:
return InquiryRefused(422, message, field=name)
def _not_open(message: str) -> InquiryRefused:
return InquiryRefused(409, message, code=INQUIRY_NOT_OPEN)
# --------------------------------------------------------------------------
# 同一次运行同时只答一条
# --------------------------------------------------------------------------
class _Answering:
"""正在回答质询的运行(进程内登记)。
只在本进程里有效:另一个进程里的批准拦不住,那时追加按条件更新核对(append_events),写不进就照实说没写进。"""
def __init__(self) -> None:
self._runs: set[str] = set()
def busy(self, run_id: str) -> bool:
return run_id in self._runs
@contextlib.contextmanager
def hold(self, run_id: str) -> Iterator[None]:
# 查和登记之间没有 await:同一个事件循环里的两条质询不会都看到「没人在答」
if run_id in self._runs:
raise InquiryRefused(409, BUSY, code=INQUIRY_BUSY)
self._runs.add(run_id)
try:
yield
finally:
self._runs.discard(run_id)
_ANSWERING = _Answering()
def inquiry_busy(run_id: str) -> bool:
"""这次运行是不是正在回答质询(api/runs.py 据此让审批回复、继续运行回 409)。"""
return _ANSWERING.busy(run_id)
# --------------------------------------------------------------------------
# 请求
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Ask:
"""一条质询:哪份报告(报告撰写节点的 id)的哪一句(句子单元 id,可以不指明)、选中的原文、问题、问的是哪张审批卡。"""
report: str
unit: str | None
quote: str | None
question: str
approval_id: str | None
def parse_ask(body: Any) -> Ask:
"""请求体 → Ask。不合法时抛 422,field 指明是哪一项。"""
if not isinstance(body, dict):
raise _invalid(BAD_BODY, None)
report = body.get("report")
if not isinstance(report, str) or not report.strip():
raise _invalid("请指明质询的是哪份报告(报告撰写节点的 ID)", "report")
unit = _blank_as_none(body.get("unit"))
if unit is not None and not isinstance(unit, str):
raise _invalid("句子编号需要是文字,例如「u12」;不指明句子时留空", "unit")
quote = body.get("quote")
if quote is not None and not isinstance(quote, str):
raise _invalid("选中的原文需要是文字", "quote")
quote = (quote or "").strip() or None
if quote is not None and len(quote) > QUOTE_MAX:
raise _invalid(f"选中的原文最长 {QUOTE_MAX} 字,当前为 {len(quote)} 字,请缩短选区", "quote")
question = body.get("question")
if not isinstance(question, str) or not question.strip():
raise _invalid("请填写要质询的问题", "question")
question = question.strip()
if len(question) > QUESTION_MAX:
raise _invalid(f"问题最长 {QUESTION_MAX} 字,当前为 {len(question)} 字", "question")
approval_id = _blank_as_none(body.get("approval_id"))
if approval_id is not None and not isinstance(approval_id, str):
raise _invalid("审批卡的 ID 需要是文字;不是在审批卡上提问时留空", "approval_id")
return Ask(report=report.strip(), unit=unit.strip() if unit else None, quote=quote, question=question,
approval_id=approval_id.strip() if approval_id else None)
def _blank_as_none(value: Any) -> Any:
"""不指明句子、不是在审批卡上问的:写 null 和写空串是一个意思。别的类型原样交回,由调用方报格式不对。"""
return None if isinstance(value, str) and not value.strip() else value
# --------------------------------------------------------------------------
# 开放条件
# --------------------------------------------------------------------------
Events = list[tuple[int, str, str | None, Any]]
@dataclass
class _Opening:
"""开放了的这一次质询:往哪种运行里写(mode)、写的时候要核对什么(approval_id / seal),以及被问的报告文档。
loader 是取证据的函数:封存之后只认封存范围内的工件(同按需裁判,api/evidence._sealed_loader),停在审批时和
报告节点里的裁判一样直接读工件库。events 是这次运行的事件(封存之后只取封存范围内的)。"""
run_id: str
mode: str
title: str
versions: Any
events: Events
doc: dict[str, Any]
loader: Callable[[str], Any]
approval_id: str | None = None
seal: tuple[int | None, str | None] | None = None
async def _open(run_id: str, ask: Ask) -> _Opening:
"""这条质询开不开放:开放就交回往哪写、问的是哪份文档;不开放抛 409(输入不对的抛 422、运行不在的抛 404)。"""
cards: list[str] = []
rows: Events = []
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise InquiryRefused(404, "运行记录不存在", code=RUN_NOT_FOUND)
status, run_class, graph, versions = run.status, run.run_class, run.graph or {}, run.data_versions
if status == "interrupted":
cards = [str(a) for a in (await session.execute(
select(Approval.id).where(Approval.run_id == run_id, Approval.status == "pending"))).scalars()]
rows = [tuple(r) for r in await session.execute(
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
.where(RunEvent.run_id == run_id).order_by(RunEvent.seq))]
title = _node_title(graph, ask.report)
if status == "interrupted":
if not cards:
raise _not_open(NO_CARD)
if ask.approval_id is None:
raise _invalid(NEED_CARD, "approval_id")
if ask.approval_id not in cards:
raise _not_open(CARD_GONE)
doc = _waiting_doc(rows, run_id, ask.report, title)
return _Opening(run_id=run_id, mode="interrupted", title=title, versions=versions, events=rows, doc=doc,
loader=artifact_store.load, approval_id=ask.approval_id)
if status not in _TERMINAL:
raise _not_open(STILL_RUNNING)
if ask.approval_id is not None:
raise _not_open(CARD_DONE)
if run_class == "formal":
raise _not_open(FORMAL_SEALED)
from app.api.evidence import _reports, _sealed, _sealed_loader # api.evidence 导入了 engine 里的模块
sealed = await _sealed(run_id)
if sealed.status not in _TERMINAL:
# 上面读完状态之后运行被继续了:记录上的封存是上一次结束时的,追加会被下一次封存收进去
raise _not_open(STILL_RUNNING)
if not sealed.sealed:
raise _not_open(UNSEALED)
if not sealed.trusted:
raise _not_open(BROKEN_SEAL)
report = next((r for r in _reports(sealed) if r.node_id == ask.report), None)
if report is None:
raise _invalid(_no_report(title), "report")
if report.hash_ok is False:
raise _invalid(DOC_TAMPERED, "report")
if report.doc is None:
raise _invalid(DOC_MISSING, "report")
# 带期 4 标记的文档才让摘录取得到哈希链上的清单,和按需裁判同一个口径
loader = _sealed_loader(sealed, chain=report.doc.get("provenance") == DOC_PROVENANCE)
return _Opening(run_id=run_id, mode="sealed", title=title, versions=versions, events=list(sealed.events),
doc=report.doc, loader=loader, seal=(sealed.run_manifest_seq, sealed.manifest_hash))
def _node_title(graph: dict[str, Any], node_id: str) -> str:
for n in graph.get("nodes") or []:
if isinstance(n, dict) and n.get("id") == node_id:
return str((n.get("data") or {}).get("label") or node_id)
return node_id
def _no_report(title: str) -> str:
return f"这次运行里没有报告「{title}」的报告文档,请刷新页面后重新选择"
def _waiting_doc(events: Events, run_id: str, report: str, title: str) -> dict[str, Any]:
"""停在审批时被问的报告文档:这个报告节点最后一次 report.checked 里的那份(同证据接口的 _reports)。文档要真是
这次运行里这个节点写的——内容寻址只保证没被改,不保证没被张冠李戴。"""
artifact = None
for _, etype, node_id, data in events:
if etype == EventType.REPORT_CHECKED and node_id == report and isinstance(data, dict) and data.get("doc_artifact"):
artifact = str(data["doc_artifact"])
if artifact is None:
raise _invalid(_no_report(title), "report")
try:
doc = artifact_store.load(artifact)
except ValueError as e:
raise _invalid(DOC_TAMPERED, "report") from e
if not isinstance(doc, dict):
raise _invalid(DOC_MISSING, "report")
if doc.get("node_id") != report or doc.get("run_id") != run_id:
raise _invalid(DOC_TAMPERED, "report")
return doc
# --------------------------------------------------------------------------
# 被问的那句
# --------------------------------------------------------------------------
@dataclass
class _Target:
"""被问的那句:按裁判的候选句建(judge._candidate,带它挂的依据),表格里的一格另带整张表。"""
cand: judging.Candidate
table: str | None = None
def _target(doc: dict[str, Any], unit: str) -> _Target:
"""文档里的这一句。按 judge.candidates 的走法记小节和先后;不是结论句的(表格里的一格、标题)也照样建,
审批人问的不一定是结论句。没有这一句抛 422。"""
markdown = str(doc.get("markdown") or "")
section = ""
for order, (block, item) in enumerate(iter_units(doc)):
text = judging._unit_text(item, markdown)
if block.get("type") == "heading":
section = text
if item.get("id") != unit:
continue
priority = judging.claim_priority(item, block, markdown) or 3
cand = judging._candidate(item, priority, order, text, section)
return _Target(cand, _table_text(block, markdown) if block.get("type") == "table" else None)
raise _invalid(f"报告里没有这句({unit}),请刷新页面后重新选择", "unit")
def _table_text(block: dict[str, Any], markdown: str) -> str:
"""表格整张压成几行(表头在前),一格的值离了行列说不清是什么。"""
rows: dict[int, dict[int, str]] = {}
for unit in block.get("units") or []:
loc = unit.get("loc") if isinstance(unit, dict) else None
if isinstance(loc, dict) and isinstance(loc.get("row"), int) and isinstance(loc.get("col"), int):
rows.setdefault(loc["row"], {})[loc["col"]] = judging._unit_text(unit, markdown)
lines = [" | ".join(cells[c] for c in sorted(cells)) for _, cells in sorted(rows.items())]
kept = lines[:TABLE_ROWS + 1]
return "\n".join(kept) + ("\n…(后面的行省略了)" if len(lines) > len(kept) else "")
def _excerpts(doc: dict[str, Any], catalog: dict[str, Any], cand: judging.Candidate, loader: Callable[[str], Any],
masked: dict[str, Any]) -> list[str]:
"""这句挂的每件证据的摘录:和 judge.prepare 一样逐件摘(数据源遮罩的列不给,带期 4 标记的文档多来历行),
指向原报告编号的写法标上「原报告」——agent 自己的查询也编 Q1、Q2…,不分开的话会把它们混成一回事。"""
fetch = judging._Fetch(loader)
provenance_on = doc.get("provenance") == DOC_PROVENANCE
queries = {a for a, e in catalog.items() if isinstance(e, dict) and e.get("kind") == "query"}
out: list[str] = []
for alias in cand.aliases:
entry = catalog.get(alias)
if isinstance(entry, dict) and (text := judging._excerpt(alias, entry, [cand], fetch, masked, catalog,
provenance_on=provenance_on)):
out.append(mark_original(text, alias, queries))
return out
def _report_refs(catalog: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""报告目录里回答可以直接引用的出处:有值的口径卡指标(m:)和运行输入(i:)。它们的编号和 agent 自己的 Q1… 不会
撞;报告的查询格子不行——agent 的查询也编 Q1,引用 Q1 说不清是哪一份。没有值的引用了也解析不了,不列。"""
return {a: e for a, e in catalog.items()
if isinstance(e, dict) and e.get("kind") in ("metric", "input") and e.get("value") not in (None, "")}
def _run_sources(events: Events) -> list[str]:
"""这次运行用过的查询工具对应的数据源,按第一次用到的先后。"""
out: list[str] = []
for _, etype, _, data in events:
if etype not in (EventType.TOOL_START, EventType.TOOL_END) or not isinstance(data, dict):
continue
tool = str(data.get("tool") or "")
name = tool[len(QUERY_PREFIX):] if tool.startswith(QUERY_PREFIX) else ""
if name and name not in out:
out.append(name)
return out
async def _usable_sources(names: Iterable[str]) -> dict[str, list[str]]:
"""现在能查的数据源(存在、没有停用,核查建工具时也只认这样的)→ 它现在设了遮罩的列。"""
from app.data.engine import masked_columns
wanted = list(dict.fromkeys(names))
if not wanted:
return {}
async with SessionLocal() as session:
rows = await session.execute(select(DataSource.name, DataSource.options)
.where(DataSource.name.in_(wanted), DataSource.enabled.is_(True)))
return {str(name): masked_columns(options) for name, options in rows.all()}
def _quoted(names: Iterable[str]) -> str:
return "、".join(f"「{n}」" for n in names)
def _task(op: _Opening, ask: Ask, target: _Target | None, excerpts: list[str], refs: dict[str, dict[str, Any]],
live: dict[str, list[str]], lost: list[str]) -> str:
if target is not None:
cand = target.cand
where = f"中的这句话{f'(所在小节「{cand.section}」)' if cand.section else ''}:\n「{cand.text}」"
if target.table:
where += f"\n\n这句话在一张表格里,表格是(每行一条,单元格用 | 隔开):\n{target.table}"
else:
where = ",提问人没有指明是哪一句"
if ask.quote:
where += f"\n\n提问人选中的原文:\n「{ask.quote}」"
if target is None:
markdown = str(op.doc.get("markdown") or "")
cut = markdown[:REPORT_CHARS] + ("\n…(后面的省略了)" if len(markdown) > REPORT_CHARS else "")
where += f"\n\n报告正文:\n{cut}"
evidence = ""
if target is not None:
evidence = ("这句话挂的证据摘录(编号属于原报告;你的查询另起编号,回答里只能引用你自己的查询):\n"
+ "\n\n".join(excerpts) + "\n\n") if excerpts else "这句话没有挂依据。\n\n"
lines = [f"[[{alias}]] {entry.get('label') or alias}" for alias, entry in list(refs.items())[:REFS_MAX]]
more = f"\n…另有 {len(refs) - REFS_MAX} 项" if len(refs) > REFS_MAX else ""
refs_text = ("报告里已有的指标和运行输入(回答里可以照这个写法直接引用):\n" + "\n".join(lines) + more + "\n\n"
if lines else "")
sources = "、".join(live) if live else "(没有可以查询的数据源,只能依据上面的报告内容作答)"
if lost:
sources += f"(这次运行还用到了{_quoted(lost)},已不存在或已停用,无法查询)"
hidden = [f"「{name}」的 {'、'.join(cols)}" for name, cols in live.items() if cols]
masked = f"设了遮罩的列(回答里不要写出这些列的值):{';'.join(hidden)}\n\n" if hidden else ""
return INQUIRY_TASK_PROMPT.format(report=op.title, where=where, evidence=evidence, refs=refs_text,
sources=sources, masked=masked, question=ask.question)
# --------------------------------------------------------------------------
# 遮罩
# --------------------------------------------------------------------------
def _query_shaped(content: Any) -> bool:
return isinstance(content, dict) and isinstance(content.get("rows"), list)
async def _hidden_columns(snapshot: dict[str, Any], masks: Any) -> tuple[set[str], list[str]]:
"""一份查询快照要遮的列(小写)和已经不在的数据源,照证据面板的规则(api/evidence.py 的 _query_step):查询当时
记下的,加上数据源现在设的;合并查询按它合并的那几个数据源算。"""
from app.api.evidence import _merge_masks, _recorded_masks
if snapshot.get("source") == MERGE_SOURCE:
return await _merge_masks(snapshot, masks)
cols, found = await masks.of(snapshot.get("source"))
return cols | _recorded_masks(snapshot), ([] if found or not snapshot.get("source") else [str(snapshot["source"])])
def _masked_word() -> str:
from app.api.evidence import MASKED
return MASKED
def masked_snapshot(snapshot: dict[str, Any], hidden: set[str]) -> dict[str, Any]:
"""遮罩列的每一格换成「已遮罩」,这几列的类型记成文本(渲染时不当数)。没有要遮的列原样返回。"""
columns = [str(c) for c in snapshot.get("columns") or []]
index = {i for i, c in enumerate(columns) if c.lower() in hidden}
if not index:
return snapshot
word = _masked_word()
def row_of(row: Any) -> Any:
if isinstance(row, list):
return [word if i in index else v for i, v in enumerate(row)]
if isinstance(row, dict):
return {k: word if str(k).lower() in hidden else v for k, v in row.items()}
return row
types = dict(snapshot["column_types"]) if isinstance(snapshot.get("column_types"), dict) else {}
types.update({columns[i]: "text" for i in index})
return {**snapshot, "rows": [row_of(r) for r in snapshot["rows"]], "column_types": types}
def _masking_loader(hidden: dict[str, set[str]]) -> Callable[[str], Any]:
"""组装回答文档用的 loader:agent 自己的查询快照按遮罩遮好再交出去,引用到遮罩列的格子就渲染成「已遮罩」。
别的查询快照不交(回答的目录里只有 agent 自己的查询),表结构快照这类没有行的原样。哈希照旧复验。"""
def load(artifact: str) -> Any:
content = artifact_store.load(artifact)
if not _query_shaped(content):
return content
return masked_snapshot(content, hidden[artifact]) if artifact in hidden else None
return load
def _forms(value: Any) -> set[str]:
"""一格的值在正文里可能的写法:原样(压成一行),是数的再加上千分位的写法(13,900,000,001)。"""
text = json.dumps(value, ensure_ascii=False, default=str) if isinstance(value, (dict, list)) else str(value)
text = re.sub(r"\s+", " ", text).strip()
forms = {text}
if isinstance(value, (int, float)) or numeric_text(value) is not None:
with contextlib.suppress(RenderError, ValueError, ArithmeticError):
forms.add(render_cell(value))
return {f for f in forms if len(f) >= SECRET_MIN}
def _pattern(value: str) -> str:
"""一个值的正则:头尾是字母数字的按词边界认,数字还不能是更长的数的一截(1,390 里的 390、12.5 里的 2.5)。"""
head = tail = ""
if _WORD.match(value[0]):
head = r"(?<![0-9A-Za-z_])" + (r"(?<!\d[.,])" if value[0].isdigit() else "")
if _WORD.match(value[-1]):
tail = r"(?![0-9A-Za-z_])" + (r"(?![.,]\d)" if value[-1].isdigit() else "")
return head + re.escape(value) + tail
@dataclass
class _Secrets:
"""遮罩列里出现过的值。模型看得到原始的查询结果(工具交回的是原样),回答里照着复述、或者把值写进查询条件,
都会把它带进回答正文和查询清单;按值找出来换成「已遮罩」。这只减少暴露,不是安全边界(同证据面板的遮罩)。"""
values: set[str] = field(default_factory=set)
def add(self, snapshot: Any, hidden: set[str]) -> None:
if not _query_shaped(snapshot) or not hidden:
return
columns = [str(c) for c in snapshot.get("columns") or []]
index = [i for i, c in enumerate(columns) if c.lower() in hidden]
for row in snapshot["rows"]:
if len(self.values) >= SECRETS_MAX:
return
if isinstance(row, list):
cells = [row[i] for i in index if i < len(row)]
elif isinstance(row, dict):
cells = [v for k, v in row.items() if str(k).lower() in hidden]
else:
continue
for value in cells:
if value is not None and not isinstance(value, bool):
self.values |= _forms(value)
def scrubber(self) -> Callable[[str], str]:
if not self.values:
return lambda text: text
# 长的在前:13900000001 先于它的一截被认出来
pattern = re.compile("|".join(_pattern(v) for v in sorted(self.values, key=lambda v: (-len(v), v))))
word = _masked_word()
return lambda text: pattern.sub(word, text) if text else text
def _outside_markers(text: str, scrub: Callable[[str], str]) -> str:
"""只换引用标记之外的文字:标记里是编号、行号、列名,换了会把一个好好的引用弄坏。"""
out: list[str] = []
last = 0
for m in MARKER_RE.finditer(text):
out += [scrub(text[last:m.start()]), m.group(0)]
last = m.end()
out.append(scrub(text[last:]))
return "".join(out)
# --------------------------------------------------------------------------
# 回答
# --------------------------------------------------------------------------
def _limit(outcome: VerifyOutcome) -> str | None:
if outcome.limit in LIMITS:
return outcome.limit
return "max_queries" if outcome.limit == "max_steps" else None
async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict[str, Any]) -> dict[str, Any]:
"""问 agent,组装回答文档,交回事件数据里回答的那几项。模型用不了、触顶、组装出错都落在 ok / limit / error 里:
钱已经花了,回答没成也照样记一条。"""
from app.api.evidence import _Masks
from app.api.settings import verify_model_spec
doc = op.doc
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
masked = await judging.source_masks(catalog, loader=op.loader)
excerpts = _excerpts(doc, catalog, target.cand, op.loader, masked) if target is not None else []
refs = _report_refs(catalog)
wanted = _run_sources(op.events)
usable = await _usable_sources(wanted)
live, lost = {s: usable[s] for s in wanted if s in usable}, [s for s in wanted if s not in usable]
part = settings.get("inquiry") if isinstance(settings.get("inquiry"), dict) else {}
async with SessionLocal() as session:
spec = await verify_model_spec(session, None, settings=settings)
outcome = await run_verify_agent(
spec=spec, system=INQUIRY_SYSTEM, task=_task(op, ask, target, excerpts, refs, live, lost), sources=list(live),
output_schema=INQUIRY_SCHEMA, max_queries=part.get("max_queries"), timeout_s=part.get("timeout_s"),
daily_max_usd=settings.get("daily_max_usd"), run_id=op.run_id, data_versions=op.versions)
# 遮罩:agent 自己的查询快照、报告自己的查询快照(被问的那句可能直接写着遮罩列的值,agent 会照着复述)
masks = _Masks()
hidden: dict[str, set[str]] = {}
secrets = _Secrets()
for query in outcome.queries:
snapshot = _quiet_load(artifact_store.load, query.get("artifact"))
if _query_shaped(snapshot):
hidden[query["artifact"]] = (await _hidden_columns(snapshot, masks))[0]
secrets.add(snapshot, hidden[query["artifact"]])
for entry in catalog.values():
if isinstance(entry, dict) and entry.get("kind") == "query":
snapshot = _quiet_load(op.loader, entry.get("artifact"))
if _query_shaped(snapshot):
secrets.add(snapshot, (await _hidden_columns(snapshot, masks))[0])
scrub = secrets.scrubber()
text, doc_id, failed = "", None, None
source = (outcome.rationale or {}).get("source") if outcome.result is not None else None
if isinstance(source, str) and source.strip():
try:
answer = _compose(source, {**refs, **((outcome.rationale or {}).get("catalog") or {})}, op.run_id,
hidden, scrub)
except Exception as e: # noqa: BLE001 - 原文没组装成文档就不交出去:里面的引用没换成值,遮罩也没做
logger.warning("质询的回答组装失败(运行 %s)", op.run_id, exc_info=True)
failed = f"整理回答时出错({describe_exception(e)})"
else:
if lost:
answer = with_note(answer, f"注:这次运行还用到了数据源{_quoted(lost)},已不存在或已停用,未能查询,"
"以上回答不含其中的数据。")
text = str(answer.get("markdown") or "")
doc_id = await _store(answer, op, ask)
ok = bool(text.strip())
queries = [{**{k: q.get(k) for k in QUERY_KEYS}, "sql": scrub(str(q.get("sql") or ""))} for q in outcome.queries]
return {
"answer_text": text, "answer_doc": doc_id, "ok": ok, "limit": _limit(outcome),
"error": None if ok else (outcome.error or failed or NO_ANSWER),
# 结果一模一样的两次查询是同一份快照(工件按内容寻址):引用列表里只记一次
"inquiry_artifacts": list(dict.fromkeys(q["artifact"] for q in queries if q.get("artifact"))),
"queries": queries,
"model": outcome.model, "usage": {k: outcome.usage.get(k, 0) for k in USAGE_KEYS},
}
def _quiet_load(loader: Callable[[str], Any], artifact: Any) -> Any:
"""取一份快照;取不到、哈希不符都当没有(这里只是为了找遮罩列的值,取不到的那份本来也渲染不出来)。"""
if not artifact:
return None
try:
return loader(str(artifact))
except Exception: # noqa: BLE001
return None
def _compose(source: str, catalog: dict[str, Any], run_id: str, hidden: dict[str, set[str]],
scrub: Callable[[str], str]) -> dict[str, Any]:
"""agent 写的回答原文(带引用标记)→ 回答文档:目录是 agent 自己的查询,加上报告里的指标和运行输入;快照按遮罩
遮好再渲染。遮罩列的值先在标记之外的文字里换掉;换完正文里还认得出遮罩列的值(标记里写了值,解析不了,原样
出现在正文里),就连标记一起换了再组装一遍——宁可弄坏一个引用,也不让值露出来。"""
loader = _masking_loader(hidden)
doc = compose_doc(_outside_markers(source, scrub), catalog, run_id=run_id, loader=loader)
markdown = str(doc.get("markdown") or "")
if scrub(markdown) != markdown:
doc = compose_doc(scrub(source), catalog, run_id=run_id, loader=loader)
return doc
async def _store(doc: dict[str, Any], op: _Opening, ask: Ask) -> str | None:
try:
return await artifact_store.put_json(doc, kind=ANSWER_KIND, run_id=op.run_id,
meta={"report": ask.report, "unit": ask.unit})
except Exception: # noqa: BLE001 - 存不下不影响回答本身:正文照常交回,只是数字点不开
logger.warning("质询的回答文档保存失败(运行 %s)", op.run_id, exc_info=True)
return None
# --------------------------------------------------------------------------
# 写入与读取
# --------------------------------------------------------------------------
async def _record(op: _Opening, data: dict[str, Any]) -> tuple[Any, str | None]:
"""记一条 approval.inquiry。返回 (写入的事件, 没写进时的说明)。
停在审批时:那张卡仍待审批、运行仍停着才写(批过了,运行可能已经停到下一张卡上)。封存之后:仍是开始回答时
的那份封存才写。append_events 返回 None 就是运行在回答期间变了。"""
items = [(EventType.APPROVAL_INQUIRY, None, data)]
try:
if op.mode == "interrupted":
written = await append_events(op.run_id, items, require="interrupted", approval_id=op.approval_id)
else:
written = await append_events(op.run_id, items, require="sealed", seal=op.seal)
except Exception as e: # noqa: BLE001 - 写不进运行记录也要把回答交回给提问的人
logger.warning("质询的问答写入运行记录失败(运行 %s)", op.run_id, exc_info=True)
return None, f"这次质询的问答没能写入运行记录({describe_exception(e)})"
return (written[0], None) if written else (None, NOT_RECORDED)
def _iso(ts: Any) -> str | None:
if isinstance(ts, bool) or not isinstance(ts, (int, float)):
return None
return datetime.fromtimestamp(float(ts), timezone.utc).isoformat()
def inquiry_of(data: Any, *, seq: int | None, ts: Any) -> dict[str, Any]:
"""事件数据 → 接口上的 Inquiry 对象(前后端约定的形状)。POST 的返回和 GET 的列表都经这里,两边一样。"""
data = data if isinstance(data, dict) else {}
usage = data.get("usage") if isinstance(data.get("usage"), dict) else {}
return {
"seq": seq, "at": _iso(ts), "actor": data.get("actor"), "report": data.get("report"), "unit": data.get("unit"),
"quote": data.get("quote"), "question": str(data.get("question") or ""),
"answer": {"text": str(data.get("answer_text") or ""), "doc_artifact": data.get("answer_doc"),
"ok": data.get("ok") is True, "limit": data.get("limit"), "error": data.get("error")},
"queries": [{k: q.get(k) for k in QUERY_KEYS} for q in data.get("queries") or [] if isinstance(q, dict)],
"model": data.get("model"),
"usage": {k: usage.get(k, 0) for k in USAGE_KEYS},
"post_seal": bool(data.get("post_seal")), "approval_id": data.get("approval_id"),
}
async def ask(run_id: str, request: Ask, *, actor: str | None = None) -> dict[str, Any]:
"""答一条质询:{inquiry, recorded, note}。不受理时抛 InquiryRefused。
设置按现在的(不按发起时快照进运行选项的那份):质询不在图里执行、不参与重放,关掉「审批质询」应当马上生效。"""
from app.api.settings import VERIFY_SETTING_LABEL, verify_settings
async with SessionLocal() as session:
settings = await verify_settings(session)
part = settings.get("inquiry") if isinstance(settings.get("inquiry"), dict) else {}
if not part.get("enabled", True):
raise InquiryRefused(409, DISABLED_WORDS.format(label=VERIFY_SETTING_LABEL["inquiry.enabled"]),
code=INQUIRY_DISABLED)
with _ANSWERING.hold(run_id):
opening = await _open(run_id, request)
target = _target(opening.doc, request.unit) if request.unit else None
data = {"question": request.question, "quote": request.quote, "report": request.report, "unit": request.unit,
"approval_id": opening.approval_id, "actor": actor,
**await _answer(opening, request, target, settings)}
event, note = await _record(opening, data)
if event is not None:
return {"inquiry": inquiry_of(event.data, seq=event.seq, ts=event.ts), "recorded": True, "note": None}
shown = {**data, "post_seal": True} if opening.mode == "sealed" else data
return {"inquiry": inquiry_of(shown, seq=None, ts=time.time()), "recorded": False, "note": note}
async def _inquiry_events(run_id: str) -> list[tuple[int, float, dict[str, Any]]]:
async with SessionLocal() as session:
if await session.get(Run, run_id) is None:
raise InquiryRefused(404, "运行记录不存在", code=RUN_NOT_FOUND)
rows = await session.execute(
select(RunEvent.seq, RunEvent.ts, RunEvent.data)
.where(RunEvent.run_id == run_id, RunEvent.type == EventType.APPROVAL_INQUIRY).order_by(RunEvent.seq))
return [(seq, ts, data if isinstance(data, dict) else {}) for seq, ts, data in rows]
async def list_inquiries(run_id: str, *, report: str | None = None, unit: str | None = None) -> list[dict[str, Any]]:
"""这次运行的质询(封存前后的都算),按先后;给了 report / unit 只要问那份报告、那一句的。"""
return [inquiry_of(data, seq=seq, ts=ts) for seq, ts, data in await _inquiry_events(run_id)
if (not report or data.get("report") == report) and (not unit or data.get("unit") == unit)]
async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]:
"""质询引用过的工件:查询快照(遮罩列照证据面板遮好,SQL 里写着的遮罩列的值同样换掉)或回答文档(组装时就遮好了)。
{id, content, masked[, mask_note]}。不是这次运行的质询引用过的一律 404:工件 id 不能拿来取任意快照。"""
from app.api.evidence import SOURCE_GONE, _Masks
referenced = [data for _, _, data in await _inquiry_events(run_id)
if artifact_id in (data.get("inquiry_artifacts") or []) or data.get("answer_doc") == artifact_id]
if not referenced:
raise InquiryRefused(404, "这次运行的质询没有引用这件工件", code=ARTIFACT_NOT_FOUND)
try:
content = artifact_store.load(artifact_id)
except ValueError as e:
raise InquiryRefused(409, "工件与哈希不一致,疑似被修改,不予展示", code=ARTIFACT_TAMPERED) from e
if content is None:
raise InquiryRefused(404, "无法读取这件工件,可能已被删除", code=ARTIFACT_NOT_FOUND)
if not _query_shaped(content):
return {"id": artifact_id, "content": content, "masked": []}
masks = _Masks()
hidden, gone = await _hidden_columns(content, masks)
# 遮罩列的值按引用它的那几条质询各自查到的全部快照找:查询条件里写着的值,不一定在这一份快照的结果里
secrets = _Secrets()
for data in referenced:
for other in dict.fromkeys(str(a) for a in data.get("inquiry_artifacts") or []):
snapshot = content if other == artifact_id else _quiet_load(artifact_store.load, other)
if _query_shaped(snapshot):
secrets.add(snapshot, hidden if other == artifact_id else (await _hidden_columns(snapshot, masks))[0])
shown = masked_snapshot(content, hidden)
if isinstance(shown.get("sql"), str):
shown = {**shown, "sql": secrets.scrubber()(shown["sql"])}
columns = [str(c) for c in content.get("columns") or []]
out: dict[str, Any] = {"id": artifact_id, "content": shown, "masked": [c for c in columns if c.lower() in hidden]}
if gone:
out["mask_note"] = SOURCE_GONE.format(source="、".join(gone))
return out
__all__ = [
"BUSY_CONTINUE", "BUSY_DECIDE", "INQUIRY_BUSY", "INQUIRY_DISABLED", "INQUIRY_NOT_OPEN", "INQUIRY_SCHEMA",
"INQUIRY_SYSTEM", "NOT_RECORDED", "QUESTION_MAX", "QUOTE_MAX", "Ask", "InquiryRefused", "ask", "inquiry_artifact",
"inquiry_busy", "inquiry_of", "list_inquiries", "masked_snapshot", "parse_ask",
]