from __future__ import annotations
|
|
import hashlib
|
import json
|
import re
|
from pathlib import Path
|
from typing import Any
|
|
from .full_report import HEADINGS, render_report
|
from .snapshot_builder import CORE_VALUE_PATHS, protected_hash
|
|
|
FORBIDDEN_PLACEHOLDERS = ("TODO", "TBD", "待计算", "NaN", "Infinity")
|
FORBIDDEN_TRADING = ("买入", "卖出", "加仓", "减仓", "止损", "保证收益")
|
DIRECTIONAL_WITHOUT_JUDGMENT = (
|
"明显偏贵",
|
"明显低估",
|
"安全边际充足",
|
"建议持有",
|
"当前价格高于",
|
"当前价格低于",
|
)
|
|
|
def _tables_valid(report: str) -> bool:
|
active_width: int | None = None
|
for line in report.splitlines():
|
if line.startswith("|") and line.endswith("|"):
|
width = len(line.split("|"))
|
if active_width is None:
|
active_width = width
|
elif width != active_width:
|
return False
|
else:
|
active_width = None
|
return True
|
|
|
def _local_links_valid(report: str, artifact_root: Path | None) -> bool:
|
for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", report):
|
if re.match(r"https?://", target):
|
continue
|
if artifact_root is None:
|
return False
|
candidate = artifact_root / target
|
if not candidate.is_file():
|
return False
|
return True
|
|
|
def run_qa(
|
report: str,
|
snapshot: dict[str, Any],
|
build_report: dict[str, Any],
|
results: dict[str, Any] | None,
|
artifact_root: Path | None = None,
|
) -> dict[str, Any]:
|
errors: list[str] = []
|
headings = re.findall(r"^## \d+\. (.+)$", report, re.M)
|
if headings != HEADINGS or len(set(headings)) != 16:
|
errors.append("16 节标题不齐全、重复或顺序错误")
|
for token in FORBIDDEN_PLACEHOLDERS:
|
if token in report:
|
errors.append(f"存在占位词:{token}")
|
for token in FORBIDDEN_TRADING:
|
if token in report:
|
errors.append(f"存在交易指令/承诺:{token}")
|
if report.count("```") % 2:
|
errors.append("Markdown fence 未闭合")
|
if not _tables_valid(report):
|
errors.append("Markdown 表格列数不一致")
|
if not _local_links_valid(report, artifact_root):
|
errors.append("本地产物链接不存在")
|
if results is None:
|
if "GAP:需要人工判断覆盖层" not in report:
|
errors.append("无 judgment 未声明判断缺口")
|
for token in DIRECTIONAL_WITHOUT_JUDGMENT:
|
if token in report:
|
errors.append(f"无 judgment 出现方向结论:{token}")
|
if not build_report.get("as_of_safe"):
|
errors.append("as-of 检查未通过")
|
raw_hashes = set(build_report.get("raw_hashes", []))
|
if not raw_hashes:
|
errors.append("缺少原始响应哈希")
|
source_by_id = {
|
item.get("id"): item for item in snapshot.get("sources", []) if item.get("id")
|
}
|
source_ids = set(source_by_id)
|
as_of = snapshot.get("meta", {}).get("as_of_date")
|
lineage = build_report.get("field_lineage", {})
|
for field in CORE_VALUE_PATHS:
|
item = lineage.get(field)
|
if not item:
|
errors.append(f"核心字段缺少血缘:{field}")
|
continue
|
if item.get("source_id") not in source_ids:
|
errors.append(f"核心字段 source_id 不存在:{field}")
|
else:
|
structured = source_by_id[item["source_id"]]
|
if structured.get("period_end") != item.get("data_date"):
|
errors.append(f"核心字段 data_date/source period 不一致:{field}")
|
if structured.get("publish_date") != item.get("publish_date"):
|
errors.append(f"核心字段 publish_date/source 不一致:{field}")
|
if not any(
|
field == support or field.startswith(f"{support}.")
|
for support in structured.get("supports", [])
|
):
|
errors.append(f"核心字段结构化来源不支持字段:{field}")
|
if item.get("raw_hash") not in raw_hashes:
|
errors.append(f"核心字段 raw hash 不存在:{field}")
|
if not item.get("publish_date") or not item.get("data_date"):
|
errors.append(f"核心字段日期缺失:{field}")
|
elif item["publish_date"] > as_of or item["data_date"] > as_of:
|
errors.append(f"核心字段含未来日期:{field}")
|
if field.startswith(("financials.", "balance_sheet.")):
|
if item.get("a1_source_id") not in source_ids or item.get("a1_raw_hash") not in raw_hashes:
|
errors.append(f"核心财务缺少 A1/raw 回链:{field}")
|
if not item.get("a1_publish_date") or not item.get("a1_period_end"):
|
errors.append(f"核心财务 A1 日期缺失:{field}")
|
a1 = source_by_id.get(item.get("a1_source_id"))
|
support = item.get("a1_support")
|
if a1 is None or support not in a1.get("supports", []):
|
errors.append(f"核心财务 A1 不支持字段:{field}")
|
elif (
|
a1.get("publish_date") != item.get("a1_publish_date")
|
or a1.get("period_end") != item.get("a1_period_end")
|
):
|
errors.append(f"核心财务 A1 日期/期间不一致:{field}")
|
if field.startswith("financials.prior_year_same_period."):
|
relation = item.get("comparison_relation") or {}
|
if (
|
relation.get("type") != "same_response_comparative_row"
|
or relation.get("comparison_period_end") != item.get("data_date")
|
or relation.get("current_period_end") != item.get("a1_period_end")
|
or relation.get("shared_publish_date") != item.get("a1_publish_date")
|
or item.get("publish_date") != item.get("a1_publish_date")
|
):
|
errors.append(f"上年同期比较关系/A1 不一致:{field}")
|
elif item.get("a1_period_end") != item.get("data_date"):
|
errors.append(f"核心财务 data_date/A1 period 不一致:{field}")
|
if protected_hash(snapshot) != build_report.get("mechanical_protected_sha256"):
|
errors.append("judgment 改写了机械保护字段")
|
expected_report = render_report(snapshot, build_report, build_report.get("gaps", []), results)
|
if report != expected_report:
|
errors.append("报告不是 snapshot/V1 results 的确定渲染")
|
if results is not None:
|
for value in (
|
results["metrics"]["market_cap"],
|
results["metrics"]["ttm_revenue"],
|
results["metrics"]["ttm_attributable_profit"],
|
results["metrics"]["normalized_profit"],
|
results["metrics"]["pb"],
|
results["metrics"]["ps"],
|
):
|
if str(value) not in report:
|
errors.append(f"V1 关键数值未进入报告:{value}")
|
return {
|
"schema_version": 2,
|
"status": "PASS" if not errors else "FAIL",
|
"checks": {
|
"headings": headings == HEADINGS and len(set(headings)) == 16,
|
"markdown_fences": report.count("```") % 2 == 0,
|
"markdown_tables": _tables_valid(report),
|
"local_links": _local_links_valid(report, artifact_root),
|
"as_of": bool(build_report.get("as_of_safe")),
|
"field_lineage": all(field in lineage for field in CORE_VALUE_PATHS),
|
"raw_hashes": bool(raw_hashes),
|
"protected_fields": protected_hash(snapshot)
|
== build_report.get("mechanical_protected_sha256"),
|
"deterministic_render": report == expected_report,
|
"judgment_mode": results is not None,
|
},
|
"errors": errors,
|
}
|
|
|
def verify_manifest(root: Path) -> list[str]:
|
errors: list[str] = []
|
path = root / "manifest.json"
|
try:
|
manifest = json.loads(path.read_text(encoding="utf-8"))
|
except (OSError, json.JSONDecodeError) as exc:
|
return [f"manifest 不可读:{exc}"]
|
if not manifest.get("complete"):
|
errors.append("manifest complete 不是 true")
|
for rel, expected in manifest.get("artifacts", {}).items():
|
artifact = root / rel
|
try:
|
data = artifact.read_bytes()
|
except OSError:
|
errors.append(f"manifest 产物缺失:{rel}")
|
continue
|
if len(data) != expected.get("bytes") or hashlib.sha256(data).hexdigest() != expected.get("sha256"):
|
errors.append(f"manifest hash/bytes 不一致:{rel}")
|
return errors
|