package network// src/main/kotlin/network/RagService.kt import Defines.EMBEDDING_PORT import Defines.LLM_PORT import TradingLogStore import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore import dev.langchain4j.data.document.Metadata 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.delay import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json 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 model.ConfigIndex import model.KisSession import model.RankingStock 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.AutoTradingManager import service.FinancialAnalyzer import service.InvestmentScores import service.TechnicalAnalyzer import service.TradingDecisionCallback import service.UrlCacheManager import java.nio.file.Paths import java.time.Duration import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import java.util.Locale import java.util.concurrent.TimeUnit //interface TradingAnalyst { // @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:${EMBEDDING_PORT}/v1") .apiKey("unused") .build() private val chatModel = OpenAiChatModel.builder() .baseUrl("http://127.0.0.1:${LLM_PORT}/v1") .apiKey("unused") .temperature(0.0) // [중요] 0.0으로 설정하여 결정론적 응답 유도 .timeout(Duration.ofSeconds(60)) // .frequencyPenalty(1.1) .maxTokens(400) // 👈 루프 방지를 위해 반드시 짧게 제한! // 1.x 버전에서는 responseFormat이 아래처럼 바뀔 수 있으니 체크하세요 .responseFormat("json_object") .build() // private val analyst = 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 { // 실제 응답 로그 출력 (디버깅용) println("📥 [AI Raw Response]:\n$raw") val regex = Regex("""\{.*\}""", RegexOption.DOT_MATCHES_ALL) val match = regex.find(raw)?.value if (match == null) { println("⚠️ [JsonSanitizer] JSON 형식을 찾을 수 없습니다.") return "{}" // 빈 객체라도 반환하여 EOF 방지 } return match.trim() .removePrefix("```json") .removePrefix("```") .removeSuffix("```") .trim() } } private fun isRecentNews(dateStr: String?, maxDays: Long = 3): Boolean { if (dateStr.isNullOrBlank()) return false return try { // 네이버 뉴스 OpenAPI 기본 포맷: "Mon, 06 Apr 2026 12:00:00 +0900" val formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) val pubDate = ZonedDateTime.parse(dateStr, formatter) val now = ZonedDateTime.now() // 뉴스가 미래로 표기된 경우도 대비하여 절대값 처리 Math.abs(ChronoUnit.DAYS.between(pubDate, now)) <= maxDays } catch (e: Exception) { // 다른 날짜 포맷(예: "yyyy.MM.dd")으로 들어오는 경우를 위한 Fallback try { val fallbackFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ENGLISH) val pubDate = ZonedDateTime.parse("$dateStr 00:00 +0900", fallbackFormatter) Math.abs(ChronoUnit.DAYS.between(pubDate, ZonedDateTime.now())) <= maxDays } catch (e2: Exception) { false // 날짜 파싱 실패 시 보수적으로 '오래된 뉴스'로 취급하여 스크래핑 유도 } } } suspend fun processStock(currentPrice: Double, technicalAnalyzer: TechnicalAnalyzer, stockName: String, stockCode: String, result: TradingDecisionCallback) { val totalStartTime = System.currentTimeMillis() // 전체 시작 시간 coroutineScope { try { var 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 ?: "" // 1. 재무 데이터 가져오기 시간 측정 val financialStartTime = System.currentTimeMillis() val financialDataDeferred = async { NewsService.fetchFinancialGrowth(corpInfo?.cCode ?: "") } tradingDecision.financialData = financialDataDeferred.await() val financialStmt = FinancialMapper.mapRawTextToStatement(tradingDecision.financialData ?: "") val financialDuration = System.currentTimeMillis() - financialStartTime println("⏱️ [$stockName] 재무 분석 소요: ${financialDuration}ms") if (FinancialAnalyzer.isSafetyBeltMet(financialStmt)) { // 3. 기술적 지표 계산 시간 측정 val techStartTime = System.currentTimeMillis() val financialScore = FinancialAnalyzer.calculateScore(financialStmt) val scores = technicalAnalyzer.calculateScores(financialScore) val techDuration = System.currentTimeMillis() - techStartTime println("⏱️ [$stockName] 기술적 지표 계산 소요: ${techDuration}ms") val guideLine = KisSession.config.getValues(ConfigIndex.MIN_PURCHASE_SCORE_INDEX) if (scores.avg() > (guideLine.times(0.50))) { // 2. 뉴스 스크래핑 및 학습 시간 측정 val ragStartTime = System.currentTimeMillis() val question = "${corpInfo?.cName} $stockName[$stockCode]의 향후 실적 전망과 관련된 핵심 뉴스" val questionEmbedding = embeddingModel.embed(question).content() // --- 💡 [수정됨] 2. 해당 주식의 최신 뉴스 존재 여부 확인 (최대 10개) --- val preSearchResult = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 해당 종목만 필터링 .maxResults(10) .minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정 .build() ) // 검색된 청크들 중 최근 3일 이내의 날짜를 가진 데이터가 하나라도 있는지 확인 val hasRecentData = preSearchResult.matches().any { match -> val pubDate = match.embedded().metadata().getString("date") isRecentNews(pubDate, maxDays = 1) } // --- 💡 [수정됨] 3. 최신 데이터가 없을 때만 브라우저 스크래핑(Playwright) 실행 --- val newsIngestStartTime = System.currentTimeMillis() if (!hasRecentData) { println("🌐 [$stockName] 최근 3일 내 뉴스가 없습니다. 새 뉴스를 스크래핑합니다.") corpInfo?.let { try { NewsService.fetchAndIngestNews(it) } catch (e: Exception) { println("❌ [$stockName] 뉴스 스크래핑 실패: ${e.message}") } } } else { println("✅ [$stockName] 최근 3일 내 뉴스가 DB에 존재하여 브라우저 스크래핑을 생략합니다.") } val newsIngestDuration = System.currentTimeMillis() - newsIngestStartTime println("⏱️ [$stockName] 뉴스 수집/인덱싱 판단 소요: ${newsIngestDuration}ms") result(tradingDecision, false) tradingDecision.techSummary = technicalAnalyzer.generateComprehensiveReport() result(tradingDecision, false) // --- 💡 [수정됨] 4. 최종 문맥(Context) 추출 --- // (만약 위에서 스크래핑을 새로 했다면 최신 데이터가 포함되어 검색됩니다) val finalSearchResult = embeddingStore.search( EmbeddingSearchRequest.builder() .queryEmbedding(questionEmbedding) .filter(MetadataFilterBuilder.metadataKey("stockCode").isEqualTo(stockCode)) // 교차 오염 방지를 위해 필터 필수 .maxResults(3) .minScore(0.3) // 👈 0.70은 너무 엄격할 수 있으니 0.65로 하향 조정 .build() ) println("🔎 [$stockName] RAG 검색된 문서 개수: ${finalSearchResult.matches().size}개") finalSearchResult.matches().forEach { match -> println("📊 [RAG Score: ${match.score()}] 본문: ${match.embedded().text().replace("\n", " ").take(50)}...") } tradingDecision.newsContext = finalSearchResult.matches().joinToString("\n") { it.embedded().text() } val ragDuration = System.currentTimeMillis() - ragStartTime println("⏱️ [$stockName] RAG 뉴스 검색 소요: ${ragDuration}ms") result(tradingDecision, false) TradingLogStore.addAnalyzer(stockName, stockCode, "${FinancialAnalyzer.toString(financialStmt)}${scores.toString()}", true) // 5. AI 최종 결정(LLM 호출) 시간 측정 val aiDecisionStartTime = System.currentTimeMillis() val finalDecision = decideTrading(stockCode, scores, financialStmt, tradingDecision) val aiDecisionDuration = System.currentTimeMillis() - aiDecisionStartTime println("⏱️ [$stockName] AI 최종 판단 소요: ${aiDecisionDuration}ms") val totalDuration = System.currentTimeMillis() - totalStartTime println("✅ [$stockName] 전체 분석 완료 총 소요: ${totalDuration}ms") // 상세 로그 남기기 TradingLogStore.addAnalyzer(stockName, stockCode, "분석시간 상세: 재무(${financialDuration}ms), 뉴스(${newsIngestDuration}ms), RAG(${ragDuration}ms), AI(${aiDecisionDuration}ms), 전체 분석 완료(${totalDuration}ms)", true) result(finalDecision, true) } else { println("✋ [$stockName] 기술 점수 미달로 분석 중단 ${scores.toString()}") TradingLogStore.addAnalyzer(stockName, stockCode, "기술 점수 미달로 분석 중단") if (FinancialAnalyzer.isBuyConsiderationMet(financialStmt)) { TradingLogStore.addLog(tradingDecision,"WATCH","우량주로 판단되나 거래량 혹은 최근 거래 점수 미달로 재분석 대상에 추가") AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = stockCode,hts_kor_isnm = stockName)) } tradingDecision.confidence = 1.0 result(tradingDecision, false) } } else { println("🚨 [$stockName] ${FinancialAnalyzer.toString(financialStmt)} 재무 안전벨트 미달") TradingLogStore.addAnalyzer(stockName, stockCode, "재무 안전벨트 미달로 분석 중단 ${FinancialAnalyzer.toString(financialStmt)}") tradingDecision.confidence = 1.0 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() } private fun LLM_API_URL() = "http://127.0.0.1:$LLM_PORT/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) // 💡 루프 탈출을 위해 더 과감하게 설정 put("top_p", 0.85) // 💡 추가 put("top_k", 40) // 💡 추가 (서버에서 지원할 경우) // put("frequency_penalty", 0.7) // 💡 반복 단어 억제 강화 // put("presence_penalty", 0.5) put("max_tokens", 400) 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? { var retryCount = 0 val maxRetries = 2 while (retryCount <= maxRetries) { // 1. 뉴스 데이터가 100자 이상일 때만 유효한 것으로 판단 val validNews = tempDecision.newsContext?.takeIf { it.trim().length >= 100 }?.take(400) // 2. 뉴스 유무에 따른 동적 데이터 섹션 구성 val newsDataSection = if (validNews != null) { "4. News Context: $validNews" } else { "4. News Context: No significant news available. Rely on financials." } val prompt = """ # Task: Senior AI Investment Analyst Your goal is to provide a final trading decision based on STRICT data analysis. # Data (SOURCE OF TRUTH) 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. Technical Analysis Summary: ${tempDecision.techSummary ?: "No technical summary available."} $newsDataSection # Step-by-Step Analysis Logic 1. Financial Review: First, evaluate the 'Financials' section for long-term stability. 2. News Verification: Second, check 'News Context' (if available) for immediate market sentiment or specific issues. 3. Synthesis: Finalize the 'decision' (BUY, SELL, HOLD) by combining the Financials and News analysis. 4. Confidence: Assign a confidence score (0-100) based on how clearly the data points to the decision. # Confidence Scoring Guide (CRITICAL) Assign the 'confidence' score based on these rules: - 80-100: When Financials are strong AND News Context clearly supports the trend. - 50-79: When Financials are stable but News is neutral or missing. - 10-49: When Financials and News contradict each other. - 1-9: Reserved ONLY for extreme data corruption. - NEVER output 0 unless the data is completely unreadable. Even a weak guess should be at least 10. # Strict Constraints - SCORE INTEGRITY: You MUST copy the 'System Scores' into the output JSON exactly as provided. NO TRANSFORMATION. - REASON LENGTH: The "reason" field MUST be written in KOREAN and MUST be between 10 to 50 characters. - JSON ONLY: Output ONLY a valid JSON object. No markdown, no pre-text, no post-text. # Output JSON Structure (STRICT NAMES) { "ultraShortScore": ${scores.ultraShort}, "shortTermScore": ${scores.shortTerm}, "midTermScore": ${scores.midTerm}, "longTermScore": ${scores.longTerm}, "decision": "HOLD", "reason": "10자 이상 50자 이내의 한국어 분석 결과", "confidence": 0 } """.trimIndent() try { val rawResponse = callLlamaWithSchema(prompt) // 환각 및 루프 검사 println("rawResponse $rawResponse") val sanitized = rawResponse.trim().removeSurrounding("```json", "```").trim() val decision = Json { ignoreUnknownKeys = true; isLenient = true }.decodeFromString(sanitized) // 2. 사유 길이 및 데이터 정합성 검증 (사용자 요청 반영) val reasonLen = decision.reason?.length ?: 0 val isReasonValid = reasonLen in 5..60 // 약간의 마진 허용 // 점수가 보존되었는지 확인 (Scalping 점수 대조) val isScorePreserved = decision.ultraShortScore == scores.ultraShort.toDouble() if (isReasonValid && isScorePreserved) { return decision.apply { this.stockCode = tempDecision.stockCode this.stockName = tempDecision.stockName this.corpName = tempDecision.corpName this.financialData = tempDecision.financialData this.newsContext = tempDecision.newsContext } } else { println("⚠️ [검증 실패] 사유길이($reasonLen) 또는 점수보존($isScorePreserved) 실패. 재시도 합니다.") retryCount++ } } catch (e: Exception) { println("❌ [파싱 오류] ${e.message} - 재시도 시도 중... (${retryCount + 1})") retryCount++ delay(500) } } // 💡 [최종 탈출] 모든 재시도 실패 시 무한 루프를 돌지 않고 null 반환 println("🚨 [시스템] $stockName 분석 재시도 횟수 초과. 분석을 스킵합니다.") return TradingDecision().apply { this.stockName = stockName this.decision = "HOLD" this.reason = "AI 분석 지연으로 인한 자동 관망 처리" } } } @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() } } object FinancialMapper { /** * 제공된 텍스트 데이터를 파싱하여 FinancialStatement 객체로 변환 */ fun mapRawTextToStatement(rawText: String): FinancialStatement { if (rawText.isBlank()) { return FinancialStatement() } // println(rawText) val currentValues = extractYearlyValues(rawText, "당기") val previousValues = extractYearlyValues(rawText, "전기") // 1. 영업이익 증가율: (당기 - 전기) / |전기| * 100 val opCurrent = currentValues["영업이익"] ?: 0.0 val opPrevious = previousValues["영업이익"] ?: 0.0 val opGrowth = if (opPrevious != 0.0) ((opCurrent - opPrevious) / Math.abs(opPrevious)) * 100 else 0.0 // 2. 당기순이익 증가율 val niCurrent = currentValues["당기순이익(손실)"] ?: 0.0 val niPrevious = previousValues["당기순이익(손실)"] ?: 0.0 val niGrowth = if (niPrevious != 0.0) ((niCurrent - niPrevious) / Math.abs(niPrevious)) * 100 else 0.0 // 3. ROE: 당기순이익 / 당기 자본총계 * 100 val equityCurrent = currentValues["자본총계"] ?: 1.0 val roe = (niCurrent / equityCurrent) * 100 // 4. 부채비율: 당기 부채총계 / 당기 자본총계 * 100 val debtCurrent = currentValues["부채총계"] ?: 0.0 val debtRatio = (debtCurrent / equityCurrent) * 100 // 5. 당좌비율(유동성): 당기 유동자산 / 당기 유동부채 * 100 val currentAssets = currentValues["유동자산"] ?: 0.0 val currentLiabilities = currentValues["유동부채"] ?: 1.0 val quickRatio = (currentAssets / currentLiabilities) * 100 return FinancialStatement( operatingProfitGrowth = opGrowth, netIncomeGrowth = niGrowth, roe = roe, debtRatio = debtRatio, quickRatio = quickRatio, isOperatingProfitPositive = opCurrent > 0, isNetIncomePositive = niCurrent > 0 ).apply { println("당기순이익: ${niCurrent} , isSafetyBeltMet ${FinancialAnalyzer.isSafetyBeltMet(this)}") } } private fun extractYearlyValues(text: String, type: String): Map { val result = mutableMapOf() // 핵심 수정: 항목명 뒤에 (당기) 또는 (전기)가 오고, 그 직후의 숫자(마이너스, 쉼표 포함)를 캡처 // 쉼표나 공백으로 끝나는 지점까지 찾습니다. val regex = Regex("""([가-힣\s()]+)\s\($type\)([-0-9,.]+)""") regex.findAll(text).forEach { match -> val key = match.groupValues[1].trim() // 숫자 내 쉼표 제거 후 Double 변환 val rawValue = match.groupValues[2].replace(",", "").toDoubleOrNull() ?: 0.0 result[key] = rawValue } return result } } @Serializable data class FinancialStatement( val revenueGrowth: Double = 0.0, // 매출액 증가율 val operatingProfitGrowth: Double = 0.0, // 영업이익 증가율 val netIncomeGrowth: Double = 0.0, // 당기순이익 증가율 val roe: Double = 0.0, // ROE val debtRatio: Double = 0.0, // 부채비율 val quickRatio: Double = 0.0, // 당좌비율 val isOperatingProfitPositive: Boolean = false, // 당기 영업이익 흑자 여부 val isNetIncomePositive: Boolean = false )