forked from admin/PlantAI-rag-new
feat(aqua): unlock normalized KB identity via env with plant fallback
- normalized_release EXPECTED_RELEASE defaults to NORMALIZED_RELEASE_ID (plant value) - from_env falls back to constant on empty env; backend_guard uses env-or-constant - start.sh sources env/active_library.env after prod.env (qa + legacy-rag) - build_all.sh --library <name|dir> renders library identity (no name whitelist) - add kb-libraries skeleton (plant/aqua identity cards) and tooling/aqua_kb Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,10 @@ bioflow_service/bioflow.log
|
||||
|
||||
# QA 数据库裸进程离线二进制载体(构建机从镜像/官网提取,不入 git)
|
||||
deploy/qa-db/bin/
|
||||
|
||||
# 水产库大文件目录(source 26G zip 解压产物、build 清单、registry 建库生成、data 运行数据)
|
||||
kb-libraries/aqua/source/
|
||||
kb-libraries/aqua/build/
|
||||
kb-libraries/aqua/registry/*
|
||||
!kb-libraries/aqua/registry/README.md
|
||||
kb-libraries/aqua/data/
|
||||
|
||||
@@ -33,6 +33,10 @@ set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
if [[ -f "$AGENT_ROOT/env/active_library.env" ]]; then
|
||||
set -a; . "$AGENT_ROOT/env/active_library.env"; set +a
|
||||
fi
|
||||
|
||||
cd "$AGENT_ROOT"
|
||||
setsid "$PYTHON" -m uvicorn "$APP_MOD" \
|
||||
--host "$BIND" --port "$PORT" \
|
||||
|
||||
@@ -5,7 +5,8 @@ from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
EXPECTED_RELEASE = "plant_srt_pc2025_v3_20260817_normalized"
|
||||
# 库名锁已 env 化:镜像文件 agent/legacy-rag/src/rag/normalized_release.py 必须逐字同步(md5 校验)。
|
||||
EXPECTED_RELEASE = os.environ.get("NORMALIZED_RELEASE_ID") or "plant_srt_pc2025_v3_20260817_normalized"
|
||||
MAX_SEEDS = 32
|
||||
MAX_RESULTS = 200
|
||||
|
||||
@@ -37,12 +38,8 @@ class NormalizedReleaseSettings:
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "NormalizedReleaseSettings":
|
||||
release_id = os.environ.get("NORMALIZED_RELEASE_ID", "").strip()
|
||||
collection = os.environ.get("V15_QDRANT_COLLECTION", "").strip()
|
||||
if release_id != EXPECTED_RELEASE:
|
||||
raise RuntimeError(f"release mismatch: {release_id!r}")
|
||||
if collection != EXPECTED_RELEASE:
|
||||
raise RuntimeError(f"collection mismatch: {collection!r}")
|
||||
release_id = os.environ.get("NORMALIZED_RELEASE_ID", "").strip() or EXPECTED_RELEASE
|
||||
collection = os.environ.get("V15_QDRANT_COLLECTION", "").strip() or EXPECTED_RELEASE
|
||||
return cls(release_id=release_id, qdrant_collection=collection)
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
if [[ -f "$AGENT_ROOT/env/active_library.env" ]]; then
|
||||
set -a; . "$AGENT_ROOT/env/active_library.env"; set +a
|
||||
fi
|
||||
|
||||
# reranker 模型路径回退:env 未指定时用安装包内模型(install/models/bge-reranker-base)
|
||||
if [[ -z "${QA_V1_BGE_RERANKER_MODEL:-}" && -d "$AGENT_ROOT/../models/bge-reranker-base" ]]; then
|
||||
export QA_V1_BGE_RERANKER_MODEL="$AGENT_ROOT/../models/bge-reranker-base"
|
||||
|
||||
@@ -5,7 +5,8 @@ from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
EXPECTED_RELEASE = "plant_srt_pc2025_v3_20260817_normalized"
|
||||
# 库名锁已 env 化:镜像文件 agent/legacy-rag/src/rag/normalized_release.py 必须逐字同步(md5 校验)。
|
||||
EXPECTED_RELEASE = os.environ.get("NORMALIZED_RELEASE_ID") or "plant_srt_pc2025_v3_20260817_normalized"
|
||||
MAX_SEEDS = 32
|
||||
MAX_RESULTS = 200
|
||||
|
||||
@@ -37,12 +38,8 @@ class NormalizedReleaseSettings:
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "NormalizedReleaseSettings":
|
||||
release_id = os.environ.get("NORMALIZED_RELEASE_ID", "").strip()
|
||||
collection = os.environ.get("V15_QDRANT_COLLECTION", "").strip()
|
||||
if release_id != EXPECTED_RELEASE:
|
||||
raise RuntimeError(f"release mismatch: {release_id!r}")
|
||||
if collection != EXPECTED_RELEASE:
|
||||
raise RuntimeError(f"collection mismatch: {collection!r}")
|
||||
release_id = os.environ.get("NORMALIZED_RELEASE_ID", "").strip() or EXPECTED_RELEASE
|
||||
collection = os.environ.get("V15_QDRANT_COLLECTION", "").strip() or EXPECTED_RELEASE
|
||||
return cls(release_id=release_id, qdrant_collection=collection)
|
||||
|
||||
|
||||
|
||||
@@ -71,18 +71,8 @@ def _qdrant_sample(url: str, collection: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _validate_normalized_backends() -> Dict[str, Any]:
|
||||
release_id = os.environ.get("NORMALIZED_RELEASE_ID", "").strip()
|
||||
if release_id != NORMALIZED_RELEASE:
|
||||
raise BackendIdentityError(
|
||||
f"normalized release mismatch: expected {NORMALIZED_RELEASE!r}, "
|
||||
f"got {release_id!r}"
|
||||
)
|
||||
collection = os.environ.get("V15_QDRANT_COLLECTION", "").strip()
|
||||
if collection != NORMALIZED_RELEASE:
|
||||
raise BackendIdentityError(
|
||||
f"normalized collection mismatch: expected {NORMALIZED_RELEASE!r}, "
|
||||
f"got {collection!r}"
|
||||
)
|
||||
release_id = os.environ.get("NORMALIZED_RELEASE_ID", "").strip() or NORMALIZED_RELEASE
|
||||
collection = os.environ.get("V15_QDRANT_COLLECTION", "").strip() or NORMALIZED_RELEASE
|
||||
qdrant_url = os.environ.get(
|
||||
"V15_QDRANT_URL", os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
|
||||
).rstrip("/")
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
Arabidopsis thaliana:
|
||||
type: species
|
||||
aliases:
|
||||
- Arabidopsis thaliana
|
||||
- A. thaliana
|
||||
- Arabidopsis
|
||||
- 拟南芥
|
||||
forbidden_expansions:
|
||||
- Brassicaceae
|
||||
- cruciferous plants
|
||||
- 十字花科
|
||||
|
||||
Oryza sativa:
|
||||
type: species
|
||||
aliases:
|
||||
- Oryza sativa
|
||||
- O. sativa
|
||||
- rice
|
||||
- 水稻
|
||||
|
||||
Glycine max:
|
||||
type: species
|
||||
aliases:
|
||||
- Glycine max
|
||||
- G. max
|
||||
- soybean
|
||||
- soybean plant
|
||||
- 大豆
|
||||
|
||||
Zea mays:
|
||||
type: species
|
||||
aliases:
|
||||
- Zea mays
|
||||
- Z. mays
|
||||
- maize
|
||||
- corn
|
||||
- 玉米
|
||||
|
||||
Litchi chinensis:
|
||||
type: species
|
||||
aliases:
|
||||
- Litchi chinensis
|
||||
- L. chinensis
|
||||
- litchi
|
||||
- lychee
|
||||
- 荔枝
|
||||
|
||||
Triticum aestivum:
|
||||
type: species
|
||||
retrieval_label: wheat
|
||||
aliases:
|
||||
- Triticum aestivum
|
||||
- T. aestivum
|
||||
- wheat
|
||||
- bread wheat
|
||||
- common wheat
|
||||
- 小麦
|
||||
|
||||
Solanum lycopersicum:
|
||||
type: species
|
||||
aliases:
|
||||
- Solanum lycopersicum
|
||||
- S. lycopersicum
|
||||
- tomato
|
||||
- 番茄
|
||||
|
||||
Solanum melongena:
|
||||
type: species
|
||||
aliases:
|
||||
- Solanum melongena
|
||||
- S. melongena
|
||||
- eggplant
|
||||
- brinjal
|
||||
- 茄子
|
||||
|
||||
Solanum tuberosum:
|
||||
type: species
|
||||
aliases:
|
||||
- Solanum tuberosum
|
||||
- S. tuberosum
|
||||
- potato
|
||||
- 马铃薯
|
||||
- 土豆
|
||||
|
||||
Ipomoea batatas:
|
||||
type: species
|
||||
aliases:
|
||||
- Ipomoea batatas
|
||||
- I. batatas
|
||||
- sweet potato
|
||||
- sweetpotato
|
||||
- 甘薯
|
||||
- 红薯
|
||||
forbidden_expansions:
|
||||
- Solanum tuberosum
|
||||
- S. tuberosum
|
||||
- potato
|
||||
- 马铃薯
|
||||
- 土豆
|
||||
|
||||
GmP450 genes:
|
||||
type: gene_family
|
||||
aliases:
|
||||
- GmP450 genes
|
||||
- GmP450 gene family
|
||||
- GmP450
|
||||
- soybean P450 genes
|
||||
- soybean cytochrome P450 genes
|
||||
|
||||
SmCYP450 genes:
|
||||
type: gene_family
|
||||
aliases:
|
||||
- SmCYP450 genes
|
||||
- SmCYP450 gene family
|
||||
- SmCYP450
|
||||
- eggplant CYP450 genes
|
||||
- eggplant cytochrome P450 genes
|
||||
|
||||
SPL gene family:
|
||||
type: gene_family
|
||||
retrieval_label: SPL genes
|
||||
aliases:
|
||||
- SPL gene family
|
||||
- SPL 基因家族
|
||||
- SPL genes
|
||||
- SPLs
|
||||
- SPL
|
||||
- SBP genes
|
||||
- SBP-box genes
|
||||
- Squamosa promoter-binding protein-like genes
|
||||
- SQUAMOSA promoter binding protein-like genes
|
||||
|
||||
flowering:
|
||||
type: biological_process
|
||||
retrieval_terms:
|
||||
- ovary
|
||||
- anthesis
|
||||
- expression
|
||||
aliases:
|
||||
- flowering
|
||||
- flowering time
|
||||
- floral transition
|
||||
- 开花
|
||||
|
||||
GST genes:
|
||||
type: gene_family
|
||||
aliases:
|
||||
- GST genes
|
||||
- GST gene family
|
||||
- GSTs
|
||||
- GST
|
||||
- glutathione S-transferase genes
|
||||
- glutathione S-transferase gene family
|
||||
- glutathione S-transferases
|
||||
- glutathione S-transferase
|
||||
- 谷胱甘肽S-转移酶
|
||||
- 谷胱甘肽 S-转移酶
|
||||
- 谷胱甘肽S-转移酶基因
|
||||
- 谷胱甘肽S-转移酶基因家族
|
||||
@@ -0,0 +1,200 @@
|
||||
expression:
|
||||
location:
|
||||
- expressed_in
|
||||
- highly_expressed_in
|
||||
- predominantly_expressed_in
|
||||
- accumulated_at_higher_level_in
|
||||
- detected_in
|
||||
- enriched_in
|
||||
up:
|
||||
- upregulated_in
|
||||
- induced_under
|
||||
- induced_in
|
||||
- increased_in
|
||||
- increased_under
|
||||
- enhanced_expression_in
|
||||
- accumulated_at_higher_level_in
|
||||
down:
|
||||
- downregulated_in
|
||||
- decreased_in
|
||||
- decreased_under
|
||||
- reduced_in
|
||||
- repressed_in
|
||||
condition:
|
||||
- differentially_expressed_under
|
||||
- expression_changed_under
|
||||
- expression_changed_in
|
||||
- responds_to
|
||||
- responsive_to
|
||||
- not_induced_under
|
||||
|
||||
catalog_count:
|
||||
- has_member_count
|
||||
- identified_in
|
||||
- systematically_identified_in
|
||||
- genome_wide_identified_in
|
||||
- were_identified_in
|
||||
- was_identified_in
|
||||
- identified
|
||||
- were identified in
|
||||
- was identified in
|
||||
- identified in
|
||||
- contains
|
||||
- contain
|
||||
- encodes
|
||||
- include
|
||||
- includes
|
||||
- had
|
||||
- has_number_of_members
|
||||
- comprehensively_analyzed_in
|
||||
- analyzed_in
|
||||
- surveyed_in
|
||||
|
||||
classification:
|
||||
- belongs_to
|
||||
- member_of
|
||||
- classified_into
|
||||
- divided_into
|
||||
- separated_into
|
||||
- grouped_into
|
||||
- clustered_with
|
||||
|
||||
localization:
|
||||
- localizes_to
|
||||
- located_in
|
||||
- predicted_to_localize_to
|
||||
- subcellular_localized_to
|
||||
|
||||
structure:
|
||||
- contains_domain
|
||||
- contains_motif
|
||||
- contains_cis_element
|
||||
- has_intron_count
|
||||
- has_exon_count
|
||||
- has_conserved_domain
|
||||
- lacks_binding_site_for
|
||||
|
||||
function_effect:
|
||||
- promotes
|
||||
- inhibits
|
||||
- enhances
|
||||
- reduces
|
||||
- affects
|
||||
- regulates
|
||||
- involved_in
|
||||
- participates_in
|
||||
- contributes_to
|
||||
- associated_with
|
||||
|
||||
perturbation_effect:
|
||||
- overexpression_increases
|
||||
- overexpression_enhances
|
||||
- overexpression_promotes
|
||||
- overexpression_causes
|
||||
- knockdown_reduces
|
||||
- knockdown_enhances
|
||||
- silencing_reduces
|
||||
- mutation_affects
|
||||
|
||||
method:
|
||||
- used_tool
|
||||
- analyzed_by
|
||||
- predicted_by
|
||||
- aligned_with
|
||||
- constructed_with
|
||||
- identified_by
|
||||
|
||||
dataset:
|
||||
- has_accession
|
||||
- available_at
|
||||
- has_data_type
|
||||
- from_database
|
||||
- from_article
|
||||
- used_for
|
||||
|
||||
prior_work:
|
||||
- reported_in
|
||||
- previously_reported_in
|
||||
- cited_as
|
||||
- known_to
|
||||
|
||||
stress_response:
|
||||
- responds_to
|
||||
- responsive_to
|
||||
- induced_by
|
||||
- induced_under
|
||||
- induced_in
|
||||
- upregulated_under
|
||||
- upregulated_in
|
||||
- involved_in_stress_response
|
||||
- associated_with_stress_tolerance
|
||||
- confers_tolerance_to
|
||||
- enhances_tolerance_to
|
||||
- improves_tolerance_to
|
||||
- stress tolerance
|
||||
- abiotic stress
|
||||
- biotic stress
|
||||
|
||||
detoxification:
|
||||
- involved_in_detoxification
|
||||
- detoxifies
|
||||
- detoxification
|
||||
- conjugates_with_glutathione
|
||||
- catalyzes_glutathione_conjugation
|
||||
- scavenges
|
||||
- removes
|
||||
- clears
|
||||
- reduces_ros
|
||||
- maintains_redox_homeostasis
|
||||
- glutathione conjugation
|
||||
- xenobiotic detoxification
|
||||
|
||||
oxidative_stress:
|
||||
- scavenges_ros
|
||||
- scavenges_h2o2
|
||||
- reduces_h2o2
|
||||
- regulates_redox_homeostasis
|
||||
- enhances_oxidative_stress_tolerance
|
||||
- involved_in_antioxidant_response
|
||||
- reactive oxygen species
|
||||
- oxidative stress
|
||||
- redox homeostasis
|
||||
|
||||
functional_validation:
|
||||
- overexpression_enhances
|
||||
- overexpression_increases
|
||||
- overexpression_confers
|
||||
- overexpression_improves
|
||||
- overexpression_promotes
|
||||
- over-expression
|
||||
- overexpression
|
||||
- co_expression_enhances
|
||||
- co-expression
|
||||
- transgenic
|
||||
- mutation_affects
|
||||
- knockdown_reduces
|
||||
- silencing_reduces
|
||||
|
||||
family_expansion:
|
||||
- expanded_by
|
||||
- tandemly_duplicated
|
||||
- segmentally_duplicated
|
||||
- retained_after_wgd
|
||||
- underwent_positive_selection
|
||||
- functionally_diverged
|
||||
- supergene family
|
||||
- gene family expansion
|
||||
- tandem duplication
|
||||
- segmental duplication
|
||||
- positive selection
|
||||
|
||||
signaling_regulation:
|
||||
- upregulated_by
|
||||
- activated_by
|
||||
- regulated_by
|
||||
- downstream_of
|
||||
- target_of
|
||||
- ABA signaling
|
||||
- signal transduction
|
||||
- kinase
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
name: aqua
|
||||
release_id: aqua_srt_v1_20260930_normalized
|
||||
neo4j_database: aqua-normalized
|
||||
qdrant_collection: aqua_srt_v1_20260930_normalized
|
||||
alias_config: configs/rag/entity_alias.yaml
|
||||
taxonomy_config: configs/rag/relation_taxonomy.yaml
|
||||
registry_path: ""
|
||||
@@ -0,0 +1 @@
|
||||
留空——aqua 库 registry 由建库阶段生成后放入此目录;此目录仅为 kb-libraries 结构占位。
|
||||
@@ -0,0 +1 @@
|
||||
留空——plant 库沿用编译源 agent/qa/configs/rag/ 与部署包内置配置;此目录仅为 kb-libraries 结构占位。
|
||||
@@ -0,0 +1,7 @@
|
||||
name: plant
|
||||
release_id: plant_srt_pc2025_v3_20260817_normalized
|
||||
neo4j_database: neo4j
|
||||
qdrant_collection: plant_srt_pc2025_v3_20260817_normalized
|
||||
alias_config: ""
|
||||
taxonomy_config: ""
|
||||
registry_path: ""
|
||||
@@ -0,0 +1 @@
|
||||
留空——plant 库沿用编译源 agent/qa/configs/rag/ 与部署包内置配置;此目录仅为 kb-libraries 结构占位。
|
||||
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pair_aqua_sources.py — 水产库 source 目录 MD 卡与 PDF 文件名 DOI 配对联表工具。
|
||||
|
||||
输入(--source <aqua/source>):
|
||||
题目摘要_MD/*.md MD 卡(DOI 两种形态: https://doi.org/... 与 DOI:...)
|
||||
PDF正文/*.pdf 文件名格式: 年份_标题_doi(下划线化)_8位hex.pdf
|
||||
DOI汇总.txt (qc_sample 检材,本工具不读)
|
||||
|
||||
产出(--out <build/manifest.csv>):
|
||||
manifest.csv doi,doi_norm,md_path,pdf_path,status,title,year,md_size,pdf_size,pdf_sha256
|
||||
按 doi_norm 排序;status ∈ paired|md_only|pdf_only;
|
||||
pdf_sha256 仅 pdf 行有值(ThreadPoolExecutor(8) 并行计算)。
|
||||
census.json {md_count,pdf_count,paired,md_only,pdf_only,undecodable,
|
||||
doi_conflicts,generated_at,source_dir}(与 manifest 同目录)
|
||||
|
||||
只依赖标准库。写盘先写 *.tmp 再 os.replace,重跑幂等。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
MD_URL_RE = re.compile(r"https?://doi\.org/([^\s\)\])]+)")
|
||||
MD_DOI_RE = re.compile(r"DOI:([^\s\]((]+)")
|
||||
MD_TITLE_RE = re.compile(r"^#\s+(.+)$")
|
||||
MD_YEAR_RE = re.compile(r"年份[::]\s*(\d{4})")
|
||||
DIGITS_RE = re.compile(r"^\d{4,9}$")
|
||||
HEX8_RE = re.compile(r"^[0-9a-fA-F]{8}$")
|
||||
|
||||
CSV_HEADER = [
|
||||
"doi", "doi_norm", "md_path", "pdf_path", "status",
|
||||
"title", "year", "md_size", "pdf_size", "pdf_sha256",
|
||||
]
|
||||
|
||||
|
||||
def norm_doi(d: str) -> str:
|
||||
"""归一化:去首尾空白、小写、去尾部标点;内部 _ 不换 /(MD 卡里已是斜杠形态)。"""
|
||||
return d.strip().lower().rstrip(".,;)]")
|
||||
|
||||
|
||||
def extract_md_doi(text: str) -> str:
|
||||
"""两种 DOI 形态均尝试,取首个非空。"""
|
||||
m = MD_URL_RE.search(text)
|
||||
if not m:
|
||||
m = MD_DOI_RE.search(text)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def md_metadata(text: str) -> tuple[str, str]:
|
||||
title = ""
|
||||
for line in text.splitlines():
|
||||
m = MD_TITLE_RE.match(line)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
break
|
||||
m = MD_YEAR_RE.search(text)
|
||||
return title, m.group(1) if m else ""
|
||||
|
||||
|
||||
def decode_pdf_doi(filename: str, md_norms) -> tuple[str, str]:
|
||||
"""从 PDF 文件名解码 DOI,返回 (doi_norm, doi_norm) 或 ("", "")。
|
||||
|
||||
算法:找最后一个 10. 出现位置;此后按下划线切段;首段必须为 4-9 位数字
|
||||
(即「10 + 4-9 位数字」);从第 2 段起,段开头为字母数字的并入 DOI;
|
||||
末段恰好为 8 位十六进制(其后只剩 .pdf)则停止并入。歧义消解:对每处
|
||||
合法的 10. 位置生成「前缀固定、尾部可延伸」的候选集合,优先取下划线
|
||||
换成斜杠后能命中 md_norms 的最长解释;仍不中的取最短解释。
|
||||
"""
|
||||
stem = filename[:-4] if filename.lower().endswith(".pdf") else filename
|
||||
candidates: list[str] = []
|
||||
for pos in reversed([m.start() for m in re.finditer(r"10\.", stem)]):
|
||||
segs = stem[pos + 3:].split("_")
|
||||
if not segs or not DIGITS_RE.match(segs[0]):
|
||||
continue # 首段不满足「10 + 4-9 位数字」,该 10. 位置无效
|
||||
parts = [segs[0]]
|
||||
for i in range(1, len(segs)):
|
||||
seg = segs[i]
|
||||
if i == len(segs) - 1 and HEX8_RE.match(seg):
|
||||
break # 8 位十六进制哈希(其后只剩 .pdf),停止并入
|
||||
if seg and seg[0].isalnum():
|
||||
parts.append(seg)
|
||||
else:
|
||||
break
|
||||
for k in range(1, len(parts) + 1):
|
||||
candidates.append(norm_doi("10." + "/".join(parts[:k])))
|
||||
if not candidates:
|
||||
return "", ""
|
||||
hits = [c for c in candidates if c in md_norms]
|
||||
chosen = max(hits, key=len) if hits else min(candidates, key=len)
|
||||
return chosen, chosen
|
||||
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def pdf_title_year(stem: str) -> tuple[str, str]:
|
||||
"""从文件名提取年份(首段 4 位数字)与标题(年份后、DOI 起始前)。"""
|
||||
parts = stem.split("_")
|
||||
year = parts[0] if len(parts) > 1 and re.fullmatch(r"\d{4}", parts[0]) else ""
|
||||
title = ""
|
||||
idx = stem.rfind("10.")
|
||||
if idx > 0:
|
||||
start = len(parts[0]) + 1 if year else 0
|
||||
title = stem[start:idx].strip("_").replace("_", " ").strip()
|
||||
return title, year
|
||||
|
||||
|
||||
def token_overlap(md_title: str, pdf_stem: str) -> int:
|
||||
mt = set(re.findall(r"[a-z0-9]+", md_title.lower()))
|
||||
pt = set(re.findall(r"[a-z0-9]+", pdf_stem.lower()))
|
||||
return len(mt & pt)
|
||||
|
||||
|
||||
def md_only_row(m: dict) -> dict:
|
||||
return {
|
||||
"doi": m["doi"], "doi_norm": m["doi_norm"], "md_path": m["md_path"],
|
||||
"pdf_path": "", "status": "md_only", "title": m["title"],
|
||||
"year": m["year"], "md_size": m["size"], "pdf_size": "",
|
||||
"pdf_sha256": "",
|
||||
}
|
||||
|
||||
|
||||
def pdf_only_row(p: dict) -> dict:
|
||||
return {
|
||||
"doi": p["doi"], "doi_norm": p["doi_norm"], "md_path": "",
|
||||
"pdf_path": p["pdf_path"], "status": "pdf_only", "title": p["title"],
|
||||
"year": p["year"], "md_size": "", "pdf_size": p["size"],
|
||||
"pdf_sha256": p["sha256"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="水产库 MD 卡与 PDF 文件名 DOI 配对")
|
||||
ap.add_argument("--source", required=True, help="aqua/source 目录")
|
||||
ap.add_argument("--out", required=True, help="manifest.csv 输出路径(如 build/manifest.csv)")
|
||||
args = ap.parse_args()
|
||||
|
||||
src = Path(args.source)
|
||||
md_dir = src / "题目摘要_MD"
|
||||
pdf_dir = src / "PDF正文"
|
||||
if not md_dir.is_dir() or not pdf_dir.is_dir():
|
||||
print(f"source 目录缺 题目摘要_MD 或 PDF正文: {src}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
out_csv = Path(args.out)
|
||||
out_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ---- MD 卡 ----
|
||||
md_list = []
|
||||
for f in sorted(md_dir.glob("*.md")):
|
||||
text = f.read_text(encoding="utf-8", errors="replace")
|
||||
doi = extract_md_doi(text)
|
||||
title, year = md_metadata(text)
|
||||
md_list.append({
|
||||
"doi": doi,
|
||||
"doi_norm": norm_doi(doi) if doi else "",
|
||||
"md_path": str(f.relative_to(src)),
|
||||
"title": title,
|
||||
"year": year,
|
||||
"size": f.stat().st_size,
|
||||
})
|
||||
md_norms = {m["doi_norm"] for m in md_list if m["doi_norm"]}
|
||||
|
||||
# ---- PDF(sha256 并行) ----
|
||||
pdf_paths = sorted(pdf_dir.glob("*.pdf"))
|
||||
pdf_sha: dict[str, str] = {}
|
||||
with ThreadPoolExecutor(max_workers=8) as ex:
|
||||
for rel, digest in ex.map(
|
||||
lambda f: (str(f.relative_to(src)), sha256_of(f)), pdf_paths
|
||||
):
|
||||
pdf_sha[rel] = digest
|
||||
|
||||
pdf_list = []
|
||||
for f in pdf_paths:
|
||||
rel = str(f.relative_to(src))
|
||||
doi, doi_norm = decode_pdf_doi(f.name, md_norms)
|
||||
title, year = pdf_title_year(f.stem)
|
||||
pdf_list.append({
|
||||
"doi": doi,
|
||||
"doi_norm": doi_norm,
|
||||
"pdf_path": rel,
|
||||
"title": title,
|
||||
"year": year,
|
||||
"size": f.stat().st_size,
|
||||
"sha256": pdf_sha[rel],
|
||||
})
|
||||
|
||||
# ---- 配对 ----
|
||||
md_by_norm: dict[str, list] = defaultdict(list)
|
||||
for m in md_list:
|
||||
if m["doi_norm"]:
|
||||
md_by_norm[m["doi_norm"]].append(m)
|
||||
pdf_by_norm: dict[str, list] = defaultdict(list)
|
||||
for p in pdf_list:
|
||||
if p["doi_norm"]:
|
||||
pdf_by_norm[p["doi_norm"]].append(p)
|
||||
|
||||
rows: list[dict] = []
|
||||
paired = 0
|
||||
conflict_groups = 0
|
||||
for norm in sorted(set(md_by_norm) | set(pdf_by_norm)):
|
||||
g_md = md_by_norm.get(norm, [])
|
||||
g_pdf = pdf_by_norm.get(norm, [])
|
||||
if len(g_md) == 1 and len(g_pdf) == 1:
|
||||
m, p = g_md[0], g_pdf[0]
|
||||
rows.append({
|
||||
"doi": m["doi"], "doi_norm": norm, "md_path": m["md_path"],
|
||||
"pdf_path": p["pdf_path"], "status": "paired",
|
||||
"title": m["title"], "year": m["year"], "md_size": m["size"],
|
||||
"pdf_size": p["size"], "pdf_sha256": p["sha256"],
|
||||
})
|
||||
paired += 1
|
||||
elif g_md and g_pdf:
|
||||
conflict_groups += 1
|
||||
# DOI 一对多:按「文件名年份_标题前缀相似」择一配对,其余记独立行
|
||||
paired_md: set[str] = set()
|
||||
remaining_pdf = list(g_pdf)
|
||||
for m in g_md:
|
||||
if not remaining_pdf:
|
||||
break
|
||||
scored = [
|
||||
(token_overlap(m["title"], Path(p["pdf_path"]).stem), p)
|
||||
for p in remaining_pdf
|
||||
]
|
||||
best_score, best_p = max(scored, key=lambda t: t[0])
|
||||
if best_score <= 0:
|
||||
break
|
||||
remaining_pdf.remove(best_p)
|
||||
paired_md.add(m["md_path"])
|
||||
rows.append({
|
||||
"doi": m["doi"], "doi_norm": norm, "md_path": m["md_path"],
|
||||
"pdf_path": best_p["pdf_path"], "status": "paired",
|
||||
"title": m["title"], "year": m["year"], "md_size": m["size"],
|
||||
"pdf_size": best_p["size"], "pdf_sha256": best_p["sha256"],
|
||||
})
|
||||
paired += 1
|
||||
for m in g_md:
|
||||
if m["md_path"] not in paired_md:
|
||||
rows.append(md_only_row(m))
|
||||
for p in remaining_pdf:
|
||||
rows.append(pdf_only_row(p))
|
||||
else:
|
||||
for m in g_md:
|
||||
rows.append(md_only_row(m))
|
||||
for p in g_pdf:
|
||||
rows.append(pdf_only_row(p))
|
||||
|
||||
# 无 DOI 的 MD 卡 / 不可解码的 PDF(无法定位 10.x)
|
||||
for m in md_list:
|
||||
if not m["doi_norm"]:
|
||||
rows.append(md_only_row(m))
|
||||
for p in pdf_list:
|
||||
if not p["doi_norm"]:
|
||||
rows.append(pdf_only_row(p))
|
||||
|
||||
md_only = sum(1 for r in rows if r["status"] == "md_only")
|
||||
pdf_only = sum(1 for r in rows if r["status"] == "pdf_only")
|
||||
undecodable = sum(1 for p in pdf_list if not p["doi_norm"])
|
||||
# 三类合计 == md_count + pdf_count - paired(paired 行内 md+pdf 各计 1)
|
||||
assert paired + md_only + pdf_only == len(md_list) + len(pdf_list) - paired, \
|
||||
f"census 算术自检失败: paired={paired} md_only={md_only} pdf_only={pdf_only}"
|
||||
census = {
|
||||
"md_count": len(md_list),
|
||||
"pdf_count": len(pdf_list),
|
||||
"paired": paired,
|
||||
"md_only": md_only,
|
||||
"pdf_only": pdf_only,
|
||||
"undecodable": undecodable,
|
||||
"doi_conflicts": conflict_groups,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"source_dir": str(src),
|
||||
}
|
||||
|
||||
rows.sort(key=lambda r: (r["doi_norm"], r["md_path"] or r["pdf_path"]))
|
||||
tmp_csv = out_csv.with_name(out_csv.name + ".tmp")
|
||||
with tmp_csv.open("w", encoding="utf-8", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=CSV_HEADER)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
tmp_csv.replace(out_csv)
|
||||
|
||||
census_path = out_csv.with_name("census.json")
|
||||
tmp_census = census_path.with_name("census.json.tmp")
|
||||
with tmp_census.open("w", encoding="utf-8") as fh:
|
||||
json.dump(census, fh, ensure_ascii=False, indent=2)
|
||||
tmp_census.replace(census_path)
|
||||
|
||||
print(f"md={len(md_list)} pdf={len(pdf_list)} "
|
||||
f"paired={paired} md_only={md_only} pdf_only={pdf_only} "
|
||||
f"undecodable={undecodable} doi_conflicts={conflict_groups}")
|
||||
print(f"manifest: {out_csv}")
|
||||
print(f"census: {census_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""qc_sample.py — 抽取 manifest.csv 三种状态样本并做可核验的规则检查。
|
||||
|
||||
用法: qc_sample.py [--manifest build/manifest.csv] [--source aqua/source]
|
||||
[--n 20] [--seed 42]
|
||||
|
||||
规则(任一不通过 exit 1):
|
||||
paired: md_path、pdf_path 都存在;md 文本 grep 到 doi_norm(斜杠或下划线形态
|
||||
任一命中);PDF 文件名归一化后等于该 doi_norm。
|
||||
md_only: pdf_path 为空;PDF正文 目录下无 *<doi_norm 下划线形态>* 与
|
||||
*<斜杠换成下划线>* 文件;DOI汇总.txt 中 grep 到该 DOI(两种形态)。
|
||||
pdf_only: md_path 为空;题目摘要_MD 目录下 grep 不到该 DOI(两种形态)。
|
||||
|
||||
只依赖标准库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def norm_doi(d: str) -> str:
|
||||
"""DOI 归一化(与 pair_aqua_sources 同规则)。"""
|
||||
return d.strip().lower().rstrip(".,;)]")
|
||||
|
||||
|
||||
def slug_form(doi_norm: str) -> str:
|
||||
"""斜杠换下划线 + 点换下划线:文件名 DOI 段的下划线形态。"""
|
||||
return doi_norm.replace("/", "_").replace(".", "_")
|
||||
|
||||
|
||||
def grep_pdf_norm(filename: str, doi_norm: str) -> bool:
|
||||
"""PDF 文件名归一化后是否等于 doi_norm(复用 pair 的解码算法,取等值判定)。"""
|
||||
stem = filename[:-4] if filename.lower().endswith(".pdf") else filename
|
||||
cands: list[str] = []
|
||||
for pos in reversed([m.start() for m in re.finditer(r"10\.", stem)]):
|
||||
segs = stem[pos + 3:].split("_")
|
||||
if not segs or not re.fullmatch(r"\d{4,9}", segs[0]):
|
||||
continue
|
||||
parts = [segs[0]]
|
||||
for i in range(1, len(segs)):
|
||||
seg = segs[i]
|
||||
if i == len(segs) - 1 and re.fullmatch(r"[0-9a-fA-F]{8}", seg):
|
||||
break
|
||||
if seg and seg[0].isalnum():
|
||||
parts.append(seg)
|
||||
else:
|
||||
break
|
||||
for k in range(1, len(parts) + 1):
|
||||
cands.append(norm_doi("10." + "/".join(parts[:k])))
|
||||
return doi_norm in cands
|
||||
|
||||
|
||||
def grep_file_text(f: Path, needle_slash: str, needle_under: str) -> bool:
|
||||
if not f.is_file():
|
||||
return False
|
||||
text = f.read_text(encoding="utf-8", errors="replace")
|
||||
return needle_slash in text or needle_under in text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="manifest 状态样本质量抽检")
|
||||
ap.add_argument("--manifest", default="build/manifest.csv")
|
||||
ap.add_argument("--source", default="aqua/source")
|
||||
ap.add_argument("--n", type=int, default=20)
|
||||
ap.add_argument("--seed", type=int, default=42)
|
||||
args = ap.parse_args()
|
||||
|
||||
manifest = Path(args.manifest)
|
||||
src = Path(args.source)
|
||||
md_dir = src / "题目摘要_MD"
|
||||
pdf_dir = src / "PDF正文"
|
||||
doi_txt = src / "DOI汇总.txt"
|
||||
|
||||
rows = list(csv.DictReader(manifest.open(encoding="utf-8", newline="")))
|
||||
groups: dict[str, list[dict]] = {"paired": [], "md_only": [], "pdf_only": []}
|
||||
for r in rows:
|
||||
if r["status"] in groups:
|
||||
groups[r["status"]].append(r)
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
samples = {st: rng.sample(groups[st], min(args.n, len(groups[st]))) for st in groups}
|
||||
|
||||
def check_md_text(doi_norm: str, md_path: str) -> bool:
|
||||
text = (src / md_path).read_text(encoding="utf-8", errors="replace")
|
||||
return doi_norm in text or slug_form(doi_norm) in text
|
||||
|
||||
def check_md_dir_grep(doi_norm: str) -> bool:
|
||||
n_slash = doi_norm
|
||||
n_under = slug_form(doi_norm)
|
||||
for f in md_dir.glob("*.md"):
|
||||
t = f.read_text(encoding="utf-8", errors="replace")
|
||||
if n_slash in t or n_under in t:
|
||||
return True
|
||||
return False
|
||||
|
||||
fails = 0
|
||||
checked: dict[str, int] = {}
|
||||
|
||||
print("== qc_sample ==")
|
||||
print(f"manifest={manifest} rows={len(rows)} "
|
||||
f"(paired={len(groups['paired'])} md_only={len(groups['md_only'])} "
|
||||
f"pdf_only={len(groups['pdf_only'])}) seed={args.seed} n={args.n}\n")
|
||||
|
||||
for st in ("paired", "md_only", "pdf_only"):
|
||||
print(f"--- {st} (抽 {len(samples[st])} 条) ---")
|
||||
for r in samples[st]:
|
||||
dn = r["doi_norm"]; md = r["md_path"]; pdf = r["pdf_path"]
|
||||
ok = True
|
||||
if st == "paired":
|
||||
if not (md and pdf and (src / md).is_file() and (src / pdf).is_file()):
|
||||
ok = False
|
||||
elif not check_md_text(dn, md):
|
||||
ok = False
|
||||
elif not grep_pdf_norm(Path(pdf).name, dn):
|
||||
ok = False
|
||||
elif st == "md_only":
|
||||
if pdf:
|
||||
ok = False
|
||||
else:
|
||||
has_glob = any(
|
||||
pdf_dir.glob("*" + slug_form(dn) + "*")
|
||||
) or any(
|
||||
pdf_dir.glob("*" + dn.replace("/", "_") + "*")
|
||||
)
|
||||
if has_glob:
|
||||
ok = False
|
||||
elif not grep_file_text(doi_txt, dn, slug_form(dn)):
|
||||
ok = False
|
||||
else: # pdf_only
|
||||
if md:
|
||||
ok = False
|
||||
elif check_md_dir_grep(dn):
|
||||
ok = False
|
||||
if not ok:
|
||||
fails += 1
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] {st:8s} "
|
||||
f"doi={dn[:46]:46s} md={Path(md).name if md else '-':20s} "
|
||||
f"pdf={Path(pdf).name if pdf else '-'}")
|
||||
checked[st] = len(samples[st])
|
||||
|
||||
print("\n== 结果 ==")
|
||||
print(f"checked={sum(checked.values())} fails={fails}")
|
||||
for st in ("paired", "md_only", "pdf_only"):
|
||||
print(f" {st}: {checked[st]} 条")
|
||||
print("PASS" if fails == 0 else "FAIL")
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# 渲染 active_library.env:读 <库目录>/manifest.yaml,输出五触点 env。
|
||||
# 用法: render_active_library_env.sh <manifest.yaml> <输出文件>
|
||||
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $QA_ROOT/<值>;
|
||||
# 为空时回退 $QA_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
|
||||
set -euo pipefail
|
||||
MANIFEST="$1"; OUT="$2"
|
||||
release_id="$(awk -F': *' '/^release_id:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
|
||||
collection="$(awk -F': *' '/^qdrant_collection:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
|
||||
alias_cfg="$(awk -F': *' '/^alias_config:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
|
||||
tax_cfg="$(awk -F': *' '/^taxonomy_config:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
|
||||
registry="$(awk -F': *' '/^registry_path:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
|
||||
[ -n "$release_id" ] || { echo "manifest 缺 release_id" >&2; exit 1; }
|
||||
tmp="$OUT.tmp"
|
||||
{
|
||||
echo "NORMALIZED_RELEASE_ID=$release_id"
|
||||
echo "V15_QDRANT_COLLECTION=${collection:-$release_id}"
|
||||
[ -n "$alias_cfg" ] && echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/$alias_cfg" || echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/configs/rag/entity_alias.yaml"
|
||||
[ -n "$tax_cfg" ] && echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/$tax_cfg" || echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/configs/rag/relation_taxonomy.yaml"
|
||||
[ -n "$registry" ] && echo "QA_PAPER_METADATA_REGISTRY=\$QA_ROOT/$registry"
|
||||
} > "$tmp"
|
||||
mv "$tmp" "$OUT"
|
||||
@@ -8,6 +8,25 @@ umask 027
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
. "$SCRIPT_DIR/tooling.env"
|
||||
|
||||
# ---- 0. 库身份(kb-libraries/<lib>)——不枚举库名,任意含 manifest.yaml 的目录均可 ----
|
||||
RAG_LIBRARY="${RAG_LIBRARY:-plant}"
|
||||
rest_args=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--library=*) RAG_LIBRARY="${arg#--library=}" ;;
|
||||
--library) RAG_LIBRARY="__next__" ;;
|
||||
*) if [ "$RAG_LIBRARY" = "__next__" ]; then RAG_LIBRARY="$arg"; else rest_args+=("$arg"); fi ;;
|
||||
esac
|
||||
done
|
||||
[ "$RAG_LIBRARY" = "__next__" ] && { echo "build_all: --library 缺值" >&2; exit 2; }
|
||||
LIBRARY_DIR="$PROJECT_ROOT/kb-libraries/$RAG_LIBRARY"
|
||||
[ -d "$LIBRARY_DIR" ] || LIBRARY_DIR="$RAG_LIBRARY" # 允许传任意目录路径
|
||||
[ -f "$LIBRARY_DIR/manifest.yaml" ] || { echo "build_all: 库目录 $LIBRARY_DIR 缺 manifest.yaml" >&2; exit 2; }
|
||||
grep -qE '^release_id:' "$LIBRARY_DIR/manifest.yaml" || { echo "build_all: manifest.yaml 缺 release_id" >&2; exit 2; }
|
||||
echo "库身份: $RAG_LIBRARY ($LIBRARY_DIR)"
|
||||
set -- "${rest_args[@]}"
|
||||
|
||||
VERSION="${1:-$VERSION}"
|
||||
BUILD_ROOT="$SCRIPT_DIR/build/$VERSION"
|
||||
RELEASE_ROOT="$BUILD_ROOT/install"
|
||||
@@ -127,6 +146,14 @@ for t in qa methodology legacy-rag; do
|
||||
cp "$PROJECT_ROOT/agent/$t/env/prod.env.example" "$RELEASE_ROOT/$t/env/prod.env.example" 2>/dev/null || true
|
||||
done
|
||||
cp "$PROJECT_ROOT/sdk/.env.example" "$RELEASE_ROOT/sdk/.env.example" 2>/dev/null || true
|
||||
# ---- 库身份装包:manifest + configs + active_library.env ----
|
||||
mkdir -p "$RELEASE_ROOT/install/kb" "$RELEASE_ROOT/qa/env"
|
||||
cp "$LIBRARY_DIR/manifest.yaml" "$RELEASE_ROOT/install/kb/manifest.yaml"
|
||||
if [ -d "$LIBRARY_DIR/configs" ]; then
|
||||
cp "$LIBRARY_DIR"/configs/*.yaml "$RELEASE_ROOT/qa/configs/rag/" 2>/dev/null || true
|
||||
fi
|
||||
bash "$PROJECT_ROOT/tooling/aqua_kb/render_active_library_env.sh" \
|
||||
"$LIBRARY_DIR/manifest.yaml" "$RELEASE_ROOT/qa/env/active_library.env"
|
||||
# KB 包(定版后)
|
||||
[ -f "$BUILD_ROOT/knowledge-$VERSION.tar" ] && cp "$BUILD_ROOT/knowledge-$VERSION.tar"* "$RELEASE_ROOT/"
|
||||
# 部署脚本
|
||||
|
||||
Reference in New Issue
Block a user