Merge feat/mechanics-deterministic-pass: deterministic mechanics suggestion family
Reuse the companion prose.ts rules as the single deterministic detector; applyable rules feed 'mechanics' suggestion cards via a persist-only endpoint, awareness rules stay companion bubbles. Mechanics wins span collisions with LLM cards. Migration 0008 adds the type. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -336,6 +336,35 @@ CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
|
|||||||
name: "0007_vocab_definition",
|
name: "0007_vocab_definition",
|
||||||
stmt: `
|
stmt: `
|
||||||
ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT '';
|
ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT '';
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Deterministic mechanics pass. Adds a 'mechanics' suggestion family for
|
||||||
|
// rule-based fixes (doubled words, spacing/punctuation, lowercase "i",
|
||||||
|
// curated confusables) detected in pure Go — no LLM. As with 0005, the
|
||||||
|
// `type` CHECK can't be ALTERed in place, so rebuild the table with the
|
||||||
|
// extended constraint, copy every row across, and recreate the index.
|
||||||
|
name: "0008_mechanics_suggestion_type",
|
||||||
|
stmt: `
|
||||||
|
CREATE TABLE suggestions_new (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
from_pos INTEGER NOT NULL,
|
||||||
|
to_pos INTEGER NOT NULL,
|
||||||
|
original TEXT NOT NULL,
|
||||||
|
replacement TEXT NOT NULL,
|
||||||
|
explanation TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice','collocation','mechanics')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
||||||
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
||||||
|
|
||||||
|
DROP TABLE suggestions;
|
||||||
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
||||||
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const (
|
|||||||
SuggestionTypeClarity = "clarity"
|
SuggestionTypeClarity = "clarity"
|
||||||
SuggestionTypeVoice = "voice"
|
SuggestionTypeVoice = "voice"
|
||||||
SuggestionTypeCollocation = "collocation"
|
SuggestionTypeCollocation = "collocation"
|
||||||
|
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
|
||||||
|
|
||||||
SuggestionStatusPending = "pending"
|
SuggestionStatusPending = "pending"
|
||||||
SuggestionStatusAccepted = "accepted"
|
SuggestionStatusAccepted = "accepted"
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ package suggestions
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ func New(database *db.DB, client llm.LLMClient) *Handler {
|
|||||||
// the existing /api/docs router so they share its base path.
|
// the existing /api/docs router so they share its base path.
|
||||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||||
r.Post("/{id}/check", h.check)
|
r.Post("/{id}/check", h.check)
|
||||||
|
r.Post("/{id}/mechanics", h.mechanics)
|
||||||
r.Post("/{id}/voice", h.voice)
|
r.Post("/{id}/voice", h.voice)
|
||||||
r.Post("/{id}/collocation", h.collocation)
|
r.Post("/{id}/collocation", h.collocation)
|
||||||
r.Post("/{id}/rewrite", h.rewrite)
|
r.Post("/{id}/rewrite", h.rewrite)
|
||||||
@@ -64,10 +67,118 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
||||||
|
// The deterministic mechanics family is owned by a separate pass (see mechanics),
|
||||||
|
// detected client-side; a grammar checkpoint leaves those flags untouched.
|
||||||
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
||||||
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mechanicsFinding is one deterministic, rule-based fix detected client-side (see
|
||||||
|
// web Companion/prose.ts). Detection lives in the frontend — the same rules that
|
||||||
|
// power the companion's prose notes — so the server only persists these; it does
|
||||||
|
// not compute them. Offsets are exact plaintext spans from the detector.
|
||||||
|
type mechanicsFinding struct {
|
||||||
|
From int `json:"from"`
|
||||||
|
To int `json:"to"`
|
||||||
|
Original string `json:"original"`
|
||||||
|
Replacement string `json:"replacement"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxMechanicsFindings caps a single submission so a runaway client can't flood
|
||||||
|
// the table; far above any realistic count for one document.
|
||||||
|
const maxMechanicsFindings = 500
|
||||||
|
|
||||||
|
// mechanics persists the client-detected deterministic fixes as the 'mechanics'
|
||||||
|
// family and returns the document's unified pending set. Free and not rate-
|
||||||
|
// limited — it runs alongside the grammar checkpoint. It mirrors a single LLM
|
||||||
|
// family: it replaces only the pending mechanics rows, honours actioned-
|
||||||
|
// suppression, and (unlike the LLM passes) keeps the detector's exact offsets
|
||||||
|
// rather than re-locating by string, which matters when the same word repeats.
|
||||||
|
func (h *Handler) mechanics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Confirm the document exists (and is the local user's) for clean 404s.
|
||||||
|
var exists bool
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM documents WHERE id = ? AND user_id = ?)`,
|
||||||
|
docID, db.LocalUserID,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
httputil.ErrorJSON(w, http.StatusNotFound, "document not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Findings []mechanicsFinding `json:"findings"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
|
httputil.BadRequest(w, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Findings) > maxMechanicsFindings {
|
||||||
|
body.Findings = body.Findings[:maxMechanicsFindings]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.replaceMechanics(docID, body.Findings); err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := h.fetchPending(docID)
|
||||||
|
if err != nil {
|
||||||
|
httputil.ServerError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httputil.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceMechanics swaps the document's pending mechanics rows for the supplied
|
||||||
|
// findings in one transaction, leaving the LLM families and actioned rows
|
||||||
|
// untouched. Findings the user already accepted or dismissed are suppressed (the
|
||||||
|
// detector has no memory between runs), and malformed spans are skipped.
|
||||||
|
func (h *Handler) replaceMechanics(docID string, findings []mechanicsFinding) error {
|
||||||
|
tx, err := h.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND type = ?`,
|
||||||
|
docID, db.SuggestionStatusPending, db.SuggestionTypeMechanics,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
actioned, err := actionedKeys(tx, docID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.From < 0 || f.To <= f.From || strings.TrimSpace(f.Original) == "" {
|
||||||
|
continue // malformed span — the client re-anchors by string anyway
|
||||||
|
}
|
||||||
|
if _, seen := actioned[suggestionKey(f.Original, f.Replacement)]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
docID, f.From, f.To, f.Original, f.Replacement, f.Explanation, db.SuggestionTypeMechanics,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
||||||
// explicit-action pass; replaces only the pending voice flags.
|
// explicit-action pass; replaces only the pending voice flags.
|
||||||
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -163,9 +274,11 @@ type pendingScope struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
|
// grammarScope owns the grammar/phrasing/idiom/clarity flags — everything but
|
||||||
// the explicit-action families (voice, collocation), which run on their own
|
// the other self-owned families (voice, collocation, mechanics), which run on
|
||||||
// cadence and must survive a grammar checkpoint.
|
// their own cadence/pass and must survive a grammar checkpoint. Notably the
|
||||||
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation')", forceType: ""}
|
// deterministic mechanics pass writes its rows in the same /check request just
|
||||||
|
// before this DELETE runs, so excluding it here is what keeps them alive.
|
||||||
|
grammarScope = pendingScope{deleteWhere: "type NOT IN ('voice','collocation','mechanics')", forceType: ""}
|
||||||
// voiceScope owns the voice flags only.
|
// voiceScope owns the voice flags only.
|
||||||
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
||||||
// collocationScope owns the collocation flags only.
|
// collocationScope owns the collocation flags only.
|
||||||
@@ -291,7 +404,51 @@ func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
|||||||
}
|
}
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dedupeSpans(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeSpans resolves collisions between the deterministic mechanics family and
|
||||||
|
// the LLM families: when a mechanics finding and an LLM suggestion fight over the
|
||||||
|
// same characters, mechanics wins and the LLM card is dropped. Its span is exact
|
||||||
|
// (the detector matched it), whereas the LLM positions are only advisory
|
||||||
|
// (re-anchored by string at render), so the precise fix should own the span.
|
||||||
|
//
|
||||||
|
// This deliberately does NOT dedupe LLM-vs-LLM overlaps: voice (awareness-only,
|
||||||
|
// no replacement) and collocation legitimately co-occupy the same span, and that
|
||||||
|
// is intended. Suggestions that never anchored (from_pos < 0) occupy no real span
|
||||||
|
// and are always kept.
|
||||||
|
func dedupeSpans(in []db.Suggestion) []db.Suggestion {
|
||||||
|
type span struct{ from, to int }
|
||||||
|
var claimed []span
|
||||||
|
for _, s := range in {
|
||||||
|
if s.Type == db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||||
|
claimed = append(claimed, span{s.FromPos, s.ToPos})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(claimed) == 0 {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]db.Suggestion, 0, len(in))
|
||||||
|
for _, s := range in {
|
||||||
|
if s.Type != db.SuggestionTypeMechanics && s.FromPos >= 0 {
|
||||||
|
overlaps := false
|
||||||
|
for _, sp := range claimed {
|
||||||
|
if s.FromPos < sp.to && sp.from < s.ToPos {
|
||||||
|
overlaps = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if overlaps {
|
||||||
|
continue // an exact mechanics fix owns these characters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// accept marks a suggestion accepted (the client applies the replacement text).
|
// accept marks a suggestion accepted (the client applies the replacement text).
|
||||||
|
|||||||
@@ -316,3 +316,116 @@ func TestCheckpointRateLimit(t *testing.T) {
|
|||||||
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
|
t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postMechanics submits a deterministic-findings batch (as the client's prose
|
||||||
|
// detector would) and returns the unified pending set.
|
||||||
|
func postMechanics(t *testing.T, srv http.Handler, docID, findingsJSON string) []db.Suggestion {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/mechanics", `{"findings":`+findingsJSON+`}`)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mechanics: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMechanicsPersistsFindings proves the endpoint persists client-detected
|
||||||
|
// findings as a 'mechanics' family with the exact offsets the client supplied,
|
||||||
|
// and that a later grammar checkpoint leaves them untouched (own family).
|
||||||
|
func TestMechanicsPersistsFindings(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
got := postMechanics(t, srv, docID, `[
|
||||||
|
{"from":0,"to":1,"original":"i","replacement":"I","explanation":"capitalize I"},
|
||||||
|
{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}
|
||||||
|
]`)
|
||||||
|
var mech []db.Suggestion
|
||||||
|
for _, s := range got {
|
||||||
|
if s.Type == db.SuggestionTypeMechanics {
|
||||||
|
mech = append(mech, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mech) != 2 {
|
||||||
|
t.Fatalf("want 2 mechanics findings, got %d: %+v", len(mech), got)
|
||||||
|
}
|
||||||
|
// The client's exact offsets are preserved verbatim.
|
||||||
|
for _, s := range mech {
|
||||||
|
if s.Original == "the the" && (s.FromPos != 6 || s.ToPos != 13) {
|
||||||
|
t.Errorf("offsets not preserved: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grammar checkpoint must not wipe the mechanics family.
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var after []db.Suggestion
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &after)
|
||||||
|
count := 0
|
||||||
|
for _, s := range after {
|
||||||
|
if s.Type == db.SuggestionTypeMechanics {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("grammar checkpoint disturbed the mechanics family: got %d, want 2", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMechanicsWinsSpanCollision proves that when a mechanics finding and an LLM
|
||||||
|
// grammar suggestion claim overlapping characters, the LLM card is dropped from
|
||||||
|
// the response and the exact mechanics fix owns the span.
|
||||||
|
func TestMechanicsWinsSpanCollision(t *testing.T) {
|
||||||
|
// LLM grammar suggestion covers "the the cat", overlapping the mechanics
|
||||||
|
// "the the" finding at [0,7].
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"the the cat","replacement":"the cat","explanation":"wordy","type":"grammar"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
if _, err := h.DB.Exec(`UPDATE documents SET content_text = ? WHERE id = ?`, "the the cat sat", docID); err != nil {
|
||||||
|
t.Fatalf("set content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the mechanics finding, then run the grammar pass.
|
||||||
|
postMechanics(t, srv, docID, `[{"from":0,"to":7,"original":"the the","replacement":"the","explanation":"doubled word"}]`)
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
if s.Type == db.SuggestionTypeGrammar {
|
||||||
|
t.Fatalf("overlapping grammar card should have been dropped in favor of mechanics, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Type != db.SuggestionTypeMechanics {
|
||||||
|
t.Fatalf("want sole mechanics finding, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMechanicsActionedSuppression proves a dismissed mechanics fix is not
|
||||||
|
// re-proposed on the next submission of the same finding.
|
||||||
|
func TestMechanicsActionedSuppression(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[]}`}
|
||||||
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
|
findings := `[{"from":6,"to":13,"original":"the the","replacement":"the","explanation":"doubled word"}]`
|
||||||
|
got := postMechanics(t, srv, docID, findings)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("want 1 finding, got %+v", got)
|
||||||
|
}
|
||||||
|
// Dismiss it, then resubmit the same finding — it must stay suppressed.
|
||||||
|
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/dismiss", "")
|
||||||
|
again := postMechanics(t, srv, docID, findings)
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("dismissed mechanics fix should not reappear, got %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export default function App() {
|
|||||||
if (currentDoc) {
|
if (currentDoc) {
|
||||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||||
schedule(change)
|
schedule(change)
|
||||||
scheduleCheckpoint()
|
scheduleCheckpoint(change.content_text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export interface Gloss {
|
|||||||
gloss: string
|
gloss: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation'
|
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation' | 'mechanics'
|
||||||
|
|
||||||
// One word in the vocabulary garden: a looked-up word with its gloss/phonetic,
|
// One word in the vocabulary garden: a looked-up word with its gloss/phonetic,
|
||||||
// the sentence it was met in, and its spaced-repetition state. `reps` drives how
|
// the sentence it was met in, and its spaced-repetition state. `reps` drives how
|
||||||
@@ -135,6 +135,17 @@ export interface Suggestion {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deterministic, rule-based fix detected client-side (see Companion/prose.ts).
|
||||||
|
// The frontend owns mechanics detection; the backend only persists these as the
|
||||||
|
// 'mechanics' suggestion family. Spans are exact plaintext offsets.
|
||||||
|
export interface MechanicsFinding {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
original: string
|
||||||
|
replacement: string
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -168,6 +179,14 @@ export const api = {
|
|||||||
// word pairings ("do a decision" → "make a decision"). Returns the unified
|
// word pairings ("do a decision" → "make a decision"). Returns the unified
|
||||||
// pending set too. Rate-limited per document server-side.
|
// pending set too. Rate-limited per document server-side.
|
||||||
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }),
|
collocationDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }),
|
||||||
|
// Mechanics pass: persist the client-detected deterministic fixes as the
|
||||||
|
// 'mechanics' family and return the unified pending set. Not rate-limited (it's
|
||||||
|
// free, local detection); runs alongside the grammar checkpoint.
|
||||||
|
submitMechanics: (id: string, findings: MechanicsFinding[]) =>
|
||||||
|
req<Suggestion[]>(`/docs/${id}/mechanics`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ findings }),
|
||||||
|
}),
|
||||||
// Pending suggestions for a doc, loaded when the editor opens it.
|
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||||
acceptSuggestion: (id: string) =>
|
acceptSuggestion: (id: string) =>
|
||||||
|
|||||||
@@ -248,3 +248,119 @@ describe('capitalization basics', () => {
|
|||||||
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence')
|
expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
import { mechanicsFindings } from './prose'
|
||||||
|
|
||||||
|
// mechanicsFindings is the applyable subset feeding the suggestion cards. Each
|
||||||
|
// finding must carry an EXACT span (text.slice(from,to) === original) and a
|
||||||
|
// replacement that actually changes the text — these become one-click edits.
|
||||||
|
describe('mechanicsFindings — applyable fixes', () => {
|
||||||
|
// Every finding's span must be exact, regardless of which rule produced it.
|
||||||
|
function expectExactSpans(text: string) {
|
||||||
|
const found = mechanicsFindings(text)
|
||||||
|
for (const f of found) {
|
||||||
|
expect(text.slice(f.from, f.to), `span for ${JSON.stringify(f)}`).toBe(f.original)
|
||||||
|
expect(f.replacement).not.toBe(f.original)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
const one = (text: string, rulePred: (f: ReturnType<typeof mechanicsFindings>[number]) => boolean) =>
|
||||||
|
expectExactSpans(text).find(rulePred)
|
||||||
|
|
||||||
|
it('doubled word → single word, exact span', () => {
|
||||||
|
const f = one('I saw the the cat in the garden today.', (f) => f.original === 'the the')
|
||||||
|
expect(f?.replacement).toBe('the')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds BOTH doublings with distinct spans', () => {
|
||||||
|
const found = mechanicsFindings('the the dog and the the cat ran around the yard.')
|
||||||
|
.filter((f) => f.original === 'the the')
|
||||||
|
expect(found).toHaveLength(2)
|
||||||
|
expect(found[0].from).not.toBe(found[1].from)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Spans are widened to a distinctive phrase so they re-anchor by string in the
|
||||||
|
// editor (a bare "a"/"i"/" is" would match the wrong spot). The replacement
|
||||||
|
// carries the whole corrected phrase.
|
||||||
|
|
||||||
|
it('lowercase standalone i is awareness-only (no card)', () => {
|
||||||
|
// It still flags as a companion bubble (see analyzeProse), but a single
|
||||||
|
// character can't be anchored, so it must NOT become a card.
|
||||||
|
expect(mechanicsFindings('Yesterday i walked to the store and came home.').length).toBe(0)
|
||||||
|
expect(rulesFor('Yesterday i walked to the store and came home.')).toContain('cap-i')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a → an: spans the whole "a <noun>" phrase', () => {
|
||||||
|
const f = one('She found a umbrella under the old wooden table.', (f) => f.original === 'a umbrella')
|
||||||
|
expect(f?.replacement).toBe('an umbrella')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uncountable plural → singular', () => {
|
||||||
|
const f = one('She gave me many informations about the new job.', (f) => f.original === 'informations')
|
||||||
|
expect(f?.replacement).toBe('information')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lowercase proper noun → capitalized', () => {
|
||||||
|
const f = one('I am learning english during my free time this year.', (f) => f.original === 'english')
|
||||||
|
expect(f?.replacement).toBe('English')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('subject-verb agreement → corrects the verb, spans subject+verb', () => {
|
||||||
|
const f = one('She have three cats and a dog at her house.', (f) => f.original === 'She have')
|
||||||
|
expect(f?.replacement).toBe('She has')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('singular noun after a number → plural, spans number+noun', () => {
|
||||||
|
const f = one('I bought five apple at the market this morning.', (f) => f.original === 'five apple')
|
||||||
|
expect(f?.replacement).toBe('five apples')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('comparative + then → than, spans the phrase', () => {
|
||||||
|
const f = one('This book is better then the one I read last week.', (f) => f.original === 'better then')
|
||||||
|
expect(f?.replacement).toBe('better than')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('space before punctuation is removed (spans the word)', () => {
|
||||||
|
const f = one('I love it , it makes me very happy indeed.', (f) => f.original === 'it ,')
|
||||||
|
expect(f?.replacement).toBe('it,')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('missing space after a comma is added (spans both words)', () => {
|
||||||
|
const f = one('I bought apples,bananas and pears at the store.', (f) => f.original === 'apples,bananas')
|
||||||
|
expect(f?.replacement).toBe('apples, bananas')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stacked determiner drops the article', () => {
|
||||||
|
const f = one('She left the my book on the kitchen table again.', (f) => f.original === 'the my')
|
||||||
|
expect(f?.replacement).toBe('my')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('there is + plural → there are, spans the phrase', () => {
|
||||||
|
const f = one('There is many people waiting outside in the cold.', (f) => f.original === 'There is many')
|
||||||
|
expect(f?.replacement).toBe('There are many')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("it's own → its own, spans the phrase", () => {
|
||||||
|
const f = one("The cat licked it's own paw very slowly today.", (f) => f.original === "it's own")
|
||||||
|
expect(f?.replacement).toBe('its own')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("its a → it's a, spans the phrase", () => {
|
||||||
|
const f = one('I think its a wonderful day to go outside now.', (f) => f.original === 'its a')
|
||||||
|
expect(f?.replacement).toBe("it's a")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Awareness-only rules never produce cards — they stay companion bubbles.
|
||||||
|
it('run-ons and splices produce NO card findings', () => {
|
||||||
|
const runOn = 'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.'
|
||||||
|
expect(mechanicsFindings(runOn).some((f) => f.original.length > 30)).toBe(false)
|
||||||
|
const splice = 'I finished the report, I sent it to my boss right away today.'
|
||||||
|
// no card spans the comma-join; only (if any) small word-level fixes
|
||||||
|
expect(mechanicsFindings(splice).every((f) => f.to - f.from < 12)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clean prose yields no findings', () => {
|
||||||
|
expect(mechanicsFindings('The garden was quiet and the rain fell softly on the leaves.')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
||||||
// a short slice of her own sentence so the advice clearly belongs to *this*
|
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||||||
// paragraph and not a generic tip jar.
|
// paragraph and not a generic tip jar.
|
||||||
|
//
|
||||||
|
// Two consumers share these rules (one source of truth, no duplication):
|
||||||
|
// • the companion bubbles — awareness notes, shown one at a time (analyzeProse)
|
||||||
|
// • the suggestion cards — applyable one-click fixes (mechanicsFindings)
|
||||||
|
// A rule is "applyable" when it can name an exact span and a single replacement;
|
||||||
|
// it attaches a `fix` and feeds the cards. Awareness-only rules (run-ons, comma
|
||||||
|
// splices, …) carry no `fix` and stay companion bubbles. The companion hides
|
||||||
|
// fix-bearing hints so the same span never appears as both a bubble and a card.
|
||||||
|
|
||||||
import type { Line } from './tips'
|
import type { Line } from './tips'
|
||||||
|
|
||||||
@@ -17,6 +25,28 @@ export interface ProseHint extends Line {
|
|||||||
id: string
|
id: string
|
||||||
// Rule family, used to vary advice (don't show the same kind twice in a row).
|
// Rule family, used to vary advice (don't show the same kind twice in a row).
|
||||||
rule: string
|
rule: string
|
||||||
|
// Present only on *applyable* findings: the exact span and the text to swap in.
|
||||||
|
// These become suggestion cards; the companion skips them (see useCompanion).
|
||||||
|
fix?: Fix
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exact, one-click edit: replace text[from:to] with `replacement`.
|
||||||
|
export interface Fix {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
replacement: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deterministic suggestion-card finding, derived from an applyable hint. Mirrors
|
||||||
|
// the backend's card shape (original/replacement/explanation + span) so the card
|
||||||
|
// pipeline can persist it as the 'mechanics' family. `explanation` is the English
|
||||||
|
// note; the card's own translate action renders Mandarin on demand.
|
||||||
|
export interface MechanicsFinding {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
original: string
|
||||||
|
replacement: string
|
||||||
|
explanation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── small text helpers ──────────────────────────────────────────────────────
|
// ── small text helpers ──────────────────────────────────────────────────────
|
||||||
@@ -48,11 +78,23 @@ function key(s: string): string {
|
|||||||
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
|
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capitalize `repl`'s first letter when `original` started uppercase, so fixing a
|
||||||
|
// sentence-initial word doesn't quietly lowercase the line.
|
||||||
|
function matchCase(original: string, repl: string): string {
|
||||||
|
if (!original || !repl) return repl
|
||||||
|
const c = original[0]
|
||||||
|
if (c >= 'A' && c <= 'Z') return repl[0].toUpperCase() + repl.slice(1)
|
||||||
|
return repl
|
||||||
|
}
|
||||||
|
|
||||||
// ── individual rules ────────────────────────────────────────────────────────
|
// ── individual rules ────────────────────────────────────────────────────────
|
||||||
// Each rule pushes at most a couple of findings; the caller shows one at a time.
|
// Each rule pushes its findings; awareness rules cap themselves to a couple so a
|
||||||
|
// single sentence can't flood the bubble, while applyable rules report every
|
||||||
|
// occurrence (each becomes its own card). Applyable rules attach a `fix`.
|
||||||
|
|
||||||
// Run-on / overly long sentences — the single most common readability problem
|
// Run-on / overly long sentences — the single most common readability problem
|
||||||
// for ESL writers, who often chain clauses that a period would serve better.
|
// for ESL writers, who often chain clauses that a period would serve better.
|
||||||
|
// Awareness-only: there is no single mechanical fix for "split this sentence".
|
||||||
function runOns(text: string, out: ProseHint[]) {
|
function runOns(text: string, out: ProseHint[]) {
|
||||||
let found = 0
|
let found = 0
|
||||||
for (const s of sentences(text)) {
|
for (const s of sentences(text)) {
|
||||||
@@ -110,6 +152,7 @@ const INTRO_LEAD = new Set([
|
|||||||
// approximate that by requiring the lead to be ≥3 words and not begin with an
|
// approximate that by requiring the lead to be ≥3 words and not begin with an
|
||||||
// introductory word — short or transition-led leads are intro phrases, not
|
// introductory word — short or transition-led leads are intro phrases, not
|
||||||
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
|
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
|
||||||
|
// Awareness-only: the fix is ambiguous (period vs. a joining word).
|
||||||
function commaSplices(text: string, out: ProseHint[]) {
|
function commaSplices(text: string, out: ProseHint[]) {
|
||||||
let found = 0
|
let found = 0
|
||||||
for (const s of sentences(text)) {
|
for (const s of sentences(text)) {
|
||||||
@@ -137,6 +180,7 @@ function commaSplices(text: string, out: ProseHint[]) {
|
|||||||
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
|
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
|
||||||
// straight into a verb, with no noun naming what it points back to. Only flag
|
// straight into a verb, with no noun naming what it points back to. Only flag
|
||||||
// when there's a prior sentence (so an antecedent is actually in question).
|
// when there's a prior sentence (so an antecedent is actually in question).
|
||||||
|
// Awareness-only: naming the referent needs the writer.
|
||||||
const ANTECEDENT_RE =
|
const ANTECEDENT_RE =
|
||||||
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
|
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
|
||||||
|
|
||||||
@@ -160,7 +204,8 @@ function antecedents(text: string, out: ProseHint[]) {
|
|||||||
|
|
||||||
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
|
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
|
||||||
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
|
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
|
||||||
// compound clause (“…, but she and I…”) for a list.
|
// compound clause (“…, but she and I…”) for a list. Awareness-only: inserting
|
||||||
|
// the serial comma is borderline-stylistic, so we nudge rather than auto-edit.
|
||||||
const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g
|
const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g
|
||||||
const CLAUSE_STARTERS = new Set([
|
const CLAUSE_STARTERS = new Set([
|
||||||
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
|
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
|
||||||
@@ -187,81 +232,32 @@ function oxford(text: string, out: ProseHint[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
// A transition word opening a sentence with no comma after it (“However we…”).
|
||||||
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
||||||
const A_BEFORE_VOWEL_RE = /\ba\s+([aeiou][a-z]+)\b/g
|
// like “Then/First/Finally” are left out (their comma is optional). Awareness-
|
||||||
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
// only: it fires per-sentence (offsets are sentence-relative), so we nudge.
|
||||||
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
const INTRO_RE =
|
||||||
const AN_BEFORE_CONSONANT_RE = /\ban\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
||||||
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
|
||||||
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
|
||||||
|
|
||||||
function articles(text: string, out: ProseHint[]) {
|
function introComma(text: string, out: ProseHint[]) {
|
||||||
let m: RegExpExecArray | null
|
for (const s of sentences(text)) {
|
||||||
let found = 0
|
const m = s.match(INTRO_RE)
|
||||||
A_BEFORE_VOWEL_RE.lastIndex = 0
|
if (m) {
|
||||||
while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) {
|
out.push({
|
||||||
if (CONSONANT_SOUND_RE.test(m[1])) continue
|
id: `introcomma:${m[1].toLowerCase()}`,
|
||||||
found++
|
rule: 'introcomma',
|
||||||
out.push({
|
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||||||
id: `article-an:${key(m[1])}`,
|
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||||||
rule: 'article',
|
})
|
||||||
zh: `元音开头的词前用 “an”:“an ${m[1]}”。`,
|
break
|
||||||
en: `Before a vowel sound, use “an”: “an ${m[1]}”.`,
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
|
||||||
while ((m = AN_BEFORE_CONSONANT_RE.exec(text)) && found < 2) {
|
|
||||||
if (VOWEL_SOUND_RE.test(m[1])) continue
|
|
||||||
found++
|
|
||||||
out.push({
|
|
||||||
id: `article-a:${key(m[1])}`,
|
|
||||||
rule: 'article',
|
|
||||||
zh: `辅音开头的词前用 “a”:“a ${m[1]}”。`,
|
|
||||||
en: `Before a consonant sound, use “a”: “a ${m[1]}”.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
|
||||||
// be legitimate (“the fact that that happened”, “she had had enough”).
|
|
||||||
const DOUBLE_RE = /\b([A-Za-z]+)\s+\1\b/gi
|
|
||||||
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
|
||||||
|
|
||||||
function doubles(text: string, out: ProseHint[]) {
|
|
||||||
let m: RegExpExecArray | null
|
|
||||||
let found = 0
|
|
||||||
DOUBLE_RE.lastIndex = 0
|
|
||||||
while ((m = DOUBLE_RE.exec(text)) && found < 1) {
|
|
||||||
const w = m[1].toLowerCase()
|
|
||||||
if (LEGIT_DOUBLES.has(w)) continue
|
|
||||||
found++
|
|
||||||
out.push({
|
|
||||||
id: `double:${key(m[0])}`,
|
|
||||||
rule: 'double',
|
|
||||||
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
|
||||||
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
|
||||||
// edge, to avoid tangling with “i.e.” and the like.
|
|
||||||
const LOWER_I_RE = /(?:^|\s)i(?=\s|$)/m
|
|
||||||
|
|
||||||
function pronounI(text: string, out: ProseHint[]) {
|
|
||||||
if (LOWER_I_RE.test(text)) {
|
|
||||||
out.push({
|
|
||||||
id: 'cap-i',
|
|
||||||
rule: 'cap-i',
|
|
||||||
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
|
||||||
en: 'In English, “I” is always written as a capital letter.',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sentence not starting with a capital. We look after a terminator, and ignore
|
// Sentence not starting with a capital. We look after a terminator, and ignore
|
||||||
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
|
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
|
||||||
|
// Awareness-only: a bare “. x” also matches abbreviations (“U.S. then”), so we
|
||||||
|
// nudge rather than auto-capitalize.
|
||||||
const LOWER_START_RE = /[.!?]\s+([a-z])/
|
const LOWER_START_RE = /[.!?]\s+([a-z])/
|
||||||
|
|
||||||
function sentenceCaps(text: string, out: ProseHint[]) {
|
function sentenceCaps(text: string, out: ProseHint[]) {
|
||||||
@@ -275,40 +271,148 @@ function sentenceCaps(text: string, out: ProseHint[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
// ── applyable rules (also feed the suggestion cards) ─────────────────────────
|
||||||
const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/
|
|
||||||
|
|
||||||
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
||||||
if (SPACE_BEFORE_PUNCT_RE.test(text)) {
|
// be legitimate (“the fact that that happened”, “she had had enough”).
|
||||||
|
const DOUBLE_RE = /\b([A-Za-z]+)(\s+)\1\b/gi
|
||||||
|
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
||||||
|
|
||||||
|
function doubles(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
DOUBLE_RE.lastIndex = 0
|
||||||
|
while ((m = DOUBLE_RE.exec(text))) {
|
||||||
|
const w = m[1].toLowerCase()
|
||||||
|
if (LEGIT_DOUBLES.has(w)) continue
|
||||||
|
const from = m.index
|
||||||
|
const to = from + m[0].length
|
||||||
out.push({
|
out.push({
|
||||||
id: 'space-punct',
|
id: `double:${from}:${key(m[0])}`,
|
||||||
rule: 'space-punct',
|
rule: 'double',
|
||||||
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||||||
en: 'No space before punctuation — it tucks right against the word.',
|
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||||||
|
fix: { from, to, replacement: m[1] },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chinese-L1 interference rules (Tier 1) ──────────────────────────────────
|
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
||||||
// Mandarin lacks plural inflection, articles, and verb agreement, so these
|
// edge, to avoid tangling with “i.e.” and the like. Awareness-only: a single
|
||||||
// patterns are the predictable places that grammar "leaks" into her English.
|
// character can't be re-anchored unambiguously by string (every word holds an
|
||||||
// All are closed-list or strong-signal to keep false positives near zero.
|
// “i”), so this stays a companion nudge rather than a one-click card.
|
||||||
|
const LOWER_I_RE = /(^|[^\p{L}\p{N}_])i(?=$|[^\p{L}\p{N}_])/mu
|
||||||
|
|
||||||
|
function pronounI(text: string, out: ProseHint[]) {
|
||||||
|
const m = LOWER_I_RE.exec(text)
|
||||||
|
LOWER_I_RE.lastIndex = 0
|
||||||
|
if (!m) return
|
||||||
|
const at = m.index + m[1].length
|
||||||
|
if (text[at + 1] === '.') return // “i.e.” etc. — precision over recall
|
||||||
|
out.push({
|
||||||
|
id: 'cap-i',
|
||||||
|
rule: 'cap-i',
|
||||||
|
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||||||
|
en: 'In English, “I” is always written as a capital letter.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
||||||
|
const SPACE_BEFORE_PUNCT_RE = /([A-Za-z]+) +([,;:!?]|\.(?!\.))/g
|
||||||
|
|
||||||
|
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
SPACE_BEFORE_PUNCT_RE.lastIndex = 0
|
||||||
|
while ((m = SPACE_BEFORE_PUNCT_RE.exec(text))) {
|
||||||
|
const from = m.index
|
||||||
|
const to = from + m[0].length
|
||||||
|
out.push({
|
||||||
|
id: `space-punct:${from}`,
|
||||||
|
rule: 'space-punct',
|
||||||
|
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||||||
|
en: 'No space before punctuation — it tucks right against the word.',
|
||||||
|
fix: { from, to, replacement: m[1] + m[2] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
||||||
|
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
||||||
|
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
||||||
|
const NO_SPACE_COMMA_RE = /([A-Za-z]+,)([A-Za-z]+)/g
|
||||||
|
const NO_SPACE_PERIOD_RE = /([a-z]{2,}\.)([A-Z][a-z]+)/g
|
||||||
|
|
||||||
|
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||||||
|
for (const re of [NO_SPACE_COMMA_RE, NO_SPACE_PERIOD_RE]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
re.lastIndex = 0
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
const from = m.index
|
||||||
|
const to = from + m[0].length
|
||||||
|
out.push({
|
||||||
|
id: `space-after:${from}`,
|
||||||
|
rule: 'space-after',
|
||||||
|
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||||||
|
en: 'Add a space after a comma or period before the next word.',
|
||||||
|
fix: { from, to, replacement: `${m[1]} ${m[2]}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
||||||
|
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
||||||
|
const A_BEFORE_VOWEL_RE = /\b(a)\s+([aeiou][a-z]+)\b/g
|
||||||
|
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
||||||
|
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
||||||
|
const AN_BEFORE_CONSONANT_RE = /\b(an)\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
||||||
|
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
||||||
|
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
||||||
|
|
||||||
|
function articles(text: string, out: ProseHint[]) {
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
A_BEFORE_VOWEL_RE.lastIndex = 0
|
||||||
|
while ((m = A_BEFORE_VOWEL_RE.exec(text))) {
|
||||||
|
if (CONSONANT_SOUND_RE.test(m[2])) continue
|
||||||
|
// Span the whole "a <noun>" so the original is a distinctive, anchorable
|
||||||
|
// phrase (a bare "a" matches everywhere). Replacement swaps only the article.
|
||||||
|
out.push({
|
||||||
|
id: `article-an:${m.index}`,
|
||||||
|
rule: 'article',
|
||||||
|
zh: `元音开头的词前用 “an”:“an ${m[2]}”。`,
|
||||||
|
en: `Before a vowel sound, use “an”: “an ${m[2]}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'an') + m[0].slice(m[1].length) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
||||||
|
while ((m = AN_BEFORE_CONSONANT_RE.exec(text))) {
|
||||||
|
if (VOWEL_SOUND_RE.test(m[2])) continue
|
||||||
|
out.push({
|
||||||
|
id: `article-a:${m.index}`,
|
||||||
|
rule: 'article',
|
||||||
|
zh: `辅音开头的词前用 “a”:“a ${m[2]}”。`,
|
||||||
|
en: `Before a consonant sound, use “a”: “a ${m[2]}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'a') + m[0].slice(m[1].length) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
|
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
|
||||||
// essentially never valid with a trailing “s”, so the list is its own guard.
|
// essentially never valid with a trailing “s”, so the list is its own guard.
|
||||||
const UNCOUNTABLE_PLURAL_RE =
|
const UNCOUNTABLE_PLURAL_RE =
|
||||||
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/i
|
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/gi
|
||||||
|
|
||||||
function uncountables(text: string, out: ProseHint[]) {
|
function uncountables(text: string, out: ProseHint[]) {
|
||||||
const m = UNCOUNTABLE_PLURAL_RE.exec(text)
|
let m: RegExpExecArray | null
|
||||||
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
|
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
|
||||||
if (m) {
|
while ((m = UNCOUNTABLE_PLURAL_RE.exec(text))) {
|
||||||
const singular = m[1].replace(/s$/i, '')
|
const word = m[1]
|
||||||
|
const singular = word.replace(/s$/i, '')
|
||||||
out.push({
|
out.push({
|
||||||
id: `uncountable:${m[1].toLowerCase()}`,
|
id: `uncountable:${m.index}`,
|
||||||
rule: 'uncountable',
|
rule: 'uncountable',
|
||||||
zh: `“${m[1]}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
zh: `“${word}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||||||
en: `“${m[1]}” is uncountable — drop the “s”: just “${singular}”.`,
|
en: `“${word}” is uncountable — drop the “s”: just “${singular}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + word.length, replacement: singular },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,17 +428,20 @@ const PROPER_NOUNS =
|
|||||||
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
|
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
|
||||||
'australian|mexican|brazilian|european'
|
'australian|mexican|brazilian|european'
|
||||||
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
|
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
|
||||||
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`)
|
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`, 'g')
|
||||||
|
|
||||||
function properCaps(text: string, out: ProseHint[]) {
|
function properCaps(text: string, out: ProseHint[]) {
|
||||||
const m = PROPER_NOUN_RE.exec(text)
|
let m: RegExpExecArray | null
|
||||||
if (m) {
|
PROPER_NOUN_RE.lastIndex = 0
|
||||||
const fixed = m[1][0].toUpperCase() + m[1].slice(1)
|
while ((m = PROPER_NOUN_RE.exec(text))) {
|
||||||
|
const word = m[1]
|
||||||
|
const fixed = word[0].toUpperCase() + word.slice(1)
|
||||||
out.push({
|
out.push({
|
||||||
id: `propercap:${m[1]}`,
|
id: `propercap:${m.index}`,
|
||||||
rule: 'propercap',
|
rule: 'propercap',
|
||||||
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||||||
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + word.length, replacement: fixed },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,17 +474,20 @@ const CAUSATIVE = new Set([
|
|||||||
|
|
||||||
function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
let found = 0
|
|
||||||
SVA_RE.lastIndex = 0
|
SVA_RE.lastIndex = 0
|
||||||
while ((m = SVA_RE.exec(text)) && found < 2) {
|
while ((m = SVA_RE.exec(text))) {
|
||||||
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
|
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
|
||||||
found++
|
const verb = m[3]
|
||||||
const fixed = third(m[3])
|
const fixed = third(verb)
|
||||||
|
// Span the whole match (subject + verb, plus any captured lead) so the
|
||||||
|
// original anchors; the replacement corrects only the trailing verb.
|
||||||
|
const head = m[0].slice(0, m[0].length - verb.length)
|
||||||
out.push({
|
out.push({
|
||||||
id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`,
|
id: `sva:${m.index}`,
|
||||||
rule: 'sva',
|
rule: 'sva',
|
||||||
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
||||||
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: head + matchCase(verb, fixed) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,35 +507,40 @@ const NON_S_PLURAL = new Set([
|
|||||||
|
|
||||||
function pluralAfterNumber(text: string, out: ProseHint[]) {
|
function pluralAfterNumber(text: string, out: ProseHint[]) {
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
let found = 0
|
|
||||||
NUMBER_NOUN_RE.lastIndex = 0
|
NUMBER_NOUN_RE.lastIndex = 0
|
||||||
while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) {
|
while ((m = NUMBER_NOUN_RE.exec(text))) {
|
||||||
const noun = m[2].toLowerCase()
|
const noun = m[2].toLowerCase()
|
||||||
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
|
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
|
||||||
found++
|
// Span "<number> <noun>" so the original anchors; append “s” to the noun.
|
||||||
out.push({
|
out.push({
|
||||||
id: `plural:${key(m[0])}`,
|
id: `plural:${m.index}`,
|
||||||
rule: 'plural',
|
rule: 'plural',
|
||||||
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
||||||
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: `${m[0]}s` },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
|
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
|
||||||
// possessive, so the article often gets stacked on top. One of the two must go.
|
// possessive, so the article often gets stacked on top. The article goes.
|
||||||
const DOUBLE_DET_RE =
|
const DOUBLE_DET_RE =
|
||||||
/\b(a|an|the)\s+(a|an|the|my|your|his|her|its|our|their)\b/gi
|
/\b(a|an|the)(\s+)(a|an|the|my|your|his|her|its|our|their)\b/gi
|
||||||
|
|
||||||
function doubleDeterminer(text: string, out: ProseHint[]) {
|
function doubleDeterminer(text: string, out: ProseHint[]) {
|
||||||
const m = DOUBLE_DET_RE.exec(text)
|
let m: RegExpExecArray | null
|
||||||
DOUBLE_DET_RE.lastIndex = 0
|
DOUBLE_DET_RE.lastIndex = 0
|
||||||
if (m) {
|
while ((m = DOUBLE_DET_RE.exec(text))) {
|
||||||
|
// Two *identical* words ("the the") are a doubled word, not stacked
|
||||||
|
// determiners — leave that to the `doubles` rule so we don't double-flag.
|
||||||
|
if (m[1].toLowerCase() === m[3].toLowerCase()) continue
|
||||||
out.push({
|
out.push({
|
||||||
id: `doubledet:${key(m[0])}`,
|
id: `doubledet:${m.index}`,
|
||||||
rule: 'doubledet',
|
rule: 'doubledet',
|
||||||
zh: `“${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
zh: `“${m[1]} ${m[3]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||||||
en: `“${m[1]} ${m[2]}” stacks two determiners — keep just one.`,
|
en: `“${m[1]} ${m[3]}” stacks two determiners — keep just one.`,
|
||||||
|
// Drop the article (m[1]); keep the second determiner, casing preserved.
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], m[3]) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,46 +549,52 @@ function doubleDeterminer(text: string, out: ProseHint[]) {
|
|||||||
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
|
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
|
||||||
// nouns: “there is some water”).
|
// nouns: “there is some water”).
|
||||||
const THERE_IS_RE =
|
const THERE_IS_RE =
|
||||||
/\bthere(?:\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/i
|
/\bthere(\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/gi
|
||||||
|
|
||||||
function thereIsPlural(text: string, out: ProseHint[]) {
|
function thereIsPlural(text: string, out: ProseHint[]) {
|
||||||
const m = THERE_IS_RE.exec(text)
|
let m: RegExpExecArray | null
|
||||||
THERE_IS_RE.lastIndex = 0
|
THERE_IS_RE.lastIndex = 0
|
||||||
if (m) {
|
while ((m = THERE_IS_RE.exec(text))) {
|
||||||
|
// Span the whole "there is <plural>" phrase so it anchors; swap the verb part
|
||||||
|
// (m[1]: “ is” / “'s”) for “ are”, preserving the “there” casing and the cue.
|
||||||
|
const replacement = m[0].slice(0, 5) + ' are' + m[0].slice(5 + m[1].length)
|
||||||
out.push({
|
out.push({
|
||||||
id: `thereis:${m[1].toLowerCase()}`,
|
id: `thereis:${m.index}`,
|
||||||
rule: 'thereis',
|
rule: 'thereis',
|
||||||
zh: `后面是复数时用 “there are”:“there are ${m[1]}…”。`,
|
zh: `后面是复数时用 “there are”:“there are ${m[2]}…”。`,
|
||||||
en: `With a plural, use “there are”: “there are ${m[1]}…”.`,
|
en: `With a plural, use “there are”: “there are ${m[2]}…”.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── confusables (Tier 2) ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// its / it's — only the two unambiguous directions: “it's own” (always its own)
|
// its / it's — only the two unambiguous directions: “it's own” (always its own)
|
||||||
// and “its a/an” (the possessive can't take an article → it's a/an).
|
// and “its a/an” (the possessive can't take an article → it's a/an).
|
||||||
const ITS_OWN_RE = /\bit's\s+own\b/i
|
const ITS_OWN_RE = /\b(it's|it’s)\s+own\b/gi
|
||||||
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i
|
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/gi
|
||||||
|
|
||||||
function itsConfusion(text: string, out: ProseHint[]) {
|
function itsConfusion(text: string, out: ProseHint[]) {
|
||||||
if (ITS_OWN_RE.test(text)) {
|
let m: RegExpExecArray | null
|
||||||
|
ITS_OWN_RE.lastIndex = 0
|
||||||
|
while ((m = ITS_OWN_RE.exec(text))) {
|
||||||
|
// Span "it's own" so it anchors; swap the leading token to the possessive.
|
||||||
out.push({
|
out.push({
|
||||||
id: 'its-own',
|
id: `its-own:${m.index}`,
|
||||||
rule: 'its',
|
rule: 'its',
|
||||||
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||||||
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[1], 'its') + m[0].slice(m[1].length) },
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const m = ITS_ARTICLE_RE.exec(text)
|
|
||||||
ITS_ARTICLE_RE.lastIndex = 0
|
ITS_ARTICLE_RE.lastIndex = 0
|
||||||
if (m) {
|
while ((m = ITS_ARTICLE_RE.exec(text))) {
|
||||||
|
// Span "its a/an" so it anchors; swap "its" → "it's".
|
||||||
out.push({
|
out.push({
|
||||||
id: 'its-article',
|
id: `its-article:${m.index}`,
|
||||||
rule: 'its',
|
rule: 'its',
|
||||||
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||||||
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||||||
|
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(m[0][0], "it's") + m[0].slice(3) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,57 +606,20 @@ const COMPARATIVES =
|
|||||||
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
|
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
|
||||||
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
|
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
|
||||||
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
|
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
|
||||||
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})\\s+then\\b`, 'i')
|
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})(\\s+)then\\b`, 'gi')
|
||||||
|
|
||||||
function thanThen(text: string, out: ProseHint[]) {
|
function thanThen(text: string, out: ProseHint[]) {
|
||||||
const m = THAN_THEN_RE.exec(text)
|
let m: RegExpExecArray | null
|
||||||
if (m) {
|
THAN_THEN_RE.lastIndex = 0
|
||||||
|
while ((m = THAN_THEN_RE.exec(text))) {
|
||||||
|
// Span "<comparative> then" so it anchors; swap the trailing “then” → “than”.
|
||||||
|
const thenFrom = m.index + m[0].length - 4
|
||||||
out.push({
|
out.push({
|
||||||
id: `than:${m[1].toLowerCase()}`,
|
id: `than:${m.index}`,
|
||||||
rule: 'than',
|
rule: 'than',
|
||||||
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
||||||
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||||||
})
|
fix: { from: m.index, to: m.index + m[0].length, replacement: m[0].slice(0, m[0].length - 4) + matchCase(text[thenFrom], 'than') },
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A transition word opening a sentence with no comma after it (“However we…”).
|
|
||||||
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
|
||||||
// like “Then/First/Finally” are left out (their comma is optional).
|
|
||||||
const INTRO_RE =
|
|
||||||
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
|
||||||
|
|
||||||
function introComma(text: string, out: ProseHint[]) {
|
|
||||||
let found = false
|
|
||||||
for (const s of sentences(text)) {
|
|
||||||
const m = s.match(INTRO_RE)
|
|
||||||
if (m) {
|
|
||||||
out.push({
|
|
||||||
id: `introcomma:${m[1].toLowerCase()}`,
|
|
||||||
rule: 'introcomma',
|
|
||||||
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
|
||||||
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
|
||||||
})
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
|
||||||
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
|
||||||
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
|
||||||
const NO_SPACE_COMMA_RE = /[A-Za-z],[A-Za-z]/
|
|
||||||
const NO_SPACE_PERIOD_RE = /[a-z]{2,}\.[A-Z][a-z]/
|
|
||||||
|
|
||||||
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
|
||||||
if (NO_SPACE_COMMA_RE.test(text) || NO_SPACE_PERIOD_RE.test(text)) {
|
|
||||||
out.push({
|
|
||||||
id: 'space-after',
|
|
||||||
rule: 'space-after',
|
|
||||||
zh: '逗号、句号后面要空一格,再接下一个词。',
|
|
||||||
en: 'Add a space after a comma or period before the next word.',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,6 +652,8 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
|||||||
|
|
||||||
// analyzeProse returns context-aware hints, highest-priority first. It bails on
|
// analyzeProse returns context-aware hints, highest-priority first. It bails on
|
||||||
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
|
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
|
||||||
|
// Hints that carry a `fix` are applyable (they also surface as suggestion cards);
|
||||||
|
// the companion filters those out so a span isn't both a bubble and a card.
|
||||||
export function analyzeProse(text: string): ProseHint[] {
|
export function analyzeProse(text: string): ProseHint[] {
|
||||||
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
|
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
|
||||||
if (englishWords < 8) return []
|
if (englishWords < 8) return []
|
||||||
@@ -575,3 +661,35 @@ export function analyzeProse(text: string): ProseHint[] {
|
|||||||
for (const rule of RULES) rule(text, out)
|
for (const rule of RULES) rule(text, out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mechanicsFindings returns every applyable deterministic fix in the text, as
|
||||||
|
// suggestion-card findings with exact spans. No word-count floor: a doubled word
|
||||||
|
// or a stray lowercase “i” is worth fixing even in a short draft, the way a
|
||||||
|
// spell-checker would. The card pipeline persists these as the 'mechanics'
|
||||||
|
// family; collisions with the LLM cards are resolved server-side (mechanics
|
||||||
|
// wins, since its span is exact).
|
||||||
|
export function mechanicsFindings(text: string): MechanicsFinding[] {
|
||||||
|
const hints: ProseHint[] = []
|
||||||
|
for (const rule of RULES) rule(text, hints)
|
||||||
|
const found: MechanicsFinding[] = []
|
||||||
|
for (const h of hints) {
|
||||||
|
if (!h.fix) continue
|
||||||
|
const { from, to, replacement } = h.fix
|
||||||
|
const original = text.slice(from, to)
|
||||||
|
if (!original || original === replacement) continue
|
||||||
|
found.push({ from, to, original, replacement, explanation: h.en })
|
||||||
|
}
|
||||||
|
// Two rules can occasionally claim overlapping spans (e.g. a doubled word that
|
||||||
|
// also reads as stacked determiners). Resolve to one card per stretch of text:
|
||||||
|
// earlier start wins, ties broken by the longer span. The backend resolves the
|
||||||
|
// separate mechanics-vs-LLM collisions; this handles mechanics-vs-mechanics.
|
||||||
|
found.sort((a, b) => (a.from !== b.from ? a.from - b.from : b.to - a.to))
|
||||||
|
const out: MechanicsFinding[] = []
|
||||||
|
let lastEnd = -1
|
||||||
|
for (const f of found) {
|
||||||
|
if (f.from < lastEnd) continue
|
||||||
|
out.push(f)
|
||||||
|
lastEnd = f.to
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,7 +127,10 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
|||||||
// every finding was shown recently. Avoids repeating a finding or the same
|
// every finding was shown recently. Avoids repeating a finding or the same
|
||||||
// rule family back-to-back so the companion never feels like a broken record.
|
// rule family back-to-back so the companion never feels like a broken record.
|
||||||
const nextTip = useCallback((): Bubble => {
|
const nextTip = useCallback((): Bubble => {
|
||||||
const hints = analyzeProse(textRef.current)
|
// Applyable hints (those with a `fix`) surface as one-click suggestion cards,
|
||||||
|
// so the companion skips them — a span shouldn't be both a bubble and a card.
|
||||||
|
// What's left is the awareness notes (run-ons, splices, …) the kitten alone gives.
|
||||||
|
const hints = analyzeProse(textRef.current).filter((h) => !h.fix)
|
||||||
const fresh = hints.find(
|
const fresh = hints.find(
|
||||||
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
(h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ export const TYPE_META: Record<SuggestionType, { color: string; label: string }>
|
|||||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||||
collocation: { color: 'var(--color-blossom)', label: 'Word pairing' },
|
collocation: { color: 'var(--color-blossom)', label: 'Word pairing' },
|
||||||
|
mechanics: { color: 'var(--color-sage)', label: 'Tidy-up' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, type Suggestion } from '../api/client'
|
import { api, type Suggestion } from '../api/client'
|
||||||
|
import { mechanicsFindings } from '../components/Companion/prose'
|
||||||
|
|
||||||
const DEBOUNCE_MS = 4000
|
const DEBOUNCE_MS = 4000
|
||||||
|
|
||||||
@@ -27,6 +28,13 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
const docIdRef = useRef(docId)
|
const docIdRef = useRef(docId)
|
||||||
docIdRef.current = docId
|
docIdRef.current = docId
|
||||||
|
|
||||||
|
// Latest plaintext, kept fresh by schedule(), so the deterministic mechanics
|
||||||
|
// pass (detected client-side, see prose.ts) reads the current document when the
|
||||||
|
// debounce fires — no need to re-thread text through every call. null until the
|
||||||
|
// first edit of the current doc, so a tone-only check before any edit doesn't
|
||||||
|
// submit an empty batch and wipe the doc's persisted mechanics rows.
|
||||||
|
const latestTextRef = useRef<string | null>(null)
|
||||||
|
|
||||||
// Token to discard responses from a doc we've since navigated away from.
|
// Token to discard responses from a doc we've since navigated away from.
|
||||||
const runRef = useRef(0)
|
const runRef = useRef(0)
|
||||||
|
|
||||||
@@ -46,6 +54,21 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
setChecking(true)
|
setChecking(true)
|
||||||
let retrying = false
|
let retrying = false
|
||||||
try {
|
try {
|
||||||
|
// Deterministic mechanics first: detect client-side and persist as the
|
||||||
|
// 'mechanics' family before the grammar pass, so the checkpoint's unified
|
||||||
|
// response already carries them. Best-effort and only on the initial try —
|
||||||
|
// a grammar retry shouldn't re-submit unchanged findings. A mechanics
|
||||||
|
// failure must not block the grammar pass.
|
||||||
|
if (attempt === 0 && latestTextRef.current !== null) {
|
||||||
|
try {
|
||||||
|
// Render the mechanics fixes immediately — they're instant (no LLM), so
|
||||||
|
// the unified set they return shouldn't wait on the slow grammar pass.
|
||||||
|
const withMech = await api.submitMechanics(id, mechanicsFindings(latestTextRef.current))
|
||||||
|
if (run === runRef.current && id === docIdRef.current) setSuggestions(withMech)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('mechanics submit failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
const fresh = await api.checkDoc(id)
|
const fresh = await api.checkDoc(id)
|
||||||
if (run === runRef.current && id === docIdRef.current) {
|
if (run === runRef.current && id === docIdRef.current) {
|
||||||
setSuggestions(fresh)
|
setSuggestions(fresh)
|
||||||
@@ -116,8 +139,11 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
[runExplicitPass],
|
[runExplicitPass],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Call on every edit; schedules a check 4s after typing settles.
|
// Call on every edit; schedules a check 4s after typing settles. Pass the
|
||||||
const schedule = useCallback(() => {
|
// current plaintext so the mechanics pass sees the latest document; omit it
|
||||||
|
// (e.g. a tone-only change) to reuse the last text.
|
||||||
|
const schedule = useCallback((text?: string) => {
|
||||||
|
if (text !== undefined) latestTextRef.current = text
|
||||||
clearTimeout(debounceRef.current)
|
clearTimeout(debounceRef.current)
|
||||||
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
|
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
|
||||||
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
|
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
|
||||||
@@ -129,6 +155,7 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
clearTimeout(debounceRef.current)
|
clearTimeout(debounceRef.current)
|
||||||
clearTimeout(retryRef.current)
|
clearTimeout(retryRef.current)
|
||||||
runRef.current++
|
runRef.current++
|
||||||
|
latestTextRef.current = null // unknown until the new doc's first edit
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
setChecking(false)
|
setChecking(false)
|
||||||
setVoicing(false)
|
setVoicing(false)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
--color-sky: #A8CCE8; /* clarity */
|
--color-sky: #A8CCE8; /* clarity */
|
||||||
--color-honey: #CE9B4F; /* voice */
|
--color-honey: #CE9B4F; /* voice */
|
||||||
--color-blossom: #E59ABF; /* collocation — warm blossom pink */
|
--color-blossom: #E59ABF; /* collocation — warm blossom pink */
|
||||||
|
--color-sage: #B7C7B9; /* mechanics — calm sage, a gentle tidy-up nudge */
|
||||||
--color-success: #8FCFA8; /* saved / accepted */
|
--color-success: #8FCFA8; /* saved / accepted */
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
@@ -234,6 +235,7 @@ button, a, input {
|
|||||||
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
|
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
|
||||||
.petal-suggestion-voice { border-bottom-color: var(--color-honey); }
|
.petal-suggestion-voice { border-bottom-color: var(--color-honey); }
|
||||||
.petal-suggestion-collocation { border-bottom-color: var(--color-blossom); }
|
.petal-suggestion-collocation { border-bottom-color: var(--color-blossom); }
|
||||||
|
.petal-suggestion-mechanics { border-bottom-color: var(--color-sage); }
|
||||||
|
|
||||||
@keyframes petal-suggestion-in {
|
@keyframes petal-suggestion-in {
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
|||||||
Reference in New Issue
Block a user