ffzeroHua commited on
Commit
719249d
·
verified ·
1 Parent(s): 8344eac

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +192 -343
app.py CHANGED
@@ -1,41 +1,46 @@
1
  import os
2
- import re
3
  import json
 
4
  import pickle
5
  import threading
 
 
 
 
6
  import traceback
7
- import requests
8
  import numpy as np
9
- from typing import *
10
  from datetime import datetime
11
-
12
- # Web 服务与 HF Hub 依赖
13
- from fastapi import FastAPI
14
- import uvicorn
15
  from huggingface_hub import HfApi, hf_hub_download
16
 
17
  # ==========================================
18
- # [新增] 导入我们刚刚制作的单文件下载器
19
  # ==========================================
20
- from tensoul_2 import download_paipu
21
 
22
- # 底层特征引擎 (Teacher)
23
- from libriichi3p.mjai import Bot as RiichiBot
24
- from libriichi3p.consts import ACTION_SPACE
25
 
26
- # 底层特征引擎 (Student)
27
  try:
28
  from libriichiSanma import state as sanma_state
29
  except ImportError:
30
  import libriichi as sanma_state
31
 
32
  # ==========================================
33
- # [配置与环境变量]
34
  # ==========================================
35
- HF_TOKEN = os.environ.get("HF_TOKEN", "")
36
- DATASET_REPO = os.environ.get("DATASET_REPO", "AstraNASA/tenhou-scc")
37
- URL_LIST_FILE = os.environ.get("URL_LIST_FILE", "majsoul_logs.txt")
 
 
 
 
 
