A dismissed card stays dismissed, even offline
The server already suppressed every span she had accepted or dismissed, on
both the LLM reconcile and the mechanics pass, with tests either side. What
had no memory was the half that never asks it: item 3b's rule pack renders
250 ms after a keystroke with no network, and its record of "she already
answered this" was a set cleared on every document switch and added to only
for cards dismissed while still provisional.
So dismissing a persisted rule-pack card recorded nothing client-side and the
next keystroke put it straight back until the server's reply removed it again;
and after a reload the client knew nothing at all — permanently so with the
server unreachable, which is the case the rule pack exists for.
GET /docs/{id}/settled hands over the normalized originals of the document's
actioned rows, scoped through documents because an original quotes her
sentence. The client seeds a SettledSpans from it on open and adds to it for
every card that leaves, keyed on the original alone the way the server keys
it. The load adds rather than assigns, so a dismissal made while it is in
flight survives it.
normalizeForDedup now exists in both languages, compared across a network
boundary, so the same nine cases are asserted on both sides and each test
names the other.
Also: the status-bar count — "🌸 5片花瓣待打磨 · 5 petals to polish" beside the
word count, from the packs, hidden at zero. An empty rail already says nothing
is waiting; a badge announcing it after every check is a verdict, which the
review's non-goals rule out.
Verified in Chrome at 1517x810 with the server killed: a new violation was
detected, underlined and counted with no network, while the dismissed span
stayed gone.
This commit is contained in:
@@ -56,6 +56,7 @@ func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||
r.Post("/{id}/collocation", h.collocation)
|
||||
r.Post("/{id}/rewrite", h.rewrite)
|
||||
r.Get("/{id}/suggestions", h.listForDoc)
|
||||
r.Get("/{id}/settled", h.listSettled)
|
||||
}
|
||||
|
||||
// Routes returns the router mounted at /api/suggestions for per-suggestion
|
||||
@@ -567,6 +568,76 @@ func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// listSettled returns the normalized originals of every edit the user has
|
||||
// already accepted or dismissed on this document — the same spans buildSuppressor
|
||||
// drops on the server, handed to the client so its instant rule-pack pass can
|
||||
// drop them too.
|
||||
//
|
||||
// Without this the offline half of the loop has no memory. The rule pack detects
|
||||
// from the text alone and re-runs 250 ms after a keystroke, so a dismissed "the
|
||||
// the" comes straight back the moment she types anywhere in the document; the
|
||||
// server's reply then removes it again. That flicker is the visible symptom, but
|
||||
// the real one is worse: with the server unreachable — the case the rule pack
|
||||
// exists for — the reply never comes and a card she dismissed simply stays.
|
||||
//
|
||||
// Only the originals are sent. Replacements are the model's words, not hers, and
|
||||
// the client only needs to answer "has she settled this span?"
|
||||
func (h *Handler) listSettled(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.fetchSettled(auth.UserID(r.Context()), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
httputil.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, settledResponse{Originals: out})
|
||||
}
|
||||
|
||||
// settledResponse wraps the list so the endpoint can grow a second field without
|
||||
// breaking a client that reads a bare array.
|
||||
type settledResponse struct {
|
||||
Originals []string `json:"originals"`
|
||||
}
|
||||
|
||||
// fetchSettled loads the distinct normalized originals of the document's actioned
|
||||
// rows. Scoped through documents for the same reason fetchPending is: an original
|
||||
// is a quotation of her writing.
|
||||
func (h *Handler) fetchSettled(userID, docID string) ([]string, error) {
|
||||
rows, err := h.DB.Query(
|
||||
`SELECT DISTINCT s.original
|
||||
FROM suggestions s
|
||||
JOIN documents d ON d.id = s.doc_id
|
||||
WHERE s.doc_id = ? AND d.user_id = ? AND s.status IN (?, ?)`,
|
||||
docID, userID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// DISTINCT is on the raw text; normalizing can collapse two rows into one, so
|
||||
// dedupe again on this side to keep the payload honest.
|
||||
seen := map[string]struct{}{}
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var original string
|
||||
if err := rows.Scan(&original); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
norm := normalizeForDedup(original)
|
||||
if norm == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[norm]; dup {
|
||||
continue
|
||||
}
|
||||
seen[norm] = struct{}{}
|
||||
out = append(out, norm)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetchPending loads a document's pending suggestions, joined through documents
|
||||
// so the rows are only reachable by the document's owner. A suggestion quotes the
|
||||
// sentence it corrects, so an unscoped read here would leak document text to
|
||||
|
||||
Reference in New Issue
Block a user