Current section

Files

Jump to
nous lib nous knowledge_base tools.ex
Raw

lib/nous/knowledge_base/tools.ex

defmodule Nous.KnowledgeBase.Tools do
# identify_issues/4 builds two MapSets from optional Store callbacks
# whose return type dialyzer can't fully narrow; same opaque-capture
# false-positive as Workflow.Engine.
@dialyzer :no_opaque
@moduledoc """
Agent tools for knowledge base operations.
Follows the `Nous.Memory.Tools` pattern — each tool receives `ctx`
via `takes_ctx: true` and returns `{:ok, result, ContextUpdate.new()}`.
"""
alias Nous.KnowledgeBase.{Document, Entry, Link}
alias Nous.Tool
alias Nous.Tool.ContextUpdate
@doc """
Returns all knowledge base tools as a list.
"""
def all_tools do
[
kb_search_tool(),
kb_read_tool(),
kb_list_tool(),
kb_ingest_tool(),
kb_add_entry_tool(),
kb_link_tool(),
kb_backlinks_tool(),
kb_health_check_tool(),
kb_generate_tool()
]
end
# ---------------------------------------------------------------------------
# Tool definitions
# ---------------------------------------------------------------------------
defp kb_search_tool do
%Tool{
name: "kb_search",
description:
"Search the knowledge base wiki for relevant entries. Returns entries ranked by relevance.",
parameters: %{
"type" => "object",
"properties" => %{
"query" => %{
"type" => "string",
"description" => "Search query"
},
"limit" => %{
"type" => "integer",
"description" => "Maximum number of results (default: 5)"
},
"tags" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Filter by tags"
},
"entry_type" => %{
"type" => "string",
"enum" => ["article", "concept", "summary", "index", "glossary"],
"description" => "Filter by entry type"
}
},
"required" => ["query"]
},
function: &__MODULE__.kb_search/2,
takes_ctx: true,
category: :search
}
end
defp kb_read_tool do
%Tool{
name: "kb_read",
description:
"Read a specific knowledge base entry by its slug or ID. Returns the full entry content.",
parameters: %{
"type" => "object",
"properties" => %{
"slug_or_id" => %{
"type" => "string",
"description" => "Entry slug (e.g. 'elixir-genserver') or entry ID"
}
},
"required" => ["slug_or_id"]
},
function: &__MODULE__.kb_read/2,
takes_ctx: true,
category: :read
}
end
defp kb_list_tool do
%Tool{
name: "kb_list",
description:
"List knowledge base entries, optionally filtered by tags, concepts, or entry type.",
parameters: %{
"type" => "object",
"properties" => %{
"tags" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Filter by tags"
},
"concepts" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Filter by concepts"
},
"entry_type" => %{
"type" => "string",
"enum" => ["article", "concept", "summary", "index", "glossary"],
"description" => "Filter by entry type"
},
"limit" => %{
"type" => "integer",
"description" => "Maximum number of entries (default: 20)"
}
}
},
function: &__MODULE__.kb_list/2,
takes_ctx: true,
category: :read
}
end
defp kb_ingest_tool do
%Tool{
name: "kb_ingest",
description:
"Ingest a raw document into the knowledge base for later compilation into wiki entries.",
parameters: %{
"type" => "object",
"properties" => %{
"title" => %{
"type" => "string",
"description" => "Document title"
},
"content" => %{
"type" => "string",
"description" => "Raw document content (markdown, text, etc.)"
},
"doc_type" => %{
"type" => "string",
"enum" => ["markdown", "text", "url", "pdf", "html"],
"description" => "Document type (default: markdown)"
},
"source_url" => %{
"type" => "string",
"description" => "Original source URL if applicable"
}
},
"required" => ["title", "content"]
},
function: &__MODULE__.kb_ingest/2,
takes_ctx: true,
category: :write
}
end
defp kb_add_entry_tool do
%Tool{
name: "kb_add_entry",
description:
"Directly create or update a compiled wiki entry in the knowledge base. Use [[slug]] for wiki-links.",
parameters: %{
"type" => "object",
"properties" => %{
"title" => %{
"type" => "string",
"description" => "Entry title"
},
"content" => %{
"type" => "string",
"description" =>
"Wiki entry content in markdown. Use [[slug]] for links to other entries."
},
"summary" => %{
"type" => "string",
"description" => "1-3 sentence summary"
},
"concepts" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Key concepts covered in this entry"
},
"tags" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Tags for categorization"
},
"entry_type" => %{
"type" => "string",
"enum" => ["article", "concept", "summary", "index", "glossary"],
"description" => "Entry type (default: article)"
},
"source_doc_ids" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "IDs of source documents this entry was compiled from"
}
},
"required" => ["title", "content"]
},
function: &__MODULE__.kb_add_entry/2,
takes_ctx: true,
category: :write
}
end
defp kb_link_tool do
%Tool{
name: "kb_link",
description: "Create a link between two knowledge base entries.",
parameters: %{
"type" => "object",
"properties" => %{
"from_slug" => %{
"type" => "string",
"description" => "Slug or ID of the source entry"
},
"to_slug" => %{
"type" => "string",
"description" => "Slug or ID of the target entry"
},
"link_type" => %{
"type" => "string",
"enum" => ["backlink", "cross_reference", "concept", "see_also", "parent_child"],
"description" => "Type of link (default: cross_reference)"
},
"label" => %{
"type" => "string",
"description" => "Display label for the link"
}
},
"required" => ["from_slug", "to_slug"]
},
function: &__MODULE__.kb_link/2,
takes_ctx: true,
category: :write
}
end
defp kb_backlinks_tool do
%Tool{
name: "kb_backlinks",
description: "Find all entries that link to a given entry.",
parameters: %{
"type" => "object",
"properties" => %{
"slug_or_id" => %{
"type" => "string",
"description" => "Entry slug or ID to find backlinks for"
}
},
"required" => ["slug_or_id"]
},
function: &__MODULE__.kb_backlinks/2,
takes_ctx: true,
category: :read
}
end
defp kb_health_check_tool do
%Tool{
name: "kb_health_check",
description:
"Run a health audit on the knowledge base. Checks for orphaned entries, stale content, missing links, and gaps.",
parameters: %{
"type" => "object",
"properties" => %{}
},
function: &__MODULE__.kb_health_check/2,
takes_ctx: true,
category: :read
}
end
defp kb_generate_tool do
%Tool{
name: "kb_generate",
description:
"Generate a structured output (report, summary, or slides) from knowledge base entries on a given topic.",
parameters: %{
"type" => "object",
"properties" => %{
"topic" => %{
"type" => "string",
"description" => "Topic to generate output about"
},
"output_type" => %{
"type" => "string",
"enum" => ["report", "summary", "slides"],
"description" => "Type of output to generate (default: summary)"
},
"limit" => %{
"type" => "integer",
"description" => "Maximum number of entries to include (default: 10)"
}
},
"required" => ["topic"]
},
function: &__MODULE__.kb_generate/2,
takes_ctx: true,
category: :execute
}
end
# ---------------------------------------------------------------------------
# Tool implementations
# ---------------------------------------------------------------------------
def kb_search(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
query = Map.fetch!(args, "query")
limit = Map.get(args, "limit", 5)
entry_type = parse_entry_type(Map.get(args, "entry_type"))
opts =
[limit: limit, kb_id: get_kb_id(ctx)]
|> maybe_put(:entry_type, entry_type)
|> maybe_put(:tags, Map.get(args, "tags"))
case store_mod.search_entries(store_state, query, opts) do
{:ok, results} ->
formatted =
Enum.map(results, fn {entry, score} ->
%{
slug: entry.slug,
title: entry.title,
summary: entry.summary,
entry_type: to_string(entry.entry_type),
concepts: entry.concepts,
tags: entry.tags,
confidence: entry.confidence,
score: Float.round(score, 4)
}
end)
{:ok, %{status: "found", count: length(formatted), entries: formatted},
ContextUpdate.new()}
{:error, reason} ->
{:ok, %{status: "error", message: "Search failed: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_read(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
slug_or_id = Map.fetch!(args, "slug_or_id")
result =
case store_mod.fetch_entry_by_slug(store_state, slug_or_id) do
{:ok, _} = ok -> ok
{:error, :not_found} -> store_mod.fetch_entry(store_state, slug_or_id)
end
case result do
{:ok, entry} ->
{:ok,
%{
id: entry.id,
slug: entry.slug,
title: entry.title,
content: entry.content,
summary: entry.summary,
entry_type: to_string(entry.entry_type),
concepts: entry.concepts,
tags: entry.tags,
confidence: entry.confidence,
source_doc_ids: entry.source_doc_ids,
created_at: DateTime.to_iso8601(entry.created_at),
updated_at: DateTime.to_iso8601(entry.updated_at)
}, ContextUpdate.new()}
{:error, :not_found} ->
{:ok, %{status: "not_found", message: "Entry '#{slug_or_id}' not found"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_list(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
limit = Map.get(args, "limit", 20)
entry_type = parse_entry_type(Map.get(args, "entry_type"))
opts =
[limit: limit, kb_id: get_kb_id(ctx)]
|> maybe_put(:entry_type, entry_type)
|> maybe_put(:tags, Map.get(args, "tags"))
|> maybe_put(:concepts, Map.get(args, "concepts"))
case store_mod.list_entries(store_state, opts) do
{:ok, entries} ->
formatted =
Enum.map(entries, fn entry ->
%{
slug: entry.slug,
title: entry.title,
entry_type: to_string(entry.entry_type),
concepts: entry.concepts,
tags: entry.tags
}
end)
{:ok, %{status: "ok", count: length(formatted), entries: formatted},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_ingest(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
doc =
Document.new(%{
title: Map.fetch!(args, "title"),
content: Map.fetch!(args, "content"),
doc_type: parse_doc_type(Map.get(args, "doc_type", "markdown")),
source_url: Map.get(args, "source_url"),
kb_id: get_kb_id(ctx)
})
case store_mod.store_document(store_state, doc) do
{:ok, new_state} ->
{:ok,
%{
status: "ingested",
id: doc.id,
title: doc.title,
checksum: doc.checksum
}, update_store_state(ctx, new_state)}
{:error, reason} ->
{:ok, %{status: "error", message: "Ingest failed: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_add_entry(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
config = ctx.deps[:kb_config] || %{}
title = Map.fetch!(args, "title")
content = Map.fetch!(args, "content")
# Generate embedding if provider configured
embedding = maybe_embed(config, content)
entry =
Entry.new(%{
title: title,
content: content,
summary: Map.get(args, "summary"),
concepts: Map.get(args, "concepts", []),
tags: Map.get(args, "tags", []),
entry_type: parse_entry_type(Map.get(args, "entry_type")) || :article,
source_doc_ids: Map.get(args, "source_doc_ids", []),
embedding: embedding,
kb_id: get_kb_id(ctx)
})
case store_mod.store_entry(store_state, entry) do
{:ok, new_state} ->
{:ok,
%{
status: "created",
id: entry.id,
slug: entry.slug,
title: entry.title
}, update_store_state(ctx, new_state)}
{:error, reason} ->
{:ok, %{status: "error", message: "Failed to add entry: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_link(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
from_slug = Map.fetch!(args, "from_slug")
to_slug = Map.fetch!(args, "to_slug")
with {:ok, from_entry} <- resolve_entry(store_mod, store_state, from_slug),
{:ok, to_entry} <- resolve_entry(store_mod, store_state, to_slug) do
link =
Link.new(%{
from_entry_id: from_entry.id,
to_entry_id: to_entry.id,
link_type: parse_link_type(Map.get(args, "link_type")),
label: Map.get(args, "label"),
kb_id: get_kb_id(ctx)
})
case store_mod.store_link(store_state, link) do
{:ok, new_state} ->
{:ok,
%{
status: "linked",
id: link.id,
from: from_entry.slug,
to: to_entry.slug,
link_type: to_string(link.link_type)
}, update_store_state(ctx, new_state)}
{:error, reason} ->
{:ok, %{status: "error", message: "Link failed: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_found} ->
{:ok,
%{status: "error", message: "Could not find entry '#{from_slug}' or '#{to_slug}'"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_backlinks(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
slug_or_id = Map.fetch!(args, "slug_or_id")
with {:ok, entry} <- resolve_entry(store_mod, store_state, slug_or_id),
{:ok, links} <- store_mod.backlinks(store_state, entry.id) do
# Fetch the source entries for each backlink
entries =
links
|> Enum.map(fn link ->
case store_mod.fetch_entry(store_state, link.from_entry_id) do
{:ok, e} ->
%{
slug: e.slug,
title: e.title,
link_type: to_string(link.link_type),
label: link.label
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
{:ok,
%{
status: "ok",
entry: entry.slug,
backlink_count: length(entries),
backlinks: entries
}, ContextUpdate.new()}
else
{:error, :not_found} ->
{:ok, %{status: "not_found", message: "Entry '#{slug_or_id}' not found"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_health_check(ctx, _args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
kb_id = get_kb_id(ctx)
opts = if kb_id, do: [kb_id: kb_id], else: []
{:ok, entries} = store_mod.list_entries(store_state, opts)
{:ok, docs} = store_mod.list_documents(store_state, opts)
# Count links via the optional bulk callback when available - O(L)
# versus per-entry outlinks/2 which scans the link table per entry
# (O(E*L) on the only included ETS impl). On a 1k-entry KB with 5k
# links this is the difference between ~5M comparisons and ~5k.
counts_by_source = link_counts_by_source(store_mod, store_state)
link_count = counts_by_source |> Map.values() |> Enum.sum()
# Identify issues
issues = identify_issues(store_mod, store_state, entries, docs)
# Compute scores
now = DateTime.utc_now()
freshness_score =
if entries == [] do
0.0
else
avg_age =
entries
|> Enum.map(fn e -> DateTime.diff(now, e.updated_at, :hour) end)
|> then(fn ages -> Enum.sum(ages) / length(ages) end)
# Score decays over 30 days (720 hours)
Float.round(:math.exp(-avg_age / 720), 3)
end
pending_docs = Enum.count(docs, fn d -> d.status == :pending end)
coverage_score =
if docs == [] do
1.0
else
compiled = Enum.count(docs, fn d -> d.status == :compiled end)
Float.round(compiled / length(docs), 3)
end
report =
Nous.KnowledgeBase.HealthReport.new(%{
kb_id: kb_id,
total_entries: length(entries),
total_links: link_count,
total_documents: length(docs),
issues: issues,
coverage_score: coverage_score,
freshness_score: freshness_score,
# L-4: weight by severity so a single high-severity issue isn't
# treated like a single low-severity nit. Clamps before rounding
# so very-bad KBs saturate at 0.0 cleanly.
coherence_score:
if(issues == [],
do: 1.0,
else:
issues
|> Enum.reduce(1.0, fn issue, acc ->
weight =
case issue.severity do
:high -> 0.2
:medium -> 0.1
:low -> 0.05
_ -> 0.05
end
acc - weight
end)
|> max(0.0)
|> Float.round(3)
)
})
{:ok,
%{
status: "ok",
total_entries: report.total_entries,
total_links: report.total_links,
total_documents: report.total_documents,
pending_documents: pending_docs,
coverage_score: report.coverage_score,
freshness_score: report.freshness_score,
coherence_score: report.coherence_score,
issue_count: length(report.issues),
issues:
Enum.map(report.issues, fn issue ->
%{
type: to_string(issue.type),
description: issue.description,
severity: to_string(issue.severity)
}
end)
}, ContextUpdate.new()}
else
{:error, :not_initialized} -> not_initialized_error()
end
end
def kb_generate(ctx, args) do
with {:ok, store_mod, store_state} <- get_store(ctx) do
topic = Map.fetch!(args, "topic")
output_type = Map.get(args, "output_type", "summary")
limit = Map.get(args, "limit", 10)
# Search for relevant entries
opts = [limit: limit, kb_id: get_kb_id(ctx)]
case store_mod.search_entries(store_state, topic, opts) do
{:ok, results} ->
entries = Enum.map(results, fn {entry, _score} -> entry end)
content =
case output_type do
"slides" -> format_as_slides(topic, entries)
"report" -> format_as_report(topic, entries)
_ -> format_as_summary(topic, entries)
end
{:ok,
%{
status: "generated",
output_type: output_type,
topic: topic,
entry_count: length(entries),
content: content
}, ContextUpdate.new()}
{:error, reason} ->
{:ok, %{status: "error", message: "Generation failed: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} -> not_initialized_error()
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp get_store(ctx) do
config = ctx.deps[:kb_config] || %{}
store_mod = config[:store]
store_state = config[:store_state]
if store_mod && store_state do
{:ok, store_mod, store_state}
else
{:error, :not_initialized}
end
end
defp not_initialized_error do
{:ok, %{status: "error", message: "Knowledge base not initialized"}, ContextUpdate.new()}
end
defp get_kb_id(ctx) do
config = ctx.deps[:kb_config] || %{}
config[:kb_id]
end
defp resolve_entry(store_mod, store_state, slug_or_id) do
case store_mod.fetch_entry_by_slug(store_state, slug_or_id) do
{:ok, _} = ok -> ok
{:error, :not_found} -> store_mod.fetch_entry(store_state, slug_or_id)
end
end
defp update_store_state(ctx, new_state) do
config = ctx.deps[:kb_config] || %{}
updated_config = Map.put(config, :store_state, new_state)
ContextUpdate.new() |> ContextUpdate.set(:kb_config, updated_config)
end
defp maybe_embed(config, content) do
embedding_provider = config[:embedding]
embedding_opts = config[:embedding_opts] || []
if embedding_provider do
case Nous.Memory.Embedding.embed(embedding_provider, content, embedding_opts) do
{:ok, emb} -> emb
{:error, _} -> nil
end
end
end
defp identify_issues(store_mod, store_state, entries, docs) do
issues = []
# M-1: hoist link reads out of the per-entry filter. Build two
# MapSets of "entries with any links" once (O(L) total) and use them
# for the orphan check (O(E) lookups), instead of calling backlinks/2
# AND outlinks/2 inside Enum.filter for every entry (O(E*L)).
out_sources = link_counts_by_source(store_mod, store_state) |> Map.keys() |> MapSet.new()
in_targets = link_targets_by_destination(store_mod, store_state, entries)
# Orphaned entries (no source documents and no links)
orphan_issues =
entries
|> Enum.filter(fn entry ->
entry.source_doc_ids == [] and
not MapSet.member?(in_targets, entry.id) and
not MapSet.member?(out_sources, entry.id)
end)
|> Enum.map(fn entry ->
%{
type: :orphan,
entry_id: entry.id,
description: "Entry '#{entry.title}' has no source documents and no links",
severity: :low,
suggested_action: "Add source documents or link to related entries"
}
end)
# Low confidence entries
low_confidence_issues =
entries
|> Enum.filter(fn entry -> entry.confidence < 0.3 end)
|> Enum.map(fn entry ->
%{
type: :low_confidence,
entry_id: entry.id,
description: "Entry '#{entry.title}' has low confidence (#{entry.confidence})",
severity: :medium,
suggested_action: "Verify and update entry content from source documents"
}
end)
# Failed documents
failed_doc_issues =
docs
|> Enum.filter(fn doc -> doc.status == :failed end)
|> Enum.map(fn doc ->
%{
type: :inconsistent,
entry_id: nil,
description: "Document '#{doc.title}' failed to compile",
severity: :high,
suggested_action: "Re-ingest or manually review document"
}
end)
issues ++ orphan_issues ++ low_confidence_issues ++ failed_doc_issues
end
# Use the optional bulk Store callback if implemented; fall back to
# per-entry outlinks/2 (the legacy O(E*L) path) so older custom backends
# keep working without code changes.
defp link_counts_by_source(store_mod, store_state) do
if function_exported?(store_mod, :link_counts_by_source, 1) do
case store_mod.link_counts_by_source(store_state) do
{:ok, counts} -> counts
_ -> %{}
end
else
%{}
end
end
# Build a MapSet of entry IDs that appear as the destination of any link.
# Single scan via the bulk callback (when available); otherwise empty
# (the orphan check is conservative: an entry with no source docs AND
# no recorded incoming/outgoing links is flagged).
defp link_targets_by_destination(store_mod, store_state, _entries) do
if function_exported?(store_mod, :link_counts_by_destination, 1) do
case store_mod.link_counts_by_destination(store_state) do
{:ok, counts} -> counts |> Map.keys() |> MapSet.new()
_ -> MapSet.new()
end
else
MapSet.new()
end
end
defp format_as_summary(topic, entries) do
entry_texts =
entries
|> Enum.map(fn entry ->
"### #{entry.title}\n#{entry.summary || String.slice(entry.content, 0, 200)}"
end)
|> Enum.join("\n\n")
"""
# Summary: #{topic}
#{entry_texts}
"""
end
defp format_as_report(topic, entries) do
entry_texts =
entries
|> Enum.map(fn entry ->
concepts =
if entry.concepts != [],
do: "**Concepts:** #{Enum.join(entry.concepts, ", ")}\n\n",
else: ""
"## #{entry.title}\n\n#{concepts}#{entry.content}"
end)
|> Enum.join("\n\n---\n\n")
"""
# Report: #{topic}
**Entries consulted:** #{length(entries)}
#{entry_texts}
"""
end
defp format_as_slides(topic, entries) do
slides =
entries
|> Enum.map(fn entry ->
"---\n\n# #{entry.title}\n\n#{entry.summary || String.slice(entry.content, 0, 300)}"
end)
|> Enum.join("\n\n")
"""
---
marp: true
theme: default
---
# #{topic}
#{slides}
"""
end
defp parse_entry_type("article"), do: :article
defp parse_entry_type("concept"), do: :concept
defp parse_entry_type("summary"), do: :summary
defp parse_entry_type("index"), do: :index
defp parse_entry_type("glossary"), do: :glossary
defp parse_entry_type(_), do: nil
defp parse_doc_type("markdown"), do: :markdown
defp parse_doc_type("text"), do: :text
defp parse_doc_type("url"), do: :url
defp parse_doc_type("pdf"), do: :pdf
defp parse_doc_type("html"), do: :html
defp parse_doc_type(_), do: :markdown
defp parse_link_type("backlink"), do: :backlink
defp parse_link_type("cross_reference"), do: :cross_reference
defp parse_link_type("concept"), do: :concept
defp parse_link_type("see_also"), do: :see_also
defp parse_link_type("parent_child"), do: :parent_child
defp parse_link_type(_), do: :cross_reference
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end