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
|
||
|
|
}
|