.
This commit is contained in:
@@ -1,23 +1,36 @@
|
||||
// src/main/kotlin/network/RagService.kt
|
||||
package network// src/main/kotlin/network/RagService.kt
|
||||
|
||||
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
|
||||
import dev.langchain4j.data.document.Metadata
|
||||
import dev.langchain4j.data.message.UserMessage
|
||||
import dev.langchain4j.data.segment.TextSegment
|
||||
import dev.langchain4j.exception.InternalServerException
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel
|
||||
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
|
||||
import dev.langchain4j.service.AiServices
|
||||
import dev.langchain4j.service.SystemMessage
|
||||
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
|
||||
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import model.CandleData
|
||||
import network.DartCodeManager
|
||||
import network.FinancialMapper
|
||||
import network.FinancialStatement
|
||||
import network.NewsService
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.lucene.store.MMapDirectory
|
||||
import org.slf4j.MDC.put
|
||||
import service.FinancialAnalyzer
|
||||
import service.InvestmentScores
|
||||
import service.TechnicalAnalyzer
|
||||
@@ -25,9 +38,10 @@ import service.TradingDecisionCallback
|
||||
import service.UrlCacheManager
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface TradingAnalyst {
|
||||
@dev.langchain4j.service.SystemMessage("""
|
||||
@SystemMessage("""
|
||||
You are a Senior Stock Analyst.
|
||||
Analyze the data and provide a decision in JSON format.
|
||||
You must respond ONLY with a valid JSON object.
|
||||
@@ -48,13 +62,13 @@ object RagService {
|
||||
.apiKey("unused")
|
||||
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.frequencyPenalty(1.1)
|
||||
.maxTokens(500) // 👈 루프 방지를 위해 반드시 짧게 제한!
|
||||
// .frequencyPenalty(1.1)
|
||||
.maxTokens(400) // 👈 루프 방지를 위해 반드시 짧게 제한!
|
||||
// 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요
|
||||
.responseFormat("json_object")
|
||||
.build()
|
||||
|
||||
private val analyst = dev.langchain4j.service.AiServices.builder(TradingAnalyst::class.java)
|
||||
private val analyst = AiServices.builder(TradingAnalyst::class.java)
|
||||
.chatModel(chatModel)
|
||||
.build()
|
||||
|
||||
@@ -139,8 +153,18 @@ object RagService {
|
||||
|
||||
object JsonSanitizer {
|
||||
fun formatJson(raw: String): String {
|
||||
// 실제 응답 로그 출력 (디버깅용)
|
||||
println("📥 [AI Raw Response]:\n$raw")
|
||||
|
||||
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
|
||||
return raw.trim()
|
||||
val match = regex.find(raw)?.value
|
||||
|
||||
if (match == null) {
|
||||
println("⚠️ [JsonSanitizer] JSON 형식을 찾을 수 없습니다.")
|
||||
return "{}" // 빈 객체라도 반환하여 EOF 방지
|
||||
}
|
||||
|
||||
return match.trim()
|
||||
.removePrefix("```json")
|
||||
.removePrefix("```")
|
||||
.removeSuffix("```")
|
||||
@@ -264,13 +288,80 @@ object RagService {
|
||||
//// println(response)
|
||||
// return response.aiMessage().text()
|
||||
// }
|
||||
private const val LLM_API_URL = "http://127.0.0.1:8080/v1/chat/completions"
|
||||
|
||||
private suspend fun callLlamaWithSchema(prompt: String): String {
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// 문자열 치환 대신 안전한 JSON 객체 빌더 사용
|
||||
val requestBodyJson = buildJsonObject {
|
||||
put("model", "local-model")
|
||||
put("temperature", 0.1) // 0.1 유지 (결정론적 응답)
|
||||
put("top_p", 0.9)
|
||||
put("max_tokens", 500)
|
||||
|
||||
putJsonArray("messages") {
|
||||
addJsonObject {
|
||||
put("role", "system")
|
||||
put("content", "You are a helpful AI financial analyst. You must output responses ONLY in valid JSON format.")
|
||||
}
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
put("content", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 복잡한 json_schema를 지우고, 단순히 JSON 형식으로만 내보내라고 지시합니다.
|
||||
putJsonObject("response_format") {
|
||||
put("type", "json_object")
|
||||
}
|
||||
}.toString()
|
||||
println("requestBodyJson =>> $requestBodyJson")
|
||||
val request = Request.Builder()
|
||||
.url(LLM_API_URL)
|
||||
.post(requestBodyJson.toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
return kotlinx.coroutines.Dispatchers.IO.let {
|
||||
kotlinx.coroutines.withContext(it) {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Exception("LLM API Error: ${response.code} ${response.message}")
|
||||
|
||||
val responseBody = response.body?.string() ?: "{}"
|
||||
val json = Json.parseToJsonElement(responseBody).jsonObject
|
||||
json["choices"]?.jsonArray?.get(0)?.jsonObject?.get("message")?.jsonObject?.get("content")?.jsonPrimitive?.content ?: "{}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
suspend fun decideTrading(
|
||||
stockName: String,
|
||||
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."
|
||||
}
|
||||
|
||||
// 💡 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'."
|
||||
}
|
||||
|
||||
val prompt = """
|
||||
# Task: Senior AI Investment Analyst
|
||||
Analyze the stock '$stockName' and determine the final trading decision based on the data below.
|
||||
@@ -278,51 +369,52 @@ Analyze the stock '$stockName' and determine the final trading decision based on
|
||||
# Data
|
||||
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)}%
|
||||
3. News Context: ${tempDecision.newsContext?.take(400)} // 👈 뉴스 길이를 물리적으로 제한
|
||||
$newsDataSection
|
||||
|
||||
# Constraints
|
||||
1. 모든 점수와 confidence는 0에서 100 사이의 **정수(Integer)**로만 작성하십시오.
|
||||
- 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).
|
||||
- Synchronization: High scalping score + positive news momentum = Higher BUY confidence.
|
||||
- Output: Response ONLY in valid JSON format. No extra text.
|
||||
- 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)'.
|
||||
|
||||
# Output JSON Format (Reason must be in Korean)
|
||||
# Example Output JSON Format
|
||||
{
|
||||
"ultraShortScore": ${scores.ultraShort},
|
||||
"shortTermScore": ${scores.shortTerm},
|
||||
"midTermScore": ${scores.midTerm},
|
||||
"longTermScore": ${scores.longTerm},
|
||||
"decision": "BUY/SELL/HOLD",
|
||||
"reason": "재무와 뉴스를 대조한 분석 결과 (한국어)",
|
||||
"confidence": 0~100
|
||||
"ultraShortScore": 0,
|
||||
"shortTermScore": 0,
|
||||
"midTermScore": 0,
|
||||
"longTermScore": 0,
|
||||
"decision": "HOLD",
|
||||
"reason": "적자 지속 및 네수파립 임상 대기 중으로 관망 필요",
|
||||
"confidence": 50
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = chatModel.chat(UserMessage.from(prompt))
|
||||
val rawResponse = response.aiMessage().text()
|
||||
val jsonResponse = JsonSanitizer.formatJson(rawResponse)
|
||||
// println("📥 [AI Raw JSON]:\n$jsonResponse")
|
||||
|
||||
// 2. 유연한 파서 설정 (소수점 및 예외 상황 대응)
|
||||
val lenientJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
|
||||
// JSON 파싱 (Kotlinx Serialization 활용)
|
||||
return try {
|
||||
// println(jsonResponse)
|
||||
val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse)
|
||||
decision.financialData = tempDecision.financialData
|
||||
decision.newsContext = tempDecision.newsContext
|
||||
decision.techSummary = tempDecision.techSummary
|
||||
decision.stockCode = tempDecision.stockCode
|
||||
decision.corpName = tempDecision.corpName
|
||||
decision.stockName = tempDecision.stockName
|
||||
val rawResponse = callLlamaWithSchema(prompt)
|
||||
println("📥 [AI Strict JSON]:\n$rawResponse")
|
||||
|
||||
// 엄격한 스키마가 적용되었으므로 JsonSanitizer 없이 바로 파싱 가능
|
||||
val lenientJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
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: dev.langchain4j.exception.InternalServerException) {
|
||||
} catch (e: InternalServerException) {
|
||||
// 서버 에러 (컨텍스트 초과 등) 발생 시 로그 남기고 null 반환 혹은 커스텀 에러 처리
|
||||
println("🚨 [AI Server Error] ${e.message}")
|
||||
if (e.message?.contains("Context size") == true) {
|
||||
|
||||
Reference in New Issue
Block a user