prepare.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. """Deterministic local ETL. Source text is data; no network, eval or model calls."""
  2. import argparse
  3. import ast
  4. import csv
  5. import hashlib
  6. import io
  7. import json
  8. import re
  9. import sys
  10. import tempfile
  11. from collections import Counter
  12. from pathlib import Path
  13. ROOT = Path(__file__).resolve().parents[2]
  14. KINDS = {"general": "通用知识", "policy": "惠企政策", "qa": "问答知识", "finance": "金融产品"}
  15. POLICY_FIELDS = {
  16. "policy_name": "政策名称", "department": "政策所属部门", "document_number": "政策文号",
  17. "reported_item": "部门上报事项", "model_suggested_item": "大模型拆解申报事项",
  18. "target": "支持对象", "support": "支持方式和标准", "conditions": "申报条件",
  19. "benefit": "企业收益", "start_text": "开始时间", "end_text": "结束时间",
  20. "time_note": "时间备注", "source_annotations": "metadata",
  21. }
  22. FINANCE_FIELDS = dict(zip(
  23. ("source_number", "product_name", "product_type", "institution", "institution_type",
  24. "approval_mode", "policy_backed", "approval_document", "interest_rate_text",
  25. "loan_amount_text", "loan_term_text", "guarantee", "approval_time", "repayment",
  26. "fees", "service_region", "target", "special_zone", "entry_conditions",
  27. "features", "target_description", "description", "detail_url"),
  28. ("序号", "产品名称", "产品类型", "金融机构", "机构类型", "审批模式", "是否政策性",
  29. "审批备案文号", "利率范围", "贷款额度(万元)", "贷款期限(月)", "担保方式", "审批时间",
  30. "还款方式", "相关费用", "服务区域", "适用对象", "特色专区", "准入条件", "产品特色",
  31. "适用群体说明", "产品简介", "详情页网址"), strict=True))
  32. def dump(value):
  33. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
  34. def digest(value):
  35. return hashlib.sha256(value if isinstance(value, bytes) else value.encode("utf-8")).hexdigest()
  36. def clean(value):
  37. if isinstance(value, str):
  38. return value.replace("\r\n", "\n").replace("\r", "\n").strip()
  39. if isinstance(value, list):
  40. return [clean(x) for x in value]
  41. if isinstance(value, dict):
  42. return {k: clean(v) for k, v in value.items()}
  43. return value
  44. def pointer(key):
  45. return str(key).replace("~", "~0").replace("/", "~1")
  46. def leaves(value, path=""):
  47. """Preserve field and array boundaries, including empty values, in readable text."""
  48. if isinstance(value, dict) and value:
  49. for key, item in value.items():
  50. yield from leaves(item, path + "/" + pointer(key))
  51. elif isinstance(value, list) and value:
  52. for i, item in enumerate(value):
  53. yield from leaves(item, path + "/" + str(i))
  54. else:
  55. yield {"field_path": path or "/", "text": value if isinstance(value, str) else dump(value)}
  56. def strict_json(text):
  57. def pairs(items):
  58. result = {}
  59. for k, v in items:
  60. if k in result:
  61. raise ValueError("duplicate JSON key")
  62. result[k] = v
  63. return result
  64. def bad_constant(_):
  65. raise ValueError("non-finite JSON number")
  66. return json.loads(text, object_pairs_hook=pairs, parse_constant=bad_constant)
  67. def read_units(path):
  68. raw_bytes = path.read_bytes()
  69. if path.suffix.lower() == '.xlsx':
  70. from .police_workbook import read_police_workbook
  71. return raw_bytes, read_police_workbook(raw_bytes)
  72. text = raw_bytes.decode("utf-8-sig")
  73. suffix = path.suffix.lower()
  74. if suffix == ".json":
  75. values = strict_json(text)
  76. if not isinstance(values, list) or not all(isinstance(x, dict) for x in values):
  77. raise ValueError("expected JSON array of objects")
  78. return raw_bytes, [(f"/{i}", value) for i, value in enumerate(values)]
  79. if suffix == ".csv":
  80. reader = csv.reader(io.StringIO(text, newline=""), strict=True)
  81. headers = next(reader)
  82. if not headers or len(headers) != len(set(headers)):
  83. raise ValueError("missing or duplicate CSV columns")
  84. rows = []
  85. previous = reader.line_num
  86. for i, row in enumerate(reader, 1):
  87. if len(row) != len(headers):
  88. raise ValueError(f"CSV column count mismatch at record {i}")
  89. rows.append((f"record:{i};lines:{previous + 1}-{reader.line_num}", dict(zip(headers, row))))
  90. previous = reader.line_num
  91. return raw_bytes, rows
  92. if suffix in {".txt", ".md"}:
  93. return raw_bytes, [(f"lines:1-{len(text.splitlines())}", {"text": text})]
  94. raise ValueError("unsupported file extension")
  95. def first(value, keys):
  96. return next((value[k] for k in keys if value.get(k)), None)
  97. def normalize(rel, locator, raw, sha):
  98. value = clean(raw)
  99. path = Path(rel)
  100. flags = ["validity_not_verified"]
  101. kind = {"惠企政策.json": "policy", "qa.json": "qa", "E企融金融产品列表.csv": "finance"}.get(path.name, "general")
  102. if kind == "policy" and "content" in value:
  103. # New exports wrap the original policy object in content; retain the
  104. # untouched wrapper in raw_record and reject malformed bodies explicitly.
  105. detail = strict_json(value["content"]) if isinstance(value["content"], str) else value["content"]
  106. if not isinstance(detail, dict):
  107. raise ValueError("expected policy content object")
  108. value = {**clean(detail), **value}
  109. title = first(value, ["title", "标题", "政策名称", "question", "产品名称"])
  110. url = first(value, ["link", "原文地址", "政策原文地址", "详情页网址"])
  111. if kind == "policy":
  112. data = {key: value.get(source) for key, source in POLICY_FIELDS.items()}
  113. # The 170-item export renamed the item field and flattened the
  114. # model-assisted annotations. Keep those annotations separate from
  115. # official conditions and expose the cited policy text as evidence.
  116. data["reported_item"] = data["reported_item"] or value.get("申报事项")
  117. data["source_text"] = value.get("涉及原文")
  118. if data["source_annotations"] is None:
  119. annotations = {key: value[key] for key in ("梳理后建议申报条件", "是否“免申即享”") if key in value}
  120. data["source_annotations"] = annotations or None
  121. conditions = data["conditions"]
  122. if isinstance(conditions, str) and conditions.startswith("["):
  123. try:
  124. parsed = ast.literal_eval(conditions)
  125. if isinstance(parsed, list) and all(isinstance(x, str) for x in parsed):
  126. data["conditions"] = clean(parsed)
  127. else:
  128. flags.append("conditions_unparsed")
  129. except (ValueError, SyntaxError):
  130. flags.append("conditions_unparsed")
  131. flags.append("source_model_annotations_unverified")
  132. elif kind == "qa":
  133. data = {"question": value.get("question"), "answer": value.get("answer")}
  134. elif kind == "finance":
  135. data = {key: value.get(source) for key, source in FINANCE_FIELDS.items()}
  136. flags.append("financial_terms_not_verified")
  137. else:
  138. structured = None
  139. body = value.get("content")
  140. if isinstance(body, str) and body.startswith(("{", "[")):
  141. try:
  142. structured = strict_json(body)
  143. body = None
  144. except ValueError:
  145. flags.append("embedded_json_unparsed")
  146. if "text" in value:
  147. body = value["text"]
  148. header, separator, remaining = body.partition("=" * 20)
  149. if separator:
  150. title_match = re.search(r"^标题:(.*)$", header, re.M)
  151. url_match = re.search(r"^来源:(.*)$", header, re.M)
  152. title = title_match[1].strip() if title_match else title
  153. url = url_match[1].strip() if url_match else url
  154. body = remaining.lstrip("=").strip()
  155. title = title or path.stem
  156. if body is None and structured is None:
  157. structured = {k: v for k, v in value.items() if k not in {"标题", "原文地址"}}
  158. data = {"body": body, "structured_content": structured,
  159. "document_role": "catalog" if path.suffix == ".md" else "knowledge"}
  160. if data["document_role"] == "catalog":
  161. flags.append("catalog_not_policy_evidence")
  162. if not title:
  163. title = path.stem
  164. flags.append("title_from_filename")
  165. if not url:
  166. flags.append("source_url_missing")
  167. elif not isinstance(url, str) or not re.match(r"^https?://[^\s]+$", url):
  168. flags.append("source_url_invalid")
  169. if kind == "qa" and not data.get("answer"):
  170. flags.append("empty_answer")
  171. if kind == "general" and not (data["body"] or data["structured_content"]):
  172. flags.append("empty_content")
  173. # Only explicit source fields become scope. Directory classification is not applicability.
  174. scope = {"region": value.get("服务区域"), "target": first(value, ["支持对象", "适用对象"])}
  175. if kind == "general" and isinstance(data["structured_content"], dict):
  176. detail = data["structured_content"]
  177. scope["region"] = first(detail, ["区划名称", "所属区划"])
  178. basic = detail.get("基本信息", {})
  179. if isinstance(basic, dict):
  180. scope["target"] = basic.get("服务对象")
  181. if not scope["region"]:
  182. flags.append("region_not_structured")
  183. if not scope["target"]:
  184. flags.append("target_not_structured")
  185. record_id = "k_" + digest(rel + "#" + locator)[:24]
  186. sections = list(leaves(data))
  187. for i, section in enumerate(sections, 1):
  188. section["id"] = f"{record_id}:s{i}"
  189. return {
  190. "schema_version": "1.0", "id": record_id, "kind": kind, "title": title,
  191. "collection": path.parts[0] if len(path.parts) > 1 else path.stem,
  192. "classification_path": list(path.parts[:-1]), "applicability": scope,
  193. "source": {"file": "reference/" + rel, "locator": locator, "sha256": sha,
  194. "url": url, "publisher": first(value, ["source", "部门", "政策所属部门"]),
  195. "published_text": value.get("date"), "updated_text": value.get("update_time"),
  196. "acquired_at": None},
  197. "validity": {"status": "unverified", "effective_from": None, "effective_to": None},
  198. "data": data, "sections": sections, "raw_record": raw,
  199. "content_sha256": digest(dump({"kind": kind, "title": title, "data": data})),
  200. "quality_flags": flags, "duplicate_of": None,
  201. }
  202. def collect(source):
  203. records, files = [], []
  204. if not source.is_dir():
  205. raise ValueError("source directory does not exist")
  206. paths = sorted((p for p in source.rglob("*") if p.is_file()), key=lambda p: p.relative_to(source).as_posix())
  207. if not paths:
  208. raise ValueError("source directory is empty")
  209. for path in paths:
  210. rel = path.relative_to(source).as_posix()
  211. if path.is_symlink() or not path.resolve().is_relative_to(source.resolve()):
  212. raise ValueError(f"source escapes input directory: {rel}")
  213. try:
  214. raw_bytes, units = read_units(path)
  215. sha = digest(raw_bytes)
  216. records.extend(normalize(rel, locator, raw, sha) for locator, raw in units)
  217. except (ValueError, UnicodeError, csv.Error, StopIteration) as exc:
  218. raise ValueError(f"cannot parse {rel}: {type(exc).__name__}") from exc
  219. files.append({"file": "reference/" + rel, "sha256": sha, "bytes": len(raw_bytes), "records": len(units)})
  220. seen = {}
  221. for record in records:
  222. fingerprint = record["content_sha256"]
  223. if fingerprint in seen:
  224. record["duplicate_of"] = seen[fingerprint]
  225. record["quality_flags"].append("exact_content_duplicate")
  226. else:
  227. seen[fingerprint] = record["id"]
  228. return records, files
  229. def readable(records, label):
  230. parts = [f"# {label}\n", "此文件由程序生成。原始事实及其有效性未经联网核验;定位信息见每条来源。\n"]
  231. for r in records:
  232. parts.extend([f"## {r['title']}\n", f"- 标识:{r['id']}\n- 分类:{' / '.join(r['classification_path']) or r['collection']}\n",
  233. f"- 来源文件:{r['source']['file']}\n- 来源定位:{r['source']['locator']}\n",
  234. f"- 原文地址:{r['source']['url'] or '未提供'}\n- 待核验:{', '.join(r['quality_flags'])}\n",
  235. f"- 重复指向:{r['duplicate_of'] or '无'}\n"])
  236. for section in r["sections"]:
  237. # Text is quoted, so embedded Markdown/HTML is never presented as executable HTML.
  238. text = section["text"].replace("<", "&lt;").replace(">", "&gt;")
  239. parts.append(f"### {section['field_path']}\n\n" + "\n".join("> " + line for line in text.split("\n")) + "\n")
  240. return "\n".join(parts)
  241. def build_payloads(records, files):
  242. payloads = {}
  243. for kind, label in KINDS.items():
  244. selected = [r for r in records if r["kind"] == kind]
  245. payloads[f"{kind}.jsonl"] = "".join(dump(r) + "\n" for r in selected)
  246. payloads[f"{kind}.md"] = readable(selected, label)
  247. report = {"schema_version": "1.0", "source_files": len(files), "records": len(records),
  248. "counts": dict(Counter(r["kind"] for r in records)), "files": files,
  249. "flags": dict(Counter(f for r in records for f in r["quality_flags"])),
  250. "duplicates": [{"id": r["id"], "duplicate_of": r["duplicate_of"]} for r in records if r["duplicate_of"]],
  251. "output_sha256": {name: digest(text) for name, text in payloads.items()}}
  252. payloads["report.json"] = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
  253. return payloads, report
  254. def verify_output(source, output, *, collector=collect):
  255. records, files = collector(source)
  256. expected, report = build_payloads(records, files)
  257. for name, text in expected.items():
  258. if (output / name).read_bytes() != text.encode("utf-8"):
  259. raise ValueError(f"output mismatch: {name}")
  260. if len({r["id"] for r in records}) != len(records):
  261. raise ValueError("duplicate record IDs")
  262. if sum(f["records"] for f in files) != len(records):
  263. raise ValueError("source coverage mismatch")
  264. return report
  265. def run(source, output, *, collector=collect):
  266. source, output = source.resolve(), output.resolve()
  267. if output.is_relative_to(source) or source.is_relative_to(output):
  268. raise ValueError("input and output directories must not overlap")
  269. records, files = collector(source) # Parse all inputs before touching existing output.
  270. payloads, report = build_payloads(records, files)
  271. output.mkdir(parents=True, exist_ok=True)
  272. for name, text in payloads.items():
  273. target = output / name
  274. if target.is_symlink():
  275. raise ValueError("output must not be a symbolic link")
  276. if target.is_file() and target.read_bytes() == text.encode("utf-8"):
  277. continue
  278. # Unique, exclusively created temporary files cannot follow an existing link.
  279. with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n",
  280. dir=output, prefix=name + ".", suffix=".tmp", delete=False) as stream:
  281. tmp = Path(stream.name)
  282. try:
  283. stream.write(text)
  284. except BaseException:
  285. stream.close()
  286. tmp.unlink(missing_ok=True)
  287. raise
  288. try:
  289. tmp.replace(target)
  290. finally:
  291. tmp.unlink(missing_ok=True)
  292. return report
  293. def main(argv=None):
  294. parser = argparse.ArgumentParser(description=__doc__)
  295. parser.add_argument("--source", type=Path, default=ROOT / "reference" / "营商助手",
  296. help="raw source directory (default: reference/营商助手)")
  297. parser.add_argument("--output", type=Path, default=ROOT / "knowledge" / "prepared")
  298. parser.add_argument("--verify", action="store_true", help="verify existing output without rewriting")
  299. args = parser.parse_args(argv)
  300. try:
  301. report = verify_output(args.source, args.output) if args.verify else run(args.source, args.output)
  302. except (ValueError, OSError) as exc:
  303. print(f"Preparation failed: {exc}", file=sys.stderr)
  304. return 1
  305. print(dump({"files": report["source_files"], "records": report["records"], "counts": report["counts"],
  306. "duplicates": len(report["duplicates"]), "verified": args.verify}))
  307. return 0