Packages
nous
0.15.7
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/knowledge_base/store/ets.ex
defmodule Nous.KnowledgeBase.Store.ETS do
@moduledoc """
ETS-backed knowledge base store implementation.
Uses three unnamed ETS tables (documents, entries, links) so multiple
instances can coexist. Text search uses `String.jaro_distance/2` for
fuzzy matching on entry content and title.
"""
@behaviour Nous.KnowledgeBase.Store
alias Nous.KnowledgeBase.{Document, Entry, Link}
@type state :: %{
documents: :ets.table(),
entries: :ets.table(),
links: :ets.table()
}
@impl true
def init(_opts) do
state = %{
documents: :ets.new(:kb_documents, [:set, :public]),
entries: :ets.new(:kb_entries, [:set, :public]),
links: :ets.new(:kb_links, [:set, :public])
}
{:ok, state}
end
# ---------------------------------------------------------------------------
# Document CRUD
# ---------------------------------------------------------------------------
@impl true
def store_document(state, %Document{} = doc) do
:ets.insert(state.documents, {doc.id, doc})
{:ok, state}
end
@impl true
def fetch_document(state, id) do
case :ets.lookup(state.documents, id) do
[{^id, doc}] -> {:ok, doc}
[] -> {:error, :not_found}
end
end
@impl true
def update_document(state, id, updates) do
case fetch_document(state, id) do
{:ok, doc} ->
now = DateTime.utc_now()
updated = struct(doc, Map.put(updates, :updated_at, now))
:ets.insert(state.documents, {id, updated})
{:ok, state}
error ->
error
end
end
@impl true
def list_documents(state, opts) do
kb_id = Keyword.get(opts, :kb_id)
status = Keyword.get(opts, :status)
docs =
state.documents
|> all_records()
|> filter_by_kb_id(kb_id)
|> maybe_filter(fn doc -> is_nil(status) || doc.status == status end)
{:ok, docs}
end
@impl true
def delete_document(state, id) do
:ets.delete(state.documents, id)
{:ok, state}
end
# ---------------------------------------------------------------------------
# Entry CRUD + search
# ---------------------------------------------------------------------------
@impl true
def store_entry(state, %Entry{} = entry) do
:ets.insert(state.entries, {entry.id, entry})
{:ok, state}
end
@impl true
def fetch_entry(state, id) do
case :ets.lookup(state.entries, id) do
[{^id, entry}] -> {:ok, entry}
[] -> {:error, :not_found}
end
end
@impl true
def fetch_entry_by_slug(state, slug) do
result =
state.entries
|> all_records()
|> Enum.find(fn entry -> entry.slug == slug end)
case result do
nil -> {:error, :not_found}
entry -> {:ok, entry}
end
end
@impl true
def update_entry(state, id, updates) do
case fetch_entry(state, id) do
{:ok, entry} ->
now = DateTime.utc_now()
updated = struct(entry, Map.put(updates, :updated_at, now))
:ets.insert(state.entries, {id, updated})
{:ok, state}
error ->
error
end
end
@impl true
def delete_entry(state, id) do
:ets.delete(state.entries, id)
{:ok, state}
end
@impl true
def list_entries(state, opts) do
kb_id = Keyword.get(opts, :kb_id)
tags = Keyword.get(opts, :tags)
concepts = Keyword.get(opts, :concepts)
entry_type = Keyword.get(opts, :entry_type)
limit = Keyword.get(opts, :limit)
entries =
state.entries
|> all_records()
|> filter_by_kb_id(kb_id)
|> maybe_filter(fn entry -> is_nil(entry_type) || entry.entry_type == entry_type end)
|> maybe_filter(fn entry ->
is_nil(tags) || Enum.any?(tags, &(&1 in entry.tags))
end)
|> maybe_filter(fn entry ->
is_nil(concepts) || Enum.any?(concepts, &(&1 in entry.concepts))
end)
|> maybe_take(limit)
{:ok, entries}
end
@impl true
def search_entries(state, query, opts) do
kb_id = Keyword.get(opts, :kb_id)
limit = Keyword.get(opts, :limit, 10)
min_score = Keyword.get(opts, :min_score, 0.0)
query_down = String.downcase(query)
results =
state.entries
|> all_records()
|> filter_by_kb_id(kb_id)
|> Enum.map(fn entry ->
# Score against both title and content for better matching
title_score = String.jaro_distance(query_down, String.downcase(entry.title))
content_score = String.jaro_distance(query_down, String.downcase(entry.content))
# Weight title matches higher
score = max(title_score * 1.2, content_score) |> min(1.0)
{entry, score}
end)
|> Enum.filter(fn {_entry, score} -> score > min_score end)
|> Enum.sort_by(fn {_entry, score} -> score end, :desc)
|> Enum.take(limit)
{:ok, results}
end
# ---------------------------------------------------------------------------
# Link CRUD + graph
# ---------------------------------------------------------------------------
@impl true
def store_link(state, %Link{} = link) do
:ets.insert(state.links, {link.id, link})
{:ok, state}
end
@impl true
def delete_link(state, id) do
:ets.delete(state.links, id)
{:ok, state}
end
@impl true
def backlinks(state, entry_id) do
links =
state.links
|> all_records()
|> Enum.filter(fn link -> link.to_entry_id == entry_id end)
{:ok, links}
end
@impl true
def outlinks(state, entry_id) do
links =
state.links
|> all_records()
|> Enum.filter(fn link -> link.from_entry_id == entry_id end)
{:ok, links}
end
@impl true
def link_counts_by_source(state) do
counts =
state.links
|> all_records()
|> Enum.frequencies_by(& &1.from_entry_id)
{:ok, counts}
end
@impl true
def related_entries(state, entry_id, opts) do
limit = Keyword.get(opts, :limit, 10)
# Get all linked entry IDs (both directions)
linked_ids =
state.links
|> all_records()
|> Enum.flat_map(fn link ->
cond do
link.from_entry_id == entry_id -> [link.to_entry_id]
link.to_entry_id == entry_id -> [link.from_entry_id]
true -> []
end
end)
|> Enum.uniq()
# Fetch the actual entries
entries =
linked_ids
|> Enum.map(fn id ->
case fetch_entry(state, id) do
{:ok, entry} -> entry
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.take(limit)
{:ok, entries}
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp all_records(table) do
:ets.tab2list(table) |> Enum.map(fn {_id, record} -> record end)
end
defp filter_by_kb_id(records, nil), do: records
defp filter_by_kb_id(records, kb_id) do
Enum.filter(records, fn record -> record.kb_id == kb_id end)
end
defp maybe_filter(records, fun), do: Enum.filter(records, fun)
defp maybe_take(records, nil), do: records
defp maybe_take(records, limit), do: Enum.take(records, limit)
end