diff --git a/cmd/matrixd/main.go b/cmd/matrixd/main.go index 6a0a49f..e67c49e 100644 --- a/cmd/matrixd/main.go +++ b/cmd/matrixd/main.go @@ -7,8 +7,12 @@ // GET /matrix/corpora — list known vectord indexes // POST /matrix/relevance — adjacency-pollution filter // POST /matrix/downgrade — strong-model downgrade gate -// POST /matrix/playbooks/record — record a (query → answer) +// POST /matrix/playbooks/record — record a single (query → answer) // success for the learning loop +// POST /matrix/playbooks/bulk — bulk-record N successes; useful +// for backfilling historical +// placement data into the +// playbook substrate // // matrixd talks to embedd (for query-text embedding) and vectord // (for per-corpus search) via HTTP. Both URLs come from @@ -66,6 +70,7 @@ func (h *handlers) register(r chi.Router) { r.Post("/matrix/relevance", h.handleRelevance) r.Post("/matrix/downgrade", h.handleDowngrade) r.Post("/matrix/playbooks/record", h.handlePlaybookRecord) + r.Post("/matrix/playbooks/bulk", h.handlePlaybookBulk) } func (h *handlers) handleSearch(w http.ResponseWriter, r *http.Request) { @@ -142,6 +147,67 @@ func (h *handlers) handlePlaybookRecord(w http.ResponseWriter, r *http.Request) }) } +// playbookBulkRequest is the POST /matrix/playbooks/bulk body — +// component C (operational rating wiring). Used to backfill +// historical placement data, or batch-record a session's worth of +// coordinator click-tracking. Each Entry is recorded independently; +// failures are reported per-entry without aborting the batch. +type playbookBulkRequest struct { + Entries []playbookRecordRequest `json:"entries"` + Corpus string `json:"corpus,omitempty"` // applies to all if entry-level not set +} + +// playbookBulkResult reports per-entry outcomes plus the aggregate +// count. Errors include the entry index so callers can locate the +// offending record without diffing. +type playbookBulkResult struct { + Recorded int `json:"recorded"` + Failed int `json:"failed"` + Results []playbookBulkItemResult `json:"results"` +} + +type playbookBulkItemResult struct { + Index int `json:"index"` + PlaybookID string `json:"playbook_id,omitempty"` + Error string `json:"error,omitempty"` +} + +func (h *handlers) handlePlaybookBulk(w http.ResponseWriter, r *http.Request) { + var req playbookBulkRequest + if !decodeJSON(w, r, &req) { + return + } + if len(req.Entries) == 0 { + http.Error(w, "entries must be non-empty", http.StatusBadRequest) + return + } + + out := playbookBulkResult{ + Results: make([]playbookBulkItemResult, len(req.Entries)), + } + for i, item := range req.Entries { + corpus := item.Corpus + if corpus == "" { + corpus = req.Corpus + } + entry := matrix.NewPlaybookEntry(item.QueryText, item.AnswerID, item.AnswerCorpus, item.Score, item.Tags) + if err := entry.Validate(); err != nil { + out.Results[i] = playbookBulkItemResult{Index: i, Error: err.Error()} + out.Failed++ + continue + } + pbID, err := h.r.Record(r.Context(), entry, corpus) + if err != nil { + out.Results[i] = playbookBulkItemResult{Index: i, Error: err.Error()} + out.Failed++ + continue + } + out.Results[i] = playbookBulkItemResult{Index: i, PlaybookID: pbID} + out.Recorded++ + } + writeJSON(w, http.StatusOK, out) +} + // downgradeRequest is the POST /matrix/downgrade body. Mirrors // matrix.DowngradeInput. When ForceFullOverride is omitted from // the body, the value falls back to matrixd's process env diff --git a/scripts/playbook_smoke.sh b/scripts/playbook_smoke.sh index ebf0ca9..a850da0 100755 --- a/scripts/playbook_smoke.sh +++ b/scripts/playbook_smoke.sh @@ -166,6 +166,29 @@ else echo " ✗ ratio out of band: $RATIO"; FAILED=1 fi +# ── 4. /matrix/playbooks/bulk — component C (operational rating wiring) +echo "[playbook-smoke] bulk record 3 entries:" +BULK_RESP="$(curl -sS -X POST http://127.0.0.1:3110/v1/matrix/playbooks/bulk \ + -H 'Content-Type: application/json' \ + -d "$(jq -n '{ + entries: [ + {query_text: "alpha test query", answer_id: "widget-a", answer_corpus: "widgets", score: 0.9}, + {query_text: "bravo test query", answer_id: "widget-b", answer_corpus: "widgets", score: 0.8}, + {query_text: "", answer_id: "x", answer_corpus: "widgets", score: 0.5} + ] + }')")" +RECORDED="$(echo "$BULK_RESP" | jq -r '.recorded')" +FAIL="$(echo "$BULK_RESP" | jq -r '.failed')" +GOT_PB_A="$(echo "$BULK_RESP" | jq -r '.results[0].playbook_id // empty')" +ERR_BAD="$(echo "$BULK_RESP" | jq -r '.results[2].error // empty')" +if [ "$RECORDED" = "2" ] && [ "$FAIL" = "1" ] && [ -n "$GOT_PB_A" ] && [ -n "$ERR_BAD" ]; then + echo " ✓ 2 recorded, 1 failed (empty query_text caught), per-entry IDs/errors returned" +else + echo " ✗ recorded=$RECORDED failed=$FAIL pb_a=$GOT_PB_A err=$ERR_BAD" + echo " full: $BULK_RESP" + FAILED=1 +fi + if [ "$FAILED" -eq 0 ]; then echo "[playbook-smoke] Playbook acceptance gate: PASSED" exit 0