De tre sidste af de fem. Alle tre samme sted hen: det, man ellers selv skulle sidde og gøre klokken syv om morgenen. Dagens plan. Én besked om morgenen om det, der forfalder i dag, og det, der er sprunget over. Tallene tælles i basen og gives til modellen som kendsgerninger, den ikke må regne om — en plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model, står tallene alene, og det er stadig det, man havde brug for at vide. Er der hverken noget, der forfalder, eller noget, der er sprunget over, kommer der ingen besked: en morgen uden noget at gøre er en god morgen, og en besked om ingenting lærer folk at lade være med at åbne beskeder. Timen er personens egen — klokken syv er syv forskellige steder — og jobbet kører hver time frem for på et klokkeslæt, så en instans, der sov, ikke springer dagen over. Flytter man timen, ryger dagens mærke, så man kan se virkningen i dag i stedet for i morgen. Mails, der giver mening. Opgaven hed, hvad mailen hed: "Anders Jensen: SV: SV: Vedr. levering uge 12". Nok til at genkende, ikke nok til at gøre noget ved. Nu skriver modellen den handling, mailen beder om, og lader være, hvis den ikke beder om nogen. Ét kald pr. gennemløb frem for ét pr. mail — femogtyve mails er ellers femogtyve gange ventetid — og emnet bliver stående i beskrivelsen, så en misforstået mail stadig kan læses. Falder modellen ud, hedder opgaven afsender og emne som før: en model, der er nede, må ikke kunne stoppe posten. Og svar på et spørgsmål. "Hvad lovede jeg Anders i august?" — svaret står spredt over tre noter, og ingen af dem indeholder ordet "lovede". Søgningen kræver, at alle ord optræder, hvilket er rigtigt for et søgefelt og forkert for et spørgsmål, så kandidaterne hentes ord for ord og de noter, flere ord rammer, kommer øverst. Modellen får kun dem: den slår ikke selv noget op, så et svar kan ikke handle om nogen andens noter. Og svaret bærer, hvad det står på — en model, der siger "det gjorde du den 14.", er kun brugbar, hvis man kan slå op i det, den læste det i. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
6.2 KiB
Go
204 lines
6.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// Spørg om sine egne noter og opgaver i almindeligt sprog.
|
|
//
|
|
// Søgningen finder det, der indeholder ordene. Det her svarer på spørgsmålet —
|
|
// "hvad lovede jeg Anders i august?" — og det er ikke det samme: svaret står
|
|
// spredt over tre noter og en opgave, og ingen af dem indeholder ordet "lovede".
|
|
//
|
|
// To halvdele, og rækkefølgen er hele sikkerheden i det. Basen finder kandidaterne
|
|
// med den søgning, der allerede findes, og som allerede kun kan se det, personen
|
|
// selv må se. Modellen får kun dem — den slår ikke selv noget op og kan ikke bede
|
|
// om mere. Et svar, der ikke står i det udleverede, kan derfor ikke handle om
|
|
// nogen andens noter.
|
|
//
|
|
// Og svaret bærer, hvad det er bygget på. En model, der svarer "det gjorde du den
|
|
// 14.", er kun brugbar, hvis man kan slå op i den note, den læste det i.
|
|
|
|
type askRequest struct {
|
|
Question string `json:"question"`
|
|
}
|
|
|
|
type askSourceJSON struct {
|
|
Kind string `json:"kind"` // "note" eller "task"
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
// handleAIAsk svarer på et spørgsmål ud fra personens egne noter og opgaver.
|
|
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
|
|
var req askRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
question := strings.TrimSpace(req.Question)
|
|
if question == "" {
|
|
writeFieldErrors(w, map[string]string{"question": "required"})
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
client, ok := s.aiClientFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Ord for ord, ikke hele spørgsmålet på én gang.
|
|
//
|
|
// Søgningen kræver, at *alle* ord optræder, og det er den rigtige regel for et
|
|
// søgefelt: skriver man to ord, mener man begge. Men et spørgsmål er ikke en
|
|
// søgning — "hvad lovede jeg Anders i august" indeholder ét ord, der står i
|
|
// noten, og fem, der ikke gør, så et krav om dem alle finder ingenting hver
|
|
// eneste gang.
|
|
//
|
|
// Så: et groft net her, og modellen læser fangsten. Alternativet — at lade
|
|
// modellen skrive søgningen — lægger et led mere ind, der kan tage fejl,
|
|
// mellem spørgsmålet og de noter, der faktisk findes.
|
|
notes, tasks, err := s.candidatesFor(r, user.ID, question)
|
|
if err != nil {
|
|
s.internal(w, r, "search for the question", err)
|
|
return
|
|
}
|
|
if len(notes) == 0 && len(tasks) == 0 {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"answer": "", "sources": []askSourceJSON{},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Nummereret, fordi svaret skal kunne pege tilbage. En henvisning til "noten
|
|
// om leveringen" er ikke noget, fladen kan lave et link ud af; et nummer er.
|
|
sources := make([]askSourceJSON, 0, len(notes)+len(tasks))
|
|
passages := make([]string, 0, len(notes)+len(tasks))
|
|
for _, n := range notes {
|
|
sources = append(sources, askSourceJSON{Kind: "note", ID: n.ID, Title: n.Title})
|
|
passages = append(passages, "["+itoaSource(len(sources))+"] Note: "+n.Title+"\n"+
|
|
firstWords(n.Body, 300))
|
|
}
|
|
for _, t := range tasks {
|
|
sources = append(sources, askSourceJSON{Kind: "task", ID: t.ID, Title: t.Content})
|
|
line := "[" + itoaSource(len(sources)) + "] Task: " + t.Content
|
|
if t.DueDate != "" {
|
|
line += " (" + t.DueDate + ")"
|
|
}
|
|
if t.Description != "" {
|
|
line += "\n" + firstWords(t.Description, 60)
|
|
}
|
|
passages = append(passages, line)
|
|
}
|
|
|
|
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
|
defer cancel()
|
|
|
|
answer, used, err := client.Ask(ctx, question, passages, user.Locale)
|
|
if err != nil {
|
|
s.log.Warn("ai ask", "err", err)
|
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
|
return
|
|
}
|
|
|
|
// Kun de kilder, svaret faktisk brugte. Alle tolv ville gøre henvisningen
|
|
// værdiløs: en liste over alt, der blev kigget i, er ikke det samme som det,
|
|
// svaret står på.
|
|
cited := make([]askSourceJSON, 0, len(used))
|
|
for _, n := range used {
|
|
if n >= 1 && n <= len(sources) {
|
|
cited = append(cited, sources[n-1])
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"answer": answer, "sources": cited})
|
|
}
|
|
|
|
func itoaSource(n int) string {
|
|
if n < 10 {
|
|
return string(rune('0' + n))
|
|
}
|
|
return string(rune('0'+n/10)) + string(rune('0'+n%10))
|
|
}
|
|
|
|
// candidatesFor henter det, spørgsmålet kunne handle om.
|
|
//
|
|
// Ét opslag pr. ord, lagt sammen og med det, der rammes af flere ord, øverst: et
|
|
// spørgsmål har ét eller to bærende ord og en håndfuld, der bare er dansk, og den
|
|
// note, der indeholder begge de bærende, er den, det handler om.
|
|
//
|
|
// Korte ord springes over. "i", "på" og "the" står i alt, og en note, der blev
|
|
// hentet, fordi den indeholder "i", fortrænger en, der faktisk svarer.
|
|
func (s *Server) candidatesFor(r *http.Request, userID, question string) ([]store.Note, []store.Task, error) {
|
|
terms := store.SearchTerms(question)
|
|
|
|
type scored struct {
|
|
note store.Note
|
|
task store.Task
|
|
hits int
|
|
}
|
|
noteHits := map[string]*scored{}
|
|
taskHits := map[string]*scored{}
|
|
|
|
for _, term := range terms {
|
|
if len([]rune(term)) < 3 {
|
|
continue
|
|
}
|
|
found, err := s.db.SearchNotes(r.Context(), term, 10)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, n := range found {
|
|
if at, ok := noteHits[n.ID]; ok {
|
|
at.hits++
|
|
continue
|
|
}
|
|
noteHits[n.ID] = &scored{note: n, hits: 1}
|
|
}
|
|
|
|
tasks, err := s.db.ListTasks(r.Context(), userID, store.TaskFilter{
|
|
Search: term, Limit: 10, IncludeCompleted: true,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, t := range tasks {
|
|
if at, ok := taskHits[t.ID]; ok {
|
|
at.hits++
|
|
continue
|
|
}
|
|
taskHits[t.ID] = &scored{task: t, hits: 1}
|
|
}
|
|
}
|
|
|
|
notes := make([]store.Note, 0, len(noteHits))
|
|
for _, at := range noteHits {
|
|
notes = append(notes, at.note)
|
|
}
|
|
sort.SliceStable(notes, func(i, j int) bool {
|
|
return noteHits[notes[i].ID].hits > noteHits[notes[j].ID].hits
|
|
})
|
|
|
|
tasks := make([]store.Task, 0, len(taskHits))
|
|
for _, at := range taskHits {
|
|
tasks = append(tasks, at.task)
|
|
}
|
|
sort.SliceStable(tasks, func(i, j int) bool {
|
|
return taskHits[tasks[i].ID].hits > taskHits[tasks[j].ID].hits
|
|
})
|
|
|
|
// Loftet er, hvad der kan stå i én prompt uden at koste mere, end svaret er
|
|
// værd — og hvad et menneske ville have gidet læse igennem selv.
|
|
return capped(notes, 12), capped(tasks, 20), nil
|
|
}
|
|
|
|
func capped[T any](in []T, n int) []T {
|
|
if len(in) > n {
|
|
return in[:n]
|
|
}
|
|
return in
|
|
}
|