diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b33cc45..70894ed 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: > diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 60cf1c4..5a6299f 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -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. diff --git a/internal/httpapi/ai_suggest_handlers.go b/internal/httpapi/ai_suggest_handlers.go new file mode 100644 index 0000000..9553b2c --- /dev/null +++ b/internal/httpapi/ai_suggest_handlers.go @@ -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], " ") + " …" +} diff --git a/internal/httpapi/ai_suggest_test.go b/internal/httpapi/ai_suggest_test.go new file mode 100644 index 0000000..fa6366a --- /dev/null +++ b/internal/httpapi/ai_suggest_test.go @@ -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 +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 017f4b4..3fb58b1 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) diff --git a/web/src/lib/api.js b/web/src/lib/api.js index b60ffa6..c30aa23 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -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'), diff --git a/web/src/lib/components/AISuggestions.svelte b/web/src/lib/components/AISuggestions.svelte new file mode 100644 index 0000000..b82a9b8 --- /dev/null +++ b/web/src/lib/components/AISuggestions.svelte @@ -0,0 +1,189 @@ + + +
+
+

{title}

+ +
+ + {#if state === 'loading'} +

{t('ai.thinking')}

+ {:else if state === 'failed'} +

{error}

+ {:else if !rows.length} +

{t('ai.nothingToSuggest')}

+ {:else} + {#if hint}

{hint}

{/if} + + {#if !left} +

{t('ai.allDone')}

+ {/if} + {/if} +
+ + diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 1c0ba37..fa0d98e 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -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', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index feb2ff0..34d5791 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -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', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index ea3cdb7..1c9126c 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -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} + api.aiNoteActions(selected.id)} + apply={(row, line) => api.aiCreateFromNote(selected.id, line)} + onclose={() => (showActions = false)} + /> + {/if} +