feat(tasks): task search across REST, tool, and TUI
Ranked exact/substring search: TaskSearch (all terms AND, ranked title > key > goal > criteria > notes) over one project or the whole board via TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool for agents, and a `/` filter in the TUI board. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
/**
|
||||
* Exact/substring task search. A task matches when every whitespace-separated term in the query
|
||||
* appears (case-insensitive) in some searchable field; results are ranked by the strongest field a
|
||||
* term hits (title > key > goal > acceptance criteria > notes), ties broken by id for stability.
|
||||
* Pure and store-free so it can be reused over [TaskService.list]/[TaskService.listAll] and unit
|
||||
* tested directly.
|
||||
*/
|
||||
object TaskSearch {
|
||||
|
||||
fun search(tasks: List<Task>, query: String): List<Task> {
|
||||
val terms = query.trim().lowercase().split(WHITESPACE).filter { it.isNotBlank() }
|
||||
if (terms.isEmpty()) return tasks
|
||||
return tasks
|
||||
.mapNotNull { task -> score(task, terms)?.let { task to it } }
|
||||
.sortedWith(compareByDescending<Pair<Task, Int>> { it.second }.thenBy { it.first.taskId.value })
|
||||
.map { it.first }
|
||||
}
|
||||
|
||||
/** Sum of the best per-term field weight, or null when any term matches no field. */
|
||||
private fun score(task: Task, terms: List<String>): Int? {
|
||||
val s = task.state
|
||||
val fields = listOf(
|
||||
(s.title ?: "") to TITLE_WEIGHT,
|
||||
(s.key ?: "") to KEY_WEIGHT,
|
||||
(s.goal ?: "") to GOAL_WEIGHT,
|
||||
s.acceptanceCriteria.joinToString(" ") to CRITERIA_WEIGHT,
|
||||
s.notes.joinToString(" ") { it.body } to NOTES_WEIGHT,
|
||||
).map { (text, weight) -> text.lowercase() to weight }
|
||||
|
||||
var total = 0
|
||||
for (term in terms) {
|
||||
val best = fields.filter { it.first.contains(term) }.maxOfOrNull { it.second } ?: return null
|
||||
total += best
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
private const val TITLE_WEIGHT = 5
|
||||
private const val KEY_WEIGHT = 4
|
||||
private const val GOAL_WEIGHT = 3
|
||||
private const val CRITERIA_WEIGHT = 2
|
||||
private const val NOTES_WEIGHT = 1
|
||||
}
|
||||
@@ -70,6 +70,10 @@ class TaskService(
|
||||
return if (state.deleted) null else Task(taskId, state)
|
||||
}
|
||||
|
||||
/** Ranked text search over one project (when given) or the whole cross-project board. */
|
||||
fun search(query: String, projectId: ProjectId? = null): List<Task> =
|
||||
TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query)
|
||||
|
||||
suspend fun createTask(
|
||||
projectId: ProjectId,
|
||||
title: String,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.TaskId
|
||||
import com.correx.core.events.types.TaskNoteAuthor
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskSearchTest {
|
||||
|
||||
private fun task(
|
||||
id: String,
|
||||
title: String?,
|
||||
goal: String? = null,
|
||||
criteria: List<String> = emptyList(),
|
||||
notes: List<String> = emptyList(),
|
||||
) = Task(
|
||||
TaskId(id),
|
||||
TaskState(
|
||||
key = id,
|
||||
title = title,
|
||||
goal = goal,
|
||||
acceptanceCriteria = criteria,
|
||||
notes = notes.map { TaskNote(TaskNoteAuthor.AGENT, it, Instant.parse("2026-01-01T00:00:00Z")) },
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `requires all terms and ranks title hits above body hits`() {
|
||||
val titleHit = task("auth-1", title = "JWT refresh flow", goal = "stay authed")
|
||||
val goalHit = task("auth-2", title = "Token rotation", goal = "rotate the jwt refresh token")
|
||||
val noMatch = task("auth-3", title = "Logging", goal = "structured logs")
|
||||
|
||||
val results = TaskSearch.search(listOf(goalHit, noMatch, titleHit), "jwt refresh")
|
||||
|
||||
assertEquals(listOf("auth-1", "auth-2"), results.map { it.taskId.value })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches across acceptance criteria and notes`() {
|
||||
val viaNotes = task("auth-1", title = "x", notes = listOf("the migration failed on rollback"))
|
||||
val viaCriteria = task("auth-2", title = "y", criteria = listOf("rollback is idempotent"))
|
||||
|
||||
val results = TaskSearch.search(listOf(viaNotes, viaCriteria), "rollback")
|
||||
|
||||
assertEquals(setOf("auth-1", "auth-2"), results.map { it.taskId.value }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query returns the input unchanged`() {
|
||||
val tasks = listOf(task("auth-1", "a"), task("auth-2", "b"))
|
||||
assertEquals(tasks, TaskSearch.search(tasks, " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no match yields empty`() {
|
||||
assertEquals(emptyList<Task>(), TaskSearch.search(listOf(task("auth-1", "nothing here")), "zebra"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user