diff --git a/infrastructure/research/src/main/kotlin/com/correx/infrastructure/research/web/WebSearchTool.kt b/infrastructure/research/src/main/kotlin/com/correx/infrastructure/research/web/WebSearchTool.kt new file mode 100644 index 00000000..f335caf6 --- /dev/null +++ b/infrastructure/research/src/main/kotlin/com/correx/infrastructure/research/web/WebSearchTool.kt @@ -0,0 +1,115 @@ +package com.correx.infrastructure.research.web + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ToolInvocationId +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.net.URLEncoder + +/** + * Searches the self-hosted SearXNG instance (research-workflow-spec §3). Local-first: no API keys, + * no third-party telemetry. Search hits only the one configured [searxngBaseUrl], so unlike + * [WebFetchTool] there is no arbitrary-host egress to police — it is [Tier.T1]. + * + * Returns the top [maxResults] results as a readable list, with the result URLs in metadata so the + * fetch stage can act on them (and the operator can approve the source list, not each URL). + */ +class WebSearchTool( + private val httpClient: HttpClient, + private val searxngBaseUrl: String, + private val maxResults: Int = DEFAULT_MAX_RESULTS, +) : Tool, ToolExecutor { + + private val json = Json { ignoreUnknownKeys = true } + + override val name: String = "web_search" + override val description: String = "Search the web (local SearXNG); returns titles, URLs, and snippets." + override val tier: Tier = Tier.T1 + override val requiredCapabilities: Set = setOf(ToolCapability.NETWORK_ACCESS) + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("query") { + put("type", "string") + put("description", "Search query.") + } + } + put("required", buildJsonArray { add(JsonPrimitive("query")) }) + } + + override fun validateRequest(request: ToolRequest): ValidationResult { + val query = request.parameters["query"] as? String + return if (query.isNullOrBlank()) { + ValidationResult.Invalid("Missing 'query' parameter.") + } else { + ValidationResult.Valid + } + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val invalid = validateRequest(request) as? ValidationResult.Invalid + if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false) + val query = request.parameters["query"] as String + return runCatching { search(request.invocationId, query) } + .getOrElse { ToolResult.Failure(request.invocationId, "search failed: ${it.message}", recoverable = true) } + } + + private suspend fun search(invocationId: ToolInvocationId, query: String): ToolResult { + val url = "${searxngBaseUrl.trimEnd('/')}/search?q=${URLEncoder.encode(query, "UTF-8")}&format=json" + val response = httpClient.get(url) + if (!response.status.isSuccess()) { + return ToolResult.Failure(invocationId, "SearXNG HTTP ${response.status.value}", recoverable = true) + } + val results = json.decodeFromString(response.bodyAsText()).results.take(maxResults) + return ToolResult.Success( + invocationId = invocationId, + output = render(query, results), + metadata = mapOf( + "query" to query, + "result_count" to results.size.toString(), + "urls" to results.joinToString("\n") { it.url }, + ), + ) + } + + private fun render(query: String, results: List): String { + if (results.isEmpty()) return "No results for: $query" + return results.mapIndexed { index, r -> + buildString { + append("${index + 1}. ${r.title.ifBlank { r.url }}\n") + append(" ${r.url}") + if (r.content.isNotBlank()) append("\n ${r.content.trim()}") + } + }.joinToString("\n\n") + } + + @Serializable + private data class SearxResponse(val results: List = emptyList()) + + @Serializable + private data class SearxResult( + val url: String = "", + val title: String = "", + val content: String = "", + ) + + companion object { + const val DEFAULT_MAX_RESULTS = 8 + } +} diff --git a/infrastructure/research/src/test/kotlin/com/correx/infrastructure/research/web/WebSearchToolTest.kt b/infrastructure/research/src/test/kotlin/com/correx/infrastructure/research/web/WebSearchToolTest.kt new file mode 100644 index 00000000..c06f77bd --- /dev/null +++ b/infrastructure/research/src/test/kotlin/com/correx/infrastructure/research/web/WebSearchToolTest.kt @@ -0,0 +1,104 @@ +package com.correx.infrastructure.research.web + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import com.correx.core.utils.TypeId +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WebSearchToolTest { + + private var lastRequestedUrl: String = "" + + private fun toolFor( + json: String, + status: HttpStatusCode = HttpStatusCode.OK, + maxResults: Int = 8, + ): WebSearchTool { + val engine = MockEngine { request -> + lastRequestedUrl = request.url.toString() + respond(content = json, status = status, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + return WebSearchTool(HttpClient(engine), searxngBaseUrl = "http://searx.local:8888", maxResults = maxResults) + } + + private fun request(query: String?) = ToolRequest( + invocationId = TypeId("inv-1"), + sessionId = TypeId("s-1"), + stageId = TypeId("stage-1"), + toolName = "web_search", + parameters = if (query == null) emptyMap() else mapOf("query" to query), + ) + + private fun search(tool: WebSearchTool, query: String = "kotlin coroutines") = + runBlocking { tool.execute(request(query)) } + + private val sample = """ + {"results":[ + {"url":"https://a.com/1","title":"First","content":"about one"}, + {"url":"https://b.com/2","title":"Second","content":"about two"} + ]} + """.trimIndent() + + @Test + fun `is tier one and network capable`() { + val tool = toolFor(sample) + assertEquals(Tier.T1, tool.tier) + assertTrue(ToolCapability.NETWORK_ACCESS in tool.requiredCapabilities) + } + + @Test + fun `queries searxng json endpoint with encoded query`() { + search(toolFor(sample), "a b&c") + assertTrue(lastRequestedUrl.contains("/search?q="), lastRequestedUrl) + assertTrue(lastRequestedUrl.contains("format=json"), lastRequestedUrl) + assertTrue(lastRequestedUrl.contains("a+b%26c"), "query not url-encoded: $lastRequestedUrl") + } + + @Test + fun `renders results and exposes urls in metadata`() { + val success = assertInstanceOf(ToolResult.Success::class.java, search(toolFor(sample))) + assertTrue(success.output.contains("First")) + assertTrue(success.output.contains("https://a.com/1")) + assertEquals("2", success.metadata["result_count"]) + assertEquals("https://a.com/1\nhttps://b.com/2", success.metadata["urls"]) + } + + @Test + fun `respects max results cap`() { + val success = assertInstanceOf(ToolResult.Success::class.java, search(toolFor(sample, maxResults = 1))) + assertEquals("1", success.metadata["result_count"]) + assertEquals("https://a.com/1", success.metadata["urls"]) + } + + @Test + fun `empty results render a no-results message`() { + val success = assertInstanceOf(ToolResult.Success::class.java, search(toolFor("""{"results":[]}"""))) + assertTrue(success.output.contains("No results")) + assertEquals("0", success.metadata["result_count"]) + } + + @Test + fun `searxng error is a recoverable failure`() { + val result = search(toolFor("err", status = HttpStatusCode.BadGateway)) + val failure = assertInstanceOf(ToolResult.Failure::class.java, result) + assertTrue(failure.recoverable) + } + + @Test + fun `blank query is rejected`() { + assertInstanceOf(ValidationResult.Invalid::class.java, toolFor(sample).validateRequest(request(" "))) + } +}