| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- """Deterministic local ETL. Source text is data; no network, eval or model calls."""
- import argparse
- import ast
- import csv
- import hashlib
- import io
- import json
- import re
- import sys
- import tempfile
- from collections import Counter
- from pathlib import Path
- ROOT = Path(__file__).resolve().parents[2]
- KINDS = {"general": "通用知识", "policy": "惠企政策", "qa": "问答知识", "finance": "金融产品"}
- POLICY_FIELDS = {
- "policy_name": "政策名称", "department": "政策所属部门", "document_number": "政策文号",
- "reported_item": "部门上报事项", "model_suggested_item": "大模型拆解申报事项",
- "target": "支持对象", "support": "支持方式和标准", "conditions": "申报条件",
- "benefit": "企业收益", "start_text": "开始时间", "end_text": "结束时间",
- "time_note": "时间备注", "source_annotations": "metadata",
- }
- FINANCE_FIELDS = dict(zip(
- ("source_number", "product_name", "product_type", "institution", "institution_type",
- "approval_mode", "policy_backed", "approval_document", "interest_rate_text",
- "loan_amount_text", "loan_term_text", "guarantee", "approval_time", "repayment",
- "fees", "service_region", "target", "special_zone", "entry_conditions",
- "features", "target_description", "description", "detail_url"),
- ("序号", "产品名称", "产品类型", "金融机构", "机构类型", "审批模式", "是否政策性",
- "审批备案文号", "利率范围", "贷款额度(万元)", "贷款期限(月)", "担保方式", "审批时间",
- "还款方式", "相关费用", "服务区域", "适用对象", "特色专区", "准入条件", "产品特色",
- "适用群体说明", "产品简介", "详情页网址"), strict=True))
- def dump(value):
- return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
- def digest(value):
- return hashlib.sha256(value if isinstance(value, bytes) else value.encode("utf-8")).hexdigest()
- def clean(value):
- if isinstance(value, str):
- return value.replace("\r\n", "\n").replace("\r", "\n").strip()
- if isinstance(value, list):
- return [clean(x) for x in value]
- if isinstance(value, dict):
- return {k: clean(v) for k, v in value.items()}
- return value
- def pointer(key):
- return str(key).replace("~", "~0").replace("/", "~1")
- def leaves(value, path=""):
- """Preserve field and array boundaries, including empty values, in readable text."""
- if isinstance(value, dict) and value:
- for key, item in value.items():
- yield from leaves(item, path + "/" + pointer(key))
- elif isinstance(value, list) and value:
- for i, item in enumerate(value):
- yield from leaves(item, path + "/" + str(i))
- else:
- yield {"field_path": path or "/", "text": value if isinstance(value, str) else dump(value)}
- def strict_json(text):
- def pairs(items):
- result = {}
- for k, v in items:
- if k in result:
- raise ValueError("duplicate JSON key")
- result[k] = v
- return result
- def bad_constant(_):
- raise ValueError("non-finite JSON number")
- return json.loads(text, object_pairs_hook=pairs, parse_constant=bad_constant)
- def read_units(path):
- raw_bytes = path.read_bytes()
- if path.suffix.lower() == '.xlsx':
- from .police_workbook import read_police_workbook
- return raw_bytes, read_police_workbook(raw_bytes)
- text = raw_bytes.decode("utf-8-sig")
- suffix = path.suffix.lower()
- if suffix == ".json":
- values = strict_json(text)
- if not isinstance(values, list) or not all(isinstance(x, dict) for x in values):
- raise ValueError("expected JSON array of objects")
- return raw_bytes, [(f"/{i}", value) for i, value in enumerate(values)]
- if suffix == ".csv":
- reader = csv.reader(io.StringIO(text, newline=""), strict=True)
- headers = next(reader)
- if not headers or len(headers) != len(set(headers)):
- raise ValueError("missing or duplicate CSV columns")
- rows = []
- previous = reader.line_num
- for i, row in enumerate(reader, 1):
- if len(row) != len(headers):
- raise ValueError(f"CSV column count mismatch at record {i}")
- rows.append((f"record:{i};lines:{previous + 1}-{reader.line_num}", dict(zip(headers, row))))
- previous = reader.line_num
- return raw_bytes, rows
- if suffix in {".txt", ".md"}:
- return raw_bytes, [(f"lines:1-{len(text.splitlines())}", {"text": text})]
- raise ValueError("unsupported file extension")
- def first(value, keys):
- return next((value[k] for k in keys if value.get(k)), None)
- def normalize(rel, locator, raw, sha):
- value = clean(raw)
- path = Path(rel)
- flags = ["validity_not_verified"]
- kind = {"惠企政策.json": "policy", "qa.json": "qa", "E企融金融产品列表.csv": "finance"}.get(path.name, "general")
- if kind == "policy" and "content" in value:
- # New exports wrap the original policy object in content; retain the
- # untouched wrapper in raw_record and reject malformed bodies explicitly.
- detail = strict_json(value["content"]) if isinstance(value["content"], str) else value["content"]
- if not isinstance(detail, dict):
- raise ValueError("expected policy content object")
- value = {**clean(detail), **value}
- title = first(value, ["title", "标题", "政策名称", "question", "产品名称"])
- url = first(value, ["link", "原文地址", "政策原文地址", "详情页网址"])
- if kind == "policy":
- data = {key: value.get(source) for key, source in POLICY_FIELDS.items()}
- # The 170-item export renamed the item field and flattened the
- # model-assisted annotations. Keep those annotations separate from
- # official conditions and expose the cited policy text as evidence.
- data["reported_item"] = data["reported_item"] or value.get("申报事项")
- data["source_text"] = value.get("涉及原文")
- if data["source_annotations"] is None:
- annotations = {key: value[key] for key in ("梳理后建议申报条件", "是否“免申即享”") if key in value}
- data["source_annotations"] = annotations or None
- conditions = data["conditions"]
- if isinstance(conditions, str) and conditions.startswith("["):
- try:
- parsed = ast.literal_eval(conditions)
- if isinstance(parsed, list) and all(isinstance(x, str) for x in parsed):
- data["conditions"] = clean(parsed)
- else:
- flags.append("conditions_unparsed")
- except (ValueError, SyntaxError):
- flags.append("conditions_unparsed")
- flags.append("source_model_annotations_unverified")
- elif kind == "qa":
- data = {"question": value.get("question"), "answer": value.get("answer")}
- elif kind == "finance":
- data = {key: value.get(source) for key, source in FINANCE_FIELDS.items()}
- flags.append("financial_terms_not_verified")
- else:
- structured = None
- body = value.get("content")
- if isinstance(body, str) and body.startswith(("{", "[")):
- try:
- structured = strict_json(body)
- body = None
- except ValueError:
- flags.append("embedded_json_unparsed")
- if "text" in value:
- body = value["text"]
- header, separator, remaining = body.partition("=" * 20)
- if separator:
- title_match = re.search(r"^标题:(.*)$", header, re.M)
- url_match = re.search(r"^来源:(.*)$", header, re.M)
- title = title_match[1].strip() if title_match else title
- url = url_match[1].strip() if url_match else url
- body = remaining.lstrip("=").strip()
- title = title or path.stem
- if body is None and structured is None:
- structured = {k: v for k, v in value.items() if k not in {"标题", "原文地址"}}
- data = {"body": body, "structured_content": structured,
- "document_role": "catalog" if path.suffix == ".md" else "knowledge"}
- if data["document_role"] == "catalog":
- flags.append("catalog_not_policy_evidence")
- if not title:
- title = path.stem
- flags.append("title_from_filename")
- if not url:
- flags.append("source_url_missing")
- elif not isinstance(url, str) or not re.match(r"^https?://[^\s]+$", url):
- flags.append("source_url_invalid")
- if kind == "qa" and not data.get("answer"):
- flags.append("empty_answer")
- if kind == "general" and not (data["body"] or data["structured_content"]):
- flags.append("empty_content")
- # Only explicit source fields become scope. Directory classification is not applicability.
- scope = {"region": value.get("服务区域"), "target": first(value, ["支持对象", "适用对象"])}
- if kind == "general" and isinstance(data["structured_content"], dict):
- detail = data["structured_content"]
- scope["region"] = first(detail, ["区划名称", "所属区划"])
- basic = detail.get("基本信息", {})
- if isinstance(basic, dict):
- scope["target"] = basic.get("服务对象")
- if not scope["region"]:
- flags.append("region_not_structured")
- if not scope["target"]:
- flags.append("target_not_structured")
- record_id = "k_" + digest(rel + "#" + locator)[:24]
- sections = list(leaves(data))
- for i, section in enumerate(sections, 1):
- section["id"] = f"{record_id}:s{i}"
- return {
- "schema_version": "1.0", "id": record_id, "kind": kind, "title": title,
- "collection": path.parts[0] if len(path.parts) > 1 else path.stem,
- "classification_path": list(path.parts[:-1]), "applicability": scope,
- "source": {"file": "reference/" + rel, "locator": locator, "sha256": sha,
- "url": url, "publisher": first(value, ["source", "部门", "政策所属部门"]),
- "published_text": value.get("date"), "updated_text": value.get("update_time"),
- "acquired_at": None},
- "validity": {"status": "unverified", "effective_from": None, "effective_to": None},
- "data": data, "sections": sections, "raw_record": raw,
- "content_sha256": digest(dump({"kind": kind, "title": title, "data": data})),
- "quality_flags": flags, "duplicate_of": None,
- }
- def collect(source):
- records, files = [], []
- if not source.is_dir():
- raise ValueError("source directory does not exist")
- paths = sorted((p for p in source.rglob("*") if p.is_file()), key=lambda p: p.relative_to(source).as_posix())
- if not paths:
- raise ValueError("source directory is empty")
- for path in paths:
- rel = path.relative_to(source).as_posix()
- if path.is_symlink() or not path.resolve().is_relative_to(source.resolve()):
- raise ValueError(f"source escapes input directory: {rel}")
- try:
- raw_bytes, units = read_units(path)
- sha = digest(raw_bytes)
- records.extend(normalize(rel, locator, raw, sha) for locator, raw in units)
- except (ValueError, UnicodeError, csv.Error, StopIteration) as exc:
- raise ValueError(f"cannot parse {rel}: {type(exc).__name__}") from exc
- files.append({"file": "reference/" + rel, "sha256": sha, "bytes": len(raw_bytes), "records": len(units)})
- seen = {}
- for record in records:
- fingerprint = record["content_sha256"]
- if fingerprint in seen:
- record["duplicate_of"] = seen[fingerprint]
- record["quality_flags"].append("exact_content_duplicate")
- else:
- seen[fingerprint] = record["id"]
- return records, files
- def readable(records, label):
- parts = [f"# {label}\n", "此文件由程序生成。原始事实及其有效性未经联网核验;定位信息见每条来源。\n"]
- for r in records:
- parts.extend([f"## {r['title']}\n", f"- 标识:{r['id']}\n- 分类:{' / '.join(r['classification_path']) or r['collection']}\n",
- f"- 来源文件:{r['source']['file']}\n- 来源定位:{r['source']['locator']}\n",
- f"- 原文地址:{r['source']['url'] or '未提供'}\n- 待核验:{', '.join(r['quality_flags'])}\n",
- f"- 重复指向:{r['duplicate_of'] or '无'}\n"])
- for section in r["sections"]:
- # Text is quoted, so embedded Markdown/HTML is never presented as executable HTML.
- text = section["text"].replace("<", "<").replace(">", ">")
- parts.append(f"### {section['field_path']}\n\n" + "\n".join("> " + line for line in text.split("\n")) + "\n")
- return "\n".join(parts)
- def build_payloads(records, files):
- payloads = {}
- for kind, label in KINDS.items():
- selected = [r for r in records if r["kind"] == kind]
- payloads[f"{kind}.jsonl"] = "".join(dump(r) + "\n" for r in selected)
- payloads[f"{kind}.md"] = readable(selected, label)
- report = {"schema_version": "1.0", "source_files": len(files), "records": len(records),
- "counts": dict(Counter(r["kind"] for r in records)), "files": files,
- "flags": dict(Counter(f for r in records for f in r["quality_flags"])),
- "duplicates": [{"id": r["id"], "duplicate_of": r["duplicate_of"]} for r in records if r["duplicate_of"]],
- "output_sha256": {name: digest(text) for name, text in payloads.items()}}
- payloads["report.json"] = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
- return payloads, report
- def verify_output(source, output, *, collector=collect):
- records, files = collector(source)
- expected, report = build_payloads(records, files)
- for name, text in expected.items():
- if (output / name).read_bytes() != text.encode("utf-8"):
- raise ValueError(f"output mismatch: {name}")
- if len({r["id"] for r in records}) != len(records):
- raise ValueError("duplicate record IDs")
- if sum(f["records"] for f in files) != len(records):
- raise ValueError("source coverage mismatch")
- return report
- def run(source, output, *, collector=collect):
- source, output = source.resolve(), output.resolve()
- if output.is_relative_to(source) or source.is_relative_to(output):
- raise ValueError("input and output directories must not overlap")
- records, files = collector(source) # Parse all inputs before touching existing output.
- payloads, report = build_payloads(records, files)
- output.mkdir(parents=True, exist_ok=True)
- for name, text in payloads.items():
- target = output / name
- if target.is_symlink():
- raise ValueError("output must not be a symbolic link")
- if target.is_file() and target.read_bytes() == text.encode("utf-8"):
- continue
- # Unique, exclusively created temporary files cannot follow an existing link.
- with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n",
- dir=output, prefix=name + ".", suffix=".tmp", delete=False) as stream:
- tmp = Path(stream.name)
- try:
- stream.write(text)
- except BaseException:
- stream.close()
- tmp.unlink(missing_ok=True)
- raise
- try:
- tmp.replace(target)
- finally:
- tmp.unlink(missing_ok=True)
- return report
- def main(argv=None):
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--source", type=Path, default=ROOT / "reference" / "营商助手",
- help="raw source directory (default: reference/营商助手)")
- parser.add_argument("--output", type=Path, default=ROOT / "knowledge" / "prepared")
- parser.add_argument("--verify", action="store_true", help="verify existing output without rewriting")
- args = parser.parse_args(argv)
- try:
- report = verify_output(args.source, args.output) if args.verify else run(args.source, args.output)
- except (ValueError, OSError) as exc:
- print(f"Preparation failed: {exc}", file=sys.stderr)
- return 1
- print(dump({"files": report["source_files"], "records": report["records"], "counts": report["counts"],
- "duplicates": len(report["duplicates"]), "verified": args.verify}))
- return 0
|