Wendy-Fly commited on
Commit
d399f19
·
verified ·
1 Parent(s): 58c42b0

warm-start patch 2/3: find_positions.py

Browse files
patched/vendor/ranking_moderation/scripts/find_positions.py ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import logging
4
+ from pathlib import Path
5
+ import yaml
6
+ import json
7
+ import tqdm
8
+ from logging.handlers import RotatingFileHandler
9
+ from datetime import datetime
10
+ import argparse
11
+ from typing import Dict, Any, Set
12
+ import copy
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+
15
+ # Add project root to Python path
16
+ project_root = Path(__file__).parent.parent
17
+ sys.path.append(str(project_root))
18
+
19
+ # from src.ranking_moderation.bradley_terry_ranking import BradleyTerryRanking as Ranking
20
+ from src.ranking_moderation.true_skill_ranking import TrueSkillRanking as Ranking
21
+ from src.ranking_moderation.pairwise_comparison import PairwiseComparison, format_convs_as_xml, parse_unit_judgment, format_comments_as_xml, format_convs_as_xml_image
22
+ from src.llm import AzureProxyOpenAI, LLMConfig
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ def setup_logging() -> logging.Logger:
27
+ """Configure logging with rotating file handlers."""
28
+ # Create logs directory if it doesn't exist
29
+ log_dir = Path("logs")
30
+ log_dir.mkdir(exist_ok=True)
31
+
32
+ # Configure logging
33
+ logging.basicConfig(
34
+ level=logging.DEBUG,
35
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
36
+ datefmt='%Y-%m-%d %H:%M:%S',
37
+ handlers=[
38
+ # Console handler - INFO level only
39
+ logging.StreamHandler(),
40
+ # Debug log file handler - DEBUG and above
41
+ RotatingFileHandler(
42
+ filename=log_dir / "debug.log",
43
+ maxBytes=10*1024*1024, # 10MB
44
+ backupCount=5,
45
+ encoding='utf-8'
46
+ ),
47
+ # Info log file handler - INFO and above
48
+ RotatingFileHandler(
49
+ filename=log_dir / "info.log",
50
+ maxBytes=10*1024*1024, # 10MB
51
+ backupCount=5,
52
+ encoding='utf-8'
53
+ ),
54
+ # Error log file handler - ERROR and above
55
+ RotatingFileHandler(
56
+ filename=log_dir / "error.log",
57
+ maxBytes=10*1024*1024, # 10MB
58
+ backupCount=5,
59
+ encoding='utf-8'
60
+ )
61
+ ]
62
+ )
63
+
64
+ # Set console handler to INFO level
65
+ console_handler = logging.getLogger().handlers[0]
66
+ console_handler.setLevel(logging.ERROR)
67
+
68
+ # Set file handlers to their respective levels
69
+ debug_handler = logging.getLogger().handlers[1]
70
+ debug_handler.setLevel(logging.DEBUG)
71
+
72
+ info_handler = logging.getLogger().handlers[2]
73
+ info_handler.setLevel(logging.INFO)
74
+
75
+ error_handler = logging.getLogger().handlers[3]
76
+ error_handler.setLevel(logging.ERROR)
77
+
78
+ # Set all loggers to DEBUG level
79
+ for logger_name in logging.root.manager.loggerDict:
80
+
81
+ logger = logging.getLogger(logger_name)
82
+ if logger_name in ("bytedes.mw", "elasticsearch", "urllib3.connectionpool"):
83
+ logger.setLevel(logging.ERROR)
84
+ else:
85
+ logger.setLevel(logging.DEBUG)
86
+ logger.propagate = True
87
+
88
+
89
+ return logging.getLogger(__name__)
90
+
91
+ def parse_args():
92
+ """Parse command line arguments."""
93
+ parser = argparse.ArgumentParser(description='Find positions for new comments using Bradley-Terry ranking')
94
+ parser.add_argument('--config', type=str, required=True,
95
+ help='Path to configuration YAML file')
96
+ parser.add_argument('--input_file', type=str, required=False, default=None,
97
+ help='Input file (overrides yaml config field)')
98
+ parser.add_argument('--output_dir', type=str, required=False, default=None,
99
+ help='output dir (overrides yaml config field)')
100
+ parser.add_argument('--pairwise_comparison', type=str, required=False, default=None,
101
+ help='pairwise_comparison (overrides yaml config field)')
102
+ parser.add_argument('--ranking', type=str, required=False, default=None,
103
+ help='ranking (overrides yaml config field)')
104
+ parser.add_argument('--es_index', type=str, required=False, default=None,
105
+ help='es_index (overrides yaml config field)')
106
+ return parser.parse_args()
107
+
108
+ def load_config(config_path: str) -> dict:
109
+ """Load configuration from YAML file."""
110
+ with open(config_path, "r", encoding="utf-8") as f:
111
+ return yaml.safe_load(f)
112
+
113
+ def save_results(results: list, output_dir: Path, timestamp: str):
114
+ """Save results to both JSON and CSV formats."""
115
+ # Save JSON
116
+ json_path = output_dir / f"results_{timestamp}.json"
117
+ with json_path.open("w", encoding="utf-8") as f:
118
+ json.dump(results, f, ensure_ascii=False, indent=2)
119
+ logger.info(f"Saved results to {json_path}")
120
+
121
+ def save_results_atomic(results, output_dir, timestamp):
122
+ # 最终文件
123
+ filename = output_dir / f"results_{timestamp}.json"
124
+ # 临时文件:只在 filename 后面加 ".tmp"
125
+ tmp_filename = filename.with_suffix(".json.tmp")
126
+
127
+ # 1) 写到临时文件
128
+ with open(tmp_filename, "w", encoding="utf-8") as f:
129
+ json.dump(results, f, ensure_ascii=False, indent=2)
130
+ f.flush()
131
+ os.fsync(f.fileno())
132
+
133
+ # 2) 原子替换
134
+ os.replace(tmp_filename, filename)
135
+
136
+ def map_fields(instance: dict, field_mapping: dict) -> dict:
137
+ """Map fields according to configuration.
138
+
139
+ Args:
140
+ instance: Original instance dictionary
141
+ field_mapping: Field mapping configuration
142
+
143
+ Returns:
144
+ dict: Mapped instance with required fields
145
+ """
146
+ mapped_instance = {}
147
+ copy.deepcopy(instance)
148
+
149
+ # Map required fields
150
+ for target_field, source_field in field_mapping.items():
151
+ if isinstance(source_field, str):
152
+ if source_field in instance:
153
+ mapped_instance[target_field] = str(instance[source_field])
154
+ else:
155
+ logger.warning(f"Source field {source_field} not found in instance")
156
+ mapped_instance[target_field] = None
157
+ elif isinstance(source_field, list):
158
+ # Handle metadata fields
159
+ mapped_instance[target_field] = {}
160
+ for field in source_field:
161
+ if field in instance:
162
+ mapped_instance[target_field][field] = instance[field]
163
+
164
+ # Copy all original fields
165
+ instance.update(mapped_instance)
166
+
167
+ return instance
168
+
169
+ def load_test_items(input_file: Path, field_mapping: dict) -> list:
170
+ """Load test comments from JSON file and map fields.
171
+
172
+ Args:
173
+ input_file: Path to the input JSON file
174
+ field_mapping: Field mapping configuration
175
+
176
+ Returns:
177
+ List of mapped comment dictionaries
178
+ """
179
+ logger.info(f"Loading items from {input_file}")
180
+ items = []
181
+ try:
182
+ if input_file.suffix == ".json":
183
+ with input_file.open("r", encoding="utf-8") as f:
184
+ data = json.load(f)
185
+
186
+ # Handle different JSON structures
187
+ if isinstance(data, list):
188
+ items = data
189
+ elif isinstance(data, dict) and "data" in data:
190
+ items = data["data"]
191
+ else:
192
+ raise ValueError(f"Unexpected JSON structure in {input_file}")
193
+ elif input_file.suffix == ".jsonl":
194
+ try:
195
+ with input_file.open("r", encoding="utf-8") as f:
196
+ for line in f:
197
+ items.append(json.loads(line))
198
+ except json.JSONDecodeError:
199
+ # 如果按行解析失败,尝试一次性读取标准 JSON 格式
200
+ with input_file.open("r", encoding="utf-8") as f:
201
+ items = json.load(f)
202
+ else:
203
+ raise ValueError(f"File {input_file} type is not recognized.")
204
+
205
+ # Map fields according to configuration
206
+ mapped_items = [map_fields(item, field_mapping) for item in items]
207
+
208
+ logger.info(f"Loaded {len(mapped_items)} test items")
209
+ return mapped_items
210
+
211
+ except Exception as e:
212
+ logger.error(f"Error loading {input_file}: {str(e)}")
213
+ raise
214
+
215
+ def load_processed_items(output_dir: Path) -> Set[str]:
216
+ """Load previously processed comment IDs from output directory.
217
+
218
+ Args:
219
+ output_dir: Path to the output directory containing processed results
220
+
221
+ Returns:
222
+ Set of processed comment IDs
223
+ """
224
+ processed_ids = set()
225
+
226
+ # Look for all JSON result files
227
+ result_files = list(output_dir.glob("results_*.json"))
228
+ for result_file in result_files:
229
+ try:
230
+ with result_file.open("r", encoding="utf-8") as f:
231
+ results = json.load(f)
232
+ for result in results:
233
+ if "error" in result:
234
+ continue
235
+ if isinstance(result, dict) and "item" in result:
236
+ item = result["item"]
237
+ if isinstance(item, dict) and "item_id" in comment:
238
+ processed_ids.add(str(comment[task_id]))
239
+ except Exception as e:
240
+ logger.error(f"Error loading processed comments from {result_file}: {str(e)}")
241
+ continue
242
+
243
+ logger.info(f"Loaded {len(processed_ids)} previously processed comment IDs")
244
+ return processed_ids
245
+
246
+ def initialize_llm(config: Dict[str, Any]) -> AzureProxyOpenAI:
247
+ """Initialize LLM with configuration.
248
+
249
+ Args:
250
+ config: LLM configuration dictionary
251
+
252
+ Returns:
253
+ AzureProxyOpenAI: Initialized LLM instance
254
+ """
255
+
256
+ llm_config = LLMConfig(
257
+ api_key=None,
258
+ api_base=config["api_url"],
259
+ model_name=config["model_name"],
260
+ caller=config["caller"],
261
+ max_tokens=config["max_tokens"],
262
+ temperature=config["temperature"]
263
+ )
264
+ return AzureProxyOpenAI(llm_config)
265
+
266
+
267
+ def process_single_item(item, bt_ranker, config):
268
+ """
269
+ 在线程中执行对单条评论的 find_sample_index 逻辑,并返回一个结果 dict。
270
+ 如果发生异常,则返回包含错误信息的 dict。
271
+ """
272
+ # if not specified, by default use binary_search method
273
+ if "search_method" in config["ranking"]:
274
+ search_method = config["ranking"]["search_method"]
275
+ else:
276
+ search_method = 'similarity_search'
277
+
278
+ # warm-start:从 item 取 embedding 估出来的位置当 binary search 起点
279
+ start_pos = item.get("estimated_position")
280
+ if start_pos is not None:
281
+ try:
282
+ start_pos = int(start_pos)
283
+ except (TypeError, ValueError):
284
+ start_pos = None
285
+
286
+ try:
287
+ result = bt_ranker.find_sample_index(
288
+ new_item=item,
289
+ initial_comparisons=config["ranking"]["initial_comparisons"],
290
+ num_rounds=config["ranking"]["num_rounds"],
291
+ search_method=search_method,
292
+ start_pos=start_pos,
293
+ )
294
+ # 在外层不添加 timestamp 和 comment、processed_at,由主线程统一封装
295
+ return {
296
+ "success": True,
297
+ "payload": result
298
+ }
299
+ except Exception as e:
300
+ return {
301
+ "success": False,
302
+ "error": str(e)
303
+ }
304
+
305
+ def main():
306
+ # Parse command line arguments
307
+ args = parse_args()
308
+
309
+ # Load configuration
310
+ config = load_config(args.config)
311
+ # config = load_config(project_root / "config" / "test_find_positions_config.yaml")
312
+
313
+ # Override
314
+ # If CLI args provided override what's in YAML
315
+ if args.input_file:
316
+ if "data" in config:
317
+ config["data"]["input_file"] = args.input_file
318
+ if args.output_dir:
319
+ if "data" in config:
320
+ config["data"]["output_dir"] = args.output_dir
321
+ if args.pairwise_comparison:
322
+ if "data" in config and "storage" in config["data"]:
323
+ config["data"]["storage"]["pairwise_comparison"] = args.pairwise_comparison
324
+ if args.ranking:
325
+ if "data" in config and "storage" in config["data"]:
326
+ config["data"]["storage"]["ranking"] = args.ranking
327
+ if args.es_index:
328
+ if "data" in config and "storage" in config["data"]:
329
+ config["data"]["storage"]["es_index"] = args.es_index
330
+
331
+
332
+
333
+ # Setup logging
334
+ global logger
335
+ logger = setup_logging()
336
+
337
+ # Load prompt templates
338
+ prompt_path = project_root / config["data"]["prompt_template"]
339
+ with open(prompt_path, "r", encoding="utf-8") as f:
340
+ prompt_templates = yaml.safe_load(f)
341
+
342
+ # Initialize LLM
343
+ llm = initialize_llm(config["llm"])
344
+
345
+ # Setup storage directories
346
+ pairwise_storage_dir = project_root / config["data"]["storage"]["pairwise_comparison"]
347
+ ranking_storage_dir = project_root / config["data"]["storage"]["ranking"]
348
+ ranking_file_name = config["data"]["storage"].get("filename", "ruler_items.json")
349
+ output_dir = project_root / config["data"]["output_dir"]
350
+ pairwise_storage_dir.mkdir(parents=True, exist_ok=True)
351
+ ranking_storage_dir.mkdir(parents=True, exist_ok=True)
352
+ output_dir.mkdir(parents=True, exist_ok=True)
353
+
354
+ business = config.get("business") or config.get("data", {}).get("business", "dm")
355
+
356
+ # Initialize PairwiseComparison
357
+ if business == "dm":
358
+ format_items = format_convs_as_xml
359
+ elif business == "dm_mm":
360
+ format_items = format_convs_as_xml_image
361
+ else:
362
+ format_items = format_comments_as_xml
363
+
364
+ pairwise_comparison = PairwiseComparison(
365
+ llm=llm,
366
+ prompt_templates=prompt_templates,
367
+ format_items=format_items,
368
+ parse_judgment=parse_unit_judgment,
369
+ data_dir=str(pairwise_storage_dir),
370
+ es_psm=config["data"]["storage"]["es_psm"],
371
+ es_index=config["data"]["storage"]["es_index"],
372
+ local_cache_enabled=(config["data"]["storage"]["es_index"] in (None, "None")),
373
+ max_comparisons_per_pair=config["ranking"]["max_comparisons_per_pair"],
374
+ max_workers=config["ranking"]["max_workers"],
375
+ max_retries=config["ranking"]["max_retries"],
376
+ initial_retry_delay=config["ranking"]["initial_retry_delay"],
377
+ business=business
378
+ )
379
+
380
+ # Initialize BradleyTerryRanking
381
+ bt_ranker = Ranking(
382
+ pairwise_comparison=pairwise_comparison,
383
+ data_dir=str(ranking_storage_dir),
384
+ file_name=str(ranking_file_name),
385
+ convergence_threshold=config["ranking"]["convergence_threshold"]
386
+ )
387
+
388
+ # Load previously processed comments
389
+ processed_ids = load_processed_items(output_dir)
390
+
391
+ # Load test comments with field mapping
392
+ input_file = project_root / config["data"]["input_file"]
393
+ test_items = load_test_items(input_file, config["data"]["field_mapping"])
394
+ # random.shuffle(test_comments)
395
+ # test_comments = test_comments[:5]
396
+
397
+ if not test_items:
398
+ logger.error("No test items loaded. Exiting.")
399
+ return
400
+
401
+ # Filter out already processed comments
402
+ test_items = [
403
+ item for item in test_items
404
+ if str(item["item_id"]) not in processed_ids
405
+ ]
406
+
407
+ if not test_items:
408
+ logger.info("All items have been processed. Exiting.")
409
+ return
410
+
411
+ logger.info(f"Found {len(test_items)} new comments to process")
412
+
413
+ # Initialize results list
414
+ results = []
415
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
416
+
417
+ future_to_item = {}
418
+
419
+ with ThreadPoolExecutor(max_workers=config["ranking"]["max_workers"]) as executor:
420
+ # 2) 为每条评论提交一个任务
421
+ for item in test_items:
422
+ future = executor.submit(process_single_item, item, bt_ranker, config)
423
+ future_to_item[future] = item
424
+
425
+ processed_count = 0
426
+ # 3) as_completed 会在每个 future 运行完毕时返回
427
+ for future in tqdm.tqdm(as_completed(future_to_item),
428
+ total=len(test_items),
429
+ desc="Processing comments"):
430
+ comment = future_to_item[future]
431
+ # 4) 在这里拿到单条评论的结果(或异常)
432
+ try:
433
+ outcome = future.result()
434
+ except Exception as e:
435
+ # 如果连 process_single_comment 本身抛出了未捕获异常,也把它当作一个 error 结果处理
436
+ outcome = {
437
+ "success": False,
438
+ "error": f"Uncaught exception: {str(e)}"
439
+ }
440
+ processed_count += 1
441
+ # 5) 根据 outcome 构造要保存到 results 列表里的条目
442
+ if outcome["success"]:
443
+ result = outcome["payload"]
444
+ # 把原始评论内容、处理时间戳补回去
445
+ result["comment"] = comment
446
+ result["processed_at"] = datetime.now().isoformat()
447
+ results.append(result)
448
+
449
+ # 记录日志
450
+ pos = result.get("position", None)
451
+ score = result.get("score", None)
452
+ rounds_done = result.get("rounds_completed", None)
453
+ if pos is not None:
454
+ logger.debug(f"Found position for item {item.get('item_id', '')}: {pos}")
455
+ if score is not None:
456
+ logger.debug(f"Final score: {score:.4f}")
457
+ if rounds_done is not None:
458
+ logger.debug(f"Rounds completed: {rounds_done}")
459
+ else:
460
+ # 出现了业务层面的错误
461
+ error_entry = {
462
+ "item": item,
463
+ "error": outcome.get("error"),
464
+ "processed_at": datetime.now().isoformat()
465
+ }
466
+ results.append(error_entry)
467
+ logger.error(f"Error processing item {item.get('item_id', '')}: {outcome.get('error')}")
468
+
469
+ # 6) 每处理完一条,就把当前 results 列表写盘
470
+ if processed_count % 10 ==0:
471
+ save_results_atomic(results, output_dir, timestamp)
472
+
473
+ save_results_atomic(results, output_dir, timestamp)
474
+ logger.info(f"Completed processing {len(test_items)} items")
475
+ logger.info(f"Results saved to {output_dir}")
476
+
477
+ if __name__ == "__main__":
478
+ try:
479
+ main()
480
+ except Exception as e:
481
+ logger.exception("An error occurred during execution")
482
+ raise