AI Assistant
An AI assistant that operates the use cases of an application, with the permissions of the signed-in user and nothing more.
Tutorial 77 shows the mechanics of the tool loop. This tutorial shows the conventions: how a real business application exposes its use cases to a model, how it ships its authorization, and what keeps a model from changing something nobody asked for. The example is a small library app; the AI system and the requirement catalogue come from the framework.
The assistant needs a configured AI provider, e.g. an Anthropic token in the admin center, and the roles “Bibliothekar” and “AI Assistant User”. Without them the app runs without the assistant button.
Structure
Unlike the other tutorials, which put everything into one main.go, this one follows the go_nago_ddd1 profile
of speclink, the traceability tool worldiety uses to check Nago
projects. This structure is the reason why the assistant below needs no adapter at all. See
Architecture and speclink for the style and the tool.
cmd/ai-example/main.go entry point: bootstrap and wiring, no business logic
app/library/ the bounded context: what the system does
model.go aggregate, filter, request, result
perm.go one permission per use case
repository.go what the context needs to store, not how
usecases.go the UseCases bundle
uc_find_all_books.go one use case per file, type and constructor together
uc_lend_book.go
uc_return_book.go
uc_*.annotation.go binds a use case to its requirements
ui/page_books.go package uilibrary: the view for humans
ai/tools.go package ailibrary: the view for a model
cfg/cfg.go package cfglibrary: the only place where both meet
requirements/
dec/R-DEC-*.spec.go the decisions: why the library works the way it does
fun/library/R-LIB-*.spec.go what it must doDependencies point inwards only: ui and ai import library, library imports neither. A context which
imports its own UI cannot be tested without a renderer. ai/ sits next to ui/ on purpose: a model is one way
to reach the context, just as a screen is, and neither belongs to the domain.
Because the entry point lives under cmd/, the run command is one path segment longer than for the other
tutorials:
go run go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/cmd/ai-example@latestThe example uses github.com/worldiety/speclink/spec, a module without dependencies which only contains the
declarations. The speclink tool itself (speclink.json, speclink verify, the static checks) is not part of
it, because a compiler frontend does not belong into the module graph of an application. So the requirements are
declared and read at runtime, but not checked statically. A real project adds that.
Why the tools need no adapter
A use case in this profile has the shape
func(subject auth.Subject, in In) (Out, error)This is not a convenience for the AI but the architecture rule of the project: the subject is a parameter, so
the caller has to decide who is acting. And it is exactly the shape a tool needs, so
completion.NewUseCaseTool takes the use case unchanged:
lend := completion.NewUseCaseTool("lend_book",
"Leiht ein Exemplar eines Buches an eine Person aus. Nenne vorher Titel und Person, damit klar ist, was passiert.",
uc.LendBook)No adapter, no subject at construction time, no rebuild per request. The tools are built once at start-up and shared by every window and every user. An application cut differently has to write a wrapper for every tool, and has to write the authorization a second time by hand.
Seven rules
These rules are what the example is about; app/library/ai/tools.go follows them.
1. A tool goes through a use case, never through a repository
This is the whole authorization model. A Nago use case audits the given subject, the same one a click in the UI
would be checked against. NewUseCaseTool passes the subject of the asking user through, so the assistant
cannot read what this user may not read, and cannot write what they may not write. A tool which bypasses the use
case and reads the repository directly breaks this unnoticed, because it works.
2. Use cases need no wrapper
NewUseCaseTool takes func(auth.Subject, In) (Out, error). For the second common shape,
func(auth.Subject, Filter) iter.Seq2[T, error], there is completion.NewSeqTool (see rule 4). If you really
need a function of your own, use completion.NewSubjectTool, which also receives the acting subject.
3. Mark writing tools, do not describe them
lend := completion.NewUseCaseTool(...).
AsMutating("gibt ein Exemplar heraus und trägt die Person als Ausleiher ein")A sentence in the system prompt (“always ask before you write”) is a request to the model. The mark is a gate in front of it: unless the operator switched the confirmation off in the assistant settings, the chat holds the call and shows the tool, its effect and the arguments the model chose before anything happens. The operator setting Nur lesender Zugriff (read-only) removes writing tools completely, so the model does not even learn that they exist.
The text passed to AsMutating is what the user reads before confirming. An empty text makes the dialog
worthless.
4. Bound every listing
list := completion.NewSeqTool("list_books", "…", uc.FindAllBooks)NewSeqTool adds a limit property to the schema (default completion.DefaultSeqToolLimit = 200, at most
completion.MaxSeqToolLimit = 1000) and wraps the result in items, count, truncated and a plain-text
note. Without it, a FindAll over a few thousand records exhausts the context window, and the error looks
like a provider problem rather than a missing limit. A list cut short silently is worse than one cut short
openly: the model should narrow its filter instead of drawing conclusions from half an answer.
5. Names and descriptions are part of the interface
Tool names are lower_snake_case, and descriptions are written for someone who has never seen the application.
Both are validated at construction: an invalid name or an empty description panics at start-up instead of
confusing the model at runtime.
The same holds for the desc tags of the input type; they are the documentation the model reads:
type BookFilter struct {
Query string `json:"query" optional:"true" desc:"case-insensitive substring matched against title and author"`
OnlyAvailable bool `json:"onlyAvailable" optional:"true" desc:"when true, only books with at least one free copy are returned"`
Borrower string `json:"borrower" optional:"true" desc:"only books currently lent to this borrower"`
}6. The domain type is the tool type
A separate DTO next to the aggregate is usually unnecessary. Put the desc tags on the domain type, just as
label tags sit where form.Auto reads them:
type Book struct {
ID BookID `json:"id" desc:"stable identifier, used when lending or returning"`
Copies int `json:"copies" desc:"total number of copies owned, lent out ones included"`
LentTo []string `json:"lentTo,omitempty" desc:"names of the people currently holding a copy; …"`
// …
}The schema reflection handles this: named string types (type BookID string) become string, unexported fields
are skipped, time.Time becomes a date-time string.
A DTO is right when the model needs something the domain type does not have: a value formatted for humans, an aggregate computed over several records, or a deliberately reduced view. Not to rename fields.
A field is required by default; optional:"true", a json omitempty or a pointer make it optional. A filter is
the opposite of a request, so every filter field carries optional:"true":
type BookFilter struct {
Query string `json:"query" optional:"true" desc:"…"` // filter: narrows
}
type LendRequest struct {
Book BookID `json:"book" desc:"…"` // request: commands
}A filter whose fields are reported as required forces the model to invent values for all of them on every call, and it will, because the schema says so.
7. Describe the result only where it is not obvious
No provider accepts an output schema for tools; a tool definition has a name, a description and an input schema.
The only place a description of the result can go is the description text, which is sent with every request for
every tool. So WithResultDoc() is an explicit opt-in:
list := completion.NewSeqTool("list_books", "…", uc.FindAllBooks).
WithResultDoc() // because of truncated: the model cannot tell that from the data
lend := completion.NewUseCaseTool("lend_book", "…", uc.LendBook).
AsMutating("…") // no WithResultDoc: summary and available speak for themselvesWhere field names speak for themselves, the model learns the shape from the first real result, for free. Note
the consequence: without WithResultDoc(), the desc tags of the result type never reach the model.
Permissions: two roles, not one
The assistant needs three framework permissions: list the providers, list the models, create a session
(uicompletion.RequiredPermissions). Do not put them into the business role. cfgai.Enable declares a system
role for them:
cfgai.RoleAssistantUser // "nago.ai.assistant.user", shown as "AI Assistant User"Assign the role and the button appears. The business role is unaffected, and the assistant can still only do what the business role allows. Ship your own business role the same way:
cfg.DeclareSystemRole(role.Role{
ID: RoleLibrarian,
Name: "Bibliothekar",
Description: "Darf den Bestand einsehen sowie Exemplare ausleihen und zurücknehmen.",
}, LibrarianPermissions()...)A system role can neither be deleted nor have its permissions replaced in the admin UI, since both would be undone on the next start. Name and description are written only when the role is created; the operator may change them and a restart does not overwrite that. See role management.
This deserves a test: the bootstrap account holds every permission by construction, so nobody notices when a
shipped role grants none of them, until the first real user sees an empty page. app/library/cfg/roles_test.go
therefore spells out the permission IDs as literals. A test which derives its expectation from the code under
test agrees with every change, including the wrong one.
Context: where the user is
Routes describe themselves when they are registered:
// app/library/cfg/cfg.go
cfg.RootViewWithDecoration(pages.Books, func(wnd core.Window) core.View {
return uilibrary.PageBooks(wnd, uc)
}, application.Purpose(
"Den Bestand der Bibliothek durchsehen: welche Titel es gibt, wie viele Exemplare frei sind und wer welches ausgeliehen hat."))uicompletion.WindowContext(wnd) turns this into the situational part of the prompt: the route and its purpose,
the parameters the page was opened with, and who is asking with their permissions. Everything comes from what the
framework already knows, so it cannot drift like a hand-maintained list of screens. The domain part of the
prompt stays separate, see SystemPromptFunc below.
Requirements as a tool: “why is it like this?”
Every domain tool answers a question about data. None answers the question people actually have when the system refuses something:
“Von Der Prozess ist derzeit kein Exemplar frei.” Then why can’t I reserve one?
Without a tool for that, the model does not refuse to answer; it invents a reason, fluent and plausible. An invented rule is worse than silence, because it sounds like the system talking about itself.
Nago ships this
Requirements are declared with spec.Declare and end up in a runtime catalogue:
// requirements/dec/R-DEC-AVAILABILITY.spec.go
var RDecAvailability = spec.Declare(spec.Requirement{
ID: "R-DEC-AVAILABILITY",
Kind: spec.Decision,
Discipline: spec.Technical,
Status: spec.Normative,
Title: "Verfügbarkeit wird berechnet, nicht gespeichert",
Text: "Die Zahl der freien Exemplare ergibt sich aus Copies minus der Länge von LentTo …",
Rationale: "Ein abgeleiteter Wert, der zusätzlich gespeichert wird, …",
Consequences: "Jede Anzeige rechnet neu. …",
})The annotation file binds use case and requirement. It is part of the normal build, so it breaks when the use case disappears:
// app/library/uc_lend_book.annotation.go
var _ = spec.For[LendBook](
spec.Satisfies(fun.RLibLend, dec.RDecBorrower),
spec.Help("Gibt ein Exemplar an eine Person heraus. Ist keines frei, wird die Ausleihe abgelehnt — eine Vormerkung gibt es noch nicht."),
)That is all the work. The tools come from the framework:
specMod := option.Must(cfgspeclink.Enable(cfg))
tools := append(ailibrary.Tools(lib.UseCases), aispeclink.Tools(specMod.UseCases)...)
SystemPromptFunc: func() string {
return ailibrary.SystemPrompt + "\n\n" +
aispeclink.Index(wnd.Subject(), specMod.UseCases) + "\n" +
uicompletion.WindowContext(wnd)
},aispeclink.Tools returns list_requirements, read_requirement, read_capabilities and
read_source_document; aispeclink.Index renders one line per visible requirement for the prompt. There is no
hand-written knowledge layer in this example, and that is the point: a hand-made copy of the catalogue drifts
without anything breaking.
State is not existence
R-LIB-RESERVATION is deliberately planned and bound to nothing. A view which only reads the bindings would not
see it at all, and the assistant would answer “that does not exist” instead of “that is not implemented yet”. This
is why Nago reads the catalogue (spec.Requirements()), not the binding registry.
Who may see which requirement
spec.Requirement has a Disclosure field (public, internal, confidential, secret). speclink states that
nothing enforces it; enforcing it is up to whoever shows the text to somebody, so Nago does:
| Disclosure | without nago.speclink.requirement.read_internal | with |
|---|---|---|
public | visible | visible |
internal, confidential | hidden | visible |
secret | never | only individually, never in a list |
A requirement the user may not see is reported as not existing. Answering “it exists, but you may not see it” already reveals more than guessing an ID should.
The system role nago.speclink.reader bundles the reading permissions (list and read requirements, list
capabilities, read source documents). read_internal is deliberately not part of it, because that is a decision
about a person, not about a feature.
An admin page on the side
cfgspeclink.Enable also registers the page admin/speclink/requirements. Same list, same use cases, same
disclosure rules, so what an operator sees there and what the assistant may say are the same set by
construction.
The button
Provider resolution, model choice, settings, caching and diagnostics come with cfgai:
cfg.SetDecorator(func(wnd core.Window, view core.View) core.View {
return scaffold(wnd, modAI.Assistant.Decorate(wnd, view, cfgai.AssistantOptions{ /* … */ }))
})It hangs on the decorator rather than on single pages, so the assistant is really everywhere, including the
admin pages of the framework. When it cannot run (no provider, no model, hidden by the operator, missing role),
Decorate returns the view unchanged and logs the reason once. A missing token must not become a banner that
follows the user through the application.
The assistant also follows the user from page to page within the browser tab: it stays open and keeps the draft
and, with History, the conversation, including a turn which is still running. A navigation allocates a new
window, so these states are kept by the scope of the tab, per signed-in user.
A tool may show a view: “open this page”
A model can only write text, and a link in that text is a poor way to send the user somewhere: the model invents the address, and a link to another origin may even carry data out, if a prompt injection succeeded. Therefore, links of the model navigate within the application without reloading the page, and an external one opens only after the user saw its full address.
Better, a tool decides where to go and the chat shows a button for it, completion.Tool.View:
suggest := completion.NewUseCaseTool("suggest_view", "…", uc.SuggestView) // checks that the page exists and the user may open it
suggest.View = func(wnd core.Window, args, result json.RawMessage) core.View {
var out SuggestedView
if json.Unmarshal(result, &out) != nil {
return nil
}
return ui.SecondaryButton(func() {
wnd.Navigation().ForwardTo(out.Path, out.Values)
}).Title("Ansicht „"+out.Title+"“ öffnen").Key("suggest_view", string(args))
}The chat renders the view below the call, also while the section of the tool calls is collapsed, and again when a conversation is restored from the history. Hence, the view depends on the arguments and the result only.
Date and time are built in
A model has no clock. Its “today” is its training cut-off, and it calculates with that fluently and wrongly:
“overdue since yesterday”, “next Monday”, “in two weeks”. So the assistant brings the tool current_time on its
own: date, time, weekday, ISO week and time zone of the window (wnd.Location()). This tutorial does nothing for
it.
Why a tool and not a line in the system prompt: a prompt that changes every second never hits the prompt cache of
the provider, while a tool costs only when the model needs it. If you provide a tool with the same name, the
built-in one steps back; AssistantOptions.DisableCurrentTime turns it off.
Research on the web
The assistant can search the web and read pages, e.g. to compare a book with its reviews. Both are off by default, because searches are billed apart from the tokens and their queries leave the application. The operator turns them on in the global settings KI-Assistent, section Web-Recherche, each on its own: Websuche erlauben searches, Webseiten abrufen erlauben reads pages whose addresses already appear in the conversation, written by the user or found by the search. Like the confirmation, a single assistant may deviate from the operator’s choice:
modAI.Assistant.Decorate(wnd, view, cfgai.AssistantOptions{
// …
WebResearch: cfgai.WebResearchAlways, // or cfgai.WebResearchNever; the zero value follows the settings
})Nago fetches nothing itself: the provider runs both tools within the request, as server tools of the Messages API. Anthropic runs them natively. The Nago AI Service runs them for its other models as well: it reads pages from public addresses only, and only those which already appear in the conversation, so a model steered by a foreign page cannot send data to an address it made up. A search needs a search service configured there.
The chat shows each search with its hits and each page read, as links, in the section of the tool calls, and marks every cited passage of the answer with a numbered link to its page. A model which refuses the tools, e.g. because the organization disabled them, continues without them. Other providers, like a local model, ignore the settings.
The screen as feedback channel
Every tool above tells the model what is in the data. None tells it what the user sees, and that is often the
question: “what does the number on the right mean?”, or the model just lent a book and assumes the list was
updated. uicompletion.ScreenTool is a ready-made tool for this, a kind of built-in Playwright:
Tools: slices.Concat(tools, []completion.Tool{
uicompletion.ScreenTool(wnd, uicompletion.ScreenToolOptions{}),
}),The tool is called inspect_screen. It always returns an accessibility snapshot of what is currently rendered,
with roles, names, values and states as an indented tree:
- heading "Bestand" [level=1]
- textbox "Suche": "Kafka"
- button "Ausleihen"
- checkbox "Nur verfügbare" [checked]If the model asks for it (image: true), a PNG is added. Text is the default because it is more precise and much
cheaper: an image stays in the history and is billed again on every following turn.
Three things are deliberate:
- It is bound to the window. Unlike the domain tools, it is built per window in the decorator, because it looks
at exactly this window.
slices.Concatcopies, so the shared slice is never appended to from several windows at once. It is also never handed to sub-agents. - It needs no permission. The model only sees what is rendered for the acting user anyway, and the developer has to wire the tool explicitly.
- Pending changes are rendered first.
wnd.Screenshotsends the latest state before requesting the capture, and the frontend processes messages in order. A look right afterlend_bookshows the result, not the state before.
The PNG is re-rendered from the DOM by the frontend, not captured from the screen. Images from foreign domains
without CORS, iframes and videos may be missing. That is good enough for “does this look right”; if you need the
capture yourself, call wnd.Screenshot(core.ScreenshotOptions{…}) directly.
Try it
Sign in with the bootstrap admin; its password is in cmd/ai-example/main.go. Store a provider token in the
vault of the admin center (see secret management) and assign the roles
“Bibliothekar” and “AI Assistant User” to the users. For the questions about requirements they also need the
role “Requirements Reader” (nago.speclink.reader).
Questions to try:
- “Was ist von Kafka da?” A read query.
- “Leih Die Verwandlung an Bernd aus.” The confirmation dialog appears. Decline once and watch the model pick up the refusal instead of failing.
- Enable Nur lesender Zugriff in the assistant settings and ask again. The model no longer knows the tool.
- “Warum steht bei den Ausleihern nur ein Name und kein Benutzerkonto?” The model looks up
R-DEC-BORROWERand also names what the decision costs, instead of making something up. - “Kann ich ein ausgeliehenes Buch vormerken?” The answer is “not yet”, not “does not exist”:
R-LIB-RESERVATIONisplanned. - “Was kann ich hier eigentlich machen?” Orientation via
read_capabilities. - “Welcher Wochentag ist heute, und welche Kalenderwoche?”
current_timeinstead of a guessed date. - “Was steht bei mir gerade auf dem Bildschirm?”
inspect_screenreturns the snapshot; “Wie sieht das aus?” also fetches the image.
Code
The entry point bootstraps the framework, enables the library context, the AI system and the requirement catalogue, and hangs the assistant on the decorator:
// Command ai-example puts an AI assistant on top of an ordinary nago application.
//
// The entry point lives under cmd/ and does what an entry point does: it bootstraps the framework, enables
// the contexts and decides how they are reached. Every piece of business meaning is behind app/library.
package main
import (
"slices"
"time"
"github.com/worldiety/option"
"go.wdy.de/nago/application"
cfgai "go.wdy.de/nago/application/ai/cfg"
"go.wdy.de/nago/application/ai/completion"
uicompletion "go.wdy.de/nago/application/ai/completion/ui"
_ "go.wdy.de/nago/application/ai/provider/anthropic"
aispeclink "go.wdy.de/nago/application/speclink/ai"
cfgspeclink "go.wdy.de/nago/application/speclink/cfg"
ailibrary "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library/ai"
cfglibrary "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library/cfg"
"go.wdy.de/nago/presentation/core"
"go.wdy.de/nago/web/vuejs"
)
func main() {
application.Configure(func(cfg *application.Configurator) {
cfg.SetApplicationID("de.worldiety.tutorial_113")
cfg.Serve(vuejs.Dist())
option.MustZero(cfg.StandardSystems())
option.Must(option.Must(cfg.UserManagement()).UseCases.EnableBootstrapAdmin(time.Now().Add(time.Hour), "%6UbRsCuM8N$auy"))
// The context wires itself: store, use cases, its role and its screens. Nothing about the library
// leaks into this file.
lib := option.Must(cfglibrary.Enable(cfg))
modAI := option.Must(cfgai.Enable(cfg))
// The requirement catalogue of this binary, with ready-made tools on top. Nothing about the library
// is repeated here: spec.Declare filled the catalogue during package initialisation, and the tools
// read it through use cases that audit the acting subject like every other one.
specMod := option.Must(cfgspeclink.Enable(cfg))
// Build the tools once. They receive the acting subject per call, so there is nothing per-user about
// them and nothing to rebuild per turn.
tools := append(ailibrary.Tools(lib.UseCases), aispeclink.Tools(specMod.UseCases)...)
scaffold := cfg.NewScaffold().
Login(true).
MenuEntry().Title("Bestand").Forward(lib.Pages.Books).Private().
Decorator()
// The assistant hangs on the decorator rather than on a page, so it is genuinely on every screen -
// including the administration pages the framework brings along. When it cannot run (no provider, no
// model, hidden by the operator, missing role) Decorate returns the view untouched and logs why.
cfg.SetDecorator(func(wnd core.Window, view core.View) core.View {
return scaffold(wnd, modAI.Assistant.Decorate(wnd, view, cfgai.AssistantOptions{
Title: "Bibliotheks-Assistent",
Label: "Assistent",
// One tag for the whole application: the assistant follows the user from screen to screen, so
// a conversation started on one page and continued on another is one conversation.
Tags: []string{"tutorial113:assistant"},
History: true,
AskUser: true,
MaxTurns: 32,
Agents: []uicompletion.Agent{{
ID: "librarian",
Name: "Bibliotheks-Assistent",
// The screen tool is the only one built per window, because it looks at this window. It is
// the feedback channel of the assistant: after lending a book, the model can check what the
// user actually sees instead of assuming it worked. slices.Concat copies, so the shared
// slice is never appended to from several windows at once.
Tools: slices.Concat(tools, []completion.Tool{
uicompletion.ScreenTool(wnd, uicompletion.ScreenToolOptions{}),
}),
// Rebuilt on every question, because both derived halves move: the requirement index grows
// with the project, and the situational half changes with every navigation. Only the
// domain half is a constant, and it is the only one written by hand.
SystemPromptFunc: func() string {
return ailibrary.SystemPrompt + "\n\n" +
aispeclink.Index(wnd.Subject(), specMod.UseCases) + "\n" +
uicompletion.WindowContext(wnd)
},
}},
}),
)
})
}).Run()
}
The wiring of the library context: store, use cases, its role and its screen:
// Package cfglibrary wires the library context into a running application.
//
// It is the layer where storage, screens and domain meet, and the only one that is allowed to. The context
// itself knows only its repository interface, so swapping the store never touches a use case, and it knows
// nothing about its own screens.
package cfglibrary
import (
"fmt"
"go.wdy.de/nago/application"
"go.wdy.de/nago/application/permission"
"go.wdy.de/nago/application/role"
"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library"
uilibrary "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library/ui"
"go.wdy.de/nago/pkg/data/json"
"go.wdy.de/nago/presentation/core"
)
// RoleLibrarian is what an operator assigns to give somebody the library.
const RoleLibrarian role.ID = "tutorial.library.librarian"
// Module is what the rest of the application depends on.
type Module struct {
UseCases library.UseCases
Pages uilibrary.Pages
}
// Enable builds the library context, seeds it, ships its role and registers its screens.
func Enable(cfg *application.Configurator) (Module, error) {
store, err := cfg.EntityStore("tutorial.library.book")
if err != nil {
return Module{}, fmt.Errorf("cannot open book store: %w", err)
}
repo := library.BookRepository(json.NewSloppyJSONRepository[library.Book, library.BookID](store))
if err := library.Seed(repo); err != nil {
return Module{}, fmt.Errorf("cannot seed the shelf: %w", err)
}
uc := library.NewUseCases(repo)
if err := declareLibrarianRole(cfg); err != nil {
return Module{}, err
}
pages := uilibrary.Pages{Books: "."}
// The purpose is registered with the route rather than with a menu entry, because the registration is the
// only declaration every screen has: not every screen appears in a menu, and menus are rearranged freely.
// uicompletion.WindowContext reads it to tell the model where the user is standing.
cfg.RootViewWithDecoration(pages.Books, func(wnd core.Window) core.View {
return uilibrary.PageBooks(wnd, uc)
}, application.Purpose(
"Den Bestand der Bibliothek durchsehen: welche Titel es gibt, wie viele Exemplare frei sind und wer welches ausgeliehen hat."))
return Module{UseCases: uc, Pages: pages}, nil
}
// declareLibrarianRole ships the application's own authorization as one assignable role.
//
// Two things are worth noticing here.
//
// First, the assistant permissions are deliberately NOT in this list. cfgai declares its own system role
// (cfgai.RoleAssistantUser) holding exactly the three framework permissions the chat needs, and assigning
// that role is what makes the button appear. Keeping them apart means an operator can decide per user whether
// they get the assistant, without touching what the user may do in the application - and the assistant,
// bounded by the acting subject, still cannot exceed the library permissions granted here.
//
// Second, this is a system role, so it cannot be deleted and its permission set cannot be edited away in the
// admin UI. Both would be undone on the next start anyway; refusing outright is the honest version.
func declareLibrarianRole(cfg *application.Configurator) error {
return cfg.DeclareSystemRole(role.Role{
ID: RoleLibrarian,
Name: "Bibliothekar",
Description: "Darf den Bestand einsehen sowie Exemplare ausleihen und zurücknehmen.",
}, LibrarianPermissions()...)
}
// LibrarianPermissions is spelled out as its own function so a test can check it without reaching into the
// declaration.
func LibrarianPermissions() []permission.ID {
return []permission.ID{
library.PermFindAllBooks,
library.PermLendBook,
library.PermReturnBook,
}
}
The view for a model: the tools and the domain half of the system prompt:
// Package ailibrary exposes the library to a language model.
//
// It sits beside ui/ rather than inside the context on purpose: a model is a way of reaching this context,
// exactly as a screen is, and neither belongs to the domain. The dependency points inwards only - ailibrary
// imports library, and library imports neither.
//
// That placement is also the answer to the question this example exists for. Because the tools call use
// cases, and a use case audits the acting subject, the assistant is bounded by the permissions of whoever is
// operating it. There is no second authorisation model to write, and none to forget.
package ailibrary
import (
"go.wdy.de/nago/application/ai/completion"
"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library"
)
// Tools exposes the library to the model.
//
// Note what is not here: no wrapper around the use cases, no subject parameter, no per-request rebuild. A
// nago use case has the shape func(auth.Subject, Request) (Response, error), which is exactly the shape a
// tool needs, so [completion.NewUseCaseTool] takes it unchanged and passes the acting subject through on
// every call. The tools are therefore built once, at start-up, and shared by every window and every user.
//
// The rules this file follows are worth stating explicitly, because they are what keeps an assistant safe:
//
// 1. A tool goes through a use case, never through a repository. The use case audits the acting subject, so
// the assistant can only ever do what the person operating it could do by hand. This is not a
// convention - it is the entire authorization model.
// 2. A tool that changes something is marked with AsMutating. Not described as writing in prose: marked. A
// sentence in the system prompt is a request the model may ignore; the mark is a gate it cannot pass.
// 3. A listing goes through NewSeqTool, which bounds the result and tells the model when it was cut short.
// Handing a model an unbounded list is how a context window gets exhausted.
// 4. Names are lower_snake_case and descriptions are written for someone who has never seen the
// application. Both are validated at construction, so a mistake is a panic at start-up rather than a
// confused model at runtime.
// 5. The domain types are handed to the model directly, carrying their own `desc` tags. A DTO is written
// when the model needs something the aggregate does not have - not to rename fields.
// 6. WithResultDoc is used where the shape of the answer is not obvious from its field names. It costs
// tokens on every request, so it is spent deliberately: the listing wraps its entries in a truncation
// flag the model has to react to, while lend_book returns a sentence and a number that speak for
// themselves.
func Tools(uc library.UseCases) []completion.Tool {
list := completion.NewSeqTool("list_books",
"Listet den Bestand der Bibliothek mit Titel, Autor, Gesamtzahl der Exemplare und den aktuellen Ausleihern. "+
"Nutze die Filter, um die Antwort klein zu halten, statt die ganze Liste zu holen und selbst zu suchen.",
uc.FindAllBooks).
WithResultDoc()
lend := completion.NewUseCaseTool("lend_book",
"Leiht ein Exemplar eines Buches an eine Person aus. Nenne vorher Titel und Person, damit klar ist, was passiert.",
uc.LendBook).
AsMutating("gibt ein Exemplar heraus und trägt die Person als Ausleiher ein")
ret := completion.NewUseCaseTool("return_book",
"Nimmt ein ausgeliehenes Exemplar von einer Person zurück.",
uc.ReturnBook).
AsMutating("nimmt ein Exemplar zurück und entfernt die Person aus der Ausleihliste")
return []completion.Tool{list, lend, ret}
}
// SystemPrompt is the domain half of what the model is told.
//
// It carries no requirement index and no list of decisions. Both come from the framework: aispeclink.Index
// renders the catalogue, and uicompletion.WindowContext renders where the user stands. Writing either of
// them here would be a copy of something already recorded, and the copy is the one that rots.
//
// What is deliberately absent is any instruction about asking before writing. That is enforced structurally
// by the confirmation of the assistant, and repeating it here would suggest it were the prompt's job.
const SystemPrompt = `Du hilfst beim Betrieb einer kleinen Bibliothek.
Arbeitsweise:
- Rate nicht. Alles Fachliche steht hinter Werkzeugen; wenn du etwas nicht weißt, hole es dir.
- Antworte knapp und in ganzen Sätzen. Nenne Bücher mit Titel und Autor, nicht mit ihrer Kennung.
- Wenn ein Werkzeug meldet, dass die Liste gekürzt wurde, sage das dazu und grenze die Suche ein, statt so zu tun, als wäre sie vollständig.
- Ein Berechtigungsfehler ist ein erwartetes Ergebnis, keine Störung. Sage dann, dass dem Nutzer die Berechtigung fehlt.
Fragt jemand, WARUM sich das System so verhält oder warum es etwas ablehnt, lies die zugehörige Anforderung
mit read_requirement und antworte daraus. Erfinde keine Begründung — eine erfundene Regel klingt wie das
System, das über sich selbst spricht, und ist schlimmer als keine Antwort. Bei Entscheidungen nenne auch,
was sie kostet, wenn jemand sie in Frage stellt.
Achte auf den Zustand einer Anforderung: „planned" heißt, dass etwas bewusst noch nicht umgesetzt ist. Das
ist etwas anderes als „gibt es nicht", und der Unterschied ist für den Fragenden der ganze Punkt.
Für Orientierungsfragen („was kann das hier eigentlich") nimm read_capabilities.
Bezieht sich jemand auf etwas, das er gerade sieht, oder hast du etwas geändert, sieh mit inspect_screen nach,
was tatsächlich angezeigt wird, statt es anzunehmen.`
The domain: aggregate, permissions, repository, use cases and their annotations:
model.go
// Package library is a bounded context: it says what the system does, and nothing about how it is reached.
//
// It knows nothing about the user interface in ui/ and nothing about the AI tools in ai/ - both are ways of
// reaching this context, and the dependency only ever points inwards. That is what lets the assistant be
// built from these use cases without a single adapter: a use case already has the shape a tool needs, and it
// already audits the acting subject.
package library
// BookID identifies a book in the library.
type BookID string
// Book is a title the library owns, in some number of copies.
//
// This aggregate is handed to the model as it is - there is no separate DTO next to it. The `desc` tags are
// what makes that work: they are the documentation the model reads, exactly as `label` tags are what
// form.Auto reads. A second type that had to be kept in sync with this one would buy nothing and would
// eventually drift.
//
// Write a DTO when the model needs something this type does not have: a value formatted for a human, an
// aggregate computed across records, or a subset that deliberately hides a field. Not merely to rename
// things.
type Book struct {
ID BookID `json:"id" desc:"stable identifier, used when lending or returning"`
Title string `json:"title"`
Author string `json:"author"`
// Copies is how many the library owns in total.
Copies int `json:"copies" desc:"total number of copies owned, lent out ones included"`
// LentTo lists the borrowers currently holding a copy.
//
// The number of free copies is Copies minus the length of this list. It is not a field of its own on
// purpose: a derived value that is also stored is a value that can disagree with itself.
LentTo []string `json:"lentTo,omitempty" desc:"names of the people currently holding a copy; a book is fully lent out when this has as many entries as there are copies"`
}
// Identity makes the book an aggregate root.
func (b Book) Identity() BookID { return b.ID }
// WithIdentity is required by the repository.
func (b Book) WithIdentity(id BookID) Book {
b.ID = id
return b
}
// String is what a picker displays.
func (b Book) String() string { return b.Title }
// Available is how many copies can still be lent out.
func (b Book) Available() int {
return b.Copies - len(b.LentTo)
}
// BookFilter narrows a listing.
//
// Every field is optional: a filter narrows a listing, it does not command one. The `optional` tags say so to
// a model that is handed this type as a tool input - without them the schema would demand all three on every
// call, and the model would dutifully invent them.
//
// The `desc` tags are not decoration either: they are the documentation the model reads to decide how to call
// this. Write them for a colleague who has never seen the application.
type BookFilter struct {
Query string `json:"query" optional:"true" desc:"case-insensitive substring matched against title and author"`
OnlyAvailable bool `json:"onlyAvailable" optional:"true" desc:"when true, only books with at least one free copy are returned"`
Borrower string `json:"borrower" optional:"true" desc:"only books currently lent to this borrower"`
}
// LendRequest asks for one copy of a book.
//
// Both fields are mandatory and neither carries `optional`, so the schema tells the model as much before it
// ever calls. That is the difference to [BookFilter]: a filter narrows, a request commands.
type LendRequest struct {
Book BookID `json:"book" desc:"the id of the book, as returned by list_books"`
Borrower string `json:"borrower" desc:"the name of the person receiving the copy"`
}
// LendResult reports in words what happened, because that is what both a human and a model need back.
type LendResult struct {
Summary string `json:"summary"`
Available int `json:"available" desc:"copies still available after this operation"`
}
perm.go
package library
import (
"go.wdy.de/nago/application/permission"
)
// Permissions of the library, one per use case and bound to it through the type parameter.
//
// One per use case is what makes authorisation assignable and auditable: an operator hands out the ability to
// lend without also handing out the ability to take back. A permission covering several use cases can only be
// granted or withheld wholesale.
//
// The texts come from the framework's translation catalogue via the Declare<Verb> helpers rather than being
// written here, because these strings appear in the role editor, where a non-developer decides who may do
// what - in their own language.
//
// This is also the entire authorisation model of the assistant. The tools in ai/ call these use cases, the
// use cases audit these permissions against the acting subject, and so the assistant can never do more than
// the person operating it.
var (
PermFindAllBooks = permission.DeclareFindAll[FindAllBooks]("tutorial.library.book.find_all", "Buch")
PermLendBook = permission.DeclareUpdate[LendBook]("tutorial.library.book.lend", "Buch")
PermReturnBook = permission.DeclareUpdate[ReturnBook]("tutorial.library.book.return", "Buch")
)
repository.go
package library
import (
"go.wdy.de/nago/pkg/data"
)
// BookRepository stores the books. The context declares what it needs and never how it is stored; which
// store this is, is decided once in cfg/.
type BookRepository = data.Repository[Book, BookID]
// Seed puts something on the shelf so the example has something to talk about. It does nothing when the
// shelf is not empty, so a restart does not duplicate the stock.
func Seed(repo BookRepository) error {
count, err := repo.Count()
if err != nil {
return err
}
if count > 0 {
return nil
}
books := []Book{
{ID: "b1", Title: "Der Prozess", Author: "Franz Kafka", Copies: 2},
{ID: "b2", Title: "Die Verwandlung", Author: "Franz Kafka", Copies: 1},
{ID: "b3", Title: "Effective Go", Author: "The Go Authors", Copies: 3},
{ID: "b4", Title: "Der Steppenwolf", Author: "Hermann Hesse", Copies: 1, LentTo: []string{"Anna"}},
}
for _, b := range books {
if err := repo.Save(b); err != nil {
return err
}
}
return nil
}
uc_find_all_books.annotation.go
// This file sits next to uc_find_all_books.go and is part of the normal build. That coupling is the point:
// delete the use case and this file stops compiling, so the binding cannot rot unnoticed.
//
// Satisfies is the one statement no static analysis can infer — which requirement a construct was written
// for. Help is what an assistant reads back through read_capabilities.
package library
import (
"github.com/worldiety/speclink/spec"
"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/dec"
fun "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/fun/library"
)
var _ = spec.For[FindAllBooks](
spec.Satisfies(fun.RLibStock, dec.RDecAvailability),
spec.Help("Zeigt den Bestand mit Titel, Autor, Gesamtzahl der Exemplare und den aktuellen Ausleihern. Die Zahl der freien Exemplare ergibt sich aus den ersten beiden."),
)
uc_find_all_books.go
package library
import (
"iter"
"strings"
"go.wdy.de/nago/auth"
)
// FindAllBooks reads the stock, narrowed by a filter.
//
// Reading is a promise too - that someone may see a thing - so a query is no different from any other use
// case: its own file, its own constructor, its own permission, its own place in the bundle.
type FindAllBooks func(subject auth.Subject, filter BookFilter) iter.Seq2[Book, error]
// NewFindAllBooks builds the listing query over the book repository.
func NewFindAllBooks(repo BookRepository) FindAllBooks {
return func(subject auth.Subject, filter BookFilter) iter.Seq2[Book, error] {
return func(yield func(Book, error) bool) {
if err := subject.Audit(PermFindAllBooks); err != nil {
yield(Book{}, err)
return
}
for book, err := range repo.All() {
if err != nil {
yield(Book{}, err)
return
}
if !filter.Matches(book) {
continue
}
if !yield(book, nil) {
return
}
}
}
}
}
// Matches reports whether a book falls into the filter.
func (f BookFilter) Matches(b Book) bool {
if f.OnlyAvailable && b.Available() <= 0 {
return false
}
if q := strings.ToLower(strings.TrimSpace(f.Query)); q != "" {
if !strings.Contains(strings.ToLower(b.Title), q) && !strings.Contains(strings.ToLower(b.Author), q) {
return false
}
}
if f.Borrower != "" {
if !b.isLentTo(f.Borrower) {
return false
}
}
return true
}
// isLentTo reports whether this person currently holds a copy. The comparison ignores case because the name
// is typed by a human - or, increasingly, produced by one from a sentence.
func (b Book) isLentTo(borrower string) bool {
for _, who := range b.LentTo {
if strings.EqualFold(who, borrower) {
return true
}
}
return false
}
uc_lend_book.annotation.go
package library
import (
"github.com/worldiety/speclink/spec"
"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/dec"
fun "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/fun/library"
)
var _ = spec.For[LendBook](
spec.Satisfies(fun.RLibLend, dec.RDecBorrower),
spec.Help("Gibt ein Exemplar an eine Person heraus. Ist keines frei, wird die Ausleihe abgelehnt — eine Vormerkung gibt es noch nicht."),
)
uc_lend_book.go
package library
import (
"fmt"
"strings"
"go.wdy.de/nago/auth"
)
// LendBook hands one copy of a book to a person.
type LendBook func(subject auth.Subject, req LendRequest) (LendResult, error)
// NewLendBook builds the lending use case.
func NewLendBook(repo BookRepository) LendBook {
return func(subject auth.Subject, req LendRequest) (LendResult, error) {
if err := subject.Audit(PermLendBook); err != nil {
return LendResult{}, err
}
borrower := strings.TrimSpace(req.Borrower)
if borrower == "" {
return LendResult{}, fmt.Errorf("ohne Namen kann kein Exemplar herausgegeben werden")
}
optBook, err := repo.FindByID(req.Book)
if err != nil {
return LendResult{}, err
}
if optBook.IsNone() {
return LendResult{}, fmt.Errorf("kein Buch mit der Kennung %q", req.Book)
}
book := optBook.Unwrap()
if book.Available() <= 0 {
return LendResult{}, fmt.Errorf("von %q ist derzeit kein Exemplar frei", book.Title)
}
book.LentTo = append(book.LentTo, borrower)
if err := repo.Save(book); err != nil {
return LendResult{}, err
}
// The result says what happened in words. A caller that is a model needs that as much as a human
// does: it has to report back, and it must not have to reconstruct the outcome from the arguments it
// sent.
return LendResult{
Summary: fmt.Sprintf("%q an %s ausgeliehen.", book.Title, borrower),
Available: book.Available(),
}, nil
}
}
uc_return_book.annotation.go
package library
import (
"github.com/worldiety/speclink/spec"
"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/dec"
fun "go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/requirements/fun/library"
)
var _ = spec.For[ReturnBook](
spec.Satisfies(fun.RLibReturn, dec.RDecReturnMatch),
spec.Help("Nimmt ein Exemplar von einer Person zurück. Hat die Person kein Exemplar dieses Titels, wird die Rücknahme abgelehnt."),
)
uc_return_book.go
package library
import (
"fmt"
"strings"
"go.wdy.de/nago/auth"
)
// ReturnBook takes a lent copy back from a person.
type ReturnBook func(subject auth.Subject, req LendRequest) (LendResult, error)
// NewReturnBook builds the return use case.
func NewReturnBook(repo BookRepository) ReturnBook {
return func(subject auth.Subject, req LendRequest) (LendResult, error) {
if err := subject.Audit(PermReturnBook); err != nil {
return LendResult{}, err
}
optBook, err := repo.FindByID(req.Book)
if err != nil {
return LendResult{}, err
}
if optBook.IsNone() {
return LendResult{}, fmt.Errorf("kein Buch mit der Kennung %q", req.Book)
}
book := optBook.Unwrap()
idx := -1
for i, who := range book.LentTo {
if strings.EqualFold(who, req.Borrower) {
idx = i
break
}
}
if idx < 0 {
return LendResult{}, fmt.Errorf("%s hat kein Exemplar von %q", req.Borrower, book.Title)
}
book.LentTo = append(book.LentTo[:idx], book.LentTo[idx+1:]...)
if err := repo.Save(book); err != nil {
return LendResult{}, err
}
return LendResult{
Summary: fmt.Sprintf("%q von %s zurückerhalten.", book.Title, req.Borrower),
Available: book.Available(),
}, nil
}
}
usecases.go
package library
// UseCases bundles the capabilities of the library context.
//
// Callers depend on this bundle rather than on the internals, and [NewUseCases] is the single place where the
// repository is threaded through. The screens in ui/ and the tools in ai/ both take it, and neither can reach
// past it into the store.
type UseCases struct {
FindAllBooks FindAllBooks
LendBook LendBook
ReturnBook ReturnBook
}
// NewUseCases wires the library use cases.
func NewUseCases(repo BookRepository) UseCases {
return UseCases{
FindAllBooks: NewFindAllBooks(repo),
LendBook: NewLendBook(repo),
ReturnBook: NewReturnBook(repo),
}
}
The view for humans:
// Package uilibrary renders the library for a human.
//
// The directory is always ui/, so the package name is what tells a reader whose interface this is. It imports
// the context; the context must never import it, because that would invert the ordering and make the domain
// untestable without a renderer.
package uilibrary
import (
"strconv"
"go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/app/library"
"go.wdy.de/nago/presentation/core"
"go.wdy.de/nago/presentation/ui"
)
// Pages are the routes of this context.
type Pages struct {
Books core.NavigationPath
}
// PageBooks lists the stock.
//
// It is here so the assistant has a screen to be aware of, and so the application.Purpose registered in cfg/
// describes something real. It is deliberately plain: the subject of this example is next door in ai/.
func PageBooks(wnd core.Window, uc library.UseCases) core.View {
var rows []core.View
for book, err := range uc.FindAllBooks(wnd.Subject(), library.BookFilter{}) {
if err != nil {
return ui.Text(err.Error())
}
rows = append(rows, ui.HStack(
ui.Text(book.Title).Font(ui.BodyMedium),
ui.Text(book.Author),
ui.Text(availabilityOf(book)),
).Gap(ui.L16).FullWidth().Alignment(ui.Leading))
}
if len(rows) == 0 {
rows = append(rows, ui.Text("Kein Bestand sichtbar. Fehlt die Rolle „Bibliothekar“?"))
}
return ui.VStack(
ui.H1("Bestand"),
ui.Text("Frag den Assistenten unten rechts, was verfügbar ist, oder lass ihn ein Exemplar ausleihen. Vor jeder Änderung wird nachgefragt."),
ui.VStack(rows...).Gap(ui.L8).FullWidth(),
).Gap(ui.L16).Alignment(ui.Leading).FullWidth().
Padding(ui.Padding{}.All(ui.L16))
}
// availabilityOf says in words what a bare number would leave the reader to work out.
func availabilityOf(b library.Book) string {
switch {
case b.Available() <= 0:
return "alle Exemplare ausgeliehen"
case b.Available() == 1:
return "1 Exemplar frei"
default:
return strconv.Itoa(b.Available()) + " Exemplare frei"
}
}
The requirements:
R-DEC-AVAILABILITY.spec.go
// Package dec holds the recorded decisions of this example.
//
// One requirement per file, the file named after the identity. spec.Declare puts it into the runtime
// catalogue, which is what lets the running program - and the assistant in it - explain itself. Without that
// call the value exists only for the static tool, and Go offers no reflection over package level variables
// to find it again.
package dec
import "github.com/worldiety/speclink/spec"
var RDecAvailability = spec.Declare(spec.Requirement{
ID: "R-DEC-AVAILABILITY",
Kind: spec.Decision,
Discipline: spec.Technical,
Status: spec.Normative,
Title: "Verfügbarkeit wird berechnet, nicht gespeichert",
Text: "Die Zahl der freien Exemplare ergibt sich aus Copies minus der Länge von LentTo und wird nirgends abgelegt.",
Rationale: "Ein abgeleiteter Wert, der zusätzlich gespeichert wird, ist ein Wert, der sich mit sich selbst widersprechen kann. Genau das passiert beim ersten Absturz zwischen zwei Schreibvorgängen, und danach glaubt niemand mehr der Zahl.",
// Mandatory for a decision, and the half nobody writes unprompted: a justification is pleasant to write
// and gets written at length, while admitting what the ruling makes worse does not.
Consequences: "Jede Anzeige rechnet neu. Eine Auswertung über zehntausend Titel lässt sich nicht über einen Index beantworten, sondern muss den Bestand lesen.",
})
R-DEC-BORROWER.spec.go
package dec
import "github.com/worldiety/speclink/spec"
var RDecBorrower = spec.Declare(spec.Requirement{
ID: "R-DEC-BORROWER",
Kind: spec.Decision,
Discipline: spec.Business,
Status: spec.Normative,
Title: "Ausleiher werden als Name geführt, nicht als Benutzerkonto",
Text: "LentTo enthält Klartextnamen; es gibt keine Verknüpfung zur Benutzerverwaltung.",
Rationale: "Eine Bibliothek leiht auch an Menschen aus, die kein Konto in diesem System haben — Gäste, Praktikanten, die Kollegin aus dem Nachbarhaus. Ein Fremdschlüssel auf die Benutzertabelle hätte genau diese Fälle ausgeschlossen.",
Consequences: "Zwei Personen gleichen Namens sind nicht unterscheidbar, und es gibt keine automatische Erinnerung, weil das System keine Adresse kennt. Wer das braucht, muss diese Entscheidung zuerst umdrehen.",
})
R-DEC-RETURN-MATCH.spec.go
package dec
import "github.com/worldiety/speclink/spec"
var RDecReturnMatch = spec.Declare(spec.Requirement{
ID: "R-DEC-RETURN-MATCH",
Kind: spec.Decision,
Discipline: spec.Technical,
Status: spec.Normative,
Title: "Die Rücknahme vergleicht Namen ohne Rücksicht auf Groß- und Kleinschreibung",
Text: "ReturnBook findet den Ausleiher über einen Vergleich, der Groß- und Kleinschreibung ignoriert.",
Rationale: "Der Name wird von einem Menschen getippt — und zunehmend von einem Sprachmodell aus einem Satz gebildet. Ein exakter Vergleich hätte zur Folge, dass „anna“ ein Exemplar nicht zurückgeben kann, das „Anna“ ausgeliehen hat, und der Fehler sähe aus wie ein Datenfehler.",
Consequences: "„Anna“ und „anna“ können nicht gleichzeitig je ein Exemplar desselben Titels halten, ohne dass die Rücknahme rät. Bei mehr als einer Handvoll Ausleihern braucht es eine echte Kennung statt eines Namens.",
DerivedFrom: []spec.Requirement{RDecBorrower},
})
R-LIB-LEND.spec.go
package library
import "github.com/worldiety/speclink/spec"
var RLibLend = spec.Declare(spec.Requirement{
ID: "R-LIB-LEND",
Kind: spec.Functional,
Discipline: spec.Business,
Status: spec.Normative,
Title: "Exemplar ausleihen",
Text: "Ein freies Exemplar MUSS an eine namentlich genannte Person ausgeliehen werden können; ist keines frei, MUSS die Ausleihe abgelehnt werden.",
})
R-LIB-RESERVATION.spec.go
package library
import "github.com/worldiety/speclink/spec"
// RLibReservation is deliberately Planned and bound to nothing.
//
// It is in this example on purpose. speclink demands a binding only for Normative requirements, so a planned
// one appears in no binding and would be missing from a view built on spec.Entries() alone — and an
// assistant reading that view would answer "so etwas gibt es nicht", which is a different and much worse
// statement than "das ist noch nicht umgesetzt". Reading the catalogue keeps the two apart.
var RLibReservation = spec.Declare(spec.Requirement{
ID: "R-LIB-RESERVATION",
Kind: spec.Functional,
Discipline: spec.Business,
Status: spec.Planned,
Title: "Vormerkung auf ein ausgeliehenes Exemplar",
Text: "Ist kein Exemplar frei, SOLL sich eine Person vormerken lassen können und benachrichtigt werden, sobald eines zurückkommt.",
})
R-LIB-RETURN.spec.go
package library
import "github.com/worldiety/speclink/spec"
var RLibReturn = spec.Declare(spec.Requirement{
ID: "R-LIB-RETURN",
Kind: spec.Functional,
Discipline: spec.Business,
Status: spec.Normative,
Title: "Exemplar zurücknehmen",
Text: "Ein ausgeliehenes Exemplar MUSS von der Person zurückgenommen werden können, die es hält.",
})
R-LIB-STOCK.spec.go
// Package library holds the functional requirements of the library domain.
package library
import "github.com/worldiety/speclink/spec"
var RLibStock = spec.Declare(spec.Requirement{
ID: "R-LIB-STOCK",
Kind: spec.Functional,
Discipline: spec.Business,
Status: spec.Normative,
Title: "Bestand einsehen",
Text: "Der Bestand MUSS mit Titel, Autor, Gesamtzahl der Exemplare und den aktuellen Ausleihern eingesehen werden können.",
})
View on GitHub ↗
· run it with go run go.wdy.de/nago/example/cmd/tutorial-113-ai-assistant/cmd/ai-example@latest