feat(research): WebSearchTool — local SearXNG search (research-workflow §3)
Searches the self-hosted SearXNG JSON API: local-first, no API keys, no third-party telemetry. Hits only the configured endpoint, so unlike web_fetch there is no arbitrary-host egress to police — T1. Returns the top-N results as a readable list (title / url / snippet) with the result URLs in metadata so the fetch stage can act on them and the operator can approve the source list rather than each URL. Tested with Ktor MockEngine (query encoding, result parsing/cap, empty + error paths). Completes the §3 "two tools" pair; with slice-1 extraction this is the deterministic + network-tool foundation of the research workflow.
This commit is contained in:
+115
@@ -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<ToolCapability> = 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<SearxResponse>(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<SearxResult>): 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<SearxResult> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SearxResult(
|
||||||
|
val url: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val content: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_MAX_RESULTS = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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(" ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user