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