defmodule Magma.Artefact do
@moduledoc """
`Magma.Artefact` is a behaviour for defining different types of artefacts.
A Magma artefact represents a specific type of output that can be generated
for some Magma matter. The module provides a set of callbacks for specifying
various aspects of the artefacts such as naming, path definitions, and
document generation details.
"""
alias Magma.{Concept, View}
alias __MODULE__
@fields [:name, :concept]
def fields, do: @fields
@type t :: struct
@type type :: module
@doc """
A callback that returns the name of an artefact to be used as default for the `:name` field.
"""
@callback default_name(Concept.t()) :: binary | nil
@doc """
A callback that returns the name of the `Magma.Artefact.Prompt` document for this type of matter.
"""
@callback prompt_name(t()) :: binary
@doc """
A callback that returns the system prompt text of the `Magma.Artefact.Prompt` document for this type of matter that describes what to generate.
As opposed to the `c:request_prompt_task/1` this is a general, static text
used by artefacts of this type.
The generated default implementation returns a transclusion of the
"System prompt" section of the respective artefact config document.
"""
@callback system_prompt_task(Concept.t()) :: binary
@doc """
A callback that returns the request prompt text of the `Magma.Artefact.Concept` document for this type of matter that describes what to generate.
Despite returning also a general text like the `c:system_prompt_task/1`, this
one is included in the "Artefacts" section of the `Magma.Concept` document
(and only transcluded in `Magma.Artefact.Prompt` document), so that the user
has a chance to adapt it for a specific instance of this artefact type.
The generated default implementation returns the content of the
"Task prompt" section of the respective artefact config document,
after EEx evaluation with the bindings returned by `c:request_prompt_task_template_bindings/1`.
"""
@callback request_prompt_task(Concept.t()) :: binary
@doc """
A callback that returns the bindings to be applied when rendering the `c:request_prompt_task/1` EEx template.
"""
@callback request_prompt_task_template_bindings(Concept.t()) :: keyword
@doc """
A callback that returns the title of the "Artefacts" subsection for this type of matter in the `Magma.Concept` document.
This section consists of links to the `Magma.Artefact.Prompt` and the
`Magma.Artefact.Version` of this document and another subsection for the
text returned by the `c:request_prompt_task/1` callback.
"""
@callback concept_section_title :: binary
@doc """
A callback that returns the title of the "Artefacts" subsection for this type of matter in the `Magma.Concept` document where for the text returned by the `c:request_prompt_task/1` callback is rendered.
By default, this is just the `concept_section_title/0` with `"prompt task"` appended.
"""
@callback concept_prompt_task_section_title :: binary
@doc """
A callback that allows to specify texts which should be included generally in the "Context knowledge" section of the `Magma.Artefact.Prompt` document about this type of artefact.
"""
@callback context_knowledge(Concept.t()) :: binary | nil
@doc """
A callback that returns the title to be used for the `Magma.Artefact.Version` document.
"""
@callback version_title(Artefact.Version.t()) :: binary
@doc """
A callback that allows to specify a text which should be included in the prologue of the `Magma.Artefact.Version` document of this artefact type.
"""
@callback version_prologue(Artefact.Version.t()) :: binary | nil
@doc """
A callback that returns if the initial header of a generated `Magma.PromptResult` for this type artefact should be stripped.
Since the title for the `Magma.PromptResult` is already defined,
the title generated by an LLM should be ignored usually.
For some types of artefacts, however, this should not be the case.
These artefact types, the default implementation returning `true`,
can be overwritten.
"""
@callback trim_prompt_result_header? :: boolean
@doc """
A callback that returns the general path segment to be used for documents for this type of artefact.
"""
@callback relative_base_path(t()) :: Path.t()
@doc """
A callback that returns the path for `Magma.Artefact.Prompt` documents about this type of artefact.
Since the `Magma.PromptResult` document are always stored in the subdirectory
where the prompt are stored, this function also determines their path.
This path is relative to the `Magma.Vault.artefact_generation_path/0`.
"""
@callback relative_prompt_path(t()) :: Path.t()
@doc """
A callback that returns the path for `Magma.Artefact.Version` documents about this type of artefact.
This path is relative to the `Magma.Vault.artefact_version_path/0`.
"""
@callback relative_version_path(t()) :: Path.t()
@doc """
A callback that creates a new instance of a type of artefact with the default name.
"""
@callback new(Concept.t()) :: {:ok, t()} | {:error, any}
@doc """
A callback that creates a new instance of a type of artefact.
"""
@callback new(Concept.t(), keyword) :: {:ok, t()} | {:error, any}
@doc """
A callback that allows to implement a custom `Magma.Artefact.Version` document creation function.
This function should return `nil` if the default `Magma.Artefact.Version.create/2`
should be used (which the default implementation does automatically).
"""
@callback create_version(Artefact.Version.t(), keyword) ::
{:ok, Path.t() | Artefact.Version.t()} | {:error, any} | nil
defmacro __using__(opts) do
matter_type = Keyword.fetch!(opts, :matter)
additional_fields = Keyword.get(opts, :fields, [])
quote do
@behaviour Magma.Artefact
alias Magma.Artefact
defstruct Artefact.fields() ++ unquote(additional_fields)
def matter_type, do: unquote(matter_type)
def config do
Magma.Config.artefact(__MODULE__)
end
def config(key) do
Magma.Config.artefact(__MODULE__, key)
end
def config_name do
Magma.Config.Artefact.name_by_type(__MODULE__)
end
@impl true
def concept_section_title, do: Artefact.type_name(__MODULE__)
@impl true
def concept_prompt_task_section_title, do: "#{concept_section_title()} prompt task"
@impl true
def prompt_name(%__MODULE__{name: name}), do: "Prompt for #{name}"
@impl true
def relative_prompt_path(%__MODULE__{} = artefact) do
artefact
|> relative_base_path()
|> Path.join("#{prompt_name(artefact)}.md")
end
@impl true
def relative_version_path(%__MODULE__{name: name} = artefact) do
artefact
|> relative_base_path()
|> Path.join("#{name}.md")
end
@impl true
def version_title(%Artefact.Version{artefact: %__MODULE__{}} = version), do: version.name
@impl true
def version_prologue(%Artefact.Version{artefact: %__MODULE__{}}), do: nil
@impl true
def trim_prompt_result_header?, do: true
@impl true
def system_prompt_task(_concept) do
View.transclude(config_name(), Magma.Config.Artefact.system_prompt_section_title())
end
@impl true
def request_prompt_task(concept) do
Magma.Config.Artefact.render_request_prompt(
config(),
request_prompt_task_template_bindings(concept)
)
end
@impl true
def request_prompt_task_template_bindings(concept) do
[
project:
if(match?(%Magma.Matter.Project{}, concept.subject),
do: concept,
else: Magma.Config.project()
),
concept: concept,
subject: concept.subject
]
end
@impl true
def context_knowledge(%Concept{}) do
Magma.Config.Artefact.context_knowledge_transclusion(__MODULE__)
end
@impl true
def new(concept, attrs \\ []) do
with {:ok, attrs} <- attrs |> Keyword.get(:name) |> set_name_attr(concept, attrs) do
{:ok, struct(__MODULE__, Keyword.put(attrs, :concept, concept))}
end
end
def new!(concept, attrs \\ []) do
case new(concept, attrs) do
{:ok, artefact} -> artefact
{:error, error} -> raise error
end
end
defp set_name_attr(nil, concept, attrs) do
if default_name = default_name(concept) do
{:ok, Keyword.put(attrs, :name, default_name)}
else
{:error, "name missing on #{inspect(__MODULE__)}"}
end
end
defp set_name_attr(name, _, attrs) when is_binary(name), do: {:ok, attrs}
defp set_name_attr(name, _, _), do: {:error, "invalid name type: #{inspect(name)}"}
@impl true
def create_version(%Artefact.Version{artefact: %__MODULE__{}}, opts), do: nil
defoverridable prompt_name: 1,
trim_prompt_result_header?: 0,
relative_prompt_path: 1,
relative_version_path: 1,
version_title: 1,
version_prologue: 1,
new: 1,
new: 2,
create_version: 2,
system_prompt_task: 1,
request_prompt_task: 1,
request_prompt_task_template_bindings: 1
end
end
@doc """
Returns the artefact type name for the given artefact type module.
## Example
iex> Magma.Artefact.type_name(Magma.Artefacts.ModuleDoc)
"ModuleDoc"
iex> Magma.Artefact.type_name(Magma.Artefacts.Article)
"Article"
iex> Magma.Artefact.type_name(Magma.Vault)
** (RuntimeError) Invalid Magma.Artefacts type: Magma.Vault
iex> Magma.Artefact.type_name(NonExisting)
** (RuntimeError) Invalid Magma.Artefacts type: NonExisting
"""
def type_name(type, validate \\ true) do
if not validate or type?(type) do
case Module.split(type) do
["Magma", "Artefacts" | name_parts] -> Enum.join(name_parts, ".")
_ -> raise "Invalid Magma.Artefacts type name scheme: #{inspect(type)}"
end
else
raise "Invalid Magma.Artefacts type: #{inspect(type)}"
end
end
@doc """
Returns the artefact type module for the given string.
## Example
iex> Magma.Artefact.type("ModuleDoc")
Magma.Artefacts.ModuleDoc
iex> Magma.Artefact.type("TableOfContents")
Magma.Artefacts.TableOfContents
iex> Magma.Artefact.type("Vault")
nil
iex> Magma.Artefact.type("NonExisting")
nil
"""
def type(string) when is_binary(string) do
module = Module.concat(Magma.Artefacts, string)
if type?(module) do
module
end
end
@doc """
Checks if the given `module` is a `Magma.Artefact` type module.
"""
def type?(module) do
Code.ensure_loaded?(module) and function_exported?(module, :relative_prompt_path, 1)
end
def relative_prompt_path(%artefact_type{} = artefact) do
artefact_type.relative_prompt_path(artefact)
end
def relative_version_path(%artefact_type{} = artefact) do
artefact_type.relative_version_path(artefact)
end
@doc """
Extracts an `Magma.Artefact` instance from YAML frontmatter metadata.
The function attempts to retrieve the `magma_artefact` and
`magma_concept` from the metadata. It returns a tuple containing
the artefact (if found and valid), and the remaining metadata.
"""
def extract_from_metadata(metadata) do
{artefact_type_name, metadata} = Map.pop(metadata, :magma_artefact)
{artefact_name, metadata} = Map.pop(metadata, :magma_artefact_name)
{concept_link, metadata} = Map.pop(metadata, :magma_concept)
cond do
!artefact_type_name ->
{:error, "magma_artefact missing"}
!concept_link ->
{:error, "magma_concept missing"}
artefact_type = type(artefact_type_name) ->
with {:ok, concept} <- Concept.load_linked(concept_link),
{:ok, artefact} <- artefact_type.new(concept, name: artefact_name) do
{:ok, artefact, metadata}
end
true ->
{:error, "invalid magma_artefact type: #{artefact_type_name}"}
end
end
def render_front_matter(%artefact_type{concept: concept, name: name}) do
"""
magma_artefact: #{type_name(artefact_type)}
magma_concept: "#{View.link_to(concept)}"
#{if name && name != artefact_type.default_name(concept) do
"magma_artefact_name: #{name}"
end}
"""
|> String.trim_trailing()
end
end