650 lines
25 KiB
Python
650 lines
25 KiB
Python
"""
|
|
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)
|
|
|