Files
AgentLab/backend/app/data/recipe_fixes.py
T
ThearchyHelios 9e1ff11278 交叉表支持按文字导入的指标(如「高峰时段」一行写着「18-19」),堵上占位符陷阱:配方的 measures 分段加 text_measures(标签 → text / hour_range),落成文字列(时段存规范写法),空格、公式、占位符照块的取值规则,数字、日期、解析不了的时段报 value_not_text / value_not_hour_range;规则起草遇到无标题指标块里整行是文字的行(标签不带词表单位)按文字指标起草,并出卡片和问题要人确认(「不是」就去掉,回到按数字导入、试运行拒收),确认清单加 text_measure 项,前后对照报类型和存法变化;数值区不合格的格一行合成一条起草失败(不再一行 30 条)。陷阱:以前照「请添加占位符」把「18-19」加成占位符,整格静默存成空值、干跑照样通过——静态校验拒收含数字的占位符(placeholder_digits,重放和沿用现行配方的放行),value_not_number 的下一步按格子情况写:整行文字的提示改「指标类型」并给修复按钮 declare_text_measure,含数字的不再提示加占位符;标签带单位的拒按文字导入(text_measure_unit),文字列不能写单位(unit_on_text)。配方面板每个指标多一个「指标类型」下拉(数字 / 时段 / 文字)。默认值不进规范形式,已存配方的哈希不变。测试 tests/test_recipe_text_measures.py(合成夹具,假名);check-manage 加指标类型三项
2026-10-20 11:41:28 +08:00

