File size: 8,900 Bytes
9b4263f 32bf563 9b4263f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | """语义评测用例加载(两条脚本共用)。
磁盘格式:
1) 完整用例(按 chunk)
必填: name, source, chunk_index, text, query
query: 非空数组;每项必填 query(str)、expect_relevant;相关例须显式有 expect_keywords,无关例可 []
同一 chunk 内 query 文案不得重复
每项可选: disputed, dispute_note;扩展如 lang, pair
2) 索引(字符串数组;放在 cases/subsets/,指向 cases/ 下完整用例)
每项必须是 "chunkName#query文案"(精确到一条 query;文案必须存在且唯一)
加载后展平为「一条 query × 一个 chunk」供评测脚本使用(此时 query 为 str)。
多 query 时展平 name 为 "chunkName#query文案"。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
_CHUNK_REQUIRED = ("name", "source", "chunk_index", "text", "query")
_QUERY_OPTIONAL = ("disputed", "dispute_note", "lang", "pair")
def _validate_query_entry(chunk_name: str, qe: Dict[str, Any], idx: int) -> None:
if not isinstance(qe, dict):
raise ValueError(f"用例 {chunk_name} query[{idx}] 须为对象")
if not isinstance(qe.get("query"), str) or not qe["query"]:
raise ValueError(f"用例 {chunk_name} query[{idx}] 缺少非空 query 字符串")
if qe.get("expect_relevant") is None:
raise ValueError(
f"用例 {chunk_name} query[{idx}] 的 expect_relevant 未填写(勿提交 skeleton)"
)
# 相关例须显式写 expect_keywords,避免漏标后静默成 []
if bool(qe.get("expect_relevant")) and "expect_keywords" not in qe:
raise ValueError(
f"相关用例 {chunk_name} query[{idx}] 须有 expect_keywords 数组"
)
if "expect_keywords" in qe and not isinstance(qe["expect_keywords"], list):
raise ValueError(f"用例 {chunk_name} query[{idx}] 的 expect_keywords 须为数组")
def _check_unique_query_texts(c: Dict[str, Any]) -> None:
texts = [qe["query"] for qe in c["query"]]
if len(texts) != len(set(texts)):
raise ValueError(f"用例 {c['name']} 的 query 文案有重复,无法用文案索引")
def _validate_chunk(c: Dict[str, Any]) -> None:
for k in _CHUNK_REQUIRED:
if k not in c:
raise ValueError(f"用例缺少 {k}: {c.get('name')}")
qlist = c["query"]
if not isinstance(qlist, list) or not qlist:
raise ValueError(f"用例 {c['name']} 的 query 须为非空数组")
for i, qe in enumerate(qlist):
_validate_query_entry(c["name"], qe, i)
_check_unique_query_texts(c)
def _flat_name(chunk_name: str, qtext: str, *, multi: bool) -> str:
return chunk_name if not multi else f"{chunk_name}#{qtext}"
def _flatten_query_entry(c: Dict[str, Any], qe: Dict[str, Any]) -> Dict[str, Any]:
multi = len(c["query"]) > 1
item: Dict[str, Any] = {
"name": _flat_name(c["name"], qe["query"], multi=multi),
"source": c["source"],
"chunk_index": c["chunk_index"],
"text": c["text"],
"query": qe["query"],
"expect_relevant": qe["expect_relevant"],
"expect_keywords": list(qe.get("expect_keywords") or []),
}
for k in _QUERY_OPTIONAL:
if k in qe:
item[k] = qe[k]
return item
def _flatten_chunk(c: Dict[str, Any]) -> List[Dict[str, Any]]:
return [_flatten_query_entry(c, qe) for qe in c["query"]]
def _parse_index_ref(ref: str) -> Tuple[str, str]:
if not isinstance(ref, str) or not ref.strip():
raise ValueError(f"索引项须为非空字符串,得到 {ref!r}")
if "#" not in ref:
raise ValueError(f"索引须为 chunkName#query文案,不能省略 query: {ref!r}")
name, qtext = ref.split("#", 1)
if not name:
raise ValueError(f"索引缺少 chunk name: {ref!r}")
if not qtext:
raise ValueError(f"索引 '#' 后 query 文案为空: {ref!r}")
return name, qtext
def _is_chunk_corpus(raw: Any) -> bool:
return (
isinstance(raw, list)
and bool(raw)
and all(isinstance(x, dict) for x in raw)
)
def _is_index(raw: Any) -> bool:
return (
isinstance(raw, list)
and bool(raw)
and all(isinstance(x, str) for x in raw)
)
def _load_corpus_by_name(cases_dir: Path) -> Dict[str, Dict[str, Any]]:
"""cases_dir 下所有完整用例文件 → name 唯一映射(不递归子目录)。"""
by_name: Dict[str, Dict[str, Any]] = {}
for p in sorted(cases_dir.glob("*.json")):
raw = json.loads(p.read_text(encoding="utf-8"))
if not _is_chunk_corpus(raw):
continue
for c in raw:
_validate_chunk(c)
n = c["name"]
if n in by_name:
raise ValueError(f"chunk name 重复: {n!r}(文件 {p.name})")
by_name[n] = c
return by_name
def _corpus_dir_for_index(index_path: Path) -> Path:
"""索引在 cases/subsets/ 时,完整用例在上一级 cases/。"""
parent = index_path.parent
if parent.name == "subsets":
return parent.parent
return parent
def _resolve_index(path: Path, refs: List[str]) -> List[Dict[str, Any]]:
corpus = _load_corpus_by_name(_corpus_dir_for_index(path))
if not corpus:
raise ValueError(f"索引 {path} 对应目录下没有完整用例可供解析")
flat: List[Dict[str, Any]] = []
seen: set[str] = set()
for ref in refs:
if ref in seen:
raise ValueError(f"索引重复: {ref!r}({path})")
seen.add(ref)
name, qtext = _parse_index_ref(ref)
chunk = corpus.get(name)
if chunk is None:
raise ValueError(f"索引找不到 chunk {name!r}({ref!r} @ {path})")
matches = [qe for qe in chunk["query"] if qe["query"] == qtext]
if not matches:
available = [qe["query"] for qe in chunk["query"]]
raise ValueError(
f"索引 {ref!r}: chunk {name!r} 无 query 文案 {qtext!r};已有 {available}"
)
# 文案唯一已在 _validate_chunk 保证
flat.append(_flatten_query_entry(chunk, matches[0]))
return flat
def load_case_file(path: Path) -> List[Dict[str, Any]]:
"""读取完整用例或索引,展平为 list[评测用例](query 为 str)。"""
raw = json.loads(path.read_text(encoding="utf-8"))
if _is_index(raw):
return _resolve_index(path, raw)
if _is_chunk_corpus(raw):
flat: List[Dict[str, Any]] = []
for c in raw:
_validate_chunk(c)
flat.extend(_flatten_chunk(c))
return flat
raise ValueError(
f"用例文件须为「chunk 对象数组」或「索引字符串数组」: {path}"
)
def load_articles(cases_dir: Path) -> List[Dict[str, Any]]:
"""按文章(Article)聚合完整用例,供 multi-chunk 上下文的切片拼接。
返回 list[文章];每篇文章:
{ "file": 用例文件名, "source": source 字段, "chunks": [按 chunk_index 升序的原始 chunk, ...] }
以「用例文件 × source」为一篇文章的标识来隔离,避免不同文件里 source 重名
(如 synthetic 系列 source 都是 "inline")彼此串号。chunk 序号跳号不影响——
上下文切组只按「排序后的实际 chunk 列表」每 MULTI_CHUNK_MAX 切一段,而非序号跨度。
"""
from collections import defaultdict
articles: List[Dict[str, Any]] = []
for p in sorted(Path(cases_dir).glob("*.json")):
raw = json.loads(p.read_text(encoding="utf-8"))
if not _is_chunk_corpus(raw):
continue
by_source: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
for c in raw:
if not isinstance(c, dict) or "source" not in c or "chunk_index" not in c or "text" not in c:
continue
by_source[c["source"]].append(c)
for source, chunks in by_source.items():
ordered = sorted(chunks, key=lambda x: x.get("chunk_index", 0))
articles.append({"file": p.name, "source": source, "chunks": ordered})
return articles
def load_all_cases(path: Path) -> List[Dict[str, Any]]:
"""相关性评测:全部(chunk×query)用例。"""
return load_case_file(path)
def load_relevant_cases(path: Path) -> Tuple[List[Dict[str, Any]], int]:
"""关键词评测:仅 expect_relevant=true;返回 (相关用例, 跳过的无关条数)。"""
raw = load_case_file(path)
cases: List[Dict[str, Any]] = []
skipped = 0
for c in raw:
if not bool(c.get("expect_relevant")):
skipped += 1
continue
if not isinstance(c.get("expect_keywords"), list):
raise ValueError(f"相关用例 {c['name']} 须有 expect_keywords 数组")
cases.append(c)
return cases, skipped
|