Finish Phase 22: the half of Petal that works with the tunnel down
Grammar lite, the false-friend list, the daily invitation and the offline miscollocations — the four remaining §5–§6 items, all client-side and all alive on a box that cannot reach the model. The offline collocations forced a schema change. `type` had been doubling as the answer to "which engine found this" — `mechanics` meant offline — and that stops being true the moment an offline rule proposes a collocation. Migration 0013 adds `source` (llm | local) and every pass now scopes its DELETE by engine; without it the coach silently wiped every offline chunk on the page. Existing rows backfill by type, so a pre-0013 collocation row is claimed as the coach's, which it was: the offline list did not exist yet. The rule pack is hand-curated rather than mined, and the entries left out are the point — `married with` is wrong until "married with children", `arrive to` wants at or in depending on the noun. A pack running on every keystroke must not correct correct writing. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -13,6 +13,11 @@ interface Props {
|
||||
editTick: number
|
||||
acceptTick: number
|
||||
text: string
|
||||
// The open document is still empty — the one state the daily writing
|
||||
// invitation is offered in.
|
||||
blankPage: boolean
|
||||
// Called when she takes the invitation up, with the English prompt.
|
||||
onAcceptInvitation: (prompt: string) => void
|
||||
}
|
||||
|
||||
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
|
||||
@@ -30,17 +35,38 @@ 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, llmDown, editTick, acceptTick, text }: Props) {
|
||||
export function PetalCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
llmDown,
|
||||
editTick,
|
||||
acceptTick,
|
||||
text,
|
||||
blankPage,
|
||||
onAcceptInvitation,
|
||||
}: Props) {
|
||||
const t = usePack()
|
||||
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||
const {
|
||||
mood,
|
||||
bubble,
|
||||
dismiss,
|
||||
holdBubble,
|
||||
releaseBubble,
|
||||
acceptInvite,
|
||||
declineInvite,
|
||||
setInviteHandler,
|
||||
} = useCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
llmDown,
|
||||
editTick,
|
||||
acceptTick,
|
||||
text,
|
||||
blankPage,
|
||||
})
|
||||
|
||||
useEffect(() => setInviteHandler(onAcceptInvitation), [setInviteHandler, onAcceptInvitation])
|
||||
|
||||
const [companionId, setCompanionId] = useState<string>(
|
||||
() => readPref(STORAGE_KEY) || DEFAULT_COMPANION,
|
||||
)
|
||||
@@ -179,6 +205,36 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
||||
>
|
||||
{bubble.en}
|
||||
</p>
|
||||
|
||||
{/* The daily invitation's two answers. "Not today" is a real button
|
||||
sitting level with the other one, not a small grey escape — a no
|
||||
that has to be hunted for isn't much of a no. */}
|
||||
{bubble.invite && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
acceptInvite(bubble.invite!.prompt)
|
||||
}}
|
||||
className="rounded-full px-3.5 py-1.5 text-sm font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
{t.companion.inviteAccept}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
declineInvite()
|
||||
}}
|
||||
className="rounded-full px-3.5 py-1.5 text-sm font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
>
|
||||
{t.companion.inviteDecline}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { analyzeProse, mechanicsFindings } from './prose'
|
||||
import { resetPackForTests, setPackLang } from '../../i18n'
|
||||
|
||||
// Phase 22's offline half: the grammar-lite rule pack, the embedded
|
||||
// miscollocation list, the false-friend heads-up, and the per-pair L1 rules.
|
||||
//
|
||||
// The bar these tests enforce is the one the pack promises: **precision over
|
||||
// recall**. Every rule is pinned in two directions — the mistake it must catch,
|
||||
// and the correct English next to it that it must leave alone. A rule that
|
||||
// cannot be guarded that way was left out of the pack rather than tested
|
||||
// loosely here.
|
||||
|
||||
afterEach(() => resetPackForTests())
|
||||
|
||||
const rules = (text: string) => analyzeProse(text).map((h) => h.rule)
|
||||
const findings = (text: string) => mechanicsFindings(text)
|
||||
|
||||
// The one-click fix a given rule produced, if any.
|
||||
function fix(text: string, original: string) {
|
||||
return findings(text).find((f) => f.original.toLowerCase() === original.toLowerCase())
|
||||
}
|
||||
|
||||
// Every finding must be able to anchor: its span has to be exactly the text it
|
||||
// claims, or the editor applies the edit to the wrong characters.
|
||||
function expectExactSpans(text: string) {
|
||||
for (const f of findings(text)) {
|
||||
expect(text.slice(f.from, f.to), `span mismatch for "${f.original}"`).toBe(f.original)
|
||||
}
|
||||
return findings(text)
|
||||
}
|
||||
|
||||
describe('prepositions', () => {
|
||||
it('depend of → depend on, keeping the writer\'s own verb form', () => {
|
||||
expect(fix('It all depends of the weather on the day we travel.', 'depends of')?.replacement).toBe(
|
||||
'depends on',
|
||||
)
|
||||
expect(fix('Depending of the weather we will go to the beach today.', 'Depending of')?.replacement).toBe(
|
||||
'Depending on',
|
||||
)
|
||||
})
|
||||
|
||||
it('discuss about → discuss (the preposition simply goes)', () => {
|
||||
expect(fix('We discussed about the plan for a long time yesterday.', 'discussed about')?.replacement).toBe(
|
||||
'discussed',
|
||||
)
|
||||
})
|
||||
|
||||
it('explain me → explain to me', () => {
|
||||
expect(fix('Can you explain me the rules of this game again please.', 'explain me')?.replacement).toBe(
|
||||
'explain to me',
|
||||
)
|
||||
})
|
||||
|
||||
it('listen the radio → listen to the radio', () => {
|
||||
expect(fix('I listen the radio every morning while I make my coffee.', 'listen the')?.replacement).toBe(
|
||||
'listen to the',
|
||||
)
|
||||
})
|
||||
|
||||
// The pairings deliberately NOT in the list, because they are only usually
|
||||
// wrong. Each of these is correct English and must stay silent.
|
||||
it('leaves the correct prepositions alone', () => {
|
||||
expect(rules('It all depends on the weather on the day we travel.')).not.toContain('preposition')
|
||||
expect(rules('We discussed the plan for a long time yesterday afternoon.')).not.toContain('preposition')
|
||||
expect(rules('I listen to the radio every morning while I make coffee.')).not.toContain('preposition')
|
||||
// Left out of the pack on purpose — "married with children" is a phrase,
|
||||
// "arrive to" wants at or in, "different than" is ordinary American usage.
|
||||
expect(rules('She is married with children and lives near the old harbour.')).not.toContain('preposition')
|
||||
expect(rules('This result is different than the one we saw last week.')).not.toContain('preposition')
|
||||
})
|
||||
})
|
||||
|
||||
describe('doubled comparatives', () => {
|
||||
it('more better → better', () => {
|
||||
expect(fix('This one is more better than the other one we tried.', 'more better')?.replacement).toBe('better')
|
||||
})
|
||||
|
||||
it('most easiest → easiest', () => {
|
||||
expect(fix('That was the most easiest question on the whole exam paper.', 'most easiest')?.replacement).toBe(
|
||||
'easiest',
|
||||
)
|
||||
})
|
||||
|
||||
// The guard the generic /\w+er/ pattern would have failed: these are correct.
|
||||
it('leaves ordinary "more/most + adjective" alone', () => {
|
||||
expect(rules('She is more clever than anyone else in the whole class.')).not.toContain('doublecomp')
|
||||
expect(rules('He was the most eager student in the room that morning.')).not.toContain('doublecomp')
|
||||
expect(rules('This is the most beautiful garden I have ever seen here.')).not.toContain('doublecomp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('people is', () => {
|
||||
it('people is → people are, and people has → people have', () => {
|
||||
expect(fix('Many people is waiting outside the hall in the rain.', 'people is')?.replacement).toBe('people are')
|
||||
expect(fix('Some people has never seen the sea in their whole life.', 'people has')?.replacement).toBe(
|
||||
'people have',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the plural alone', () => {
|
||||
expect(rules('Many people are waiting outside the hall in the rain.')).not.toContain('peopleare')
|
||||
})
|
||||
})
|
||||
|
||||
describe('miscollocations', () => {
|
||||
// The whole point of the family: these file as 'collocation', not
|
||||
// 'mechanics', so an accepted chunk plants in the vocabulary garden exactly
|
||||
// as one the LLM coach proposed would.
|
||||
it('files as the collocation family, not as mechanics', () => {
|
||||
const f = fix('I had to do a decision about the job offer quickly.', 'do a decision')
|
||||
expect(f?.replacement).toBe('make a decision')
|
||||
expect(f?.type).toBe('collocation')
|
||||
})
|
||||
|
||||
it('mechanics fixes keep their own family', () => {
|
||||
expect(fix('I saw the the cat in the garden this morning.', 'the the')?.type).toBe('mechanics')
|
||||
})
|
||||
|
||||
it('agrees with the tense the writer was already using', () => {
|
||||
expect(fix('She did a mistake on the form and had to start again.', 'did a mistake')?.replacement).toBe(
|
||||
'made a mistake',
|
||||
)
|
||||
expect(fix('He is making his homework at the kitchen table right now.', 'making his homework')?.replacement).toBe(
|
||||
'doing his homework',
|
||||
)
|
||||
})
|
||||
|
||||
it('say me → tell me', () => {
|
||||
expect(fix('Please say me what happened at the meeting this afternoon.', 'say me')?.replacement).toBe('tell me')
|
||||
})
|
||||
|
||||
it('make a photo → take a photo', () => {
|
||||
expect(fix('We made a photo together in front of the old church.', 'made a photo')?.replacement).toBe(
|
||||
'took a photo',
|
||||
)
|
||||
})
|
||||
|
||||
it('strong rain → heavy rain', () => {
|
||||
expect(fix('There was strong rain all afternoon and we stayed inside.', 'strong rain')?.replacement).toBe(
|
||||
'heavy rain',
|
||||
)
|
||||
})
|
||||
|
||||
// "strong wind" is the correct pairing, and the rule that fixes "big wind"
|
||||
// must not propose it as a change to itself.
|
||||
it('never proposes a phrase identical to what she wrote', () => {
|
||||
expect(rules('There was a strong wind blowing across the open field today.')).not.toContain('collocation')
|
||||
expect(fix('There was a big wind blowing across the open field today.', 'big wind')?.replacement).toBe(
|
||||
'strong wind',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the correct pairings alone', () => {
|
||||
expect(rules('I had to make a decision about the job offer quickly.')).not.toContain('collocation')
|
||||
expect(rules('She does her homework at the kitchen table every evening.')).not.toContain('collocation')
|
||||
expect(rules('We took a photo together in front of the old church.')).not.toContain('collocation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('false friends', () => {
|
||||
it('flags a Portuguese false friend for the pt-PT pair, awareness-only', () => {
|
||||
setPackLang('pt-PT')
|
||||
const hints = analyzeProse('I will eventually finish the report before the end of the week.')
|
||||
const ff = hints.find((h) => h.rule === 'falsefriend')
|
||||
expect(ff).toBeDefined()
|
||||
// Never a card: the word may well be the one she meant, and a one-click
|
||||
// "fix" would be Petal deciding that for her.
|
||||
expect(ff?.fix).toBeUndefined()
|
||||
expect(findings('I will eventually finish the report before the end of the week.')).toEqual([])
|
||||
})
|
||||
|
||||
it('raises at most one per pass — a heads-up, not a sweep', () => {
|
||||
setPackLang('pt-PT')
|
||||
const hints = analyzeProse(
|
||||
'Actually I did not pretend to assist the lecture at the library this week.',
|
||||
)
|
||||
expect(hints.filter((h) => h.rule === 'falsefriend')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('says nothing for the zh pair, which has no false friends at all', () => {
|
||||
setPackLang('zh')
|
||||
expect(rules('I will eventually finish the report before the end of the week.')).not.toContain('falsefriend')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-pair L1 interference', () => {
|
||||
it('pt-PT: "have 30 years" → "am 30 years old"', () => {
|
||||
setPackLang('pt-PT')
|
||||
expect(fix('My sister I have 30 years and she is older than me.', 'I have 30 years')?.replacement).toBe(
|
||||
'I am 30 years old',
|
||||
)
|
||||
// The subject and tense she wrote in are carried into the correction.
|
||||
expect(fix('When we met she had twenty years and I was still at school.', 'she had twenty years')?.replacement).toBe(
|
||||
'she was twenty years old',
|
||||
)
|
||||
})
|
||||
|
||||
it('pt-PT: "I am agree" → "I agree"', () => {
|
||||
setPackLang('pt-PT')
|
||||
expect(fix('I am agree with everything that was said at the meeting.', 'I am agree')?.replacement).toBe('I agree')
|
||||
})
|
||||
|
||||
it('pt-PT: "since three years" → "for three years"', () => {
|
||||
setPackLang('pt-PT')
|
||||
expect(fix('I have lived in this city since three years and I love it.', 'since three years')?.replacement).toBe(
|
||||
'for three years',
|
||||
)
|
||||
})
|
||||
|
||||
it('pt-PT: leaves "since" with a starting point alone', () => {
|
||||
setPackLang('pt-PT')
|
||||
expect(rules('I have lived in this city since 2020 and I still love it.')).not.toContain('since')
|
||||
})
|
||||
|
||||
it('zh: "very like" → "really like", and "open the light" → "turn on the light"', () => {
|
||||
setPackLang('zh')
|
||||
expect(fix('I very like the small garden behind my grandmother house.', 'very like')?.replacement).toBe(
|
||||
'really like',
|
||||
)
|
||||
expect(fix('Please open the light before you come into the dark room.', 'open the light')?.replacement).toBe(
|
||||
'turn on the light',
|
||||
)
|
||||
expect(fix('She closed the television and went straight to bed last night.', 'closed the television')?.replacement).toBe(
|
||||
'turned off the television',
|
||||
)
|
||||
})
|
||||
|
||||
it('zh: although…but is awareness-only — the "but" is too common to anchor a card to', () => {
|
||||
setPackLang('zh')
|
||||
const text = 'Although it was raining hard, but we still went to the park.'
|
||||
expect(rules(text)).toContain('althoughbut')
|
||||
expect(findings(text).some((f) => f.original.includes('but'))).toBe(false)
|
||||
})
|
||||
|
||||
it('zh: leaves "very + adjective" alone', () => {
|
||||
setPackLang('zh')
|
||||
expect(rules('I am very happy about the small garden behind the house.')).not.toContain('veryverb')
|
||||
})
|
||||
|
||||
// The gating is the reason these rules can be confident. A rule that is a
|
||||
// near-certainty for one L1 is only a guess for another, and a guess does not
|
||||
// belong in a rule pack that runs on every keystroke.
|
||||
it('does not run one pair\'s interference rules for the other pair', () => {
|
||||
setPackLang('zh')
|
||||
expect(rules('I have 30 years and I still live near the old harbour.')).not.toContain('haveyears')
|
||||
setPackLang('pt-PT')
|
||||
expect(rules('I very like the small garden behind my grandmother house.')).not.toContain('veryverb')
|
||||
})
|
||||
})
|
||||
|
||||
describe('spans stay exact across every new rule', () => {
|
||||
it('anchors each finding to the text it claims', () => {
|
||||
setPackLang('pt-PT')
|
||||
expectExactSpans(
|
||||
'I have 30 years and I am agree that we depends of the weather, and she did a mistake since three years.',
|
||||
)
|
||||
setPackLang('zh')
|
||||
expectExactSpans('I very like to open the light, and many people is more better at it than me.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { markInvited, mayInvite, todayKey } from './invitation'
|
||||
import { resetPackForTests, setPackLang } from '../../i18n'
|
||||
import { zh } from '../../i18n/packs/zh'
|
||||
import { ptPT } from '../../i18n/packs/pt-PT'
|
||||
import { declined, invitations } from './tips'
|
||||
|
||||
// The suite runs without a DOM, so localStorage is stubbed the same way
|
||||
// prefs.test.ts does it.
|
||||
function fakeStorage(): Storage {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return map.size
|
||||
},
|
||||
key: (i: number) => [...map.keys()][i] ?? null,
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void map.set(k, v),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
} as Storage
|
||||
}
|
||||
|
||||
beforeEach(() => vi.stubGlobal('localStorage', fakeStorage()))
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
resetPackForTests()
|
||||
})
|
||||
|
||||
describe('once a day', () => {
|
||||
it('offers, then does not offer again the same day', () => {
|
||||
expect(mayInvite()).toBe(true)
|
||||
markInvited()
|
||||
expect(mayInvite()).toBe(false)
|
||||
})
|
||||
|
||||
it('offers again the next day', () => {
|
||||
const monday = new Date(2026, 6, 27, 10, 0)
|
||||
const tuesday = new Date(2026, 6, 28, 9, 0)
|
||||
markInvited(monday)
|
||||
expect(mayInvite(monday)).toBe(false)
|
||||
expect(mayInvite(tuesday)).toBe(true)
|
||||
})
|
||||
|
||||
// The whole promise of §5c: nothing is counting. A month away has to look
|
||||
// exactly like a day away, because the alternative is a streak, and a streak
|
||||
// punishes exactly the person this feature is for.
|
||||
it('treats a month away the same as a day away', () => {
|
||||
const june = new Date(2026, 5, 1, 10, 0)
|
||||
const july = new Date(2026, 6, 27, 10, 0)
|
||||
markInvited(june)
|
||||
expect(mayInvite(july)).toBe(true)
|
||||
// And after being asked again, still only ever one stored value: a date.
|
||||
markInvited(july)
|
||||
expect(localStorage.getItem('petal.invited')).toBe(todayKey(july))
|
||||
// One stored value, and it is a date. Nothing accumulates.
|
||||
expect(localStorage.length).toBe(1)
|
||||
})
|
||||
|
||||
it('uses the writer\'s local day, so a late night and the small hours differ', () => {
|
||||
const lateNight = new Date(2026, 6, 27, 23, 30)
|
||||
const smallHours = new Date(2026, 6, 28, 1, 15)
|
||||
markInvited(lateNight)
|
||||
expect(mayInvite(smallHours)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the invitation copy', () => {
|
||||
const PACKS = [
|
||||
{ name: 'zh', pack: zh },
|
||||
{ name: 'pt-PT', pack: ptPT },
|
||||
]
|
||||
|
||||
it('every pack offers something to write about, and a way to say no', () => {
|
||||
for (const { name, pack } of PACKS) {
|
||||
expect(pack.companion.invitations.length, name).toBeGreaterThan(3)
|
||||
expect(pack.companion.inviteAccept.trim(), name).not.toBe('')
|
||||
expect(pack.companion.inviteDecline.trim(), name).not.toBe('')
|
||||
expect(pack.companion.declined.native.trim(), name).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
// The copy is bound by the same rule as the timing: no streaks, no guilt, no
|
||||
// counting of days. This is the part a well-meaning future edit would undo —
|
||||
// "day 4 in a row!" is a natural thing to write and the wrong thing to say.
|
||||
it('never invokes a streak, a target, or a missed day', () => {
|
||||
const forbidden =
|
||||
/streak|in a row|every day|don't break|dont break|missed|behind|连续|打卡|坚持|seguidos|todos os dias|falhaste/i
|
||||
for (const { name, pack } of PACKS) {
|
||||
const copy = [
|
||||
...pack.companion.invitations.flatMap((l) => [l.native, l.en]),
|
||||
pack.companion.inviteAccept,
|
||||
pack.companion.inviteDecline,
|
||||
pack.companion.declined.native,
|
||||
pack.companion.declined.en,
|
||||
]
|
||||
for (const line of copy) {
|
||||
expect(line, `${name}: ${line}`).not.toMatch(forbidden)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reads from the pair in force, like every other companion line', () => {
|
||||
setPackLang('pt-PT')
|
||||
expect(invitations()).toBe(ptPT.companion.invitations)
|
||||
expect(declined()).toBe(ptPT.companion.declined)
|
||||
setPackLang('zh')
|
||||
expect(invitations()).toBe(zh.companion.invitations)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
// When the companion may offer its daily invitation to write.
|
||||
//
|
||||
// A handful of lines, pulled out of the timing engine on purpose: this is the
|
||||
// part of §5c with the ethics in it, and it should be readable and testable on
|
||||
// its own rather than buried in a heartbeat.
|
||||
//
|
||||
// The rule is a *date*, not a count and not a run of days. Petal remembers the
|
||||
// last day it asked and nothing else — so there is no streak to break, no tally
|
||||
// of days missed, and nothing that gets worse for being away a week. Coming
|
||||
// back after a month looks exactly like coming back tomorrow, which is the only
|
||||
// version of this feature worth shipping to someone learning a language.
|
||||
//
|
||||
// Either answer spends the day's invitation. Being asked again after saying no
|
||||
// would turn "not today" into a negotiation.
|
||||
|
||||
import { readPref, writePref } from '../../lib/prefs'
|
||||
|
||||
const INVITED_KEY = 'petal.invited'
|
||||
|
||||
// The local calendar day. Local rather than UTC because "today" is the writer's
|
||||
// day: a nudge at 11pm and another at 1am would otherwise be two different days.
|
||||
export function todayKey(now = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
// mayInvite reports whether today's invitation is still unspent.
|
||||
export function mayInvite(now = new Date()): boolean {
|
||||
return readPref(INVITED_KEY) !== todayKey(now)
|
||||
}
|
||||
|
||||
// markInvited spends it — called when the invitation is *offered*, not when it
|
||||
// is accepted, because declining has to count too.
|
||||
export function markInvited(now = new Date()): void {
|
||||
writePref(INVITED_KEY, todayKey(now))
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
// fix-bearing hints so the same span never appears as both a bubble and a card.
|
||||
|
||||
import { pack } from '../../i18n'
|
||||
import type { PairLang } from '../../i18n'
|
||||
import type { Line } from './tips'
|
||||
|
||||
// The pair's prose copy. Read per finding rather than captured once, so a rule
|
||||
@@ -41,8 +42,16 @@ export interface Fix {
|
||||
from: number
|
||||
to: number
|
||||
replacement: string
|
||||
// Which suggestion family the card belongs to. Omitted means 'mechanics' — a
|
||||
// fix to *this* sentence. The miscollocation rules set 'collocation', because
|
||||
// what they hand over is a reusable chunk: the writer sees the same rail and
|
||||
// the same warm phrasing as the LLM coach, and an accepted chunk plants in the
|
||||
// vocabulary garden exactly as the coach's would.
|
||||
family?: FindingFamily
|
||||
}
|
||||
|
||||
export type FindingFamily = 'mechanics' | 'collocation'
|
||||
|
||||
// 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
|
||||
@@ -53,6 +62,9 @@ export interface MechanicsFinding {
|
||||
original: string
|
||||
replacement: string
|
||||
explanation: string
|
||||
// The family the server should file it under (see Fix.family). Always sent, so
|
||||
// the backend never has to infer it from the endpoint it arrived on.
|
||||
type: FindingFamily
|
||||
}
|
||||
|
||||
// ── small text helpers ──────────────────────────────────────────────────────
|
||||
@@ -630,19 +642,438 @@ function thanThen(text: string, out: ProseHint[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// "people is" — people is already plural in English, and every language Petal
|
||||
// pairs with has a singular word for it (人 / a gente / les gens / la gente).
|
||||
// Universal rather than per-pair for exactly that reason.
|
||||
const PEOPLE_IS_RE = /\b(people)\s+(is|was|has)\b/gi
|
||||
const PEOPLE_PLURAL: Record<string, string> = { is: 'are', was: 'were', has: 'have' }
|
||||
|
||||
function peopleAre(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
PEOPLE_IS_RE.lastIndex = 0
|
||||
while ((m = PEOPLE_IS_RE.exec(text))) {
|
||||
const plural = PEOPLE_PLURAL[m[2].toLowerCase()]
|
||||
const fixed = `${m[1]} ${matchCase(m[2], plural)}`
|
||||
out.push({
|
||||
id: `peopleare:${m.index}`,
|
||||
rule: 'peopleare',
|
||||
native: P().peopleArePlural(plural),
|
||||
en: `“People” is plural in English: “people ${plural}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── preposition pairs ───────────────────────────────────────────────────────
|
||||
// The verbs and adjectives whose preposition English simply *decides* for you.
|
||||
// There is no rule to learn here — "depend" takes "on" and that is the end of
|
||||
// it — which is exactly what makes the offline pack the right place for them:
|
||||
// they are data, not judgement, and a lookup is instant.
|
||||
//
|
||||
// Every entry is a pairing that is wrong in essentially all contexts. The ones
|
||||
// that are only *usually* wrong were left out on purpose: "married with" is a
|
||||
// mistake until "married with children", "arrive to" wants at or in depending
|
||||
// on the noun, "different than" is ordinary American English. Precision over
|
||||
// recall — a confident wrong correction costs more than a quiet miss.
|
||||
interface PrepRule {
|
||||
// Matched case-insensitively, with \b at both ends. The first group is the
|
||||
// head word (kept, casing preserved), the rest is replaced wholesale.
|
||||
re: RegExp
|
||||
// The corrected phrase, with $1 standing for the captured head word.
|
||||
to: string
|
||||
}
|
||||
|
||||
const PREPOSITIONS: PrepRule[] = [
|
||||
{ re: /\b(depend|depends|depended|depending)\s+of\b/gi, to: '$1 on' },
|
||||
{ re: /\b(discuss|discusses|discussed|discussing)\s+about\b/gi, to: '$1' },
|
||||
{ re: /\b(participate|participates|participated|participating)\s+to\b/gi, to: '$1 in' },
|
||||
{ re: /\b(interested)\s+(?:about|for)\b/gi, to: '$1 in' },
|
||||
{ re: /\b(responsible)\s+of\b/gi, to: '$1 for' },
|
||||
{ re: /\b(capable)\s+to\b/gi, to: '$1 of' },
|
||||
{ re: /\b(afraid)\s+(?:from|of to)\b/gi, to: '$1 of' },
|
||||
{ re: /\b(according)\s+with\b/gi, to: '$1 to' },
|
||||
{ re: /\b(explain|explains|explained)\s+(me|us|him|her|them)\b/gi, to: '$1 to $2' },
|
||||
// "listen the radio" — the object of "listen" always arrives through "to".
|
||||
{ re: /\b(listen|listens|listened|listening)\s+(the|a|an|my|your|his|her|our|their|this|that|these|those|music|me|him|us|them)\b/gi, to: '$1 to $2' },
|
||||
]
|
||||
|
||||
function prepositions(text: string, out: ProseHint[]) {
|
||||
for (const rule of PREPOSITIONS) {
|
||||
let m: RegExpExecArray | null
|
||||
rule.re.lastIndex = 0
|
||||
while ((m = rule.re.exec(text))) {
|
||||
// Rebuild the corrected phrase from the captures so the head word keeps the
|
||||
// writer's own casing ("Depending of" → "Depending on").
|
||||
const fixed = rule.to.replace(/\$(\d)/g, (_, d: string) => m![Number(d)] ?? '')
|
||||
if (fixed === m[0]) continue
|
||||
out.push({
|
||||
id: `prep:${m.index}:${key(m[0])}`,
|
||||
rule: 'preposition',
|
||||
native: P().preposition(m[0].trim(), fixed),
|
||||
en: `In English it's “${fixed}”, not “${m[0].trim()}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Doubled comparatives and superlatives — "more better", "most easiest". The
|
||||
// -er/-est ending already carries the comparison, so the "more"/"most" is the
|
||||
// part that goes. An explicit form list rather than a generic /\w+er/ pattern,
|
||||
// which would catch "more clever" and "most eager" (both perfectly correct).
|
||||
const COMPARATIVE_FORMS =
|
||||
'better|worse|greater|older|younger|bigger|smaller|larger|faster|slower|higher|' +
|
||||
'lower|cheaper|stronger|weaker|easier|harder|earlier|later|sooner|longer|' +
|
||||
'shorter|taller|richer|poorer|happier|safer|nicer|closer|warmer|colder'
|
||||
const SUPERLATIVE_FORMS =
|
||||
'best|worst|greatest|oldest|youngest|biggest|smallest|largest|fastest|slowest|' +
|
||||
'highest|lowest|cheapest|strongest|weakest|easiest|hardest|earliest|latest|' +
|
||||
'soonest|longest|shortest|tallest|richest|poorest|happiest|safest|nicest|' +
|
||||
'closest|warmest|coldest'
|
||||
const DOUBLE_COMPARATIVE_RE = new RegExp(
|
||||
`\\b(more)\\s+(${COMPARATIVE_FORMS})\\b|\\b(most)\\s+(${SUPERLATIVE_FORMS})\\b`,
|
||||
'gi',
|
||||
)
|
||||
|
||||
function doubleComparative(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
DOUBLE_COMPARATIVE_RE.lastIndex = 0
|
||||
while ((m = DOUBLE_COMPARATIVE_RE.exec(text))) {
|
||||
const lead = m[1] ?? m[3]
|
||||
const word = m[2] ?? m[4]
|
||||
out.push({
|
||||
id: `doublecomp:${m.index}`,
|
||||
rule: 'doublecomp',
|
||||
native: P().doubleComparative(lead, word),
|
||||
en: `“${word}” is already the comparison — “${lead}” isn't needed: just “${word}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: matchCase(lead, word) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── miscollocations (the collocation family, offline half) ──────────────────
|
||||
// A few dozen entries of curated data doing what the collocation coach does
|
||||
// with a model behind a VPN. These are the do/make, say/tell, heavy-rain pairs
|
||||
// that fill every ESL collocation workbook: the writer's grammar is perfect and
|
||||
// the pairing is simply not the one English uses.
|
||||
//
|
||||
// They file as 'collocation', not 'mechanics', because that is what they are —
|
||||
// and it earns the writer the rest of the family's behaviour for free: the same
|
||||
// card, and a phrase card planted in the vocabulary garden when she accepts.
|
||||
// The writer never needs to know which engine spoke.
|
||||
//
|
||||
// Each entry is a whole-phrase swap so the card can anchor by string, and the
|
||||
// object is captured rather than listed, so "do a serious mistake" is caught
|
||||
// alongside "do a mistake".
|
||||
interface CollocationRule {
|
||||
re: RegExp
|
||||
to: string
|
||||
}
|
||||
|
||||
const MISCOLLOCATIONS: CollocationRule[] = [
|
||||
// do / make — the classic pair, in both directions.
|
||||
{ re: /\b(do|does|did|doing)\s+(a|an|the|my|your|his|her|our|their)\s+(decision|mistake|mistakes|effort|question|questions|progress|joke|jokes|favou?r)\b/gi, to: 'MAKE' },
|
||||
{ re: /\b(make|makes|made|making)\s+(my|your|his|her|our|their|the)\s+(homework|housework|laundry|dishes|shopping)\b/gi, to: 'DO' },
|
||||
{ re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her|our|their)\s+(photo|photos|picture|pictures|shower|bath|walk|trip|nap|break|exam|exams|test|bus|taxi|train)\b/gi, to: 'TAKE' },
|
||||
{ re: /\b(make|makes|made|making)\s+(a|an|the)\s+(party|baby|good time|meeting)\b/gi, to: 'HAVE' },
|
||||
{ re: /\b(make|makes|made|making)\s+(a|an|the|my|your|his|her)\s+(question|questions)\b/gi, to: 'ASK' },
|
||||
{ re: /\b(make|makes|made|making|do|does|did|doing)\s+attention\b/gi, to: 'PAY_ATTENTION' },
|
||||
// say / tell — "say me" for "tell me" is near-universal among learners.
|
||||
{ re: /\b(say|says|said|saying)\s+(me|him|her|us|them)\b/gi, to: 'TELL' },
|
||||
{ re: /\b(say|says|said|saying)\s+(a|the)\s+(lie|lies|truth|joke|jokes|story|stories)\b/gi, to: 'TELL_A' },
|
||||
// Weather and intensity — English picks a different adjective per noun.
|
||||
{ re: /\b(strong|big|hard|huge)\s+(rain|snow|traffic|fog)\b/gi, to: 'HEAVY' },
|
||||
{ re: /\b(strong|heavy|big)\s+(wind|winds)\b/gi, to: 'STRONG_WIND' },
|
||||
]
|
||||
|
||||
// Rebuild the corrected phrase for one miscollocation match. Kept as code rather
|
||||
// than a `to` template because the verb has to agree with the writer's own tense
|
||||
// ("did a mistake" → "made a mistake"), and only the verb form knows that.
|
||||
const VERB_FORMS: Record<string, Record<string, string>> = {
|
||||
make: { base: 'make', s: 'makes', past: 'made', ing: 'making' },
|
||||
do: { base: 'do', s: 'does', past: 'did', ing: 'doing' },
|
||||
take: { base: 'take', s: 'takes', past: 'took', ing: 'taking' },
|
||||
have: { base: 'have', s: 'has', past: 'had', ing: 'having' },
|
||||
ask: { base: 'ask', s: 'asks', past: 'asked', ing: 'asking' },
|
||||
tell: { base: 'tell', s: 'tells', past: 'told', ing: 'telling' },
|
||||
pay: { base: 'pay', s: 'pays', past: 'paid', ing: 'paying' },
|
||||
}
|
||||
|
||||
// Which slot of VERB_FORMS the writer's own verb occupies, so the replacement
|
||||
// lands in the same tense she was writing in.
|
||||
function verbSlot(verb: string): string {
|
||||
const v = verb.toLowerCase()
|
||||
if (v.endsWith('ing')) return 'ing'
|
||||
if (v === 'did' || v === 'made' || v === 'took' || v === 'had' || v === 'told' || v === 'said' || v === 'paid' || v.endsWith('ed')) return 'past'
|
||||
if (v === 'does' || v === 'says' || v === 'has' || v.endsWith('s')) return 's'
|
||||
return 'base'
|
||||
}
|
||||
|
||||
function collocationFix(m: RegExpExecArray, to: string): string | null {
|
||||
const slot = verbSlot(m[1])
|
||||
const conj = (v: string) => matchCase(m[1], VERB_FORMS[v][slot])
|
||||
switch (to) {
|
||||
case 'MAKE':
|
||||
case 'DO':
|
||||
case 'TAKE':
|
||||
case 'HAVE':
|
||||
case 'ASK':
|
||||
return `${conj(to.toLowerCase())} ${m[2]} ${m[3]}`
|
||||
case 'TELL':
|
||||
return `${conj('tell')} ${m[2]}`
|
||||
case 'TELL_A':
|
||||
return `${conj('tell')} ${m[2]} ${m[3]}`
|
||||
case 'PAY_ATTENTION':
|
||||
return `${conj('pay')} attention`
|
||||
case 'HEAVY':
|
||||
return `${matchCase(m[1], 'heavy')} ${m[2]}`
|
||||
case 'STRONG_WIND':
|
||||
return m[1].toLowerCase() === 'strong' ? null : `${matchCase(m[1], 'strong')} ${m[2]}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function miscollocations(text: string, out: ProseHint[]) {
|
||||
for (const rule of MISCOLLOCATIONS) {
|
||||
let m: RegExpExecArray | null
|
||||
rule.re.lastIndex = 0
|
||||
while ((m = rule.re.exec(text))) {
|
||||
const fixed = collocationFix(m, rule.to)
|
||||
if (!fixed || fixed.toLowerCase() === m[0].toLowerCase()) continue
|
||||
out.push({
|
||||
id: `colloc:${m.index}:${key(m[0])}`,
|
||||
rule: 'collocation',
|
||||
native: P().collocation(m[0].trim(), fixed),
|
||||
en: `English usually pairs these differently: “${fixed}” rather than “${m[0].trim()}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed, family: 'collocation' },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── false friends ───────────────────────────────────────────────────────────
|
||||
// A word that looks like one of hers and means something else. This is the
|
||||
// mistake that makes a learner feel foolish rather than merely corrected, so
|
||||
// Petal only ever raises an eyebrow: awareness-only, one per pass, and never a
|
||||
// replacement. "Actually" really might be the word she wanted — the flag says
|
||||
// what it means in English and lets her decide.
|
||||
//
|
||||
// The list is per pair and lives in the langpack (a zh pair has none: the trap
|
||||
// needs a shared script to spring). See Pack.falseFriends.
|
||||
function falseFriends(text: string, out: ProseHint[]) {
|
||||
const list = pack().falseFriends
|
||||
const words = Object.keys(list)
|
||||
if (words.length === 0) return
|
||||
// Escaped, because these keys are pack data: a future author writing "e.g."
|
||||
// should get a heads-up, not a pattern that quietly matches everything.
|
||||
const re = new RegExp(`\\b(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`, 'gi')
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text))) {
|
||||
const entry = list[m[1].toLowerCase()]
|
||||
if (!entry) continue
|
||||
out.push({
|
||||
id: `falsefriend:${m[1].toLowerCase()}`,
|
||||
rule: 'falsefriend',
|
||||
native: entry.native,
|
||||
en: entry.en,
|
||||
})
|
||||
return // one per pass — a heads-up, not a sweep
|
||||
}
|
||||
}
|
||||
|
||||
// ── per-pair L1 interference ────────────────────────────────────────────────
|
||||
// Mistakes that are not "English mistakes" at all but the writer's own language
|
||||
// showing through: *ter 30 anos* becomes "have 30 years", 很喜欢 becomes "very
|
||||
// like", 开灯 becomes "open the light". They are gated by pair precisely so they
|
||||
// can be confident — a pattern that is a near-certainty for a Portuguese speaker
|
||||
// is only a guess for anybody else, and a guess doesn't belong in a rule pack.
|
||||
//
|
||||
// The rules a pair *doesn't* get are as deliberate as the ones it does. Mandarin
|
||||
// drops articles and slips he/she, both of which the plan names — and neither is
|
||||
// detectable from the text alone. "She said he was late" is a perfect sentence
|
||||
// whichever pronoun was meant, and no offline rule can tell a missing "the" from
|
||||
// a mass noun. Flagging them would mean correcting correct writing, which is the
|
||||
// one thing this pack promises not to do.
|
||||
|
||||
const NUMBER_WORD =
|
||||
'one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|' +
|
||||
'fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|' +
|
||||
'fifty|sixty|seventy|eighty|ninety'
|
||||
|
||||
// *Ter X anos* / *avoir X ans* / *tener X años*: age is something you *have* in
|
||||
// every Romance language and something you *are* in English.
|
||||
const HAVE_YEARS_RE = new RegExp(
|
||||
`\\b(I|you|we|they|he|she)\\s+(have|has|had)\\s+(\\d{1,3}|${NUMBER_WORD})\\s+years(\\s+old)?\\b`,
|
||||
'gi',
|
||||
)
|
||||
|
||||
// The English "to be" that matches the subject and the tense she wrote in.
|
||||
function beFor(subject: string, verb: string): string {
|
||||
const past = verb.toLowerCase() === 'had'
|
||||
const s = subject.toLowerCase()
|
||||
if (s === 'i') return past ? 'was' : 'am'
|
||||
if (s === 'he' || s === 'she') return past ? 'was' : 'is'
|
||||
return past ? 'were' : 'are'
|
||||
}
|
||||
|
||||
function haveYears(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
HAVE_YEARS_RE.lastIndex = 0
|
||||
while ((m = HAVE_YEARS_RE.exec(text))) {
|
||||
const fixed = `${m[1]} ${beFor(m[1], m[2])} ${m[3]} years old`
|
||||
out.push({
|
||||
id: `haveyears:${m.index}`,
|
||||
rule: 'haveyears',
|
||||
native: P().ageIsNotHave(m[3]),
|
||||
en: `In English you *are* your age: “${fixed}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// *Estou de acordo* / *je suis d'accord*: agreement is a verb in English, so the
|
||||
// "to be" in front of it has nothing to do.
|
||||
const AM_AGREE_RE = /\b(I|you|we|they|he|she)\s+(am|are|is|was|were)\s+agree\b/gi
|
||||
|
||||
function amAgree(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
AM_AGREE_RE.lastIndex = 0
|
||||
while ((m = AM_AGREE_RE.exec(text))) {
|
||||
const past = /^(was|were)$/i.test(m[2])
|
||||
const verb = past ? 'agreed' : m[1].toLowerCase() === 'he' || m[1].toLowerCase() === 'she' ? 'agrees' : 'agree'
|
||||
const fixed = `${m[1]} ${verb}`
|
||||
out.push({
|
||||
id: `amagree:${m.index}`,
|
||||
rule: 'amagree',
|
||||
native: P().agreeIsAVerb,
|
||||
en: `“Agree” is already the verb: “${fixed}”, not “${m[0].trim()}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// *desde há três anos* / *depuis trois ans*: a stretch of time takes "for";
|
||||
// "since" wants the moment it started.
|
||||
const SINCE_DURATION_RE = new RegExp(
|
||||
`\\b(since)\\s+((?:\\d{1,3}|${NUMBER_WORD}|a few|several|many)\\s+(?:years|months|weeks|days|hours|minutes))\\b`,
|
||||
'gi',
|
||||
)
|
||||
|
||||
function sinceDuration(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
SINCE_DURATION_RE.lastIndex = 0
|
||||
while ((m = SINCE_DURATION_RE.exec(text))) {
|
||||
const fixed = `${matchCase(m[1], 'for')} ${m[2]}`
|
||||
out.push({
|
||||
id: `since:${m.index}`,
|
||||
rule: 'since',
|
||||
native: P().forNotSince(m[2]),
|
||||
en: `For a length of time use “for”: “${fixed}”. “Since” names when it started.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 很喜欢 — 很 modifies adjectives *and* verbs in Mandarin, so "very" arrives in
|
||||
// front of English verbs, where it cannot go.
|
||||
const VERY_VERB_RE =
|
||||
/\b(very)\s+(like|likes|liked|want|wants|wanted|enjoy|enjoys|enjoyed|hope|hopes|hoped|miss|misses|missed|need|needs|needed|love|loves|loved|agree|agrees|agreed|understand|understands)\b/gi
|
||||
|
||||
function veryVerb(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
VERY_VERB_RE.lastIndex = 0
|
||||
while ((m = VERY_VERB_RE.exec(text))) {
|
||||
const fixed = `${matchCase(m[1], 'really')} ${m[2]}`
|
||||
out.push({
|
||||
id: `veryverb:${m.index}`,
|
||||
rule: 'veryverb',
|
||||
native: P().veryBeforeVerb(m[2]),
|
||||
en: `“Very” goes with adjectives, not verbs — “${fixed}” (or “${m[2]} … very much”).`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 开灯 / 关电视 — Mandarin opens and closes appliances; English turns them on
|
||||
// and off.
|
||||
const TURN_FORMS: Record<string, string> = {
|
||||
base: 'turn',
|
||||
s: 'turns',
|
||||
past: 'turned',
|
||||
ing: 'turning',
|
||||
}
|
||||
const OPEN_LIGHT_RE =
|
||||
/\b(open|opens|opened|close|closes|closed)\s+(the|a|my|your|his|her|our|their)\s+(light|lights|lamp|tv|television|radio|computer|fan|heater|air conditioner|air-conditioner)\b/gi
|
||||
|
||||
function openTheLight(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
OPEN_LIGHT_RE.lastIndex = 0
|
||||
while ((m = OPEN_LIGHT_RE.exec(text))) {
|
||||
const opening = m[1].toLowerCase().startsWith('open')
|
||||
const turn = matchCase(m[1], TURN_FORMS[verbSlot(m[1])] ?? 'turn')
|
||||
const fixed = `${turn} ${opening ? 'on' : 'off'} ${m[2]} ${m[3]}`
|
||||
out.push({
|
||||
id: `openlight:${m.index}`,
|
||||
rule: 'openlight',
|
||||
native: P().turnOnNotOpen(m[3], opening),
|
||||
en: `In English you turn a ${m[3]} ${opening ? 'on' : 'off'}: “${fixed}”.`,
|
||||
fix: { from: m.index, to: m.index + m[0].length, replacement: fixed },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 虽然…但是 is a matched pair in Mandarin; English takes one or the other, never
|
||||
// both. Awareness-only: which half to drop is the writer's call, and the "but"
|
||||
// on its own is far too common a word to anchor a card to.
|
||||
const ALTHOUGH_BUT_RE = /\b(although|though|even though)\b[^.!?]{0,120}?,?\s+but\b/gi
|
||||
|
||||
function althoughBut(text: string, out: ProseHint[]) {
|
||||
let m: RegExpExecArray | null
|
||||
ALTHOUGH_BUT_RE.lastIndex = 0
|
||||
while ((m = ALTHOUGH_BUT_RE.exec(text))) {
|
||||
out.push({
|
||||
id: `althoughbut:${key(m[0])}`,
|
||||
rule: 'althoughbut',
|
||||
native: P().althoughOrBut(m[1]),
|
||||
en: `English uses “${m[1]}” or “but”, not both — one of them can go.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type Rule = (text: string, out: ProseHint[]) => void
|
||||
|
||||
// Which interference rules belong to which pair. A pair with no entry simply
|
||||
// runs the shared pack, which is the correct behaviour for a language Petal has
|
||||
// not studied yet rather than a gap to fill with guesses.
|
||||
const L1_RULES: Partial<Record<PairLang, Rule[]>> = {
|
||||
zh: [veryVerb, openTheLight, althoughBut],
|
||||
'pt-PT': [haveYears, amAgree, sinceDuration],
|
||||
fr: [haveYears, amAgree, sinceDuration],
|
||||
es: [haveYears, amAgree, sinceDuration],
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
// The false-friend heads-up sits near the top: of everything here it is the one
|
||||
// that costs her most to find out about later.
|
||||
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
||||
runOns,
|
||||
commaSplices,
|
||||
falseFriends,
|
||||
antecedents,
|
||||
oxford,
|
||||
miscollocations,
|
||||
articles,
|
||||
uncountables,
|
||||
properCaps,
|
||||
subjectVerbAgreement,
|
||||
peopleAre,
|
||||
prepositions,
|
||||
doubleComparative,
|
||||
pluralAfterNumber,
|
||||
doubleDeterminer,
|
||||
thereIsPlural,
|
||||
@@ -656,6 +1087,13 @@ const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
||||
spaceAfterPunct,
|
||||
]
|
||||
|
||||
// Every rule that runs for this writer: the shared pack, plus the interference
|
||||
// rules belonging to her pair. Read per call rather than built once — the pair
|
||||
// isn't known until /api/me answers, and the checker runs long before and after.
|
||||
function rulesFor(): Rule[] {
|
||||
return [...RULES, ...(L1_RULES[pack().code] ?? [])]
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Hints that carry a `fix` are applyable (they also surface as suggestion cards);
|
||||
@@ -664,26 +1102,27 @@ 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)
|
||||
for (const rule of rulesFor()) rule(text, 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).
|
||||
// spell-checker would. The card pipeline persists these under the family each
|
||||
// finding names (mechanics, or collocation for the miscollocation rules);
|
||||
// collisions with the LLM cards are resolved server-side (the offline card wins,
|
||||
// since its span is exact).
|
||||
export function mechanicsFindings(text: string): MechanicsFinding[] {
|
||||
const hints: ProseHint[] = []
|
||||
for (const rule of RULES) rule(text, hints)
|
||||
for (const rule of rulesFor()) rule(text, hints)
|
||||
const found: MechanicsFinding[] = []
|
||||
for (const h of hints) {
|
||||
if (!h.fix) continue
|
||||
const { from, to, replacement } = h.fix
|
||||
const { from, to, replacement, family } = h.fix
|
||||
const original = text.slice(from, to)
|
||||
if (!original || original === replacement) continue
|
||||
found.push({ from, to, original, replacement, explanation: h.en })
|
||||
found.push({ from, to, original, replacement, explanation: h.en, type: family ?? 'mechanics' })
|
||||
}
|
||||
// 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:
|
||||
|
||||
@@ -18,6 +18,8 @@ export const errors = (): Line[] => pack().companion.errors
|
||||
export const greeting = (): Line => pack().companion.greeting
|
||||
export const welcomeBack = (): Line => pack().companion.welcomeBack
|
||||
export const milestoneLine = (n: number): Line => pack().companion.milestone(n)
|
||||
export const invitations = (): Line[] => pack().companion.invitations
|
||||
export const declined = (): Line => pack().companion.declined
|
||||
|
||||
// Word-count milestones worth a little cheer — every 100 words, on up. A count,
|
||||
// not copy: the same in every language.
|
||||
|
||||
@@ -4,15 +4,18 @@ import {
|
||||
MILESTONES,
|
||||
bedtime,
|
||||
breaks,
|
||||
declined,
|
||||
encouragements,
|
||||
errors,
|
||||
greeting,
|
||||
invitations,
|
||||
milestoneLine,
|
||||
pick,
|
||||
tips,
|
||||
welcomeBack,
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { markInvited, mayInvite } from './invitation'
|
||||
import { analyzeProse } from './prose'
|
||||
import { personalCheer, warmPersonalCheers } from './journalCheers'
|
||||
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
||||
@@ -22,9 +25,12 @@ import { isBedtime } from '../../lib/night'
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
||||
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime'
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error' | 'bedtime' | 'invite'
|
||||
export interface Bubble extends Line {
|
||||
tone: BubbleTone
|
||||
// Present only on the daily invitation: the two answers it can be given. The
|
||||
// bubble is otherwise a thing to read, so this is the one that grows buttons.
|
||||
invite?: { prompt: string }
|
||||
}
|
||||
|
||||
interface Signals {
|
||||
@@ -40,6 +46,10 @@ interface Signals {
|
||||
// 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
|
||||
// True when the open document is still empty — she has Petal in front of her
|
||||
// and nothing started. The only condition under which the daily invitation is
|
||||
// offered: a writer already mid-paragraph does not need to be invited.
|
||||
blankPage: boolean
|
||||
}
|
||||
|
||||
// Timing knobs (ms). Tuned to feel present but never naggy.
|
||||
@@ -48,6 +58,18 @@ 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 BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
|
||||
// How long an empty page sits there before the kitten offers something to write
|
||||
// about. Long enough that a writer who opened Petal knowing what she wanted to
|
||||
// say is already typing, short enough to still be an offer rather than an
|
||||
// interruption.
|
||||
const INVITE_AFTER_MS = 50_000
|
||||
// …and the window closes: past this the session has its own shape, and an
|
||||
// invitation would be arriving out of nowhere.
|
||||
const INVITE_WINDOW_MS = 8 * 60_000
|
||||
|
||||
// The once-a-day rule itself lives in ./invitation — it is the part of this
|
||||
// feature with a promise in it (a date, never a streak), and it reads better
|
||||
// stated once than tangled into the heartbeat below.
|
||||
// The late-night window itself (isBedtime) lives in ../../lib/night so the
|
||||
// companion nag and the night-mode theme/starfall share one definition.
|
||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||
@@ -64,7 +86,17 @@ const now = () => Date.now()
|
||||
// length of the native + 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 : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : BUBBLE_MS
|
||||
// An invitation is the one bubble with a decision in it, so it gets the
|
||||
// longest look — and it still leaves on its own, which is a third way of
|
||||
// saying no that costs nothing.
|
||||
const base =
|
||||
b.tone === 'cheer'
|
||||
? CHEER_MS
|
||||
: b.tone === 'invite'
|
||||
? BUBBLE_MS + 12_000
|
||||
: b.tone === 'bedtime'
|
||||
? BUBBLE_MS + 4_000
|
||||
: BUBBLE_MS
|
||||
const chars = b.native.length + b.en.length
|
||||
return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR)
|
||||
}
|
||||
@@ -72,7 +104,15 @@ function readBubbleMs(b: Bubble): number {
|
||||
// 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, llmDown, editTick, acceptTick, text }: Signals) {
|
||||
export function useCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
llmDown,
|
||||
editTick,
|
||||
acceptTick,
|
||||
text,
|
||||
blankPage,
|
||||
}: Signals) {
|
||||
const [mood, setMood] = useState<Mood>('idle')
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||
|
||||
@@ -84,6 +124,7 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
const lastBedtime = useRef(0)
|
||||
const nextMilestone = useRef(0) // index into MILESTONES
|
||||
const sleeping = useRef(false)
|
||||
const invited = useRef(false) // this session, alongside the stored date
|
||||
|
||||
// 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).
|
||||
@@ -144,6 +185,27 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
return { ...pick(tips()), tone: 'tip' }
|
||||
}, [])
|
||||
|
||||
// The daily invitation. Accepting hands the prompt back to the app (it titles
|
||||
// the blank page with it, so the question stays in view while she answers it);
|
||||
// declining costs nothing at all and says so. Either answer spends the day's
|
||||
// one invitation — being asked twice after saying no would make "no" a
|
||||
// negotiation.
|
||||
const blankRef = useRef(blankPage)
|
||||
blankRef.current = blankPage
|
||||
const onInviteRef = useRef<((prompt: string) => void) | undefined>(undefined)
|
||||
|
||||
const acceptInvite = useCallback((prompt: string) => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
setBubble(null)
|
||||
setMood('happy')
|
||||
onInviteRef.current?.(prompt)
|
||||
}, [])
|
||||
|
||||
const declineInvite = useCallback(() => {
|
||||
say({ ...declined(), tone: 'tip' })
|
||||
}, [say])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
clearTimeout(moodTimer.current)
|
||||
@@ -264,6 +326,26 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
const t = now()
|
||||
const idleFor = t - lastActivity.current
|
||||
|
||||
// An empty page, a little way into the session, and no invitation yet
|
||||
// today: offer something small to write about. Checked before the idle
|
||||
// branch on purpose — sitting in front of a blank page without typing is
|
||||
// exactly the state this is for, and it is the one state the nap rule
|
||||
// would otherwise swallow.
|
||||
const sinceStart = t - sessionStart.current
|
||||
if (
|
||||
blankRef.current &&
|
||||
!invited.current &&
|
||||
sinceStart > INVITE_AFTER_MS &&
|
||||
sinceStart < INVITE_WINDOW_MS &&
|
||||
mayInvite()
|
||||
) {
|
||||
invited.current = true
|
||||
markInvited()
|
||||
const line = pick(invitations())
|
||||
say({ ...line, tone: 'invite', invite: { prompt: line.en } })
|
||||
return
|
||||
}
|
||||
|
||||
if (idleFor > IDLE_MS) {
|
||||
sleeping.current = true
|
||||
if (!bubble) setMood('sleeping')
|
||||
@@ -297,6 +379,12 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
return () => clearInterval(id)
|
||||
}, [bubble, say, nextTip])
|
||||
|
||||
// The app's handler for an accepted invitation, kept in a ref so a new
|
||||
// callback identity never re-arms the heartbeat.
|
||||
const setInviteHandler = useCallback((fn: (prompt: string) => void) => {
|
||||
onInviteRef.current = fn
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(bubbleTimer.current)
|
||||
@@ -305,5 +393,14 @@ export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptT
|
||||
[],
|
||||
)
|
||||
|
||||
return { mood, bubble, dismiss, holdBubble, releaseBubble }
|
||||
return {
|
||||
mood,
|
||||
bubble,
|
||||
dismiss,
|
||||
holdBubble,
|
||||
releaseBubble,
|
||||
acceptInvite,
|
||||
declineInvite,
|
||||
setInviteHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
const reverse = info?.reverse ?? null
|
||||
// Null whenever the dictionary has no opinion — the chip then doesn't render.
|
||||
const band = info ? wordBand(info.frequency ?? 0, info.difficulty ?? -1) : null
|
||||
const empty = !loading && !gloss && !reverse && definitions.length === 0 && synonyms.length === 0
|
||||
// A word that looks like one of hers and means something else. Curated per
|
||||
// pair (the zh pack has none), and shown the moment she looks the word up —
|
||||
// which is the moment she is deciding whether to trust it.
|
||||
const falseFriend = t.falseFriends[word.toLowerCase()] ?? null
|
||||
const empty =
|
||||
!loading && !gloss && !reverse && !falseFriend && definitions.length === 0 && synonyms.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -130,6 +135,28 @@ export function WordCard({ word, info, loading, saved, onToggleSave, style, onRe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The false-friend heads-up, above the definition because it is the one
|
||||
thing here she might otherwise not think to check. Deliberately not a
|
||||
warning: no red, no exclamation, and nothing to accept or dismiss —
|
||||
the word may well be exactly the one she meant, and the card's job is
|
||||
only to make sure she knows what it says in English. */}
|
||||
{falseFriend && (
|
||||
<div
|
||||
className="mt-2.5 rounded-xl px-2.5 py-2"
|
||||
style={{ background: 'var(--color-lavender)' }}
|
||||
>
|
||||
<p className="leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||
<span aria-hidden className="mr-1">
|
||||
🫖
|
||||
</span>
|
||||
{falseFriend.native}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
{falseFriend.en}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chinese gloss first — it's what the Mandarin-speaking writer reaches for. */}
|
||||
{gloss && (
|
||||
<p
|
||||
|
||||
Reference in New Issue
Block a user