feat(nest): POST/GET nest messages, Jev-first tagging, event-driven crow response (#10)
This commit is contained in:
1 parent
ba5dbbc7ae
commit
e4e4d12dea
5 files changed
+281
-1
No files matched your search
@@ -24,6 +24,7 @@ type Event struct {
|
|||||||
Output string `json:"output,omitempty"`
|
Output string `json:"output,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
|
Prob any `json:"probs,omitempty"` // question probabilities, when a call returns them (calibration data)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlightLog struct {
|
type FlightLog struct {
|
||||||
|
|||||||
@@ -116,6 +116,22 @@ func (h *Hub) Publish(listID, name string, fields map[string]any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenNest lazily opens a stream for a nest's conversation. Nest ids are
|
||||||
|
// slugs and list ids are hex, so the same map serves both without collision.
|
||||||
|
func (h *Hub) OpenNest(nestID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.lists[nestID] == nil {
|
||||||
|
h.lists[nestID] = &listStream{created: time.Now(), subs: map[chan sseMsg]struct{}{}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishNest records one nest event and sends it to subscribers.
|
||||||
|
func (h *Hub) PublishNest(nestID, name string, fields map[string]any) {
|
||||||
|
h.OpenNest(nestID)
|
||||||
|
h.Publish(nestID, name, fields)
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe returns the history after lastID and a channel for what comes next.
|
// Subscribe returns the history after lastID and a channel for what comes next.
|
||||||
// The channel closes if the subscriber falls too far behind.
|
// The channel closes if the subscriber falls too far behind.
|
||||||
func (h *Hub) Subscribe(listID string, lastID int) ([]sseMsg, chan sseMsg, bool) {
|
func (h *Hub) Subscribe(listID string, lastID int) ([]sseMsg, chan sseMsg, bool) {
|
||||||
|
|||||||
@@ -159,6 +159,68 @@ func (c JevCall) confident(threshold float64) bool {
|
|||||||
return c.ModeConf >= threshold && c.TaskTypeConf >= threshold
|
return c.ModeConf >= threshold && c.TaskTypeConf >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kindQuestions tags a nest message with its kind (A1: Jev first). The escape
|
||||||
|
// option "say" keeps Jev from forcing a wrong label: any message it isn't sure
|
||||||
|
// about falls back to the safest kind.
|
||||||
|
var kindQuestions = map[string]any{
|
||||||
|
"kind": map[string]any{
|
||||||
|
"type": "choice",
|
||||||
|
"instructions": "What kind of chatroom message is this?",
|
||||||
|
"criteria": map[string]string{
|
||||||
|
"request": "Asks a member or the room for work.",
|
||||||
|
"claim": "Takes ownership of a request.",
|
||||||
|
"deliver": "Posts the result of a request.",
|
||||||
|
"refer": "Points to a file or artifact instead of pasting it.",
|
||||||
|
"say": "Ordinary talk, or unclear.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyKind returns the kind when Jev is set and its choice sits at or
|
||||||
|
// above the confidence band, with the choice probabilities for the log. Not ok
|
||||||
|
// means the caller falls back to the poster's header (EVENTS.md tagging rule).
|
||||||
|
func (j *JevClient) ClassifyKind(ctx context.Context, text string) (string, map[string]float64, bool) {
|
||||||
|
if j == nil {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
answers, _, _, err := j.SystemOne(ctx, text, kindQuestions)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
a := answers["kind"]
|
||||||
|
if a.Choice == "" || a.Confidence == nil || *a.Confidence < j.threshold {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
return a.Choice, a.Probabilities, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyKind tags a nest message under the same guardrails as any call:
|
||||||
|
// flock cap, timeout and flight log, with the probabilities recorded.
|
||||||
|
func (f *Flock) ClassifyKind(ctx context.Context, text string, budgets ...*Budget) (string, map[string]float64, bool) {
|
||||||
|
if f.jev == nil {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
for _, b := range budgets {
|
||||||
|
if err := b.Check(); err != nil {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.sem <- struct{}{}
|
||||||
|
defer func() { <-f.sem }()
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
kind, probs, ok := f.jev.ClassifyKind(cctx, text)
|
||||||
|
e := Event{Kind: "tag", Item: text, Crow: "Sentry", Model: f.jev.model, Ms: time.Since(start).Milliseconds(), Prob: probs, Note: kind}
|
||||||
|
if !ok {
|
||||||
|
e.Note = "" // unsure: the header decides
|
||||||
|
}
|
||||||
|
if f.log != nil {
|
||||||
|
f.log.Write(e)
|
||||||
|
}
|
||||||
|
return kind, probs, ok
|
||||||
|
}
|
||||||
|
|
||||||
func (c JevCall) summary() string {
|
func (c JevCall) summary() string {
|
||||||
return fmt.Sprintf("Jev: %s %.0f%%, %s %.0f%%", c.Mode, c.ModeConf*100, c.TaskType, c.TaskTypeConf*100)
|
return fmt.Sprintf("Jev: %s %.0f%%, %s %.0f%%", c.Mode, c.ModeConf*100, c.TaskType, c.TaskTypeConf*100)
|
||||||
}
|
}
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var crowPostable = map[string]bool{
|
||||||
|
"say": true,
|
||||||
|
"request": true,
|
||||||
|
"claim": true,
|
||||||
|
"deliver": true,
|
||||||
|
"refer": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nestID := r.PathValue("nest_id")
|
||||||
|
if _, found := s.nests.Get(nestID); !found {
|
||||||
|
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := s.msgs.All(nestID)
|
||||||
|
out := make([]map[string]any, 0, len(all))
|
||||||
|
for _, m := range all {
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"msg_id": m.MsgID(),
|
||||||
|
"kind": m.Kind,
|
||||||
|
"author": m.Author,
|
||||||
|
"text": m.Text,
|
||||||
|
"to": m.To,
|
||||||
|
"refers_to": m.RefersTo,
|
||||||
|
"tagged_by": m.TaggedBy,
|
||||||
|
"ts": m.Timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nestID := r.PathValue("nest_id")
|
||||||
|
nest, found := s.nests.Get(nestID)
|
||||||
|
if !found {
|
||||||
|
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Author string `json:"author"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
To string `json:"to"`
|
||||||
|
RefersTo string `json:"refers_to"`
|
||||||
|
}
|
||||||
|
if !decode(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body.Text = strings.TrimSpace(body.Text)
|
||||||
|
body.To = strings.TrimSpace(body.To)
|
||||||
|
|
||||||
|
// Guardrails (EVENTS.md: Nest messages → Guardrails)
|
||||||
|
if n := utf8.RuneCountInString(body.Text); n < 1 || n > s.p.G.MaxItemChars {
|
||||||
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Author == "owner" {
|
||||||
|
// owner can always post
|
||||||
|
} else if !slices.Contains(nest.Crows, body.Author) {
|
||||||
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Author != "owner" {
|
||||||
|
if ok, msg, wait := s.p.Ledger.Admit(body.Author, time.Now(), s.p.G); !ok {
|
||||||
|
cooldown := int(wait.Seconds())
|
||||||
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{"message": msg, "cooldown_s": cooldown})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Kind != "" && body.Author != "owner" && !crowPostable[body.Kind] {
|
||||||
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
||||||
|
kind, taggedBy := s.tagKind(body.Kind, body.Text)
|
||||||
|
|
||||||
|
msg := Message{
|
||||||
|
NestID: nestID,
|
||||||
|
Kind: kind,
|
||||||
|
Author: body.Author,
|
||||||
|
Text: body.Text,
|
||||||
|
To: body.To,
|
||||||
|
RefersTo: body.RefersTo,
|
||||||
|
TaggedBy: taggedBy,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
record, err := s.msgs.Append(msg)
|
||||||
|
if err != nil {
|
||||||
|
httpError(w, http.StatusInternalServerError, "could not save the message: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.emitNestMessage(record)
|
||||||
|
|
||||||
|
// Event-driven trigger: if kind is request and To is a crow, respond.
|
||||||
|
if kind == "request" && record.To != "" && record.To != "owner" {
|
||||||
|
go s.crowResponds(nest, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"msg_id": record.MsgID(),
|
||||||
|
"kind": record.Kind,
|
||||||
|
"author": record.Author,
|
||||||
|
"text": record.Text,
|
||||||
|
"to": record.To,
|
||||||
|
"refers_to": record.RefersTo,
|
||||||
|
"tagged_by": record.TaggedBy,
|
||||||
|
"ts": record.Timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagKind runs the A1 rule: Jev first; Jev unsure -> the poster's header;
|
||||||
|
// neither -> say. tagged_by records which source won, and a Jev override of a
|
||||||
|
// header logs as a disagreement (fuel for calibration).
|
||||||
|
func (s *Server) tagKind(header, text string) (string, string) {
|
||||||
|
kind, _, ok := s.p.Flock.ClassifyKind(s.ctx, text)
|
||||||
|
if ok {
|
||||||
|
if crowPostable[header] && header != kind {
|
||||||
|
return kind, "disagreement"
|
||||||
|
}
|
||||||
|
return kind, "jev"
|
||||||
|
}
|
||||||
|
if crowPostable[header] {
|
||||||
|
return header, "header"
|
||||||
|
}
|
||||||
|
return "say", "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) emitNestMessage(m Message) {
|
||||||
|
s.hub.PublishNest(m.NestID, "nest.message", map[string]any{
|
||||||
|
"nest_id": m.NestID,
|
||||||
|
"msg_id": m.MsgID(),
|
||||||
|
"kind": m.Kind,
|
||||||
|
"author": m.Author,
|
||||||
|
"text": m.Text,
|
||||||
|
"to": m.To,
|
||||||
|
"refers_to": m.RefersTo,
|
||||||
|
"tagged_by": m.TaggedBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) crowResponds(nest Nest, req Message) {
|
||||||
|
crows := s.p.Flock.pick(nest, 1)
|
||||||
|
if len(crows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crow := crows[0] // B4 (@-addressing) makes this the addressed crow
|
||||||
|
system := systemFor(crow, crowSystem, true)
|
||||||
|
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nTEXT: " + req.Text
|
||||||
|
budget := &Budget{name: "nest-message", limit: s.p.G.TokenBudgetPerItem}
|
||||||
|
answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply := Message{
|
||||||
|
NestID: nest.ID,
|
||||||
|
Kind: "say",
|
||||||
|
Author: crow,
|
||||||
|
Text: strings.TrimSpace(answer),
|
||||||
|
To: req.Author,
|
||||||
|
RefersTo: req.MsgID(),
|
||||||
|
TaggedBy: "harness",
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
record, err := s.msgs.Append(reply)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.emitNestMessage(record)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type Server struct {
|
|||||||
approver *WebApprover
|
approver *WebApprover
|
||||||
hub *Hub
|
hub *Hub
|
||||||
nests *NestStore
|
nests *NestStore
|
||||||
|
msgs *MessageStore
|
||||||
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
||||||
beat time.Duration
|
beat time.Duration
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,9 @@ func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *N
|
|||||||
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat}
|
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithMessages attaches the nest conversation log (chatroom path).
|
||||||
|
func (s *Server) WithMessages(m *MessageStore) *Server { s.msgs = m; return s }
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /api/lists", s.postList)
|
mux.HandleFunc("POST /api/lists", s.postList)
|
||||||
@@ -49,6 +53,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/crows", s.crows)
|
mux.HandleFunc("GET /api/crows", s.crows)
|
||||||
mux.HandleFunc("GET /api/nests", s.getNests)
|
mux.HandleFunc("GET /api/nests", s.getNests)
|
||||||
mux.HandleFunc("POST /api/nests", s.postNest)
|
mux.HandleFunc("POST /api/nests", s.postNest)
|
||||||
|
mux.HandleFunc("GET /api/nests/{nest_id}/messages", s.getNestMessages)
|
||||||
|
mux.HandleFunc("POST /api/nests/{nest_id}/messages", s.postNestMessage)
|
||||||
if st, err := os.Stat(webDir); err == nil && st.IsDir() {
|
if st, err := os.Stat(webDir); err == nil && st.IsDir() {
|
||||||
mux.Handle("GET /", http.FileServer(http.Dir(webDir)))
|
mux.Handle("GET /", http.FileServer(http.Dir(webDir)))
|
||||||
} else {
|
} else {
|
||||||
@@ -73,6 +79,8 @@ func cmdServe(args []string) {
|
|||||||
defer flog.Close()
|
defer flog.Close()
|
||||||
nests, err := LoadNests(filepath.Join(dataDir, "nests.json"))
|
nests, err := LoadNests(filepath.Join(dataDir, "nests.json"))
|
||||||
must(err)
|
must(err)
|
||||||
|
msgs, err := LoadMessageStore(filepath.Join(dataDir, "messages"))
|
||||||
|
must(err)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -84,7 +92,7 @@ func cmdServe(args []string) {
|
|||||||
if jev != nil {
|
if jev != nil {
|
||||||
fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name())
|
fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name())
|
||||||
}
|
}
|
||||||
s := NewServer(ctx, p, approver, nests)
|
s := NewServer(ctx, p, approver, nests).WithMessages(msgs)
|
||||||
|
|
||||||
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -190,6 +198,15 @@ func newListID() string {
|
|||||||
|
|
||||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||||
listID := r.URL.Query().Get("list_id")
|
listID := r.URL.Query().Get("list_id")
|
||||||
|
nestID := r.URL.Query().Get("nest_id")
|
||||||
|
if listID == "" && nestID != "" {
|
||||||
|
if _, found := s.nests.Get(nestID); !found {
|
||||||
|
httpError(w, http.StatusNotFound, "no such nest_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.hub.OpenNest(nestID)
|
||||||
|
listID = nestID
|
||||||
|
}
|
||||||
lastID, _ := strconv.Atoi(r.Header.Get("Last-Event-ID"))
|
lastID, _ := strconv.Atoi(r.Header.Get("Last-Event-ID"))
|
||||||
past, ch, ok := s.hub.Subscribe(listID, lastID)
|
past, ch, ok := s.hub.Subscribe(listID, lastID)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
Reference in new issue
Block a user