38
 
 
39
  MASK_3P = [
40
  "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m",
41
  "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p",
@@ -44,371 +49,215 @@ MASK_3P = [
44
  '5mr', '5pr', '5sr',
45
  'reach', 'pon', 'kan', 'nukidora', 'hora', 'ryukyoku', 'none'
46
  ]
47
-
48
  NONE_CODE = MASK_3P.index('none')
49
  KAN_CODE = MASK_3P.index('kan')
50
- _thread_local = threading.local()
51
 
 
52
  worker_status = {
53
- "status": "Starting up...",
54
- "urls_processed": 0,
55
- "total_chunks_uploaded": 0,
56
- "total_records_extracted": 0,
57
- "current_target": "",
58
- "errors": 0
59
  }
60
 
 
 
 
61
  # ==========================================
62
- # [解析器] 保持不变
63
  # ==========================================
64
- class TenhouParser:
65
- @staticmethod
66
- def tile_name(x):
67
- if x in (51, 52, 53): return ['5mr', '5pr', '5sr'][x - 51]
68
- num, suit = x % 10, x // 10
69
- if suit in (1, 2, 3): return str(num) + 'mps'[suit - 1]
70
- if suit == 4: return 'ESWNPFC'[num - 1]
71
- return '?'
72
 
73
- @classmethod
74
- def get_meld_tiles(cls, actor, s):
75
- i, player = 0, 0
76
- result = {'pai': [], 'consumed': [], 'actor': actor}
77
- while i < len(s):
78
- player += 1
79
- tile_type = 'consumed'
80
- if s[i] in 'cpmakf':
81
- tile_type = 'pai'
82
- result['type'] = ['chi', 'pon', 'daiminkan', 'ankan', 'kakan', 'nukidora']['cpmakf'.index(s[i])]
83
- if s[i] in 'cpm':
84
- result['target'] = (4 - player + actor) % 4
85
- i += 1
86
- result[tile_type].append(cls.tile_name(int(s[i:i+2])))
87
- i += 2
88
- result['pai'] = result['pai'][0]
89
- if result.get('type') == 'ankan': result['consumed'].append(result['pai'])
90
- return result
91
 
92
- @classmethod
93
- def parse_events(cls, actor, income, outcome):
94
- incoming, outcoming = [], []
95
- for i, event in enumerate(income):
96
- if type(event) is str: incoming.append(cls.get_meld_tiles(actor, event))
97
- else: incoming.append({'type': 'tsumo', 'pai': cls.tile_name(event), 'actor': actor})
98
- for i, event in enumerate(outcome):
99
- if type(event) is str and event[0] != 'r':
100
- outcoming.append(cls.get_meld_tiles(actor, event))
101
- else:
102
- if event == 0:
103
- outcoming.append({'type': 'empty'})
104
- continue
105
- reach = False
106
- if type(event) is str and event[0] == 'r':
107
- reach, event = True, int(event[1:])
108
- outcoming.append({'type': 'reach', 'actor': actor})
109
- outcoming.append({'type': 'dahai', 'pai': cls.tile_name(event if event != 60 else income[i]), 'actor': actor, 'tsumogiri': event == 60})
110
- if reach: outcoming.append({'type': 'reach_accepted', 'actor': actor})
111
- return incoming, outcoming
112
-
113
- @classmethod
114
- def merge_events(cls, oya, events, dora_markers):
115
- current, result = oya, []
116
- def finished(x): return all(len(i[0]) == 0 and len(i[1]) == 0 for i in x)
117
- while not finished(events):
118
- income, outcome = events[current]
119
- nuki = False
120
- if len(income):
121
- result.append(income.pop(0))
122
- if result[-1]['type'] == 'daiminkan':
123
- if len(dora_markers) > 0: result.append({'type': 'dora', 'dora_marker': cls.tile_name(dora_markers.pop(0))})
124
- outcome.pop(0)
125
- continue
126
- if len(outcome):
127
- result.append(outcome.pop(0))
128
- pai, t = result[-1].get('pai'), result[-1]['type']
129
- if t == 'reach':
130
- result.append(outcome.pop(0))
131
- pai = result[-1].get('pai')
132
- result.append(outcome.pop(0))
133
- nuki = False
134
- for actor, x in enumerate(events):
135
- if actor == current or len(x[1]) == 0: continue
136
- if x[0][0]['type'] != 'tsumo' and x[0][0].get('pai') == pai and not (x[0][0]['type'] == 'chi' and not (x[0][0]['actor'] + 3) % 4 == actor):
137
- nuki, current = True, actor
138
- break
139
- if t in ('ankan', 'kakan', 'nukidora'):
140
- if t != 'nukidora' and len(dora_markers) > 0: result.append({'type': 'dora', 'dora_marker': cls.tile_name(dora_markers.pop(0))})
141
- nuki = True
142
- if not nuki: current = (current + 1) % 4
143
- return result
144
-
145
- @classmethod
146
- def parse_single_round(cls, data):
147
- round_info, scores, dora_markers, uradora, result_info = data[0], data[1], data[2], data[3], data[-1]
148
- oya = round_info[0] % 4
149
- patch = lambda arr: arr if len(arr) >= 13 else [0] * 13
150
- events = [{
151
- 'type': 'start_kyoku', 'bakaze': 'ESWN'[round_info[0] // 4], 'kyoku': oya + 1,
152
- 'honba': round_info[1], 'kyotaku': round_info[2], 'oya': oya,
153
- 'dora_marker': cls.tile_name(dora_markers.pop(0)), 'scores': scores,
154
- # 这里的 data[13] 在三麻时虽然是结果数据,但 patch() 会把它变成 13 个 '?'
155
- 'tehais': [[cls.tile_name(i) for i in patch(data[k])] for k in [4, 7, 10, 13]]
156
- }]
157
-
158
- # 还原原版的做法:分别解析,只在四麻时解析第四个玩家
159
- e1 = cls.parse_events(0, data[5], data[6])
160
- e2 = cls.parse_events(1, data[8], data[9])
161
- e3 = cls.parse_events(2, data[11], data[12])
162
- e4 = ([], [])
163
- if len(data) >= 15:
164
- e4 = cls.parse_events(3, data[14], data[15])
165
-
166
- e_list = [e1, e2, e3, e4]
167
- events += cls.merge_events(oya, e_list, dora_markers)
168
-
169
- last_type = events[-1]['type']
170
- if last_type == 'tsumo' and result_info[0] == '和了':
171
- events.append({'type': 'hora', 'actor': events[-1]['actor'], 'target': events[-1]['actor']})
172
- elif result_info[0] == '和了':
173
- actor = next(i for i, x in enumerate(result_info[1]) if x > 0)
174
- events.append({'type': 'hora', 'actor': actor, 'target': actor})
175
- elif last_type == 'tsumo' or '九牌' in result_info[0]:
176
- events.append({'type': 'ryukyoku', 'actor': events[-1]['actor']})
177
-
178
- return events
179
-
180
- @classmethod
181
- def parse_log(cls, log):
182
- scores = log.get('sc', [])
183
- weights = [1.0, 1.0, 1.0]
184
- seat = log['name'].index('私') if '私' in log['name'] else -1
185
- parsed_rounds = []
186
- for i in log['log'][:]:
187
- round_events = [{"type": "start_game", "id": seat, "weight": weights}] + cls.parse_single_round(i)
188
- parsed_rounds.append(round_events)
189
- return parsed_rounds
190
-
191
- # ==========================================
192
- # [特征拦截假引擎 (Teacher)]
193
- # ==========================================
194
- class DummyFeatureEngine:
195
- def __init__(self):
196
- self.engine_type = 'mortal'
197
- self.name = 'DataMiner'
198
- self.version = 4
199
- self.is_oracle = False
200
- self.enable_quick_eval = True
201
- self.enable_rule_based_agari_guard = True
202
 
203
- def react_batch(self, obs, masks, invisible_obs):
204
- _thread_local.interception = (obs, masks, invisible_obs)
205
- batch_size = len(obs)
206
- actions, q_outs, pure_masks = [], [], []
207
-
208
- for m in masks:
209
- m_list = m.tolist() if hasattr(m, 'tolist') else list(m)
210
- pure_masks.append(m_list)
211
- try: valid_action = m_list.index(True)
212
- except ValueError: valid_action = 0
213
- actions.append(valid_action)
214
- q_outs.append([0.0] * len(m_list))
215
- return actions, q_outs, pure_masks, [True] * batch_size
216
 
217
  # ==========================================
218
- # [双重特征打包架构 (Distillation)]
219
  # ==========================================
220
- class FeatureEncoder:
221
- def __init__(self, chunk_size=2048, pool_size=8):
222
  self.chunk_size = chunk_size
223
- self.pool_size = pool_size
224
- self.inputs, self.outputs, self.weights = [], [], []
225
- self.chunk_count = 0
226
- self.hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
227
-
228
- self.local_pool_dir = "local_chunks_pool"
229
  os.makedirs(self.local_pool_dir, exist_ok=True)
230
 
231
- @staticmethod
232
- def action_to_mask(who, action):
233
- if action is None: return NONE_CODE
234
- if type(action) is str: action = json.loads(action)
235
- if action.get('actor') != who or action.get('type') == 'tsumo': return NONE_CODE
236
- if action['type'] == 'dahai': return MASK_3P.index(action['pai'])
237
- if action['type'] in ('daiminkan', 'ankan', 'kakan'): return KAN_CODE
238
- if action['type'] in MASK_3P: return MASK_3P.index(action['type'])
239
- raise Exception(f"Unknown action map: {action}")
240
-
241
- def save_and_check_upload(self):
242
- if not self.inputs: return
243
 
244
- filename = f"chunk_distill_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{self.chunk_count}.pkl"
 
 
 
 
245
  filepath = os.path.join(self.local_pool_dir, filename)
246
 
 
247
  with open(filepath, 'wb') as f:
248
- pickle.dump({'inputs': self.inputs, 'outputs': self.outputs, 'weights': self.weights}, f)
249
-
250
- print(f"📦 已生成蒸馏缓存: {filename} ({len(self.inputs)} records).")
251
 
252
- self.chunk_count += 1
253
  self.inputs.clear()
254
  self.outputs.clear()
255
- self.weights.clear()
256
-
257
- current_files = os.listdir(self.local_pool_dir)
258
- if len(current_files) >= self.pool_size:
259
- self.upload_pool()
260
-
261
- def upload_pool(self):
262
- current_files = os.listdir(self.local_pool_dir)
263
- if not current_files or not self.hf_api or not DATASET_REPO: return
264
-
265
- import time
266
- print(f"🚀 本地池满,正在批量上传 {len(current_files)} 个文件...")
267
 
268
- for attempt in range(6):
269
- try:
270
- self.hf_api.upload_folder(
271
- folder_path=self.local_pool_dir,
272
- path_in_repo="distill_better_3",
273
- repo_id=DATASET_REPO,
274
- repo_type="dataset"
275
- )
276
- print(f"✅ 上传成功 (Attempt {attempt + 1}).")
277
- worker_status["total_chunks_uploaded"] += len(current_files)
278
- for f in current_files: os.remove(os.path.join(self.local_pool_dir, f))
279
- break
280
- except Exception as e:
281
- wait_time = 5 * (2 ** attempt)
282
- print(f"⚠️ Upload failed: {e}. Waiting {wait_time}s...")
283
- time.sleep(wait_time)
284
 
285
- def process_game(self, events):
286
- who = -1
287
- current_weight = 1.0
288
-
289
- ps_student = None
290
- bot_teacher = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
- for i, event in enumerate(events):
293
- if event.get('type') == 'start_game':
294
- who = event['id']
295
- weights_list = event.get('weight', [1.0, 1.0, 1.0])
296
- current_weight = weights_list[who]
297
 
298
- # 初始化双模型状态机
299
- ps_student = sanma_state.PlayerState(who)
300
- bot_teacher = RiichiBot(DummyFeatureEngine(), who)
301
-
302
- if ps_student is None or bot_teacher is None:
303
- continue
304
-
305
- if event.get('type') == 'end_game':
306
- continue
307
 
308
- next_event = None
309
- for j in range(i + 1, len(events)):
310
- if events[j].get('type') not in ('dora', 'reach_accepted'):
311
- next_event = events[j]; break
312
-
313
- event_str = json.dumps(event, separators=(",", ":"))
314
-
315
- # --- 1. Teacher 更新与拦截 ---
316
- _thread_local.interception = None
317
- bot_teacher.react(event_str)
318
- intercepted = getattr(_thread_local, 'interception', None)
319
-
320
- # --- 2. Student 更新与特征生成 ---
321
- cans = ps_student.update(event_str)
322
-
323
- if intercepted is None or not cans.can_act:
324
- continue
325
 
326
- obs_t, masks_t, _ = intercepted
327
- obs_s, mask_s = ps_student.encode_obs(4, False)
328
-
329
- valid_actions_count = int(np.count_nonzero(masks_t[0]))
330
- if valid_actions_count <= 1:
331
- continue
332
-
333
- try:
334
- output_code = self.action_to_mask(who, next_event)
 
 
 
 
 
335
 
336
- # 存入字典,解耦新老数据格式
337
- self.inputs.append({
338
- "obs_student": obs_s,
339
- "mask_student": mask_s,
340
- "obs_teacher": obs_t[0], # 去除 batch 维度
341
- "mask_teacher": masks_t[0]
342
- })
343
- self.outputs.append(output_code)
344
- self.weights.append(current_weight)
345
 
346
- worker_status["total_records_extracted"] += 1
347
- except Exception: pass
 
348
 
349
- if len(self.inputs) >= self.chunk_size:
350
- self.save_and_check_upload()
 
 
 
 
 
 
 
351
 
352
  # ==========================================
353
- # [数据挖掘总管线]
354
  # ==========================================
355
- def worker_pipeline():
356
- if not HF_TOKEN or not DATASET_REPO:
357
- worker_status["status"] = "Error: HF_TOKEN or DATASET_REPO missing!"
358
- return
359
-
360
- worker_status["status"] = "Fetching target URL list..."
361
- try:
362
- url_file_path = hf_hub_download(repo_id=DATASET_REPO, filename=URL_LIST_FILE, repo_type="dataset", token=HF_TOKEN)
363
- with open(url_file_path, 'r') as f: target_urls = [line.strip() for line in f if line.strip()]
364
- except Exception as e:
365
- worker_status["status"] = f"Failed to fetch {URL_LIST_FILE}: {e}"
366
- return
367
-
368
- encoder = FeatureEncoder(chunk_size=2048, pool_size=8)
369
- worker_status["status"] = "Mining..."
370
-
371
- for url in target_urls:
372
- worker_status["current_target"] = url
373
-
374
  try:
375
- # 1. 使用统一下载接口(支持天凤/雀魂自动识别)
376
- paipu_json_str = download_paipu(url)
377
- paipu_data = json.loads(paipu_json_str)
378
-
379
- # 2. 从解析后的数据中提取目标玩家座位 (0, 1, 2)
380
- target_seat = paipu_data.get('pov', -1)
381
-
382
- # 3. 将统一的 JSON 数据喂给解析器
383
- parsed_games = TenhouParser.parse_log(paipu_data)
384
-
385
- for game in parsed_games:
386
- # 默认是三麻,遍历3个座位
387
- for j in range(3):
388
- # 如果能解析出固定视角(target_seat != -1),就只处理那个视角的特征,丢弃其他视角的冗余计算
389
- if target_seat != -1 and j != target_seat:
390
- continue
391
-
392
- # 动态注入当前处理的视角ID
393
- game[0]['id'] = j
394
- encoder.process_game(game)
395
-
396
- worker_status["urls_processed"] += 1
397
  except Exception as e:
398
- print(f"Error processing {url}: {e}")
399
  traceback.print_exc()
400
- worker_status["errors"] += 1
401
 
402
- encoder.save_and_check_upload()
403
- encoder.upload_pool()
404
- worker_status["status"] = "Finished! All URLs processed."
405
- worker_status["current_target"] = "Idle"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- app = FastAPI()
408
- @app.get("/")
409
- def read_status(): return worker_status
 
 
410
 
411
- if __name__ == '__main__':
412
- thread = threading.Thread(target=worker_pipeline, daemon=True)
413
- thread.start()
414
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
  import os
 
2
  import json
3
+ import orjson
4
  import pickle
5
  import threading
6
+ import time
7
+ import uuid
8
+ import glob
9
+ import random
10
  import traceback
 
11
  import numpy as np
 
12
  from datetime import datetime
13
+ import gradio as gr
 
 
 
14
  from huggingface_hub import HfApi, hf_hub_download
15
 
16
  # ==========================================
17
+ # [依赖导入]
18
  # ==========================================
19
+ from riichienv import RiichiEnv, GameRule
20
 
21
+ # 导入两种不同架构的加载函数
22
+ from model3pLOCAL import load_model as load_model_teacher # 775 维 (Teacher: 9070)
23
+ from model3pNEW import load_model as load_model_student # 1012 维 (Student: 57000)
24
 
25
+ # 导入纯 Python 版的状态机,用于提取 1012 维特征
26
  try:
27
  from libriichiSanma import state as sanma_state
28
  except ImportError:
29
  import libriichi as sanma_state
30
 
31
  # ==========================================
32
+ # [全局配置]
33
  # ==========================================
34
+ DATASET_REPO = "AstraNASA/tenhou-scc" # 存放打包好的 DAgger 数据的仓库
35
+ MODEL_REPO_ID = "ffzeroHua/Riichi-Model-Repo"
36
+ HF_TOKEN = os.getenv("HF_TOKEN")
37
+ WORKER_ID = os.getenv("WORKER_ID", str(uuid.uuid4())[:6])
38
+
39
+ # 🚀 对战双方权重
40
+ STUDENT_MODEL = "StudentSanma_Distilled_Step57000.pth"
41
+ TEACHER_MODEL = "Elite4z9070.pth"
42
 
43
+ # 动作空间映射
44
  MASK_3P = [
45
  "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m",
46
  "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p",
 
49
  '5mr', '5pr', '5sr',
50
  'reach', 'pon', 'kan', 'nukidora', 'hora', 'ryukyoku', 'none'
51
  ]
 
52
  NONE_CODE = MASK_3P.index('none')
53
  KAN_CODE = MASK_3P.index('kan')
 
54
 
55
+ # UI 状态监控
56
  worker_status = {
57
+ "games_played": 0,
58
+ "records_extracted": 0,
59
+ "chunks_uploaded": 0,
60
+ "status": "Starting DAgger Factory..."
 
 
61
  }
62
 
63
+ api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
64
+ EVAL_RUNNING = True
65
+
66
  # ==========================================
67
+ # [辅助函数]
68
  # ==========================================
69
+ def sync_models_from_hub():
70
+ if HF_TOKEN and "你的用户名" not in MODEL_REPO_ID:
71
+ print(f"☁️ 正在从 Hub 拉取模型...")
72
+ hf_hub_download(repo_id=MODEL_REPO_ID, filename=STUDENT_MODEL, repo_type="model", local_dir=".", token=HF_TOKEN)
73
+ hf_hub_download(repo_id=MODEL_REPO_ID, filename=TEACHER_MODEL, repo_type="model", local_dir=".", token=HF_TOKEN)
74
+ print("✅ 模型就绪!")
 
 
75
 
76
+ def patch_event_fast(event_str):
77
+ if '"kita"' in event_str:
78
+ event_str = event_str.replace('"kita"', '"nukidora"')
79
+ if '"start_kyoku"' in event_str or '"deltas"' in event_str:
80
+ event = orjson.loads(event_str)
81
+ if event.get('type') == 'start_kyoku':
82
+ scores = event.setdefault('scores', [])
83
+ while len(scores) < 4: scores.append(0)
84
+ tehais = event.setdefault('tehais', [])
85
+ while len(tehais) < 4: tehais.append(["?" for _ in range(13)])
86
+ if 'deltas' in event:
87
+ deltas = event['deltas']
88
+ while len(deltas) < 4: deltas.append(0)
89
+ return orjson.dumps(event).decode('utf-8')
90
+ return event_str
 
 
 
91
 
92
+ def patch_resp_fast(resp_str):
93
+ if not resp_str: return resp_str
94
+ return resp_str.replace('"nukidora"', '"kita"')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ def action_to_label(who, action_dict):
97
+ if action_dict is None: return NONE_CODE
98
+ if action_dict.get('actor') != who or action_dict.get('type') == 'tsumo': return NONE_CODE
99
+ t = action_dict['type']
100
+ if t == 'dahai': return MASK_3P.index(action_dict['pai'])
101
+ if t in ('daiminkan', 'ankan', 'kakan'): return KAN_CODE
102
+ if t in MASK_3P: return MASK_3P.index(t)
103
+ return NONE_CODE
 
 
 
 
 
104
 
105
  # ==========================================
106
+ # [DAgger 特征打包]
107
  # ==========================================
108
+ class DAggerEncoder:
109
+ def __init__(self, chunk_size=2048):
110
  self.chunk_size = chunk_size
111
+ self.inputs, self.outputs = [], []
112
+ self.local_pool_dir = f"dagger_pool_{WORKER_ID}"
 
 
 
 
113
  os.makedirs(self.local_pool_dir, exist_ok=True)
114
 
115
+ def append(self, obs_s, mask_s, label):
116
+ self.inputs.append({
117
+ "obs_student": obs_s,
118
+ "mask_student": mask_s
119
+ })
120
+ self.outputs.append(label)
121
+ worker_status["records_extracted"] += 1
 
 
 
 
 
122
 
123
+ if len(self.inputs) >= self.chunk_size:
124
+ self.save_and_upload()
125
+
126
+ def save_and_upload(self):
127
+ filename = f"chunk_dagger_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{WORKER_ID}.pkl"
128
  filepath = os.path.join(self.local_pool_dir, filename)
129
 
130
+ # 保存本地
131
  with open(filepath, 'wb') as f:
132
+ pickle.dump({'inputs': self.inputs, 'outputs': self.outputs}, f)
 
 
133
 
 
134
  self.inputs.clear()
135
  self.outputs.clear()
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
+ # 上传到 Hub (使用独立的线程防止阻塞打牌)
138
+ if api and DATASET_REPO:
139
+ threading.Thread(target=self._upload_task, args=(filepath, filename)).start()
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
+ def _upload_task(self, filepath, filename):
142
+ try:
143
+ api.upload_file(
144
+ path_or_fileobj=filepath,
145
+ path_in_repo=f"dagger_chunks/{filename}", # 传到 dagger_chunks 文件夹下
146
+ repo_id=DATASET_REPO,
147
+ repo_type="dataset"
148
+ )
149
+ worker_status["chunks_uploaded"] += 1
150
+ os.remove(filepath)
151
+ print(f"☁️ [DAgger] 成功上传数据块: {filename}")
152
+ except Exception as e:
153
+ print(f"⚠️ 上传失败: {e}")
154
+
155
+ # ==========================================
156
+ # [单局 DAgger 引擎]
157
+ # ==========================================
158
+ def play_dagger_game(encoder: DAggerEncoder):
159
+ env = RiichiEnv(game_mode="3p-red-half", rule=GameRule.default_tenhou())
160
+
161
+ # 每次开局,必须重新实例化 Bot,防止底层 Rust 状态机污染
162
+ # Student 负责打牌,Teacher 负责指点
163
+ student_bots = {i: load_model_student(i, STUDENT_MODEL) for i in range(3)}
164
+ teacher_bots = {i: load_model_teacher(i, TEACHER_MODEL) for i in range(3)}
165
+
166
+ # 纯 Python 状态机,负责截获 Student 视角的 1012维 Obs
167
+ python_states = {i: sanma_state.PlayerState(i) for i in range(3)}
168
+
169
+ obs_dict = env.reset()
170
+
171
+ while not env.done():
172
+ actions_to_env = {}
173
 
174
+ for pid, env_obs in obs_dict.items():
175
+ student_final_action = None
176
+
177
+ for event_str in env_obs.new_events():
178
+ event_patched = patch_event_fast(event_str)
179
 
180
+ # 1. 更新 Python 状态机以生成 1012 维特征
181
+ cans = python_states[pid].update(event_patched)
 
 
 
 
 
 
 
182
 
183
+ # 2. 幽灵教师思考 (但不参与实战控制)
184
+ teacher_resp_str = teacher_bots[pid].react(event_patched)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
+ # 3. 如果到了需要决策的节点,进行 DAgger 抓取
187
+ if cans.can_act:
188
+ obs_s, mask_s = python_states[pid].encode_obs(4, False)
189
+
190
+ try:
191
+ teacher_resp = patch_resp_fast(teacher_resp_str)
192
+ action_dict = json.loads(teacher_resp) if teacher_resp else None
193
+ label_idx = action_to_label(pid, action_dict)
194
+
195
+ # 只有当存在有效决策空间时,才记录
196
+ if int(np.count_nonzero(mask_s)) > 1:
197
+ encoder.append(obs_s, mask_s, label_idx)
198
+ except Exception:
199
+ pass
200
 
201
+ # 4. 学生思考 (真正掌控局面)
202
+ student_resp_str = student_bots[pid].react(event_patched)
 
 
 
 
 
 
 
203
 
204
+ # 提取学生最后一次有效响应,喂给环境
205
+ if student_resp_str and '"type":"none"' not in student_resp_str.replace(' ', ''):
206
+ student_final_action = env_obs.select_action_from_mjai(patch_resp_fast(student_resp_str))
207
 
208
+ if student_final_action is None:
209
+ student_final_action = env_obs.select_action_from_mjai('{"type": "none"}')
210
+
211
+ actions_to_env[pid] = student_final_action
212
+
213
+ # 环境按照学生(劣势)的动作继续
214
+ obs_dict = env.step(actions_to_env)
215
+
216
+ worker_status["games_played"] += 1
217
 
218
  # ==========================================
219
+ # [后台循环]
220
  # ==========================================
221
+ def background_dagger_loop():
222
+ sync_models_from_hub()
223
+ encoder = DAggerEncoder(chunk_size=2048)
224
+ worker_status["status"] = "Generating DAgger Data..."
225
+
226
+ # 限制单线程跑牌,防止内存爆炸,反正是在 HF Space 白嫖
227
+ while EVAL_RUNNING:
 
 
 
 
 
 
 
 
 
 
 
 
228
  try:
229
+ play_dagger_game(encoder)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  except Exception as e:
231
+ print(f"Game crashed: {e}")
232
  traceback.print_exc()
233
+ time.sleep(1)
234
 
235
+ # ==========================================
236
+ # [前端 UI]
237
+ # ==========================================
238
+ def get_stats():
239
+ md = f"""
240
+ ### ⚔️ DAgger 数据工厂运行中...
241
+ - **🏭 工厂状态:** {worker_status['status']}
242
+ - **🤖 掌控对局:** 3只 `{STUDENT_MODEL}` (1012 维)
243
+ - **👻 幽灵教练:** 3只 `{TEACHER_MODEL}` (9070 黑盒)
244
+ ---
245
+ - **🀄 已完成对局:** {worker_status['games_played']} 局
246
+ - **🧠 提取的高质量决策:** {worker_status['records_extracted']} 条
247
+ - **📦 已上传数据块 (Chunks):** {worker_status['chunks_uploaded']} 个
248
+ - **🌐 节点 ID:** `{WORKER_ID}`
249
+
250
+ *提示:在你的 Colab 微调程序中,只需将 target_prefix 设为 `"dagger_chunks"` 即可开始训练!*
251
+ """
252
+ return md
253
 
254
+ with gr.Blocks() as demo:
255
+ gr.Markdown("# 🀄 Mahjong DAgger 数据提纯引擎")
256
+ stats_output = gr.Markdown("⏳ 正在初始化 DAgger 引擎并拉取模型...")
257
+ demo.load(fn=get_stats, inputs=None, outputs=stats_output)
258
+ gr.Timer(5).tick(fn=get_stats, inputs=None, outputs=stats_output)
259
 
260
+ if __name__ == "__main__":
261
+ t = threading.Thread(target=background_dagger_loop, daemon=True)
262
+ t.start()
263
+ demo.queue().launch(server_name="0.0.0.0", server_port=7860)