Skip to content

Commit 4a4edb9

Browse files
authored
Merge pull request #415 from shijinpjlab/dev_0601
feat: 结果保存优化:数据库字段序列化、ExecutorResultSaveArgs新增参数
2 parents cc10901 + 66ff5d9 commit 4a4edb9

7 files changed

Lines changed: 333 additions & 36 deletions

File tree

dingo/config/input_args.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ class ExecutorResultSaveArgs(BaseModel):
7777
all_labels: bool = False
7878
raw: bool = False
7979
merge: bool = False # 如果为True,所有数据写入同一个jsonl文件,不分文件夹
80+
limit: Optional[int] = None # 每个输出文件最多写入条数,None表示不限制
81+
field_list: Optional[List[str]] = None # 仅保存指定字段;若均不存在则报错
82+
full_field_sample_count: int = 0 # 保留完整字段样本条数,0表示关闭
8083

8184

8285
class ExecutorArgs(BaseModel):

dingo/exec/local.py

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import os
66
import time
77
import uuid
8-
from typing import Generator, List, Optional
8+
from datetime import date, datetime
9+
from decimal import Decimal
10+
from typing import Dict, Generator, List, Optional
911

1012
from tqdm import tqdm
1113

@@ -25,6 +27,8 @@ def __init__(self, input_args: InputArgs):
2527
self.input_args: InputArgs = input_args
2628
self.llm: Optional[BaseLLM] = None
2729
self.summary: SummaryModel = SummaryModel()
30+
self._full_field_written_count: int = 0
31+
self._file_written_count: Dict[str, int] = {}
2832

