....
This commit is contained in:
@@ -7,22 +7,23 @@ import dev.langchain4j.data.segment.TextSegment
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel
|
||||
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
|
||||
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
|
||||
import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import model.CandleData
|
||||
import network.DartCodeManager
|
||||
import network.KisTradeService
|
||||
import network.NewsService
|
||||
import org.apache.lucene.store.MMapDirectory
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import service.TechnicalAnalyzer
|
||||
import service.TradingDecisionCallback
|
||||
import service.UrlCacheManager
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
|
||||
object RagService {
|
||||
|
||||
// 임베딩 모델 (8081) 및 채팅 모델 (8080) 설정
|
||||
private val embeddingModel = OpenAiEmbeddingModel.builder()
|
||||
.baseUrl("http://127.0.0.1:8081/v1")
|
||||
@@ -32,6 +33,7 @@ object RagService {
|
||||
private val chatModel = OpenAiChatModel.builder()
|
||||
.baseUrl("http://127.0.0.1:8080/v1")
|
||||
.apiKey("unused")
|
||||
.temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.build()
|
||||
|
||||
@@ -45,40 +47,104 @@ object RagService {
|
||||
LuceneEmbeddingStore.builder()
|
||||
.directory(directory)
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun active() {
|
||||
println("[Cache] Active")
|
||||
if (UrlCacheManager.isInitialized()) return
|
||||
println("[Cache] initialize")
|
||||
UrlCacheManager.initialize(embeddingStore, embeddingModel)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
|
||||
*/
|
||||
fun ingest(text: String, newsLink: String = "", pubDate: String = "") {
|
||||
// 소스 코드의 TextSegment 구조에 맞춰 메타데이터 생성
|
||||
val metadata = Metadata()
|
||||
metadata.put("link", newsLink)
|
||||
metadata.put("date", pubDate)
|
||||
fun ingestWithChunking(
|
||||
text: String,
|
||||
newsLink: String = "",
|
||||
pubDate: String = "",
|
||||
stcokName: String,
|
||||
corpCode: String,
|
||||
corpName: String,
|
||||
stockCode: String
|
||||
) {
|
||||
val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정
|
||||
|
||||
// TextSegment.from(text, metadata) 팩토리 메서드 활용
|
||||
val segment = TextSegment.from(text, metadata)
|
||||
val embedding = embeddingModel.embed(segment).content()
|
||||
// 1. 문단 단위로 먼저 분리
|
||||
val paragraphs = text.split(Regex("\n\n+"))
|
||||
val chunks = mutableListOf<String>()
|
||||
var currentChunk = StringBuilder()
|
||||
|
||||
// LuceneEmbeddingStore.add(Embedding, TextSegment) 호출
|
||||
embeddingStore.add(embedding, segment)
|
||||
println("🔎 [Lucene] 인덱싱 성공: ${text.take(20)}...")
|
||||
for (para in paragraphs) {
|
||||
// 현재 청크에 문단을 더했을 때 제한을 넘으면 지금까지의 내용을 확정
|
||||
if (currentChunk.length + para.length > MAX_CHUNK_SIZE && currentChunk.isNotEmpty()) {
|
||||
chunks.add(currentChunk.toString().trim())
|
||||
currentChunk = StringBuilder()
|
||||
}
|
||||
currentChunk.append(para).append("\n\n")
|
||||
|
||||
// 문단 하나 자체가 너무 긴 경우 글자 수로 강제 분할
|
||||
if (currentChunk.length > MAX_CHUNK_SIZE) {
|
||||
val longPara = currentChunk.toString()
|
||||
longPara.chunked(MAX_CHUNK_SIZE).forEach { chunks.add(it.trim()) }
|
||||
currentChunk = StringBuilder()
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk.isNotEmpty()) chunks.add(currentChunk.toString().trim())
|
||||
|
||||
// 2. 쪼개진 각 청크를 루씬에 개별 임베딩하여 저장
|
||||
chunks.forEachIndexed { index, chunk ->
|
||||
if (chunk.length > 10) { // 너무 짧은 노이즈 제외
|
||||
val metadata = Metadata()
|
||||
metadata.put("link", newsLink)
|
||||
metadata.put("date", pubDate)
|
||||
metadata.put("chunk_idx", index) // 순서 정보 유지
|
||||
metadata.put("stcokName",stcokName)
|
||||
metadata.put("corpCode",corpCode)
|
||||
metadata.put("corpName",corpName)
|
||||
metadata.put("stockCode",stockCode)
|
||||
|
||||
val segment = TextSegment.from(chunk, metadata)
|
||||
val embedding = embeddingModel.embed(segment).content()
|
||||
embeddingStore.add(embedding, segment)
|
||||
}
|
||||
}
|
||||
println("🔎 [Lucene] ${chunks.size}개의 청크로 인덱싱 완료")
|
||||
}
|
||||
|
||||
suspend fun processStock(stockCode: String,result :(String, Boolean)->Unit,decide : (String,TradingDecision?)->Unit) {
|
||||
object JsonSanitizer {
|
||||
fun formatJson(raw: String): String {
|
||||
val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL)
|
||||
return raw.trim()
|
||||
.removePrefix("```json")
|
||||
.removePrefix("```")
|
||||
.removeSuffix("```")
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun processStock(stockName: String,stockCode: String,result : TradingDecisionCallback) {
|
||||
// 1. 10분간의 데이터 가져오기 (API 호출)
|
||||
coroutineScope {
|
||||
var tradingDecision : TradingDecision = TradingDecision()
|
||||
val corpCode = DartCodeManager.getCorpCode(stockCode)
|
||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpCode) }
|
||||
tradingDecision.stockCode = stockCode
|
||||
var corpInfo = DartCodeManager.getCorpCode(stockCode)
|
||||
corpInfo?.stockName = stockName
|
||||
corpInfo?.let { NewsService.fetchAndIngestNews(it) }
|
||||
|
||||
val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") }
|
||||
|
||||
tradingDecision.financialData = financialDataDeferred.await()
|
||||
result(tradingDecision.toString(),false)
|
||||
result(tradingDecision,false)
|
||||
|
||||
tradingDecision.techSummary = TechnicalAnalyzer.generateComprehensiveReport()
|
||||
result(tradingDecision.toString(),false)
|
||||
result(tradingDecision,false)
|
||||
|
||||
val question = "$stockCode 종목의 현재 주가 흐름과 뉴스, 재무 실적을 바탕으로 종합 투자 전략을 세워줘."
|
||||
val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스"
|
||||
val questionEmbedding = embeddingModel.embed(question).content()
|
||||
val searchResult = embeddingStore.search(
|
||||
EmbeddingSearchRequest.builder()
|
||||
@@ -87,12 +153,28 @@ object RagService {
|
||||
.build()
|
||||
)
|
||||
tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() }
|
||||
result(tradingDecision.toString(),false)
|
||||
decide(stockCode,decideTrading(stockCode, tradingDecision.techSummary ?: "", tradingDecision.newsContext ?: "",tradingDecision.financialData ?: ""))
|
||||
result(tradingDecision,false)
|
||||
result(decideTrading(stockCode, tradingDecision),true)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun isUrlAlreadyIndexed(url: String): Boolean {
|
||||
// 1. 메타데이터의 'link' 필드가 해당 URL과 일치하는지 필터 구성
|
||||
val filter = MetadataFilterBuilder.metadataKey("link").isEqualTo(url)
|
||||
|
||||
// 2. 검색 요청 생성 (벡터 유사도와 상관없이 필터 조건에 맞는 것 1개만 찾음)
|
||||
// 주의: 인터페이스에 따라 더미 벡터(0,0,...)가 필요할 수 있습니다.
|
||||
val searchRequest = EmbeddingSearchRequest.builder()
|
||||
.filter(filter)
|
||||
.maxResults(1)
|
||||
.build()
|
||||
|
||||
val result = embeddingStore.search(searchRequest)
|
||||
|
||||
// 결과가 비어있지 않다면 이미 저장된 URL입니다.
|
||||
return result.matches().isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
|
||||
@@ -144,57 +226,80 @@ object RagService {
|
||||
|
||||
suspend fun decideTrading(
|
||||
stockName: String,
|
||||
techSummary: String,
|
||||
newsContext: String,
|
||||
financialData: String
|
||||
tempDecision: TradingDecision
|
||||
): TradingDecision? {
|
||||
val prompt = """
|
||||
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
||||
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 단기 데이트레이딩 전문가이자 전문 애널리스트입니다.
|
||||
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
|
||||
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
||||
|
||||
[데이터 요약]
|
||||
- 종목: $stockName
|
||||
$techSummary
|
||||
- 기업/재무: $financialData
|
||||
- 시장 심리: $newsContext
|
||||
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
||||
당신은 수치 기반의 '정량 분석(Quantitative Analysis)' 트레이딩 전문가이자 전문 애널리스트입니다.
|
||||
제공된 데이터를 바탕으로 투자 기간별 스코어를 산출하고 최종 매매 결정을 내리십시오.
|
||||
아래 데이터를 분석하여 '매수', '매도', '관망' 중 하나를 결정하세요.
|
||||
|
||||
[데이터 요약]
|
||||
- 종목: $stockName
|
||||
- 분석: ${tempDecision.techSummary}
|
||||
- 기업/재무: ${tempDecision.financialData}
|
||||
- 시장 심리: ${tempDecision.newsContext}
|
||||
|
||||
[스코어 산출 가이드 (0-100)]
|
||||
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
|
||||
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
|
||||
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
|
||||
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
|
||||
[스코어 산출 가이드 (0-100)]
|
||||
1. 초단기: 30분봉 추세, MFI, OBV 에너지가 일치하면 80점 이상.
|
||||
2. 단기: 일봉 이평선 정배열 및 3일 변동률 양수일 때 70점 이상.
|
||||
3. 중기: 주봉 추세와 재무 성장성(매출/영익)이 동반 상승 시 75점 이상.
|
||||
4. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단.
|
||||
|
||||
[응답 형식]
|
||||
반드시 아래 JSON 형식으로만 답변하십시오:
|
||||
{
|
||||
"ultraShortScore": (숫자),
|
||||
"shortTermScore": (숫자),
|
||||
"midTermScore": (숫자),
|
||||
"longTermScore": (숫자),
|
||||
"decision": "BUY" | "SELL" | "HOLD",
|
||||
"reason": "결정적 근거 한 줄",
|
||||
"confidence": 0~100
|
||||
}
|
||||
<|eot_id|>
|
||||
<|start_header_id|>user<|end_header_id|>
|
||||
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
|
||||
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
||||
[응답 지침 - 엄격 준수]
|
||||
1. 분석 내용에 대한 설명, 서론, 결론을 절대 작성하지 마십시오.
|
||||
2. 오직 JSON 데이터만 출력하십시오.
|
||||
3. JSON 외의 텍스트가 포함될 경우 시스템이 중단됩니다.
|
||||
4. 응답은 반드시 '{' 문자로 시작하여 '}' 문자로 끝나야 합니다.
|
||||
[응답 형식]
|
||||
반드시 아래 JSON 형식으로만 답변하십시오:
|
||||
{
|
||||
"ultraShortScore": (숫자),
|
||||
"shortTermScore": (숫자),
|
||||
"midTermScore": (숫자),
|
||||
"longTermScore": (숫자),
|
||||
"decision": "BUY" | "SELL" | "HOLD",
|
||||
"reason": "결정적 근거 한 줄",
|
||||
"confidence": 0~100
|
||||
}
|
||||
<|eot_id|>
|
||||
<|start_header_id|>user<|end_header_id|>
|
||||
모든 데이터를 종합하여 스코어링 리포트를 작성하십시오.
|
||||
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
||||
""".trimIndent()
|
||||
|
||||
val response = chatModel.chat(UserMessage.from(prompt))
|
||||
val jsonResponse = response.aiMessage().text()
|
||||
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 = Json.decodeFromString<TradingDecision>(jsonResponse)
|
||||
decision.financialData = financialData
|
||||
decision.newsContext = newsContext
|
||||
decision.techSummary = techSummary
|
||||
val decision = lenientJson.decodeFromString<TradingDecision>(jsonResponse)
|
||||
decision.financialData = tempDecision.financialData
|
||||
decision.newsContext = tempDecision.newsContext
|
||||
decision.techSummary = tempDecision.techSummary
|
||||
decision.stockCode = tempDecision.stockCode
|
||||
decision
|
||||
} catch (e: dev.langchain4j.exception.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
|
||||
}
|
||||
}
|
||||
@@ -203,18 +308,28 @@ object RagService {
|
||||
}
|
||||
@Serializable
|
||||
class TradingDecision {
|
||||
val ultraShortScore: Int = 0 // 초단기 (분봉/에너지)
|
||||
val shortTermScore: Int = 0 // 단기 (일봉/뉴스)
|
||||
val midTermScore: Int = 0 // 중기 (주봉/재무)
|
||||
val longTermScore: Int = 0
|
||||
|
||||
val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지)
|
||||
val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스)
|
||||
val midTermScore: Double = 0.0 // 중기 (주봉/재무)
|
||||
val longTermScore: Double = 0.0
|
||||
var stockCode: String = ""
|
||||
var decision: String? = null
|
||||
var reason: String? = null
|
||||
var confidence: Int = 0
|
||||
var confidence: Double = 0.0
|
||||
var techSummary : String? = null
|
||||
var newsContext : String? = null
|
||||
var financialData : String? = null
|
||||
|
||||
fun profitPossible() =
|
||||
listOf<Double>(ultraShortScore,
|
||||
shortTermScore,
|
||||
midTermScore,
|
||||
longTermScore).average()
|
||||
|
||||
override fun toString(): String {
|
||||
return """
|
||||
수익실현 가능성 : ${profitPossible()}
|
||||
ultraShortScore :$ultraShortScore
|
||||
shortTermScore :$shortTermScore
|
||||
midTermScore :$midTermScore
|
||||
@@ -222,9 +337,9 @@ longTermScore :$longTermScore
|
||||
decision: $decision
|
||||
reason: $reason
|
||||
confidence: $confidence
|
||||
techSummary: $techSummary
|
||||
newsContext: $newsContext
|
||||
financialData: $financialData
|
||||
기술 분석: $techSummary
|
||||
뉴스: $newsContext
|
||||
재무재표: $financialData
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user