diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt index 5b8b8c6..159e195 100644 --- a/src/main/kotlin/network/RagService.kt +++ b/src/main/kotlin/network/RagService.kt @@ -13,6 +13,7 @@ import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -296,10 +297,13 @@ object RagService { // 문자열 치환 대신 안전한 JSON 객체 빌더 사용 val requestBodyJson = buildJsonObject { put("model", "local-model") - put("temperature", 0.0) // 0.1 유지 (결정론적 응답) - put("top_p", 0.9) - put("max_tokens", 500) + put("temperature", 0.1) // 💡 루프 탈출을 위해 더 과감하게 설정 + put("top_p", 0.85) // 💡 추가 + put("top_k", 40) // 💡 추가 (서버에서 지원할 경우) +// put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화 +// put("presence_penalty", 0.5) + put("max_tokens", 400) putJsonArray("messages") { addJsonObject { put("role", "system") @@ -339,96 +343,126 @@ object RagService { .connectTimeout(60, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .build() + suspend fun decideTrading( stockName: String, - scores: InvestmentScores, // 직접 계산한 점수 객체 - financialStmt: FinancialStatement, // 매핑된 재무 수치 객체 + scores: InvestmentScores, + financialStmt: FinancialStatement, tempDecision: TradingDecision ): TradingDecision? { - // 💡 1. 뉴스 데이터가 유효한지(100자 이상인지) 확인 - val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 } - // 💡 2. 동적 데이터 섹션 구성 - val newsDataSection = if (validNews != null) { - "3. News Context: $validNews" - } else { - "3. News Context: None available. Base your decision ONLY on System Scores and Financials." - } + var retryCount = 0 + val maxRetries = 2 - // 💡 3. 동적 제약 조건 구성 - val newsConstraint = if (validNews != null) { - "- Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD)." - } else { - "- No news data is available. Rely strictly on Financials and System Scores for your 'decision' and 'reason'." - } + while (retryCount <= maxRetries) { - val prompt = """ + // 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단 + val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }?.take(400) + + // 2. 뉴스 유무에 따른 동적 데이터 섹션 구성 + val newsDataSection = if (validNews != null) { + "3. News Context: $validNews" + } else { + "3. News Context: No significant news available. Rely on financials." + } + + + val prompt = """ # Task: Senior AI Investment Analyst -Analyze the stock '$stockName' and determine the final trading decision based on the data below. -# Data +Your goal is to provide a final trading decision based on STRICT data analysis. + + +# Data (SOURCE OF TRUTH) 1. System Scores: Scalping(${scores.ultraShort}), Short(${scores.shortTerm}), Mid(${scores.midTerm}), Long(${scores.longTerm}) + 2. Financials: Operating Profit ${if(financialStmt.isOperatingProfitPositive) "PROFIT" else "LOSS"} (Growth: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%), ROE: ${"%.2f".format(financialStmt.roe)}%, Debt: ${"%.2f".format(financialStmt.debtRatio)}% + $newsDataSection -# Constraints -- Copy the exact 'System Scores' from the Data section into the output JSON. -- Match Financials with News: If profit is negative but news is hyped, stay CAUTIOUS (HOLD). -- The "reason" field MUST be written in KOREAN and MUST NOT exceed 50 characters. Keep it concise. -- Output ONLY a valid JSON object matching the exact structure below. DO NOT output placeholder text like '(integer)'. -# Example Output JSON Format +# Step-by-Step Analysis Logic + +1. Financial Review: First, evaluate the 'Financials' section for long-term stability. + +2. News Verification: Second, check 'News Context' (if available) for immediate market sentiment or specific issues. + +3. Synthesis: Finalize the 'decision' (BUY, SELL, HOLD) by combining the Financials and News analysis. + +4. Confidence: Assign a confidence score (0-100) based on how clearly the data points to the decision. + + +# Strict Constraints + +- SCORE INTEGRITY: You MUST copy the 'System Scores' into the output JSON exactly as provided. NO TRANSFORMATION. + +- REASON LENGTH: The "reason" field MUST be written in KOREAN and MUST be between 10 to 50 characters. + +- JSON ONLY: Output ONLY a valid JSON object. No markdown, no pre-text, no post-text. + + +# Output JSON Structure (STRICT NAMES) + { - "ultraShortScore": 0, - "shortTermScore": 0, - "midTermScore": 0, - "longTermScore": 0, + "ultraShortScore": ${scores.ultraShort}, + "shortTermScore": ${scores.shortTerm}, + "midTermScore": ${scores.midTerm}, + "longTermScore": ${scores.longTerm}, "decision": "HOLD", - "reason": "적자 지속 및 네수파립 임상 대기 중으로 관망 필요", - "confidence": 50 + "reason": "10자 이상 50자 이내의 한국어 분석 결과", + "confidence": 0 } + """.trimIndent() + try { + val rawResponse = callLlamaWithSchema(prompt) +// 환각 및 루프 검사 + println("rawResponse $rawResponse") - return try { - val rawResponse = callLlamaWithSchema(prompt) - println("📥 [AI Strict JSON]:\n$rawResponse") + val sanitized = rawResponse.trim().removeSurrounding("```json", "```").trim() - // 엄격한 스키마가 적용되었으므로 JsonSanitizer 없이 바로 파싱 가능 - val lenientJson = Json { - ignoreUnknownKeys = true - isLenient = true - coerceInputValues = true + val decision = Json { ignoreUnknownKeys = true; isLenient = true }.decodeFromString(sanitized) + + // 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영) + val reasonLen = decision.reason?.length ?: 0 + val isReasonValid = reasonLen in 5..60 // 약간의 마진 허용 + + // 점수가 보존되었는지 확인 (Scalping 점수 대조) + val isScorePreserved = decision.ultraShortScore == scores.ultraShort.toDouble() + + if (isReasonValid && isScorePreserved) { + return decision.apply { + this.stockCode = tempDecision.stockCode + this.stockName = tempDecision.stockName + this.corpName = tempDecision.corpName + this.financialData = tempDecision.financialData + this.newsContext = tempDecision.newsContext + } + } else { + println("⚠️ [검증 실패] 사유길이($reasonLen) 또는 점수보존($isScorePreserved) 실패. 재시도 합니다.") + retryCount++ + } + + + + } catch (e: Exception) { + println("❌ [파싱 오류] ${e.message} - 재시도 시도 중... (${retryCount + 1})") + retryCount++ + delay(500) } - - val decision = lenientJson.decodeFromString(rawResponse) - - // 데이터 매핑 - decision.apply { - financialData = tempDecision.financialData - newsContext = tempDecision.newsContext - techSummary = tempDecision.techSummary - stockCode = tempDecision.stockCode - corpName = tempDecision.corpName - this.stockName = tempDecision.stockName - } - - decision - } catch (e: InternalServerException) { - // 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리 - println("🚨 [AI Server Error] ${e.message}") - if (e.message?.contains("Context size") == true) { - println("⚠️ 데이터가 너무 많습니다. 요약 로직을 점검하세요.") - } - tempDecision - null - } catch (e: Exception) { - println("❌ [General Error] ${e.message}") - null + } + // 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환 + println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.") + return TradingDecision().apply { + this.stockName = stockName + this.decision = "HOLD" + this.reason = "AI 분석 지연으로 인한 자동 관망 처리" } } + } diff --git a/src/main/kotlin/service/AutoTradingManager.kt b/src/main/kotlin/service/AutoTradingManager.kt index 201e864..8789673 100644 --- a/src/main/kotlin/service/AutoTradingManager.kt +++ b/src/main/kotlin/service/AutoTradingManager.kt @@ -462,7 +462,7 @@ object AutoTradingManager { } reanalysisList.clear() remainingCandidates.addAll(candidates.filter { it.code !in myHoldings && it.code !in pendingStocks && it.code !in executionCache.values.map { it.code } && it.code !in failList} - .distinctBy { it.code }.shuffled()) + .distinctBy { it.code }) } else { println("미확인 데이터 ${remainingCandidates.size}") // remainingCandidates.removeIf { it.code in myHoldings || it.code in pendingStocks || it.code in executionCache.values.map { it.code } || it.code in failList} diff --git a/src/main/kotlin/service/LlamaServerManager.kt b/src/main/kotlin/service/LlamaServerManager.kt index fa977c4..c0e27f5 100644 --- a/src/main/kotlin/service/LlamaServerManager.kt +++ b/src/main/kotlin/service/LlamaServerManager.kt @@ -15,11 +15,37 @@ object LlamaServerManager { private val processes = ConcurrentHashMap() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) init { + killZombieProcesses() + Runtime.getRuntime().addShutdownHook(Thread { stopAll() }) } + // OS 시스템 명령어를 이용해 찌꺼기 프로세스를 이름으로 찾아 강제 종료하는 함수 + private fun killZombieProcesses() { + try { + val os = System.getProperty("os.name").lowercase() + if (os.contains("win")) { + // 윈도우: taskkill 명령어로 llama-server.exe 강제 종료 (/F: 강제, /T: 트리거된 자식까지) + ProcessBuilder("cmd", "/c", "taskkill /F /IM llama-server.exe /T") + .redirectErrorStream(true) + .start() + .waitFor() + println("🧹 [System] 이전 llama-server 좀비 프로세스 정리 완료 (Windows)") + } else { + // 맥/리눅스: pkill 사용 + ProcessBuilder("pkill", "-f", "llama-server") + .redirectErrorStream(true) + .start() + .waitFor() + println("🧹 [System] 이전 llama-server 좀비 프로세스 정리 완료 (Mac/Linux)") + } + } catch (e: Exception) { + // 실행 중인 프로세스가 없어서 에러가 나도 조용히 무시합니다. + } + } + fun startServer(binPath: String, modelPath: String, port: Int, nGpuLayers: Int = 99) { // 이미 해당 포트에서 실행 중이거나 모델 경로가 비었으면 무시합니다. if (processes.containsKey(port) || modelPath.isBlank()) return @@ -28,7 +54,7 @@ object LlamaServerManager { val isWin = os.contains("win") val (nGpuLayers, threads) = when { os.contains("mac") && (arch.contains("arm64") || arch.contains("aarch64")) -> 99 to 8 - isWin -> 16 to 6 // NUC Core Ultra 7: GPU 레이어 40 내외, 스레드 12 권장 + isWin -> 4 to 12 // NUC Core Ultra 7: GPU 레이어 40 내외, 스레드 12 권장 else -> 0 to 4 // 인텔 맥 2017 등 }