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>
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/ai"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// Dagens plan: én besked om morgenen om det, der venter i dag.
|
|
//
|
|
// Klokken siger, hvad andre har gjort. Det her siger, hvad der ligger foran en —
|
|
// og det er en anden slags besked: den kommer, fordi det er blevet morgen, ikke
|
|
// fordi nogen har rørt noget. Uden den skal man selv åbne listen for at opdage,
|
|
// at der lå tre ting og forfaldt i går.
|
|
//
|
|
// Sendt som en besked frem for en mail, fordi røret allerede findes: klokken,
|
|
// push til telefonen og mærket på ikonet er ét kald, og en mail mere i indbakken
|
|
// er præcis det, dette program findes for at slippe for.
|
|
|
|
const planScope = "dailyplan"
|
|
|
|
// planSettings er, hvad én person har bedt om.
|
|
//
|
|
// Slået fra som udgangspunkt. En besked, man ikke har bedt om, klokken syv om
|
|
// morgenen, er ikke en hjælp — og modsat beaconet er der ingen, der har brug for,
|
|
// at det her er tændt for at kunne gøre sit arbejde.
|
|
type planSettings struct {
|
|
Enabled bool `json:"enabled"`
|
|
// Hour er lokal tid hos personen selv. Et tal og ikke et klokkeslæt: planen
|
|
// sendes på timen, og et minuttal ville love en præcision, en time-tikkende
|
|
// baggrundsjob ikke har.
|
|
Hour int `json:"hour"`
|
|
// LastSent er datoen, den sidst blev sendt, som "2006-01-02" i personens egen
|
|
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
|
|
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
|
|
LastSent string `json:"last_sent,omitempty"`
|
|
}
|
|
|
|
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
|
|
out := planSettings{Hour: 7}
|
|
stored, err := s.db.UserSettings(ctx, userID, planScope)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if v, ok := stored["enabled"].(bool); ok {
|
|
out.Enabled = v
|
|
}
|
|
if v, ok := stored["hour"].(float64); ok && v >= 0 && v <= 23 {
|
|
out.Hour = int(v)
|
|
}
|
|
if v, ok := stored["last_sent"].(string); ok {
|
|
out.LastSent = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
|
|
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
|
|
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
|
p, err := s.planSettings(r.Context(), userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.internal(w, r, "plan settings", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
type planRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Hour *int `json:"hour"`
|
|
}
|
|
|
|
func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
|
|
var req planRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
p, err := s.planSettings(r.Context(), user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "plan settings", err)
|
|
return
|
|
}
|
|
if req.Enabled != nil {
|
|
p.Enabled = *req.Enabled
|
|
}
|
|
if req.Hour != nil {
|
|
if *req.Hour < 0 || *req.Hour > 23 {
|
|
writeFieldErrors(w, map[string]string{"hour": "must be between 0 and 23"})
|
|
return
|
|
}
|
|
p.Hour = *req.Hour
|
|
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu.
|
|
// Uden det her ville en, der flytter planen fra 07 til 16, først få den i
|
|
// morgen — hvilket ligner, at indstillingen ikke virkede.
|
|
p.LastSent = ""
|
|
}
|
|
if err := s.savePlanSettings(r.Context(), user.ID, p); err != nil {
|
|
s.internal(w, r, "save plan settings", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
// handlePlanNow sender dagens plan med det samme, til den der beder om det.
|
|
//
|
|
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
|
|
// af, er en indstilling, ingen tør slå til.
|
|
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
plan, err := s.buildPlan(r.Context(), user)
|
|
if err != nil {
|
|
s.internal(w, r, "build plan", err)
|
|
return
|
|
}
|
|
if plan == "" {
|
|
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
|
|
return
|
|
}
|
|
s.notify(r, &store.Notification{
|
|
UserID: user.ID, Kind: "daily.plan",
|
|
Title: planTitle(user.Locale), Body: plan,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{"plan": plan, "sent": true})
|
|
}
|
|
|
|
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
|
|
// endnu i dag.
|
|
//
|
|
// Kaldt hver time frem for lagt på et klokkeslæt — samme form som backup og
|
|
// beacon, og af samme grund: en instans, der sov klokken syv, ville ellers
|
|
// springe dagen over. Og fordi klokken syv er syv forskellige steder: personens
|
|
// egen tidszone afgør, hvornår det er morgen hos dem.
|
|
func (s *Server) DailyPlans(ctx context.Context) error {
|
|
users, err := s.db.ListUsers(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, u := range users {
|
|
user, err := s.db.UserByID(ctx, u.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
p, err := s.planSettings(ctx, user.ID)
|
|
if err != nil || !p.Enabled {
|
|
continue
|
|
}
|
|
|
|
now := time.Now().In(userLocation(user.Timezone))
|
|
today := now.Format("2006-01-02")
|
|
if p.LastSent == today || now.Hour() < p.Hour {
|
|
continue
|
|
}
|
|
// Kun samme dag. En instans, der har været slukket i tre dage, skal ikke
|
|
// sende tre planer, når den kommer op — de to af dem er om dage, der er
|
|
// gået, og en plan for i forgårs er ikke en plan.
|
|
if now.Hour() > p.Hour+3 {
|
|
p.LastSent = today
|
|
_ = s.savePlanSettings(ctx, user.ID, p)
|
|
continue
|
|
}
|
|
|
|
plan, err := s.buildPlan(ctx, user)
|
|
if err != nil {
|
|
s.log.Warn("build daily plan", "err", err, "user", user.ID)
|
|
continue
|
|
}
|
|
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god
|
|
// morgen, og en besked, der siger "der er ingenting", lærer folk at lade
|
|
// være med at åbne beskeder.
|
|
if plan != "" {
|
|
s.notifyDirect(ctx, &store.Notification{
|
|
UserID: user.ID, Kind: "daily.plan",
|
|
Title: planTitle(user.Locale), Body: plan,
|
|
})
|
|
}
|
|
p.LastSent = today
|
|
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
|
|
s.log.Warn("save plan settings", "err", err, "user", user.ID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildPlan skriver dagens plan.
|
|
//
|
|
// Tallene kommer fra basen og sætningen fra modellen — i den rækkefølge. En model,
|
|
// der selv skulle tælle, ville af og til tælle forkert, og en plan, der siger fire
|
|
// opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model sat op,
|
|
// står tallene alene, og det er stadig det, man havde brug for at vide.
|
|
func (s *Server) buildPlan(ctx context.Context, user *store.User) (string, error) {
|
|
loc := userLocation(user.Timezone)
|
|
now := time.Now().In(loc)
|
|
today := now.Format("2006-01-02")
|
|
// I går, ikke i dag: grænserne i filteret er inklusive, så "før i dag" skrevet
|
|
// som DueBefore: today ville tælle dagens egne opgaver med som sprunget over —
|
|
// og en plan, der klokken syv om morgenen siger, at dagens opgaver allerede er
|
|
// overskredet, er en plan, man holder op med at læse.
|
|
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
|
|
|
|
overdue, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{DueBefore: yesterday, Limit: 50})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
due, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
|
DueFrom: today, DueBefore: today, Limit: 50,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(overdue) == 0 && len(due) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
plain := plainPlan(len(due), len(overdue), user.Locale)
|
|
|
|
cfg, err := s.aiConfigFor(ctx, user)
|
|
if err != nil || !cfg.Configured() {
|
|
return plain, nil
|
|
}
|
|
|
|
lines := make([]string, 0, len(due)+len(overdue))
|
|
for _, t := range overdue {
|
|
lines = append(lines, "- "+t.Content+" ("+overdueWord(user.Locale)+" "+t.DueDate+")")
|
|
}
|
|
for _, t := range due {
|
|
lines = append(lines, "- "+t.Content)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
|
|
written, err := ai.New(cfg).DailyPlan(ctx, lines, len(due), len(overdue), user.Locale)
|
|
if err != nil {
|
|
// Modellen er ikke det, planen står og falder med. Tallene er rigtige uden
|
|
// den, og en morgen uden besked, fordi en API-nøgle er udløbet, er en
|
|
// dårligere handel end en besked uden en velskrevet sætning.
|
|
s.log.Info("daily plan without the model", "err", err, "user", user.ID)
|
|
return plain, nil
|
|
}
|
|
return strings.TrimSpace(written), nil
|
|
}
|
|
|
|
// aiConfigFor er aiConfig uden en request bag sig — baggrundsjobbet har ingen.
|
|
//
|
|
// AllowPrivate er falsk her, og det er med vilje: retten til at nå det private
|
|
// net blev givet til en administrator, der sad og skrev adressen ind, og et job,
|
|
// der kører klokken syv om morgenen, er ikke nogen, der sidder og skriver. En
|
|
// model på maskinen selv skal derfor bruges gennem fladen, ikke af planen.
|
|
func (s *Server) aiConfigFor(ctx context.Context, user *store.User) (ai.Config, error) {
|
|
values, err := s.db.UserSettings(ctx, user.ID, "ai")
|
|
if err != nil {
|
|
return ai.Config{}, err
|
|
}
|
|
str := func(key string) string {
|
|
v, _ := values[key].(string)
|
|
return v
|
|
}
|
|
return ai.Config{
|
|
Provider: ai.Provider(str("provider")),
|
|
APIKey: str("api_key"),
|
|
BaseURL: str("base_url"),
|
|
Model: str("model"),
|
|
}, nil
|
|
}
|
|
|
|
// notifyDirect er notify uden en request. Baggrundsjobbet har ingen bruger i
|
|
// konteksten og ingen actor — planen er ikke noget, nogen har gjort ved en.
|
|
func (s *Server) notifyDirect(ctx context.Context, n *store.Notification) {
|
|
if err := s.db.CreateNotification(ctx, n); err != nil {
|
|
s.log.Warn("create notification", "err", err)
|
|
return
|
|
}
|
|
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
|
ID: n.ID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
|
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
go s.pushToUser(n.UserID, n.Title, n.Body, "")
|
|
}
|
|
|
|
func planTitle(locale string) string {
|
|
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
|
return "Today"
|
|
}
|
|
return "I dag"
|
|
}
|
|
|
|
func overdueWord(locale string) string {
|
|
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
|
return "was due"
|
|
}
|
|
return "forfaldt"
|
|
}
|
|
|
|
// plainPlan er planen uden en model: to tal, og ikke et ord mere.
|
|
//
|
|
// Det er ikke en nødløsning, det er bunden. Det, man har brug for at vide klokken
|
|
// syv, er om der er noget, der er sprunget over — og det tal er rigtigt, uanset
|
|
// om nogen har lagt en API-nøgle ind.
|
|
func plainPlan(due, overdue int, locale string) string {
|
|
english := strings.HasPrefix(strings.ToLower(locale), "en")
|
|
var parts []string
|
|
if due > 0 {
|
|
if english {
|
|
parts = append(parts, strconv.Itoa(due)+" due today")
|
|
} else {
|
|
parts = append(parts, strconv.Itoa(due)+" forfalder i dag")
|
|
}
|
|
}
|
|
if overdue > 0 {
|
|
if english {
|
|
parts = append(parts, strconv.Itoa(overdue)+" overdue")
|
|
} else {
|
|
parts = append(parts, strconv.Itoa(overdue)+" er sprunget over")
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|