// 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.NewsService import org.apache.lucene.store.MMapDirectory 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(technicalAnalyzer: TechnicalAnalyzer,stockName: String,stockCode: String,result : TradingDecisionCallback) { // 1. 10분간의 데이터 가져오기 (API 호출) coroutineScope { try { var tradingDecision: TradingDecision = TradingDecision() tradingDecision.stockCode = stockCode var corpInfo = DartCodeManager.getCorpCode(stockCode) corpInfo?.stockName = stockName tradingDecision.stockName = stockName tradingDecision.corpName = corpInfo?.cName ?: "" corpInfo?.let { NewsService.fetchAndIngestNews(it) } val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } tradingDecision.financialData = financialDataDeferred.await() 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, tradingDecision), true) }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, tempDecision: TradingDecision ): TradingDecision? { val prompt = """ <|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. 장기: 월봉 위치와 기업의 근본적인 시장 지배력 기반 판단. [응답 지침 - 엄격 준수] 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 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 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() } }