mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
合入第 2 波复核修复 S1:分析结论的占比、变化率、比率之差按百分数 / 百分点显示——结果表带每列的显示格式、具名的数带格式,证据渲染、整表、写作目录、证据面板、导出、可验证报告都按它;显示规则 4 版,老文档按记下的版本复核;空格写「—」
# Conflicts: # backend/tests/test_analysis_pages.py
This commit is contained in:
@@ -64,13 +64,18 @@ def today_param(env: Env) -> str:
|
||||
def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str = "ok",
|
||||
reason_code: str | None = None, reason: str | None = None, table: Mapping[str, Any] | None = None,
|
||||
values: Mapping[str, Any] | None = None, verdict: Mapping[str, Any] | None = None, criterion: str,
|
||||
phrase: str, caveats: Sequence[Mapping[str, str]] = ()) -> AnalysisResult:
|
||||
"""组装一份结论:形状严格按 contract.AnalysisResult,params 补上查过的目录事实。"""
|
||||
phrase: str, caveats: Sequence[Mapping[str, str]] = (),
|
||||
formats: Mapping[str, str] | None = None, named: Mapping[str, str] | None = None) -> AnalysisResult:
|
||||
"""组装一份结论:形状严格按 contract.AnalysisResult,params 补上查过的目录事实。formats 是结果表各列的换算(占比、
|
||||
变化率的列记 pct,比率的变化记 pp),named 是具名的数的换算(value_formats):都只留记了的,为空时整项不写。"""
|
||||
record = env.facts.record()
|
||||
out_params = {k: v for k, v in params.items() if v is not None}
|
||||
if record:
|
||||
out_params["catalog"] = record
|
||||
return {
|
||||
out_table = dict(table) if table is not None else None
|
||||
if out_table is not None and (table_formats := column_formats(formats or {})):
|
||||
out_table["formats"] = table_formats
|
||||
out: AnalysisResult = {
|
||||
"op": env.op,
|
||||
"mode": env.mode,
|
||||
"method": METHODS[(env.op, env.mode)],
|
||||
@@ -81,7 +86,7 @@ def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str
|
||||
"status": status, # type: ignore[typeddict-item]
|
||||
"reason_code": reason_code,
|
||||
"reason": reason,
|
||||
"table": dict(table) if table is not None else None, # type: ignore[typeddict-item]
|
||||
"table": out_table, # type: ignore[typeddict-item]
|
||||
"values": dict(values or {}),
|
||||
"verdict": dict(verdict) if verdict is not None else None, # type: ignore[typeddict-item]
|
||||
"criterion": criterion,
|
||||
@@ -89,6 +94,9 @@ def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str
|
||||
"caveats": [dict(c) for c in caveats], # type: ignore[misc]
|
||||
"hypothesis": env.hypothesis,
|
||||
}
|
||||
if named_formats := value_formats(named or {}):
|
||||
out["value_formats"] = named_formats
|
||||
return out
|
||||
|
||||
|
||||
def refuse(env: Env, params: dict[str, Any], used: Sequence[str], code: str, reason: str, *,
|
||||
@@ -155,9 +163,19 @@ def column_types(columns: Sequence[str], *, dims: Mapping[str, str | None] | Non
|
||||
return out
|
||||
|
||||
|
||||
def column_formats(formats: Mapping[str, str]) -> dict[str, str]:
|
||||
"""结果表的 formats(ResultTable.formats):列 id → 换算。只留记了换算的列,都为空时整项不写(结论和以前一字不差)。"""
|
||||
return {str(k): v for k, v in formats.items() if v}
|
||||
|
||||
|
||||
def value_formats(values: Mapping[str, Any]) -> dict[str, str]:
|
||||
"""结论的 value_formats(AnalysisResult.value_formats):键 → 换算。只留记了换算的数,都为空时整项不写。"""
|
||||
return {str(k): v for k, v in values.items() if v}
|
||||
|
||||
|
||||
def sign_word(value: Fraction | float | int) -> str | None:
|
||||
return "up" if value > 0 else "down" if value < 0 else None
|
||||
|
||||
|
||||
__all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_label", "column_types", "label_for", "need", "refuse",
|
||||
"reserved_check", "sign_word", "today_param", "written_places"]
|
||||
__all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_formats", "column_label", "column_types", "label_for",
|
||||
"need", "refuse", "reserved_check", "sign_word", "today_param", "value_formats", "written_places"]
|
||||
|
||||
@@ -277,6 +277,10 @@ class ResultTable(TypedDict):
|
||||
# 列 id → 类型(写法同查询快照的 column_types;第 1 波 W1-a 加,可选):维度列照输入快照里那一列查询时记下的类型
|
||||
# (文本型的码值 "1203" 才不会被渲染成「1,203」;输入没记类型的不写),数值列 number,说明、叫法这类文字列 text
|
||||
column_types: NotRequired[dict[str, str]]
|
||||
# 列 id → 换算(第 2 波加,可选):占比、变化率、权重这类以比率存的列,渲染时按它换算,换算的写法同 [[m:]] 的后缀
|
||||
# (pct 显示成百分数、pp 显示成「+1.2 个百分点」)。引用这一列的一格、展开整表时没写换算的按它渲染,写作者自己写了换算的
|
||||
# 照他的(引用上记下实际用的换算,导出、可验证报告照它渲染)
|
||||
formats: NotRequired[dict[str, str]]
|
||||
|
||||
|
||||
class AnalysisRequest(TypedDict, total=False):
|
||||
@@ -302,6 +306,9 @@ class AnalysisResult(TypedDict, total=False):
|
||||
reason: str | None
|
||||
table: ResultTable | None
|
||||
values: dict[str, Any] # 具名标量:[[a:A3.top_share]];数一律十进制字符串
|
||||
# 键 → 换算(第 2 波加,可选):占比、变化率这类以比率存的数,渲染时按它换算(写法同 ResultTable.formats)。
|
||||
# [[a:A3.top_share]] 没写换算的按它渲染,写作者自己写了换算的照他的
|
||||
value_formats: dict[str, str]
|
||||
verdict: Verdict | None
|
||||
criterion: str # 判定标准,一句,照实际阈值写
|
||||
phrase: str # 判定语(app/analysis/phrases 渲染),[[a:A3]] 渲染成它
|
||||
@@ -417,6 +424,16 @@ class ScopeBlock(TypedDict, total=False):
|
||||
#: 复算核对时不比的字段:hypothesis 是 agent 写的话,不是算出来的
|
||||
NOT_RECOMPUTED = frozenset({"hypothesis"})
|
||||
|
||||
#: 结论里记格式的两处:结果表的 formats(列 id → 换算)、values 的 value_formats(键 → 换算)。换算取值同 [[m:]] 的后缀,
|
||||
#: 另加 pp(比率的变化按百分点写:「+1.2 个百分点」)
|
||||
TABLE_FORMATS = "formats"
|
||||
VALUE_FORMATS = "value_formats"
|
||||
#: 比率的换算:pct 按百分数写(0.62 显示成 62.0%)
|
||||
CONV_PCT = "pct"
|
||||
#: 比率的变化按百分点写(0.012 显示成「+1.2 个百分点」,下降时带负号):[[m:]] 的后缀里原本没有这一种
|
||||
CONV_POINTS = "pp"
|
||||
FORMAT_CONVS: tuple[str, ...] = (CONV_PCT, CONV_POINTS)
|
||||
|
||||
|
||||
def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
|
||||
"""两份结论(封存的、复算的)是否逐字段相同(hypothesis 除外)。"""
|
||||
@@ -425,8 +442,10 @@ def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "DEFAULTS", "LABELS", "LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES",
|
||||
"MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY", "TOOL_NAMES",
|
||||
"ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "CONV_PCT", "CONV_POINTS", "DEFAULTS", "FORMAT_CONVS", "LABELS",
|
||||
"LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES",
|
||||
"MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY",
|
||||
"TABLE_FORMATS", "TOOL_NAMES", "VALUE_FORMATS",
|
||||
"VERDICTS", "AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot",
|
||||
"MetricComparability", "Probe", "Prober", "ResultTable", "ScopeBlock", "ScopeItem", "Verdict", "catalog_result",
|
||||
"effective_settings", "is_result", "ledger_entry", "method_label", "same", "settings_problem",
|
||||
|
||||
@@ -29,7 +29,7 @@ from app.analysis import comparable as cmp
|
||||
from app.analysis import phrases as ph
|
||||
from app.analysis.common import (Env, build, closes, column_label, column_types, label_for, need, refuse,
|
||||
reserved_check, sign_word, today_param)
|
||||
from app.analysis.contract import CallError
|
||||
from app.analysis.contract import CONV_PCT, CONV_POINTS, CallError
|
||||
from app.analysis.inputs import Snap, as_list, hashable, same_value
|
||||
from app.analysis.numbers import canon, to_fraction
|
||||
|
||||
@@ -227,6 +227,17 @@ def _parent(raw: Mapping[str, Any], env: Env, mode: str, dims: int) -> tuple[dic
|
||||
return {"of": name, "key": key}, dict(zip(columns, hit)), depth
|
||||
|
||||
|
||||
#: 结果表里以比率存的列(占比、变化率、权重、比率本身):引用、展开时按百分数显示(ResultTable.formats 记 pct)
|
||||
_RATE_COLUMNS = ("pct", "share")
|
||||
|
||||
|
||||
def _rate_formats(columns: Sequence[str], rate: Sequence[str] = _RATE_COLUMNS,
|
||||
points: Sequence[str] = ()) -> dict[str, str]:
|
||||
"""结果表各列记的换算:rate 里的列(占比、变化率、权重、比率本身)记 pct,points 里的列(比率的变化)记 pp。"""
|
||||
return {**{c: CONV_PCT for c in rate if c in columns},
|
||||
**{c: CONV_POINTS for c in points if c in columns}}
|
||||
|
||||
|
||||
_FIXED_ADDITIVE = ("base", "target", "delta", "pct", "share", "flag")
|
||||
_FIXED_RATIO = ("n_base", "n_target", "d_base", "d_target", "w_base", "w_target", "r_base", "r_target", "mix", "rate",
|
||||
"total", "flag")
|
||||
@@ -470,7 +481,9 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li
|
||||
skip = (("offset",) if code == "flat" else ()) + (("comparability",) if outcome is None else ())
|
||||
phrase = ph.with_caveats(main, caveats, skip=skip)
|
||||
return build(env, params, used, table=table, values=values, verdict=verdict,
|
||||
criterion=ph.criterion_additive(s, top_k_max=k_max, summed=summed), phrase=phrase, caveats=caveats)
|
||||
criterion=ph.criterion_additive(s, top_k_max=k_max, summed=summed), phrase=phrase, caveats=caveats,
|
||||
formats=_rate_formats(columns),
|
||||
named={k: CONV_PCT for k in ("pct_total", "top_share", "top_k_share")})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -681,7 +694,13 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[
|
||||
"direction": sign_word(delta) if code in ("mix_driven", "rate_driven", "both") else None}
|
||||
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
|
||||
phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()),
|
||||
caveats=caveats)
|
||||
caveats=caveats,
|
||||
# 权重、各段的比率按百分数显示;结构效应、自身效应、合计影响是比率的变化,按百分点
|
||||
formats=_rate_formats(columns, rate=("w_base", "w_target", "r_base", "r_target"),
|
||||
points=("mix", "rate", "total")),
|
||||
named={k: CONV_PCT for k in ("r_base_total", "r_target_total")}
|
||||
| {k: CONV_POINTS for k in ("delta", "mix_total", "rate_total")}
|
||||
| {"mix_share": CONV_PCT, "rate_share": CONV_PCT})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -811,7 +830,8 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any:
|
||||
verdict = {"code": code, "subjects": subjects, "direction": sign_word(delta) if code != "flat" else None}
|
||||
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
|
||||
phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()),
|
||||
caveats=caveats)
|
||||
caveats=caveats, formats=_rate_formats(columns),
|
||||
named={k: CONV_PCT for k in ("pct_total", "top_share")})
|
||||
|
||||
|
||||
__all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "USED", "additive", "factor", "lmdi",
|
||||
|
||||
@@ -33,7 +33,7 @@ from typing import Any
|
||||
from app.analysis import phrases as ph
|
||||
from app.analysis import stats
|
||||
from app.analysis.common import Env, build, column_label, column_types, label_for, need, refuse, sign_word
|
||||
from app.analysis.contract import CallError
|
||||
from app.analysis.contract import CONV_PCT, CONV_POINTS, CallError
|
||||
from app.analysis.inputs import Snap, as_list, hashable, same_value
|
||||
from app.analysis.numbers import canon, to_fraction
|
||||
|
||||
@@ -389,8 +389,13 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair]
|
||||
"test": shown.test, "pairs": str(len(pairs)), "differs": str(len(hits))}
|
||||
verdict = {"code": code, "subjects": subjects, "direction": direction}
|
||||
phrase = ph.with_caveats(main, caveats, skip=("multiple_comparisons",) if len(tested) > 1 else ())
|
||||
# 比率模式的组的比率按百分数显示,两组的差、差的区间是比率之差,按百分点(均值模式是量,照原样)
|
||||
rate = ("a_value", "b_value") if mode == "rate" else ()
|
||||
points = ("diff", "ci_lo", "ci_hi") if mode == "rate" else ()
|
||||
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, phrase=phrase,
|
||||
caveats=caveats)
|
||||
caveats=caveats, formats={**{c: CONV_PCT for c in rate}, **{c: CONV_POINTS for c in points}},
|
||||
named={**{k: CONV_PCT for k in ("a", "b")}, **{k: CONV_POINTS for k in ("diff", "ci_lo", "ci_hi")}}
|
||||
if mode == "rate" else {})
|
||||
|
||||
|
||||
def _sensitivity(pairs: list[Pair], mode: str, alpha: Fraction, min_expected: Fraction,
|
||||
|
||||
@@ -33,7 +33,7 @@ from app.analysis import phrases as ph
|
||||
from app.analysis import stats
|
||||
from app.analysis.common import (Env, build, column_label, column_types, label_for, need, refuse, reserved_check,
|
||||
sign_word, today_param)
|
||||
from app.analysis.contract import CallError
|
||||
from app.analysis.contract import CONV_PCT, CallError
|
||||
from app.analysis.inputs import Snap, same_value
|
||||
from app.analysis.numbers import canon, to_fraction
|
||||
|
||||
@@ -258,8 +258,13 @@ def baseline(raw: Mapping[str, Any], env: Env) -> Any:
|
||||
"z": canon(z) if z is not None else None, "rank": str(stats.rank_desc(x, hv)), "n_history": str(n),
|
||||
"hist_min": canon(min(hv)), "hist_max": canon(max(hv))}
|
||||
verdict = {"code": code, "subjects": [], "direction": sign_word(x - m)}
|
||||
# 按变化率比时,本期的变化率、中位数、常规波动范围、历史最小最大都是变化率,按百分数显示;按值本身比时照原样
|
||||
change = ("change",) if basis == "change" else ()
|
||||
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
|
||||
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats)
|
||||
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats,
|
||||
formats={c: CONV_PCT for c in change},
|
||||
named={k: CONV_PCT for k in ("x", "median", "band_lo", "band_hi", "hist_min", "hist_max")
|
||||
if basis == "change"})
|
||||
|
||||
|
||||
def _compare_gap(series: Series, ordinal: int, lag: int, unit: str) -> str:
|
||||
@@ -360,7 +365,9 @@ def trend(raw: Mapping[str, Any], env: Env) -> Any:
|
||||
verdict = {"code": code, "subjects": [], "direction": "up" if code == "rising" else "down" if code == "falling"
|
||||
else None}
|
||||
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
|
||||
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats)
|
||||
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats,
|
||||
# 相对斜率是比率(每期变百分之几),按百分数显示;斜率本身、p 值、各期之比照原样
|
||||
named={"rel_slope": CONV_PCT})
|
||||
|
||||
|
||||
def _tail_gap(series: Series, ordinal: int) -> str:
|
||||
|
||||
@@ -1188,12 +1188,17 @@ async def _analysis_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict
|
||||
|
||||
def _analysis_calc(alias: str, result: dict[str, Any], cited: dict[str, Any], hidden: set[str]) -> dict[str, Any]:
|
||||
"""计算一步:结果表(前 table_top_k 段加上被引用的那一行附近,最多 MAX_ANALYSIS_ROWS 行,高亮被引用的格)、具名的数(原值、
|
||||
渲染出来的字)、关键数的代入式。结果表里万一有取自遮罩列的列(输入查询步骤里遮着的列名),照查询步骤写「已遮罩」。"""
|
||||
渲染出来的字)、关键数的代入式。数和格都按结论记的格式写出来(占比、变化率是百分数,比率的变化是百分点),和正文引用
|
||||
之后显示的字一样;结果表的空格写「—」不写 null,面板照原样显示。结果表里万一有取自遮罩列的列(输入查询步骤里遮着的
|
||||
列名),照查询步骤写「已遮罩」。"""
|
||||
values = result.get("values") if isinstance(result.get("values"), dict) else {}
|
||||
named = result.get("value_formats") if isinstance(result.get("value_formats"), dict) else {}
|
||||
shown_values = []
|
||||
for key, raw in values.items():
|
||||
try:
|
||||
text = render_cell(raw)
|
||||
# 具名的数按结论记的格式写(占比、变化率是百分数),和引用之后正文显示的字一样
|
||||
conv = named.get(key) if isinstance(named.get(key), str) and named.get(key) else None
|
||||
text = render_cell(raw, conv)
|
||||
except RenderError:
|
||||
text = MISSING
|
||||
shown_values.append({"key": str(key), "raw": raw, "rendered": text,
|
||||
@@ -1216,8 +1221,8 @@ def _analysis_calc(alias: str, result: dict[str, Any], cited: dict[str, Any], hi
|
||||
step["table"] = {
|
||||
"columns": columns, "labels": dict(table.get("labels") or {}) if isinstance(table.get("labels"), dict) else {},
|
||||
"total_rows": len(rows), "row_index": picked,
|
||||
"rows": [[MASKED if i in masked else v for i, v in enumerate(rows[r])] if isinstance(rows[r], list) else rows[r]
|
||||
for r in picked],
|
||||
"rows": [[MASKED if i in masked else _shown_cell(v, table, columns[i]) for i, v in enumerate(rows[r])]
|
||||
if isinstance(rows[r], list) else rows[r] for r in picked],
|
||||
"masked": [columns[i] for i in sorted(masked)],
|
||||
"highlight": {"cells": [[row, str(cited.get("column"))]] if isinstance(row, int) else []},
|
||||
**({"window_truncated": True} if len(index) > MAX_ANALYSIS_ROWS else {}),
|
||||
@@ -1225,6 +1230,22 @@ def _analysis_calc(alias: str, result: dict[str, Any], cited: dict[str, Any], hi
|
||||
return step
|
||||
|
||||
|
||||
def _shown_cell(raw: Any, table: Mapping[str, Any], column: str) -> str:
|
||||
"""结果表的一格按结论记的格式写出来(占比、变化率是百分数,比率的变化是百分点),空格写「—」不写 null:面板照原样显示,
|
||||
读者看到的和正文引用那一格时一样。列类型照结果表的 column_types(文本列照原样,不按值猜);渲染不出来(不是 0 却显示成 0)
|
||||
的照原样写。"""
|
||||
if raw is None:
|
||||
return MISSING
|
||||
formats = table.get("formats") if isinstance(table.get("formats"), dict) else {}
|
||||
types = table.get("column_types") if isinstance(table.get("column_types"), dict) else {}
|
||||
conv = formats.get(column) if isinstance(formats.get(column), str) and formats.get(column) else None
|
||||
kind = types.get(column) if isinstance(types.get(column), str) else None
|
||||
try:
|
||||
return render_cell(raw, conv, kind=kind)
|
||||
except RenderError:
|
||||
return str(raw)
|
||||
|
||||
|
||||
async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str, Any], doc: dict[str, Any],
|
||||
sealed: _Sealed, masks: _Masks, *, queries: bool = True
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
@@ -1266,8 +1287,12 @@ async def _analysis_inputs(alias: str, result: dict[str, Any], catalog: dict[str
|
||||
sealed=sealed.trusted and query is not None)
|
||||
if role in totals and totals[role] in values:
|
||||
with contextlib.suppress(RenderError):
|
||||
step["closure"] = {"mode": mode, "total_key": totals[role], "total": render_cell(values[totals[role]]),
|
||||
"headline": render_cell(item.get("value")), "closed": not unclosed}
|
||||
# 整体比率那一格和对照的那一格按结论记的格式写(比率拆解里都是比率,一起写成百分数),和具名的数、正文一致
|
||||
named = result.get("value_formats") if isinstance(result.get("value_formats"), dict) else {}
|
||||
key = totals[role]
|
||||
conv = named.get(key) if isinstance(named.get(key), str) and named.get(key) else None
|
||||
step["closure"] = {"mode": mode, "total_key": key, "total": render_cell(values[key], conv),
|
||||
"headline": render_cell(item.get("value"), conv), "closed": not unclosed}
|
||||
steps.append(step)
|
||||
if artifact and query is not None:
|
||||
wanted = cells.setdefault(artifact, [])
|
||||
|
||||
@@ -596,7 +596,8 @@
|
||||
|
||||
const shifted = (d, k) => ({ neg: d.neg, coef: d.coef, exp: d.exp + k })
|
||||
|
||||
/** _render_numeric:万 / 亿留两位、去尾零,单位接在后面;pct、比率乘 100 加 %(不接单位);int、.N 改小数位 */
|
||||
/** _render_numeric:万 / 亿留两位、去尾零,单位接在后面;pct、比率乘 100 加 %(不接单位);pp 乘 100 写成「+1.2 个百分点」;
|
||||
* int、.N 改小数位 */
|
||||
function renderNumeric(d, unit, decimals, fmt, conv) {
|
||||
if (adjusted(d) >= MAX_DIGITS) throw new Unshown(T.shownTooLong)
|
||||
const ratio = fmt === 'percent_of_ratio'
|
||||
@@ -609,6 +610,20 @@
|
||||
if (unit.includes('%') || unit.includes('%')) throw new Unshown(T.shownPctUnit)
|
||||
return `${digitsOf(shifted(d, 2), decimals != null ? Math.max(decimals - 2, 0) : 1, group, false)}%`
|
||||
}
|
||||
if (conv === 'pp') {
|
||||
// 比率的变化按百分点写:一位小数、正数带「+」(0 不带);不是 0 的值写出来成了 0.0 的多给小数位(最多 4 位),
|
||||
// 不写假的 0(同服务端 engine/evidence._render_numeric 的 pp)
|
||||
if (unit.includes('%') || unit.includes('%')) throw new Unshown(T.shownPctUnit)
|
||||
const points = shifted(d, 2)
|
||||
if (isZero(points)) return `${digitsOf(points, 1, group, false)} 个百分点`
|
||||
let places = 1
|
||||
for (; places < 5; places++) {
|
||||
if (!isZero(quantize(points, -places))) break
|
||||
}
|
||||
if (places >= 5) throw new Unshown(T.shownZero)
|
||||
const out = digitsOf(points, places, group, false)
|
||||
return `${out.startsWith('-') ? out : `+${out}`} 个百分点`
|
||||
}
|
||||
let places
|
||||
if (conv === 'int') places = 0
|
||||
else if (typeof conv === 'string' && CONV_PLACES.test(conv)) places = Number(conv.slice(1))
|
||||
@@ -652,12 +667,13 @@
|
||||
|
||||
/**
|
||||
* render_cell(shown(raw)):快照里的一格。kind 是查询时记下的列类型,display 是报告文档记下的单元格显示规则版本
|
||||
* (2 版起 ISO 日期时间的 T 换成空格;3 版起标识列的数不分千位)。plain 是引用上记下的「这一格按标识列显示」:
|
||||
* (2 版起 ISO 日期时间的 T 换成空格;3 版起标识列的数不分千位;4 版起引用分析结论时按结论记的格式换算)。
|
||||
* plain 是引用上记下的「这一格按标识列显示」:
|
||||
* 哪几列是标识列服务端按 SQL 和冻结的数据目录判(cell_format.plain_columns),本页解析不了 SQL,照引用上记的渲染——
|
||||
* 它只决定分不分千位,值本身照样逐位比对。DECIMAL 落成的数字文本按数渲染、不经 float;文字压成一行,竖线换成 ¦
|
||||
*/
|
||||
function renderCell(raw, token, conv, kind, display, plain = false) {
|
||||
if (display !== 1 && display !== 2 && display !== 3) throw new Unshown(T.shownDisplay(display))
|
||||
if (display !== 1 && display !== 2 && display !== 3 && display !== 4) 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
|
||||
@@ -1556,6 +1572,12 @@
|
||||
/** 引用上记的换算(|万、|pct 这类),没写是 null */
|
||||
const convOf = (cite) => (own(cite, 'conv') === undefined ? null : cite.conv)
|
||||
|
||||
/** 分析结论给这一列 / 这个数记的换算(formats / value_formats:占比、变化率的列 pct,比率的变化 pp),没记是 null */
|
||||
const namedConv = (formats, key) => {
|
||||
const found = isObj(formats) ? formats[key] : null
|
||||
return typeof found === 'string' && found ? found : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析结论的一处引用(checkNumber 已核过工件的哈希和引用链):引用的值等于结论里记的那个值,正文显示的字等于按服务端
|
||||
* 的规则渲染出来的字。判定语按文本格渲染(engine/evidence.render_cell 的 kind=text:压成一行、竖线换成 ¦),具名的数按
|
||||
@@ -1586,7 +1608,10 @@
|
||||
}
|
||||
if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numAnalysisCell }
|
||||
const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1
|
||||
return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, convOf(cite),
|
||||
// 没写换算、结论的结果表给这一列记了格式(formats:4 版显示规则起)、文档也按 4 版复核的,按它渲染(服务端同样);
|
||||
// 写作者自己写了换算的照他的
|
||||
const conv = convOf(cite) ?? (display >= 4 ? namedConv(table.formats, cell.column) : null)
|
||||
return checkShown(info, seg, () => renderCell(cell.raw, info.truthToken, conv,
|
||||
columnKind(table, cell.column), display, own(cite, 'plain') === true))
|
||||
}
|
||||
if (loc.key != null) {
|
||||
@@ -1597,7 +1622,10 @@
|
||||
info.truthToken = tokenAt(tokensOf(art), ['values', loc.key])
|
||||
if (typeof raw === 'string' && typeof info.truth === 'number') info.truthText = pyStrip(raw)
|
||||
if (!sameValue(cite.value, info.truth)) return { ...info, result: FAIL, reason: T.numValueDiffers }
|
||||
return checkShown(info, seg, () => renderCell(raw, info.truthToken, convOf(cite), null, 1))
|
||||
// 没写换算、结论给这个数记了格式(value_formats;4 版显示规则起)的按它渲染(服务端同样);写作者自己写了换算的照他的
|
||||
const display = Object.prototype.hasOwnProperty.call(doc, 'cell_display') ? doc.cell_display : 1
|
||||
const conv = convOf(cite) ?? (display >= 4 ? namedConv(result.value_formats, loc.key) : null)
|
||||
return checkShown(info, seg, () => renderCell(raw, info.truthToken, conv, null, 1))
|
||||
}
|
||||
if (typeof result.phrase !== 'string' || !result.phrase.trim()) return { ...info, result: FAIL, reason: T.numNoPhrase }
|
||||
info.truth = result.phrase
|
||||
|
||||
@@ -91,11 +91,15 @@ ENTITY_SYNTAXES = frozenset({1, 2})
|
||||
#: 「没有第 N 行」重新解析得了,会被当成文档被改过(render_mismatch)。另外标识列(编码、编号、分组键,见
|
||||
#: cell_format.plain_columns)的数不分千位:编码 1203 不写成「1,203」。真有格子因此改了写法的文档记 cell_display: 3,
|
||||
#: 引用上记 plain: true(离线核对页解析不了 SQL,照它渲染)
|
||||
#: - 4:引用分析结论的结果表一格、具名的数时没写换算的,按结论给这一列 / 这个数记的格式渲染(占比、变化率按百分数,
|
||||
#: 比率的变化按百分点:0.62 显示成 62.0%,不再照原样写 0.618556701),实际用的换算记在引用上(conv,导出页和可验证报告
|
||||
#: 照它渲染)。3 版及以前组装的文档里的引用照当年原样显示,复核按记下的版本,不因此判对不上
|
||||
#: 新组装的文档里真有格子因为显示规则改了写法时才记 cell_display(参与内容哈希),记的是渲染得出这些格子的最低一版
|
||||
#: (只有日期时间被改写的记 2:标识列没有因此改写法的,3 版的整表截行不改格子的写法),没有的文档和以前一字不差。复核必须按
|
||||
#: 文档生成时的那一版:老文档存的是带 T 的字,拿新规则重新渲染对不上,会被当成文档被改过(render_mismatch)
|
||||
CELL_DISPLAY = 3
|
||||
CELL_DISPLAYS = frozenset({1, 2, 3})
|
||||
#: (只有日期时间被改写的记 2:标识列没有因此改写法的,3 版的整表截行不改格子的写法,引用分析结论时本来就有换算的记 1);没有的
|
||||
#: 文档和以前一字不差。复核必须按文档生成时的那一版:老文档存的是带 T 的字,拿新规则重新渲染对不上,会被当成文档被改过
|
||||
#: (render_mismatch)
|
||||
CELL_DISPLAY = 4
|
||||
CELL_DISPLAYS = frozenset({1, 2, 3, 4})
|
||||
_ISO_DATETIME = re.compile(r"^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$")
|
||||
|
||||
|
||||
@@ -148,7 +152,7 @@ class Citation(TypedDict, total=False):
|
||||
status: Literal["resolved", "unresolved", "mismatch"]
|
||||
reason: str # unresolved 时为什么
|
||||
unknown: bool # 实体:本次运行哪里都没有这个名字(可疑实体,不是写法错了)
|
||||
conv: str # 换算:万 / 亿 / pct / int / .N
|
||||
conv: str # 换算:万 / 亿 / pct / pp / int / .N(没写换算、按分析结论记的格式渲染的也照记,导出、可验证报告照它渲染)
|
||||
value: Any
|
||||
rendered: str
|
||||
source: dict[str, Any] # 引文:原文所在的检索快照、文档、片段
|
||||
@@ -573,6 +577,11 @@ MISSING = "—"
|
||||
|
||||
_CONV_SCALE = {"万": Decimal(10) ** 4, "亿": Decimal(10) ** 8}
|
||||
_CONV_PLACES = re.compile(r"^\.([0-6])$")
|
||||
#: 比率的变化按百分点写(0.012 →「+1.2 个百分点」,-0.003 →「-0.3 个百分点」):分析结论的结果表、具名的数里的换算,
|
||||
#: 算子记成 pp(analysis.contract.CONV_POINTS)。正数带「+」(和 pct 一样),不是 0 的值写出来成了 0.0 的多给小数位
|
||||
#: (最多 4 位),不写假的 0
|
||||
_CONV_POINTS = "pp"
|
||||
_POINTS_SUFFIX = " 个百分点"
|
||||
#: 小数位最多写到多少。负数是取整到十位、百位……(round(x, -2) 的意思)
|
||||
MAX_PLACES = 100
|
||||
#: 整数部分最多多少位。浮点数到头也就 309 位,再大的整数按规则一位位写出来没有意义
|
||||
@@ -648,7 +657,7 @@ def render_number(
|
||||
- decimals:固定小数位;不写就按值本来的样子(整数不带小数点)
|
||||
- 单位直接接在数字后面:45,678.5元、8.7%
|
||||
- conv 是报告里写的换算:万 / 亿(保留两位、去尾零,单位接在后面:4.57万元)、
|
||||
pct(比率 ×100 加 %)、int(取整)、.N(N 位小数)
|
||||
pct(比率 ×100 加 %)、pp(比率的变化 ×100 写成「+1.2 个百分点」)、int(取整)、.N(N 位小数)
|
||||
|
||||
值是 None 显示「—」。文本值原样(压成一行),不能换算。
|
||||
|
||||
@@ -696,13 +705,30 @@ def _render_numeric(d: Decimal, *, unit: str, decimals: int | None, fmt: str, co
|
||||
raise RenderError("该指标本身是百分数(单位为 %),不能再换算为 pct")
|
||||
places = max(decimals - 2, 0) if decimals is not None else 1
|
||||
return _digits(_ctx(d).multiply(d, 100), places, group=group, zero=rounded) + "%"
|
||||
if conv == _CONV_POINTS:
|
||||
# 比率的变化按百分点写:一位小数、正数带「+」(0 不带);不是 0 的值写出来成了 0.0 的多给小数位(最多 4 位),
|
||||
# 不写假的 0。说法照 phrases.points / ratio_change 里已有的百分点写法,只是表格里的数没有「上升 / 下降」可依,
|
||||
# 正负靠符号
|
||||
if "%" in unit or "%" in unit:
|
||||
raise RenderError("该指标本身是百分数(单位为 %),不能再换算为 pp")
|
||||
points = _ctx(d).multiply(d, 100)
|
||||
if points.is_zero():
|
||||
return _digits(points, 1, group=group) + _POINTS_SUFFIX
|
||||
zero = f"{shown} 按 |pp 显示为 0,精度不够:请去掉换算,或改用 |.N 多保留几位小数"
|
||||
for places in range(1, 5):
|
||||
exp = -(places)
|
||||
if points.quantize(Decimal(1).scaleb(exp), context=_ctx(points, places)).is_zero():
|
||||
continue
|
||||
out = _digits(points, places, group=group, zero=zero)
|
||||
return ("+" + out if not out.startswith("-") else out) + _POINTS_SUFFIX
|
||||
raise RenderError(zero)
|
||||
|
||||
if conv == "int":
|
||||
places: int | None = 0
|
||||
elif conv and (m := _CONV_PLACES.match(conv)):
|
||||
places = int(m.group(1))
|
||||
elif conv:
|
||||
raise RenderError(f"无法识别的换算 |{conv},只支持 万 / 亿 / pct / int / .N(N 为 0–6)")
|
||||
raise RenderError(f"无法识别的换算 |{conv},只支持 万 / 亿 / pct / pp / int / .N(N 为 0–6)")
|
||||
else:
|
||||
rounded = (f"{shown} 按口径卡的小数位({decimals})显示为 0,精度不够:请在口径卡中增加小数位"
|
||||
if decimals is not None else
|
||||
@@ -1468,8 +1494,8 @@ class _Snapshots:
|
||||
|
||||
def table(self, alias: str, entry: dict[str, Any]) -> tuple[Any, str | None]:
|
||||
"""按单元格、整表取的那张表:(表, 取不到的原因)。查询结果是查询快照本身;分析结论是它的结果表(形状同查询快照:
|
||||
columns 是稳定的列 id,另带 labels 给人看的列名,整表的表头写它)。结果表另记了列类型(column_types)的照着渲染,
|
||||
没记的按值认(同老快照)。"""
|
||||
columns 是稳定的列 id,另带 labels 给人看的列名,整表的表头写它)。结果表另记了列类型(column_types)、各列的换算
|
||||
(formats,占比、变化率这类以比率存的列)的照着渲染,没记的按值认(同老快照)。"""
|
||||
if entry.get("kind") != LEDGER_KIND:
|
||||
return self.get(alias, entry.get("artifact"))
|
||||
result, why = self.analysis(alias, entry)
|
||||
@@ -1480,9 +1506,11 @@ class _Snapshots:
|
||||
return None, f"{alias} 没有结果表" + ("(这次分析没有做成)" if result.get("status") == "refused" else "")
|
||||
labels = table.get("labels")
|
||||
types = table.get("column_types")
|
||||
formats = table.get("formats")
|
||||
return {"columns": [str(c) for c in table.get("columns") or []], "rows": table["rows"],
|
||||
"labels": {str(k): str(v) for k, v in labels.items()} if isinstance(labels, dict) else {},
|
||||
**({"column_types": types} if isinstance(types, dict) else {})}, None
|
||||
**({"column_types": types} if isinstance(types, dict) else {}),
|
||||
**({"formats": {str(k): str(v) for k, v in formats.items()}} if isinstance(formats, dict) else {})}, None
|
||||
|
||||
def hits(self, alias: str, artifact: Any) -> tuple[list[Any], str | None]:
|
||||
"""检索快照里的命中:(命中列表, 取不到的原因)。"""
|
||||
@@ -1569,6 +1597,19 @@ def _noun(entry: dict[str, Any] | None) -> str:
|
||||
return "结果表" if (entry or {}).get("kind") == LEDGER_KIND else "查询结果"
|
||||
|
||||
|
||||
def _column_conv(snapshot: Any, column: str, display: int = CELL_DISPLAY) -> str | None:
|
||||
"""分析结论的结果表给这一列记的换算(ResultTable.formats:占比、变化率的列记 pct,比率的变化记 pp)。查询快照没有这一项,
|
||||
一切照原样(None)。按分析结论记的格式显示是 4 版起的显示规则:老文档(3 版及以前)里的引用照当年原样显示,复核不因此
|
||||
判对不上。"""
|
||||
if display < 4:
|
||||
return None
|
||||
formats = snapshot.get("formats") if isinstance(snapshot, dict) else None
|
||||
if not isinstance(formats, dict):
|
||||
return None
|
||||
found = formats.get(column)
|
||||
return found if isinstance(found, str) and found else None
|
||||
|
||||
|
||||
def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots,
|
||||
cells_allowed: bool) -> dict[str, Any]:
|
||||
ref = marker.get("ref") or ""
|
||||
@@ -1596,7 +1637,14 @@ def _resolve_cell(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snaps
|
||||
raw, column = locate_cell(snapshot, parsed["row"], parsed["column"])
|
||||
kind = column_kind(snapshot, column)
|
||||
shown = snaps.shown(raw)
|
||||
# 分析结论的结果表记了这一列的换算(占比、变化率这类以比率存的列;4 版显示规则起)、写作者没写换算的,按它渲染;
|
||||
# 写作者自己写了换算的照他的。实际用的换算记进引用(导出页、可验证报告照它渲染)
|
||||
conv = conv or _column_conv(snapshot, column, snaps.display)
|
||||
rendered = render_cell(shown, conv, kind=kind)
|
||||
if conv and not marker.get("conv") and rendered != render_cell(shown, None, kind=kind):
|
||||
# 按分析结论记的格式渲染、真改了写法(4 版起):文档的 cell_display 记成 4,引用上记 conv,
|
||||
# 导出页和可验证报告照引用上的换算渲染
|
||||
snaps.needed = max(snaps.needed, 4)
|
||||
plain = column in snaps.plain(entry.get("artifact"), snapshot) \
|
||||
and render_cell(shown, conv, kind=kind, plain=True) != rendered
|
||||
if plain:
|
||||
@@ -1840,6 +1888,7 @@ def _resolve_analysis(marker: dict[str, Any], catalog: dict[str, Any], snaps: _S
|
||||
cells_allowed: bool) -> dict[str, Any]:
|
||||
"""[[a:A3]] → 判定语:分析结论里系统按模板渲染好的那一整句(判定是平台的算子算的,写作者只引用,00-总方案 D5)。
|
||||
[[a:A3.top_share|pct]] → 具名标量:结论 values 里的一项(数一律十进制字符串),按单元格的规则渲染,换算同 [[m:]]。
|
||||
没写换算、结论给这个数记了格式(value_formats)的按它渲染(0.62 显示成 62.0%,不是 0.62),实际用的换算记进引用。
|
||||
|
||||
判定语是一个片段(种类 analysis),里面的数和字都是系统写的:组装、复核都按带引用的片段整段涂白,不算裸数字。受管级别的
|
||||
正式出具、契约没开「单元格引用」时,Agent 现场做的分析解析不了(ANALYSIS_CELLS_REASON),分析卡做的照常解析。"""
|
||||
@@ -1879,6 +1928,13 @@ def _resolve_analysis(marker: dict[str, Any], catalog: dict[str, Any], snaps: _S
|
||||
return _unresolved(marker, alias, LEDGER_KIND, _Reason(
|
||||
f"{alias} 中没有名为「{key}」的数", f"{alias} 里没有「{key}」,能引用的数:{names}(写成 [[{MARKER_KIND}:{alias}.键]])"))
|
||||
raw = values[key]
|
||||
# 结论里给这个数记了换算(value_formats:占比、变化率这类以比率存的数;4 版显示规则起)而写作者没写换算的,按它渲染;
|
||||
# 写作者自己写了换算的照他的。实际用的换算记进引用(导出页、可验证报告照它渲染)
|
||||
named = result.get("value_formats") if isinstance(result.get("value_formats"), dict) else {}
|
||||
if snaps.display >= 4 and not conv and isinstance(named.get(key), str) and named.get(key):
|
||||
conv = named[key]
|
||||
# 按分析结论记的格式渲染了(4 版起):文档记成 4,引用上记 conv(写了换算才真有换算,写法一定和以前不同)
|
||||
snaps.needed = max(snaps.needed, 4)
|
||||
try:
|
||||
rendered = render_cell(raw, conv)
|
||||
except RenderError as e:
|
||||
@@ -4032,7 +4088,8 @@ def _query_prompt(entry: dict[str, Any], snaps: _Snapshots, cells_allowed: bool,
|
||||
ANALYSIS_PROMPT = (
|
||||
"分析结论(系统按平台的方法算好的判定。判定语、具名的数、结果表都只能用引用标记写,系统照结论渲染、逐字核对):\n"
|
||||
"写法:判定语单独写 [[a:A1]],系统换成整句判定;具名的数写 [[a:A1.键]],换算同指标,比重、变化率这类比率写成 "
|
||||
"[[a:A1.top_share|pct]] 显示成百分数;结果表的一格写 [[v:A1.r0.列]](行号从 0 数),要列出多行多列,单独一行写 "
|
||||
"[[a:A1.top_share|pct]] 显示成百分数;比重、变化率不写换算也按百分数显示,比率的变化按百分点([[v:A1.r0.mix]] 显示成「1.2 "
|
||||
"个百分点」);结果表的一格写 [[v:A1.r0.列]](行号从 0 数),要列出多行多列,单独一行写 "
|
||||
"[[table:A1 cols=列a,列b rows=0-4]];拿它当结论的依据,句末写 [[see:A1]]。"
|
||||
"「主要来自」「超出常规波动」「呈上升趋势」这类判断已经写在判定语里:引用判定语,不要自己改写判定;"
|
||||
"算不了的照实写算不了的原因。下面没有的编号、键、列不要编造。"
|
||||
@@ -4105,10 +4162,13 @@ def _analysis_prompt(entry: dict[str, Any], catalog: dict[str, Any], snaps: _Sna
|
||||
if see_only:
|
||||
return lines
|
||||
values = result.get("values") if isinstance(result.get("values"), dict) else {}
|
||||
named = result.get("value_formats") if isinstance(result.get("value_formats"), dict) else {}
|
||||
shown = []
|
||||
for key, raw in list(values.items())[:_PROMPT_VALUES]:
|
||||
try:
|
||||
shown.append(f"{key}={render_cell(raw)}")
|
||||
# 具名的数按结论记的格式写(占比、变化率是百分数):写作者在这里看到的就是引用之后显示的字
|
||||
conv = named.get(key) if isinstance(named.get(key), str) and named.get(key) else None
|
||||
shown.append(f"{key}={render_cell(raw, conv)}")
|
||||
except RenderError:
|
||||
shown.append(f"{key}={MISSING}")
|
||||
if shown:
|
||||
@@ -4131,12 +4191,15 @@ def _analysis_prompt(entry: dict[str, Any], catalog: dict[str, Any], snaps: _Sna
|
||||
top = top if isinstance(top, int) and not isinstance(top, bool) and top > 0 else _TOP_K
|
||||
lines.append(f" 结果表(共 {len(rows)} 段):列 {named}")
|
||||
plain = snaps.plain(entry.get("artifact"), snapshot)
|
||||
formats = snapshot.get("formats") if isinstance(snapshot.get("formats"), dict) else {}
|
||||
for r in range(min(top, len(rows))):
|
||||
cells = []
|
||||
for c in columns:
|
||||
try:
|
||||
raw, _ = locate_cell(snapshot, r, c)
|
||||
cells.append(f"{c}={render_cell(snaps.shown(raw), kind=column_kind(snapshot, c), plain=c in plain)}")
|
||||
conv = formats.get(c) if isinstance(formats.get(c), str) and formats.get(c) else None
|
||||
cells.append(f"{c}={render_cell(snaps.shown(raw), conv, kind=column_kind(snapshot, c),
|
||||
plain=c in plain)}")
|
||||
except (CellError, RenderError):
|
||||
cells.append(f"{c}={MISSING}")
|
||||
lines.append(f" r{r}:" + ",".join(cells))
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""分析结论里的占比、变化率、权重按原始小数显示(第 2 波复核 S1,显示规则 4 版)。
|
||||
|
||||
复核实测:结果表里占变化的比重、变化率以比率存(0.618556701031),正文引用 [[a:A1.top_share]]、[[v:A1.r0.share]]、
|
||||
[[table:A1 cols=region,pct,share]] 时照原样写「占比 3.5190412373」;判定语写「占 351.9%」,正文写「占比 3.5190412373」,
|
||||
读的人把 0.62 当成 62% 的百分之一。现在算子给结果表的、以比率存的列记换算(ResultTable.formats:占比、变化率、权重记 pct,
|
||||
比率的变化记 pp),结论的 values 里同样记一份(value_formats);引用、整表、写作目录、证据面板、导出、可验证报告都按它渲染,
|
||||
实际用的换算记在引用上(导出页、可验证报告照它渲染)。写作者自己写了换算的照他的。3 版及以前组装的文档照当年原样显示,
|
||||
复核不因此判对不上。
|
||||
|
||||
数据全是虚构的(华东、华南、华北、西南,销售额、订单数,虚构零售库 shop)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.analysis.contract import CONV_PCT, CONV_POINTS, ledger_entry
|
||||
from app.core import artifact_store
|
||||
from app.engine import evidence as ev
|
||||
from app.engine.evidence import build_catalog, compose_doc, iter_segments, render_cell, resolve_ref, verify_doc, \
|
||||
writer_catalog
|
||||
from app.main import app as asgi
|
||||
from tests.analysis_samples import analyze, snap
|
||||
from tests.test_analysis_bundle import Files, GRAPH, RUN, RUN_ID, bundle_of, item, verdicts
|
||||
from tests.test_analysis_evidence import SNAPSHOT, Store, real_result
|
||||
from tests.test_analysis_evidence_api import seg_id, sealed_run
|
||||
|
||||
WEEKS = [["华东", "2026-W41", 820], ["华东", "2026-W42", 700], ["华北", "2026-W41", 600], ["华北", "2026-W42", 560],
|
||||
["华南", "2026-W41", 700], ["华南", "2026-W42", 666], ["西南", "2026-W41", 100], ["西南", "2026-W42", 100]]
|
||||
REGIONS = snap("Q2", "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week",
|
||||
["region", "week", "gmv"], WEEKS, types={"region": "text", "week": "text", "gmv": "number"})
|
||||
|
||||
|
||||
def run_sync(coro: Any) -> Any:
|
||||
from tests.test_analysis_evidence import run_sync as wait
|
||||
|
||||
return wait(coro)
|
||||
|
||||
|
||||
def analyzed(op: str, mode: str | None, **params: Any) -> dict[str, Any]:
|
||||
"""真算一份结论(同 tests/analysis_samples.analyze:跑一次、复算一遍,逐字段相同)。"""
|
||||
return run_sync(analyze(op, mode, snaps=[REGIONS], query="Q2", **params))
|
||||
|
||||
|
||||
def catalog_of(*, result: dict[str, Any] | None = None) -> tuple[Store, dict[str, Any]]:
|
||||
"""一次查询(Q1)加一份真算的按地区拆的分析结论(A1):(工件库, 写作目录)。结论带算子记的格式。"""
|
||||
store = Store()
|
||||
q = store.put(SNAPSHOT)
|
||||
res = result or real_result("Q2")
|
||||
a = store.put(res)
|
||||
ledger = [{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": q, "tool": "db_query__shop",
|
||||
"source": "shop", "columns": ["region", "week", "gmv"], "rows": 8, "truncated": False},
|
||||
ledger_entry(res, node_id="an", exec_no=1, artifact=a, item="drop")]
|
||||
return store, build_catalog(nodes={}, ledger=ledger, loader=store.load)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 换算的写法:pct、pp
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, conv, shown", [
|
||||
("0.618556701", "pct", "61.9%"),
|
||||
("3.51904123734", "pct", "351.9%"),
|
||||
("-0.0142977693", "pct", "-1.4%"),
|
||||
("0.0123", "pp", "+1.2 个百分点"),
|
||||
("-0.121944594212", "pp", "-12.2 个百分点"),
|
||||
("0", "pp", "0.0 个百分点"),
|
||||
("0.00004", "pp", "+0.004 个百分点"), # 不是 0 的不写成 0.0
|
||||
])
|
||||
def test_pct_and_percentage_points_are_written_the_way_the_verdict_phrase_writes_them(value, conv, shown):
|
||||
"""pct 和 phrases.pct 一个写法(一位小数、不带正号);pp 是百分点(一位小数,不是 0 的 0.0 多给小数位),
|
||||
和判定语里「上升 0.4 个百分点」同一个说法。"""
|
||||
assert render_cell(value, conv) == shown
|
||||
|
||||
|
||||
def test_a_conversion_that_shows_a_nonzero_value_as_zero_still_fails():
|
||||
with pytest.raises(ev.RenderError, match="显示为 0"):
|
||||
render_cell("0.0000001", "pp")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 各算子记的格式
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_additive_decomposition_formats_shares_and_rates():
|
||||
result = analyzed("decompose", "additive", by=["region"], period="week", base="2026-W41", target="2026-W42",
|
||||
measure="gmv", label="销售额")
|
||||
assert result["table"]["formats"] == {"pct": CONV_PCT, "share": CONV_PCT}
|
||||
assert result["value_formats"] == {"pct_total": CONV_PCT, "top_share": CONV_PCT, "top_k_share": CONV_PCT}
|
||||
# 维度列、量(基期、本期、变化)没有换算
|
||||
assert set(result["table"]["formats"]) & {"region", "base", "target", "delta"} == set()
|
||||
|
||||
|
||||
def test_the_ratio_decomposition_formats_weights_as_percent_and_effects_as_points():
|
||||
rows = [["华东", "2026-W41", 8, 100], ["华东", "2026-W42", 10, 120], ["华南", "2026-W41", 5, 130],
|
||||
["华南", "2026-W42", 4, 140]]
|
||||
rates = snap("Q3", "SELECT region, week, refunds, orders FROM refunds GROUP BY region, week",
|
||||
["region", "week", "refunds", "orders"], rows,
|
||||
types={"region": "text", "week": "text", "refunds": "number", "orders": "number"})
|
||||
result = run_sync(analyze("decompose", "ratio", snaps=[rates], query="Q3", by=["region"], period="week",
|
||||
base="2026-W41", target="2026-W42", numerator="refunds", denominator="orders",
|
||||
label="退款率"))
|
||||
# 权重、各段的比率是比率本身 → pct;结构效应、自身效应、合计影响是比率的变化 → pp
|
||||
assert result["table"]["formats"] == {"w_base": CONV_PCT, "w_target": CONV_PCT, "r_base": CONV_PCT,
|
||||
"r_target": CONV_PCT, "mix": CONV_POINTS, "rate": CONV_POINTS,
|
||||
"total": CONV_POINTS}
|
||||
assert result["value_formats"]["r_base_total"] == CONV_PCT and result["value_formats"]["r_target_total"] == CONV_PCT
|
||||
assert {result["value_formats"][k] for k in ("delta", "mix_total", "rate_total")} == {CONV_POINTS}
|
||||
assert result["value_formats"]["mix_share"] == CONV_PCT
|
||||
|
||||
|
||||
def test_the_factor_decomposition_formats_change_rates_and_shares():
|
||||
product = snap("Q4", "SELECT week, gmv, users, price FROM orders", ["week", "gmv", "users", "price"],
|
||||
[["2026-W41", 1000, 100, 10], ["2026-W42", 1188, 110, 10.8]],
|
||||
types={"week": "text", "gmv": "number", "users": "number", "price": "number"})
|
||||
result = run_sync(analyze("decompose", "factor", snaps=[product], query="Q4", period="week", base="2026-W41",
|
||||
target="2026-W42", product="gmv", factors=["users", "price"], label="销售额"))
|
||||
assert result["table"]["formats"] == {"pct": CONV_PCT, "share": CONV_PCT}
|
||||
assert result["value_formats"] == {"pct_total": CONV_PCT, "top_share": CONV_PCT}
|
||||
|
||||
|
||||
def test_the_baseline_formats_change_rates_only_when_it_compares_changes():
|
||||
weekly = snap("Q1", "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week", ["week", "gmv"],
|
||||
[[f"2026-W{k:02d}", 2200 + (k % 5) * 10] for k in range(30, 42)] + [["2026-W42", 2138]],
|
||||
types={"week": "text", "gmv": "number"})
|
||||
changes = run_sync(analyze("baseline", None, snaps=[weekly], query="Q1", period="week", measure="gmv",
|
||||
label="销售额", grain="week"))
|
||||
assert changes["table"]["formats"] == {"change": CONV_PCT}
|
||||
assert {"x", "median", "band_lo", "band_hi"} <= set(changes["value_formats"])
|
||||
levels = run_sync(analyze("baseline", None, snaps=[weekly], query="Q1", period="week", measure="gmv",
|
||||
label="销售额", grain="week", basis="level"))
|
||||
# 按值本身比时是量,照原样显示(没有换算)
|
||||
assert "formats" not in levels["table"] and "value_formats" not in levels
|
||||
|
||||
|
||||
def test_the_trend_formats_the_relative_slope():
|
||||
weekly = snap("Q1", "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week", ["week", "gmv"],
|
||||
[[f"2026-W{k:02d}", 100 + k] for k in range(30, 42)], types={"week": "text", "gmv": "number"})
|
||||
result = run_sync(analyze("trend", None, snaps=[weekly], query="Q1", period="week", measure="gmv",
|
||||
label="销售额", grain="week"))
|
||||
assert result["value_formats"] == {"rel_slope": CONV_PCT}
|
||||
|
||||
|
||||
def test_the_differ_formats_rates_as_percent_and_differences_as_points():
|
||||
groups = snap("Q3", "SELECT region, refunds, orders FROM refunds GROUP BY region", ["region", "refunds", "orders"],
|
||||
[["华东", 100, 2000], ["华南", 120, 1800], ["华北", 90, 2100], ["西南", 40, 1900]],
|
||||
types={"region": "text", "refunds": "number", "orders": "number"})
|
||||
result = run_sync(analyze("differ", "rate", snaps=[groups], query="Q3", by=["region"], numerator="refunds",
|
||||
denominator="orders", label="退款率"))
|
||||
assert result["table"]["formats"] == {"a_value": CONV_PCT, "b_value": CONV_PCT, "diff": CONV_POINTS,
|
||||
"ci_lo": CONV_POINTS, "ci_hi": CONV_POINTS}
|
||||
assert {result["value_formats"][k] for k in ("a", "b")} == {CONV_PCT}
|
||||
assert {result["value_formats"][k] for k in ("diff", "ci_lo", "ci_hi")} == {CONV_POINTS}
|
||||
|
||||
|
||||
def test_a_run_twice_is_byte_for_byte_the_same_and_recompute_matches():
|
||||
"""格式是结论的一部分:同样的输入两次 run 逐字节相同,复算和封存的逐字段相同(analysis_samples.analyze 顺带核对复算)。"""
|
||||
first = analyzed("decompose", "additive", by=["region"], period="week", base="2026-W41", target="2026-W42",
|
||||
measure="gmv", label="销售额")
|
||||
second = analyzed("decompose", "additive", by=["region"], period="week", base="2026-W41", target="2026-W42",
|
||||
measure="gmv", label="销售额")
|
||||
assert artifact_store.canonical_json(first) == artifact_store.canonical_json(second)
|
||||
assert first["table"]["formats"] == second["table"]["formats"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 引用:没写换算时取列 / 数的格式,写了照写作者的
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_citing_a_share_or_rate_without_a_conversion_uses_the_conclusion_format():
|
||||
store, catalog = catalog_of()
|
||||
doc = compose_doc("华东占 [[a:A1.top_share]],变化率 [[v:A1.r0.pct]],占比 [[v:A1.r0.share]],"
|
||||
"变化量 [[v:A1.r0.delta]]。[[see:A1]]", catalog, loader=store.load)
|
||||
shown = {s["ref"]: s["text"] for s in iter_segments(doc) if s.get("ref")}
|
||||
assert shown["a:A1.top_share"] == "61.9%"
|
||||
assert shown["v:A1.r0.pct"] == "-14.6%" and shown["v:A1.r0.share"] == "61.9%"
|
||||
assert shown["v:A1.r0.delta"] == "-120" # 量照原样
|
||||
cites = {s["ref"]: s["cite"] for s in iter_segments(doc) if s.get("ref")}
|
||||
# 实际用的换算记在引用上(导出页、可验证报告照它渲染)
|
||||
assert cites["a:A1.top_share"]["conv"] == "pct" and cites["v:A1.r0.share"]["conv"] == "pct"
|
||||
assert "conv" not in cites["v:A1.r0.delta"]
|
||||
assert doc["cell_display"] == 4 and doc["violations"] == []
|
||||
assert verify_doc(doc, catalog, loader=store.load)["ok"]
|
||||
|
||||
|
||||
def test_a_writer_supplied_conversion_wins_over_the_conclusion_format():
|
||||
store, catalog = catalog_of()
|
||||
cite = resolve_ref("a:A1.top_share|.2", catalog, loader=store.load)
|
||||
assert cite["rendered"] == "0.62" and cite["conv"] == ".2"
|
||||
cell = resolve_ref("v:A1.r0.share|pct", catalog, loader=store.load)
|
||||
assert cell["rendered"] == "61.9%"
|
||||
|
||||
|
||||
def test_a_whole_table_is_expanded_with_the_conclusion_formats():
|
||||
store, catalog = catalog_of()
|
||||
doc = compose_doc("[[table:A1 cols=region,delta,pct,share rows=0-1]]", catalog, loader=store.load)
|
||||
assert "| region | 变化 | 变化率 | 占变化的比重 |" in doc["markdown"]
|
||||
assert "| 华东 | -120 | -14.6% | 61.9% |" in doc["markdown"]
|
||||
assert "| 华北 | -40 | -6.7% | 20.6% |" in doc["markdown"]
|
||||
assert verify_doc(doc, catalog, loader=store.load)["ok"]
|
||||
|
||||
|
||||
def test_an_old_document_keeps_the_raw_decimals_and_still_verifies(monkeypatch):
|
||||
"""3 版及以前组装的文档:引用上是 0.618556701 这样的原样,复核按记下的版本渲染,不判对不上;按新规则重渲染会判对不上。"""
|
||||
store, catalog = catalog_of()
|
||||
monkeypatch.setattr(ev, "CELL_DISPLAY", 3)
|
||||
old = compose_doc("占比 [[a:A1.top_share]],[[v:A1.r0.share]]。[[see:A1]]", catalog, loader=store.load)
|
||||
monkeypatch.undo()
|
||||
assert "cell_display" not in old
|
||||
shown = {s["ref"]: s["text"] for s in iter_segments(old) if s.get("ref")}
|
||||
assert shown["a:A1.top_share"] == "0.618556701" and shown["v:A1.r0.share"] == "0.618556701"
|
||||
assert verify_doc(old, catalog, loader=store.load)["ok"]
|
||||
# 拿 4 版复核这份老文档:引用上的字还是原样,照样对得上(引用上没记换算,按引用上的字渲染)
|
||||
assert {s["ref"]: s["cite"].get("conv") for s in iter_segments(old) if s.get("ref")} == \
|
||||
{"a:A1.top_share": None, "v:A1.r0.share": None}
|
||||
# 反过来:4 版组装的文档按 3 版复核,引用上的字是 61.9%,老规则渲染出来是原样,判对不上
|
||||
now = compose_doc("占比 [[a:A1.top_share]]。[[see:A1]]", catalog, loader=store.load)
|
||||
mismatched = verify_doc({**now, "cell_display": 3}, catalog, loader=store.load)
|
||||
assert mismatched["ok"] is False
|
||||
assert [v["code"] for v in mismatched["violations"]] == ["render_mismatch"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 写作目录
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_writer_catalog_shows_the_shares_as_percentages():
|
||||
store, catalog = catalog_of()
|
||||
text = writer_catalog(catalog, loader=store.load).text
|
||||
section = text[text.index("分析结论("):text.index("查询结果(")]
|
||||
assert "top_share=61.9%" in section and "top_share=0.618" not in section
|
||||
assert "r0:region=华东,base=820,target=700,delta=-120,pct=-14.6%,share=61.9%" in section
|
||||
assert "份额、变化率不写换算也按百分数显示" in section or "不写换算也按百分数显示" in section
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 证据面板接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def test_the_panel_shows_the_named_value_and_the_table_cells_as_written(client):
|
||||
made = await sealed_run("[[a:A1]]。占比 [[a:A1.top_share]],[[v:A1.r0.share]]。[[see:A1]]")
|
||||
body = (await client.get(
|
||||
f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1.top_share')}")).json()
|
||||
calc = body["chain"][2]
|
||||
top = next(v for v in calc["values"] if v["key"] == "top_share")
|
||||
assert top["raw"] == "0.618556701031" and top["rendered"] == "61.9%"
|
||||
rows = calc["table"]["rows"]
|
||||
share = calc["table"]["columns"].index("share")
|
||||
rate = calc["table"]["columns"].index("pct")
|
||||
flag = calc["table"]["columns"].index("flag")
|
||||
assert [r[share] for r in rows] == ["61.9%", "20.6%", "17.5%", "0.0%"]
|
||||
assert rows[0][rate] == "-14.6%" and rows[0][flag] == "—" # 空格写「—」,不写 null
|
||||
assert body["chain"][0]["render_ok"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 导出页、可验证报告
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
NODE_NS = {"__name__": "node-check"}
|
||||
try:
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import app.bundle
|
||||
import app.report_export
|
||||
NODE = shutil.which("node")
|
||||
except Exception: # pragma: no cover
|
||||
NODE = None
|
||||
|
||||
RUNTIME = Path(app.report_export.__file__).parent / "runtime.js" if NODE else None
|
||||
VERIFIER = Path(app.bundle.__file__).parent / "verifier.js" if NODE else None
|
||||
DOM = Path(__file__).parent.joinpath("test_analysis_pages.py").read_text(encoding="utf-8").split('DOM = r"""', 1)[1] \
|
||||
.split('"""', 1)[0]
|
||||
|
||||
|
||||
def run_node(script: str, path: Path, payload: dict[str, Any]) -> Any:
|
||||
done = subprocess.run([NODE, "-e", DOM + script, str(path)], input=json.dumps(payload), capture_output=True,
|
||||
text=True, timeout=120)
|
||||
assert done.returncode == 0, done.stderr[-3000:]
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
RUN_RUNTIME = r"""
|
||||
document.getElementById('report-data').textContent = input.data
|
||||
new Function(fs.readFileSync(process.argv[1], 'utf8'))()
|
||||
const out = {}
|
||||
for (const seg of input.segs) {
|
||||
const el = new El_('button')
|
||||
for (const [k, v] of Object.entries(seg)) el.setAttribute(`data-${k}`, v)
|
||||
el.textContent = seg.text
|
||||
for (const f of document.getElementById('report').listeners.click || []) f({ target: el })
|
||||
out[seg.seg] = document.getElementById('detail-body').textContent
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out))
|
||||
"""
|
||||
|
||||
RUN_VERIFIER = r"""
|
||||
document.getElementById('bundle-data').textContent = input.data
|
||||
document.getElementById('seal-hash').textContent = input.seal
|
||||
new Function(fs.readFileSync(process.argv[1], 'utf8'))()
|
||||
setTimeout(() => {
|
||||
const reports = document.getElementById('reports')
|
||||
const out = { opened: {} }
|
||||
for (const btn of find(reports, (n) => n.attrs['data-num'])) {
|
||||
for (const f of btn.listeners.click || []) f()
|
||||
out.opened[btn.attrs['data-num']] = document.getElementById('detail-body').textContent
|
||||
}
|
||||
out.verified = document.documentElement.attrs['data-verified'] ?? null
|
||||
process.stdout.write(JSON.stringify(out))
|
||||
}, 200)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的脚本")
|
||||
async def test_the_export_page_shows_percentages_in_the_calculation_step():
|
||||
made = await sealed_run("[[a:A1]]。占比 [[a:A1.top_share]],[[v:A1.r0.share]]。[[see:A1]]")
|
||||
async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as c:
|
||||
html = (await c.get(f"/api/runs/{made['run']}/report.html")).text
|
||||
import re
|
||||
|
||||
data = re.search(r'<script type="application/json" id="report-data">(.*?)</script>', html, re.S).group(1)
|
||||
phrase = seg_id(made["doc"], "a:A1")
|
||||
out = run_node(RUN_RUNTIME, RUNTIME, {"data": data, "segs": [
|
||||
{"seg": phrase, "kind": "analysis", "state": "deterministic", "text": made["result"]["phrase"]}]})
|
||||
body = out[phrase].replace(" ", "").replace(" ", "")
|
||||
# 具名的数、结果表的格按结论记的格式写:top_share 61.9%、变化率 -14.6%,没有原始小数、没有 null
|
||||
assert "top_share61.9%" in body and "top_k_share82.5%" in body
|
||||
assert "占变化的比重" in body and "-14.6%" in body
|
||||
assert "0.618556701" not in body and "null" not in body
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="没有 node,无法执行页面上的脚本")
|
||||
def test_the_verifiable_report_renders_the_conclusion_format():
|
||||
bundle, _, _ = bundle_of("## 异动\n\n[[a:A1]]。华东占 [[a:A1.top_share]],占比 [[v:A1.r0.share]]。[[see:A1]]")
|
||||
out = run_node(RUN_VERIFIER, VERIFIER, {"data": json.dumps(bundle), "seal": bundle["meta"]["manifest_hash"]})
|
||||
assert out["verified"] == ""
|
||||
share = next(text for text in out["opened"].values() if "具名的数「top_share」" in text)
|
||||
assert "正文显示61.9%(与按显示规则渲染的结果一致)" in share
|
||||
cell = next(text for text in out["opened"].values() if "结果表 · 第 1 行 · share" in text)
|
||||
assert "正文显示61.9%" in cell
|
||||
@@ -170,7 +170,7 @@ async def test_named_values_and_result_cells_point_at_what_they_cite(client):
|
||||
assert value["segment"]["text"] == "61.9%" and value["chain"][0]["render_ok"] is True
|
||||
calc = value["chain"][2]
|
||||
assert next(v for v in calc["values"] if v["key"] == "top_share") == {
|
||||
"key": "top_share", "raw": "0.618556701031", "rendered": "0.618556701", "cited": True}
|
||||
"key": "top_share", "raw": "0.618556701031", "rendered": "61.9%", "cited": True}
|
||||
assert value["chain"][-2]["text"] == "复算一致:按封存的输入重新计算,结论逐项相同"
|
||||
assert value["note"] == "这个数取自分析结论的具名的数,由系统按固定规则显示,未经模型转写"
|
||||
|
||||
@@ -372,10 +372,10 @@ async def test_a_ratio_headline_closes_against_the_overall_ratio(client):
|
||||
made = await ratio_run()
|
||||
body = (await client.get(f"/api/runs/{made['run']}/evidence/segments/{seg_id(made['doc'], 'a:A1')}")).json()
|
||||
inputs = {s["role"]: s for s in body["chain"] if s["step"] == "analysis_input"}
|
||||
assert inputs["headline_base"]["closure"] == {"mode": "ratio", "total_key": "r_base_total", "total": "0.035",
|
||||
"headline": "0.035", "closed": True}
|
||||
assert inputs["headline_target"]["closure"] == {"mode": "ratio", "total_key": "r_target_total", "total": "0.053",
|
||||
"headline": "0.053", "closed": True}
|
||||
assert inputs["headline_base"]["closure"] == {"mode": "ratio", "total_key": "r_base_total", "total": "3.5%",
|
||||
"headline": "3.5%", "closed": True}
|
||||
assert inputs["headline_target"]["closure"] == {"mode": "ratio", "total_key": "r_target_total", "total": "5.3%",
|
||||
"headline": "5.3%", "closed": True}
|
||||
assert inputs["headline_base"]["role_label"] == "基期整体比率"
|
||||
assert inputs["headline_target"]["role_label"] == "本期整体比率"
|
||||
assert next(s for s in body["chain"] if s["step"] == "analysis_recompute")["status"] == "same"
|
||||
|
||||
@@ -163,7 +163,8 @@ async def test_the_export_page_opens_the_analysis_steps_and_the_scope_numbers():
|
||||
|
||||
async def test_the_export_page_speaks_of_the_overall_ratio_for_a_ratio_decomposition():
|
||||
"""比率拆解的总数那一格是整体比率:导出页写「整体比率那一格」「整体比率 x 与各段分子之和 ÷ 分母之和 y 闭合」,不说
|
||||
「各段之和」。可加拆解照旧(上一个用例的页面里没有「整体比率」)。"""
|
||||
「各段之和」。比率按结论记的格式写成百分数(3.5%,不是 0.035),两格一致。可加拆解照旧(上一个用例的页面里没有
|
||||
「整体比率」)。"""
|
||||
made = await ratio_run()
|
||||
async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as client:
|
||||
html = (await client.get(f"/api/runs/{made['run']}/report.html")).text
|
||||
@@ -172,9 +173,9 @@ async def test_the_export_page_speaks_of_the_overall_ratio_for_a_ratio_decomposi
|
||||
out = run_node(RUN_RUNTIME, RUNTIME, {"data": data, "segs": [
|
||||
{"seg": phrase, "kind": "analysis", "state": "deterministic", "text": made["result"]["phrase"]}]})
|
||||
body = out[phrase]["body"]
|
||||
for text in ("基期整体比率", "整体比率那一格", "第 1 行「rate」 = 0.035",
|
||||
"整体比率 0.035 与各段分子之和 ÷ 分母之和 0.035 闭合",
|
||||
"整体比率 0.053 与各段分子之和 ÷ 分母之和 0.053 闭合"):
|
||||
for text in ("基期整体比率", "整体比率那一格", "自身效应", "结构效应",
|
||||
"整体比率 3.5% 与各段分子之和 ÷ 分母之和 3.5% 闭合",
|
||||
"整体比率 5.3% 与各段分子之和 ÷ 分母之和 5.3% 闭合"):
|
||||
assert text in body, text
|
||||
assert "各段之和 0.0" not in body
|
||||
|
||||
|
||||
@@ -44,8 +44,10 @@ async def _run(op, mode, params, *, probe=None, **kw):
|
||||
|
||||
|
||||
def _assert_shape(result):
|
||||
"""结论的字段、取值严格按契约。"""
|
||||
assert set(result) == set(c.AnalysisResult.__annotations__)
|
||||
"""结论的字段、取值严格按契约。两样可选的(第 2 波 S1 加):结论的 value_formats(具名的数的换算)、结果表的
|
||||
formats(以比率存的列的换算)——都为空时整项不写,和以前的结论一字不差。"""
|
||||
assert set(result) <= set(c.AnalysisResult.__annotations__)
|
||||
assert set(c.AnalysisResult.__annotations__) - set(result) <= {"value_formats"}
|
||||
assert result["op"] in c.OPS and result["method"] == c.METHODS[(result["op"], result["mode"])]
|
||||
assert result["status"] in ("ok", "refused")
|
||||
if result["status"] == "refused":
|
||||
@@ -57,11 +59,15 @@ def _assert_shape(result):
|
||||
assert all(isinstance(s, list) and all(isinstance(x, str) for x in s) for s in result["verdict"]["subjects"])
|
||||
assert result["verdict"]["direction"] in (None, "up", "down")
|
||||
table = result["table"]
|
||||
assert set(table) == {"columns", "labels", "rows", "column_types"}
|
||||
assert set(table) - {"formats"} == {"columns", "labels", "rows", "column_types"}
|
||||
assert set(table["labels"]) == set(table["columns"]) and set(table["column_types"]) <= set(table["columns"])
|
||||
assert list(table["column_types"]) == [c for c in table["columns"] if c in table["column_types"]]
|
||||
assert set(table["column_types"].values()) <= {"number", "text", "date", "datetime", "time", "boolean"}
|
||||
assert all(len(r) == len(table["columns"]) for r in table["rows"])
|
||||
assert set(table.get("formats") or {}) <= set(table["columns"])
|
||||
assert set((table.get("formats") or {}).values()) <= set(c.FORMAT_CONVS)
|
||||
assert set(result.get("value_formats") or {}) <= set(result["values"])
|
||||
assert set((result.get("value_formats") or {}).values()) <= set(c.FORMAT_CONVS)
|
||||
assert all(v is None or isinstance(v, str) for v in result["values"].values())
|
||||
assert all(set(cv) == {"code", "text"} and cv["code"] in c.CAVEATS for cv in result["caveats"])
|
||||
assert set(result["settings"]) <= set(c.DEFAULTS)
|
||||
|
||||
@@ -413,7 +413,7 @@ def test_an_unknown_cell_display_version_cannot_pass():
|
||||
|
||||
bundle = formats_bundle()
|
||||
art, doc = doc_of(bundle)
|
||||
doc["cell_display"] = 4
|
||||
doc["cell_display"] = 9
|
||||
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)
|
||||
|
||||
@@ -143,6 +143,7 @@ The built-in template 11, Anomaly Analysis (Traceable), is built this way: fetch
|
||||
```
|
||||
|
||||
- The Analysis Conclusions section of the writing catalog lists each conclusion's number, verdict phrase, named values, the top 10 segments of its result table, and caveats or the reason it couldn't be done.
|
||||
- The system shows shares and change rates in analysis conclusions as percentages (`[[a:A1.top_share]]` and `[[v:A1.r0.share]]` render as 62.0%), and changes in rates in percentage points (1.2 percentage points); writers cite them as usual and do not add a conversion. A conversion the writer does write is kept.
|
||||
- Conclusions from analysis cards can always be cited. In formal issuance at the governed level, analyses an agent ran on the spot with analysis tools follow the same rule as the cells it queried: they can be cited only when the issuance contract turns on Cell Citations (they can still serve as the basis at the end of a sentence).
|
||||
- The measure name in a verdict phrase comes from the agent or the template; the system first strips digits, percent signs, units, and citation markers from it and cuts it to 20 characters, so text written by the system never smuggles in numbers written by the model.
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ In the report-writer node (`report`), the model writes only citation markers, an
|
||||
[[see:m:gmv,Q1,A1]] basis of a claim sentence; attached at the end of the sentence, not rendered
|
||||
```
|
||||
|
||||
Analysis conclusions are numbered A1, A2, … in ledger order, and the writing catalog has a separate Analysis Conclusions section. Judgment words such as "mainly from", "unusual", or "rising steadily" must cite a matching analysis conclusion and agree with its verdict; see [Analysis claim check](analysis.md#analysis-claim-check).
|
||||
Analysis conclusions are numbered A1, A2, … in ledger order, and the writing catalog has a separate Analysis Conclusions section. The system shows shares and change rates in analysis conclusions as percentages (`[[a:A1.top_share]]` renders as 62.0%), and changes in rates in percentage points (1.2 percentage points); writers cite them as usual and do not add a conversion. Judgment words such as "mainly from", "unusual", or "rising steadily" must cite a matching analysis conclusion and agree with its verdict; see [Analysis claim check](analysis.md#analysis-claim-check).
|
||||
|
||||
### Handling violations
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
```
|
||||
|
||||
- 写作目录里「分析结论」一段列出每份结论的编号、判定语、具名的数、结果表的前 10 段、附带说明或算不了的原因。
|
||||
- 分析结论里的占比、变化率由系统按百分数显示(`[[a:A1.top_share]]`、`[[v:A1.r0.share]]` 写出来是 62.0%),比率的变化按百分点(1.2 个百分点),写作者照常引用、不用自己写换算;自己写了换算的照他的。
|
||||
- 分析卡的结论总能引用。受管级别的正式出具里,Agent 用分析工具现场做的分析和它查的格同一个规矩:出具契约开了「单元格引用」才能引用(句末依据照样能挂)。
|
||||
- 判定语里的度量叫法是 Agent 或模板给的,系统先去掉里面的数字、百分号、单位和引用标记,截到 20 字以内:系统写的字里不会夹带模型写的数。
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ flowchart LR
|
||||
[[see:m:gmv,Q1,A1]] 结论句的依据,挂在句末,不渲染
|
||||
```
|
||||
|
||||
分析结论按台账先后编成 A1、A2……,写作目录里另有一段「分析结论」。「主要来自」「异常」「持续上升」这类判断词要挂对应的分析结论、判定对得上,见[分析说法核对](analysis.md#分析说法核对)。
|
||||
分析结论按台账先后编成 A1、A2……,写作目录里另有一段「分析结论」。分析结论里的占比、变化率由系统按百分数显示(`[[a:A1.top_share]]` 写出来是 62.0%),比率的变化按百分点(1.2 个百分点),写作者照常引用、不用自己写换算。「主要来自」「异常」「持续上升」这类判断词要挂对应的分析结论、判定对得上,见[分析说法核对](analysis.md#分析说法核对)。
|
||||
|
||||
### 违规怎么处理
|
||||
|
||||
|
||||
@@ -192,7 +192,8 @@ function MethodPart({ step }: { step: EvidenceAnalysisStep }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 计算:关键数的代入式、具名的数(被引用的那个描出来)、结果表(被引用的那一格高亮,遮罩的列写「已遮罩」) */
|
||||
/** 计算:关键数的代入式、具名的数(被引用的那个描出来)、结果表(被引用的那一格高亮,遮罩的列写「已遮罩」)。
|
||||
* 数与格都是服务端按结论记的格式写好的字(占比、变化率是百分数,空格是「—」),这里照原样显示 */
|
||||
function CalcPart({ step }: { step: EvidenceAnalysisStep }) {
|
||||
const table = step.table && Array.isArray(step.table.columns) && Array.isArray(step.table.rows) ? step.table : null
|
||||
const labels = table?.labels ?? {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -614,6 +614,15 @@ await section('analysis', '分析结论:判定语、具名的数、结果表
|
||||
check('计算一步:关键数的代入式照服务端', (await calc.locator('[data-ev-analysis-formulas]').innerText()).includes('华东的占比 = 华东的变化 ÷ 总变化 = -300 ÷ (-340) = 88.2%'))
|
||||
check('……具名的数、结果表(表头是给人看的列名)', await calc.locator('[data-ev-analysis-value="top_share"]').count() === 1
|
||||
&& (await calc.locator('[data-ev-analysis-table] th').allInnerTexts()).join('、').includes('占变化的比重'))
|
||||
// 第 2 波 S1:占比、变化率按百分数显示(以前照原样写 0.8823529412),空格写「—」不写 null
|
||||
check('……具名的数按结论记的格式写:占比 88.2%,不是 0.8823529412',
|
||||
(await calc.locator('[data-ev-analysis-value="top_share"]').innerText()).includes('88.2%')
|
||||
&& !(await calc.locator('[data-ev-analysis-value="top_share"]').innerText()).includes('0.88'))
|
||||
const calcRows = (await calc.locator('[data-ev-analysis-table] tbody tr').allInnerTexts()).map((t) => t.replace(/\s+/g, ' '))
|
||||
check('……结果表照结论记的格式写:变化率 -30.0%、占变化的比重 88.2%,空格写「—」',
|
||||
calcRows[0].includes('-30.0%') && calcRows[0].includes('88.2%') && !calcRows[0].includes('null')
|
||||
&& !calcRows[0].includes('-0.3') && !(await calc.locator('[data-ev-analysis-table] tbody').innerText()).includes('null'),
|
||||
calcRows[0])
|
||||
const inputs = p.locator('[data-ev-analysis-input]')
|
||||
check('输入一步:每份一节(基期总数、本期总数、数据)', JSON.stringify(await inputs.evaluateAll((els) => els.map((e) => e.dataset.evAnalysisInput)))
|
||||
=== JSON.stringify(['headline_base', 'headline_target', 'data']))
|
||||
@@ -630,6 +639,14 @@ await section('analysis', '分析结论:判定语、具名的数、结果表
|
||||
check('分析结论的数不走口径账本:没有「口径」一步', await p.locator('[data-ev-ledger]').count() === 0)
|
||||
check('面板底下写服务端的说明(判定只说明变化来自哪里)', (await p.locator('[data-ev-analysis-note]').innerText()).includes('判定只说明变化来自哪里'))
|
||||
|
||||
// 没写换算的具名的数、结果表的格:按结论记的格式显示(第 2 波 S1),正文、出处链、整表一致
|
||||
for (const [sid, text] of [['s19', '-30.0%'], ['s21', '88.2%'], ['s23', '88.2%']]) {
|
||||
check(`没写换算的 ${sid === 's23' ? '具名的数 [[a:A1.top_share]]' : '结果表的格 [[v:A1.r0.' + (sid === 's19' ? 'pct' : 'share') + ']]'}按结论记的格式显示 ${text}`,
|
||||
(await box.locator(`[data-seg="${sid}"]`).innerText()) === text, await box.locator(`[data-seg="${sid}"]`).innerText())
|
||||
}
|
||||
check('……整表 [[table:A1 cols=region,delta,pct,share]] 的格也按格式写', (await box.locator('[data-seg="s38"]').innerText()) === '-30.0%'
|
||||
&& (await box.locator('[data-seg="s40"]').innerText()) === '88.2%')
|
||||
|
||||
// 具名的数、结果表的格:同一条链,「这一处引用的是」写清是哪一项,被引用的那个描出来
|
||||
await box.locator('[data-seg="s9"]').click()
|
||||
await p.locator('[data-ev-analysis-cited="value"]').waitFor({ timeout: 8000 })
|
||||
|
||||
@@ -2270,7 +2270,11 @@ await section('证据页签:分析说法违规的审计行写种类的叫法
|
||||
&& claimed.every((r) => r.label.includes(',') && !r.label.includes('无证据')), JSON.stringify(claimed.map((r) => r.label)))
|
||||
check('……别的无证据行照旧写「无证据」(没挂依据的两句结论)', rows.filter((r) => !r.issue && r.state === '?无证据').length === 2,
|
||||
JSON.stringify(rows.map((r) => `${r.issue}:${r.state}`)))
|
||||
check('……有出处那一组:判定语、具名的数、结果表的格三行', rows.filter((r) => r.state.includes('有出处')).length === 3, JSON.stringify(rows.map((r) => r.state)))
|
||||
// 第 2 波 S1 起报告多引了变化率、比重两格、一处没写换算的具名的数、整表展开的 8 格:有出处那一组的行数照夹具里
|
||||
// 审计的「有出处」一组数(不再写死 3)
|
||||
const cited = a.audit.groups.find((g) => g.key === 'cited')?.count ?? 0
|
||||
check('……有出处那一组:判定语、具名的数、结果表的各格,行数和审计的一致', cited > 3
|
||||
&& rows.filter((r) => r.state.includes('有出处')).length === cited, `${cited} / ${JSON.stringify(rows.map((r) => r.state))}`)
|
||||
await page.route(new RegExp(`/api/runs/${EV_A}/evidence/segments/`), (r) => {
|
||||
const sid = decodeURIComponent(new URL(r.request().url()).pathname.split('/').pop())
|
||||
return a.segments[sid] ? r.fulfill({ json: a.segments[sid] }) : r.fulfill({ status: 404, json: { detail: '没有这个片段' } })
|
||||
|
||||
Reference in New Issue
Block a user