defmodule Cairnloop.Retrieval.Providers.ResolvedCases do
import Ecto.Query
alias Cairnloop.Retrieval.{ResolvedCaseChunk, ResolvedCaseEvidence, Result}
defp repo do
Application.fetch_env!(:cairnloop, :repo)
end
def search(query, opts \\ []) do
limit = Keyword.get(opts, :limit, 5)
embedding_vector = Keyword.get_lazy(opts, :embedding_vector, fn -> nil end)
host_user_id = Keyword.get(opts, :host_user_id)
if host_user_id in [nil, ""] do
[]
else
keyword_task = Task.async(fn -> keyword_candidates(query, limit, opts) end)
semantic_task = Task.async(fn -> semantic_candidates(embedding_vector, limit, opts) end)
merge_candidates(Task.await(keyword_task), Task.await(semantic_task))
end
end
def keyword_candidates(query, limit, opts \\ []) do
host_user_id = Keyword.get(opts, :host_user_id)
ResolvedCaseChunk
|> join(:inner, [chunk], evidence in ResolvedCaseEvidence,
on: evidence.id == chunk.resolved_case_evidence_id
)
|> maybe_filter_host_user(host_user_id)
|> where(
[chunk, _evidence],
fragment(
"cairnloop_resolved_case_chunks.search_vector @@ websearch_to_tsquery('english', ?)",
^query
)
)
|> order_by(
[chunk, _evidence],
desc:
fragment(
"ts_rank(cairnloop_resolved_case_chunks.search_vector, websearch_to_tsquery('english', ?))",
^query
)
)
|> limit(^limit)
|> select([chunk, evidence], %{
id: chunk.id,
title: evidence.subject,
content: chunk.content,
source_type: :resolved_case,
trust_level: :assistive,
visibility: :host,
conversation_id: evidence.conversation_id,
chunk_index: chunk.chunk_index,
resolved_at: evidence.resolved_at,
issue_summary: evidence.issue_summary,
resolution_note: evidence.resolution_note,
actions_taken: evidence.actions_taken,
outcome: evidence.outcome,
citation_target: %{
conversation_id: evidence.conversation_id,
chunk_index: chunk.chunk_index
},
metadata: %{
destination: %{
type: :resolved_case,
conversation_id: evidence.conversation_id,
chunk_index: chunk.chunk_index
},
action_label: "Open resolved case",
citation_backreferences: evidence.citation_backreferences
}
})
|> repo().all()
|> Enum.with_index(1)
|> Enum.map(fn {row, rank} -> Map.put(row, :keyword_rank, rank) end)
end
def semantic_candidates(embedding_vector, limit, opts \\ [])
def semantic_candidates(nil, _limit, _opts), do: []
def semantic_candidates(embedding_vector, limit, opts) do
host_user_id = Keyword.get(opts, :host_user_id)
ResolvedCaseChunk
|> join(:inner, [chunk], evidence in ResolvedCaseEvidence,
on: evidence.id == chunk.resolved_case_evidence_id
)
|> maybe_filter_host_user(host_user_id)
|> order_by(
[chunk, _evidence],
fragment("? <-> ?", chunk.embedding, ^Pgvector.new(embedding_vector))
)
|> limit(^limit)
|> select([chunk, evidence], %{
id: chunk.id,
title: evidence.subject,
content: chunk.content,
source_type: :resolved_case,
trust_level: :assistive,
visibility: :host,
conversation_id: evidence.conversation_id,
chunk_index: chunk.chunk_index,
resolved_at: evidence.resolved_at,
issue_summary: evidence.issue_summary,
resolution_note: evidence.resolution_note,
actions_taken: evidence.actions_taken,
outcome: evidence.outcome,
citation_target: %{
conversation_id: evidence.conversation_id,
chunk_index: chunk.chunk_index
},
metadata: %{
destination: %{
type: :resolved_case,
conversation_id: evidence.conversation_id,
chunk_index: chunk.chunk_index
},
action_label: "Open resolved case",
citation_backreferences: evidence.citation_backreferences
}
})
|> repo().all()
|> Enum.with_index(1)
|> Enum.map(fn {row, rank} -> Map.put(row, :semantic_rank, rank) end)
end
defp merge_candidates(keyword_rows, semantic_rows) do
(keyword_rows ++ semantic_rows)
|> Enum.group_by(& &1.citation_target)
|> Enum.map(fn {_citation_target, rows} ->
Enum.reduce(rows, %Result{}, fn row, %Result{} = acc ->
%Result{
acc
| id: row.id || acc.id,
title: row.title || acc.title,
content: row.content || acc.content,
source_type: row.source_type || acc.source_type,
trust_level: row.trust_level || acc.trust_level,
visibility: row.visibility || acc.visibility,
conversation_id: row[:conversation_id] || acc.conversation_id,
chunk_index: row[:chunk_index] || acc.chunk_index,
resolved_at: row[:resolved_at] || acc.resolved_at,
issue_summary: row[:issue_summary] || acc.issue_summary,
resolution_note: row[:resolution_note] || acc.resolution_note,
actions_taken: row[:actions_taken] || acc.actions_taken,
outcome: row[:outcome] || acc.outcome,
citation_target: row.citation_target || acc.citation_target,
metadata: Map.merge(acc.metadata || %{}, row[:metadata] || %{}),
keyword_rank: row[:keyword_rank] || acc.keyword_rank,
semantic_rank: row[:semantic_rank] || acc.semantic_rank
}
end)
end)
end
defp maybe_filter_host_user(query, nil), do: query
defp maybe_filter_host_user(query, host_user_id) do
where(query, [_chunk, evidence], evidence.host_user_id == ^to_string(host_user_id))
end
end