""" AI Diagnosis View - 현재 로드된 로그 데이터를 요약/진단하고, 사용자의 질문을 LLM에 전달합니다. - API 키/모델은 app/core/settings.py 의 AI 설정(ai_settings.json)에서 로드합니다. """ from __future__ import annotations import json from dataclasses import asdict, is_dataclass from typing import Any, Dict, List, Optional, Tuple from PySide6.QtCore import QObject, QThread, Signal, Qt from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit, QComboBox, QGroupBox, QMessageBox, QSpinBox, QLineEdit, ) from app.ai import get_ai_client, AIProviderType from app.core.settings import get_settings_manager from app.core.sync_controller import sync_manager from app.ui.dialogs.api_key_dialog import APIKeyDialog from app.ai.maintenance_kb import get_maintenance_kb def _safe_to_dict(obj: Any) -> Dict[str, Any]: """데이터 객체를 JSON 직렬화 가능한 dict로 축약 변환""" if obj is None: return {} if is_dataclass(obj): d = asdict(obj) elif hasattr(obj, "__dict__"): d = dict(obj.__dict__) else: return {"value": str(obj)} # 너무 긴 텍스트는 컷 for k, v in list(d.items()): if isinstance(v, str) and len(v) > 400: d[k] = v[:400] + "..." return d def _compute_basic_stats(rows: List[Any]) -> Dict[str, Any]: """로그 데이터(객체 리스트)에서 기본 통계를 계산""" if not rows: return {"count": 0} def _get_float(name: str) -> List[float]: vals = [] for r in rows: v = getattr(r, name, None) if v is None: continue try: vals.append(float(v)) except Exception: pass return vals def _get_bool_ratio(name: str) -> float: total = len(rows) if total == 0: return 0.0 on = 0 for r in rows: if bool(getattr(r, name, False)): on += 1 return on / total speeds = _get_float("trainspeed") dtgs = _get_float("dtg") pwms = _get_float("pwm_value") stats = { "count": len(rows), "time_start": getattr(rows[0], "time", ""), "time_end": getattr(rows[-1], "time", ""), "speed": { "min": min(speeds) if speeds else None, "max": max(speeds) if speeds else None, "avg": (sum(speeds) / len(speeds)) if speeds else None, }, "dtg": { "min": min(dtgs) if dtgs else None, "max": max(dtgs) if dtgs else None, "avg": (sum(dtgs) / len(dtgs)) if dtgs else None, }, "pwm": { "min": min(pwms) if pwms else None, "max": max(pwms) if pwms else None, "avg": (sum(pwms) / len(pwms)) if pwms else None, }, # 자주 쓰는 플래그 비율(ON 비율) "flags_ratio": { "system_active": _get_bool_ratio("system_active"), "over_spd_warning": _get_bool_ratio("over_spd_warning"), "tasc": _get_bool_ratio("tasc"), "twct_enable": _get_bool_ratio("twct_enable"), "door_open": _get_bool_ratio("door_open"), "door_close": _get_bool_ratio("door_close"), "psd_open": _get_bool_ratio("psd_open"), "psd_close": _get_bool_ratio("psd_close"), }, } return stats class _AIWorker(QObject): finished = Signal(str) failed = Signal(str) def __init__(self, prompt: str): super().__init__() self.prompt = prompt def run(self): try: client = get_ai_client() if not client.is_ready(): raise RuntimeError("AI가 아직 준비되지 않았습니다. 설정에서 API 키를 입력하세요.") resp = client.chat(self.prompt, temperature=0.2) self.finished.emit(resp.content or "") except Exception as e: self.failed.emit(str(e)) class AIDiagnosisView(QWidget): """AnalysisPanel의 AI Diagnosis 탭 위젯""" def __init__(self, panel_id: str, graph_view: Optional[QObject] = None, parent: Optional[QWidget] = None): super().__init__(parent) self.panel_id = panel_id self.graph_view = graph_view self.file_path: Optional[str] = None self.data_list: List[Any] = [] self.current_index: Optional[int] = None self.current_row: Optional[Any] = None self._thread: Optional[QThread] = None self._worker: Optional[_AIWorker] = None self._busy: bool = False self._build_ui() self.refresh_status() # 그래프 커서 이동(동기화 매니저 이벤트) 기반으로 현재 포인트 갱신 sync_manager.time_changed.connect(self._on_time_changed) def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(12, 12, 12, 12) root.setSpacing(10) # 상단 상태/컨트롤 top = QHBoxLayout() self.lbl_status = QLabel("AI: (미설정)") self.lbl_status.setStyleSheet("color: #ccc; font-weight: bold;") self.provider_combo = QComboBox() self.provider_combo.setMinimumWidth(220) self.provider_combo.currentIndexChanged.connect(self._on_provider_changed) btn_settings = QPushButton("🔑 설정") btn_settings.clicked.connect(self._open_settings) top.addWidget(self.lbl_status, stretch=1) top.addWidget(QLabel("프로바이더:")) top.addWidget(self.provider_combo) top.addWidget(btn_settings) root.addLayout(top) # 데이터 범위 선택 range_box = QGroupBox("분석 범위") range_layout = QHBoxLayout(range_box) range_layout.addWidget(QLabel("커서 기준 ±(초):")) self.spin_window_sec = QSpinBox() self.spin_window_sec.setRange(10, 3600) self.spin_window_sec.setValue(300) range_layout.addWidget(self.spin_window_sec) range_layout.addStretch() root.addWidget(range_box) # 사용자 입력(열번/호차/편성/증상) user_box = QGroupBox("사용자 입력 (운행/편성/증상)") user_layout = QVBoxLayout(user_box) row1 = QHBoxLayout() self.in_train_no = QLineEdit() self.in_train_no.setPlaceholderText("열번 (예: 101)") self.in_car_no = QLineEdit() self.in_car_no.setPlaceholderText("호차 (예: 3)") self.in_formation = QLineEdit() self.in_formation.setPlaceholderText("편성 (예: 8량/4M4T 등)") row1.addWidget(QLabel("열번:")) row1.addWidget(self.in_train_no) row1.addWidget(QLabel("호차:")) row1.addWidget(self.in_car_no) row1.addWidget(QLabel("편성:")) row1.addWidget(self.in_formation) user_layout.addLayout(row1) self.in_symptoms = QTextEdit() # '증상'을 자유 질의/요청까지 포함하는 단일 입력으로 정리(중복 제거) self.in_symptoms.setPlaceholderText( "증상/요청을 입력하세요.\n" "예) 정위치 불가로 추정됨. 커서 시점 전후에서 문/PSD 불일치 여부와 원인 후보를 추론해줘.\n" "예) 비상제동 개입 구간이 있다면 타임라인(사용자 표시) 기준으로 원인 후보를 분석해줘." ) self.in_symptoms.setFixedHeight(90) user_layout.addWidget(QLabel("증상/요청:")) user_layout.addWidget(self.in_symptoms) kb_row = QHBoxLayout() self.lbl_kb = QLabel("정비지침서: 0건 로드") btn_kb_reload = QPushButton("📚 지침서 새로고침") btn_kb_reload.clicked.connect(self._reload_kb) kb_row.addWidget(self.lbl_kb, stretch=1) kb_row.addWidget(btn_kb_reload) user_layout.addLayout(kb_row) root.addWidget(user_box) # 버튼들 btn_row = QHBoxLayout() self.btn_diag_current = QPushButton("🧭 현재 포인트 진단") self.btn_diag_current.clicked.connect(self.run_current_diagnosis) self.btn_summarize = QPushButton("🧾 전체 요약") self.btn_summarize.clicked.connect(self.run_overall_summary) self.btn_infer = QPushButton("💬 증상/요청 추론") self.btn_infer.clicked.connect(self.run_symptom_inference) self.btn_clear = QPushButton("🧹 출력 지우기") self.btn_clear.clicked.connect(lambda: self.txt_output.clear()) btn_row.addWidget(self.btn_diag_current) btn_row.addWidget(self.btn_summarize) btn_row.addWidget(self.btn_infer) btn_row.addWidget(self.btn_clear) btn_row.addStretch() root.addLayout(btn_row) # 출력 self.txt_output = QTextEdit() self.txt_output.setReadOnly(True) self.txt_output.setPlaceholderText("AI 응답이 여기에 표시됩니다.") root.addWidget(QLabel("결과:")) root.addWidget(self.txt_output, stretch=1) self._reload_kb() def set_file_context(self, file_path: str): """현재 로드 중인 파일 경로 저장(자동 입력 추출용)""" self.file_path = file_path self._autofill_from_file_context(force=False) def set_data(self, data_list: List[Any]): self.data_list = data_list or [] # 커서를 아직 안 움직여도 AI가 동작하도록 기본값(첫 레코드)을 잡아둠 self.current_index = 0 if self.data_list else None self.current_row = self.data_list[0] if self.data_list else None self._autofill_from_data(force=False) self.refresh_status() def refresh_status(self): """설정/클라이언트 상태를 UI에 반영""" settings = get_settings_manager().ai_settings current = settings.get_current_provider() # 콤보 재구성(키가 설정된 프로바이더만) self.provider_combo.blockSignals(True) self.provider_combo.clear() configured = [] for p in ["openai", "openrouter", "gemini", "xai"]: if settings.is_provider_configured(p): configured.append(p) # 아무것도 없으면 전체 목록 보여주되 선택만 못 하게 안내 providers_to_show = configured if configured else ["openai", "openrouter", "gemini", "xai"] for p in providers_to_show: try: name = { "openai": "OpenAI", "openrouter": "OpenRouter", "gemini": "Google Gemini", "xai": "xAI (Grok)", }[p] except Exception: name = p self.provider_combo.addItem(name, p) if current: idx = self.provider_combo.findData(current) if idx >= 0: self.provider_combo.setCurrentIndex(idx) self.provider_combo.blockSignals(False) client = get_ai_client() st = client.get_status() if st.get("ready"): self.lbl_status.setText(f"AI: {st.get('provider')} / {st.get('model')} (키: {st.get('api_key')})") self.lbl_status.setStyleSheet("color: #00D084; font-weight: bold;") else: self.lbl_status.setText("AI: 미준비 (설정에서 API 키 입력 필요)") self.lbl_status.setStyleSheet("color: #FFAB00; font-weight: bold;") def _reload_kb(self): kb = get_maintenance_kb() kb.reload() self.lbl_kb.setText(f"정비지침서: {kb.count()}건 로드") def _autofill_from_data(self, force: bool = False): """데이터(레코드)에서 열번 등 자동 채움(사용자 수정 가능)""" if not self.data_list: return first = self.data_list[0] trainno = str(getattr(first, "trainno", "") or "").strip() if trainno and (force or not (self.in_train_no.text() or "").strip()): self.in_train_no.setText(trainno) # 호차/편성은 데이터에 없을 수 있어 파일/문자열 기반 보완 self._autofill_from_file_context(force=force) def _autofill_from_file_context(self, force: bool = False): """파일명에서 호차/편성 힌트 추출(없으면 비움)""" if not self.file_path: return name = str(self.file_path).replace("\\", "/").split("/")[-1] up = name.upper() # 예: ..._TC2_... -> 호차=2, 편성 힌트=TC2(+ HCR/TCR 등) car_no = "" formation = "" import re m = re.search(r"\bTC(\d+)\b", up) if m: car_no = m.group(1) formation = f"TC{car_no}" # HCR/TCR 등 운영 힌트를 편성에 함께 기록 hints = [] for h in ["HCR", "TCR"]: if h in up: hints.append(h) if hints: formation = (formation + " " + " ".join(hints)).strip() if car_no and (force or not (self.in_car_no.text() or "").strip()): self.in_car_no.setText(car_no) if formation and (force or not (self.in_formation.text() or "").strip()): self.in_formation.setText(formation) def _open_settings(self): dlg = APIKeyDialog(self) dlg.settings_changed.connect(self._after_settings_changed) dlg.exec() def _after_settings_changed(self): # MainWindow 쪽에서도 init을 하지만, 여기서도 즉시 반영 self.refresh_status() def _on_provider_changed(self, _idx: int): provider = self.provider_combo.currentData() if not provider: return settings_mgr = get_settings_manager() settings_mgr.ai_settings.set_current_provider(provider) settings_mgr.save() # 현재 프로바이더로 클라이언트 재초기화 시도 try: from app.ai import AIProviderType api_key = settings_mgr.ai_settings.get_api_key(provider) model = settings_mgr.ai_settings.get_model(provider) if api_key: get_ai_client().set_provider(AIProviderType(provider), api_key, model) except Exception as e: self._append_output(f"[경고] 프로바이더 초기화 실패: {e}") self.refresh_status() def _on_time_changed(self, index: int, data: Any, source_id: str): # sync 이벤트는 반대 패널에도 오므로, 내 panel_id와 무관하게 “현재 포인트”로만 사용 # 단, 현재 탭이 어느 패널에 붙었는지를 기준으로 source_id가 같은 경우에만 갱신 if source_id != self.panel_id: return self.current_index = index self.current_row = data def _append_output(self, text: str): self.txt_output.append(text) self.txt_output.verticalScrollBar().setValue(self.txt_output.verticalScrollBar().maximum()) def _build_prompt_common(self) -> str: stats = _compute_basic_stats(self.data_list) # 그래프 맥락(선택 신호/가시 구간/타임라인)을 같이 포함 graph_ctx = self._get_graph_context() user_ctx = self._get_user_context() kb_ctx = self._get_kb_context() return ( "당신은 철도 차량 MMI 로그를 분석하는 진단 엔지니어 AI입니다.\n" "출력은 한국어로, 근거를 데이터 필드명 기준으로 간단히 제시하세요.\n\n" f"[데이터 요약]\n{json.dumps(stats, ensure_ascii=False, indent=2)}\n" f"\n[그래프 컨텍스트(선택 신호/표시 구간/타임라인)]\n{json.dumps(graph_ctx, ensure_ascii=False, indent=2)}\n" f"\n[사용자 입력(운행/편성/증상)]\n{json.dumps(user_ctx, ensure_ascii=False, indent=2)}\n" + (f"\n[정비지침서 발췌]\n{kb_ctx}\n" if kb_ctx else "") ) def _get_user_context(self) -> Dict[str, Any]: return { "train_no": (self.in_train_no.text() or "").strip(), "car_no": (self.in_car_no.text() or "").strip(), "formation": (self.in_formation.text() or "").strip(), "symptoms": (self.in_symptoms.toPlainText() or "").strip(), } def _get_graph_context(self) -> Dict[str, Any]: if not self.graph_view: return {} try: # GraphView에 helper가 있으면 사용 if hasattr(self.graph_view, "get_ai_context"): return self.graph_view.get_ai_context(self.current_index) # fallback: 최소 정보만 수집 ctx: Dict[str, Any] = {} if hasattr(self.graph_view, "axis_x"): mn = self.graph_view.axis_x.min().toMSecsSinceEpoch() mx = self.graph_view.axis_x.max().toMSecsSinceEpoch() ctx["visible_range_ms"] = {"min": mn, "max": mx, "seconds": (mx - mn) / 1000.0} if hasattr(self.graph_view, "signals_map"): visible = [] for k, info in self.graph_view.signals_map.items(): try: if info.get("series") and info["series"].isVisible(): visible.append({"key": k, "name": info.get("base", k)}) except Exception: pass ctx["visible_signals"] = visible if hasattr(self.graph_view, "drawing_objects"): ctx["drawing_objects"] = self._serialize_drawing_objects(self.graph_view.drawing_objects) return ctx except Exception: return {} def _serialize_drawing_objects(self, objs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: out = [] if not objs: return out for o in objs: if not isinstance(o, dict): continue t = o.get("type") if t not in ("timeline", "text", "rect", "circle", "line"): continue item = dict(o) # QColor / Qt.PenStyle 등 직렬화 불가 요소 변환 col = item.get("color") try: if hasattr(col, "name"): item["color"] = col.name() except Exception: pass style = item.get("style") try: if hasattr(style, "value"): item["style"] = style.value except Exception: pass out.append(item) return out def _get_kb_context(self) -> str: kb = get_maintenance_kb() user = self._get_user_context() query = " ".join([user.get("symptoms", "")]).strip() if not query: return "" hits = kb.search(query, top_k=3) if not hits: return "" # 프롬프트 길이 제한을 위해 짧게 lines = [] for h in hits: title = h.get("title", "untitled") snippet = h.get("snippet", "") lines.append(f"- {title}: {snippet}") return "\n".join(lines)[:2500] def _slice_window(self) -> Tuple[List[Any], int, int]: if not self.data_list: return [], 0, 0 if self.current_index is None: return self.data_list, 0, len(self.data_list) - 1 half = int(self.spin_window_sec.value()) start = max(0, self.current_index - half) end = min(len(self.data_list) - 1, self.current_index + half) return self.data_list[start : end + 1], start, end def _run_prompt(self, prompt: str): # 이미 실행 중이면 차단 (QThread deleteLater로 인한 래퍼 삭제 문제 회피) if self._busy: QMessageBox.information(self, "AI", "이미 요청이 실행 중입니다. 완료 후 다시 시도하세요.") return self.btn_diag_current.setEnabled(False) self.btn_summarize.setEnabled(False) self.btn_infer.setEnabled(False) self._append_output("\n---\n[AI 요청] 실행 중...") self._busy = True self._thread = QThread(self) self._worker = _AIWorker(prompt) self._worker.moveToThread(self._thread) self._thread.started.connect(self._worker.run) self._worker.finished.connect(self._on_ai_finished) self._worker.failed.connect(self._on_ai_failed) # 정리 self._worker.finished.connect(self._thread.quit) self._worker.failed.connect(self._thread.quit) self._thread.finished.connect(self._on_thread_finished) self._thread.start() def _on_thread_finished(self): # thread 객체가 deleteLater 등으로 사라져도 다음 실행에 영향 없도록 참조 정리 self._thread = None self._worker = None self._busy = False def _on_ai_finished(self, text: str): self._append_output(text.strip()) self.btn_diag_current.setEnabled(True) self.btn_summarize.setEnabled(True) self.btn_infer.setEnabled(True) # 종료/정리는 thread.finished에서 처리 def _on_ai_failed(self, err: str): self._append_output(f"[오류] {err}") self.btn_diag_current.setEnabled(True) self.btn_summarize.setEnabled(True) self.btn_infer.setEnabled(True) # 종료/정리는 thread.finished에서 처리 def run_overall_summary(self): if not self.data_list: QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.") return prompt = ( self._build_prompt_common() + "\n[요청]\n" "- 전체 운행 흐름(속도/DTG/문/ATC/경고) 관점에서 요약\n" "- 이상 징후(과속경고, 문 상태 불일치, TASC/DTG 이상 등) 후보를 3~7개로 제시\n" "- 각 후보에 대해 '가능 원인/추가 확인 포인트'를 짧게 제안\n" ) self._run_prompt(prompt) def run_current_diagnosis(self): if not self.data_list: QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.") return window_rows, s, e = self._slice_window() current = _safe_to_dict(self.current_row) window_stats = _compute_basic_stats(window_rows) prompt = ( self._build_prompt_common() + f"\n[커서 정보]\nindex={self.current_index}\n" + f"\n[커서 레코드]\n{json.dumps(current, ensure_ascii=False, indent=2)}\n" + f"\n[커서 주변({s}~{e}) 통계]\n{json.dumps(window_stats, ensure_ascii=False, indent=2)}\n" + "\n[요청]\n" "- 커서 시점의 상태를 설명하고, 주변 구간에서의 변화/이상 징후를 진단\n" "- 문/PSD/ATC/TASC/DTG/과속경고 관련 이슈 가능성을 우선순위로 제시\n" ) self._run_prompt(prompt) def run_symptom_inference(self): """증상/요청(단일 입력) 기반 추론 실행""" if not self.data_list: QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.") return stext = (self.in_symptoms.toPlainText() or "").strip() if not stext: QMessageBox.information(self, "AI", "증상/요청을 입력하세요.") return window_rows, s, e = self._slice_window() window_stats = _compute_basic_stats(window_rows) current = _safe_to_dict(self.current_row) prompt = ( self._build_prompt_common() + f"\n[커서 정보]\nindex={self.current_index}\n" + f"\n[커서 레코드]\n{json.dumps(current, ensure_ascii=False, indent=2)}\n" + f"\n[커서 주변({s}~{e}) 통계]\n{json.dumps(window_stats, ensure_ascii=False, indent=2)}\n" + f"\n[사용자 증상/요청]\n{stext}\n" + "\n[요청]\n- 입력된 증상/요청을 기준으로 원인 후보와 근거(필드명)를 제시하고, 추가 확인 포인트/정비 접근을 제안\n" ) self._run_prompt(prompt) def run_range_analysis(self, start_idx: int, end_idx: int, start_ms: int, end_ms: int): """사용자 선택 구간 분석 요청""" if not self.data_list: QMessageBox.information(self, "AI", "먼저 로그 파일을 로드하세요.") return if start_idx < 0 or end_idx < 0 or start_idx >= len(self.data_list) or end_idx >= len(self.data_list): QMessageBox.information(self, "AI", "선택 구간이 올바르지 않습니다.") return if start_idx > end_idx: start_idx, end_idx = end_idx, start_idx rows = self.data_list[start_idx:end_idx + 1] window_stats = _compute_basic_stats(rows) start_rec = _safe_to_dict(rows[0]) if rows else {} end_rec = _safe_to_dict(rows[-1]) if rows else {} prompt = ( self._build_prompt_common() + f"\n[선택 구간]\nindex={start_idx}~{end_idx}\n" + f"time_ms={start_ms}~{end_ms}\n" + f"\n[시작 레코드]\n{json.dumps(start_rec, ensure_ascii=False, indent=2)}\n" + f"\n[끝 레코드]\n{json.dumps(end_rec, ensure_ascii=False, indent=2)}\n" + f"\n[구간 통계]\n{json.dumps(window_stats, ensure_ascii=False, indent=2)}\n" + "\n[요청]\n" + "- 선택 구간의 운행 흐름과 특이점(속도/DTG/문/ATC/경고)을 요약\n" + "- 이상 징후 후보와 원인 추정, 추가 확인 포인트를 제시\n" ) self._run_prompt(prompt)