first commit
This commit is contained in:
commit
ef3d9212b5
|
|
@ -0,0 +1,3 @@
|
|||
venv/
|
||||
build/
|
||||
dist/
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,32 @@
|
|||
"""
|
||||
AI 모듈 패키지
|
||||
AI 프로바이더 관리 및 통합 인터페이스를 제공합니다.
|
||||
"""
|
||||
|
||||
from .base import AIProviderType, AIMessage, AIResponse, BaseAIProvider
|
||||
from .ai_client import AIClient, get_ai_client
|
||||
from .providers import (
|
||||
OpenAIProvider,
|
||||
OpenRouterProvider,
|
||||
GeminiProvider,
|
||||
XAIProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core
|
||||
'AIProviderType',
|
||||
'AIMessage',
|
||||
'AIResponse',
|
||||
'BaseAIProvider',
|
||||
|
||||
# Client
|
||||
'AIClient',
|
||||
'get_ai_client',
|
||||
|
||||
# Providers
|
||||
'OpenAIProvider',
|
||||
'OpenRouterProvider',
|
||||
'GeminiProvider',
|
||||
'XAIProvider',
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,340 @@
|
|||
"""
|
||||
AI Client - AI 프로바이더 관리 및 통합 인터페이스
|
||||
|
||||
사용 예시:
|
||||
from app.ai.ai_client import AIClient, AIProviderType
|
||||
|
||||
# 클라이언트 생성
|
||||
client = AIClient()
|
||||
|
||||
# 프로바이더 설정
|
||||
client.set_provider(AIProviderType.OPENAI, api_key="sk-...")
|
||||
|
||||
# 또는 설정에서 자동 로드
|
||||
client.load_from_settings()
|
||||
|
||||
# 채팅 요청
|
||||
response = client.chat("안녕하세요!")
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, List, Type
|
||||
from .base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
|
||||
from .providers import OpenAIProvider, OpenRouterProvider, GeminiProvider, XAIProvider
|
||||
|
||||
|
||||
class AIClient:
|
||||
"""
|
||||
AI 클라이언트 매니저
|
||||
여러 AI 프로바이더를 관리하고 통합 인터페이스를 제공합니다.
|
||||
"""
|
||||
|
||||
# 프로바이더 타입별 클래스 매핑
|
||||
PROVIDER_CLASSES: Dict[AIProviderType, Type[BaseAIProvider]] = {
|
||||
AIProviderType.OPENAI: OpenAIProvider,
|
||||
AIProviderType.OPENROUTER: OpenRouterProvider,
|
||||
AIProviderType.GEMINI: GeminiProvider,
|
||||
AIProviderType.XAI: XAIProvider,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._current_provider: Optional[BaseAIProvider] = None
|
||||
self._providers: Dict[AIProviderType, BaseAIProvider] = {}
|
||||
self._settings = None
|
||||
|
||||
@property
|
||||
def current_provider(self) -> Optional[BaseAIProvider]:
|
||||
"""현재 활성화된 프로바이더"""
|
||||
return self._current_provider
|
||||
|
||||
@property
|
||||
def current_provider_type(self) -> Optional[AIProviderType]:
|
||||
"""현재 프로바이더 타입"""
|
||||
if self._current_provider:
|
||||
return self._current_provider.provider_type
|
||||
return None
|
||||
|
||||
@property
|
||||
def available_provider_types(self) -> List[AIProviderType]:
|
||||
"""사용 가능한 프로바이더 타입 목록"""
|
||||
return list(self.PROVIDER_CLASSES.keys())
|
||||
|
||||
def get_provider_name(self, provider_type: AIProviderType) -> str:
|
||||
"""프로바이더 타입에 해당하는 표시 이름 반환"""
|
||||
names = {
|
||||
AIProviderType.OPENAI: "OpenAI",
|
||||
AIProviderType.OPENROUTER: "OpenRouter",
|
||||
AIProviderType.GEMINI: "Google Gemini",
|
||||
AIProviderType.XAI: "xAI (Grok)",
|
||||
}
|
||||
return names.get(provider_type, provider_type.value)
|
||||
|
||||
def set_provider(
|
||||
self,
|
||||
provider_type: AIProviderType,
|
||||
api_key: str,
|
||||
model: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
프로바이더 설정 및 활성화
|
||||
|
||||
Args:
|
||||
provider_type: 프로바이더 타입
|
||||
api_key: API 키
|
||||
model: 사용할 모델 (None이면 기본 모델)
|
||||
|
||||
Returns:
|
||||
초기화 성공 여부
|
||||
"""
|
||||
provider_class = self.PROVIDER_CLASSES.get(provider_type)
|
||||
if not provider_class:
|
||||
raise ValueError(f"지원하지 않는 프로바이더: {provider_type}")
|
||||
|
||||
# 프로바이더 인스턴스 생성
|
||||
provider = provider_class(api_key=api_key, model=model)
|
||||
|
||||
# 초기화 시도
|
||||
if provider.initialize():
|
||||
self._providers[provider_type] = provider
|
||||
self._current_provider = provider
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def switch_provider(self, provider_type: AIProviderType) -> bool:
|
||||
"""
|
||||
이미 등록된 프로바이더로 전환
|
||||
|
||||
Args:
|
||||
provider_type: 전환할 프로바이더 타입
|
||||
|
||||
Returns:
|
||||
전환 성공 여부
|
||||
"""
|
||||
if provider_type in self._providers:
|
||||
self._current_provider = self._providers[provider_type]
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_api_key(self, provider_type: AIProviderType, api_key: str) -> bool:
|
||||
"""
|
||||
프로바이더의 API 키 업데이트
|
||||
|
||||
Args:
|
||||
provider_type: 프로바이더 타입
|
||||
api_key: 새 API 키
|
||||
|
||||
Returns:
|
||||
업데이트 및 재초기화 성공 여부
|
||||
"""
|
||||
if provider_type in self._providers:
|
||||
provider = self._providers[provider_type]
|
||||
provider.update_api_key(api_key)
|
||||
return provider.initialize()
|
||||
return False
|
||||
|
||||
def update_model(self, model: str) -> bool:
|
||||
"""
|
||||
현재 프로바이더의 모델 변경
|
||||
|
||||
Args:
|
||||
model: 새 모델명
|
||||
|
||||
Returns:
|
||||
변경 성공 여부
|
||||
"""
|
||||
if self._current_provider:
|
||||
self._current_provider.model = model
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_available_models(self) -> List[str]:
|
||||
"""현재 프로바이더의 사용 가능한 모델 목록"""
|
||||
if self._current_provider:
|
||||
return self._current_provider.available_models
|
||||
return []
|
||||
|
||||
def load_from_settings(self, settings: 'AISettings') -> bool:
|
||||
"""
|
||||
설정에서 프로바이더 정보 로드
|
||||
|
||||
Args:
|
||||
settings: AISettings 인스턴스
|
||||
|
||||
Returns:
|
||||
로드 성공 여부
|
||||
"""
|
||||
self._settings = settings
|
||||
|
||||
# 현재 선택된 프로바이더 가져오기
|
||||
current_type = settings.get_current_provider()
|
||||
if not current_type:
|
||||
return False
|
||||
|
||||
# API 키 가져오기
|
||||
api_key = settings.get_api_key(current_type)
|
||||
if not api_key:
|
||||
return False
|
||||
|
||||
# 모델 가져오기
|
||||
model = settings.get_model(current_type)
|
||||
|
||||
return self.set_provider(current_type, api_key, model)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
user_message: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""
|
||||
간편 채팅 메서드 (동기)
|
||||
|
||||
Args:
|
||||
user_message: 사용자 메시지
|
||||
system_prompt: 시스템 프롬프트 (선택)
|
||||
temperature: 응답 다양성
|
||||
max_tokens: 최대 토큰
|
||||
|
||||
Returns:
|
||||
AI 응답
|
||||
"""
|
||||
if not self._current_provider:
|
||||
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append(AIMessage(role="system", content=system_prompt))
|
||||
messages.append(AIMessage(role="user", content=user_message))
|
||||
|
||||
return self._current_provider.chat_sync(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
async def chat_async(
|
||||
self,
|
||||
user_message: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""
|
||||
간편 채팅 메서드 (비동기)
|
||||
|
||||
Args:
|
||||
user_message: 사용자 메시지
|
||||
system_prompt: 시스템 프롬프트 (선택)
|
||||
temperature: 응답 다양성
|
||||
max_tokens: 최대 토큰
|
||||
|
||||
Returns:
|
||||
AI 응답
|
||||
"""
|
||||
if not self._current_provider:
|
||||
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append(AIMessage(role="system", content=system_prompt))
|
||||
messages.append(AIMessage(role="user", content=user_message))
|
||||
|
||||
return await self._current_provider.chat(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def chat_with_history(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""
|
||||
대화 히스토리와 함께 채팅 (동기)
|
||||
|
||||
Args:
|
||||
messages: 메시지 리스트
|
||||
temperature: 응답 다양성
|
||||
max_tokens: 최대 토큰
|
||||
|
||||
Returns:
|
||||
AI 응답
|
||||
"""
|
||||
if not self._current_provider:
|
||||
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
|
||||
|
||||
return self._current_provider.chat_sync(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
async def chat_with_history_async(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""
|
||||
대화 히스토리와 함께 채팅 (비동기)
|
||||
|
||||
Args:
|
||||
messages: 메시지 리스트
|
||||
temperature: 응답 다양성
|
||||
max_tokens: 최대 토큰
|
||||
|
||||
Returns:
|
||||
AI 응답
|
||||
"""
|
||||
if not self._current_provider:
|
||||
raise RuntimeError("프로바이더가 설정되지 않았습니다.")
|
||||
|
||||
return await self._current_provider.chat(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""프로바이더가 사용 준비되었는지 확인"""
|
||||
return self._current_provider is not None and self._current_provider._is_initialized
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""현재 상태 정보 반환"""
|
||||
if self._current_provider:
|
||||
return {
|
||||
"ready": self.is_ready(),
|
||||
"provider": self._current_provider.provider_name,
|
||||
"model": self._current_provider.model,
|
||||
"api_key": self._current_provider.api_key, # 마스킹됨
|
||||
}
|
||||
return {
|
||||
"ready": False,
|
||||
"provider": None,
|
||||
"model": None,
|
||||
"api_key": None,
|
||||
}
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
_ai_client_instance: Optional[AIClient] = None
|
||||
|
||||
|
||||
def get_ai_client() -> AIClient:
|
||||
"""AI 클라이언트 싱글톤 인스턴스 반환"""
|
||||
global _ai_client_instance
|
||||
if _ai_client_instance is None:
|
||||
_ai_client_instance = AIClient()
|
||||
return _ai_client_instance
|
||||
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"""
|
||||
AI Provider 기본 클래스 정의
|
||||
모든 AI 프로바이더는 이 기본 클래스를 상속받아 구현합니다.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AIProviderType(Enum):
|
||||
"""지원하는 AI 프로바이더 타입"""
|
||||
OPENAI = "openai"
|
||||
OPENROUTER = "openrouter"
|
||||
GEMINI = "gemini"
|
||||
XAI = "xai"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIMessage:
|
||||
"""AI 메시지 데이터 클래스"""
|
||||
role: str # 'system', 'user', 'assistant'
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIResponse:
|
||||
"""AI 응답 데이터 클래스"""
|
||||
content: str
|
||||
model: str
|
||||
provider: str
|
||||
usage: Optional[Dict[str, int]] = None
|
||||
raw_response: Optional[Any] = None
|
||||
|
||||
|
||||
class BaseAIProvider(ABC):
|
||||
"""
|
||||
AI Provider 추상 기본 클래스
|
||||
모든 AI 프로바이더는 이 클래스를 상속받아 구현해야 합니다.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: Optional[str] = None):
|
||||
"""
|
||||
Args:
|
||||
api_key: API 키
|
||||
model: 사용할 모델명 (None이면 기본 모델 사용)
|
||||
"""
|
||||
self._api_key = api_key
|
||||
self._model = model or self.default_model
|
||||
self._is_initialized = False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider_type(self) -> AIProviderType:
|
||||
"""프로바이더 타입 반환"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider_name(self) -> str:
|
||||
"""프로바이더 이름 반환"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def default_model(self) -> str:
|
||||
"""기본 모델명 반환"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def available_models(self) -> List[str]:
|
||||
"""사용 가능한 모델 목록 반환"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""현재 사용 중인 모델명"""
|
||||
return self._model
|
||||
|
||||
@model.setter
|
||||
def model(self, value: str):
|
||||
"""모델 변경"""
|
||||
self._model = value
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
"""API 키 (마스킹된 값)"""
|
||||
if self._api_key:
|
||||
return self._api_key[:8] + "..." + self._api_key[-4:]
|
||||
return ""
|
||||
|
||||
def update_api_key(self, api_key: str):
|
||||
"""API 키 업데이트"""
|
||||
self._api_key = api_key
|
||||
self._is_initialized = False
|
||||
|
||||
@abstractmethod
|
||||
def initialize(self) -> bool:
|
||||
"""
|
||||
프로바이더 초기화 (클라이언트 생성 등)
|
||||
Returns:
|
||||
초기화 성공 여부
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""
|
||||
채팅 요청 (비동기)
|
||||
|
||||
Args:
|
||||
messages: 대화 메시지 리스트
|
||||
temperature: 응답 다양성 (0.0 ~ 1.0)
|
||||
max_tokens: 최대 토큰 수
|
||||
**kwargs: 추가 파라미터
|
||||
|
||||
Returns:
|
||||
AI 응답
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def chat_sync(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""
|
||||
채팅 요청 (동기)
|
||||
|
||||
Args:
|
||||
messages: 대화 메시지 리스트
|
||||
temperature: 응답 다양성 (0.0 ~ 1.0)
|
||||
max_tokens: 최대 토큰 수
|
||||
**kwargs: 추가 파라미터
|
||||
|
||||
Returns:
|
||||
AI 응답
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate_api_key(self) -> bool:
|
||||
"""API 키 유효성 검사 (간단한 형식 체크)"""
|
||||
return bool(self._api_key and len(self._api_key) > 10)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}(model={self._model}, initialized={self._is_initialized})"
|
||||
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import polars as pl
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.core.reference_data import ref_data
|
||||
|
||||
class MMIAIInterface:
|
||||
"""
|
||||
Interface for AI to interact with MMI Data.
|
||||
Provides tools and data access methods.
|
||||
"""
|
||||
|
||||
def __init__(self, parquet_path: str):
|
||||
self.parquet_path = parquet_path
|
||||
self.df: Optional[pl.DataFrame] = None
|
||||
self._load_data()
|
||||
|
||||
def _load_data(self):
|
||||
try:
|
||||
self.df = pl.read_parquet(self.parquet_path)
|
||||
except Exception as e:
|
||||
print(f"[AI Interface] Load Error: {e}")
|
||||
self.df = None
|
||||
|
||||
def get_tools_schema(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns the JSON schema for tools available to the AI.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_error_stats",
|
||||
"description": "Get statistics of errors (e.g., ATC status, Warnings) for the current log.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_station_event",
|
||||
"description": "Find events happening at a specific station.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"station_name": {"type": "string", "description": "Name of the station (e.g., '범어사')"}
|
||||
},
|
||||
"required": ["station_name"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
def get_error_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Tool: Get Error Statistics
|
||||
"""
|
||||
if self.df is None:
|
||||
return {"error": "No data loaded"}
|
||||
|
||||
# Example: Count ATC Status != 'ATC ACTIVE' (64)
|
||||
# Note: atc_byte 64 is ACTIVE.
|
||||
# We need to filter where system_id == 1 (Main) usually?
|
||||
# Let's return stats for Main system (1)
|
||||
|
||||
main_df = self.df.filter(pl.col("system_id") == 1)
|
||||
total_rows = len(main_df)
|
||||
|
||||
# Count non-active frames
|
||||
# 64 is ATC ACTIVE.
|
||||
non_active_count = main_df.filter(pl.col("atc_byte") != 64).height
|
||||
|
||||
return {
|
||||
"total_frames": total_rows,
|
||||
"non_active_frames": non_active_count,
|
||||
"active_ratio": (total_rows - non_active_count) / total_rows if total_rows > 0 else 0
|
||||
}
|
||||
|
||||
def search_station_event(self, station_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Tool: Search events at a station
|
||||
"""
|
||||
if self.df is None:
|
||||
return {"error": "No data loaded"}
|
||||
|
||||
# Filter by station name
|
||||
# We assume 'station_name' column exists (added by FastParser)
|
||||
station_df = self.df.filter(pl.col("station_name") == station_name)
|
||||
|
||||
if station_df.is_empty():
|
||||
return {"message": f"No events found at {station_name}"}
|
||||
|
||||
# Return summary of that station visit
|
||||
# e.g. avg speed, time range
|
||||
return {
|
||||
"station": station_name,
|
||||
"visit_count": station_df.height,
|
||||
"avg_speed": station_df["trainspeed"].mean(),
|
||||
"start_time": str(station_df["timestamp"].min()),
|
||||
"end_time": str(station_df["timestamp"].max())
|
||||
}
|
||||
|
||||
def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Dispatcher for tool execution.
|
||||
"""
|
||||
if tool_name == "get_error_stats":
|
||||
return self.get_error_stats()
|
||||
elif tool_name == "search_station_event":
|
||||
return self.search_station_event(arguments.get("station_name", ""))
|
||||
else:
|
||||
return {"error": "Unknown tool"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
# LangChain / Ollama Integration
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
정비지침서(또는 추가 지식) JSON 로딩/검색 기반
|
||||
|
||||
목표:
|
||||
- 사용자가 JSON 형태로 정비 지침서를 추가해두면 앱이 읽어서 AI 프롬프트에 관련 발췌를 넣을 수 있게 함
|
||||
|
||||
기본 경로:
|
||||
- resources/maintenance_kb/*.json
|
||||
- ~/.mmi_analyzer/maintenance_kb/*.json
|
||||
|
||||
JSON 포맷(권장, 유연하게 파싱):
|
||||
[
|
||||
{
|
||||
"id": "door_001",
|
||||
"title": "출입문 불일치 대응",
|
||||
"tags": ["door", "psd", "fault"],
|
||||
"keywords": ["출입문", "PSD", "불일치", "DOORSTAT"],
|
||||
"content": "정비 절차 ...",
|
||||
"source": "정비지침서 v1.2"
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def _tokenize(text: str) -> List[str]:
|
||||
text = (text or "").lower()
|
||||
# 한글/영문/숫자 중심 토큰화 (아주 단순)
|
||||
tokens = re.findall(r"[0-9a-z가-힣]{2,}", text)
|
||||
return tokens
|
||||
|
||||
|
||||
@dataclass
|
||||
class KBDoc:
|
||||
doc_id: str
|
||||
title: str
|
||||
content: str
|
||||
tags: List[str]
|
||||
keywords: List[str]
|
||||
source: str
|
||||
raw: Dict[str, Any]
|
||||
|
||||
|
||||
class MaintenanceKnowledgeBase:
|
||||
def __init__(self):
|
||||
self._docs: List[KBDoc] = []
|
||||
|
||||
self._resource_dir = Path(__file__).resolve().parents[2] / "resources" / "maintenance_kb"
|
||||
self._user_dir = Path.home() / ".mmi_analyzer" / "maintenance_kb"
|
||||
|
||||
self._resource_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._user_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._docs)
|
||||
|
||||
def reload(self) -> None:
|
||||
self._docs = []
|
||||
for base in [self._resource_dir, self._user_dir]:
|
||||
for p in sorted(base.glob("*.json")):
|
||||
self._load_file(p)
|
||||
|
||||
def _load_file(self, path: Path) -> None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for i, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title") or item.get("name") or path.stem)
|
||||
content = str(item.get("content") or item.get("text") or "")
|
||||
tags = item.get("tags") or []
|
||||
keywords = item.get("keywords") or []
|
||||
source = str(item.get("source") or str(path.name))
|
||||
doc_id = str(item.get("id") or f"{path.stem}:{i}")
|
||||
if not content and item.get("steps"):
|
||||
# steps 배열을 content로 합치기
|
||||
try:
|
||||
content = "\n".join([str(s) for s in item.get("steps")])
|
||||
except Exception:
|
||||
content = ""
|
||||
|
||||
self._docs.append(
|
||||
KBDoc(
|
||||
doc_id=doc_id,
|
||||
title=title,
|
||||
content=content,
|
||||
tags=list(tags) if isinstance(tags, list) else [str(tags)],
|
||||
keywords=list(keywords) if isinstance(keywords, list) else [str(keywords)],
|
||||
source=source,
|
||||
raw=item,
|
||||
)
|
||||
)
|
||||
|
||||
def search(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
|
||||
q_tokens = set(_tokenize(query))
|
||||
if not q_tokens or not self._docs:
|
||||
return []
|
||||
|
||||
scored: List[Tuple[float, KBDoc]] = []
|
||||
for d in self._docs:
|
||||
hay = " ".join([d.title, d.content, " ".join(d.tags), " ".join(d.keywords)]).lower()
|
||||
h_tokens = _tokenize(hay)
|
||||
if not h_tokens:
|
||||
continue
|
||||
|
||||
# 단순 점수: 토큰 매칭 수 + 키워드/태그 가중치
|
||||
base = 0.0
|
||||
for t in q_tokens:
|
||||
if t in hay:
|
||||
base += 1.0
|
||||
for kw in d.keywords:
|
||||
if kw and kw.lower() in query.lower():
|
||||
base += 2.0
|
||||
scored.append((base, d))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, d in scored[: max(0, top_k)]:
|
||||
if score <= 0:
|
||||
continue
|
||||
snippet = d.content.strip().replace("\r\n", "\n")
|
||||
if len(snippet) > 600:
|
||||
snippet = snippet[:600] + "..."
|
||||
results.append(
|
||||
{
|
||||
"id": d.doc_id,
|
||||
"title": d.title,
|
||||
"score": score,
|
||||
"source": d.source,
|
||||
"snippet": snippet,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
_kb_singleton: Optional[MaintenanceKnowledgeBase] = None
|
||||
|
||||
|
||||
def get_maintenance_kb() -> MaintenanceKnowledgeBase:
|
||||
global _kb_singleton
|
||||
if _kb_singleton is None:
|
||||
_kb_singleton = MaintenanceKnowledgeBase()
|
||||
_kb_singleton.reload()
|
||||
return _kb_singleton
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
AI Providers 패키지
|
||||
각 AI 서비스 프로바이더 구현을 포함합니다.
|
||||
"""
|
||||
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .openrouter_provider import OpenRouterProvider
|
||||
from .gemini_provider import GeminiProvider
|
||||
from .xai_provider import XAIProvider
|
||||
|
||||
__all__ = [
|
||||
'OpenAIProvider',
|
||||
'OpenRouterProvider',
|
||||
'GeminiProvider',
|
||||
'XAIProvider'
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,156 @@
|
|||
"""
|
||||
Google Gemini Provider 구현
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
|
||||
|
||||
|
||||
class GeminiProvider(BaseAIProvider):
|
||||
"""Google Gemini API 프로바이더"""
|
||||
|
||||
AVAILABLE_MODELS = [
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-1.0-pro",
|
||||
]
|
||||
|
||||
def __init__(self, api_key: str, model: Optional[str] = None):
|
||||
super().__init__(api_key, model)
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def provider_type(self) -> AIProviderType:
|
||||
return AIProviderType.GEMINI
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "Google Gemini"
|
||||
|
||||
@property
|
||||
def default_model(self) -> str:
|
||||
return "gemini-1.5-flash"
|
||||
|
||||
@property
|
||||
def available_models(self) -> List[str]:
|
||||
return self.AVAILABLE_MODELS.copy()
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""Gemini 클라이언트 초기화"""
|
||||
if not self.validate_api_key():
|
||||
return False
|
||||
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
|
||||
genai.configure(api_key=self._api_key)
|
||||
self._client = genai
|
||||
self._is_initialized = True
|
||||
return True
|
||||
except ImportError:
|
||||
print("Google Generative AI 라이브러리가 설치되지 않았습니다. pip install google-generativeai 를 실행하세요.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Gemini 초기화 실패: {e}")
|
||||
return False
|
||||
|
||||
def _convert_messages_to_gemini(self, messages: List[AIMessage]) -> tuple:
|
||||
"""
|
||||
AIMessage를 Gemini 형식으로 변환
|
||||
Returns:
|
||||
(system_instruction, history, last_user_message)
|
||||
"""
|
||||
system_instruction = None
|
||||
history = []
|
||||
last_user_message = None
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "system":
|
||||
system_instruction = msg.content
|
||||
elif msg.role == "user":
|
||||
last_user_message = msg.content
|
||||
# 이전 user 메시지는 history에 추가
|
||||
if len([m for m in messages if m.role == "user"]) > 1:
|
||||
history.append({"role": "user", "parts": [msg.content]})
|
||||
elif msg.role == "assistant":
|
||||
history.append({"role": "model", "parts": [msg.content]})
|
||||
|
||||
# 마지막 user 메시지를 history에서 제거 (send_message로 별도 전송)
|
||||
if history and history[-1].get("role") == "user":
|
||||
history.pop()
|
||||
|
||||
return system_instruction, history, last_user_message
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""비동기 채팅 요청 (Gemini는 sync를 async로 래핑)"""
|
||||
# Gemini SDK는 기본적으로 동기식이므로 executor에서 실행
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.chat_sync(messages, temperature, max_tokens, **kwargs)
|
||||
)
|
||||
|
||||
def chat_sync(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""동기 채팅 요청"""
|
||||
if not self._is_initialized:
|
||||
if not self.initialize():
|
||||
raise RuntimeError("Gemini 초기화 실패")
|
||||
|
||||
system_instruction, history, last_user_message = self._convert_messages_to_gemini(messages)
|
||||
|
||||
# 모델 생성 설정
|
||||
generation_config = {
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
generation_config["max_output_tokens"] = max_tokens
|
||||
|
||||
# 모델 인스턴스 생성
|
||||
model = self._client.GenerativeModel(
|
||||
model_name=self._model,
|
||||
generation_config=generation_config,
|
||||
system_instruction=system_instruction
|
||||
)
|
||||
|
||||
# 대화 시작
|
||||
if history:
|
||||
chat = model.start_chat(history=history)
|
||||
else:
|
||||
chat = model.start_chat()
|
||||
|
||||
# 메시지 전송
|
||||
response = chat.send_message(last_user_message or "")
|
||||
|
||||
# 토큰 사용량 추출 시도
|
||||
usage = None
|
||||
if hasattr(response, 'usage_metadata'):
|
||||
usage = {
|
||||
"prompt_tokens": getattr(response.usage_metadata, 'prompt_token_count', 0),
|
||||
"completion_tokens": getattr(response.usage_metadata, 'candidates_token_count', 0),
|
||||
"total_tokens": getattr(response.usage_metadata, 'total_token_count', 0),
|
||||
}
|
||||
|
||||
return AIResponse(
|
||||
content=response.text,
|
||||
model=self._model,
|
||||
provider=self.provider_name,
|
||||
usage=usage,
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
OpenAI Provider 구현
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
|
||||
|
||||
|
||||
class OpenAIProvider(BaseAIProvider):
|
||||
"""OpenAI API 프로바이더"""
|
||||
|
||||
AVAILABLE_MODELS = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-3.5-turbo",
|
||||
"o1-preview",
|
||||
"o1-mini",
|
||||
]
|
||||
|
||||
def __init__(self, api_key: str, model: Optional[str] = None):
|
||||
super().__init__(api_key, model)
|
||||
self._client = None
|
||||
self._async_client = None
|
||||
|
||||
@property
|
||||
def provider_type(self) -> AIProviderType:
|
||||
return AIProviderType.OPENAI
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "OpenAI"
|
||||
|
||||
@property
|
||||
def default_model(self) -> str:
|
||||
return "gpt-4o-mini"
|
||||
|
||||
@property
|
||||
def available_models(self) -> List[str]:
|
||||
return self.AVAILABLE_MODELS.copy()
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""OpenAI 클라이언트 초기화"""
|
||||
if not self.validate_api_key():
|
||||
return False
|
||||
|
||||
try:
|
||||
from openai import OpenAI, AsyncOpenAI
|
||||
|
||||
self._client = OpenAI(api_key=self._api_key)
|
||||
self._async_client = AsyncOpenAI(api_key=self._api_key)
|
||||
self._is_initialized = True
|
||||
return True
|
||||
except ImportError:
|
||||
print("OpenAI 라이브러리가 설치되지 않았습니다. pip install openai 를 실행하세요.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"OpenAI 초기화 실패: {e}")
|
||||
return False
|
||||
|
||||
def _convert_messages(self, messages: List[AIMessage]) -> List[Dict[str, str]]:
|
||||
"""AIMessage를 OpenAI 형식으로 변환"""
|
||||
return [{"role": msg.role, "content": msg.content} for msg in messages]
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""비동기 채팅 요청"""
|
||||
if not self._is_initialized:
|
||||
if not self.initialize():
|
||||
raise RuntimeError("OpenAI 초기화 실패")
|
||||
|
||||
converted_messages = self._convert_messages(messages)
|
||||
|
||||
params = {
|
||||
"model": self._model,
|
||||
"messages": converted_messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
params["max_tokens"] = max_tokens
|
||||
params.update(kwargs)
|
||||
|
||||
response = await self._async_client.chat.completions.create(**params)
|
||||
|
||||
return AIResponse(
|
||||
content=response.choices[0].message.content,
|
||||
model=response.model,
|
||||
provider=self.provider_name,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
} if response.usage else None,
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
def chat_sync(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""동기 채팅 요청"""
|
||||
if not self._is_initialized:
|
||||
if not self.initialize():
|
||||
raise RuntimeError("OpenAI 초기화 실패")
|
||||
|
||||
converted_messages = self._convert_messages(messages)
|
||||
|
||||
params = {
|
||||
"model": self._model,
|
||||
"messages": converted_messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
params["max_tokens"] = max_tokens
|
||||
params.update(kwargs)
|
||||
|
||||
response = self._client.chat.completions.create(**params)
|
||||
|
||||
return AIResponse(
|
||||
content=response.choices[0].message.content,
|
||||
model=response.model,
|
||||
provider=self.provider_name,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
} if response.usage else None,
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
"""
|
||||
OpenRouter Provider 구현
|
||||
OpenRouter는 다양한 AI 모델에 대한 통합 API를 제공합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
|
||||
|
||||
|
||||
class OpenRouterProvider(BaseAIProvider):
|
||||
"""OpenRouter API 프로바이더"""
|
||||
|
||||
BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
AVAILABLE_MODELS = [
|
||||
"openai/gpt-4o",
|
||||
"openai/gpt-4o-mini",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-3-opus",
|
||||
"google/gemini-pro-1.5",
|
||||
"google/gemini-flash-1.5",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
"mistralai/mixtral-8x7b-instruct",
|
||||
"deepseek/deepseek-chat",
|
||||
]
|
||||
|
||||
def __init__(self, api_key: str, model: Optional[str] = None):
|
||||
super().__init__(api_key, model)
|
||||
self._client = None
|
||||
self._async_client = None
|
||||
|
||||
@property
|
||||
def provider_type(self) -> AIProviderType:
|
||||
return AIProviderType.OPENROUTER
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "OpenRouter"
|
||||
|
||||
@property
|
||||
def default_model(self) -> str:
|
||||
return "openai/gpt-4o-mini"
|
||||
|
||||
@property
|
||||
def available_models(self) -> List[str]:
|
||||
return self.AVAILABLE_MODELS.copy()
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""OpenRouter 클라이언트 초기화 (OpenAI 호환 API 사용)"""
|
||||
if not self.validate_api_key():
|
||||
return False
|
||||
|
||||
try:
|
||||
from openai import OpenAI, AsyncOpenAI
|
||||
|
||||
self._client = OpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=self.BASE_URL
|
||||
)
|
||||
self._async_client = AsyncOpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=self.BASE_URL
|
||||
)
|
||||
self._is_initialized = True
|
||||
return True
|
||||
except ImportError:
|
||||
print("OpenAI 라이브러리가 설치되지 않았습니다. pip install openai 를 실행하세요.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"OpenRouter 초기화 실패: {e}")
|
||||
return False
|
||||
|
||||
def _convert_messages(self, messages: List[AIMessage]) -> List[Dict[str, str]]:
|
||||
"""AIMessage를 OpenAI 형식으로 변환"""
|
||||
return [{"role": msg.role, "content": msg.content} for msg in messages]
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""비동기 채팅 요청"""
|
||||
if not self._is_initialized:
|
||||
if not self.initialize():
|
||||
raise RuntimeError("OpenRouter 초기화 실패")
|
||||
|
||||
converted_messages = self._convert_messages(messages)
|
||||
|
||||
params = {
|
||||
"model": self._model,
|
||||
"messages": converted_messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
params["max_tokens"] = max_tokens
|
||||
|
||||
# OpenRouter 특화 헤더는 extra_headers로 전달 가능
|
||||
params.update(kwargs)
|
||||
|
||||
response = await self._async_client.chat.completions.create(**params)
|
||||
|
||||
return AIResponse(
|
||||
content=response.choices[0].message.content,
|
||||
model=response.model,
|
||||
provider=self.provider_name,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
} if response.usage else None,
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
def chat_sync(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""동기 채팅 요청"""
|
||||
if not self._is_initialized:
|
||||
if not self.initialize():
|
||||
raise RuntimeError("OpenRouter 초기화 실패")
|
||||
|
||||
converted_messages = self._convert_messages(messages)
|
||||
|
||||
params = {
|
||||
"model": self._model,
|
||||
"messages": converted_messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
params["max_tokens"] = max_tokens
|
||||
params.update(kwargs)
|
||||
|
||||
response = self._client.chat.completions.create(**params)
|
||||
|
||||
return AIResponse(
|
||||
content=response.choices[0].message.content,
|
||||
model=response.model,
|
||||
provider=self.provider_name,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
} if response.usage else None,
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
"""
|
||||
xAI (Grok) Provider 구현
|
||||
xAI API는 OpenAI 호환 형식을 사용합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..base import BaseAIProvider, AIProviderType, AIMessage, AIResponse
|
||||
|
||||
|
||||
class XAIProvider(BaseAIProvider):
|
||||
"""xAI (Grok) API 프로바이더"""
|
||||
|
||||
BASE_URL = "https://api.x.ai/v1"
|
||||
|
||||
AVAILABLE_MODELS = [
|
||||
"grok-beta",
|
||||
"grok-2-1212",
|
||||
"grok-2-vision-1212",
|
||||
]
|
||||
|
||||
def __init__(self, api_key: str, model: Optional[str] = None):
|
||||
super().__init__(api_key, model)
|
||||
self._client = None
|
||||
self._async_client = None
|
||||
|
||||
@property
|
||||
def provider_type(self) -> AIProviderType:
|
||||
return AIProviderType.XAI
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "xAI (Grok)"
|
||||
|
||||
@property
|
||||
def default_model(self) -> str:
|
||||
return "grok-beta"
|
||||
|
||||
@property
|
||||
def available_models(self) -> List[str]:
|
||||
return self.AVAILABLE_MODELS.copy()
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""xAI 클라이언트 초기화 (OpenAI 호환 API 사용)"""
|
||||
if not self.validate_api_key():
|
||||
return False
|
||||
|
||||
try:
|
||||
from openai import OpenAI, AsyncOpenAI
|
||||
|
||||
self._client = OpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=self.BASE_URL
|
||||
)
|
||||
self._async_client = AsyncOpenAI(
|
||||
api_key=self._api_key,
|
||||
base_url=self.BASE_URL
|
||||
)
|
||||
self._is_initialized = True
|
||||
return True
|
||||
except ImportError:
|
||||
print("OpenAI 라이브러리가 설치되지 않았습니다. pip install openai 를 실행하세요.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"xAI 초기화 실패: {e}")
|
||||
return False
|
||||
|
||||
def _convert_messages(self, messages: List[AIMessage]) -> List[Dict[str, str]]:
|
||||
"""AIMessage를 OpenAI 형식으로 변환"""
|
||||
return [{"role": msg.role, "content": msg.content} for msg in messages]
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""비동기 채팅 요청"""
|
||||
if not self._is_initialized:
|
||||
if not self.initialize():
|
||||
raise RuntimeError("xAI 초기화 실패")
|
||||
|
||||
converted_messages = self._convert_messages(messages)
|
||||
|
||||
params = {
|
||||
"model": self._model,
|
||||
"messages": converted_messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
params["max_tokens"] = max_tokens
|
||||
params.update(kwargs)
|
||||
|
||||
response = await self._async_client.chat.completions.create(**params)
|
||||
|
||||
return AIResponse(
|
||||
content=response.choices[0].message.content,
|
||||
model=response.model,
|
||||
provider=self.provider_name,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
} if response.usage else None,
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
def chat_sync(
|
||||
self,
|
||||
messages: List[AIMessage],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> AIResponse:
|
||||
"""동기 채팅 요청"""
|
||||
if not self._is_initialized:
|
||||
if not self.initialize():
|
||||
raise RuntimeError("xAI 초기화 실패")
|
||||
|
||||
converted_messages = self._convert_messages(messages)
|
||||
|
||||
params = {
|
||||
"model": self._model,
|
||||
"messages": converted_messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
params["max_tokens"] = max_tokens
|
||||
params.update(kwargs)
|
||||
|
||||
response = self._client.chat.completions.create(**params)
|
||||
|
||||
return AIResponse(
|
||||
content=response.choices[0].message.content,
|
||||
model=response.model,
|
||||
provider=self.provider_name,
|
||||
usage={
|
||||
"prompt_tokens": response.usage.prompt_tokens,
|
||||
"completion_tokens": response.usage.completion_tokens,
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
} if response.usage else None,
|
||||
raw_response=response
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Signal Correlation Logic
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Pattern Matching Logic
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,255 @@
|
|||
"""
|
||||
통합 레퍼런스 데이터 관리자
|
||||
SQLite 로컬 캐시 + Supabase 동기화 준비
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Dict, Optional, List
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.reference_data_defaults import (
|
||||
DEFAULT_STATIONS,
|
||||
DEFAULT_ERROR_CODES,
|
||||
REFERENCE_DATA_VERSION
|
||||
)
|
||||
|
||||
|
||||
class DoorDirection(Enum):
|
||||
DW = "DW" # Door West (Left)
|
||||
DE = "DE" # Door East (Right)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationMeta:
|
||||
code: int
|
||||
name: str
|
||||
pos: float
|
||||
door_dir: DoorDirection
|
||||
station_no: int
|
||||
track_line: str # 'TC1' or 'TC2'
|
||||
|
||||
|
||||
class ReferenceDataManager:
|
||||
"""
|
||||
통합 레퍼런스 데이터 관리자
|
||||
- SQLite 로컬 캐싱
|
||||
- 메모리 캐시로 빠른 조회
|
||||
- Supabase 동기화 준비
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ReferenceDataManager, cls).__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
# DB 경로 설정
|
||||
self.db_path = Path(__file__).parent / "ref_cache.db"
|
||||
|
||||
# 메모리 캐시
|
||||
self.tc1_stations: Dict[int, StationMeta] = {}
|
||||
self.tc2_stations: Dict[int, StationMeta] = {}
|
||||
self.station_name_map: Dict[str, int] = {}
|
||||
self.error_code_map: Dict[str, str] = {}
|
||||
|
||||
self._is_loaded = False
|
||||
|
||||
# 초기화
|
||||
self._init_db()
|
||||
self._load_to_memory()
|
||||
|
||||
def _init_db(self):
|
||||
"""DB 초기화 (첫 실행 시 기본 데이터로 생성)"""
|
||||
db_exists = self.db_path.exists()
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 테이블 생성
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS stations (
|
||||
code INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
pos REAL NOT NULL,
|
||||
door_dir TEXT NOT NULL,
|
||||
station_no INTEGER NOT NULL,
|
||||
track_line TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS error_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# 인덱스 생성
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_station_name ON stations(name)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_track_line ON stations(track_line)")
|
||||
|
||||
# 첫 실행 시 기본 데이터 삽입
|
||||
if not db_exists:
|
||||
print("[RefData] Initializing database with default data...")
|
||||
|
||||
# 역 정보 삽입
|
||||
for station in DEFAULT_STATIONS:
|
||||
cursor.execute("""
|
||||
INSERT INTO stations (code, name, pos, door_dir, station_no, track_line)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
station["code"],
|
||||
station["name"],
|
||||
station["pos"],
|
||||
station["door_dir"],
|
||||
station["station_no"],
|
||||
station["track_line"]
|
||||
))
|
||||
|
||||
# 에러 코드 삽입
|
||||
for code, desc in DEFAULT_ERROR_CODES.items():
|
||||
cursor.execute("""
|
||||
INSERT INTO error_codes (code, description)
|
||||
VALUES (?, ?)
|
||||
""", (code, desc))
|
||||
|
||||
# 버전 정보 삽입
|
||||
cursor.execute("""
|
||||
INSERT INTO metadata (key, value)
|
||||
VALUES ('version', ?)
|
||||
""", (REFERENCE_DATA_VERSION,))
|
||||
|
||||
conn.commit()
|
||||
print(f"[RefData] Database initialized with version {REFERENCE_DATA_VERSION}")
|
||||
|
||||
conn.close()
|
||||
|
||||
def _load_to_memory(self):
|
||||
"""SQLite에서 메모리로 로드 (빠른 조회를 위한 캐싱)"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 역 정보 로드
|
||||
cursor.execute("SELECT code, name, pos, door_dir, station_no, track_line FROM stations")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
code, name, pos, door_dir, station_no, track_line = row
|
||||
|
||||
station = StationMeta(
|
||||
code=code,
|
||||
name=name,
|
||||
pos=pos,
|
||||
door_dir=DoorDirection(door_dir),
|
||||
station_no=station_no,
|
||||
track_line=track_line
|
||||
)
|
||||
|
||||
if track_line == "TC1":
|
||||
self.tc1_stations[code] = station
|
||||
elif track_line == "TC2":
|
||||
self.tc2_stations[code] = station
|
||||
|
||||
# 이름 매핑 (TC1 우선)
|
||||
if track_line == "TC1":
|
||||
self.station_name_map[name] = code
|
||||
|
||||
# TC2 이름 매핑 (TC1에 없는 경우만)
|
||||
for code, station in self.tc2_stations.items():
|
||||
if station.name not in self.station_name_map:
|
||||
self.station_name_map[station.name] = code
|
||||
|
||||
# 에러 코드 로드
|
||||
cursor.execute("SELECT code, description FROM error_codes")
|
||||
for code, desc in cursor.fetchall():
|
||||
self.error_code_map[code] = desc
|
||||
|
||||
conn.close()
|
||||
|
||||
self._is_loaded = True
|
||||
print(f"[RefData] Loaded from cache. TC1: {len(self.tc1_stations)}, TC2: {len(self.tc2_stations)}")
|
||||
|
||||
# ========== 조회 메서드 (기존 API 호환) ==========
|
||||
|
||||
def get_station_name(self, code: int) -> str:
|
||||
"""역 코드로 역 이름 조회"""
|
||||
if code in self.tc1_stations:
|
||||
return self.tc1_stations[code].name
|
||||
if code in self.tc2_stations:
|
||||
return self.tc2_stations[code].name
|
||||
return ""
|
||||
|
||||
def get_station_info(self, code: int) -> Optional[StationMeta]:
|
||||
"""역 코드로 역 정보 조회"""
|
||||
if code in self.tc1_stations:
|
||||
return self.tc1_stations[code]
|
||||
if code in self.tc2_stations:
|
||||
return self.tc2_stations[code]
|
||||
return None
|
||||
|
||||
def get_station_code(self, name: str) -> int:
|
||||
"""역 이름으로 역 코드 조회"""
|
||||
return self.station_name_map.get(name, 0)
|
||||
|
||||
def get_station_distance(self, station_name: str, is_tc1: bool) -> float:
|
||||
"""역 이름으로 거리 조회"""
|
||||
target_list = self.tc1_stations if is_tc1 else self.tc2_stations
|
||||
for station in target_list.values():
|
||||
if station.name == station_name:
|
||||
return station.pos
|
||||
return 0.0
|
||||
|
||||
def get_door_direction(self, station_name_or_code, is_tc1: bool) -> DoorDirection:
|
||||
"""역 이름 또는 코드로 도어 방향 조회"""
|
||||
target_list = self.tc1_stations if is_tc1 else self.tc2_stations
|
||||
|
||||
for station in target_list.values():
|
||||
if isinstance(station_name_or_code, str):
|
||||
if station.name == station_name_or_code:
|
||||
return station.door_dir
|
||||
else:
|
||||
if station.code == station_name_or_code:
|
||||
return station.door_dir
|
||||
|
||||
return DoorDirection.DW
|
||||
|
||||
# ========== Supabase 동기화 (추후 구현) ==========
|
||||
|
||||
async def sync_from_supabase(self):
|
||||
"""
|
||||
Supabase에서 최신 데이터 동기화
|
||||
TODO: 추후 구현
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_version(self) -> str:
|
||||
"""현재 데이터 버전 조회"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'version'")
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
return result[0] if result else "unknown"
|
||||
|
||||
|
||||
# 전역 인스턴스
|
||||
ref_data = ReferenceDataManager()
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
기본 레퍼런스 데이터 정의
|
||||
첫 실행 시 SQLite DB 초기화에 사용됨
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# 역 정보 기본 데이터
|
||||
DEFAULT_STATIONS: List[Dict[str, Any]] = [
|
||||
# TC1 (하선) - 예시 데이터, 실제 데이터로 교체 필요
|
||||
{"code": 1001, "name": "역1", "pos": 0.0, "door_dir": "DW", "station_no": 1, "track_line": "TC1"},
|
||||
{"code": 1002, "name": "역2", "pos": 1500.0, "door_dir": "DE", "station_no": 2, "track_line": "TC1"},
|
||||
{"code": 1003, "name": "역3", "pos": 3200.0, "door_dir": "DW", "station_no": 3, "track_line": "TC1"},
|
||||
|
||||
# TC2 (상선) - 예시 데이터, 실제 데이터로 교체 필요
|
||||
{"code": 2001, "name": "역1", "pos": 0.0, "door_dir": "DE", "station_no": 1, "track_line": "TC2"},
|
||||
{"code": 2002, "name": "역2", "pos": 1500.0, "door_dir": "DW", "station_no": 2, "track_line": "TC2"},
|
||||
{"code": 2003, "name": "역3", "pos": 3200.0, "door_dir": "DE", "station_no": 3, "track_line": "TC2"},
|
||||
]
|
||||
|
||||
# 에러 코드 매핑 (예시)
|
||||
DEFAULT_ERROR_CODES: Dict[str, str] = {
|
||||
"E001": "통신 오류",
|
||||
"E002": "센서 이상",
|
||||
"E003": "제동 시스템 오류",
|
||||
}
|
||||
|
||||
# 메타데이터 버전 정보
|
||||
REFERENCE_DATA_VERSION = "1.0.0"
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
"""
|
||||
Settings management
|
||||
애플리케이션 설정 관리 모듈
|
||||
|
||||
- 일반 앱 설정: ~/.mmi_analyzer/settings.json
|
||||
- AI 설정(키 포함): ~/.mmi_analyzer/ai_settings.json (별도 파일)
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIProviderConfig:
|
||||
"""개별 AI 프로바이더 설정"""
|
||||
api_key: str = ""
|
||||
model: str = ""
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AISettings:
|
||||
"""AI 관련 설정"""
|
||||
current_provider: str = "" # 현재 선택된 프로바이더
|
||||
providers: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
# 기본 프로바이더 설정 초기화
|
||||
default_providers = {
|
||||
"openai": {"api_key": "", "model": "gpt-4o-mini", "enabled": False},
|
||||
"openrouter": {"api_key": "", "model": "openai/gpt-4o-mini", "enabled": False},
|
||||
"gemini": {"api_key": "", "model": "gemini-1.5-flash", "enabled": False},
|
||||
"xai": {"api_key": "", "model": "grok-beta", "enabled": False},
|
||||
}
|
||||
for key, value in default_providers.items():
|
||||
if key not in self.providers:
|
||||
self.providers[key] = value
|
||||
|
||||
def get_api_key(self, provider_type: str) -> str:
|
||||
"""프로바이더의 API 키 반환"""
|
||||
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
|
||||
if provider_key in self.providers:
|
||||
return self.providers[provider_key].get("api_key", "")
|
||||
return ""
|
||||
|
||||
def set_api_key(self, provider_type: str, api_key: str):
|
||||
"""프로바이더의 API 키 설정"""
|
||||
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
|
||||
if provider_key not in self.providers:
|
||||
self.providers[provider_key] = {}
|
||||
self.providers[provider_key]["api_key"] = api_key
|
||||
if api_key:
|
||||
self.providers[provider_key]["enabled"] = True
|
||||
|
||||
def get_model(self, provider_type: str) -> str:
|
||||
"""프로바이더의 모델 반환"""
|
||||
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
|
||||
if provider_key in self.providers:
|
||||
return self.providers[provider_key].get("model", "")
|
||||
return ""
|
||||
|
||||
def set_model(self, provider_type: str, model: str):
|
||||
"""프로바이더의 모델 설정"""
|
||||
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
|
||||
if provider_key not in self.providers:
|
||||
self.providers[provider_key] = {}
|
||||
self.providers[provider_key]["model"] = model
|
||||
|
||||
def get_current_provider(self) -> Optional[str]:
|
||||
"""현재 선택된 프로바이더 타입 반환"""
|
||||
return self.current_provider if self.current_provider else None
|
||||
|
||||
def set_current_provider(self, provider_type: str):
|
||||
"""현재 프로바이더 설정"""
|
||||
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
|
||||
self.current_provider = provider_key
|
||||
|
||||
def is_provider_configured(self, provider_type: str) -> bool:
|
||||
"""프로바이더가 설정되었는지 확인"""
|
||||
provider_key = provider_type.value if hasattr(provider_type, 'value') else provider_type
|
||||
if provider_key in self.providers:
|
||||
api_key = self.providers[provider_key].get("api_key", "")
|
||||
return bool(api_key)
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppSettings:
|
||||
"""전체 애플리케이션 설정"""
|
||||
ai: AISettings = field(default_factory=AISettings)
|
||||
theme: str = "dark"
|
||||
language: str = "ko"
|
||||
window_geometry: Dict[str, int] = field(default_factory=lambda: {
|
||||
"x": 100, "y": 100, "width": 1800, "height": 1000
|
||||
})
|
||||
recent_files: list = field(default_factory=list)
|
||||
max_recent_files: int = 10
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
"""
|
||||
설정 관리자
|
||||
설정 파일 저장/로드 및 관리
|
||||
"""
|
||||
|
||||
DEFAULT_SETTINGS_DIR = Path.home() / ".mmi_analyzer"
|
||||
APP_SETTINGS_FILE = "settings.json"
|
||||
AI_SETTINGS_FILE = "ai_settings.json"
|
||||
|
||||
def __init__(self, settings_dir: Optional[Path] = None):
|
||||
self._settings_dir = settings_dir or self.DEFAULT_SETTINGS_DIR
|
||||
self._app_settings_file = self._settings_dir / self.APP_SETTINGS_FILE
|
||||
self._ai_settings_file = self._settings_dir / self.AI_SETTINGS_FILE
|
||||
self._settings: Optional[AppSettings] = None
|
||||
|
||||
# 설정 디렉토리 생성
|
||||
self._settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@property
|
||||
def settings(self) -> AppSettings:
|
||||
"""현재 설정 반환 (없으면 로드)"""
|
||||
if self._settings is None:
|
||||
self.load()
|
||||
return self._settings
|
||||
|
||||
@property
|
||||
def ai_settings(self) -> AISettings:
|
||||
"""AI 설정 반환"""
|
||||
return self.settings.ai
|
||||
|
||||
def load(self) -> AppSettings:
|
||||
"""설정 파일에서 로드"""
|
||||
app_data: Dict[str, Any] = {}
|
||||
ai_data: Dict[str, Any] = {}
|
||||
|
||||
# 1) 앱 설정 로드
|
||||
if self._app_settings_file.exists():
|
||||
try:
|
||||
with open(self._app_settings_file, "r", encoding="utf-8") as f:
|
||||
app_data = json.load(f) or {}
|
||||
except Exception as e:
|
||||
print(f"앱 설정 로드 실패: {e}")
|
||||
app_data = {}
|
||||
|
||||
# 2) AI 설정 로드 (별도 파일)
|
||||
if self._ai_settings_file.exists():
|
||||
try:
|
||||
with open(self._ai_settings_file, "r", encoding="utf-8") as f:
|
||||
ai_data = json.load(f) or {}
|
||||
except Exception as e:
|
||||
print(f"AI 설정 로드 실패: {e}")
|
||||
ai_data = {}
|
||||
else:
|
||||
# 3) 마이그레이션: 과거 settings.json 안의 ai 섹션이 있으면 ai_settings.json로 이동
|
||||
legacy_ai = (app_data or {}).get("ai")
|
||||
if isinstance(legacy_ai, dict) and legacy_ai:
|
||||
ai_data = legacy_ai
|
||||
try:
|
||||
# 저장 후 app_data에서 ai 섹션 제거
|
||||
with open(self._ai_settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(ai_data, f, indent=2, ensure_ascii=False)
|
||||
app_data.pop("ai", None)
|
||||
with open(self._app_settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(app_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"AI 설정 마이그레이션 실패: {e}")
|
||||
|
||||
# AI 설정 복원
|
||||
ai_settings = AISettings(
|
||||
current_provider=ai_data.get("current_provider", "") if isinstance(ai_data, dict) else "",
|
||||
providers=ai_data.get("providers", {}) if isinstance(ai_data, dict) else {},
|
||||
)
|
||||
|
||||
self._settings = AppSettings(
|
||||
ai=ai_settings,
|
||||
theme=app_data.get("theme", "dark"),
|
||||
language=app_data.get("language", "ko"),
|
||||
window_geometry=app_data.get("window_geometry", {}),
|
||||
recent_files=app_data.get("recent_files", []),
|
||||
max_recent_files=app_data.get("max_recent_files", 10),
|
||||
)
|
||||
|
||||
return self._settings
|
||||
|
||||
def save(self):
|
||||
"""설정을 파일에 저장"""
|
||||
if self._settings is None:
|
||||
return
|
||||
|
||||
# 앱 설정 저장 (AI 제외)
|
||||
try:
|
||||
app_data = {
|
||||
"theme": self._settings.theme,
|
||||
"language": self._settings.language,
|
||||
"window_geometry": self._settings.window_geometry,
|
||||
"recent_files": self._settings.recent_files,
|
||||
"max_recent_files": self._settings.max_recent_files,
|
||||
}
|
||||
with open(self._app_settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(app_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"앱 설정 저장 실패: {e}")
|
||||
|
||||
# AI 설정 저장 (별도)
|
||||
try:
|
||||
ai_data = {
|
||||
"current_provider": self._settings.ai.current_provider,
|
||||
"providers": self._settings.ai.providers,
|
||||
}
|
||||
with open(self._ai_settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(ai_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"AI 설정 저장 실패: {e}")
|
||||
|
||||
def reset(self):
|
||||
"""설정 초기화"""
|
||||
self._settings = AppSettings()
|
||||
self.save()
|
||||
|
||||
def add_recent_file(self, file_path: str):
|
||||
"""최근 파일 추가"""
|
||||
if file_path in self.settings.recent_files:
|
||||
self.settings.recent_files.remove(file_path)
|
||||
|
||||
self.settings.recent_files.insert(0, file_path)
|
||||
|
||||
# 최대 개수 제한
|
||||
while len(self.settings.recent_files) > self.settings.max_recent_files:
|
||||
self.settings.recent_files.pop()
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
_settings_manager: Optional[SettingsManager] = None
|
||||
|
||||
|
||||
def get_settings_manager() -> SettingsManager:
|
||||
"""설정 관리자 싱글톤 인스턴스 반환"""
|
||||
global _settings_manager
|
||||
if _settings_manager is None:
|
||||
_settings_manager = SettingsManager()
|
||||
return _settings_manager
|
||||
|
||||
|
||||
def get_settings() -> AppSettings:
|
||||
"""현재 설정 반환"""
|
||||
return get_settings_manager().settings
|
||||
|
||||
|
||||
def get_ai_settings() -> AISettings:
|
||||
"""AI 설정 반환"""
|
||||
return get_settings_manager().ai_settings
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
class SyncController(QObject):
|
||||
# 시점 변경 시그널 (인덱스, 데이터행, 보낸사람ID)
|
||||
time_changed = Signal(int, object, str)
|
||||
|
||||
# X축 범위 변경 시그널 (min_ms, max_ms, 보낸사람ID)
|
||||
# 밀리초(ms)는 1.7e12 수준이라 32-bit int를 초과할 수 있어 object로 받음(오버플로우 방지)
|
||||
x_range_changed = Signal(object, object, object)
|
||||
|
||||
# 동기화 활성화 여부
|
||||
is_sync_enabled = True
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._total_records = 0
|
||||
|
||||
def request_sync(self, index, data_row, source_id):
|
||||
"""
|
||||
특정 패널에서 시간 변경 요청이 오면,
|
||||
동기화가 켜져 있을 때만 다른 패널들에게 전파
|
||||
"""
|
||||
if self.is_sync_enabled:
|
||||
# Sync가 켜져있으면 모두에게 알림 (source_id 포함)
|
||||
self.time_changed.emit(index, data_row, source_id)
|
||||
else:
|
||||
# Sync가 꺼져있으면 아무것도 안 함 (각자 놈)
|
||||
pass
|
||||
|
||||
def request_x_range_sync(self, min_ms: int, max_ms: int, source_id):
|
||||
"""
|
||||
특정 패널에서 X축(시간축) 범위 변경 요청이 오면,
|
||||
동기화가 켜져 있을 때만 다른 패널들에게 전파
|
||||
"""
|
||||
if self.is_sync_enabled:
|
||||
# int 캐스팅은 유지하되 시그널 타입은 object라 안전(파이썬 int 그대로 전달)
|
||||
self.x_range_changed.emit(int(min_ms), int(max_ms), source_id)
|
||||
|
||||
sync_manager = SyncController()
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
from PySide6.QtGui import QColor, QPalette
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
class ThemeManager:
|
||||
"""애플리케이션 전체의 테마(Light/Dark)를 관리"""
|
||||
|
||||
LIGHT_THEME = {
|
||||
"bg": "#FFFFFF", "fg": "#000000", "panel": "#F0F0F0",
|
||||
"accent": "#0078D7", "border": "#CCCCCC", "text": "#333333"
|
||||
}
|
||||
|
||||
DARK_THEME = {
|
||||
"bg": "#1E1E1E", "fg": "#FFFFFF", "panel": "#2D2D30",
|
||||
"accent": "#007ACC", "border": "#3E3E42", "text": "#D4D4D4"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def apply_theme(app: QApplication, theme="dark"):
|
||||
palette = QPalette()
|
||||
colors = ThemeManager.DARK_THEME if theme == "dark" else ThemeManager.LIGHT_THEME
|
||||
|
||||
# 기본 팔레트 설정 (Qt 기본 위젯용)
|
||||
# (상세 팔레트 설정 코드는 생략하고 스타일시트 위주로 적용)
|
||||
|
||||
# 공통 스타일시트 (Fusion 스타일 + 커스텀)
|
||||
qss = f"""
|
||||
QMainWindow {{ background-color: {colors['bg']}; color: {colors['text']}; }}
|
||||
QWidget {{ background-color: {colors['bg']}; color: {colors['text']}; font-family: 'Malgun Gothic', 'Segoe UI'; font-size: 10pt; }}
|
||||
QTabWidget::pane {{ border: 1px solid {colors['border']}; background: {colors['panel']}; }}
|
||||
QTabBar::tab {{ background: {colors['bg']}; border: 1px solid {colors['border']}; padding: 8px 16px; margin-right: 2px; }}
|
||||
QTabBar::tab:selected {{ background: {colors['accent']}; color: white; font-weight: bold; }}
|
||||
QSplitter::handle {{ background-color: {colors['border']}; width: 2px; }}
|
||||
QPushButton {{ background-color: {colors['panel']}; border: 1px solid {colors['border']}; padding: 6px 12px; border-radius: 4px; }}
|
||||
QPushButton:hover {{ background-color: {colors['accent']}; color: white; }}
|
||||
QStatusBar {{ background: {colors['panel']}; color: {colors['text']}; border-top: 1px solid {colors['border']}; }}
|
||||
"""
|
||||
app.setStyleSheet(qss)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
# SQLite/JSON handler
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import os
|
||||
import numpy as np
|
||||
import os
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
from typing import Optional
|
||||
|
||||
class FastLogParser:
|
||||
CHUNK_SIZE = 84
|
||||
|
||||
@staticmethod
|
||||
def parse_to_parquet(input_path: str, output_path: str = None) -> Optional[str]:
|
||||
"""
|
||||
Numpy 벡터 연산을 사용하여 MMI 바이너리 로그를 초고속으로 파싱 후 Parquet 저장.
|
||||
(VDI/VDO 포함 모든 필드 완벽 구현)
|
||||
"""
|
||||
if not os.path.exists(input_path):
|
||||
return None
|
||||
|
||||
file_size = os.path.getsize(input_path)
|
||||
n_records = file_size // FastLogParser.CHUNK_SIZE
|
||||
|
||||
if n_records == 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(input_path, 'rb') as f:
|
||||
# 파일 전체를 한번에 메모리로 로드 (가장 빠름)
|
||||
raw_data = np.fromfile(f, dtype=np.uint8)
|
||||
|
||||
# 잘린 마지막 청크 제거 및 Reshape
|
||||
limit = n_records * FastLogParser.CHUNK_SIZE
|
||||
raw_data = raw_data[:limit]
|
||||
# (Row수, 84바이트) 형태의 2차원 배열로 변환
|
||||
m = raw_data.reshape(n_records, FastLogParser.CHUNK_SIZE)
|
||||
|
||||
# --- [1. Basic Info] ---
|
||||
seq = m[:, 1]
|
||||
source = m[:, 3]
|
||||
# 16(0x10) or 80(0x50) -> 1계, 그 외 -> 2계
|
||||
system_id = np.where((source == 16) | (source == 80), 1, 2).astype(np.uint8)
|
||||
# 이중계 판단 (32가 아니면 Break/Standby)
|
||||
is_break = (source != 32)
|
||||
|
||||
# Time (BCD-like hex display: 0x25 -> 25)
|
||||
# (val // 16) * 10 + (val % 16) 로직이 맞음
|
||||
def bcd(arr): return (arr // 16) * 10 + (arr % 16)
|
||||
|
||||
year = bcd(m[:, 4]).astype(np.int16) + 2000
|
||||
month = bcd(m[:, 5])
|
||||
day = bcd(m[:, 6])
|
||||
hour = bcd(m[:, 7])
|
||||
minute = bcd(m[:, 8])
|
||||
second = bcd(m[:, 9])
|
||||
|
||||
# --- [2. Speed & Analog] ---
|
||||
# uint8 두 개를 합쳐 uint16으로 변환 시 반드시 먼저 astype(uint16)을 해야 함 (안 그러면 오버플로우)
|
||||
trainspeed = ((m[:, 10].astype(np.uint16) << 8) | m[:, 11].astype(np.uint16)) / 10.0
|
||||
limitspeed = m[:, 12]
|
||||
pwm_value = m[:, 22]
|
||||
ato_limitSpeed = m[:, 33]
|
||||
tasc_value = m[:, 33]
|
||||
|
||||
# --- [3. Digital Flags (Bitwise Vectorized)] ---
|
||||
# Byte 13: DO ATC
|
||||
b13 = m[:, 13]
|
||||
do_zvr = (b13 & 32) > 0; do_edl = (b13 & 16) > 0; do_edr = (b13 & 8) > 0
|
||||
do_fsb = (b13 & 4) > 0; do_ebm = (b13 & 2) > 0; do_ebp = (b13 & 1) > 0
|
||||
|
||||
# Byte 14: Status
|
||||
b14 = m[:, 14]
|
||||
system_active = (b14 & 128) > 0; over_spd_warning = (b14 & 64) > 0
|
||||
tcr = (b14 & 32) > 0; hcr = (b14 & 16) > 0
|
||||
door_open = (b14 & 8) > 0; door_close = (b14 & 4) > 0
|
||||
psd_open = (b14 & 2) > 0; psd_close = (b14 & 1) > 0
|
||||
|
||||
# Byte 15: Mode
|
||||
b15 = m[:, 15]
|
||||
fa = (b15 & 128) > 0; auto = (b15 & 64) > 0; mcs = (b15 & 32) > 0
|
||||
yard = (b15 & 16) > 0; fmc = (b15 & 8) > 0
|
||||
reverser_rvs = (b15 & 4) > 0; reverser_fwd = (b15 & 2) > 0; reverser_neu = (b15 & 1) > 0
|
||||
|
||||
# Byte 16: Mascon
|
||||
b16 = m[:, 16]
|
||||
ato_start_btn = (b16 & 128) > 0; ato_eb_req = (b16 & 64) > 0
|
||||
tacho_dir_a = (b16 & 32) > 0; tacho_dir_b = (b16 & 16) > 0
|
||||
mascon_dr = (b16 & 2) > 0; mascon_br = (b16 & 4) > 0; mascon_eb = (b16 & 8) > 0
|
||||
|
||||
# Byte 19: ATC Status
|
||||
b19 = m[:, 19]
|
||||
val_19 = b19 & 192 # 0, 192, 64, 128
|
||||
wheelcheck = (b19 & 32) > 0
|
||||
|
||||
# Byte 20: Fail
|
||||
b20 = m[:, 20]
|
||||
fail_atcr = (b20 & 128) > 0; fail_atoc = (b20 & 64) > 0; fail_tcms = (b20 & 32) > 0
|
||||
fail_tacho2 = (b20 & 2) > 0; fail_tacho1 = (b20 & 1) > 0
|
||||
|
||||
# Byte 21: Marker
|
||||
b21 = m[:, 21]
|
||||
recovery = (b21 & 128) > 0; nomal = (b21 & 64) > 0; tasc = (b21 & 32) > 0
|
||||
marker_val = b21 & 31 # 16, 8, 4, 2, 1
|
||||
|
||||
# Byte 23: RLY ATO
|
||||
b23 = m[:, 23]
|
||||
trac_dr = (b23 & 128) > 0; trac_br = (b23 & 64) > 0; trac_cs = (b23 & 32) > 0
|
||||
ador = (b23 & 16) > 0; adol = (b23 & 8) > 0; adc = (b23 & 4) > 0
|
||||
start_enable = (b23 & 2) > 0; trainberth = (b23 & 1) > 0
|
||||
|
||||
# Byte 24: TCMS
|
||||
b24 = m[:, 24]
|
||||
tc2 = (b24 & 128) > 0; tc1 = (b24 & 64) > 0; tascdb = (b24 & 32) > 0
|
||||
|
||||
# Byte 25: ETC
|
||||
b25 = m[:, 25]
|
||||
pre_brake = (b25 & 128) > 0; limit_drive = (b25 & 64) > 0
|
||||
ov_stop1 = (b25 & 8) > 0; ov_stop2 = (b25 & 4) > 0
|
||||
sh_stop1 = (b25 & 2) > 0; sh_stop2 = (b25 & 1) > 0
|
||||
|
||||
# Byte 26-27: Train No
|
||||
trainno_int = (m[:, 26].astype(np.uint16) << 8) | m[:, 27].astype(np.uint16)
|
||||
|
||||
# Byte 28-30: Stations
|
||||
pstn = m[:, 28]; nstn = m[:, 29]; dstn = m[:, 30]
|
||||
|
||||
# Byte 31-32: DTG (Distance to Go) - 정밀 로직 구현
|
||||
num_dtg = (m[:, 31].astype(np.uint16) << 8) | m[:, 32].astype(np.uint16)
|
||||
|
||||
# DTG 부호 처리: uint16을 int16으로 해석해서 음수 여부 판단
|
||||
# C# 로직: if (num_dtg & 32768) != 0: num_dtg -= 65536
|
||||
# Numpy에서는 그냥 .view(np.int16)하거나 astype(np.int16)하면 32768(0x8000) 이상은 자동으로 음수가 됨.
|
||||
dtg_signed = num_dtg.astype(np.int16)
|
||||
|
||||
# 조건: TASC 모드가 아니면서 속도가 0이고 역행(Dr)이 아닐 때 부호 반전
|
||||
cond_reverse = (~tasc) & (trainspeed == 0.0) & (~trac_dr)
|
||||
|
||||
# np.where(조건, 참일때값, 거짓일때값)
|
||||
# 참일 때: (값 * -1) / 100.0
|
||||
# 거짓일 때: TASC면 / 100.0, 아니면 / 10.0
|
||||
dtg = np.where(
|
||||
cond_reverse,
|
||||
dtg_signed * -1.0 / 100.0,
|
||||
np.where(tasc, num_dtg / 100.0, num_dtg / 10.0)
|
||||
)
|
||||
|
||||
# Byte 34: TWC
|
||||
b34 = m[:, 34]
|
||||
twct_enable = (b34 & 16) > 0; door_close_warning = (b34 & 8) > 0; wrongdoor = (b34 & 4) > 0
|
||||
|
||||
# Byte 35: ATC Code
|
||||
b35 = m[:, 35]
|
||||
atc_idx = (b35 & 240) >> 4
|
||||
osc_f0_ok = (b35 & 8) > 0
|
||||
|
||||
# Byte 36-39, 55, 58: Freq (XOR)
|
||||
atc_code_carrier_f = (((m[:, 36]^65).astype(np.uint16) << 8) | (m[:, 37]^82).astype(np.uint16)) * 10.0
|
||||
atc_code_f = (((m[:, 38]^99).astype(np.uint16) << 8) | (m[:, 39]^116).astype(np.uint16)) / 10.0
|
||||
osc_f = (((m[:, 55]^99).astype(np.uint16) << 8) | (m[:, 58]^100).astype(np.uint16)) / 10.0
|
||||
|
||||
# --- [Missing Fields Restored: VDI/VDO] ---
|
||||
# VDI A (63-65)
|
||||
vdia_rvs = (m[:, 63] & 128) > 0; vdia_neu = (m[:, 63] & 64) > 0; vdia_fwd = (m[:, 63] & 32) > 0
|
||||
vdia_mascondr = (m[:, 63] & 16) > 0; vdia_masconbr = (m[:, 63] & 8) > 0; vdia_masconeb = (m[:, 63] & 4) > 0
|
||||
vdia_doorclose = (m[:, 63] & 2) > 0; vdia_dooropen = (m[:, 63] & 1) > 0
|
||||
vdia_fmc = (m[:, 64] & 64) > 0; vdia_yard = (m[:, 64] & 32) > 0; vdia_mcs = (m[:, 64] & 16) > 0
|
||||
vdia_auto = (m[:, 64] & 8) > 0; vdia_fa = (m[:, 64] & 4) > 0
|
||||
|
||||
# VDI B (66-68)
|
||||
vdib_rvs = (m[:, 66] & 128) > 0; vdib_neu = (m[:, 66] & 64) > 0; vdib_fwd = (m[:, 66] & 32) > 0
|
||||
vdib_mascondr = (m[:, 66] & 16) > 0; vdib_masconbr = (m[:, 66] & 8) > 0; vdib_masconeb = (m[:, 66] & 4) > 0
|
||||
vdib_doorclose = (m[:, 66] & 2) > 0; vdib_dooropen = (m[:, 66] & 1) > 0
|
||||
|
||||
# VDI C (69-71)
|
||||
vdic_tc2 = (m[:, 69] & 128) > 0; vdic_tc1 = (m[:, 69] & 64) > 0
|
||||
vdic_edlfb = (m[:, 69] & 32) > 0; vdic_edrfb = (m[:, 69] & 16) > 0
|
||||
vdic_psdclose = (m[:, 70] & 2) > 0; vdic_psdopen = (m[:, 70] & 1) > 0
|
||||
|
||||
# VDO A (75-76)
|
||||
vdoa_edl = (m[:, 75] & 32) > 0; vdoa_edr = (m[:, 75] & 16) > 0
|
||||
vdoa_zvr = (m[:, 75] & 8) > 0; vdoa_fsb = (m[:, 75] & 4) > 0
|
||||
|
||||
# --- [4. Create DataFrame] ---
|
||||
data_dict = {
|
||||
"seq": seq, "source": source, "system_id": system_id, "is_break": is_break,
|
||||
"year": year, "month": month, "day": day, "hour": hour, "minute": minute, "second": second,
|
||||
"trainspeed": trainspeed, "limitspeed": limitspeed, "pwm_value": pwm_value,
|
||||
"ato_limitSpeed": ato_limitSpeed, "tasc_value": tasc_value,
|
||||
"do_zvr": do_zvr, "do_edl": do_edl, "do_edr": do_edr, "do_fsb": do_fsb, "do_ebm": do_ebm, "do_ebp": do_ebp,
|
||||
"system_active": system_active, "over_spd_warning": over_spd_warning, "tcr": tcr, "hcr": hcr,
|
||||
"door_open": door_open, "door_close": door_close, "psd_open": psd_open, "psd_close": psd_close,
|
||||
"fa": fa, "auto": auto, "mcs": mcs, "yard": yard, "fmc": fmc,
|
||||
"reverser_rvs": reverser_rvs, "reverser_fwd": reverser_fwd, "reverser_neu": reverser_neu,
|
||||
"ato_start_btn": ato_start_btn, "ato_eb_req": ato_eb_req,
|
||||
"tacho_dir_a": tacho_dir_a, "tacho_dir_b": tacho_dir_b,
|
||||
"mascon_dr": mascon_dr, "mascon_br": mascon_br, "mascon_eb": mascon_eb,
|
||||
"atc_status_code": val_19,
|
||||
"fail_atcr": fail_atcr, "fail_atoc": fail_atoc, "fail_tcms": fail_tcms,
|
||||
"recovery": recovery, "nomal": nomal, "tasc": tasc, "marker_val": marker_val,
|
||||
"trac_dr": trac_dr, "trac_br": trac_br, "trac_cs": trac_cs,
|
||||
"tc2": tc2, "tc1": tc1, "tascdb": tascdb,
|
||||
"pre_brake": pre_brake, "limit_drive": limit_drive,
|
||||
"trainno_int": trainno_int,
|
||||
"pstn": pstn, "nstn": nstn, "dstn": dstn,
|
||||
"dtg": dtg,
|
||||
"twct_enable": twct_enable, "door_close_warning": door_close_warning, "wrongdoor": wrongdoor,
|
||||
"atc_idx": atc_idx, "osc_f0_ok": osc_f0_ok,
|
||||
"atc_code_carrier_f": atc_code_carrier_f, "atc_code_f": atc_code_f, "osc_f": osc_f,
|
||||
|
||||
# 복원된 VDI/VDO
|
||||
"vdia_rvs": vdia_rvs, "vdia_neu": vdia_neu, "vdia_fwd": vdia_fwd,
|
||||
"vdia_mascondr": vdia_mascondr, "vdia_masconbr": vdia_masconbr,
|
||||
"vdia_doorclose": vdia_doorclose, "vdia_dooropen": vdia_dooropen,
|
||||
"vdib_doorclose": vdib_doorclose, "vdib_dooropen": vdib_dooropen,
|
||||
"vdoa_edl": vdoa_edl, "vdoa_edr": vdoa_edr, "vdoa_zvr": vdoa_zvr
|
||||
}
|
||||
|
||||
df = pl.DataFrame(data_dict)
|
||||
|
||||
# --- [5. Post-Processing] ---
|
||||
# Polars의 강력한 문자열 처리 기능 사용 (Lambda 루프 제거 -> 속도 향상)
|
||||
|
||||
# 시간 문자열 생성
|
||||
df = df.with_columns(
|
||||
pl.format("{}-{}-{} {}:{}:{}",
|
||||
pl.col("year"),
|
||||
pl.col("month").cast(pl.String).str.zfill(2),
|
||||
pl.col("day").cast(pl.String).str.zfill(2),
|
||||
pl.col("hour").cast(pl.String).str.zfill(2),
|
||||
pl.col("minute").cast(pl.String).str.zfill(2),
|
||||
pl.col("second").cast(pl.String).str.zfill(2)
|
||||
).alias("time")
|
||||
)
|
||||
|
||||
# 열차번호 Hex String 변환
|
||||
df = df.with_columns(
|
||||
pl.col("trainno_int")
|
||||
.map_elements(lambda x: f"{x:04X}", return_dtype=pl.String)
|
||||
.alias("trainno")
|
||||
)
|
||||
|
||||
# [Optional] 여기서 역 이름 매핑이나 코드 변환을 수행할 수 있음
|
||||
# (이전 대화의 Processor 로직을 여기에 통합해도 됨)
|
||||
|
||||
if output_path is None:
|
||||
output_path = input_path.replace(".dat", ".parquet")
|
||||
|
||||
df.write_parquet(output_path, compression="zstd")
|
||||
return output_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FastParser] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Data caching and management
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
import struct
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 1. 데이터 구조 정의 (C# DOSItoMMIClass 완벽 대응)
|
||||
# -------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class DOSItoMMIClass:
|
||||
# [기본 정보]
|
||||
seq: int = 0
|
||||
source: int = 0 # 16/80: 1계, 그외: 2계
|
||||
time: str = ""
|
||||
trainno: str = "" # 열차번호
|
||||
|
||||
# [속도 및 아날로그 값]
|
||||
trainspeed: float = 0.0 # 현재 속도
|
||||
limitspeed: int = 0 # 제한 속도
|
||||
pwm_value: int = 0 # PWM
|
||||
dtg: float = 0.0 # 잔여 거리
|
||||
ato_limitSpeed: int = 0 # ATO 제한 속도
|
||||
tasc_value: int = 0 # 그래프용(ato_limitSpeed와 동일)
|
||||
|
||||
# [ATC/ATO 주파수 및 코드 (XOR 복호화 데이터)]
|
||||
osc_f: float = 0.0 # OSC 주파수
|
||||
atc_code_carrier_f: float = 0.0 # ATC Carrier Freq
|
||||
atc_code_f: float = 0.0 # ATC Code Freq
|
||||
atc_carrier_f: str = "-" # F1~F4 문자열
|
||||
|
||||
# [ATC 카드 상태 - Byte 13, 14, 19]
|
||||
do_zvr: bool = False; do_edl: bool = False; do_edr: bool = False
|
||||
do_fsb: bool = False; do_ebm: bool = False; do_ebp: bool = False
|
||||
|
||||
system_active: bool = False; over_spd_warning: bool = False
|
||||
tcr: bool = False; hcr: bool = False
|
||||
door_open: bool = False; door_close: bool = False
|
||||
psd_open: bool = False; psd_close: bool = False
|
||||
|
||||
atc_status: str = "-" # INITIAL, MANUAL, ACTIVE, STANDBY
|
||||
wheelcheck: bool = False
|
||||
mpdt: str = "-" # START, NG, OK
|
||||
ipdt: str = "-" # NG, OK
|
||||
|
||||
# [운전 모드 및 마스콘 - Byte 15, 16]
|
||||
fa: bool = False; auto: bool = False; mcs: bool = False
|
||||
yard: bool = False; fmc: bool = False
|
||||
|
||||
reverser_rvs: bool = False; reverser_fwd: bool = False; reverser_neu: bool = False
|
||||
|
||||
ato_start_btn: bool = False; ato_eb_req: bool = False
|
||||
tacho_dir_a: bool = False; tacho_dir_b: bool = False
|
||||
mascon_neu: bool = False; mascon_dr: bool = False
|
||||
mascon_br: bool = False; mascon_eb: bool = False
|
||||
|
||||
# [FAIL 카드 정보 - Byte 20]
|
||||
fail_atcr: bool = False; fail_atoc: bool = False; fail_tcms: bool = False
|
||||
fail_tacho2: bool = False; fail_tacho1: bool = False
|
||||
|
||||
# [마커 및 TWC - Byte 21, 28-30, 34]
|
||||
recovery: bool = False; nomal: bool = False; tasc: bool = False
|
||||
marker: str = "-" # ATS, PGX, PG3-2, PG2, PG1
|
||||
|
||||
pstn: int = 0; nstn: int = 0; dstn: int = 0 # 전역, 현역, 종착역
|
||||
twct_enable: bool = False # TWC 수신 여부
|
||||
door_close_warning: bool = False # DCW
|
||||
wrongdoor: bool = False
|
||||
nextdoor: str = "-"
|
||||
station_name: str = "" # 역 이름 (TrackInfo 매핑)
|
||||
|
||||
# [ATO RLY 및 기타 - Byte 23, 24, 25]
|
||||
trac_dr: bool = False; trac_br: bool = False; trac_cs: bool = False
|
||||
ador: bool = False; adol: bool = False; adc: bool = False
|
||||
start_enable: bool = False; trainberth: bool = False
|
||||
|
||||
tc2: bool = False; tc1: bool = False; tascdb: bool = False
|
||||
tcmsdoor: str = "-" # LDO, RDO
|
||||
doormod: str = "-" # A/A, A/M, M/M
|
||||
|
||||
pre_brake: bool = False; limit_drive: bool = False
|
||||
ov_stop1: bool = False; ov_stop2: bool = False
|
||||
sh_stop1: bool = False; sh_stop2: bool = False
|
||||
|
||||
# [ATC 코드 상세 - Byte 35]
|
||||
atc_code: str = "-" # 01, 02, 25 ...
|
||||
atc_code_str: str = "-" # 호환용(동일 값)
|
||||
atc_code_val: int = 0 # 그래프용 속도값
|
||||
osc_f0_ok: bool = False
|
||||
|
||||
# [에러 메시지 모음]
|
||||
diagnostic_err_msg: str = ""
|
||||
atc_err_msg: str = ""
|
||||
interface_err_msg: str = ""
|
||||
ato_err_msg: str = ""
|
||||
|
||||
# [VDI/VDO 카드 (DIO) - Byte 63~78]
|
||||
# VDI A
|
||||
vdia_stat: str = ""; vdia_input: str = ""
|
||||
vdia_rvs: bool = False; vdia_neu: bool = False; vdia_fwd: bool = False
|
||||
vdia_mascondr: bool = False; vdia_masconbr: bool = False; vdia_masconeb: bool = False
|
||||
vdia_doorclose: bool = False; vdia_dooropen: bool = False
|
||||
vdia_fmc: bool = False; vdia_yard: bool = False; vdia_mcs: bool = False
|
||||
vdia_auto: bool = False; vdia_fa: bool = False; vdia_tcr: bool = False; vdia_hcr: bool = False
|
||||
|
||||
# VDI B
|
||||
vdib_stat: str = ""; vdib_input: str = ""
|
||||
vdib_rvs: bool = False; vdib_neu: bool = False; vdib_fwd: bool = False
|
||||
vdib_mascondr: bool = False; vdib_masconbr: bool = False; vdib_masconeb: bool = False
|
||||
vdib_doorclose: bool = False; vdib_dooropen: bool = False
|
||||
vdib_fmc: bool = False; vdib_yard: bool = False; vdib_mcs: bool = False
|
||||
vdib_auto: bool = False; vdib_fa: bool = False; vdib_tcr: bool = False; vdib_hcr: bool = False
|
||||
|
||||
# VDI C
|
||||
vdic_stat: str = ""; vdic_input: str = ""
|
||||
vdic_tc2: bool = False; vdic_tc1: bool = False
|
||||
vdic_edlfb: bool = False; vdic_edrfb: bool = False; vdic_zvrfb: bool = False
|
||||
vdic_fsbfb: bool = False; vdic_ebmfb: bool = False; vdic_ebpfb: bool = False
|
||||
vdic_unit1: bool = False; vdic_startbtn: bool = False
|
||||
vdic_psdclose: bool = False; vdic_psdopen: bool = False
|
||||
|
||||
# VDI D
|
||||
vdid_stat: str = ""; vdid_input: str = ""
|
||||
vdid_tc2: bool = False; vdid_tc1: bool = False
|
||||
vdid_edlfb: bool = False; vdid_edrfb: bool = False; vdid_zvrfb: bool = False
|
||||
vdid_fsbfb: bool = False; vdid_ebmfb: bool = False; vdid_ebpfb: bool = False
|
||||
vdid_unit1: bool = False; vdid_startbtn: bool = False
|
||||
vdid_psdclose: bool = False; vdid_psdopen: bool = False
|
||||
|
||||
# VDO A/B
|
||||
vdoa_stat: str = ""; vdob_stat: str = ""
|
||||
vdoa_edl: bool = False; vdoa_edr: bool = False; vdoa_zvr: bool = False
|
||||
vdoa_fsb: bool = False; vdoa_ebm: bool = False; vdoa_ebp: bool = False
|
||||
vdob_edl: bool = False; vdob_edr: bool = False; vdob_zvr: bool = False
|
||||
vdob_fsb: bool = False; vdob_ebm: bool = False; vdob_ebp: bool = False
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. 핵심 파싱 로직 (C# DDOSItoMMI.cs 정밀 이식)
|
||||
# -------------------------------------------------------------------------
|
||||
class DDOSItoMMI:
|
||||
@staticmethod
|
||||
def set_data(data: bytes) -> DOSItoMMIClass:
|
||||
mmi = DOSItoMMIClass()
|
||||
|
||||
# 데이터 길이 보호
|
||||
if len(data) < 84:
|
||||
return mmi
|
||||
|
||||
# [Header]
|
||||
mmi.seq = data[1]
|
||||
mmi.source = data[3]
|
||||
|
||||
# [Time] "20YY.MM.DD HH:mm:ss"
|
||||
try:
|
||||
mmi.time = (f"20{data[4]:02x}.{data[5]:02x}.{data[6]:02x} "
|
||||
f"{data[7]:02x}:{data[8]:02x}:{data[9]:02x}")
|
||||
except:
|
||||
mmi.time = "Invalid Time"
|
||||
|
||||
# [Speed] Byte 10-11
|
||||
speed_raw = (data[10] << 8) | data[11]
|
||||
mmi.trainspeed = speed_raw / 10.0
|
||||
mmi.limitspeed = data[12]
|
||||
|
||||
# [DO ATC / Byte 13]
|
||||
mmi.do_zvr = (data[13] & 32) > 0
|
||||
mmi.do_edl = (data[13] & 16) > 0
|
||||
mmi.do_edr = (data[13] & 8) > 0
|
||||
mmi.do_fsb = (data[13] & 4) > 0
|
||||
mmi.do_ebm = (data[13] & 2) > 0
|
||||
mmi.do_ebp = (data[13] & 1) > 0
|
||||
|
||||
# [Status / Byte 14]
|
||||
mmi.system_active = (data[14] & 128) > 0
|
||||
mmi.over_spd_warning = (data[14] & 64) > 0
|
||||
mmi.tcr = (data[14] & 32) > 0
|
||||
mmi.hcr = (data[14] & 16) > 0
|
||||
mmi.door_open = (data[14] & 8) > 0
|
||||
mmi.door_close = (data[14] & 4) > 0
|
||||
mmi.psd_open = (data[14] & 2) > 0
|
||||
mmi.psd_close = (data[14] & 1) > 0
|
||||
|
||||
# [Mode / Byte 15]
|
||||
mmi.fa = (data[15] & 128) > 0
|
||||
mmi.auto = (data[15] & 64) > 0
|
||||
mmi.mcs = (data[15] & 32) > 0
|
||||
mmi.yard = (data[15] & 16) > 0
|
||||
mmi.fmc = (data[15] & 8) > 0
|
||||
mmi.reverser_rvs = (data[15] & 4) > 0
|
||||
mmi.reverser_fwd = (data[15] & 2) > 0
|
||||
mmi.reverser_neu = (data[15] & 1) > 0
|
||||
|
||||
# [Mascon & ATO / Byte 16]
|
||||
mmi.ato_start_btn = (data[16] & 128) > 0
|
||||
mmi.ato_eb_req = (data[16] & 64) > 0
|
||||
mmi.tacho_dir_a = (data[16] & 32) > 0
|
||||
mmi.tacho_dir_b = (data[16] & 16) > 0
|
||||
mmi.mascon_neu = (data[16] & 0) > 0 # Logic Copy
|
||||
mmi.mascon_dr = (data[16] & 2) > 0
|
||||
mmi.mascon_br = (data[16] & 4) > 0
|
||||
mmi.mascon_eb = (data[16] & 8) > 0
|
||||
|
||||
# [ATC Status / Byte 19]
|
||||
val_19 = data[19] & 192
|
||||
if val_19 == 0: mmi.atc_status = "INITIAL PDT"
|
||||
elif val_19 == 192: mmi.atc_status = "MANUAL PDT"
|
||||
elif val_19 == 64: mmi.atc_status = "ATC ACTIVE"
|
||||
elif val_19 == 128: mmi.atc_status = "ATC STANDBY"
|
||||
|
||||
mmi.wheelcheck = (data[19] & 32) > 0
|
||||
|
||||
val_mpdt = data[19] & 28
|
||||
if val_mpdt == 16: mmi.mpdt = "START"
|
||||
elif val_mpdt == 8: mmi.mpdt = "NG"
|
||||
elif val_mpdt == 4: mmi.mpdt = "OK"
|
||||
|
||||
val_ipdt = data[19] & 3
|
||||
if val_ipdt == 2: mmi.ipdt = "NG"
|
||||
elif val_ipdt == 1: mmi.ipdt = "OK"
|
||||
|
||||
# [Fail / Byte 20]
|
||||
mmi.fail_atcr = (data[20] & 128) > 0
|
||||
mmi.fail_atoc = (data[20] & 64) > 0
|
||||
mmi.fail_tcms = (data[20] & 32) > 0
|
||||
mmi.fail_tacho2 = (data[20] & 2) > 0
|
||||
mmi.fail_tacho1 = (data[20] & 1) > 0
|
||||
|
||||
# [Marker / Byte 21]
|
||||
mmi.recovery = (data[21] & 128) > 0
|
||||
mmi.nomal = (data[21] & 64) > 0
|
||||
mmi.tasc = (data[21] & 32) > 0
|
||||
|
||||
val_21 = data[21] & 31
|
||||
if val_21 == 16: mmi.marker = "ATS"
|
||||
elif val_21 == 8: mmi.marker = "PGX"
|
||||
elif val_21 == 4: mmi.marker = "PG3-2"
|
||||
elif val_21 == 2: mmi.marker = "PG2"
|
||||
elif val_21 == 1: mmi.marker = "PG1"
|
||||
|
||||
# [PWM / Byte 22]
|
||||
mmi.pwm_value = data[22]
|
||||
|
||||
# [RLY ATO / Byte 23]
|
||||
mmi.trac_dr = (data[23] & 128) > 0
|
||||
mmi.trac_br = (data[23] & 64) > 0
|
||||
mmi.trac_cs = (data[23] & 32) > 0
|
||||
mmi.ador = (data[23] & 16) > 0
|
||||
mmi.adol = (data[23] & 8) > 0
|
||||
mmi.adc = (data[23] & 4) > 0
|
||||
mmi.start_enable = (data[23] & 2) > 0
|
||||
mmi.trainberth = (data[23] & 1) > 0
|
||||
|
||||
# [TCMS / Byte 24]
|
||||
mmi.tc2 = (data[24] & 128) > 0
|
||||
mmi.tc1 = (data[24] & 64) > 0
|
||||
mmi.tascdb = (data[24] & 32) > 0
|
||||
|
||||
val_tcms_door = data[24] & 24
|
||||
if val_tcms_door == 16: mmi.tcmsdoor = "LDO"
|
||||
elif val_tcms_door == 8: mmi.tcmsdoor = "RDO"
|
||||
elif val_tcms_door == 24: mmi.tcmsdoor = "L,RDO"
|
||||
|
||||
val_door_mode = data[24] & 7
|
||||
if val_door_mode == 4: mmi.doormod = "A / A"
|
||||
elif val_door_mode == 2: mmi.doormod = "A / M"
|
||||
elif val_door_mode == 1: mmi.doormod = "M / M"
|
||||
|
||||
# [ETC / Byte 25]
|
||||
mmi.pre_brake = (data[25] & 128) > 0
|
||||
mmi.limit_drive = (data[25] & 64) > 0
|
||||
mmi.ov_stop1 = (data[25] & 8) > 0
|
||||
mmi.ov_stop2 = (data[25] & 4) > 0
|
||||
mmi.sh_stop1 = (data[25] & 2) > 0
|
||||
mmi.sh_stop2 = (data[25] & 1) > 0
|
||||
|
||||
# [Train No / Byte 26-27]
|
||||
mmi.trainno = f"{(data[26] << 8 | data[27]):04X}"
|
||||
|
||||
# [Stations / Byte 28-30]
|
||||
mmi.pstn = data[28]
|
||||
mmi.nstn = data[29]
|
||||
mmi.dstn = data[30]
|
||||
|
||||
# [DTG / Byte 31-32]
|
||||
num_dtg = (data[31] << 8) | data[32]
|
||||
if mmi.tasc:
|
||||
mmi.dtg = num_dtg / 100.0
|
||||
else:
|
||||
if mmi.trainspeed == 0.0 and not mmi.trac_dr:
|
||||
if (num_dtg & 32768) != 0:
|
||||
num_dtg -= 65536
|
||||
mmi.dtg = (num_dtg / 100.0) * -1.0
|
||||
else:
|
||||
mmi.dtg = num_dtg / 10.0
|
||||
|
||||
# [ATO Limit / Byte 33]
|
||||
mmi.ato_limitSpeed = data[33]
|
||||
mmi.tasc_value = data[33]
|
||||
|
||||
# [TWC / Byte 34]
|
||||
# kur(128), osc(64), inching(32), twct_enable(16), dcw(8), wrongdoor(4)
|
||||
mmi.twct_enable = (data[34] & 16) > 0
|
||||
mmi.door_close_warning = (data[34] & 8) > 0
|
||||
mmi.wrongdoor = (data[34] & 4) > 0
|
||||
|
||||
val_nextdoor = data[34] & 3
|
||||
if val_nextdoor == 2: mmi.nextdoor = "RIGHT"
|
||||
elif val_nextdoor == 1: mmi.nextdoor = "LEFT"
|
||||
|
||||
# [ATC Code / Byte 35]
|
||||
# 상위 4비트: Code Index
|
||||
atc_idx = (data[35] & 240) >> 4
|
||||
atc_str_map = {0:"02", 1:"01", 2:"25", 3:"40", 4:"55", 5:"65", 6:"75", 7:"DE", 8:"DW"}
|
||||
mmi.atc_code = atc_str_map.get(atc_idx, "-")
|
||||
mmi.atc_code_str = mmi.atc_code
|
||||
|
||||
# 그래프용 ATC 코드 속도 매핑 유지
|
||||
atc_speed_map = {0: 0, 1: 0, 2: 25, 3: 40, 4: 55, 5: 65, 6: 75, 7: -1, 8: -1}
|
||||
mmi.atc_code_val = atc_speed_map.get(atc_idx, 0)
|
||||
|
||||
mmi.osc_f0_ok = (data[35] & 8) > 0
|
||||
|
||||
# 하위 3비트: Carrier Freq
|
||||
carr_idx = data[35] & 7
|
||||
if carr_idx == 1: mmi.atc_carrier_f = "F1"
|
||||
elif carr_idx == 2: mmi.atc_carrier_f = "F2"
|
||||
elif carr_idx == 3: mmi.atc_carrier_f = "F3"
|
||||
elif carr_idx == 4: mmi.atc_carrier_f = "F4"
|
||||
|
||||
# [Frequency Calculations (XOR Obfuscation) / Byte 36-39, 55, 58]
|
||||
mmi.atc_code_carrier_f = ((data[36] ^ 65) << 8 | (data[37] ^ 82)) * 10.0
|
||||
mmi.atc_code_f = ((data[38] ^ 99) << 8 | (data[39] ^ 116)) / 10.0
|
||||
mmi.osc_f = ((data[55] ^ 99) << 8 | (data[58] ^ 100)) / 10.0
|
||||
|
||||
# [VDI A - Byte 63, 64, 65]
|
||||
mmi.vdia_rvs = (data[63] & 128) > 0
|
||||
mmi.vdia_neu = (data[63] & 64) > 0
|
||||
mmi.vdia_fwd = (data[63] & 32) > 0
|
||||
mmi.vdia_mascondr = (data[63] & 16) > 0
|
||||
mmi.vdia_masconbr = (data[63] & 8) > 0
|
||||
mmi.vdia_masconeb = (data[63] & 4) > 0
|
||||
mmi.vdia_doorclose = (data[63] & 2) > 0
|
||||
mmi.vdia_dooropen = (data[63] & 1) > 0
|
||||
|
||||
mmi.vdia_fmc = (data[64] & 64) > 0
|
||||
mmi.vdia_yard = (data[64] & 32) > 0
|
||||
mmi.vdia_mcs = (data[64] & 16) > 0
|
||||
mmi.vdia_auto = (data[64] & 8) > 0
|
||||
mmi.vdia_fa = (data[64] & 4) > 0
|
||||
mmi.vdia_tcr = (data[64] & 2) > 0
|
||||
mmi.vdia_hcr = (data[64] & 1) > 0
|
||||
mmi.vdia_stat = f"{data[65]:02X}"
|
||||
|
||||
# [VDI B - Byte 66, 67, 68]
|
||||
mmi.vdib_rvs = (data[66] & 128) > 0
|
||||
mmi.vdib_neu = (data[66] & 64) > 0
|
||||
mmi.vdib_fwd = (data[66] & 32) > 0
|
||||
mmi.vdib_mascondr = (data[66] & 16) > 0
|
||||
mmi.vdib_masconbr = (data[66] & 8) > 0
|
||||
mmi.vdib_masconeb = (data[66] & 4) > 0
|
||||
mmi.vdib_doorclose = (data[66] & 2) > 0
|
||||
mmi.vdib_dooropen = (data[66] & 1) > 0
|
||||
|
||||
mmi.vdib_fmc = (data[67] & 64) > 0
|
||||
mmi.vdib_yard = (data[67] & 32) > 0
|
||||
mmi.vdib_mcs = (data[67] & 16) > 0
|
||||
mmi.vdib_auto = (data[67] & 8) > 0
|
||||
mmi.vdib_fa = (data[67] & 4) > 0
|
||||
mmi.vdib_tcr = (data[67] & 2) > 0
|
||||
mmi.vdib_hcr = (data[67] & 1) > 0
|
||||
mmi.vdib_stat = f"{data[68]:02X}"
|
||||
|
||||
# [VDI C - Byte 69, 70, 71]
|
||||
mmi.vdic_tc2 = (data[69] & 128) > 0
|
||||
mmi.vdic_tc1 = (data[69] & 64) > 0
|
||||
mmi.vdic_edlfb = (data[69] & 32) > 0
|
||||
mmi.vdic_edrfb = (data[69] & 16) > 0
|
||||
mmi.vdic_zvrfb = (data[69] & 8) > 0
|
||||
mmi.vdic_fsbfb = (data[69] & 4) > 0
|
||||
mmi.vdic_ebmfb = (data[69] & 2) > 0
|
||||
mmi.vdic_ebpfb = (data[69] & 1) > 0
|
||||
|
||||
mmi.vdic_unit1 = (data[70] & 128) > 0
|
||||
mmi.vdic_startbtn = (data[70] & 4) > 0
|
||||
mmi.vdic_psdclose = (data[70] & 2) > 0
|
||||
mmi.vdic_psdopen = (data[70] & 1) > 0
|
||||
mmi.vdic_stat = f"{data[71]:02X}"
|
||||
|
||||
# [VDI D - Byte 72, 73, 74]
|
||||
mmi.vdid_tc2 = (data[72] & 128) > 0
|
||||
mmi.vdid_tc1 = (data[72] & 64) > 0
|
||||
mmi.vdid_edlfb = (data[72] & 32) > 0
|
||||
mmi.vdid_edrfb = (data[72] & 16) > 0
|
||||
mmi.vdid_zvrfb = (data[72] & 8) > 0
|
||||
mmi.vdid_fsbfb = (data[72] & 4) > 0
|
||||
mmi.vdid_ebmfb = (data[72] & 2) > 0
|
||||
mmi.vdid_ebpfb = (data[72] & 1) > 0
|
||||
|
||||
mmi.vdid_unit1 = (data[73] & 128) > 0
|
||||
mmi.vdid_startbtn = (data[73] & 4) > 0
|
||||
mmi.vdid_psdclose = (data[73] & 2) > 0
|
||||
mmi.vdid_psdopen = (data[73] & 1) > 0
|
||||
mmi.vdid_stat = f"{data[74]:02X}"
|
||||
|
||||
# [VDO A - Byte 75, 76]
|
||||
mmi.vdoa_edl = (data[75] & 32) > 0
|
||||
mmi.vdoa_edr = (data[75] & 16) > 0
|
||||
mmi.vdoa_zvr = (data[75] & 8) > 0
|
||||
mmi.vdoa_fsb = (data[75] & 4) > 0
|
||||
mmi.vdoa_ebm = (data[75] & 2) > 0
|
||||
mmi.vdoa_ebp = (data[75] & 1) > 0
|
||||
mmi.vdoa_stat = f"{data[76]:02X}"
|
||||
|
||||
# [VDO B - Byte 77, 78]
|
||||
mmi.vdob_edl = (data[77] & 32) > 0
|
||||
mmi.vdob_edr = (data[77] & 16) > 0
|
||||
mmi.vdob_zvr = (data[77] & 8) > 0
|
||||
mmi.vdob_fsb = (data[77] & 4) > 0
|
||||
mmi.vdob_ebm = (data[77] & 2) > 0
|
||||
mmi.vdob_ebp = (data[77] & 1) > 0
|
||||
mmi.vdob_stat = f"{data[78]:02X}"
|
||||
|
||||
return mmi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. 파일 로더
|
||||
# -------------------------------------------------------------------------
|
||||
class LogLoader:
|
||||
def load(self, filepath: str) -> List[DOSItoMMIClass]:
|
||||
result_list = []
|
||||
if not os.path.exists(filepath):
|
||||
return result_list
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
with open(filepath, 'rb') as f:
|
||||
CHUNK_SIZE = 84
|
||||
while f.tell() < file_size:
|
||||
chunk = f.read(CHUNK_SIZE)
|
||||
if len(chunk) < CHUNK_SIZE: break
|
||||
|
||||
# 오류 방지용 try-except 추가 권장
|
||||
try:
|
||||
mmi_data = DDOSItoMMI.set_data(chunk)
|
||||
result_list.append(mmi_data)
|
||||
except Exception as e:
|
||||
print(f"Error parsing chunk at {f.tell()}: {e}")
|
||||
|
||||
return result_list
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import struct
|
||||
import os
|
||||
import polars as pl
|
||||
from typing import Dict, List, Any
|
||||
|
||||
class MMIParser:
|
||||
"""
|
||||
MMI 바이너리 로그를 파싱하여 Polars DataFrame으로 반환하는 클래스
|
||||
"""
|
||||
CHUNK_SIZE = 84
|
||||
|
||||
@staticmethod
|
||||
def parse_chunk(data: bytes) -> Dict[str, Any]:
|
||||
"""84바이트 청크 하나를 딕셔너리로 변환"""
|
||||
if len(data) < 84:
|
||||
return None
|
||||
|
||||
# 기본 파싱 (비트 연산 로직 유지)
|
||||
# 속도를 위해 dataclass 대신 dict 사용
|
||||
row = {}
|
||||
|
||||
# [Header]
|
||||
row["seq"] = data[1]
|
||||
row["source"] = data[3]
|
||||
# 이중계 판단 로직: 소스가 32(0x20)가 아니면 Break(고장/대기)로 간주
|
||||
# (사용자 정의 로직에 따라 16, 32 외의 값 처리도 필요할 수 있음)
|
||||
row["is_break"] = (data[3] != 32)
|
||||
|
||||
# [Time]
|
||||
try:
|
||||
row["time"] = (f"20{data[4]:02x}.{data[5]:02x}.{data[6]:02x} "
|
||||
f"{data[7]:02x}:{data[8]:02x}:{data[9]:02x}")
|
||||
except:
|
||||
row["time"] = None
|
||||
|
||||
# [Speed & Analog]
|
||||
row["trainspeed"] = ((data[10] << 8) | data[11]) / 10.0
|
||||
row["limitspeed"] = data[12]
|
||||
row["pwm_value"] = data[22]
|
||||
|
||||
# [DTG Calculation]
|
||||
tasc = (data[21] & 32) > 0
|
||||
trac_dr = (data[23] & 128) > 0
|
||||
num_dtg = (data[31] << 8) | data[32]
|
||||
|
||||
if tasc:
|
||||
row["dtg"] = num_dtg / 100.0
|
||||
else:
|
||||
if row["trainspeed"] == 0.0 and not trac_dr:
|
||||
if (num_dtg & 32768) != 0:
|
||||
num_dtg -= 65536
|
||||
row["dtg"] = (num_dtg / 100.0) * -1.0
|
||||
else:
|
||||
row["dtg"] = num_dtg / 10.0
|
||||
|
||||
# [Frequency & XOR Decoding]
|
||||
row["atc_code_carrier_f"] = ((data[36] ^ 65) << 8 | (data[37] ^ 82)) * 10.0
|
||||
row["atc_code_f"] = ((data[38] ^ 99) << 8 | (data[39] ^ 116)) / 10.0
|
||||
|
||||
# [Bitmasks - 주요 신호만 예시로 포함, 필요 시 전체 추가]
|
||||
# bitmasking은 polars의 map_elements보다 여기서 처리해서 넘기는게 빠름
|
||||
row["do_zvr"] = (data[13] & 32) > 0
|
||||
row["do_ebm"] = (data[13] & 2) > 0
|
||||
row["system_active"] = (data[14] & 128) > 0
|
||||
row["door_open"] = (data[14] & 8) > 0
|
||||
row["door_close"] = (data[14] & 4) > 0
|
||||
|
||||
# [Station Codes for Semantic Mapping]
|
||||
row["pstn"] = data[28] # 이전역
|
||||
row["nstn"] = data[29] # 현재/다음역
|
||||
row["dstn"] = data[30] # 종착역
|
||||
|
||||
# [ATC Code Parsing]
|
||||
atc_idx = (data[35] & 240) >> 4
|
||||
# 매핑은 여기서 문자열로 변환하지 않고 숫자(Index)만 넘깁니다.
|
||||
# (문자열 변환은 Processor에서 일괄 처리하는 게 속도상 유리)
|
||||
row["atc_code_idx"] = atc_idx
|
||||
|
||||
return row
|
||||
|
||||
def load_to_dataframe(self, filepath: str) -> pl.DataFrame:
|
||||
"""파일 전체를 읽어 Polars DataFrame으로 반환"""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"File not found: {filepath}")
|
||||
|
||||
raw_data = []
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
with open(filepath, 'rb') as f:
|
||||
# 파일 전체를 메모리에 로드 (50만개 * 84byte = 약 42MB, 메모리에 충분함)
|
||||
# IO 속도 향상을 위해 한 번에 읽음
|
||||
buffer = f.read()
|
||||
|
||||
# 청크 단위 파싱
|
||||
total_chunks = len(buffer) // self.CHUNK_SIZE
|
||||
for i in range(total_chunks):
|
||||
start = i * self.CHUNK_SIZE
|
||||
end = start + self.CHUNK_SIZE
|
||||
chunk = buffer[start:end]
|
||||
|
||||
parsed_row = self.parse_chunk(chunk)
|
||||
if parsed_row:
|
||||
raw_data.append(parsed_row)
|
||||
|
||||
# Polars DataFrame 생성 (고속)
|
||||
return pl.DataFrame(raw_data)
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import polars as pl
|
||||
from mmi_parser import MMIParser
|
||||
|
||||
class MMIProcessor:
|
||||
def __init__(self):
|
||||
# 역 코드 매핑 테이블 (예시)
|
||||
self.station_map = {
|
||||
134: "범어사",
|
||||
135: "남산",
|
||||
136: "두실",
|
||||
# ... 실제 매핑 데이터 추가
|
||||
}
|
||||
|
||||
# ATC 코드 매핑
|
||||
self.atc_code_map = {
|
||||
0: "02", 1: "01", 2: "25", 3: "40", 4: "55"
|
||||
}
|
||||
|
||||
def process_and_save(self, input_path: str, output_parquet_path: str):
|
||||
# 1. 파싱 (Raw Data 로드)
|
||||
parser = MMIParser()
|
||||
df = parser.load_to_dataframe(input_path)
|
||||
|
||||
print(f"Loaded {len(df)} rows.")
|
||||
|
||||
# 2. 이중계 필터링 (Redundancy Logic)
|
||||
# 로직: 같은 seq 중에서 is_break가 False(정상)인 것을 우선 선택
|
||||
# sort: seq 오름차순, is_break 오름차순 (False=0, True=1 이므로 정상 데이터가 위로 옴)
|
||||
df_clean = (
|
||||
df.sort(["seq", "is_break"])
|
||||
.unique(subset=["seq"], keep="first") # 그룹별 첫 번째(정상)만 유지
|
||||
)
|
||||
|
||||
print(f"Filtered redundancy. {len(df)} -> {len(df_clean)} rows.")
|
||||
|
||||
# 3. 시멘틱 데이터 보강 (Semantic Enrichment)
|
||||
# map_dict를 사용하여 고속 매핑
|
||||
df_enriched = df_clean.with_columns([
|
||||
pl.col("nstn").replace(self.station_map).alias("station_name"),
|
||||
pl.col("atc_code_idx").replace(self.atc_code_map).alias("atc_code_str")
|
||||
])
|
||||
|
||||
# 4. Parquet 저장 (압축 사용)
|
||||
df_enriched.write_parquet(output_parquet_path, compression="zstd")
|
||||
print(f"Saved to {output_parquet_path}")
|
||||
|
||||
return df_enriched
|
||||
|
||||
# --- 실행 예시 ---
|
||||
if __name__ == "__main__":
|
||||
processor = MMIProcessor()
|
||||
# 로우 데이터 경로와 저장할 경로 지정
|
||||
df = processor.process_and_save("raw_log.bin", "processed_data.parquet")
|
||||
|
||||
# 결과 확인
|
||||
print(df.head())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,322 @@
|
|||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QTabWidget, QLabel, QPushButton, QHBoxLayout, QFileDialog
|
||||
from PySide6.QtCore import Signal
|
||||
from app.ui.views.graph_view import GraphView
|
||||
# 추후 구현할 뷰들: from app.ui.views.dashboard_view import DashboardView
|
||||
from app.data.log_parser import LogLoader
|
||||
from app.core.sync_controller import sync_manager
|
||||
from app.ui.views.dashboard_view import DashboardView
|
||||
from app.ui.views.ai_view import AIDiagnosisView
|
||||
import polars as pl
|
||||
from dataclasses import fields
|
||||
from app.data.fast_parser import FastLogParser
|
||||
from app.data.log_parser import DOSItoMMIClass
|
||||
|
||||
class AnalysisPanel(QWidget):
|
||||
# 데이터 로드 완료 시그널
|
||||
data_loaded = Signal()
|
||||
|
||||
def __init__(self, panel_id, parent=None):
|
||||
super().__init__(parent)
|
||||
self.panel_id = panel_id # "left" or "right"
|
||||
self.loader = None # 지연 초기화 - 실제 사용 시점에 생성
|
||||
self.raw_data = []
|
||||
self.data_date = None # 데이터 날짜 (YYYY.MM.DD 형식)
|
||||
self.file_path = None
|
||||
self.current_index = None
|
||||
self.current_row = None
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
# 동기화 신호 받기
|
||||
sync_manager.time_changed.connect(self.on_sync_received)
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# [상단] 개별 파일 로드 버튼 (A/B 비교를 위해)
|
||||
top_bar = QHBoxLayout()
|
||||
self.lbl_file = QLabel("No File Loaded")
|
||||
self.btn_load = QPushButton(f"Load File ({self.panel_id.upper()})")
|
||||
self.btn_load.clicked.connect(self.open_file)
|
||||
|
||||
top_bar.addWidget(self.lbl_file)
|
||||
top_bar.addWidget(self.btn_load)
|
||||
layout.addLayout(top_bar)
|
||||
|
||||
# [중앙] 탭 위젯
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
# 탭 1: 그래프 뷰
|
||||
self.graph_view = GraphView(self.panel_id)
|
||||
self.tabs.addTab(self.graph_view, "📊 Graph")
|
||||
|
||||
# 탭 2: 기존 뷰어 (껍데기)
|
||||
self.dashboard_view = DashboardView(self.panel_id)
|
||||
self.tabs.addTab(self.dashboard_view, "📟 Legacy Viewer")
|
||||
|
||||
# 탭 3: 신호 목록 (껍데기)
|
||||
self.tabs.addTab(QWidget(), "📜 Signal List")
|
||||
|
||||
# 탭 4: AI 분석 (GraphView 컨텍스트 포함)
|
||||
self.ai_view = AIDiagnosisView(self.panel_id, graph_view=self.graph_view)
|
||||
self.tabs.addTab(self.ai_view, "🤖 AI Diagnosis")
|
||||
|
||||
# 그래프 선택 구간 → AI 요청 연결
|
||||
self.graph_view.range_ai_requested.connect(self._on_range_ai_requested)
|
||||
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
def open_file(self):
|
||||
file_name, _ = QFileDialog.getOpenFileName(self, "Open Log", "", "Log Files (*.dat)")
|
||||
if file_name:
|
||||
self.load_data(file_name)
|
||||
|
||||
def load_data(self, file_path):
|
||||
self.file_path = file_path
|
||||
self.lbl_file.setText(file_path.split("/")[-1])
|
||||
# 1. FastLogParser 시도 (Polars)
|
||||
parquet_path = file_path.replace(".dat", ".parquet")
|
||||
try:
|
||||
# 파싱 및 Parquet 저장 (이미 있으면 스킵 가능하지만, 로직상 호출)
|
||||
# FastLogParser 내부에서 파일 존재 체크 등을 수행함
|
||||
output_path = FastLogParser.parse_to_parquet(file_path)
|
||||
|
||||
if output_path:
|
||||
# Polars로 읽기
|
||||
df = pl.read_parquet(output_path)
|
||||
|
||||
# DataFrame -> List[DOSItoMMIClass] 변환
|
||||
# 1) 필드 목록 추출
|
||||
valid_fields = {f.name for f in fields(DOSItoMMIClass)}
|
||||
|
||||
# 2) 딕셔너리 변환 (Polars -> Python List of Dicts)
|
||||
# rows = df.to_dicts() # 이 방식은 빠름
|
||||
|
||||
# 3) 객체 생성 (불필요한 컬럼 제거)
|
||||
# 벡터화된 방식은 아니지만, Python 루프보다는 빠름 (파싱 비용 제거됨)
|
||||
# 50만건 기준 약 1~2초 소요 예상
|
||||
self.raw_data = [
|
||||
DOSItoMMIClass(**{k: v for k, v in row.items() if k in valid_fields})
|
||||
for row in df.to_dicts()
|
||||
]
|
||||
print(f"[AnalysisPanel] Loaded {len(self.raw_data)} records via FastLogParser")
|
||||
|
||||
else:
|
||||
raise Exception("FastLogParser returned None")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AnalysisPanel] FastLogParser failed, falling back to legacy loader: {e}")
|
||||
# LogLoader 지연 초기화
|
||||
if self.loader is None:
|
||||
self.loader = LogLoader()
|
||||
self.raw_data = self.loader.load(file_path)
|
||||
|
||||
# 레거시 로더 사용 시 역 정보 매핑 (기존 로직 유지)
|
||||
from app.core.reference_data import ref_data
|
||||
if ref_data._is_loaded and self.raw_data:
|
||||
for item in self.raw_data:
|
||||
if item.pstn > 0:
|
||||
st_name = ref_data.get_station_name(item.pstn)
|
||||
if st_name:
|
||||
item.station_name = st_name
|
||||
|
||||
|
||||
# 데이터 날짜 추출 (첫 번째 레코드의 time 필드에서)
|
||||
if self.raw_data and len(self.raw_data) > 0:
|
||||
time_str = getattr(self.raw_data[0], 'time', '')
|
||||
# time 형식: "20YY.MM.DD HH:mm:ss" -> 날짜 부분만 추출
|
||||
if time_str and len(time_str) >= 10:
|
||||
self.data_date = time_str[:10] # "20YY.MM.DD"
|
||||
else:
|
||||
self.data_date = None
|
||||
else:
|
||||
self.data_date = None
|
||||
|
||||
# 하위 뷰들에게 데이터 전달
|
||||
self.graph_view.set_data(self.raw_data)
|
||||
self.dashboard_view.set_data(self.raw_data) # Legacy Viewer에도 전달
|
||||
self.ai_view.set_file_context(file_path)
|
||||
self.ai_view.set_data(self.raw_data)
|
||||
|
||||
# 기본 커서 포인트(첫 레코드) 설정
|
||||
if self.raw_data:
|
||||
self.current_index = 0
|
||||
self.current_row = self.raw_data[0]
|
||||
|
||||
# 첫 번째 패널이 로드되면 전체 레코드 수 설정 (임시)
|
||||
if self.panel_id == "left":
|
||||
sync_manager._total_records = len(self.raw_data)
|
||||
|
||||
# 데이터 로드 완료 시그널 발생
|
||||
self.data_loaded.emit()
|
||||
|
||||
def on_sync_received(self, index, data, source_id):
|
||||
"""
|
||||
SyncController로부터 "시간 바꿔!" 명령이 오면 실행
|
||||
단, 내가 보낸 신호가 아닐 때만 반응해야 함 (Loop 방지)
|
||||
"""
|
||||
# 내 패널에서 움직인 커서도 기록해둬야 AI/플로팅 채팅에서 현재 시점을 알 수 있음
|
||||
if source_id == self.panel_id:
|
||||
self.current_index = index
|
||||
self.current_row = data
|
||||
return
|
||||
|
||||
if source_id != self.panel_id:
|
||||
# 타임라인/커서 이동
|
||||
self.graph_view.set_index(index)
|
||||
# 추후 다른 뷰들도 이동 (예: self.dashboard.set_values(data))
|
||||
|
||||
def _on_range_ai_requested(self, start_ms: int, end_ms: int, start_idx: int, end_idx: int):
|
||||
"""그래프에서 선택한 구간을 AI 분석으로 전달"""
|
||||
if hasattr(self, "ai_view") and self.ai_view:
|
||||
self.ai_view.run_range_analysis(start_idx, end_idx, start_ms, end_ms)
|
||||
|
||||
def set_slave_mode(self, is_slave):
|
||||
# Sync 모드일 때 로드 버튼 숨기기
|
||||
self.btn_load.setVisible(not is_slave)
|
||||
self.lbl_file.setText("Linked to Left Panel" if is_slave else "No File")
|
||||
|
||||
def receive_shared_data(self, data_list):
|
||||
# 다른 패널에서 데이터 받아오기
|
||||
self.raw_data = data_list
|
||||
self.graph_view.set_data(data_list)
|
||||
self.dashboard_view.set_data(data_list) # Legacy Viewer에도 전달
|
||||
self.ai_view.set_data(data_list)
|
||||
|
||||
if self.raw_data:
|
||||
self.current_index = 0
|
||||
self.current_row = self.raw_data[0]
|
||||
|
||||
def get_ai_context(self):
|
||||
"""플로팅 AI/외부 호출용: 현재 패널의 AI 컨텍스트 묶음"""
|
||||
ctx = {
|
||||
"panel_id": self.panel_id,
|
||||
"file_path": self.file_path,
|
||||
"data_date": self.data_date,
|
||||
"current_index": self.current_index,
|
||||
}
|
||||
try:
|
||||
if hasattr(self, "graph_view") and self.graph_view and hasattr(self.graph_view, "get_ai_context"):
|
||||
ctx["graph"] = self.graph_view.get_ai_context(self.current_index)
|
||||
except Exception:
|
||||
ctx["graph"] = {}
|
||||
|
||||
try:
|
||||
if self.current_row is not None:
|
||||
d = self.current_row
|
||||
ctx["record"] = {
|
||||
"time": getattr(d, "time", ""),
|
||||
"trainno": getattr(d, "trainno", ""),
|
||||
"trainspeed": getattr(d, "trainspeed", None),
|
||||
"dtg": getattr(d, "dtg", None),
|
||||
"atc_status": getattr(d, "atc_status", ""),
|
||||
"marker": getattr(d, "marker", ""),
|
||||
"pstn": getattr(d, "pstn", None),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return ctx
|
||||
|
||||
def compute_pg_at_station(self, marker_name: str = "PG3-2"):
|
||||
"""
|
||||
'각 역마다 PG3-2 위치에서의 속도/TASC' 같은 질의를 위해
|
||||
역 구간별(역 이벤트 ~ 다음 역 이벤트)로 PG 마커를 매칭해 지표를 추출한다.
|
||||
"""
|
||||
if not self.raw_data or not hasattr(self, "graph_view") or not self.graph_view:
|
||||
return []
|
||||
|
||||
gv = self.graph_view
|
||||
if not hasattr(gv, "chart_view") or not getattr(gv, "start_timestamp", 0):
|
||||
return []
|
||||
|
||||
# 역 이벤트(시간, 역명)
|
||||
try:
|
||||
stations = list(gv.chart_view.event_markers.get("STATION", []))
|
||||
except Exception:
|
||||
stations = []
|
||||
stations = [(int(ts), str(name)) for ts, name in stations if ts is not None]
|
||||
stations.sort(key=lambda x: x[0])
|
||||
if not stations:
|
||||
return []
|
||||
|
||||
# PG Rx 마커(시간, 타입)
|
||||
try:
|
||||
pgs = list(gv.chart_view.event_markers.get("PG", []))
|
||||
except Exception:
|
||||
pgs = []
|
||||
pgs = [(int(ts), str(name)) for ts, name in pgs if ts is not None]
|
||||
pgs = [p for p in pgs if p[1] == marker_name]
|
||||
pgs.sort(key=lambda x: x[0])
|
||||
|
||||
results = []
|
||||
for i, (st_ts, st_name) in enumerate(stations):
|
||||
seg_start = st_ts
|
||||
seg_end = stations[i + 1][0] if i + 1 < len(stations) else 10**18
|
||||
|
||||
# 구간 내 첫 PG3-2 (없으면 None)
|
||||
pg_ts = None
|
||||
for t, n in pgs:
|
||||
if seg_start <= t < seg_end:
|
||||
pg_ts = t
|
||||
break
|
||||
|
||||
row = {
|
||||
"station": st_name,
|
||||
"station_ts": st_ts,
|
||||
"pg_marker": marker_name,
|
||||
"pg_ts": pg_ts,
|
||||
}
|
||||
|
||||
if pg_ts is not None:
|
||||
idx = int(round((pg_ts - gv.start_timestamp) / 1000.0))
|
||||
idx = max(0, min(len(self.raw_data) - 1, idx))
|
||||
d = self.raw_data[idx]
|
||||
# raw 값
|
||||
row.update(
|
||||
{
|
||||
"index": idx,
|
||||
"time": getattr(d, "time", ""),
|
||||
"raw_trainspeed": getattr(d, "trainspeed", None),
|
||||
"raw_tasc_value": getattr(d, "tasc_value", None),
|
||||
"raw_dtg": getattr(d, "dtg", None),
|
||||
"raw_atc_status": getattr(d, "atc_status", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# filtered(그래프 표시) 값도 함께
|
||||
try:
|
||||
sp = gv.signals_map.get("speed", {}).get("data", [])
|
||||
ta = gv.signals_map.get("tasc", {}).get("data", [])
|
||||
dg = gv.signals_map.get("dtg", {}).get("data", [])
|
||||
if 0 <= idx < len(sp):
|
||||
row["filtered_speed"] = sp[idx]
|
||||
if 0 <= idx < len(ta):
|
||||
row["filtered_tasc"] = ta[idx]
|
||||
if 0 <= idx < len(dg):
|
||||
row["filtered_dtg"] = dg[idx]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# all-zero 프레임 여부
|
||||
try:
|
||||
if hasattr(gv, "_all_zero_mask") and gv._all_zero_mask and 0 <= idx < len(gv._all_zero_mask):
|
||||
row["all_zero_frame"] = bool(gv._all_zero_mask[idx])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 우선 filtered로 차이 계산, 없으면 raw로 폴백
|
||||
s = row.get("filtered_speed", row.get("raw_trainspeed"))
|
||||
t = row.get("filtered_tasc", row.get("raw_tasc_value"))
|
||||
if s is not None and t is not None:
|
||||
row["speed_minus_tasc"] = float(s) - float(t)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
results.append(row)
|
||||
|
||||
return results
|
||||
|
||||
def get_data_date(self):
|
||||
"""현재 로드된 데이터의 날짜 반환"""
|
||||
return self.data_date
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,74 @@
|
|||
from PySide6.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Qt, QPoint, QRectF
|
||||
from PySide6.QtGui import QPainter, QColor, QPen, QFont, QPolygon
|
||||
|
||||
class CircularGauge(QWidget):
|
||||
def __init__(self, title="SPEED", min_val=0, max_val=120, unit="km/h", color="#00E5FF"):
|
||||
super().__init__()
|
||||
self.value = 0
|
||||
self.target_value = 0 # 목표 속도 (빨간 바늘 등)
|
||||
self.min_val = min_val
|
||||
self.max_val = max_val
|
||||
self.title = title
|
||||
self.unit = unit
|
||||
self.accent_color = QColor(color)
|
||||
self.setMinimumSize(200, 200)
|
||||
|
||||
def set_value(self, val, target=None):
|
||||
self.value = max(self.min_val, min(val, self.max_val))
|
||||
if target is not None:
|
||||
self.target_value = max(self.min_val, min(target, self.max_val))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
w, h = self.width(), self.height()
|
||||
side = min(w, h)
|
||||
painter.translate(w/2, h/2)
|
||||
painter.scale(side/200.0, side/200.0)
|
||||
|
||||
# 1. 배경
|
||||
painter.setPen(QPen(QColor("#222"), 4))
|
||||
painter.drawEllipse(-90, -90, 180, 180)
|
||||
|
||||
# 2. 눈금 (Scale)
|
||||
start_angle = 135
|
||||
span_angle = 270
|
||||
|
||||
painter.setPen(QPen(Qt.white, 2))
|
||||
painter.setFont(QFont("Arial", 8))
|
||||
|
||||
step = (self.max_val - self.min_val) / 10
|
||||
for i in range(11):
|
||||
val = self.min_val + i * step
|
||||
angle = start_angle + (i * (span_angle / 10))
|
||||
|
||||
painter.save()
|
||||
painter.rotate(angle)
|
||||
painter.drawLine(80, 0, 90, 0) # 눈금
|
||||
painter.restore()
|
||||
|
||||
# 숫자 (삼각함수로 위치 계산 생략하고 간략히 처리하거나 추가 구현)
|
||||
|
||||
# 3. 메인 바늘 (Value)
|
||||
val_pct = (self.value - self.min_val) / (self.max_val - self.min_val)
|
||||
angle = start_angle + (val_pct * span_angle)
|
||||
|
||||
painter.save()
|
||||
painter.rotate(angle)
|
||||
painter.setBrush(self.accent_color)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.drawConvexPolygon([QPoint(0, -3), QPoint(0, 3), QPoint(85, 0)])
|
||||
painter.restore()
|
||||
|
||||
# 4. 텍스트 표시
|
||||
painter.setPen(self.accent_color)
|
||||
painter.setFont(QFont("Arial", 16, QFont.Bold))
|
||||
painter.drawText(QRectF(-50, 20, 100, 30), Qt.AlignCenter, f"{self.value:.1f}")
|
||||
|
||||
painter.setPen(Qt.gray)
|
||||
painter.setFont(QFont("Arial", 9))
|
||||
painter.drawText(QRectF(-50, 50, 100, 20), Qt.AlignCenter, self.unit)
|
||||
painter.drawText(QRectF(-50, -60, 100, 20), Qt.AlignCenter, self.title)
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
from PySide6.QtWidgets import QLabel, QMenu, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Qt,Signal, QPoint
|
||||
from PySide6.QtGui import QMouseEvent, QEnterEvent, QDragLeaveEvent, QCursor
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
"""
|
||||
클릭 / 더블클릭 / 우클릭 컨텍스트 메뉴 / 마우스 호버링 / 드래그 / 그리드 스냅 지원 QLabel
|
||||
|
||||
[Signals]
|
||||
- clicked(): 좌클릭
|
||||
- double_clicked(): 좌측 더블클릭
|
||||
- right_clicked(pos: QPoint): 우클릭 (로컬 좌표)
|
||||
- hover_entered(): 마우스 진입
|
||||
- hover_left(): 마우스 이탈
|
||||
- drag_started(pos: QPoint): 드래그 시작
|
||||
- dragging(delta: QPoint): 드래그 중 (이동량)
|
||||
- drag_finished(): 드래그 종료
|
||||
|
||||
/사용예시
|
||||
label = ClickableLabel("클릭해보세요")
|
||||
label.clicked.connect(lambda: logger.info("원클릭"))
|
||||
label.double_clicked.connect(lambda: logger.info("더블클릭"))
|
||||
label.hover_entered.connect(lambda: logger.info("마우스 진입"))
|
||||
label.hover_left.connect(lambda: logger.info("마우스 이탈"))
|
||||
|
||||
/우클릭 컨텍스트
|
||||
label.right_clicked.connect(
|
||||
lambda pos: logger.info(f"우클릭 위치: {pos}")
|
||||
)
|
||||
|
||||
/드래그
|
||||
label.drag_started.connect(
|
||||
lambda pos: logger.info(f"드래그 시작 위치: {pos}")
|
||||
)
|
||||
label.dragging.connect(
|
||||
lambda delta: logger.info(f"드래그 이동량: {delta}")
|
||||
)
|
||||
label.drag_finished.connect(lambda: logger.info("드래그 종료"))
|
||||
|
||||
def on_drag(delta: QPoint):
|
||||
label.move(label.pos() + delta)
|
||||
label.dragging.connect(on_drag)
|
||||
|
||||
/드래그 시작 시 UI 반응 정의 : 배경색 변경
|
||||
label.drag_started.connect(lambda _: label.setStyleSheet("background:#d0ebff"))
|
||||
label.drag_finished.connect(lambda: label.setStyleSheet(""))
|
||||
|
||||
호버링 (호버링 사용시 setMouseTracking(True) 필요)
|
||||
def on_hover_enter():
|
||||
label.setStyleSheet("background-color: #f0f0f0;")
|
||||
|
||||
def on_hover_leave():
|
||||
label.setStyleSheet("background-color: none;")
|
||||
|
||||
label.hover_entered.connect(on_hover_enter)
|
||||
label.hover_left.connect(on_hover_leave)
|
||||
|
||||
|
||||
"""
|
||||
|
||||
clicked = Signal() # 좌클릭
|
||||
double_clicked = Signal() # 더블클릭
|
||||
right_clicked = Signal(QPoint) # 우클릭
|
||||
|
||||
hover_entered = Signal() # 마우스 진입
|
||||
hover_left = Signal() # 마우스 이탈
|
||||
|
||||
drag_started = Signal(QPoint) # 드래그 시작 위치
|
||||
dragging = Signal(QPoint) # 드래그 이동량
|
||||
drag_finished = Signal() # 드래그 종료
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str = "",
|
||||
parent=None,
|
||||
hover_text=None,
|
||||
*,
|
||||
enable_click: bool = True,
|
||||
enable_double_click: bool = True,
|
||||
enable_right_click: bool = False,
|
||||
enable_hover: bool = True,
|
||||
enable_drag: bool = False,
|
||||
enable_context_menu: bool = False,
|
||||
context_menu_builder=None,
|
||||
enable_grid_snap: bool = False,
|
||||
grid_size: int = 20,
|
||||
**kwargs
|
||||
):
|
||||
|
||||
super().__init__(text, parent)
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# 기능 활성화 플래그
|
||||
# -----------------------------
|
||||
self.enable_click = enable_click
|
||||
self.enable_double_click = enable_double_click
|
||||
self.enable_right_click = enable_right_click
|
||||
self.enable_hover = enable_hover
|
||||
self.enable_drag = enable_drag
|
||||
self.enable_context_menu = enable_context_menu
|
||||
self._context_menu_builder = context_menu_builder
|
||||
self.enable_grid_snap = enable_grid_snap
|
||||
|
||||
# -----------------------------
|
||||
# 드래그 / 그리드 설정
|
||||
# -----------------------------
|
||||
self.grid_size = grid_size
|
||||
self._drag_threshold = 5 # px (클릭 vs 드래그 구분)
|
||||
|
||||
self._press_pos: QPoint | None = None
|
||||
self._dragging = False
|
||||
|
||||
# -----------------------------
|
||||
# UI 기본 설정
|
||||
# -----------------------------
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
# 텍스트 중앙 정렬
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
|
||||
self._hover_text = hover_text
|
||||
self._hover_text_provider: callable | None = None
|
||||
self._hover_popup = None
|
||||
|
||||
# hover는 mouse tracking 필수
|
||||
if self.enable_hover:
|
||||
self.setMouseTracking(True)
|
||||
else:
|
||||
self.setMouseTracking(False)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 마우스 누름
|
||||
# -------------------------------------------------
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
if event.button() == Qt.LeftButton:
|
||||
if self.enable_click:
|
||||
self.clicked.emit()
|
||||
|
||||
if self.enable_drag:
|
||||
self._press_pos = event.pos()
|
||||
self._dragging = False
|
||||
|
||||
elif event.button() == Qt.RightButton:
|
||||
if self.enable_right_click:
|
||||
self.right_clicked.emit(event.pos())
|
||||
|
||||
if self.enable_context_menu:
|
||||
self._show_context_menu(event.globalPos())
|
||||
|
||||
super().mousePressEvent(event)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 마우스 이동 (드래그)
|
||||
# -------------------------------------------------
|
||||
def mouseMoveEvent(self, event: QMouseEvent):
|
||||
if not self.enable_drag or self._press_pos is None:
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
|
||||
delta = event.pos() - self._press_pos
|
||||
|
||||
if not self._dragging and delta.manhattanLength() >= self._drag_threshold:
|
||||
self._dragging = True
|
||||
self.drag_started.emit(self._press_pos)
|
||||
|
||||
if self._dragging:
|
||||
self.dragging.emit(delta)
|
||||
self._move_with_grid_snap(delta)
|
||||
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 마우스 해제
|
||||
# -------------------------------------------------
|
||||
def mouseReleaseEvent(self, event: QMouseEvent):
|
||||
if event.button() == Qt.LeftButton and self.enable_drag:
|
||||
if self._dragging:
|
||||
self.drag_finished.emit()
|
||||
|
||||
self._dragging = False
|
||||
self._press_pos = None
|
||||
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 더블클릭
|
||||
# -------------------------------------------------
|
||||
def mouseDoubleClickEvent(self, event: QMouseEvent):
|
||||
if event.button() == Qt.LeftButton and self.enable_double_click:
|
||||
self.double_clicked.emit()
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 호버
|
||||
|
||||
# 사용법 (주의:provider 함수에서 DB 조회, 네트워크 요청, 긴 계산 등을 포함하면 안됨,필요한 경우 캐시+갱신 이벤트구조로 구현)
|
||||
|
||||
# label.set_hover_text_provider(
|
||||
# lambda: f"""
|
||||
# 상태: {node.status}
|
||||
# 전압: {node.voltage}V
|
||||
# 최근 점검: {node.last_check}
|
||||
# """.strip()
|
||||
# )
|
||||
|
||||
# -------------------------------------------------
|
||||
def enterEvent(self, event: QEnterEvent):
|
||||
if self.enable_hover:
|
||||
self.hover_entered.emit()
|
||||
text = self._get_hover_text()
|
||||
if text:
|
||||
self._hover_popup = HoverInfoPopup(text)
|
||||
pos = QCursor.pos() + QPoint(10, 10) # 마우스 커서 위치에서 약간 오프셋
|
||||
self._hover_popup.move(pos)
|
||||
self._hover_popup.show()
|
||||
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QDragLeaveEvent):
|
||||
|
||||
if not self.enable_hover:
|
||||
super().leaveEvent(event)
|
||||
return
|
||||
|
||||
# popup이 있고, 마우스가 popup 안에 있으면 닫지 않음
|
||||
if self._hover_popup and self._hover_popup.is_mouse_inside():
|
||||
return
|
||||
|
||||
self.hover_left.emit()
|
||||
|
||||
if self.enable_hover:
|
||||
if self._hover_popup:
|
||||
self._hover_popup.close()
|
||||
self._hover_popup = None
|
||||
super().leaveEvent(event)
|
||||
|
||||
def set_hover_text(self, text: str):
|
||||
"""
|
||||
hover 정보 텍스트 설정 (즉시 반영)
|
||||
"""
|
||||
self._hover_text = text
|
||||
self._hover_text_provider = None
|
||||
|
||||
if self._hover_popup:
|
||||
self._hover_popup.set_text(text)
|
||||
|
||||
def _get_hover_text(self) -> str | None:
|
||||
if self._hover_text_provider:
|
||||
return self._hover_text_provider()
|
||||
return self._hover_text
|
||||
|
||||
def clear_hover_text(self):
|
||||
"""
|
||||
hover 정보 제거
|
||||
"""
|
||||
self._hover_text = None
|
||||
self._hover_text_provider = None
|
||||
|
||||
if self._hover_popup:
|
||||
self._hover_popup.close()
|
||||
self._hover_popup = None
|
||||
|
||||
def set_hover_text_provider(self, provider: callable):
|
||||
"""
|
||||
hover 시점에 호출되는 함수 등록
|
||||
"""
|
||||
self._hover_text_provider = provider
|
||||
self._hover_text = None
|
||||
|
||||
if self._hover_popup:
|
||||
self._hover_popup.set_text(provider())
|
||||
|
||||
|
||||
# =================================================
|
||||
# 내부 기능
|
||||
# =================================================
|
||||
def _show_context_menu(self, global_pos):
|
||||
if not self._context_menu_builder:
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
self._context_menu_builder(menu, self) # 컨텍스트 메뉴 빌더 호출
|
||||
menu.exec(global_pos)
|
||||
|
||||
''' 외부에서 컨텍스트 메뉴 빌더 정의
|
||||
def build_label_menu(menu: QMenu, label: ClickableLabel):
|
||||
menu.addAction("편집", lambda: edit_label(label))
|
||||
menu.addAction("삭제", lambda: delete_label(label))
|
||||
menu.addSeparator()
|
||||
menu.addAction("정보", lambda: show_info(label))
|
||||
|
||||
def edit_label(label):
|
||||
logger.info(f"편집: {label.text()}")
|
||||
|
||||
def delete_label(label):
|
||||
label.deleteLater()
|
||||
|
||||
def show_info(label):
|
||||
logger.info(f"위치: {label.pos()}")
|
||||
|
||||
객체 생성시 컨텍스트 메뉴 빌더 전달
|
||||
label = ClickableLabel(
|
||||
"NODE-1",
|
||||
context_menu_builder=build_label_menu
|
||||
)
|
||||
'''
|
||||
|
||||
def _move_with_grid_snap(self, delta: QPoint):
|
||||
new_pos = self.pos() + delta
|
||||
|
||||
if self.enable_grid_snap:
|
||||
x = round(new_pos.x() / self.grid_size) * self.grid_size
|
||||
y = round(new_pos.y() / self.grid_size) * self.grid_size
|
||||
self.move(QPoint(x, y))
|
||||
else:
|
||||
self.move(new_pos)
|
||||
|
||||
|
||||
|
||||
class PopupTheme:
|
||||
LIGHT = "light"
|
||||
DARK = "dark"
|
||||
|
||||
|
||||
class HoverInfoPopup(QWidget):
|
||||
"""
|
||||
마우스 호버 시 정보 팝업 표시 QWidget
|
||||
|
||||
사용예시
|
||||
popup = HoverInfoPopup("호버 정보")
|
||||
popup.show()
|
||||
"""
|
||||
def __init__(self, text: str, theme=PopupTheme.DARK, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# -----------------------------
|
||||
# 테마 설정
|
||||
# -----------------------------
|
||||
self._theme = theme
|
||||
self.setWindowFlags(
|
||||
Qt.ToolTip |
|
||||
Qt.FramelessWindowHint |
|
||||
Qt.BypassWindowManagerHint
|
||||
)
|
||||
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
|
||||
self.apply_theme(theme)
|
||||
|
||||
# -----------------------------
|
||||
# 윈도우 설정
|
||||
# -----------------------------
|
||||
self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
|
||||
|
||||
# -----------------------------
|
||||
# 드래그 설정
|
||||
# -----------------------------
|
||||
self._drag_start_pos = None
|
||||
|
||||
# -----------------------------
|
||||
# UI 설정
|
||||
# -----------------------------
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
|
||||
# -----------------------------
|
||||
# 라벨 설정
|
||||
# -----------------------------
|
||||
self.label = QLabel(text)
|
||||
self.label.setTextInteractionFlags(
|
||||
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard
|
||||
)
|
||||
|
||||
layout.addWidget(self.label)
|
||||
|
||||
self.setStyleSheet("""
|
||||
QWidget {
|
||||
background-color: #ffffe1;
|
||||
border: 1px solid #c8c8a9;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
""")
|
||||
|
||||
# -----------------------------
|
||||
# 드래그 이동
|
||||
# -----------------------------
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self._drag_start_pos = event.globalPos() - self.frameGeometry().topLeft()
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent):
|
||||
if self._drag_start_pos:
|
||||
self.move(event.globalPos() - self._drag_start_pos)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent):
|
||||
self._drag_start_pos = None
|
||||
|
||||
# -------------------------------------------------
|
||||
# 텍스트 설정
|
||||
# -------------------------------------------------
|
||||
def set_text(self, text: str):
|
||||
self.label.setText(text)
|
||||
self.adjustSize() # 내용 바뀌면 크기 자동 조정
|
||||
|
||||
# -------------------------------------------------
|
||||
# 마우스 위치 확인
|
||||
# -------------------------------------------------
|
||||
def is_mouse_inside(self) -> bool:
|
||||
pos = QCursor.pos()
|
||||
return self.geometry().contains(pos)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 마우스 이탈 시 팝업 닫기
|
||||
# -------------------------------------------------
|
||||
def leaveEvent(self, event):
|
||||
self.close()
|
||||
super().leaveEvent(event)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 테마 적용
|
||||
# -------------------------------------------------
|
||||
def apply_theme(self, theme: str):
|
||||
if theme == PopupTheme.DARK:
|
||||
self.setStyleSheet("""
|
||||
QWidget {
|
||||
background-color: #2b2b2b;
|
||||
color: #eaeaea;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
else:
|
||||
self.setStyleSheet("""
|
||||
QWidget {
|
||||
background-color: #ffffe1;
|
||||
color: #202020;
|
||||
border: 1px solid #c8c8a9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"""
|
||||
신호 표시용 컴포넌트들
|
||||
- OnOffSignalLabel: ON/OFF 신호 표시
|
||||
- ModeSignalLabel: MODE 신호 표시 (여러 상태 중 하나)
|
||||
- DataSignalLabel: 데이터 신호 표시 (신호명 + 값)
|
||||
- SignalCard: 신호들을 그룹화하는 카드
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QFrame, QMenu, QGridLayout, QScrollArea, QSizePolicy)
|
||||
from PySide6.QtCore import Qt, Signal, QPoint
|
||||
from PySide6.QtGui import QCursor, QMouseEvent
|
||||
|
||||
# ============================================================================
|
||||
# 1. ON/OFF 신호 라벨
|
||||
# ============================================================================
|
||||
class OnOffSignalLabel(QLabel):
|
||||
"""
|
||||
ON/OFF 신호 표시용 라벨
|
||||
- ON: 연두색 배경 + 검은색 글자
|
||||
- OFF: 어두운 회색 배경 + 흰색 글자
|
||||
"""
|
||||
|
||||
clicked = Signal()
|
||||
right_clicked = Signal(QPoint)
|
||||
|
||||
# 스타일 상수
|
||||
STYLE_ON = "background-color: #76FF03; color: black; border-radius: 3px; padding: 2px 6px; font-weight: bold;"
|
||||
STYLE_OFF = "background-color: #3C3C3C; color: #AAAAAA; border-radius: 3px; padding: 2px 6px;"
|
||||
|
||||
def __init__(self, signal_name: str, display_name: str = None,
|
||||
description: str = "", on_list: list = None, off_list: list = None, parent=None):
|
||||
super().__init__(display_name or signal_name, parent)
|
||||
|
||||
self.signal_name = signal_name
|
||||
self.display_name = display_name or signal_name
|
||||
self.description = description
|
||||
self.on_list = on_list or [] # ON일 때 목록
|
||||
self.off_list = off_list or [] # OFF일 때 목록
|
||||
self._style_on_override = None
|
||||
self._style_off_override = None
|
||||
|
||||
self._is_on = False
|
||||
|
||||
# UI 설정
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setMinimumSize(40, 18)
|
||||
self.setMaximumHeight(22)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
# 툴팁 설정
|
||||
if description:
|
||||
self.setToolTip(description)
|
||||
|
||||
self.set_status(False)
|
||||
|
||||
def set_status(self, is_on: bool):
|
||||
"""신호 상태 설정"""
|
||||
self._is_on = is_on
|
||||
if is_on:
|
||||
self.setStyleSheet(self._style_on_override or self.STYLE_ON)
|
||||
else:
|
||||
self.setStyleSheet(self._style_off_override or self.STYLE_OFF)
|
||||
|
||||
def set_style_override(self, on_style: str | None = None, off_style: str | None = None):
|
||||
"""ON/OFF 스타일 오버라이드"""
|
||||
self._style_on_override = on_style
|
||||
self._style_off_override = off_style
|
||||
# 현재 상태 즉시 반영
|
||||
self.set_status(self._is_on)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
elif event.button() == Qt.RightButton:
|
||||
self.right_clicked.emit(event.pos())
|
||||
self._show_context_menu(event.globalPosition().toPoint())
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def _show_context_menu(self, global_pos):
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("""
|
||||
QMenu { background-color: #2D2D30; color: white; border: 1px solid #555; }
|
||||
QMenu::item:selected { background-color: #0078D7; }
|
||||
""")
|
||||
|
||||
# ON일 때 목록
|
||||
if self.on_list:
|
||||
on_menu = menu.addMenu("🟢 ON 목록")
|
||||
for item in self.on_list:
|
||||
on_menu.addAction(item)
|
||||
|
||||
# OFF일 때 목록
|
||||
if self.off_list:
|
||||
off_menu = menu.addMenu("⚫ OFF 목록")
|
||||
for item in self.off_list:
|
||||
off_menu.addAction(item)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# 신호 설명
|
||||
if self.description:
|
||||
desc_action = menu.addAction("ℹ️ 신호 설명")
|
||||
desc_action.triggered.connect(self._show_description_popup)
|
||||
|
||||
menu.exec(global_pos)
|
||||
|
||||
def _show_description_popup(self):
|
||||
from app.ui.components.clickableLabel import HoverInfoPopup
|
||||
popup = HoverInfoPopup(f"<b>{self.display_name}</b><br><br>{self.description}")
|
||||
popup.move(QCursor.pos() + QPoint(10, 10))
|
||||
popup.show()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. MODE 신호 라벨 (여러 상태 중 하나)
|
||||
# ============================================================================
|
||||
class ModeSignalLabel(QLabel):
|
||||
"""
|
||||
MODE 신호 표시용 라벨
|
||||
- 신호 없음: OFF (어두운 회색 배경 + 흰색 글자)
|
||||
- 신호 있음: 해당 모드 표시 (연보라색 배경 + 검은색 글자)
|
||||
"""
|
||||
|
||||
clicked = Signal()
|
||||
right_clicked = Signal(QPoint)
|
||||
|
||||
STYLE_OFF = "background-color: #3C3C3C; color: #AAAAAA; border-radius: 3px; padding: 2px 6px;"
|
||||
STYLE_ON = "background-color: #B388FF; color: black; border-radius: 3px; padding: 2px 6px; font-weight: bold;"
|
||||
|
||||
def __init__(self, signal_name: str, mode_list: list = None,
|
||||
description: str = "", parent=None):
|
||||
super().__init__("OFF", parent)
|
||||
|
||||
self.signal_name = signal_name
|
||||
self.mode_list = mode_list or [] # 가능한 모드 목록
|
||||
self.description = description
|
||||
self._current_mode = None
|
||||
|
||||
# UI 설정
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setMinimumSize(45, 18)
|
||||
self.setMaximumHeight(22)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
if description:
|
||||
self.setToolTip(description)
|
||||
|
||||
self.set_mode(None)
|
||||
|
||||
def set_mode(self, mode: str):
|
||||
"""현재 모드 설정"""
|
||||
self._current_mode = mode
|
||||
if mode:
|
||||
self.setText(mode)
|
||||
self.setStyleSheet(self.STYLE_ON)
|
||||
else:
|
||||
self.setText("OFF")
|
||||
self.setStyleSheet(self.STYLE_OFF)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
elif event.button() == Qt.RightButton:
|
||||
self.right_clicked.emit(event.pos())
|
||||
self._show_context_menu(event.globalPosition().toPoint())
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def _show_context_menu(self, global_pos):
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("""
|
||||
QMenu { background-color: #2D2D30; color: white; border: 1px solid #555; }
|
||||
QMenu::item:selected { background-color: #0078D7; }
|
||||
""")
|
||||
|
||||
# 모드 목록
|
||||
if self.mode_list:
|
||||
mode_menu = menu.addMenu("📋 모드 목록")
|
||||
for mode in self.mode_list:
|
||||
action = mode_menu.addAction(mode)
|
||||
if mode == self._current_mode:
|
||||
action.setEnabled(False)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
if self.description:
|
||||
desc_action = menu.addAction("ℹ️ 신호 설명")
|
||||
desc_action.triggered.connect(self._show_description_popup)
|
||||
|
||||
menu.exec(global_pos)
|
||||
|
||||
def _show_description_popup(self):
|
||||
from app.ui.components.clickableLabel import HoverInfoPopup
|
||||
popup = HoverInfoPopup(f"<b>{self.signal_name}</b><br><br>{self.description}")
|
||||
popup.move(QCursor.pos() + QPoint(10, 10))
|
||||
popup.show()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. DATA 신호 라벨 (신호명 + 값) - 세로 배치
|
||||
# ============================================================================
|
||||
class DataSignalLabel(QFrame):
|
||||
"""
|
||||
데이터 신호 표시용 라벨 (신호명 + 값 2개 세로 조합)
|
||||
- 상단: 신호명 (밝은 파랑 배경 + 흰색 글자)
|
||||
- 하단: 신호값 (남색 배경 + 흰색 글자)
|
||||
"""
|
||||
|
||||
clicked = Signal()
|
||||
right_clicked = Signal(QPoint)
|
||||
|
||||
STYLE_NAME = "background-color: #2196F3; color: white; border-radius: 3px 0 0 3px; padding: 2px 6px; font-size: 9px;"
|
||||
STYLE_VALUE = "background-color: #1A237E; color: white; border-radius: 0 3px 3px 0; padding: 2px 6px; font-weight: bold; font-size: 10px;"
|
||||
|
||||
def __init__(self, signal_name: str, display_name: str = None,
|
||||
unit: str = "", possible_values: list = None,
|
||||
description: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.signal_name = signal_name
|
||||
self.display_name = display_name or signal_name
|
||||
self.unit = unit
|
||||
self.possible_values = possible_values or []
|
||||
self.description = description
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
if description:
|
||||
self.setToolTip(description)
|
||||
|
||||
def _setup_ui(self):
|
||||
# 가로 배치 (이름 | 값)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# 좌측: 신호명 라벨
|
||||
self.lbl_name = QLabel(self.display_name)
|
||||
self.lbl_name.setStyleSheet(self.STYLE_NAME)
|
||||
self.lbl_name.setAlignment(Qt.AlignCenter)
|
||||
self.lbl_name.setMinimumWidth(40)
|
||||
self.lbl_name.setFixedHeight(22)
|
||||
|
||||
# 우측: 신호값 라벨
|
||||
self.lbl_value = QLabel("-")
|
||||
self.lbl_value.setStyleSheet(self.STYLE_VALUE)
|
||||
self.lbl_value.setAlignment(Qt.AlignCenter)
|
||||
self.lbl_value.setMinimumWidth(50)
|
||||
self.lbl_value.setFixedHeight(22)
|
||||
|
||||
layout.addWidget(self.lbl_name)
|
||||
layout.addWidget(self.lbl_value)
|
||||
|
||||
self.setFixedHeight(24) # 1행 높이
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
def set_value(self, value):
|
||||
"""신호 값 설정"""
|
||||
if self.unit:
|
||||
self.lbl_value.setText(f"{value}{self.unit}")
|
||||
else:
|
||||
self.lbl_value.setText(str(value))
|
||||
|
||||
def set_value_style(self, style: str):
|
||||
"""값 라벨 스타일 커스텀 설정"""
|
||||
self.lbl_value.setStyleSheet(self.STYLE_VALUE + style)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
elif event.button() == Qt.RightButton:
|
||||
self.right_clicked.emit(event.pos())
|
||||
self._show_context_menu(event.globalPosition().toPoint())
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def _show_context_menu(self, global_pos):
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("""
|
||||
QMenu { background-color: #2D2D30; color: white; border: 1px solid #555; }
|
||||
QMenu::item:selected { background-color: #0078D7; }
|
||||
""")
|
||||
|
||||
# 가능한 값 목록
|
||||
if self.possible_values:
|
||||
val_menu = menu.addMenu("📋 가능한 값")
|
||||
for val in self.possible_values:
|
||||
val_menu.addAction(str(val))
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
if self.description:
|
||||
desc_action = menu.addAction("ℹ️ 신호 설명")
|
||||
desc_action.triggered.connect(self._show_description_popup)
|
||||
|
||||
menu.exec(global_pos)
|
||||
|
||||
def _show_description_popup(self):
|
||||
from app.ui.components.clickableLabel import HoverInfoPopup
|
||||
popup = HoverInfoPopup(f"<b>{self.display_name}</b><br><br>{self.description}")
|
||||
popup.move(QCursor.pos() + QPoint(10, 10))
|
||||
popup.show()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 4. 신호 카드 (그룹화)
|
||||
# ============================================================================
|
||||
class SignalCard(QFrame):
|
||||
"""
|
||||
신호들을 그룹화하는 카드 컴포넌트
|
||||
- 타이틀 바
|
||||
- ON/OFF, MODE 컴포넌트: 1행 1열
|
||||
- DATA 컴포넌트: 1행에 2~4열 차지 (가로로 넓게)
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.title = title
|
||||
self.signals = {} # signal_name -> widget
|
||||
self._onoff_widgets = [] # ON/OFF, MODE 위젯들
|
||||
self._data_widgets = [] # DATA 위젯들
|
||||
self._horizontal_data_mode = False # DATA 가로 배치 모드
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setStyleSheet("""
|
||||
SignalCard {
|
||||
background-color: #252526;
|
||||
border: 1px solid #3E3E42;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
self.setMinimumWidth(150)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(2)
|
||||
|
||||
# 타이틀
|
||||
self.lbl_title = QLabel(self.title)
|
||||
self.lbl_title.setFixedHeight(20) # 제목 높이 고정
|
||||
self.lbl_title.setStyleSheet("""
|
||||
font-weight: bold;
|
||||
color: #00B0FF;
|
||||
font-size: 10px;
|
||||
padding: 1px 3px;
|
||||
border-bottom: 1px solid #3E3E42;
|
||||
""")
|
||||
layout.addWidget(self.lbl_title)
|
||||
|
||||
# 신호 컨테이너 (GridLayout 사용)
|
||||
self.signal_container = QWidget()
|
||||
self.signal_grid = QGridLayout(self.signal_container)
|
||||
self.signal_grid.setContentsMargins(0, 2, 0, 0)
|
||||
self.signal_grid.setSpacing(2)
|
||||
|
||||
layout.addWidget(self.signal_container)
|
||||
|
||||
# 현재 그리드 위치 추적
|
||||
self._grid_row = 0
|
||||
self._grid_col = 0
|
||||
self._max_cols = 4 # 4열 배치 (DATA가 2~4열 차지 가능)
|
||||
self._data_colspan = 2 # DATA 위젯의 기본 colspan (2~4열 조정 가능)
|
||||
|
||||
def set_horizontal_data_mode(self, enabled: bool):
|
||||
"""DATA 위젯 가로 배치 모드 설정"""
|
||||
self._horizontal_data_mode = enabled
|
||||
|
||||
def add_signal(self, widget, signal_name: str = None):
|
||||
"""신호 위젯 추가 - 타입에 따라 배치 방식 다름"""
|
||||
if isinstance(widget, DataSignalLabel):
|
||||
if self._horizontal_data_mode:
|
||||
# DATA 가로 배치 모드: 1행 1열 (ON/OFF처럼)
|
||||
self.signal_grid.addWidget(widget, self._grid_row, self._grid_col)
|
||||
self._grid_col += 1
|
||||
if self._grid_col >= self._max_cols:
|
||||
self._grid_col = 0
|
||||
self._grid_row += 1
|
||||
else:
|
||||
# 기본 모드: DATA 위젯은 1행에 2~4열 차지 (가로로 넓게)
|
||||
# 현재 열이 0이 아니면 다음 행으로 이동
|
||||
if self._grid_col != 0:
|
||||
self._grid_row += 1
|
||||
self._grid_col = 0
|
||||
|
||||
# colspan 계산 (남은 공간에 따라 2~4열)
|
||||
colspan = min(self._data_colspan, self._max_cols - self._grid_col)
|
||||
self.signal_grid.addWidget(widget, self._grid_row, self._grid_col, 1, colspan)
|
||||
self._grid_col += colspan
|
||||
if self._grid_col >= self._max_cols:
|
||||
self._grid_col = 0
|
||||
self._grid_row += 1
|
||||
self._data_widgets.append(widget)
|
||||
else:
|
||||
# ON/OFF, MODE 위젯: 1행 1열
|
||||
self.signal_grid.addWidget(widget, self._grid_row, self._grid_col)
|
||||
self._grid_col += 1
|
||||
if self._grid_col >= self._max_cols:
|
||||
self._grid_col = 0
|
||||
self._grid_row += 1
|
||||
self._onoff_widgets.append(widget)
|
||||
|
||||
if signal_name:
|
||||
self.signals[signal_name] = widget
|
||||
|
||||
def get_signal(self, signal_name: str):
|
||||
"""신호 위젯 가져오기"""
|
||||
return self.signals.get(signal_name)
|
||||
|
||||
def finalize_layout(self):
|
||||
"""레이아웃 마무리 - 남는 공간에 stretch 추가"""
|
||||
# 마지막 행 다음에 stretch 추가
|
||||
if self._grid_col > 0:
|
||||
self._grid_row += 1
|
||||
self.signal_grid.setRowStretch(self._grid_row, 1)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 5. FlowLayout (하위 호환용 - 더 이상 사용하지 않음)
|
||||
# ============================================================================
|
||||
class FlowLayout(QVBoxLayout):
|
||||
"""
|
||||
간단한 FlowLayout 구현 (레거시 호환용)
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._widgets = []
|
||||
self._row_layouts = []
|
||||
self._spacing = 4
|
||||
self.setSpacing(4)
|
||||
|
||||
def setSpacing(self, spacing):
|
||||
self._spacing = spacing
|
||||
super().setSpacing(spacing)
|
||||
|
||||
def addWidget(self, widget):
|
||||
self._widgets.append(widget)
|
||||
self._reflow()
|
||||
|
||||
def _reflow(self):
|
||||
# 기존 레이아웃 정리
|
||||
for row_layout in self._row_layouts:
|
||||
while row_layout.count():
|
||||
item = row_layout.takeAt(0)
|
||||
|
||||
for row_layout in self._row_layouts:
|
||||
self.removeItem(row_layout)
|
||||
self._row_layouts.clear()
|
||||
|
||||
# 새 행 생성
|
||||
current_row = QHBoxLayout()
|
||||
current_row.setSpacing(self._spacing)
|
||||
self._row_layouts.append(current_row)
|
||||
super().addLayout(current_row)
|
||||
|
||||
for widget in self._widgets:
|
||||
current_row.addWidget(widget)
|
||||
|
||||
current_row.addStretch()
|
||||
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
from PySide6.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Qt, QRectF
|
||||
from PySide6.QtGui import QPainter, QColor, QPen, QFont, QBrush, QPainterPath
|
||||
|
||||
class SpeedometerWidget(QWidget):
|
||||
def __init__(self, title="SPEED", unit="km/h", max_value=120, parent=None):
|
||||
super().__init__(parent)
|
||||
self.title = title
|
||||
self.unit = unit
|
||||
self.max_value = max_value
|
||||
self.current_value = 0.0
|
||||
self.limit_value = 0.0
|
||||
self.atc_code = 0 # ATC CODE 추가
|
||||
self.setMinimumSize(120, 120)
|
||||
|
||||
def set_value(self, value):
|
||||
try:
|
||||
self.current_value = float(value)
|
||||
except ValueError:
|
||||
self.current_value = 0.0
|
||||
self.update()
|
||||
|
||||
def set_limit(self, limit):
|
||||
try:
|
||||
self.limit_value = float(limit)
|
||||
except ValueError:
|
||||
self.limit_value = 0.0
|
||||
self.update()
|
||||
|
||||
def set_atc_code(self, code):
|
||||
"""ATC CODE 설정"""
|
||||
try:
|
||||
self.atc_code = int(code)
|
||||
except (ValueError, TypeError):
|
||||
self.atc_code = 0
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
width = self.width()
|
||||
height = self.height()
|
||||
side = min(width, height)
|
||||
|
||||
# 중앙 정렬
|
||||
painter.translate(width / 2, height / 2)
|
||||
painter.scale(side / 200.0, side / 200.0)
|
||||
|
||||
# 배경 (어두운 원)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setBrush(QColor(30, 30, 30))
|
||||
painter.drawEllipse(-100, -100, 200, 200)
|
||||
|
||||
# 눈금 그리기 (Start: 135도, Span: 270도)
|
||||
start_angle = 135 * 16
|
||||
span_angle = -270 * 16
|
||||
|
||||
# 게이지 배경 (회색 아크)
|
||||
pen = QPen(QColor(60, 60, 60))
|
||||
pen.setWidth(15)
|
||||
pen.setCapStyle(Qt.FlatCap)
|
||||
painter.setPen(pen)
|
||||
painter.drawArc(-80, -80, 160, 160, start_angle, span_angle)
|
||||
|
||||
# 현재 속도 아크 (녹색/노란색/빨간색)
|
||||
if self.current_value > self.limit_value and self.limit_value > 0:
|
||||
color = QColor(255, 82, 82) # 초과 시 빨강
|
||||
elif self.current_value > 0:
|
||||
color = QColor(0, 208, 132) # 평소 녹색
|
||||
else:
|
||||
color = QColor(60, 60, 60)
|
||||
|
||||
if self.current_value > 0:
|
||||
val_ratio = min(self.current_value / self.max_value, 1.0)
|
||||
val_span = int(span_angle * val_ratio)
|
||||
|
||||
pen.setColor(color)
|
||||
painter.setPen(pen)
|
||||
painter.drawArc(-80, -80, 160, 160, start_angle, val_span)
|
||||
|
||||
# 제한 속도 표시 (작은 마커)
|
||||
if self.limit_value > 0:
|
||||
limit_ratio = min(self.limit_value / self.max_value, 1.0)
|
||||
limit_angle = 135 + (270 * limit_ratio)
|
||||
|
||||
painter.save()
|
||||
painter.rotate(limit_angle)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setBrush(QColor(255, 165, 0)) # 주황색 마커
|
||||
painter.drawRect(-2, -95, 4, 10) # 눈금 위치에 마커
|
||||
painter.restore()
|
||||
|
||||
# ATC CODE 바늘 및 동그라미 표시
|
||||
if self.atc_code > 0:
|
||||
# ATC CODE에 해당하는 각도 계산 (0~max_value 범위)
|
||||
atc_ratio = min(self.atc_code / self.max_value, 1.0)
|
||||
atc_angle = 135 + (270 * atc_ratio)
|
||||
|
||||
painter.save()
|
||||
painter.rotate(atc_angle)
|
||||
|
||||
# 빨간 바늘 (외부에서 내부로)
|
||||
pen = QPen(QColor(255, 50, 50))
|
||||
pen.setWidth(3)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(0, -95, 0, -70) # 외부(-95)에서 내부(-70)로
|
||||
|
||||
# 바늘 외부에 동그라미
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setBrush(QColor(255, 50, 50))
|
||||
painter.drawEllipse(-8, -110, 16, 16) # 바늘 외부에 원
|
||||
|
||||
# 동그라미 안에 ATC CODE 텍스트
|
||||
painter.setPen(QColor(255, 255, 255))
|
||||
font = QFont("Arial", 8, QFont.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(QRectF(-8, -110, 16, 16), Qt.AlignCenter, str(self.atc_code))
|
||||
|
||||
painter.restore()
|
||||
|
||||
# 텍스트 표시
|
||||
painter.setPen(QColor(255, 255, 255))
|
||||
|
||||
# 현재 값 (큰 글씨)
|
||||
font = QFont("Arial", 28, QFont.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(QRectF(-100, -20, 200, 40), Qt.AlignCenter, f"{self.current_value:.1f}")
|
||||
|
||||
# 단위 (작은 글씨)
|
||||
font.setPointSize(10)
|
||||
painter.setFont(font)
|
||||
painter.drawText(QRectF(-100, 20, 200, 20), Qt.AlignCenter, self.unit)
|
||||
|
||||
# 타이틀 (상단)
|
||||
font.setPointSize(12)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor(180, 180, 180))
|
||||
painter.drawText(QRectF(-100, -70, 200, 20), Qt.AlignCenter, self.title)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
from PySide6.QtWidgets import QLabel
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
class StatusLamp(QLabel):
|
||||
def __init__(self, text, on_color="#00FF00", off_color="#333333", text_color="white", font_size=10, shape="rect"):
|
||||
super().__init__(text)
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.on_color = on_color
|
||||
self.off_color = off_color
|
||||
self.text_color = text_color
|
||||
self.shape = shape
|
||||
|
||||
# 기본 스타일 정의
|
||||
radius = "12px" if shape == "circle" else "2px"
|
||||
padding = "2px"
|
||||
|
||||
self.base_style = f"""
|
||||
QLabel {{
|
||||
color: {self.text_color};
|
||||
border: 1px solid #555555;
|
||||
border-radius: {radius};
|
||||
font-family: 'Malgun Gothic';
|
||||
font-weight: bold;
|
||||
font-size: {font_size}pt;
|
||||
padding: {padding};
|
||||
}}
|
||||
"""
|
||||
self.set_status(False)
|
||||
|
||||
def set_status(self, is_on: bool):
|
||||
bg = self.on_color if is_on else self.off_color
|
||||
# 켜졌을 때 글자색을 검정으로 할지 흰색으로 할지 (가독성)
|
||||
fg = "black" if (is_on and self.on_color in ["#00FF00", "#FFFF00", "#00BFFF", "#FFA500"]) else self.text_color
|
||||
|
||||
self.setStyleSheet(f"""
|
||||
{self.base_style}
|
||||
QLabel {{ background-color: {bg}; color: {fg}; }}
|
||||
""")
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Timeline Slider
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
from PySide6.QtWidgets import QCheckBox
|
||||
from PySide6.QtCore import Property, QSize, Qt, QRect
|
||||
from PySide6.QtGui import QPainter, QColor, QBrush, QPen
|
||||
|
||||
class ToggleButton(QCheckBox):
|
||||
"""아이폰 스타일의 커스텀 토글 스위치"""
|
||||
def __init__(self, width=50, bg_color="#777", circle_color="#DDD", active_color="#00BCff",
|
||||
disabled_color="#444"):
|
||||
super().__init__()
|
||||
self.setFixedSize(width, 28)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
self._bg_color = bg_color
|
||||
self._circle_color = circle_color
|
||||
self._active_color = active_color
|
||||
self._disabled_color = disabled_color
|
||||
self._circle_position = 3
|
||||
|
||||
self.stateChanged.connect(self.start_animation)
|
||||
|
||||
def start_animation(self, state):
|
||||
self.update() # 애니메이션 없이 즉시 변경 (필요시 QPropertyAnimation 추가 가능)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""마우스 클릭 이벤트 명시적 처리"""
|
||||
if event.button() == Qt.LeftButton and self.isEnabled():
|
||||
self.setChecked(not self.isChecked())
|
||||
event.accept()
|
||||
else:
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def paintEvent(self, e):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
rect = self.rect()
|
||||
|
||||
# 비활성화 상태
|
||||
if not self.isEnabled():
|
||||
p.setBrush(QColor(self._disabled_color))
|
||||
p.setPen(Qt.NoPen)
|
||||
p.drawRoundedRect(0, 0, rect.width(), rect.height(), rect.height() / 2, rect.height() / 2)
|
||||
|
||||
# 어두운 원
|
||||
circle_dia = rect.height() - 6
|
||||
p.setBrush(QColor("#555"))
|
||||
p.drawEllipse(3, 3, circle_dia, circle_dia)
|
||||
p.end()
|
||||
return
|
||||
|
||||
# 배경 그리기 (Capsule 모양)
|
||||
if self.isChecked():
|
||||
p.setBrush(QColor(self._active_color))
|
||||
p.setPen(Qt.NoPen)
|
||||
else:
|
||||
p.setBrush(QColor(self._bg_color))
|
||||
p.setPen(Qt.NoPen)
|
||||
|
||||
p.drawRoundedRect(0, 0, rect.width(), rect.height(), rect.height() / 2, rect.height() / 2)
|
||||
|
||||
# 원 그리기
|
||||
circle_dia = rect.height() - 6
|
||||
p.setBrush(QColor(self._circle_color))
|
||||
|
||||
if self.isChecked():
|
||||
p.drawEllipse(rect.width() - circle_dia - 3, 3, circle_dia, circle_dia)
|
||||
else:
|
||||
p.drawEllipse(3, 3, circle_dia, circle_dia)
|
||||
|
||||
p.end()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
UI 다이얼로그 패키지
|
||||
"""
|
||||
|
||||
from .api_key_dialog import APIKeyDialog
|
||||
|
||||
__all__ = ['APIKeyDialog']
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,521 @@
|
|||
"""
|
||||
API 키 설정 다이얼로그
|
||||
AI 서비스 API 키 입력 및 관리 UI
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QLabel, QLineEdit, QComboBox, QPushButton,
|
||||
QTabWidget, QWidget, QGroupBox, QMessageBox,
|
||||
QFrame, QSizePolicy
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont, QIcon
|
||||
|
||||
from app.core.settings import get_settings_manager, AISettings
|
||||
from app.ai import AIProviderType, get_ai_client
|
||||
|
||||
|
||||
class ProviderTab(QWidget):
|
||||
"""개별 프로바이더 설정 탭"""
|
||||
|
||||
api_key_changed = Signal(str, str) # provider_type, api_key
|
||||
|
||||
def __init__(self, provider_type: str, provider_name: str, available_models: list, parent=None):
|
||||
super().__init__(parent)
|
||||
self.provider_type = provider_type
|
||||
self.provider_name = provider_name
|
||||
self.available_models = available_models
|
||||
|
||||
self.setup_ui()
|
||||
self.load_settings()
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# 프로바이더 정보
|
||||
info_group = QGroupBox(f"{self.provider_name} 설정")
|
||||
info_group.setStyleSheet("""
|
||||
QGroupBox {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
""")
|
||||
info_layout = QGridLayout(info_group)
|
||||
info_layout.setSpacing(12)
|
||||
info_layout.setContentsMargins(16, 24, 16, 16)
|
||||
|
||||
# API Key 입력
|
||||
api_key_label = QLabel("API Key:")
|
||||
api_key_label.setStyleSheet("font-weight: bold;")
|
||||
self.api_key_input = QLineEdit()
|
||||
self.api_key_input.setPlaceholderText("API 키를 입력하세요...")
|
||||
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.api_key_input.setMinimumHeight(36)
|
||||
self.api_key_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
background: #2d2d2d;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #0078d4;
|
||||
}
|
||||
""")
|
||||
|
||||
# API Key 보기/숨기기 버튼
|
||||
self.toggle_visibility_btn = QPushButton("👁")
|
||||
self.toggle_visibility_btn.setFixedSize(36, 36)
|
||||
self.toggle_visibility_btn.setCheckable(True)
|
||||
self.toggle_visibility_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background: #3d3d3d;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #404040;
|
||||
}
|
||||
""")
|
||||
self.toggle_visibility_btn.clicked.connect(self.toggle_api_key_visibility)
|
||||
|
||||
api_key_row = QHBoxLayout()
|
||||
api_key_row.addWidget(self.api_key_input)
|
||||
api_key_row.addWidget(self.toggle_visibility_btn)
|
||||
|
||||
info_layout.addWidget(api_key_label, 0, 0)
|
||||
info_layout.addLayout(api_key_row, 0, 1)
|
||||
|
||||
# 모델 선택
|
||||
model_label = QLabel("모델:")
|
||||
model_label.setStyleSheet("font-weight: bold;")
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.addItems(self.available_models)
|
||||
self.model_combo.setMinimumHeight(36)
|
||||
self.model_combo.setStyleSheet("""
|
||||
QComboBox {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
background: #2d2d2d;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
QComboBox:hover {
|
||||
border-color: #666;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 30px;
|
||||
}
|
||||
QComboBox::down-arrow {
|
||||
image: none;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #888;
|
||||
margin-right: 10px;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #555;
|
||||
selection-background-color: #0078d4;
|
||||
}
|
||||
""")
|
||||
|
||||
info_layout.addWidget(model_label, 1, 0)
|
||||
info_layout.addWidget(self.model_combo, 1, 1)
|
||||
|
||||
info_layout.setColumnStretch(1, 1)
|
||||
|
||||
layout.addWidget(info_group)
|
||||
|
||||
# 테스트 버튼
|
||||
test_layout = QHBoxLayout()
|
||||
test_layout.addStretch()
|
||||
|
||||
self.test_btn = QPushButton("🔗 연결 테스트")
|
||||
self.test_btn.setMinimumHeight(40)
|
||||
self.test_btn.setMinimumWidth(140)
|
||||
self.test_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #0078d4;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #1084d8;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #006cc1;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background: #555;
|
||||
color: #888;
|
||||
}
|
||||
""")
|
||||
self.test_btn.clicked.connect(self.test_connection)
|
||||
|
||||
test_layout.addWidget(self.test_btn)
|
||||
layout.addLayout(test_layout)
|
||||
|
||||
# 상태 표시
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #888; font-size: 12px;")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def toggle_api_key_visibility(self, checked):
|
||||
"""API 키 표시/숨기기 토글"""
|
||||
if checked:
|
||||
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Normal)
|
||||
self.toggle_visibility_btn.setText("🔒")
|
||||
else:
|
||||
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.toggle_visibility_btn.setText("👁")
|
||||
|
||||
def load_settings(self):
|
||||
"""설정에서 값 로드"""
|
||||
settings = get_settings_manager().ai_settings
|
||||
|
||||
api_key = settings.get_api_key(self.provider_type)
|
||||
if api_key:
|
||||
self.api_key_input.setText(api_key)
|
||||
|
||||
model = settings.get_model(self.provider_type)
|
||||
if model:
|
||||
index = self.model_combo.findText(model)
|
||||
if index >= 0:
|
||||
self.model_combo.setCurrentIndex(index)
|
||||
|
||||
def save_settings(self):
|
||||
"""설정 저장"""
|
||||
settings_manager = get_settings_manager()
|
||||
settings = settings_manager.ai_settings
|
||||
|
||||
settings.set_api_key(self.provider_type, self.api_key_input.text())
|
||||
settings.set_model(self.provider_type, self.model_combo.currentText())
|
||||
|
||||
settings_manager.save()
|
||||
|
||||
def test_connection(self):
|
||||
"""연결 테스트"""
|
||||
api_key = self.api_key_input.text().strip()
|
||||
if not api_key:
|
||||
self.status_label.setText("❌ API 키를 입력하세요")
|
||||
self.status_label.setStyleSheet("color: #ff5252; font-size: 12px;")
|
||||
return
|
||||
|
||||
self.status_label.setText("⏳ 연결 테스트 중...")
|
||||
self.status_label.setStyleSheet("color: #ffc107; font-size: 12px;")
|
||||
self.test_btn.setEnabled(False)
|
||||
|
||||
# 테스트 실행 (간단한 동기 테스트)
|
||||
try:
|
||||
from app.ai import AIClient, AIProviderType
|
||||
|
||||
client = AIClient()
|
||||
provider_type = AIProviderType(self.provider_type)
|
||||
model = self.model_combo.currentText()
|
||||
|
||||
if client.set_provider(provider_type, api_key, model):
|
||||
# 간단한 테스트 메시지
|
||||
response = client.chat("Hello", max_tokens=10)
|
||||
self.status_label.setText(f"✅ 연결 성공! ({response.model})")
|
||||
self.status_label.setStyleSheet("color: #4caf50; font-size: 12px;")
|
||||
else:
|
||||
self.status_label.setText("❌ 초기화 실패")
|
||||
self.status_label.setStyleSheet("color: #ff5252; font-size: 12px;")
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:50] + "..." if len(str(e)) > 50 else str(e)
|
||||
self.status_label.setText(f"❌ 오류: {error_msg}")
|
||||
self.status_label.setStyleSheet("color: #ff5252; font-size: 12px;")
|
||||
finally:
|
||||
self.test_btn.setEnabled(True)
|
||||
|
||||
def get_api_key(self) -> str:
|
||||
return self.api_key_input.text().strip()
|
||||
|
||||
def get_model(self) -> str:
|
||||
return self.model_combo.currentText()
|
||||
|
||||
|
||||
class APIKeyDialog(QDialog):
|
||||
"""API 키 설정 다이얼로그"""
|
||||
|
||||
settings_changed = Signal()
|
||||
|
||||
# 프로바이더별 정보
|
||||
PROVIDERS = [
|
||||
{
|
||||
"type": "openai",
|
||||
"name": "OpenAI",
|
||||
"models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo", "o1-preview", "o1-mini"],
|
||||
},
|
||||
{
|
||||
"type": "openrouter",
|
||||
"name": "OpenRouter",
|
||||
"models": [
|
||||
"openai/gpt-4o", "openai/gpt-4o-mini",
|
||||
"anthropic/claude-3.5-sonnet", "anthropic/claude-3-opus",
|
||||
"google/gemini-pro-1.5", "google/gemini-flash-1.5",
|
||||
"meta-llama/llama-3.1-70b-instruct", "meta-llama/llama-3.1-8b-instruct",
|
||||
"mistralai/mixtral-8x7b-instruct", "deepseek/deepseek-chat",
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "gemini",
|
||||
"name": "Google Gemini",
|
||||
"models": ["gemini-2.0-flash-exp", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b", "gemini-1.0-pro"],
|
||||
},
|
||||
{
|
||||
"type": "xai",
|
||||
"name": "xAI (Grok)",
|
||||
"models": ["grok-beta", "grok-2-1212", "grok-2-vision-1212"],
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("AI API 키 설정")
|
||||
self.setMinimumSize(550, 450)
|
||||
self.setModal(True)
|
||||
|
||||
self.provider_tabs = {}
|
||||
|
||||
self.setup_ui()
|
||||
self.load_current_provider()
|
||||
|
||||
def setup_ui(self):
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background: #1e1e1e;
|
||||
color: #fff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ddd;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 8px;
|
||||
background: #252525;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #2d2d2d;
|
||||
color: #888;
|
||||
padding: 10px 20px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #252525;
|
||||
color: #fff;
|
||||
}
|
||||
QTabBar::tab:hover {
|
||||
background: #353535;
|
||||
color: #ccc;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# 헤더
|
||||
header = QLabel("🔑 AI API 키 설정")
|
||||
header.setStyleSheet("""
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
padding: 8px 0;
|
||||
""")
|
||||
layout.addWidget(header)
|
||||
|
||||
# 현재 프로바이더 선택
|
||||
provider_layout = QHBoxLayout()
|
||||
provider_label = QLabel("기본 프로바이더:")
|
||||
provider_label.setStyleSheet("font-weight: bold;")
|
||||
|
||||
self.provider_combo = QComboBox()
|
||||
for provider in self.PROVIDERS:
|
||||
self.provider_combo.addItem(provider["name"], provider["type"])
|
||||
self.provider_combo.setMinimumHeight(36)
|
||||
self.provider_combo.setMinimumWidth(200)
|
||||
self.provider_combo.setStyleSheet("""
|
||||
QComboBox {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
background: #2d2d2d;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
QComboBox:hover {
|
||||
border-color: #666;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 30px;
|
||||
}
|
||||
QComboBox::down-arrow {
|
||||
image: none;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #888;
|
||||
margin-right: 10px;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #555;
|
||||
selection-background-color: #0078d4;
|
||||
}
|
||||
""")
|
||||
|
||||
provider_layout.addWidget(provider_label)
|
||||
provider_layout.addWidget(self.provider_combo)
|
||||
provider_layout.addStretch()
|
||||
|
||||
layout.addLayout(provider_layout)
|
||||
|
||||
# 탭 위젯
|
||||
self.tab_widget = QTabWidget()
|
||||
|
||||
for provider in self.PROVIDERS:
|
||||
tab = ProviderTab(
|
||||
provider_type=provider["type"],
|
||||
provider_name=provider["name"],
|
||||
available_models=provider["models"]
|
||||
)
|
||||
self.provider_tabs[provider["type"]] = tab
|
||||
self.tab_widget.addTab(tab, provider["name"])
|
||||
|
||||
layout.addWidget(self.tab_widget)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn.setMinimumHeight(40)
|
||||
cancel_btn.setMinimumWidth(100)
|
||||
cancel_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
padding: 10px 24px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
background: #2d2d2d;
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #3d3d3d;
|
||||
border-color: #666;
|
||||
}
|
||||
""")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
|
||||
save_btn = QPushButton("저장")
|
||||
save_btn.setMinimumHeight(40)
|
||||
save_btn.setMinimumWidth(100)
|
||||
save_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #0078d4;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #1084d8;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #006cc1;
|
||||
}
|
||||
""")
|
||||
save_btn.clicked.connect(self.save_and_close)
|
||||
|
||||
button_layout.addWidget(cancel_btn)
|
||||
button_layout.addWidget(save_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def load_current_provider(self):
|
||||
"""현재 설정된 프로바이더 로드"""
|
||||
settings = get_settings_manager().ai_settings
|
||||
current = settings.get_current_provider()
|
||||
|
||||
if current:
|
||||
index = self.provider_combo.findData(current)
|
||||
if index >= 0:
|
||||
self.provider_combo.setCurrentIndex(index)
|
||||
|
||||
# 해당 탭으로 전환
|
||||
for i, provider in enumerate(self.PROVIDERS):
|
||||
if provider["type"] == current:
|
||||
self.tab_widget.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
def save_and_close(self):
|
||||
"""설정 저장 후 닫기"""
|
||||
settings_manager = get_settings_manager()
|
||||
|
||||
# 모든 탭의 설정 저장
|
||||
for provider_type, tab in self.provider_tabs.items():
|
||||
tab.save_settings()
|
||||
|
||||
# 기본 프로바이더 설정
|
||||
current_provider = self.provider_combo.currentData()
|
||||
settings_manager.ai_settings.set_current_provider(current_provider)
|
||||
|
||||
settings_manager.save()
|
||||
|
||||
# AI 클라이언트 업데이트
|
||||
self._update_ai_client()
|
||||
|
||||
self.settings_changed.emit()
|
||||
self.accept()
|
||||
|
||||
def _update_ai_client(self):
|
||||
"""저장된 설정으로 AI 클라이언트 업데이트"""
|
||||
try:
|
||||
from app.ai import get_ai_client, AIProviderType
|
||||
|
||||
settings = get_settings_manager().ai_settings
|
||||
current_provider = settings.get_current_provider()
|
||||
|
||||
if current_provider:
|
||||
api_key = settings.get_api_key(current_provider)
|
||||
model = settings.get_model(current_provider)
|
||||
|
||||
if api_key:
|
||||
client = get_ai_client()
|
||||
provider_type = AIProviderType(current_provider)
|
||||
client.set_provider(provider_type, api_key, model)
|
||||
except Exception as e:
|
||||
print(f"AI 클라이언트 업데이트 실패: {e}")
|
||||
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QSplitter,
|
||||
QToolBar,
|
||||
QStatusBar,
|
||||
QLabel,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QRect
|
||||
from PySide6.QtGui import QAction
|
||||
|
||||
from app.ui.analysis_panel import AnalysisPanel
|
||||
from app.core.sync_controller import sync_manager
|
||||
from app.ui.components.toggle_button import ToggleButton
|
||||
from app.ui.dialogs.api_key_dialog import APIKeyDialog
|
||||
from app.core.settings import get_settings_manager
|
||||
from app.ui.widgets.ai_floating_chat import AIFloatingButton, AIChatDialog, animate_open, animate_close
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("SL200 AI Smart Analyzer")
|
||||
self.resize(1800, 1000)
|
||||
|
||||
self.setup_ui()
|
||||
self.setup_menubar()
|
||||
self.setup_toolbar()
|
||||
|
||||
# 패널 데이터 로드 시 날짜 비교를 위한 시그널 연결
|
||||
self.left_panel.data_loaded.connect(self.check_sync_availability)
|
||||
self.right_panel.data_loaded.connect(self.check_sync_availability)
|
||||
|
||||
# AI 클라이언트 초기화
|
||||
self._init_ai_client()
|
||||
|
||||
def setup_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
layout = QVBoxLayout(central)
|
||||
|
||||
# 좌우 분할
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
# 왼쪽/오른쪽 패널 생성 (ID 부여)
|
||||
self.left_panel = AnalysisPanel("left")
|
||||
self.right_panel = AnalysisPanel("right")
|
||||
|
||||
splitter.addWidget(self.left_panel)
|
||||
splitter.addWidget(self.right_panel)
|
||||
splitter.setSizes([900, 900]) # 1:1 비율
|
||||
|
||||
layout.addWidget(splitter)
|
||||
|
||||
self.status_bar = QStatusBar()
|
||||
self.setStatusBar(self.status_bar)
|
||||
|
||||
# 플로팅 AI 버튼
|
||||
self._ai_button = AIFloatingButton(central)
|
||||
self._ai_button.clicked.connect(self._open_ai_chat)
|
||||
self._ai_button.show()
|
||||
self._ai_chat: AIChatDialog | None = None
|
||||
self._ai_button.move_to_bottom_right()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
try:
|
||||
if hasattr(self, "_ai_button") and self._ai_button:
|
||||
self._ai_button.move_to_bottom_right()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def setup_toolbar(self):
|
||||
toolbar = QToolBar("Main Toolbar")
|
||||
self.addToolBar(toolbar)
|
||||
|
||||
# 커스텀 토글 버튼
|
||||
self.sync_toggle = ToggleButton(width=60, active_color="#00D084") # 녹색
|
||||
self.sync_toggle.toggled.connect(self.on_sync_toggled)
|
||||
self.sync_toggle.setEnabled(False) # 초기에는 비활성화
|
||||
|
||||
toolbar.addWidget(QLabel(" SYNC MODE: "))
|
||||
toolbar.addWidget(self.sync_toggle)
|
||||
|
||||
# 상태 라벨
|
||||
self.sync_status_label = QLabel(" (데이터 없음)")
|
||||
self.sync_status_label.setStyleSheet("color: #888;")
|
||||
toolbar.addWidget(self.sync_status_label)
|
||||
|
||||
def setup_menubar(self):
|
||||
"""메뉴바 설정"""
|
||||
menubar = self.menuBar()
|
||||
|
||||
# 파일 메뉴
|
||||
menubar.addMenu("파일(&F)")
|
||||
|
||||
# 설정 메뉴
|
||||
settings_menu = menubar.addMenu("설정(&S)")
|
||||
|
||||
# AI API 키 설정
|
||||
ai_settings_action = QAction("🔑 AI API 키 설정...", self)
|
||||
ai_settings_action.setShortcut("Ctrl+Shift+A")
|
||||
ai_settings_action.triggered.connect(self.show_api_key_dialog)
|
||||
settings_menu.addAction(ai_settings_action)
|
||||
|
||||
# 도움말 메뉴
|
||||
menubar.addMenu("도움말(&H)")
|
||||
|
||||
def show_api_key_dialog(self):
|
||||
"""API 키 설정 다이얼로그 표시"""
|
||||
dialog = APIKeyDialog(self)
|
||||
dialog.settings_changed.connect(self._on_ai_settings_changed)
|
||||
dialog.exec()
|
||||
|
||||
def _on_ai_settings_changed(self):
|
||||
"""AI 설정이 변경되었을 때"""
|
||||
self.status_bar.showMessage("AI 설정이 저장되었습니다.", 3000)
|
||||
self._init_ai_client()
|
||||
|
||||
def check_sync_availability(self):
|
||||
"""두 패널의 데이터 날짜가 같은지 확인하여 Sync 버튼 활성화/비활성화"""
|
||||
left_date = self.left_panel.get_data_date()
|
||||
right_date = self.right_panel.get_data_date()
|
||||
|
||||
# 둘 다 데이터가 있는 경우에만 비교
|
||||
if left_date and right_date:
|
||||
if left_date == right_date:
|
||||
# 날짜가 같으면 Sync 활성화
|
||||
self.sync_toggle.setEnabled(True)
|
||||
self.sync_status_label.setText(f" ({left_date})")
|
||||
self.sync_status_label.setStyleSheet("color: #00D084;")
|
||||
else:
|
||||
# 날짜가 다르면 Sync 비활성화
|
||||
self.sync_toggle.setEnabled(False)
|
||||
self.sync_toggle.setChecked(False) # 자동으로 끔
|
||||
self.sync_status_label.setText(f" (날짜 불일치: {left_date} ≠ {right_date})")
|
||||
self.sync_status_label.setStyleSheet("color: #FF5252;")
|
||||
elif left_date or right_date:
|
||||
# 한쪽만 데이터가 있음
|
||||
self.sync_toggle.setEnabled(False)
|
||||
date_info = left_date or right_date
|
||||
self.sync_status_label.setText(f" ({date_info} - 상대 패널 없음)")
|
||||
self.sync_status_label.setStyleSheet("color: #888;")
|
||||
else:
|
||||
# 둘 다 데이터 없음
|
||||
self.sync_toggle.setEnabled(False)
|
||||
self.sync_status_label.setText(" (데이터 없음)")
|
||||
self.sync_status_label.setStyleSheet("color: #888;")
|
||||
|
||||
def on_sync_toggled(self, checked):
|
||||
"""동기화 모드 변경 시 동작"""
|
||||
sync_manager.is_sync_enabled = checked
|
||||
|
||||
if checked:
|
||||
# [SYNC ON]
|
||||
# 1. 오른쪽 패널의 파일 로드 버튼 숨김
|
||||
self.right_panel.set_slave_mode(True)
|
||||
|
||||
# 2. 왼쪽 패널의 데이터가 있다면 오른쪽으로 복사
|
||||
if self.left_panel.raw_data:
|
||||
self.right_panel.receive_shared_data(self.left_panel.raw_data)
|
||||
|
||||
self.status_bar.showMessage("Sync ON: 오른쪽 패널이 왼쪽 데이터와 동기화됩니다.")
|
||||
else:
|
||||
# [SYNC OFF]
|
||||
self.right_panel.set_slave_mode(False)
|
||||
self.status_bar.showMessage("Sync OFF: 독립 분석 모드")
|
||||
|
||||
def _init_ai_client(self):
|
||||
"""저장된 설정으로 AI 클라이언트 초기화(가능할 때만)"""
|
||||
try:
|
||||
from app.ai import get_ai_client, AIProviderType
|
||||
|
||||
settings = get_settings_manager().ai_settings
|
||||
current_provider = settings.get_current_provider()
|
||||
|
||||
if not current_provider:
|
||||
return
|
||||
|
||||
api_key = settings.get_api_key(current_provider)
|
||||
model = settings.get_model(current_provider)
|
||||
|
||||
if not api_key:
|
||||
return
|
||||
|
||||
client = get_ai_client()
|
||||
provider_type = AIProviderType(current_provider)
|
||||
if client.set_provider(provider_type, api_key, model):
|
||||
st = client.get_status()
|
||||
self.status_bar.showMessage(f"AI 준비: {st.get('provider')} / {st.get('model')}", 3000)
|
||||
except Exception as e:
|
||||
print(f"AI 클라이언트 초기화 실패: {e}")
|
||||
|
||||
def _get_panel_ctx(self, panel_id: str, user_query: str | None = None):
|
||||
panel = self.right_panel if panel_id == "right" else self.left_panel
|
||||
ctx = panel.get_ai_context()
|
||||
|
||||
# 질의에 따라 추가 분석 결과를 포함(프롬프트 품질 개선)
|
||||
if user_query:
|
||||
q = user_query.lower()
|
||||
if ("pg3" in q or "pg3-2" in q) and ("역" in user_query):
|
||||
try:
|
||||
ctx.setdefault("analysis", {})
|
||||
ctx["analysis"]["pg3_2_per_station"] = panel.compute_pg_at_station("PG3-2")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ctx
|
||||
|
||||
def _is_qobject_alive(self, obj) -> bool:
|
||||
"""PySide6 객체가 이미 delete된 상태인지 안전하게 판별"""
|
||||
if obj is None:
|
||||
return False
|
||||
try:
|
||||
import shiboken6 # type: ignore
|
||||
return bool(shiboken6.isValid(obj))
|
||||
except Exception:
|
||||
# shiboken6이 없거나, 일부 환경에서 import 실패 시 런타임 호출로 체크
|
||||
try:
|
||||
obj.objectName()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _on_ai_chat_destroyed(self, *_args):
|
||||
# 삭제된 객체 참조 제거(다음 클릭에서 RuntimeError 방지)
|
||||
self._ai_chat = None
|
||||
self._ai_button.set_open_state(False)
|
||||
|
||||
def _open_ai_chat(self):
|
||||
# 열려 있으면: 웹처럼 버튼(X)로 닫기 동작
|
||||
if self._ai_chat and self._is_qobject_alive(self._ai_chat) and self._ai_chat.isVisible():
|
||||
btn = self._ai_button
|
||||
start_rect = self._ai_chat.geometry()
|
||||
# 닫힘 애니메이션은 버튼 위치로 축소
|
||||
btn_pos = btn.mapTo(self, btn.rect().topLeft())
|
||||
end_rect = QRect(btn_pos.x(), btn_pos.y(), btn.width(), btn.height())
|
||||
animate_close(self._ai_chat, start_rect, end_rect, duration_ms=180)
|
||||
return
|
||||
|
||||
# 레퍼런스는 남아있지만 이미 삭제된 경우 정리
|
||||
if self._ai_chat and not self._is_qobject_alive(self._ai_chat):
|
||||
self._ai_chat = None
|
||||
self._ai_button.set_open_state(False)
|
||||
|
||||
self._ai_chat = AIChatDialog(self, self._get_panel_ctx)
|
||||
self._ai_chat.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
self._ai_chat.destroyed.connect(self._on_ai_chat_destroyed)
|
||||
self._ai_button.set_open_state(True)
|
||||
|
||||
# 버튼 위치 기준으로 확장 애니메이션
|
||||
btn = self._ai_button
|
||||
# 메인윈도우 좌표로 변환
|
||||
btn_top_left = btn.mapTo(self, btn.rect().topLeft())
|
||||
start_rect = QRect(btn_top_left.x(), btn_top_left.y(), btn.width(), btn.height())
|
||||
|
||||
# 최종 크기: 버튼을 가리지 않도록 '버튼 위로' 배치(가능하면)
|
||||
w, h = 560, 680
|
||||
gap = 10
|
||||
margin = 14
|
||||
|
||||
# 1) 우선: 버튼 위로(오른쪽 정렬)
|
||||
end_x = btn_top_left.x() + btn.width() - w
|
||||
end_y = btn_top_left.y() - h - gap
|
||||
|
||||
# 위로 배치 불가능하면: 버튼 왼쪽으로
|
||||
if end_y < margin:
|
||||
end_y = max(margin, btn_top_left.y() + btn.height() - h) # 세로는 버튼과 겹치지 않게 최대한 맞춤
|
||||
end_x = btn_top_left.x() - w - gap
|
||||
|
||||
# 그래도 화면 밖이면: 화면 안으로 클램프(버튼 가림 최소화)
|
||||
end_x = max(margin, min(end_x, self.width() - w - margin))
|
||||
end_y = max(margin, min(end_y, self.height() - h - margin))
|
||||
|
||||
end_rect = QRect(end_x, end_y, w, h)
|
||||
|
||||
animate_open(self._ai_chat, start_rect, end_rect, duration_ms=280)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,649 @@
|
|||
"""
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1 @@
|
|||
# Data Grid View
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from .ai_floating_chat import AIFloatingButton, AIChatDialog, animate_open
|
||||
|
||||
__all__ = ["AIFloatingButton", "AIChatDialog", "animate_open"]
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,494 @@
|
|||
"""
|
||||
플로팅 AI 버튼 + 확장(애니메이션) 채팅 모달
|
||||
|
||||
요구사항:
|
||||
- 웹의 고객응대/AI assistant처럼 우하단 플로팅 버튼
|
||||
- 클릭 시 작은 패널에서 큰 채팅 모달로 확장(애니메이션)
|
||||
- 탭 전환 없이 현재 화면 컨텍스트(날짜/열번/편성/호차/타임라인/표시신호/역 전후2개 등) 포함
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, QRect, QObject, QThread, Signal, QDateTime
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QPushButton,
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QLineEdit,
|
||||
QTextEdit,
|
||||
QMessageBox,
|
||||
QFrame,
|
||||
QDialogButtonBox,
|
||||
)
|
||||
|
||||
from app.ai import get_ai_client
|
||||
from app.ai.maintenance_kb import get_maintenance_kb
|
||||
from app.ui.dialogs.api_key_dialog import APIKeyDialog
|
||||
|
||||
|
||||
def _make_json_safe(obj):
|
||||
"""json.dumps에서 안전하도록 dict/list/QColor 등을 변환"""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _make_json_safe(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple, set)):
|
||||
return [_make_json_safe(v) for v in obj]
|
||||
if isinstance(obj, QDateTime):
|
||||
try:
|
||||
return obj.toString("yyyy-MM-dd HH:mm:ss")
|
||||
except Exception:
|
||||
return str(obj)
|
||||
# QColor 등 name()이 있는 객체 처리
|
||||
try:
|
||||
if hasattr(obj, "name") and callable(obj.name):
|
||||
return obj.name()
|
||||
except Exception:
|
||||
pass
|
||||
# Enum 등 value가 있는 경우
|
||||
try:
|
||||
if hasattr(obj, "value"):
|
||||
return getattr(obj, "value")
|
||||
except Exception:
|
||||
pass
|
||||
return obj
|
||||
|
||||
|
||||
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 AIFloatingButton(QPushButton):
|
||||
"""우하단 플로팅 버튼"""
|
||||
|
||||
def __init__(self, parent: QWidget):
|
||||
super().__init__("AI", parent)
|
||||
self.setFixedSize(56, 56)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setToolTip("AI Assistant 열기")
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #0078d4;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton:hover { background-color: #1084d8; }
|
||||
QPushButton:pressed { background-color: #006cc1; }
|
||||
"""
|
||||
)
|
||||
self._is_open = False
|
||||
|
||||
def set_open_state(self, is_open: bool):
|
||||
self._is_open = bool(is_open)
|
||||
if self._is_open:
|
||||
self.setText("×")
|
||||
self.setToolTip("AI Assistant 닫기")
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 28px;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
QPushButton:hover { background-color: #333; }
|
||||
QPushButton:pressed { background-color: #222; }
|
||||
"""
|
||||
)
|
||||
else:
|
||||
self.setText("AI")
|
||||
self.setToolTip("AI Assistant 열기")
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #0078d4;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton:hover { background-color: #1084d8; }
|
||||
QPushButton:pressed { background-color: #006cc1; }
|
||||
"""
|
||||
)
|
||||
|
||||
def move_to_bottom_right(self, margin: int = 18):
|
||||
p = self.parentWidget()
|
||||
if not p:
|
||||
return
|
||||
x = p.width() - self.width() - margin
|
||||
y = p.height() - self.height() - margin
|
||||
self.move(max(0, x), max(0, y))
|
||||
|
||||
|
||||
class AIChatDialog(QDialog):
|
||||
"""확장되는 채팅 모달(프레임리스)"""
|
||||
|
||||
def __init__(self, main_window: QWidget, get_panel_ctx_callable):
|
||||
super().__init__(main_window)
|
||||
self._get_panel_ctx = get_panel_ctx_callable
|
||||
|
||||
self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog)
|
||||
self.setModal(False)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||||
|
||||
self._busy = False
|
||||
self._thread: Optional[QThread] = None
|
||||
self._worker: Optional[_AIWorker] = None
|
||||
self._last_payload: Optional[Dict[str, Any]] = None
|
||||
|
||||
self._build_ui()
|
||||
self.refresh_header()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# 실제 카드(둥근 배경)
|
||||
self.card = QFrame()
|
||||
self.card.setStyleSheet(
|
||||
"""
|
||||
QFrame {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 14px;
|
||||
}
|
||||
QLabel { color: #ddd; }
|
||||
QLineEdit, QTextEdit {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
color: #eee;
|
||||
}
|
||||
QTextEdit { font-size: 12px; }
|
||||
"""
|
||||
)
|
||||
card_layout = QVBoxLayout(self.card)
|
||||
card_layout.setContentsMargins(12, 12, 12, 12)
|
||||
card_layout.setSpacing(10)
|
||||
|
||||
# 헤더
|
||||
header = QHBoxLayout()
|
||||
self.lbl_title = QLabel("AI Assistant")
|
||||
self.lbl_title.setStyleSheet("font-weight: bold; font-size: 14px; color: #fff;")
|
||||
|
||||
self.cmb_panel = QComboBox()
|
||||
self.cmb_panel.addItem("Left Panel", "left")
|
||||
self.cmb_panel.addItem("Right Panel", "right")
|
||||
self.cmb_panel.currentIndexChanged.connect(self.refresh_header)
|
||||
|
||||
self.lbl_ai_status = QLabel("AI: 미준비")
|
||||
self.lbl_ai_status.setStyleSheet("color: #FFAB00;")
|
||||
|
||||
btn_settings = QPushButton("설정")
|
||||
btn_settings.setFixedHeight(30)
|
||||
btn_settings.clicked.connect(self._open_settings)
|
||||
btn_settings.setStyleSheet(
|
||||
"QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;padding:6px 10px;} "
|
||||
"QPushButton:hover{background:#333;}"
|
||||
)
|
||||
|
||||
btn_close = QPushButton("×")
|
||||
btn_close.setFixedSize(30, 30)
|
||||
btn_close.clicked.connect(self.close)
|
||||
btn_close.setStyleSheet(
|
||||
"QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;font-size:16px;} "
|
||||
"QPushButton:hover{background:#333;}"
|
||||
)
|
||||
|
||||
btn_payload = QPushButton("JSON")
|
||||
btn_payload.setFixedHeight(30)
|
||||
btn_payload.clicked.connect(self.show_last_payload)
|
||||
btn_payload.setStyleSheet(
|
||||
"QPushButton{background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;border-radius:8px;padding:6px 10px;} "
|
||||
"QPushButton:hover{background:#333;}"
|
||||
)
|
||||
|
||||
header.addWidget(self.lbl_title)
|
||||
header.addStretch()
|
||||
header.addWidget(self.cmb_panel)
|
||||
header.addWidget(self.lbl_ai_status)
|
||||
header.addWidget(btn_payload)
|
||||
header.addWidget(btn_settings)
|
||||
header.addWidget(btn_close)
|
||||
card_layout.addLayout(header)
|
||||
|
||||
# 기본정보(자동입력 + 수정가능)
|
||||
info_row = QHBoxLayout()
|
||||
self.in_date = QLineEdit()
|
||||
self.in_date.setPlaceholderText("날짜")
|
||||
self.in_trainno = QLineEdit()
|
||||
self.in_trainno.setPlaceholderText("열번")
|
||||
self.in_car = QLineEdit()
|
||||
self.in_car.setPlaceholderText("호차")
|
||||
self.in_formation = QLineEdit()
|
||||
self.in_formation.setPlaceholderText("편성")
|
||||
for w in [self.in_date, self.in_trainno, self.in_car, self.in_formation]:
|
||||
w.setFixedHeight(32)
|
||||
info_row.addWidget(QLabel("날짜"))
|
||||
info_row.addWidget(self.in_date)
|
||||
info_row.addWidget(QLabel("열번"))
|
||||
info_row.addWidget(self.in_trainno)
|
||||
info_row.addWidget(QLabel("호차"))
|
||||
info_row.addWidget(self.in_car)
|
||||
info_row.addWidget(QLabel("편성"))
|
||||
info_row.addWidget(self.in_formation)
|
||||
card_layout.addLayout(info_row)
|
||||
|
||||
# 추가정보(가이드/툴팁)
|
||||
self.in_extra = QTextEdit()
|
||||
self.in_extra.setFixedHeight(90)
|
||||
self.in_extra.setPlaceholderText(
|
||||
"추가정보 작성 가이드(필요한 것만 기입)\n"
|
||||
"- 발생역: \n"
|
||||
"- 발생시간: \n"
|
||||
"- 발생증상: \n"
|
||||
"- 발생장치: \n"
|
||||
"- 참고: (정상/고장 비교, 다른 열번 같은 역 비교, 특정 조건 구간 등)\n"
|
||||
)
|
||||
self.in_extra.setToolTip(
|
||||
"예시:\n"
|
||||
"발생역=서면, 발생시간=18:09:12, 발생증상=정위치 불가 추정,\n"
|
||||
"발생장치=ATO/TASC, 참고=타임라인으로 표시한 구간 중심으로 원인 후보 추론"
|
||||
)
|
||||
card_layout.addWidget(QLabel("추가정보"))
|
||||
card_layout.addWidget(self.in_extra)
|
||||
|
||||
# 채팅 히스토리
|
||||
self.txt_chat = QTextEdit()
|
||||
self.txt_chat.setReadOnly(True)
|
||||
self.txt_chat.setPlaceholderText("대화 내용이 여기에 표시됩니다.")
|
||||
self.txt_chat.setMinimumHeight(240)
|
||||
card_layout.addWidget(self.txt_chat, stretch=1)
|
||||
|
||||
# 입력 + 전송
|
||||
bottom = QHBoxLayout()
|
||||
self.in_msg = QLineEdit()
|
||||
self.in_msg.setPlaceholderText("요청을 입력하세요. (예: 유사기록 검색 / 특정 조건 구간 분석 / 역명 기준 비교 등)")
|
||||
self.in_msg.setFixedHeight(34)
|
||||
|
||||
btn_send = QPushButton("전송")
|
||||
btn_send.setFixedHeight(34)
|
||||
btn_send.clicked.connect(self.send_message)
|
||||
btn_send.setStyleSheet(
|
||||
"QPushButton{background:#0078d4;color:white;border:none;border-radius:8px;padding:6px 14px;font-weight:bold;} "
|
||||
"QPushButton:hover{background:#1084d8;} "
|
||||
"QPushButton:pressed{background:#006cc1;}"
|
||||
)
|
||||
bottom.addWidget(self.in_msg, stretch=1)
|
||||
bottom.addWidget(btn_send)
|
||||
card_layout.addLayout(bottom)
|
||||
|
||||
root.addWidget(self.card)
|
||||
|
||||
def _open_settings(self):
|
||||
dlg = APIKeyDialog(self)
|
||||
dlg.settings_changed.connect(self.refresh_header)
|
||||
dlg.exec()
|
||||
|
||||
def refresh_header(self):
|
||||
# AI 상태
|
||||
st = get_ai_client().get_status()
|
||||
if st.get("ready"):
|
||||
self.lbl_ai_status.setText(f"AI: {st.get('provider')} / {st.get('model')}")
|
||||
self.lbl_ai_status.setStyleSheet("color: #00D084;")
|
||||
else:
|
||||
self.lbl_ai_status.setText("AI: 미준비")
|
||||
self.lbl_ai_status.setStyleSheet("color: #FFAB00;")
|
||||
|
||||
# 패널 컨텍스트에서 기본정보 자동 채움(비어있을 때만)
|
||||
ctx = self._get_panel_ctx(self.cmb_panel.currentData(), None)
|
||||
date = (ctx.get("data_date") or "") if isinstance(ctx, dict) else ""
|
||||
record = (ctx.get("record") or {}) if isinstance(ctx, dict) else {}
|
||||
trainno = str(record.get("trainno") or "")
|
||||
|
||||
if date and not self.in_date.text().strip():
|
||||
self.in_date.setText(date)
|
||||
if trainno and not self.in_trainno.text().strip():
|
||||
self.in_trainno.setText(trainno)
|
||||
|
||||
def _append(self, who: str, text: str):
|
||||
self.txt_chat.append(f"[{who}]\n{text}\n")
|
||||
sb = self.txt_chat.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
def _build_prompt(self, user_text: str) -> str:
|
||||
panel_id = self.cmb_panel.currentData()
|
||||
ctx = self._get_panel_ctx(panel_id, user_text) or {}
|
||||
|
||||
basic = {
|
||||
"date": self.in_date.text().strip(),
|
||||
"trainno": self.in_trainno.text().strip(),
|
||||
"car_no": self.in_car.text().strip(),
|
||||
"formation": self.in_formation.text().strip(),
|
||||
}
|
||||
extra = (self.in_extra.toPlainText() or "").strip()
|
||||
|
||||
# 정비지침서 발췌(유사기록/조건분석 요청에도 도움)
|
||||
kb = get_maintenance_kb()
|
||||
kb_hits = kb.search(" ".join([user_text, extra]), top_k=3)
|
||||
|
||||
payload = {
|
||||
"basic": basic,
|
||||
"extra": extra,
|
||||
"panel_id": panel_id,
|
||||
"ctx": ctx,
|
||||
"kb_hits": kb_hits,
|
||||
"user_text": user_text,
|
||||
}
|
||||
safe_payload = _make_json_safe(payload)
|
||||
self._last_payload = safe_payload
|
||||
self._auto_save_payload(safe_payload)
|
||||
|
||||
return (
|
||||
"당신은 철도 차량 MMI 로그를 분석하는 AI 어시스턴트입니다.\n"
|
||||
"사용자 요청에 대해, 가능한 경우 데이터 근거(필드명/타임라인/역 전후2개)를 들어 설명하세요.\n"
|
||||
"불확실하면 추가로 필요한 데이터/확인 포인트를 질문 형태로 제시하세요.\n\n"
|
||||
f"[기본정보]\n{json.dumps(_make_json_safe(basic), ensure_ascii=False, indent=2)}\n"
|
||||
f"\n[추가정보]\n{extra}\n"
|
||||
f"\n[현재 화면 컨텍스트]\n{json.dumps(_make_json_safe(ctx), ensure_ascii=False, indent=2)}\n"
|
||||
+ (f"\n[정비지침서/지식 발췌]\n{json.dumps(_make_json_safe(kb_hits), ensure_ascii=False, indent=2)}\n" if kb_hits else "")
|
||||
+ f"\n[사용자 요청]\n{user_text}\n"
|
||||
)
|
||||
|
||||
def _auto_save_payload(self, payload: Dict[str, Any]):
|
||||
try:
|
||||
out_dir = Path.home() / ".mmi_analyzer" / "ai_debug"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / "last_payload.json"
|
||||
path.write_text(json.dumps(_make_json_safe(payload), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def show_last_payload(self):
|
||||
if not self._last_payload:
|
||||
QMessageBox.information(self, "AI", "아직 전송된 payload가 없습니다.")
|
||||
return
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("AI Payload (JSON)")
|
||||
dlg.resize(720, 520)
|
||||
lay = QVBoxLayout(dlg)
|
||||
txt = QTextEdit()
|
||||
txt.setReadOnly(True)
|
||||
txt.setText(json.dumps(_make_json_safe(self._last_payload), ensure_ascii=False, indent=2))
|
||||
lay.addWidget(txt, stretch=1)
|
||||
btns = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
btns.rejected.connect(dlg.reject)
|
||||
btns.accepted.connect(dlg.accept)
|
||||
lay.addWidget(btns)
|
||||
dlg.exec()
|
||||
|
||||
def send_message(self):
|
||||
text = (self.in_msg.text() or "").strip()
|
||||
if not text:
|
||||
return
|
||||
if self._busy:
|
||||
QMessageBox.information(self, "AI", "이미 요청이 실행 중입니다. 완료 후 다시 시도하세요.")
|
||||
return
|
||||
|
||||
self.in_msg.clear()
|
||||
self._append("사용자", text)
|
||||
|
||||
prompt = self._build_prompt(text)
|
||||
self._run(prompt)
|
||||
|
||||
def _run(self, prompt: str):
|
||||
self._busy = True
|
||||
self._append("AI", "(응답 생성 중...)")
|
||||
|
||||
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_done)
|
||||
self._worker.failed.connect(self._on_fail)
|
||||
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_done(self, text: str):
|
||||
# 마지막 "(응답 생성 중...)" 줄은 단순히 구분만 하고 새 답변을 추가
|
||||
self._append("AI", text.strip())
|
||||
|
||||
def _on_fail(self, err: str):
|
||||
self._append("AI", f"[오류] {err}")
|
||||
|
||||
def _on_thread_finished(self):
|
||||
self._thread = None
|
||||
self._worker = None
|
||||
self._busy = False
|
||||
|
||||
|
||||
def animate_open(dialog: QDialog, start_rect: QRect, end_rect: QRect, duration_ms: int = 220):
|
||||
dialog.setGeometry(start_rect)
|
||||
dialog.setWindowOpacity(0.0)
|
||||
dialog.show()
|
||||
anim = QPropertyAnimation(dialog, b"geometry", dialog)
|
||||
anim.setDuration(duration_ms)
|
||||
anim.setStartValue(start_rect)
|
||||
anim.setEndValue(end_rect)
|
||||
anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||
anim.start()
|
||||
dialog._open_anim = anim # GC 방지
|
||||
|
||||
fade = QPropertyAnimation(dialog, b"windowOpacity", dialog)
|
||||
fade.setDuration(max(120, duration_ms))
|
||||
fade.setStartValue(0.0)
|
||||
fade.setEndValue(1.0)
|
||||
fade.setEasingCurve(QEasingCurve.OutCubic)
|
||||
fade.start()
|
||||
dialog._open_fade = fade
|
||||
|
||||
|
||||
def animate_close(dialog: QDialog, start_rect: QRect, end_rect: QRect, duration_ms: int = 180):
|
||||
anim = QPropertyAnimation(dialog, b"geometry", dialog)
|
||||
anim.setDuration(duration_ms)
|
||||
anim.setStartValue(start_rect)
|
||||
anim.setEndValue(end_rect)
|
||||
anim.setEasingCurve(QEasingCurve.InCubic)
|
||||
anim.finished.connect(dialog.close)
|
||||
anim.start()
|
||||
dialog._close_anim = anim
|
||||
|
||||
fade = QPropertyAnimation(dialog, b"windowOpacity", dialog)
|
||||
fade.setDuration(max(100, duration_ms))
|
||||
fade.setStartValue(dialog.windowOpacity())
|
||||
fade.setEndValue(0.0)
|
||||
fade.setEasingCurve(QEasingCurve.InCubic)
|
||||
fade.start()
|
||||
dialog._close_fade = fade
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using System;
|
||||
|
||||
namespace SL200_RTLogViewer
|
||||
{
|
||||
// Token: 0x02000006 RID: 6
|
||||
public enum DoorDirection
|
||||
{
|
||||
// Token: 0x0400001E RID: 30
|
||||
DE,
|
||||
// Token: 0x0400001F RID: 31
|
||||
DW
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SL200_RTLogViewer
|
||||
{
|
||||
// Token: 0x02000005 RID: 5
|
||||
public static class Dpi
|
||||
{
|
||||
// Token: 0x06000021 RID: 33
|
||||
[DllImport("Shcore.dll")]
|
||||
public static extern int SetProcessDpiAwareness(int processDpiAwareness);
|
||||
|
||||
// Token: 0x02000055 RID: 85
|
||||
public enum DpiAwareness
|
||||
{
|
||||
// Token: 0x0400041C RID: 1052
|
||||
None,
|
||||
// Token: 0x0400041D RID: 1053
|
||||
SystemAware,
|
||||
// Token: 0x0400041E RID: 1054
|
||||
PerMonitorAware
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
using SL200_RealtimeLogViewer.RealtimeLog;
|
||||
using SL200_RTLogViewer.Properties;
|
||||
using SL200_RTLogViewer.subForm;
|
||||
|
||||
namespace SL200_RTLogViewer
|
||||
{
|
||||
// Token: 0x02000003 RID: 3
|
||||
public class Form1 : Form
|
||||
{
|
||||
// Token: 0x06000014 RID: 20 RVA: 0x00003A80 File Offset: 0x00001C80
|
||||
public Form1()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
}
|
||||
|
||||
// Token: 0x06000015 RID: 21 RVA: 0x00003AB0 File Offset: 0x00001CB0
|
||||
private void button1_Click_1(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
RealtimelogForm realtimelogForm = new RealtimelogForm();
|
||||
Form1.logOn = false;
|
||||
realtimelogForm.FormBorderStyle = FormBorderStyle.None;
|
||||
realtimelogForm.TopLevel = false;
|
||||
realtimelogForm.TopMost = true;
|
||||
this.mainpanel.Controls.Add(realtimelogForm);
|
||||
realtimelogForm.Dock = DockStyle.Fill;
|
||||
this.ShowChildForm(realtimelogForm);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("실시간 뷰어 기능을 사용하려면 프로그램 폴더내에 \n WinPcap을 설치해야 합니다. ", " 알림 ");
|
||||
}
|
||||
}
|
||||
|
||||
// Token: 0x06000016 RID: 22 RVA: 0x00003B30 File Offset: 0x00001D30
|
||||
private void button2_Click(object sender, EventArgs e)
|
||||
{
|
||||
Form1.logOn = true;
|
||||
this.savedlogForm.FormBorderStyle = FormBorderStyle.None;
|
||||
this.savedlogForm.TopLevel = false;
|
||||
this.savedlogForm.TopMost = true;
|
||||
this.mainpanel.Controls.Add(this.savedlogForm);
|
||||
this.savedlogForm.Dock = DockStyle.Fill;
|
||||
this.ShowChildForm(this.savedlogForm);
|
||||
}
|
||||
|
||||
// Token: 0x06000017 RID: 23 RVA: 0x00003B9C File Offset: 0x00001D9C
|
||||
private void ShowChildForm(Form childForm)
|
||||
{
|
||||
bool flag = childForm.Equals(this._activeForm);
|
||||
if (!flag)
|
||||
{
|
||||
bool flag2 = this._activeForm != null;
|
||||
if (flag2)
|
||||
{
|
||||
this._activeForm.Hide();
|
||||
}
|
||||
this._activeForm = childForm;
|
||||
this._activeForm.Show();
|
||||
}
|
||||
}
|
||||
|
||||
// Token: 0x06000018 RID: 24 RVA: 0x00003BE8 File Offset: 0x00001DE8
|
||||
private void Form1_FormClosed(object sender, FormClosedEventArgs e)
|
||||
{
|
||||
this.savedlogForm.Close();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
// Token: 0x06000019 RID: 25 RVA: 0x00003BFE File Offset: 0x00001DFE
|
||||
private void Form1_Load(object sender, EventArgs e)
|
||||
{
|
||||
TrackInfo.SetInfo();
|
||||
this.button2_Click(sender, e);
|
||||
}
|
||||
|
||||
// Token: 0x0600001A RID: 26 RVA: 0x00003C10 File Offset: 0x00001E10
|
||||
public void ReSetFontSize(Control.ControlCollection ctrls)
|
||||
{
|
||||
float num = 1f;
|
||||
using (RegistryKey registryKey = Registry.CurrentUser.OpenSubKey("Control Panel\\Desktop\\WindowMetrics"))
|
||||
{
|
||||
bool flag = registryKey != null;
|
||||
if (flag)
|
||||
{
|
||||
object value = registryKey.GetValue("AppliedDPI");
|
||||
bool flag2 = value != null;
|
||||
if (flag2)
|
||||
{
|
||||
int num2 = (int)value;
|
||||
num = (float)num2 / 96f;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool flag3 = (double)num == 1.25;
|
||||
if (flag3)
|
||||
{
|
||||
this.SetAllControlsFontSize(base.Controls, 1.27f);
|
||||
}
|
||||
}
|
||||
|
||||
// Token: 0x0600001B RID: 27 RVA: 0x00003CB0 File Offset: 0x00001EB0
|
||||
private void SetAllControlsFontSize(Control.ControlCollection ctrls, float scale)
|
||||
{
|
||||
foreach (object obj in ctrls)
|
||||
{
|
||||
Control control = (Control)obj;
|
||||
bool flag = control.Controls != null;
|
||||
if (flag)
|
||||
{
|
||||
this.SetAllControlsFontSize(control.Controls, scale);
|
||||
}
|
||||
bool flag2 = control != null;
|
||||
if (flag2)
|
||||
{
|
||||
Font font = control.Font;
|
||||
control.Font = new Font(font.Name, font.Size / scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Token: 0x0600001C RID: 28 RVA: 0x00003D50 File Offset: 0x00001F50
|
||||
private void button3_Click(object sender, EventArgs e)
|
||||
{
|
||||
this.mmiMsgForm.FormBorderStyle = FormBorderStyle.None;
|
||||
this.mmiMsgForm.TopLevel = false;
|
||||
this.mmiMsgForm.TopMost = true;
|
||||
this.mainpanel.Controls.Add(this.mmiMsgForm);
|
||||
this.mmiMsgForm.Dock = DockStyle.Fill;
|
||||
this.ShowChildForm(this.mmiMsgForm);
|
||||
}
|
||||
|
||||
// Token: 0x0600001D RID: 29 RVA: 0x00003DB8 File Offset: 0x00001FB8
|
||||
private void button4_Click(object sender, EventArgs e)
|
||||
{
|
||||
TermsDefinitionForm termsDefinitionForm = new TermsDefinitionForm();
|
||||
termsDefinitionForm.Show();
|
||||
}
|
||||
|
||||
// Token: 0x0600001E RID: 30 RVA: 0x00003DD4 File Offset: 0x00001FD4
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
bool flag = disposing && this.components != null;
|
||||
if (flag)
|
||||
{
|
||||
this.components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
// Token: 0x0600001F RID: 31 RVA: 0x00003E0C File Offset: 0x0000200C
|
||||
private void InitializeComponent()
|
||||
{
|
||||
ComponentResourceManager componentResourceManager = new ComponentResourceManager(typeof(Form1));
|
||||
this.panel1 = new Panel();
|
||||
this.button4 = new Button();
|
||||
this.button3 = new Button();
|
||||
this.button1 = new Button();
|
||||
this.button2 = new Button();
|
||||
this.mainpanel = new Panel();
|
||||
this.panel1.SuspendLayout();
|
||||
base.SuspendLayout();
|
||||
this.panel1.AutoSize = true;
|
||||
this.panel1.Controls.Add(this.button4);
|
||||
this.panel1.Controls.Add(this.button3);
|
||||
this.panel1.Controls.Add(this.button1);
|
||||
this.panel1.Controls.Add(this.button2);
|
||||
this.panel1.Dock = DockStyle.Top;
|
||||
this.panel1.Location = new Point(0, 0);
|
||||
this.panel1.Margin = new Padding(3, 4, 3, 4);
|
||||
this.panel1.Name = "panel1";
|
||||
this.panel1.Size = new Size(1530, 70);
|
||||
this.panel1.TabIndex = 2;
|
||||
this.button4.AutoSize = true;
|
||||
this.button4.BackColor = Color.Transparent;
|
||||
this.button4.Font = new Font("나눔고딕 ExtraBold", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
|
||||
this.button4.Image = (Image)componentResourceManager.GetObject("button4.Image");
|
||||
this.button4.ImageAlign = ContentAlignment.MiddleLeft;
|
||||
this.button4.Location = new Point(591, 4);
|
||||
this.button4.Margin = new Padding(3, 4, 3, 4);
|
||||
this.button4.Name = "button4";
|
||||
this.button4.Size = new Size(168, 61);
|
||||
this.button4.TabIndex = 4;
|
||||
this.button4.Text = "약어 설명";
|
||||
this.button4.TextAlign = ContentAlignment.MiddleRight;
|
||||
this.button4.UseVisualStyleBackColor = false;
|
||||
this.button4.Click += this.button4_Click;
|
||||
this.button3.AutoSize = true;
|
||||
this.button3.BackColor = Color.Transparent;
|
||||
this.button3.Font = new Font("나눔고딕 ExtraBold", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
|
||||
this.button3.Image = (Image)componentResourceManager.GetObject("button3.Image");
|
||||
this.button3.ImageAlign = ContentAlignment.MiddleLeft;
|
||||
this.button3.Location = new Point(396, 4);
|
||||
this.button3.Margin = new Padding(3, 4, 3, 4);
|
||||
this.button3.Name = "button3";
|
||||
this.button3.Size = new Size(192, 61);
|
||||
this.button3.TabIndex = 3;
|
||||
this.button3.Text = "메세지 뷰어";
|
||||
this.button3.TextAlign = ContentAlignment.MiddleRight;
|
||||
this.button3.UseVisualStyleBackColor = false;
|
||||
this.button3.Click += this.button3_Click;
|
||||
this.button1.AutoSize = true;
|
||||
this.button1.BackColor = Color.Transparent;
|
||||
this.button1.Font = new Font("맑은 고딕", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
|
||||
this.button1.Image = Resources.realtime;
|
||||
this.button1.ImageAlign = ContentAlignment.MiddleLeft;
|
||||
this.button1.Location = new Point(3, 4);
|
||||
this.button1.Margin = new Padding(3, 4, 3, 4);
|
||||
this.button1.Name = "button1";
|
||||
this.button1.Size = new Size(195, 62);
|
||||
this.button1.TabIndex = 1;
|
||||
this.button1.Text = "실시간 뷰어";
|
||||
this.button1.TextAlign = ContentAlignment.MiddleRight;
|
||||
this.button1.UseVisualStyleBackColor = false;
|
||||
this.button1.Click += this.button1_Click_1;
|
||||
this.button2.AutoSize = true;
|
||||
this.button2.BackColor = Color.Transparent;
|
||||
this.button2.Font = new Font("맑은 고딕", 15.75f, FontStyle.Bold, GraphicsUnit.Point, 129);
|
||||
this.button2.Image = Resources.saved;
|
||||
this.button2.ImageAlign = ContentAlignment.MiddleLeft;
|
||||
this.button2.Location = new Point(201, 4);
|
||||
this.button2.Margin = new Padding(3, 4, 3, 4);
|
||||
this.button2.Name = "button2";
|
||||
this.button2.Size = new Size(192, 61);
|
||||
this.button2.TabIndex = 2;
|
||||
this.button2.Text = "LOG 뷰어";
|
||||
this.button2.TextAlign = ContentAlignment.MiddleRight;
|
||||
this.button2.UseVisualStyleBackColor = false;
|
||||
this.button2.Click += this.button2_Click;
|
||||
this.mainpanel.AutoSize = true;
|
||||
this.mainpanel.AutoSizeMode = AutoSizeMode.GrowAndShrink;
|
||||
this.mainpanel.Dock = DockStyle.Fill;
|
||||
this.mainpanel.Location = new Point(0, 70);
|
||||
this.mainpanel.Margin = new Padding(3, 4, 3, 4);
|
||||
this.mainpanel.Name = "mainpanel";
|
||||
this.mainpanel.Size = new Size(1530, 852);
|
||||
this.mainpanel.TabIndex = 0;
|
||||
base.AutoScaleMode = AutoScaleMode.None;
|
||||
this.AutoScroll = true;
|
||||
base.AutoSizeMode = AutoSizeMode.GrowAndShrink;
|
||||
base.ClientSize = new Size(1530, 922);
|
||||
base.Controls.Add(this.mainpanel);
|
||||
base.Controls.Add(this.panel1);
|
||||
base.FormBorderStyle = FormBorderStyle.Fixed3D;
|
||||
base.Icon = (Icon)componentResourceManager.GetObject("$this.Icon");
|
||||
base.Margin = new Padding(3, 4, 3, 4);
|
||||
base.MaximizeBox = false;
|
||||
this.MaximumSize = new Size(1550, 965);
|
||||
this.MinimumSize = new Size(1550, 965);
|
||||
base.Name = "Form1";
|
||||
base.SizeGripStyle = SizeGripStyle.Hide;
|
||||
base.StartPosition = FormStartPosition.CenterScreen;
|
||||
this.Text = "SL200_LogViewer Ver 1.0.1_25.10";
|
||||
base.FormClosed += this.Form1_FormClosed;
|
||||
base.Load += this.Form1_Load;
|
||||
this.panel1.ResumeLayout(false);
|
||||
this.panel1.PerformLayout();
|
||||
base.ResumeLayout(false);
|
||||
base.PerformLayout();
|
||||
}
|
||||
|
||||
// Token: 0x04000012 RID: 18
|
||||
private SavedlogForm savedlogForm = new SavedlogForm();
|
||||
|
||||
// Token: 0x04000013 RID: 19
|
||||
private mmiMsgForm mmiMsgForm = new mmiMsgForm();
|
||||
|
||||
// Token: 0x04000014 RID: 20
|
||||
private Form _activeForm;
|
||||
|
||||
// Token: 0x04000015 RID: 21
|
||||
public static bool logOn;
|
||||
|
||||
// Token: 0x04000016 RID: 22
|
||||
private IContainer components = null;
|
||||
|
||||
// Token: 0x04000017 RID: 23
|
||||
private Button button1;
|
||||
|
||||
// Token: 0x04000018 RID: 24
|
||||
private Panel panel1;
|
||||
|
||||
// Token: 0x04000019 RID: 25
|
||||
private Button button2;
|
||||
|
||||
// Token: 0x0400001A RID: 26
|
||||
private Panel mainpanel;
|
||||
|
||||
// Token: 0x0400001B RID: 27
|
||||
private Button button3;
|
||||
|
||||
// Token: 0x0400001C RID: 28
|
||||
private Button button4;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using System;
|
||||
|
||||
namespace SL200_RTLogViewer.lib
|
||||
{
|
||||
// Token: 0x02000021 RID: 33
|
||||
public class ACPU200
|
||||
{
|
||||
// Token: 0x06000139 RID: 313 RVA: 0x000240A0 File Offset: 0x000222A0
|
||||
public static ACPU200Class SetData(byte[] data)
|
||||
{
|
||||
return new ACPU200Class
|
||||
{
|
||||
seq = Convert.ToInt32(data[0]),
|
||||
ver = Convert.ToInt32((int)(data[1] & 252)),
|
||||
boardStat = Convert.ToInt32((int)(data[1] & 3)),
|
||||
source = Convert.ToInt32((int)(data[2] & 240)),
|
||||
destination = Convert.ToInt32((int)(data[2] & 15)),
|
||||
pwm = Convert.ToInt32(data[3]),
|
||||
dr = ((data[4] & 128) > 0),
|
||||
br = ((data[4] & 64) > 0),
|
||||
cs = ((data[4] & 32) > 0),
|
||||
ador = ((data[4] & 16) > 0),
|
||||
adol = ((data[4] & 8) > 0),
|
||||
adc = ((data[4] & 4) > 0),
|
||||
recovery = ((data[4] & 2) > 0),
|
||||
nomal = ((data[4] & 1) > 0),
|
||||
aa = ((data[5] & 128) > 0),
|
||||
am = ((data[5] & 64) > 0),
|
||||
mm = ((data[5] & 32) > 0),
|
||||
ats = ((data[5] & 16) > 0),
|
||||
pgx = ((data[5] & 8) > 0),
|
||||
pg32 = ((data[5] & 4) > 0),
|
||||
pg2 = ((data[5] & 2) > 0),
|
||||
pg1 = ((data[5] & 1) > 0),
|
||||
distance = Convert.ToDouble((int)data[6] << 8 | (int)data[7])
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
using System;
|
||||
|
||||
namespace SL200_RTLogViewer.lib
|
||||
{
|
||||
// Token: 0x02000020 RID: 32
|
||||
public class ACPU200Class
|
||||
{
|
||||
// Token: 0x04000213 RID: 531
|
||||
public int seq;
|
||||
|
||||
// Token: 0x04000214 RID: 532
|
||||
public int ver;
|
||||
|
||||
// Token: 0x04000215 RID: 533
|
||||
public int boardStat;
|
||||
|
||||
// Token: 0x04000216 RID: 534
|
||||
public int source;
|
||||
|
||||
// Token: 0x04000217 RID: 535
|
||||
public int destination;
|
||||
|
||||
// Token: 0x04000218 RID: 536
|
||||
public int pwm;
|
||||
|
||||
// Token: 0x04000219 RID: 537
|
||||
public bool dr;
|
||||
|
||||
// Token: 0x0400021A RID: 538
|
||||
public bool br;
|
||||
|
||||
// Token: 0x0400021B RID: 539
|
||||
public bool cs;
|
||||
|
||||
// Token: 0x0400021C RID: 540
|
||||
public bool adol;
|
||||
|
||||
// Token: 0x0400021D RID: 541
|
||||
public bool ador;
|
||||
|
||||
// Token: 0x0400021E RID: 542
|
||||
public bool adc;
|
||||
|
||||
// Token: 0x0400021F RID: 543
|
||||
public bool recovery;
|
||||
|
||||
// Token: 0x04000220 RID: 544
|
||||
public bool nomal;
|
||||
|
||||
// Token: 0x04000221 RID: 545
|
||||
public bool aa;
|
||||
|
||||
// Token: 0x04000222 RID: 546
|
||||
public bool am;
|
||||
|
||||
// Token: 0x04000223 RID: 547
|
||||
public bool mm;
|
||||
|
||||
// Token: 0x04000224 RID: 548
|
||||
public bool ats;
|
||||
|
||||
// Token: 0x04000225 RID: 549
|
||||
public bool pgx;
|
||||
|
||||
// Token: 0x04000226 RID: 550
|
||||
public bool pg32;
|
||||
|
||||
// Token: 0x04000227 RID: 551
|
||||
public bool pg2;
|
||||
|
||||
// Token: 0x04000228 RID: 552
|
||||
public bool pg1;
|
||||
|
||||
// Token: 0x04000229 RID: 553
|
||||
public double distance;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using System;
|
||||
|
||||
namespace SL200_RTLogViewer.lib
|
||||
{
|
||||
// Token: 0x02000023 RID: 35
|
||||
public class ACPU201
|
||||
{
|
||||
// Token: 0x0600013C RID: 316 RVA: 0x00024274 File Offset: 0x00022474
|
||||
public static ACPU201Class SetData(byte[] data)
|
||||
{
|
||||
return new ACPU201Class
|
||||
{
|
||||
seq = Convert.ToInt32(data[0]),
|
||||
atolimit = Convert.ToInt32(data[1]),
|
||||
ebreq = ((data[2] & 128) > 0),
|
||||
stationvalid = ((data[2] & 32) > 0),
|
||||
dcwvalid = ((data[2] & 16) > 0),
|
||||
dcw = ((data[2] & 8) > 0),
|
||||
wrongdoor = ((data[2] & 4) > 0),
|
||||
nextStationDr = ((data[2] & 2) > 0),
|
||||
prestation = Convert.ToInt32(data[3]),
|
||||
nextstation = Convert.ToInt32(data[4]),
|
||||
deststation = Convert.ToInt32(data[5]),
|
||||
trainnumber = Convert.ToString((int)data[6] << 8 | (int)data[7], 16)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using System;
|
||||
|
||||
namespace SL200_RTLogViewer.lib
|
||||
{
|
||||
// Token: 0x02000022 RID: 34
|
||||
public class ACPU201Class
|
||||
{
|
||||
// Token: 0x0400022A RID: 554
|
||||
public int seq;
|
||||
|
||||
// Token: 0x0400022B RID: 555
|
||||
public int atolimit;
|
||||
|
||||
// Token: 0x0400022C RID: 556
|
||||
public bool ebreq;
|
||||
|
||||
// Token: 0x0400022D RID: 557
|
||||
public bool stationvalid;
|
||||
|
||||
// Token: 0x0400022E RID: 558
|
||||
public bool dcwvalid;
|
||||
|
||||
// Token: 0x0400022F RID: 559
|
||||
public bool dcw;
|
||||
|
||||
// Token: 0x04000230 RID: 560
|
||||
public bool wrongdoor;
|
||||
|
||||
// Token: 0x04000231 RID: 561
|
||||
public bool nextStationDr;
|
||||
|
||||
// Token: 0x04000232 RID: 562
|
||||
public int prestation;
|
||||
|
||||
// Token: 0x04000233 RID: 563
|
||||
public int nextstation;
|
||||
|
||||
// Token: 0x04000234 RID: 564
|
||||
public int deststation;
|
||||
|
||||
// Token: 0x04000235 RID: 565
|
||||
public string trainnumber;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
using System;
|
||||
|
||||
namespace SL200_RTLogViewer.lib
|
||||
{
|
||||
// Token: 0x02000025 RID: 37
|
||||
public class ACPU202
|
||||
{
|
||||
// Token: 0x0600013F RID: 319 RVA: 0x00024370 File Offset: 0x00022570
|
||||
public static ACPU202Class SetData(byte[] data)
|
||||
{
|
||||
ACPU202Class acpu202Class = new ACPU202Class();
|
||||
acpu202Class.seq = Convert.ToInt32(data[0]);
|
||||
acpu202Class.sel = ((data[1] & 64) > 0);
|
||||
acpu202Class.kur = ((data[1] & 32) > 0);
|
||||
acpu202Class.osc = ((data[1] & 16) > 0);
|
||||
acpu202Class.twctxe = ((data[1] & 8) > 0);
|
||||
acpu202Class.inching = ((data[1] & 4) > 0);
|
||||
acpu202Class.tasc = ((data[1] & 2) > 0);
|
||||
acpu202Class.trainberth = ((data[1] & 1) > 0);
|
||||
acpu202Class.rlyfbe = ((data[2] & 128) > 0);
|
||||
acpu202Class.drcle = ((data[2] & 64) > 0);
|
||||
acpu202Class.drope = ((data[2] & 32) > 0);
|
||||
acpu202Class.stncodee = ((data[2] & 16) > 0);
|
||||
acpu202Class.majorover = ((data[2] & 8) > 0);
|
||||
acpu202Class.minorover = ((data[2] & 4) > 0);
|
||||
acpu202Class.majorbelow = ((data[2] & 2) > 0);
|
||||
acpu202Class.minorbelow = ((data[2] & 1) > 0);
|
||||
acpu202Class.atcdxl = ((data[3] & 128) > 0);
|
||||
acpu202Class.atcar = ((data[3] & 64) > 0);
|
||||
acpu202Class.prebr = ((data[3] & 128) > 0);
|
||||
acpu202Class.limitdr = ((data[3] & 64) > 0);
|
||||
acpu202Class.tascdb = ((data[3] & 32) > 0);
|
||||
acpu202Class.pg32miss = ((data[3] & 16) > 0);
|
||||
acpu202Class.pg2miss = ((data[3] & 8) > 0);
|
||||
acpu202Class.pg1miss = ((data[3] & 4) > 0);
|
||||
acpu202Class.atoante = ((data[3] & 2) > 0);
|
||||
acpu202Class.atostope = ((data[3] & 1) > 0);
|
||||
bool tasc = acpu202Class.tasc;
|
||||
if (tasc)
|
||||
{
|
||||
acpu202Class.dtg = Convert.ToDouble((int)data[4] << 8 | (int)data[5]) / 100.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
acpu202Class.dtg = Convert.ToDouble((int)data[4] << 8 | (int)data[5]) / 10.0;
|
||||
}
|
||||
acpu202Class.liveoscf = Convert.ToDouble((int)(data[6] ^ 99) << 8 | (int)(data[7] ^ 100)) / 10.0;
|
||||
return acpu202Class;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue