.
This commit is contained in:
parent
3d4f4d7412
commit
eee4f1bab7
@ -13,6 +13,7 @@ import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@ -296,10 +297,13 @@ object RagService {
|
|||||||
// 문자열 치환 대신 안전한 JSON 객체 빌더 사용
|
// 문자열 치환 대신 안전한 JSON 객체 빌더 사용
|
||||||
val requestBodyJson = buildJsonObject {
|
val requestBodyJson = buildJsonObject {
|
||||||
put("model", "local-model")
|
put("model", "local-model")
|
||||||
put("temperature", 0.0) // 0.1 유지 (결정론적 응답)
|
put("temperature", 0.1) // 💡 루프 탈출을 위해 더 과감하게 설정
|
||||||
put("top_p", 0.9)
|
put("top_p", 0.85) // 💡 추가
|
||||||
put("max_tokens", 500)
|
put("top_k", 40) // 💡 추가 (서버에서 지원할 경우)
|
||||||
|
// put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화
|
||||||
|
// put("presence_penalty", 0.5)
|
||||||
|
|
||||||
|
put("max_tokens", 400)
|
||||||
putJsonArray("messages") {
|
putJsonArray("messages") {
|
||||||
addJsonObject {
|
addJsonObject {
|
||||||
put("role", "system")
|
put("role", "system")
|
||||||
@ -339,96 +343,126 @@ object RagService {
|
|||||||
.connectTimeout(60, TimeUnit.SECONDS)
|
.connectTimeout(60, TimeUnit.SECONDS)
|
||||||
.readTimeout(120, TimeUnit.SECONDS)
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
suspend fun decideTrading(
|
suspend fun decideTrading(
|
||||||
stockName: String,
|
stockName: String,
|
||||||
scores: InvestmentScores, // 직접 계산한 점수 객체
|
scores: InvestmentScores,
|
||||||
financialStmt: FinancialStatement, // 매핑된 재무 수치 객체
|
financialStmt: FinancialStatement,
|
||||||
tempDecision: TradingDecision
|
tempDecision: TradingDecision
|
||||||
): TradingDecision? {
|
): TradingDecision? {
|
||||||
// 💡 1. 뉴스 데이터가 유효한지(100자 이상인지) 확인
|
|
||||||
val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }
|
|
||||||
|
|
||||||
// 💡 2. 동적 데이터 섹션 구성
|
var retryCount = 0
|
||||||
val newsDataSection = if (validNews != null) {
|
val maxRetries = 2
|
||||||
"3. News Context: $validNews"
|
|
||||||
} else {
|
|
||||||
"3. News Context: None available. Base your decision ONLY on System Scores and Financials."
|
|
||||||
}
|
|
||||||
|
|
||||||
// 💡 3. 동적 제약 조건 구성
|
while (retryCount <= maxRetries) {
|
||||||
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'."
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
# 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})
|
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)}%
|
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
|
$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,
|
"ultraShortScore": ${scores.ultraShort},
|
||||||
"shortTermScore": 0,
|
"shortTermScore": ${scores.shortTerm},
|
||||||
"midTermScore": 0,
|
"midTermScore": ${scores.midTerm},
|
||||||
"longTermScore": 0,
|
"longTermScore": ${scores.longTerm},
|
||||||
"decision": "HOLD",
|
"decision": "HOLD",
|
||||||
"reason": "적자 지속 및 네수파립 임상 대기 중으로 관망 필요",
|
"reason": "10자 이상 50자 이내의 한국어 분석 결과",
|
||||||
"confidence": 50
|
"confidence": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
try {
|
||||||
|
val rawResponse = callLlamaWithSchema(prompt)
|
||||||
|
// 환각 및 루프 검사
|
||||||
|
println("rawResponse $rawResponse")
|
||||||
|
|
||||||
|
|
||||||
return try {
|
val sanitized = rawResponse.trim().removeSurrounding("```json", "```").trim()
|
||||||
val rawResponse = callLlamaWithSchema(prompt)
|
|
||||||
println("📥 [AI Strict JSON]:\n$rawResponse")
|
|
||||||
|
|
||||||
// 엄격한 스키마가 적용되었으므로 JsonSanitizer 없이 바로 파싱 가능
|
val decision = Json { ignoreUnknownKeys = true; isLenient = true }.decodeFromString<TradingDecision>(sanitized)
|
||||||
val lenientJson = Json {
|
|
||||||
ignoreUnknownKeys = true
|
// 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영)
|
||||||
isLenient = true
|
val reasonLen = decision.reason?.length ?: 0
|
||||||
coerceInputValues = true
|
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)
|
// 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환
|
||||||
|
println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.")
|
||||||
// 데이터 매핑
|
return TradingDecision().apply {
|
||||||
decision.apply {
|
this.stockName = stockName
|
||||||
financialData = tempDecision.financialData
|
this.decision = "HOLD"
|
||||||
newsContext = tempDecision.newsContext
|
this.reason = "AI 분석 지연으로 인한 자동 관망 처리"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -462,7 +462,7 @@ object AutoTradingManager {
|
|||||||
}
|
}
|
||||||
reanalysisList.clear()
|
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}
|
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 {
|
} else {
|
||||||
println("미확인 데이터 ${remainingCandidates.size}")
|
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}
|
// remainingCandidates.removeIf { it.code in myHoldings || it.code in pendingStocks || it.code in executionCache.values.map { it.code } || it.code in failList}
|
||||||
|
|||||||
@ -15,11 +15,37 @@ object LlamaServerManager {
|
|||||||
private val processes = ConcurrentHashMap<Int, Process>()
|
private val processes = ConcurrentHashMap<Int, Process>()
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
init {
|
init {
|
||||||
|
killZombieProcesses()
|
||||||
|
|
||||||
Runtime.getRuntime().addShutdownHook(Thread {
|
Runtime.getRuntime().addShutdownHook(Thread {
|
||||||
stopAll()
|
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) {
|
fun startServer(binPath: String, modelPath: String, port: Int, nGpuLayers: Int = 99) {
|
||||||
// 이미 해당 포트에서 실행 중이거나 모델 경로가 비었으면 무시합니다.
|
// 이미 해당 포트에서 실행 중이거나 모델 경로가 비었으면 무시합니다.
|
||||||
if (processes.containsKey(port) || modelPath.isBlank()) return
|
if (processes.containsKey(port) || modelPath.isBlank()) return
|
||||||
@ -28,7 +54,7 @@ object LlamaServerManager {
|
|||||||
val isWin = os.contains("win")
|
val isWin = os.contains("win")
|
||||||
val (nGpuLayers, threads) = when {
|
val (nGpuLayers, threads) = when {
|
||||||
os.contains("mac") && (arch.contains("arm64") || arch.contains("aarch64")) -> 99 to 8
|
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 등
|
else -> 0 to 4 // 인텔 맥 2017 등
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user