Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion lib/loopctl_web/controllers/knowledge_search_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ defmodule LoopctlWeb.KnowledgeSearchController do
description: "Search mode: keyword, semantic, or combined (default: combined)",
required: false
],
format: [
in: :query,
type: :string,
description:
"Response shape: results (default, ranked results + snippets), stubs (capped " <>
"stubs with hub enrichment, for surveying a topic without pulling bodies), or " <>
"bodies (full bodies + linked references). stubs and bodies require a query " <>
"and do not support cursor pagination. An unknown value is a 400.",
required: false
],
project_id: [
in: :query,
type: :string,
Expand Down Expand Up @@ -342,6 +352,7 @@ defmodule LoopctlWeb.KnowledgeSearchController do
api_key = conn.assigns.current_api_key

with {:ok, query_spec} <- resolve_query(params),
{:ok, format} <- validate_format(params),
{:ok, mode} <- validate_mode(params),
:ok <- validate_search_limit(params, query_spec),
:ok <- validate_include_body(params),
Expand Down Expand Up @@ -376,7 +387,7 @@ defmodule LoopctlWeb.KnowledgeSearchController do
# - param malformed → 400 (not a string)
case keyset_cursor(params) do
:none ->
run_search(conn, tenant_id, query_spec, mode, opts)
run_in_format(conn, tenant_id, query_spec, mode, opts, format)

:invalid ->
{:error, :bad_request, "cursor parameter must be a string"}
Expand Down Expand Up @@ -446,6 +457,97 @@ defmodule LoopctlWeb.KnowledgeSearchController do
end
end

# ONE search command, three response shapes (#670 follow-up).
#
# `progressive_index/3` and `get_context/3` are not different searches. The first calls
# `search_keyword/3` and then caps-and-stubs; the second is the combined search returning
# full bodies. Exposed as separate TOOLS they asked an agent to decide, per query, which
# door to knock on — and that decision is unobservable, so it confounds every measurement
# of the ranking behind them: you cannot tell an algorithm's effect from an agent's choice
# of entrypoint. A parameter is a variable we control; a tool choice is a confounder we do
# not.
#
# The existing tools/endpoints stay and are NOT retired — they now share this path.
#
# results (default) — ranked results + snippets. Unchanged; the only shape that
# supports keyset pagination, which is why the dispatch sits on the
# `:none` cursor branch.
# stubs — capped stubs with hub enrichment, for surveying a broad topic
# without pulling bodies into context.
# bodies — full bodies plus linked references, for one deep read.
@formats ~w(results stubs bodies)

defp validate_format(params) do
case params["format"] do
nil -> {:ok, "results"}
value when value in @formats -> {:ok, value}
_other -> {:error, :bad_request, "format must be one of: #{Enum.join(@formats, ", ")}"}
end
end

defp run_in_format(conn, tenant_id, query_spec, mode, opts, "results"),
do: run_search(conn, tenant_id, query_spec, mode, opts)

defp run_in_format(conn, tenant_id, query_spec, _mode, opts, format) do
case search_text(query_spec) do
nil ->
# The shaped formats are relevance shapes; there is no stub or body rendering of an
# enumeration page, and silently returning the ranked shape instead would answer a
# different question than the one asked.
{:error, :bad_request, "format=#{format} requires a query"}

query ->
shaped_result(conn, tenant_id, query, opts, format)
end
end

defp shaped_result(conn, tenant_id, query, opts, "stubs") do
case Knowledge.progressive_index(tenant_id, query, opts) do
{:ok, result} ->
record_shaped_attempt(conn, query, opts, "progressive", length(result.stubs))
json(conn, LoopctlWeb.KnowledgeProgressiveJSON.index(result))

{:error, :empty_query} ->
{:error, :bad_request, "Query parameter 'q' is required and cannot be empty"}

{:error, :bad_request, msg} ->
{:error, :bad_request, msg}
end
end

defp shaped_result(conn, tenant_id, query, opts, "bodies") do
case Knowledge.get_context(tenant_id, query, opts) do
{:ok, result} ->
record_shaped_attempt(conn, query, opts, "context", length(result.results))
json(conn, LoopctlWeb.KnowledgeContextJSON.context(result))

{:error, :empty_query} ->
{:error, :bad_request, "Query parameter 'q' is required and cannot be empty"}
end
end

# Attributed to `knowledge_search`, because that is the tool the caller used — the shape is
# a PARAMETER of this command, not a different surface. Recording it as the sibling tool
# would split one command's traffic across three names and undo the very comparability the
# parameter exists to create. The shape rides in `mode_used`.
defp record_shaped_attempt(conn, query, opts, mode, result_count) do
SearchTelemetry.record_attempt(conn, %{
query: query,
tool: "knowledge_search",
mode_requested: Keyword.get(opts, :_mode_requested),
mode_used: mode,
result_count: result_count,
duration_ms: shaped_duration_ms(opts)
})
end

defp shaped_duration_ms(opts) do
case Keyword.get(opts, :_started_at) do
started when is_integer(started) -> System.monotonic_time(:millisecond) - started
_ -> nil
end
end

defp run_search(conn, tenant_id, query_spec, mode, opts) do
case execute_search(tenant_id, query_spec, mode, opts) do
{:ok, result} ->
Expand Down
123 changes: 123 additions & 0 deletions test/loopctl_web/controllers/one_search_command_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
defmodule LoopctlWeb.OneSearchCommandTest do
@moduledoc """
`knowledge_search` can now produce all three response shapes, so an agent stops choosing
between tools that run the same search.

That choice was never just ergonomics. `progressive_index/3` calls `search_keyword/3` and
caps-and-stubs; `get_context/3` is the combined search returning full bodies. Exposed as
separate TOOLS they asked an agent to decide, per query, which door to knock on — and that
decision is unobservable, so it confounds any measurement of the ranking behind them. A
parameter is a variable we control; a tool choice is a confounder we do not.

The sibling tools are NOT retired; they share this path.
"""
use LoopctlWeb.ConnCase, async: true

import Ecto.Query

alias Loopctl.AdminRepo
alias Loopctl.Knowledge.SearchEvent

setup :verify_on_exit!

setup do
tenant = fixture(:tenant)
agent = fixture(:agent, %{tenant_id: tenant.id})
{raw, _key} = fixture(:api_key, %{tenant_id: tenant.id, role: :agent, agent_id: agent.id})
marker = "onecmd#{System.unique_integer([:positive])}"

fixture(:article, %{
tenant_id: tenant.id,
status: :published,
title: "#{marker} guide",
body: "#{marker} the body of the one-command guide"
})

%{tenant: tenant, agent: agent, raw: raw, marker: marker}
end

defp search(raw, qs) do
Phoenix.ConnTest.build_conn()
|> Plug.Conn.put_req_header("authorization", "Bearer #{raw}")
|> Phoenix.ConnTest.get("/api/v1/knowledge/search?#{qs}")
end

defp events(tenant_id) do
from(e in SearchEvent, where: e.tenant_id == ^tenant_id, order_by: e.inserted_at)
|> AdminRepo.all()
end

describe "format parameter" do
test "defaults to the ranked results shape", %{raw: raw, marker: marker} do
body = json_response(search(raw, "q=#{marker}"), 200)

assert is_list(body["data"])
assert body["meta"]["search_mode"]
end

test "format=stubs returns the progressive shape", %{raw: raw, marker: marker} do
body = json_response(search(raw, "q=#{marker}&format=stubs"), 200)

assert is_list(body["data"])
assert is_integer(body["meta"]["top_k"])
assert Map.has_key?(body["meta"], "candidate_count")
end

test "format=bodies returns full bodies", %{raw: raw, marker: marker} do
body = json_response(search(raw, "q=#{marker}&format=bodies"), 200)

assert [%{"body" => article_body} | _] = body["data"]
assert article_body =~ marker
end

test "an unknown format is refused rather than silently ranked", %{raw: raw, marker: marker} do
conn = search(raw, "q=#{marker}&format=nonsense")
assert conn.status == 400
end

test "a shaped format without a query is refused", %{raw: raw} do
# These are relevance shapes; there is no stub rendering of an enumeration page, and
# quietly returning the ranked shape would answer a different question.
conn = search(raw, "tags=anything&format=stubs")
assert conn.status == 400
end
end

describe "telemetry attributes every shape to the one command" do
test "a stubs search records tool=knowledge_search with the shape in mode_used", %{
tenant: tenant,
agent: agent,
raw: raw,
marker: marker
} do
search(raw, "q=#{marker}&format=stubs")

# Recording it as the sibling tool would split one command's traffic across three
# names and undo the comparability the parameter exists to create.
assert [event] = events(tenant.id)
assert event.tool == "knowledge_search"
assert event.mode_used == "progressive"
assert event.agent_id == agent.id
assert is_integer(event.duration_ms)
end

test "a bodies search does the same", %{tenant: tenant, raw: raw, marker: marker} do
search(raw, "q=#{marker}&format=bodies")

assert [event] = events(tenant.id)
assert event.tool == "knowledge_search"
assert event.mode_used == "context"
end

test "a shaped miss is recorded as zero_results, not as nothing", %{
tenant: tenant,
raw: raw
} do
search(raw, "q=nothingmatchesthisatall#{System.unique_integer([:positive])}&format=stubs")

assert [event] = events(tenant.id)
assert event.outcome == "zero_results"
assert event.result_count == 0
end
end
end
Loading