1247 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""修复按钮(期 3,WP-2,P3-SPEC 第 3 节):从问题算出封闭的修复提议,再把选中的选项换算成配方补丁。
**客户端只发 fix_id、选项值和理由,不发补丁**(3.1)。补丁里的文字只有三种来源:
- 文件里的原文:标签、表头、分段标题、锚点文字,都来自执行器填的 Problem.fix_args(执行器从格子里取);
- 系统按固定规则推出的名字:起草器第 8 条的列名(recipe_suggest._measure_column)、{基表}_表内合计、{前一段 id}合计;
- 用户写的理由:只用作说明,不参与匹配。
没有正则,没有用户输入的匹配常量。所以 fix_ops 只认 proposal.options 里有的选项值:带文件原文的选项值
(「<候选格>|pick:<词>」、新工作表名)都是服务端算出来放进去的,客户端改不了内容。
**入参 recipe 一律是完整形式**(Recipe.model_validate(x).model_dump(mode="json"),与 answers_base 相同,评审二-M1):
暂存区存的是 canonical 形式,等于默认值的字段被去掉了,在它上面写补丁,replace /sheets/{si}/hidden/rows 这类路径会
不存在。WP-5 调用前先转成完整形式。补丁只用 recipe.apply_patch 支持的 add / replace / remove,路径按 id 在完整
形式里找下标。
提议不存库:staging_out 每次按暂存区里存的问题现算。同样的问题得到同样的提议 id("fx-" + sha1(kind|目标键)[:12]),
界面按问题上的 fix_ids 放按钮(FixAnchor,评审三-B2)。
"""
from __future__ import annotations
import dataclasses
import hashlib
import re
from typing import Any, Callable
from pydantic import ValidationError
from app.data.names import canon, collide_key
from app.data.recipe import apply_patch, recipe_sha256
from app.data.recipe_parsers import (
UNITS,
candidate_words,
looks_numeric_text,
match_key,
month_day_or_date,
split_unit_suffix,
)
from app.data.recipe_suggest import _measure_column
from app.data.recipe_types import (
DraftFacts,
EditResult,
Fact,
FixAnchor,
FixOption,
FixProposal,
Problem,
Recipe,
derive_tables,
)
#: 理由的长度(契约 IgnoreRow.reason、Dismissed.reason 都是 1–200 字)
REASON_MAX = 200
#: 交叉表的分段上限(契约 CrosstabBlock.segments max_length)
_SEGMENTS_MAX = 16
#: 分段标题、工作表名、锚点、表头的长度上限(契约 Locate.title、SheetMatch.name、IgnoreRow.label / IgnoreOutside.anchor、
#: IgnoreColumn.header)。超长的原文写不进配方,不出这个选项
_TITLE_MAX, _SHEET_MAX, _ANCHOR_MAX, _HEADER_MAX = 40, 31, 40, 80
#: 占位符:最长 8 字、每块最多 8 个(契约 Placeholder.text、CrossValues.placeholders)
_PLACEHOLDER_MAX, _PLACEHOLDERS_MAX = 8, 8
#: rename_title 的 pick 类选项最多给几个候选词
_PICK_OPTIONS_MAX = 3
class EditRequestError(ValueError):
"""修改请求本身不对(不是「泛化不了」)。code 是接口层要回的机读码(P3-SPEC 9.1):
- edit_invalid:选项不在提议里、理由超长、框选的选项写错(422);
- reason_required:需要理由的选项没给理由(422;界面要先填理由再预览,10.2)。
是 ValueError 的子类:接口层 ``except ValueError`` 时 code 缺省按 edit_invalid。消息可以直接给人看。"""
def __init__(self, code: str, message: str):
self.code = code
super().__init__(message)
class _Stale(Exception):
"""提议的目标在当前工作配方里已经找不到了(分段、关系、工作表被改掉):换算成 ok=False 的 EditResult。"""
# ==========================================================================
# 小工具(recipe_select 也用)
# ==========================================================================
def esc(token: Any) -> str:
"""JSON Pointer 的一段(RFC 6901:~ 写成 ~0、/ 写成 ~1)。"""
return str(token).replace("~", "~0").replace("/", "~1")
def ptr(*parts: Any) -> str:
return "".join("/" + esc(p) for p in parts)
def quoted(items: list[str] | tuple[str, ...]) -> str:
return "、".join(f"「{x}」" for x in items)
def has_digit(text: str) -> bool:
"""与静态校验的 period_literal 同一判断:含任何数字(含全角数字)。"""
return any(ch.isdigit() for ch in text)
def fix_id(kind: str, key: str) -> str:
return "fx-" + hashlib.sha1(f"{kind}|{key}".encode("utf-8")).hexdigest()[:12]
def iter_segments(recipe: dict[str, Any]):
"""完整形式的配方里每个交叉表分段:(si, bi, gi, 块, 分段)。"""
for si, sheet in enumerate(recipe.get("sheets") or []):
for bi, block in enumerate(sheet.get("blocks") or []):
if block.get("layout") != "crosstab":
continue
for gi, seg in enumerate(block.get("segments") or []):
yield si, bi, gi, block, seg
def find_segment(recipe: dict[str, Any], seg_id: Any) -> tuple[int, int, int, dict, dict] | None:
return next(((si, bi, gi, b, s) for si, bi, gi, b, s in iter_segments(recipe) if s.get("id") == seg_id), None)
def find_block(recipe: dict[str, Any], block_id: Any) -> tuple[int, int, dict] | None:
for si, sheet in enumerate(recipe.get("sheets") or []):
for bi, block in enumerate(sheet.get("blocks") or []):
if block.get("id") == block_id:
return si, bi, block
return None
def sheet_index(recipe: dict[str, Any], actual: Any, renamed: dict[str, str] | None = None) -> int | None:
"""实际工作表名 → 配方里的工作表下标:按 match_key 同名;按 fallback 认的(renamed:配方名 → 实际名)也算;
配方只有一个工作表时就是它(执行器的 only_visible_sheet 也只在那时生效)。"""
sheets = recipe.get("sheets") or []
if not isinstance(actual, str):
return None
key = match_key(actual)
for si, sheet in enumerate(sheets):
if match_key((sheet.get("match") or {}).get("name")) == key:
return si
for old, new in (renamed or {}).items():
if match_key(new) == key:
for si, sheet in enumerate(sheets):
if match_key((sheet.get("match") or {}).get("name")) == match_key(old):
return si
return 0 if len(sheets) == 1 else None
def table_index(recipe: dict[str, Any], name: Any) -> int | None:
return next((ti for ti, t in enumerate(recipe.get("tables") or []) if t.get("name") == name), None)
def unique_name(name: str, used: set[str], *, limit: int = 48) -> str:
"""名字撞了(collide_key)就加 _2、_3……(与起草器同一规则)。used 原地更新。"""
base = name[:limit]
out, n = base, 2
while collide_key(out) in used:
tail = f"_{n}"
out = base[: limit - len(tail)] + tail
n += 1
used.add(collide_key(out))
return out
def unique_id(want: str, used: set[str], *, limit: int = 32) -> str:
"""分段、块的 id:整份配方内唯一(逐字比较,同静态校验 segment_id_duplicate / block_id_duplicate)。"""
base = want[:limit]
out, n = base, 2
while out in used:
tail = f"_{n}"
out = base[: limit - len(tail)] + tail
n += 1
used.add(out)
return out
def all_ids(recipe: dict[str, Any]) -> set[str]:
"""配方里已用的分段 id 和块 id(两者同在 block_order 里,也不能彼此重名)。"""
out: set[str] = set()
for sheet in recipe.get("sheets") or []:
for block in sheet.get("blocks") or []:
out.add(str(block.get("id")))
for seg in block.get("segments") or []:
out.add(str(seg.get("id")))
return out
def finish(recipe: dict[str, Any] | None, ops: list[dict[str, Any]], *, kind: str, key: str, title: str,
summary: list[str], anchors: list | None = None, notes: list[str] | None = None,
expected: dict[str, str | None] | None = None, block: str | None = None,
breaking: bool = False) -> EditResult:
"""应用补丁、按 schema 解析、算哈希(与暂存区的哈希同一口径:apply_patch → 解析 → recipe_sha256)。
补丁应用不上或解析不过时 ok=False:正常流程不会出现(补丁按当前工作配方写),出现说明提议已经过期,
界面照原因提示重新打开问题。语义问题(静态校验的其余规则)不在这里判:预览时 WP-5 照常静态校验、干跑。"""
base = recipe if isinstance(recipe, dict) else {}
try:
new = apply_patch(base, ops)
sha = recipe_sha256(Recipe.model_validate(new))
except (ValueError, ValidationError):
msg = "这条修改已无法应用到当前的配方上:配方在提出修改之后又被改过。请重新查看问题和修复建议"
return EditResult(ok=False, kind=kind, key=key, title=title, summary=[], ops=[], anchors=[], # type: ignore[arg-type]
notes=list(notes or []), problems=[Problem("edit_not_applicable", "recipe", msg,
model_message="edit_not_applicable")])
return EditResult(ok=True, kind=kind, key=key, title=title, summary=summary, ops=ops, # type: ignore[arg-type]
anchors=list(anchors or []), notes=list(notes or []), problems=[], recipe_sha256_after=sha,
expected=expected, block=block, breaking=breaking)
def _get(obj: Any, key: str) -> Any:
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
def _facts(facts: dict[str, Any] | DraftFacts | None) -> list[Fact]:
if facts is None:
return []
if isinstance(facts, DraftFacts):
return list(facts.facts)
out: list[Fact] = []
for f in (facts.get("facts") or []) if isinstance(facts, dict) else []:
if isinstance(f, Fact):
out.append(f)
elif isinstance(f, dict) and f.get("id") and f.get("kind"):
out.append(Fact(f["id"], f["kind"], f.get("sheet") or "", f.get("text") or "", dict(f.get("detail") or {})))
return out
def _proposal(kind: str, key: str, *, code: str | None, title: str, cells: list[str], target: dict[str, Any],
options: list[FixOption], anchor: FixAnchor) -> FixProposal:
return FixProposal(id=fix_id(kind, key), kind=kind, problem_code=code, title=title,
cells=[c for c in cells if isinstance(c, str)][:40], target=target, options=options,
anchor=anchor)
def _key(kind: str, target: dict[str, Any]) -> str:
"""目标键(3.2):确认项 fix:<目标键>、提议 id 都由它来。只取目标里的文件原文和配方里的 id。"""
t = target
if kind == "remove_label":
return f"remove_label:{t['segment']}:{'|'.join(t['labels'])}"
if kind == "add_label":
return f"add_label:{t['segment']}:{'|'.join(x['raw'] for x in t['labels'])}"
if kind == "edit_members":
return f"edit_members:{t['relation']}"
if kind == "rename_title":
return f"rename_title:{t['segment']}"
if kind == "declare_total":
return f"declare_total:{t['segment']}"
if kind == "ignore_cells":
how = t["how"]
texts = t.get("labels") or t.get("headers") or [r["anchor"] for r in t.get("rows") or []]
where = t.get("block") if how != "outside" else t.get("sheet")
return f"ignore_cells:{how}:{where}:{'|'.join(texts)}"
if kind == "declare_placeholder":
return f"declare_placeholder:{t['block']}:{'|'.join(t['texts'])}"
if kind == "declare_hidden":
return f"declare_hidden:{t['sheet']}:{t['axis']}"
if kind == "rename_sheet":
return f"rename_sheet:{t['sheet_id']}"
if kind == "declare_subtotals":
return f"declare_subtotals:{t['block']}"
if kind == "declare_text_measure":
return f"declare_text_measure:{t['segment']}:{t['label']}"
raise KeyError(kind)
# ==========================================================================
# ① 移除标签
# ==========================================================================
def _p_remove_label(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
found = find_segment(recipe, args.get("segment"))
labels = [x for x in args.get("labels") or [] if isinstance(x, str)]
if found is None or not labels:
return None
_si, _bi, _gi, _block, seg = found
expect = list((seg.get("labels") or {}).get("expect") or [])
keys = {match_key(x) for x in labels}
removed = [e for e in expect if e in labels or match_key(e) in keys]
if not removed or len(removed) == len(expect):
# 剩下的 expect 为空:分段不能为空,请在配方面板里删掉整个分段
return None
measures = seg.get("role") == "measures"
cols = [seg.get("measures", {}).get(e) for e in removed if seg.get("measures", {}).get(e)]
if measures:
detail = (f"表「{seg.get('table')}」不再导入列{quoted(cols)},这是破坏性变更(按期累积时按退役处理,"
f"早期各期保留原值);今后各期如果又出现{quoted(removed)},会再次拒收")
else:
detail = f"今后各期如果又出现{quoted(removed)},会再次拒收"
target = {"segment": seg["id"], "labels": removed}
return _proposal("remove_label", _key("remove_label", target), code=_get(prob, "code"),
title=f"在分段「{seg['id']}」的期望标签中去掉{quoted(removed)}",
cells=list(_get(prob, "cells") or []), target=target,
options=[FixOption("remove", f"去掉标签{quoted(removed)}", detail=detail, breaking=measures)],
anchor=anchor)
def _o_remove_label(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_segment(recipe, p.target.get("segment"))
if found is None:
raise _Stale
si, bi, gi, _block, seg = found
sp = ("sheets", si, "blocks", bi, "segments", gi)
expect = list(seg["labels"]["expect"])
removed = [e for e in expect if e in p.target["labels"]]
remaining = [e for e in expect if e not in removed]
if not removed or not remaining:
raise _Stale
ops: list[dict[str, Any]] = [{"op": "replace", "path": ptr(*sp, "labels", "expect"), "value": remaining}]
summary = [f"分段「{seg['id']}」的期望标签去掉{quoted(removed)}"]
if seg.get("role") == "measures":
ti = table_index(recipe, seg.get("table"))
cols = []
for e in removed:
col = seg["measures"].get(e)
if e in (seg.get("text_measures") or {}):
ops.append({"op": "remove", "path": ptr(*sp, "text_measures", e)})
if col is None:
continue
cols.append(col)
ops.append({"op": "remove", "path": ptr(*sp, "measures", e)})
if ti is not None and col in (recipe["tables"][ti].get("units") or {}):
ops.append({"op": "remove", "path": ptr("tables", ti, "units", col)})
if cols:
summary.append(f"表「{seg.get('table')}」不再导入列{quoted(cols)}")
return ops, summary
# ==========================================================================
# ② 加入标签
# ==========================================================================
def _new_columns(recipe: dict, seg: dict, raws: list[str]) -> list[tuple[str, str, str | None, str | None]]:
"""measures 段新加的标签 → [(原文, 列名, 词表内单位, 括号原文)]。列名和单位照起草器第 8 条推
(recipe_suggest._measure_column,框选的分段也用它):括号里的单位在词表里时列名去掉单位、记单位;不在词表里
时列名保留原文(「分区丙(千人次)」→「分区丙_千人次」)、不记单位。只按 3.2 ② 的字面去掉括号的话,兄弟列
都是「人次」时新列看起来同名同口径,却没有单位,确认项也不提示(unit:* 只对有单位的列出)。
与这张表已有的列 collide_key 撞名时加 _2。位置号与起草器相同(第几个标签 + 1,轴列是第 1 列)。"""
try:
cols, _ = derive_tables(Recipe.model_validate(recipe))
except ValidationError:
cols = {}
used = {collide_key(c.name) for c in cols.get(seg.get("table"), []) if c.name}
start = len((seg.get("labels") or {}).get("expect") or [])
out = []
for k, raw in enumerate(raws):
name, unit, paren = _measure_column(raw, start + k + 2)
out.append((raw, unique_name(name, used), unit, paren))
return out
def _column_text(col: str, unit: str | None, paren: str | None) -> str:
"""新列的人话:「新增列「分区丙」,单位「人次」」;括号里的不是词表里的单位时写明没有登记单位。"""
if unit:
return f"新增列「{col}」,单位「{unit}」"
if paren:
return f"新增列「{col}」(括号里的「{paren}」不在单位词表里,列名保留原文,没有登记单位)"
return f"新增列「{col}」"
def _p_add_label(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
found = find_segment(recipe, args.get("segment"))
if found is None:
return None
_si, _bi, _gi, _block, seg = found
role = args.get("role")
if role is not None and role != seg.get("role"):
return None
expect = (seg.get("labels") or {}).get("expect") or []
have = {match_key(e) for e in expect}
labels: list[dict[str, Any]] = []
for item in args.get("labels") or []:
raw = _get(item, "raw")
if isinstance(raw, str) and raw.strip() and match_key(raw) not in have:
have.add(match_key(raw))
labels.append({"raw": raw.strip(), "cell": _get(item, "cell")})
if not labels:
return None
raws = [x["raw"] for x in labels]
if seg.get("role") == "measures":
cols = _new_columns(recipe, seg, raws)
detail = ";".join(_column_text(col, unit, paren) for _r, col, unit, paren in cols)
detail += "。今后各期必须有这些标签,没有会拒收;按期累积时早期各期这一列为空值"
else:
detail = "今后各期必须有这些标签,没有会拒收"
target = {"segment": seg["id"], "role": seg.get("role"), "labels": labels}
return _proposal("add_label", _key("add_label", target), code=_get(prob, "code"),
title=f"在分段「{seg['id']}」的期望标签中加入{quoted(raws)}",
cells=[x["cell"] for x in labels if x.get("cell")] or list(_get(prob, "cells") or []),
target=target, options=[FixOption("add", f"加入标签{quoted(raws)}", detail=detail)],
anchor=anchor)
def _o_add_label(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_segment(recipe, p.target.get("segment"))
if found is None:
raise _Stale
si, bi, gi, _block, seg = found
sp = ("sheets", si, "blocks", bi, "segments", gi)
expect = list(seg["labels"]["expect"])
have = {match_key(e) for e in expect}
raws = [x["raw"] for x in p.target["labels"] if match_key(x["raw"]) not in have]
if not raws:
raise _Stale
ops: list[dict[str, Any]] = [{"op": "replace", "path": ptr(*sp, "labels", "expect"), "value": expect + raws}]
summary = [f"分段「{seg['id']}」的期望标签加入{quoted(raws)}"]
if seg.get("role") == "measures":
ti = table_index(recipe, seg.get("table"))
for raw, col, unit, paren in _new_columns(recipe, seg, raws):
ops.append({"op": "add", "path": ptr(*sp, "measures", raw), "value": col})
if unit and ti is not None:
# 满足静态校验第 13 条 unit_label_conflict:原表标签写着单位,列的单位也要写
ops.append({"op": "add", "path": ptr("tables", ti, "units", col), "value": unit})
summary.append(f"表「{seg.get('table')}」" + _column_text(col, unit if ti is not None else None, paren))
return ops, summary
# ==========================================================================
# ③ 编辑恒等式成员
# ==========================================================================
_REL_PATH = re.compile(r"/relations/(\d+)(?:/.*)?")
def _measures_of(recipe: dict, seg_id: Any, labels: list[str]) -> dict | None:
"""Fact 指向的 measures 分段:按 id 找;找不到时按标签找唯一含这些标签的分段(同静态校验 measures_seg)。"""
segs = [s for *_r, s in iter_segments(recipe) if s.get("role") == "measures"]
hit = next((s for s in segs if s.get("id") == seg_id), None)
if hit is not None:
return hit
keys = {match_key(x) for x in labels}
found = [s for s in segs if keys <= {match_key(e) for e in (s.get("labels") or {}).get("expect") or []}]
return found[0] if len(found) == 1 else None
def _fact_columns(recipe: dict, fact: Fact) -> tuple[str, str, list[str]] | None:
"""sum_eq 事实 → (表, 合计列, 组成列):标签原文经该 measures 段的映射换成列名,映射不到为 None。"""
d = fact.detail or {}
total, parts = d.get("total"), d.get("parts")
if fact.kind != "sum_eq" or not isinstance(total, str) or not isinstance(parts, list):
return None
seg = _measures_of(recipe, d.get("segment"), [total, *[p for p in parts if isinstance(p, str)]])
if seg is None:
return None
by_key = {match_key(e): col for e, col in (seg.get("measures") or {}).items()}
t = by_key.get(match_key(total))
ps = [by_key.get(match_key(x)) for x in parts if isinstance(x, str)]
if t is None or None in ps or len(ps) < 2:
return None
return seg.get("table"), t, ps # type: ignore[return-value]
def _total_col(recipe: dict, table: Any, fact: Fact) -> str | None:
"""sum_eq 事实的合计标签在写入 table 的 measures 段里映射到的列(事实所在分段同 id 的优先)。组成标签映射
不全也照样算:只用来认「这条事实说的是不是这条关系的合计列」。"""
d = fact.detail or {}
total = d.get("total")
if fact.kind != "sum_eq" or not isinstance(total, str):
return None
segs = [s for *_r, s in iter_segments(recipe) if s.get("role") == "measures" and s.get("table") == table]
for s in sorted(segs, key=lambda x: x.get("id") != d.get("segment")):
col = {match_key(e): c for e, c in (s.get("measures") or {}).items()}.get(match_key(total))
if col is not None:
return col
return None
def _claimable(recipe: dict, rel: dict, facts: list[Fact]) -> Fact | None:
"""成员映射不全时,这条关系还能认领的本期 sum_eq 事实:
1. 它原来认领的那条(按编号),合计标签映射到关系的合计列;
2. 否则同一张表上合计标签映射到同一合计列的 sum_eq,恰好一条(编号按本期的发现顺序排,可能错开)。
已经被别的关系认领的不算:再认领一次会报 fact_claimed_twice。"""
others = {r.get("claims") for r in recipe.get("relations") or [] if r is not rel and r.get("claims")}
ok = [f for f in facts if f.id not in others and _total_col(recipe, rel.get("table"), f) == rel.get("total")]
own = next((f for f in ok if f.id == rel.get("claims")), None)
if own is not None:
return own
return ok[0] if len(ok) == 1 else None
def _unmapped(recipe: dict, rel: dict, fact: Fact) -> list[str]:
"""事实里在写入这张表的 measures 段里映射不到列的标签(被 ① 去掉的那些)。"""
have = {match_key(e) for *_r, s in iter_segments(recipe) if s.get("role") == "measures"
and s.get("table") == rel.get("table") for e in (s.get("measures") or {})}
d = fact.detail or {}
labels = [d.get("total"), *(d.get("parts") or [])]
return [x for x in labels if isinstance(x, str) and match_key(x) not in have]
def _fact_text(fact: Fact) -> str:
d = fact.detail or {}
parts = [x for x in d.get("parts") or [] if isinstance(x, str)]
return f"{d.get('total')} = {' + '.join(parts)}"
def _p_edit_members(recipe: dict, rp: Any, facts: list[Fact], anchor: FixAnchor) -> FixProposal | None:
code = _get(rp, "code")
m = _REL_PATH.fullmatch(str(_get(rp, "path") or ""))
if code not in ("fact_claim_mismatch", "column_unknown") or m is None:
return None
rels = recipe.get("relations") or []
ri = int(m.group(1))
if ri >= len(rels) or rels[ri].get("kind") != "sum_eq":
return None
rel = rels[ri]
if code == "column_unknown" and not str(_get(rp, "path")).startswith(ptr("relations", ri, "parts")) \
and _get(rp, "path") != ptr("relations", ri, "total"):
return None
by_id = {f.id: f for f in facts}
fact = by_id.get(rel.get("claims"))
cols = _fact_columns(recipe, fact) if fact is not None else None
if cols is None or cols[0] != rel.get("table"):
# 认领的那条不是能对上的 sum_eq(编号错开、或本期不成立):找同一张表上合计列相同的那条 sum_eq,恰好一条才用
hits = [(f, c) for f in facts if (c := _fact_columns(recipe, f)) is not None
and c[0] == rel.get("table") and c[1] == rel.get("total")]
fact, cols = hits[0] if len(hits) == 1 else (None, None)
options: list[FixOption] = []
manual = "也可以在配方面板中手工修改成员并去掉认领,之后每期照常核对"
if cols is not None and fact is not None:
_t, total, parts = cols
if (total, sorted(parts), fact.id) != (rel.get("total"), sorted(rel.get("parts") or []), rel.get("claims")):
options.append(FixOption("update", f"按系统发现更新为「{total} = {' + '.join(parts)}」",
detail="今后每期按新的成员核对"))
options.append(FixOption("dismiss", "不再登记这条关系", needs_reason=True,
detail="今后不再核对这条关系;系统发现记为已说明不登记"))
elif (fact := _claimable(recipe, rel, facts)) is not None:
# 本期的事实还在、只是成员映射不全(3.2 ① 的连带:去掉的组成列被这条关系引用):update 给不出,
# 「不登记」认领这条事实(3.2 ① 写明这种情形下 ③ 只给「不登记」)。退回「删除这条关系」的话,事实在
# 改动范围之外时连未认领都不报,核对连同它的系统发现一起不声不响地消失
gone = _unmapped(recipe, rel, fact)
why = f"本期的系统发现「{_fact_text(fact)}」含有配方不再导入的标签{quoted(gone)},无法按它更新成员" if gone \
else f"本期的系统发现「{_fact_text(fact)}」无法换算成这张表的列"
options.append(FixOption("dismiss", "不再登记这条关系", needs_reason=True,
detail=f"{why};不再登记后今后不再核对这条关系,系统发现记为已说明不登记。{manual}"))
else:
# 本期没有可以认领的恒等式(D13 加了分项而新恒等式某天不成立时 _sum_holds 不出 sum_eq 事实)。
# 不登记要认领一条系统发现,这里没有能认领的,只能删掉这条关系;删掉一条数据质量核对要写明理由
fact = None
options.append(FixOption("remove", "删除这条关系", needs_reason=True,
detail=f"本期未发现可以替换或认领的恒等式,删除后不再核对这条关系;{manual}"))
target: dict[str, Any] = {"relation": rel.get("id"), "fact": fact.id if fact else None,
"total": cols[1] if cols else None, "parts": list(cols[2]) if cols else []}
return _proposal("edit_members", _key("edit_members", target), code=code,
title=f"关系「{rel.get('id')}」的成员与本期的系统发现对不上", cells=[], target=target,
options=options, anchor=anchor)
def _o_edit_members(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
rels = recipe.get("relations") or []
rid = p.target.get("relation")
ri = next((i for i, r in enumerate(rels) if r.get("id") == rid), None)
if ri is None:
raise _Stale
rel = rels[ri]
if opt.value == "remove":
return [{"op": "remove", "path": ptr("relations", ri)}], [f"删除关系「{rid}」(理由:{reason})"]
if opt.value == "dismiss":
value = {"id": rid, "kind": "dismissed", "claims": p.target["fact"], "reason": reason}
return [{"op": "replace", "path": ptr("relations", ri), "value": value}], \
[f"关系「{rid}」不再登记(理由:{reason})"]
if rel.get("kind") != "sum_eq":
raise _Stale
total, parts, fid = p.target["total"], list(p.target["parts"]), p.target["fact"]
ops: list[dict[str, Any]] = [{"op": "replace", "path": ptr("relations", ri, "parts"), "value": parts}]
if rel.get("total") != total:
ops.append({"op": "replace", "path": ptr("relations", ri, "total"), "value": total})
if rel.get("claims") != fid:
ops.append({"op": "add", "path": ptr("relations", ri, "claims"), "value": fid})
return ops, [f"关系「{rid}」的成员更新为「{total} = {' + '.join(parts)}」"]
# ==========================================================================
# ④ 分段标题改名
# ==========================================================================
def _siblings(recipe: dict, seg: dict) -> list[str]:
"""写进同一张表的其他分段的分段标题(候选词按兄弟标题去掉公共部分,同静态校验 check_const)。"""
out = []
for *_r, s in iter_segments(recipe):
loc = s.get("locate") or {}
if (s is not seg and s.get("role") == "dimension" and s.get("table") == seg.get("table")
and loc.get("by") == "section_title" and loc.get("title")):
out.append(loc["title"])
return out
def keep_ok(seg: dict, title: str) -> bool:
"""改标题时常量能不能沿用:每个常量的 canon(pick) 都是 canon(新标题) 的子串(3.3 (b),静态校验据此放行)。"""
return all(canon(c.get("pick", "")) in canon(title) for c in (seg.get("const") or {}).values())
def title_options(recipe: dict, seg: dict, cell: str, text: str) -> list[FixOption]:
"""一个候选标题的选项:沿用常量(keep_ok 时)和改用候选词(只有一个常量列时,最多 _PICK_OPTIONS_MAX 个)。"""
consts = seg.get("const") or {}
opts: list[FixOption] = []
after = "今后各期只认新标题;改回旧标题时会再次拒收"
if keep_ok(seg, text):
kept = "," + "、".join(f"「{k}」沿用「{v.get('pick')}」" for k, v in consts.items()) if consts else ""
opts.append(FixOption(f"{cell}|keep", f"改为「{text}」{kept}", detail=after))
if len(consts) == 1:
col, c = next(iter(consts.items()))
words = [w for w in candidate_words(text, _siblings(recipe, seg)) if w != c.get("pick")][:_PICK_OPTIONS_MAX]
for w in words:
opts.append(FixOption(f"{cell}|pick:{w}", f"改为「{text}」,「{col}」改用「{w}」",
detail=f"这是破坏性变更:这一列的值从「{c.get('pick')}」变为「{w}」。{after}",
breaking=True))
return opts
def _p_rename_title(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
found = find_segment(recipe, args.get("segment"))
if found is None:
return None
_si, _bi, _gi, _block, seg = found
loc = seg.get("locate") or {}
if loc.get("by") != "section_title" or seg.get("role") == "derived":
return None
cands: list[dict[str, str]] = []
options: list[FixOption] = []
for item in args.get("candidates") or []:
cell, text = _get(item, "cell"), _get(item, "text")
if not isinstance(cell, str) or not isinstance(text, str) or not text.strip() or len(text) > _TITLE_MAX:
continue
opts = title_options(recipe, seg, cell, text.strip())
if opts:
cands.append({"cell": cell, "text": text.strip()})
options += opts
if not options:
return None
target = {"segment": seg["id"], "title": loc.get("title"), "candidates": cands}
return _proposal("rename_title", _key("rename_title", target), code=_get(prob, "code"),
title=f"分段「{seg['id']}」的分段标题「{loc.get('title')}」没有找到,可以改用文件里的新标题",
cells=[c["cell"] for c in cands], target=target, options=options, anchor=anchor)
def _o_rename_title(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_segment(recipe, p.target.get("segment"))
if found is None:
raise _Stale
si, bi, gi, _block, seg = found
sp = ("sheets", si, "blocks", bi, "segments", gi)
cell, _, how = opt.value.rpartition("|")
cand = next((c for c in p.target.get("candidates") or [] if c["cell"] == cell), None)
if cand is None:
raise _Stale
text = cand["text"]
ops: list[dict[str, Any]] = [{"op": "replace", "path": ptr(*sp, "locate", "title"), "value": text}]
summary = [f"分段「{seg['id']}」的分段标题改为「{text}」"]
if how.startswith("pick:"):
consts = seg.get("const") or {}
if len(consts) != 1:
raise _Stale
col = next(iter(consts))
word = how[len("pick:"):]
ops.append({"op": "replace", "path": ptr(*sp, "const", col, "pick"), "value": word})
summary.append(f"「{col}」的值改为「{word}」")
elif seg.get("const"):
summary.append("、".join(f"「{k}」沿用「{v.get('pick')}」" for k, v in seg["const"].items()))
return ops, summary
# ==========================================================================
# ⑤ 声明为合计行(框选的 derived 也用)
# ==========================================================================
def total_blockers(recipe: dict, si: int, bi: int, gi: int) -> str | None:
"""分段能不能接一个合计分段:不能时返回给人看的原因(3.2 ⑤ 的五个前提里与配方有关的三条)。"""
block = recipe["sheets"][si]["blocks"][bi]
seg = block["segments"][gi]
if seg.get("role") != "dimension":
return f"分段「{seg.get('id')}」不是按维度展开的分段,合计行无法按时段区间核对"
if sorted((seg.get("dim") or {}).get("derive", {}).values()) != ["end", "start"]:
return f"分段「{seg.get('id')}」没有派生起始小时和结束小时,合计行无法按时段区间核对"
if any(s.get("role") == "derived" and (s.get("locate") or {}).get("segment") == seg.get("id")
for s in block["segments"]):
return f"分段「{seg.get('id')}」后面已经有合计分段"
if len(block["segments"]) >= _SEGMENTS_MAX:
return f"交叉表「{block.get('id')}」的分段已经到上限({_SEGMENTS_MAX} 个)"
return None
def total_ops(recipe: dict, si: int, bi: int, gi: int, labels: list[str], keep: bool) -> tuple[list, list[str], str | None]:
"""在分段后面插入合计分段(derived):(补丁, 摘要, 另存的表名或 None)。
分段 id = 前一段 id +「合计」,撞了加 _2;另存时同一块里已有 derived 段另存到同一张基表的合计表的,沿用那张表的
table、dim、derive、value(D18 的日间合计和原来的夜间合计存进同一张「时段客流_表内合计」);否则新建
{基表}_表内合计,并在表清单里加上(grain = 轴列 + 合计项,单位取基表值列的单位)。"""
block = recipe["sheets"][si]["blocks"][bi]
seg = block["segments"][gi]
bp = ("sheets", si, "blocks", bi)
sp = bp + ("segments", gi)
new_id = unique_id(f"{seg['id']}合计", all_ids(recipe))
keep_as = None
table_ops: list[dict[str, Any]] = []
kept: str | None = None
if keep:
same = next((s["keep_as"] for s in block["segments"] if s.get("role") == "derived" and s.get("keep_as")
and (s.get("verify") or {}).get("against_table") == seg.get("table")), None)
if same is not None:
keep_as = {k: same[k] for k in ("table", "dim", "derive", "value")}
else:
used = {collide_key(t.get("name", "")) for t in recipe.get("tables") or []}
name = unique_name(f"{seg['table']}_表内合计", used)
keep_as = {"table": name, "dim": "合计项", "derive": dict((seg.get("dim") or {}).get("derive") or {}),
"value": seg.get("value")}
ti = table_index(recipe, seg.get("table"))
unit = ((recipe["tables"][ti].get("units") or {}).get(seg.get("value")) if ti is not None else None)
table_ops.append({"op": "add", "path": "/tables/-", "value": {
"name": name, "grain": [block.get("axis", {}).get("name", "日期"), "合计项"], "kind": "reported_total",
"units": {seg.get("value"): unit} if unit else {}, "note": ""}})
kept = keep_as["table"]
derived = {"id": new_id, "role": "derived", "locate": {"by": "after", "title": None, "segment": seg["id"]},
"labels_parser": "hour_range_total", "labels": {"expect": list(labels)},
"verify": {"kind": "label_range_sum", "against_table": seg.get("table"), "value": seg.get("value")},
"keep_as": keep_as}
ops = [{"op": "add", "path": ptr(*sp, "stop_parser"), "value": "hour_range_total"},
{"op": "add", "path": ptr(*bp, "segments", gi + 1), "value": derived}, *table_ops]
summary = [f"分段「{seg['id']}」遇到合计行就结束",
f"新增合计分段「{new_id}」:{quoted(labels)}改作合计核对"
+ (f",原值另存表「{kept}」" if kept else ",不另存")]
return ops, summary, kept
def _p_declare_total(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
found = find_segment(recipe, args.get("segment"))
if found is None or args.get("role", "dimension") != "dimension" or not args.get("at_end"):
return None
si, bi, gi, _block, seg = found
labels = list(args.get("labels") or [])
raws = [_get(x, "raw") for x in labels]
if not labels or not all(_get(x, "total") for x in labels) or not all(isinstance(r, str) and r for r in raws):
return None
if total_blockers(recipe, si, bi, gi) is not None:
return None
_ops, _s, kept = total_ops(recipe, si, bi, gi, raws, True)
target = {"segment": seg["id"], "labels": raws}
return _proposal("declare_total", _key("declare_total", target), code=_get(prob, "code"),
title=f"分段「{seg['id']}」末尾的{quoted(raws)}是合计行",
cells=[_get(x, "cell") for x in labels if _get(x, "cell")] or list(_get(prob, "cells") or []),
target=target,
options=[FixOption("keep", f"改作合计核对,原值另存表「{kept}」",
detail="今后各期必须有这一行,没有会拒收"),
FixOption("check_only", "改作合计核对,不另存",
detail="今后各期必须有这一行,没有会拒收;这一行只参与核对,记进回执的「排除的行」")],
anchor=anchor)
def _o_declare_total(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_segment(recipe, p.target.get("segment"))
if found is None:
raise _Stale
si, bi, gi, _block, _seg = found
if total_blockers(recipe, si, bi, gi) is not None:
raise _Stale
ops, summary, _kept = total_ops(recipe, si, bi, gi, list(p.target["labels"]), opt.value == "keep")
return ops, summary
# ==========================================================================
# 期 5:列表中间像小计的行改作小计核对
# ==========================================================================
def _subtotal_keep_name(recipe: dict, block: dict) -> str:
used = {collide_key(t.get("name") or "") for t in recipe.get("tables") or []}
return unique_name(f"{block.get('table')}_小计", used)
def _p_declare_subtotals(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
"""subtotal_suspect → 列表块加 rows.subtotals(标签列、开头词取自执行器给的 fix_args:文件里的合计字样)。
块已经配了小计、标签列不在块里、开头词含数字时不提议。"""
found = find_block(recipe, args.get("block"))
if found is None:
return None
_si, _bi, block = found
label, pick = args.get("label_column"), args.get("pick")
if block.get("layout") != "list" or (block.get("rows") or {}).get("subtotals"):
return None
if label not in [c.get("name") for c in block.get("columns") or []] or not isinstance(pick, str) \
or not pick or has_digit(pick):
return None
target = {"block": block["id"], "label_column": label, "pick": pick}
keep = _subtotal_keep_name(recipe, block)
return _proposal("declare_subtotals", _key("declare_subtotals", target), code=_get(prob, "code"),
title=f"列表「{block['id']}」中间「{pick}」开头的行是小计",
cells=list(args.get("cells") or _get(prob, "cells") or []), target=target,
options=[FixOption("check_only", "改作小计核对,不另存",
detail="每行小计按上一行小计之后的明细逐列求和核对,小计行不进数据,记进回执的"
"「排除的行」"),
FixOption("keep", f"改作小计核对,原值另存表「{keep}」",
detail="同上,原表的小计另存成一张表;另存的小计表不能按期累积")],
anchor=anchor)
def _o_declare_subtotals(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_block(recipe, p.target.get("block"))
if found is None:
raise _Stale
si, bi, block = found
if (block.get("rows") or {}).get("subtotals"):
raise _Stale
keep = _subtotal_keep_name(recipe, block) if opt.value == "keep" else None
value = {"label_column": p.target["label_column"], "pick": p.target["pick"], "scope": "since_previous",
"group_column": None, "keep_as": keep}
ops: list[dict[str, Any]] = [{"op": "add", "path": ptr("sheets", si, "blocks", bi, "rows", "subtotals"),
"value": value}]
if keep:
ops.append({"op": "add", "path": "/tables/-", "value": {
"name": keep, "grain": [], "kind": "reported_total", "units": {}, "note": ""}})
summary = [f"列表「{block['id']}」中间「{p.target['pick']}」开头的行改作小计核对,不进数据"
+ (f",原值另存表「{keep}」" if keep else ",不另存")]
return ops, summary
# ==========================================================================
# ⑥ 忽略(按文字记)
# ==========================================================================
def _split_digits(texts: list[str], limit: int) -> tuple[list[str], list[str]]:
"""(可以作锚点的, 含数字的)。超长的写不进配方,直接不要;按 match_key 去重。"""
clean: list[str] = []
digits: list[str] = []
seen: set[str] = set()
for t in texts:
t = t.strip()
k = match_key(t)
if not t or k in seen or len(t) > limit:
continue
seen.add(k)
(digits if has_digit(t) else clean).append(t)
return clean, digits
def _ignore_proposal(target: dict, *, code: Any, cells: list[str], anchor: FixAnchor, label: str,
clean: list[str], digits: list[str], extra: str = "") -> FixProposal | None:
if not clean:
# 锚点都含数字:不出提议(3.2 ⑥「锚点含数字时不出提议」)。出一个不带选项的提议的话,staging_out 按 anchor
# 给问题写上 fix_ids,界面照 10.2 放修复按钮,点开却没有可选的处理方式。部分含数字时只给其余的锚点,
# 含数字的那几个写进选项的 detail(见下);框选忽略同样拦(selection_anchor_has_digits)
return None
detail = "今后各期按这段文字忽略;某一期没有出现时什么也不忽略,也不报错"
if digits:
detail += f"。{quoted(digits)}含数字,下一期很可能对不上,不按文字忽略:请修改文件,或把它框选为新的分段"
if extra:
detail += f"。{extra}"
return _proposal("ignore_cells", _key("ignore_cells", target), code=code, title=label, cells=cells,
target=target, options=[FixOption("ignore", label, detail=detail, needs_reason=True)],
anchor=anchor)
def _p_ignore_cells(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
how = args.get("how")
code = _get(prob, "code")
cells = list(_get(prob, "cells") or [])
if how == "rows":
found = find_block(recipe, args.get("block"))
if found is None or found[2].get("layout") != "crosstab":
return None
have = {match_key(x.get("label")) for x in found[2].get("ignore_rows") or []}
raws = [r for x in args.get("labels") or [] if isinstance(r := _get(x, "raw"), str)
and match_key(r) not in have]
clean, digits = _split_digits(raws, _ANCHOR_MAX)
if not clean and not digits:
return None # 标签列为空:那是 row_without_label,没有锚点
target = {"block": found[2]["id"], "how": "rows", "labels": clean or digits}
return _ignore_proposal(target, code=code, cells=cells, anchor=anchor, clean=clean, digits=digits,
label=f"按行标签忽略{quoted(clean)}" + ("这几行" if len(clean) > 1 else "这一行"),
extra="被忽略的行记进回执的「排除的行」")
if how == "columns":
found = find_block(recipe, args.get("block"))
if found is None:
return None
heads = [_get(x, "raw") for x in args.get("headers") or []]
if not heads or any(not isinstance(h, str) or not h.strip() for h in heads):
return None # 有表头不是文字
if any(month_day_or_date(h) is not None for h in heads):
return None # 能解析成日期:和日期轴冲突
have = {match_key(x.get("header")) for x in found[2].get("ignore_columns") or []}
clean, digits = _split_digits([h for h in heads if match_key(h) not in have], _HEADER_MAX)
if not clean and not digits:
return None
target = {"block": found[2]["id"], "how": "columns", "headers": clean or digits}
return _ignore_proposal(target, code=code, cells=cells, anchor=anchor, clean=clean, digits=digits,
label=f"按表头忽略{quoted(clean)}" + ("这几列" if len(clean) > 1 else "这一列"))
if how == "outside":
si = sheet_index(recipe, args.get("sheet"))
if si is None:
return None
have = {match_key(x.get("anchor")) for x in recipe["sheets"][si].get("ignore_outside") or []}
rows = [r for r in args.get("rows") or [] if isinstance(r, dict)]
texts = [a for r in rows if isinstance(a := r.get("anchor"), str) and match_key(a) not in have]
clean, digits = _split_digits(texts, _ANCHOR_MAX)
bare = [r.get("row") for r in rows if not isinstance(r.get("anchor"), str) or not r.get("anchor")]
if not clean and not digits:
return None # 这几格没有可以作锚点的文字,只能修改文件
lines = {match_key(r["anchor"]): r.get("row") for r in rows if isinstance(r.get("anchor"), str)}
where = ";".join(f"第 {lines.get(match_key(a))} 行有文字「{a}」" for a in clean)
target = {"sheet": args.get("sheet"), "how": "outside",
"rows": [{"row": lines.get(match_key(a)), "anchor": a} for a in (clean or digits)]}
extra = ("另有" + "、".join(f"第 {r} 行" for r in bare if r) + "没有可以作锚点的文字,只能修改文件") if bare else ""
return _ignore_proposal(target, code=code, cells=cells, anchor=anchor, clean=clean, digits=digits,
label=f"{where}:忽略{'这些行' if len(clean) > 1 else '这一行'}导入区域之外的数字",
extra=extra)
return None
def _o_ignore_cells(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
t = p.target
if t["how"] == "outside":
si = sheet_index(recipe, t.get("sheet"))
if si is None:
raise _Stale
anchors = [r["anchor"] for r in t["rows"]]
ops = [{"op": "add", "path": ptr("sheets", si, "ignore_outside", "-"), "value": {"anchor": a, "reason": reason}}
for a in anchors]
return ops, [f"工作表「{t.get('sheet')}」:同一行有{quoted(anchors)}时,忽略这一行导入区域之外的数字(理由:{reason})"]
found = find_block(recipe, t.get("block"))
if found is None:
raise _Stale
si, bi, block = found
if t["how"] == "rows":
if block.get("layout") != "crosstab":
raise _Stale
ops = [{"op": "add", "path": ptr("sheets", si, "blocks", bi, "ignore_rows", "-"),
"value": {"label": x, "reason": reason}} for x in t["labels"]]
return ops, [f"交叉表「{block['id']}」按行标签忽略{quoted(t['labels'])}(理由:{reason})"]
ops = [{"op": "add", "path": ptr("sheets", si, "blocks", bi, "ignore_columns", "-"),
"value": {"header": h, "reason": reason}} for h in t["headers"]]
return ops, [f"块「{block['id']}」按表头忽略{quoted(t['headers'])}(理由:{reason})"]
# ==========================================================================
# ⑦ 声明占位符
# ==========================================================================
def _p_declare_placeholder(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
found = find_block(recipe, args.get("block"))
if found is None:
return None
block = found[2]
have = {canon(x.get("text", "")) for x in (block.get("values") or {}).get("placeholders") or []}
room = _PLACEHOLDERS_MAX - len(have)
texts: list[str] = []
for t in args.get("texts") or []:
if (isinstance(t, str) and t.strip() and len(t.strip()) <= _PLACEHOLDER_MAX
and not looks_numeric_text(t) and canon(t) not in have and t.strip() not in texts):
texts.append(t.strip())
texts = texts[:max(room, 0)]
if not texts:
return None
target = {"block": block["id"], "texts": texts}
after = "今后各期这种写法都存为空值"
return _proposal("declare_placeholder", _key("declare_placeholder", target), code=_get(prob, "code"),
title=f"块「{block['id']}」的数据格里有{quoted(texts)},不是数字",
cells=list(_get(prob, "cells") or []), target=target,
options=[FixOption("no_data", f"把{quoted(texts)}也声明为无数据(存为空值)", detail=after),
FixOption("not_applicable", f"把{quoted(texts)}声明为不适用(存为空值)", detail=after)],
anchor=anchor)
def _o_declare_placeholder(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_block(recipe, p.target.get("block"))
if found is None:
raise _Stale
si, bi, block = found
meaning = "无数据" if opt.value == "no_data" else "不适用"
ops = [{"op": "add", "path": ptr("sheets", si, "blocks", bi, "values", "placeholders", "-"),
"value": {"text": t, "meaning": meaning}} for t in p.target["texts"]]
return ops, [f"块「{block['id']}」把{quoted(p.target['texts'])}声明为{meaning}(存为空值)"]
# ==========================================================================
# ⑪ 按文字导入整行是文字的指标(value_not_number 的另一个出口:「高峰时段」一行写的是「18-19」)
# ==========================================================================
_TEXT_PARSER_OPTION = {
"hour_range": ("按时段导入(存规范写法,如 18-19)", "以后各期这一行每格都要能按时段解析,否则拒收"),
"text": ("按文字原样导入", "以后各期这一行按原文存,不再要求是数字"),
}
def _p_declare_text_measure(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> list[FixProposal]:
"""执行器在 fix_args.text_rows 里给出整行都是文字的指标(标签原文、标签格、能用的解析方式)。每个指标一条提议;
标签写着词表里的单位的不提议(静态校验 text_measure_unit 会拒),已经按文字导入的不再提议。"""
out: list[FixProposal] = []
for row in args.get("text_rows") or []:
found = find_segment(recipe, row.get("segment"))
label = row.get("label")
if found is None or not isinstance(label, str):
continue
seg = found[4]
if (seg.get("role") != "measures" or label not in (seg.get("labels") or {}).get("expect", [])
or label in (seg.get("text_measures") or {}) or not seg.get("measures", {}).get(label)):
continue
unit = split_unit_suffix(label)[1]
if unit is not None and unit in UNITS:
continue
parsers = [p for p in row.get("parsers") or [] if p in _TEXT_PARSER_OPTION]
if not parsers:
continue
target = {"segment": seg["id"], "label": label}
col = seg["measures"][label]
cell = row.get("cell")
out.append(_proposal(
"declare_text_measure", _key("declare_text_measure", target), code=_get(prob, "code"),
title=f"「{label}」这一行写的都是文字,不是数字",
cells=[cell] if isinstance(cell, str) else [], target=target,
options=[FixOption(p, _TEXT_PARSER_OPTION[p][0],
detail=f"表「{seg.get('table')}」的列「{col}」改成文字列;{_TEXT_PARSER_OPTION[p][1]}",
breaking=True) for p in parsers],
anchor=anchor))
return out
def _o_declare_text_measure(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
found = find_segment(recipe, p.target.get("segment"))
label = p.target.get("label")
if found is None or found[4].get("role") != "measures" or label not in found[4].get("measures", {}):
raise _Stale
si, bi, gi, _block, seg = found
sp = ("sheets", si, "blocks", bi, "segments", gi)
ops: list[dict[str, Any]] = []
if "text_measures" not in seg:
ops.append({"op": "add", "path": ptr(*sp, "text_measures"), "value": {}})
ops.append({"op": "add", "path": ptr(*sp, "text_measures", label), "value": {"parser": opt.value}})
col = seg["measures"][label]
ti = table_index(recipe, seg.get("table"))
if ti is not None and col in (recipe["tables"][ti].get("units") or {}):
ops.append({"op": "remove", "path": ptr("tables", ti, "units", col)})
return ops, [f"分段「{seg['id']}」的「{label}」{opt.label}:表「{seg.get('table')}」的列「{col}」是文字列"]
# ==========================================================================
# ⑧ 声明隐藏行列怎么处理
# ==========================================================================
def _p_declare_hidden(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
axis = args.get("axis")
si = sheet_index(recipe, args.get("sheet"))
if axis not in ("rows", "cols") or si is None:
return None
sheet = args.get("sheet")
filt = "可能是筛选隐藏的行。" if args.get("autofilter") else ""
if axis == "rows":
options = [FixOption("include", "隐藏行照常导入", detail=f"{filt}今后各期的隐藏行都照常导入"),
FixOption("exclude", "隐藏行不导入", detail=f"{filt}今后各期的隐藏行都跳过,记进回执的「排除的行」")]
what = "行"
else:
options = [FixOption("include", "隐藏列照常导入", detail="今后各期的隐藏列都照常导入")]
what = "列"
target = {"sheet": sheet, "axis": axis}
return _proposal("declare_hidden", _key("declare_hidden", target), code=_get(prob, "code"),
title=f"工作表「{sheet}」的数据区里有隐藏的{what}", cells=list(_get(prob, "cells") or []),
target=target, options=options, anchor=anchor)
def _o_declare_hidden(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
si = sheet_index(recipe, p.target.get("sheet"))
if si is None:
raise _Stale
return ([{"op": "replace", "path": ptr("sheets", si, "hidden", p.target["axis"]), "value": opt.value}],
[f"工作表「{p.target.get('sheet')}」:{opt.label}"])
# ==========================================================================
# ⑨ 更新工作表名(P3-SPEC 6.1)
# ==========================================================================
def _sheet_option(name: str) -> FixOption:
detail = "下一期不再需要确认改名"
if has_digit(name):
detail = "新名字里有月份或期次数字,下一期很可能又会改名;可以不更新,每期确认改名即可"
return FixOption(name, f"把配方里的工作表名更新为「{name}」", detail=detail)
def _p_rename_sheet(recipe: dict, prob: Any, args: dict, anchor: FixAnchor) -> FixProposal | None:
sheets = recipe.get("sheets") or []
si = next((i for i, s in enumerate(sheets) if s.get("id") == args.get("sheet_id")), None)
if si is None:
return None
cands = [c for c in args.get("candidates") or [] if isinstance(c, str) and c and len(c) <= _SHEET_MAX]
if not cands:
return None
old = (sheets[si].get("match") or {}).get("name")
target = {"sheet_id": sheets[si]["id"], "name": old, "candidates": cands}
return _proposal("rename_sheet", _key("rename_sheet", target), code=_get(prob, "code"),
title=f"没有找到工作表「{old}」,可以改用文件里的另一个工作表", cells=list(_get(prob, "cells") or []),
target=target, options=[_sheet_option(c) for c in cands], anchor=anchor)
def _p_sheet_renamed(recipe: dict, old: str, new: str) -> FixProposal | None:
"""按 fallback 认出了改名的工作表(Extraction.sheets.renamed):不是问题,但给提议(6.1 触发一)。"""
sheets = recipe.get("sheets") or []
si = next((i for i, s in enumerate(sheets) if match_key((s.get("match") or {}).get("name")) == match_key(old)), None)
if si is None or not isinstance(new, str) or not new or len(new) > _SHEET_MAX:
return None
target = {"sheet_id": sheets[si]["id"], "name": old, "candidates": [new]}
return _proposal("rename_sheet", _key("rename_sheet", target), code=None,
title=f"工作表「{old}」改名为「{new}」", cells=[], target=target, options=[_sheet_option(new)],
anchor=FixAnchor("sheet_renamed", None, new))
def _o_rename_sheet(recipe: dict, p: FixProposal, opt: FixOption, reason: str | None) -> tuple[list, list[str]]:
sheets = recipe.get("sheets") or []
si = next((i for i, s in enumerate(sheets) if s.get("id") == p.target.get("sheet_id")), None)
if si is None or opt.value not in p.target.get("candidates", []):
raise _Stale
return ([{"op": "replace", "path": ptr("sheets", si, "match", "name"), "value": opt.value}],
[f"配方里的工作表名「{p.target.get('name')}」更新为「{opt.value}」"])
# ==========================================================================
# 入口
# ==========================================================================
_Builder = Callable[[dict, Any, dict, FixAnchor], "FixProposal | None"]
_BUILDERS: dict[str, _Builder] = {
"remove_label": _p_remove_label, "add_label": _p_add_label, "rename_title": _p_rename_title,
"declare_total": _p_declare_total, "ignore_cells": _p_ignore_cells, "declare_placeholder": _p_declare_placeholder,
"declare_hidden": _p_declare_hidden, "rename_sheet": _p_rename_sheet, "declare_subtotals": _p_declare_subtotals,
}
_OPS: dict[str, Callable[[dict, FixProposal, FixOption, str | None], tuple[list, list[str]]]] = {
"remove_label": _o_remove_label, "add_label": _o_add_label, "edit_members": _o_edit_members,
"rename_title": _o_rename_title, "declare_total": _o_declare_total, "ignore_cells": _o_ignore_cells,
"declare_placeholder": _o_declare_placeholder, "declare_hidden": _o_declare_hidden, "rename_sheet": _o_rename_sheet,
"declare_subtotals": _o_declare_subtotals, "declare_text_measure": _o_declare_text_measure,
}
#: 同一条问题的另一种出口(问题的 fix 字段只写一种,界面按 fix_ids 放按钮,所以能挂几个提议):value_not_number 的
#: 格子是整行文字时,除了声明占位符,还可以把这个指标改按文字导入
_EXTRA_BUILDERS: dict[str, Callable[[dict, Any, dict, FixAnchor], list[FixProposal]]] = {
"value_not_number": _p_declare_text_measure,
}
def propose_fixes(recipe: dict[str, Any], problems: list[dict[str, Any]], recipe_problems: list[dict[str, Any]],
facts: dict[str, Any] | DraftFacts | None,
renamed: dict[str, str] | None = None) -> list[FixProposal]:
"""问题 → 修复提议(纯函数,不抛异常)。
- problems:执行器的问题(Problem 的 JSON,或 Problem 本身)。按 fix 字段分派、按 fix_args 构造;没有 fix_args
的不出提议(界面不能光靠 fix 字符串构造补丁);
- recipe_problems:静态校验的问题。fact_claim_mismatch(以及关系成员列不存在的 column_unknown)出 ③;
- facts:本期的系统发现(DraftFacts 或它的 JSON),③ 的成员只能取自这里;
- renamed:试运行的 Extraction.sheets.renamed(配方名 → 实际名),出 ⑨。
每个提议带 anchor:index 是该问题在传进来的 problems / recipe_problems 里的下标,staging_out 据此把 fix_ids
写到对应的问题上。同样的目标只出一个提议(按 id 去重,anchor 取第一条)。"""
if not isinstance(recipe, dict):
return []
out: list[FixProposal] = []
seen: set[str] = set()
def push(p: FixProposal | None) -> None:
if p is not None and p.id not in seen:
seen.add(p.id)
out.append(p)
for i, prob in enumerate(problems or []):
args = _get(prob, "fix_args")
builder = _BUILDERS.get(_get(prob, "fix") or "")
if builder is None or not isinstance(args, dict):
continue
try:
push(builder(recipe, prob, args, FixAnchor("problem", i)))
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
pass # fix_args 的形状不对(执行器的契约问题):不出提议,不拖垮 staging_out
extra = _EXTRA_BUILDERS.get(_get(prob, "code") or "")
if extra is None:
continue
try:
for p in extra(recipe, prob, args, FixAnchor("problem", i)):
push(p)
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
continue
fl = _facts(facts)
for i, rp in enumerate(recipe_problems or []):
try:
push(_p_edit_members(recipe, rp, fl, FixAnchor("recipe_problem", i)))
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
continue
for old, new in (renamed or {}).items():
push(_p_sheet_renamed(recipe, old, new))
return out
def _edit_title(p: FixProposal, opt: FixOption) -> str:
"""EditResult.title 是确认项 fix:<目标键> 的文案,要写明选了哪一项(选项的 label 加上主语)。"""
t = p.target
if p.kind == "edit_members":
return f"关系「{t.get('relation')}」:{opt.label}"
if p.kind == "rename_title":
return f"分段「{t.get('segment')}」的分段标题{opt.label}"
if p.kind == "declare_total":
return f"分段「{t.get('segment')}」末尾的{quoted(t.get('labels') or [])}{opt.label}"
if p.kind == "declare_placeholder":
return f"块「{t.get('block')}」{opt.label}"
if p.kind == "declare_hidden":
return f"工作表「{t.get('sheet')}」:{opt.label}"
if p.kind == "rename_sheet":
return opt.label
if p.kind == "declare_subtotals":
return f"列表「{t.get('block')}」中间「{t.get('pick')}」开头的行{opt.label}"
if p.kind == "declare_text_measure":
return f"分段「{t.get('segment')}」的「{t.get('label')}」{opt.label}"
return p.title
def fix_ops(recipe: dict[str, Any], proposal: FixProposal, option: str, reason: str | None) -> EditResult:
"""选中的选项 → EditResult(补丁、人话摘要、修改后的配方哈希)。
选项不在 proposal.options 里、需要理由而没给(或超长)时抛 EditRequestError(code 见该类)。提议的目标在
当前工作配方里已经找不到时返回 ok=False(problems 写原因)。理由去掉首尾空白后写进配方,进配方哈希:预览和
应用必须用同一个理由(评审三-B1)。"""
opt = next((o for o in proposal.options if o.value == option), None)
if opt is None:
raise EditRequestError("edit_invalid", "所选的处理方式不在这条修复建议的选项里,请重新打开修复建议")
text = reason.strip() if isinstance(reason, str) else ""
if opt.needs_reason:
if not text:
raise EditRequestError("reason_required", "这个选项需要写明理由")
if len(text) > REASON_MAX:
raise EditRequestError("edit_invalid", f"理由不能超过 {REASON_MAX} 字")
key = _key(proposal.kind, proposal.target)
title = _edit_title(proposal, opt)
try:
ops, summary = _OPS[proposal.kind](recipe, proposal, opt, text if opt.needs_reason else None)
except (_Stale, KeyError, TypeError, IndexError):
msg = "这条修复建议对应的内容已不在当前的配方里:配方在提出建议之后又被改过。请重新查看问题和修复建议"
return EditResult(ok=False, kind="fix", key=key, title=title, summary=[], ops=[], anchors=[], notes=[],
problems=[Problem("edit_not_applicable", "recipe", msg, model_message="edit_not_applicable")])
return finish(recipe, ops, kind="fix", key=key, title=title, summary=summary, breaking=opt.breaking)
def proposal_from_dict(data: dict[str, Any]) -> FixProposal:
"""FixProposal 的 JSON(asdict)→ dataclass。WP-5 把提议存进响应、按 fix_id 找回来时用。"""
opts = [FixOption(**{k: v for k, v in o.items() if k in {f.name for f in dataclasses.fields(FixOption)}})
for o in data.get("options") or []]
a = data.get("anchor") or {}
return FixProposal(id=data["id"], kind=data["kind"], problem_code=data.get("problem_code"), title=data["title"],
cells=list(data.get("cells") or []), target=dict(data.get("target") or {}), options=opts,
anchor=FixAnchor(a.get("kind", "problem"), a.get("index"), a.get("sheet")))