Merge pull request #68 from kristianwind/ai-foreslaar-du-bestemmer

feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
This commit is contained in:
Kristian Wind
2026-09-08 15:06:21 +02:00
committed by GitHub
11 changed files with 1106 additions and 2 deletions
+133
View File
@@ -3532,6 +3532,124 @@ paths:
$ref: "#/components/responses/Conflict"
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/inbox/tidy:
post:
tags: [integrations]
summary: Suggest how the loose tasks in the inbox could have been written.
description: |
Suggests, never writes. Each suggestion is the line the person would have
typed — quick-add syntax, the same one parser as everywhere else — so it
can be read and edited by whoever it is put to, rather than being a set of
fields a model filled in that you take or leave.
The model is given the projects that exist and may name no other: a
`#Project` it invented would create one by accident the first time
somebody said yes.
responses:
"200":
description: The suggestions, one per task, and none for a task already filed well.
content:
application/json:
schema:
type: object
properties:
suggestions:
type: array
items: { $ref: "#/components/schemas/AISuggestion" }
"409": { $ref: "#/components/responses/Conflict" }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/inbox/tidy/apply:
post:
tags: [integrations]
summary: Write one accepted suggestion into the task it is about.
description: |
The line is parsed here rather than in the browser, so "tomorrow" means
what it means everywhere else — and a suggestion edited by hand before it
was accepted is read exactly like one that was not.
Only what the line actually says is applied. A missing date means "the
line said nothing about a date", never "clear the one that is there". A
project the model invented moves nothing.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [task_id, line]
properties:
task_id: { type: string }
line: { type: string }
responses:
"200":
description: The task as it now stands.
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/ai/notes/{noteID}/actions:
parameters:
- { name: noteID, in: path, required: true, schema: { type: string } }
post:
tags: [integrations]
summary: Pull what somebody committed to out of a note.
description: |
Only things somebody committed to or was asked to do — a heading is not a
task, a topic discussed is not a task. An empty array is a useful answer
and a made-up list is not.
Each suggestion quotes the words it came from, so a wrong one can be seen
through rather than merely being wrong.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
suggestions:
type: array
items: { $ref: "#/components/schemas/AISuggestion" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/notes/{noteID}/actions/apply:
parameters:
- { name: noteID, in: path, required: true, schema: { type: string } }
post:
tags: [integrations]
summary: Create one accepted task from a note.
description: |
The note is not touched. Writing the link back into it would make the two
point at each other, and a text a program edits without being asked is a
text people stop trusting — so the task carries where it came from, as a
`[[note title]]` in its description, and the note is left alone.
The note's own project is the default; a `#Project` in the line wins,
because typing it is a choice and inheriting is a circumstance.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [line]
properties:
line: { type: string }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/mcp:
post:
tags: [integrations]
@@ -4315,6 +4433,21 @@ components:
note_id: { type: string }
title: { type: string }
AISuggestion:
type: object
description: >
One proposed line, in quick-add syntax, plus one sentence on why it looks
like that. Nothing has happened yet — a suggestion becomes something when
somebody says yes to it.
properties:
task_id:
type: string
description: >
The task the suggestion is about, when it is about one that already
exists. Absent when the suggestion is a new task.
line: { type: string, example: "Ring til Anders i morgen p1 #Firma" }
why: { type: string }
NoteInvite:
type: object
description: >
+126
View File
@@ -395,6 +395,132 @@ back, do not congratulate, and do not offer productivity advice.`, language)
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
}
// Suggestion er én foreslået opgave, skrevet som en linje, man selv kunne have
// tastet.
//
// `line` er quick-add-syntaks — "Ring til Anders i morgen p1 #Firma" — og ikke et
// felt pr. egenskab. To grunde, og den anden er den vigtige. Parseren findes
// allerede og er den samme, uanset hvor teksten kom fra, så der er ingen anden
// fortolkning at holde i takt med den. Og et forslag, der er skrevet i det sprog,
// man selv skriver i, kan læses og rettes af den, det bliver stillet til — hvor
// et sæt felter, en model har udfyldt, er noget, man skal tage eller lade være.
//
// `why` er én sætning om, hvorfor den ser sådan ud. Den står ved siden af
// forslaget, så et forkert gæt kan gennemskues frem for bare at være forkert.
type Suggestion struct {
Line string `json:"line"`
Why string `json:"why,omitempty"`
}
// TidyInbox foreslår, hvordan løse opgaver i indbakken kunne skrives færdig.
//
// Modellen får de projekter, der findes, og må kun bruge dem: et `#Nyt Projekt`,
// den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja.
func (c *Client) TidyInbox(ctx context.Context, tasks, projects []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
known := "none"
if len(projects) > 0 {
known = "#" + strings.Join(projects, ", #")
}
system := fmt.Sprintf(`You tidy up a task inbox.
For each task you are given, write the line the person would have typed if they
had filed it properly. Keep their own words for what the task is — you are filing
it, not rewriting it.
The line may carry, in this syntax and no other:
#Project one of these existing projects, and never one that is not: %s
p1..p4 priority, where p1 is most urgent
a date in plain %s, such as "i morgen", "on friday", "15/3"
Leave out anything the task does not tell you. A guessed deadline is worse than
none: the person can add one, but they cannot see a wrong one you invented.
Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "...", "why": "..."} where why is at most one short sentence in %s
saying what you read out of the task. Give one element per task, in the order you
were given them, and leave a task out entirely if it is already filed well.`,
known, language, today, language)
prompt := "Tasks:\n" + strings.Join(tasks, "\n")
return c.suggestions(ctx, system, prompt)
}
// ActionsInNote trækker det, nogen har lovet, ud af en note.
//
// Beskrivelsen er stram med vilje. En model, der får lov at "finde opgaver" i en
// tekst, finder også emner, overskrifter og gode idéer — og så er resultatet en
// liste, man skal luge i, hvilket er dyrere end at skrive de tre punkter selv.
func (c *Client) ActionsInNote(ctx context.Context, note string, projects []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
known := "none"
if len(projects) > 0 {
known = "#" + strings.Join(projects, ", #")
}
system := fmt.Sprintf(`You read a note and pull out what somebody has to do.
Only things somebody committed to or was asked to do. A heading is not a task, a
topic that was discussed is not a task, and an idea nobody agreed to is not a
task. If the note contains none, reply with an empty array — that is a useful
answer and a made-up list is not.
Write each one as the line the person would have typed, in %s, in this syntax and
no other:
#Project one of these existing projects, and never one that is not: %s
p1..p4 priority, where p1 is most urgent
a date in plain %s, only when the note actually gives one
Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "...", "why": "..."} where why quotes the few words in the note that
the task came from, so it can be checked.`, language, known, language, today)
return c.suggestions(ctx, system, "Note:\n"+note)
}
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
// der ikke er en linje.
//
// Et tomt svar er ikke en fejl. "Der er ikke noget at foreslå" er et rigtigt svar
// på begge spørgsmål, og en fejlmeddelelse ville lære folk, at funktionen er i
// stykker, når den i virkeligheden var enig med dem.
func (c *Client) suggestions(ctx context.Context, system, prompt string) ([]Suggestion, error) {
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
if err != nil {
return nil, err
}
var out []Suggestion
if err := json.Unmarshal([]byte(extractJSON(reply)), &out); err != nil {
return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
}
clean := make([]Suggestion, 0, len(out))
for _, s := range out {
s.Line = strings.TrimSpace(s.Line)
s.Why = strings.TrimSpace(s.Why)
if s.Line != "" {
clean = append(clean, s)
}
}
return clean, nil
}
// languageOf er det sprog, svaret skal skrives i. Ét sted, fordi det ellers er
// den slags, der bliver rettet ét sted ud af fire.
func languageOf(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "English"
}
return "Danish"
}
// extractJSON pulls an array out of a reply that may have been wrapped in a code
// fence or preceded by a sentence, which models do however firmly they are asked
// not to.
+370
View File
@@ -0,0 +1,370 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
)
// AI'ens forslag, og hvad der sker med dem.
//
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
// skal have ret tit nok til at spare et par tastetryk.
//
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
// gennem den samme som alt andet.
// aiSuggestionJSON er ét forslag på vej til fladen.
type aiSuggestionJSON struct {
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
TaskID string `json:"task_id,omitempty"`
Line string `json:"line"`
Why string `json:"why,omitempty"`
}
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "ai settings", err)
return nil, false
}
if !cfg.Configured() {
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
// lægger sin egen nøgle ind.
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
return nil, false
}
return ai.New(cfg), true
}
// projectNames er de projekter, modellen må nævne.
//
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
// nogen sagde ja til forslaget.
func (s *Server) projectNames(r *http.Request) []string {
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
if err != nil {
s.log.Warn("projects for ai", "err", err)
return nil
}
out := make([]string, 0, len(projects))
for _, p := range projects {
if p.IsInbox {
continue
}
out = append(out, p.Name)
}
return out
}
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
// skrives færdig.
//
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
// spørgsmål og et ja pr. linje.
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
inbox, err := s.db.InboxID(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "inbox", err)
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{inbox}, Limit: 50,
})
if err != nil {
s.internal(w, r, "list inbox", err)
return
}
if len(tasks) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
return
}
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
// må ikke kunne flytte alle de andres forslag én plads — derfor står
// nummeret i teksten og bliver læst tilbage.
lines := make([]string, 0, len(tasks))
for i, t := range tasks {
line := strconv.Itoa(i+1) + ". " + t.Content
if t.Description != "" {
line += " — " + firstWords(t.Description, 30)
}
lines = append(lines, line)
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
if err != nil {
s.log.Warn("ai tidy inbox", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
out := make([]aiSuggestionJSON, 0, len(suggestions))
for i, sug := range suggestions {
n, line := numberedLine(sug.Line)
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
if n == 0 {
n = i + 1
}
if n < 1 || n > len(tasks) {
continue
}
if strings.TrimSpace(line) == "" {
continue
}
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}
type applyLineRequest struct {
TaskID string `json:"task_id"`
Line string `json:"line"`
}
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
// om.
//
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
// der ikke blev rørt.
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
var req applyLineRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
if strings.TrimSpace(req.Line) == "" {
writeFieldErrors(w, map[string]string{"line": "required"})
return
}
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
loc := userLocation(user.Timezone)
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
return
}
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
// slette noget.
update := store.TaskUpdate{Content: &parsed.Content}
if parsed.Priority != 0 {
update.Priority = &parsed.Priority
}
if parsed.DueDate != "" {
update.SetDue, update.DueDate = true, parsed.DueDate
}
if parsed.Recurrence != "" {
update.RecurrenceRule = &parsed.Recurrence
}
if len(parsed.Labels) > 0 {
update.Labels, update.SetLabels = parsed.Labels, true
}
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
s.storeError(w, r, "update task", err)
return
}
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
s.internal(w, r, "move task", err)
return
}
}
}
}
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
}
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
//
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
// bare at være forkert.
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if strings.TrimSpace(n.Body) == "" {
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
return
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
if err != nil {
s.log.Warn("ai note actions", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
out := make([]aiSuggestionJSON, 0, len(suggestions))
for _, sug := range suggestions {
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}
// handleAICreateFromNote laver én accepteret opgave fra en note.
//
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
// den kom fra — og noten kan findes på sin titel.
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
var req applyLineRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
loc := userLocation(user.Timezone)
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
return
}
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
// linjen vinder, fordi det er valgt frem for arvet.
projectID := n.ProjectID
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
}
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, r, "inbox", err)
return
}
}
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
task := &store.Task{
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, r, "create task from note", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
}
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
//
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
// model, der springer en opgave over, ville så flytte alle de følgende forslag
// én plads, og så bliver "sæt dato på den her" sat på en anden.
func numberedLine(s string) (int, string) {
s = strings.TrimSpace(s)
dot := strings.IndexByte(s, '.')
if dot <= 0 || dot > 3 {
return 0, s
}
n := 0
for _, c := range s[:dot] {
if c < '0' || c > '9' {
return 0, s
}
n = n*10 + int(c-'0')
}
return n, strings.TrimSpace(s[dot+1:])
}
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
// fylde den med en hel note.
func firstWords(s string, n int) string {
fields := strings.Fields(s)
if len(fields) <= n {
return strings.Join(fields, " ")
}
return strings.Join(fields[:n], " ") + " …"
}
+189
View File
@@ -0,0 +1,189 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
//
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
func fakeModel(t *testing.T, reply string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]string{"content": reply}},
},
})
}))
t.Cleanup(srv.Close)
return srv.URL
}
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
// på en adresse i det private net, og bootstrap-kontoen er en.
func (ts *testServer) useModel(t *testing.T, url string) {
t.Helper()
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "base_url": url, "model": "prøvemodel",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
}
}
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
// anden opgave end den, den blev skrevet om.
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
]`))
first := ts.quickTask(t, "noget som helst")
second := ts.quickTask(t, "anders 12345678")
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
}
list, _ := out["suggestions"].([]any)
if len(list) != 1 {
t.Fatalf("forslag = %v, want ét", list)
}
sug := list[0].(map[string]any)
if sug["task_id"] != second {
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
}
if sug["line"] != "Ring til Anders i morgen p1" {
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
}
if sug["why"] == "" {
t.Error("forslaget siger ikke hvorfor")
}
_ = first
}
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[]`))
taskID := ts.quickTask(t, "anders")
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
"task_id": taskID, "line": "Ring til Anders i morgen p1",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
}
if out["content"] != "Ring til Anders" {
t.Errorf("indhold = %v", out["content"])
}
if out["priority"] != float64(1) {
t.Errorf("prioritet = %v, want 1", out["priority"])
}
if out["due_date"] == nil || out["due_date"] == "" {
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
}
}
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
// opgave hen, hvor ejeren ikke leder efter den.
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[]`))
taskID := ts.quickTask(t, "anders")
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("accepter: %d", resp.StatusCode)
}
if after["project_id"] != before["project_id"] {
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
}
}
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
// stole på.
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
]`))
body := "# Møde med Anders\njeg sender prislisten fredag"
noteID := ts.createNote(t, body)
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
}
list, _ := out["suggestions"].([]any)
if len(list) != 1 {
t.Fatalf("forslag = %v, want ét", list)
}
line := list[0].(map[string]any)["line"].(string)
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
map[string]any{"line": line})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
}
if task["content"] != "Send prisliste til Anders" {
t.Errorf("opgave = %v", task["content"])
}
// Opgaven bærer, hvor den kom fra, som et notelink.
if task["description"] != "[[Møde med Anders]]" {
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
}
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
if note["body"] != body {
t.Errorf("noten blev rettet:\n%v", note["body"])
}
}
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
// noget".
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
}
}
}
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
func (ts *testServer) quickTask(t *testing.T, text string) string {
t.Helper()
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
}
id, _ := out["id"].(string)
if id == "" {
t.Fatalf("quickadd gav intet id: %v", out)
}
return id
}
+7
View File
@@ -352,6 +352,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
})
r.Get("/version", s.handleVersion)
+8
View File
@@ -482,6 +482,14 @@ export const api = {
setAISettings: (data) => put('/ai/settings', data),
aiSummary: () => post('/ai/summary'),
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
// Forslag frem for skrivninger: de to første spørger, de to sidste er svaret
// på et ja. Linjen sendes med, som den står i feltet — den kan være rettet i
// hånden, inden den blev accepteret, og skal læses på samme måde som en, der
// ikke blev rørt.
aiTidyInbox: () => post('/ai/inbox/tidy'),
aiApplyToTask: (taskId, line) => post('/ai/inbox/tidy/apply', { task_id: taskId, line }),
aiNoteActions: (noteId) => post(`/ai/notes/${noteId}/actions`),
aiCreateFromNote: (noteId, line) => post(`/ai/notes/${noteId}/actions/apply`, { line }),
version: () => get('/version'),
+189
View File
@@ -0,0 +1,189 @@
<script>
/**
* En liste af forslag, man siger ja eller nej til, ét ad gangen.
*
* Den samme rude to steder — oprydning i indbakken og opgaver ud af en note —
* fordi det er den samme handling: en model har foreslået en linje, og et
* menneske bestemmer. To ruder ville betyde to svar på "hvad sker der, når
* modellen tager fejl", og det spørgsmål har kun ét rigtigt svar.
*
* Forslaget står i et felt, man kan skrive i, frem for i en etiket. Det er
* hele forskellen mellem at tage imod og at bestemme: et forslag, der er tæt
* på, bliver rettet på to tegn i stedet for at blive kasseret — og linjen
* læses af den samme parser bagefter, uanset om den blev rørt.
*
* `why` står under linjen, fordi et forkert gæt skal kunne gennemskues. "Fordi
* der står 'inden fredag' i noten" er til at være uenig i; en dato, der bare
* dukkede op, er ikke.
*/
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
import { humanMessage } from '$lib/api.js';
let { title, hint, load, apply, onclose } = $props();
let state = $state('loading'); // loading | ready | failed
let error = $state('');
let rows = $state([]);
let busy = $state(null);
$effect(() => {
let alive = true;
state = 'loading';
load()
.then((r) => {
if (!alive) return;
rows = (r?.suggestions ?? []).map((s, i) => ({ ...s, key: i, line: s.line, done: false }));
state = 'ready';
})
.catch((e) => {
if (!alive) return;
error = humanMessage(e);
state = 'failed';
});
return () => {
alive = false;
};
});
async function accept(row) {
if (busy !== null) return;
busy = row.key;
try {
await apply(row, row.line);
rows = rows.map((r) => (r.key === row.key ? { ...r, done: true } : r));
} catch (e) {
app.toast(humanMessage(e));
} finally {
busy = null;
}
}
function skip(row) {
rows = rows.filter((r) => r.key !== row.key);
}
const left = $derived(rows.filter((r) => !r.done).length);
</script>
<section class="suggest">
<header>
<h3>{title}</h3>
<button class="close" onclick={onclose} aria-label={t('detail.close')}>×</button>
</header>
{#if state === 'loading'}
<p class="hint">{t('ai.thinking')}</p>
{:else if state === 'failed'}
<p class="hint bad">{error}</p>
{:else if !rows.length}
<p class="hint">{t('ai.nothingToSuggest')}</p>
{:else}
{#if hint}<p class="hint">{hint}</p>{/if}
<ul>
{#each rows as row (row.key)}
<li class:done={row.done}>
<input
value={row.line}
oninput={(e) => (row.line = e.currentTarget.value)}
disabled={row.done}
aria-label={title}
/>
{#if row.why}<p class="why">{row.why}</p>{/if}
{#if !row.done}
<div class="row">
<button class="button" disabled={busy !== null} onclick={() => accept(row)}>
{t('ai.accept')}
</button>
<button class="secondary" onclick={() => skip(row)}>{t('ai.skip')}</button>
</div>
{:else}
<p class="why ok">{t('ai.applied')}</p>
{/if}
</li>
{/each}
</ul>
{#if !left}
<p class="hint">{t('ai.allDone')}</p>
{/if}
{/if}
</section>
<style>
.suggest {
margin: var(--s2) 0;
padding: var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-raised, var(--surface));
}
header {
display: flex;
align-items: baseline;
gap: var(--s2);
}
h3 {
margin: 0 0 var(--s2);
font-size: var(--text-sm);
}
.close {
margin-left: auto;
border: none;
background: none;
color: var(--ink-faint);
cursor: pointer;
font-size: var(--text-md);
line-height: 1;
}
.hint {
margin: 0 0 var(--s2);
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: 1.5;
}
.hint.bad {
color: var(--danger, var(--ink));
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s2);
}
li input {
width: 100%;
font-size: var(--text-sm);
padding: var(--s1);
}
li.done input {
opacity: 0.6;
}
/* Grunden til forslaget, under det. Mindre end linjen selv: det er baggrund
for en beslutning, ikke selve beslutningen. */
.why {
margin: 2px 0 var(--s1);
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: 1.4;
}
.why.ok {
color: var(--ok, var(--ink-muted));
}
.row {
display: flex;
gap: var(--s2);
}
</style>
+16
View File
@@ -77,6 +77,22 @@ export const da = {
'notes.sortUpdated': 'Senest rørt',
'notes.sortCreated': 'Oprettet',
'notes.sortTitle': 'Navn',
// AI'ens forslag. Ordene skal gøre det klart, at der bliver foreslået og ikke
// rettet: den, der siger ja, er den, der bestemmer.
'ai.tidyInbox': 'Ryd op i indbakken',
'ai.tidyTitle': 'Forslag til indbakken',
'ai.tidyHint':
'Sådan kunne linjerne have set ud, hvis de var skrevet færdig. Ret i dem, du vil rette i — der sker først noget, når du siger ja.',
'ai.findActions': 'Find opgaver i noten',
'ai.actionsTitle': 'Opgaver i noten',
'ai.actionsHint':
'Det, nogen har lovet eller er blevet bedt om i teksten. Citatet under hver linje er de ord, den kom fra.',
'ai.thinking': 'Læser …',
'ai.nothingToSuggest': 'Der er ikke noget at foreslå.',
'ai.accept': 'Tilføj',
'ai.skip': 'Nej tak',
'ai.applied': 'Gjort.',
'ai.allDone': 'Ikke mere at tage stilling til.',
'beacon.title': 'Tælling af installationer',
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
'beacon.send': 'Send en anonym daglig melding',
+16
View File
@@ -74,6 +74,22 @@ export const en = {
'notes.sortUpdated': 'Last touched',
'notes.sortCreated': 'Created',
'notes.sortTitle': 'Name',
// The AI's suggestions. The words have to make it plain that this proposes
// rather than edits: whoever says yes is the one deciding.
'ai.tidyInbox': 'Tidy up the inbox',
'ai.tidyTitle': 'Suggestions for the inbox',
'ai.tidyHint':
'How the lines could have read if they had been finished. Edit the ones you want to edit — nothing happens until you say yes.',
'ai.findActions': 'Find tasks in the note',
'ai.actionsTitle': 'Tasks in the note',
'ai.actionsHint':
'What somebody promised or was asked to do in the text. The quote under each line is the words it came from.',
'ai.thinking': 'Reading …',
'ai.nothingToSuggest': 'There is nothing to suggest.',
'ai.accept': 'Add',
'ai.skip': 'No thanks',
'ai.applied': 'Done.',
'ai.allDone': 'Nothing left to decide on.',
'beacon.title': 'Counting installations',
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
'beacon.send': 'Send an anonymous daily report',
+23 -2
View File
@@ -19,6 +19,7 @@
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import AISuggestions from '$lib/components/AISuggestions.svelte';
let notes = $state([]);
@@ -207,6 +208,9 @@
// Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej
// frem, og det skal kunne kopieres frem for at forsvinde.
let shareInviteLink = $state('');
// Opgaver, der ligger i notens tekst. Lukket, indtil nogen spørger: det koster
// et kald til en model, og de fleste noter er ikke referater.
let showActions = $state(false);
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// Dem, et `@` kan betyde i den åbne note.
@@ -246,8 +250,9 @@
$effect(() => {
const id = selected?.id;
// Nulstil ved hvert skift, så den forrige notes folk ikke står et øjeblik
// under den nye.
// Nulstil ved hvert skift, så den forrige notes folk — og den forrige notes
// forslag — ikke står et øjeblik under den nye.
showActions = false;
shares = [];
shareCandidates = [];
shareFollows = [];
@@ -1086,6 +1091,16 @@
onsave={save}
/>
{#if showActions}
<AISuggestions
title={t('ai.actionsTitle')}
hint={t('ai.actionsHint')}
load={() => api.aiNoteActions(selected.id)}
apply={(row, line) => api.aiCreateFromNote(selected.id, line)}
onclose={() => (showActions = false)}
/>
{/if}
<footer>
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
{#if links.length}
@@ -1274,6 +1289,12 @@
{/if}
<button class="button" onclick={save}>{t('notes.save')}</button>
<!-- Det, nogen har lovet i et referat, står i teksten og ikke på
listen. Knappen finder det frem som forslag; opgaverne bliver
til noget, når man siger ja til dem. -->
<button class="button" onclick={() => (showActions = !showActions)}>
{t('ai.findActions')}
</button>
{#if ownsSelected}
<!-- Laid away, not thrown away: archive is the note leaving the list
without leaving the account, and it reads "bring it back" once it
+29
View File
@@ -10,6 +10,7 @@
import TaskList from '$lib/components/TaskList.svelte';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import AISuggestions from '$lib/components/AISuggestions.svelte';
import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
import { focusOnMount } from '$lib/focus.js';
@@ -31,6 +32,10 @@
let members = $state([]);
let showShare = $state(false);
let showLog = $state(false);
// Oprydningen i indbakken. Kun dér: det er den ene liste, der bliver fyldt med
// linjer, ingen nåede at skrive færdig, og derfor den ene, hvor det er værd at
// spørge en model om, hvordan de skulle have set ud.
let showTidy = $state(false);
let activity = $state([]);
let inviteEmail = $state('');
let inviteRole = $state('editor');
@@ -613,6 +618,18 @@
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
</button>
{#if project.is_inbox}
<button
role="menuitem"
onclick={() => {
showTidy = !showTidy;
showMenu = false;
}}
>
{t('ai.tidyInbox')}
</button>
{/if}
{#if !project.is_inbox}
<button
role="menuitem"
@@ -665,6 +682,18 @@
</div>
</header>
{#if showTidy}
<!-- Forslag, ikke rettelser. Hver linje står i et felt, man kan skrive
i, og sker først, når nogen siger ja til den. -->
<AISuggestions
title={t('ai.tidyTitle')}
hint={t('ai.tidyHint')}
load={() => api.aiTidyInbox()}
apply={(row, line) => api.aiApplyToTask(row.task_id, line)}
onclose={() => (showTidy = false)}
/>
{/if}
{#if showLog}
<div class="panel">
<ul class="log">