2933
def load_data(self) -> Generator[Data, None, None]:
3034
"""
@@ -252,6 +256,28 @@ def summarize(self, summary: SummaryModel) -> SummaryModel:
252256
new_summary.finish_time = time.strftime("%Y%m%d_%H%M%S", time.localtime())
253257
return new_summary
254258

259+
@staticmethod
260+
def _json_default(value):
261+
if isinstance(value, Decimal):
262+
return float(value)
263+
if isinstance(value, (datetime, date)):
264+
return value.isoformat()
265+
return str(value)
266+
267+
@classmethod
268+
def _json_dumps(cls, value: dict) -> str:
269+
return json.dumps(value, ensure_ascii=False, default=cls._json_default)
270+
271+
def _resolve_field_list(self, input_args: InputArgs) -> Optional[List[str]]:
272+
if input_args.executor.result_save.full_field_sample_count <= 0:
273+
return input_args.executor.result_save.field_list
274+
275+
if self._full_field_written_count < input_args.executor.result_save.full_field_sample_count:
276+
self._full_field_written_count += 1
277+
return None
278+
279+
return input_args.executor.result_save.field_list
280+
255281
def write_single_data(
256282
self, path: str, input_args: InputArgs, result_info: ResultInfo
257283
):
@@ -261,20 +287,20 @@ def write_single_data(
261287
# 如果启用 merge 模式,将所有数据写入同一个文件
262288
if input_args.executor.result_save.merge:
263289
f_n = os.path.join(path, "all_results.jsonl")
290+
if not self._should_write_to_file(input_args, f_n):
291+
return
292+
str_json = self._build_output_json(input_args, result_info)
264293
with open(f_n, "a", encoding="utf-8") as f:
265-
# if input_args.executor.result_save.raw:
266-
# str_json = json.dumps(result_info.to_raw_dict(), ensure_ascii=False)
267-
# else:
268-
# str_json = json.dumps(result_info.to_dict(), ensure_ascii=False)
269-
str_json = json.dumps(result_info.to_raw_dict(), ensure_ascii=False)
270294
f.write(str_json + "\n")
295+
self._file_written_count[f_n] = self._file_written_count.get(f_n, 0) + 1
271296
return
272297

273298
if not input_args.executor.result_save.good and not result_info.eval_status:
274299
return
275300

276301
# 用集合记录已经写过的(字段名, label名)组合,避免重复写入
277302
written_labels = set()
303+
str_json: Optional[str] = None
278304

279305
# 遍历 eval_details 的第一层(字段名组合),第二层是List[EvalDetail]
280306
for field_name, eval_detail_list in result_info.eval_details.items():
@@ -314,18 +340,39 @@ def write_single_data(
314340
# 没有点分割,直接在字段文件夹下创建文件
315341
f_n = os.path.join(field_dir, parts[0] + ".jsonl")
316342

343+
if not self._should_write_to_file(input_args, f_n):
344+
continue
345+
if str_json is None:
346+
str_json = self._build_output_json(input_args, result_info)
317347
with open(f_n, "a", encoding="utf-8") as f:
318-
if input_args.executor.result_save.raw:
319-
str_json = json.dumps(result_info.to_raw_dict(), ensure_ascii=False)
320-
else:
321-
str_json = json.dumps(result_info.to_dict(), ensure_ascii=False)
322348
f.write(str_json + "\n")
349+
self._file_written_count[f_n] = self._file_written_count.get(f_n, 0) + 1
350+
351+
def _should_write_to_file(self, input_args: InputArgs, file_path: str) -> bool:
352+
limit = input_args.executor.result_save.limit
353+
if limit is None:
354+
return True
355+
return self._file_written_count.get(file_path, 0) < limit
356+
357+
def _build_output_json(self, input_args: InputArgs, result_info: ResultInfo) -> str:
358+
field_list = self._resolve_field_list(input_args)
359+
if input_args.executor.result_save.raw:
360+
output_data = result_info.to_raw_dict(field_list=field_list)
361+
else:
362+
output_data = result_info.to_dict(field_list=field_list)
363+
return self._json_dumps(output_data)
323364

324365
def write_summary(self, path: str, input_args: InputArgs, summary: SummaryModel):
325366
if not input_args.executor.result_save.bad:
326367
return
327368
with open(path + "/summary.json", "w", encoding="utf-8") as f:
328-
json.dump(summary.to_dict(), f, indent=4, ensure_ascii=False)
369+
json.dump(
370+
summary.to_dict(),
371+
f,
372+
indent=4,
373+
ensure_ascii=False,
374+
default=self._json_default,
375+
)
329376

330377
def get_summary(self):
331378
return self.summary

dingo/io/output/result_info.py

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from typing import Dict, List
1+
import ast
2+
import json
3+
from collections.abc import Mapping
4+
from datetime import date, datetime
5+
from decimal import Decimal
6+
from typing import Any, Dict, List, Optional
27

38
from pydantic import BaseModel
49

@@ -11,38 +16,96 @@ class ResultInfo(BaseModel):
1116
eval_status: bool = False
1217
eval_details: Dict[str, List[EvalDetail]] = {}
1318

14-
def to_dict(self):
19+
@staticmethod
20+
def _apply_field_filter(output_data: Dict[str, Any], field_list: Optional[List[str]]) -> Dict[str, Any]:
21+
if field_list is None:
22+
return output_data
23+
24+
if len(field_list) == 0:
25+
return {}
26+
27+
missing_fields = [field for field in field_list if field not in output_data]
28+
if missing_fields:
29+
sample_keys = list(output_data.keys())[:20]
30+
raise ValueError(
31+
f"result_save.field_list 中字段不存在: {missing_fields}。"
32+
f"可用字段示例: {sample_keys}"
33+
)
34+
35+
return {field: output_data[field] for field in field_list}
36+
37+
@classmethod
38+
def _parse_container_string(cls, value: str) -> Any:
39+
text = value.strip()
40+
if len(text) < 2:
41+
return value
42+
if not (
43+
(text.startswith("[") and text.endswith("]"))
44+
or (text.startswith("{") and text.endswith("}"))
45+
):
46+
return value
47+
try:
48+
parsed = json.loads(text)
49+
except json.JSONDecodeError:
50+
try:
51+
parsed = ast.literal_eval(text)
52+
except (ValueError, SyntaxError):
53+
return value
54+
if isinstance(parsed, (dict, list, tuple, set)):
55+
return cls._normalize_value(parsed)
56+
return value
57+
58+
@classmethod
59+
def _normalize_value(cls, value: Any) -> Any:
60+
if isinstance(value, (str, int, float, bool)) or value is None:
61+
if isinstance(value, str):
62+
return cls._parse_container_string(value)
63+
return value
64+
if isinstance(value, Decimal):
65+
return float(value)
66+
if isinstance(value, (datetime, date)):
67+
return value.isoformat()
68+
if isinstance(value, Mapping):
69+
return {str(k): cls._normalize_value(v) for k, v in value.items()}
70+
if isinstance(value, (list, tuple, set)):
71+
return [cls._normalize_value(item) for item in value]
72+
return value
73+
74+
def to_dict(self, field_list: Optional[List[str]] = None):
1575
"""将ResultInfo转换为字典格式
1676
1777
Returns:
1878
包含所有字段的字典,其中eval_details被转换为嵌套字典结构
1979
"""
20-
return {
80+
output_data = {
2181
'dingo_id': self.dingo_id,
22-
'raw_data': self.raw_data,
82+
'raw_data': self._normalize_value(self.raw_data),
2383
'eval_status': self.eval_status,
2484
'eval_details': {
2585
k: [model_res.model_dump() for model_res in v]
2686
for k, v in self.eval_details.items()
2787
},
2888
}
89+
return self._apply_field_filter(output_data, field_list)
2990

30-
def to_raw_dict(self):
91+
def to_raw_dict(self, field_list: Optional[List[str]] = None):
3192
"""将ResultInfo合并到raw_data中
3293
3394
Returns:
3495
包含原始数据和dingo_result的字典
3596
"""
97+
merged_raw_data = self._normalize_value(self.raw_data)
98+
3699
def move_conflict_field(field_name: str):
37-
if field_name not in self.raw_data:
100+
if field_name not in merged_raw_data:
38101
return
39102

40103
index = 1
41104
while True:
42105
backup_field = f'{field_name}_old_v{index}'
43-
if backup_field not in self.raw_data:
44-
self.raw_data[backup_field] = self.raw_data[field_name]
45-
del self.raw_data[field_name]
106+
if backup_field not in merged_raw_data:
107+
merged_raw_data[backup_field] = merged_raw_data[field_name]
108+
del merged_raw_data[field_name]
46109
return
47110
index += 1
48111

@@ -55,6 +118,6 @@ def move_conflict_field(field_name: str):
55118
}
56119
move_conflict_field('dingo_id')
57120
move_conflict_field('dingo_result')
58-
self.raw_data['dingo_id'] = self.dingo_id
59-
self.raw_data['dingo_result'] = dingo_result
60-
return self.raw_data
121+
merged_raw_data['dingo_id'] = self.dingo_id
122+
merged_raw_data['dingo_result'] = dingo_result
123+
return self._apply_field_filter(merged_raw_data, field_list)

dingo/model/model.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,28 @@ def load_model(cls):
124124
if cls.module_loaded:
125125
return
126126
this_module_directory = os.path.dirname(os.path.abspath(__file__))
127-
# rule auto register
128-
for file in os.listdir(os.path.join(this_module_directory, "rule")):
129-
path = os.path.join(this_module_directory, "rule", file)
130-
if (
131-
os.path.isfile(path)
132-
and file.endswith(".py")
133-
and not file == "__init__.py"
134-
):
135-
try:
136-
importlib.import_module("dingo.model.rule." + file.split(".")[0])
137-
except ModuleNotFoundError as e:
138-
log.debug(e)
127+
# rule auto register - 递归扫描子目录
128+
rule_base_dir = os.path.join(this_module_directory, "rule")
129+
for root, dirs, files in os.walk(rule_base_dir):
130+
dirs[:] = [d for d in dirs if d != "__pycache__"]
131+
132+
for file in files:
133+
if file.endswith(".py") and file != "__init__.py":
134+
rel_path = os.path.relpath(root, rule_base_dir)
135+
if rel_path == ".":
136+
module_name = f"dingo.model.rule.{file[:-3]}"
137+
else:
138+
rel_module = rel_path.replace(os.sep, ".")
139+
module_name = f"dingo.model.rule.{rel_module}.{file[:-3]}"
140+
141+
try:
142+
importlib.import_module(module_name)
143+
except ModuleNotFoundError as e:
144+
log.debug(e)
145+
except ImportError as e:
146+
log.debug("=" * 30 + " ImportError " + "=" * 30)
147+
log.debug(f"module {module_name} not imported because: \n{e}")
148+
log.debug("=" * 73)
139149

140150
# llm auto register - 递归扫描子目录
141151
llm_base_dir = os.path.join(this_module_directory, "llm")

dingo/model/rule/scibase/rule_quanliang.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ class RuleQuanliangFieldValidation(BaseRule):
623623
"evaluation_results": "",
624624
}
625625

626-
_required_fields = [RequiredField.METADATA]
626+
_required_fields = []
627627
dynamic_config = EvaluatorRuleArgs(key_list=list(FIELD_VALIDATORS.keys()))
628628

629629
@classmethod

test/scripts/exec/test_local.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,83 @@
77

88

99
class TestLocal:
10+
def test_write_single_data_limit_per_file(self, tmp_path):
11+
input_data = {
12+
"executor": {
13+
"result_save": {
14+
"bad": True,
15+
"limit": 1,
16+
}
17+
},
18+
"evaluator": [],
19+
}
20+
input_args = InputArgs(**input_data)
21+
executor = LocalExecutor(input_args)
22+
23+
result_info = ResultInfo(
24+
dingo_id="1",
25+
raw_data={"content": "test"},
26+
eval_status=True,
27+
eval_details={
28+
"content": [
29+
EvalDetail(
30+
metric="RuleColonEnd",
31+
status=True,
32+
label=["QUALITY_BAD_EFFECTIVENESS.RuleColonEnd"],
33+
reason=["bad sample"],
34+
)
35+
]
36+
},
37+
)
38+
output_path = str(tmp_path)
39+
40+
executor.write_single_data(output_path, input_args, result_info)
41+
executor.write_single_data(output_path, input_args, result_info)
42+
43+
target_file = tmp_path / "content" / "QUALITY_BAD_EFFECTIVENESS" / "RuleColonEnd.jsonl"
44+
assert target_file.exists()
45+
lines = target_file.read_text(encoding="utf-8").strip().splitlines()
46+
assert len(lines) == 1
47+
48+
def test_write_single_data_limit_per_file_merge_mode(self, tmp_path):
49+
input_data = {
50+
"executor": {
51+
"result_save": {
52+
"bad": True,
53+
"merge": True,
54+
"limit": 1,
55+
}
56+
},
57+
"evaluator": [],
58+
}
59+
input_args = InputArgs(**input_data)
60+
executor = LocalExecutor(input_args)
61+
62+
result_info = ResultInfo(
63+
dingo_id="1",
64+
raw_data={"content": "test"},
65+
eval_status=True,
66+
eval_details={
67+
"content": [
68+
EvalDetail(
69+
metric="RuleColonEnd",
70+
status=True,
71+
label=["QUALITY_BAD_EFFECTIVENESS.RuleColonEnd"],
72+
reason=["bad sample"],
73+
)
74+
]
75+
},
76+
)
77+
output_path = str(tmp_path)
78+
79+
executor.write_single_data(output_path, input_args, result_info)
80+
executor.write_single_data(output_path, input_args, result_info)
81+
82+
target_file = tmp_path / "all_results.jsonl"
83+
assert target_file.exists()
84+
lines = target_file.read_text(encoding="utf-8").strip().splitlines()
85+
assert len(lines) == 1
86+
1087
def test_merge_result_info(self):
1188
existing_list = []
1289
new_item1 = ResultInfo(

0 commit comments

Comments
 (0)