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:
prosolis
2026-07-27 15:05:55 -07:00
parent e9b8595456
commit 1bbc8fc8d3
20 changed files with 1678 additions and 50 deletions
+40
View File
@@ -220,3 +220,43 @@ describe('the pt-PT pack', () => {
expect(ptPT.editor.alsoIn).not.toBe(zh.editor.alsoIn)
})
})
// False friends are a per-pair dataset rather than copy: the Latin pairs carry
// the traps their writers actually fall into, and the zh pair legitimately has
// none. Both halves of that are worth pinning.
describe('false friends', () => {
it('the zh pair has none, because the trap needs a shared script', () => {
expect(Object.keys(zh.falseFriends)).toHaveLength(0)
})
it('the pt-PT pair carries the ones that cost most', () => {
// Not an exhaustive list — these are the four every European Portuguese
// speaker meets in their first month of writing English.
for (const word of ['actually', 'pretend', 'realize', 'library']) {
expect(ptPT.falseFriends[word], word).toBeDefined()
}
expect(Object.keys(ptPT.falseFriends).length).toBeGreaterThan(10)
})
it('is keyed by the lowercase English word, so a lookup can find it', () => {
for (const p of PACKS) {
for (const key of Object.keys(p.falseFriends)) {
expect(key, `${p.code}: "${key}" must be lowercase`).toBe(key.toLowerCase())
expect(p.falseFriends[key].native.trim(), key).not.toBe('')
expect(p.falseFriends[key].en.trim(), key).not.toBe('')
}
}
})
// The heads-up must never read as an accusation: she may well have meant the
// word. It says what the English one means and stops there.
it('never tells her she is wrong', () => {
const forbidden = /wrong|mistake|error|incorrect|don't use|do not use|errado|erro|incorrecto/i
for (const p of PACKS) {
for (const [key, line] of Object.entries(p.falseFriends)) {
expect(line.native, `${p.code}/${key}`).not.toMatch(forbidden)
expect(line.en, `${p.code}/${key}`).not.toMatch(forbidden)
}
}
})
})
+110
View File
@@ -115,6 +115,23 @@ export const ptPT: Pack = {
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
// Uma coisa pequena para escrever, oferecida uma vez por dia a uma página
// em branco. Memórias e opiniões, nunca exercícios: não há aqui nada que se
// possa errar, que é precisamente a intenção.
invitations: [
{ native: 'Escreve 50 palavras: uma coisa pequena que te fez sorrir hoje 🌸', en: 'Write 50 words: one small thing that made you smile today.' },
{ native: 'Escreve 50 palavras: a melhor coisa que comeste hoje', en: 'Write 50 words: the best thing you ate today.' },
{ native: 'Escreve 50 palavras: o que vês da tua janela neste momento', en: 'Write 50 words: what you can see out of your window right now.' },
{ native: 'Escreve 50 palavras: um sítio onde voltarias de bom grado', en: 'Write 50 words: somewhere you would happily go back to.' },
{ native: 'Escreve 50 palavras: uma coisa que aprendeste esta semana', en: 'Write 50 words: one thing you learned this week.' },
{ native: 'Escreve 50 palavras: algo para dizeres a ti própria daqui a um ano', en: 'Write 50 words: something to tell yourself a year from now.' },
{ native: 'Escreve 50 palavras: uma música que tens ouvido ultimamente', en: 'Write 50 words: a song you have had on lately.' },
{ native: 'Escreve 50 palavras: alguém a quem gostavas de agradecer hoje', en: 'Write 50 words: someone you would like to thank today.' },
],
inviteAccept: 'Vamos lá · Lets write',
inviteDecline: 'Hoje não · Not today',
declined: { native: 'Está bem, volto a dormir 😴', en: 'Fair enough — back to my nap. 😴' },
names: {
cat: 'Gato dorminhoco',
dog: 'Cão contente',
@@ -146,6 +163,99 @@ export const ptPT: Pack = {
itsOwn: '“its” = “it is”. Para dizer “o seu / dele”, é “its” — portanto “its own”.',
itsIs: (rest) => `Aqui é “its ${rest}” (it is); “its” é o possessivo.`,
thanNotThen: (word) => `Nas comparações usa-se “than”, não “then”: “${word} than”.`,
preposition: (wrong, right) => `Em inglês diz-se “${right}”, não “${wrong}” — esta preposição é fixa.`,
collocation: (wrong, right) => `Em inglês estas palavras andam juntas assim: “${right}”, e não “${wrong}”.`,
doubleComparative: (lead, word) => `${word}” já é o comparativo — não precisa do “${lead}”: basta “${word}”.`,
peopleArePlural: (verb) => `“People” é plural em inglês: “people ${verb}”.`,
ageIsNotHave: (years) => `Em inglês a idade é com o verbo *to be*, não com *have*: “I am ${years} years old”.`,
agreeIsAVerb: '“Agree” já é o verbo — não leva *to be* à frente: diz-se “I agree”.',
forNotSince: (duration) => `Para uma duração usa-se “for”: “for ${duration}”. O “since” marca o início (since 2020).`,
veryBeforeVerb: (verb) => `“Very” só acompanha adjectivos, não verbos: “really ${verb}”, ou “${verb} … very much”.`,
turnOnNotOpen: (thing, on) => `Em inglês os aparelhos não se abrem nem se fecham — ligam-se e desligam-se: “turn ${on ? 'on' : 'off'} the ${thing}”.`,
althoughOrBut: (word) => `Em inglês usa-se “${word}” ou “but”, nunca os dois na mesma frase.`,
},
// Os falsos amigos entre o português e o inglês — a armadilha que faz uma
// pessoa sentir-se tola em vez de apenas corrigida. Por isso são só um aviso:
// o Petal nunca troca a palavra, porque “actually” pode muito bem ser mesmo a
// que ela queria.
falseFriends: {
actually: {
native: '“Actually” quer dizer *na verdade*, não *actualmente*. Para “actualmente” diz-se “currently” / “nowadays”.',
en: '“Actually” means *in fact*. For the Portuguese *actualmente*, English uses “currently”.',
},
eventually: {
native: '“Eventually” quer dizer *por fim, mais cedo ou mais tarde* — não *eventualmente*. Para isso: “possibly” ou “if necessary”.',
en: '“Eventually” means *in the end*, not *possibly*.',
},
pretend: {
native: '“Pretend” é *fingir*. Para *pretender* diz-se “intend” ou “plan to”.',
en: '“Pretend” means to fake something; the Portuguese *pretender* is “intend”.',
},
realize: {
native: '“Realize” é *aperceber-se*. Para *realizar* (fazer, levar a cabo) diz-se “carry out” ou “achieve”.',
en: '“Realize” means to become aware; *realizar* is “carry out”.',
},
assist: {
native: '“Assist” é *ajudar*. Para *assistir a* (ver) diz-se “attend” ou “watch”.',
en: '“Assist” means to help; *assistir a* is “attend” or “watch”.',
},
library: {
native: '“Library” é a *biblioteca*. A *livraria* é “bookshop” / “bookstore”.',
en: '“Library” is where books are lent; a shop that sells them is a “bookshop”.',
},
parents: {
native: '“Parents” são só o pai e a mãe. Os *parentes* são “relatives”.',
en: '“Parents” means mother and father only; *parentes* are “relatives”.',
},
sensible: {
native: '“Sensible” é *sensato*. Para *sensível* diz-se “sensitive”.',
en: '“Sensible” means level-headed; *sensível* is “sensitive”.',
},
push: {
native: '“Push” é *empurrar* — o contrário de *puxar* (“pull”).',
en: '“Push” is the opposite of *puxar*, which is “pull”.',
},
college: {
native: '“College” é o ensino superior. O *colégio* (escola) é “school”.',
en: '“College” is higher education; a *colégio* is a “school”.',
},
exit: {
native: '“Exit” é a *saída*. O *êxito* é “success”.',
en: '“Exit” is the way out; *êxito* is “success”.',
},
fabric: {
native: '“Fabric” é o *tecido*. A *fábrica* é “factory”.',
en: '“Fabric” is cloth; a *fábrica* is a “factory”.',
},
lecture: {
native: '“Lecture” é uma *aula / palestra*. A *leitura* é “reading”.',
en: '“Lecture” is a talk; *leitura* is “reading”.',
},
notice: {
native: '“Notice” é *reparar* ou um *aviso*. A *notícia* é “news”.',
en: '“Notice” is to spot something; *notícia* is “news”.',
},
support: {
native: '“Support” é *apoiar*. Para *suportar* (aguentar) diz-se “put up with” ou “bear”.',
en: '“Support” means to back someone up; *suportar* is to “bear”.',
},
costume: {
native: '“Costume” é um *disfarce / traje*. Os *costumes* são “customs” ou “habits”.',
en: '“Costume” is fancy dress; *costumes* are “customs”.',
},
deception: {
native: '“Deception” é *engano*. A *decepção* é “disappointment”.',
en: '“Deception” means being misled; *decepção* is “disappointment”.',
},
injury: {
native: '“Injury” é uma *lesão*. A *injúria* é “insult”.',
en: '“Injury” is physical harm; *injúria* is an “insult”.',
},
balcony: {
native: '“Balcony” é a *varanda*. O *balcão* é “counter”.',
en: '“Balcony” is a terrace; a *balcão* is a “counter”.',
},
},
docs: {
+34
View File
@@ -99,6 +99,25 @@ export const zh: Pack = {
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
// A small thing to write about, offered once a day to an empty page. Each
// one is a memory or an opinion rather than an exercise — nothing here can
// be got wrong, which is the point. Fifty words is a deliberately small ask.
invitations: [
{ native: '写 50 个字:今天让你微笑的一件小事 🌸', en: 'Write 50 words: one small thing that made you smile today.' },
{ native: '写 50 个字:今天吃到最好吃的东西', en: 'Write 50 words: the best thing you ate today.' },
{ native: '写 50 个字:你现在窗外看到了什么?', en: "Write 50 words: what you can see out of your window right now." },
{ native: '写 50 个字:一个你很想再去一次的地方', en: 'Write 50 words: somewhere you would happily go back to.' },
{ native: '写 50 个字:这个星期学到的一件事', en: 'Write 50 words: one thing you learned this week.' },
{ native: '写 50 个字:给一年后的自己说句话', en: 'Write 50 words: something to tell yourself a year from now.' },
{ native: '写 50 个字:最近听到的一首歌', en: 'Write 50 words: a song you have had on lately.' },
{ native: '写 50 个字:今天想谢谢谁?', en: 'Write 50 words: someone you would like to thank today.' },
],
inviteAccept: '好,来写 · Lets write',
inviteDecline: '今天不了 · Not today',
// The entire consequence of saying no. No streak was broken, because there
// is no streak.
declined: { native: '好吧,我继续睡 😴', en: 'Fair enough — back to my nap. 😴' },
// Keyed by the companion ids in Companion/companions.ts.
names: {
cat: '瞌睡猫',
@@ -131,8 +150,23 @@ export const zh: Pack = {
itsOwn: '“its” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
itsIs: (rest) => `这里应该是 “its ${rest}”(it is),“its” 是“它的”。`,
thanNotThen: (word) => `比较的时候用 “than”,不是 “then”:“${word} than”。`,
preposition: (wrong, right) => `英文习惯说 “${right}”,不是 “${wrong}”,这个介词是固定搭配。`,
collocation: (wrong, right) => `英文里这两个词一般这样搭:“${right}”,而不是 “${wrong}”。`,
doubleComparative: (lead, word) => `${word}” 本身已经是比较级了,前面不用再加 “${lead}”:写 “${word}” 就好。`,
peopleArePlural: (verb) => `“people” 在英文里是复数,要用 “people ${verb}”。`,
ageIsNotHave: (years) => `英文说年龄用 be 动词,不是 have:“I am ${years} years old”。`,
agreeIsAVerb: '“agree” 本身就是动词,前面不用加 be:说 “I agree” 就好。',
forNotSince: (duration) => `表示持续了多久用 “for”:“for ${duration}”;“since” 后面接的是起点(since 2020)。`,
veryBeforeVerb: (verb) => `“very” 只能修饰形容词,不能直接放在动词前面:可以说 “really ${verb}”,或者 “${verb} … very much”。`,
turnOnNotOpen: (thing, on) => `英文里电器是 turn ${on ? 'on' : 'off'},不用 open/close:“turn ${on ? 'on' : 'off'} the ${thing}”。`,
althoughOrBut: (word) => `英文里 “${word}” 和 “but” 只用一个就够了,不像中文的“虽然…但是…”。`,
},
// The zh pair has no false friends: the trap needs a shared script, and 汉字
// and the Latin alphabet never look alike. An empty list here is the honest
// answer, not an unwritten one.
falseFriends: {},
docs: {
sortRecent: '最近 · Recent',
sortTitle: '标题 · Title',
+36
View File
@@ -69,6 +69,13 @@ export interface Pack {
milestone: (words: number) => Line
// Mascot names, keyed by the companion ids in companions.ts.
names: Record<string, string>
// A once-a-day invitation to write, offered only to a blank page (§5c).
// Explicitly no streaks and no guilt: `declined` is the sleepy, entirely
// untroubled reply to "not today", and it is the only consequence there is.
invitations: Line[]
inviteAccept: string
inviteDecline: string
declined: Line
}
// Rule-based prose notes (see Companion/prose.ts). Detection is English
@@ -95,8 +102,37 @@ export interface Pack {
itsOwn: string
itsIs: (rest: string) => string
thanNotThen: (word: string) => string
// Grammar-lite, the offline half of the suggestion rail. `preposition` and
// `collocation` are single templates rather than a line per entry: the rule
// pack behind them is a few dozen curated pairs, and a pack author should be
// able to say "English says X, not Y" once and have every pair read well.
preposition: (wrong: string, right: string) => string
collocation: (wrong: string, right: string) => string
doubleComparative: (lead: string, word: string) => string
peopleArePlural: (verb: string) => string
// Interference from the writer's own language (see prose.ts L1_RULES). Every
// pack owns all of these even though a pair only ever runs some of them —
// the copy is cheap, and the day Petal decides a rule is safe for another
// pair it should not also need new strings.
ageIsNotHave: (years: string) => string
agreeIsAVerb: string
forNotSince: (duration: string) => string
veryBeforeVerb: (verb: string) => string
turnOnNotOpen: (thing: string, on: boolean) => string
althoughOrBut: (word: string) => string
}
// Words that look like one of hers and mean something else, keyed by the
// lowercase English word. This is the mistake that makes a learner feel
// foolish rather than merely corrected, so both surfaces are heads-up only:
// a quiet block in the WordCard, and at most one gentle companion note per
// pass. Petal never "fixes" one — *actually* may well be the word she meant.
//
// A pair needs a shared script for the trap to spring at all, so the zh pack
// is legitimately empty; the Latin pairs carry the two dozen that account for
// most of the damage.
falseFriends: Record<string, Line>
docs: {
sortRecent: string
sortTitle: string