Files
group_fqcd_jr/tools/knowledge_import_preflight.py

222 lines
8.9 KiB
Python
Raw Permalink 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.
"""将一期公开 QA 候选转换为不含外部写入的待审核导入清单。"""
import argparse
import json
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
from app.core.knowledge_contracts import ALLOWED_KNOWLEDGE_COLLECTIONS
from app.service.knowledge_config import KnowledgeRuntimeConfig
# 仅允许一期三类公开知识按既定路由进入后续发布流程。
EXPECTED_COLLECTIONS = {
intent: collection
for intent, (collection, _top_k) in KnowledgeRuntimeConfig.DEFAULT_ROUTES.items()
}
# 公开候选必须具备的字段,缺失时不能生成不完整的发布清单。
REQUIRED_PUBLIC_FIELDS = frozenset({
"qa_id",
"title",
"question",
"paraphrases",
"answer",
"scope",
"intent",
"collection",
"execution_mode",
"retrieval_status",
"audience",
"agent_data_access",
"tags",
"source_type",
"source_file",
"source_version",
"review_status",
"status",
})
def _required_string(record: Mapping[str, object], field: str, qa_id: str) -> str:
"""读取非空字符串字段,拒绝将不完整资料带入后续发布阶段。"""
value = record.get(field)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{qa_id}: {field} must be a non-empty string")
return value.strip()
def _required_strings(record: Mapping[str, object], field: str, qa_id: str) -> list[str]:
"""读取非空字符串数组,并保持来源中已经审核的条目顺序。"""
value = record.get(field)
if not isinstance(value, list) or not value:
raise ValueError(f"{qa_id}: {field} must be a non-empty string list")
strings = [item.strip() for item in value if isinstance(item, str) and item.strip()]
if len(strings) != len(value):
raise ValueError(f"{qa_id}: {field} must contain only non-empty strings")
return strings
def _is_rule_only(record: Mapping[str, object]) -> bool:
"""控制类记录必须由应用层固定路由处理,绝不能进入向量导入清单。"""
return (
record.get("collection") is None
and record.get("execution_mode") == "fixed_route"
and record.get("retrieval_status") == "rule_only"
)
def _validate_public_record(record: Mapping[str, object]) -> None:
"""验证候选是否符合一期公开知识与最小权限边界。"""
qa_id = _required_string(record, "qa_id", "<unknown>")
missing = sorted(field for field in REQUIRED_PUBLIC_FIELDS if field not in record)
if missing:
raise ValueError(f"{qa_id}: missing required fields: {', '.join(missing)}")
intent = _required_string(record, "intent", qa_id)
collection = _required_string(record, "collection", qa_id)
if intent not in EXPECTED_COLLECTIONS:
raise ValueError(f"{qa_id}: unsupported public intent: {intent}")
if collection not in ALLOWED_KNOWLEDGE_COLLECTIONS:
raise ValueError(f"{qa_id}: collection is not allowlisted: {collection}")
if collection != EXPECTED_COLLECTIONS[intent]:
raise ValueError(f"{qa_id}: collection does not match intent route")
if record.get("scope") != "public":
raise ValueError(f"{qa_id}: public vector record must have scope=public")
if record.get("execution_mode") != "vector_search":
raise ValueError(f"{qa_id}: public record must use vector_search")
if record.get("retrieval_status") != "approved_candidate":
raise ValueError(f"{qa_id}: record is not an approved candidate")
if record.get("review_status") != "approved_candidate":
raise ValueError(f"{qa_id}: review state cannot enter preflight")
if record.get("status") != "active":
raise ValueError(f"{qa_id}: inactive record cannot enter preflight")
if record.get("agent_data_access") != "none":
raise ValueError(f"{qa_id}: agent data access must remain none")
audience = _required_strings(record, "audience", qa_id)
if set(audience) != {"visitor", "authenticated_user"}:
raise ValueError(f"{qa_id}: audience must be visitor and authenticated_user")
_required_string(record, "title", qa_id)
_required_string(record, "question", qa_id)
_required_strings(record, "paraphrases", qa_id)
_required_string(record, "answer", qa_id)
_required_strings(record, "tags", qa_id)
_required_string(record, "source_type", qa_id)
_required_string(record, "source_file", qa_id)
_required_string(record, "source_version", qa_id)
def _public_entry(record: Mapping[str, object]) -> dict[str, object]:
"""构造供管理员审核的确定性条目,不生成数据库主键或向量。"""
qa_id = _required_string(record, "qa_id", "<unknown>")
question = _required_string(record, "question", qa_id)
paraphrases = _required_strings(record, "paraphrases", qa_id)
tags = _required_strings(record, "tags", qa_id)
answer = _required_string(record, "answer", qa_id)
content = {
"qa_id": qa_id,
"question": question,
"paraphrases": paraphrases,
"answer": answer,
"audience": _required_strings(record, "audience", qa_id),
"agent_data_access": "none",
"source_version": _required_string(record, "source_version", qa_id),
}
return {
"qa_id": qa_id,
"knowledge_type": _required_string(record, "intent", qa_id),
"title": _required_string(record, "title", qa_id),
"milvus_collection": _required_string(record, "collection", qa_id),
"version": _required_string(record, "source_version", qa_id),
"source_file": _required_string(record, "source_file", qa_id),
"source_type": _required_string(record, "source_type", qa_id),
"source_url": record.get("source_url"),
"effective_date": record.get("effective_date"),
"expire_date": record.get("expire_date"),
"tags": tags,
"content_text": json.dumps(content, ensure_ascii=False, separators=(",", ":")),
"retrieval_text": (
f"标准问题:{question}\n"
f"相似问法:{';'.join(paraphrases)}\n"
f"标签:{'、'.join(tags)}"
),
"snippet": question[:300],
# 管理员填入真实审核人并批准前,预检清单绝不伪装成已发布数据。
"review_status": "pending_review",
"status": "active",
}
def build_import_manifest(
records: Sequence[Mapping[str, object]], *, source_name: str
) -> dict[str, object]:
"""构建可复查的导入清单,并拒绝任何不满足公开边界的非控制类记录。"""
entries: list[dict[str, object]] = []
seen_ids: set[str] = set()
excluded_rule_records = 0
for record in records:
if _is_rule_only(record):
excluded_rule_records += 1
continue
_validate_public_record(record)
entry = _public_entry(record)
qa_id = str(entry["qa_id"])
if qa_id in seen_ids:
raise ValueError(f"duplicate qa_id: {qa_id}")
seen_ids.add(qa_id)
entries.append(entry)
return {
"source_name": source_name,
"summary": {
"total_records": len(records),
"eligible_records": len(entries),
"excluded_rule_records": excluded_rule_records,
"publication_state": "pending_review",
},
"records": entries,
}
def load_jsonl(source: Path) -> list[dict[str, object]]:
"""读取 UTF-8 JSONL,并为每个无效 JSON 行返回带行号的明确错误。"""
records: list[dict[str, object]] = []
for line_number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), start=1):
if not line.strip():
continue
try:
value: Any = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"line {line_number}: invalid JSON") from exc
if not isinstance(value, dict):
raise ValueError(f"line {line_number}: record must be an object")
records.append(value)
return records
def write_manifest(target: Path, manifest: Mapping[str, object]) -> None:
"""仅在调用者显式传入输出路径时,写入本地待审核 JSON 清单。"""
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
def main() -> int:
"""执行本地预检;该入口不读取配置也不连接任何外部服务。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path)
arguments = parser.parse_args()
manifest = build_import_manifest(
load_jsonl(arguments.input), source_name=arguments.input.name
)
if arguments.output is None:
print(json.dumps(manifest, ensure_ascii=False, indent=2))
else:
write_manifest(arguments.output, manifest)
print(f"WROTE: {arguments.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())