{"repo": "asowerp/2026-practice-of-programming-in-python-and-cpp", "files": {"ui/task3_widget.py": {"b9055e875c52beba21a8e483cb9bd8b5b65ddb8b": {"t": 1786068483.86647, "n": 21682, "h": "d341779693e241b4", "c": "from __future__ import annotations\n\nfrom PySide6.QtCore import QObject, QThread, Qt, Signal, Slot\nfrom PySide6.QtGui import QFont\nfrom PySide6.QtWidgets import (\n QComboBox,\n QFormLayout,\n QGroupBox,\n QHBoxLayout,\n QLabel,\n QLineEdit,\n QPlainTextEdit,\n QPushButton,\n QSplitter,\n QTextEdit,\n QVBoxLayout,\n QWidget,\n)\n\nfrom engine.ai_log_assistant import AIDebugResult, build_model_config, explain_log_mismatch, get_model_names\nfrom engine.class_manager import ClassManager\nfrom engine.task3_log_helper import LogComparison, build_log_text, compare_logs, filter_events, summarize_bundle\nfrom engine.warcraft_engine import STANDARD_STAGE_DEFINITIONS, SimulationBundle\nfrom ui.block_workspace import BlockProgramEditor, BlockSpec, FieldSpec\nfrom ui.timeline_controller import TimelineController\nfrom ui.timeline_panel import TimelinePanel\n\n\nclass AIAnalysisWorker(QObject):\n progress = Signal(str)\n finished = Signal(object)\n\n def __init__(self, config, comparison: LogComparison, expected_text: str, actual_text: str) -> None:\n super().__init__()\n self.config = config\n self.comparison = comparison\n self.expected_text = expected_text\n self.actual_text = actual_text\n\n @Slot()\n def run(self) -> None:\n try:\n result = explain_log_mismatch(\n self.config,\n self.comparison,\n expected_text=self.expected_text,\n actual_text=self.actual_text,\n progress=self.progress.emit,\n )\n except Exception as exc:\n result = AIDebugResult(False, f\"AI 分析线程异常: {exc}\")\n self.finished.emit(result)\n\n\nclass Task3Widget(QWidget):\n def __init__(self, manager: ClassManager | None = None) -> None:\n super().__init__()\n self.manager = manager or ClassManager.get_instance()\n self.imported_bundle: SimulationBundle | None = None\n self.last_comparison: LogComparison | None = None\n self.timeline_controller: TimelineController | None = None\n self.ai_thread: QThread | None = None\n self.ai_worker: AIAnalysisWorker | None = None\n self._build_ui()\n\n def _build_ui(self) -> None:\n main_layout = QVBoxLayout(self)\n main_layout.setContentsMargins(16, 16, 16, 16)\n main_layout.setSpacing(12)\n\n title = QLabel(\"Task3:完整日志生成与对拍\")\n title_font = QFont()\n title_font.setPointSize(16)\n title_font.setBold(True)\n title.setFont(title_font)\n\n subtitle = QLabel(\n \"Task3 消费 Task2 导出的整局事件流。可以生成标准日志、筛选局部事件、对比学生输出,\"\n \"并在提供 API Key 后让 AI 辅助定位格式或逻辑错误。\"\n )\n subtitle.setWordWrap(True)\n subtitle.setStyleSheet(\"color: #94A3B8; font-size: 13px;\")\n main_layout.addWidget(title)\n main_layout.addWidget(subtitle)\n\n action_row = QHBoxLayout()\n self.example_btn = QPushButton(\"加载推荐积木\")\n self.run_btn = QPushButton(\"执行日志脚本\")\n self.run_btn.setObjectName(\"btnPrimary\")\n self.clear_btn = QPushButton(\"清空差异视图\")\n self.ai_btn = QPushButton(\"AI 分析差异\")\n self.example_btn.setToolTip(\"自动填入导入、生成日志、对拍输出的推荐流程。\")\n self.run_btn.setToolTip(\"按左侧积木顺序生成标准日志并对拍你的输出。\")\n self.clear_btn.setToolTip(\"清空右侧日志、差异和 AI 建议,不删除左侧积木脚本。\")\n self.ai_btn.setToolTip(\"在存在差异时,用所选模型辅助定位原因。\")\n self.example_btn.clicked.connect(self.load_example_script)\n self.run_btn.clicked.connect(self.run_script)\n self.clear_btn.clicked.connect(self.clear_outputs)\n self.ai_btn.clicked.connect(self.run_ai_debug)\n action_row.addWidget(self.example_btn)\n action_row.addWidget(self.run_btn)\n action_row.addWidget(self.clear_btn)\n action_row.addWidget(self.ai_btn)\n action_row.addStretch(1)\n main_layout.addLayout(action_row)\n\n splitter = QSplitter(Qt.Orientation.Horizontal)\n splitter.setHandleWidth(1)\n self.block_editor = BlockProgramEditor(self._build_block_specs())\n splitter.addWidget(self.block_editor)\n splitter.addWidget(self._build_result_panel())\n splitter.setSizes([600, 780])\n splitter.setToolTip(\"拖动中间的分隔线可以调整左右面板宽度\")\n main_layout.addWidget(splitter)\n self._refresh_ai_button_state()\n\n def _build_result_panel(self) -> QWidget:\n container = QWidget()\n layout = QVBoxLayout(container)\n\n self.import_status_label = QLabel(\"尚未接收 Task2 导出的整局事件流。\")\n self.import_status_label.setStyleSheet(\"font-weight: 700; color: #4F46E5;\")\n layout.addWidget(self.import_status_label)\n\n self.summary_label = QLabel(\"导入 Task2 结果后,再用左侧积木生成标准日志并对拍。\")\n self.summary_label.setStyleSheet(\"font-weight: 700; color: #4F46E5;\")\n layout.addWidget(self.summary_label)\n\n right_splitter = QSplitter(Qt.Orientation.Horizontal)\n right_splitter.setHandleWidth(1)\n\n left_widget = QWidget()\n left_layout = QVBoxLayout(left_widget)\n left_layout.setContentsMargins(0, 0, 0, 0)\n\n timeline_group = QGroupBox(\"导入事件时间轴\")\n timeline_layout = QVBoxLayout(timeline_group)\n self.timeline_panel = TimelinePanel(\n \"Task3 对拍时间轴\",\n \"从 Task2 导入事件后,这里会显示同一条事件流,便于按时间定位日志差异。\",\n )\n self.timeline_controller = TimelineController(self.timeline_panel)\n self.timeline_controller.eventSelected.connect(self.focus_timeline_event)\n timeline_layout.addWidget(self.timeline_panel)\n left_layout.addWidget(timeline_group, 1)\n\n ai_group = QGroupBox(\"AI 调错助手\")\n ai_layout = QFormLayout(ai_group)\n self.ai_model_combo = QComboBox()\n self.ai_model_combo.addItems(get_model_names())\n self.ai_key_edit = QLineEdit()\n self.ai_key_edit.setEchoMode(QLineEdit.EchoMode.Password)\n self.ai_key_edit.setPlaceholderText(\"填写 API Key,仅用于本次请求,不会保存\")\n self.ai_model_override_edit = QLineEdit()\n self.ai_model_override_edit.setPlaceholderText(\"可选:覆盖模型名,例如 deepseek-v4-flash 或 deepseek-v4-pro\")\n self.ai_base_url_edit = QLineEdit()\n self.ai_base_url_edit.setPlaceholderText(\"可选:OpenAI 兼容 base URL,例如 https://api.deepseek.com\")\n ai_layout.addRow(\"模型\", self.ai_model_combo)\n ai_layout.addRow(\"API Key\", self.ai_key_edit)\n ai_layout.addRow(\"模型名\", self.ai_model_override_edit)\n ai_layout.addRow(\"接口地址\", self.ai_base_url_edit)\n left_layout.addWidget(ai_group)\n\n process_group = QGroupBox(\"脚本执行日志\")\n process_layout = QVBoxLayout(process_group)\n self.process_output = QPlainTextEdit()\n self.process_output.setReadOnly(True)\n self.process_output.setPlaceholderText(\"执行日志脚本后,这里会显示每个积木的处理结果。\")\n process_layout.addWidget(self.process_output)\n left_layout.addWidget(process_group)\n\n right_widget = QWidget()\n right_layout = QVBoxLayout(right_widget)\n right_layout.setContentsMargins(0, 0, 0, 0)\n\n text_row = QHBoxLayout()\n\n expected_group = QGroupBox(\"当前标准日志\")\n expected_layout = QVBoxLayout(expected_group)\n self.expected_output = QPlainTextEdit()\n self.expected_output.setReadOnly(True)\n self.expected_output.setPlaceholderText(\"生成标准日志后会显示在这里。\")\n expected_layout.addWidget(self.expected_output)\n\n actual_group = QGroupBox(\"你的输出\")\n actual_layout = QVBoxLayout(actual_group)\n self.actual_output = QPlainTextEdit()\n self.actual_output.setPlaceholderText(\"把你程序的输出粘贴到这里,再执行“对比我的输出”。\")\n actual_layout.addWidget(self.actual_output)\n\n text_row.addWidget(expected_group)\n text_row.addWidget(actual_group)\n right_layout.addLayout(text_row, 1)\n\n diff_group = QGroupBox(\"差异高亮\")\n diff_layout = QVBoxLayout(diff_group)\n self.diff_view = QTextEdit()\n self.diff_view.setReadOnly(True)\n self.diff_view.setPlaceholderText(\"执行“对比我的输出”后,这里会显示逐行差异。\")\n diff_layout.addWidget(self.diff_view)\n right_layout.addWidget(diff_group, 1)\n\n ai_result_group = QGroupBox(\"AI 调错建议\")\n ai_result_layout = QVBoxLayout(ai_result_group)\n self.ai_output = QPlainTextEdit()\n self.ai_output.setReadOnly(True)\n self.ai_output.setPlaceholderText(\"先执行日志脚本并产生差异,再点击“AI 分析差异”。\")\n ai_result_layout.addWidget(self.ai_output)\n right_layout.addWidget(ai_result_group, 1)\n\n right_splitter.addWidget(left_widget)\n right_splitter.addWidget(right_widget)\n right_splitter.setSizes([320, 460])\n right_splitter.setToolTip(\"拖动分隔线可调整右侧两栏宽度\")\n layout.addWidget(right_splitter, 1)\n\n return container\n\n def _build_block_specs(self) -> list[BlockSpec]:\n stage_keys = [definition.key for definition in STANDARD_STAGE_DEFINITIONS]\n return [\n BlockSpec(\n key=\"use_imported_bundle\",\n title=\"使用 Task2 导入结果\",\n color=\"#2563EB\",\n description=\"读取 Task2 导出的整局事件流、Case 参数和阶段日程。\",\n ),\n BlockSpec(\n key=\"inspect_bundle\",\n title=\"查看当前导入摘要\",\n color=\"#0891B2\",\n description=\"查看当前是标准题面模式还是自定义教学模式,并预览前几条事件。\",\n ),\n BlockSpec(\n key=\"filter_hour\",\n title=\"按小时筛选\",\n color=\"#16A34A\",\n description=\"只保留某个小时内的事件,用来单独查看局部输出。\",\n fields=[\n FieldSpec(\"hour\", \"小时\", \"int\", 0, minimum=0, maximum=100, width=140),\n ],\n ),\n BlockSpec(\n key=\"filter_stage\",\n title=\"按阶段筛选\",\n color=\"#22C55E\",\n description=\"只保留某类阶段,例如 spawn / march / battle。\",\n fields=[\n FieldSpec(\"stage_key\", \"阶段\", \"combo\", stage_keys[0], stage_keys, editable=False, width=170),\n ],\n ),\n BlockSpec(\n key=\"filter_keyword\",\n title=\"按关键词筛选\",\n color=\"#65A30D\",\n description=\"按关键词过滤事件描述,例如 city 1、dragon、headquarter。\",\n fields=[\n FieldSpec(\"keyword\", \"关键词\", \"text\", \"city 1\", width=170, placeholder=\"例如 city 1\"),\n ],\n ),\n BlockSpec(\n key=\"generate_log\",\n title=\"生成当前日志\",\n color=\"#EA580C\",\n description=\"把当前筛选后的事件序列转换成标准文本。\",\n ),\n BlockSpec(\n key=\"compare_output\",\n title=\"对比我的输出\",\n color=\"#DC2626\",\n description=\"将当前生成的日志与右侧“你的输出”逐行比较。\",\n ),\n BlockSpec(\n key=\"reset_filters\",\n title=\"清空筛选链\",\n color=\"#475569\",\n description=\"恢复为导入时的完整事件流。\",\n ),\n ]\n\n def load_example_script(self) -> None:\n self.block_editor.set_script([\n {\"key\": \"use_imported_bundle\", \"fields\": {}},\n {\"key\": \"inspect_bundle\", \"fields\": {}},\n {\"key\": \"generate_log\", \"fields\": {}},\n {\"key\": \"compare_output\", \"fields\": {}},\n ])\n self.summary_label.setText(\"已加载推荐积木。下一步先从 Task2 导入事件,再点击“执行日志脚本”。\")\n self.summary_label.setStyleSheet(\"font-weight: 700; color: #4F46E5;\")\n\n def clear_outputs(self) -> None:\n self.process_output.clear()\n self.expected_output.clear()\n self.diff_view.clear()\n self.ai_output.clear()\n self.last_comparison = None\n if self.timeline_controller is not None and self.imported_bundle is None:\n self.timeline_controller.set_events([])\n self.summary_label.setText(\"输出已清空。导入结果和左侧脚本仍保留,可继续对拍。\")\n self.summary_label.setStyleSheet(\"font-weight: 700; color: #4F46E5;\")\n self._refresh_ai_button_state()\n\n def load_simulation_bundle(self, bundle: object) -> None:\n if not isinstance(bundle, SimulationBundle):\n self.imported_bundle = None\n self.import_status_label.setText(\"收到的数据不是合法的 Task2 模拟结果。\")\n self.import_status_label.setStyleSheet(\"font-weight: 700; color: #DC2626;\")\n if self.timeline_controller is not None:\n self.timeline_controller.set_events([])\n return\n self.imported_bundle = bundle\n self.import_status_label.setText(\n f\"已接收 Task2 导出的 {len(bundle.events)} 条事件,当前模式:{bundle.mode_label}。\"\n )\n self.import_status_label.setStyleSheet(\"font-weight: 700; color: #4F46E5;\")\n self.process_output.setPlainText(summarize_bundle(bundle))\n if self.timeline_controller is not None:\n self.timeline_controller.load_simulation_bundle(bundle)\n self.summary_label.setText(\"Task2 事件已导入。下一步加载推荐积木或执行已有日志脚本。\")\n self.summary_label.setStyleSheet(\"font-weight: 700; color: #4F46E5;\")\n self._refresh_ai_button_state()\n\n def focus_timeline_event(self, index: int, event: object) -> None:\n event_time = getattr(event, \"display_time\", \"--:--\")\n stage_key = getattr(event, \"stage_key\", \"unknown\")\n description = getattr(event, \"description\", str(event))\n self.summary_label.setText(f\"时间轴已定位到事件 #{index + 1}:{event_time} / {stage_key}。\")\n self.summary_label.setStyleSheet(\"font-weight: 700; color: #4F46E5;\")\n if self.expected_output.toPlainText().strip():\n matching_line = event.to_log_line() if hasattr(event, \"to_log_line\") else description\n self.diff_view.setHtml(\n \"
\"\n f\"当前时间轴事件 #{index + 1}
\"\n f\"时间:{event_time}
阶段:{stage_key}
\"\n f\"
{matching_line}
\"\n \"
\"\n )\n\n def run_script(self) -> None:\n script = self.block_editor.get_script()\n if not script:\n self.summary_label.setText(\"工作区还是空的,请先拖拽日志积木。\")\n self.summary_label.setStyleSheet(\"font-weight: 700; color: #DC2626;\")\n return\n\n context: dict[str, object] = {\n \"bundle\": None,\n \"events\": [],\n \"log_text\": \"\",\n }\n messages: list[str] = []\n last_summary = \"日志脚本执行结束。\"\n last_html = \"\"\n self.last_comparison = None\n\n for index, block in enumerate(script, start=1):\n ok, message, summary, html = self._execute_block(context, str(block.get(\"key\", \"\")), block.get(\"fields\", {}))\n prefix = \"[通过]\" if ok else \"[错误]\"\n messages.append(f\"{prefix} 积木 {index}: {message}\")\n if summary:\n last_summary = summary\n if html:\n last_html = html\n\n self.process_output.setPlainText(\"\\n\".join(messages))\n self.expected_output.setPlainText(str(context.get(\"log_text\", \"\")))\n self.diff_view.setHtml(last_html)\n self.summary_label.setText(last_summary)\n color = \"#16A34A\" if \"完全一致\" in last_summary else \"#4F46E5\"\n if \"差异\" in last_summary:\n color = \"#DC2626\"\n self.summary_label.setStyleSheet(f\"font-weight: 700; color: {color};\")\n self._refresh_ai_button_state()\n\n def run_ai_debug(self) -> None:\n expected_text = self.expected_output.toPlainText()\n actual_text = self.actual_output.toPlainText()\n if not expected_text.strip():\n self.ai_output.setPlainText(\"请先执行日志脚本,生成当前标准日志。\")\n self._refresh_ai_button_state()\n return\n comparison = self.last_comparison or compare_logs(expected_text, actual_text)\n if comparison.matched:\n self.ai_output.setPlainText(\"输出已经完全一致,不需要 AI 调错。\")\n self._refresh_ai_button_state()\n return\n try:\n config = build_model_config(\n self.ai_model_combo.currentText(),\n self.ai_key_edit.text(),\n model_override=self.ai_model_override_edit.text(),\n base_url_override=self.ai_base_url_edit.text(),\n )\n except ValueError as exc:\n self.ai_output.setPlainText(str(exc))\n return\n\n self._start_ai_analysis(config, comparison, expected_text, actual_text)\n\n def _start_ai_analysis(self, config, comparison: LogComparison, expected_text: str, actual_text: str) -> None:\n if self.ai_thread is not None and self.ai_thread.isRunning():\n self.ai_output.setPlainText(\"AI 正在思考中,请等待当前分析完成。\")\n return\n\n self.ai_output.setPlainText(\"AI 正在思考中:准备发送差异摘要...\")\n self.ai_btn.setEnabled(False)\n self.ai_btn.setText(\"AI 分析中...\")\n\n self.ai_thread = QThread(self)\n self.ai_worker = AIAnalysisWorker(config, comparison, expected_text, actual_text)\n self.ai_worker.moveToThread(self.ai_thread)\n self.ai_thread.started.connect(self.ai_worker.run)\n self.ai_worker.progress.connect(self._on_ai_progress)\n self.ai_worker.finished.connect(self._on_ai_finished)\n self.ai_worker.finished.connect(self.ai_thread.quit)\n self.ai_worker.finished.connect(self.ai_worker.deleteLater)\n self.ai_thread.finished.connect(self._cleanup_ai_thread)\n self.ai_thread.start()\n\n @Slot(str)\n def _on_ai_progress(self, message: str) -> None:\n self.ai_output.setPlainText(message)\n\n @Slot(object)\n def _on_ai_finished(self, result) -> None:\n if result.ok:\n self.ai_output.setPlainText(result.suggestion)\n else:\n self.ai_output.setPlainText(result.message + (\"\\n\\n\" + result.suggestion if result.suggestion else \"\"))\n\n @Slot()\n def _cleanup_ai_thread(self) -> None:\n if self.ai_thread is not None:\n self.ai_thread.deleteLater()\n self.ai_thread = None\n self.ai_worker = None\n self.ai_btn.setText(\"AI 分析差异\")\n self._refresh_ai_button_state()\n\n def _refresh_ai_button_state(self) -> None:\n if self.ai_thread is not None and self.ai_thread.isRunning():\n self.ai_btn.setEnabled(False)\n self.ai_btn.setToolTip(\"AI 正在后台分析当前差异,请稍等。\")\n return\n has_expected = bool(self.expected_output.toPlainText().strip())\n has_mismatch = self.last_comparison is not None and not self.last_comparison.matched\n self.ai_btn.setEnabled(has_expected and has_mismatch)\n if not has_expected:\n self.ai_btn.setToolTip(\"先执行日志脚本生成标准日志。\")\n elif not has_mismatch:\n self.ai_btn.setToolTip(\"只有存在对拍差异时才需要 AI 分析。\")\n else:\n self.ai_btn.setToolTip(\"使用所选模型分析当前日志差异。\")\n\n def _execute_block(self, context: dict[str, object], key: str, fields: dict[str, object]) -> tuple[bool, str, str, str]:\n bundle = context.get(\"bundle\")\n events = context.get(\"events\")\n if key == \"use_imported_bundle\":\n if self.imported_bundle is None:\n return False, \"当前还没有从 Task2 导入整局事件流。\", \"\", \"\"\n context[\"bundle\"] = self.imported_bundle\n context[\"events\"] = list(self.imported_bundle.events)\n return True, f\"已载入 {len(self.imported_bundle.events)} 条事件。\", f\"当前模式:{self.imported_bundle.mode_label}\", \"\"\n\n if key == \"inspect_bundle\":\n if not isinstance(bundle, SimulationBundle):\n return False, \"请先使用 Task2 导入结果。\", \"\", \"\"\n return True, summarize_bundle(bundle), f\"当前模式:{bundle.mode_label}\", \"\"\n\n if key == \"filter_hour\":\n if not isinstance(events, list):\n return False, \"当前没有事件流,请先导入 Task2 结果。\", \"\", \"\"\n hour = int(fields.get(\"hour\", 0))\n context[\"events\"] = filter_events(events, hour=hour)\n return True, f\"已筛选到第 {hour} 小时,共剩余 {len(context['events'])} 条事件。\", \"\", \"\"\n\n if key == \"filter_stage\":\n if not isinstance(events, list):\n return False, \"当前没有事件流,请先导入 Task2 结果。\", \"\", \"\"\n stage_key = str(fields.get(\"stage_key\", \"\")).strip()\n context[\"events\"] = filter_events(events, stage_key=stage_key)\n return True, f\"已筛选阶段 {stage_key},共剩余 {len(context['events'])} 条事件。\", \"\", \"\"\n\n if key == \"filter_keyword\":\n if not isinstance(events, list):\n return False, \"当前没有事件流,请先导入 Task2 结果。\", \"\", \"\"\n keyword = str(fields.get(\"keyword\", \"\")).strip()\n context[\"events\"] = filter_events(events, city_keyword=keyword)\n return True, f\"已按关键词 `{keyword}` 筛选,共剩余 {len(context['events'])} 条事件。\", \"\", \"\"\n\n if key == \"generate_log\":\n if not isinstance(events, list):\n return False, \"当前没有事件流,请先导入 Task2 结果。\", \"\", \"\"\n context[\"log_text\"] = build_log_text(events)\n if not str(context[\"log_text\"]).strip():\n return True, \"当前筛选链下没有可输出的事件。\", \"日志已生成,但为空。\", \"\"\n return True, f\"已生成 {len(str(context['log_text']).splitlines())} 行日志。\", \"标准日志已生成。\", \"\"\n\n if key == \"compare_output\":\n log_text = str(context.get(\"log_text\", \"\"))\n if not log_text.strip():\n return False, \"请先生成当前日志。\", \"\", \"\"\n comparison = compare_logs(log_text, self.actual_output.toPlainText())\n self.last_comparison = comparison\n return comparison.matched, comparison.summary, comparison.summary, comparison.html\n\n if key == \"reset_filters\":\n if not isinstance(bundle, SimulationBundle):\n return False, \"当前没有导入结果可恢复。\", \"\", \"\"\n context[\"events\"] = list(bundle.events)\n context[\"log_text\"] = \"\"\n return True, \"已恢复为导入时的完整事件流。\", \"筛选链已清空。\", \"\"\n\n return False, f\"未知积木 {key}。\", \"\", \"\"\n"}}}}