This commit is contained in:
lun_admin 2026-03-18 16:01:42 +09:00
parent 3d4f4d7412
commit eee4f1bab7
3 changed files with 129 additions and 69 deletions

View File

@ -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<TradingDecision>(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<TradingDecision>(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 분석 지연으로 인한 자동 관망 처리"
}
}
}

View File

@ -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}

View File

@ -15,11 +15,37 @@ object LlamaServerManager {
private val processes = ConcurrentHashMap<Int, Process>()
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 등
}