mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
报告里标识列的数不分千位(产线编码 1203 不再写成「1,203」):新增 engine/cell_format.plain_columns,按序看查询当时冻结的数据目录给源列标的度量类型(identifier / status / attribute 不分,flow / stock / ratio 分)、源列名像编码编号(CODE、ID、NO 整词或结尾、驼峰的 Id/No/Code、编码、编号、代码)、是分组键(列、别名、序号都认)而不是聚合;SUM、COUNT、AVG 这类聚合是量,MIN、MAX 按那一列判;判断不了的照旧分千位。只看查询快照和它指向的表结构快照(内容寻址),组装、流式渲染、写作目录、出口复核一致。单元格显示规则 3 版:真有格子因此改了写法的文档记 cell_display: 3、引用上记 plain;离线核对页认 3 版、照 plain 渲染(解析不了 SQL),老文档按记下的版本复核照旧分千位。样本里订单号(order_no)随之不分组,check-bundle 跟着改
This commit is contained in:
@@ -615,11 +615,14 @@
|
||||
|
||||
/**
|
||||
* render_cell(shown(raw)):快照里的一格。kind 是查询时记下的列类型,display 是报告文档记下的单元格显示规则版本
|
||||
* (2 版起 ISO 日期时间的 T 换成空格)。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦
|
||||
* (2 版起 ISO 日期时间的 T 换成空格;3 版起标识列的数不分千位)。plain 是引用上记下的「这一格按标识列显示」:
|
||||
* 哪几列是标识列服务端按 SQL 和冻结的数据目录判(cell_format.plain_columns),本页解析不了 SQL,照引用上记的渲染——
|
||||
* 它只决定分不分千位,值本身照样逐位比对。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦
|
||||
*/
|
||||
function renderCell(raw, token, conv, kind, display) {
|
||||
if (display !== 1 && display !== 2) throw new Unshown(T.shownDisplay(display))
|
||||
function renderCell(raw, token, conv, kind, display, plain = false) {
|
||||
if (display !== 1 && display !== 2 && display !== 3) throw new Unshown(T.shownDisplay(display))
|
||||
if (!PY_NUMERIC || !ISO_DATETIME) throw new Unshown(T.shownOld)
|
||||
const fmt = display >= 3 && plain === true ? 'plain' : 'thousands'
|
||||
let value = raw
|
||||
if (display >= 2 && typeof raw === 'string') {
|
||||
const m = ISO_DATETIME.exec(pyStrip(raw))
|
||||
@@ -630,12 +633,12 @@
|
||||
// Python 把全角、阿拉伯文这些数字也当数(int('1٢٣') 是 123),本页只照 ASCII 算
|
||||
if (/[^\x00-\x7f]/.test(text)) throw new Unshown(T.shownDigits)
|
||||
if (!/^-?\d+$/.test(text) || text.replace('-', '').length <= INT_DIGITS) {
|
||||
return renderNumeric(decimalOf(text), '', null, 'thousands', conv)
|
||||
return renderNumeric(decimalOf(text), '', null, fmt, conv)
|
||||
}
|
||||
}
|
||||
// 对象、列表:服务端按 Python 的 json.dumps 写(键的先后、浮点的写法),本页没有移植
|
||||
if (isObj(value) || Array.isArray(value)) throw new Unshown(T.shownShape)
|
||||
return renderNumber(value, token, { conv }).replace(/\|/g, '¦')
|
||||
return renderNumber(value, token, { conv, fmt }).replace(/\|/g, '¦')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1469,7 +1472,7 @@
|
||||
if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numCellDiffers }
|
||||
const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1
|
||||
return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite),
|
||||
columnKind(art.json, cell.column), display))
|
||||
columnKind(art.json, cell.column), display, own(cite, 'plain') === true))
|
||||
}
|
||||
|
||||
/** 引用上记的换算(|万、|pct 这类),没写是 null */
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""查询结果里哪几列是标识列:报告里显示这几列的数不分千位(evidence.render_cell 的 plain,单元格显示规则 3 版起)。
|
||||
|
||||
部署上撞过:产线编码这类数值型的编码列(编码 1203)在报告的表格里写成「1,203」,读的人以为是个量。千分位只该给量用。
|
||||
按序看三样信号,第一样说得出的算数;都说不出的照旧分千位:
|
||||
|
||||
1. 查询当时冻结的数据目录(查询快照的 schema_artifact 指向的表结构快照里的 catalog)给源列标的度量类型:identifier
|
||||
(标识)、status(状态)、attribute(属性)不分千位;flow(流量)、stock(存量)、ratio(比率)是量,分千位;
|
||||
2. 结果列取自的源列名像编码、编号:CODE、ID、NO 整个词或者在结尾(STATION_CODE、order_id、userNo),带「编码」「编号」
|
||||
「代码」的;
|
||||
3. 结果列是分组键(GROUP BY 里有它,写列、写别名、写序号都算),不是聚合。
|
||||
|
||||
SUM、COUNT、AVG 这类聚合出来的列是量,分千位,不看后面几样;MIN、MAX 取的是那一列本身的值,按那一列判。SQL 解析不了、
|
||||
结果列在 SQL 里找不到(SELECT *、UNION)的,只按结果列名本身和目录里唯一那张表判。
|
||||
|
||||
只看查询快照(SQL、列名)和它指向的表结构快照(都按内容寻址、不会变):同一份快照什么时候判都是同一个结果,组装、
|
||||
流式渲染、出口复核一致。离线核对页(bundle/verifier.js)没法解析 SQL,照引用上记下的 plain 渲染(见 evidence._resolve_cell)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.data.names import name_key
|
||||
|
||||
#: 目录里不是量的度量类型:这些列的数是编号、码值、属性,不分千位
|
||||
PLAIN_MEASURES = frozenset({"identifier", "status", "attribute"})
|
||||
#: 目录里是量的度量类型
|
||||
COUNTED_MEASURES = frozenset({"flow", "stock", "ratio"})
|
||||
#: 列名像编码、编号:CODE / ID / NO 整个词、或者以 _CODE、_ID、_NO 结尾(不分大小写);驼峰的 stationCode、orderId、userNo;
|
||||
#: 带「编码」「编号」「代码」的
|
||||
_CODE_NAME = re.compile(r"(?i:(?:^|_)(?:code|id|no)$)|[a-z0-9](?:Code|Id|ID|No|NO)$|编码|编号|代码")
|
||||
#: 解析 SQL 按这几种读法依次试(快照里没记是哪种库):先通用读法,读不通再换,取第一种读得通的——同一条 SQL 结果固定
|
||||
_READINGS = (None, "mysql", "postgres", "oracle", "sqlite")
|
||||
|
||||
|
||||
def code_like(name: Any) -> bool:
|
||||
"""列名像编码、编号(_CODE_NAME)。"""
|
||||
return isinstance(name, str) and bool(_CODE_NAME.search(name.strip()))
|
||||
|
||||
|
||||
def plain_columns(snapshot: Mapping[str, Any], schema: Callable[[str], Any] | None = None) -> frozenset[str]:
|
||||
"""查询快照里要按标识列显示(数不分千位)的结果列名(照快照 columns 的写法)。
|
||||
|
||||
schema 取表结构快照的内容(参数是 schema_artifact,取不到返回 None),用来读冻结的数据目录;不给就不看目录。
|
||||
不抛异常:分析出了意外就当判断不了(照旧分千位)。"""
|
||||
columns = [str(c) for c in snapshot.get("columns") or []]
|
||||
if not columns:
|
||||
return frozenset()
|
||||
try:
|
||||
catalog = _frozen_catalog(snapshot, schema)
|
||||
select = _outer_select(snapshot.get("sql"))
|
||||
return frozenset(c for c in columns if _plain(c, select, catalog))
|
||||
except Exception: # noqa: BLE001 - 判断不了就照旧分千位,不能让报告渲染崩掉
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _frozen_catalog(snapshot: Mapping[str, Any], schema: Callable[[str], Any] | None) -> dict[str, dict[str, str]]:
|
||||
"""冻结的数据目录里各列的度量类型:{表名的 name_key: {列名的 name_key: 度量类型}}。表名带库名的,最后一段也记一份。"""
|
||||
artifact = snapshot.get("schema_artifact")
|
||||
content = schema(artifact) if schema is not None and isinstance(artifact, str) and artifact else None
|
||||
frozen = content.get("catalog") if isinstance(content, dict) else None
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
for table, entry in (frozen or {}).items() if isinstance(frozen, dict) else ():
|
||||
notes = entry.get("notes") if isinstance(entry, dict) else None
|
||||
cols = notes.get("columns") if isinstance(notes, dict) else None
|
||||
measures = {name_key(str(c)): str(item["measure"]["value"]) for c, item in (cols or {}).items()
|
||||
if isinstance(item, dict) and isinstance(item.get("measure"), dict)
|
||||
and isinstance(item["measure"].get("value"), str)}
|
||||
if measures:
|
||||
for key in {name_key(str(table)), name_key(str(table).rsplit(".", 1)[-1])}:
|
||||
out.setdefault(key, {}).update(measures)
|
||||
return out
|
||||
|
||||
|
||||
def _outer_select(sql: Any) -> Any:
|
||||
"""最外层的 SELECT(sqlglot 的节点);解析不了、最外层不是一个 SELECT(UNION 这类)的为 None。"""
|
||||
from sqlglot import exp
|
||||
|
||||
from app.data.sql_semantics import _parse
|
||||
|
||||
for read in _READINGS:
|
||||
root, _ = _parse(sql, read)
|
||||
if root is not None:
|
||||
return root if isinstance(root, exp.Select) else None
|
||||
return None
|
||||
|
||||
|
||||
def _plain(column: str, select: Any, catalog: dict[str, dict[str, str]]) -> bool:
|
||||
from sqlglot import exp
|
||||
|
||||
item = _projection(select, column) if select is not None else None
|
||||
if item is None:
|
||||
# SELECT *、UNION、解析不了:只看结果列名本身,目录按唯一那张表找
|
||||
tables = _tables(select) if select is not None else {}
|
||||
measure = _measure(catalog, next(iter(tables.values())) if len(tables) == 1 else None, column)
|
||||
return measure in PLAIN_MEASURES if measure else code_like(column)
|
||||
value = _unwrap(item.unalias())
|
||||
if isinstance(value, exp.AggFunc) and not isinstance(value, (exp.Min, exp.Max, exp.AnyValue)):
|
||||
return False # SUM / COUNT / AVG……:量
|
||||
if isinstance(value, (exp.Min, exp.Max, exp.AnyValue)):
|
||||
value = _unwrap(value.this)
|
||||
if isinstance(value, exp.Column):
|
||||
tables = _tables(select)
|
||||
table = tables.get(name_key(value.table)) if value.table else \
|
||||
(next(iter(tables.values())) if len(tables) == 1 else None)
|
||||
measure = _measure(catalog, table, value.name)
|
||||
if measure in PLAIN_MEASURES:
|
||||
return True
|
||||
if measure in COUNTED_MEASURES:
|
||||
return False
|
||||
if code_like(value.name):
|
||||
return True
|
||||
return _grouped(select, item)
|
||||
|
||||
|
||||
def _unwrap(node: Any) -> Any:
|
||||
from sqlglot import exp
|
||||
|
||||
while isinstance(node, (exp.Paren, exp.Cast, exp.TryCast)):
|
||||
node = node.this
|
||||
return node
|
||||
|
||||
|
||||
def _projection(select: Any, column: str) -> Any:
|
||||
"""结果列 column 对应的那一项选取(按别名或列名,先一字不差、再按 name_key)。找不到、或者对上不止一项的为 None。"""
|
||||
items = [p for p in select.expressions if p.alias_or_name]
|
||||
for same in (lambda a: a == column, lambda a: name_key(a) == name_key(column)):
|
||||
hits = [p for p in items if same(p.alias_or_name)]
|
||||
if hits:
|
||||
return hits[0] if len(hits) == 1 else None
|
||||
return None
|
||||
|
||||
|
||||
def _tables(select: Any) -> dict[str, str]:
|
||||
"""这一层 FROM / JOIN 里的实表:{别名或表名的 name_key: 表名(带库名的照写)}。子查询、CTE 不算实表。"""
|
||||
from sqlglot import exp
|
||||
|
||||
with_ = select.args.get("with_") or select.args.get("with")
|
||||
ctes = {name_key(c.alias_or_name) for c in with_.expressions} if with_ is not None else set()
|
||||
sources = [(select.args.get("from_") or select.args.get("from"))]
|
||||
sources += [j for j in select.args.get("joins") or []]
|
||||
out: dict[str, str] = {}
|
||||
for source in sources:
|
||||
table = getattr(source, "this", None)
|
||||
if not isinstance(table, exp.Table) or name_key(table.name) in ctes:
|
||||
continue
|
||||
name = ".".join(p for p in (table.db, table.name) if p)
|
||||
out[name_key(table.alias_or_name)] = name
|
||||
return out
|
||||
|
||||
|
||||
def _measure(catalog: dict[str, dict[str, str]], table: str | None, column: str) -> str | None:
|
||||
if not table or not catalog:
|
||||
return None
|
||||
for key in (name_key(table), name_key(table.rsplit(".", 1)[-1])):
|
||||
if key in catalog:
|
||||
return catalog[key].get(name_key(column))
|
||||
return None
|
||||
|
||||
|
||||
def _grouped(select: Any, item: Any) -> bool:
|
||||
"""这一项选取是分组键:GROUP BY 里写了同样的表达式、它的别名,或者它的序号(GROUP BY 1)。"""
|
||||
from sqlglot import exp
|
||||
|
||||
group = select.args.get("group")
|
||||
if group is None:
|
||||
return False
|
||||
value = _unwrap(item.unalias())
|
||||
position = next((k for k, p in enumerate(select.expressions, start=1) if p is item), None)
|
||||
alias = name_key(item.alias) if item.alias else None
|
||||
for key in group.expressions:
|
||||
key = _unwrap(key)
|
||||
if isinstance(key, exp.Literal) and key.is_int and int(key.this) == position:
|
||||
return True
|
||||
if alias and isinstance(key, exp.Column) and not key.table and name_key(key.name) == alias:
|
||||
return True
|
||||
if key == value:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["COUNTED_MEASURES", "PLAIN_MEASURES", "code_like", "plain_columns"]
|
||||
@@ -79,9 +79,11 @@ ENTITY_SYNTAXES = frozenset({1, 2})
|
||||
#: eid、证据下钻都不变
|
||||
#: - 3:整表标记 rows= 的结束行越界、起始行在范围内时,截到最后一行(以前整张表算解析不了)。截完照样展开成一格格的
|
||||
#: 单元格引用、按格复核,所以这一条只影响组装;复核 3 版以前的文档时,没展开的整表标记照老规矩判,不然老文档里那句
|
||||
#: 「没有第 N 行」重新解析得了,会被当成文档被改过(render_mismatch)
|
||||
#: 「没有第 N 行」重新解析得了,会被当成文档被改过(render_mismatch)。另外标识列(编码、编号、分组键,见
|
||||
#: cell_format.plain_columns)的数不分千位:编码 1203 不写成「1,203」。真有格子因此改了写法的文档记 cell_display: 3,
|
||||
#: 引用上记 plain: true(离线核对页解析不了 SQL,照它渲染)
|
||||
#: 新组装的文档里真有格子因为显示规则改了写法时才记 cell_display(参与内容哈希),记的是渲染得出这些格子的最低一版
|
||||
#: (只有日期时间被改写的记 2:3 版只多了组装时的整表截行,不改格子的写法),没有的文档和以前一字不差。复核必须按
|
||||
#: (只有日期时间被改写的记 2:标识列没有因此改写法的,3 版的整表截行不改格子的写法),没有的文档和以前一字不差。复核必须按
|
||||
#: 文档生成时的那一版:老文档存的是带 T 的字,拿新规则重新渲染对不上,会被当成文档被改过(render_mismatch)
|
||||
CELL_DISPLAY = 3
|
||||
CELL_DISPLAYS = frozenset({1, 2, 3})
|
||||
@@ -704,10 +706,11 @@ def render_metric(metric: dict[str, Any], conv: str | None = None) -> str:
|
||||
)
|
||||
|
||||
|
||||
def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None) -> str:
|
||||
def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None, plain: bool = False) -> str:
|
||||
"""查询快照里的一格渲染成字。
|
||||
|
||||
- 数按千分位,小数位照值本来的样子(最多 10 位、去尾零);DECIMAL 落盘后的数字文本
|
||||
- 数按千分位,小数位照值本来的样子(最多 10 位、去尾零);plain 时不分组(标识列:编码 1203 不写成「1,203」,
|
||||
哪几列是标识列见 cell_format.plain_columns)。DECIMAL 落盘后的数字文本
|
||||
("45678.50",小数位为 0 的 "45678",规则见 expressions.cell_value)按数算,用 Decimal
|
||||
渲染,不经 float 丢精度
|
||||
- kind 是这一列查询时记下的类型:text 列按文本,VARCHAR 的 "2026" 不渲染成「2,026」;
|
||||
@@ -715,16 +718,17 @@ def render_cell(value: Any, conv: str | None = None, *, kind: str | None = None)
|
||||
- NULL 显示「—」:这是快照里的事实(那一格就是空的),不是缺值,更不是 0
|
||||
- 文本压成一行,竖线换成 ¦:它常常落在表格里,原样的 | 会多切出一格
|
||||
"""
|
||||
fmt = "plain" if plain else DEFAULT_FORMAT
|
||||
if isinstance(value, str) and cell_value(value, kind) is not value:
|
||||
try:
|
||||
return _render_numeric(Decimal(value.strip()), unit="", decimals=None, fmt=DEFAULT_FORMAT, conv=conv)
|
||||
return _render_numeric(Decimal(value.strip()), unit="", decimals=None, fmt=fmt, conv=conv)
|
||||
except RenderError:
|
||||
raise
|
||||
except (ArithmeticError, ValueError) as e:
|
||||
raise RenderError(f"「{value}」无法按规则显示") from e
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||
return render_number(value, fmt=DEFAULT_FORMAT, conv=conv).replace("|", "¦")
|
||||
return render_number(value, fmt=fmt, conv=conv).replace("|", "¦")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -1321,6 +1325,19 @@ class _Snapshots:
|
||||
#: 这一次解析里的单元格要按哪一版显示规则才渲染得出来(有格子因为某一版的规则改了写法,就至少是那一版):
|
||||
#: 组装时据此决定文档记不记、记哪一版 cell_display
|
||||
self.needed = 1
|
||||
#: 每份查询快照里的标识列(cell_format.plain_columns),按快照的工件 id 记
|
||||
self.plains: dict[str, frozenset[str]] = {}
|
||||
|
||||
def plain(self, artifact: Any, snapshot: Any) -> frozenset[str]:
|
||||
"""这份查询快照里按标识列显示(数不分千位)的结果列。3 版以前的显示规则没有这一条,一律分千位。"""
|
||||
if self.display < 3 or not isinstance(snapshot, dict):
|
||||
return frozenset()
|
||||
key = str(artifact or "")
|
||||
if key not in self.plains:
|
||||
from app.engine.cell_format import plain_columns
|
||||
|
||||
self.plains[key] = plain_columns(snapshot, lambda a: self._fetch(a, "表结构快照")[0])
|
||||
return self.plains[key]
|
||||
|
||||
def shown(self, raw: Any) -> Any:
|
||||
"""按显示规则要显示的原值:2 版起 ISO 日期时间的 T 换成空格。没改就是原值本身。"""
|
||||
@@ -1343,6 +1360,7 @@ class _Snapshots:
|
||||
return self
|
||||
twin = _Snapshots(self.loader, entities=self.entities_on, syntax=syntax, display=display)
|
||||
twin.memo = self.memo
|
||||
twin.plains = self.plains
|
||||
return twin
|
||||
|
||||
def get(self, alias: str, artifact: Any) -> tuple[Any, str | None]:
|
||||
@@ -1441,7 +1459,14 @@ def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snaps
|
||||
try:
|
||||
raw, column = locate_cell(snapshot, parsed["row"], parsed["column"])
|
||||
kind = column_kind(snapshot, column)
|
||||
rendered = render_cell(snaps.shown(raw), conv, kind=kind)
|
||||
shown = snaps.shown(raw)
|
||||
rendered = render_cell(shown, conv, kind=kind)
|
||||
plain = column in snaps.plain(entry.get("artifact"), snapshot) \
|
||||
and render_cell(shown, conv, kind=kind, plain=True) != rendered
|
||||
if plain:
|
||||
# 标识列(3 版起):数不分千位。真改了写法才记——文档的 cell_display 记成 3,引用上记 plain,离线核对页照它渲染
|
||||
rendered = render_cell(shown, conv, kind=kind, plain=True)
|
||||
snaps.needed = max(snaps.needed, 3)
|
||||
except CellError as e:
|
||||
return _unresolved(marker, alias, "cell", f"{alias} {e}")
|
||||
except RenderError as e:
|
||||
@@ -1452,6 +1477,8 @@ def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snaps
|
||||
"rendered": rendered}
|
||||
if conv:
|
||||
cite["conv"] = conv
|
||||
if plain:
|
||||
cite["plain"] = True
|
||||
return cite
|
||||
|
||||
|
||||
@@ -3636,6 +3663,7 @@ def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool,
|
||||
return lines
|
||||
columns = [str(c) for c in snapshot.get("columns") or []][:_PROMPT_COLS]
|
||||
rows = snapshot["rows"]
|
||||
plain = snaps.plain(entry.get("artifact"), snapshot)
|
||||
example = None
|
||||
for r, record in enumerate(rows[:shown_rows]):
|
||||
cells = []
|
||||
@@ -3643,7 +3671,8 @@ def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool,
|
||||
try:
|
||||
raw, _ = locate_cell(snapshot, r, c)
|
||||
kind = column_kind(snapshot, c)
|
||||
shown, numeric = render_cell(snaps.shown(raw), kind=kind), is_number(cell_value(raw, kind))
|
||||
shown = render_cell(snaps.shown(raw), kind=kind, plain=c in plain)
|
||||
numeric = is_number(cell_value(raw, kind))
|
||||
except (CellError, RenderError):
|
||||
shown, numeric = MISSING, False
|
||||
cells.append(f"{c}={shown}")
|
||||
@@ -3798,7 +3827,8 @@ def _field_prompt(path: str, value: Any, ev: dict[str, Any], aliases: dict[str,
|
||||
if why:
|
||||
raise CellError(why)
|
||||
raw, column = locate_cell(snapshot, row, column)
|
||||
shown = render_cell(snaps.shown(raw), kind=column_kind(snapshot, column))
|
||||
shown = render_cell(snaps.shown(raw), kind=column_kind(snapshot, column),
|
||||
plain=column in snaps.plain(ev.get("artifact"), snapshot))
|
||||
except (CellError, RenderError):
|
||||
try:
|
||||
shown = render_cell(value)
|
||||
|
||||
@@ -60,7 +60,7 @@ function render(c) {
|
||||
try {
|
||||
const value = v.parseJson(c.json)
|
||||
const token = v.tokenAt(v.numberTokens(c.json), [])
|
||||
if (c.cell) return { shown: v.renderCell(value, token, c.conv, c.kind, c.display) }
|
||||
if (c.cell) return { shown: v.renderCell(value, token, c.conv, c.kind, c.display, c.plain) }
|
||||
const decimals = v.parseJson(c.decimals)
|
||||
return { shown: v.renderNumber(value, token, { unit: c.unit, decimals,
|
||||
decimalsToken: v.tokenAt(v.numberTokens(c.decimals), []), fmt: c.format, conv: c.conv }) }
|
||||
@@ -137,9 +137,10 @@ def py_number(value: Any, unit: str, decimals: Any, fmt: Any, conv: Any) -> str
|
||||
return None
|
||||
|
||||
|
||||
def py_cell(raw: Any, conv: Any, kind: Any, display: int) -> str | None:
|
||||
def py_cell(raw: Any, conv: Any, kind: Any, display: int, plain: bool = False) -> str | None:
|
||||
try:
|
||||
return render_cell(_Snapshots(display=display).shown(raw), conv, kind=kind)
|
||||
# 3 版起标识列的数不分千位:服务端按 SQL 判(cell_format),引用上记 plain,页面照它渲染
|
||||
return render_cell(_Snapshots(display=display).shown(raw), conv, kind=kind, plain=plain and display >= 3)
|
||||
except RenderError:
|
||||
return None
|
||||
|
||||
@@ -190,9 +191,9 @@ def cell_cases() -> list[dict[str, Any]]:
|
||||
text = json.dumps(raw, ensure_ascii=False)
|
||||
for kind in CELL_KINDS:
|
||||
for conv in CELL_CONVS:
|
||||
for display in (1, 2):
|
||||
for display, plain in ((1, False), (2, False), (3, False), (3, True), (2, True)):
|
||||
cases.append({"cell": True, "json": text, "kind": kind, "conv": conv, "display": display,
|
||||
"py": py_cell(json.loads(text), conv, kind, display)})
|
||||
"plain": plain, "py": py_cell(json.loads(text), conv, kind, display, plain)})
|
||||
return cases
|
||||
|
||||
|
||||
@@ -331,7 +332,8 @@ def test_every_format_in_the_monthly_sample_passes_as_exported():
|
||||
"m:conv_rate": "2.35%", "m:growth": "-8.2%", "m:orders": "12840单", "m:avg": "5.81元", "m:avg|.3": "5.811元",
|
||||
"m:avg|int": "6元", "m:share|pct": "34.6%", "m:export": "15,230.4美元",
|
||||
"v:Q1.r0.region": "华南", "v:Q1.r0.gmv": "45,678.5", "v:Q1.r0.gmv|万": "4.57万", "v:Q1.r1.region": "华东",
|
||||
"v:Q1.r1.gmv": "-1,200.75", "v:Q1.r0.order_no": "202,609,071,234,567,890",
|
||||
# 订单号是标识列(列名以 _no 结尾):3 版显示规则起不分千位,超过 2^53 也一位不差
|
||||
"v:Q1.r1.gmv": "-1,200.75", "v:Q1.r0.order_no": "202609071234567890",
|
||||
"v:Q1.r0.paid_at": "2026-09-07 08:21:39",
|
||||
}
|
||||
|
||||
@@ -353,8 +355,8 @@ def test_every_format_in_the_monthly_sample_passes_as_exported():
|
||||
("v:Q1.r0.gmv", "54,678.5", "45678.5", "45,678.5"), # DECIMAL 落成的数字文本
|
||||
("v:Q1.r1.gmv", "1,200.75", "-1200.75", "-1,200.75"), # 负数单元格
|
||||
("v:Q1.r0.gmv|万", "5.57万", "45678.5", "4.57万"), # 单元格换算成万
|
||||
("v:Q1.r0.order_no", "202,609,071,234,567,891", "202609071234567890",
|
||||
"202,609,071,234,567,890"), # 超过 2^53 的整数
|
||||
# 超过 2^53 的整数;标识列不分组,应显示的字和引用的值一样,原因里不另写
|
||||
("v:Q1.r0.order_no", "202609071234567891", "202609071234567890", None),
|
||||
("v:Q1.r0.paid_at", "2026-09-08 08:21:39", "2026-09-07T08:21:39", "2026-09-07 08:21:39"), # 日期时间
|
||||
])
|
||||
def test_changing_only_the_shown_text_fails_and_names_both(ref, text, cited, expected):
|
||||
@@ -391,13 +393,27 @@ def test_a_format_the_page_does_not_know_cannot_pass(ref, cite, text):
|
||||
assert hit["result"] == "na" and hit["reason"].startswith("无法核对显示文字:"), hit
|
||||
|
||||
|
||||
def test_identifier_cells_read_without_separators_and_the_page_follows():
|
||||
"""标识列(订单号这类编号,列名以 _no 结尾)的数不分千位:文档记 cell_display: 3、引用上记 plain,页面照它渲染、
|
||||
原样通过;把 plain 去掉的伪造页,页面按千分位渲染,和正文显示的字对不上,不通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
bundle = formats_bundle()
|
||||
art, doc = doc_of(bundle)
|
||||
seg = segment(doc, "v:Q1.r0.order_no")
|
||||
assert doc["cell_display"] == 3 and seg["text"] == "202609071234567890" and seg["cite"]["plain"] is True
|
||||
assert item(verdicts(bundle), "v:Q1.r0.order_no")["result"] == "pass"
|
||||
seg["cite"].pop("plain")
|
||||
assert item(verdicts(reseal(bundle, art, doc)), "v:Q1.r0.order_no")["result"] == "fail"
|
||||
|
||||
|
||||
def test_an_unknown_cell_display_version_cannot_pass():
|
||||
"""文档记下的单元格显示规则版本认不出(服务端复核记 bad_schema):单元格的显示文字判无法核对;口径卡的照常核对。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
bundle = formats_bundle()
|
||||
art, doc = doc_of(bundle)
|
||||
doc["cell_display"] = 3
|
||||
doc["cell_display"] = 4
|
||||
got = verdicts(reseal(bundle, art, doc))
|
||||
cells = [n for n in got["numbers"] if n["ref"].startswith("v:")]
|
||||
assert cells and all(n["result"] == "na" and n["reason"].startswith("无法核对显示文字:") for n in cells)
|
||||
@@ -521,7 +537,7 @@ def test_numeric_text_cells_show_their_own_digits_in_the_panel():
|
||||
table["rows"][0][2] = "202609071234567891"
|
||||
got = verdicts(formats_bundle(table=table))
|
||||
order = item(got, "v:Q1.r0.order_no")
|
||||
assert order["result"] == "pass" and order["text"] == "202,609,071,234,567,891", order
|
||||
assert order["result"] == "pass" and order["text"] == "202609071234567891", order
|
||||
assert order["truthText"] == "202609071234567891", order
|
||||
assert item(got, "v:Q1.r0.gmv")["truthText"] == "45678.50"
|
||||
# 不是数字文本的格子不另写原文:照旧按值写
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""报告里标识列的数不分千位(engine/cell_format.plain_columns,单元格显示规则 3 版)。
|
||||
|
||||
部署上撞过:产线编码这类数值型的编码列在报告的表格里写成「1,203」,读的人以为是个量。现在按序看三样信号:查询当时冻结的
|
||||
数据目录给源列标的度量类型(identifier / status / attribute 不分,flow / stock / ratio 分)、源列名像编码编号(CODE、
|
||||
_ID、ID、NO、编码、编号、代码)、是分组键而不是聚合;SUM、COUNT、AVG 出来的是量,照旧分千位;判断不了的照旧分千位。
|
||||
真有格子因此改了写法的文档记 cell_display: 3、引用上记 plain;老文档按记下的版本复核,照旧分千位。数据是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import artifact_store
|
||||
from app.engine import evidence as ev
|
||||
from app.engine.cell_format import code_like, plain_columns
|
||||
from app.engine.evidence import build_catalog, compose_doc, verify_doc, writer_catalog
|
||||
|
||||
SCHEMA = {"source": "plant", "tables": {"plant_output": {"columns": []}},
|
||||
"catalog": {"plant_output": {"version": 2, "notes": {"columns": {
|
||||
"batch": {"measure": {"value": "identifier", "source": "human", "status": "confirmed"}},
|
||||
"capacity": {"measure": {"value": "stock", "source": "human", "status": "confirmed"}}}}}}}
|
||||
SQL = ("SELECT p.STATION_CODE AS 产线, YEAR(p.made_on) AS 年份, p.capacity AS 产能, SUM(p.qty) AS 产量, "
|
||||
"COUNT(*) AS 工单数, MAX(p.batch) AS 批次 FROM plant_output p "
|
||||
"GROUP BY p.STATION_CODE, YEAR(p.made_on), p.capacity")
|
||||
COLUMNS = ["产线", "年份", "产能", "产量", "工单数", "批次"]
|
||||
|
||||
|
||||
def _store(obj: dict) -> str:
|
||||
text = artifact_store.canonical_json(obj)
|
||||
artifact = artifact_store.content_hash(text)
|
||||
path = artifact_store._path_of(artifact)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return artifact
|
||||
|
||||
|
||||
def _snap(schema_artifact: str) -> dict:
|
||||
return {"columns": COLUMNS, "column_types": {c: "number" for c in COLUMNS},
|
||||
"rows": [[1203, 2031, 7001, 15200, 1450, 88001], [1207, 2031, 6400, 9300, 1210, 88002]],
|
||||
"row_count": 2, "truncated": False, "elapsed_ms": 2, "sql": SQL, "source": "plant",
|
||||
"schema_artifact": schema_artifact}
|
||||
|
||||
|
||||
AMOUNTS = {"columns": ["金额"], "column_types": {"金额": "number"}, "rows": [[12345]], "row_count": 1,
|
||||
"truncated": False, "elapsed_ms": 1, "sql": "SELECT o.amount * 2 AS 金额 FROM orders o", "source": "plant"}
|
||||
|
||||
|
||||
def _query(artifact: str, columns: list[str], call: str) -> dict:
|
||||
return {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": artifact, "via": "b" * 64, "call_id": call,
|
||||
"tool": "db_query__plant", "source": "plant", "columns": columns, "rows": 2, "truncated": False}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def catalog():
|
||||
snap = _store(_snap(_store(SCHEMA)))
|
||||
ledger = [_query(snap, COLUMNS, "call_1"), _query(_store(AMOUNTS), ["金额"], "call_2")]
|
||||
return build_catalog(nodes={}, ledger=ledger, inputs={})
|
||||
|
||||
|
||||
TEXT = ("[[v:Q1.r0.年份]] 年 [[v:Q1.r0.产线]] 线产量 [[v:Q1.r0.产量]],工单 [[v:Q1.r0.工单数]] 张,产能 [[v:Q1.r0.产能]],"
|
||||
"批次 [[v:Q1.r0.批次]];另一项金额 [[v:Q2.r0.金额]]。[[see:Q1,Q2]]")
|
||||
|
||||
|
||||
def _shown(doc) -> dict[str, str]:
|
||||
return {s["ref"]: s["text"] for b in doc["blocks"] for u in b["units"] for s in u["segments"]
|
||||
if s.get("ref", "").startswith("v:")}
|
||||
|
||||
|
||||
def test_identifier_columns_read_without_separators(catalog):
|
||||
doc = compose_doc(TEXT, catalog)
|
||||
assert doc["violations"] == [], doc["violations"]
|
||||
shown = _shown(doc)
|
||||
assert shown["v:Q1.r0.产线"] == "1203" # 源列名像编码(STATION_CODE)
|
||||
assert shown["v:Q1.r0.年份"] == "2031" # 分组键
|
||||
assert shown["v:Q1.r0.批次"] == "88001" # 目录标的是标识(MAX 取的是那一列本身的值)
|
||||
assert shown["v:Q1.r0.产能"] == "7,001" # 分组键,但目录标的是存量:是量,照旧分千位
|
||||
assert shown["v:Q1.r0.产量"] == "15,200" # SUM:量
|
||||
assert shown["v:Q1.r0.工单数"] == "1,450" # COUNT:量
|
||||
assert shown["v:Q2.r0.金额"] == "12,345" # 判断不了:照旧分千位
|
||||
assert doc["cell_display"] == 3
|
||||
plain = {s["ref"] for b in doc["blocks"] for u in b["units"] for s in u["segments"]
|
||||
if (s.get("cite") or {}).get("plain")}
|
||||
assert plain == {"v:Q1.r0.产线", "v:Q1.r0.年份", "v:Q1.r0.批次"}
|
||||
assert verify_doc(doc, catalog)["ok"]
|
||||
|
||||
|
||||
def test_tables_and_the_writer_catalog_read_the_same_way(catalog):
|
||||
doc = compose_doc("[[table:Q1 cols=产线,产量 rows=0-1]]", catalog)
|
||||
assert doc["violations"] == [] and "| 1203 | 15,200 |" in doc["markdown"] and "| 1207 | 9,300 |" in doc["markdown"]
|
||||
text = writer_catalog(catalog).text
|
||||
assert "产线=1203" in text and "产量=15,200" in text, text
|
||||
|
||||
|
||||
def test_documents_composed_before_still_verify_with_separators(catalog, monkeypatch):
|
||||
monkeypatch.setattr(ev, "CELL_DISPLAY", 2)
|
||||
old = compose_doc(TEXT, catalog)
|
||||
monkeypatch.undo()
|
||||
assert _shown(old)["v:Q1.r0.产线"] == "1,203" and "cell_display" not in old
|
||||
assert verify_doc(old, catalog)["ok"], "按文档记下的版本复核:照旧分千位,不判成被改过"
|
||||
|
||||
|
||||
def test_a_document_edited_back_to_separators_is_caught(catalog):
|
||||
doc = compose_doc("产线 [[v:Q1.r0.产线]]。", catalog)
|
||||
seg = next(s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s.get("ref") == "v:Q1.r0.产线")
|
||||
start = seg["span"][0]
|
||||
doc["markdown"] = doc["markdown"].replace("1203", "1,203")
|
||||
seg["text"], seg["span"] = "1,203", [start, start + 5]
|
||||
assert "render_mismatch" in {v["code"] for v in verify_doc(doc, catalog)["violations"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, yes", [
|
||||
("STATION_CODE", True), ("order_id", True), ("ID", True), ("user_no", True), ("orderId", True), ("stationCode", True),
|
||||
("产线编码", True), ("工单编号", True), ("物料代码", True),
|
||||
("qty", False), ("piano", False), ("video", False), ("income", False), ("产量", False),
|
||||
])
|
||||
def test_code_like_names(name, yes):
|
||||
assert code_like(name) is yes
|
||||
|
||||
|
||||
def test_unparseable_sql_falls_back_to_the_column_name():
|
||||
snap = {"columns": ["station_id", "total"], "sql": "this is not sql", "rows": []}
|
||||
assert plain_columns(snap) == frozenset({"station_id"})
|
||||
@@ -864,7 +864,8 @@ await section('样本页:各种形状', async () => {
|
||||
check('formats:点开换算成万的数字,引用的值、口径卡里的值照原值写,注明与按显示规则渲染的结果一致',
|
||||
detail.includes('74613.65') && detail.includes('与按显示规则渲染的结果一致') && !detail.includes('按显示规则应为'),
|
||||
detail.replace(/\s+/g, ' ').slice(0, 160))
|
||||
const order = page.locator('button.num').filter({ hasText: '202,609,071,234,567,890' })
|
||||
// 订单号是标识列(列名以 _no 结尾):3 版显示规则起不分千位
|
||||
const order = page.locator('button.num').filter({ hasText: /^202609071234567890$/ })
|
||||
detail = '找不到订单号'
|
||||
let rowCell = ''
|
||||
if (await order.count()) {
|
||||
|
||||
Reference in New Issue
Block a user