textmachine/platform/internal/httpapi/reading.go

301 lines
11 KiB
Go

package httpapi
import (
"encoding/json"
"net/http"
"time"
"textmachine/platform/internal/ingest"
)
// reading.go: the chapter tree, its pairs, the book's notes and its memory bank.
//
// One rule shapes all four: a collection answers ONE page and the revision that page is a picture
// of, read in the same transaction. The client stamps a multi-page WALK with the lowest revision it
// saw and uses that as the watermark of its next delta read (canon §Revision) — server-side that
// means every page carries its own number and none of them carries a promise about the others.
type wireChapter struct {
ID string `json:"id"`
Number *int `json:"number"`
// Heading is the chapter's label as it comes from the DATA of the book, or null. A deployment
// whose parser does not extract labels answers null and MUST NOT put a rendered ordinal here.
Heading *string `json:"heading"`
UnitsTotal int `json:"units_total"`
UnitsDone int `json:"units_done"`
NoteCount int `json:"note_count"`
}
type wireChapterPage struct {
Revision int64 `json:"revision"`
NextCursor *string `json:"next_cursor"`
StructureVersion int `json:"structure_version"`
Chapters []wireChapter `json:"chapters"`
}
type wireUnit struct {
ID string `json:"id"`
Source string `json:"source"`
// Target is non-empty exactly when State is `translated`, the empty string otherwise — never
// absent, never null.
Target string `json:"target"`
State string `json:"state"`
Notes []wireNote `json:"notes"`
}
type wireUnitPage struct {
Revision int64 `json:"revision"`
NextCursor *string `json:"next_cursor"`
StructureVersion int `json:"structure_version"`
Units []wireUnit `json:"units"`
}
type wireNote struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Severity string `json:"severity"`
Code string `json:"code"`
ChapterID string `json:"chapter_id"`
// UnitID is optional and for one reason only: a note is about a pair, or about a whole chapter.
UnitID *string `json:"unit_id,omitempty"`
}
type wireNotePage struct {
Revision int64 `json:"revision"`
NextCursor *string `json:"next_cursor"`
StructureVersion int `json:"structure_version"`
Notes []wireNote `json:"notes"`
}
type wireTerm struct {
ID string `json:"id"`
Src string `json:"src"`
Dst string `json:"dst"`
Kind *string `json:"kind"`
Status string `json:"status"`
Origin string `json:"origin"`
Sense string `json:"sense"`
// The window is in chapter NUMBERS, which are not keys: it lives in the coordinates of the
// current structure_version. null means "no boundary".
SinceChapter *int `json:"since_chapter"`
UntilChapter *int `json:"until_chapter"`
}
// wireBankPage carries the whole-bank aggregates on the FIRST page only — any response to a request
// with no cursor, a delta read included. They are `omitempty` for that reason and for no other:
// absence here means "does not apply to this page", one of the two places on this surface where it
// does (canon §BankPage).
//
// ⚠ `pending_decisions` and `complete` are gone WITH the canon (0.5.0, PD-399): they counted for
// the abolished per-term model and had degraded into a live count of `proposed` rows. "How many are
// still undecided" is the engine's answer and rides the correction receipt (`signature`), not a
// read of this table.
type wireBankPage struct {
Revision int64 `json:"revision"`
NextCursor *string `json:"next_cursor"`
StructureVersion int `json:"structure_version"`
Total *int `json:"total,omitempty"`
Signed *int `json:"signed,omitempty"`
// ⛔ RAW, and not a `*wireConsolidation` with `omitempty`, because this member has TWO different
// absences and that tag can only spell one. On a later page it is ABSENT, like the counts above —
// the aggregates ride the first page only. On the FIRST page it is `null` when nothing has
// measured completeness, which the canon promises and a client must be able to tell from a bank
// that is whole. A nil pointer under `omitempty` collapses both into "no key", so the state the
// canon describes was unreachable and the comment that claimed otherwise was false by the tag one
// line below it.
Consolidation json.RawMessage `json:"consolidation,omitempty"`
Terms []wireTerm `json:"terms"`
}
type wireConsolidation struct {
Complete bool `json:"complete"`
RenderBatchesDropped int `json:"render_batches_dropped"`
ClassifyBatchesDropped int `json:"classify_batches_dropped"`
Consolidated int `json:"consolidated"`
Declined int `json:"declined"`
Unanswered int `json:"unanswered"`
NeverAsked int `json:"never_asked"`
}
// wireSigningStop is what the last signing stop asked about. `Unreadable` carries the fact an empty
// list cannot: without it "this deployment could not read the question" and "the stop asked nothing"
// are the same bytes, and a client would tell a person there is nothing to decide at the one moment
// the product stops to ask them.
type wireSigningStop struct {
Unreadable bool `json:"unreadable"`
Offered []wireOffered `json:"offered"`
}
type wireOffered struct {
Src string `json:"src"`
Dst string `json:"dst"`
Kind *string `json:"kind"`
Channel *string `json:"channel"`
Freq *int `json:"freq"`
Spread *int `json:"spread"`
Conventions *int `json:"conventions"`
Confidence *int `json:"confidence"`
Invented bool `json:"invented"`
Contradicts []string `json:"contradicts"`
BankHolds []string `json:"bank_holds"`
Variants []string `json:"variants"`
}
func (h *v0) listChapters(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
page, err := h.lib.ListChapters(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r), r.URL.Query().Get("cursor"))
if err != nil {
h.fail(w, r, err)
return
}
out := wireChapterPage{
Revision: page.Revision, StructureVersion: page.StructureVersion,
Chapters: make([]wireChapter, 0, len(page.Chapters)),
}
if page.NextCursor != "" {
out.NextCursor = &page.NextCursor
}
for _, c := range page.Chapters {
out.Chapters = append(out.Chapters, projectChapter(c))
}
h.writeJSON(w, r, http.StatusOK, out)
}
// listUnits answers the pairs of ONE chapter.
//
// Per chapter and only per chapter: a book-wide pairs endpoint is never introduced, and the client's
// whole memory model stands on that (canon §listUnits). A chapter that existed and no longer does is
// `410`, so a client re-reads the tree rather than checking the address.
func (h *v0) listUnits(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
page, err := h.lib.ListUnits(r.Context(), user, r.PathValue("bookId"), r.PathValue("chapterId"),
h.pageLimit(r), r.URL.Query().Get("cursor"))
if err != nil {
h.fail(w, r, err)
return
}
out := wireUnitPage{
Revision: page.Revision, StructureVersion: page.StructureVersion,
Units: make([]wireUnit, 0, len(page.Units)),
}
if page.NextCursor != "" {
out.NextCursor = &page.NextCursor
}
for _, u := range page.Units {
out.Units = append(out.Units, projectUnit(u))
}
h.writeJSON(w, r, http.StatusOK, out)
}
func (h *v0) listNotes(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
after, ok := h.afterVersion(w, r)
if !ok {
return
}
page, err := h.lib.ListNotes(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r),
r.URL.Query().Get("cursor"), after)
if err != nil {
h.fail(w, r, err)
return
}
out := wireNotePage{
Revision: page.Revision, StructureVersion: page.StructureVersion,
Notes: make([]wireNote, 0, len(page.Notes)),
}
if page.NextCursor != "" {
out.NextCursor = &page.NextCursor
}
unnamed := 0
for _, n := range page.Notes {
note := projectNote(n)
if note.Code == ingest.NoteCodeUnspecified {
unnamed++
}
out.Notes = append(out.Notes, note)
}
if unnamed > 0 {
// A flag reason the contract's map does not name. Loud, because the answer is a line in that
// map rather than a change here — and silent, it would look like a note the engine produced
// without a reason.
h.log.ErrorContext(r.Context(), "notes carry a flag reason this build cannot name; the contract's map needs a line",
"notes", unnamed)
}
h.writeJSON(w, r, http.StatusOK, out)
}
// listBank answers the memory bank, and it is also the STATE of a signing stop — informationally,
// never as a gate. Signing is ONE act over the whole bank: `resume` lifts the stop with the
// decisions as they stand, and no counter here decides whether continuing may be offered (D39.144).
func (h *v0) listBank(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
after, ok := h.afterVersion(w, r)
if !ok {
return
}
page, err := h.lib.ListBank(r.Context(), user, r.PathValue("bookId"), h.pageLimit(r),
r.URL.Query().Get("cursor"), after)
if err != nil {
h.fail(w, r, err)
return
}
out := wireBankPage{
Revision: page.Revision, StructureVersion: page.StructureVersion,
Terms: make([]wireTerm, 0, len(page.Terms)),
}
if page.NextCursor != "" {
out.NextCursor = &page.NextCursor
}
if page.First {
c := page.Counts
out.Total, out.Signed = &c.Total, &c.Signed
// Set on the first page WHATEVER the answer is, so that `null` reaches the client; the raw
// member stays nil on later pages and is then omitted.
out.Consolidation = projectConsolidation(page.Consolidation)
}
for _, t := range page.Terms {
out.Terms = append(out.Terms, projectTerm(t))
}
h.writeJSON(w, r, http.StatusOK, out)
}
// readBankSigningStop answers what the LAST signing stop asked about — the one screen in the product
// where a person is asked for a decision (engine backlog row 224).
//
// It does not say whether a stop is standing: that is the run's status, and the past tense is the same
// one the correction receipt already uses about this population. Uncursored and undeltaed, because a
// term offered here has no id to name in a delta and the stop's ranking is the information.
func (h *v0) readBankSigningStop(w http.ResponseWriter, r *http.Request) {
user, ok := principal(w, r)
if !ok {
return
}
stop, err := h.lib.BankSigningStop(r.Context(), user, r.PathValue("bookId"))
if err != nil {
h.fail(w, r, err)
return
}
out := wireSigningStop{Unreadable: stop.Unreadable, Offered: make([]wireOffered, 0, len(stop.Offered))}
for _, t := range stop.Offered {
out.Offered = append(out.Offered, projectOfferedTerm(t))
}
h.writeJSON(w, r, http.StatusOK, out)
}
// The write half of the bank lives at `POST /books/{bookId}/bank/corrections` (bank.go): the
// per-term act is a CORRECTION applied by the engine's own verb, never a signature — signing stayed
// ONE act over the whole bank, `resume` (D39.144, D39.156). What stood here before it — the
// abolished per-term decisions route — went on the owner's word of 22.08; the `bank_decisions`
// table it fed is dead and nothing here reads it.