// 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 interface TradingAnalyst { @dev.langchain4j.service.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. """) fun analyzeStock(@dev.langchain4j.service.UserMessage prompt: String): TradingDecision } 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)) .frequencyPenalty(1.1) .maxTokens(500) // 👈 루프 방지를 위해 반드시 짧게 제한! // 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요 .responseFormat("json_object") .build() private val analyst = dev.langchain4j.service.AiServices.builder(TradingAnalyst::class.java) .chatModel(chatModel) .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 = """ # Task: Senior AI Investment Analyst Analyze the stock '$stockName' and determine the final trading decision based on the data below. # 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)} // 👈 뉴스 길이를 물리적으로 제한 # Constraints 1. 모든 점수와 confidence는 0에서 100 사이의 **정수(Integer)**로만 작성하십시오. - 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. # Output JSON Format (Reason must be in Korean) { "ultraShortScore": ${scores.ultraShort}, "shortTermScore": ${scores.shortTerm}, "midTermScore": ${scores.midTerm}, "longTermScore": ${scores.longTerm}, "decision": "BUY/SELL/HOLD", "reason": "재무와 뉴스를 대조한 분석 결과 (한국어)", "confidence": 0~100 } """.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() } }