...
This commit is contained in:
parent
56945a56d1
commit
dfc5de7cdc
Binary file not shown.
@ -10,6 +10,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
|||||||
import model.AppConfig
|
import model.AppConfig
|
||||||
import model.KisSession
|
import model.KisSession
|
||||||
import network.LlamaServerManager
|
import network.LlamaServerManager
|
||||||
|
import network.NewsService
|
||||||
import org.jetbrains.exposed.sql.selectAll
|
import org.jetbrains.exposed.sql.selectAll
|
||||||
import ui.DashboardScreen
|
import ui.DashboardScreen
|
||||||
import ui.SettingsScreen
|
import ui.SettingsScreen
|
||||||
@ -47,7 +48,6 @@ fun main() = application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
isLoaded = true
|
isLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,14 +62,28 @@ object TradeLogTable : Table("trade_logs") {
|
|||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
class VectorColumnType(private val dimension: Int) : ColumnType<String>() {
|
class VectorColumnType(private val dimension: Int) : ColumnType<DoubleArray>() {
|
||||||
// [수정] H2에서 ARRAY는 내부 타입을 명시해야 합니다.
|
|
||||||
// VECTOR 대신 FLOAT8 ARRAY를 사용하면 벡터 연산 함수와 100% 호환됩니다.
|
|
||||||
override fun sqlType(): String = "FLOAT8 ARRAY"
|
override fun sqlType(): String = "FLOAT8 ARRAY"
|
||||||
|
|
||||||
override fun valueFromDB(value: Any): String = value.toString()
|
override fun valueFromDB(value: Any): DoubleArray {
|
||||||
|
return when (value) {
|
||||||
|
// H2 드라이버에서 반환된 객체를 처리
|
||||||
|
is java.sql.Array -> {
|
||||||
|
val array = value.array as Array<*>
|
||||||
|
array.map { (it as Number).toDouble() }.toDoubleArray()
|
||||||
|
}
|
||||||
|
is Array<*> -> value.map { (it as Number).toDouble() }.toDoubleArray()
|
||||||
|
is DoubleArray -> value
|
||||||
|
else -> DoubleArray(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun notNullValueToDB(value: String): Any = value
|
// [핵심 수정 부분]
|
||||||
|
// primitive double[]을 Object Double[]로 변환하여 반환합니다.
|
||||||
|
// 이렇게 해야 H2 JDBC 드라이버가 직렬화 대신 SQL ARRAY로 인식합니다.
|
||||||
|
override fun notNullValueToDB(value: DoubleArray): Any {
|
||||||
|
return value.toTypedArray()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object VectorStoreTable : Table("VECTOR_STORE") {
|
object VectorStoreTable : Table("VECTOR_STORE") {
|
||||||
@ -77,8 +91,8 @@ object VectorStoreTable : Table("VECTOR_STORE") {
|
|||||||
val content = text("content")
|
val content = text("content")
|
||||||
val metadata = text("metadata")
|
val metadata = text("metadata")
|
||||||
|
|
||||||
// 이제 FLOAT8 ARRAY 타입으로 컬럼이 생성됩니다.
|
// 이제 이 컬럼은 DoubleArray 데이터를 직접 주고받습니다.
|
||||||
val embedding = registerColumn<String>("embedding", VectorColumnType(1024))
|
val embedding = registerColumn<DoubleArray>("embedding", VectorColumnType(1024))
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/main/kotlin/model/NewsModel.kt
Normal file
16
src/main/kotlin/model/NewsModel.kt
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NaverNewsResponse(
|
||||||
|
val items: List<NewsItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NewsItem(
|
||||||
|
val title: String,
|
||||||
|
val originallink: String,
|
||||||
|
val description: String,
|
||||||
|
val pubDate: String
|
||||||
|
)
|
||||||
61
src/main/kotlin/network/NewsService.kt
Normal file
61
src/main/kotlin/network/NewsService.kt
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.engine.cio.CIOEngineConfig
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.logging.DEFAULT
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logger
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.http.ContentType.Application.Json
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import model.NaverNewsResponse
|
||||||
|
|
||||||
|
object NewsService {
|
||||||
|
private val client = HttpClient<CIOEngineConfig>(CIO) {
|
||||||
|
|
||||||
|
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true })
|
||||||
|
install(Logging) {
|
||||||
|
logger = Logger.DEFAULT
|
||||||
|
level = LogLevel.ALL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchAndIngestNews(query: String) {
|
||||||
|
val clientId = "CqXQXHO3h0kqtYsXkePY" // 설정에서 가져오도록 수정 필요
|
||||||
|
val clientSecret = "DODCxb1M4Z"
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response: NaverNewsResponse = client.get("https://openapi.naver.com/v1/search/news.json") {
|
||||||
|
parameter("query", query)
|
||||||
|
parameter("display", 10) // 최근 10개 뉴스
|
||||||
|
parameter("sort", "sim") // 유사도 순 (또는 date 발간순)
|
||||||
|
header("X-Naver-Client-Id", clientId)
|
||||||
|
header("X-Naver-Client-Secret", clientSecret)
|
||||||
|
}.body()
|
||||||
|
|
||||||
|
response.items.forEach { item ->
|
||||||
|
// HTML 태그 제거 및 텍스트 정제
|
||||||
|
val cleanTitle = item.title.replace(Regex("<[^>]*>"), "")
|
||||||
|
val cleanDesc = item.description.replace(Regex("<[^>]*>"), "")
|
||||||
|
val fullText = "[$cleanTitle] $cleanDesc"
|
||||||
|
println(fullText)
|
||||||
|
// RAG 서비스에 학습(Ingest) 시키기
|
||||||
|
RagService.ingest(
|
||||||
|
text = fullText,
|
||||||
|
meta = "{\"link\": \"${item.originallink}\", \"date\": \"${item.pubDate}\"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
println("📰 '${query}' 관련 뉴스 10개 학습 완료")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("❌ 뉴스 가져오기 실패: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
// src/main/kotlin/network/RagService.kt
|
// src/main/kotlin/network/RagService.kt
|
||||||
|
|
||||||
|
import VectorStoreTable.metadata
|
||||||
import dev.langchain4j.data.segment.TextSegment
|
import dev.langchain4j.data.segment.TextSegment
|
||||||
import dev.langchain4j.model.openai.OpenAiChatModel
|
import dev.langchain4j.model.openai.OpenAiChatModel
|
||||||
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
|
import dev.langchain4j.model.openai.OpenAiEmbeddingModel
|
||||||
@ -25,14 +26,13 @@ object RagService {
|
|||||||
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
|
* 텍스트를 임베딩하여 H2 DB에 저장합니다.
|
||||||
*/
|
*/
|
||||||
fun ingest(text: String, meta: String = "") {
|
fun ingest(text: String, meta: String = "") {
|
||||||
val embedding = embeddingModel.embed(text).content().vector()
|
val embeddingVector: DoubleArray = embeddingModel.embed(text).content().vector().map { it.toDouble() }.toDoubleArray()
|
||||||
|
|
||||||
transaction {
|
transaction {
|
||||||
VectorStoreTable.insert {
|
VectorStoreTable.insert {
|
||||||
it[content] = text
|
it[content] = text
|
||||||
it[metadata] = meta
|
it[metadata] = meta
|
||||||
// 벡터 데이터를 문자열 형태로 저장 (H2 포맷)
|
// [수정] 문자열 변환 없이 객체 그대로 전달
|
||||||
it[VectorStoreTable.embedding] = embedding.joinToString(",", "[", "]")
|
it[embedding] = embeddingVector
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println("💾 H2 벡터 저장 완료: ${text.take(15)}...")
|
println("💾 H2 벡터 저장 완료: ${text.take(15)}...")
|
||||||
@ -41,35 +41,41 @@ object RagService {
|
|||||||
/**
|
/**
|
||||||
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
|
* 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다.
|
||||||
*/
|
*/
|
||||||
fun ask(question: String): String {
|
fun askWithContext(question: String): String {
|
||||||
val queryVector = embeddingModel.embed(question).content().vector()
|
val queryVector = embeddingModel.embed(question).content().vector()
|
||||||
val vectorStr = queryVector.joinToString(",", "[", "]")
|
// H2 ARRAY 포맷에 맞춰 (v1, v2, ...) 형태로 변환
|
||||||
|
val vectorStr = queryVector.joinToString(",", "(", ")")
|
||||||
|
|
||||||
// H2의 VECTOR_COSINE_SIMILARITY 함수를 사용하여 검색
|
|
||||||
val context = transaction {
|
val context = transaction {
|
||||||
val query = "SELECT content FROM VECTOR_STORE " +
|
// 코사인 유사도 기준 상위 5개 뉴스 추출
|
||||||
"ORDER BY VECTOR_COSINE_SIMILARITY(embedding, '$vectorStr') DESC " +
|
val query = """
|
||||||
"LIMIT 3"
|
SELECT CONTENT FROM VECTOR_STORE
|
||||||
|
ORDER BY VECTOR_COSINE_SIMILARITY(EMBEDDING, CAST('$vectorStr' AS FLOAT8 ARRAY)) DESC
|
||||||
|
LIMIT 5
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
val results = mutableListOf<String>()
|
val results = mutableListOf<String>()
|
||||||
exec(query) { rs ->
|
exec(query) { rs ->
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
results.add(rs.getString("content"))
|
results.add(rs.getString("CONTENT"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.joinToString("\n\n")
|
results.joinToString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
val prompt = """
|
val finalPrompt = """
|
||||||
[참고 정보]
|
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
|
||||||
$context
|
당신은 실시간 뉴스 분석에 능통한 20년 경력의 주식 전문가입니다.
|
||||||
|
제공된 [참고 자료]를 바탕으로 사용자의 질문에 전문적이고 단호하게 답하세요.<|eot_id|>
|
||||||
[질문]
|
<|start_header_id|>user<|end_header_id|>
|
||||||
$question
|
[참고 자료]
|
||||||
|
$context
|
||||||
위 정보를 참고하여 분석 결과를 말해주세요.
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
return chatModel.generate(prompt)
|
[질문]
|
||||||
|
$question
|
||||||
|
<|eot_id|><|start_header_id|>assistant<|end_header_id|>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
return chatModel.generate(finalPrompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
25
src/main/kotlin/service/StockAnalysisManager.kt
Normal file
25
src/main/kotlin/service/StockAnalysisManager.kt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import network.NewsService
|
||||||
|
|
||||||
|
object StockAnalysisManager {
|
||||||
|
|
||||||
|
suspend fun analyzeStockWithRealTimeData(stockName: String, currentPrice: String): String {
|
||||||
|
println("🔍 [1/3] '${stockName}' 실시간 뉴스 수집 및 학습 시작...")
|
||||||
|
|
||||||
|
// 1. 실시간 뉴스 검색 및 DB 저장 (Embedding 서버 8081 활용)
|
||||||
|
// 키워드를 "종목명 주가 전망"으로 최적화하여 검색
|
||||||
|
NewsService.fetchAndIngestNews("$stockName 주가 전망")
|
||||||
|
|
||||||
|
println("🧠 [2/3] 관련 컨텍스트 추출 중...")
|
||||||
|
|
||||||
|
// 2. 방금 저장된 뉴스를 포함하여 DB에서 관련성 높은 정보 추출
|
||||||
|
val question = "${stockName}의 현재가 ${currentPrice}원 기준, 최근 뉴스 수급 상황과 향후 단기 전망을 분석해줘."
|
||||||
|
val context = RagService.askWithContext(question)
|
||||||
|
|
||||||
|
println("🤖 [3/3] AI 분석 생성 중 (Chat 서버 8080)...")
|
||||||
|
|
||||||
|
// 3. 최종 분석 결과 반환
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.material.Button
|
import androidx.compose.material.Button
|
||||||
import androidx.compose.material.ButtonDefaults
|
import androidx.compose.material.ButtonDefaults
|
||||||
import androidx.compose.material.Card
|
import androidx.compose.material.Card
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.material.Divider
|
import androidx.compose.material.Divider
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
@ -24,11 +25,12 @@ import kotlinx.coroutines.launch
|
|||||||
import model.KisSession
|
import model.KisSession
|
||||||
import model.RealTimeTrade
|
import model.RealTimeTrade
|
||||||
import network.AiService
|
import network.AiService
|
||||||
|
import service.StockAnalysisManager
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
|
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.RealTimeTrade>) {
|
||||||
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isAnalyzing by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// KisSession의 전역 설정을 참조
|
// KisSession의 전역 설정을 참조
|
||||||
@ -53,15 +55,29 @@ fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<model.R
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isLoading = true
|
isAnalyzing = true
|
||||||
aiOpinion = "데이터 분석 중..."
|
try {
|
||||||
aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades)
|
// 실시간 데이터 수집부터 분석까지 한 번에 실행
|
||||||
isLoading = false
|
aiOpinion = StockAnalysisManager.analyzeStockWithRealTimeData(
|
||||||
|
stockName = stockName,
|
||||||
|
currentPrice = currentPrice
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
aiOpinion = "분석 중 오류 발생: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
isAnalyzing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = isModelConfigured && !isLoading
|
enabled = !isAnalyzing
|
||||||
) {
|
) {
|
||||||
Text(if (isLoading) "분석 중" else "분석 실행", fontSize = 11.sp)
|
if (isAnalyzing) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("뉴스 분석 중...")
|
||||||
|
} else {
|
||||||
|
Text("AI 실시간 전략 분석")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(vertical = 8.dp))
|
Divider(Modifier.padding(vertical = 8.dp))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user