From 7df224106d6db63e0e3441fcfdc1cc47f0b0994a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:51:51 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=81=9C=E5=9C=A8=E5=AE=A1=E6=89=B9=E6=97=B6=EF=BC=8C=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E4=BA=BA=E9=92=88=E5=AF=B9=E6=8A=A5=E5=91=8A=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E5=8F=A5=E8=AF=9D=E6=8F=90=E9=97=AE=EF=BC=8C?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=20agent=20=E7=8E=B0=E6=9F=A5=E7=8E=B0?= =?UTF-8?q?=E7=AD=94=EF=BC=8C=E9=97=AE=E7=AD=94=E5=86=99=E8=BF=9B=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E3=80=81=E9=9A=8F=E8=BF=90=E8=A1=8C=E5=B0=81=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine/inquiry.py:开放条件(停在审批且问的是待处理的那张卡;或已封存、核对一致的探索运行,追加在封存之后; 正式运行封存后、设置关闭、还在执行一律 409);同一运行同时只答一条(进程内登记);回答交给 run_verify_agent (不传 emit,数据版本取运行记录,上限和模型按核查设置),任务里给被问的那句、照 judge.prepare 取的证据摘录 (遮罩照旧、原报告编号标明)、选中的原文和问题,数据源取这次运行用过的查询工具;回答由 compose_doc 组装成文档 落工件,数字可以引用自己的查询格子或报告已有的指标、运行输入,裸数字被标出;引用到遮罩列的格子渲染成「已遮罩」, 回答正文和查询清单里出现的遮罩列的值换成「已遮罩」;写入走 append_events,运行在回答期间变了就不写、照实说明, 触顶和失败照样记一条 - api/inquiry.py:POST 提问、GET 列表(按报告、句子筛选)、GET 质询工件(只认质询引用过的,遮罩照证据面板); 输入不合法回 {detail, field} - api/runs.py:质询正在回答时,审批回复(批准 / 驳回)和继续运行回 409 inquiry_busy,拦在记签批人之前 --- backend/app/api/inquiry.py | 58 ++- backend/app/api/runs.py | 12 + backend/app/engine/inquiry.py | 814 ++++++++++++++++++++++++++++++ backend/tests/test_inquiry_api.py | 768 ++++++++++++++++++++++++++++ 4 files changed, 1649 insertions(+), 3 deletions(-) create mode 100644 backend/app/engine/inquiry.py create mode 100644 backend/tests/test_inquiry_api.py diff --git a/backend/app/api/inquiry.py b/backend/app/api/inquiry.py index 829b228c..7c5b73ad 100644 --- a/backend/app/api/inquiry.py +++ b/backend/app/api/inquiry.py @@ -1,9 +1,61 @@ -"""审批质询:审批人在批准前就待审的报告提出质询。 +"""审批质询:审批人针对待审报告里的一句话提问,只读 agent 现查现答(规则和做法见 engine/inquiry.py)。 -目前只登记了路由前缀,接口由审批质询的工作包补上。路径挂在 /api/runs/{run_id}/… 下面。 +- POST /api/runs/{run_id}/inquiries:{report, unit, quote?, question, approval_id?} → {inquiry, recorded, note} +- GET /api/runs/{run_id}/inquiries?report=&unit=:{items: [Inquiry]},封存前后的都算,按先后 +- GET /api/runs/{run_id}/inquiries/artifacts/{artifact_id}:质询引用过的工件,查询快照的遮罩列照证据面板遮好 + +不受理:409 {detail, code}(inquiry_not_open / inquiry_busy / inquiry_disabled);输入不合法 422 {detail, field}, +field 指明是哪一项(前后端约定的形状,不是 FastAPI 自己那种校验错误数组,所以请求体自己解析)。 """ from __future__ import annotations -from fastapi import APIRouter +from typing import Any + +from fastapi import APIRouter, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.api.coded import CodedHTTPException +from app.api.runs import actor_of +from app.engine import inquiry +from app.engine.inquiry import InquiryRefused router = APIRouter(prefix="/api/runs", tags=["inquiry"]) + + +def _refused(e: InquiryRefused) -> Any: + """不受理 → HTTP 响应。422 照约定只回 {detail, field};其余带机读码的回 {detail, code}。""" + if e.status == 422: + return JSONResponse(status_code=422, content={"detail": e.message, "field": e.field}) + if e.code: + raise CodedHTTPException(e.status, e.message, e.code) + raise HTTPException(e.status, e.message) + + +@router.post("/{run_id}/inquiries", response_model=None) +async def ask_inquiry(run_id: str, request: Request, x_actor: str | None = Header(default=None)) -> Any: + """问一个问题。回答要等 agent 查完库(最长约为设置里的时长上限再加半分钟收尾),接口一直等到回答完才返回。""" + try: + body = await request.json() + except (ValueError, UnicodeDecodeError): + body = None + try: + return await inquiry.ask(run_id, inquiry.parse_ask(body), actor=actor_of(x_actor)) + except InquiryRefused as e: + return _refused(e) + + +@router.get("/{run_id}/inquiries", response_model=None) +async def list_inquiries(run_id: str, report: str | None = None, unit: str | None = None) -> Any: + try: + return {"items": await inquiry.list_inquiries(run_id, report=report or None, unit=unit or None)} + except InquiryRefused as e: + return _refused(e) + + +@router.get("/{run_id}/inquiries/artifacts/{artifact_id}", response_model=None) +async def inquiry_artifact(run_id: str, artifact_id: str) -> Any: + try: + return await inquiry.inquiry_artifact(run_id, artifact_id) + except InquiryRefused as e: + return _refused(e) + diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 00fd3637..fc6c44dd 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -15,6 +15,7 @@ from app.api.coded import CodedHTTPException from app.core.bus import bus from app.db.base import SessionLocal, get_session from app.db.models import Approval, Artifact, Run, RunEvent +from app.engine.inquiry import BUSY_CONTINUE, BUSY_DECIDE, INQUIRY_BUSY, inquiry_busy # 发起规则和发起前检查绑定的工具搬到了 engine/launch.py。这几个名字以前从这里导入,照旧可用 from app.engine.launch import ( # noqa: F401 TOOL_MISSING, LaunchRefused, launch_run, missing_tools, node_setting, tool_bindings, @@ -261,10 +262,18 @@ def _missing(e: KeyError) -> HTTPException: return HTTPException(404, str(e.args[0]) if e.args else "运行记录不存在") +def _not_while_inquiring(run_id: str, detail: str) -> None: + """这次运行正在回答审批质询(engine/inquiry.py 的进程内登记)时,审批回复和继续运行一律 409:问答要写进 + 运行停着的这一段,运行先往下走了,审批人看着回答再批的那份依据就落了空。""" + if inquiry_busy(run_id): + raise CodedHTTPException(409, detail, INQUIRY_BUSY) + + @router.post("/{run_id}/resume", response_model=RunOut) async def resume_run( run_id: str, payload: ResumeIn, x_actor: str | None = Header(default=None) ) -> Run: + _not_while_inquiring(run_id, BUSY_DECIDE) try: # approval_id 以前定义了却从没传下去——一次运行里有多条待审批时, # 引擎无从知道这次回复的是哪一条 @@ -296,6 +305,7 @@ async def continue_run( 一行状态判定,而 checkpoint 一直都在——改个模型 id 就得把前面两分钟的取数 重跑一遍,纯属白等。 """ + _not_while_inquiring(run_id, BUSY_CONTINUE) try: return await run_manager.continue_failed( run_id, graph=payload.graph, actor=actor_of(x_actor), @@ -593,6 +603,8 @@ async def decide( raise HTTPException(404, "审批记录不存在,所属的运行可能已被删除") if approval.status != "pending": raise HTTPException(409, "这条审批已处理过,请刷新页面查看最新状态") + # 在落签批人之前拦:拒掉的批准不该留下「谁试图批的」 + _not_while_inquiring(approval.run_id, BUSY_DECIDE) # 责任归属先落库再恢复执行——恢复失败也要留下"谁试图批的" approval.resolved_by = actor_of(x_actor) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py new file mode 100644 index 00000000..85cbceda --- /dev/null +++ b/backend/app/engine/inquiry.py @@ -0,0 +1,814 @@ +"""审批质询:审批人针对待审报告里的一句话提问,一个只读 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" + "- 查不到、数据里没有判断所需的信息,就照实说查不到,不要编造" +) +INQUIRY_TASK_PROMPT = ( + "报告「{report}」{where}\n\n" + "{evidence}" + "{refs}" + "可以查询的数据源:{sources}\n\n" + "审批人的问题:\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 = body.get("unit") + if unit is not None and (not isinstance(unit, str) or not unit.strip()): + 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 = body.get("approval_id") + if approval_id is not None and (not isinstance(approval_id, str) or not approval_id.strip()): + 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) + + +# -------------------------------------------------------------------------- +# 开放条件 +# -------------------------------------------------------------------------- + +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: + 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 + cards = [str(a) for a in (await session.execute( + select(Approval.id).where(Approval.run_id == run_id, Approval.status == "pending"))).scalars()] \ + if status == "interrupted" else [] + rows: Events = [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))] if status == "interrupted" else [] + 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 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")} + + +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]) -> set[str]: + """现在能查的:存在、没有停用(核查建工具时也只认这样的)。""" + wanted = list(dict.fromkeys(names)) + if not wanted: + return set() + async with SessionLocal() as session: + rows = await session.execute( + select(DataSource.name).where(DataSource.name.in_(wanted), DataSource.enabled.is_(True))) + return {str(name) for name in rows.scalars()} + + +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: 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)},已不存在或已停用,无法查询)" + return INQUIRY_TASK_PROMPT.format(report=op.title, where=where, evidence=evidence, refs=refs_text, + sources=sources, 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"(? 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 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=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 = "", None + source = (outcome.rationale or {}).get("source") if outcome.result is not None else None + if isinstance(source, str) and source.strip(): + answer = _compose(source, {**refs, **((outcome.rationale or {}).get("catalog") or {})}, op.run_id, hidden, + scrub) + 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 NO_ANSWER), + "inquiry_artifacts": [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", +] diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py new file mode 100644 index 00000000..b6adf89e --- /dev/null +++ b/backend/tests/test_inquiry_api.py @@ -0,0 +1,768 @@ +"""审批质询(engine/inquiry.py、api/inquiry.py):运行停在人工审批时,审批人针对报告里的一句话提问,一个只读 agent +现查现答;问答写进这次运行(approval.inquiry 事件),随运行结束一起封存,报告本身一个字不改。 + +- 停在审批时质询:事件 node_id 为空、数据按前后端约定的形状;质询的查询工件只在 inquiry_artifacts / queries 里, + 不在 query_artifact 下(证据接口不把它们当成报告的证据);批准、运行结束封存之后 verify 一致,封存范围包含这条事件; + 用量计入运行用量 +- 回答期间运行被批准(竞态):回答照样交回,recorded 为假、note 说明,事件不落库 +- 同一运行同时只答一条:第二条质询、审批回复(批准 / 驳回)、继续运行都回 409 inquiry_busy,拒掉的批准不留痕迹 +- 封存之后:探索运行可以追问(post_seal,落在封存之后,不计运行用量),正式运行 409;设置里关了 409; + 没停在那张审批卡上、还在执行、封存核对不通过都 409;输入不合法 422,指明是哪一项 +- 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出;遮罩列的格子渲染成「已遮罩」, + 回答正文、查询清单、事件里都不出现遮罩列的值 +- 触顶、失败照样记一条事件 +- 读取:列表按报告、句子筛选,按时间排;质询工件只认质询引用过的,遮罩照证据面板 +- 恢复后的重放不受影响:前面的节点不重复执行 + +模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额、手机号都是虚构的。 +""" +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import uuid +from dataclasses import dataclass, field + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage, ToolMessage +from sqlalchemy import delete, select + +from app.core import artifact_store +from app.data import introspect +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import Approval, DataSource, Run, RunEvent, Setting +from app.engine import inquiry as inquiry_mod +from app.engine import spend, verify_agent +from app.engine.evidence import iter_segments, iter_units +from app.engine.judge import SPEND_KEY as JUDGE_SPEND_KEY +from app.engine.nodes import report as report_mod +from app.engine.runner import run_manager, verify_manifest +from app.main import app +from app.providers.factory import ProviderNotConfigured +from app.providers.mock_model import MockChatModel + +PRICED = "claude-sonnet-5" +#: 各区域销售额:华东 1500(其中 300 是测试账号下的单)、华南 900、华北 400 +QUERY = "SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales GROUP BY region ORDER BY 销售额 DESC" +TEXT = "华东销售额 [[v:Q1.r0.销售额]],居各区域之首。[[see:Q1]]华南销售额 [[v:Q1.r1.销售额]]。[[see:Q1]]" +EAST, SOUTH = "华东销售额 1,500,居各区域之首。", "华南销售额 900。" +NO_TEST = ("SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales WHERE is_test = 0 GROUP BY region " + "ORDER BY 销售额 DESC") +NO_TEST_ANSWER = "仍居各区域之首:排除测试账号后华东为 [[v:Q1.r0.销售额]],华南为 [[v:Q1.r1.销售额]]。[[see:Q1]]" +NO_TEST_TEXT = "仍居各区域之首:排除测试账号后华东为 1,200,华南为 900。" +QUESTION = "排除测试账号会怎样?" +USAGE_KEYS = {"input_tokens", "output_tokens", "cost_usd", "calls", "unpriced_calls"} +ITEM_KEYS = {"seq", "at", "actor", "report", "unit", "quote", "question", "answer", "queries", "model", "usage", + "post_seal", "approval_id"} +EVENT_KEYS = {"question", "quote", "report", "unit", "approval_id", "actor", "answer_text", "answer_doc", "ok", + "limit", "error", "inquiry_artifacts", "queries", "model", "usage"} +QUERY_KEYS = {"alias", "artifact", "sql", "row_count", "truncated", "source"} +NOT_RECORDED = "运行状态已经变化,这次质询的问答没有写入运行记录" +BUSY_DECIDE = {"detail": "正在回答质询,回答完再批准或驳回", "code": "inquiry_busy"} + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key.in_( + [spend.SPEND_KEY, JUDGE_SPEND_KEY, "verify", "judge", "copilot"]))) + await session.commit() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +async def _source(tmp_path, *, options=None) -> tuple[str, int]: + path = tmp_path / f"{uuid.uuid4().hex[:8]}.db" + db = sqlite3.connect(path) + db.executescript( + "CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, is_test INTEGER);" + "INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0), (2, '华东', '13900000002', 300, 1)," + " (3, '华南', '13900000003', 900, 0), (4, '华北', '13900000004', 400, 0);") + db.commit() + db.close() + name = f"shop_{uuid.uuid4().hex[:8]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, + options=options or {}) + session.add(row) + await session.flush() + row.schema_cache = await introspect.introspect(row) + await session.commit() + return name, row.id + + +@pytest.fixture +async def shop(tmp_path): + """只读的手工源:四笔订单,华东两笔(其中一笔 300 是测试账号)、华南一笔 900、华北一笔 400。返回源名。""" + name, source_id = await _source(tmp_path) + yield name + await engines.invalidate(source_id) + + +@pytest.fixture +async def masked_shop(tmp_path): + """同样的四笔订单,买家手机号这一列设了遮罩。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}) + yield name + await engines.invalidate(source_id) + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def gated(source: str, *, gate: bool = True) -> dict: + """取数 → 报告 → 人工审批 → 成果。gate 为假时没有审批,一口气跑完、封存。""" + return chain( + node("start", "input"), + node("fetch", "tool", tool=f"db_query__{source}", args={"sql": QUERY}), + node("write", "report", "写周报", instructions="写周报"), + *([node("gate", "human", mode="approve", title="周报可以出具吗")] if gate else []), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ) + + +@dataclass +class Play: + """质询 agent 对某个问题的剧本:依次执行的查询,最后交的回答。""" + + queries: list[str] = field(default_factory=list) + answer: str = "查不到判断所需的数据。" + + +PLAYS = {"排除测试账号": Play([NO_TEST], NO_TEST_ANSWER)} + + +class Models: + """写作者和质询 agent 两份剧本。写作者回 writer;质询 agent 按任务里的问题认出剧本,照着查询、交回答。 + + before:质询 agent 每次调用模型之前先 await 它(测试在这里卡住回答、或者在回答途中批掉审批)。 + tasks 记下质询 agent 收到的任务原文,finals 记下交回答那次调用收到的消息,specs 记下每次要的模型。""" + + def __init__(self, monkeypatch, source: str | None = None, *, writer: str = TEXT, plays=None, + model_id: str = PRICED, broken: Exception | None = None, before=None): + self.tasks: list[str] = [] + self.finals: list[list] = [] + self.writes = 0 + self.specs: list = [] + plays = PLAYS if plays is None else plays + + def which(task: str) -> Play: + return next((p for k, p in plays.items() if k in task), Play()) + + def decide(model, messages): + system = str(messages[0].content) + if inquiry_mod.INQUIRY_SYSTEM in system: + task = str(messages[1].content) if len(messages) > 1 else "" + play = which(task) + if model.response_format: + self.finals.append(list(messages)) + return AIMessage(content=json.dumps({"rationale": play.answer}, ensure_ascii=False)) + if not any(isinstance(m, ToolMessage) for m in messages): + self.tasks.append(task) + done = sum(1 for m in messages if isinstance(m, ToolMessage)) + if done < len(play.queries): + call = {"name": f"db_query__{source}", "args": {"sql": play.queries[done]}, "id": f"iq-{done}"} + return AIMessage(content="查一下。", tool_calls=[call]) + return AIMessage(content="查完了。") + if report_mod.ROLE in system: + self.writes += 1 + return AIMessage(content=writer) + raise AssertionError(f"没想到的模型调用:{system[:60]}") + + monkeypatch.setattr(MockChatModel, "_decide", decide) + original = MockChatModel._agenerate + + async def held(chat, messages, stop=None, run_manager=None, **kwargs): + if before is not None and inquiry_mod.INQUIRY_SYSTEM in str(messages[0].content): + await before() + return await original(chat, messages, stop, run_manager, **kwargs) + + monkeypatch.setattr(MockChatModel, "_agenerate", held) + + async def verify_model(session, spec): + self.specs.append(spec) + if broken is not None: + raise broken + return MockChatModel(model_name=model_id), model_id + + monkeypatch.setattr(verify_agent, "get_chat_model", verify_model) + + +async def _row(run_id: str) -> Run: + async with SessionLocal() as session: + return await session.get(Run, run_id) + + +async def wait(run_id: str, statuses=("succeeded", "failed")) -> Run: + for _ in range(600): + row = await _row(run_id) + if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None): + await run_manager.wait_idle(run_id) + return await _row(run_id) + await asyncio.sleep(0.03) + raise AssertionError(f"运行 {run_id} 停在 {row.status}") + + +async def pause(source: str, *, run_class: str = "exploratory", graph: dict | None = None) -> Run: + run = await run_manager.start(graph=graph or gated(source), input_payload={}, run_class=run_class) + return await wait(run.id, ("interrupted",)) + + +async def finish(source: str, *, run_class: str = "exploratory", graph: dict | None = None) -> Run: + run = await run_manager.start(graph=graph or gated(source, gate=False), input_payload={}, run_class=run_class) + return await wait(run.id) + + +async def pending(run_id: str) -> list[Approval]: + async with SessionLocal() as session: + return list((await session.execute( + select(Approval).where(Approval.run_id == run_id, Approval.status == "pending"))).scalars()) + + +async def events(run_id: str, etype: str, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +async def report_doc(run_id: str) -> dict: + [checked] = await events(run_id, "report.checked", "write") + return artifact_store.load(checked.data["doc_artifact"]) + + +def unit_of(doc: dict, text: str) -> str: + md = doc["markdown"] + return next(u["id"] for _, u in iter_units(doc) if md[u["span"][0]:u["span"][1]].strip() == text) + + +async def ask(client, run_id: str, **body): + return await client.post(f"/api/runs/{run_id}/inquiries", json=body) + + +async def set_verify(value: dict) -> None: + async with SessionLocal() as session: + session.add(Setting(key="verify", value=value)) + await session.commit() + + +# -------------------------------------------------------------------------- +# 停在审批时质询 → 写进事件 → 批准 → 封存 +# -------------------------------------------------------------------------- + + +async def test_asking_while_waiting_is_recorded_and_sealed_with_the_run(monkeypatch, shop, client): + models = Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + doc = await report_doc(paused.id) + east = unit_of(doc, EAST) + + r = await ask(client, paused.id, report="write", unit=east, quote="居各区域之首", question=QUESTION, + approval_id=approval.id) + assert r.status_code == 200, r.text + body = r.json() + assert body.keys() == {"inquiry", "recorded", "note"} + assert body["recorded"] is True and body["note"] is None + item = body["inquiry"] + assert item.keys() == ITEM_KEYS + assert (item["report"], item["unit"], item["quote"], item["question"], item["approval_id"]) == ( + "write", east, "居各区域之首", QUESTION, approval.id) + assert item["post_seal"] is False and item["actor"] is None and isinstance(item["at"], str) + answer = item["answer"] + assert answer.keys() == {"text", "doc_artifact", "ok", "limit", "error"} + assert (answer["ok"], answer["limit"], answer["error"]) == (True, None, None) + assert answer["text"] == NO_TEST_TEXT, "回答先给结论,数字按查询结果换好" + answer_doc = artifact_store.load(answer["doc_artifact"]) + assert answer_doc["markdown"] == answer["text"] and answer_doc["violations"] == [] + cited = [s["cite"] for s in iter_segments(answer_doc) if s.get("cite")] + assert [(c["alias"], c["locator"]["row"], c["value"]) for c in cited] == [("Q1", 0, 1200), ("Q1", 1, 900)] + [query] = item["queries"] + assert query.keys() == QUERY_KEYS + assert (query["alias"], query["source"], query["sql"], query["row_count"], query["truncated"]) == ( + "Q1", shop, NO_TEST, 3, False) + assert artifact_store.load(query["artifact"])["rows"] == [["华东", 1200], ["华南", 900], ["华北", 400]] + assert item["model"] == PRICED + assert item["usage"].keys() == USAGE_KEYS and item["usage"]["calls"] >= 2 and item["usage"]["cost_usd"] > 0 + + # 交给质询 agent 的:被问的那句、它挂的证据摘录(原报告的编号标明)、选中的原文、问题、可以查的数据源 + [task] = models.tasks + assert EAST in task and "【原报告 Q1】" in task and "居各区域之首" in task and QUESTION in task and shop in task + + # 事件:node_id 为空,数据按约定;查询工件不在 query_artifact 下 + [event] = await events(paused.id, "approval.inquiry") + assert event.node_id is None and event.seq == item["seq"] + assert event.data.keys() == EVENT_KEYS + assert event.data["inquiry_artifacts"] == [query["artifact"]] + assert event.data["queries"] == item["queries"] + assert (event.data["answer_text"], event.data["answer_doc"]) == (answer["text"], answer["doc_artifact"]) + assert "query_artifact" not in event.data + assert (await _row(paused.id)).status == "interrupted" + + # 列表里是同一条 + listed = await client.get(f"/api/runs/{paused.id}/inquiries") + assert listed.status_code == 200 and listed.json() == {"items": [item]} + + # 批准 → 运行结束封存:这条事件在封存范围里,verify 一致 + r = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": True}) + assert r.status_code == 200, r.text + final = await wait(paused.id) + assert final.status == "succeeded", final.error + assert final.manifest_seq > event.seq + assert (await verify_manifest(paused.id))["ok"] is True + # 用量计进运行用量,明细在 inquiry 下 + assert final.usage["inquiry"]["calls"] == item["usage"]["calls"] + assert final.usage["inquiry"]["input_tokens"] == item["usage"]["input_tokens"] + # 证据接口不把质询的查询当成报告的证据 + from app.api.evidence import _sealed + + assert query["artifact"] not in (await _sealed(paused.id)).queries + # 报告本身一个字没改 + assert (await report_doc(paused.id)) == doc + # 改它一个字,封存核对就对不上:它确实在封存范围里 + async with SessionLocal() as session: + stored = await session.get(RunEvent, event.id) + stored.data = {**stored.data, "answer_text": "改过的回答"} + await session.commit() + assert (await verify_manifest(paused.id))["ok"] is False + + +async def test_an_approval_that_lands_while_answering_leaves_no_record(monkeypatch, shop, client): + """回答途中运行被批准了(另一个进程、或者抢在登记之前的批准):回答照样交回给提问的人,但不写进运行记录。""" + seen: dict = {} + + async def approve_meanwhile(): + if not seen.get("done"): + seen["done"] = True + await run_manager.resume(seen["run"], {"approved": True}, approval_id=seen["approval"]) + + Models(monkeypatch, shop, before=approve_meanwhile) + paused = await pause(shop) + [approval] = await pending(paused.id) + seen.update(run=paused.id, approval=approval.id) + + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + body = r.json() + assert body["recorded"] is False and body["note"] == NOT_RECORDED + assert body["inquiry"]["seq"] is None and body["inquiry"]["answer"]["ok"] is True + assert body["inquiry"]["answer"]["text"] == NO_TEST_TEXT + + final = await wait(paused.id) + assert final.status == "succeeded", final.error + assert await events(paused.id, "approval.inquiry") == [] + assert (await verify_manifest(paused.id))["ok"] is True + assert "inquiry" not in final.usage + assert (await client.get(f"/api/runs/{paused.id}/inquiries")).json() == {"items": []} + + +async def test_while_answering_approving_resuming_and_continuing_are_refused(monkeypatch, shop, client): + entered, release = asyncio.Event(), asyncio.Event() + + async def hold(): + entered.set() + await release.wait() + + Models(monkeypatch, shop, before=hold) + paused = await pause(shop) + [approval] = await pending(paused.id) + asking = asyncio.create_task(ask(client, paused.id, report="write", unit=None, question=QUESTION, + approval_id=approval.id)) + try: + await asyncio.wait_for(entered.wait(), 10) + refused = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": False, "note": "先别批"}, + headers={"X-Actor": "reviewer"}) + assert refused.status_code == 409 and refused.json() == BUSY_DECIDE + refused = await client.post(f"/api/runs/{paused.id}/resume", + json={"response": {"approved": True}, "approval_id": approval.id}) + assert refused.status_code == 409 and refused.json() == BUSY_DECIDE + refused = await client.post(f"/api/runs/{paused.id}/continue", json={}) + assert refused.status_code == 409 and refused.json()["code"] == "inquiry_busy" + second = await ask(client, paused.id, report="write", unit=None, question="华南为什么低?", + approval_id=approval.id) + assert second.status_code == 409 and second.json()["code"] == "inquiry_busy" + # 拒掉的批准不留痕迹:卡仍待审批,没记下签批人 + async with SessionLocal() as session: + card = await session.get(Approval, approval.id) + assert card.status == "pending" and card.resolved_by is None + assert (await _row(paused.id)).status == "interrupted" + finally: + release.set() + answered = await asyncio.wait_for(asking, 30) + assert answered.status_code == 200 and answered.json()["recorded"] is True + + # 回答完了,照常批准 + r = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": True}) + assert r.status_code == 200, r.text + assert (await wait(paused.id)).status == "succeeded" + + +# -------------------------------------------------------------------------- +# 封存之后:探索运行可以追问,正式运行不行 +# -------------------------------------------------------------------------- + + +async def test_after_the_seal_only_exploratory_runs_take_inquiries(monkeypatch, shop, client): + Models(monkeypatch, shop) + done = await finish(shop) + assert done.status == "succeeded", done.error + east = unit_of(await report_doc(done.id), EAST) + + r = await ask(client, done.id, report="write", unit=east, question=QUESTION) + assert r.status_code == 200, r.text + body = r.json() + assert body["recorded"] is True and body["note"] is None + item = body["inquiry"] + assert item["post_seal"] is True and item["approval_id"] is None and item["answer"]["text"] == NO_TEST_TEXT + [event] = await events(done.id, "approval.inquiry") + assert event.data["post_seal"] is True and event.seq > done.manifest_seq + after = await _row(done.id) + assert (after.manifest_seq, after.manifest_hash) == (done.manifest_seq, done.manifest_hash) + assert (await verify_manifest(done.id))["ok"] is True + assert "inquiry" not in after.usage, "封存之后的问答是批注,不计进运行用量" + assert (await client.get(f"/api/runs/{done.id}/inquiries")).json()["items"] == [item] + + # 带着审批卡来问:那张卡已经不在等了 + r = await ask(client, done.id, report="write", unit=east, question=QUESTION, approval_id="no-such-card") + assert r.status_code == 409 and r.json()["code"] == "inquiry_not_open" + + formal = await finish(shop, run_class="formal") + assert formal.status == "succeeded", formal.error + r = await ask(client, formal.id, report="write", unit=unit_of(await report_doc(formal.id), EAST), + question=QUESTION) + assert r.status_code == 409 and r.json()["code"] == "inquiry_not_open" + assert "正式运行" in r.json()["detail"] + assert await events(formal.id, "approval.inquiry") == [] + + +async def test_a_formal_run_waiting_for_approval_takes_inquiries(monkeypatch, shop, client): + """正式运行停在审批时可以质询:问答随运行一起封存,正式运行同样可以复核。""" + Models(monkeypatch, shop) + paused = await pause(shop, run_class="formal") + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200 and r.json()["recorded"] is True + await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": True}) + final = await wait(paused.id) + [event] = await events(paused.id, "approval.inquiry") + assert final.manifest_seq > event.seq and (await verify_manifest(paused.id))["ok"] is True + + +async def test_switched_off_in_settings_it_is_refused(monkeypatch, shop, client): + await set_verify({"inquiry": {"enabled": False}}) + models = Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 409 and r.json()["code"] == "inquiry_disabled" + assert "「审批质询 · 启用」" in r.json()["detail"] + assert models.tasks == [] and models.specs == [] + assert await events(paused.id, "approval.inquiry") == [] + + +async def test_the_run_must_be_waiting_on_that_approval(monkeypatch, shop, client): + models = Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION) + assert r.status_code == 422 and r.json() == {"detail": r.json()["detail"], "field": "approval_id"} + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id="no-such-card") + assert r.status_code == 409 and r.json()["code"] == "inquiry_not_open" + + # 还在执行的运行 + async with SessionLocal() as session: + running = Run(workflow_name="还在执行", status="running", graph={}, input={}) + session.add(running) + await session.commit() + running_id = running.id + r = await ask(client, running_id, report="write", unit=None, question=QUESTION) + assert r.status_code == 409 and r.json()["code"] == "inquiry_not_open" + + r = await ask(client, "no-such-run", report="write", unit=None, question=QUESTION) + assert r.status_code == 404 + + # 封存核对不通过的探索运行:报告和证据已不可信,不再质询 + done = await finish(shop) + async with SessionLocal() as session: + first = (await session.execute(select(RunEvent).where(RunEvent.run_id == done.id) + .order_by(RunEvent.seq).limit(1))).scalar_one() + first.data = {**(first.data or {}), "tampered": True} + await session.commit() + r = await ask(client, done.id, report="write", unit=None, question=QUESTION) + assert r.status_code == 409 and r.json()["code"] == "inquiry_not_open" + assert models.tasks == [], "开放条件不满足时不问模型" + + +@pytest.mark.parametrize("body, field_name", [ + ({"unit": None, "question": QUESTION}, "report"), + ({"report": 3, "question": QUESTION}, "report"), + ({"report": "write", "unit": None}, "question"), + ({"report": "write", "unit": None, "question": " "}, "question"), + ({"report": "write", "unit": None, "question": "问" * 1001}, "question"), + ({"report": "write", "unit": None, "question": QUESTION, "quote": "引" * 501}, "quote"), + ({"report": "write", "unit": None, "question": QUESTION, "quote": 5}, "quote"), + ({"report": "write", "unit": 12, "question": QUESTION}, "unit"), + ({"report": "write", "unit": None, "question": QUESTION, "approval_id": 7}, "approval_id"), +]) +async def test_bad_requests_name_the_field(monkeypatch, shop, client, body, field_name): + Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, **{"approval_id": approval.id, **body}) + assert r.status_code == 422, r.text + assert r.json().keys() == {"detail", "field"} and r.json()["field"] == field_name + assert r.json()["detail"] and "[" not in r.json()["detail"] + + +async def test_the_report_and_the_sentence_must_exist(monkeypatch, shop, client): + models = Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="no-such-report", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 422 and r.json()["field"] == "report" + r = await ask(client, paused.id, report="write", unit="u999", question=QUESTION, approval_id=approval.id) + assert r.status_code == 422 and r.json()["field"] == "unit" + r = await client.post(f"/api/runs/{paused.id}/inquiries", content=b"[1, 2]", + headers={"content-type": "application/json"}) + assert r.status_code == 422 and r.json().keys() == {"detail", "field"} + r = await client.post(f"/api/runs/{paused.id}/inquiries", content=b"{not json", + headers={"content-type": "application/json"}) + assert r.status_code == 422 and r.json().keys() == {"detail", "field"} + assert models.tasks == [] + + +# -------------------------------------------------------------------------- +# 回答:出处、裸数字、遮罩 +# -------------------------------------------------------------------------- + +TOP_BUYER = "SELECT buyer_phone, amount AS 金额 FROM sales ORDER BY amount DESC LIMIT 1" +BUYER_ORDERS = "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001'" + + +async def test_bare_numbers_are_flagged_and_masked_values_never_show(monkeypatch, masked_shop, client): + plays = {"买得最多": Play([TOP_BUYER, BUYER_ORDERS], + "金额最高的一笔来自 [[v:Q1.r0.buyer_phone]](手机号 13900000001),金额 [[v:Q1.r0.金额]];" + "这位客户共 [[v:Q2.r0.笔数]] 笔订单,另有 3 笔小额订单。")} + Models(monkeypatch, masked_shop, plays=plays) + paused = await pause(masked_shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="买得最多的是哪位客户?", + approval_id=approval.id) + assert r.status_code == 200, r.text + assert "1390000000" not in r.text, "回答、查询清单里都不能出现遮罩列的值" + item = r.json()["inquiry"] + answer = item["answer"] + assert answer["ok"] is True + assert answer["text"] == "金额最高的一笔来自 已遮罩(手机号 已遮罩),金额 1,200;这位客户共 1 笔订单,另有 3 笔小额订单。" + doc = artifact_store.load(answer["doc_artifact"]) + assert "1390000000" not in json.dumps(doc, ensure_ascii=False) + # 裸数字被标出来:界面在它下面画「无出处」 + assert [s["text"] for s in iter_segments(doc) if s.get("issue") == "uncited_number"] == ["3"] + # 遮罩列的格子:出处照记,值是「已遮罩」 + masked = [s["cite"] for s in iter_segments(doc) if (s.get("cite") or {}).get("locator", {}).get("column") + == "buyer_phone"] + assert [(c["alias"], c["status"], c["value"]) for c in masked] == [("Q1", "resolved", "已遮罩")] + first, second = item["queries"] + assert first["sql"] == TOP_BUYER + assert second["sql"] == "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩'" + [event] = await events(paused.id, "approval.inquiry") + assert "1390000000" not in json.dumps(event.data, ensure_ascii=False) + + # 质询工件:遮罩照证据面板,查询里写着的遮罩列的值同样换掉 + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{first['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["已遮罩", 1200]] and got.json()["masked"] == ["buyer_phone"] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{second['artifact']}") + assert got.status_code == 200 and "1390000000" not in got.text + assert got.json()["content"]["rows"] == [[1]] + + +async def test_the_answer_may_cite_the_reports_own_metrics(monkeypatch, client): + """报告里已有的出处(口径卡指标)回答里可以直接引用;这次运行没用到查询工具,agent 只能依据证据作答。""" + metrics = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}] + graph = chain( + node("start", "input"), + node("calc", "transform", mode="expression", expression="{'gmv': 45678.5}", assign_to="kpi"), + node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=metrics), + node("write", "report", instructions="写周报"), + node("gate", "human", mode="approve", title="确认"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ) + plays = {"销售额": Play([], "按口径卡,本周销售额为 [[m:gmv]],这是本周的口径值。[[see:m:gmv]]")} + models = Models(monkeypatch, None, writer="本周销售额 [[m:gmv]]。[[see:m:gmv]]", plays=plays) + paused = await pause("", graph=graph) + [approval] = await pending(paused.id) + doc = await report_doc(paused.id) + r = await ask(client, paused.id, report="write", unit=unit_of(doc, "本周销售额 45,678.5元。"), + question="销售额是怎么算的?", approval_id=approval.id) + assert r.status_code == 200, r.text + answer = r.json()["inquiry"]["answer"] + assert answer["text"] == "按口径卡,本周销售额为 45,678.5元,这是本周的口径值。" + assert artifact_store.load(answer["doc_artifact"])["violations"] == [] + [task] = models.tasks + assert "[[m:gmv]]" in task and "销售额 = 45,678.5元" in task + assert r.json()["inquiry"]["queries"] == [] + + +async def test_a_cell_in_a_table_comes_with_the_whole_table(monkeypatch, shop, client): + """问的是表格里的一格:一格的值离了行列说不清是什么,任务里另带整张表(表头在前)和这一格的出处摘录。""" + models = Models(monkeypatch, shop, writer="各区域销售额如下。\n\n[[table:Q1]]") + paused = await pause(shop) + [approval] = await pending(paused.id) + doc = await report_doc(paused.id) + cell = unit_of(doc, "1,500") + r = await ask(client, paused.id, report="write", unit=cell, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + [task] = models.tasks + assert "「1,500」" in task and "区域 | 销售额\n华东 | 1,500\n华南 | 900\n华北 | 400" in task + # 摘录照裁判的办法:被引用的列排在前面,只给被引用的那一行 + assert "【原报告 Q1】" in task and "列:销售额 | 区域\nr0:1,500 | 华东\n" in task + + +def test_masked_values_are_matched_as_whole_values(): + """遮罩列的值按整值认:更长的数里的一截、千分位写法里的一截不换;一个字的值不换(换了会把不相干的词弄坏); + 数按原样和千分位两种写法认。""" + secrets = inquiry_mod._Secrets() + snapshot = {"columns": ["phone", "name", "gender", "code", "salary", "region"], + "rows": [["13900000001", "张伟", "男", "390", 12000, "华东"]]} + secrets.add(snapshot, {"phone", "name", "gender", "code", "salary"}) + scrub = secrets.scrubber() + assert scrub("客户 13900000001、139000000012、1,390 元、390 笔、张伟买了男装,月薪 12,000,另 12000.5") == ( + "客户 已遮罩、139000000012、1,390 元、已遮罩 笔、已遮罩买了男装,月薪 已遮罩,另 12000.5") + assert scrub("华东") == "华东", "没遮的列不换" + assert inquiry_mod._Secrets().scrubber()("13900000001") == "13900000001" + + +# -------------------------------------------------------------------------- +# 触顶与失败 +# -------------------------------------------------------------------------- + + +async def test_hitting_the_query_limit_is_recorded(monkeypatch, shop, client): + await set_verify({"inquiry": {"max_queries": 1}}) + plays = {"排除测试账号": Play([NO_TEST, QUERY], NO_TEST_ANSWER)} + models = Models(monkeypatch, shop, plays=plays) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["limit"]) == (True, "max_queries"), "触顶照样按查到的结果收尾" + # 没指明句子:任务里给报告正文,不给哪一句的摘录 + [task] = models.tasks + assert "审批人没有指明是哪一句" in task and f"报告正文:\n{EAST}{SOUTH}" in task and "原报告" not in task + assert [q["sql"] for q in item["queries"]] == [NO_TEST] + [event] = await events(paused.id, "approval.inquiry") + assert (event.data["ok"], event.data["limit"]) == (True, "max_queries") + + +async def test_an_unavailable_model_is_recorded_as_a_failure(monkeypatch, shop, client): + Models(monkeypatch, shop, broken=ProviderNotConfigured("没有配置模型接入")) + # 写作者照常用演示模型:只有质询 agent 的模型用不了 + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + body = r.json() + assert body["recorded"] is True + answer = body["inquiry"]["answer"] + assert answer["ok"] is False and answer["text"] == "" and answer["doc_artifact"] is None + assert answer["error"] and "模型" in answer["error"] + assert body["inquiry"]["queries"] == [] and body["inquiry"]["usage"]["calls"] == 0 + [event] = await events(paused.id, "approval.inquiry") + assert event.data["ok"] is False and event.data["error"] == answer["error"] + + +async def test_the_daily_cap_is_recorded_as_a_limit(monkeypatch, shop, client): + async with SessionLocal() as session: + session.add(Setting(key=spend.SPEND_KEY, value={"date": spend._today(), "usd": 5.0, "calls": 3})) + await session.commit() + models = Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + answer = r.json()["inquiry"]["answer"] + assert (answer["ok"], answer["limit"]) == (False, "daily_max_usd") and answer["error"] + assert models.finals == [] + [event] = await events(paused.id, "approval.inquiry") + assert event.data["limit"] == "daily_max_usd" + + +# -------------------------------------------------------------------------- +# 读取 +# -------------------------------------------------------------------------- + + +async def test_listing_filters_by_report_and_sentence_and_artifacts_are_whitelisted(monkeypatch, shop, client): + plays = {"排除测试账号": Play([NO_TEST], NO_TEST_ANSWER), "华南为什么": Play([], "查不到判断所需的数据。")} + Models(monkeypatch, shop, plays=plays) + paused = await pause(shop) + [approval] = await pending(paused.id) + doc = await report_doc(paused.id) + east, south = unit_of(doc, EAST), unit_of(doc, SOUTH) + first = (await ask(client, paused.id, report="write", unit=east, question=QUESTION, + approval_id=approval.id)).json()["inquiry"] + second = (await ask(client, paused.id, report="write", unit=south, question="华南为什么低?", + approval_id=approval.id)).json()["inquiry"] + assert first["seq"] < second["seq"] + + listed = await client.get(f"/api/runs/{paused.id}/inquiries") + assert listed.json() == {"items": [first, second]} + assert (await client.get(f"/api/runs/{paused.id}/inquiries", params={"report": "write", "unit": south}) + ).json() == {"items": [second]} + assert (await client.get(f"/api/runs/{paused.id}/inquiries", params={"report": "other"})).json() == {"items": []} + assert (await client.get("/api/runs/no-such-run/inquiries")).status_code == 404 + + [query] = first["queries"] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["id"] == query["artifact"] and got.json()["masked"] == [] + assert got.json()["content"]["rows"] == [["华东", 1200], ["华南", 900], ["华北", 400]] + # 回答文档也是质询引用过的工件 + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{first['answer']['doc_artifact']}") + assert got.status_code == 200 and got.json()["content"]["markdown"] == NO_TEST_TEXT + # 报告自己的查询快照不是质询引用过的 + [fetched] = [e for e in await events(paused.id, "tool.end", "fetch")] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{fetched.data['query_artifact']}") + assert got.status_code == 404 + assert (await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/nothing")).status_code == 404 + + +# -------------------------------------------------------------------------- +# 重放 +# -------------------------------------------------------------------------- + + +async def test_resuming_after_an_inquiry_does_not_repeat_the_nodes(monkeypatch, shop, client): + """质询不发 tool.start(_stale_replay 只看它),批准后从断点继续:取数、报告都不再执行一遍。""" + models = Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.json()["recorded"] is True + assert [e for e in await events(paused.id, "tool.start") if e.node_id is None] == [] + await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": True}) + final = await wait(paused.id) + assert final.status == "succeeded", final.error + for node_id in ("fetch", "write"): + assert len(await events(paused.id, "node.started", node_id)) == 1, f"{node_id} 又执行了一遍" + assert len(await events(paused.id, "tool.start", "fetch")) == 1 + assert models.writes == 1 From e6dfe18c8382311ff71983b884ddbf3f6c0d7b45 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:54:54 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E8=87=AA=E6=9F=A5=E4=BF=AE=E6=AD=A3=EF=BC=9A=E5=B0=81=E5=AD=98?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E8=BF=BD=E9=97=AE=E4=B8=8D=E4=B8=80=E5=AE=9A?= =?UTF-8?q?=E6=9D=A5=E8=87=AA=E5=AE=A1=E6=89=B9=E4=BA=BA=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E6=94=B9=E6=88=90=E4=B8=AD=E6=80=A7=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E6=8F=90=E9=97=AE=E4=BA=BA=E3=80=8D=EF=BC=9B=E5=91=8A?= =?UTF-8?q?=E8=AF=89=20agent=20=E5=93=AA=E5=87=A0=E5=88=97=E8=AE=BE?= =?UTF-8?q?=E4=BA=86=E9=81=AE=E7=BD=A9=EF=BC=9B=E7=BB=84=E8=A3=85=E5=9B=9E?= =?UTF-8?q?=E7=AD=94=E5=87=BA=E9=94=99=E7=85=A7=E6=A0=B7=E8=AE=B0=E4=B8=80?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 任务里列出可查询的数据源现在设了遮罩的列,让 agent 不写出原值(系统的替换仍兜底) - 回答原文没能组装成文档时不交出原文(引用没换成值、遮罩没做),记成失败,查过的和用量照记 - 第二次读状态时运行已被继续:不按旧封存追加,回 409 - 不指明句子、不在审批卡上问时,unit / approval_id 写空串等同于 null - 只列有值的报告指标和运行输入:没有值的引用了也解析不了 --- backend/app/engine/inquiry.py | 90 +++++++++++++++++++------------ backend/tests/test_inquiry_api.py | 33 ++++++++++-- 2 files changed, 84 insertions(+), 39 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 85cbceda..7c30c674 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -93,11 +93,12 @@ _TERMINAL = ("succeeded", "failed", "cancelled") _WORD = re.compile(r"[0-9A-Za-z_]") INQUIRY_SYSTEM = ( - "你在帮审批人核对一份待批准的报告:审批人针对报告里的一句话提了问题,你查库作答。你不写报告,也不改报告。\n" + "你在帮人核对一份报告:提问人针对报告里的一句话提了问题,你查库作答。你不写报告,也不改报告。\n" "- 问题是「如果……会怎样」(排除测试账号、换个时间范围、只看某个区域)时,写只读查询把它算出来,不要凭印象推测\n" "- 回答直截了当:第一句直接给结论(是还是不是、是多少、差多少),再用一两句说依据;不要复述问题,不要客套\n" "- 回答里的每个数字都写成引用:你自己查询结果里的一格写 [[v:Qn.rX.列名]],报告里已有的指标、运行输入照任务里列出的" "写法引用;不要直接写数字,没有出处的数字会被标出来\n" + "- 数据源设了遮罩的列(手机号、姓名这类)不要在回答里写出原值,需要指明时说「该客户」「这一笔」\n" "- 查不到、数据里没有判断所需的信息,就照实说查不到,不要编造" ) INQUIRY_TASK_PROMPT = ( @@ -105,7 +106,8 @@ INQUIRY_TASK_PROMPT = ( "{evidence}" "{refs}" "可以查询的数据源:{sources}\n\n" - "审批人的问题:\n{question}\n\n" + "{masked}" + "提问人的问题:\n{question}\n\n" "按上面的做法作答,回答写在 rationale 里。" ) #: 交给核查 agent 的结果结构:只有回答一项(verify_agent 的理由字段,沿用这里写的说明) @@ -212,7 +214,8 @@ def parse_ask(body: Any) -> Ask: report = body.get("report") if not isinstance(report, str) or not report.strip(): raise _invalid("请指明质询的是哪份报告(报告撰写节点的 ID)", "report") - unit = body.get("unit") + # 不指明句子、不是在审批卡上问的,写 null 和写空串是一个意思 + unit = body.get("unit") or None if unit is not None and (not isinstance(unit, str) or not unit.strip()): raise _invalid("句子编号需要是文字,例如「u12」;不指明句子时留空", "unit") quote = body.get("quote") @@ -227,7 +230,7 @@ def parse_ask(body: Any) -> Ask: question = question.strip() if len(question) > QUESTION_MAX: raise _invalid(f"问题最长 {QUESTION_MAX} 字,当前为 {len(question)} 字", "question") - approval_id = body.get("approval_id") + approval_id = body.get("approval_id") or None if approval_id is not None and (not isinstance(approval_id, str) or not approval_id.strip()): raise _invalid("审批卡的 ID 需要是文字;不是在审批卡上提问时留空", "approval_id") return Ask(report=report.strip(), unit=unit.strip() if unit else None, quote=quote, question=question, @@ -260,17 +263,20 @@ class _Opening: 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 - cards = [str(a) for a in (await session.execute( - select(Approval.id).where(Approval.run_id == run_id, Approval.status == "pending"))).scalars()] \ - if status == "interrupted" else [] - rows: Events = [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))] if status == "interrupted" else [] + 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: @@ -291,6 +297,9 @@ async def _open(run_id: str, ask: Ask) -> _Opening: 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: @@ -398,9 +407,10 @@ def _excerpts(doc: dict[str, Any], catalog: dict[str, Any], cand: judging.Candid 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")} + """报告目录里回答可以直接引用的出处:有值的口径卡指标(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]: @@ -416,15 +426,17 @@ def _run_sources(events: Events) -> list[str]: return out -async def _usable_sources(names: Iterable[str]) -> set[str]: - """现在能查的:存在、没有停用(核查建工具时也只认这样的)。""" +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 set() + return {} async with SessionLocal() as session: - rows = await session.execute( - select(DataSource.name).where(DataSource.name.in_(wanted), DataSource.enabled.is_(True))) - return {str(name) for name in rows.scalars()} + 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: @@ -432,16 +444,16 @@ def _quoted(names: Iterable[str]) -> str: def _task(op: _Opening, ask: Ask, target: _Target | None, excerpts: list[str], refs: dict[str, dict[str, Any]], - live: list[str], lost: list[str]) -> str: + 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 = ",审批人没有指明是哪一句" + where = ",提问人没有指明是哪一句" if ask.quote: - where += f"\n\n审批人选中的原文:\n「{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 "") @@ -457,8 +469,10 @@ def _task(op: _Opening, ask: Ask, target: _Target | None, excerpts: list[str], r 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, question=ask.question) + sources=sources, masked=masked, question=ask.question) # -------------------------------------------------------------------------- @@ -598,7 +612,8 @@ def _limit(outcome: VerifyOutcome) -> str | None: async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict[str, Any]) -> dict[str, Any]: - """问 agent,组装回答文档,交回事件数据里回答的那几项。永远不抛:模型用不了、触顶都落在 ok / limit / error 里。""" + """问 agent,组装回答文档,交回事件数据里回答的那几项。模型用不了、触顶、组装出错都落在 ok / limit / error 里: + 钱已经花了,回答没成也照样记一条。""" from app.api.evidence import _Masks from app.api.settings import verify_model_spec @@ -609,12 +624,12 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict refs = _report_refs(catalog) wanted = _run_sources(op.events) usable = await _usable_sources(wanted) - live, lost = [s for s in wanted if s in usable], [s for s in wanted if s not in usable] + 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=live, + 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) @@ -634,21 +649,26 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict secrets.add(snapshot, (await _hidden_columns(snapshot, masks))[0]) scrub = secrets.scrubber() - text, doc_id = "", None + 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(): - answer = _compose(source, {**refs, **((outcome.rationale or {}).get("catalog") or {})}, op.run_id, hidden, - scrub) - if lost: - answer = with_note(answer, f"注:这次运行还用到了数据源{_quoted(lost)},已不存在或已停用,未能查询," - "以上回答不含其中的数据。") - text = str(answer.get("markdown") or "") - doc_id = await _store(answer, op, ask) + 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 NO_ANSWER), + "error": None if ok else (outcome.error or failed or NO_ANSWER), "inquiry_artifacts": [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}, } diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index b6adf89e..98bf870d 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -393,7 +393,8 @@ async def test_while_answering_approving_resuming_and_continuing_are_refused(mon json={"response": {"approved": True}, "approval_id": approval.id}) assert refused.status_code == 409 and refused.json() == BUSY_DECIDE refused = await client.post(f"/api/runs/{paused.id}/continue", json={}) - assert refused.status_code == 409 and refused.json()["code"] == "inquiry_busy" + assert refused.status_code == 409 + assert refused.json() == {"detail": "正在回答质询,回答完再继续运行", "code": "inquiry_busy"} second = await ask(client, paused.id, report="write", unit=None, question="华南为什么低?", approval_id=approval.id) assert second.status_code == 409 and second.json()["code"] == "inquiry_busy" @@ -560,12 +561,15 @@ async def test_bare_numbers_are_flagged_and_masked_values_never_show(monkeypatch plays = {"买得最多": Play([TOP_BUYER, BUYER_ORDERS], "金额最高的一笔来自 [[v:Q1.r0.buyer_phone]](手机号 13900000001),金额 [[v:Q1.r0.金额]];" "这位客户共 [[v:Q2.r0.笔数]] 笔订单,另有 3 笔小额订单。")} - Models(monkeypatch, masked_shop, plays=plays) + models = Models(monkeypatch, masked_shop, plays=plays) paused = await pause(masked_shop) [approval] = await pending(paused.id) - r = await ask(client, paused.id, report="write", unit=None, question="买得最多的是哪位客户?", + r = await ask(client, paused.id, report="write", unit="", question="买得最多的是哪位客户?", approval_id=approval.id) assert r.status_code == 200, r.text + assert r.json()["inquiry"]["unit"] is None, "不指明句子时写空串和写 null 一样" + [task] = models.tasks + assert f"设了遮罩的列(回答里不要写出这些列的值):「{masked_shop}」的 buyer_phone" in task assert "1390000000" not in r.text, "回答、查询清单里都不能出现遮罩列的值" item = r.json()["inquiry"] answer = item["answer"] @@ -667,7 +671,7 @@ async def test_hitting_the_query_limit_is_recorded(monkeypatch, shop, client): assert (item["answer"]["ok"], item["answer"]["limit"]) == (True, "max_queries"), "触顶照样按查到的结果收尾" # 没指明句子:任务里给报告正文,不给哪一句的摘录 [task] = models.tasks - assert "审批人没有指明是哪一句" in task and f"报告正文:\n{EAST}{SOUTH}" in task and "原报告" not in task + assert "提问人没有指明是哪一句" in task and f"报告正文:\n{EAST}{SOUTH}" in task and "原报告" not in task assert [q["sql"] for q in item["queries"]] == [NO_TEST] [event] = await events(paused.id, "approval.inquiry") assert (event.data["ok"], event.data["limit"]) == (True, "max_queries") @@ -690,6 +694,27 @@ async def test_an_unavailable_model_is_recorded_as_a_failure(monkeypatch, shop, assert event.data["ok"] is False and event.data["error"] == answer["error"] +async def test_an_answer_that_cannot_be_put_together_is_recorded_as_a_failure(monkeypatch, shop, client): + """回答原文没能组装成文档:原文里的引用没换成值、遮罩也没做,不交出去;查过的、花掉的照记。""" + Models(monkeypatch, shop) + + def broken(*args, **kwargs): + raise RuntimeError("文档结构异常") + + monkeypatch.setattr(inquiry_mod, "compose_doc", broken) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"], item["answer"]["doc_artifact"]) == (False, "", None) + assert item["answer"]["error"].startswith("整理回答时出错") + assert [q["sql"] for q in item["queries"]] == [NO_TEST] and item["usage"]["calls"] >= 2 + assert r.json()["recorded"] is True + [event] = await events(paused.id, "approval.inquiry") + assert event.data["ok"] is False and event.data["usage"] == item["usage"] + + async def test_the_daily_cap_is_recorded_as_a_limit(monkeypatch, shop, client): async with SessionLocal() as session: session.add(Setting(key=spend.SPEND_KEY, value={"date": spend._today(), "usd": 5.0, "calls": 3})) From f62d6bf9d6e28083eeb16f1434192eb0274efe3b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:57:57 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E7=BD=B2=E5=90=8D=E7=85=A7=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=A4=B4=E8=AE=B0=EF=BC=9B=E5=86=99=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E6=A0=B8=E6=9F=A5=E8=AE=BE=E7=BD=AE=E5=92=8C=E8=8A=B1=E8=B4=B9?= =?UTF-8?q?=E8=B4=A6=E5=9C=A8=E6=B5=8B=E8=AF=95=E7=BB=93=E6=9D=9F=E6=97=B6?= =?UTF-8?q?=E6=B8=85=E6=8E=89=EF=BC=8C=E4=B8=8D=E7=95=99=E7=BB=99=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E8=BF=9B=E7=A8=8B=E9=87=8C=E5=90=8E=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_inquiry_api.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 98bf870d..811dda00 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -64,15 +64,21 @@ NOT_RECORDED = "运行状态已经变化,这次质询的问答没有写入运 BUSY_DECIDE = {"detail": "正在回答质询,回答完再批准或驳回", "code": "inquiry_busy"} -@pytest.fixture(autouse=True) -async def engine_up(): - await run_manager.setup() +async def _clean_settings() -> None: async with SessionLocal() as session: await session.execute(delete(Setting).where(Setting.key.in_( [spend.SPEND_KEY, JUDGE_SPEND_KEY, "verify", "judge", "copilot"]))) await session.commit() + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + await _clean_settings() yield await run_manager.shutdown() + # 这里写过的核查设置、花费账不留给同一进程里后面的测试文件 + await _clean_settings() @pytest.fixture @@ -743,8 +749,11 @@ async def test_listing_filters_by_report_and_sentence_and_artifacts_are_whitelis [approval] = await pending(paused.id) doc = await report_doc(paused.id) east, south = unit_of(doc, EAST), unit_of(doc, SOUTH) - first = (await ask(client, paused.id, report="write", unit=east, question=QUESTION, - approval_id=approval.id)).json()["inquiry"] + # 署名照请求头记(前端按 encodeURIComponent 编码后发) + first = (await client.post(f"/api/runs/{paused.id}/inquiries", headers={"X-Actor": "%E5%BC%A0%E5%B7%A5"}, + json={"report": "write", "unit": east, "question": QUESTION, + "approval_id": approval.id})).json()["inquiry"] + assert first["actor"] == "张工" second = (await ask(client, paused.id, report="write", unit=south, question="华南为什么低?", approval_id=approval.id)).json()["inquiry"] assert first["seq"] < second["seq"] From 2db83a69e8b7fe668dfa0c2bb9875a72aca2e93d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 18:58:41 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=EF=BC=9A=E5=8F=AA=E6=9C=89=E7=A9=BA=E4=B8=B2=E7=AE=97=E6=B2=A1?= =?UTF-8?q?=E6=8C=87=E6=98=8E=E5=8F=A5=E5=AD=90=E3=80=81=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=8D=A1=EF=BC=8C0=E3=80=81false=20=E8=BF=99=E7=B1=BB=E7=85=A7?= =?UTF-8?q?=E6=A0=B7=E6=8A=A5=E6=A0=BC=E5=BC=8F=E4=B8=8D=E5=AF=B9=EF=BC=9B?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=9B=B8=E5=90=8C=E7=9A=84=E4=B8=A4=E6=AC=A1?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=85=B1=E7=94=A8=E4=B8=80=E4=BB=BD=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=EF=BC=8C=E5=BC=95=E7=94=A8=E5=88=97=E8=A1=A8=E9=87=8C?= =?UTF-8?q?=E5=8F=AA=E8=AE=B0=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/inquiry.py | 18 ++++++++++++------ backend/tests/test_inquiry_api.py | 2 ++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 7c30c674..07b46f78 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -214,9 +214,8 @@ def parse_ask(body: Any) -> Ask: report = body.get("report") if not isinstance(report, str) or not report.strip(): raise _invalid("请指明质询的是哪份报告(报告撰写节点的 ID)", "report") - # 不指明句子、不是在审批卡上问的,写 null 和写空串是一个意思 - unit = body.get("unit") or None - if unit is not None and (not isinstance(unit, str) or not unit.strip()): + 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): @@ -230,13 +229,18 @@ def parse_ask(body: Any) -> Ask: question = question.strip() if len(question) > QUESTION_MAX: raise _invalid(f"问题最长 {QUESTION_MAX} 字,当前为 {len(question)} 字", "question") - approval_id = body.get("approval_id") or None - if approval_id is not None and (not isinstance(approval_id, str) or not approval_id.strip()): + 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 + + # -------------------------------------------------------------------------- # 开放条件 # -------------------------------------------------------------------------- @@ -669,7 +673,9 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict 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": [q["artifact"] for q in queries if q.get("artifact")], "queries": queries, + # 结果一模一样的两次查询是同一份快照(工件按内容寻址):引用列表里只记一次 + "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}, } diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 811dda00..d0b2971b 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -526,7 +526,9 @@ async def test_the_run_must_be_waiting_on_that_approval(monkeypatch, shop, clien ({"report": "write", "unit": None, "question": QUESTION, "quote": "引" * 501}, "quote"), ({"report": "write", "unit": None, "question": QUESTION, "quote": 5}, "quote"), ({"report": "write", "unit": 12, "question": QUESTION}, "unit"), + ({"report": "write", "unit": 0, "question": QUESTION}, "unit"), ({"report": "write", "unit": None, "question": QUESTION, "approval_id": 7}, "approval_id"), + ({"report": "write", "unit": None, "question": QUESTION, "approval_id": False}, "approval_id"), ]) async def test_bad_requests_name_the_field(monkeypatch, shop, client, body, field_name): Models(monkeypatch, shop) From a7333368f1845cd7677b7af72848a25551198e9c Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 19:00:36 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A1=A5=E5=85=A8=EF=BC=9A=E5=B0=81=E5=AD=98?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=E7=9A=84=E8=BF=BD=E9=97=AE=E9=80=94=E4=B8=AD?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E8=A2=AB=E7=BB=A7=E7=BB=AD=E4=BA=86=E4=B8=8D?= =?UTF-8?q?=E5=86=99=E8=BF=9B=E8=AE=B0=E5=BD=95=E3=80=81/verify=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=A0=B8=E5=AF=B9=E4=B8=80=E8=87=B4=E3=80=81?= =?UTF-8?q?=E6=89=B9=E5=87=86=E5=92=8C=E9=A9=B3=E5=9B=9E=E9=83=BD=E5=9C=A8?= =?UTF-8?q?=E5=9B=9E=E7=AD=94=E6=9C=9F=E9=97=B4=E8=A2=AB=E6=8B=A6=EF=BC=9B?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=AF=B4=E6=98=8E=E5=86=99=E6=98=8E=E8=B4=A8?= =?UTF-8?q?=E8=AF=A2=E5=B7=A5=E4=BB=B6=E7=9A=84=E5=BD=A2=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败的探索运行封存后追问,回答途中被继续运行:回答照样交回,recorded 为假,继续运行重新封存后核对一致 - 停在审批时质询、批准、封存之后,经 /api/runs/{id}/verify 核对,封存范围盖住这条质询 - 质询期间批准、驳回都回 409 inquiry_busy - api/inquiry.py 的说明补上质询工件的返回形状 {id, content, masked[, mask_note]},以及回答文档原样给 --- backend/app/api/inquiry.py | 4 ++- backend/tests/test_inquiry_api.py | 51 ++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/backend/app/api/inquiry.py b/backend/app/api/inquiry.py index 7c5b73ad..111ae5fa 100644 --- a/backend/app/api/inquiry.py +++ b/backend/app/api/inquiry.py @@ -2,7 +2,9 @@ - POST /api/runs/{run_id}/inquiries:{report, unit, quote?, question, approval_id?} → {inquiry, recorded, note} - GET /api/runs/{run_id}/inquiries?report=&unit=:{items: [Inquiry]},封存前后的都算,按先后 -- GET /api/runs/{run_id}/inquiries/artifacts/{artifact_id}:质询引用过的工件,查询快照的遮罩列照证据面板遮好 +- GET /api/runs/{run_id}/inquiries/artifacts/{artifact_id}:质询引用过的工件 {id, content, masked[, mask_note]}—— + 查询快照的遮罩列照证据面板遮好(masked 是遮掉的列名);回答文档(answer.doc_artifact)组装时就遮好了,原样给。 + 不是这次运行的质询引用过的回 404:工件 id 不能拿来取任意快照 不受理:409 {detail, code}(inquiry_not_open / inquiry_busy / inquiry_disabled);输入不合法 422 {detail, field}, field 指明是哪一项(前后端约定的形状,不是 FastAPI 自己那种校验错误数组,所以请求体自己解析)。 diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index d0b2971b..7926b046 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -34,12 +34,15 @@ from app.data import introspect from app.data.engine import engines from app.db.base import SessionLocal from app.db.models import Approval, DataSource, Run, RunEvent, Setting +from app.engine import compiler from app.engine import inquiry as inquiry_mod from app.engine import spend, verify_agent +from app.engine.context import NodeError from app.engine.evidence import iter_segments, iter_units from app.engine.judge import SPEND_KEY as JUDGE_SPEND_KEY from app.engine.nodes import report as report_mod from app.engine.runner import run_manager, verify_manifest +from app.engine.schema import NodeType from app.main import app from app.providers.factory import ProviderNotConfigured from app.providers.mock_model import MockChatModel @@ -331,7 +334,8 @@ async def test_asking_while_waiting_is_recorded_and_sealed_with_the_run(monkeypa final = await wait(paused.id) assert final.status == "succeeded", final.error assert final.manifest_seq > event.seq - assert (await verify_manifest(paused.id))["ok"] is True + verified = (await client.get(f"/api/runs/{paused.id}/verify")).json() + assert verified["ok"] is True and verified["sealed_at"] == final.manifest_seq >= event.seq # 用量计进运行用量,明细在 inquiry 下 assert final.usage["inquiry"]["calls"] == item["usage"]["calls"] assert final.usage["inquiry"]["input_tokens"] == item["usage"]["input_tokens"] @@ -392,9 +396,10 @@ async def test_while_answering_approving_resuming_and_continuing_are_refused(mon approval_id=approval.id)) try: await asyncio.wait_for(entered.wait(), 10) - refused = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": False, "note": "先别批"}, - headers={"X-Actor": "reviewer"}) - assert refused.status_code == 409 and refused.json() == BUSY_DECIDE + for decision in ({"approved": True}, {"approved": False, "note": "先别批"}): + refused = await client.post(f"/api/approvals/{approval.id}/decide", json=decision, + headers={"X-Actor": "reviewer"}) + assert refused.status_code == 409 and refused.json() == BUSY_DECIDE refused = await client.post(f"/api/runs/{paused.id}/resume", json={"response": {"approved": True}, "approval_id": approval.id}) assert refused.status_code == 409 and refused.json() == BUSY_DECIDE @@ -458,6 +463,44 @@ async def test_after_the_seal_only_exploratory_runs_take_inquiries(monkeypatch, assert await events(formal.id, "approval.inquiry") == [] +async def test_a_follow_up_is_not_written_once_the_run_is_continued(monkeypatch, shop, client): + """失败的探索运行也封存了,可以追问。追问途中运行被继续了(另一个进程、或者抢在登记之前):记录上的封存不再是 + 开始回答时的那份,回答照样交回,不写进运行记录;继续运行跑完重新封存,核对照旧一致。""" + state = {"fail": True, "continued": False} + original = compiler.RUNNERS[NodeType.TRANSFORM] + + async def flaky(st, ctx): + if ctx.node.id == "boom" and state["fail"]: + raise NodeError(ctx.node.id, "下游接口暂时不可用") + return await original(st, ctx) + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, flaky) + + async def continue_meanwhile(): + if not state["continued"]: + state["continued"], state["fail"] = True, False + await run_manager.continue_failed(state["run"]) + + Models(monkeypatch, shop, before=continue_meanwhile) + graph = chain(node("start", "input"), node("fetch", "tool", tool=f"db_query__{shop}", args={"sql": QUERY}), + node("write", "report", "写周报", instructions="写周报"), + node("boom", "transform", mode="template", template="门店 17 家"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + failed = await finish(shop, graph=graph) + assert failed.status == "failed" and failed.manifest_seq is not None + state["run"] = failed.id + + r = await ask(client, failed.id, report="write", unit=None, question=QUESTION) + assert r.status_code == 200, r.text + body = r.json() + assert body["recorded"] is False and body["note"] == NOT_RECORDED + assert body["inquiry"]["post_seal"] is True and body["inquiry"]["answer"]["text"] == NO_TEST_TEXT + final = await wait(failed.id) + assert final.status == "succeeded", final.error + assert await events(failed.id, "approval.inquiry") == [] + assert (await verify_manifest(failed.id))["ok"] is True + + async def test_a_formal_run_waiting_for_approval_takes_inquiries(monkeypatch, shop, client): """正式运行停在审批时可以质询:问答随运行一起封存,正式运行同样可以复核。""" Models(monkeypatch, shop) From 778fb8b83edb6f82a314c356a4814440cbe4814b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 08:52:16 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=201=20?= =?UTF-8?q?=E8=BD=AE=EF=BC=8C=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E9=81=AE?= =?UTF-8?q?=E7=BD=A9=E6=94=B9=E6=8C=89=20SQL=20=E8=AE=A4=E5=87=BA=E6=94=B9?= =?UTF-8?q?=E4=BA=86=E5=90=8D=E5=AD=97=E7=9A=84=E9=81=AE=E7=BD=A9=E5=88=97?= =?UTF-8?q?=E3=80=81=E5=86=8D=E6=8C=89=E5=80=BC=E9=81=AE=EF=BC=8C=E6=8C=89?= =?UTF-8?q?=E5=80=BC=E8=AE=A4=E6=9C=89=E9=97=A8=E6=A7=9B=E3=80=81SQL=20?= =?UTF-8?q?=E5=8F=AA=E6=8D=A2=E5=AD=97=E9=9D=A2=E9=87=8F=EF=BC=9B=E6=B2=A1?= =?UTF-8?q?=E5=86=99=E8=BF=9B=E8=AE=B0=E5=BD=95=E7=9A=84=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E7=85=A7=E6=A0=B7=E7=82=B9=E5=BE=97=E5=BC=80=EF=BC=9B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E6=A0=B8=E5=AF=B9=E6=98=AF=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=94=A8=E8=BF=87=E7=9A=84=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=EF=BC=9B=E6=8A=A5=E9=94=99=E5=92=8C=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E6=9C=AB=E5=B0=BE=E7=9A=84=E8=AF=B4=E6=98=8E=E7=BB=99=E6=8F=90?= =?UTF-8?q?=E9=97=AE=E4=BA=BA=E7=9C=8B=E5=BE=97=E6=87=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核提出的问题和改法: - 遮罩列起了别名(本仓库查询的惯例是中文别名)、套了函数、经子查询或 CTE 转一道,原值会出现在回答正文、事件和质询工件里,第二遍组装也拦不住:按 SQL 认出改了名字的遮罩列(COUNT 只数个数、窗口的分区键只决定次序,不算;解析不了又写着遮罩列的,整份快照遮掉),快照另按值遮(别的列里写着遮罩值的格子);组装两遍之后正文里还认得出遮罩值的回答不交出去;提示词写明遮罩列不要选出原值、不要起别名 - 按值遮罩范围过宽:年龄这类短的遮罩值会把阈值、天数、日期、LIMIT 换成「已遮罩」,封存的查询清单不再是实际执行的 SQL。纯数字至少 5 位才按值认,日期、小数、时间、千分位里的一截不算;SQL 只换字面量和注释里的值,短的数只在直接和遮罩列(含别名)比较时才换;问题、选中原文里提问人自己写的值不换 - 提问人写的阈值在回答里被标「无出处」:问题、选中原文里的数作为 allow_numbers 交给两遍组装 - 同一条 SQL 事件里遮了、质询工件接口里没遮:工件接口直接用事件里记下的那条,遮罩值照回答时收(含报告自己的查询快照) - 没写进运行记录的回答交回了工件 id,质询工件接口却一律 404:这样的回答在进程里留一小时(最多 200 条),这段时间里照样认它引用的工件 - 回答里引用的报告指标(m:)点不开:质询工件接口认回答文档目录里的口径卡 - 遮罩值上万个时每次回答、每次取工件都在事件循环里编一个上万分支的正则,卡住进程一秒上下:先按词、按开头几个字和长短筛出正文里真有的几个值再编;收值、解析 SQL、逐格比对放进线程 - 按名字认源:同名重建的源会被当成运行用过的去查,没固定版本的上传表格还会被悄悄补固定进已封存的运行。按运行里查询快照记的源 id、运行固定的版本核对,对不上的、没固定过的不交给 agent - 封存之后的追问查的是现在的数据,读者分不清是口径差异还是数据更新:任务里给报告生成的时间,回答末尾注明 - 模型轮数用完被说成查询次数到顶,引用一个都解析不了照样算回答完成:轮数用完 limit 记 null;轮数用完、查询执行失败、引用解析不了都在回答末尾注明 - 报错透出核查 agent 的内部说法(「交结果」「按结构」、裸写的字段名):按情形换成发生了什么加下一步,原话只进日志 - 运行在执行时的说法对正式运行不成立、对过时的审批卡也没说清:带着审批卡来的叫人刷新,没带的按运行类别说什么时候能问 - 回答文档的工件引用 meta 记下问题的前 40 字,运行的工件列表据此认得出是哪个问题的回答 - 测试补上证明接线的用例:两道审批的竞态(approval_id 守卫)、上传表格按运行固定的版本查、设置里的上限和 data_versions 交到 agent 手里、开放途中运行被继续了、报告文档不属于这次运行;新增遮罩的单元测试。每条都做过去掉实现、测试变红的验证 --- backend/app/api/inquiry.py | 7 +- backend/app/engine/inquiry.py | 900 ++++++++++++++++++++++---- backend/tests/test_inquiry_api.py | 480 ++++++++++++-- backend/tests/test_inquiry_masking.py | 204 ++++++ 4 files changed, 1417 insertions(+), 174 deletions(-) create mode 100644 backend/tests/test_inquiry_masking.py diff --git a/backend/app/api/inquiry.py b/backend/app/api/inquiry.py index 111ae5fa..65b8b774 100644 --- a/backend/app/api/inquiry.py +++ b/backend/app/api/inquiry.py @@ -3,8 +3,11 @@ - POST /api/runs/{run_id}/inquiries:{report, unit, quote?, question, approval_id?} → {inquiry, recorded, note} - GET /api/runs/{run_id}/inquiries?report=&unit=:{items: [Inquiry]},封存前后的都算,按先后 - GET /api/runs/{run_id}/inquiries/artifacts/{artifact_id}:质询引用过的工件 {id, content, masked[, mask_note]}—— - 查询快照的遮罩列照证据面板遮好(masked 是遮掉的列名);回答文档(answer.doc_artifact)组装时就遮好了,原样给。 - 不是这次运行的质询引用过的回 404:工件 id 不能拿来取任意快照 + 查询快照(queries[].artifact)照回答时的办法遮好:遮罩列(含起了别名、套了函数的)整列遮,别的列里写着遮罩值的格子 + 也遮,masked 是有格子被遮掉的列名;content.sql 就是事件里记下的那条(遮罩值已换掉),两处一字不差。回答文档 + (answer.doc_artifact)组装时就遮好了,原样给;回答里引用的报告指标(doc.catalog 里 m: 条目的 artifact,口径卡) + 也从这里取,界面一律按 doc.catalog[alias].artifact 取。没写进运行记录的回答(recorded 为假)在本进程里留一小时, + 这段时间里它引用的工件照样能取;之后、或者服务重启过,回 404。别的工件一律 404 不受理:409 {detail, code}(inquiry_not_open / inquiry_busy / inquiry_disabled);输入不合法 422 {detail, field}, field 指明是哪一项(前后端约定的形状,不是 FastAPI 自己那种校验错误数组,所以请求体自己解析)。 diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 07b46f78..e6d3b762 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -8,43 +8,52 @@ (同按需裁判);设置里关了「审批质询 · 启用」一律不答。同一次运行同时只答一条:进程内登记(_ANSWERING),登记期间 这次运行的审批回复、继续运行也不受理(api/runs.py),免得运行往下走了、问答还在路上 2. 回答(_answer):交给 agent 被问的那句原文、它挂的证据摘录(照 judge.prepare 的办法取,数据源遮罩的列照旧不给,原报告 - 的编号标明「原报告」)、审批人选中的原文和问题;工具是这次运行用过的查询工具对应的数据源;上限按设置(每次的查询次数、 - 时长,全局每日金额)。回答先给结论,数字只能引用 agent 自己查询的格子,或者报告里已有的指标、运行输入;回答文档由 - compose_doc 组装,裸数字被标出来 -3. 遮罩:回答里引用到遮罩列的格子渲染成「已遮罩」(按遮罩过的快照组装,_masking_loader);回答正文、查询清单里出现的 - 遮罩列的值换成「已遮罩」(_Secrets)。哪些列要遮照证据面板的规则:查询当时记下的,加上数据源现在设的 + 的编号标明「原报告」)、审批人选中的原文和问题;工具是这次运行用过的查询工具对应的数据源(同名重建的、没固定过版本的 + 上传表格不算,见 _usable_sources);上限按设置(每次的查询次数、时长,全局每日金额)。回答先给结论,数字只能引用 agent + 自己查询的格子,或者报告里已有的指标、运行输入;回答文档由 compose_doc 组装,裸数字被标出来(提问人自己写的数除外)。 + 封存之后的追问查的是数据源现在的数据,任务里给报告生成的时间,回答末尾注明;触顶、查询失败、引用解析不了也在末尾注明 +3. 遮罩(_plan):哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列 + (起别名、套函数、经子查询转一道,_derived_hidden);快照按列遮,再按值遮(别的列里写着遮罩值的格子,_masked_view)。 + 回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql):按值认有门槛,SQL 只换字面量,阈值、日期、LIMIT + 照原样,封存的是实际执行的语句。组装两遍之后正文里还认得出遮罩值的,这份回答不交出去 4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的 _usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键 收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存), - append_events 不写,回答照样交回给提问的人,说明没有写进运行记录。触顶、失败照样记一条 -5. 读取:列表读 approval.inquiry 事件(封存前后的都算,按先后);质询工件只认质询事件引用过的,遮罩照证据面板 + append_events 不写,回答照样交回给提问的人,说明没有写进运行记录(这样的回答在进程里留一阵,_HELD)。触顶、失败 + 照样记一条 +5. 读取:列表读 approval.inquiry 事件(封存前后的都算,按先后);质询工件只认质询引用过的(查询快照、回答文档、回答里 + 引用的报告口径卡),遮罩照回答时的办法 不发 tool.start、llm.start 这类事件(run_verify_agent 不传 emit):界面从接口的返回取回答;runner 的 _stale_replay 只看 tool.start,质询不影响恢复后的重放。 """ from __future__ import annotations +import asyncio import contextlib import json import logging import re import time +from collections import OrderedDict 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 sqlalchemy import func, select from app.core import artifact_store from app.core.errors import describe_exception from app.core.events import EventType +from app.data.catalog import QUERY_SNAPSHOT_KIND 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.db.models import Approval, Artifact, 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.evidence import MARKER_RE, RenderError, compose_doc, iter_segments, iter_units, render_cell from app.engine.expressions import numeric_text +from app.engine.issuance import extract_numbers 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 @@ -69,24 +78,32 @@ BUSY = "这次运行另有一条质询正在回答,回答完再提问" #: 质询正在回答时,这次运行的审批回复(批准 / 驳回)、继续运行回的话(api/runs.py) BUSY_DECIDE = "正在回答质询,回答完再批准或驳回" BUSY_CONTINUE = "正在回答质询,回答完再继续运行" -#: 触顶原因:前后端约定的四种。verify_agent 另有 max_steps(模型想了太多轮),轮数是按查询次数推出来的,算作查询次数到顶 +#: 触顶原因:前后端约定的四种。verify_agent 另有 max_steps(模型想了太多轮):走到那一步时查询次数一定还没用完, +#: 不能说成查询次数到顶,记为 null,回答末尾注明(NOTE_ROUNDS) 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") -#: 回答文档存成工件时的种类 +#: 回答文档存成工件时的种类;工件引用的 meta 里记下问题的前这么多字,运行的工件列表据此认得出是哪个问题的回答 ANSWER_KIND = "inquiry_answer" +QUESTION_BRIEF = 40 #: 没指明句子时交给 agent 的报告正文最多多少字 REPORT_CHARS = 4000 #: 列给 agent 的报告指标、运行输入最多几条 REFS_MAX = 40 #: 被问的是表格里的一格时,交给 agent 的表格最多几行(不含表头) TABLE_ROWS = 20 -#: 遮罩列的值短于这么多字就不在正文里换:一个字的值(「是」「男」)换掉,会把不相干的词也换得面目全非 +#: 按值认遮罩值的门槛:文字至少这么多字(一个字的「是」「男」换掉,会把不相干的词也换得面目全非);纯数字至少这么多位 +#: (年龄、等级、小额金额这类短的数和阈值、天数、日期、LIMIT 里的数太容易撞上,按值换会把实际执行的 SQL、提问人自己 +#: 的条件改得面目全非。它们只在 SQL 里和遮罩列直接比较时才换,见 _scrub_sql;引用到的格子照旧按列遮) SECRET_MIN = 2 -#: 遮罩列的值最多收这么多个(一份快照最多上千行):再多就按已经收到的换 +SECRET_DIGITS = 5 +#: 遮罩列的值最多收这么多种写法(一份快照最多上千行):再多就按已经收到的换 SECRETS_MAX = 20_000 +#: 没写进运行记录的回答在进程里留多久、最多留几条:这段时间里质询工件接口照样认它引用的工件 +HELD_TTL_S = 3600 +HELD_MAX = 200 #: 运行的终态。封存之后的追问只在这几种状态上 _TERMINAL = ("succeeded", "failed", "cancelled") #: 按词边界认值的字符:值的头尾是这样的字时,前后不能紧挨着同类的字(13900000001 不换掉 139000000012 的一截) @@ -98,7 +115,8 @@ INQUIRY_SYSTEM = ( "- 回答直截了当:第一句直接给结论(是还是不是、是多少、差多少),再用一两句说依据;不要复述问题,不要客套\n" "- 回答里的每个数字都写成引用:你自己查询结果里的一格写 [[v:Qn.rX.列名]],报告里已有的指标、运行输入照任务里列出的" "写法引用;不要直接写数字,没有出处的数字会被标出来\n" - "- 数据源设了遮罩的列(手机号、姓名这类)不要在回答里写出原值,需要指明时说「该客户」「这一笔」\n" + "- 数据源设了遮罩的列(手机号、姓名这类):查询里不要选出原值,也不要给它们起别名;要数人数、笔数用 COUNT,回答里需要" + "指明时说「该客户」「这一笔」,不要写出原值\n" "- 查不到、数据里没有判断所需的信息,就照实说查不到,不要编造" ) INQUIRY_TASK_PROMPT = ( @@ -106,6 +124,7 @@ INQUIRY_TASK_PROMPT = ( "{evidence}" "{refs}" "可以查询的数据源:{sources}\n\n" + "{since}" "{masked}" "提问人的问题:\n{question}\n\n" "按上面的做法作答,回答写在 rationale 里。" @@ -124,7 +143,9 @@ INQUIRY_SCHEMA: dict[str, Any] = { # 不受理的说法 DISABLED_WORDS = "审批质询已关闭(设置中的「{label}」),如需质询,请在设置的「核查」一节开启" -STILL_RUNNING = "运行还在执行,停在人工审批时、或者运行结束封存之后才能质询" +STILL_RUNNING = "运行仍在执行。停在人工审批时,或运行结束并封存后,才能质询" +STILL_RUNNING_FORMAL = "运行仍在执行,停在人工审批时才能质询" +CARD_MOVED = "这张审批卡已处理,运行正在继续执行,请刷新页面查看最新状态" NO_CARD = "运行没有停在人工审批上(可能是服务重启时中断的),没有可以质询的审批" CARD_GONE = "这张审批卡已处理,或不是这次运行正在等待的审批,请刷新页面查看最新状态" CARD_DONE = "这张审批卡已处理,运行已经结束,请刷新页面查看最新状态" @@ -136,7 +157,20 @@ BAD_BODY = ("请求格式有误:需要一个对象,写明报告(report, "可选句子编号(unit)、选中的原文(quote)、审批卡(approval_id)") DOC_TAMPERED = "报告文档与哈希不一致,疑似被修改,不能据此质询" DOC_MISSING = "无法读取报告文档" -NO_ANSWER = "模型没有给出回答内容" +# 没能给出回答的说法:说发生了什么、下一步。核查 agent 的原话(「交结果」「按结构」、裸写的字段名)只进日志 +NO_ANSWER = "模型没有给出回答内容,请重新提问" +LEAKED = "回答中含有设了遮罩的值,无法遮住,已不予展示,请换个问法重新提问" +ERR_DAILY = "今日核查花费已达上限({cap}),未能给出回答,可在设置的「核查」一节调高上限" +ERR_COST = "这次回答的花费已达上限,未能给出回答,可缩小问题范围后重新提问" +ERR_TIMEOUT = "回答超过时长上限{seconds},未能给出回答,可缩小问题范围后重新提问" +ERR_SHAPE = "模型未能按要求给出回答,请重新提问;如反复出现,请在设置的「核查」一节更换核查模型" +ERR_SILENT = "模型没有给出回复,未能给出回答,请重新提问" +# 回答末尾的系统说明(with_note:另起一段,不挂出处、不查裸数字) +NOTE_LOST = "注:这次运行还用到了数据源{names},已不存在、已停用或已被重建,未能查询,以上回答不含其中的数据。" +NOTE_ROUNDS = "注:调用模型的轮数已用完,以上回答只依据已经查到的结果,可能不完整。" +NOTE_FAILED = "注:有 {n} 次查询执行失败(查询清单只列成功的查询),以上回答可能缺少依据。" +NOTE_UNRESOLVED = "注:回答中有 {n} 处引用无法解析,相关的数没有出处。" +NOTE_LIVE = "注:以上回答依据的是数据源当前的数据,晚于报告生成的时间;与报告不一致时,可能是报告生成之后数据有更新。" class InquiryRefused(Exception): @@ -191,6 +225,32 @@ def inquiry_busy(run_id: str) -> bool: return _ANSWERING.busy(run_id) +class _Held: + """没写进运行记录的回答(回答期间运行状态变了):进程内留 HELD_TTL_S 秒、最多 HELD_MAX 条,先进先出。 + + 提问人手里那份回答里的数要点得开,质询工件接口在这段时间里照样认它引用的工件(遮罩照旧)。过了时间、或者服务 + 重启过,接口照常回 404:回答本身已经交回去了,只是数字不能再下钻。""" + + def __init__(self) -> None: + self._items: OrderedDict[int, tuple[str, float, dict[str, Any]]] = OrderedDict() + self._next = 0 + + def keep(self, run_id: str, data: dict[str, Any]) -> None: + self._next += 1 + self._items[self._next] = (run_id, time.monotonic() + HELD_TTL_S, data) + while len(self._items) > HELD_MAX: + self._items.popitem(last=False) + + def of(self, run_id: str) -> list[dict[str, Any]]: + now = time.monotonic() + for key in [k for k, (_, until, _) in self._items.items() if until <= now]: + del self._items[key] + return [data for owner, _, data in self._items.values() if owner == run_id] + + +_HELD = _Held() + + # -------------------------------------------------------------------------- # 请求 # -------------------------------------------------------------------------- @@ -253,7 +313,8 @@ class _Opening: """开放了的这一次质询:往哪种运行里写(mode)、写的时候要核对什么(approval_id / seal),以及被问的报告文档。 loader 是取证据的函数:封存之后只认封存范围内的工件(同按需裁判,api/evidence._sealed_loader),停在审批时和 - 报告节点里的裁判一样直接读工件库。events 是这次运行的事件(封存之后只取封存范围内的)。""" + 报告节点里的裁判一样直接读工件库。events 是这次运行的事件(封存之后只取封存范围内的)。finished 是运行结束的时间 + (UTC,封存之后的追问才有):报告是那时生成的,之后数据可能有更新。""" run_id: str mode: str @@ -264,6 +325,7 @@ class _Opening: loader: Callable[[str], Any] approval_id: str | None = None seal: tuple[int | None, str | None] | None = None + finished: str | None = None async def _open(run_id: str, ask: Ask) -> _Opening: @@ -275,6 +337,7 @@ async def _open(run_id: str, ask: Ask) -> _Opening: 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 + finished = _utc(run.finished_at) 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()] @@ -293,7 +356,9 @@ async def _open(run_id: str, ask: Ask) -> _Opening: 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) + # 带着审批卡来的,多半是卡刚被别人批了、运行往下走了:叫人刷新。没带的按运行类别说什么时候能问 + raise _not_open(CARD_MOVED if ask.approval_id is not None + else STILL_RUNNING_FORMAL if run_class == "formal" else STILL_RUNNING) if ask.approval_id is not None: raise _not_open(CARD_DONE) if run_class == "formal": @@ -318,7 +383,17 @@ async def _open(run_id: str, ask: Ask) -> _Opening: # 带期 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)) + doc=report.doc, loader=loader, seal=(sealed.run_manifest_seq, sealed.manifest_hash), + finished=finished) + + +def _utc(ts: datetime | None) -> str | None: + """运行记录上的时间 → UTC 的「2026-10-06T08:12:33」。带时区的换成 UTC,不带的本来就是 UTC。""" + if ts is None: + return None + if ts.tzinfo is not None: + ts = ts.astimezone(timezone.utc).replace(tzinfo=None) + return ts.isoformat(timespec="seconds") def _node_title(graph: dict[str, Any], node_id: str) -> str: @@ -430,17 +505,43 @@ def _run_sources(events: Events) -> list[str]: return out -async def _usable_sources(names: Iterable[str]) -> dict[str, list[str]]: - """现在能查的数据源(存在、没有停用,核查建工具时也只认这样的)→ 它现在设了遮罩的列。""" +async def _usable_sources(run_id: str, names: Iterable[str], versions: Any) -> dict[str, list[str]]: + """这次运行用过、现在还能查的数据源 → 它现在设了遮罩的列。 + + 工具按名字建(核查建工具时只认存在、没有停用的源),名字背后的源却可能换过:运行用过的源被删了、又用同一个名字 + 新建一个,查到的已经不是这次运行的数据。所以按名字找到现在的源之后,再核对是不是运行用过的那一个——运行里的查询 + 快照在工件引用的 meta 里记着源 id(质询自己的查询不算,它们的 node_id 为空),上传表格还记在运行固定的版本里 + (data_versions)。没固定过版本的上传表格也不交给 agent:建工具时会把当前版本补固定进运行记录(tools/datasource + 的 _pin_late),质询不发事件,已经封存的运行就被悄悄改了。升级前发起的运行(data_versions 为 None)照旧按当前版本。""" from app.data.engine import masked_columns wanted = list(dict.fromkeys(names)) if not wanted: return {} + pins = versions if isinstance(versions, dict) else None 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()} + rows = (await session.execute( + select(DataSource.id, DataSource.name, DataSource.origin, DataSource.options) + .where(DataSource.name.in_(wanted), DataSource.enabled.is_(True)))).all() + metas = (await session.execute( + select(Artifact.meta).where(Artifact.run_id == run_id, Artifact.kind == QUERY_SNAPSHOT_KIND, + func.coalesce(Artifact.node_id, "") != ""))).scalars().all() + used: dict[str, set[str]] = {} + for meta in metas: + if isinstance(meta, dict) and meta.get("source") in wanted and meta.get("source_id"): + used.setdefault(str(meta["source"]), set()).add(str(meta["source_id"])) + for source_id, entry in (pins or {}).items(): + if isinstance(entry, dict) and entry.get("name") in wanted: + used.setdefault(str(entry["name"]), set()).add(str(source_id)) + out: dict[str, list[str]] = {} + for source_id, name, origin, options in rows: + known = used.get(str(name)) + if known and str(source_id) not in known: + continue + if (origin or "manual") == "upload" and pins is not None and source_id not in pins: + continue + out[str(name)] = masked_columns(options) + return out def _quoted(names: Iterable[str]) -> str: @@ -472,15 +573,19 @@ def _task(op: _Opening, ask: Ask, target: _Target | None, excerpts: list[str], r if lines else "") sources = "、".join(live) if live else "(没有可以查询的数据源,只能依据上面的报告内容作答)" if lost: - sources += f"(这次运行还用到了{_quoted(lost)},已不存在或已停用,无法查询)" + sources += f"(这次运行还用到了{_quoted(lost)},已不存在、已停用或已被重建,无法查询)" + # 封存之后的追问:报告是运行结束时生成的,现在查到的数据可能已经变了 + since = (f"报告生成于 {op.finished}(UTC),之后数据可能有更新:查询结果和报告对不上时,先说明这一点。\n\n" + if op.mode == "sealed" and op.finished else "") hidden = [f"「{name}」的 {'、'.join(cols)}" for name, cols in live.items() if cols] - masked = f"设了遮罩的列(回答里不要写出这些列的值):{';'.join(hidden)}\n\n" if hidden else "" + 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) + sources=sources, since=since, masked=masked, question=ask.question) # -------------------------------------------------------------------------- -# 遮罩 +# 遮罩:哪些列 # -------------------------------------------------------------------------- @@ -505,66 +610,195 @@ def _masked_word() -> str: return MASKED -def masked_snapshot(snapshot: dict[str, Any], hidden: set[str]) -> dict[str, Any]: - """遮罩列的每一格换成「已遮罩」,这几列的类型记成文本(渲染时不当数)。没有要遮的列原样返回。""" +def _value_columns(node: Any) -> set[str]: + """一个选出列的值取自哪些列(列名小写)。COUNT 里的只数个数,窗口的分区键、排序键只决定次序,都带不出值,不算; + 套在里面的子查询只看它选出来的列(它的条件、连接里写的列带不出值)。""" + from sqlglot import exp + + out: set[str] = set() + stack = [node] + while stack: + item = stack.pop() + if isinstance(item, exp.Column): + if not isinstance(item.this, exp.Star) and item.name: + out.add(item.name.lower()) + elif isinstance(item, exp.Count): + continue + elif isinstance(item, exp.Window): + stack.append(item.this) + elif isinstance(item, exp.Select): + stack.extend(item.expressions) + else: + stack.extend(item.iter_expressions()) + return out + + +def _is_star(item: Any) -> bool: + from sqlglot import exp + + return isinstance(item, exp.Star) or (isinstance(item, exp.Column) and isinstance(item.this, exp.Star)) + + +def _branches(root: Any) -> list[list[Any]]: + """最外层选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。""" + from sqlglot import exp + + out: list[list[Any]] = [] + stack = [root] + while stack: + node = stack.pop() + if isinstance(node, exp.Subquery): + stack.append(node.this) + elif isinstance(node, exp.SetOperation): + stack.extend((node.expression, node.this)) + elif isinstance(node, exp.Select): + out.append(list(node.expressions)) + return out + + +def _tainted(sql: str, kind: str | None, masked: set[str]) -> tuple[set[str], set[int]] | None: + """SQL 里指代遮罩列的名字(小写),和最外层选出的列里值取自遮罩列的那几列的位置。解析不了返回 None。 + + 名字从遮罩列本身起,一层层传下去:选出列的值取自这些名字的(buyer_phone AS 手机号、UPPER(buyer_phone) AS x),它的 + 别名也算;派生来源写了列名表的(WITH t(p) AS (SELECT buyer_phone …)),按位置对上的列名也算,直到不再多出名字。 + 同名不同层的也一并算上(宁可多遮)。位置是给没起别名的表达式用的:UPPER(buyer_phone) 的结果列名对不上任何名字。""" + from sqlglot import exp + + from app.data.sql_semantics import _parse, _read + + try: + root, _ = _parse(sql, _read(kind)) + except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理 + root = None + if root is None: + return None + try: + names = set(masked) + selects = list(root.find_all(exp.Select)) + lists = [a for a in root.find_all(exp.TableAlias) if a.columns + and isinstance(a.parent, (exp.CTE, exp.Subquery)) and isinstance(a.parent.this, exp.Select)] + for _ in range(len(selects) + len(lists) + 1): + before = len(names) + for select_ in selects: + for item in select_.expressions: + name = item.alias_or_name.lower() + if name and name not in names and names & _value_columns(item): + names.add(name) + for alias in lists: + for column, item in zip(alias.columns, alias.parent.this.expressions): + name = column.name.lower() + if name and name not in names and (names & _value_columns(item) + or item.alias_or_name.lower() in names): + names.add(name) + if len(names) == before: + break + positions: set[int] = set() + for branch in _branches(root): + if not any(_is_star(item) for item in branch): + positions |= {i for i, item in enumerate(branch) if names & _value_columns(item)} + return names, positions + except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(宁可多遮) + logger.warning("质询的 SQL 遮罩分析失败", exc_info=True) + return None + + +def _mentions(sql: str, names: Iterable[str]) -> bool: + return any(re.search(rf"(? tuple[set[str], set[str]]: + """(这份快照要遮的列, SQL 里指代遮罩列的名字),都是小写。 + + 证据面板只按列名遮(hidden);agent 的查询起了别名、套了函数,结果列名就对不上。本仓库的查询惯例是起中文别名 + (region AS 区域),所以按 SQL 认出改了名字的遮罩列(_tainted)。SQL 解析不了、里面又写着遮罩列的,整份快照的 + 列都遮掉;合并查询的快照不看 SQL,它记着自己的来历(_hidden_columns 已按 _merge_masks 遮好)。""" + base = set(hidden) + sql = snapshot.get("sql") + if not base or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: + return base, base 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} + found = _tainted(sql, kind, base) + if found is None: + return (base | {c.lower() for c in columns}, base) if _mentions(sql, base) else (base, base) + names, positions = found + return names | {columns[i].lower() for i in positions if i < len(columns)}, names -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 +#: 一个数的写法:可带正负号、千分位、小数、百分号 +_NUMERIC = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?") +#: 正文里一个个「词」:字母数字下划线连成的一串。头尾都是这种字的遮罩值,只能整个对上这样一串 +_WORDS = re.compile(r"[0-9A-Za-z_]+") +#: 正文里的一串数(可带千分位、小数):带千分位的遮罩值按去掉逗号的样子比 +_DIGITS = re.compile(r"\d[\d,]*(?:\.\d+)?") +#: 中文这类遮罩值按开头几个字分桶找(_Secrets._partition) +_PREFIX = 8 - return load + +def _plain_text(value: Any) -> str: + text = json.dumps(value, ensure_ascii=False, default=str) if isinstance(value, (dict, list)) else str(value) + return re.sub(r"\s+", " ", text).strip() + + +def _numeric(value: Any) -> bool: + if isinstance(value, bool): + return False + return isinstance(value, (int, float)) or (isinstance(value, str) and numeric_text(value) is not None) 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: + text = _plain_text(value) + forms = {text} if text else set() + if _numeric(value): with contextlib.suppress(RenderError, ValueError, ArithmeticError): forms.add(render_cell(value)) - return {f for f in forms if len(f) >= SECRET_MIN} + return forms + + +def _strong(form: str) -> bool: + """按值认得出的写法(见 SECRET_MIN、SECRET_DIGITS)。""" + if _NUMERIC.fullmatch(form): + return sum(c.isdigit() for c in form) >= SECRET_DIGITS + return len(form) >= SECRET_MIN + + +def _digits_of(form: str) -> str: + return form.replace(",", "").lstrip("+-").rstrip("%") def _pattern(value: str) -> str: - """一个值的正则:头尾是字母数字的按词边界认,数字还不能是更长的数的一截(1,390 里的 390、12.5 里的 2.5)。""" + """一个值的正则:头尾是字母数字的按词边界认;数字还不能是更长的数、日期、时间的一截(12.5 里的 2.5、2026-01-25 里的 + 25、12:30 里的 30),也不能是千分位写法里的一组(1,390 里的 390)。逗号隔开的一串长号码(13900000001,13900000002) + 各自照认:千分位一组只有三位。""" head = tail = "" if _WORD.match(value[0]): - head = r"(? None: if not _query_shaped(snapshot) or not hidden: @@ -572,8 +806,8 @@ class _Secrets: 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 len(self.weak) >= SECRETS_MAX: + break if isinstance(row, list): cells = [row[i] for i in index if i < len(row)] elif isinstance(row, dict): @@ -581,44 +815,360 @@ class _Secrets: else: continue for value in cells: - if value is not None and not isinstance(value, bool): - self.values |= _forms(value) + if value is None or isinstance(value, bool): + continue + for form in _forms(value): + self.weak.add(form) + if _strong(form): + self.strong.add(form) + self._index = None - 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 protect(self, texts: Iterable[str | None]) -> None: + """提问人自己写进问题、选中原文里的值不换(「13900000001 这位客户」「排除 300 以下的」):问题本身就带着它 + (事件里原样记着),换了只会让回答、查询对不上问题。""" + given = " ".join(t for t in texts if t) + if not given: + return + numbers = {_digits_of(n) for n in _DIGITS.findall(given)} + + def asked(form: str) -> bool: + return _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given + + self.strong = {f for f in self.strong if not asked(f)} + self.weak = {f for f in self.weak if not asked(f)} + self._index, self._compiled = None, {} + + def hit(self, value: Any) -> bool: + """一格(或 SQL 里一个字面量)的值是不是按值认得出的遮罩值。短的数不算(见 SECRET_DIGITS)。""" + if value is None or isinstance(value, bool) or not self.strong: + return False + text = _plain_text(value) + if text in self.strong: + return True + if _numeric(value) and sum(c.isdigit() for c in text) >= SECRET_DIGITS: + with contextlib.suppress(RenderError, ValueError, ArithmeticError): + return render_cell(value) in self.strong + return False + + def weak_hit(self, text: str) -> bool: + """SQL 里一个字面量是不是遮罩列里出现过的值(短的数也算)。""" + return _plain_text(text) in self.weak + + def scrub(self, text: str) -> str: + if not text or not self.strong: + return text + pattern = self.pattern(text) + return pattern.sub(_masked_word(), text) if pattern is not None else text + + def pattern(self, text: str) -> re.Pattern[str] | None: + """认 text 里出现的遮罩值的正则;一个都没有时为 None。""" + found = self._found(text) if text and self.strong else set() + if not found: + return None + key = frozenset(found) + if key not in self._compiled: + if len(self._compiled) > 64: + self._compiled = {} + # 长的在前:13900000001 先于它的一截被认出来 + self._compiled[key] = re.compile("|".join(_pattern(v) for v in sorted(found, key=lambda v: (-len(v), v)))) + return self._compiled[key] + + def _found(self, text: str) -> set[str]: + words, numbers, others = self._partition() + found = {w for w in _WORDS.findall(text) if w in words} + if numbers: + for run in _DIGITS.findall(text): + found |= numbers.get(_digits_of(run), set()) + for i in range(len(text) - 1) if others else (): + for size, buckets in others.items(): + for length, values in buckets.get(text[i:i + size], {}).items(): + if (piece := text[i:i + length]) in values: + found.add(piece) + return found + + def prepare(self) -> None: + """先把索引建好(收完值、去掉提问人写的之后):上万个值建一次要几十毫秒,放在线程里建,别等到事件循环里第一次换。""" + self._partition() + + def _partition(self) -> tuple[set[str], dict[str, set[str]], dict[int, dict[str, dict[int, set[str]]]]]: + """strong 分成三类:只有字母数字的(按词比)、带千分位小数的数(按去掉逗号的数比)、其余的(中文、日期、 + 邮箱)。其余的按开头至多 _PREFIX 个字分桶、桶里再按长短分:同一个市区的上万个地址开头七八个字都一样,只按开头 + 分的话,正文里每碰到一次那几个字都要逐个比;再按长短分,碰到一次只是按几种长短各查一下。三类各自只在正文里 + 真出现时才进正则。""" + if self._index is None: + words: set[str] = set() + numbers: dict[str, set[str]] = {} + others: dict[int, dict[str, dict[int, set[str]]]] = {} + for value in self.strong: + if _WORDS.fullmatch(value): + words.add(value) + elif _NUMERIC.fullmatch(value): + numbers.setdefault(_digits_of(value), set()).add(value) + else: + size = min(len(value), _PREFIX) + others.setdefault(size, {}).setdefault(value[:size], {}).setdefault(len(value), set()).add(value) + self._index = (words, numbers, others) + return self._index -def _outside_markers(text: str, scrub: Callable[[str], str]) -> str: +def _outside_markers(text: str, secrets: _Secrets) -> str: """只换引用标记之外的文字:标记里是编号、行号、列名,换了会把一个好好的引用弄坏。""" + pattern = secrets.pattern(text) + if pattern is None: + return text + spans = [m.span() for m in MARKER_RE.finditer(text)] + word = _masked_word() + return pattern.sub(lambda m: m.group(0) if any(s < m.end() and m.start() < e for s, e in spans) else word, text) + + +def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) -> tuple[dict[str, Any], list[str]]: + """一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数); + 别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。 + 原快照不动;没有要遮的返回原快照。""" + columns = [str(c) for c in snapshot.get("columns") or []] + index = {i for i, c in enumerate(columns) if c.lower() in hidden} + by_value = bool(secrets.strong) + if not index and not by_value: + return snapshot, [] + word = _masked_word() + touched = set(index) + position = {c: i for i, c in enumerate(columns)} + + def cell(i: int | None, value: Any) -> Any: + if i in index: + return word + if by_value and secrets.hit(value): + if i is not None: + touched.add(i) + return word + return value + + rows: list[Any] = [] + for row in snapshot["rows"]: + if isinstance(row, list): + rows.append([cell(i, v) for i, v in enumerate(row)]) + elif isinstance(row, dict): + rows.append({k: word if str(k).lower() in hidden else cell(position.get(str(k)), v) + for k, v in row.items()}) + else: + rows.append(row) + if not touched: + return snapshot, [] + 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": rows, "column_types": types}, [columns[i] for i in sorted(touched)] + + +_COMPARE_TOKENS = frozenset({"EQ", "NEQ", "GT", "GTE", "LT", "LTE", "LIKE", "ILIKE", "GLOB", "RLIKE", "NULLSAFE_EQ"}) +_LITERAL_TOKENS = frozenset({"STRING", "NUMBER"}) +_NAME_TOKENS = frozenset({"VAR", "IDENTIFIER"}) + + +def _against(tokens: list[Any], i: int, compared: set[str]) -> bool: + """第 i 个字面量是不是直接和遮罩列比较:col = 'x'、col IN ('x', …)、col NOT LIKE 'x'、col BETWEEN a AND b, + 以及 'x' = col。列可以带表名(s.buyer_phone),可以是遮罩列的别名(compared 里已经有)。""" + kinds = [t.token_type.name for t in tokens] + j = i - 1 + k = j + while k >= 0 and kinds[k] in ("COMMA", "STRING", "NUMBER", "DASH"): + k -= 1 + if k >= 1 and kinds[k] == "L_PAREN" and kinds[k - 1] == "IN": + j = k - 2 + elif j >= 0 and kinds[j] in _COMPARE_TOKENS: + j -= 1 + elif j >= 2 and kinds[j] == "AND" and kinds[j - 1] in _LITERAL_TOKENS and kinds[j - 2] == "BETWEEN": + j -= 3 + elif j >= 0 and kinds[j] == "BETWEEN": + j -= 1 + else: + # 字面量在左边:'x' = col、'x' = t.col + if i + 2 < len(tokens) and kinds[i + 1] in _COMPARE_TOKENS and kinds[i + 2] in _NAME_TOKENS: + last = i + 2 + while last + 2 < len(tokens) and kinds[last + 1] == "DOT" and kinds[last + 2] in _NAME_TOKENS: + last += 2 + return tokens[last].text.lower() in compared + return False + if j >= 0 and kinds[j] == "NOT": + j -= 1 + return j >= 0 and kinds[j] in _NAME_TOKENS and tokens[j].text.lower() in compared + + +def _replace_literal(raw: str, kind: str, change: Callable[[str], str]) -> str: + """字面量的原文 → 换过的原文:字符串只换引号里面的(引号、N'' 这类前缀照留),数整个换。""" + if kind == "NUMBER": + return change(raw) + for quote in ("'", '"'): + start = raw.find(quote) + if start >= 0 and len(raw) - start >= 2 and raw.endswith(quote): + return raw[:start + 1] + change(raw[start + 1:-1]) + quote + return change(raw) + + +def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None) -> str: + """SQL 里遮罩列的值换成「已遮罩」,别的一个字不动:查询清单随运行封存,它得是实际执行的那条,审批人看 what-if 的 + 条件(阈值、日期、LIMIT、别的列的条件)看的就是它。 + + - 字符串字面量、注释里按值换(同正文:够长的数、两个字以上的文字);数字字面量够长、又是遮罩值的整个换; + - 直接和遮罩列(或它的别名,compared)比较的字面量是遮罩列里出现过的值就换,短的数也换(age = 25)。 + 分词出错(引号没闭合这类)就整条按正文的办法换。""" + if not sql or not (secrets.strong or secrets.weak): + return sql + from app.data.sql_semantics import _read, _tokens + + try: + tokens = _tokens(sql, _read(kind)) + except Exception: # noqa: BLE001 - 分词器认不了的写法:按正文的办法换 + return secrets.scrub(sql) + word = _masked_word() 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:])) + for i, token in enumerate(tokens): + start, end = token.start, token.end + 1 + if start < last or end > len(sql): + continue + gap = sql[last:start] + out.append(secrets.scrub(gap) if gap.strip() else gap) + raw = sql[start:end] + kind_of = token.token_type.name + if kind_of in _LITERAL_TOKENS: + if compared and secrets.weak_hit(token.text) and _against(tokens, i, compared): + raw = _replace_literal(raw, kind_of, lambda _: word) + elif kind_of == "STRING": + raw = _replace_literal(raw, kind_of, secrets.scrub) + elif secrets.hit(token.text): + raw = word + out.append(raw) + last = end + rest = sql[last:] + out.append(secrets.scrub(rest) if rest.strip() else rest) return "".join(out) +@dataclass +class _Plan: + """一次回答(或一次取质询工件)的遮罩:agent 每份查询快照遮好的样子(views)和遮掉的列(masked)、SQL 里指代 + 遮罩列的名字(tainted)、数据源的类型(kinds,SQL 按它的方言分词)、遮罩列里出现过的值(secrets),以及已经不在的 + 数据源(gone,按查询当时记下的遮罩处理)。""" + + views: dict[str, dict[str, Any]] + masked: dict[str, list[str]] + tainted: dict[str, set[str]] + kinds: dict[str, str | None] + secrets: _Secrets + gone: dict[str, list[str]] + + +async def _source_kinds(names: Iterable[str]) -> dict[str, str | None]: + wanted = sorted({n for n in names if n}) + if not wanted: + return {} + async with SessionLocal() as session: + rows = (await session.execute(select(DataSource.name, DataSource.kind) + .where(DataSource.name.in_(wanted)))).all() + return {str(name): kind for name, kind in rows} + + +async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], given: list[str | None]) -> _Plan: + """own:agent 自己的查询快照(工件 id → 内容);others:报告自己的查询快照(被问的那句可能直接写着遮罩列的值, + agent 会照着复述、写进查询条件);given:问题和选中的原文(里面的值不换)。 + + 查数据源的遮罩要读库,在这里读;解析 SQL、收上万个值、逐格比对是纯计算,放进线程:放在事件循环里会挡住同一进程 + 里别的运行(同 tools/datasource 的 _sql_checks)。""" + from app.api.evidence import _Masks + + masks = _Masks() + named = {artifact: await _hidden_columns(snapshot, masks) for artifact, snapshot in own.items()} + based = [(snapshot, (await _hidden_columns(snapshot, masks))[0]) for snapshot in others] + kinds = await _source_kinds(str(s.get("source") or "") for s in [*own.values(), *others]) + return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given) + + +def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str], list[str]]], + based: list[tuple[dict[str, Any], set[str]]], kinds: dict[str, str | None], + given: list[str | None]) -> _Plan: + secrets = _Secrets() + hidden: dict[str, set[str]] = {} + tainted: dict[str, set[str]] = {} + for artifact, snapshot in own.items(): + hidden[artifact], tainted[artifact] = _derived_hidden(snapshot, named[artifact][0], + kinds.get(str(snapshot.get("source") or ""))) + secrets.add(snapshot, hidden[artifact]) + for snapshot, base in based: + secrets.add(snapshot, _derived_hidden(snapshot, base, kinds.get(str(snapshot.get("source") or "")))[0]) + secrets.protect(given) + secrets.prepare() + views: dict[str, dict[str, Any]] = {} + masked: dict[str, list[str]] = {} + for artifact, snapshot in own.items(): + views[artifact], masked[artifact] = _masked_view(snapshot, hidden[artifact], secrets) + return _Plan(views=views, masked=masked, tainted=tainted, kinds=kinds, secrets=secrets, + gone={artifact: named[artifact][1] for artifact in own}) + + +def _masking_loader(views: dict[str, dict[str, Any]]) -> Callable[[str], Any]: + """组装回答文档用的 loader:agent 自己的查询快照交遮好的那份(引用到遮罩的格子渲染成「已遮罩」);别的查询快照 + 不交(回答的目录里只有 agent 自己的查询);口径卡这类没有行的原样,哈希照旧复验。""" + + def load(artifact: str) -> Any: + if artifact in views: + return views[artifact] + content = artifact_store.load(artifact) + return None if _query_shaped(content) else content + + return load + + # -------------------------------------------------------------------------- # 回答 # -------------------------------------------------------------------------- +class _Leak(Exception): + """组装了两遍,正文里还认得出遮罩值(渲染出来才露的,比如恰好是遮罩值的运行输入):这份回答不交出去。""" + + def _limit(outcome: VerifyOutcome) -> str | None: - if outcome.limit in LIMITS: - return outcome.limit - return "max_queries" if outcome.limit == "max_steps" else None + return outcome.limit if outcome.limit in LIMITS else None + + +def _error_words(outcome: VerifyOutcome, *, timeout_s: Any, daily_max_usd: Any) -> str | None: + """核查 agent 没交成结果的原话 → 提问人看得懂的话(发生了什么、下一步)。原话里有内部的说法(「交结果」「按结构」、 + 裸写的字段名),只进日志;模型不可用、调用失败的说明本来就是给人看的,照用。""" + error = outcome.error + if not error: + return None + if error.startswith("已达") and error.endswith("没能交结果"): + if "每日" in error: + cap = f"核查每日金额上限 {judging._usd(daily_max_usd)}" if daily_max_usd is not None else "核查每日金额上限" + return ERR_DAILY.format(cap=cap) + if "时长" in error: + return _timeout_words(timeout_s) + return ERR_COST + if error.startswith("收尾时超过时长上限"): + return _timeout_words(timeout_s) + if error.startswith("核查模型没有按结构交结果"): + return ERR_SHAPE + if error == "核查模型没有给出回复": + return ERR_SILENT + if error.startswith("核查出错("): + return f"回答时出错{error[len('核查出错'):]},请重新提问" + return error + + +def _timeout_words(timeout_s: Any) -> str: + seconds = timeout_s if isinstance(timeout_s, (int, float)) and not isinstance(timeout_s, bool) else None + return ERR_TIMEOUT.format(seconds=f"({seconds:g} 秒)" if seconds is not None else "") + + +def _given_numbers(ask: Ask) -> list[str]: + """问题、选中的原文里的数:提问人给的阈值、条件(「排除金额低于 500 的」),回答里复述它们不算没有出处的结论。 + 查询结果里来的数照旧必须引用。""" + return [token.raw for text in (ask.question, ask.quote) if text for token in extract_numbers(text)] 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 @@ -627,7 +1177,7 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict 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) + usable = await _usable_sources(op.run_id, wanted, op.versions) 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: @@ -638,41 +1188,47 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict 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() + own: dict[str, dict[str, Any]] = {} 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() + own[str(query["artifact"])] = snapshot + others = [snapshot for entry in catalog.values() if isinstance(entry, dict) and entry.get("kind") == "query" + and _query_shaped(snapshot := _quiet_load(op.loader, entry.get("artifact")))] + plan = await _plan(own, others, [ask.question, ask.quote]) 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) + answer = _compose(source, {**refs, **((outcome.rationale or {}).get("catalog") or {})}, run_id=op.run_id, + views=plan.views, secrets=plan.secrets, allow=_given_numbers(ask)) + except _Leak: + logger.warning("质询的回答里有遮罩值遮不住,不交出去(运行 %s)", op.run_id) + failed = LEAKED except Exception as e: # noqa: BLE001 - 原文没组装成文档就不交出去:里面的引用没换成值,遮罩也没做 logger.warning("质询的回答组装失败(运行 %s)", op.run_id, exc_info=True) - failed = f"整理回答时出错({describe_exception(e)})" + failed = f"整理回答时出错({describe_exception(e)}),请重新提问" else: - if lost: - answer = with_note(answer, f"注:这次运行还用到了数据源{_quoted(lost)},已不存在或已停用,未能查询," - "以上回答不含其中的数据。") + answer = _with_notes(answer, op, outcome, part, lost, own) 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] + error = None + if not ok: + error = _error_words(outcome, timeout_s=part.get("timeout_s"), daily_max_usd=settings.get("daily_max_usd")) + if outcome.error: + logger.info("质询没能给出回答(运行 %s):%s", op.run_id, outcome.error) + queries = [] + for q in outcome.queries: + artifact, name = str(q.get("artifact") or ""), str(q.get("source") or "") + # 快照没取到的(存不下、被删了),SQL 里按这个源设的遮罩列认比较 + compared = plan.tainted.get(artifact) or {c.lower() for c in live.get(name, [])} + queries.append({**{k: q.get(k) for k in QUERY_KEYS}, + "sql": _scrub_sql(str(q.get("sql") or ""), plan.secrets, compared, plan.kinds.get(name))}) 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), + "error": None if ok else (error or failed or NO_ANSWER), # 结果一模一样的两次查询是同一份快照(工件按内容寻址):引用列表里只记一次 "inquiry_artifacts": list(dict.fromkeys(q["artifact"] for q in queries if q.get("artifact"))), "queries": queries, @@ -690,23 +1246,54 @@ def _quiet_load(loader: Callable[[str], Any], artifact: Any) -> Any: 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]: +def _compose(source: str, catalog: dict[str, Any], *, run_id: str, views: dict[str, dict[str, Any]], + secrets: _Secrets, allow: list[str]) -> dict[str, Any]: """agent 写的回答原文(带引用标记)→ 回答文档:目录是 agent 自己的查询,加上报告里的指标和运行输入;快照按遮罩 - 遮好再渲染。遮罩列的值先在标记之外的文字里换掉;换完正文里还认得出遮罩列的值(标记里写了值,解析不了,原样 - 出现在正文里),就连标记一起换了再组装一遍——宁可弄坏一个引用,也不让值露出来。""" - loader = _masking_loader(hidden) - doc = compose_doc(_outside_markers(source, scrub), catalog, run_id=run_id, loader=loader) + 遮好再渲染。遮罩值先在标记之外的文字里换掉;换完正文里还认得出遮罩值(标记里写了值,解析不了,原样出现在正文里), + 就连标记一起换了再组装一遍——宁可弄坏一个引用,也不让值露出来;还认得出(渲染出来才露的),抛 _Leak,不交出去。 + allow 是提问人自己写的数(_given_numbers),回答里复述它们不标「无出处」。""" + loader = _masking_loader(views) + allowed = list(allow) or None + doc = compose_doc(_outside_markers(source, secrets), catalog, run_id=run_id, loader=loader, allow_numbers=allowed) markdown = str(doc.get("markdown") or "") - if scrub(markdown) != markdown: - doc = compose_doc(scrub(source), catalog, run_id=run_id, loader=loader) + if secrets.scrub(markdown) != markdown: + doc = compose_doc(secrets.scrub(source), catalog, run_id=run_id, loader=loader, allow_numbers=allowed) + markdown = str(doc.get("markdown") or "") + if secrets.scrub(markdown) != markdown: + raise _Leak() return doc +def _with_notes(answer: dict[str, Any], op: _Opening, outcome: VerifyOutcome, part: dict[str, Any], lost: list[str], + own: dict[str, dict[str, Any]]) -> dict[str, Any]: + """回答末尾的系统说明:用不了的数据源、模型的轮数用完了、有查询执行失败、有引用解析不了、封存之后查的是现在的数据。 + 审批人据此知道这份回答哪里不完整,不会看到「回答完成」却满屏 ⟦?…⟧。""" + notes: list[str] = [] + if lost: + notes.append(NOTE_LOST.format(names=_quoted(lost))) + if outcome.limit == "max_steps": + notes.append(NOTE_ROUNDS) + cap = part.get("max_queries") + # 查询次数到顶时次数一定用满了(被拒的、执行失败的也算一次),清单里少的就是失败的那几次 + if outcome.limit == "max_queries" and isinstance(cap, int) and not isinstance(cap, bool) \ + and len(outcome.queries) < cap: + notes.append(NOTE_FAILED.format(n=cap - len(outcome.queries))) + unresolved = sum(1 for seg in iter_segments(answer) if seg.get("issue") == "unresolved_ref") + if unresolved: + notes.append(NOTE_UNRESOLVED.format(n=unresolved)) + # 上传表格按运行固定的版本查(快照记着 data_version),手工登记的源查的是现在的库 + if op.mode == "sealed" and any(not snapshot.get("data_version") for snapshot in own.values()): + notes.append(NOTE_LIVE) + for note in notes: + answer = with_note(answer, note) + return answer + + 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}) + meta={"report": ask.report, "unit": ask.unit, + "question": ask.question[:QUESTION_BRIEF]}) except Exception: # noqa: BLE001 - 存不下不影响回答本身:正文照常交回,只是数字点不开 logger.warning("质询的回答文档保存失败(运行 %s)", op.run_id, exc_info=True) return None @@ -778,6 +1365,7 @@ async def ask(run_id: str, request: Ask, *, actor: str | None = None) -> dict[st 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 + _HELD.keep(run_id, shown) return {"inquiry": inquiry_of(shown, seq=None, ts=time.time()), "recorded": False, "note": note} @@ -797,13 +1385,50 @@ async def list_inquiries(run_id: str, *, report: str | None = None, unit: str | 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 +def _cited_cards(doc_id: Any) -> set[str]: + """回答文档里引用的报告口径卡(m: 条目指向的工件):界面按 doc.catalog[alias].artifact 取,和查询快照走同一个接口。""" + doc = _quiet_load(artifact_store.load, doc_id) + catalog = doc.get("catalog") if isinstance(doc, dict) and isinstance(doc.get("catalog"), dict) else {} + return {str(e["artifact"]) for e in catalog.values() + if isinstance(e, dict) and e.get("kind") in ("metric", "input") and e.get("artifact")} - 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] + +async def _report_snapshots(run_id: str, reports: Iterable[Any]) -> list[dict[str, Any]]: + """这几份报告(报告节点 id)的文档目录里的查询快照:和回答时收遮罩值的是同一批(报告最后一次 report.checked 的文档)。""" + wanted = sorted({str(r) for r in reports if isinstance(r, str) and r}) + if not wanted: + return [] + async with SessionLocal() as session: + rows = await session.execute( + select(RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id, RunEvent.type == EventType.REPORT_CHECKED, RunEvent.node_id.in_(wanted)) + .order_by(RunEvent.seq)) + docs = {node_id: str(data["doc_artifact"]) for node_id, data in rows + if isinstance(data, dict) and data.get("doc_artifact")} + out: list[dict[str, Any]] = [] + for artifact in docs.values(): + doc = _quiet_load(artifact_store.load, artifact) + catalog = doc.get("catalog") if isinstance(doc, dict) and isinstance(doc.get("catalog"), dict) else {} + for entry in catalog.values(): + if isinstance(entry, dict) and entry.get("kind") == "query": + snapshot = _quiet_load(artifact_store.load, entry.get("artifact")) + if _query_shaped(snapshot): + out.append(snapshot) + return out + + +async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: + """质询引用过的工件:查询快照(遮罩照回答时的办法,SQL 用事件里记下的那条,两处一字不差)、回答文档(组装时就遮好 + 了)、回答里引用的报告口径卡。{id, content, masked[, mask_note]}。 + + 认的是这次运行的质询事件引用过的,加上没写进运行记录、还留在进程里的回答(_HELD)引用过的;别的一律 404。""" + from app.api.evidence import SOURCE_GONE + + datas = [data for _, _, data in await _inquiry_events(run_id)] + _HELD.of(run_id) + referenced = [d for d in datas + if artifact_id in (d.get("inquiry_artifacts") or []) or d.get("answer_doc") == artifact_id] + if not referenced: + referenced = [d for d in datas if d.get("answer_doc") and artifact_id in _cited_cards(d.get("answer_doc"))] if not referenced: raise InquiryRefused(404, "这次运行的质询没有引用这件工件", code=ARTIFACT_NOT_FOUND) try: @@ -814,21 +1439,28 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: 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() + # 遮罩值照回答时收:引用它的那几条质询各自查到的全部快照(查询条件里写着的值,不一定在这一份的结果里),加上报告 + # 自己的查询快照;提问人自己写的值不换 + own: dict[str, dict[str, Any]] = {artifact_id: content} 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: + if other not in own and _query_shaped(snapshot := _quiet_load(artifact_store.load, other)): + own[other] = snapshot + others = await _report_snapshots(run_id, (d.get("report") for d in referenced)) + plan = await _plan(own, others, [t for d in referenced for t in (d.get("question"), d.get("quote")) + if isinstance(t, str)]) + shown = plan.views[artifact_id] + recorded = next((q["sql"] for d in referenced for q in d.get("queries") or [] + if isinstance(q, dict) and q.get("artifact") == artifact_id and isinstance(q.get("sql"), str)), + None) + if recorded is not None: + shown = {**shown, "sql": recorded} + elif isinstance(shown.get("sql"), str): + name = str(content.get("source") or "") + shown = {**shown, "sql": _scrub_sql(shown["sql"], plan.secrets, plan.tainted.get(artifact_id, set()), + plan.kinds.get(name))} + out: dict[str, Any] = {"id": artifact_id, "content": shown, "masked": plan.masked[artifact_id]} + if gone := plan.gone.get(artifact_id): out["mask_note"] = SOURCE_GONE.format(source="、".join(gone)) return out @@ -836,5 +1468,5 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: __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", + "inquiry_busy", "inquiry_of", "list_inquiries", "parse_ask", ] diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index 7926b046..f5997e16 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -4,25 +4,32 @@ - 停在审批时质询:事件 node_id 为空、数据按前后端约定的形状;质询的查询工件只在 inquiry_artifacts / queries 里, 不在 query_artifact 下(证据接口不把它们当成报告的证据);批准、运行结束封存之后 verify 一致,封存范围包含这条事件; 用量计入运行用量 -- 回答期间运行被批准(竞态):回答照样交回,recorded 为假、note 说明,事件不落库 +- 回答期间运行被批准(竞态):回答照样交回,recorded 为假、note 说明,事件不落库,回答里的工件照样取得到;两道审批时 + 问的是第一张、回答途中运行停到了第二张上,也不写 - 同一运行同时只答一条:第二条质询、审批回复(批准 / 驳回)、继续运行都回 409 inquiry_busy,拒掉的批准不留痕迹 -- 封存之后:探索运行可以追问(post_seal,落在封存之后,不计运行用量),正式运行 409;设置里关了 409; - 没停在那张审批卡上、还在执行、封存核对不通过都 409;输入不合法 422,指明是哪一项 -- 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出;遮罩列的格子渲染成「已遮罩」, - 回答正文、查询清单、事件里都不出现遮罩列的值 -- 触顶、失败照样记一条事件 -- 读取:列表按报告、句子筛选,按时间排;质询工件只认质询引用过的,遮罩照证据面板 +- 封存之后:探索运行可以追问(post_seal,落在封存之后,不计运行用量,回答末尾注明查的是现在的数据),正式运行 409; + 设置里关了 409;没停在那张审批卡上、还在执行(按情形说下一步)、封存核对不通过、开放途中运行被继续了都 409; + 事件里记的报告文档不是这次运行的 422;输入不合法 422,指明是哪一项 +- 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出(提问人自己写的数不标);遮罩列(起了 + 别名的也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现遮罩列的值;短的遮罩值不误伤阈值、 + 天数、LIMIT +- 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 + 数据源、没固定过版本的上传表格不查 +- 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 +- 读取:列表按报告、句子筛选,按时间排;质询工件只认质询引用过的(含回答里引用的口径卡),遮罩照回答时的办法 - 恢复后的重放不受影响:前面的节点不重复执行 -模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件,区域、金额、手机号都是虚构的。 +模型一律是 MockChatModel 剧本,数据源是现造的 SQLite 文件和上传表格,区域、金额、手机号、年龄都是虚构的。 """ from __future__ import annotations import asyncio +import io import json import sqlite3 import uuid from dataclasses import dataclass, field +from datetime import timezone import pytest from httpx import ASGITransport, AsyncClient @@ -30,10 +37,11 @@ from langchain_core.messages import AIMessage, ToolMessage from sqlalchemy import delete, select from app.core import artifact_store -from app.data import introspect +from app.core.config import settings +from app.data import introspect, table_versions from app.data.engine import engines from app.db.base import SessionLocal -from app.db.models import Approval, DataSource, Run, RunEvent, Setting +from app.db.models import Approval, Artifact, DataSource, Run, RunEvent, Setting from app.engine import compiler from app.engine import inquiry as inquiry_mod from app.engine import spend, verify_agent @@ -65,6 +73,12 @@ EVENT_KEYS = {"question", "quote", "report", "unit", "approval_id", "actor", "an QUERY_KEYS = {"alias", "artifact", "sql", "row_count", "truncated", "source"} NOT_RECORDED = "运行状态已经变化,这次质询的问答没有写入运行记录" BUSY_DECIDE = {"detail": "正在回答质询,回答完再批准或驳回", "code": "inquiry_busy"} +#: 封存之后的追问查的是数据源现在的数据:回答末尾的说明 +LIVE_NOTE = "注:以上回答依据的是数据源当前的数据,晚于报告生成的时间;与报告不一致时,可能是报告生成之后数据有更新。" +#: 模型用完了调用轮数、有查询执行失败、有引用解析不了:回答末尾的说明 +ROUNDS_NOTE = "注:调用模型的轮数已用完,以上回答只依据已经查到的结果,可能不完整。" +FAILED_NOTE = "注:有 {n} 次查询执行失败(查询清单只列成功的查询),以上回答可能缺少依据。" +UNRESOLVED_NOTE = "注:回答中有 {n} 处引用无法解析,相关的数没有出处。" async def _clean_settings() -> None: @@ -90,16 +104,19 @@ async def client(): yield c -async def _source(tmp_path, *, options=None) -> tuple[str, int]: +#: 四笔订单:华东两笔(其中一笔 300 是测试账号)、华南一笔 900、华北一笔 400 +SALES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, is_test INTEGER);" + "INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0), (2, '华东', '13900000002', 300, 1)," + " (3, '华南', '13900000003', 900, 0), (4, '华北', '13900000004', 400, 0);") + + +async def _source(tmp_path, *, options=None, name: str | None = None, script: str = SALES) -> tuple[str, int]: path = tmp_path / f"{uuid.uuid4().hex[:8]}.db" db = sqlite3.connect(path) - db.executescript( - "CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone TEXT, amount INTEGER, is_test INTEGER);" - "INSERT INTO sales VALUES (1, '华东', '13900000001', 1200, 0), (2, '华东', '13900000002', 300, 1)," - " (3, '华南', '13900000003', 900, 0), (4, '华北', '13900000004', 400, 0);") + db.executescript(script) db.commit() db.close() - name = f"shop_{uuid.uuid4().hex[:8]}" + name = name or f"shop_{uuid.uuid4().hex[:8]}" async with SessionLocal() as session: row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options=options or {}) @@ -147,10 +164,13 @@ def gated(source: str, *, gate: bool = True) -> dict: @dataclass class Play: - """质询 agent 对某个问题的剧本:依次执行的查询,最后交的回答。""" + """质询 agent 对某个问题的剧本:先看几次表结构(schema),再依次执行的查询,最后交的回答。final 给了就照它交 + (模型不按结构交的样子),不给就交 {rationale: answer}。""" queries: list[str] = field(default_factory=list) answer: str = "查不到判断所需的数据。" + schema: int = 0 + final: dict | None = None PLAYS = {"排除测试账号": Play([NO_TEST], NO_TEST_ANSWER)} @@ -180,10 +200,16 @@ class Models: play = which(task) if model.response_format: self.finals.append(list(messages)) - return AIMessage(content=json.dumps({"rationale": play.answer}, ensure_ascii=False)) + final = play.final if play.final is not None else {"rationale": play.answer} + return AIMessage(content=json.dumps(final, ensure_ascii=False)) if not any(isinstance(m, ToolMessage) for m in messages): self.tasks.append(task) - done = sum(1 for m in messages if isinstance(m, ToolMessage)) + answered = [str(m.tool_call_id) for m in messages if isinstance(m, ToolMessage)] + looked = sum(1 for i in answered if i.startswith("is-")) + if looked < play.schema: + call = {"name": f"db_schema__{source}", "args": {}, "id": f"is-{looked}"} + return AIMessage(content="先看表结构。", tool_calls=[call]) + done = sum(1 for i in answered if i.startswith("iq-")) if done < len(play.queries): call = {"name": f"db_query__{source}", "args": {"sql": play.queries[done]}, "id": f"iq-{done}"} return AIMessage(content="查一下。", tool_calls=[call]) @@ -300,6 +326,10 @@ async def test_asking_while_waiting_is_recorded_and_sealed_with_the_run(monkeypa assert answer["text"] == NO_TEST_TEXT, "回答先给结论,数字按查询结果换好" answer_doc = artifact_store.load(answer["doc_artifact"]) assert answer_doc["markdown"] == answer["text"] and answer_doc["violations"] == [] + # 运行的工件列表里认得出是哪个问题的回答 + async with SessionLocal() as session: + stored = await session.get(Artifact, (answer["doc_artifact"], paused.id)) + assert stored.kind == "inquiry_answer" and stored.meta == {"report": "write", "unit": east, "question": QUESTION} cited = [s["cite"] for s in iter_segments(answer_doc) if s.get("cite")] assert [(c["alias"], c["locator"]["row"], c["value"]) for c in cited] == [("Q1", 0, 1200), ("Q1", 1, 900)] [query] = item["queries"] @@ -373,6 +403,12 @@ async def test_an_approval_that_lands_while_answering_leaves_no_record(monkeypat assert body["recorded"] is False and body["note"] == NOT_RECORDED assert body["inquiry"]["seq"] is None and body["inquiry"]["answer"]["ok"] is True assert body["inquiry"]["answer"]["text"] == NO_TEST_TEXT + # 没写进记录,提问人手里的回答照样点得开:回答文档和查询快照都在质询工件接口上 + [query] = body["inquiry"]["queries"] + for artifact in (body["inquiry"]["answer"]["doc_artifact"], query["artifact"]): + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{artifact}") + assert got.status_code == 200, got.text + assert got.json()["content"]["sql"] == NO_TEST final = await wait(paused.id) assert final.status == "succeeded", final.error @@ -431,7 +467,7 @@ async def test_while_answering_approving_resuming_and_continuing_are_refused(mon async def test_after_the_seal_only_exploratory_runs_take_inquiries(monkeypatch, shop, client): - Models(monkeypatch, shop) + models = Models(monkeypatch, shop) done = await finish(shop) assert done.status == "succeeded", done.error east = unit_of(await report_doc(done.id), EAST) @@ -441,7 +477,12 @@ async def test_after_the_seal_only_exploratory_runs_take_inquiries(monkeypatch, body = r.json() assert body["recorded"] is True and body["note"] is None item = body["inquiry"] - assert item["post_seal"] is True and item["approval_id"] is None and item["answer"]["text"] == NO_TEST_TEXT + assert item["post_seal"] is True and item["approval_id"] is None + assert item["answer"]["text"] == f"{NO_TEST_TEXT}\n\n{LIVE_NOTE}", "封存之后查的是现在的数据,回答里交代清楚" + [task] = models.tasks + ended = done.finished_at.astimezone(timezone.utc).replace(tzinfo=None) if done.finished_at.tzinfo \ + else done.finished_at + assert f"报告生成于 {ended.isoformat(timespec='seconds')}(UTC)" in task [event] = await events(done.id, "approval.inquiry") assert event.data["post_seal"] is True and event.seq > done.manifest_seq after = await _row(done.id) @@ -494,7 +535,8 @@ async def test_a_follow_up_is_not_written_once_the_run_is_continued(monkeypatch, assert r.status_code == 200, r.text body = r.json() assert body["recorded"] is False and body["note"] == NOT_RECORDED - assert body["inquiry"]["post_seal"] is True and body["inquiry"]["answer"]["text"] == NO_TEST_TEXT + assert body["inquiry"]["post_seal"] is True + assert body["inquiry"]["answer"]["text"] == f"{NO_TEST_TEXT}\n\n{LIVE_NOTE}" final = await wait(failed.id) assert final.status == "succeeded", final.error assert await events(failed.id, "approval.inquiry") == [] @@ -620,7 +662,8 @@ async def test_bare_numbers_are_flagged_and_masked_values_never_show(monkeypatch assert r.status_code == 200, r.text assert r.json()["inquiry"]["unit"] is None, "不指明句子时写空串和写 null 一样" [task] = models.tasks - assert f"设了遮罩的列(回答里不要写出这些列的值):「{masked_shop}」的 buyer_phone" in task + assert f"设了遮罩的列(查询里不要选出原值、不要起别名,回答里不要写出这些列的值):「{masked_shop}」的 buyer_phone" \ + in task assert "1390000000" not in r.text, "回答、查询清单里都不能出现遮罩列的值" item = r.json()["inquiry"] answer = item["answer"] @@ -674,6 +717,11 @@ async def test_the_answer_may_cite_the_reports_own_metrics(monkeypatch, client): [task] = models.tasks assert "[[m:gmv]]" in task and "销售额 = 45,678.5元" in task assert r.json()["inquiry"]["queries"] == [] + # 回答里的指标点得开:口径卡从质询工件接口取(界面按 doc.catalog[alias].artifact 取) + card = artifact_store.load(answer["doc_artifact"])["catalog"]["m:gmv"]["artifact"] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{card}") + assert got.status_code == 200, got.text + assert got.json()["content"]["metrics"][0]["id"] == "gmv" and got.json()["masked"] == [] async def test_a_cell_in_a_table_comes_with_the_whole_table(monkeypatch, shop, client): @@ -691,20 +739,6 @@ async def test_a_cell_in_a_table_comes_with_the_whole_table(monkeypatch, shop, c assert "【原报告 Q1】" in task and "列:销售额 | 区域\nr0:1,500 | 华东\n" in task -def test_masked_values_are_matched_as_whole_values(): - """遮罩列的值按整值认:更长的数里的一截、千分位写法里的一截不换;一个字的值不换(换了会把不相干的词弄坏); - 数按原样和千分位两种写法认。""" - secrets = inquiry_mod._Secrets() - snapshot = {"columns": ["phone", "name", "gender", "code", "salary", "region"], - "rows": [["13900000001", "张伟", "男", "390", 12000, "华东"]]} - secrets.add(snapshot, {"phone", "name", "gender", "code", "salary"}) - scrub = secrets.scrubber() - assert scrub("客户 13900000001、139000000012、1,390 元、390 笔、张伟买了男装,月薪 12,000,另 12000.5") == ( - "客户 已遮罩、139000000012、1,390 元、已遮罩 笔、已遮罩买了男装,月薪 已遮罩,另 12000.5") - assert scrub("华东") == "华东", "没遮的列不换" - assert inquiry_mod._Secrets().scrubber()("13900000001") == "13900000001" - - # -------------------------------------------------------------------------- # 触顶与失败 # -------------------------------------------------------------------------- @@ -776,7 +810,8 @@ async def test_the_daily_cap_is_recorded_as_a_limit(monkeypatch, shop, client): r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) assert r.status_code == 200, r.text answer = r.json()["inquiry"]["answer"] - assert (answer["ok"], answer["limit"]) == (False, "daily_max_usd") and answer["error"] + assert (answer["ok"], answer["limit"]) == (False, "daily_max_usd") + assert answer["error"] == "今日核查花费已达上限(核查每日金额上限 $2.00),未能给出回答,可在设置的「核查」一节调高上限" assert models.finals == [] [event] = await events(paused.id, "approval.inquiry") assert event.data["limit"] == "daily_max_usd" @@ -845,3 +880,372 @@ async def test_resuming_after_an_inquiry_does_not_repeat_the_nodes(monkeypatch, assert len(await events(paused.id, "node.started", node_id)) == 1, f"{node_id} 又执行了一遍" assert len(await events(paused.id, "tool.start", "fetch")) == 1 assert models.writes == 1 + + +# -------------------------------------------------------------------------- +# 遮罩:改了名字的遮罩列、只出现在报告里的遮罩值、不误伤别的数 +# -------------------------------------------------------------------------- + +ALIASED = "SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales ORDER BY amount DESC LIMIT 1" + + +async def test_a_masked_column_under_another_name_is_masked_everywhere(monkeypatch, masked_shop, client): + """遮罩列起了中文别名(本仓库查询的惯例):回答里引用到的格子、事件、回答文档、质询工件里都不露出原值。 + 报告自己的查询没选手机号,原值哪里都认不出来,只能按 SQL 认出「手机号」就是 buyer_phone。""" + plays = {"买得最多": Play([ALIASED], "这一笔来自 [[v:Q1.r0.手机号]],金额 [[v:Q1.r0.金额]]。[[see:Q1]]")} + models = Models(monkeypatch, masked_shop, plays=plays) + paused = await pause(masked_shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="买得最多的是哪位客户?", + approval_id=approval.id) + assert r.status_code == 200, r.text + assert "1390000000" not in r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "这一笔来自 已遮罩,金额 1,200。" + [query] = item["queries"] + assert query["sql"] == ALIASED, "SQL 里没有遮罩值,原样" + [event] = await events(paused.id, "approval.inquiry") + assert "1390000000" not in json.dumps(event.data, ensure_ascii=False) + assert "1390000000" not in json.dumps(artifact_store.load(item["answer"]["doc_artifact"]), ensure_ascii=False) + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200 and "1390000000" not in got.text + assert got.json()["content"]["rows"] == [["已遮罩", 1200]] and got.json()["masked"] == ["手机号"] + [task] = models.tasks + assert "不要起别名" in task + + +async def test_a_masked_value_seen_only_in_the_report_is_masked_alike_everywhere(monkeypatch, masked_shop, client): + """遮罩值只出现在报告自己的查询里(agent 照着它写了查询条件、在回答里复述):回答、事件里的 SQL、质询工件里的 + SQL 都换成「已遮罩」,两处给的 SQL 一字不差。""" + graph = chain(node("start", "input"), + node("fetch", "tool", tool=f"db_query__{masked_shop}", + args={"sql": "SELECT buyer_phone, amount AS 金额 FROM sales ORDER BY amount DESC"}), + node("write", "report", "写周报", instructions="写周报"), + node("gate", "human", mode="approve", title="周报可以出具吗"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + asked = "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000003'" + plays = {"这位客户": Play([asked], "号码 13900000003 的客户共 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")} + Models(monkeypatch, masked_shop, writer="金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + paused = await pause(masked_shop, graph=graph) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="这位客户还买过别的吗?", + approval_id=approval.id) + assert r.status_code == 200, r.text + assert "1390000000" not in r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "号码 已遮罩 的客户共 1 笔。" + [query] = item["queries"] + assert query["sql"] == "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩'" + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200 and "1390000000" not in got.text + assert got.json()["content"]["sql"] == query["sql"], "质询工件给的 SQL 和事件里的一字不差" + + +#: 年龄设了遮罩的订单:年龄都是两位数,和阈值、日期、LIMIT 里的数撞在一起 +AGED = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, age INTEGER, amount INTEGER, paid_on TEXT);" + "INSERT INTO sales VALUES (1, '华东', 25, 1200, '2026-01-25'), (2, '华东', 30, 300, '2026-01-20')," + " (3, '华南', 20, 900, '2026-02-01'), (4, '华北', 31, 400, '2026-02-03');") + + +async def test_numbers_that_merely_match_a_short_masked_value_are_left_alone(monkeypatch, tmp_path, client): + """遮罩列是年龄:阈值(300)、天数(30)、日期、LIMIT 里的数撞上年龄也不换,封存的 SQL 就是实际执行的那条; + 直接和年龄比较的值照样遮。提问人自己写的阈值复述在回答里,不算没有出处。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "age"}, script=AGED) + graph = chain(node("start", "input"), + node("fetch", "tool", tool=f"db_query__{name}", + args={"sql": "SELECT region AS 区域, age, amount AS 金额 FROM sales ORDER BY id"}), + node("write", "report", "写周报", instructions="写周报"), + node("gate", "human", mode="approve", title="周报可以出具吗"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + recent = "SELECT COUNT(*) AS 笔数 FROM sales WHERE amount > 300 AND paid_on >= '2026-01-25' LIMIT 20" + same_age = "SELECT COUNT(*) AS 人数 FROM sales WHERE age = 25" + plays = {"不超过 300": Play([recent, same_age], + "排除金额不超过 300 元的订单后,近 30 天还剩 [[v:Q1.r0.笔数]] 笔,其中 [[v:Q2.r0.人数]] 笔" + "来自同一年龄段。[[see:Q1]]")} + Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=graph) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="排除金额不超过 300 元的订单会怎样?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "排除金额不超过 300 元的订单后,近 30 天还剩 3 笔,其中 1 笔来自同一年龄段。" + doc = artifact_store.load(item["answer"]["doc_artifact"]) + assert [s["text"] for s in iter_segments(doc) if s.get("issue") == "uncited_number"] == ["30"], \ + "问题里的 300 是提问人给的,不标;30 没有出处,照标" + assert [q["sql"] for q in item["queries"]] == [recent, "SELECT COUNT(*) AS 人数 FROM sales WHERE age = 已遮罩"] + finally: + await engines.invalidate(source_id) + + +# -------------------------------------------------------------------------- +# 接线:两道审批的竞态、上传表格的版本、设置、开放之后状态又变了、张冠李戴的报告文档 +# -------------------------------------------------------------------------- + + +async def test_an_answer_about_the_first_of_two_approvals_is_not_written_into_the_second(monkeypatch, shop, client): + """问的是第一张审批卡,回答途中它被批掉、运行停到了第二张卡上:仍是「停在审批」,但问答不属于这一次停下,不写。""" + seen: dict = {} + + async def approve_and_wait_for_the_next(): + if seen.get("done"): + return + seen["done"] = True + await run_manager.resume(seen["run"], {"approved": True}, approval_id=seen["approval"]) + for _ in range(600): + cards = await pending(seen["run"]) + if cards and cards[0].id != seen["approval"] and (await _row(seen["run"])).status == "interrupted": + seen["next"] = cards[0].id + return + await asyncio.sleep(0.03) + raise AssertionError("运行没有停到第二道审批上") + + Models(monkeypatch, shop, before=approve_and_wait_for_the_next) + graph = chain(node("start", "input"), node("fetch", "tool", tool=f"db_query__{shop}", args={"sql": QUERY}), + node("write", "report", "写周报", instructions="写周报"), + node("gate", "human", mode="approve", title="周报可以出具吗"), + node("gate2", "human", mode="approve", title="再确认一次"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + paused = await pause(shop, graph=graph) + [first] = await pending(paused.id) + seen.update(run=paused.id, approval=first.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=first.id) + assert r.status_code == 200, r.text + assert r.json()["recorded"] is False and r.json()["note"] == NOT_RECORDED + assert (await _row(paused.id)).status == "interrupted", "运行停在第二道审批上" + assert await events(paused.id, "approval.inquiry") == [] + r = await client.post(f"/api/approvals/{seen['next']}/decide", json={"approved": True}) + assert r.status_code == 200, r.text + final = await wait(paused.id) + assert final.status == "succeeded" and (await verify_manifest(paused.id))["ok"] is True + + +def _book(rows: list[list]) -> bytes: + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = "明细" + for row in rows: + ws.append(row) + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +async def _publish(name: str, rows: list[list]) -> tuple[str, str]: + """发布一版上传表格(一张「明细」),返回 (源 id, 快照 id)。源不存在就新建。""" + (settings.data_dir / "uploads").mkdir(parents=True, exist_ok=True) + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none() + if row is None: + row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload") + result = await table_versions.publish_upload(session, row, _book(rows), "客流.xlsx", header_row=1, + mixed="reject", raw_mode=False) + return row.id, result.snapshot_id + + +async def test_an_uploaded_table_is_queried_at_the_version_the_run_pinned(monkeypatch, client): + """运行停在审批时上传表格重传了一版:质询查的仍是运行固定的那一版,和报告用的是同一份数据。""" + name = f"upload_{uuid.uuid4().hex[:8]}" + _, first = await _publish(name, [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]]) + plays = {"合计": Play(['SELECT SUM("数量") AS 合计 FROM "明细"'], "合计为 [[v:Q1.r0.合计]]。[[see:Q1]]")} + Models(monkeypatch, name, writer="数量合计 [[v:Q1.r0.total]]。[[see:Q1]]", plays=plays) + graph = chain(node("start", "input"), + node("fetch", "tool", tool=f"db_query__{name}", args={"sql": 'SELECT SUM("数量") AS total FROM "明细"'}), + node("write", "report", "写周报", instructions="写周报"), + node("gate", "human", mode="approve", title="周报可以出具吗"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + paused = await pause(name, graph=graph) + await _publish(name, [["区域", "数量"], ["分区甲", 60], ["分区乙", 40]]) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="数量合计是多少?", approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "合计为 3。", "按运行固定的那一版查,不是重传之后的" + [query] = item["queries"] + assert artifact_store.load(query["artifact"])["data_version"] == first + assert (await _row(paused.id)).data_versions == paused.data_versions + + +async def test_the_settings_and_the_runs_pinned_versions_reach_the_agent(monkeypatch, shop, client): + await set_verify({"inquiry": {"max_queries": 3, "timeout_s": 45}, "daily_max_usd": 3.5}) + seen: dict = {} + original = inquiry_mod.run_verify_agent + + async def spy(**kwargs): + seen.update(kwargs) + return await original(**kwargs) + + monkeypatch.setattr(inquiry_mod, "run_verify_agent", spy) + Models(monkeypatch, shop) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + assert (seen["max_queries"], seen["timeout_s"], seen["daily_max_usd"]) == (3, 45, 3.5) + assert seen.get("data_versions") == paused.data_versions and seen["sources"] == [shop] + assert seen["run_id"] == paused.id and seen.get("emit") is None + + +async def test_a_run_continued_while_the_inquiry_opens_is_refused(monkeypatch, shop, client): + """读完运行状态之后、核对封存之前,运行被继续了:记录上的封存是上一次结束时的,不开放(也不白问一次模型)。""" + from app.api import evidence + + models = Models(monkeypatch, shop) + done = await finish(shop) + original = evidence._sealed + + async def continued_meanwhile(run_id): + async with SessionLocal() as session: + row = await session.get(Run, run_id) + row.status = "running" + await session.commit() + return await original(run_id) + + monkeypatch.setattr(evidence, "_sealed", continued_meanwhile) + r = await ask(client, done.id, report="write", unit=None, question=QUESTION) + assert r.status_code == 409 and r.json()["code"] == "inquiry_not_open" + assert models.tasks == [] and models.specs == [] + + +async def test_a_report_document_that_belongs_to_another_run_is_refused(monkeypatch, shop, client): + """事件里记的报告文档其实是另一次运行的(内容寻址只保证没被改,不保证没被张冠李戴):不据此质询。""" + models = Models(monkeypatch, shop) + paused = await pause(shop) + other = await pause(shop) + [approval] = await pending(paused.id) + [checked] = await events(paused.id, "report.checked", "write") + [foreign] = await events(other.id, "report.checked", "write") + async with SessionLocal() as session: + stored = await session.get(RunEvent, checked.id) + stored.data = {**stored.data, "doc_artifact": foreign.data["doc_artifact"]} + await session.commit() + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 422 and r.json()["field"] == "report" + assert models.tasks == [] + + +# -------------------------------------------------------------------------- +# 数据源:同名重建的不查 +# -------------------------------------------------------------------------- + + +async def test_a_source_rebuilt_under_the_same_name_is_not_queried(monkeypatch, tmp_path, client): + """运行用过的数据源被删了、又用同一个名字新建(手工源、上传表格都算):那已经不是这次运行查过的数据,不交给 agent; + 上传表格也不会因此被补固定进运行记录。""" + name, source_id = await _source(tmp_path) + plays = {"华南为什么": Play([], "查不到判断所需的数据。")} + models = Models(monkeypatch, name, plays=plays) + done = await finish(name) + assert done.status == "succeeded", done.error + + async def drop(sid) -> None: + async with SessionLocal() as session: + await session.delete(await session.get(DataSource, sid)) + await session.commit() + await engines.invalidate(sid) + + await drop(source_id) + _, rebuilt = await _source(tmp_path, name=name) + note = f"注:这次运行还用到了数据源「{name}」,已不存在、已停用或已被重建,未能查询,以上回答不含其中的数据。" + r = await ask(client, done.id, report="write", unit=None, question="华南为什么低?") + assert r.status_code == 200, r.text + assert r.json()["inquiry"]["answer"]["text"] == f"查不到判断所需的数据。\n\n{note}" + [task] = models.tasks + assert f"(这次运行还用到了「{name}」,已不存在、已停用或已被重建,无法查询)" in task + + # 同名新建成上传表格。升级前的查询快照引用没记源 id(认不出是不是同一个源):这次运行没固定过它的版本,照样不查 + await drop(rebuilt) + await _publish(name, [["区域", "数量"], ["分区甲", 1]]) + async with SessionLocal() as session: + for ref in (await session.execute(select(Artifact).where(Artifact.run_id == done.id))).scalars(): + ref.meta = {} + await session.commit() + r = await ask(client, done.id, report="write", unit=None, question="华南为什么低?") + assert r.status_code == 200 and r.json()["inquiry"]["answer"]["text"].endswith(note) + assert (await _row(done.id)).data_versions == done.data_versions, "上传表格没有被补固定进已封存的运行" + + +# -------------------------------------------------------------------------- +# 说法:触顶、失败、开放不了 +# -------------------------------------------------------------------------- + + +async def test_running_out_of_rounds_is_not_reported_as_the_query_limit(monkeypatch, shop, client): + """模型一直在看表结构、轮数用完了:没查过一次,不能说「查询次数到顶」;回答里的引用一个都解析不了,末尾说明。""" + await set_verify({"inquiry": {"max_queries": 1}}) + plays = {"排除测试账号": Play([], "仍居首:华东为 [[v:Q1.r0.销售额]]。", schema=99)} + Models(monkeypatch, shop, plays=plays) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["limit"], item["queries"]) == (True, None, []) + assert item["answer"]["text"] == (f"仍居首:华东为 ⟦?v:Q1.r0.销售额⟧。\n\n{ROUNDS_NOTE}\n\n" + f"{UNRESOLVED_NOTE.format(n=1)}") + + +async def test_failed_queries_are_explained(monkeypatch, shop, client): + """两次查询都执行失败、次数用完了:查询清单只列成功的,回答末尾说清有几次失败、几处引用解析不了。""" + await set_verify({"inquiry": {"max_queries": 2}}) + plays = {"排除测试账号": Play(["SELECT nope FROM sales", "SELECT nothing FROM sales"], NO_TEST_ANSWER)} + Models(monkeypatch, shop, plays=plays) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["limit"], item["queries"]) == ("max_queries", []) + assert item["answer"]["text"] == ("仍居各区域之首:排除测试账号后华东为 ⟦?v:Q1.r0.销售额⟧,华南为 ⟦?v:Q1.r1.销售额⟧。\n\n" + f"{FAILED_NOTE.format(n=2)}\n\n{UNRESOLVED_NOTE.format(n=2)}") + + +async def test_an_answer_not_given_in_the_asked_shape_says_what_to_do(monkeypatch, shop, client): + plays = {"排除测试账号": Play([NO_TEST], final={"answer": "华东仍居首"})} + Models(monkeypatch, shop, plays=plays) + paused = await pause(shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question=QUESTION, approval_id=approval.id) + answer = r.json()["inquiry"]["answer"] + assert answer["ok"] is False + assert answer["error"] == "模型未能按要求给出回答,请重新提问;如反复出现,请在设置的「核查」一节更换核查模型" + + +def test_internal_wording_from_the_agent_is_not_shown(): + """核查 agent 的原话(「交结果」「按结构」、裸写的字段名)换成提问人看得懂的话,并说下一步。""" + def words(error, limit=None, result=None): + outcome = verify_agent.VerifyOutcome(result=result, cells=[], queries=[], rationale=None, usage={}, + limit=limit, error=error) + return inquiry_mod._error_words(outcome, timeout_s=120, daily_max_usd=2.0) + + assert words("收尾时超过时长上限,没能交结果", limit="timeout_s") == \ + "回答超过时长上限(120 秒),未能给出回答,可缩小问题范围后重新提问" + assert words("已达全局每日核查金额上限 $2.00,没能交结果", limit="daily_max_usd") == \ + "今日核查花费已达上限(核查每日金额上限 $2.00),未能给出回答,可在设置的「核查」一节调高上限" + assert words("核查模型没有按结构交结果(交回的内容里没有要求的字段:rationale)") == \ + "模型未能按要求给出回答,请重新提问;如反复出现,请在设置的「核查」一节更换核查模型" + assert words("核查模型没有给出回复") == "模型没有给出回复,未能给出回答,请重新提问" + assert words("核查出错(磁盘已满)") == "回答时出错(磁盘已满),请重新提问" + assert words("核查模型不可用(没有配置模型接入)") == "核查模型不可用(没有配置模型接入)" + assert words(None) is None + + +async def test_refusals_while_the_run_is_executing_say_what_to_do(monkeypatch, shop, client): + """运行在执行:带着审批卡来的多半是卡刚被别人批了,叫人刷新;没带卡的按运行类别说什么时候能问。""" + Models(monkeypatch, shop) + async with SessionLocal() as session: + exploring = Run(workflow_name="还在执行", status="running", graph={}, input={}, run_class="exploratory") + formal = Run(workflow_name="还在执行", status="queued", graph={}, input={}, run_class="formal") + session.add_all([exploring, formal]) + await session.commit() + ids = exploring.id, formal.id + r = await ask(client, ids[0], report="write", unit=None, question=QUESTION, approval_id="card-1") + assert r.status_code == 409 + assert r.json() == {"detail": "这张审批卡已处理,运行正在继续执行,请刷新页面查看最新状态", "code": "inquiry_not_open"} + r = await ask(client, ids[0], report="write", unit=None, question=QUESTION) + assert r.json()["detail"] == "运行仍在执行。停在人工审批时,或运行结束并封存后,才能质询" + r = await ask(client, ids[1], report="write", unit=None, question=QUESTION) + assert r.json()["detail"] == "运行仍在执行,停在人工审批时才能质询" diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py new file mode 100644 index 00000000..146dddad --- /dev/null +++ b/backend/tests/test_inquiry_masking.py @@ -0,0 +1,204 @@ +"""审批质询的遮罩(engine/inquiry.py):回答正文、查询清单、质询工件里都不露出数据源遮罩列的值,也不误伤别的数。 + +- 改了名字的遮罩列(起别名、套函数、经子查询或 CTE 转一道、UNION 的另一支)照样遮;COUNT 只数个数、窗口的分区键 + 只决定次序,不遮;解析不了、SQL 里又写着遮罩列的,整份快照遮掉 +- 按值认的遮罩值有门槛:纯数字够长(SECRET_DIGITS 位)、文字至少两个字;日期、小数、千分位里的一截不算;提问人自己 + 写进问题、选中原文里的不换 +- SQL 只换字面量(和注释)里的遮罩值:直接和遮罩列比较的字面量是遮罩列出现过的值就换,短的数也换;LIMIT、日期、 + 别的列的条件照原样 +- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」 +- 组装回答两遍之后正文里还认得出遮罩值的,不交出去 +- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则 + +数据都是虚构的:手机号、年龄、区域、金额。 +""" +from __future__ import annotations + +import time + +import pytest + +from app.engine import inquiry as inquiry_mod + +MASKED = "已遮罩" + + +def secrets_of(snapshot: dict, hidden: set[str]) -> inquiry_mod._Secrets: + secrets = inquiry_mod._Secrets() + secrets.add(snapshot, hidden) + return secrets + + +# -------------------------------------------------------------------------- +# 改了名字的遮罩列 +# -------------------------------------------------------------------------- + + +def hidden_of(sql: str, columns: list[str], masked: set[str] = frozenset({"buyer_phone"})) -> set[str]: + snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []} + return inquiry_mod._derived_hidden(snapshot, set(masked), "sqlite")[0] + + +@pytest.mark.parametrize("sql, columns, hidden", [ + ("SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales", ["手机号", "金额"], {"buyer_phone", "手机号"}), + ("SELECT UPPER(buyer_phone), amount FROM sales", ["UPPER(buyer_phone)", "amount"], + {"buyer_phone", "upper(buyer_phone)"}), + ("SELECT t.p AS 号码 FROM (SELECT buyer_phone AS p FROM sales) t", ["号码"], {"buyer_phone", "p", "号码"}), + ("WITH t AS (SELECT buyer_phone AS p, amount FROM sales) SELECT p, amount FROM t", ["p", "amount"], + {"buyer_phone", "p"}), + ("WITH t(p) AS (SELECT buyer_phone FROM sales) SELECT p AS 号码 FROM t", ["号码"], {"buyer_phone", "p", "号码"}), + ("SELECT region AS 区域 FROM sales UNION ALL SELECT buyer_phone FROM sales", ["区域"], {"buyer_phone", "区域"}), + ("SELECT (SELECT buyer_phone FROM sales LIMIT 1) AS 某个号码", ["某个号码"], {"buyer_phone", "某个号码"}), +]) +def test_a_masked_column_under_another_name_is_masked(sql, columns, hidden): + assert hidden_of(sql, columns) == hidden + + +@pytest.mark.parametrize("sql, columns", [ + ("SELECT region AS 区域, COUNT(DISTINCT buyer_phone) AS 买家数 FROM sales GROUP BY region", ["区域", "买家数"]), + ("SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales WHERE buyer_phone = '13900000001' GROUP BY region", + ["区域", "销售额"]), + ("SELECT ROW_NUMBER() OVER (PARTITION BY buyer_phone ORDER BY amount) AS 序号 FROM sales", ["序号"]), + ("SELECT (SELECT COUNT(*) FROM sales s2 WHERE s2.buyer_phone = s.buyer_phone) AS 笔数 FROM sales s", ["笔数"]), +]) +def test_counting_filtering_and_ordering_by_a_masked_column_shows_nothing(sql, columns): + assert hidden_of(sql, columns) == {"buyer_phone"} + + +def test_sql_that_cannot_be_read_but_names_a_masked_column_hides_everything(): + assert hidden_of("SELECT buyer_phone AS 号码 FROM sales WHERE (", ["号码", "金额"]) == {"buyer_phone", "号码", "金额"} + assert hidden_of("SELECT region FROM sales WHERE (", ["region"]) == {"buyer_phone"} + + +def test_names_that_point_at_a_masked_column_are_known_for_comparisons(): + snapshot = {"source": "shop", "sql": "SELECT p FROM (SELECT buyer_phone AS p FROM sales) t", "columns": ["p"], + "rows": []} + assert inquiry_mod._derived_hidden(snapshot, {"buyer_phone"}, "sqlite")[1] == {"buyer_phone", "p"} + + +# -------------------------------------------------------------------------- +# 按值认:门槛、边界 +# -------------------------------------------------------------------------- + + +def test_masked_values_are_matched_as_whole_values(): + """遮罩列的值按整值认:更长的数里的一截、千分位写法里的一截不换;一个字的值、太短的数不换(换了会把不相干的 + 词、阈值弄坏);数按原样和千分位两种写法认。""" + snapshot = {"columns": ["phone", "name", "gender", "code", "salary", "region"], + "rows": [["13900000001", "张伟", "男", "390", 12000, "华东"]]} + scrub = secrets_of(snapshot, {"phone", "name", "gender", "code", "salary"}).scrub + assert scrub("客户 13900000001、139000000012、1,390 元、390 笔、张伟买了男装,月薪 12,000,另 12000.5") == ( + "客户 已遮罩、139000000012、1,390 元、390 笔、已遮罩买了男装,月薪 已遮罩,另 12000.5") + assert scrub("华东") == "华东", "没遮的列不换" + assert inquiry_mod._Secrets().scrub("13900000001") == "13900000001" + + +def test_short_masked_numbers_leave_thresholds_dates_and_limits_alone(): + """遮罩列是年龄这类短的数:正文、SQL 里的阈值、日期、LIMIT 撞上同一个数也不换。""" + snapshot = {"columns": ["age", "vip_level"], "rows": [[25, 10], [31, 10], [47, 10]]} + scrub = secrets_of(snapshot, {"age", "vip_level"}).scrub + text = "近 30 天里,2026-01-25 之后的 25 笔订单比 1 月 10 日多 47 笔" + assert scrub(text) == text + + +def test_long_values_next_to_dates_and_decimals_are_not_cut_out_of_them(): + secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"}) + assert secrets.scrub("13900000001-2、13900000001.5、20261390000000-13900000001") == ( + "13900000001-2、13900000001.5、20261390000000-13900000001") + assert secrets.scrub("号码:13900000001。") == "号码:已遮罩。" + + +def test_values_the_asker_wrote_themselves_are_left_alone(): + """提问人自己写进问题、选中原文里的数(「13900000001 这位客户」)照原样:问题本身就带着它,换了只会让回答对不上。""" + secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"], ["13900000002"]]}, {"phone"}) + secrets.protect(["13900000001 这位客户买了几笔?", ""]) + assert secrets.scrub("13900000001 共 2 笔,13900000002 共 1 笔") == "13900000001 共 2 笔,已遮罩 共 1 笔" + + +def test_a_cell_holding_a_masked_value_is_masked_in_any_column(): + """别的列里写着同一个手机号(另一张表的联系方式没设遮罩):按值换。短的数不按值换,免得把同样是 25 的笔数遮了。""" + secrets = secrets_of({"columns": ["phone", "age"], "rows": [["13900000001", 25]]}, {"phone", "age"}) + snapshot = {"columns": ["联系方式", "笔数", "金额"], "column_types": {"联系方式": "text", "笔数": "number"}, + "rows": [["13900000001", 25, 13900000001], ["13900000009", 3, 1200]]} + shown, masked = inquiry_mod._masked_view(snapshot, set(), secrets) + assert shown["rows"] == [[MASKED, 25, MASKED], ["13900000009", 3, 1200]] + assert masked == ["联系方式", "金额"] + assert snapshot["rows"][0][0] == "13900000001", "原快照不动" + by_name, masked = inquiry_mod._masked_view(snapshot, {"笔数"}, inquiry_mod._Secrets()) + assert [r[1] for r in by_name["rows"]] == [MASKED, MASKED] and masked == ["笔数"] + assert by_name["column_types"]["笔数"] == "text" + + +# -------------------------------------------------------------------------- +# SQL:只换字面量 +# -------------------------------------------------------------------------- + + +@pytest.fixture +def people() -> inquiry_mod._Secrets: + return secrets_of({"columns": ["buyer_phone", "age"], "rows": [["13900000001", 25], ["13900000003", 31]]}, + {"buyer_phone", "age"}) + + +@pytest.mark.parametrize("sql, shown", [ + ("SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001'", + "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩'"), + ("SELECT COUNT(*) FROM sales s WHERE s.buyer_phone IN ('13900000001', '13900000003') AND age BETWEEN 25 AND 31", + "SELECT COUNT(*) FROM sales s WHERE s.buyer_phone IN ('已遮罩', '已遮罩') AND age BETWEEN 已遮罩 AND 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE age = 25 AND amount > 25 AND paid_on >= '2026-01-25' LIMIT 25", + "SELECT COUNT(*) FROM sales WHERE age = 已遮罩 AND amount > 25 AND paid_on >= '2026-01-25' LIMIT 25"), + ("SELECT COUNT(*) FROM sales WHERE 25 = age AND note LIKE '%13900000003%' -- 13900000001 的单\nLIMIT 20", + "SELECT COUNT(*) FROM sales WHERE 已遮罩 = age AND note LIKE '%已遮罩%' -- 已遮罩 的单\nLIMIT 20"), + ("SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40", + "SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40"), + ("SELECT amount FROM sales WHERE amount = 13900000001", "SELECT amount FROM sales WHERE amount = 已遮罩"), +]) +def test_sql_keeps_everything_but_the_masked_values(people, sql, shown): + assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, "sqlite") == shown + + +def test_a_comparison_with_a_renamed_masked_column_is_masked_too(people): + sql = "SELECT * FROM (SELECT buyer_phone AS p FROM sales) t WHERE p = '13900000003' AND 1 = 1" + assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "p"}, "sqlite") == ( + "SELECT * FROM (SELECT buyer_phone AS p FROM sales) t WHERE p = '已遮罩' AND 1 = 1") + + +def test_sql_that_cannot_be_split_into_words_is_scrubbed_like_text(people): + assert inquiry_mod._scrub_sql("SELECT 'x FROM t WHERE p = 13900000001 LIMIT 25", people, {"buyer_phone"}, + "sqlite") == "SELECT 'x FROM t WHERE p = 已遮罩 LIMIT 25" + + +# -------------------------------------------------------------------------- +# 组装回答的最后一道关 +# -------------------------------------------------------------------------- + + +def test_an_answer_still_showing_a_masked_value_is_not_handed_out(): + """运行输入里恰好是一个遮罩值(报告目录里的 i: 条目):标记里看不出来,渲染出来才露,连标记一起换也换不掉—— + 宁可不交这份回答。""" + secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"}) + catalog = {"i:客户": {"alias": "i:客户", "kind": "input", "eid": "input:客户", "locator": {"field": "客户"}, + "value": "13900000001", "label": "客户 = 13900000001"}} + with pytest.raises(inquiry_mod._Leak): + inquiry_mod._compose("这位客户是 [[i:客户]]。", catalog, run_id="r1", views={}, secrets=secrets, allow=[]) + doc = inquiry_mod._compose("没有遮罩值。", catalog, run_id="r1", views={}, secrets=secrets, allow=[]) + assert doc["markdown"] == "没有遮罩值。" + + +# -------------------------------------------------------------------------- +# 规模 +# -------------------------------------------------------------------------- + + +def test_twenty_thousand_masked_values_are_scrubbed_without_stalling(): + """上万个遮罩值(几份上千行的快照):以前现编一个上万分支的正则,编一次一秒上下,每次回答、每次取工件都卡住整个 + 进程。现在先按词、按开头两个字筛出正文里真有的几个,再换。""" + rows = [[f"139{i:08d}", f"客户{i:05d}"] for i in range(10_000)] + secrets = secrets_of({"columns": ["phone", "name"], "rows": rows}, {"phone", "name"}) + text = "近 30 天里客户00042 下了 3 笔,号码 13900000042," * 60 + started = time.perf_counter() + for _ in range(5): + out = secrets.scrub(text) + elapsed = (time.perf_counter() - started) / 5 + assert "13900000042" not in out and "客户00042" not in out and "近 30 天" in out + assert elapsed < 0.5, f"换一遍用了 {elapsed:.2f} 秒" From a641bf5fb923ca9d4d348cfab7e325d3dba5cde9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 09:52:47 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=202=20?= =?UTF-8?q?=E8=BD=AE=EF=BC=8C=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=92=8C?= =?UTF-8?q?=20*=20=E5=86=99=E5=9C=A8=E4=B8=80=E8=B5=B7=E3=80=81=E5=AD=90?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E9=87=8C=E6=B2=A1=E8=B5=B7=E5=88=AB=E5=90=8D?= =?UTF-8?q?=E7=9A=84=E9=81=AE=E7=BD=A9=E5=88=97=E8=A1=A8=E8=BE=BE=E5=BC=8F?= =?UTF-8?q?=E8=AE=A4=E5=BE=97=E5=87=BA=EF=BC=9BSQL=20=E5=8F=AA=E6=8D=A2?= =?UTF-8?q?=E5=9C=A8=E6=89=BE=E6=9F=90=E4=B8=80=E4=B8=AA=E5=80=BC=E7=9A=84?= =?UTF-8?q?=E5=AD=97=E9=9D=A2=E9=87=8F=EF=BC=8C=E8=8C=83=E5=9B=B4=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E7=9A=84=E8=BE=B9=E7=95=8C=E3=80=81LIMIT=20=E7=85=A7?= =?UTF-8?q?=E5=8E=9F=E6=A0=B7=EF=BC=9B=E6=8A=A5=E5=91=8A=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=8D=A1=E9=87=8C=E7=9A=84=E6=8C=87=E6=A0=87=E3=80=81SQL=20?= =?UTF-8?q?=E9=87=8C=E7=95=99=E7=9D=80=E7=9A=84=E9=98=88=E5=80=BC=E4=B8=8D?= =?UTF-8?q?=E5=BD=93=E9=81=AE=E7=BD=A9=E5=80=BC=EF=BC=9B=E5=B8=A6=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E7=9A=84=E5=AD=97=E7=AC=A6=E4=B8=B2=E3=80=81=E5=90=8D?= =?UTF-8?q?=E5=AD=97=E3=80=81=E5=88=97=E5=90=8D=E9=87=8C=E7=9A=84=E9=81=AE?= =?UTF-8?q?=E7=BD=A9=E5=80=BC=E4=B9=9F=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核提出的问题和改法: - 遮罩列的表达式和 SELECT * 写在同一支里、又没起别名(SELECT *, SUBSTR(buyer_phone, 4)),原值的一截出现在回答正文和质询工件里: 按位置认,* 前面的从头数、最后一个 * 后面的从结果末尾倒着数(结果的列一个不少、按顺序,重名也照留)。同一个根子的几种也一起补上: 子查询、WITH 里没起别名的表达式(外层的 * 原样带出来,列名各数据库起法不同)、两个 * 之间的项认不出落在哪一列,整份快照遮掉; 派生来源里 UNION 的另一支、带列名表的 UNION 按位置对上第一支或列名表的名字;LATERAL、多套几层括号照样认 - 和遮罩列做范围比较时的阈值、5 位以上的数不看上下文就被换成「已遮罩」,封存的 SQL 不是实际执行的那条:SQL 只换在找某一个值的 字面量(等值、不等、IN、LIKE、SQLite 的 IS)——和遮罩列比较的是遮罩值就换,和别的列比较的够长的遮罩值也换;范围比较的边界、 BETWEEN、LIMIT / OFFSET、选出列和算式里的数照原样(字符串的范围边界同样) - 回答正文里复述 SQL 里照原样留着的阈值(「金额不低于 30000 元」)被遮:这些值本来就随 SQL 摆在读者眼前,正文、快照按值遮时免掉 (只免按值认的,SQL 里和遮罩列做等值比较的照旧换) - 报告口径卡里的指标恰好是遮罩值时(单笔目标 20000),引用它的回答整份交不出去、换个问法也绕不过:报告列出的指标和运行输入的值 像问题里的数一样不当遮罩值,回答时和取质询工件时一样 - 带前缀的字符串(N''、E''、U&''、$$…$$)、带引号和不带引号的名字(AS "13900000001 的单"、张伟的单数)里的遮罩值原样封存:所有 字符串类的词只换引号里的内容,名字按值换;快照的列名、回答文档目录里的列名同样换,引用原列名的标记里的列名跟着换,正好对上 - 回答原文的标记里写着遮罩值时,单元格引用「Q1.r0.」后面的长号码会被当成小数的一截认不出来:标记按段换,单元格引用只换列名那一截; 遮罩值在组装之前就换掉,不再组装两遍,组装出来还认得出遮罩值的照旧不交出去 - 新加的接线没有测试守住:补上问题里写了遮罩列的短数时 SQL 原样保留、质询工件按报告快照里的遮罩值遮别的列,以及上面每一种情形 的单元测试和端到端测试。每处改动都做过去掉实现、测试变红的验证 --- backend/app/engine/inquiry.py | 454 +++++++++++++++++++------- backend/tests/test_inquiry_api.py | 161 ++++++++- backend/tests/test_inquiry_masking.py | 201 +++++++++++- 3 files changed, 679 insertions(+), 137 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index e6d3b762..a0e40995 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -13,9 +13,11 @@ 自己查询的格子,或者报告里已有的指标、运行输入;回答文档由 compose_doc 组装,裸数字被标出来(提问人自己写的数除外)。 封存之后的追问查的是数据源现在的数据,任务里给报告生成的时间,回答末尾注明;触顶、查询失败、引用解析不了也在末尾注明 3. 遮罩(_plan):哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列 - (起别名、套函数、经子查询转一道,_derived_hidden);快照按列遮,再按值遮(别的列里写着遮罩值的格子,_masked_view)。 - 回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql):按值认有门槛,SQL 只换字面量,阈值、日期、LIMIT - 照原样,封存的是实际执行的语句。组装两遍之后正文里还认得出遮罩值的,这份回答不交出去 + (起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉);快照按列 + 遮,再按值遮(别的列里写着遮罩值的格子,_masked_view)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、 + _scrub_sql):按值认有门槛;SQL 只换在找某一个值的字面量(等值、IN、LIKE),范围比较的边界、LIMIT 照原样,封存的是 + 实际执行的语句。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值、SQL 里照原样留着的 + 阈值。组装之后正文里还认得出遮罩值的,这份回答不交出去 4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的 _usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键 收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存), @@ -492,6 +494,17 @@ def _report_refs(catalog: dict[str, Any]) -> dict[str, dict[str, Any]]: if isinstance(e, dict) and e.get("kind") in ("metric", "input") and e.get("value") not in (None, "")} +def _ref_values(refs: dict[str, dict[str, Any]]) -> list[str]: + """报告里列出的指标、运行输入的值(原样和渲染出来的写法):报告里就看得到,碰巧和遮罩列里的某个值一样,回答引用 + 它也不算露出遮罩值(_Secrets.protect)。不免的话,引用「单笔目标」这样的指标整份回答都交不出去,换个问法也一样。""" + out: list[str] = [] + for entry in refs.values(): + out += _forms(entry.get("value")) + if isinstance(entry.get("rendered"), str): + out.append(entry["rendered"]) + return out + + def _run_sources(events: Events) -> list[str]: """这次运行用过的查询工具对应的数据源,按第一次用到的先后。""" out: list[str] = [] @@ -640,7 +653,7 @@ def _is_star(item: Any) -> bool: def _branches(root: Any) -> list[list[Any]]: - """最外层选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。""" + """一块查询选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。""" from sqlglot import exp out: list[list[Any]] = [] @@ -656,12 +669,108 @@ def _branches(root: Any) -> list[list[Any]]: return out -def _tainted(sql: str, kind: str | None, masked: set[str]) -> tuple[set[str], set[int]] | None: - """SQL 里指代遮罩列的名字(小写),和最外层选出的列里值取自遮罩列的那几列的位置。解析不了返回 None。 +def _output_name(item: Any) -> str | None: + """一个选出项在结果里的列名(小写):起了别名的是别名,直接选一列的是列名。别的表达式(函数、运算、CAST、括号、 + 子查询)的列名各数据库起法不同(SQLite 照写法原样记「SUBSTR(buyer_phone, 4)」,Postgres 记函数名或 ?column?), + 认不准,返回 None。""" + from sqlglot import exp + + if isinstance(item, exp.Alias): + return item.alias.lower() or None + if isinstance(item, exp.Column) and not isinstance(item.this, exp.Star): + return item.name.lower() or None + return None + + +def _places(branch: list[Any], width: int | None) -> list[int | None]: + """一支里每一项落在结果的第几列。没有 * 的一一对上;有 * 的,第一个 * 前面的从头数,最后一个 * 后面的从结果末尾 + 倒着数(width 是结果的列数,不知道就数不了);两个 * 之间的、* 本身认不出位置(None)。""" + stars = [i for i, item in enumerate(branch) if _is_star(item)] + if not stars: + return list(range(len(branch))) + first, last = stars[0], stars[-1] + tail = len(branch) - 1 - last + # * 至少展开成 0 列:倒着数要是数到了 * 前面那几项的位置上,列数对不上,后面的一项都不认 + countable = width is not None and width - tail >= first + return [i if i < first else width - (len(branch) - i) if i > last and countable else None + for i in range(len(branch))] + + +def _blocks(root: Any) -> list[tuple[Any, str, list[str] | None]]: + """SQL 里的每一块查询(一条 SELECT,或者 UNION 这类连起来的一串)和它的结果流到哪: + - "result":最外层,结果就是快照的列; + - "source":当成一张表用的——WITH 里的一段、FROM / JOIN 里的子查询、LATERAL,外层按列名取它的列,* 会把它的列 + 全带出去。写了列名表的(t(p))带上列名表; + - "other":标量子查询、IN / EXISTS 里的,值经外层的表达式带出(_value_columns 已经算上),列名无关紧要。 + 套在外面的括号(可能好几层)不改变结果流到哪:先越过它们再看。UNION 里的一支不另算,整串按一块。""" + from sqlglot import exp + + out: list[tuple[Any, str, list[str] | None]] = [] + for node in root.find_all(exp.Select, exp.SetOperation): + wrappers: list[Any] = [] + outer = node + while isinstance(outer.parent, exp.Subquery): + outer = outer.parent + wrappers.append(outer) + parent = outer.parent + if isinstance(parent, exp.SetOperation): + continue + if parent is None: + out.append((node, "result", None)) + elif isinstance(parent, (exp.CTE, exp.Lateral)) or (wrappers and isinstance(parent, (exp.From, exp.Join))): + # 别名(和列名表)记在 WITH 的那一段、LATERAL 上,FROM 里的子查询记在带别名的那层括号上 + alias = next((h.args["alias"] for h in [parent, *reversed(wrappers)] if h.args.get("alias")), None) + listed = [c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None + out.append((node, "source", listed)) + else: + out.append((node, "other", None)) + return out + + +def _source_names(top: Any, listed: list[str] | None, names: set[str]) -> tuple[set[str], bool]: + """派生来源的结果里值取自遮罩列的那几列的名字,以及有没有认不出名字的(没起别名的表达式、两个 * 之间的项)。 + 写了列名表的按位置对上列名表;没写的,单独一条 SELECT 取各项自己的列名,UNION 这类按位置对上第一支的列名。""" + branches = _branches(top) + found: set[str] = set() + unknown = False + + def take(name: str | None) -> None: + nonlocal unknown + if name is None: + unknown = True + else: + found.add(name) + + if listed is not None: + for branch in branches: + for item, place in zip(branch, _places(branch, len(listed))): + if names & _value_columns(item): + take(listed[place] if place is not None and place < len(listed) else None) + return found, unknown + first = branches[0] if branches else [] + for item in first: + if names & _value_columns(item): + take(_output_name(item)) + if len(branches) > 1: + # 每一支一样宽:拿一支没有 * 的定宽度,* 后面的项才数得出位置 + width = next((len(b) for b in branches if not any(_is_star(i) for i in b)), None) + heads = {place: _output_name(item) for item, place in zip(first, _places(first, width)) if place is not None} + for branch in branches[1:]: + for item, place in zip(branch, _places(branch, width)): + if names & _value_columns(item): + take(heads.get(place) if place is not None else None) + return found, unknown + + +def _tainted(sql: str, kind: str | None, masked: set[str], width: int) -> tuple[set[str], set[int]] | None: + """SQL 里指代遮罩列的名字(小写),和结果(width 列)里值取自遮罩列的那几列的位置。解析不了返回 None。 名字从遮罩列本身起,一层层传下去:选出列的值取自这些名字的(buyer_phone AS 手机号、UPPER(buyer_phone) AS x),它的 - 别名也算;派生来源写了列名表的(WITH t(p) AS (SELECT buyer_phone …)),按位置对上的列名也算,直到不再多出名字。 - 同名不同层的也一并算上(宁可多遮)。位置是给没起别名的表达式用的:UPPER(buyer_phone) 的结果列名对不上任何名字。""" + 名字也算;派生来源(子查询、WITH 里的一段)按位置对上列名表、UNION 第一支的列名,也算,直到不再多出名字。同名 + 不同层的也一并算上(宁可多遮)。 + 位置是给没起别名的表达式用的(UPPER(buyer_phone) 的结果列名对不上任何名字):和 * 写在同一支里的,* 前面的从头数、 + 后面的从末尾倒着数。认不出的——两个 * 之间的项、派生来源里没起别名的表达式(外层的 * 会把它原样带出来,列名 + 又认不准)——整份快照的列都算(同解析不了)。""" from sqlglot import exp from app.data.sql_semantics import _parse, _read @@ -675,28 +784,32 @@ def _tainted(sql: str, kind: str | None, masked: set[str]) -> tuple[set[str], se try: names = set(masked) selects = list(root.find_all(exp.Select)) - lists = [a for a in root.find_all(exp.TableAlias) if a.columns - and isinstance(a.parent, (exp.CTE, exp.Subquery)) and isinstance(a.parent.this, exp.Select)] - for _ in range(len(selects) + len(lists) + 1): + blocks = _blocks(root) + sources = [(top, listed) for top, role, listed in blocks if role == "source"] + for _ in range(len(selects) + len(sources) + 1): before = len(names) for select_ in selects: for item in select_.expressions: name = item.alias_or_name.lower() if name and name not in names and names & _value_columns(item): names.add(name) - for alias in lists: - for column, item in zip(alias.columns, alias.parent.this.expressions): - name = column.name.lower() - if name and name not in names and (names & _value_columns(item) - or item.alias_or_name.lower() in names): - names.add(name) + for top, listed in sources: + names |= _source_names(top, listed, names)[0] if len(names) == before: break + everything = any(_source_names(top, listed, names)[1] for top, listed in sources) positions: set[int] = set() - for branch in _branches(root): - if not any(_is_star(item) for item in branch): - positions |= {i for i, item in enumerate(branch) if names & _value_columns(item)} - return names, positions + for top, role, _listed in blocks: + if role != "result": + continue + for branch in _branches(top): + for item, place in zip(branch, _places(branch, width)): + if names & _value_columns(item): + if place is None or place >= width: + everything = True + else: + positions.add(place) + return names, set(range(width)) if everything else positions except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(宁可多遮) logger.warning("质询的 SQL 遮罩分析失败", exc_info=True) return None @@ -717,7 +830,7 @@ def _derived_hidden(snapshot: dict[str, Any], hidden: set[str], kind: str | None if not base or not isinstance(sql, str) or not sql.strip() or snapshot.get("source") == MERGE_SOURCE: return base, base columns = [str(c) for c in snapshot.get("columns") or []] - found = _tainted(sql, kind, base) + found = _tainted(sql, kind, base, len(columns)) if found is None: return (base | {c.lower() for c in columns}, base) if _mentions(sql, base) else (base, base) names, positions = found @@ -791,9 +904,9 @@ class _Secrets: """遮罩列里出现过的值。模型看得到原始的查询结果(工具交回的是原样),回答里照着复述、或者把值写进查询条件, 都会把它带进回答正文和查询清单;按值找出来换成「已遮罩」。这只减少暴露,不是安全边界(同证据面板的遮罩)。 - strong 是按值认得出的写法(够长的数、两个字以上的文字),正文、SQL 的字符串和注释、别的列里的格子都按它换;weak 是 - 遮罩列里的全部写法(含短的数),只用在 SQL 里和遮罩列直接比较的字面量上。换的时候先按词、按开头两个字筛出正文里 - 真有的那几个值,再拿它们现编正则:遮罩值上万个时,现编一个上万分支的正则要一秒上下,每次都卡住整个进程。""" + strong 是按值认得出的写法(够长的数、两个字以上的文字),正文、SQL 里的字符串、名字和注释、别的列里的格子、列名都按 + 它换;weak 是遮罩列里的全部写法(含短的数),只用在 SQL 里和遮罩列做等值比较的字面量上。换的时候先按词、按开头两个 + 字筛出正文里真有的那几个值,再拿它们现编正则:遮罩值上万个时,现编一个上万分支的正则要一秒上下,每次都卡住整个进程。""" strong: set[str] = field(default_factory=set) weak: set[str] = field(default_factory=set) @@ -823,9 +936,12 @@ class _Secrets: self.strong.add(form) self._index = None - def protect(self, texts: Iterable[str | None]) -> None: - """提问人自己写进问题、选中原文里的值不换(「13900000001 这位客户」「排除 300 以下的」):问题本身就带着它 - (事件里原样记着),换了只会让回答、查询对不上问题。""" + def protect(self, texts: Iterable[str | None], *, strong_only: bool = False) -> None: + """已经摆在读者眼前的值不换:提问人自己写进问题、选中原文里的(「13900000001 这位客户」「排除 300 以下的」), + 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。 + + strong_only:只免正文、快照里按值认的(SQL 里照原样留着的阈值、LIMIT,见 _shown_literals);SQL 里和遮罩列 + 做等值比较的照旧换(weak 不动)。""" given = " ".join(t for t in texts if t) if not given: return @@ -835,7 +951,8 @@ class _Secrets: return _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given self.strong = {f for f in self.strong if not asked(f)} - self.weak = {f for f in self.weak if not asked(f)} + if not strong_only: + self.weak = {f for f in self.weak if not asked(f)} self._index, self._compiled = None, {} def hit(self, value: Any) -> bool: @@ -911,19 +1028,36 @@ class _Secrets: return self._index -def _outside_markers(text: str, secrets: _Secrets) -> str: - """只换引用标记之外的文字:标记里是编号、行号、列名,换了会把一个好好的引用弄坏。""" - pattern = secrets.pattern(text) - if pattern is None: +#: 单元格引用的前一截(Q1.r0.):后面才是列名 +_CELL_HEAD = re.compile(r"^\s*[QK]\d+\.r\d+\.") + + +def _scrubbed_source(text: str, secrets: _Secrets) -> str: + """回答原文里的遮罩值换成「已遮罩」:标记之外按值换;标记里的按段换(| 隔开的几段各自换),单元格引用只换列名那 + 一截——编号、行号换不出东西,整段换的话「r0.」会让列名开头的长号码看着像小数的一截,反而认不出来。 + 标记里写着遮罩值的引用换完多半解析不了(列名里写着遮罩值的除外:快照的列名也换过了,正好对上),宁可弄坏一个 + 引用,也不让值留在存下的原文、片段里,或者从解析不了的占位里露出来。""" + if not text or not secrets.strong: return text - spans = [m.span() for m in MARKER_RE.finditer(text)] - word = _masked_word() - return pattern.sub(lambda m: m.group(0) if any(s < m.end() and m.start() < e for s, e in spans) else word, text) + out: list[str] = [] + last = 0 + for m in MARKER_RE.finditer(text): + out.append(secrets.scrub(text[last:m.start()])) + start, end = m.span("body") + parts = text[start:end].split("|") + head = _CELL_HEAD.match(parts[0]) if m.group("kind").strip() == "v" else None + cut = head.end() if head else 0 + parts = [parts[0][:cut] + secrets.scrub(parts[0][cut:]), *(secrets.scrub(p) for p in parts[1:])] + out.append(text[m.start():start] + "|".join(parts) + text[end:m.end()]) + last = m.end() + out.append(secrets.scrub(text[last:])) + return "".join(out) def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) -> tuple[dict[str, Any], list[str]]: """一份查询快照遮好的样子和遮掉的列:要遮的列(hidden,小写)每一格都换成「已遮罩」、类型记成文本(渲染时不当数); 别的列里写着按值认得出的遮罩值的格子也换(另一张表里没设遮罩的同一个手机号、改了名字没认出来的遮罩列)。 + 列名里写着遮罩值的(agent 把遮罩值写进了别名,「13900000001 的单」),列名也换(同 SQL 里的名字,_scrub_sql)。 原快照不动;没有要遮的返回原快照。""" columns = [str(c) for c in snapshot.get("columns") or []] index = {i for i, c in enumerate(columns) if c.lower() in hidden} @@ -933,6 +1067,7 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) word = _masked_word() touched = set(index) position = {c: i for i, c in enumerate(columns)} + names = [secrets.scrub(c) for c in columns] if by_value else columns def cell(i: int | None, value: Any) -> Any: if i in index: @@ -943,82 +1078,132 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) return word return value + def key(k: Any) -> Any: + i = position.get(str(k)) + return names[i] if i is not None else k + rows: list[Any] = [] for row in snapshot["rows"]: if isinstance(row, list): rows.append([cell(i, v) for i, v in enumerate(row)]) elif isinstance(row, dict): - rows.append({k: word if str(k).lower() in hidden else cell(position.get(str(k)), v) + rows.append({key(k): word if str(k).lower() in hidden else cell(position.get(str(k)), v) for k, v in row.items()}) else: rows.append(row) - if not touched: + renamed = names != columns + if not touched and not renamed: return snapshot, [] - 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": rows, "column_types": types}, [columns[i] for i in sorted(touched)] + types = {key(k): v for k, v in snapshot["column_types"].items()} \ + if isinstance(snapshot.get("column_types"), dict) else {} + types.update({names[i]: "text" for i in index}) + view = {**snapshot, "rows": rows, "column_types": types, **({"columns": names} if renamed else {})} + return view, [names[i] for i in sorted(touched)] -_COMPARE_TOKENS = frozenset({"EQ", "NEQ", "GT", "GTE", "LT", "LTE", "LIKE", "ILIKE", "GLOB", "RLIKE", "NULLSAFE_EQ"}) -_LITERAL_TOKENS = frozenset({"STRING", "NUMBER"}) +#: 在找某一个值的比较:col = 'x'、col <> 'x'、col LIKE 'x'、SQLite 的 col IS 'x'(IN 列表另外认) +_EQUAL_TOKENS = frozenset({"EQ", "NEQ", "NULLSAFE_EQ", "LIKE", "ILIKE", "GLOB", "RLIKE", "IS"}) +#: 范围比较:边界是阈值,不是在找某一个值(BETWEEN 另外认) +_RANGE_TOKENS = frozenset({"GT", "GTE", "LT", "LTE"}) _NAME_TOKENS = frozenset({"VAR", "IDENTIFIER"}) +#: 字面量前面可能连着的:正负号、MySQL 的字符集前缀(_utf8mb4'x') +_LEADS = frozenset({"DASH", "PLUS", "INTRODUCER"}) +#: 能结束一个值的词:减号跟在它们后面是两数相减,不是负号 +_VALUE_ENDS = frozenset({"NUMBER", "VAR", "IDENTIFIER", "R_PAREN", "R_BRACKET", "NULL", "TRUE", "FALSE"}) -def _against(tokens: list[Any], i: int, compared: set[str]) -> bool: - """第 i 个字面量是不是直接和遮罩列比较:col = 'x'、col IN ('x', …)、col NOT LIKE 'x'、col BETWEEN a AND b, - 以及 'x' = col。列可以带表名(s.buyer_phone),可以是遮罩列的别名(compared 里已经有)。""" - kinds = [t.token_type.name for t in tokens] - j = i - 1 +def _is_literal(kind: str) -> bool: + """字符串(含 N'…'、E'…'、$$…$$、U&'…' 这些带前缀的变体)和数字。""" + return kind == "NUMBER" or kind.endswith("STRING") + + +def _operand_start(kinds: list[str], k: int) -> int: + """第 k 个字面量连同它前面的负号、字符集前缀从哪个词开始(-25、_utf8mb4'x')。""" + while k >= 1 and kinds[k - 1] in _LEADS and ( + kinds[k - 1] == "INTRODUCER" or k < 2 or not (kinds[k - 2] in _VALUE_ENDS or _is_literal(kinds[k - 2]))): + k -= 1 + return k + + +def _name_at(tokens: list[Any], kinds: list[str], k: int) -> str | None: + return tokens[k].text.lower() if 0 <= k < len(kinds) and kinds[k] in _NAME_TOKENS else None + + +def _context(tokens: list[Any], kinds: list[str], i: int) -> tuple[str, str | None]: + """第 i 个字面量用在哪:("equal", 列名) 在找某一个值——col = 'x'、col NOT LIKE 'x'、col IS NOT 25、 + col IN ('x', …)、'x' = col;("range", None) 范围比较的边界——col > 25、col BETWEEN a AND b;("other", None) 别的 + (LIMIT / OFFSET、选出列、函数参数、算式)。列名是比较另一边的那个名字(s.buyer_phone 取 buyer_phone,小写), + 另一边不是单独一个名字(函数、算式)时为 None。""" + j = _operand_start(kinds, i) - 1 + if j >= 1 and kinds[j] == "NOT" and kinds[j - 1] == "IS": + return "equal", _name_at(tokens, kinds, j - 2) + if j >= 0 and kinds[j] in _EQUAL_TOKENS: + return "equal", _name_at(tokens, kinds, j - 2 if j >= 1 and kinds[j - 1] == "NOT" else j - 1) + if j >= 0 and (kinds[j] in _RANGE_TOKENS or kinds[j] == "BETWEEN"): + return "range", None + if j >= 1 and kinds[j] == "AND" and _is_literal(kinds[j - 1]): + # BETWEEN a AND b 的 b + lower = _operand_start(kinds, j - 1) - 1 + if lower >= 0 and kinds[lower] == "BETWEEN": + return "range", None + # IN 列表:往回越过逗号和别的字面量,找到「IN (」 k = j - while k >= 0 and kinds[k] in ("COMMA", "STRING", "NUMBER", "DASH"): + while k >= 0 and (kinds[k] == "COMMA" or kinds[k] in _LEADS or _is_literal(kinds[k])): k -= 1 if k >= 1 and kinds[k] == "L_PAREN" and kinds[k - 1] == "IN": - j = k - 2 - elif j >= 0 and kinds[j] in _COMPARE_TOKENS: - j -= 1 - elif j >= 2 and kinds[j] == "AND" and kinds[j - 1] in _LITERAL_TOKENS and kinds[j - 2] == "BETWEEN": - j -= 3 - elif j >= 0 and kinds[j] == "BETWEEN": - j -= 1 - else: - # 字面量在左边:'x' = col、'x' = t.col - if i + 2 < len(tokens) and kinds[i + 1] in _COMPARE_TOKENS and kinds[i + 2] in _NAME_TOKENS: - last = i + 2 - while last + 2 < len(tokens) and kinds[last + 1] == "DOT" and kinds[last + 2] in _NAME_TOKENS: - last += 2 - return tokens[last].text.lower() in compared - return False - if j >= 0 and kinds[j] == "NOT": - j -= 1 - return j >= 0 and kinds[j] in _NAME_TOKENS and tokens[j].text.lower() in compared + return "equal", _name_at(tokens, kinds, k - 3 if k >= 2 and kinds[k - 2] == "NOT" else k - 2) + # 字面量在左边:'x' = col、25 < t.col + after = i + 1 + if after < len(kinds) and kinds[after] in _RANGE_TOKENS: + return "range", None + if after < len(kinds) and kinds[after] in _EQUAL_TOKENS: + last = after + 1 + while last + 2 < len(kinds) and kinds[last + 1] == "DOT" and kinds[last + 2] in _NAME_TOKENS: + last += 2 + return "equal", _name_at(tokens, kinds, last) + return "other", None -def _replace_literal(raw: str, kind: str, change: Callable[[str], str]) -> str: - """字面量的原文 → 换过的原文:字符串只换引号里面的(引号、N'' 这类前缀照留),数整个换。""" - if kind == "NUMBER": - return change(raw) - for quote in ("'", '"'): - start = raw.find(quote) - if start >= 0 and len(raw) - start >= 2 and raw.endswith(quote): - return raw[:start + 1] + change(raw[start + 1:-1]) + quote +def _requote(raw: str, change: Callable[[str], str]) -> str: + """一个字面量或名字的原文 → 只换里面的字:引号、N'' E'' U&'' 这类前缀、$tag$ 照留;不带引号的整个交给 change。""" + if raw.startswith("$") and (end := raw.find("$", 1)) > 0: + tag = raw[:end + 1] + if len(raw) >= 2 * len(tag) and raw.endswith(tag): + return tag + change(raw[len(tag):-len(tag)]) + tag + for opening, closing in (("'", "'"), ('"', '"'), ("`", "`"), ("[", "]")): + start = raw.find(opening) + if start >= 0 and len(raw) - start >= 2 and raw.endswith(closing): + return raw[:start + 1] + change(raw[start + 1:-1]) + closing return change(raw) -def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None) -> str: - """SQL 里遮罩列的值换成「已遮罩」,别的一个字不动:查询清单随运行封存,它得是实际执行的那条,审批人看 what-if 的 - 条件(阈值、日期、LIMIT、别的列的条件)看的就是它。 - - - 字符串字面量、注释里按值换(同正文:够长的数、两个字以上的文字);数字字面量够长、又是遮罩值的整个换; - - 直接和遮罩列(或它的别名,compared)比较的字面量是遮罩列里出现过的值就换,短的数也换(age = 25)。 - 分词出错(引号没闭合这类)就整条按正文的办法换。""" - if not sql or not (secrets.strong or secrets.weak): - return sql +def _sql_tokens(sql: str, kind: str | None) -> tuple[list[Any], list[str]] | None: from app.data.sql_semantics import _read, _tokens try: tokens = _tokens(sql, _read(kind)) - except Exception: # noqa: BLE001 - 分词器认不了的写法:按正文的办法换 + except Exception: # noqa: BLE001 - 分词器认不了的写法(引号没闭合这类) + return None + return tokens, [t.token_type.name for t in tokens] + + +def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None) -> str: + """SQL 里遮罩列的值换成「已遮罩」,别的一个字不动:查询清单随运行封存,它得是实际执行的那条,审批人看 what-if 的 + 条件(阈值、日期、LIMIT、别的列的条件)看的就是它。只换在找某一个值的字面量(_context 的 equal): + + - 和遮罩列(或它的别名,compared)做等值、IN、LIKE 比较的,是遮罩列里出现过的值就换,短的数也换(age = 25); + 和别的列做等值比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号); + - 范围比较的边界(age > 31、BETWEEN 10000 AND 50000)、LIMIT / OFFSET、选出列和算式里的数照原样:它们是 agent + 自己定的阈值,不是在找某一个人,换了封存的就不是实际执行的语句; + - 别处的字符串(LIKE 别的列、函数参数)、注释、名字(AS "13900000001 的单"、张伟的单数)按值换(同正文:够长的数、 + 两个字以上的文字)。 + 分词出错就整条按正文的办法换。""" + if not sql or not (secrets.strong or secrets.weak): + return sql + split = _sql_tokens(sql, kind) + if split is None: return secrets.scrub(sql) + tokens, kinds = split word = _masked_word() out: list[str] = [] last = 0 @@ -1029,14 +1214,14 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None gap = sql[last:start] out.append(secrets.scrub(gap) if gap.strip() else gap) raw = sql[start:end] - kind_of = token.token_type.name - if kind_of in _LITERAL_TOKENS: - if compared and secrets.weak_hit(token.text) and _against(tokens, i, compared): - raw = _replace_literal(raw, kind_of, lambda _: word) - elif kind_of == "STRING": - raw = _replace_literal(raw, kind_of, secrets.scrub) - elif secrets.hit(token.text): - raw = word + if _is_literal(kinds[i]): + use, column = _context(tokens, kinds, i) + if use == "equal" and ((column in compared and secrets.weak_hit(token.text)) or secrets.hit(token.text)): + raw = _requote(raw, lambda _: word) + elif use != "range" and kinds[i] != "NUMBER": + raw = _requote(raw, secrets.scrub) + elif kinds[i] in _NAME_TOKENS: + raw = _requote(raw, secrets.scrub) out.append(raw) last = end rest = sql[last:] @@ -1044,6 +1229,22 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None return "".join(out) +def _shown_literals(sql: str, kind: str | None) -> list[str]: + """SQL 里不管是什么值都照原样留着的字面量(_scrub_sql 不换的两种):范围比较的边界,LIMIT、选出列这类地方的数。 + 它们随 SQL 摆在读者眼前,回答正文、快照里同样的值再遮也遮不住什么,只会让回答和 SQL 对不上。""" + split = _sql_tokens(sql, kind) if sql else None + if split is None: + return [] + tokens, kinds = split + out: list[str] = [] + for i, token in enumerate(tokens): + if _is_literal(kinds[i]): + use = _context(tokens, kinds, i)[0] + if use == "range" or (use == "other" and kinds[i] == "NUMBER"): + out.append(token.text) + return out + + @dataclass class _Plan: """一次回答(或一次取质询工件)的遮罩:agent 每份查询快照遮好的样子(views)和遮掉的列(masked)、SQL 里指代 @@ -1068,9 +1269,11 @@ async def _source_kinds(names: Iterable[str]) -> dict[str, str | None]: return {str(name): kind for name, kind in rows} -async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], given: list[str | None]) -> _Plan: +async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], given: list[str | None], + shown: list[tuple[str, str]]) -> _Plan: """own:agent 自己的查询快照(工件 id → 内容);others:报告自己的查询快照(被问的那句可能直接写着遮罩列的值, - agent 会照着复述、写进查询条件);given:问题和选中的原文(里面的值不换)。 + agent 会照着复述、写进查询条件);given:问题、选中的原文、报告里列出的指标和运行输入的值(都已经摆在读者眼前, + 里面的值不换);shown:agent 执行的 SQL 和它查的数据源名(照原样留在 SQL 里的阈值、LIMIT,正文里同样不换)。 查数据源的遮罩要读库,在这里读;解析 SQL、收上万个值、逐格比对是纯计算,放进线程:放在事件循环里会挡住同一进程 里别的运行(同 tools/datasource 的 _sql_checks)。""" @@ -1079,13 +1282,14 @@ async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], gi masks = _Masks() named = {artifact: await _hidden_columns(snapshot, masks) for artifact, snapshot in own.items()} based = [(snapshot, (await _hidden_columns(snapshot, masks))[0]) for snapshot in others] - kinds = await _source_kinds(str(s.get("source") or "") for s in [*own.values(), *others]) - return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given) + kinds = await _source_kinds([*(str(s.get("source") or "") for s in [*own.values(), *others]), + *(name for _, name in shown)]) + return await asyncio.to_thread(_settle_plan, own, named, based, kinds, given, shown) def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str], list[str]]], based: list[tuple[dict[str, Any], set[str]]], kinds: dict[str, str | None], - given: list[str | None]) -> _Plan: + given: list[str | None], shown: list[tuple[str, str]]) -> _Plan: secrets = _Secrets() hidden: dict[str, set[str]] = {} tainted: dict[str, set[str]] = {} @@ -1096,6 +1300,8 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] for snapshot, base in based: secrets.add(snapshot, _derived_hidden(snapshot, base, kinds.get(str(snapshot.get("source") or "")))[0]) secrets.protect(given) + secrets.protect([value for sql, name in shown for value in _shown_literals(sql, kinds.get(name))], + strong_only=True) secrets.prepare() views: dict[str, dict[str, Any]] = {} masked: dict[str, list[str]] = {} @@ -1124,7 +1330,7 @@ def _masking_loader(views: dict[str, dict[str, Any]]) -> Callable[[str], Any]: class _Leak(Exception): - """组装了两遍,正文里还认得出遮罩值(渲染出来才露的,比如恰好是遮罩值的运行输入):这份回答不交出去。""" + """组装出来的正文里还认得出遮罩值(渲染出来才露的,比如别的列里一格文字夹着手机号):这份回答不交出去。""" def _limit(outcome: VerifyOutcome) -> str | None: @@ -1195,7 +1401,8 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict own[str(query["artifact"])] = snapshot others = [snapshot for entry in catalog.values() if isinstance(entry, dict) and entry.get("kind") == "query" and _query_shaped(snapshot := _quiet_load(op.loader, entry.get("artifact")))] - plan = await _plan(own, others, [ask.question, ask.quote]) + plan = await _plan(own, others, [ask.question, ask.quote, *_ref_values(refs)], + [(str(q.get("sql") or ""), str(q.get("source") or "")) for q in outcome.queries]) text, doc_id, failed = "", None, None source = (outcome.rationale or {}).get("source") if outcome.result is not None else None @@ -1249,21 +1456,28 @@ def _quiet_load(loader: Callable[[str], Any], artifact: Any) -> Any: def _compose(source: str, catalog: dict[str, Any], *, run_id: str, views: dict[str, dict[str, Any]], secrets: _Secrets, allow: list[str]) -> dict[str, Any]: """agent 写的回答原文(带引用标记)→ 回答文档:目录是 agent 自己的查询,加上报告里的指标和运行输入;快照按遮罩 - 遮好再渲染。遮罩值先在标记之外的文字里换掉;换完正文里还认得出遮罩值(标记里写了值,解析不了,原样出现在正文里), - 就连标记一起换了再组装一遍——宁可弄坏一个引用,也不让值露出来;还认得出(渲染出来才露的),抛 _Leak,不交出去。 - allow 是提问人自己写的数(_given_numbers),回答里复述它们不标「无出处」。""" + 遮好再渲染。遮罩值在组装之前换掉(_scrubbed_source);组装出来正文里还认得出遮罩值(渲染出来才露的,比如别的列 + 里一格文字夹着手机号),抛 _Leak,不交出去。allow 是提问人自己写的数(_given_numbers),回答里复述它们不标 + 「无出处」。""" loader = _masking_loader(views) allowed = list(allow) or None - doc = compose_doc(_outside_markers(source, secrets), catalog, run_id=run_id, loader=loader, allow_numbers=allowed) + # 列名里写着遮罩值的查询:目录里的列名照快照换(_masked_view),整表展开、表头核对、存下的目录都按换过的名字 + catalog = {alias: _renamed_entry(entry, secrets) for alias, entry in catalog.items()} + doc = compose_doc(_scrubbed_source(source, secrets), catalog, run_id=run_id, loader=loader, + allow_numbers=allowed) markdown = str(doc.get("markdown") or "") if secrets.scrub(markdown) != markdown: - doc = compose_doc(secrets.scrub(source), catalog, run_id=run_id, loader=loader, allow_numbers=allowed) - markdown = str(doc.get("markdown") or "") - if secrets.scrub(markdown) != markdown: - raise _Leak() + raise _Leak() return doc +def _renamed_entry(entry: Any, secrets: _Secrets) -> Any: + if not (isinstance(entry, dict) and entry.get("kind") == "query" and isinstance(entry.get("columns"), list)): + return entry + names = [secrets.scrub(str(c)) for c in entry["columns"]] + return {**entry, "columns": names} if names != [str(c) for c in entry["columns"]] else entry + + def _with_notes(answer: dict[str, Any], op: _Opening, outcome: VerifyOutcome, part: dict[str, Any], lost: list[str], own: dict[str, dict[str, Any]]) -> dict[str, Any]: """回答末尾的系统说明:用不了的数据源、模型的轮数用完了、有查询执行失败、有引用解析不了、封存之后查的是现在的数据。 @@ -1393,11 +1607,12 @@ def _cited_cards(doc_id: Any) -> set[str]: if isinstance(e, dict) and e.get("kind") in ("metric", "input") and e.get("artifact")} -async def _report_snapshots(run_id: str, reports: Iterable[Any]) -> list[dict[str, Any]]: - """这几份报告(报告节点 id)的文档目录里的查询快照:和回答时收遮罩值的是同一批(报告最后一次 report.checked 的文档)。""" +async def _report_material(run_id: str, reports: Iterable[Any]) -> tuple[list[dict[str, Any]], list[str]]: + """这几份报告(报告节点 id)的文档目录里的查询快照,和列出的指标、运行输入的值:和回答时收遮罩值、免遮的是同一批 + (报告最后一次 report.checked 的文档)。""" wanted = sorted({str(r) for r in reports if isinstance(r, str) and r}) if not wanted: - return [] + return [], [] async with SessionLocal() as session: rows = await session.execute( select(RunEvent.node_id, RunEvent.data) @@ -1405,16 +1620,18 @@ async def _report_snapshots(run_id: str, reports: Iterable[Any]) -> list[dict[st .order_by(RunEvent.seq)) docs = {node_id: str(data["doc_artifact"]) for node_id, data in rows if isinstance(data, dict) and data.get("doc_artifact")} - out: list[dict[str, Any]] = [] + snapshots: list[dict[str, Any]] = [] + values: list[str] = [] for artifact in docs.values(): doc = _quiet_load(artifact_store.load, artifact) catalog = doc.get("catalog") if isinstance(doc, dict) and isinstance(doc.get("catalog"), dict) else {} + values += _ref_values(_report_refs(catalog)) for entry in catalog.values(): if isinstance(entry, dict) and entry.get("kind") == "query": snapshot = _quiet_load(artifact_store.load, entry.get("artifact")) if _query_shaped(snapshot): - out.append(snapshot) - return out + snapshots.append(snapshot) + return snapshots, values async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: @@ -1440,15 +1657,16 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: if not _query_shaped(content): return {"id": artifact_id, "content": content, "masked": []} # 遮罩值照回答时收:引用它的那几条质询各自查到的全部快照(查询条件里写着的值,不一定在这一份的结果里),加上报告 - # 自己的查询快照;提问人自己写的值不换 + # 自己的查询快照;免遮的也照回答时:提问人自己写的、报告里列出的指标和运行输入的值、SQL 里照原样留着的阈值 own: dict[str, dict[str, Any]] = {artifact_id: content} for data in referenced: for other in dict.fromkeys(str(a) for a in data.get("inquiry_artifacts") or []): if other not in own and _query_shaped(snapshot := _quiet_load(artifact_store.load, other)): own[other] = snapshot - others = await _report_snapshots(run_id, (d.get("report") for d in referenced)) - plan = await _plan(own, others, [t for d in referenced for t in (d.get("question"), d.get("quote")) - if isinstance(t, str)]) + others, values = await _report_material(run_id, (d.get("report") for d in referenced)) + asked = [t for d in referenced for t in (d.get("question"), d.get("quote")) if isinstance(t, str)] + plan = await _plan(own, others, [*asked, *values], + [(str(s.get("sql") or ""), str(s.get("source") or "")) for s in own.values()]) shown = plan.views[artifact_id] recorded = next((q["sql"] for d in referenced for q in d.get("queries") or [] if isinstance(q, dict) and q.get("artifact") == artifact_id and isinstance(q.get("sql"), str)), diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index f5997e16..a7ff5dee 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -11,8 +11,9 @@ 设置里关了 409;没停在那张审批卡上、还在执行(按情形说下一步)、封存核对不通过、开放途中运行被继续了都 409; 事件里记的报告文档不是这次运行的 422;输入不合法 422,指明是哪一项 - 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出(提问人自己写的数不标);遮罩列(起了 - 别名的也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现遮罩列的值;短的遮罩值不误伤阈值、 - 天数、LIMIT + 别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现 + 遮罩列的值;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标;质询工件按值遮 + 只出现在报告快照里的遮罩值 - 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 数据源、没固定过版本的上传表格不查 - 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 @@ -162,6 +163,14 @@ def gated(source: str, *, gate: bool = True) -> dict: ) +def fetching(source: str, sql: str) -> dict: + """取数(给定的 SQL)→ 报告 → 人工审批 → 成果。""" + return chain(node("start", "input"), node("fetch", "tool", tool=f"db_query__{source}", args={"sql": sql}), + node("write", "report", "写周报", instructions="写周报"), + node("gate", "human", mode="approve", title="周报可以出具吗"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + + @dataclass class Play: """质询 agent 对某个问题的剧本:先看几次表结构(schema),再依次执行的查询,最后交的回答。final 给了就照它交 @@ -979,6 +988,154 @@ async def test_numbers_that_merely_match_a_short_masked_value_are_left_alone(mon await engines.invalidate(source_id) +STAR_TAIL = "SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1" + + +async def test_an_unnamed_expression_next_to_a_star_is_masked_everywhere(monkeypatch, masked_shop, client): + """和 * 写在同一支里、没起别名的表达式(取手机号的后几位):结果的列名对不上任何名字,按位置认(从末尾倒着数)。 + 回答、事件、回答文档、质询工件里都不露出原值的任何一截。""" + plays = {"买得最多": Play([STAR_TAIL], "金额最高的一笔来自尾号 [[v:Q1.r0.SUBSTR(buyer_phone, 4)]] 的客户," + "金额 [[v:Q1.r0.amount]]。[[see:Q1]]")} + Models(monkeypatch, masked_shop, plays=plays) + paused = await pause(masked_shop) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="买得最多的是哪位客户?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "金额最高的一笔来自尾号 已遮罩 的客户,金额 1,200。" + [query] = item["queries"] + assert query["sql"] == STAR_TAIL + [event] = await events(paused.id, "approval.inquiry") + doc = artifact_store.load(item["answer"]["doc_artifact"]) + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200, got.text + for shown in (r.text, json.dumps(event.data, ensure_ascii=False), json.dumps(doc, ensure_ascii=False), got.text): + assert "00000001" not in shown and "1390000000" not in shown + assert got.json()["content"]["rows"] == [[1, "华东", "已遮罩", 1200, 0, "已遮罩"]] + assert got.json()["masked"] == ["buyer_phone", "SUBSTR(buyer_phone, 4)"] + + +#: 金额设了遮罩的大单:金额都是五位数,和阈值、LIMIT、访问次数的条件撞在一起 +BIG = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, visits INTEGER);" + "INSERT INTO sales VALUES (1, '华东', 10000, 25000), (2, '华东', 12000, 18000), (3, '华南', 20000, 30000)," + " (4, '华南', 30000, 22000), (5, '华北', 50000, 9000);") +#: 报告取数选了金额:金额列里的值都算遮罩值 +AMOUNTS = "SELECT region AS 区域, amount AS 金额 FROM sales ORDER BY id" + + +@pytest.fixture +async def big_shop(tmp_path): + name, source_id = await _source(tmp_path, options={"mask_columns": "amount"}, script=BIG) + yield name + await engines.invalidate(source_id) + + +async def test_thresholds_and_limits_are_sealed_as_written_even_when_they_match_masked_values( + monkeypatch, big_shop, client): + """遮罩列的值都是五位数:范围比较的边界(和金额比的、和访问次数比的)、LIMIT 撞上遮罩值也照原样封存,回答正文里 + 复述它们也不遮——它们就摆在 SQL 里。在找某一笔的(amount = 50000)照旧遮。""" + large = "SELECT COUNT(*) AS 笔数 FROM sales WHERE amount >= 30000 AND visits > 20000 LIMIT 10000" + exact = "SELECT COUNT(*) AS 笔数 FROM sales WHERE amount = 50000" + plays = {"大单": Play([large, exact], "金额不低于 30000 元、访问超过 20000 次的大单有 [[v:Q1.r0.笔数]] 笔," + "恰好 50000 元的有 [[v:Q2.r0.笔数]] 笔。[[see:Q1]]")} + Models(monkeypatch, big_shop, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + paused = await pause(big_shop, graph=fetching(big_shop, AMOUNTS)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="3 万元以上的大单有几笔?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "金额不低于 30000 元、访问超过 20000 次的大单有 1 笔,恰好 已遮罩 元的有 1 笔。" + sealed = [large, "SELECT COUNT(*) AS 笔数 FROM sales WHERE amount = 已遮罩"] + assert [q["sql"] for q in item["queries"]] == sealed + [event] = await events(paused.id, "approval.inquiry") + assert [q["sql"] for q in event.data["queries"]] == sealed + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{item['queries'][0]['artifact']}") + assert got.status_code == 200 and got.json()["content"]["sql"] == large + doc = artifact_store.load(item["answer"]["doc_artifact"]) + assert [s["text"] for s in iter_segments(doc) if s.get("issue") == "uncited_number"] == ["30000", "20000"], \ + "不遮不等于有出处:agent 自己定的阈值照样标「无出处」" + + +async def test_a_metric_from_the_report_that_equals_a_masked_value_can_be_cited(monkeypatch, big_shop, client): + """报告口径卡里的指标(单笔目标 20000)恰好和遮罩列里的某一笔一样:报告里本来就看得到,引用它的回答照常交出去, + 换个问法也不会被拦。""" + metrics = [{"id": "target", "name": "单笔目标", "unit": "元", "expression": "vars.kpi.target"}] + graph = chain(node("start", "input"), + node("fetch", "tool", tool=f"db_query__{big_shop}", args={"sql": AMOUNTS}), + node("calc", "transform", mode="expression", expression="{'target': 20000}", assign_to="kpi"), + node("caliber", "metrics", caliber="大单口径", caliber_version="v1", metrics=metrics), + node("write", "report", "写周报", instructions="写周报"), + node("gate", "human", mode="approve", title="周报可以出具吗"), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) + smallest = "SELECT amount AS 金额 FROM sales WHERE amount > 15000 ORDER BY amount LIMIT 1" + plays = {"定得高不高": Play([smallest], "单笔目标为 [[m:target]],达标的最小一笔是 [[v:Q1.r0.金额]]。[[see:Q1]]")} + Models(monkeypatch, big_shop, writer="单笔目标 [[m:target]]。[[see:m:target]]", plays=plays) + paused = await pause(big_shop, graph=graph) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="单笔目标定得高不高?", + approval_id=approval.id) + assert r.status_code == 200, r.text + answer = r.json()["inquiry"]["answer"] + assert (answer["ok"], answer["error"]) == (True, None) + assert answer["text"] == "单笔目标为 20,000元,达标的最小一笔是 已遮罩。" + + +async def test_a_masked_value_the_asker_wrote_stays_in_the_sql(monkeypatch, tmp_path, client): + """提问人自己写的数恰好是遮罩列里的值(金额设了遮罩,问的就是 300 那一笔):问题里原样写着,SQL 里和金额做等值 + 比较也不换,事件、质询工件里封存的就是实际执行的那条。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "amount"}) + without = "SELECT COUNT(*) AS 笔数 FROM sales WHERE amount <> 300" + plays = {"300 的那笔": Play([without], "去掉金额为 300 的那笔后还剩 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")} + Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, AMOUNTS)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="去掉金额为 300 的那笔会怎样?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "去掉金额为 300 的那笔后还剩 3 笔。" + [query] = item["queries"] + assert query["sql"] == without + [event] = await events(paused.id, "approval.inquiry") + assert event.data["queries"][0]["sql"] == without + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200 and got.json()["content"]["sql"] == without + finally: + await engines.invalidate(source_id) + + +#: 另有一张联系方式表:同一个手机号写在没设遮罩的列里 +CONTACTS = SALES + ("CREATE TABLE contacts (id INTEGER PRIMARY KEY, phone_num TEXT, visits INTEGER);" + "INSERT INTO contacts VALUES (1, '13900000001', 5), (2, '13900000003', 2);") + + +async def test_a_value_seen_only_in_the_reports_snapshot_is_masked_in_the_artifact_too(monkeypatch, tmp_path, client): + """agent 查的表没设遮罩,里面却写着报告快照里遮罩列的值(另一张表里的同一个手机号):质询工件照回答时的办法收 + 遮罩值,报告自己的查询快照也算,按值遮掉。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=CONTACTS) + visits = "SELECT phone_num AS 号码, visits AS 次数 FROM contacts ORDER BY id" + plays = {"常来": Play([visits], "最常来的客户来过 [[v:Q1.r0.次数]] 次。[[see:Q1]]")} + Models(monkeypatch, name, writer="金额最高的一笔 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + report_sql = "SELECT buyer_phone, amount AS 金额 FROM sales ORDER BY amount DESC" + paused = await pause(name, graph=fetching(name, report_sql)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="常来的客户来过几次?", + approval_id=approval.id) + assert r.status_code == 200, r.text + assert r.json()["inquiry"]["answer"]["text"] == "最常来的客户来过 5 次。" + [query] = r.json()["inquiry"]["queries"] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["已遮罩", 5], ["已遮罩", 2]] + assert got.json()["masked"] == ["号码"] and "1390000000" not in got.text + finally: + await engines.invalidate(source_id) + + # -------------------------------------------------------------------------- # 接线:两道审批的竞态、上传表格的版本、设置、开放之后状态又变了、张冠李戴的报告文档 # -------------------------------------------------------------------------- diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index 146dddad..ec5a3d82 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -1,19 +1,21 @@ """审批质询的遮罩(engine/inquiry.py):回答正文、查询清单、质询工件里都不露出数据源遮罩列的值,也不误伤别的数。 -- 改了名字的遮罩列(起别名、套函数、经子查询或 CTE 转一道、UNION 的另一支)照样遮;COUNT 只数个数、窗口的分区键 - 只决定次序,不遮;解析不了、SQL 里又写着遮罩列的,整份快照遮掉 +- 改了名字的遮罩列(起别名、套函数、经子查询或 CTE 转一道、UNION 的另一支、和 * 写在同一支里没起别名的表达式) + 照样遮;COUNT 只数个数、窗口的分区键只决定次序,不遮;认不出落在哪一列的(两个 * 之间、子查询里没起别名的表达式)、 + 解析不了又写着遮罩列的,整份快照遮掉 - 按值认的遮罩值有门槛:纯数字够长(SECRET_DIGITS 位)、文字至少两个字;日期、小数、千分位里的一截不算;提问人自己 - 写进问题、选中原文里的不换 -- SQL 只换字面量(和注释)里的遮罩值:直接和遮罩列比较的字面量是遮罩列出现过的值就换,短的数也换;LIMIT、日期、 - 别的列的条件照原样 -- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」 -- 组装回答两遍之后正文里还认得出遮罩值的,不交出去 + 写进问题、选中原文里的,报告列出的指标和运行输入的值,SQL 里照原样留着的阈值,都不换 +- SQL 只换在找某一个值的字面量(等值、IN、LIKE,带前缀的字符串也算)和注释、名字里的遮罩值:和遮罩列比较的字面量是 + 遮罩列出现过的值就换,短的数也换;范围比较的边界、BETWEEN、LIMIT、日期、别的列的范围条件照原样 +- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换 +- 组装回答之后正文里还认得出遮罩值的,不交出去 - 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则 -数据都是虚构的:手机号、年龄、区域、金额。 +数据都是虚构的:手机号、姓名、年龄、区域、金额。 """ from __future__ import annotations +import json import time import pytest @@ -34,9 +36,10 @@ def secrets_of(snapshot: dict, hidden: set[str]) -> inquiry_mod._Secrets: # -------------------------------------------------------------------------- -def hidden_of(sql: str, columns: list[str], masked: set[str] = frozenset({"buyer_phone"})) -> set[str]: +def hidden_of(sql: str, columns: list[str], masked: set[str] = frozenset({"buyer_phone"}), kind: str = "sqlite" + ) -> set[str]: snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []} - return inquiry_mod._derived_hidden(snapshot, set(masked), "sqlite")[0] + return inquiry_mod._derived_hidden(snapshot, set(masked), kind)[0] @pytest.mark.parametrize("sql, columns, hidden", [ @@ -54,12 +57,70 @@ def test_a_masked_column_under_another_name_is_masked(sql, columns, hidden): assert hidden_of(sql, columns) == hidden +SALES_COLUMNS = ["id", "region", "buyer_phone", "amount", "is_test"] + + +@pytest.mark.parametrize("sql, columns, hidden", [ + # * 后面没起别名的表达式:从结果末尾倒着数(SQLite 照写法原样给列名,起不出能往下传的名字) + ("SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1", + [*SALES_COLUMNS, "SUBSTR(buyer_phone, 4)"], {"buyer_phone", "substr(buyer_phone, 4)"}), + ("SELECT s.*, buyer_phone || '-x' FROM sales s", [*SALES_COLUMNS, "buyer_phone || '-x'"], + {"buyer_phone", "buyer_phone || '-x'"}), + # sqlglot 给 CAST 记的名字是里面的列名,结果的列名却是整个写法:照样按位置认 + ("SELECT *, CAST(buyer_phone AS TEXT) FROM sales", [*SALES_COLUMNS, "CAST(buyer_phone AS TEXT)"], + {"buyer_phone", "cast(buyer_phone as text)"}), + # * 前面的从头数,* 后面不是遮罩列的不遮 + ("SELECT UPPER(buyer_phone), s.*, amount * 2 FROM sales s", ["UPPER(buyer_phone)", *SALES_COLUMNS, "amount * 2"], + {"buyer_phone", "upper(buyer_phone)"}), + # 派生来源里 UNION 的另一支:结果的列名取第一支的 + ("SELECT * FROM (SELECT region FROM sales UNION ALL SELECT buyer_phone FROM sales) t", ["region"], + {"buyer_phone", "region"}), + ("WITH t(p) AS (SELECT region FROM sales UNION ALL SELECT buyer_phone FROM sales) SELECT p FROM t", ["p"], + {"buyer_phone", "p"}), + ("SELECT * FROM (SELECT *, buyer_phone AS p FROM sales) t", [*SALES_COLUMNS, "p"], {"buyer_phone", "p"}), + # 多套几层括号不改变结果流到哪 + ("((SELECT UPPER(buyer_phone) FROM sales))", ["UPPER(buyer_phone)"], {"buyer_phone", "upper(buyer_phone)"}), + ("SELECT * FROM ((SELECT buyer_phone AS p FROM sales)) t", ["p"], {"buyer_phone", "p"}), +]) +def test_an_unnamed_expression_next_to_a_star_or_under_a_union_is_masked(sql, columns, hidden): + assert hidden_of(sql, columns) == hidden + + +@pytest.mark.parametrize("sql, columns", [ + # 两个 * 之间:前后都数不出位置 + ("SELECT a.*, SUBSTR(a.buyer_phone, 4), b.* FROM sales a JOIN sales b ON a.id = b.id", + [*SALES_COLUMNS, "SUBSTR(a.buyer_phone, 4)", *SALES_COLUMNS]), + # 子查询里没起别名的表达式:外层的 * 原样带出来,列名各数据库起法不同,认不准 + ("SELECT * FROM (SELECT SUBSTR(buyer_phone, 4), amount FROM sales) t", ["SUBSTR(buyer_phone, 4)", "amount"]), + ("SELECT * FROM (SELECT SUBSTR(buyer_phone, 4) FROM sales)", ["SUBSTR(buyer_phone, 4)"]), + ("WITH t AS ((SELECT SUBSTR(buyer_phone, 4) FROM sales)) SELECT * FROM t", ["SUBSTR(buyer_phone, 4)"]), + # UNION 第一支是 *:另一支的遮罩列落在哪个名字上说不清 + ("SELECT * FROM (SELECT * FROM sales UNION ALL SELECT id, region, SUBSTR(buyer_phone, 4), amount, is_test " + "FROM sales) t", SALES_COLUMNS), +]) +def test_a_masked_expression_that_cannot_be_placed_hides_the_whole_snapshot(sql, columns): + assert hidden_of(sql, columns) == {"buyer_phone", *(c.lower() for c in columns)} + + +def test_a_lateral_subquery_is_read_like_a_derived_table(): + """Postgres 的 LATERAL:别名记在 LATERAL 上,起了别名的照名字传,没起别名的整份遮。""" + assert hidden_of("SELECT x.* FROM sales s CROSS JOIN LATERAL (SELECT s.buyer_phone AS p) x", ["p"], + kind="postgresql") == {"buyer_phone", "p"} + columns = [*SALES_COLUMNS, "substr"] + assert hidden_of("SELECT * FROM sales s CROSS JOIN LATERAL (SELECT SUBSTR(s.buyer_phone, 4)) x", columns, + kind="postgresql") == {*columns} + + @pytest.mark.parametrize("sql, columns", [ ("SELECT region AS 区域, COUNT(DISTINCT buyer_phone) AS 买家数 FROM sales GROUP BY region", ["区域", "买家数"]), ("SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales WHERE buyer_phone = '13900000001' GROUP BY region", ["区域", "销售额"]), ("SELECT ROW_NUMBER() OVER (PARTITION BY buyer_phone ORDER BY amount) AS 序号 FROM sales", ["序号"]), ("SELECT (SELECT COUNT(*) FROM sales s2 WHERE s2.buyer_phone = s.buyer_phone) AS 笔数 FROM sales s", ["笔数"]), + # 子查询里只数个数、条件里的子查询:值带不出来,外层的 * 也不怕 + ("SELECT * FROM (SELECT region, COUNT(DISTINCT buyer_phone) FROM sales GROUP BY region) t", + ["region", "COUNT(DISTINCT buyer_phone)"]), + ("SELECT region FROM sales WHERE amount > (SELECT AVG(LENGTH(buyer_phone)) FROM sales)", ["region"]), ]) def test_counting_filtering_and_ordering_by_a_masked_column_shows_nothing(sql, columns): assert hidden_of(sql, columns) == {"buyer_phone"} @@ -144,7 +205,7 @@ def people() -> inquiry_mod._Secrets: ("SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001'", "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩'"), ("SELECT COUNT(*) FROM sales s WHERE s.buyer_phone IN ('13900000001', '13900000003') AND age BETWEEN 25 AND 31", - "SELECT COUNT(*) FROM sales s WHERE s.buyer_phone IN ('已遮罩', '已遮罩') AND age BETWEEN 已遮罩 AND 已遮罩"), + "SELECT COUNT(*) FROM sales s WHERE s.buyer_phone IN ('已遮罩', '已遮罩') AND age BETWEEN 25 AND 31"), ("SELECT COUNT(*) FROM sales WHERE age = 25 AND amount > 25 AND paid_on >= '2026-01-25' LIMIT 25", "SELECT COUNT(*) FROM sales WHERE age = 已遮罩 AND amount > 25 AND paid_on >= '2026-01-25' LIMIT 25"), ("SELECT COUNT(*) FROM sales WHERE 25 = age AND note LIKE '%13900000003%' -- 13900000001 的单\nLIMIT 20", @@ -152,11 +213,76 @@ def people() -> inquiry_mod._Secrets: ("SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40", "SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40"), ("SELECT amount FROM sales WHERE amount = 13900000001", "SELECT amount FROM sales WHERE amount = 已遮罩"), + # 和遮罩列做范围比较:边界是阈值,不是在找某个人,照原样 + ("SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31", + "SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31"), + # 不等、SQLite 的 IS、NOT LIKE 也是在找某一个值 + ("SELECT COUNT(*) FROM sales WHERE age != 25 AND age IS NOT 31 AND buyer_phone NOT LIKE '13900000001'", + "SELECT COUNT(*) FROM sales WHERE age != 已遮罩 AND age IS NOT 已遮罩 AND buyer_phone NOT LIKE '已遮罩'"), ]) def test_sql_keeps_everything_but_the_masked_values(people, sql, shown): assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, "sqlite") == shown +@pytest.fixture +def amounts() -> inquiry_mod._Secrets: + """金额设了遮罩,金额都是五位数:和阈值、LIMIT、别的列的条件撞在一起。""" + return secrets_of({"columns": ["amount"], "rows": [[10000], [12000], [20000], [30000], [50000]]}, {"amount"}) + + +@pytest.mark.parametrize("sql, shown", [ + # 范围比较、LIMIT / OFFSET、选出列里的数:长的数也照原样 + ("SELECT COUNT(*) FROM sales WHERE visits > 20000 AND amount >= 30000 AND amount BETWEEN 10000 AND 50000 " + "LIMIT 10000 OFFSET 12000", + "SELECT COUNT(*) FROM sales WHERE visits > 20000 AND amount >= 30000 AND amount BETWEEN 10000 AND 50000 " + "LIMIT 10000 OFFSET 12000"), + ("SELECT 20000 AS 门槛, amount - 10000 AS 差额 FROM sales", "SELECT 20000 AS 门槛, amount - 10000 AS 差额 FROM sales"), + # 等值、IN:和遮罩列比较的换,和别的列比较的、够长的遮罩值也换 + ("SELECT COUNT(*) FROM sales WHERE amount = 30000 OR amount IN (10000, -12000) OR visits = 20000", + "SELECT COUNT(*) FROM sales WHERE amount = 已遮罩 OR amount IN (已遮罩, -已遮罩) OR visits = 已遮罩"), +]) +def test_long_masked_numbers_are_only_masked_where_a_value_is_looked_up(amounts, sql, shown): + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == shown + + +@pytest.mark.parametrize("kind, sql, shown", [ + ("oracle", "SELECT COUNT(*) FROM sales WHERE buyer_phone = N'13900000001'", + "SELECT COUNT(*) FROM sales WHERE buyer_phone = N'已遮罩'"), + ("postgresql", "SELECT COUNT(*) FROM sales WHERE buyer_phone = E'13900000001' OR note = $$13900000003$$ " + "OR note = $t$13900000001$t$ OR buyer_phone = U&'13900000003'", + "SELECT COUNT(*) FROM sales WHERE buyer_phone = E'已遮罩' OR note = $$已遮罩$$ " + "OR note = $t$已遮罩$t$ OR buyer_phone = U&'已遮罩'"), + # 名字里写着遮罩值:带引号的、不带引号的(SQLite 的中文名字可以不加引号) + ("postgresql", 'SELECT COUNT(*) AS "13900000001 的单" FROM sales', + 'SELECT COUNT(*) AS "已遮罩 的单" FROM sales'), + ("mysql", "SELECT COUNT(*) AS `13900000001 的单` FROM sales", "SELECT COUNT(*) AS `已遮罩 的单` FROM sales"), +]) +def test_prefixed_strings_and_names_are_scrubbed_too(people, kind, sql, shown): + assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, kind) == shown + + +def test_a_name_written_into_an_unquoted_alias_is_scrubbed(): + secrets = secrets_of({"columns": ["buyer_name"], "rows": [["张伟"], ["李娜"]]}, {"buyer_name"}) + assert inquiry_mod._scrub_sql("SELECT COUNT(*) AS 张伟的单数 FROM sales WHERE buyer_name = '张伟'", secrets, + {"buyer_name"}, "sqlite") == ( + "SELECT COUNT(*) AS 已遮罩的单数 FROM sales WHERE buyer_name = '已遮罩'") + + +def test_numbers_left_as_written_in_the_sql_are_not_masked_in_the_answer(amounts): + """范围比较的边界、LIMIT 照原样留在 SQL 里,回答正文里同样的数再遮也遮不住什么,只会和 SQL 对不上;等值比较 + 里换掉的照旧遮。""" + sql = "SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND amount <> 50000 LIMIT 10000" + assert inquiry_mod._shown_literals(sql, "sqlite") == ["30000", "10000"] + amounts.protect(inquiry_mod._shown_literals(sql, "sqlite"), strong_only=True) + assert amounts.scrub("金额不低于 30000 元、不是 50000 元的,取前 10000 条") == ( + "金额不低于 30000 元、不是 已遮罩 元的,取前 10000 条") + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == ( + "SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND amount <> 已遮罩 LIMIT 10000") + assert inquiry_mod._scrub_sql("SELECT COUNT(*) FROM sales WHERE amount = 30000", amounts, {"amount"}, + "sqlite") == "SELECT COUNT(*) FROM sales WHERE amount = 已遮罩", \ + "只免了正文里的:和遮罩列做等值比较的照旧换" + + def test_a_comparison_with_a_renamed_masked_column_is_masked_too(people): sql = "SELECT * FROM (SELECT buyer_phone AS p FROM sales) t WHERE p = '13900000003' AND 1 = 1" assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "p"}, "sqlite") == ( @@ -173,18 +299,59 @@ def test_sql_that_cannot_be_split_into_words_is_scrubbed_like_text(people): # -------------------------------------------------------------------------- +def query_entry(columns: list[str]) -> dict: + return {"alias": "Q1", "kind": "query", "artifact": "a1", "source": "shop", "columns": columns, "rows": 1, + "truncated": False} + + def test_an_answer_still_showing_a_masked_value_is_not_handed_out(): - """运行输入里恰好是一个遮罩值(报告目录里的 i: 条目):标记里看不出来,渲染出来才露,连标记一起换也换不掉—— - 宁可不交这份回答。""" + """别的列里一格文字夹着遮罩值(备注里写着手机号):按整格比认不出来,标记里也看不出来,渲染出来才露——宁可不交 + 这份回答。""" secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"}) - catalog = {"i:客户": {"alias": "i:客户", "kind": "input", "eid": "input:客户", "locator": {"field": "客户"}, - "value": "13900000001", "label": "客户 = 13900000001"}} + snapshot = {"columns": ["备注"], "rows": [["客户13900000001来电改址"]]} + views = {"a1": inquiry_mod._masked_view(snapshot, set(), secrets)[0]} + assert views["a1"]["rows"] == [["客户13900000001来电改址"]] + catalog = {"Q1": query_entry(["备注"])} with pytest.raises(inquiry_mod._Leak): - inquiry_mod._compose("这位客户是 [[i:客户]]。", catalog, run_id="r1", views={}, secrets=secrets, allow=[]) - doc = inquiry_mod._compose("没有遮罩值。", catalog, run_id="r1", views={}, secrets=secrets, allow=[]) + inquiry_mod._compose("备注是 [[v:Q1.r0.备注]]。", catalog, run_id="r1", views=views, secrets=secrets, allow=[]) + doc = inquiry_mod._compose("没有遮罩值。", catalog, run_id="r1", views=views, secrets=secrets, allow=[]) assert doc["markdown"] == "没有遮罩值。" +def test_report_values_that_happen_to_be_masked_values_are_shown(): + """报告口径卡里的指标恰好和遮罩列里的某个值一样(单笔目标 20000,金额也有 20000 的单):报告里本来就看得到, + 引用它的回答照常交出去。""" + secrets = secrets_of({"columns": ["amount"], "rows": [[20000], [35000]]}, {"amount"}) + catalog = {"m:target": {"alias": "m:target", "kind": "metric", "eid": "metric:c1:target", "artifact": "c1", + "locator": {"metric": "target"}, "value": 20000, "rendered": "20,000", + "label": "单笔目标 = 20,000"}} + with pytest.raises(inquiry_mod._Leak): + inquiry_mod._compose("单笔目标为 [[m:target]]。", catalog, run_id="r1", views={}, secrets=secrets, allow=[]) + secrets.protect(inquiry_mod._ref_values(inquiry_mod._report_refs(catalog))) + doc = inquiry_mod._compose("单笔目标为 [[m:target]],有一笔 35000 元。", catalog, run_id="r1", views={}, + secrets=secrets, allow=[]) + assert doc["markdown"] == "单笔目标为 20,000,有一笔 已遮罩 元。" + + +def test_a_column_named_after_a_masked_value_is_renamed(): + """agent 把遮罩值写进了别名(「13900000001 的单」):快照、回答文档里的列名同样换掉。回答引用了原列名的,标记里 + 的列名照样换,正好对上换过的列名,引用照样解析得了。""" + secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"}) + snapshot = {"columns": ["13900000001 的单", "金额"], "column_types": {"13900000001 的单": "number"}, + "rows": [[2, 1200]]} + shown, masked = inquiry_mod._masked_view(snapshot, set(), secrets) + assert shown["columns"] == ["已遮罩 的单", "金额"] and shown["rows"] == [[2, 1200]] + assert shown["column_types"] == {"已遮罩 的单": "number"} and masked == [] + assert snapshot["columns"][0] == "13900000001 的单", "原快照不动" + rows_as_dicts = {**snapshot, "rows": [{"13900000001 的单": 2, "金额": 1200}]} + assert inquiry_mod._masked_view(rows_as_dicts, set(), secrets)[0]["rows"] == [{"已遮罩 的单": 2, "金额": 1200}] + + doc = inquiry_mod._compose("这位客户共 [[v:Q1.r0.13900000001 的单]] 笔。", {"Q1": query_entry(snapshot["columns"])}, + run_id="r1", views={"a1": shown}, secrets=secrets, allow=[]) + assert doc["markdown"] == "这位客户共 2 笔。" + assert "13900000001" not in json.dumps(doc, ensure_ascii=False) + + # -------------------------------------------------------------------------- # 规模 # -------------------------------------------------------------------------- From a17c822d575534fb3b4cee13a3b8dc94994c6ea0 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 11:02:10 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=203=20?= =?UTF-8?q?=E8=BD=AE=EF=BC=8C=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9ASQL=20?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E5=AD=97=E9=9D=A2=E9=87=8F=E6=8C=89=E8=AF=AD?= =?UTF-8?q?=E6=B3=95=E6=A0=91=E8=AE=A4=E7=94=A8=E5=9C=A8=E5=93=AA=EF=BC=8C?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E7=85=A7=E5=8E=9F=E6=A0=B7=E7=95=99=E7=9D=80?= =?UTF-8?q?=E7=9A=84=E5=8F=AA=E6=9C=89=E6=B2=A1=E5=A4=B9=E4=BD=8F=E6=9F=90?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=80=BC=E7=9A=84=E8=8C=83=E5=9B=B4=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E5=92=8C=20LIMIT=20=E8=A1=8C=E6=95=B0=EF=BC=9BSQL=20?= =?UTF-8?q?=E9=87=8C=E7=95=99=E7=9D=80=E7=9A=84=E9=98=88=E5=80=BC=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E5=9B=9E=E7=AD=94=E6=AD=A3=E6=96=87=E9=87=8C=E4=B8=8D?= =?UTF-8?q?=E9=81=AE=EF=BC=8C=E6=A0=BC=E5=AD=90=E7=85=A7=E6=97=A7=E6=8C=89?= =?UTF-8?q?=E5=80=BC=E9=81=AE=EF=BC=9B=E8=B4=A8=E8=AF=A2=E5=B7=A5=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E5=85=8D=E9=81=AE=E6=8E=A5=E7=BA=BF=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核提出的问题和改法: - 第 2 轮为了不误遮阈值,把范围边界、字符串的范围边界和选出列、函数参数里的数从正文和格子的按值遮里一并免掉;而按词认用法时, 简单 CASE 的 WHEN、IN (VALUES …)、行值、= ANY(ARRAY[…])、IS NOT DISTINCT FROM、套了括号的值这几种在找某一个值的写法被当成「别处」, 两头相同的范围(BETWEEN v AND v、>= v AND <= v)被当成阈值,遮罩值就留在封存的 SQL、回答正文和质询工件里,最后一道关也放行: - 字面量改按语法树认用在哪:越过括号、负号、CAST、行值、VALUES、ARRAY、ANY / ALL 这些外壳再看上一层,上面几种都认作在找某一个值, 被比较的是哪一列也认得出(短的数照旧只在和遮罩列比较时换) - 同一列上同一个值两边都被隔开的范围按在找这个值换:BETWEEN v AND v、>= v AND <= v、> v OR < v、整数的 >= v AND < v + 1,一头是几个数 算出来的(13900000002 - 1)也算;只从一边隔开的(>= v,或按 v 分成两段)照旧是阈值 - 数字照原样留着的只剩没夹住某一个值的范围边界和 LIMIT / OFFSET 的行数,而且同一个数在这条 SQL 别的字面量里没被换掉;选出列、函数参数、 算式里够长的遮罩值照第 1 轮换;字符串的范围边界照第 1 轮按值换。名字、注释里写着阈值不牵连阈值本身(AS 超过30000的笔数) - SQL 里照原样留着的数只在回答正文里不遮,格子、列名、SQL 里照旧按值遮;同一个数在别的查询里被换掉了,正文里也遮。组装后的最后一道关 只放过正文里的这些数,渲染进单元格引用里的照旧拦下 - SQL 解析不了时认不准哪个数是阈值,一律不免,只按词认简单的等值比较 - 质询工件里两处免遮接线没有测试守住:报告指标、运行输入的值那一处,单笔目标那条端到端测试补上取质询工件,核对别的列里正好是这个数的 格子在回答、工件里都照常显示;SQL 里阈值那一处随上面的改法拿掉了(格子不再免阈值),补上「格子正好等于阈值」的端到端测试,核对回答和 工件里这一格一样遮住、两边对得上;阈值那条端到端测试补上取等值查询的质询工件,核对 SQL 和事件里一字不差 - 上面每种写法都补了单元测试和端到端测试(手机号存成整数、文字两种),另补开头带换行的 SQL 位置对得上、名字里的阈值不牵连阈值的测试。 查询清单的 SQL 现在要解析,改在线程里换(同 _plan)。每处改动都在副本上做过去掉实现、测试变红的验证 --- backend/app/engine/inquiry.py | 434 +++++++++++++++++++++----- backend/tests/test_inquiry_api.py | 113 ++++++- backend/tests/test_inquiry_masking.py | 198 +++++++++++- 3 files changed, 647 insertions(+), 98 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index a0e40995..3901764d 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -15,9 +15,10 @@ 3. 遮罩(_plan):哪些列要遮照证据面板的规则(查询当时记下的,加上数据源现在设的),另按 SQL 认出改了名字的遮罩列 (起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉);快照按列 遮,再按值遮(别的列里写着遮罩值的格子,_masked_view)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、 - _scrub_sql):按值认有门槛;SQL 只换在找某一个值的字面量(等值、IN、LIKE),范围比较的边界、LIMIT 照原样,封存的是 - 实际执行的语句。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值、SQL 里照原样留着的 - 阈值。组装之后正文里还认得出遮罩值的,这份回答不交出去 + _scrub_sql):按值认有门槛;SQL 里的字面量按语法树认用在哪(_literal_uses),数字照原样留着的只有范围比较的边界 + (没把某一个值夹住的)和 LIMIT 的行数,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE……)和别处 + 够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样留着 + 的阈值只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的,这份回答不交出去 4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的 _usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键 收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存), @@ -41,6 +42,7 @@ from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, field from datetime import datetime, timezone +from decimal import Decimal from typing import Any from sqlalchemy import func, select @@ -98,7 +100,7 @@ REFS_MAX = 40 TABLE_ROWS = 20 #: 按值认遮罩值的门槛:文字至少这么多字(一个字的「是」「男」换掉,会把不相干的词也换得面目全非);纯数字至少这么多位 #: (年龄、等级、小额金额这类短的数和阈值、天数、日期、LIMIT 里的数太容易撞上,按值换会把实际执行的 SQL、提问人自己 -#: 的条件改得面目全非。它们只在 SQL 里和遮罩列直接比较时才换,见 _scrub_sql;引用到的格子照旧按列遮) +#: 的条件改得面目全非。它们只在 SQL 里和遮罩列做等值一类比较时才换,见 _scrub_sql;引用到的格子照旧按列遮) SECRET_MIN = 2 SECRET_DIGITS = 5 #: 遮罩列的值最多收这么多种写法(一份快照最多上千行):再多就按已经收到的换 @@ -904,12 +906,14 @@ class _Secrets: """遮罩列里出现过的值。模型看得到原始的查询结果(工具交回的是原样),回答里照着复述、或者把值写进查询条件, 都会把它带进回答正文和查询清单;按值找出来换成「已遮罩」。这只减少暴露,不是安全边界(同证据面板的遮罩)。 - strong 是按值认得出的写法(够长的数、两个字以上的文字),正文、SQL 里的字符串、名字和注释、别的列里的格子、列名都按 - 它换;weak 是遮罩列里的全部写法(含短的数),只用在 SQL 里和遮罩列做等值比较的字面量上。换的时候先按词、按开头两个 + strong 是按值认得出的写法(够长的数、两个字以上的文字),正文、SQL 里的字面量、名字和注释、别的列里的格子、列名都按 + 它换;weak 是遮罩列里的全部写法(含短的数),只用在 SQL 里和遮罩列做等值比较的字面量上;shown 是 SQL 里照原样 + 留着的阈值、行数(去掉千分位、正负号的数字),只在回答正文里不换(scrub 的 prose)。换的时候先按词、按开头两个 字筛出正文里真有的那几个值,再拿它们现编正则:遮罩值上万个时,现编一个上万分支的正则要一秒上下,每次都卡住整个进程。""" strong: set[str] = field(default_factory=set) weak: set[str] = field(default_factory=set) + shown: set[str] = field(default_factory=set) _index: Any = field(default=None, repr=False, compare=False) _compiled: dict[frozenset[str], re.Pattern[str]] = field(default_factory=dict, repr=False, compare=False) @@ -936,12 +940,9 @@ class _Secrets: self.strong.add(form) self._index = None - def protect(self, texts: Iterable[str | None], *, strong_only: bool = False) -> None: + def protect(self, texts: Iterable[str | None]) -> None: """已经摆在读者眼前的值不换:提问人自己写进问题、选中原文里的(「13900000001 这位客户」「排除 300 以下的」), - 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。 - - strong_only:只免正文、快照里按值认的(SQL 里照原样留着的阈值、LIMIT,见 _shown_literals);SQL 里和遮罩列 - 做等值比较的照旧换(weak 不动)。""" + 报告里列出的指标、运行输入的值——换了只会让回答、查询对不上问题和报告。正文、格子、SQL 里都不换。""" given = " ".join(t for t in texts if t) if not given: return @@ -951,10 +952,18 @@ class _Secrets: return _digits_of(form) in numbers if _NUMERIC.fullmatch(form) else form in given self.strong = {f for f in self.strong if not asked(f)} - if not strong_only: - self.weak = {f for f in self.weak if not asked(f)} + self.weak = {f for f in self.weak if not asked(f)} self._index, self._compiled = None, {} + def show(self, numbers: Iterable[str]) -> None: + """SQL 里照原样留着的阈值、行数(_shown_literals):回答正文里复述它们不换(「金额不低于 30000 元」),换了只会 + 让回答和 SQL 对不上。只免正文:格子、列名、SQL 里照旧按值换——格子里的值正好是这个数,可能就是那个人 + (col >= v 再按 col 排序取第一行,查出来的就是 v)。""" + self.shown = set(numbers) + + def is_shown(self, form: str) -> bool: + return bool(_NUMERIC.fullmatch(form)) and _digits_of(form) in self.shown + def hit(self, value: Any) -> bool: """一格(或 SQL 里一个字面量)的值是不是按值认得出的遮罩值。短的数不算(见 SECRET_DIGITS)。""" if value is None or isinstance(value, bool) or not self.strong: @@ -971,15 +980,18 @@ class _Secrets: """SQL 里一个字面量是不是遮罩列里出现过的值(短的数也算)。""" return _plain_text(text) in self.weak - def scrub(self, text: str) -> str: + def scrub(self, text: str, *, prose: bool = False) -> str: + """text 里的遮罩值换成「已遮罩」。prose:这是回答正文,SQL 里照原样留着的阈值、行数不换(show)。""" if not text or not self.strong: return text - pattern = self.pattern(text) + pattern = self.pattern(text, prose=prose) return pattern.sub(_masked_word(), text) if pattern is not None else text - def pattern(self, text: str) -> re.Pattern[str] | None: - """认 text 里出现的遮罩值的正则;一个都没有时为 None。""" + def pattern(self, text: str, *, prose: bool = False) -> re.Pattern[str] | None: + """认 text 里出现的遮罩值的正则;一个都没有时为 None。prose 同 scrub。""" found = self._found(text) if text and self.strong else set() + if prose and self.shown: + found = {v for v in found if not self.is_shown(v)} if not found: return None key = frozenset(found) @@ -1033,8 +1045,9 @@ _CELL_HEAD = re.compile(r"^\s*[QK]\d+\.r\d+\.") def _scrubbed_source(text: str, secrets: _Secrets) -> str: - """回答原文里的遮罩值换成「已遮罩」:标记之外按值换;标记里的按段换(| 隔开的几段各自换),单元格引用只换列名那 - 一截——编号、行号换不出东西,整段换的话「r0.」会让列名开头的长号码看着像小数的一截,反而认不出来。 + """回答原文里的遮罩值换成「已遮罩」:标记之外按正文的办法换(SQL 里照原样留着的阈值不换);标记里的按段换(| 隔开 + 的几段各自换),单元格引用只换列名那一截——编号、行号换不出东西,整段换的话「r0.」会让列名开头的长号码看着像小数 + 的一截,反而认不出来。列名照快照、SQL 里的名字那样换(阈值也换),换得一样才对得上。 标记里写着遮罩值的引用换完多半解析不了(列名里写着遮罩值的除外:快照的列名也换过了,正好对上),宁可弄坏一个 引用,也不让值留在存下的原文、片段里,或者从解析不了的占位里露出来。""" if not text or not secrets.strong: @@ -1042,15 +1055,16 @@ def _scrubbed_source(text: str, secrets: _Secrets) -> str: out: list[str] = [] last = 0 for m in MARKER_RE.finditer(text): - out.append(secrets.scrub(text[last:m.start()])) + out.append(secrets.scrub(text[last:m.start()], prose=True)) start, end = m.span("body") parts = text[start:end].split("|") head = _CELL_HEAD.match(parts[0]) if m.group("kind").strip() == "v" else None - cut = head.end() if head else 0 - parts = [parts[0][:cut] + secrets.scrub(parts[0][cut:]), *(secrets.scrub(p) for p in parts[1:])] + first = parts[0][:head.end()] + secrets.scrub(parts[0][head.end():]) if head \ + else secrets.scrub(parts[0], prose=True) + parts = [first, *(secrets.scrub(p, prose=True) for p in parts[1:])] out.append(text[m.start():start] + "|".join(parts) + text[end:m.end()]) last = m.end() - out.append(secrets.scrub(text[last:])) + out.append(secrets.scrub(text[last:], prose=True)) return "".join(out) @@ -1101,15 +1115,22 @@ def _masked_view(snapshot: dict[str, Any], hidden: set[str], secrets: _Secrets) return view, [names[i] for i in sorted(touched)] -#: 在找某一个值的比较:col = 'x'、col <> 'x'、col LIKE 'x'、SQLite 的 col IS 'x'(IN 列表另外认) +#: 按词认在找某一个值的比较(SQL 解析不了时的退路,见 _context):col = 'x'、col <> 'x'、col LIKE 'x'、SQLite 的 +#: col IS 'x'(IN 列表另外认) _EQUAL_TOKENS = frozenset({"EQ", "NEQ", "NULLSAFE_EQ", "LIKE", "ILIKE", "GLOB", "RLIKE", "IS"}) -#: 范围比较:边界是阈值,不是在找某一个值(BETWEEN 另外认) +#: 范围比较(BETWEEN 另外认) _RANGE_TOKENS = frozenset({"GT", "GTE", "LT", "LTE"}) _NAME_TOKENS = frozenset({"VAR", "IDENTIFIER"}) #: 字面量前面可能连着的:正负号、MySQL 的字符集前缀(_utf8mb4'x') _LEADS = frozenset({"DASH", "PLUS", "INTRODUCER"}) #: 能结束一个值的词:减号跟在它们后面是两数相减,不是负号 _VALUE_ENDS = frozenset({"NUMBER", "VAR", "IDENTIFIER", "R_PAREN", "R_BRACKET", "NULL", "TRUE", "FALSE"}) +#: 数字照原样留在 SQL 里的两种用法(_literal_uses):范围比较的边界、LIMIT 这类的行数 +_KEPT = frozenset({"range", "limit"}) +#: 字面量写在左边时运算符翻过来:25 < age 就是 age > 25 +_FLIP = {">": "<", ">=": "<=", "<": ">", "<=": ">="} +#: 一个纯粹的数(可带正负号、小数):范围边界按数比大小,写成字符串的也一样 +_PLAIN_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?") def _is_literal(kind: str) -> bool: @@ -1130,10 +1151,10 @@ def _name_at(tokens: list[Any], kinds: list[str], k: int) -> str | None: def _context(tokens: list[Any], kinds: list[str], i: int) -> tuple[str, str | None]: - """第 i 个字面量用在哪:("equal", 列名) 在找某一个值——col = 'x'、col NOT LIKE 'x'、col IS NOT 25、 - col IN ('x', …)、'x' = col;("range", None) 范围比较的边界——col > 25、col BETWEEN a AND b;("other", None) 别的 - (LIMIT / OFFSET、选出列、函数参数、算式)。列名是比较另一边的那个名字(s.buyer_phone 取 buyer_phone,小写), - 另一边不是单独一个名字(函数、算式)时为 None。""" + """按词认第 i 个字面量用在哪,只在 SQL 解析不了时用(_literals),只认得出简单的写法:("equal", 列名) 在找某一个 + 值——col = 'x'、col NOT LIKE 'x'、col IS NOT 25、col IN ('x', …)、'x' = col;("range", None) 范围比较的边界—— + col > 25、col BETWEEN a AND b;("other", None) 别的。列名是比较另一边的那个名字(s.buyer_phone 取 buyer_phone, + 小写),另一边不是单独一个名字(函数、算式)时为 None。""" j = _operand_start(kinds, i) - 1 if j >= 1 and kinds[j] == "NOT" and kinds[j - 1] == "IS": return "equal", _name_at(tokens, kinds, j - 2) @@ -1164,6 +1185,205 @@ def _context(tokens: list[Any], kinds: list[str], i: int) -> tuple[str, str | No return "other", None +def _column_of(node: Any, index: int | None) -> str | None: + """比较另一边的列名(小写、不带表名);另一边是行值时取第 index 项。不是单独一列(函数、算式、子查询)时为 None。""" + from sqlglot import exp + + if index is not None and isinstance(node, exp.Tuple): + items = node.expressions + node = items[index] if index < len(items) else None + while isinstance(node, exp.Paren): + node = node.this + if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star): + return node.name.lower() or None + return None + + +def _use_of(node: Any) -> tuple[str, str | None]: + """语法树里一个字面量用在哪:(用法, 列名),用法见 _literal_uses。 + + 先往上越过不改变「找的是哪个值」的外壳:括号、负号、CAST、字符集前缀、COLLATE;行值、VALUES、ARRAY、ANY / ALL + 也越过,记下「在一组值里」(行值另记下是第几项)。再看外壳的上一层:等值一类、IN、简单 CASE 的 WHEN 是在找这个值; + 范围比较、LIMIT 一类只认单独一个值(一组值里的、算式里的都不算)。""" + from sqlglot import exp + + child, parent = node, node.parent + index, grouped = None, False + while parent is not None: + if isinstance(parent, exp.Tuple): + if index is None: + index = next((k for k, item in enumerate(parent.expressions) if item is child), None) + grouped = True + elif isinstance(parent, (exp.Array, exp.Values, exp.Any, exp.All)) or ( + isinstance(parent, exp.Subquery) and isinstance(child, exp.Values)) or ( + isinstance(parent, exp.Anonymous) and str(parent.this).upper() in ("ANY", "ALL", "SOME")): + # = ANY(ARRAY[…])、= ANY(VALUES …);Postgres 的 <> ALL(…) 解析成名叫 ALL 的函数 + grouped = True + elif not _wraps(parent, child): + break + child, parent = parent, parent.parent + if isinstance(parent, (exp.EQ, exp.NEQ, exp.NullSafeEQ, exp.NullSafeNEQ, exp.Is, exp.Like, exp.ILike, exp.Glob, + exp.RegexpLike, exp.SimilarTo)): + return "equal", _column_of(parent.expression if child is parent.this else parent.this, index) + if isinstance(parent, exp.In): + return "equal", None if child is parent.this else _column_of(parent.this, index) + if isinstance(parent, exp.If) and child is parent.this and isinstance(parent.parent, exp.Case) \ + and parent.parent.this is not None: + # 简单 CASE:CASE buyer_phone WHEN 13900000001 THEN …(CASE WHEN col = v 的写法走上面的等值) + return "equal", _column_of(parent.parent.this, index) + if grouped: + return "other", None + if type(parent) in _range_ops() and (child is parent.this or child is parent.expression): + return "range", _column_of(parent.expression if child is parent.this else parent.this, None) + if isinstance(parent, exp.Between) and child is not parent.this: + return "range", _column_of(parent.this, None) + if (isinstance(parent, (exp.Limit, exp.Offset)) and child is parent.expression) or ( + isinstance(parent, exp.Fetch) and child is parent.args.get("count")): + return "limit", None + return "other", None + + +def _wraps(parent: Any, child: Any) -> bool: + """parent 只是 child 的外壳,不改变它是哪个值:括号、负号、CAST、字符集前缀(_utf8mb4'x')、COLLATE。""" + from sqlglot import exp + + return isinstance(parent, (exp.Paren, exp.Neg, exp.Cast)) or ( + isinstance(parent, exp.Introducer) and child is parent.expression) or ( + isinstance(parent, exp.Collate) and child is parent.this) + + +def _range_ops() -> dict[Any, str]: + """范围比较的语法树节点 → 「列 运算符 值」方向的运算符。""" + from sqlglot import exp + + return {exp.GT: ">", exp.GTE: ">=", exp.LT: "<", exp.LTE: "<="} + + +def _constant(node: Any) -> Any: + """写死的值:数按数(几个数加减乘除算出来的也算,13900000002 - 1),字符串按原文(纯粹的数照样按数);别的为 None。""" + from sqlglot import exp + + if isinstance(node, exp.Neg): + value = _constant(node.this) + return -value if isinstance(value, Decimal) else None + if isinstance(node, (exp.Paren, exp.Cast, exp.Collate)): + return _constant(node.this) + if isinstance(node, exp.Introducer): + return _constant(node.expression) + if isinstance(node, _literal_types()): + text = str(node.this).strip() + return Decimal(text) if _PLAIN_NUMBER.fullmatch(text) else text + if isinstance(node, (exp.Add, exp.Sub, exp.Mul, exp.Div)): + a, b = _constant(node.this), _constant(node.expression) + if not (isinstance(a, Decimal) and isinstance(b, Decimal)): + return None + if isinstance(node, exp.Div): + return a / b if b else None + return a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b + return None + + +def _bare_literal(node: Any) -> Any: + """边界本身就是一个字面量(套了括号、负号、CAST 的也算)时交回那个字面量,否则 None。""" + from sqlglot import exp + + while node is not None and not isinstance(node, _literal_types()): + if isinstance(node, (exp.Paren, exp.Neg, exp.Cast, exp.Collate)): + node = node.this + elif isinstance(node, exp.Introducer): + node = node.expression + else: + return None + return node + + +def _bounds_of(node: Any) -> list[tuple[Any, str | None, Any, str]]: + """一个范围比较里写死的边界:(字面量节点, 列名, 值, 「列 运算符 值」的运算符)。边界是几个数算出来的也算, + 字面量节点记 None:它照样能夹住另一头的值,换的是另一头的字面量。""" + from sqlglot import exp + + if isinstance(node, exp.Between): + sides = [(node.args.get("low"), node.this, ">="), (node.args.get("high"), node.this, "<=")] + elif (op := _range_ops().get(type(node))) is not None: + sides = [(node.expression, node.this, op), (node.this, node.expression, _FLIP[op])] + else: + return [] + out = [] + for operand, other, op in sides: + value = _constant(operand) if operand is not None else None + if value is not None: + out.append((_bare_literal(operand), _column_of(other, None), value, op)) + return out + + +def _literal_types() -> tuple[Any, ...]: + """语法树里的字面量:数字、字符串,以及字符串带前缀的几种变体(N'…'、$$…$$、U&'…'、E'…'、X'…'、B'…')。""" + from sqlglot import exp + + return (exp.Literal, exp.National, exp.RawString, exp.UnicodeString, exp.ByteString, exp.HexString, exp.BitString) + + +def _pinned(bounds: list[tuple[Any, str | None, Any, str]]) -> set[int]: + """范围比较里把某一个值夹住了的边界字面量(交回 id(字面量节点))。bounds 见 _bounds_of。 + + 一个值 v 被从下面隔开,是说 v 和比它小一点的值结果不同(col >= v、col < v);被从上面隔开,是和比它大一点的值结果 + 不同(col <= v、col > v)。同一列上 v 两边都被隔开,条件就只认 v 这一个值:col >= v AND col <= v、BETWEEN v AND v、 + col > v OR col < v(就是 col <> v)——这是在找 v,和等值比较一样换。整数的 col < v + 1 就是 col <= v、col > v - 1 + 就是 col >= v,所以 col >= v AND col < v + 1 也算夹住了 v。只从一边隔开的是阈值:col >= v 和 col < v 各数一遍, + 是按 v 分成两段,照原样。另一边不是单独一列的(SUM(amount) >= 900)和哪一列都算同一列(宁可多换)。""" + sides: dict[Any, list[tuple[str, str | None, Any]]] = {} + for literal, column, value, op in bounds: + whole = isinstance(value, Decimal) and value == value.to_integral_value() + marks = {">=": [(value, "below")], "<=": [(value, "above")], + ">": [(value, "above"), *([(value + 1, "below")] if whole else [])], + "<": [(value, "below"), *([(value - 1, "above")] if whole else [])]}[op] + for point, side in marks: + sides.setdefault(point, []).append((side, column, literal)) + out: set[int] = set() + for marks in sides.values(): + for side, column, literal in marks: + for other, column2, literal2 in marks: + if side == "below" and other == "above" and (column is None or column2 is None or column == column2): + out |= {id(n) for n in (literal, literal2) if n is not None} + return out + + +def _literal_uses(sql: str, kind: str | None) -> dict[int, tuple[str, str | None]] | None: + """SQL 里每个字面量(按它在原文里的起始位置)用在哪,按语法树认(写法怎么变都认得出): + - ("equal", 列名):在找某一个值——等值、不等、IN(含 VALUES、行值)、LIKE 一类、IS [NOT] DISTINCT FROM、简单 CASE 的 + WHEN、= ANY(ARRAY[…]),套了括号、负号、CAST 的照认;把某一个值夹住的范围(_pinned)也算。列名是被比较的那一列 + (小写、不带表名),另一边不是单独一列时为 None; + - ("range", 列名):范围比较(> >= < <=、BETWEEN)的边界,是 agent 定的阈值; + - ("limit", None):LIMIT / OFFSET / FETCH / TOP 的行数; + - ("other", None):别的——选出列、函数参数、算式、THEN 后面的值、子查询里选出的值。 + 解析不了返回 None。""" + from sqlglot import exp + + from app.data.sql_semantics import _parse, _read + + try: + root, _ = _parse(sql, _read(kind)) + except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理 + root = None + if root is None: + return None + # 解析的是去掉头尾空白的 SQL:位置补上开头的空白,才对得上原文里的词 + offset = len(sql) - len(sql.lstrip()) + uses: dict[int, tuple[str, str | None]] = {} + try: + literals = {id(n): n for n in root.walk() if isinstance(n, _literal_types())} + pinned = _pinned([b for n in root.find_all(exp.Between, *_range_ops()) for b in _bounds_of(n)]) + for key, node in literals.items(): + start = (node.meta or {}).get("start") + if isinstance(start, int): + use, column = _use_of(node) + uses[start + offset] = ("equal", column) if key in pinned and use == "range" else (use, column) + except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(认不准的地方不免) + logger.warning("质询的 SQL 字面量分析失败", exc_info=True) + return None + return uses + + def _requote(raw: str, change: Callable[[str], str]) -> str: """一个字面量或名字的原文 → 只换里面的字:引号、N'' E'' U&'' 这类前缀、$tag$ 照留;不带引号的整个交给 change。""" if raw.startswith("$") and (end := raw.find("$", 1)) > 0: @@ -1187,23 +1407,58 @@ def _sql_tokens(sql: str, kind: str | None) -> tuple[list[Any], list[str]] | Non return tokens, [t.token_type.name for t in tokens] -def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None) -> str: - """SQL 里遮罩列的值换成「已遮罩」,别的一个字不动:查询清单随运行封存,它得是实际执行的那条,审批人看 what-if 的 - 条件(阈值、日期、LIMIT、别的列的条件)看的就是它。只换在找某一个值的字面量(_context 的 equal): +def _literals(sql: str, kind: str | None) -> tuple[list[Any], list[str], dict[int, tuple[str, str | None]]] | None: + """分词,加上每个字面量(按词的下标)用在哪(_literal_uses)。语法树里对不上位置的字面量记 "other";SQL 解析 + 不了时只按词认简单的等值比较(_context),别的一律 "other"——认不准的地方不免。分词出错返回 None。 - - 和遮罩列(或它的别名,compared)做等值、IN、LIKE 比较的,是遮罩列里出现过的值就换,短的数也换(age = 25); - 和别的列做等值比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号); - - 范围比较的边界(age > 31、BETWEEN 10000 AND 50000)、LIMIT / OFFSET、选出列和算式里的数照原样:它们是 agent - 自己定的阈值,不是在找某一个人,换了封存的就不是实际执行的语句; - - 别处的字符串(LIKE 别的列、函数参数)、注释、名字(AS "13900000001 的单"、张伟的单数)按值换(同正文:够长的数、 - 两个字以上的文字)。 - 分词出错就整条按正文的办法换。""" - if not sql or not (secrets.strong or secrets.weak): - return sql + 照原样留着的数还得在这条 SQL 的字面量里处处都是阈值、行数:同一个数在别的字面量里(等值比较、算式、字符串)也 + 出现过,那里会被换掉,这里留着就把它露了(BETWEEN 13900000001 AND 13900000001 + 0),所以这里也改记成在找这个值。 + 名字、注释里的不算:那是 agent 给阈值起的说法(AS 超过30000的笔数),照常按值换,留着的阈值不必跟着换。""" split = _sql_tokens(sql, kind) if split is None: - return secrets.scrub(sql) + return None tokens, kinds = split + uses = _literal_uses(sql, kind) + found: dict[int, tuple[str, str | None]] = {} + for i, token in enumerate(tokens): + if not _is_literal(kinds[i]): + continue + if uses is not None: + found[i] = uses.get(token.start, ("other", None)) + else: + use, column = _context(tokens, kinds, i) + found[i] = (use, column) if use == "equal" else ("other", None) + kept = {i for i, (use, _) in found.items() if kinds[i] == "NUMBER" and use in _KEPT} + elsewhere = _numbers_in(tokens[i].text for i in found if i not in kept) + for i in kept: + if _digits_of(tokens[i].text) in elsewhere: + found[i] = ("equal", found[i][1]) + return tokens, kinds, found + + +def _numbers_in(texts: Iterable[str]) -> set[str]: + """这几段字里写着的数(去掉千分位、正负号)。""" + return {_digits_of(n) for text in texts for n in _DIGITS.findall(text)} + + +def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None) -> str: + """SQL 里遮罩列的值换成「已遮罩」,别的一个字不动:查询清单随运行封存,它得是实际执行的那条,审批人看 what-if 的 + 条件(阈值、日期、LIMIT、别的列的条件)看的就是它。字面量按用在哪换(_literals): + + - 在找某一个值的(equal):和遮罩列(或它的别名,compared)比较的,是遮罩列里出现过的值就换,短的数也换 + (age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号); + - 范围比较的边界(age > 31、BETWEEN 10000 AND 50000)、LIMIT / OFFSET 的行数,数字照原样:它们是 agent 自己定的 + 阈值,不是在找某一个人,换了封存的就不是实际执行的语句; + - 别处的数(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值换; + - 字符串在哪都按值换(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人),注释、名字(AS + "13900000001 的单"、张伟的单数)也按值换(同正文:够长的数、两个字以上的文字)。 + 分词出错就整条按正文的办法换;解析不了的认不准哪个数是阈值,范围边界、行数也不免(_literals)。""" + if not sql or not (secrets.strong or secrets.weak): + return sql + split = _literals(sql, kind) + if split is None: + return secrets.scrub(sql) + tokens, kinds, uses = split word = _masked_word() out: list[str] = [] last = 0 @@ -1214,12 +1469,14 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None gap = sql[last:start] out.append(secrets.scrub(gap) if gap.strip() else gap) raw = sql[start:end] - if _is_literal(kinds[i]): - use, column = _context(tokens, kinds, i) + if i in uses: + use, column = uses[i] if use == "equal" and ((column in compared and secrets.weak_hit(token.text)) or secrets.hit(token.text)): raw = _requote(raw, lambda _: word) - elif use != "range" and kinds[i] != "NUMBER": + elif kinds[i] != "NUMBER": raw = _requote(raw, secrets.scrub) + elif use not in _KEPT and secrets.hit(token.text): + raw = word elif kinds[i] in _NAME_TOKENS: raw = _requote(raw, secrets.scrub) out.append(raw) @@ -1229,20 +1486,24 @@ def _scrub_sql(sql: str, secrets: _Secrets, compared: set[str], kind: str | None return "".join(out) -def _shown_literals(sql: str, kind: str | None) -> list[str]: - """SQL 里不管是什么值都照原样留着的字面量(_scrub_sql 不换的两种):范围比较的边界,LIMIT、选出列这类地方的数。 - 它们随 SQL 摆在读者眼前,回答正文、快照里同样的值再遮也遮不住什么,只会让回答和 SQL 对不上。""" - split = _sql_tokens(sql, kind) if sql else None - if split is None: - return [] - tokens, kinds = split - out: list[str] = [] - for i, token in enumerate(tokens): - if _is_literal(kinds[i]): - use = _context(tokens, kinds, i)[0] - if use == "range" or (use == "other" and kinds[i] == "NUMBER"): - out.append(token.text) - return out +def _shown_literals(sqls: Iterable[tuple[str, str | None]]) -> set[str]: + """几条 SQL(和各自数据源的类型)里照原样留着的数,去掉千分位、正负号:范围比较的边界、LIMIT / OFFSET 的行数 + (_scrub_sql 不换的那两种)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额不低于 30000 元」)再遮也遮不住 + 什么,只会让回答和 SQL 对不上(_Secrets.show)。同一个数在别的查询里出现在别的字面量里也不算(等值比较、选出列、 + 函数参数、字符串;同一条里的 _literals 已经不留):那里它会被换掉,正文写出来就把那里换掉的值露了。SQL 解析 + 不了的一个都不算,里面写着的数也都不算。""" + kept: set[str] = set() + elsewhere: set[str] = set() + for sql, kind in sqls: + split = _literals(sql, kind) if sql else None + if split is None: + elsewhere |= _numbers_in([sql or ""]) + continue + tokens, kinds, uses = split + keep = {i for i, (use, _) in uses.items() if kinds[i] == "NUMBER" and use in _KEPT} + kept |= {_digits_of(tokens[i].text) for i in keep} + elsewhere |= _numbers_in(tokens[i].text for i in uses if i not in keep) + return kept - elsewhere @dataclass @@ -1270,16 +1531,18 @@ async def _source_kinds(names: Iterable[str]) -> dict[str, str | None]: async def _plan(own: dict[str, dict[str, Any]], others: list[dict[str, Any]], given: list[str | None], - shown: list[tuple[str, str]]) -> _Plan: + shown: list[tuple[str, str]] | None = None) -> _Plan: """own:agent 自己的查询快照(工件 id → 内容);others:报告自己的查询快照(被问的那句可能直接写着遮罩列的值, agent 会照着复述、写进查询条件);given:问题、选中的原文、报告里列出的指标和运行输入的值(都已经摆在读者眼前, - 里面的值不换);shown:agent 执行的 SQL 和它查的数据源名(照原样留在 SQL 里的阈值、LIMIT,正文里同样不换)。 + 里面的值哪里都不换);shown:agent 执行的 SQL 和它查的数据源名(照原样留在 SQL 里的阈值、LIMIT,回答正文里 + 不换,见 _Secrets.show;取质询工件不用:快照里的格子照旧按值遮)。 查数据源的遮罩要读库,在这里读;解析 SQL、收上万个值、逐格比对是纯计算,放进线程:放在事件循环里会挡住同一进程 里别的运行(同 tools/datasource 的 _sql_checks)。""" from app.api.evidence import _Masks masks = _Masks() + shown = shown or [] named = {artifact: await _hidden_columns(snapshot, masks) for artifact, snapshot in own.items()} based = [(snapshot, (await _hidden_columns(snapshot, masks))[0]) for snapshot in others] kinds = await _source_kinds([*(str(s.get("source") or "") for s in [*own.values(), *others]), @@ -1300,8 +1563,7 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str] for snapshot, base in based: secrets.add(snapshot, _derived_hidden(snapshot, base, kinds.get(str(snapshot.get("source") or "")))[0]) secrets.protect(given) - secrets.protect([value for sql, name in shown for value in _shown_literals(sql, kinds.get(name))], - strong_only=True) + secrets.show(_shown_literals((sql, kinds.get(name)) for sql, name in shown)) secrets.prepare() views: dict[str, dict[str, Any]] = {} masked: dict[str, list[str]] = {} @@ -1426,13 +1688,7 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict error = _error_words(outcome, timeout_s=part.get("timeout_s"), daily_max_usd=settings.get("daily_max_usd")) if outcome.error: logger.info("质询没能给出回答(运行 %s):%s", op.run_id, outcome.error) - queries = [] - for q in outcome.queries: - artifact, name = str(q.get("artifact") or ""), str(q.get("source") or "") - # 快照没取到的(存不下、被删了),SQL 里按这个源设的遮罩列认比较 - compared = plan.tainted.get(artifact) or {c.lower() for c in live.get(name, [])} - queries.append({**{k: q.get(k) for k in QUERY_KEYS}, - "sql": _scrub_sql(str(q.get("sql") or ""), plan.secrets, compared, plan.kinds.get(name))}) + queries = await asyncio.to_thread(_sealed_queries, outcome.queries, plan, live) return { "answer_text": text, "answer_doc": doc_id, "ok": ok, "limit": _limit(outcome), "error": None if ok else (error or failed or NO_ANSWER), @@ -1443,6 +1699,18 @@ async def _answer(op: _Opening, ask: Ask, target: _Target | None, settings: dict } +def _sealed_queries(queries: list[dict[str, Any]], plan: _Plan, live: dict[str, list[str]]) -> list[dict[str, Any]]: + """随运行封存的查询清单:每条只留约定的几项,SQL 里的遮罩值换掉。_scrub_sql 要解析 SQL,放在线程里(同 _plan)。""" + out: list[dict[str, Any]] = [] + for q in queries: + artifact, name = str(q.get("artifact") or ""), str(q.get("source") or "") + # 快照没取到的(存不下、被删了),SQL 里按这个源设的遮罩列认比较 + compared = plan.tainted.get(artifact) or {c.lower() for c in live.get(name, [])} + out.append({**{k: q.get(k) for k in QUERY_KEYS}, + "sql": _scrub_sql(str(q.get("sql") or ""), plan.secrets, compared, plan.kinds.get(name))}) + return out + + def _quiet_load(loader: Callable[[str], Any], artifact: Any) -> Any: """取一份快照;取不到、哈希不符都当没有(这里只是为了找遮罩列的值,取不到的那份本来也渲染不出来)。""" if not artifact: @@ -1456,21 +1724,31 @@ def _quiet_load(loader: Callable[[str], Any], artifact: Any) -> Any: def _compose(source: str, catalog: dict[str, Any], *, run_id: str, views: dict[str, dict[str, Any]], secrets: _Secrets, allow: list[str]) -> dict[str, Any]: """agent 写的回答原文(带引用标记)→ 回答文档:目录是 agent 自己的查询,加上报告里的指标和运行输入;快照按遮罩 - 遮好再渲染。遮罩值在组装之前换掉(_scrubbed_source);组装出来正文里还认得出遮罩值(渲染出来才露的,比如别的列 - 里一格文字夹着手机号),抛 _Leak,不交出去。allow 是提问人自己写的数(_given_numbers),回答里复述它们不标 - 「无出处」。""" + 遮好再渲染。遮罩值在组装之前换掉(_scrubbed_source);组装出来还认得出遮罩值(渲染出来才露的,比如别的列里一格 + 文字夹着手机号),抛 _Leak,不交出去。allow 是提问人自己写的数(_given_numbers),回答里复述它们不标「无出处」。""" loader = _masking_loader(views) allowed = list(allow) or None # 列名里写着遮罩值的查询:目录里的列名照快照换(_masked_view),整表展开、表头核对、存下的目录都按换过的名字 catalog = {alias: _renamed_entry(entry, secrets) for alias, entry in catalog.items()} doc = compose_doc(_scrubbed_source(source, secrets), catalog, run_id=run_id, loader=loader, allow_numbers=allowed) - markdown = str(doc.get("markdown") or "") - if secrets.scrub(markdown) != markdown: + if _leaked(doc, secrets): raise _Leak() return doc +def _leaked(doc: dict[str, Any], secrets: _Secrets) -> bool: + """组装出来的正文里还认得出遮罩值。SQL 里照原样留着的阈值写在正文里不算(_Secrets.show),渲染进单元格引用里的 + 算:格子已经按值遮过,遮完还认得出,就是一格文字里夹着这个数,渲染出来才露。""" + markdown = str(doc.get("markdown") or "") + pattern = secrets.pattern(markdown) + if pattern is None: + return False + cells = [seg["span"] for seg in iter_segments(doc) if str(seg.get("ref") or "").startswith("v:")] + return any(not secrets.is_shown(m.group()) or any(a < m.end() and m.start() < b for a, b in cells) + for m in pattern.finditer(markdown)) + + def _renamed_entry(entry: Any, secrets: _Secrets) -> Any: if not (isinstance(entry, dict) and entry.get("kind") == "query" and isinstance(entry.get("columns"), list)): return entry @@ -1657,7 +1935,8 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: if not _query_shaped(content): return {"id": artifact_id, "content": content, "masked": []} # 遮罩值照回答时收:引用它的那几条质询各自查到的全部快照(查询条件里写着的值,不一定在这一份的结果里),加上报告 - # 自己的查询快照;免遮的也照回答时:提问人自己写的、报告里列出的指标和运行输入的值、SQL 里照原样留着的阈值 + # 自己的查询快照;免遮的也照回答时:提问人自己写的、报告里列出的指标和运行输入的值。SQL 里照原样留着的阈值只在 + # 回答正文里不遮,格子照旧按值遮,这里不用 own: dict[str, dict[str, Any]] = {artifact_id: content} for data in referenced: for other in dict.fromkeys(str(a) for a in data.get("inquiry_artifacts") or []): @@ -1665,8 +1944,7 @@ async def inquiry_artifact(run_id: str, artifact_id: str) -> dict[str, Any]: own[other] = snapshot others, values = await _report_material(run_id, (d.get("report") for d in referenced)) asked = [t for d in referenced for t in (d.get("question"), d.get("quote")) if isinstance(t, str)] - plan = await _plan(own, others, [*asked, *values], - [(str(s.get("sql") or ""), str(s.get("source") or "")) for s in own.values()]) + plan = await _plan(own, others, [*asked, *values]) shown = plan.views[artifact_id] recorded = next((q["sql"] for d in referenced for q in d.get("queries") or [] if isinstance(q, dict) and q.get("artifact") == artifact_id and isinstance(q.get("sql"), str)), diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index a7ff5dee..b877bb30 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -12,8 +12,9 @@ 事件里记的报告文档不是这次运行的 422;输入不合法 422,指明是哪一项 - 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出(提问人自己写的数不标);遮罩列(起了 别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现 - 遮罩列的值;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标;质询工件按值遮 - 只出现在报告快照里的遮罩值 + 遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码……) + 也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标;SQL 里照原样留着的 + 阈值只在回答正文里不遮,格子照旧按值遮,回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值 - 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的 数据源、没固定过版本的上传表格不查 - 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明 @@ -1016,9 +1017,9 @@ async def test_an_unnamed_expression_next_to_a_star_is_masked_everywhere(monkeyp assert got.json()["masked"] == ["buyer_phone", "SUBSTR(buyer_phone, 4)"] -#: 金额设了遮罩的大单:金额都是五位数,和阈值、LIMIT、访问次数的条件撞在一起 +#: 金额设了遮罩的大单:金额都是五位数,和阈值、LIMIT、访问次数撞在一起(第二单的访问次数正好等于第三单的金额) BIG = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, visits INTEGER);" - "INSERT INTO sales VALUES (1, '华东', 10000, 25000), (2, '华东', 12000, 18000), (3, '华南', 20000, 30000)," + "INSERT INTO sales VALUES (1, '华东', 10000, 25000), (2, '华东', 12000, 20000), (3, '华南', 20000, 30000)," " (4, '华南', 30000, 22000), (5, '华北', 50000, 9000);") #: 报告取数选了金额:金额列里的值都算遮罩值 AMOUNTS = "SELECT region AS 区域, amount AS 金额 FROM sales ORDER BY id" @@ -1053,14 +1054,39 @@ async def test_thresholds_and_limits_are_sealed_as_written_even_when_they_match_ assert [q["sql"] for q in event.data["queries"]] == sealed got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{item['queries'][0]['artifact']}") assert got.status_code == 200 and got.json()["content"]["sql"] == large + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{item['queries'][1]['artifact']}") + assert got.status_code == 200 and got.json()["content"]["sql"] == sealed[1], "质询工件的 SQL 和事件里的一字不差" doc = artifact_store.load(item["answer"]["doc_artifact"]) assert [s["text"] for s in iter_segments(doc) if s.get("issue") == "uncited_number"] == ["30000", "20000"], \ "不遮不等于有出处:agent 自己定的阈值照样标「无出处」" +async def test_a_cell_equal_to_a_threshold_in_the_sql_is_masked_alike_in_the_answer_and_the_artifact( + monkeypatch, big_shop, client): + """访问次数正好等于 SQL 里照原样留着的阈值(20000,也是某一笔的金额):正文里复述的阈值不遮,格子里的照旧按值遮 + (col >= v 再取第一行,查出来的可能就是那一笔)。回答和质询工件里这一格一样是「已遮罩」,两边对得上。""" + least = "SELECT region AS 区域, visits AS 访问 FROM sales WHERE visits >= 20000 ORDER BY visits LIMIT 1" + plays = {"访问": Play([least], "访问不少于 20000 次的区域里,最少的是 [[v:Q1.r0.区域]]," + "访问 [[v:Q1.r0.访问]] 次。[[see:Q1]]")} + Models(monkeypatch, big_shop, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + paused = await pause(big_shop, graph=fetching(big_shop, AMOUNTS)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="访问多的区域有哪些?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert item["answer"]["text"] == "访问不少于 20000 次的区域里,最少的是 华东,访问 已遮罩 次。" + [query] = item["queries"] + assert query["sql"] == least, "阈值、LIMIT 照原样封存" + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["华东", "已遮罩"]] and got.json()["masked"] == ["访问"] + assert got.json()["content"]["sql"] == least + + async def test_a_metric_from_the_report_that_equals_a_masked_value_can_be_cited(monkeypatch, big_shop, client): """报告口径卡里的指标(单笔目标 20000)恰好和遮罩列里的某一笔一样:报告里本来就看得到,引用它的回答照常交出去, - 换个问法也不会被拦。""" + 换个问法也不会被拦;别的列里正好是这个数的格子(第二单访问 20000 次)在回答和质询工件里都照常显示,两边对得上。""" metrics = [{"id": "target", "name": "单笔目标", "unit": "元", "expression": "vars.kpi.target"}] graph = chain(node("start", "input"), node("fetch", "tool", tool=f"db_query__{big_shop}", args={"sql": AMOUNTS}), @@ -1070,7 +1096,9 @@ async def test_a_metric_from_the_report_that_equals_a_masked_value_can_be_cited( node("gate", "human", mode="approve", title="周报可以出具吗"), node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])) smallest = "SELECT amount AS 金额 FROM sales WHERE amount > 15000 ORDER BY amount LIMIT 1" - plays = {"定得高不高": Play([smallest], "单笔目标为 [[m:target]],达标的最小一笔是 [[v:Q1.r0.金额]]。[[see:Q1]]")} + second = "SELECT region AS 区域, visits AS 访问 FROM sales WHERE id = 2" + plays = {"定得高不高": Play([smallest, second], "单笔目标为 [[m:target]],达标的最小一笔是 [[v:Q1.r0.金额]];" + "[[v:Q2.r0.区域]]第二单访问 [[v:Q2.r0.访问]] 次。[[see:Q1]]")} Models(monkeypatch, big_shop, writer="单笔目标 [[m:target]]。[[see:m:target]]", plays=plays) paused = await pause(big_shop, graph=graph) [approval] = await pending(paused.id) @@ -1079,7 +1107,13 @@ async def test_a_metric_from_the_report_that_equals_a_masked_value_can_be_cited( assert r.status_code == 200, r.text answer = r.json()["inquiry"]["answer"] assert (answer["ok"], answer["error"]) == (True, None) - assert answer["text"] == "单笔目标为 20,000元,达标的最小一笔是 已遮罩。" + assert answer["text"] == "单笔目标为 20,000元,达标的最小一笔是 已遮罩;华东第二单访问 20,000 次。" + first, other = r.json()["inquiry"]["queries"] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{other['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["rows"] == [["华东", 20000]] and got.json()["masked"] == [] + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{first['artifact']}") + assert got.json()["content"]["rows"] == [["已遮罩"]] and got.json()["masked"] == ["金额"] async def test_a_masked_value_the_asker_wrote_stays_in_the_sql(monkeypatch, tmp_path, client): @@ -1136,6 +1170,71 @@ async def test_a_value_seen_only_in_the_reports_snapshot_is_masked_in_the_artifa await engines.invalidate(source_id) +#: 同样的四笔订单,手机号存成整数:遮罩值在 SQL 里写成数字 +PHONES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, buyer_phone INTEGER, amount INTEGER, is_test INTEGER);" + "INSERT INTO sales VALUES (1, '华东', 13900000001, 1200, 0), (2, '华东', 13900000002, 300, 1)," + " (3, '华南', 13900000003, 900, 0), (4, '华北', 13900000004, 400, 0);") +#: 报告取数选了手机号:遮罩值从报告自己的查询快照里收 +WITH_PHONE = "SELECT region AS 区域, buyer_phone, amount AS 金额 FROM sales ORDER BY id" +_COUNT = "SELECT COUNT(*) AS 笔数 FROM sales WHERE " + + +@pytest.mark.parametrize("script, sql, sealed", [ + # 数个数用 COUNT:SUM(CASE buyer_phone …) 的结果取自遮罩列,整列按列遮 + pytest.param(PHONES, "SELECT COUNT(CASE buyer_phone WHEN 13900000001 THEN 1 END) AS 笔数 FROM sales", + "SELECT COUNT(CASE buyer_phone WHEN 已遮罩 THEN 1 END) AS 笔数 FROM sales", id="简单 CASE"), + pytest.param(PHONES, _COUNT + "buyer_phone IN (VALUES (13900000001))", _COUNT + "buyer_phone IN (VALUES (已遮罩))", + id="IN (VALUES)"), + pytest.param(PHONES, _COUNT + "buyer_phone BETWEEN 13900000001 AND 13900000001", + _COUNT + "buyer_phone BETWEEN 已遮罩 AND 已遮罩", id="BETWEEN 两头相同"), + pytest.param(PHONES, _COUNT + "buyer_phone >= 13900000001 AND buyer_phone <= 13900000001", + _COUNT + "buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩", id="不低于且不高于同一个值"), + pytest.param(PHONES, _COUNT + "NOT (buyer_phone > 13900000001 OR buyer_phone < 13900000001)", + _COUNT + "NOT (buyer_phone > 已遮罩 OR buyer_phone < 已遮罩)", id="既不大于也不小于"), + pytest.param(PHONES, _COUNT + "buyer_phone >= 13900000001 AND buyer_phone < 13900000002", + _COUNT + "buyer_phone >= 已遮罩 AND buyer_phone < 已遮罩", id="整数的半开区间"), + pytest.param(PHONES, _COUNT + "(buyer_phone, region) = (13900000001, '华东')", + _COUNT + "(buyer_phone, region) = (已遮罩, '华东')", id="行值"), + pytest.param(PHONES, _COUNT + "buyer_phone IS NOT DISTINCT FROM 13900000001", + _COUNT + "buyer_phone IS NOT DISTINCT FROM 已遮罩", id="IS NOT DISTINCT FROM"), + pytest.param(PHONES, _COUNT + "buyer_phone = (13900000001)", _COUNT + "buyer_phone = (已遮罩)", id="套了括号"), + pytest.param(PHONES, "SELECT COUNT(*) AS 笔数, 13900000001 AS 号码 FROM sales WHERE buyer_phone = 13900000001", + "SELECT COUNT(*) AS 笔数, 已遮罩 AS 号码 FROM sales WHERE buyer_phone = 已遮罩", id="选出列里的字面量"), + pytest.param(SALES, _COUNT + "buyer_phone >= '13900000001' AND buyer_phone <= '13900000001'", + _COUNT + "buyer_phone >= '已遮罩' AND buyer_phone <= '已遮罩'", id="字符串范围两头相同"), + pytest.param(SALES, _COUNT + "buyer_phone BETWEEN '13900000001' AND '13900000001'", + _COUNT + "buyer_phone BETWEEN '已遮罩' AND '已遮罩'", id="字符串 BETWEEN 两头相同"), +]) +async def test_a_customer_looked_up_in_any_shape_never_shows(monkeypatch, tmp_path, client, script, sql, sealed): + """agent 换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码……),回答里 + 复述了号码:回答正文、封存的 SQL、事件、回答文档、质询工件里都不出现这个号码。报告取数选了手机号,号码是从报告 + 自己的查询快照里认出来的。""" + name, source_id = await _source(tmp_path, options={"mask_columns": "buyer_phone"}, script=script) + plays = {"这位客户": Play([sql], "客户 13900000001 共下了 [[v:Q1.r0.笔数]] 笔。[[see:Q1]]")} + Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays) + try: + paused = await pause(name, graph=fetching(name, WITH_PHONE)) + [approval] = await pending(paused.id) + r = await ask(client, paused.id, report="write", unit=None, question="这位客户下了几笔?", + approval_id=approval.id) + assert r.status_code == 200, r.text + item = r.json()["inquiry"] + assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "客户 已遮罩 共下了 1 笔。") + [query] = item["queries"] + assert query["sql"] == sealed + [event] = await events(paused.id, "approval.inquiry") + got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}") + assert got.status_code == 200, got.text + assert got.json()["content"]["sql"] == sealed, "质询工件的 SQL 和事件里的一字不差" + doc = artifact_store.load(item["answer"]["doc_artifact"]) + for shown in (r.text, json.dumps(event.data, ensure_ascii=False), json.dumps(doc, ensure_ascii=False), got.text): + assert "1390000000" not in shown + if "号码" in sql: + assert got.json()["content"]["rows"] == [[1, "已遮罩"]] and got.json()["masked"] == ["号码"] + finally: + await engines.invalidate(source_id) + + # -------------------------------------------------------------------------- # 接线:两道审批的竞态、上传表格的版本、设置、开放之后状态又变了、张冠李戴的报告文档 # -------------------------------------------------------------------------- diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index ec5a3d82..3db62f26 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -4,11 +4,14 @@ 照样遮;COUNT 只数个数、窗口的分区键只决定次序,不遮;认不出落在哪一列的(两个 * 之间、子查询里没起别名的表达式)、 解析不了又写着遮罩列的,整份快照遮掉 - 按值认的遮罩值有门槛:纯数字够长(SECRET_DIGITS 位)、文字至少两个字;日期、小数、千分位里的一截不算;提问人自己 - 写进问题、选中原文里的,报告列出的指标和运行输入的值,SQL 里照原样留着的阈值,都不换 -- SQL 只换在找某一个值的字面量(等值、IN、LIKE,带前缀的字符串也算)和注释、名字里的遮罩值:和遮罩列比较的字面量是 - 遮罩列出现过的值就换,短的数也换;范围比较的边界、BETWEEN、LIMIT、日期、别的列的范围条件照原样 + 写进问题、选中原文里的,报告列出的指标和运行输入的值,都不换;SQL 里照原样留着的阈值、行数只在回答正文里不换, + 格子里的同一个数照旧换 +- SQL 里数字照原样留着的只有范围比较的边界(没把某一个值夹住的)和 LIMIT / OFFSET 的行数;在找某一个值的(等值、IN、 + LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、 + >= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换;选出列、函数参数、算式里够长的遮罩值也换;字符串 + (范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免 - 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换 -- 组装回答之后正文里还认得出遮罩值的,不交出去 +- 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去 - 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则 数据都是虚构的:手机号、姓名、年龄、区域、金额。 @@ -213,9 +216,9 @@ def people() -> inquiry_mod._Secrets: ("SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40", "SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40"), ("SELECT amount FROM sales WHERE amount = 13900000001", "SELECT amount FROM sales WHERE amount = 已遮罩"), - # 和遮罩列做范围比较:边界是阈值,不是在找某个人,照原样 - ("SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31", - "SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31"), + # 和遮罩列做范围比较:边界是阈值,不是在找某个人,照原样(同一个数只从一边隔开:25 的几处都是「不超过 25」那一侧) + ("SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 20 AND 25", + "SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 20 AND 25"), # 不等、SQLite 的 IS、NOT LIKE 也是在找某一个值 ("SELECT COUNT(*) FROM sales WHERE age != 25 AND age IS NOT 31 AND buyer_phone NOT LIKE '13900000001'", "SELECT COUNT(*) FROM sales WHERE age != 已遮罩 AND age IS NOT 已遮罩 AND buyer_phone NOT LIKE '已遮罩'"), @@ -231,20 +234,160 @@ def amounts() -> inquiry_mod._Secrets: @pytest.mark.parametrize("sql, shown", [ - # 范围比较、LIMIT / OFFSET、选出列里的数:长的数也照原样 + # 范围比较、LIMIT / OFFSET:长的数也照原样 ("SELECT COUNT(*) FROM sales WHERE visits > 20000 AND amount >= 30000 AND amount BETWEEN 10000 AND 50000 " "LIMIT 10000 OFFSET 12000", "SELECT COUNT(*) FROM sales WHERE visits > 20000 AND amount >= 30000 AND amount BETWEEN 10000 AND 50000 " "LIMIT 10000 OFFSET 12000"), - ("SELECT 20000 AS 门槛, amount - 10000 AS 差额 FROM sales", "SELECT 20000 AS 门槛, amount - 10000 AS 差额 FROM sales"), + # 按同一个数分成两段(不低于、低于):只从一边隔开,是阈值 + ("SELECT SUM(amount >= 30000) AS 大单, SUM(amount < 30000) AS 小单 FROM sales", + "SELECT SUM(amount >= 30000) AS 大单, SUM(amount < 30000) AS 小单 FROM sales"), + # 选出列、算式、函数参数里的数:认不准是不是在找某一个值,够长的遮罩值换(短的数照原样) + ("SELECT 20000 AS 门槛, amount - 10000 AS 差额, ROUND(amount, 2) FROM sales", + "SELECT 已遮罩 AS 门槛, amount - 已遮罩 AS 差额, ROUND(amount, 2) FROM sales"), + ("SELECT COUNT(*) FROM sales WHERE amount - 30000 >= 0", "SELECT COUNT(*) FROM sales WHERE amount - 已遮罩 >= 0"), # 等值、IN:和遮罩列比较的换,和别的列比较的、够长的遮罩值也换 ("SELECT COUNT(*) FROM sales WHERE amount = 30000 OR amount IN (10000, -12000) OR visits = 20000", "SELECT COUNT(*) FROM sales WHERE amount = 已遮罩 OR amount IN (已遮罩, -已遮罩) OR visits = 已遮罩"), ]) -def test_long_masked_numbers_are_only_masked_where_a_value_is_looked_up(amounts, sql, shown): +def test_long_masked_numbers_are_kept_only_as_thresholds_and_row_counts(amounts, sql, shown): assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == shown +@pytest.fixture +def phones() -> inquiry_mod._Secrets: + """手机号存成整数(遮罩值在 SQL 里写成数字),年龄是短的数。""" + return secrets_of({"columns": ["buyer_phone", "age"], "rows": [[13900000001, 25], [13900000002, 31]]}, + {"buyer_phone", "age"}) + + +@pytest.mark.parametrize("kind, sql, shown", [ + ("sqlite", "SELECT SUM(CASE buyer_phone WHEN 13900000001 THEN 1 ELSE 0 END) AS 笔数 FROM sales", + "SELECT SUM(CASE buyer_phone WHEN 已遮罩 THEN 1 ELSE 0 END) AS 笔数 FROM sales"), + ("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IN (VALUES (13900000001), (13900000002))", + "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IN (VALUES (已遮罩), (已遮罩))"), + ("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE (buyer_phone, region) = (13900000001, '华东')", + "SELECT COUNT(*) AS 笔数 FROM sales WHERE (buyer_phone, region) = (已遮罩, '华东')"), + ("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IS NOT DISTINCT FROM 13900000001", + "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IS NOT DISTINCT FROM 已遮罩"), + ("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = (13900000001)", + "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = (已遮罩)"), + # 选出列里的同一个号码:认不准是不是在找某一个值,够长的遮罩值照样换,不留下「13900000001 AS 号码 … = 已遮罩」 + ("sqlite", "SELECT COUNT(*) AS 笔数, 13900000001 AS 号码 FROM sales WHERE buyer_phone = 13900000001", + "SELECT COUNT(*) AS 笔数, 已遮罩 AS 号码 FROM sales WHERE buyer_phone = 已遮罩"), + ("sqlite", "SELECT COUNT(*) FROM sales WHERE COALESCE(buyer_phone, 0) = 13900000001", + "SELECT COUNT(*) FROM sales WHERE COALESCE(buyer_phone, 0) = 已遮罩"), + # 短的数:认得出被比较的是遮罩列才换 + ("sqlite", "SELECT COUNT(*) FROM sales WHERE age = (25) OR (age, region) = (31, '华东') OR age IN (VALUES (25)) " + "OR age IS NOT DISTINCT FROM 31 OR age IS DISTINCT FROM 25", + "SELECT COUNT(*) FROM sales WHERE age = (已遮罩) OR (age, region) = (已遮罩, '华东') OR age IN (VALUES (已遮罩)) " + "OR age IS NOT DISTINCT FROM 已遮罩 OR age IS DISTINCT FROM 已遮罩"), + ("sqlite", "SELECT SUM(CASE age WHEN 25 THEN 1 END) AS 人数, SUM(CASE WHEN age = 31 THEN 1 END) FROM sales", + "SELECT SUM(CASE age WHEN 已遮罩 THEN 1 END) AS 人数, SUM(CASE WHEN age = 已遮罩 THEN 1 END) FROM sales"), + ("postgresql", "SELECT COUNT(*) FROM sales WHERE buyer_phone = ANY(ARRAY[13900000001, 13900000002]) " + "AND age <> ALL(ARRAY[25]) AND age = ANY(VALUES (31))", + "SELECT COUNT(*) FROM sales WHERE buyer_phone = ANY(ARRAY[已遮罩, 已遮罩]) " + "AND age <> ALL(ARRAY[已遮罩]) AND age = ANY(VALUES (已遮罩))"), +]) +def test_a_value_looked_up_in_any_shape_is_masked_in_the_sql(phones, kind, sql, shown): + """在找某一个值的写法不止「col = 值」:简单 CASE、IN (VALUES …)、行值、IS NOT DISTINCT FROM、套了括号、 + = ANY(ARRAY[…]),都按语法树认出来;THEN 后面的 1、0 照原样。""" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == shown + + +@pytest.mark.parametrize("sql, shown", [ + ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000001", + "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone <= 13900000001", + "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE 13900000001 <= buyer_phone AND buyer_phone <= 13900000001", + "SELECT COUNT(*) FROM sales WHERE 已遮罩 <= buyer_phone AND buyer_phone <= 已遮罩"), + # 大于或小于就是不等于 + ("SELECT COUNT(*) FROM sales WHERE NOT (buyer_phone > 13900000001 OR buyer_phone < 13900000001)", + "SELECT COUNT(*) FROM sales WHERE NOT (buyer_phone > 已遮罩 OR buyer_phone < 已遮罩)"), + # 整数的 < v + 1 就是 <= v + ("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone < 13900000002", + "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone < 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 25", + "SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone >= '13900000001' AND buyer_phone <= '13900000001'", + "SELECT COUNT(*) FROM sales WHERE buyer_phone >= '已遮罩' AND buyer_phone <= '已遮罩'"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN '13900000001' AND '13900000001'", + "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN '已遮罩' AND '已遮罩'"), + # 一头是几个数算出来的(13900000002 - 1 就是 13900000001):照样夹住了 + ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000002 - 1", + "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 - 1"), + # 同一个数在算式里被换掉了,边界上的也不留:留着就把算式里换掉的露了 + ("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000001 + 0", + "SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 + 0"), +]) +def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown): + """两头是同一个值的范围(BETWEEN v AND v、>= v AND <= v)、大于或小于同一个值、整数的半开区间,都只夹住了一个值, + 是在找它,和等值比较一样换。""" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == shown + + +@pytest.mark.parametrize("sql, shown", [ + ("SELECT COUNT(*) AS 笔数, 13900000001 AS 号码 FROM sales WHERE buyer_phone >= 13900000001", + "SELECT COUNT(*) AS 笔数, 已遮罩 AS 号码 FROM sales WHERE buyer_phone >= 已遮罩"), + ("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone - 13900000001 < 5", + "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone - 已遮罩 < 5"), +]) +def test_a_number_masked_elsewhere_in_the_sql_is_not_kept_as_a_threshold(phones, sql, shown): + """同一个号码在选出列、算式里被换掉了,条件里的单边范围也不留:留着就把那里换掉的露了。""" + assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == shown + assert inquiry_mod._shown_literals([(sql, "sqlite")]) <= {"5"} + + +def test_a_threshold_named_in_an_alias_or_a_comment_stays(amounts): + """agent 把阈值写进别名、注释(AS 超过30000的笔数):名字、注释照常按值换,阈值本身照原样,回答正文里复述也不遮。""" + sql = "SELECT SUM(amount >= 30000) AS 超过30000的笔数 FROM sales -- 30000 以上算大单" + assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == ( + "SELECT SUM(amount >= 30000) AS 超过已遮罩的笔数 FROM sales -- 已遮罩 以上算大单") + assert inquiry_mod._shown_literals([(sql, "sqlite")]) == {"30000"} + + +def test_sql_starting_with_blank_lines_is_read_at_the_right_places(): + """agent 写的 SQL 常以换行开头:解析时去掉了开头的空白,按语法树认出的位置要补回来,才对得上原文里的词——阈值、 + 行数照原样,找的那个号码换掉。""" + secrets = secrets_of({"columns": ["buyer_phone", "amount"], "rows": [[13900000001, 30000]]}, {"buyer_phone", "amount"}) + sql = "\n SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND buyer_phone IN (13900000001, 1) LIMIT 30000" + assert inquiry_mod._scrub_sql(sql, secrets, {"buyer_phone", "amount"}, "sqlite") == ( + "\n SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND buyer_phone IN (已遮罩, 1) LIMIT 30000") + assert inquiry_mod._shown_literals([(sql, "sqlite")]) == {"30000"} + + +def test_string_range_bounds_are_masked_by_value(people): + """字符串的范围边界按值换:写成字符串的长号码做边界,多半是在找那一个人;日期边界不是遮罩值,照原样。""" + sql = "SELECT COUNT(*) FROM sales WHERE buyer_phone >= '13900000001' AND buyer_phone < '13900000003' " \ + "AND paid_on >= '2026-01-25'" + assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, "sqlite") == ( + "SELECT COUNT(*) FROM sales WHERE buyer_phone >= '已遮罩' AND buyer_phone < '已遮罩' " + "AND paid_on >= '2026-01-25'") + + +def test_sql_that_cannot_be_parsed_keeps_no_thresholds(): + """SQL 解析不了:认不准哪个数是阈值,一律不免——够长的遮罩值在哪都换,简单的等值比较照样按词认(短的数也换)。""" + secrets = secrets_of({"columns": ["age", "amount"], "rows": [[25, 30000], [31, 10000]]}, {"age", "amount"}) + sql = "SELECT COUNT(*) FROM sales WHERE age = 25 AND amount >= 30000 AND visits > 31 LIMIT 10000 GROUP" + assert inquiry_mod._scrub_sql(sql, secrets, {"age", "amount"}, "sqlite") == ( + "SELECT COUNT(*) FROM sales WHERE age = 已遮罩 AND amount >= 已遮罩 AND visits > 31 LIMIT 已遮罩 GROUP") + assert inquiry_mod._shown_literals([(sql, "sqlite")]) == set() + + +def test_only_thresholds_and_row_counts_count_as_shown(): + """照原样留在 SQL 里的只有范围边界(没夹住某一个值的)和 LIMIT / OFFSET 的行数;选出列、函数参数、CASE、夹住 + 某一个值的范围里的数不算,在别处(这一条或别的查询里)被换掉的数也不算。""" + shown = inquiry_mod._shown_literals + assert shown([("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND visits > 20000 LIMIT 10000 OFFSET 12000", + "sqlite")]) == {"30000", "20000", "10000", "12000"} + assert shown([("SELECT 50000 AS 门槛, SUM(CASE amount WHEN 12000 THEN 1 END), ROUND(amount, 10000) FROM sales " + "WHERE amount BETWEEN 20000 AND 20000", "sqlite")]) == set() + assert shown([("SELECT COUNT(*) FROM sales WHERE amount >= 30000", "sqlite"), + ("SELECT COUNT(*) FROM sales WHERE amount = 30000", "sqlite")]) == set() + assert shown([("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND note LIKE '%30000%'", "sqlite")]) == set() + + @pytest.mark.parametrize("kind, sql, shown", [ ("oracle", "SELECT COUNT(*) FROM sales WHERE buyer_phone = N'13900000001'", "SELECT COUNT(*) FROM sales WHERE buyer_phone = N'已遮罩'"), @@ -272,9 +415,9 @@ def test_numbers_left_as_written_in_the_sql_are_not_masked_in_the_answer(amounts """范围比较的边界、LIMIT 照原样留在 SQL 里,回答正文里同样的数再遮也遮不住什么,只会和 SQL 对不上;等值比较 里换掉的照旧遮。""" sql = "SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND amount <> 50000 LIMIT 10000" - assert inquiry_mod._shown_literals(sql, "sqlite") == ["30000", "10000"] - amounts.protect(inquiry_mod._shown_literals(sql, "sqlite"), strong_only=True) - assert amounts.scrub("金额不低于 30000 元、不是 50000 元的,取前 10000 条") == ( + assert inquiry_mod._shown_literals([(sql, "sqlite")]) == {"30000", "10000"} + amounts.show(inquiry_mod._shown_literals([(sql, "sqlite")])) + assert amounts.scrub("金额不低于 30000 元、不是 50000 元的,取前 10000 条", prose=True) == ( "金额不低于 30000 元、不是 已遮罩 元的,取前 10000 条") assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == ( "SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND amount <> 已遮罩 LIMIT 10000") @@ -283,6 +426,35 @@ def test_numbers_left_as_written_in_the_sql_are_not_masked_in_the_answer(amounts "只免了正文里的:和遮罩列做等值比较的照旧换" +def test_a_threshold_left_in_the_sql_is_shown_in_the_prose_but_not_in_the_cells(amounts): + """SQL 里照原样留着的阈值只在回答正文里不遮:格子里的同一个数照旧按值遮(col >= v 再取第一行,查出来的可能就是 + 那一笔),列名、SQL 里的名字也照旧换。组装出来正文里的不算露出,格子里渲染出来的算。""" + sql = "SELECT region AS 区域, visits AS 访问 FROM sales WHERE visits >= 20000 ORDER BY visits LIMIT 1" + amounts.show(inquiry_mod._shown_literals([(sql, "sqlite")])) + assert amounts.scrub("访问不少于 20000 次", prose=True) == "访问不少于 20000 次" + assert amounts.scrub("访问不少于 20000 次") == "访问不少于 已遮罩 次" + snapshot = {"columns": ["区域", "访问"], "rows": [["华东", 20000]]} + view, masked = inquiry_mod._masked_view(snapshot, set(), amounts) + assert view["rows"] == [["华东", MASKED]] and masked == ["访问"] + doc = inquiry_mod._compose("访问不少于 20000 次的区域里最少的是 [[v:Q1.r0.区域]],访问 [[v:Q1.r0.访问]] 次。", + {"Q1": query_entry(["区域", "访问"])}, run_id="r1", views={"a1": view}, + secrets=amounts, allow=[]) + assert doc["markdown"] == "访问不少于 20000 次的区域里最少的是 华东,访问 已遮罩 次。" + # 一格文字里夹着这个数:按整格比认不出来,渲染出来才露,照旧不交出去 + noted = {"columns": ["备注"], "rows": [["回访 20000 次"]]} + views = {"a1": inquiry_mod._masked_view(noted, set(), amounts)[0]} + with pytest.raises(inquiry_mod._Leak): + inquiry_mod._compose("备注是 [[v:Q1.r0.备注]]。", {"Q1": query_entry(["备注"])}, run_id="r1", views=views, + secrets=amounts, allow=[]) + # 引用的列名里写着这个数:列名在快照、SQL 里都照旧换,标记里的列名换得一样,引用照样解析得了 + named = {"columns": ["超过20000次"], "rows": [[3]]} + shown, _ = inquiry_mod._masked_view(named, set(), amounts) + assert shown["columns"] == ["超过已遮罩次"] + doc = inquiry_mod._compose("访问超过 20000 次的有 [[v:Q1.r0.超过20000次]] 笔。", {"Q1": query_entry(named["columns"])}, + run_id="r1", views={"a1": shown}, secrets=amounts, allow=[]) + assert doc["markdown"] == "访问超过 20000 次的有 3 笔。" + + def test_a_comparison_with_a_renamed_masked_column_is_masked_too(people): sql = "SELECT * FROM (SELECT buyer_phone AS p FROM sales) t WHERE p = '13900000003' AND 1 = 1" assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "p"}, "sqlite") == (