....
This commit is contained in:
@@ -3,13 +3,8 @@ package service
|
||||
import TradingDecision
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import model.CandleData
|
||||
import network.KisTradeService
|
||||
import network.NewsService
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
@@ -18,31 +13,34 @@ import kotlin.collections.List
|
||||
|
||||
import kotlin.math.*
|
||||
// service/AutoTradingManager.kt
|
||||
typealias TradingDecisionCallback = (TradingDecision?, Boolean)->Unit
|
||||
object AutoTradingManager {
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
val targetStocks = mutableListOf<String>()
|
||||
val targetStocks = mutableListOf<Pair<String, String>>()
|
||||
|
||||
fun addStock(stockCode : String, result :(String, Boolean)->Unit) {
|
||||
targetStocks.add(stockCode)
|
||||
startTradingLoop(result)
|
||||
fun addStock(stockName : String,stockCode : String, result :TradingDecisionCallback) {
|
||||
targetStocks.add(Pair(stockName, stockCode))
|
||||
startTradingLoop(stockName,stockCode,result)
|
||||
}
|
||||
|
||||
fun startTradingLoop(result :(String, Boolean)->Unit) {
|
||||
fun startTradingLoop(stockName : String, stockCode : String, result :TradingDecisionCallback) {
|
||||
scope.launch {
|
||||
println("🚀 10분 주기 자동 분석 및 매매 시작: ${LocalTime.now()}")
|
||||
targetStocks.forEach { stockCode ->
|
||||
// targetStocks.forEach { stockCode ->
|
||||
launch { // 종목별 병렬 분석 (M3 Pro 파워 활용)
|
||||
RagService.processStock(stockCode,result) {code ,decision ->
|
||||
when (decision?.decision) {
|
||||
"BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
|
||||
"SELL" -> executeOrder(stockCode, "매도")
|
||||
else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
|
||||
}
|
||||
result(decision.toString(),true)
|
||||
}
|
||||
RagService.processStock(stockName, stockCode,result)
|
||||
// {decision,b ->
|
||||
//// when (decision?.decision) {
|
||||
//// "BUY" -> if (decision.confidence > 70) executeOrder(stockCode, "매수")
|
||||
//// "SELL" -> executeOrder(stockCode, "매도")
|
||||
//// else -> println("[$stockCode] 관망 유지: ${decision?.reason}")
|
||||
//// }
|
||||
// result(decision,b)
|
||||
// }
|
||||
}
|
||||
}
|
||||
delay(10 * 60 * 1000) // 10분 대기
|
||||
// }
|
||||
// targetStocks.re
|
||||
// delay(10 * 60 * 1000) // 10분 대기
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +110,7 @@ object TechnicalAnalyzer {
|
||||
// [3] 이평선 및 가격 위치
|
||||
val ma5 = m10.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
val currentPrice = min30.last().stck_prpr.toDouble()
|
||||
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList())
|
||||
val signal = ScalpingAnalyzer().analyze(min30.toScalpingList(),isDailyBullish())
|
||||
// [4] 거래량 강도
|
||||
val avgVol30 = min30.map { it.cntg_vol.toLong() }.average()
|
||||
val recentVol5 = m10.takeLast(5).map { it.cntg_vol.toLong() }.average()
|
||||
@@ -121,30 +119,29 @@ object TechnicalAnalyzer {
|
||||
val stochK = calculateStochastic(min30)
|
||||
val priceRange30 = min30.maxOf { it.stck_hgpr.toDouble() } - min30.minOf { it.stck_lwpr.toDouble() }
|
||||
return """
|
||||
[초단기 기술적 스켈핑 분석]
|
||||
- 종합 스코어: ${signal.compositeScore} / 100
|
||||
- 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
|
||||
- 성공 확률 예측: ${signal.successProbPct}%
|
||||
- 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
|
||||
- RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배
|
||||
- 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원)
|
||||
- 초/단타 종합 스코어: ${signal.compositeScore} / 100
|
||||
- 초/단타 매수 신호 발생 여부: ${if (signal.buySignal) "YES" else "NO"}
|
||||
- 초/단타 성공 확률 예측: ${signal.successProbPct}%
|
||||
- 초/단타 위험 등급: ${signal.riskLevel} (ATR 변동성 기반)
|
||||
- 초/단타 RSI: ${"%.1f".format(signal.rsi)} / 거래량 비율: ${"%.1f".format(signal.volRatio)}배
|
||||
- 초/단타 권장 가격: 손절가(${signal.suggestedSlPrice.toInt()}원), 익절가(${signal.suggestedTpPrice.toInt()}원)
|
||||
- 월봉/주봉 위치: ${if(calculateChange(monthly) > 0) "장기 상승" else "장기 하락"} / ${if(calculateChange(weekly) > 0) "중기 상승" else "중기 하락"}
|
||||
- 일봉 대비: ${ "%.2f".format(changeDaily) }% 변동
|
||||
- 30분 대비: ${ "%.2f".format(change30) }% 변동
|
||||
- 10분 대비: ${ "%.2f".format(change10) }% 변동
|
||||
- 이평선 상태: 현재가(${currentPrice.toInt()}) vs MA5(${ma5.toInt()}) -> ${if(currentPrice > ma5) "상단 위치" else "하단 위치"}
|
||||
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) } (${if(obv > 0) "누적 매수 우위" else "누적 매도 우위"})
|
||||
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) } (과매수 기준: 80 / 과매도 기준: 20)
|
||||
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) } (종가 형성 위치와 거래량 결합 수치)
|
||||
- OBV (누적 거래량 에너지): ${ "%.0f".format(obv) }
|
||||
- MFI (자금 유입 지수): ${ "%.1f".format(mfi) }
|
||||
- A/D (누적 분산 라인): ${ "%.0f".format(adLine) }
|
||||
- 거래량 강도: 최근 5분 평균이 30분 평균의 ${ "%.1f".format(volStrength) }배 수준
|
||||
- ATR (평균 변동폭): ${"%.0f".format(atr)}원 (최근 캔들 하나가 평균적으로 움직이는 크기)
|
||||
- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원 (최고가-최저가 차이)
|
||||
- 스토캐스틱(%K): ${"%.1f".format(stochK)} (100에 가까울수록 최근 파동의 고점, 0에 가까울수록 저점)
|
||||
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준으로 전개 중
|
||||
- ATR (평균 변동폭): ${"%.0f".format(atr)}원
|
||||
- 30분 내 최대 진폭: ${"%.0f".format(priceRange30)}원
|
||||
- 스토캐스틱(%K): ${"%.1f".format(stochK)}
|
||||
- 변동성 강도: 현재 진폭이 ATR 대비 ${"%.1f".format(priceRange30 / atr)}배 수준
|
||||
- 30분봉 최고가: ${min30.maxOf { it.stck_hgpr.toInt() }}
|
||||
- 30분봉 최저가: ${min30.minOf { it.stck_lwpr.toInt() }}
|
||||
- RSI(14): ${ "%.1f".format(calculateRSI(min30)) }
|
||||
""".trimIndent()
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,6 +191,25 @@ object TechnicalAnalyzer {
|
||||
return if (gains + losses == 0.0) 50.0 else (gains / (gains + losses)) * 100
|
||||
}
|
||||
|
||||
fun isDailyBullish(): Boolean {
|
||||
if (daily.size < 20) return true // 데이터 부족 시 보수적으로 true 혹은 예외처리
|
||||
|
||||
val currentPrice = daily.last().stck_prpr.toDouble()
|
||||
|
||||
// 1. MA20 (한 달 생명선) 계산
|
||||
val ma20 = daily.takeLast(20).map { it.stck_prpr.toDouble() }.average()
|
||||
|
||||
// 2. MA5 (단기 가속도) 계산
|
||||
val ma5 = daily.takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
|
||||
// 3. 방향성 (어제 MA5 vs 오늘 MA5)
|
||||
val prevMa5 = daily.dropLast(1).takeLast(5).map { it.stck_prpr.toDouble() }.average()
|
||||
val isMa5Rising = ma5 > prevMa5
|
||||
|
||||
// [최종 판별]: 현재가가 생명선 위에 있고, 단기 이평선이 고개를 들었을 때만 'Bull(상승)'로 간주
|
||||
return currentPrice > ma20 && isMa5Rising
|
||||
}
|
||||
|
||||
fun calculateOBV(candles: List<CandleData>): Double {
|
||||
var obv = 0.0
|
||||
for (i in 1 until candles.size) {
|
||||
@@ -298,7 +314,7 @@ class ScalpingAnalyzer {
|
||||
return Triple(upper, sma, lower)
|
||||
}
|
||||
|
||||
fun analyze(candles: List<Candle>): ScalpingSignalModel {
|
||||
fun analyze(candles: List<Candle>, isDailyBullish: Boolean): ScalpingSignalModel {
|
||||
if (candles.size < SMA_LONG) throw IllegalArgumentException("최소 20봉 필요")
|
||||
|
||||
val closes = candles.map { it.close }
|
||||
@@ -323,16 +339,34 @@ class ScalpingAnalyzer {
|
||||
(currentClose - bbLower.last()) / (bbUpper.last() - bbLower.last())
|
||||
} else 0.5
|
||||
|
||||
// 신호 조건
|
||||
val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
|
||||
|
||||
val nearHigh = candles.takeLast(6).dropLast(1).maxOf { it.high }
|
||||
val isBreakout = currentClose > nearHigh
|
||||
|
||||
// [추가] 2. 캔들 패턴: 망치형/역망치형 등 꼬리 분석 (하단 지지력 확인)
|
||||
val bodySize = abs(current.close - current.open)
|
||||
val lowerShadow = minOf(current.close, current.open) - current.low
|
||||
val isBottomSupport = lowerShadow > bodySize * 1.5 // 밑꼬리가 몸통보다 긴 경우
|
||||
|
||||
// 신호 조건 고도화
|
||||
// 일봉 추세(dailyTrend)가 살아있고, 전고점을 돌파(isBreakout)할 때 더 높은 점수
|
||||
|
||||
// val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
val rsiBull = rsiNow > RSI_THRESHOLD
|
||||
val volSurge = volRatioNow > VOL_SURGE_THRESHOLD
|
||||
val bbGood = bbPos > BB_LOWER_POS && bbPos < BB_UPPER_POS
|
||||
val buySignal = maBull && rsiBull && volSurge && bbGood
|
||||
val maBull = currentClose > sma10Now && sma10Now > sma20Now
|
||||
val buySignal = maBull && rsiBull && volSurge && bbGood && isBreakout
|
||||
// val buySignal = maBull && rsiBull && volSurge && bbGood
|
||||
|
||||
// 종합 스코어 (가중: MA 30%, RSI 20%, Vol 30%, BB 20%)
|
||||
val score = (if (maBull) 30 else 0) + (if (rsiBull) 20 else 0) +
|
||||
(minOf((volRatioNow - 1.0) * 30, 30.0)).toInt() + (if (bbGood) 20 else 0)
|
||||
|
||||
val score = (if (maBull) 25 else 0) +
|
||||
(if (rsiBull) 15 else 0) +
|
||||
(if (isBreakout) 20 else 0) + // 돌파 에너지 가중치
|
||||
(minOf((volRatioNow - 1.0) * 20, 20.0)).toInt() +
|
||||
(if (bbGood) 10 else 0) +
|
||||
(if (isDailyBullish) 10 else 0) // 단타/장기 정렬 점수
|
||||
|
||||
// 위험도 (ATR proxy)
|
||||
val returns = closes.mapIndexed { i, c -> if (i > 0) (c - closes[i-1])/closes[i-1] * 100 else 0.0 }
|
||||
@@ -351,6 +385,8 @@ class ScalpingAnalyzer {
|
||||
val tpPrice = currentClose * (1 + DEFAULT_TP_PCT / 100)
|
||||
val rrRatio = abs(DEFAULT_TP_PCT / DEFAULT_SL_PCT)
|
||||
|
||||
|
||||
|
||||
return ScalpingSignalModel(
|
||||
currentPrice = currentClose,
|
||||
buySignal = buySignal,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package service
|
||||
|
||||
import com.microsoft.playwright.Playwright
|
||||
import com.microsoft.playwright.BrowserType
|
||||
import com.microsoft.playwright.Page
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import model.NewsItem
|
||||
import network.CorpInfo
|
||||
import kotlin.random.Random
|
||||
|
||||
object DynamicNewsScraper {
|
||||
private val playwright by lazy { Playwright.create() }
|
||||
private val browser by lazy {
|
||||
playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(true))
|
||||
}
|
||||
|
||||
fun extractSmartContentWithLineFilter(page: Page): String {
|
||||
val script = """
|
||||
() => {
|
||||
// 1. 선제적 노이즈 제거: 분석에 방해되는 태그들을 DOM에서 아예 삭제
|
||||
const junkTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME', 'SVG', 'HEADER', 'FOOTER', 'NAV'];
|
||||
document.querySelectorAll(junkTags.join(',')).forEach(el => el.remove());
|
||||
|
||||
const MIN_LINE_LENGTH = 10;
|
||||
const MIN_TOTAL_LENGTH = 100;
|
||||
const CONSECUTIVE_THRESHOLD = 2;
|
||||
|
||||
// 2. 라인별 정제 함수 (짧은 라인 연속 시 예외 처리)
|
||||
const getRefinedText = (el) => {
|
||||
// 실제 텍스트만 추출하여 라인별로 분리
|
||||
const lines = el.innerText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
let resultLines = [];
|
||||
let tempBuffer = [];
|
||||
let consecutiveShort = 0;
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.length <= MIN_LINE_LENGTH) {
|
||||
consecutiveShort++;
|
||||
tempBuffer.push(line);
|
||||
} else {
|
||||
// 짧은 줄이 연속되지 않았을 때만 버퍼를 결과에 합침
|
||||
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
|
||||
resultLines = resultLines.concat(tempBuffer);
|
||||
}
|
||||
resultLines.push(line);
|
||||
tempBuffer = [];
|
||||
consecutiveShort = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 마지막 남은 버퍼 처리 (본문 끝에 짧은 정보가 있을 경우 대비)
|
||||
if (consecutiveShort < CONSECUTIVE_THRESHOLD) {
|
||||
resultLines = resultLines.concat(tempBuffer);
|
||||
}
|
||||
return resultLines.join('\n');
|
||||
};
|
||||
|
||||
// 3. 후보 블록 탐색 및 텍스트 밀도 기반 분석
|
||||
const candidates = Array.from(document.querySelectorAll('div, section, article, p, main, td'))
|
||||
.map(el => ({
|
||||
el: el,
|
||||
refinedText: getRefinedText(el)
|
||||
}))
|
||||
.filter(item => {
|
||||
if (item.refinedText.length < MIN_TOTAL_LENGTH) return false;
|
||||
|
||||
// 링크 밀도 체크: 기사 본문은 보통 링크보다 텍스트 비중이 훨씬 높음
|
||||
const linkLength = Array.from(item.el.querySelectorAll('a'))
|
||||
.reduce((acc, a) => acc + (a.innerText || "").length, 0);
|
||||
return (linkLength / item.refinedText.length) < 0.3;
|
||||
});
|
||||
|
||||
// 4. 가장 최적의(가장 깊은 계층의) 본문 컨테이너 선정
|
||||
const best = candidates.find(parent =>
|
||||
!candidates.some(child =>
|
||||
parent.el !== child.el &&
|
||||
parent.el.contains(child.el) &&
|
||||
child.refinedText.length > parent.refinedText.length * 0.8
|
||||
)
|
||||
);
|
||||
|
||||
return best ? best.refinedText : (candidates.sort((a,b) => b.refinedText.length - a.refinedText.length)[0]?.refinedText || "");
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
return page.evaluate(script) as String
|
||||
}
|
||||
|
||||
suspend fun fetchFullContent(url: String): String {
|
||||
val context = browser.newContext()
|
||||
val page = context.newPage()
|
||||
delay(Random.nextInt(1000).toLong())
|
||||
return try {
|
||||
// 1. 페이지 이동 및 네트워크 유휴 상태까지 대기
|
||||
blockUnnecessaryResources(page)
|
||||
page.navigate(url)
|
||||
// println(url)
|
||||
page.waitForLoadState()
|
||||
|
||||
|
||||
var finded = cleanText(extractSmartContentWithLineFilter(page))
|
||||
println("finded : $finded")
|
||||
finded
|
||||
} catch (e: Exception) {
|
||||
println("❌ [Playwright] 스크래핑 실패: ${e.message}")
|
||||
""
|
||||
} finally {
|
||||
page.close()
|
||||
context.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun blockUnnecessaryResources(page: Page) {
|
||||
// 이미지, 폰트, CSS 등 불필요한 요청 가로채서 중단
|
||||
page.route("**/*.{png,jpg,jpeg,gif,webp,svg,css,woff,woff2}") { route ->
|
||||
route.abort()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanText(text: String): String {
|
||||
return text.replace(Regex("(?m)^.*기자.*$"), "") // 기자 정보 제거
|
||||
.replace(Regex("(?m)^.*무단 전재.*$"), "") // 저작권 문구 제거
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
||||
object SafeScraper {
|
||||
// 동시 실행 브라우저 탭을 5개로 제한 (M3 Pro라면 10~20개도 여유롭습니다)
|
||||
private val semaphore = Semaphore(5)
|
||||
|
||||
suspend fun scrapeParallel(corpInfo: CorpInfo,urls: List<NewsItem>) = coroutineScope {
|
||||
var query = "${corpInfo.cName} ${corpInfo.cCode} ${corpInfo.stockCode}"
|
||||
urls.map { item ->
|
||||
async {
|
||||
if (UrlCacheManager.isAlreadyProcessed(item.originallink) == false) {
|
||||
semaphore.withPermit {
|
||||
RagService.ingestWithChunking(
|
||||
text = DynamicNewsScraper.fetchFullContent(item.originallink),
|
||||
newsLink = item.originallink,
|
||||
pubDate = item.pubDate,
|
||||
stockCode = corpInfo.stockCode,
|
||||
corpName = corpInfo.cName,
|
||||
corpCode = corpInfo.cCode,
|
||||
stcokName = corpInfo.stockName
|
||||
)
|
||||
}
|
||||
println("📰 '${query}' 관련 뉴스 새로운 학습 데이터 게더링")
|
||||
} else {
|
||||
println("📰 '${query}' 관련 뉴스 기 학습 데이터 스킵")
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
println("$query 관련 뉴스 ${urls.size}개 학습 완료")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object LlamaServerManager {
|
||||
// 포트별로 프로세스를 관리합니다.
|
||||
private val processes = ConcurrentHashMap<Int, Process>()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
init {
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
stopAll()
|
||||
})
|
||||
}
|
||||
|
||||
fun startServer(binPath: String, modelPath: String, port: Int, nGpuLayers: Int = 99) {
|
||||
// 이미 해당 포트에서 실행 중이거나 모델 경로가 비었으면 무시합니다.
|
||||
if (processes.containsKey(port) || modelPath.isBlank()) return
|
||||
|
||||
val command = listOf(
|
||||
binPath,
|
||||
"-m", modelPath,
|
||||
"--port", port.toString(),
|
||||
"-c", if (port == 8081) "512" else "8192", // 임베딩용은 컨텍스트가 짧아도 충분합니다.
|
||||
"-ngl", nGpuLayers.toString(),
|
||||
"-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장
|
||||
"--embedding" // 임베딩 기능을 활성화합니다.
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val pb = ProcessBuilder(command)
|
||||
|
||||
pb.redirectErrorStream(true)
|
||||
File(binPath).setExecutable(true)
|
||||
|
||||
val process = pb.start()
|
||||
processes[port] = process
|
||||
println("✅ AI 서버 시작 시도 (Port: $port, Model: ${File(modelPath).name})")
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
// 로그 출력 (디버깅용)
|
||||
// println("[Server $port] $line")
|
||||
if (line?.contains("server is listening") == true) {
|
||||
println("🚀 AI 서버 준비 완료 (Port: $port)")
|
||||
if (processes.size > 1) {
|
||||
println("[Cache] ${processes.size}")
|
||||
RagService.active()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}")
|
||||
processes.remove(port)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun stopAll() {
|
||||
processes.forEach { (port, process) ->
|
||||
process.destroy()
|
||||
println("🛑 AI 서버 종료 (Port: $port)")
|
||||
}
|
||||
processes.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import dev.langchain4j.community.rag.content.retriever.lucene.LuceneEmbeddingStore
|
||||
import dev.langchain4j.model.embedding.EmbeddingModel
|
||||
import dev.langchain4j.store.embedding.EmbeddingSearchRequest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object UrlCacheManager {
|
||||
// 1. Thread-safe한 메모리 캐시 (M3 Pro의 멀티코어 환경 대응)
|
||||
private val processedUrls = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
/**
|
||||
* Lucene 저장소에서 모든 link 메타데이터를 읽어와 캐시를 초기화합니다.
|
||||
*/
|
||||
fun initialize(embeddingStore: LuceneEmbeddingStore, embeddingModel: EmbeddingModel) {
|
||||
try {
|
||||
// 1. 더미 텍스트로 기준 벡터 생성 (또는 0으로 채워진 리스트 생성)
|
||||
// 모델에게 아무 단어나 던져서 기준이 될 벡터 하나를 임시로 만듭니다.
|
||||
val dummyEmbedding = embeddingModel.embed("initial_load").content()
|
||||
|
||||
// 2. 검색 요청에 더미 벡터 포함
|
||||
val searchRequest = EmbeddingSearchRequest.builder()
|
||||
.queryEmbedding(dummyEmbedding) // [필수] 기준 벡터 주입으로 에러 해결
|
||||
.maxResults(2000)
|
||||
.build()
|
||||
|
||||
val result = embeddingStore.search(searchRequest)
|
||||
|
||||
result.matches().forEach { match ->
|
||||
val url = match.embedded().metadata().getString("link") as? String
|
||||
if (url != null) {
|
||||
processedUrls.add(url)
|
||||
}
|
||||
}
|
||||
println("✅ [Cache] Lucene으로부터 ${processedUrls.size}개의 URL 로드 완료")
|
||||
} catch (e: Exception) {
|
||||
println("⚠️ [Cache] 초기화 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun isInitialized(): Boolean {
|
||||
return processedUrls.isNotEmpty()
|
||||
}
|
||||
/**
|
||||
* 중복 여부 확인 (O(1) 속도)
|
||||
*/
|
||||
fun isAlreadyProcessed(url: String): Boolean = processedUrls.contains(url)
|
||||
|
||||
/**
|
||||
* 캐시 업데이트
|
||||
*/
|
||||
fun addToCache(url: String) {
|
||||
processedUrls.add(url)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user