"""文档 MD 的 ②结构化重排 + ③事实级校验(供 8765 上传转换后调用)。 来自 doc-eval 压测验证过的逻辑,纯函数、无外部副作用: · restructure_md(md) 把"忠实转换"的宽表 MD 折叠成「每产品一节 + 干净明细表」的结构化 MD · validate(src, out) 事实级校验:数字保真(幻觉)、按产品价格不串位、内容不被删减、业务规则 """ from __future__ import annotations import re from collections import Counter, defaultdict NUM = re.compile(r"\d+(?:\.\d+)?") def _clean(s: str) -> str: s = (s or "").replace("
", " ") s = re.sub(r"\s+", " ", s).strip() n = len(s) if n > 20: # 折叠"整串精确对折重复"(源 Excel 合并单元格常存双份) for mid in range(n // 2 - 2, n // 2 + 3): if 0 < mid < n: left, right = s[:mid].strip(), s[mid:].strip() if left and len(left) > 10 and left == right: return left return s def parse_blocks(md: str): lines = md.splitlines() blocks, i, n, buf = [], 0, len(lines), [] def flush(): if buf: for para in re.split(r"\n\s*\n", "\n".join(buf)): if para.strip(): blocks.append(("text", para.strip())) buf.clear() def is_row(s): return s.strip().startswith("|") and s.count("|") >= 2 def is_sep(s): return bool(re.match(r"^\s*\|[\s:|-]+\|\s*$", s)) and "-" in s while i < n: if is_row(lines[i]) and i + 1 < n and is_sep(lines[i + 1]): flush() header = [c.strip() for c in lines[i].strip().strip("|").split("|")] j, rows = i + 2, [] while j < n and is_row(lines[j]) and not is_sep(lines[j]): row = [c.strip() for c in lines[j].strip().strip("|").split("|")] rows.append((row + [""] * len(header))[: len(header)]) j += 1 blocks.append(("table", header, rows)) i = j else: buf.append(lines[i]) i += 1 flush() return blocks def classify_columns(header, rows): n, doc, keys, data = len(rows), [], [], [] for c in range(len(header)): vals = [r[c] for r in rows] nonempty = [v for v in vals if v] if not nonempty: continue distinct = len(set(nonempty)) changes = sum(1 for k in range(1, n) if vals[k] != vals[k - 1]) if distinct <= 1: doc.append(c) elif n >= 4 and changes <= max(1, int(n * 0.45)): keys.append(c) else: data.append(c) return doc, keys, data def _restructure_table(header, rows): doc, keys, data = classify_columns(header, rows) docmeta = {} for c in doc: v = next((r[c] for r in rows if r[c]), "") if v: docmeta[_clean(header[c])] = _clean(v) if not keys or not data: keep = [c for c in range(len(header)) if any(r[c] for r in rows)] out = ["| " + " | ".join(_clean(header[c]) for c in keep) + " |", "| " + " | ".join("---" for _ in keep) + " |"] out += ["| " + " | ".join(r[c] for c in keep) + " |" for r in rows] return docmeta, ["\n".join(out)] title_col = max(keys, key=lambda c: len({r[c] for r in rows if r[c]})) attr_keys = [c for c in keys if c != title_col] sections, cur_key, grp = [], object(), [] def emit(group): if not group: return seg = [f"## {_clean(group[0][title_col]) or '(未命名)'}"] for c in attr_keys: v = _clean(group[0][c]) if v: seg.append(f"- {_clean(header[c])}: {v}") seg.append("") seg.append("| " + " | ".join(_clean(header[c]) for c in data) + " |") seg.append("| " + " | ".join("---" for _ in data) + " |") for r in group: seg.append("| " + " | ".join(_clean(r[c]) for c in data) + " |") sections.append("\n".join(seg)) for r in rows: kt = tuple(r[c] for c in keys) if kt != cur_key: emit(grp) grp, cur_key = [], kt grp.append(r) emit(grp) return docmeta, sections def restructure_md(md: str, source: str = "") -> str: blocks = parse_blocks(md) docmeta, body, seen, title = {}, [], set(), "" for blk in blocks: if blk[0] == "text": para = blk[1] key = re.sub(r"\s+", "", para) if key in seen: continue seen.add(key) if not title and not para.startswith(("#", "|")): title = para.split("\n")[0] if "\n" not in para: continue if re.fullmatch(r"##\s*Sheet\d+\s*", para): continue body.append(("text", para)) else: dm, secs = _restructure_table(blk[1], blk[2]) docmeta.update(dm) body.append(("secs", secs)) out = ["## 基本信息", ""] if source: out.append(f"- 来源文件: {source}") for k, v in docmeta.items(): out.append(f"- {k}: {v}") out.append("") if title: out.append(f"# {title}\n") for kind, val in body: if kind == "text" and not val.startswith("#"): out.append(val + "\n") elif kind == "text": out.append(val + "\n") else: out.extend(s + "\n" for s in val) return "\n".join(out).strip() + "\n" # ---------- ②-LLM 语义结构化(类型自适应,用于行程单/复杂件;价格表走免费确定性)---------- STRUCTURE_PROMPT = """你是旅游 ToB 文档结构化助手。把下面从源文件转出的原始 Markdown,重排成"结构化科学 MD",供后期 LLM 检索入库。 【先判断文档类型,套用对应结构】 · 报价单/价目表:顶部「## 基本信息」表(供应商/有效期/出发地/币种);每个产品一个 `## 产品:<名称>` 小节(拼团类型/起订人数/景区小交通费/退费政策等);价格用「房型 | 成人价 | 儿童价 | 单房差」表;通用规则、参考酒店各自独立小节。 · 行程单:顶部「## 基本信息」表(产品名/天数/车型/出发地);按天分 `## D1 <当天路线>`、`## D2 …` 小节,每天完整保留 行程详情/餐食(早中晚)/住宿/交通;费用包含、费用不含、购物场所、预订须知、温馨提示 各自独立小节。 · 资源表(酒店/车辆/餐厅):每个资源一个小节 + 属性。 · 其它:用标题分层、表格如实保留。 【铁律,违反即事故】 1. 完整保留原文每一条信息、逐条照搬——严禁摘要、严禁概括、严禁删减合并、严禁省略。这是入库用的全量数据;输出篇幅应与原文相当。 2. 所有数值/价格/日期/时间/电话严格来自原文,一字不改、不新增、不计算、不脑补;看不清就原样抄。 3. 可纠正明显的"表头误标"(如"车型"列内容其实是"N人拼小团"→改标「拼团类型」),但只改标签、不改数值。 4. 元数据用可见的「## 基本信息」表,不要 YAML frontmatter。 只输出 MD,不要任何解释。""" def resolve_llm_key() -> str | None: """LLM key 解析顺序:环境变量 LLM_API_KEY / DEEPSEEK_API_KEY → ~/.ark_key 文件。""" import os k = os.environ.get("LLM_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") if k: return k.strip() try: with open(os.path.expanduser("~/.ark_key")) as f: return f.read().strip() or None except Exception: return None def structure_llm(md: str, api_key: str) -> str: """调 LLM(默认火山豆包,可用 LLM_BASE_URL/LLM_MODEL 覆盖)做类型自适应结构化,带重试退避。""" import os import time import requests base_url = os.environ.get("LLM_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3") model = os.environ.get("LLM_MODEL", "doubao-seed-2-0-lite-260428") payload = {"model": model, "temperature": 0, "messages": [{"role": "system", "content": STRUCTURE_PROMPT}, {"role": "user", "content": md}]} last = None for attempt in range(4): try: r = requests.post(f"{base_url}/chat/completions", headers={"Authorization": f"Bearer {api_key}"}, json=payload, timeout=180) r.raise_for_status() return r.json()["choices"][0]["message"]["content"] except requests.exceptions.RequestException as e: last = e if attempt < 3: time.sleep(2 * (attempt + 1)) raise last # ---------- ③ 事实级校验 ---------- def _wide_product_numbers(md: str) -> dict: out = defaultdict(set) for blk in parse_blocks(md): if blk[0] != "table": continue header, rows = blk[1], blk[2] _doc, keys, _data = classify_columns(header, rows) if not keys: continue tcol = max(keys, key=lambda c: len({r[c] for r in rows if r[c]})) for r in rows: for cell in r: out[_clean(r[tcol])].update(NUM.findall(cell)) return out def _section_numbers(md: str) -> dict: out, cur = defaultdict(set), None for line in md.splitlines(): m = re.match(r"^##\s+(?:产品[::])?\s*(.+)$", line) if m: cur = _clean(m.group(1)) elif cur and line.count("|") >= 2: out[cur].update(NUM.findall(line)) return out def _has_price_table(md: str) -> bool: for blk in parse_blocks(md): if blk[0] != "table": continue _doc, keys, data = classify_columns(blk[1], blk[2]) if keys and len(data) >= 2: vals = [r[c] for r in blk[2] for c in data] nums = [float(v) for v in vals if re.fullmatch(r"\d+(?:\.\d+)?", v)] if nums and sum(x >= 100 for x in nums) >= len(nums) * 0.5: return True return False def _strip_fm(md: str) -> str: m = re.match(r"^---\n.*?\n---\n", md, re.S) return md[m.end():] if m else md def validate(src_md: str, out_md: str) -> dict: out_body = _strip_fm(out_md) # 去掉"来源文件"行(文件名里的日期是元数据、不是业务数据,不参与数字校验) out_body = "\n".join(l for l in out_body.splitlines() if not l.lstrip().startswith("- 来源文件")) src_nums, out_nums = Counter(NUM.findall(src_md)), Counter(NUM.findall(out_body)) invented = {n: c for n, c in (out_nums - src_nums).items() if n not in src_nums} misassigned: dict = {} if _has_price_table(src_md): src_by = _wide_product_numbers(src_md) for prod, nums in _section_numbers(out_md).items(): match = next((src_by[k] for k in src_by if k and (k in prod or prod in k)), None) if match is None: continue bad = {n for n in (nums - match) if float(n) >= 100 and not 1900 <= float(n) <= 2099} if bad: misassigned[prod] = sorted(bad) shared = {n for n, c in Counter(n for ns in misassigned.values() for n in ns).items() if c >= max(3, int(len(misassigned) * 0.5))} misassigned = {p: [n for n in ns if n not in shared] for p, ns in misassigned.items() if [n for n in ns if n not in shared]} def _prose(md): return sum(len(re.sub(r"\s", "", l)) for l in md.splitlines() if l.count("|") < 2) sp, op = _prose(src_md), _prose(out_body) content_loss = f"②正文{op}字 / ①{sp}字,疑似删减" if (sp > 300 and op < sp * 0.5) else None warn = [] for line in out_md.splitlines(): if line.count("|") >= 3: cells = [c.strip() for c in line.strip("|").split("|")] nums = [c for c in cells if re.fullmatch(r"\d+(?:\.\d+)?", c)] if len(nums) >= 2 and float(nums[0]) < float(nums[1]): warn.append(f"成人价<儿童价? {line.strip()[:50]}") return { "passed": not invented and not misassigned and not content_loss, "invented": invented, "misassigned": misassigned, "content_loss": content_loss, "business_warn": warn[:5], } def build_structured(md: str, source: str = "") -> tuple[str, dict, str]: """8765 用:自动路由结构化。价格表且确定性已过 → 免费确定性重排; 行程单/复杂件/确定性没过 → 若有 key 则上 LLM(类型自适应)。返回 (结构化MD, 校验, 模式)。""" deterministic = restructure_md(md, source=source) rep = validate(md, deterministic) key = resolve_llm_key() if key and not (_has_price_table(md) and rep["passed"]): try: llm_md = structure_llm(md, key) if llm_md and llm_md.strip(): return llm_md, validate(md, llm_md), "llm" except Exception: # noqa: BLE001 pass return deterministic, rep, "deterministic"