// 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.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.FinancialMapper import network.FinancialStatement import network.NewsService import org.apache.lucene.store.MMapDirectory import service.FinancialAnalyzer import service.InvestmentScores 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") .apiKey("unused") .build() 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() private val embeddingStore: LuceneEmbeddingStore by lazy { val path = Paths.get("db/lucene_idx") // FSDirectory.open(path)도 가능하지만, 64bit 시스템(Mac)에선 MMapDirectory가 가장 빠릅니다. val directory = MMapDirectory(path) // 제공해주신 소스의 Builder 사용 LuceneEmbeddingStore.builder() .directory(directory) .build() } fun active() { // println("[Cache] Active") if (UrlCacheManager.isInitialized()) return // println("[Cache] initialize") UrlCacheManager.initialize(embeddingStore, embeddingModel) } /** * 텍스트를 임베딩하여 H2 DB에 저장합니다. */ fun ingestWithChunking( text: String, newsLink: String = "", pubDate: String = "", stcokName: String, corpCode: String, corpName: String, stockCode: String ) { val MAX_CHUNK_SIZE = 500 // 안전하게 500자 내외로 설정 // 1. 문단 단위로 먼저 분리 val paragraphs = text.split(Regex("\n\n+")) val chunks = mutableListOf() var currentChunk = StringBuilder() 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}개의 청크로 인덱싱 완료") } 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(currentPrice : Double , technicalAnalyzer: TechnicalAnalyzer,stockName: String,stockCode: String,result : TradingDecisionCallback) { // 1. 10분간의 데이터 가져오기 (API 호출) coroutineScope { try { var tradingDecision: TradingDecision = TradingDecision() tradingDecision.stockCode = stockCode tradingDecision.analyzer = technicalAnalyzer tradingDecision.currentPrice = currentPrice var corpInfo = DartCodeManager.getCorpCode(stockCode) corpInfo?.stockName = stockName tradingDecision.stockName = stockName tradingDecision.corpName = corpInfo?.cName ?: "" val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } tradingDecision.financialData = financialDataDeferred.await() val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "") if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) { corpInfo?.let { try { NewsService.fetchAndIngestNews(it) } catch (e: Exception) {} } val financialScore = FinancialAnalyzer.calculateScore(financialStmt) val scores = technicalAnalyzer.calculateScores(financialScore) result(tradingDecision, false) tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport() result(tradingDecision, false) val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" val questionEmbedding = embeddingModel.embed(question).content() val searchResult = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .maxResults(3) .build() ) tradingDecision.newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } result(tradingDecision, false) result(decideTrading(stockCode, scores,financialStmt,tradingDecision), true) } else { result(tradingDecision, false) } }catch (e: Exception) { e.printStackTrace() } } } 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 답변을 생성합니다. */ fun askWithContext(question: String, corpInfo: String, financialData: String, days : List, weeks : List, monthly : List): String { val questionEmbedding = embeddingModel.embed(question).content() val searchResult = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .maxResults(5) .build() ) val newsContext = searchResult.matches().joinToString("\n") { it.embedded().text() } // 2. 종합 분석 프롬프트 구성 val finalPrompt = """ <|begin_of_text|><|start_header_id|>system<|end_header_id|> 당신은 뉴스(심리), 재무(본질), 차트(추세)를 통합 분석하는 'AI 수석 애널리스트'입니다. 제공된 데이터를 바탕으로 아래 형식을 엄격히 지켜 분석 리포트를 작성하세요. [데이터 세트] 1. 기업 기본 정보: $corpInfo 2. 재무 성장성: $financialData 3. 기술적 추세: ${monthly}, ${weeks}, ${days} 4. 최신 이슈(뉴스): $newsContext [분석 요청 사항] 1. **업계 상황**: 해당 종목이 속한 업종의 현재 전체적인 흐름을 먼저 정리하세요. 2. **종목 이슈 분석**: 뉴스에서 포착된 핵심 키워드와 시장의 반응을 요약하세요. 3. **장기/단기 전략**: - 장기(재무/월봉 기반): 추천 혹은 비추천 사유 - 단기(뉴스/일봉 기반): 추천 혹은 비추천 사유 4. **최종 결론**: '매수/관망/매도' 의견과 그에 따른 근거를 단호하게 제시하세요. <|eot_id|> <|start_header_id|>user<|end_header_id|> 질문: $question <|eot_id|><|start_header_id|>assistant<|end_header_id|> """.trimIndent() val response = chatModel.chat(UserMessage.from(finalPrompt)) // println(response) return response.aiMessage().text() } suspend fun decideTrading( stockName: String, scores: InvestmentScores, // 직접 계산한 점수 객체 financialStmt: FinancialStatement, // 매핑된 재무 수치 객체 tempDecision: TradingDecision ): TradingDecision? { val prompt = """ <|begin_of_text|><|start_header_id|>system<|end_header_id|> 당신은 정량적 수치와 정성적 뉴스를 통합 분석하는 'AI 수석 애널리스트'입니다. 시스템이 계산한 지표 점수와 실제 재무제표 요약본을 바탕으로 최종 매매 전략을 수립하십시오. [종목 정보] - 종목명: $stockName [1. 시스템 산출 스코어 (0-100)] - 초단기(Scalping): ${scores.ultraShort} - 단기(Daily): ${scores.shortTerm} - 중기(Weekly): ${scores.midTerm} - 장기(Monthly): ${scores.longTerm} [2. 핵심 재무제표 요약] - 영업이익: ${if(financialStmt.isOperatingProfitPositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.operatingProfitGrowth)}%) - 당기순이익: ${if(financialStmt.isNetIncomePositive) "흑자" else "적자"} (성장률: ${"%.2f".format(financialStmt.netIncomeGrowth)}%) - 수익성(ROE): ${"%.2f".format(financialStmt.roe)}% - 안정성(부채비율): ${"%.2f".format(financialStmt.debtRatio)}% - 유동성(당좌비율): ${"%.2f".format(financialStmt.quickRatio)}% [3. 시장 심리 및 뉴스 컨텍스트] ${tempDecision.newsContext} [분석 지침] 1. **재무-뉴스 정합성**: 재무제표상 영업이익이 적자임에도 뉴스가 장기적 장밋빛 전망만 내놓는다면 '신중(HOLD)' 의견을 제시하십시오. 2. **기술-심리 동기화**: 초단기 점수가 높고 뉴스에서 수급 급증 키워드가 포착되면 'BUY' 신뢰도를 높이십시오. 3. **종합 결정**: 모든 수치와 컨텍스트를 고려하여 최종 Decision을 내리고, 그 근거를 핵심만 기술하십시오. [응답 지침] - JSON 데이터만 출력하십시오. 설명이나 서론은 생략합니다. - 반드시 아래 형식을 엄격히 준수하십시오. { "ultraShortScore": ${scores.ultraShort}, "shortTermScore": ${scores.shortTerm}, "midTermScore": ${scores.midTerm}, "longTermScore": ${scores.longTerm}, "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 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(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 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 } } } @Serializable class TradingDecision { var corpName : String = "" var stockName : String = "" val ultraShortScore: Double = 0.0 // 초단기 (분봉/에너지) val shortTermScore: Double = 0.0 // 단기 (일봉/뉴스) val midTermScore: Double = 0.0 // 중기 (주봉/재무) val longTermScore: Double = 0.0 // [추가] 화면 전환용 종목명 var currentPrice: Double = 0.0 var stockCode: String = "" var decision: String? = null var reason: String? = null var confidence: Double = 0.0 var techSummary : String? = null var newsContext : String? = null var financialData : String? = null var analyzer : TechnicalAnalyzer? = null fun shortPossible() = listOf(ultraShortScore, shortTermScore).average() fun profitPossible() = listOf(ultraShortScore, shortTermScore, midTermScore, longTermScore).average() fun safePossible() = listOf( midTermScore, longTermScore).average() override fun toString(): String { return """ $corpName($stockName) 수익실현 가능성 : ${profitPossible()} ultraShortScore :$ultraShortScore shortTermScore :$shortTermScore midTermScore :$midTermScore longTermScore :$longTermScore decision: $decision reason: $reason confidence: $confidence 기술 분석: $techSummary 뉴스: $newsContext 재무재표: $financialData """.trimIndent() } }