From 69ecb79da1b7a5a0423b5baf9eba16fba1702647 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 26 Jun 2026 07:16:23 -0700 Subject: [PATCH] Companion: rules-based, context-aware writing advice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the kitten's random generic tips with a deterministic, LLM-free prose checker that reads the actual document and surfaces one gentle, bilingual note at a time — quoting a slice of her own sentence so the advice is clearly about *this* draft. Rules (all conservative, tuned for a Mandarin-native ESL writer): run-ons, comma splices, unclear antecedents, Oxford comma, a/an, uncountable plurals, capitalize languages/days/months, subject-verb agreement, plural-after-number, double determiner, "there is" + plural, its/it's, than/then, comma-after-transition, doubled words, lowercase "i", sentence capitals, and spacing around punctuation. Each rule is paired with false-positive guards (e.g. "a university", "After dinner, I went home and read", existing Oxford lists) and pinned by a 45-case Vitest suite (npm test). Also: bubbles now linger proportional to how much there is to read (zh+en) and pause-on-hover, so denser advice no longer vanishes mid-read. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd --- web/package-lock.json | 364 ++++++++++- web/package.json | 7 +- web/src/App.tsx | 1 + .../components/Companion/PetalCompanion.tsx | 13 +- web/src/components/Companion/prose.test.ts | 250 ++++++++ web/src/components/Companion/prose.ts | 577 ++++++++++++++++++ web/src/components/Companion/tips.ts | 3 +- web/src/components/Companion/useCompanion.ts | 79 ++- 8 files changed, 1280 insertions(+), 14 deletions(-) create mode 100644 web/src/components/Companion/prose.test.ts create mode 100644 web/src/components/Companion/prose.ts diff --git a/web/package-lock.json b/web/package-lock.json index d6fa299..03d9d02 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,7 +28,8 @@ "dictionary-en": "^4.0.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^4.1.9" } }, "node_modules/@babel/code-frame": { @@ -1178,6 +1179,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", @@ -1934,6 +1942,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2010,12 +2036,135 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -2084,6 +2233,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2176,6 +2335,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2240,6 +2406,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2746,12 +2932,33 @@ "is-buffer": "^2.0.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3103,6 +3310,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3113,6 +3327,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -3134,6 +3362,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3151,6 +3396,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -3295,12 +3550,119 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 63ba348..5d8e2b6 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tiptap/extension-character-count": "^2.11.5", @@ -29,6 +31,7 @@ "dictionary-en": "^4.0.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^4.1.9" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 07aacbb..9e32d63 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -449,6 +449,7 @@ export default function App() { saveStatus={status} editTick={editTick} acceptTick={acceptTick} + text={docText} /> diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index 01389d6..0f4ac1f 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -9,6 +9,7 @@ interface Props { saveStatus: SaveStatus editTick: number acceptTick: number + text: string } // Emoji placeholder per mood, used for any mood a companion has no Lottie for. @@ -26,8 +27,14 @@ const STORAGE_KEY = 'petal.companion' // useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and // break reminders. Clicking the mascot opens a picker to switch companions // (the choice persists in localStorage). -export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) { - const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick }) +export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Props) { + const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({ + wordCount, + saveStatus, + editTick, + acceptTick, + text, + }) const [companionId, setCompanionId] = useState(() => { try { @@ -136,6 +143,8 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
analyzeProse(text).map((h) => h.rule) + +// Assert a given rule appears among the hints for this text. +function expectRule(text: string, rule: string) { + expect(rulesFor(text), `expected "${rule}" for: ${text}`).toContain(rule) +} + +// Assert the text produces no hints at all (false-positive guard). +function expectClean(text: string) { + expect(analyzeProse(text), `expected no hints for: ${text}`).toEqual([]) +} + +describe('analyzeProse — floor', () => { + it('stays quiet on very short drafts (under 8 English words)', () => { + expect(analyzeProse('he go now')).toEqual([]) + }) + + it('returns hints highest-priority first', () => { + // This 36-word run-on also contains a lowercase "i"; run-on must lead. + const hints = analyzeProse( + 'yesterday i walked all the way to the market and i bought apples and oranges and i saw my friend there and we talked and laughed and walked back home together in the warm afternoon sun.', + ) + expect(hints[0].rule).toBe('runon') + expect(hints.map((h) => h.rule)).toContain('cap-i') + }) +}) + +describe('run-ons', () => { + it('flags a long, comma-heavy sentence', () => { + expectRule( + '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.', + 'runon', + ) + }) + + it('leaves a normal sentence alone', () => { + expectClean('The garden was quiet in the morning and the rain fell softly on the leaves.') + }) +}) + +describe('comma splices', () => { + it('flags two independent clauses joined by a comma', () => { + expectRule('I finished the report, I sent it to my boss right away today.', 'splice') + }) + + it('catches a splice with a verb not on any list', () => { + expectRule('The garden was quiet in the morning, she poured a cup of tea slowly.', 'splice') + }) + + it('does not flag an introductory transition (However, …)', () => { + expectClean('However, we decided to stay home and rest for the entire weekend.') + }) + + it('does not flag an introductory phrase (After dinner, …)', () => { + expectClean('After dinner, I went home and read a book until I fell asleep.') + }) + + it('does not flag a pronoun list after a comma', () => { + expectClean('We invited my brother, you and your sister to the dinner party.') + }) +}) + +describe('unclear antecedents', () => { + it('flags a vague "This + verb" opener after a prior sentence', () => { + expectRule( + 'We changed the whole schedule last week. This makes everyone confused about the new times.', + 'antecedent', + ) + }) + + it('leaves "This" alone when it names its noun', () => { + expectClean( + 'We changed the whole schedule last week. This new schedule confuses everyone about the times.', + ) + }) +}) + +describe('oxford comma', () => { + it('flags a 3-item noun list missing the serial comma', () => { + expectRule('My mother, my father and my sister came to visit us last summer.', 'oxford') + }) + + it('flags a verb-phrase list missing the serial comma', () => { + expectRule('We cleaned the kitchen, washed the dishes and took out the trash.', 'oxford') + }) + + it('does not flag a list that already has the serial comma', () => { + expectClean('I bought apples, bananas, and oranges at the store this morning.') + }) + + it('does not flag a compound predicate as a list', () => { + expectClean('After dinner, I went home and read a book until I fell asleep.') + }) +}) + +describe('a / an', () => { + it('flags "a" before a vowel sound', () => { + expectRule('She found a umbrella and a old book on the table by the window.', 'article') + }) + + it('flags "an" before a consonant sound', () => { + expectRule('He gave me an book and an pencil to use during the long exam.', 'article') + }) + + it('respects sound exceptions (a university, an hour)', () => { + expectClean('He is a university student and it took an hour to arrive here.') + }) +}) + +describe('uncountable plurals', () => { + it('flags a pluralized mass noun', () => { + expectRule('She gave me many informations about the new job opening today.', 'uncountable') + }) + + it('leaves the singular mass noun alone', () => { + expectClean('He shared some useful advice and helped me with my homework tonight.') + }) +}) + +describe('capitalization of proper nouns', () => { + it('flags lowercase languages and days', () => { + expectRule('I am learning english and french during my free time every week.', 'propercap') + expectRule('We will meet on monday to discuss the final project together soon.', 'propercap') + }) + + it('does not flag ambiguous homographs (may, march)', () => { + expectClean('They may visit in the spring and march through the old town square.') + }) + + it('leaves already-capitalized proper nouns alone', () => { + expectClean('I will study English and Chinese together this coming summer break here.') + }) +}) + +describe('subject–verb agreement', () => { + it('flags a bare verb after he/she/it', () => { + expectRule('My brother is busy but he go to the gym every single morning.', 'sva') + expectRule('She have a lovely garden full of roses behind her small house.', 'sva') + }) + + it('flags a sentence-initial subject too', () => { + expectRule('It make me happy whenever the sun comes out after the rain.', 'sva') + }) + + it('does not flag causative/perception frames (let it go, make it work)', () => { + expectClean('Please let it go and try to enjoy the rest of your day.') + expectClean('We should make it work before the big meeting tomorrow afternoon.') + }) +}) + +describe('plural after a number', () => { + it('flags a singular noun after a cardinal number', () => { + expectRule('I bought three apple and some bread at the market this morning.', 'plural') + }) + + it('does not flag an adjective between number and noun', () => { + expectClean('We adopted two small dogs from the shelter near our apartment today.') + }) + + it('does not flag irregular plurals (five people)', () => { + expectClean('There were five people waiting patiently outside in the cold morning rain.') + }) + + it('does not flag an already-plural noun', () => { + expectClean('She has two cats and they sleep all day long on the couch.') + }) +}) + +describe('double determiner', () => { + it('flags a stacked article + possessive', () => { + expectRule('Please put the my book back on the shelf when you finish it.', 'doubledet') + }) +}) + +describe('there is + plural', () => { + it('flags "there is" with a plural quantifier', () => { + expectRule('There is many reasons to visit the coast during the warm months.', 'thereis') + }) + + it('does not flag "there is some" with a mass noun', () => { + expectClean('There is some water left in the bottle if you are thirsty now.') + }) +}) + +describe('its / it’s', () => { + it("flags it's used as a possessive", () => { + expectRule("The cat licked it's own paw while sitting quietly by the window.", 'its') + }) + + it('flags its used where "it is" is meant', () => { + expectRule('I think its a wonderful idea to travel together next year sometime.', 'its') + }) + + it('leaves correct possessive "its" alone', () => { + expectClean('The dog wagged its tail happily when we came home that evening.') + }) +}) + +describe('than / then', () => { + it('flags a comparative followed by "then"', () => { + expectRule('This cake tastes much better then the one we made last weekend.', 'than') + }) + + it('leaves sequential "then" alone', () => { + expectClean('We ate dinner and then we watched a long movie on the sofa.') + }) +}) + +describe('comma after a transition', () => { + it('flags a sentence-initial transition with no comma', () => { + expectRule('However we decided to stay home and rest for the whole weekend.', 'introcomma') + }) + + it('does not flag a sequence word (Then …)', () => { + expectClean('Then we walked to the park and fed the ducks by the pond.') + }) +}) + +describe('spacing around punctuation', () => { + it('flags a missing space after a comma', () => { + expectRule('I like apples,bananas and grapes from the little shop down the street.', 'space-after') + }) + + it('flags a space before punctuation', () => { + expectRule('She smiled warmly , then walked away without saying a single word today.', 'space-punct') + }) + + it('does not flag thousands separators in numbers', () => { + expectClean('The list had 1,000 items and cost us about 2,500 dollars in total.') + }) +}) + +describe('capitalization basics', () => { + it('flags a lowercase standalone "i"', () => { + expectRule('Yesterday i walked to the store and bought milk, eggs, and bread.', 'cap-i') + }) + + it('flags a sentence that does not start with a capital', () => { + expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence') + }) +}) diff --git a/web/src/components/Companion/prose.ts b/web/src/components/Companion/prose.ts new file mode 100644 index 0000000..a2769d6 --- /dev/null +++ b/web/src/components/Companion/prose.ts @@ -0,0 +1,577 @@ +// Rules-based prose checker — NO LLM. This is the companion's "smart" side: it +// reads the writer's actual text and surfaces one gentle, context-aware grammar +// or style note at a time, the way an attentive tutor leaning over her shoulder +// would. Every rule here is intentionally conservative: a wrong nudge erodes +// trust far faster than a missed one earns it, so we only speak up when a +// pattern is a high-confidence, genuinely-common English mistake. +// +// 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* +// paragraph and not a generic tip jar. + +import type { Line } from './tips' + +export interface ProseHint extends Line { + // Stable, content-derived id so the same untouched sentence isn't re-flagged + // every cadence; the companion remembers recently-shown ids. + id: string + // Rule family, used to vary advice (don't show the same kind twice in a row). + rule: string +} + +// ── small text helpers ────────────────────────────────────────────────────── + +const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g + +// Split into sentence-ish chunks (text + leading punctuation trimmed). Mirrors +// the SENTENCE_RE terminators used elsewhere, including CJK fullwidth forms. +function sentences(text: string): string[] { + const out: string[] = [] + const re = /[^.!?。!?]+[.!?。!?]*/g + let m: RegExpExecArray | null + while ((m = re.exec(text))) { + const s = m[0].trim() + if (s) out.push(s) + } + return out +} + +// First few words of a sentence, as an anchor we can quote back to her. +function anchor(s: string, n = 6): string { + const words = s.split(/\s+/).slice(0, n).join(' ') + return words.length < s.length ? `${words}…` : words +} + +// Cheap, stable hash → short id suffix, so ids stay stable across recomputes but +// change the moment she edits the offending text. +function key(s: string): string { + return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48) +} + +// ── individual rules ──────────────────────────────────────────────────────── +// Each rule pushes at most a couple of findings; the caller shows one at a time. + +// Run-on / overly long sentences — the single most common readability problem +// for ESL writers, who often chain clauses that a period would serve better. +function runOns(text: string, out: ProseHint[]) { + let found = 0 + for (const s of sentences(text)) { + if (found >= 2) break + const words = s.match(ENGLISH_WORD_RE)?.length ?? 0 + const commas = (s.match(/,/g) ?? []).length + const longRun = words >= 32 + const commaHeavy = words >= 24 && commas >= 2 + if (longRun || commaHeavy) { + found++ + out.push({ + id: `runon:${key(s)}`, + rule: 'runon', + zh: '这句话有点长啦,分成两三句会更清楚 🌸', + en: `This one runs long — try splitting it: “${anchor(s)}”`, + }) + } + } +} + +// Comma splice — two independent clauses joined by only a comma. We look for a +// comma immediately followed by a subject pronoun + a finite verb, which is a +// strong, low-false-positive signal of a spliced second clause. +// A comma, then a subject pronoun starting a fresh clause, then its verb. We no +// longer whitelist verbs (that lost too many real splices) — instead we take any +// word after the pronoun and reject the function words that signal it *isn't* a +// new clause (a conjunction/preposition list like “she and I”, “it of course”). +const SPLICE_RE = /[a-z]\w*,\s+(I|we|they|he|she|it|you)\s+([a-z]+)\b/g +const SPLICE_NONVERB = new Set([ + 'and', 'or', 'but', 'nor', 'yet', 'so', 'to', 'too', 'also', 'of', 'in', 'on', + 'at', 'for', 'with', 'as', 'who', 'whom', 'which', 'that', 'than', 'then', + 'will', // “…, I will, …” is usually an aside, not a clean splice +]) + +// First words that signal the lead is an *introductory* element (transition, +// subordinate clause, or adverbial phrase), where a following “, subject verb” +// is correct punctuation — not a splice. Guards against “However, we left.” and +// “After dinner, I went home.” +const INTRO_LEAD = new Set([ + 'after', 'before', 'when', 'while', 'although', 'though', 'because', 'since', + 'if', 'as', 'until', 'unless', 'whenever', 'wherever', 'whereas', 'however', + 'therefore', 'moreover', 'furthermore', 'nevertheless', 'nonetheless', + 'meanwhile', 'consequently', 'otherwise', 'besides', 'instead', 'finally', + 'first', 'firstly', 'second', 'secondly', 'next', 'then', 'later', 'eventually', + 'afterwards', 'anyway', 'yesterday', 'today', 'tomorrow', 'honestly', + 'actually', 'suddenly', 'sometimes', 'usually', 'often', 'also', 'still', + 'now', 'here', 'there', 'well', 'yes', 'no', 'fortunately', 'unfortunately', + 'surprisingly', 'clearly', 'obviously', 'basically', 'generally', 'initially', + 'recently', 'currently', 'sadly', 'luckily', 'hopefully', 'frankly', + 'personally', 'overall', 'soon', 'once', 'during', 'in', 'on', 'at', 'for', + 'with', 'without', 'by', 'from', 'despite', 'throughout', 'given', 'regarding', +]) + +// A genuine comma splice has an *independent clause* before the comma. We +// 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 +// spliced clauses. Precision over recall: a wrong nudge costs more than a miss. +function commaSplices(text: string, out: ProseHint[]) { + let found = 0 + for (const s of sentences(text)) { + if (found >= 2) break + SPLICE_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = SPLICE_RE.exec(s)) && found < 2) { + const commaIdx = s.indexOf(',', m.index) + const lead = s.slice(0, commaIdx).trim() + const words = lead.split(/\s+/).filter(Boolean) + const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? '' + if (words.length < 3 || INTRO_LEAD.has(firstWord)) continue + if (SPLICE_NONVERB.has(m[2].toLowerCase())) continue + found++ + out.push({ + id: `splice:${key(m[0])}`, + rule: 'splice', + zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。', + en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`, + }) + } + } +} + +// 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 +// when there's a prior sentence (so an antecedent is actually in question). +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/ + +function antecedents(text: string, out: ProseHint[]) { + const list = sentences(text) + let found = 0 + for (let i = 1; i < list.length && found < 2; i++) { + const s = list[i] + const m = s.match(ANTECEDENT_RE) + if (m) { + found++ + out.push({ + id: `antecedent:${key(s)}`, + rule: 'antecedent', + zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`, + en: `“${m[1]}” here is a little vague — name what it refers to.`, + }) + } + } +} + +// 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 +// compound clause (“…, but she and I…”) for a list. +const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g +const CLAUSE_STARTERS = new Set([ + 'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if', + 'although', 'though', 'since', 'as', 'and', 'or', 'nor', 'yet', 'then', + 'however', 'whereas', + // Subject pronouns signal a clause, not a list item (“…, I went home and…”). + 'i', 'you', 'he', 'she', 'it', 'we', 'they', +]) + +function oxford(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + let found = 0 + OXFORD_RE.lastIndex = 0 + while ((m = OXFORD_RE.exec(text)) && found < 1) { + const firstWord = m[2].split(/\s+/)[0]?.toLowerCase() ?? '' + if (CLAUSE_STARTERS.has(firstWord)) continue + found++ + out.push({ + id: `oxford:${key(m[0])}`, + rule: 'oxford', + zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。', + en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`, + }) + } +} + +// 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 = /\ba\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 = /\ban\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 + let found = 0 + A_BEFORE_VOWEL_RE.lastIndex = 0 + while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) { + if (CONSONANT_SOUND_RE.test(m[1])) continue + found++ + out.push({ + id: `article-an:${key(m[1])}`, + rule: 'article', + zh: `元音开头的词前用 “an”:“an ${m[1]}”。`, + 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 +// CJK sentences (she writes Mandarin too — those don't take Latin capitals). +const LOWER_START_RE = /[.!?]\s+([a-z])/ + +function sentenceCaps(text: string, out: ProseHint[]) { + if (LOWER_START_RE.test(text)) { + out.push({ + id: 'cap-sentence', + rule: 'cap-sentence', + zh: '每个句子的开头,用大写字母开始吧。', + en: 'Start each new sentence with a capital letter.', + }) + } +} + +// A stray space *before* punctuation (a common habit carried from CJK spacing). +const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/ + +function spaceBeforePunct(text: string, out: ProseHint[]) { + if (SPACE_BEFORE_PUNCT_RE.test(text)) { + out.push({ + id: 'space-punct', + rule: 'space-punct', + zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。', + en: 'No space before punctuation — it tucks right against the word.', + }) + } +} + +// ── Chinese-L1 interference rules (Tier 1) ────────────────────────────────── +// Mandarin lacks plural inflection, articles, and verb agreement, so these +// patterns are the predictable places that grammar "leaks" into her English. +// All are closed-list or strong-signal to keep false positives near zero. + +// 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. +const UNCOUNTABLE_PLURAL_RE = + /\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/i + +function uncountables(text: string, out: ProseHint[]) { + const m = UNCOUNTABLE_PLURAL_RE.exec(text) + UNCOUNTABLE_PLURAL_RE.lastIndex = 0 + if (m) { + const singular = m[1].replace(/s$/i, '') + out.push({ + id: `uncountable:${m[1].toLowerCase()}`, + rule: 'uncountable', + zh: `“${m[1]}” 是不可数名词,不用加 s,写 “${singular}” 就好。`, + en: `“${m[1]}” is uncountable — drop the “s”: just “${singular}”.`, + }) + } +} + +// Languages, nationalities, days, and months are proper nouns in English but +// not in Mandarin, so they're routinely left lowercase. We list only the +// unambiguous ones — “may/march/august/china/turkey/polish” are skipped because +// they're also ordinary lowercase words and would misfire. +const PROPER_NOUNS = + 'monday|tuesday|wednesday|thursday|friday|saturday|sunday|' + + 'january|february|april|june|july|september|october|november|december|' + + 'english|chinese|mandarin|cantonese|japanese|korean|french|spanish|german|' + + 'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' + + 'australian|mexican|brazilian|european' +// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged. +const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`) + +function properCaps(text: string, out: ProseHint[]) { + const m = PROPER_NOUN_RE.exec(text) + if (m) { + const fixed = m[1][0].toUpperCase() + m[1].slice(1) + out.push({ + id: `propercap:${m[1]}`, + rule: 'propercap', + zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`, + en: `Languages, days, and months are capitalized in English: “${fixed}”.`, + }) + } +} + +// Third-person singular verb form, for the agreement rule's suggestion. +function third(verb: string): string { + const v = verb.toLowerCase() + const irregular: Record = { have: 'has', do: 'does', go: 'goes' } + if (irregular[v]) return irregular[v] + if (/[^aeiou]y$/.test(v)) return v.slice(0, -1) + 'ies' + if (/(s|x|z|ch|sh)$/.test(v)) return v + 'es' + return v + 's' +} + +// Subject–verb agreement after he/she/it: a bare (base-form) verb where the +// third-person “-s” form is required. We skip the causative/perception frames +// (“let it go”, “make it work”, “help it grow”) where the bare verb is correct. +const SVA_BASE = + 'go|have|do|make|like|want|need|know|think|say|see|feel|get|take|come|give|' + + 'run|play|work|live|look|seem|become|bring|buy|find|keep|leave|tell|try|call|' + + 'move|turn|put|mean|show|use|love|hope|wish|believe|understand|remember|enjoy' +// The preceding word is optional so a sentence-initial subject (“She have…”) +// still matches; when present it lets us skip causative/perception frames. +const SVA_RE = new RegExp(`(?:(\\w+)\\s+)?\\b(he|she|it)\\s+(${SVA_BASE})\\b`, 'gi') +const CAUSATIVE = new Set([ + 'let', 'lets', 'make', 'makes', 'made', 'help', 'helps', 'helped', 'watch', + 'watches', 'watched', 'see', 'sees', 'saw', 'hear', 'heard', 'have', 'has', + 'had', 'let’s', "let's", +]) + +function subjectVerbAgreement(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + let found = 0 + SVA_RE.lastIndex = 0 + while ((m = SVA_RE.exec(text)) && found < 2) { + if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue + found++ + const fixed = third(m[3]) + out.push({ + id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`, + rule: 'sva', + zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`, + en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`, + }) + } +} + +// Singular noun after a cardinal number (“three apple”). Adjectives between the +// number and noun would misfire (“two small dogs”), so we only flag when the +// candidate is followed by a clause boundary — a verb, preposition, article, or +// punctuation — which means it's the noun itself, not a modifier. +const NUMBER_NOUN_RE = + /\b(two|three|four|five|six|seven|eight|nine|ten)\s+([a-z]{3,})\b(?=\s*(?:[.,!?;:]|$|\s(?:is|are|was|were|and|or|but|that|which|who|in|on|at|to|for|with|will|can|could|would|should|the|a|an)\b))/g +// Irregular plurals / mass nouns that are correct without an “s”. +const NON_S_PLURAL = new Set([ + 'people', 'children', 'men', 'women', 'fish', 'deer', 'sheep', 'feet', 'teeth', + 'mice', 'geese', 'police', 'staff', 'series', 'species', 'aircraft', 'cattle', + 'hundred', 'thousand', 'million', 'billion', 'dozen', 'percent', +]) + +function pluralAfterNumber(text: string, out: ProseHint[]) { + let m: RegExpExecArray | null + let found = 0 + NUMBER_NOUN_RE.lastIndex = 0 + while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) { + const noun = m[2].toLowerCase() + if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue + found++ + out.push({ + id: `plural:${key(m[0])}`, + rule: 'plural', + zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`, + en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`, + }) + } +} + +// 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. +const DOUBLE_DET_RE = + /\b(a|an|the)\s+(a|an|the|my|your|his|her|its|our|their)\b/gi + +function doubleDeterminer(text: string, out: ProseHint[]) { + const m = DOUBLE_DET_RE.exec(text) + DOUBLE_DET_RE.lastIndex = 0 + if (m) { + out.push({ + id: `doubledet:${key(m[0])}`, + rule: 'doubledet', + zh: `“${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`, + en: `“${m[1]} ${m[2]}” stacks two determiners — keep just one.`, + }) + } +} + +// “there is” with a plural quantifier → should be “there are”. We trigger only +// on clearly-plural cues (skip “some/any”, which are fine with singular mass +// nouns: “there is some water”). +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 + +function thereIsPlural(text: string, out: ProseHint[]) { + const m = THERE_IS_RE.exec(text) + THERE_IS_RE.lastIndex = 0 + if (m) { + out.push({ + id: `thereis:${m[1].toLowerCase()}`, + rule: 'thereis', + zh: `后面是复数时用 “there are”:“there are ${m[1]}…”。`, + en: `With a plural, use “there are”: “there are ${m[1]}…”.`, + }) + } +} + +// ── confusables (Tier 2) ───────────────────────────────────────────────────── + +// 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). +const ITS_OWN_RE = /\bit's\s+own\b/i +const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i + +function itsConfusion(text: string, out: ProseHint[]) { + if (ITS_OWN_RE.test(text)) { + out.push({ + id: 'its-own', + rule: 'its', + zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。', + en: '“it’s” means “it is” — the possessive is “its”: “its own”.', + }) + return + } + const m = ITS_ARTICLE_RE.exec(text) + ITS_ARTICLE_RE.lastIndex = 0 + if (m) { + out.push({ + id: 'its-article', + rule: '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.`, + }) + } +} + +// Comparative + “then” where “than” is meant (“better then”, “more then”). We +// use an explicit comparative list rather than a generic “-er” pattern, which +// would snag “after then”, “however”, “remember”, etc. +const COMPARATIVES = + 'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' + + 'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' + + 'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer' +const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})\\s+then\\b`, 'i') + +function thanThen(text: string, out: ProseHint[]) { + const m = THAN_THEN_RE.exec(text) + if (m) { + out.push({ + id: `than:${m[1].toLowerCase()}`, + rule: 'than', + zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`, + en: `For comparisons use “than”, not “then”: “${m[1]} 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.', + }) + } +} + +// ── orchestration ─────────────────────────────────────────────────────────── + +// Rules run in priority order — the ones the writer cares most about first, so +// that when several fire at once the companion leads with the weightiest note. +const RULES: Array<(text: string, out: ProseHint[]) => void> = [ + runOns, + commaSplices, + antecedents, + oxford, + articles, + uncountables, + properCaps, + subjectVerbAgreement, + pluralAfterNumber, + doubleDeterminer, + thereIsPlural, + itsConfusion, + thanThen, + introComma, + doubles, + pronounI, + sentenceCaps, + spaceBeforePunct, + spaceAfterPunct, +] + +// 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). +export function analyzeProse(text: string): ProseHint[] { + const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0 + if (englishWords < 8) return [] + const out: ProseHint[] = [] + for (const rule of RULES) rule(text, out) + return out +} diff --git a/web/src/components/Companion/tips.ts b/web/src/components/Companion/tips.ts index 99d7df5..bb9bc26 100644 --- a/web/src/components/Companion/tips.ts +++ b/web/src/components/Companion/tips.ts @@ -17,7 +17,8 @@ export const ENCOURAGEMENTS: Line[] = [ { zh: '嗯嗯,这样清楚多了 👍', en: 'Mm, that’s much clearer.' }, ] -// Gentle, periodic writing/ESL tips. Surfaced only when nothing else is showing. +// Gentle, generic writing/ESL tips — the fallback when the rules-based prose +// checker (prose.ts) finds nothing concrete to point at in her current text. export const TIPS: Line[] = [ { zh: '小贴士:英文句子短一点,会更清楚哦。', en: 'Tip: shorter English sentences often read clearer.' }, { zh: '别忘了冠词 “the” 和 “a” 哦。', en: "Don't forget articles like “the” and “a”." }, diff --git a/web/src/components/Companion/useCompanion.ts b/web/src/components/Companion/useCompanion.ts index bd90d4f..a1b6f4b 100644 --- a/web/src/components/Companion/useCompanion.ts +++ b/web/src/components/Companion/useCompanion.ts @@ -11,6 +11,7 @@ import { pick, type Line, } from './tips' +import { analyzeProse } from './prose' // The kitten's expression. Maps to a Lottie animation when assets are present, // otherwise to an emoji placeholder (see PetalCompanion). @@ -28,6 +29,9 @@ interface Signals { // suggestion — lets the companion react without a full event bus. editTick: number acceptTick: number + // The document's plain text, used by the rules-based prose checker to offer + // context-aware writing notes instead of only generic tips. + text: string } // Timing knobs (ms). Tuned to feel present but never naggy. @@ -35,14 +39,29 @@ const IDLE_MS = 75_000 // no edits → kitten naps const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles -const BUBBLE_MS = 6_500 // how long a bubble lingers -const CHEER_MS = 4_500 // shorter for quick cheers +// How long a bubble lingers. These are *floors* — readBubbleMs extends them by +// how much there is to read, since she reads both the Mandarin and the English +// (and tips now quote a slice of her own sentence, so they run longer). +const BUBBLE_MS = 9_000 // baseline for a tip / break +const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages +const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read +const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever +const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering const now = () => Date.now() +// Reading time for a bubble: a base floor by tone, stretched by the combined +// length of the Mandarin + English lines so denser advice stays up long enough +// to actually finish reading. +function readBubbleMs(b: Bubble): number { + const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS + const chars = b.zh.length + b.en.length + return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR) +} + // useCompanion is the behavior engine: it watches writing signals and decides // when the kitten speaks, what mood it shows, and how to pace itself so the // companion feels alive without interrupting. UI-agnostic — returns state only. -export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Signals) { +export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) { const [mood, setMood] = useState('idle') const [bubble, setBubble] = useState(null) @@ -54,6 +73,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si const nextMilestone = useRef(0) // index into MILESTONES const sleeping = useRef(false) + // Latest text for the prose checker, read lazily by the heartbeat (kept in a + // ref so per-keystroke changes don't re-arm the interval). + const textRef = useRef(text) + textRef.current = text + // Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged + // each cadence. A small ring — old ids age out and may resurface later. + const recentHints = useRef([]) + const lastRule = useRef('') + const bubbleTimer = useRef>(undefined) const moodTimer = useRef>(undefined) @@ -71,11 +99,30 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si setMood(opts?.celebrate ? 'celebrate' : 'talking') clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) - const dur = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS + const dur = readBubbleMs(b) bubbleTimer.current = setTimeout(() => setBubble(null), dur) moodTimer.current = setTimeout(() => setMood('idle'), dur) }, [bubble]) + // Choose the next spontaneous tip. Prefer a real, context-aware note from the + // rules-based prose checker (it's actionable and clearly about *her* writing); + // fall back to a generic encouragement-style tip when the text reads clean or + // 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. + const nextTip = useCallback((): Bubble => { + const hints = analyzeProse(textRef.current) + const fresh = hints.find( + (h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current, + ) + const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id)) + if (hint) { + lastRule.current = hint.rule + recentHints.current = [hint.id, ...recentHints.current].slice(0, 8) + return { zh: hint.zh, en: hint.en, tone: 'tip' } + } + return { ...pick(TIPS), tone: 'tip' } + }, []) + const dismiss = useCallback(() => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) @@ -83,6 +130,21 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si setMood('idle') }, []) + // Pause the auto-dismiss while she hovers a bubble — so a longer note never + // vanishes mid-read. Releasing leaves it up for a short, fixed grace so it + // doesn't linger forever once she looks away. + const holdBubble = useCallback(() => { + clearTimeout(bubbleTimer.current) + clearTimeout(moodTimer.current) + }, []) + + const releaseBubble = useCallback(() => { + clearTimeout(bubbleTimer.current) + clearTimeout(moodTimer.current) + bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS) + moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS) + }, []) + // Opening hello (once), after a short beat so it doesn't race the first paint. useEffect(() => { const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200) @@ -161,14 +223,15 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si return } - // Otherwise an occasional tip. + // Otherwise an occasional tip — context-aware when her text gives us + // something concrete to gently point at, generic warmth otherwise. if (t - lastTip.current > TIP_MIN_GAP) { lastTip.current = t - say({ ...pick(TIPS), tone: 'tip' }, { proactive: true }) + say(nextTip(), { proactive: true }) } }, 10_000) return () => clearInterval(id) - }, [bubble, say]) + }, [bubble, say, nextTip]) useEffect( () => () => { @@ -178,5 +241,5 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick }: Si [], ) - return { mood, bubble, dismiss } + return { mood, bubble, dismiss, holdBubble, releaseBubble } }