diff --git a/build.gradle.kts b/build.gradle.kts index 1bd96e1..eb01cc2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,12 @@ dependencies { // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") + + val langchain4jVersion = "0.31.0" + implementation("dev.langchain4j:langchain4j:$langchain4jVersion") + // llama.cpp 서버가 OpenAI API와 호환되므로 이 라이브러리를 사용합니다. + implementation("dev.langchain4j:langchain4j-open-ai:$langchain4jVersion") + } compose.desktop { diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 3f3a185..f5e9144 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -18,6 +18,7 @@ import ui.SettingsScreen enum class AppScreen { Settings, Dashboard } fun main() = application { + // 앱 실행 시 필요한 바이너리 경로 (실행 파일 위치) val binPath = "./src/main/resources/bin/llama-server" @@ -40,7 +41,8 @@ fun main() = application { vtsAccountNo = it[ConfigTable.vtsAccountNo], isSimulation = it[ConfigTable.isSimulation], htsId = it[ConfigTable.htsId], - modelPath = it[ConfigTable.modelPath] + modelPath = it[ConfigTable.modelPath], + embedModelPath = it[ConfigTable.embedModelPath] ) } } @@ -62,7 +64,10 @@ fun main() = application { // LLM 서버 시작 (설정된 모델 경로 사용) if (config.modelPath.isNotEmpty()) { - LlamaServerManager.startServer(binPath, config.modelPath) + LlamaServerManager.startServer(binPath, config.modelPath,port = 8080) + } + if (config.embedModelPath.isNotEmpty()) { + LlamaServerManager.startServer(binPath, config.embedModelPath, port = 8081) } // 대시보드로 화면 전환 diff --git a/src/main/kotlin/database/DatabaseFactory.kt b/src/main/kotlin/database/DatabaseFactory.kt index 05ec296..3860edc 100644 --- a/src/main/kotlin/database/DatabaseFactory.kt +++ b/src/main/kotlin/database/DatabaseFactory.kt @@ -27,6 +27,7 @@ object ConfigTable : Table("app_config") { val vtsAccountNo = varchar("vts_account_no", 20).default("") val isSimulation = bool("is_simulation").default(true) val modelPath = varchar("model_path", 512).default("") + val embedModelPath = varchar("embed_model_path", 512).default("") val htsId = varchar("hts_id", 50).default("") // HTS ID 컬럼 추가 override val primaryKey = PrimaryKey(id) } @@ -61,6 +62,27 @@ object TradeLogTable : Table("trade_logs") { override val primaryKey = PrimaryKey(id) } +class VectorColumnType(private val dimension: Int) : ColumnType() { + // [수정] H2에서 ARRAY는 내부 타입을 명시해야 합니다. + // VECTOR 대신 FLOAT8 ARRAY를 사용하면 벡터 연산 함수와 100% 호환됩니다. + override fun sqlType(): String = "FLOAT8 ARRAY" + + override fun valueFromDB(value: Any): String = value.toString() + + override fun notNullValueToDB(value: String): Any = value +} + +object VectorStoreTable : Table("VECTOR_STORE") { + val id = integer("id").autoIncrement() + val content = text("content") + val metadata = text("metadata") + + // 이제 FLOAT8 ARRAY 타입으로 컬럼이 생성됩니다. + val embedding = registerColumn("embedding", VectorColumnType(1024)) + + override val primaryKey = PrimaryKey(id) +} + object DatabaseFactory { fun init() { val dbPath = File("db/autotrade_db").absolutePath @@ -71,10 +93,12 @@ object DatabaseFactory { transaction { // 테이블 생성 (AutoTradeTable 포함) - SchemaUtils.create(ConfigTable, TradeLogTable, AutoTradeTable) + SchemaUtils.createMissingTablesAndColumns(ConfigTable, TradeLogTable, AutoTradeTable,VectorStoreTable) } } + + // --- 자동매매(감시) 관련 함수 --- @@ -174,7 +198,8 @@ object DatabaseFactory { vtsAccountNo = it[ConfigTable.vtsAccountNo], isSimulation = it[ConfigTable.isSimulation], htsId = it[ConfigTable.htsId], // htsId 로드 - modelPath = it[ConfigTable.modelPath] + modelPath = it[ConfigTable.modelPath], + embedModelPath = it[ConfigTable.embedModelPath], ) } } @@ -192,6 +217,7 @@ object DatabaseFactory { it[isSimulation] = config.isSimulation it[htsId] = config.htsId it[modelPath] = config.modelPath + it[embedModelPath] = config.embedModelPath } } } diff --git a/src/main/kotlin/model/AppConfig.kt b/src/main/kotlin/model/AppConfig.kt index 7d60502..3cf1e3e 100644 --- a/src/main/kotlin/model/AppConfig.kt +++ b/src/main/kotlin/model/AppConfig.kt @@ -25,7 +25,8 @@ data class AppConfig( var websocketToken: String = "", val isSimulation: Boolean = true, - val modelPath: String = "") { + val modelPath: String = "", + val embedModelPath: String = "") { val accountNo : String get() { diff --git a/src/main/kotlin/network/AiService.kt b/src/main/kotlin/network/AiService.kt index 6249660..19572ef 100644 --- a/src/main/kotlin/network/AiService.kt +++ b/src/main/kotlin/network/AiService.kt @@ -3,6 +3,7 @@ package network import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* @@ -19,10 +20,15 @@ object AiService { coerceInputValues = true }) } + install(HttpTimeout) { + requestTimeoutMillis = 60_000 // 전체 요청 대기 시간을 60초로 설정 + connectTimeoutMillis = 10_000 // 서버 연결 대기 시간 10초 + socketTimeoutMillis = 60_000 // 데이터 수신 대기 시간 60초 + } } - private const val LLM_URL = "http://localhost:8080/completion" - +// private const val LLM_URL = "http://localhost:8080/completion" + private const val LLM_URL = "http://127.0.0.1:8080/completion" /** * 종목명, 현재가, 실시간 체결내역을 바탕으로 AI 분석 결과를 가져옵니다. */ @@ -38,26 +44,15 @@ object AiService { // Gemma에게 전달할 프롬프트 구성 val prompt = """ - user - 당신은 20년 경력의 전문 주식 트레이더이자 데이터 분석가입니다. - 다음 데이터를 바탕으로 해당 종목의 현재 '수급 상황'과 '단기 전망'을 분석하여 3줄 이내로 핵심만 말해주세요. + <|begin_of_text|><|start_header_id|>system<|end_header_id|> + 당신은 20년 경력의 주식 트레이더입니다. 데이터를 분석하여 짧고 단호하게 조언합니다.<|eot_id|><|start_header_id|>user<|end_header_id|> + 다음 데이터를 분석하여 '수급 상황'과 '단기 전망'을 3줄 이내로 요약하세요. - [종목 정보] - - 종목명: $stockName - - 현재가: $currentPrice - - [최근 실시간 체결 내역] - $tradeSummary - - 분석 기준: - 1. 매수 체결 비중이 높은지, 매도 체결 비중이 높은지 판단하세요. - 2. 대량 체결(고래)의 움직임이 있는지 확인하세요. - 3. 단기적으로 진입하기에 적절한 시점인지 조언하세요. - - 답변은 한국어로, 친절하지만 단호한 전문가 말투를 사용하세요. - model - - """.trimIndent() + [종목] $stockName ($currentPrice) + [최근 체결] + $tradeSummary + <|eot_id|><|start_header_id|>assistant<|end_header_id|> + """.trimIndent() return try { val response = client.post(LLM_URL) { @@ -72,20 +67,53 @@ object AiService { "AI 서버 응답 오류: ${response.status}" } } catch (e: Exception) { - "분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})" + var msg = "분석 실패: 로컬 AI 서버(llama.cpp)가 실행 중인지 확인하세요. (${e.message})" + println(msg) + msg } } + + + suspend fun getEmbedding(text: String): List? { + return try { + val response = client.post("http://127.0.0.1:8080/embedding") { + contentType(ContentType.Application.Json) + setBody(EmbeddingRequest(content = text)) + } + if (response.status == HttpStatusCode.OK) { + val res: EmbeddingResponse = response.body() + res.embedding + } else null + } catch (e: Exception) { + null + } + } + + } + +@Serializable +data class EmbeddingRequest(val content: String) + +@Serializable +data class EmbeddingResponse(val embedding: List) + /** * llama.cpp 서버 요청 데이터 구조 */ @Serializable data class LlamaRequest( val prompt: String, - val n_predict: Int = 256, - val temperature: Double = 0.7, - val stop: List = listOf("<|end_of_turn|>", "") + val n_predict: Int = 256, // 답변 길이를 엄격히 제한 + val temperature: Double = 0.4, // M3 Pro에서 더 일관된 답변을 위해 낮춤 + val stop: List = listOf( + "<|eot_id|>", + "<|end_of_text|>", + "<|start_header_id|>", + "user", + "model" + ) // [중요] AI가 멈춰야 할 지점들을 명확히 지정 ) /** diff --git a/src/main/kotlin/network/LlamaServerManager.kt b/src/main/kotlin/network/LlamaServerManager.kt index b6f1da3..de478d7 100644 --- a/src/main/kotlin/network/LlamaServerManager.kt +++ b/src/main/kotlin/network/LlamaServerManager.kt @@ -4,50 +4,64 @@ import java.io.File import java.io.BufferedReader import java.io.InputStreamReader import kotlinx.coroutines.* +import java.util.concurrent.ConcurrentHashMap object LlamaServerManager { - private var process: Process? = null - private val scope = CoroutineScope(Dispatchers.IO + Job()) + // 포트별로 프로세스를 관리합니다. + private val processes = ConcurrentHashMap() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + init { + Runtime.getRuntime().addShutdownHook(Thread { + stopAll() + }) + } - fun startServer(binPath: String, modelPath: String) { - if (process != null || modelPath.isNullOrBlank()) return // 이미 실행 중이면 무시 + 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", "8080", - "-c", "2048", // 컨텍스트 길이 - "-t", "4", // 인텔 맥 코어 수에 맞춰 스레드 제한 (부하 방지) - "--embedding" // 나중에 유사도 분석 등을 위해 활성화 + "--port", port.toString(), + "-c", if (port == 8081) "512" else "4096", // 임베딩용은 컨텍스트가 짧아도 충분합니다. + "-ngl", nGpuLayers.toString(), + "-t", "6", // M3 Pro의 성능 코어를 고려하여 6~8개 권장 + "--embedding" // 임베딩 기능을 활성화합니다. ) scope.launch { try { val pb = ProcessBuilder(command) - // 실행 파일 권한 확인 (자동 부여) + + pb.redirectErrorStream(true) File(binPath).setExecutable(true) - process = pb.start() - println("✅ AI 서버 시작됨: http://localhost:8080") + val process = pb.start() + processes[port] = process + println("✅ AI 서버 시작 시도 (Port: $port, Model: ${File(modelPath).name})") - // 서버 로그 모니터링 (에러 디버깅용) - val reader = BufferedReader(InputStreamReader(process?.inputStream)) + val reader = BufferedReader(InputStreamReader(process.inputStream)) var line: String? while (reader.readLine().also { line = it } != null) { - // 서버 준비 완료 로그 확인용 - if (line?.contains("HTTP server listening") == true) { - println("🚀 AI 모델 로딩 완료 및 대기 중") + // 로그 출력 (디버깅용) + println("[Server $port] $line") + if (line?.contains("server is listening") == true) { + println("🚀 AI 서버 준비 완료 (Port: $port)") } } } catch (e: Exception) { - println("❌ AI 서버 실행 실패: ${e.message}") + println("❌ AI 서버 실행 실패 (Port: $port): ${e.message}") + processes.remove(port) } } } - fun stopServer() { - process?.destroy() - process = null - println("🛑 AI 서버 종료") + fun stopAll() { + processes.forEach { (port, process) -> + process.destroy() + println("🛑 AI 서버 종료 (Port: $port)") + } + processes.clear() } } \ No newline at end of file diff --git a/src/main/kotlin/network/RagService.kt b/src/main/kotlin/network/RagService.kt new file mode 100644 index 0000000..dae93f4 --- /dev/null +++ b/src/main/kotlin/network/RagService.kt @@ -0,0 +1,75 @@ +// src/main/kotlin/network/RagService.kt + +import dev.langchain4j.data.segment.TextSegment +import dev.langchain4j.model.openai.OpenAiChatModel +import dev.langchain4j.model.openai.OpenAiEmbeddingModel +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.plus +import org.jetbrains.exposed.sql.transactions.transaction +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") + .timeout(Duration.ofSeconds(60)) + .build() + + /** + * 텍스트를 임베딩하여 H2 DB에 저장합니다. + */ + fun ingest(text: String, meta: String = "") { + val embedding = embeddingModel.embed(text).content().vector() + + transaction { + VectorStoreTable.insert { + it[content] = text + it[metadata] = meta + // 벡터 데이터를 문자열 형태로 저장 (H2 포맷) + it[VectorStoreTable.embedding] = embedding.joinToString(",", "[", "]") + } + } + println("💾 H2 벡터 저장 완료: ${text.take(15)}...") + } + + /** + * 질문과 가장 유사한 정보를 H2에서 검색하여 AI 답변을 생성합니다. + */ + fun ask(question: String): String { + val queryVector = embeddingModel.embed(question).content().vector() + val vectorStr = queryVector.joinToString(",", "[", "]") + + // H2의 VECTOR_COSINE_SIMILARITY 함수를 사용하여 검색 + val context = transaction { + val query = "SELECT content FROM VECTOR_STORE " + + "ORDER BY VECTOR_COSINE_SIMILARITY(embedding, '$vectorStr') DESC " + + "LIMIT 3" + + val results = mutableListOf() + exec(query) { rs -> + while (rs.next()) { + results.add(rs.getString("content")) + } + } + results.joinToString("\n\n") + } + + val prompt = """ + [참고 정보] + $context + + [질문] + $question + + 위 정보를 참고하여 분석 결과를 말해주세요. + """.trimIndent() + + return chatModel.generate(prompt) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ui/SettingsScreen.kt b/src/main/kotlin/ui/SettingsScreen.kt index 9908930..0d5b44c 100644 --- a/src/main/kotlin/ui/SettingsScreen.kt +++ b/src/main/kotlin/ui/SettingsScreen.kt @@ -98,6 +98,19 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) { ) { Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp) } + Box( + modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp)) + .onExternalDrag(onDrop = { state -> + val data = state.dragData + if (data is DragData.FilesList) { + val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:") + if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath) + } + }), + contentAlignment = Alignment.Center + ) { + Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp) + } Spacer(Modifier.height(24.dp)) diff --git a/src/main/resources/bin/libggml-base.0.9.5.dylib b/src/main/resources/bin/libggml-base.0.9.5.dylib new file mode 100755 index 0000000..9ef58e8 Binary files /dev/null and b/src/main/resources/bin/libggml-base.0.9.5.dylib differ diff --git a/src/main/resources/bin/libggml-base.0.dylib b/src/main/resources/bin/libggml-base.0.dylib new file mode 120000 index 0000000..b95fcb8 --- /dev/null +++ b/src/main/resources/bin/libggml-base.0.dylib @@ -0,0 +1 @@ +libggml-base.0.9.5.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-base.dylib b/src/main/resources/bin/libggml-base.dylib new file mode 120000 index 0000000..f08d1fc --- /dev/null +++ b/src/main/resources/bin/libggml-base.dylib @@ -0,0 +1 @@ +libggml-base.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-blas.0.9.5.dylib b/src/main/resources/bin/libggml-blas.0.9.5.dylib new file mode 100755 index 0000000..ddef480 Binary files /dev/null and b/src/main/resources/bin/libggml-blas.0.9.5.dylib differ diff --git a/src/main/resources/bin/libggml-blas.0.dylib b/src/main/resources/bin/libggml-blas.0.dylib new file mode 120000 index 0000000..5efe2a6 --- /dev/null +++ b/src/main/resources/bin/libggml-blas.0.dylib @@ -0,0 +1 @@ +libggml-blas.0.9.5.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-blas.dylib b/src/main/resources/bin/libggml-blas.dylib new file mode 120000 index 0000000..28748a5 --- /dev/null +++ b/src/main/resources/bin/libggml-blas.dylib @@ -0,0 +1 @@ +libggml-blas.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-cpu.0.9.5.dylib b/src/main/resources/bin/libggml-cpu.0.9.5.dylib new file mode 100755 index 0000000..423f283 Binary files /dev/null and b/src/main/resources/bin/libggml-cpu.0.9.5.dylib differ diff --git a/src/main/resources/bin/libggml-cpu.0.dylib b/src/main/resources/bin/libggml-cpu.0.dylib new file mode 120000 index 0000000..d58caa5 --- /dev/null +++ b/src/main/resources/bin/libggml-cpu.0.dylib @@ -0,0 +1 @@ +libggml-cpu.0.9.5.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-cpu.dylib b/src/main/resources/bin/libggml-cpu.dylib new file mode 120000 index 0000000..ac40938 --- /dev/null +++ b/src/main/resources/bin/libggml-cpu.dylib @@ -0,0 +1 @@ +libggml-cpu.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-metal.0.9.5.dylib b/src/main/resources/bin/libggml-metal.0.9.5.dylib new file mode 100755 index 0000000..e414d5c Binary files /dev/null and b/src/main/resources/bin/libggml-metal.0.9.5.dylib differ diff --git a/src/main/resources/bin/libggml-metal.0.dylib b/src/main/resources/bin/libggml-metal.0.dylib new file mode 120000 index 0000000..adbf41f --- /dev/null +++ b/src/main/resources/bin/libggml-metal.0.dylib @@ -0,0 +1 @@ +libggml-metal.0.9.5.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-metal.dylib b/src/main/resources/bin/libggml-metal.dylib new file mode 120000 index 0000000..8210fd7 --- /dev/null +++ b/src/main/resources/bin/libggml-metal.dylib @@ -0,0 +1 @@ +libggml-metal.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-rpc.0.9.5.dylib b/src/main/resources/bin/libggml-rpc.0.9.5.dylib new file mode 100755 index 0000000..73a1782 Binary files /dev/null and b/src/main/resources/bin/libggml-rpc.0.9.5.dylib differ diff --git a/src/main/resources/bin/libggml-rpc.0.dylib b/src/main/resources/bin/libggml-rpc.0.dylib new file mode 120000 index 0000000..0ad03d3 --- /dev/null +++ b/src/main/resources/bin/libggml-rpc.0.dylib @@ -0,0 +1 @@ +libggml-rpc.0.9.5.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml-rpc.dylib b/src/main/resources/bin/libggml-rpc.dylib new file mode 120000 index 0000000..a871ded --- /dev/null +++ b/src/main/resources/bin/libggml-rpc.dylib @@ -0,0 +1 @@ +libggml-rpc.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml.0.9.5.dylib b/src/main/resources/bin/libggml.0.9.5.dylib new file mode 100755 index 0000000..deccb07 Binary files /dev/null and b/src/main/resources/bin/libggml.0.9.5.dylib differ diff --git a/src/main/resources/bin/libggml.0.dylib b/src/main/resources/bin/libggml.0.dylib new file mode 120000 index 0000000..7770e33 --- /dev/null +++ b/src/main/resources/bin/libggml.0.dylib @@ -0,0 +1 @@ +libggml.0.9.5.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libggml.dylib b/src/main/resources/bin/libggml.dylib new file mode 120000 index 0000000..0ef7111 --- /dev/null +++ b/src/main/resources/bin/libggml.dylib @@ -0,0 +1 @@ +libggml.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libllama.0.0.7787.dylib b/src/main/resources/bin/libllama.0.0.7787.dylib new file mode 100755 index 0000000..051fb6b Binary files /dev/null and b/src/main/resources/bin/libllama.0.0.7787.dylib differ diff --git a/src/main/resources/bin/libllama.0.dylib b/src/main/resources/bin/libllama.0.dylib new file mode 120000 index 0000000..88f39b3 --- /dev/null +++ b/src/main/resources/bin/libllama.0.dylib @@ -0,0 +1 @@ +libllama.0.0.7787.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libllama.dylib b/src/main/resources/bin/libllama.dylib new file mode 120000 index 0000000..d76d521 --- /dev/null +++ b/src/main/resources/bin/libllama.dylib @@ -0,0 +1 @@ +libllama.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libmtmd.0.0.7787.dylib b/src/main/resources/bin/libmtmd.0.0.7787.dylib new file mode 100755 index 0000000..80f07f7 Binary files /dev/null and b/src/main/resources/bin/libmtmd.0.0.7787.dylib differ diff --git a/src/main/resources/bin/libmtmd.0.dylib b/src/main/resources/bin/libmtmd.0.dylib new file mode 120000 index 0000000..fbbfcfd --- /dev/null +++ b/src/main/resources/bin/libmtmd.0.dylib @@ -0,0 +1 @@ +libmtmd.0.0.7787.dylib \ No newline at end of file diff --git a/src/main/resources/bin/libmtmd.dylib b/src/main/resources/bin/libmtmd.dylib new file mode 120000 index 0000000..21fd179 --- /dev/null +++ b/src/main/resources/bin/libmtmd.dylib @@ -0,0 +1 @@ +libmtmd.0.dylib \ No newline at end of file diff --git a/src/main/resources/bin/llama-server b/src/main/resources/bin/llama-server index b05226c..38f864c 100755 Binary files a/src/main/resources/bin/llama-server and b/src/main/resources/bin/llama-server differ