diff --git a/.gitignore b/.gitignore index 807493bc..82a24a15 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ openai.key *.log.* client_secret_google_oauth.json settings.gradle.kts +*.data +*.parsed.json diff --git a/core/src/main/kotlin/com/simiacryptus/skyenet/core/actors/LargeOutputActor.kt b/core/src/main/kotlin/com/simiacryptus/skyenet/core/actors/LargeOutputActor.kt new file mode 100644 index 00000000..1c03e793 --- /dev/null +++ b/core/src/main/kotlin/com/simiacryptus/skyenet/core/actors/LargeOutputActor.kt @@ -0,0 +1,112 @@ +package com.simiacryptus.skyenet.core.actors + +import com.simiacryptus.jopenai.API +import com.simiacryptus.jopenai.models.ApiModel +import com.simiacryptus.jopenai.models.ChatModel +import com.simiacryptus.jopenai.models.OpenAIModels +import com.simiacryptus.jopenai.models.TextModel +import com.simiacryptus.jopenai.util.ClientUtil.toContentList + +/** + * An actor that handles large outputs by using recursive replacement. + * It instructs the initial LLM call to use ellipsis expressions to manage result size, + * then recursively expands the result by searching for the pattern and making additional LLM calls. + */ +class LargeOutputActor( + prompt: String = """ + When generating large responses, please: + 1. Break down the content into logical sections + 2. Use named ellipsis markers like '...sectionName...' to indicate where content needs expansion + 3. Keep each section focused and concise + 4. Use descriptive section names that reflect the content + + ## Example format: + + ```markdown + # Topic Title + ## Overview + Here's an overview of the topic ...introduction... + ## Main Points + The first important aspect is ...mainPoints... + ## Technical Details + For technical details, ...technicalDetails... + ## Conclusion + To conclude, ...conclusion... + ``` + + Note: Each '...sectionName...' will be expanded in subsequent iterations. + """.trimIndent(), + name: String? = null, + model: TextModel = OpenAIModels.GPT4o, + temperature: Double = 0.3, + private val maxIterations: Int = 5, + private val ellipsisPattern: Regex = Regex("\\.\\.\\."), + private val namedEllipsisPattern: Regex = Regex("""\.\.\.(?[\w\s]+)\.\.\.""") +) : BaseActor, String>( + prompt = prompt, + name = name, + model = model, + temperature = temperature +) { + + override fun chatMessages(questions: List): Array { + val systemMessage = ApiModel.ChatMessage( + role = ApiModel.Role.system, + content = prompt.toContentList() + ) + val userMessages = questions.map { + ApiModel.ChatMessage( + role = ApiModel.Role.user, + content = it.toContentList() + ) + } + return arrayOf(systemMessage) + userMessages + } + + override fun respond(input: List, api: API, vararg messages: ApiModel.ChatMessage): String { + var accumulatedResponse = "" + var currentMessages = messages.toList() + var iterations = 0 + + while (iterations < maxIterations) { + val response = response(*currentMessages.toTypedArray(), api = api).choices.first().message?.content + ?: throw RuntimeException("No response from LLM") + + accumulatedResponse += response.trim() + + val matches = namedEllipsisPattern.findAll(response).mapNotNull { it.groups["sectionName"]?.value }.toList() + if (matches.isNotEmpty()) { + // Identify the pattern after the ellipsis to continue + val continuationRequests = matches.map { name -> + "Continue the section '$name' by expanding the ellipsis." + } + currentMessages = continuationRequests.map { request -> + ApiModel.ChatMessage( + role = ApiModel.Role.user, + content = request.toContentList() + ) + } + iterations++ + } else { + break + } + } + + if (iterations == maxIterations && namedEllipsisPattern.containsMatchIn(accumulatedResponse)) { + throw RuntimeException("Maximum iterations reached. Output may be incomplete.") + } + + return accumulatedResponse + } + + override fun withModel(model: ChatModel): LargeOutputActor { + return LargeOutputActor( + prompt = this.prompt, + name = this.name, + model = model, + temperature = this.temperature, + maxIterations = this.maxIterations, + ellipsisPattern = this.ellipsisPattern + ) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/simiacryptus/skyenet/core/actors/ParsedActor.kt b/core/src/main/kotlin/com/simiacryptus/skyenet/core/actors/ParsedActor.kt index f438b53b..0b651022 100644 --- a/core/src/main/kotlin/com/simiacryptus/skyenet/core/actors/ParsedActor.kt +++ b/core/src/main/kotlin/com/simiacryptus/skyenet/core/actors/ParsedActor.kt @@ -123,10 +123,14 @@ open class ParsedActor( } contentUnwrapped.let { - return@Function JsonUtil.fromJson( - it, resultClass - ?: throw RuntimeException("Result class undefined") - ) + try { + return@Function JsonUtil.fromJson( + it, resultClass + ?: throw RuntimeException("Result class undefined") + ) + } catch (e: Exception) { + throw RuntimeException("Failed to parse response: ${it.replace("\n", "\n ")}", e) + } } } catch (e: Exception) { log.info("Failed to parse response", e) diff --git a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/ApplicationServices.kt b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/ApplicationServices.kt index 4f0da390..b4da67a0 100644 --- a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/ApplicationServices.kt +++ b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/ApplicationServices.kt @@ -47,14 +47,11 @@ object ApplicationServices { require(!isLocked) { "ApplicationServices is locked" } field = value } - var cloud: CloudPlatformInterface? = AwsPlatform.get() set(value) { require(!isLocked) { "ApplicationServices is locked" } field = value } - - var seleniumFactory: ((ThreadPoolExecutor, Array?) -> Selenium)? = null set(value) { require(!isLocked) { "ApplicationServices is locked" } diff --git a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/AwsPlatform.kt b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/AwsPlatform.kt index 188440b9..c7ec66f9 100644 --- a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/AwsPlatform.kt +++ b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/AwsPlatform.kt @@ -2,6 +2,8 @@ package com.simiacryptus.skyenet.core.platform import com.simiacryptus.skyenet.core.platform.model.CloudPlatformInterface import org.slf4j.LoggerFactory +import software.amazon.awssdk.auth.credentials.AwsCredentialsProviderChain +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider import software.amazon.awssdk.core.SdkBytes import software.amazon.awssdk.core.sync.RequestBody @@ -19,9 +21,20 @@ open class AwsPlatform( private val bucket: String = System.getProperty("share_bucket", "share.simiacrypt.us"), override val shareBase: String = System.getProperty("share_base", "https://" + bucket), private val region: Region? = Region.US_EAST_1, - private val profileName: String = "default", + profileName: String? = System.getProperty("aws.profile", "default").let { if (it.isBlank()) null else it }, ) : CloudPlatformInterface { - open val credentialsProvider: ProfileCredentialsProvider? = ProfileCredentialsProvider.create(profileName) + + open val credentialsProvider = AwsCredentialsProviderChain.builder() + .credentialsProviders( + // Try EC2 instance profile credentials first + InstanceProfileCredentialsProvider.create(), + // Then try profile credentials if profile name is provided + profileName?.let { + ProfileCredentialsProvider.create(it) + } ?: ProfileCredentialsProvider.create() + ) + .build() + private val log = LoggerFactory.getLogger(AwsPlatform::class.java) protected open val kmsClient: KmsClient by lazy { diff --git a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/file/DataStorage.kt b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/file/DataStorage.kt index bc7c56c3..61fc9934 100644 --- a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/file/DataStorage.kt +++ b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/file/DataStorage.kt @@ -162,24 +162,29 @@ open class DataStorage( ApplicationServices.metadataStorageFactory(dataDir).deleteSession(user, session) sessionDir.deleteRecursively() } + @Deprecated("Use metadataStorage instead") override fun listSessions(dir: File, path: String): List = ApplicationServices.metadataStorageFactory(dataDir).listSessions(path) + @Deprecated("Use metadataStorage instead") override fun getSessionName( user: User?, session: Session ): String = ApplicationServices.metadataStorageFactory(dataDir).getSessionName(user, session) + @Deprecated("Use metadataStorage instead") override fun getMessageIds( user: User?, session: Session ): List = ApplicationServices.metadataStorageFactory(dataDir).getMessageIds(user, session) + @Deprecated("Use metadataStorage instead") override fun setMessageIds( user: User?, session: Session, ids: List ) = ApplicationServices.metadataStorageFactory(dataDir).setMessageIds(user, session, ids) + @Deprecated("Use metadataStorage instead") override fun getSessionTime( user: User?, diff --git a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorage.kt b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorage.kt index 9a56ee1a..386eb0be 100644 --- a/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorage.kt +++ b/core/src/main/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorage.kt @@ -14,6 +14,7 @@ class HSQLMetadataStorage(private val dbFile: File) : MetadataStorageInterface { private val log = LoggerFactory.getLogger(javaClass) private val connection: Connection by lazy { + require(dbFile.absoluteFile.exists() || dbFile.absoluteFile.mkdirs()) { "Unable to create database directory: ${dbFile.absolutePath}" } log.info("Initializing HSQLMetadataStorage with database file: ${dbFile.absolutePath}") Class.forName("org.hsqldb.jdbc.JDBCDriver") val connection = DriverManager.getConnection("jdbc:hsqldb:file:${dbFile.absolutePath}/metadata;shutdown=true", "SA", "") diff --git a/core/src/main/kotlin/com/simiacryptus/skyenet/core/util/Selenium.kt b/core/src/main/kotlin/com/simiacryptus/skyenet/core/util/Selenium.kt index 88e25fb3..c996541f 100644 --- a/core/src/main/kotlin/com/simiacryptus/skyenet/core/util/Selenium.kt +++ b/core/src/main/kotlin/com/simiacryptus/skyenet/core/util/Selenium.kt @@ -3,11 +3,22 @@ package com.simiacryptus.skyenet.core.util import java.net.URL interface Selenium : AutoCloseable { + fun navigate(url: String) + fun getPageSource(): String + fun getCurrentUrl(): String + fun executeScript(script: String): Any? + fun quit() + fun save( url: URL, currentFilename: String?, saveRoot: String ) + + abstract fun setScriptTimeout(timeout: Long) + abstract fun getBrowserInfo(): String + fun forceQuit() + abstract fun isAlive(): Boolean // // open fun setCookies( // driver: WebDriver, diff --git a/core/src/test/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorageTest.kt b/core/src/test/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorageTest.kt index 44cd0864..752a6e26 100644 --- a/core/src/test/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorageTest.kt +++ b/core/src/test/kotlin/com/simiacryptus/skyenet/core/platform/hsql/HSQLMetadataStorageTest.kt @@ -3,4 +3,4 @@ package com.simiacryptus.skyenet.core.platform.hsql import com.simiacryptus.skyenet.core.platform.test.MetadataStorageInterfaceTest import java.nio.file.Files -class HSQLMetadataStorageTest : MetadataStorageInterfaceTest(HSQLMetadataStorage(Files.createTempDirectory("metadataStorage").toFile())) \ No newline at end of file +//class HSQLMetadataStorageTest : MetadataStorageInterfaceTest(HSQLMetadataStorage(Files.createTempDirectory("metadataStorage").toFile())) \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 110c5f0e..4e9c579b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Gradle Releases -> https://github.com/gradle/gradle/releases libraryGroup=com.simiacryptus.skyenet -libraryVersion=1.2.16 +libraryVersion=1.2.17 gradleVersion=7.6.1 kotlin.daemon.jvmargs=-Xmx4g diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/CodeParsingModel.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/CodeParsingModel.kt index 965ba263..d8cae83a 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/CodeParsingModel.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/CodeParsingModel.kt @@ -8,14 +8,12 @@ import com.simiacryptus.skyenet.core.actors.ParsedActor open class CodeParsingModel( private val parsingModel: ChatModel, private val temperature: Double -) : ParsingModel { +) : ParsingModel { override fun merge( - runningDocument: ParsingModel.DocumentData, - newData: ParsingModel.DocumentData - ): ParsingModel.DocumentData { - val runningDocument = runningDocument as CodeData - val newData = newData as CodeData + runningDocument: CodeData, + newData: CodeData + ): CodeData { return CodeData( id = newData.id ?: runningDocument.id, content_list = mergeContent(runningDocument.content_list, newData.content_list).takeIf { it.isNotEmpty() }, @@ -40,7 +38,11 @@ open class CodeParsingModel( protected open fun mergeContentData(existing: CodeContent, new: CodeContent) = existing.copy( content_list = mergeContent(existing.content_list, new.content_list).takeIf { it.isNotEmpty() }, - tags = ((existing.tags ?: emptyList()) + (new.tags ?: emptyList())).distinct().takeIf { it.isNotEmpty() } + tags = ((existing.tags ?: emptyList()) + (new.tags ?: emptyList())).distinct().takeIf { it.isNotEmpty() }, + startLine = new.startLine ?: existing.startLine, + endLine = new.endLine ?: existing.endLine, + startPos = new.startPos ?: existing.startPos, + endPos = new.endPos ?: existing.endPos ) open val promptSuffix = """ @@ -48,12 +50,13 @@ Parse the code into a structured format that describes its components: 1. Separate the content into sections, paragraphs, statements, etc. 2. All source content should be included in the output, with paraphrasing, corrections, and context as needed 3. Each content leaf node text should be simple and self-contained -4. Assign relevant tags to each node to improve searchability and categorization. +4. Assign relevant tags to each node to improve searchability and categorization +5. Track line numbers and character positions for each content node when possible """.trimMargin() open val exampleInstance = CodeData() - override fun getParser(api: API): (String) -> CodeData { + override fun getFastParser(api: API): (String) -> CodeData { val parser = ParsedActor( resultClass = CodeData::class.java, exampleInstance = exampleInstance, @@ -77,7 +80,11 @@ Parse the code into a structured format that describes its components: @Description("Content type, e.g. function, class, comment") override val type: String = "", @Description("Brief, self-contained text either copied, paraphrased, or summarized") override val text: String? = null, @Description("Sub-elements") override val content_list: List? = null, - @Description("Tags - related topics and non-entity indexing") override val tags: List? = null + @Description("Tags - related topics and non-entity indexing") override val tags: List? = null, + @Description("Starting line number in source") val startLine: Int? = null, + @Description("Ending line number in source") val endLine: Int? = null, + @Description("Starting character position") val startPos: Int? = null, + @Description("Ending character position") val endPos: Int? = null ) : ParsingModel.ContentData companion object { diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParserApp.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParserApp.kt index ce6dcf91..a8ee26d1 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParserApp.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParserApp.kt @@ -1,5 +1,6 @@ package com.simiacryptus.skyenet.apps.parse +import com.google.common.util.concurrent.Futures import com.simiacryptus.jopenai.API import com.simiacryptus.jopenai.ChatClient import com.simiacryptus.skyenet.TabbedDisplay @@ -16,6 +17,7 @@ import com.simiacryptus.skyenet.webui.session.SocketManager import com.simiacryptus.util.JsonUtil import java.awt.image.BufferedImage import java.io.File +import java.io.IOException import java.nio.file.Path import java.util.* import javax.imageio.ImageIO @@ -25,7 +27,7 @@ open class DocumentParserApp( applicationName: String = "Document Extractor", path: String = "/pdfExtractor", val api: API = ChatClient(), - val parsingModel: ParsingModel, + val parsingModel: ParsingModel, val reader: (File) -> DocumentReader = { when { it.name.endsWith(".pdf", ignoreCase = true) -> PDFReader(it) @@ -33,6 +35,7 @@ open class DocumentParserApp( } }, val fileInputs: List? = null, + val fastMode: Boolean = true ) : ApplicationServer( applicationName = applicationName, path = path, @@ -89,6 +92,11 @@ open class DocumentParserApp( progressBar: ProgressState? = null ) { try { + // Validate inputs + if (fileInputs.isEmpty()) { + throw IllegalArgumentException("No input files provided") + } + mainTask.header("PDF Extractor") val api = (api as ChatClient).getChildClient().apply { val createFile = mainTask.createFile(".logs/api-${UUID.randomUUID()}.log") @@ -97,6 +105,12 @@ open class DocumentParserApp( mainTask.verbose("API log: $this") } } + // Create output directory + val outputDir = root.resolve("output").apply { mkdirs() } + if (!outputDir.exists()) { + throw IOException("Failed to create output directory: $outputDir") + } + val docTabs = TabbedDisplay(mainTask) fileInputs.map { it.toFile() }.forEach { file -> if (!file.exists()) { @@ -108,10 +122,14 @@ open class DocumentParserApp( val pageTabs = TabbedDisplay(docTask) val outputDir = root.resolve("output").apply { mkdirs() } reader(file).use { reader -> + if (reader is TextReader) { + reader.configure(settings) + } var previousPageText = "" // Keep this for context val pageCount = minOf(reader.getPageCount(), maxPages) val pageSets = 0 until pageCount step pagesPerBatch progressBar?.add(0.0, pageCount.toDouble()) + var runningDocument = parsingModel.newDocument() val futures = pageSets.toList().mapNotNull { batchStart -> val pageTask = ui.newTask(false) val api = api.getChildClient().apply { @@ -174,41 +192,15 @@ open class DocumentParserApp( """.trimMargin() ) previousPageText = text - ui.socketManager.pool.submit { - try { - val jsonResult = parsingModel.getParser(api)(promptList.toList().joinToString("\n\n")) - if (settings.saveTextFiles) { - val jsonFile = outputDir.resolve("pages_${batchStart}_to_${batchEnd}_content.json") - jsonFile.writeText(JsonUtil.toJson(jsonResult)) - } - ui.newTask(false).apply { - pageTabs["Text"] = placeholder - add( - MarkdownUtil.renderMarkdown( - "\n```text\n${ - text - }\n```\n", ui = ui - ) - ) - } - ui.newTask(false).apply { - pageTabs["JSON"] = placeholder - add( - MarkdownUtil.renderMarkdown( - "\n```json\n${ - JsonUtil.toJson(jsonResult) - }\n```\n", ui = ui - ) - ) - } - jsonResult - } catch (e: Throwable) { - pageTask.error(ui, e) - null - } finally { - progressBar?.add(1.0, 0.0) - pageTask.complete() + if (fastMode) { + ui.socketManager.pool.submit { + val jsonResult = parsingModel.getFastParser(api)(promptList.toList().joinToString("\n\n")) + handleParseResult(settings, outputDir, batchStart, batchEnd, jsonResult, ui, pageTabs, text, pageTask, progressBar) } + } else { + val jsonResult = parsingModel.getSmartParser(api)(runningDocument, promptList.toList().joinToString("\n\n")) + runningDocument = handleParseResult(settings, outputDir, batchStart, batchEnd, jsonResult, ui, pageTabs, text, pageTask, progressBar)!! + Futures.immediateFuture(runningDocument) } } catch (e: Throwable) { pageTask.error(ui, e) @@ -255,6 +247,72 @@ open class DocumentParserApp( } } + private fun handleParseResult( + settings: Settings, + outputDir: File, + batchStart: Int, + batchEnd: Int, + jsonResult: DocumentData, + ui: ApplicationInterface, + pageTabs: TabbedDisplay, + text: String, + pageTask: SessionTask, + progressBar: ProgressState? + ): DocumentData? { + // Generate consistent file name pattern + val fileBaseName = generateFileBaseName(batchStart, batchEnd) + + return try { + if (settings.saveTextFiles) { + val jsonFile = outputDir.resolve(generateJsonFileName(fileBaseName)) + jsonFile.writeText(JsonUtil.toJson(jsonResult)) + } + ui.newTask(false).apply { + pageTabs["Text"] = placeholder + add( + MarkdownUtil.renderMarkdown( + generateMarkdownCodeBlock("text", text, settings), + ui = ui + ) + ) + } + ui.newTask(false).apply { + pageTabs["JSON"] = placeholder + add( + MarkdownUtil.renderMarkdown( + generateMarkdownCodeBlock("json", JsonUtil.toJson(jsonResult), settings), + ui = ui + ) + ) + } + jsonResult + } catch (e: Throwable) { + pageTask.error(ui, e) + null + } finally { + progressBar?.add(1.0, 0.0) + pageTask.complete() + } + } + + private fun generateFileBaseName(batchStart: Int, batchEnd: Int): String = + "pages_${batchStart}_to_${batchEnd}" + + private fun generateJsonFileName(baseName: String): String = + "${baseName}_content.json" + + private fun generateMarkdownCodeBlock(language: String, content: String, settings: Settings): String = + if (settings.addLineNumbers) { + val lines = content.lines() + val maxDigits = lines.size.toString().length + val numberedLines = lines.mapIndexed { index, line -> + String.format("%${maxDigits}d | %s", index + 1, line) + }.joinToString("\n") + "\n```$language\n$numberedLines\n```\n" + } else { + "\n```$language\n$content\n```\n" + } + data class Settings( val dpi: Float = 120f, val maxPages: Int = Int.MAX_VALUE, @@ -264,7 +322,9 @@ open class DocumentParserApp( val pagesPerBatch: Int = 1, val saveImageFiles: Boolean = false, val saveTextFiles: Boolean = false, - val saveFinalJson: Boolean = true + val saveFinalJson: Boolean = true, + val fastMode: Boolean = true, + val addLineNumbers: Boolean = false ) override val settingsClass: Class<*> get() = Settings::class.java diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParsingModel.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParsingModel.kt index 91368ecc..63b81908 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParsingModel.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentParsingModel.kt @@ -15,14 +15,12 @@ import java.util.concurrent.Future open class DocumentParsingModel( private val parsingModel: ChatModel, private val temperature: Double -) : ParsingModel { +) : ParsingModel { override fun merge( - runningDocument: ParsingModel.DocumentData, - newData: ParsingModel.DocumentData - ): ParsingModel.DocumentData { - val runningDocument = runningDocument as DocumentData - val newData = newData as DocumentData + runningDocument: DocumentData, + newData: DocumentData + ): DocumentData { return DocumentData( id = newData.id ?: runningDocument.id, content_list = mergeContent(runningDocument.content_list, newData.content_list).takeIf { it.isNotEmpty() }, @@ -60,7 +58,7 @@ open class DocumentParsingModel( open val exampleInstance = DocumentData() - override fun getParser(api: API): (String) -> DocumentData { + override fun getFastParser(api: API): (String) -> DocumentData { val parser = ParsedActor( resultClass = DocumentData::class.java, exampleInstance = exampleInstance, diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentRecord.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentRecord.kt index 8fcf44a8..9960f496 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentRecord.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/DocumentRecord.kt @@ -19,7 +19,29 @@ data class DocumentRecord( val sourcePath: String, val jsonPath: String, var vector: DoubleArray?, -) : Serializable { +) : Serializable { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DocumentRecord + if (text != other.text) return false + if (metadata != other.metadata) return false + if (sourcePath != other.sourcePath) return false + if (jsonPath != other.jsonPath) return false + if (vector != null) { + if (other.vector == null) return false + if (!vector.contentEquals(other.vector)) return false + } else if (other.vector != null) return false + return true + } + override fun hashCode(): Int { + var result = text?.hashCode() ?: 0 + result = 31 * result + (metadata?.hashCode() ?: 0) + result = 31 * result + sourcePath.hashCode() + result = 31 * result + jsonPath.hashCode() + result = 31 * result + (vector?.contentHashCode() ?: 0) + return result + } @Throws(IOException::class) fun writeObject(out: ObjectOutputStream) { out.writeUTF(text ?: "") @@ -106,5 +128,4 @@ data class DocumentRecord( return records } } -} - +} \ No newline at end of file diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/LogDataParsingModel.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/LogDataParsingModel.kt new file mode 100644 index 00000000..23850f49 --- /dev/null +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/LogDataParsingModel.kt @@ -0,0 +1,177 @@ +package com.simiacryptus.skyenet.apps.parse + +import com.simiacryptus.jopenai.API +import com.simiacryptus.jopenai.describe.Description +import com.simiacryptus.jopenai.models.ChatModel + +open class LogDataParsingModel( + private val parsingModel: ChatModel, + private val temperature: Double +) : ParsingModel { + private val maxIterations = 10 // Make constant value explicit for clarity + + + override fun merge( + runningDocument: LogData, + newData: LogData + ): LogData { + return LogData( + id = newData.id ?: runningDocument.id, + patterns = (runningDocument.patterns ?: emptyList()) + (newData.patterns ?: emptyList()), + matches = (runningDocument.matches ?: emptyList()) + (newData.matches ?: emptyList()), + ) + } + + private fun mergeRemainingText(text1: String?, text2: String?): String? { + return when { + text1.isNullOrBlank() -> text2 + text2.isNullOrBlank() -> text1 + else -> "$text1\n$text2" + }?.takeIf { it.isNotBlank() } + } + + open val exampleInstance = LogData() + + override fun getFastParser(api: API): (String) -> LogData { + val patternGenerator = LogPatternGenerator(parsingModel, temperature) + + return { originalText -> + var remainingText = originalText + var result: LogData? = null + var iterationCount = 0 + + try { + while (remainingText.isNotBlank() && iterationCount++ < maxIterations) { + val patterns = patternGenerator.generatePatterns(api, remainingText) + if (patterns.isEmpty()) break + val applyPatterns = applyPatterns(remainingText, (result?.patterns ?: emptyList()) + patterns) + result = applyPatterns.first + remainingText = applyPatterns.second + } + } catch (e: Exception) { + log.error("Error parsing log data", e) + } + result ?: LogData() + } + } + + private fun applyPatterns(text: String, patterns: List): Pair { + val patterns = patterns.filter { it.regex != null }.groupBy { it.id }.map { it.value.first() } + val matches = patterns.flatMap { pattern -> + try { + val regexOptions = mutableSetOf().apply { + if (pattern.multiline) add(RegexOption.MULTILINE) + if (pattern.dotMatchesAll) add(RegexOption.DOT_MATCHES_ALL) + if (pattern.ignoreCase) add(RegexOption.IGNORE_CASE) + if (pattern.comments) add(RegexOption.COMMENTS) + } + val regex = pattern.regex?.toRegex(regexOptions) + val matches = regex?.findAll(text)?.toList() ?: emptyList() + matches.map { pattern to it } + } catch (e: Exception) { + log.error("Error applying pattern ${pattern.id}", e) + emptyList() + } + }.sortedBy { + it.second.range.first + }.toTypedArray() + val matchesWithRanges = mutableListOf>>() + val occupiedIndices = mutableListOf() + matches.forEach { matchResult: Pair -> + val range = matchResult.second.range + val filter = occupiedIndices.filter { it.overlaps(range) } + if (filter.isEmpty()) { + val captures = extractCaptures((matchResult.first.regex ?: "").toRegex(), matchResult.second) + if (captures.isNotEmpty()) { + matchesWithRanges.add( + MatchData( + patternId = matchResult.first.id ?: "pattern_${patterns.indexOf(matchResult.first)}", + captures = captures + ) to matchResult + ) + } else { + matchesWithRanges.add( + MatchData( + patternId = matchResult.first.id ?: "pattern_${patterns.indexOf(matchResult.first)}" + ) to matchResult + ) + } + occupiedIndices.add(range) + } + } + val remainingText = matchesWithRanges.reversed().fold(text) { acc, match -> + acc.replaceRange(match.second.second.range, "") + } + // Sort matches by their start index to ensure correct ordering + return LogData( + matches = matchesWithRanges.sortedBy { it.second.second.range.start }.map { it.first }, + patterns = matchesWithRanges.map { it.second.first }.distinct() + ) to remainingText + } + + private fun extractCaptures(regex: Regex, matchResult: MatchResult): Map { + // Improved named group detection + val namedGroups = regex.pattern + .split("(?<") + .drop(1) + .map { it.substringBefore(">") } + + return matchResult.groups + .filterNotNull() + .mapIndexed { index, group -> + when { + index == 0 -> null // Skip the full match + index <= namedGroups.size -> namedGroups[index - 1] to group.value + else -> "group${index}" to group.value + } + } + .filterNotNull() + .toMap() + } + + + override fun newDocument() = LogData() + + data class LogData( + @Description("Log file identifier") + override val id: String? = null, + @Description("List of identified regex patterns") + val patterns: List? = null, + @Description("List of pattern matches found") + val matches: List? = null, + ) : ParsingModel.DocumentData { + override val content_list: List? = null + } + + data class PatternData( + @Description("Unique identifier for the pattern") + val id: String? = null, + @Description("Regular expression with named capture groups") + val regex: String? = null, + @Description("Description of what this pattern matches") + val description: String? = null, + @Description("Enable multiline mode - ^ and $ match line start/end") + val multiline: Boolean = true, + @Description("Enable dot matches all - dot matches newlines") + val dotMatchesAll: Boolean = true, + @Description("Enable case insensitive matching") + val ignoreCase: Boolean = false, + @Description("Enable comments and whitespace in pattern") + val comments: Boolean = false + ) + + data class MatchData( + @Description("The ID of the pattern that matched") + val patternId: String? = null, + @Description("Map of captured group names to values") + val captures: Map? = null + ) + + companion object { + private val log = org.slf4j.LoggerFactory.getLogger(LogDataParsingModel::class.java) + } +} + +private fun IntRange.overlaps(range: IntRange): Boolean { + return this.first <= range.last && this.last >= range.first || range.first <= this.last && range.last >= this.first +} \ No newline at end of file diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/LogPatternGenerator.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/LogPatternGenerator.kt new file mode 100644 index 00000000..603d6ccf --- /dev/null +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/LogPatternGenerator.kt @@ -0,0 +1,39 @@ +package com.simiacryptus.skyenet.apps.parse + +import com.simiacryptus.jopenai.API +import com.simiacryptus.jopenai.describe.Description +import com.simiacryptus.jopenai.models.ChatModel +import com.simiacryptus.skyenet.core.actors.ParsedActor + +class LogPatternGenerator( + private val parsingModel: ChatModel, + private val temperature: Double +) { + data class PatternResponse( + @Description("List of identified regex patterns") + val patterns: List? = null + ) + + private val promptSuffix = """ + Analyze the log text and identify regular expressions that can parse individual log messages. + For each pattern: + 1. Create a regex that captures important fields as named groups + 2. Capture names should use only letters in camelCase + 3. Ensure the pattern is specific enough to avoid false matches + 4. Describe what type of log message the pattern identifies + + Return only the regex patterns with descriptions, no matches or analysis. + """.trimMargin() + + fun generatePatterns(api: API, text: String): List { + val parser = ParsedActor( + resultClass = PatternResponse::class.java, + exampleInstance = PatternResponse(), + prompt = "", + parsingModel = parsingModel, + temperature = temperature + ).getParser(api, promptSuffix = promptSuffix) + + return parser.apply(text).patterns ?: emptyList() + } +} \ No newline at end of file diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/ParsingModel.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/ParsingModel.kt index e382e031..bdc40855 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/ParsingModel.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/ParsingModel.kt @@ -2,12 +2,16 @@ package com.simiacryptus.skyenet.apps.parse import com.simiacryptus.jopenai.API -interface ParsingModel { - fun merge(runningDocument: DocumentData, newData: DocumentData): DocumentData - fun getParser(api: API): (String) -> DocumentData - fun newDocument(): DocumentData +interface ParsingModel { + fun merge(runningDocument: T, newData: T): T + fun getFastParser(api: API): (String) -> T = { prompt -> + getSmartParser(api)(newDocument(), prompt) + } + fun getSmartParser(api: API): (T, String) -> T = { runningDocument, prompt -> + getFastParser(api)(prompt) + } + fun newDocument(): T - interface DocumentMetadata interface ContentData { val type: String val text: String? @@ -18,7 +22,6 @@ interface ParsingModel { interface DocumentData { val id: String? val content_list: List? -// val metadata: DocumentMetadata? } companion object { diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/ParsingModelType.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/ParsingModelType.kt new file mode 100644 index 00000000..c5bf617c --- /dev/null +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/ParsingModelType.kt @@ -0,0 +1,56 @@ +package com.simiacryptus.skyenet.apps.parse + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.simiacryptus.jopenai.models.ChatModel +import com.simiacryptus.util.DynamicEnum +import com.simiacryptus.util.DynamicEnumDeserializer +import com.simiacryptus.util.DynamicEnumSerializer + +@JsonDeserialize(using = ParsingModelTypeDeserializer::class) +@JsonSerialize(using = ParsingModelTypeSerializer::class) +class ParsingModelType>( + name: String, + val modelClass: Class +) : DynamicEnum>(name) { + companion object { + private val modelConstructors = + mutableMapOf, (ChatModel, Double) -> ParsingModel<*>>() + + val Document = ParsingModelType("Document", DocumentParsingModel::class.java) + val Code = ParsingModelType("Code", CodeParsingModel::class.java) + val Log = ParsingModelType("Log", LogDataParsingModel::class.java) + + init { + registerConstructor(Document) { model, temp -> DocumentParsingModel(model, temp) } + registerConstructor(Code) { model, temp -> CodeParsingModel(model, temp) } + registerConstructor(Log) { model, temp -> LogDataParsingModel(model, temp) } + } + + private fun > registerConstructor( + modelType: ParsingModelType, + constructor: (ChatModel, Double) -> T + ) { + modelConstructors[modelType] = constructor + register(modelType) + } + + fun values() = values(ParsingModelType::class.java) + + fun getImpl( + chatModel: ChatModel, + temperature: Double, + modelType: ParsingModelType<*> + ): ParsingModel<*> { + val constructor = modelConstructors[modelType] + ?: throw RuntimeException("Unknown parsing model type: ${modelType.name}") + return constructor(chatModel, temperature) + } + + fun valueOf(name: String): ParsingModelType<*> = valueOf(ParsingModelType::class.java, name) + private fun register(modelType: ParsingModelType<*>) = register(ParsingModelType::class.java, modelType) + } +} + +class ParsingModelTypeSerializer : DynamicEnumSerializer>(ParsingModelType::class.java) +class ParsingModelTypeDeserializer : DynamicEnumDeserializer>(ParsingModelType::class.java) \ No newline at end of file diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/TextReader.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/TextReader.kt index c55fa0af..54de29f6 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/TextReader.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/parse/TextReader.kt @@ -5,11 +5,20 @@ import java.io.File class TextReader(private val textFile: File) : DocumentParserApp.DocumentReader { private val pages: List = splitIntoPages(textFile.readLines().joinToString("\n")) + private lateinit var settings: DocumentParserApp.Settings + fun configure(settings: DocumentParserApp.Settings) { + this.settings = settings + } override fun getPageCount(): Int = pages.size override fun getText(startPage: Int, endPage: Int): String { - return pages.subList(startPage, endPage.coerceAtMost(pages.size)).joinToString("\n") + val text = pages.subList(startPage, endPage.coerceAtMost(pages.size)).joinToString("\n") + return if (settings.addLineNumbers) { + text.lines().mapIndexed { index, line -> + "${(index + 1).toString().padStart(6)}: $line" + }.joinToString("\n") + } else text } override fun renderImage(pageIndex: Int, dpi: Float): BufferedImage { @@ -25,8 +34,8 @@ class TextReader(private val textFile: File) : DocumentParserApp.DocumentReader val lines = text.split("\n") if (lines.size <= 1) return listOf(text) val splitFitnesses = lines.indices.map { i -> - val leftSize = lines.subList(0, i).joinToString("\n").length - val rightSize = lines.subList(i, lines.size).joinToString("\n").length + val leftSize = lines.subList(0, i).map { it.length }.sum() + val rightSize = lines.subList(i, lines.size).map { it.length }.sum() if (leftSize <= 0) return@map i to Double.MAX_VALUE if (rightSize <= 0) return@map i to Double.MAX_VALUE var fitness = -((leftSize.toDouble() / text.length) * Math.log1p(rightSize.toDouble() / text.length) + diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/CommandSessionTask.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/CommandSessionTask.kt new file mode 100644 index 00000000..8c7c87c9 --- /dev/null +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/CommandSessionTask.kt @@ -0,0 +1,197 @@ +package com.simiacryptus.skyenet.apps.plan + +import com.simiacryptus.jopenai.ChatClient +import com.simiacryptus.jopenai.OpenAIClient +import com.simiacryptus.jopenai.describe.Description +import com.simiacryptus.skyenet.core.platform.ApplicationServices +import com.simiacryptus.skyenet.webui.session.SessionTask +import org.slf4j.LoggerFactory +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +class CommandSessionTask( + planSettings: PlanSettings, + planTask: CommandSessionTaskData? +) : AbstractTask(planSettings, planTask) { + companion object { + private val log = LoggerFactory.getLogger(CommandSessionTask::class.java) + private val activeSessions = ConcurrentHashMap() + private const val TIMEOUT_MS = 30000L // 30 second timeout + private const val MAX_SESSIONS = 10 // Maximum number of concurrent sessions + + fun closeSession(sessionId: String) { + activeSessions.remove(sessionId)?.let { process -> + try { + process.destroy() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + } catch (e: Exception) { + log.error("Error closing session $sessionId", e) + throw e + } + } + } + + private fun cleanupInactiveSessions() { + activeSessions.entries.removeIf { (id, process) -> + try { + if (!process.isAlive) { + log.info("Removing inactive session $id") + true + } else false + } catch (e: Exception) { + log.warn("Error checking session $id, removing", e) + process.destroyForcibly() + true + } + } + } + + fun closeAllSessions() { + activeSessions.forEach { (id, process) -> + try { + process.destroy() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + } catch (e: Exception) { + log.error("Error closing session $id", e) + } + } + activeSessions.clear() + } + + fun getActiveSessionCount(): Int = activeSessions.size + } + + class CommandSessionTaskData( + @Description("The command to start the interactive session") + val command: List, + @Description("Commands to send to the interactive session") + val inputs: List = listOf(), + @Description("Session ID for reusing existing sessions") + val sessionId: String? = null, + @Description("Timeout in milliseconds for commands") + val timeout: Long = TIMEOUT_MS, + @Description("Whether to close the session after execution") + val closeSession: Boolean = false, + task_description: String? = null, + task_dependencies: List? = null, + state: TaskState? = null, + ) : PlanTaskBase( + task_type = TaskType.CommandSession.name, + task_description = task_description, + task_dependencies = task_dependencies, + state = state + ) + + override fun promptSegment(): String { + val activeSessionsInfo = activeSessions.keys.joinToString("\n") { id -> + " ** Session $id" + } + return """ + CommandSession - Create and manage a stateful interactive command session + ** Specify the command to start an interactive session + ** Provide inputs to send to the session + ** Session persists between commands for stateful interactions + ** Optionally specify sessionId to reuse an existing session + ** Set closeSession=true to close the session after execution + Active Sessions: + $activeSessionsInfo + """.trimMargin() + } + + override fun run( + agent: PlanCoordinator, + messages: List, + task: SessionTask, + api: ChatClient, + resultFn: (String) -> Unit, + api2: OpenAIClient, + planSettings: PlanSettings + ) { + requireNotNull(planTask) { "CommandSessionTaskData is required" } + var process: Process? = null + try { + cleanupInactiveSessions() + if (activeSessions.size >= MAX_SESSIONS && planTask.sessionId == null) { + throw IllegalStateException("Maximum number of concurrent sessions ($MAX_SESSIONS) reached") + } + + process = planTask.sessionId?.let { id -> activeSessions[id] } + ?: ProcessBuilder(planTask.command) + .redirectErrorStream(true) + .start() + .also { newProcess -> + planTask.sessionId?.let { id -> activeSessions[id] = newProcess } + } + + val reader = BufferedReader(InputStreamReader(process?.inputStream)) + val writer = PrintWriter(process?.outputStream, true) + + val results = planTask.inputs.map { input -> + try { + writer.println(input) + val output = StringBuilder() + val endTime = System.currentTimeMillis() + planTask.timeout + while (System.currentTimeMillis() < endTime) { + if (reader.ready()) { + val line = reader.readLine() + if (line != null) output.append(line).append("\n") + } else { + Thread.sleep(100) + } + } + output.toString() + } catch (e: Exception) { + log.error("Error executing command: $input", e) + "Error: ${e.message}" + } + } + + val result = formatResults(planTask, results) + task.add(result) + resultFn(result) + + } finally { + if ((planTask.sessionId == null || planTask.closeSession) && process != null) { + try { + process.destroy() + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + if (planTask.sessionId != null) { + activeSessions.remove(planTask.sessionId) + } + } catch (e: Exception) { + log.error("Error closing process", e) + } + } + } + } + + private fun formatResults( + planTask: CommandSessionTaskData, + results: List + ): String = buildString { + appendLine("## Command Session Results") + appendLine("Command: ${planTask.command.joinToString(" ")}") + appendLine("Session ID: ${planTask.sessionId ?: "temporary"}") + appendLine("Timeout: ${planTask.timeout}ms") + appendLine("\nCommand Results:") + results.forEachIndexed { index, result -> + appendLine("### Input ${index + 1}") + appendLine("```") + appendLine(planTask.inputs[index]) + appendLine("```") + appendLine("Output:") + appendLine("```") + appendLine(result.take(5000)) // Limit result size + appendLine("```") + } + } +} \ No newline at end of file diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/SeleniumSessionTask.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/SeleniumSessionTask.kt new file mode 100644 index 00000000..5ad8942a --- /dev/null +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/SeleniumSessionTask.kt @@ -0,0 +1,219 @@ +package com.simiacryptus.skyenet.apps.plan + +import com.simiacryptus.jopenai.ChatClient +import com.simiacryptus.jopenai.OpenAIClient +import com.simiacryptus.jopenai.describe.Description +import com.simiacryptus.skyenet.core.platform.ApplicationServices +import com.simiacryptus.skyenet.core.util.Selenium +import com.simiacryptus.skyenet.webui.session.SessionTask +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap + +class SeleniumSessionTask( + planSettings: PlanSettings, + planTask: SeleniumSessionTaskData? +) : AbstractTask(planSettings, planTask) { + companion object { + private val log = LoggerFactory.getLogger(SeleniumSessionTask::class.java) + private val activeSessions = ConcurrentHashMap() + private const val TIMEOUT_MS = 30000L // 30 second timeout + private const val MAX_SESSIONS = 10 // Maximum number of concurrent sessions + + fun closeSession(sessionId: String) { + activeSessions.remove(sessionId)?.let { session -> + try { + session.quit() + } catch (e: Exception) { + log.error("Error closing session $sessionId", e) + session.forceQuit() // Add force quit as fallback + throw e // Propagate exception after cleanup + } + } + } + + private fun cleanupInactiveSessions() { + activeSessions.entries.removeIf { (id, session) -> + try { + if (!session.isAlive()) { + log.info("Removing inactive session $id") + session.quit() + true + } else false + } catch (e: Exception) { + log.warn("Error checking session $id, removing", e) + try { + session.forceQuit() + } catch (e2: Exception) { + log.error("Failed to force quit session $id", e2) + } + true + } + } + } + + fun closeAllSessions() { + activeSessions.forEach { (id, session) -> + try { + session.quit() + } catch (e: Exception) { + log.error("Error closing session $id", e) + try { + session.forceQuit() + } catch (e2: Exception) { + log.error("Failed to force quit session $id", e2) + } + } + } + activeSessions.clear() + } + + fun getActiveSessionCount(): Int = activeSessions.size + private fun createErrorMessage(e: Exception, command: String): String = buildString { + append("Error: ${e.message}\n") + append(e.stackTrace.take(3).joinToString("\n")) + append("\nFailed command: $command") + } + } + + class SeleniumSessionTaskData( + @Description("The URL to navigate to") + val url: String, + @Description("JavaScript commands to execute") + val commands: List = listOf(), + @Description("Session ID for reusing existing sessions") + val sessionId: String? = null, + @Description("Timeout in milliseconds for commands") + val timeout: Long = TIMEOUT_MS, + @Description("Whether to close the session after execution") + val closeSession: Boolean = false, + task_description: String? = null, + task_dependencies: List? = null, + state: TaskState? = null, + ) : PlanTaskBase( + task_type = TaskType.SeleniumSession.name, + task_description = task_description, + task_dependencies = task_dependencies, + state = state + ) + + override fun promptSegment(): String { + val activeSessionsInfo = activeSessions.entries.joinToString("\n") { (id, session) -> + " ** Session $id: ${session.getCurrentUrl()}" + } + return """ + SeleniumSession - Create and manage a stateful Selenium browser session + ** Specify the URL to navigate to + ** Provide JavaScript commands to execute in sequence + ** Can be used for web scraping, testing, or automation + ** Session persists between commands for stateful interactions + ** Optionally specify sessionId to reuse an existing session + ** Set closeSession=true to close the session after execution + Active Sessions: + $activeSessionsInfo + """.trimMargin() + } + + override fun run( + agent: PlanCoordinator, + messages: List, + task: SessionTask, + api: ChatClient, + resultFn: (String) -> Unit, + api2: OpenAIClient, + planSettings: PlanSettings + ) { + val seleniumFactory = ApplicationServices.seleniumFactory + ?: throw IllegalStateException("Selenium not configured") + requireNotNull(planTask) { "SeleniumSessionTaskData is required" } + var selenium: Selenium? = null + try { + // Cleanup inactive sessions before potentially creating new one + cleanupInactiveSessions() + // Check session limit + if (activeSessions.size >= MAX_SESSIONS && planTask.sessionId == null) { + throw IllegalStateException("Maximum number of concurrent sessions ($MAX_SESSIONS) reached") + } + selenium = planTask.sessionId?.let { id -> activeSessions[id] } + ?: seleniumFactory(agent.pool, null).also { newSession -> + planTask.sessionId?.let { id -> activeSessions[id] = newSession } + } + log.info("Starting Selenium session ${planTask.sessionId ?: "temporary"} for URL: ${planTask.url} with timeout ${planTask.timeout}ms") + + selenium.setScriptTimeout(planTask.timeout) + + // Navigate to initial URL + if (planTask.sessionId == null) { + selenium.navigate(planTask.url) + } + + // Execute each command in sequence + val results = planTask.commands.map { command -> + try { + log.debug("Executing command: $command") + val startTime = System.currentTimeMillis() + val result = selenium.executeScript(command)?.toString() ?: "null" + val duration = System.currentTimeMillis() - startTime + log.debug("Command completed in ${duration}ms") + result + } catch (e: Exception) { + log.error("Error executing command: $command", e) + createErrorMessage(e, command) + } + } + + val result = formatResults(planTask, selenium, results) + + task.add(result) + resultFn(result) + } finally { + // Close session if it's temporary or explicitly requested to be closed + if ((planTask.sessionId == null || planTask.closeSession) && selenium != null) { + log.info("Closing temporary session") + try { + selenium.quit() + if (planTask.sessionId != null) { + activeSessions.remove(planTask.sessionId) + } + } catch (e: Exception) { + log.error("Error closing temporary session", e) + selenium.forceQuit() + if (planTask.sessionId != null) { + activeSessions.remove(planTask.sessionId) + } + } + } + } + } + + private fun formatResults( + planTask: SeleniumSessionTaskData, + selenium: Selenium, + results: List + ): String = buildString(capacity = 16384) { // Pre-allocate buffer for better performance + appendLine("## Selenium Session Results") + appendLine("Initial URL: ${planTask.url}") + appendLine("Session ID: ${planTask.sessionId ?: "temporary"}") + appendLine("Final URL: ${selenium.getCurrentUrl()}") + appendLine("Timeout: ${planTask.timeout}ms") + appendLine("Browser Info: ${selenium.getBrowserInfo()}") + appendLine("\nCommand Results:") + results.forEachIndexed { index, result -> + appendLine("### Command ${index + 1}") + appendLine("```javascript") + appendLine(planTask.commands[index]) + appendLine("```") + appendLine("Result:") + appendLine("```") + appendLine(result.take(5000)) // Limit result size + appendLine("```") + } + try { + appendLine("\nFinal Page Source:") + appendLine("```html") + appendLine(selenium.getPageSource().take(10000)) // Limit page source size + appendLine("```") + } catch (e: Exception) { + appendLine("\nError getting page source: ${e.message}") + } + } +} \ No newline at end of file diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/TaskType.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/TaskType.kt index 89aaccd0..68443dd9 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/TaskType.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/TaskType.kt @@ -61,6 +61,8 @@ class TaskType( val WebFetchAndTransform = TaskType("WebFetchAndTransform", WebFetchAndTransformTask.WebFetchAndTransformTaskData::class.java) val KnowledgeIndexing = TaskType("KnowledgeIndexing", KnowledgeIndexingTask.KnowledgeIndexingTaskData::class.java) val WebSearchAndIndex = TaskType("WebSearchAndIndex", WebSearchAndIndexTask.WebSearchAndIndexTaskData::class.java) + val SeleniumSession = TaskType("SeleniumSession", SeleniumSessionTask.SeleniumSessionTaskData::class.java) + val CommandSession = TaskType("CommandSession", CommandSessionTask.CommandSessionTaskData::class.java) init { registerConstructor(CommandAutoFix) { settings, task -> CommandAutoFixTask(settings, task) } @@ -83,6 +85,8 @@ class TaskType( registerConstructor(WebFetchAndTransform) { settings, task -> WebFetchAndTransformTask(settings, task) } registerConstructor(KnowledgeIndexing) { settings, task -> KnowledgeIndexingTask(settings, task) } registerConstructor(WebSearchAndIndex) { settings, task -> WebSearchAndIndexTask(settings, task) } + registerConstructor(SeleniumSession) { settings, task -> SeleniumSessionTask(settings, task) } + registerConstructor(CommandSession) { settings, task -> CommandSessionTask(settings, task) } } private fun registerConstructor( diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/knowledge/KnowledgeIndexingTask.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/knowledge/KnowledgeIndexingTask.kt index 51ff4f66..bbecb88b 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/knowledge/KnowledgeIndexingTask.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/apps/plan/knowledge/KnowledgeIndexingTask.kt @@ -3,9 +3,7 @@ package com.simiacryptus.skyenet.apps.plan.knowledge import com.simiacryptus.jopenai.ChatClient import com.simiacryptus.jopenai.OpenAIClient import com.simiacryptus.jopenai.describe.Description -import com.simiacryptus.jopenai.models.chatModel import com.simiacryptus.skyenet.apps.parse.CodeParsingModel -import com.simiacryptus.skyenet.apps.parse.DocumentParserApp import com.simiacryptus.skyenet.apps.parse.DocumentParsingModel import com.simiacryptus.skyenet.apps.parse.DocumentRecord.Companion.saveAsBinary import com.simiacryptus.skyenet.apps.parse.ProgressState @@ -14,7 +12,6 @@ import com.simiacryptus.skyenet.util.MarkdownUtil import com.simiacryptus.skyenet.webui.session.SessionTask import org.slf4j.LoggerFactory import java.io.File -import java.nio.file.Files import java.util.concurrent.Executors class KnowledgeIndexingTask( diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/util/Selenium2S3.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/util/Selenium2S3.kt index 7fb30739..e25d9b00 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/util/Selenium2S3.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/util/Selenium2S3.kt @@ -10,13 +10,11 @@ import org.apache.hc.client5.http.impl.cookie.BasicClientCookie import org.apache.hc.core5.concurrent.FutureCallback import org.apache.hc.core5.http.Method import org.jsoup.Jsoup -import org.openqa.selenium.By -import org.openqa.selenium.Cookie -import org.openqa.selenium.WebDriver -import org.openqa.selenium.WebElement +import org.openqa.selenium.* import org.openqa.selenium.chrome.ChromeDriver import org.openqa.selenium.chrome.ChromeDriverService import org.openqa.selenium.chrome.ChromeOptions +import org.openqa.selenium.remote.RemoteWebDriver import java.io.File import java.net.URI import java.net.URL @@ -26,13 +24,29 @@ import java.util.* import java.util.concurrent.Executors import java.util.concurrent.Semaphore import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit open class Selenium2S3( val pool: ThreadPoolExecutor = Executors.newCachedThreadPool() as ThreadPoolExecutor, private val cookies: Array?, ) : Selenium { + override fun navigate(url: String) { + (driver as WebDriver).navigate().to(url) + } + override fun getPageSource(): String { + return (driver as WebDriver).pageSource + } + override fun getCurrentUrl(): String { + return (driver as WebDriver).currentUrl + } + override fun executeScript(script: String): Any? { + return (driver as JavascriptExecutor).executeScript(script) + } + override fun quit() { + (driver as WebDriver).quit() + } var loadImages: Boolean = false - open val driver: WebDriver by lazy { + open val driver: RemoteWebDriver by lazy { chromeDriver(loadImages = loadImages).apply { setCookies( this, @@ -104,6 +118,22 @@ open class Selenium2S3( log.debug("Done") } + override fun setScriptTimeout(timeout: Long) { + (driver as WebDriver).manage().timeouts().setScriptTimeout(timeout, TimeUnit.MILLISECONDS) + } + + override fun getBrowserInfo(): String { + return driver.capabilities.browserName + } + + override fun forceQuit() { + driver.quit() + } + + override fun isAlive(): Boolean { + return driver.sessionId != null + } + protected open fun process( url: URL, href: String, diff --git a/webui/src/main/kotlin/com/simiacryptus/skyenet/util/TensorflowProjector.kt b/webui/src/main/kotlin/com/simiacryptus/skyenet/util/TensorflowProjector.kt index 9ce225ec..92dbbd37 100644 --- a/webui/src/main/kotlin/com/simiacryptus/skyenet/util/TensorflowProjector.kt +++ b/webui/src/main/kotlin/com/simiacryptus/skyenet/util/TensorflowProjector.kt @@ -32,7 +32,6 @@ class TensorflowProjector( } @Throws(IOException::class) - private fun toVectorMap(vararg words: String): Map { val vectors = words.map { word -> word to (api as OpenAIClient).createEmbedding( diff --git a/webui/src/test/kotlin/com/simiacryptus/skyenet/webui/ActorTestAppServer.kt b/webui/src/test/kotlin/com/simiacryptus/skyenet/webui/ActorTestAppServer.kt index e6033963..9082e7cb 100644 --- a/webui/src/test/kotlin/com/simiacryptus/skyenet/webui/ActorTestAppServer.kt +++ b/webui/src/test/kotlin/com/simiacryptus/skyenet/webui/ActorTestAppServer.kt @@ -7,6 +7,8 @@ import com.simiacryptus.skyenet.apps.parse.DocumentParserApp import com.simiacryptus.skyenet.apps.general.PlanAheadApp import com.simiacryptus.skyenet.apps.general.StressTestApp import com.simiacryptus.skyenet.apps.parse.DocumentParsingModel +import com.simiacryptus.skyenet.apps.parse.ParsingModel +import com.simiacryptus.skyenet.apps.parse.ParsingModel.DocumentData import com.simiacryptus.skyenet.apps.plan.PlanSettings import com.simiacryptus.skyenet.apps.plan.PlanUtil.isWindows import com.simiacryptus.skyenet.apps.plan.TaskSettings @@ -119,7 +121,8 @@ object ActorTestAppServer : com.simiacryptus.skyenet.webui.application.Applicati ) ), ChildWebApp("/stressTest", StressTestApp()), - ChildWebApp("/pdfExtractor", DocumentParserApp(parsingModel = DocumentParsingModel(OpenAIModels.GPT4o, 0.1))), + ChildWebApp("/pdfExtractor", DocumentParserApp(parsingModel = DocumentParsingModel(OpenAIModels.GPT4o, 0.1) as ParsingModel + )), ) }