Phase 19: the copy stops being hardcoded Mandarin

Every `中文 · English` string moves out of ~29 components into
web/src/i18n: one Pack type, a verbatim zh pack, and two ways to read
it — usePack() for components, pack() for the modules that build a line
when something happens rather than when something renders.

Anything with a value in it is a function on the pack rather than a
template at the call site, English pluralisation included: word order
isn't universal, and a pack author has to be able to move the number.
The roster constants (tones, rewrite styles, export formats, companions)
keep only value + emoji, so a label can't drift from its key.

On the server, internal/llm/lang.go replaces "Simplified Chinese" in the
three prompts that actually name her language. pt-PT is spelled
"European Portuguese (pt-PT, never Brazilian Portuguese)" in the prompt
itself, and each Lang carries her word for "why" so the tutor prompt
still recognises the question when she asks it her way.

pair_lang reaches the model through the row-scoped query each handler
already ran — the one that proves she owns the document — rather than a
second lookup that could disagree with it.

Also records Phase 18's deploy: migration 0011 rehearsed against a copy
of the live VPS database, then applied for real.
This commit is contained in:
prosolis
2026-07-27 08:37:05 -07:00
parent 30d5e691c9
commit 336cae93e0
45 changed files with 1331 additions and 371 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ type checkpointResponse struct {
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
// applies the latency-guard truncation and the checkpoint sampling parameters
// from the spec.
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string, _ Lang) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
MaxTokens: checkpointMaxTokens,
+4 -3
View File
@@ -18,10 +18,11 @@ const CollocationInterval = 25 * time.Second
// reflect the full piece. Each flag carries a native replacement to apply.
//
// The tone argument is accepted for a uniform pass signature and passed through
// to the prompt so a hint can prefer a register-appropriate pairing.
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
// to the prompt so a hint can prefer a register-appropriate pairing. `lang` is
// the writer's pair language — the one each hint's short gloss is written in.
func RunCollocation(ctx context.Context, client LLMClient, contentText, tone string, lang Lang) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: CollocationMessages(contentText, tone),
Messages: CollocationMessages(contentText, tone, lang),
MaxTokens: 2048,
Temperature: 0.3,
RepetitionPenalty: 1.15,
+52
View File
@@ -0,0 +1,52 @@
package llm
import "strings"
// The pair language, as the prompts need to talk about it.
//
// Three of Petal's prompts name the writer's first language rather than merely
// being written in English: the collocation coach asks for a gloss in it, Ask
// Petal offers to answer in it, and the explanation translator renders into it.
// Before Phase 19 all three said "Simplified Chinese" outright, which made the
// zh pair the only one that could ever work.
//
// A Lang is not a translation of the prompt — the instructions stay in English,
// which is what the model follows best. It is the name the model should use for
// her language, plus the one word it should watch for when she writes in it.
type Lang struct {
// Code matches users.pair_lang.
Code string
// Name is how the prompt refers to the language, spelled the way a model
// recognises it. Regional precision matters here: "European Portuguese" is
// not "Portuguese" to a model that has read far more pt-BR than pt-PT.
Name string
// Why asks the same thing she would ask in her own language. It goes into
// the Ask Petal prompt as an example, so a model that answers only to
// English "why" still recognises the question when she types it her way.
Why string
}
// langs holds every pair Petal can currently be a partner in. A language with a
// frontend langpack but no entry here still works — it falls back to zh's
// behaviour of the prompts, which is wrong but not broken — so keep the two in
// step when a pair ships.
var langs = map[string]Lang{
"zh": {Code: "zh", Name: "Simplified Chinese (Mandarin)", Why: "为什么"},
"pt-PT": {Code: "pt-PT", Name: "European Portuguese (pt-PT, never Brazilian Portuguese)", Why: "porquê"},
"fr": {Code: "fr", Name: "French", Why: "pourquoi"},
"es": {Code: "es", Name: "Spanish", Why: "por qué"},
}
// DefaultLang is the pair assumed when none is known — the column's default, and
// the only pair that existed before Phase 19.
var DefaultLang = langs["zh"]
// LangFor resolves a users.pair_lang value. An empty or unrecognised code falls
// back to the default rather than erroring: a prompt is not the place to
// discover a configuration problem, and the writing still has to be checked.
func LangFor(code string) Lang {
if l, ok := langs[strings.TrimSpace(code)]; ok {
return l
}
return DefaultLang
}
+85
View File
@@ -0,0 +1,85 @@
package llm
import (
"strings"
"testing"
)
func TestLangForFallsBackToDefault(t *testing.T) {
if got := LangFor("zh"); got.Code != "zh" {
t.Fatalf("LangFor(zh) = %+v", got)
}
if got := LangFor("pt-PT"); got.Code != "pt-PT" {
t.Fatalf("LangFor(pt-PT) = %+v", got)
}
// A blank column, a stray value, and stray whitespace all resolve rather
// than erroring — a prompt is the wrong place to discover a config problem.
for _, in := range []string{"", " ", "klingon", "ZH"} {
if got := LangFor(in); got.Code != DefaultLang.Code {
t.Fatalf("LangFor(%q) = %q, want the default %q", in, got.Code, DefaultLang.Code)
}
}
if got := LangFor(" zh "); got.Code != "zh" {
t.Fatalf("LangFor with padding = %+v", got)
}
}
// The three prompts that name the writer's language must actually name *hers*.
// Before Phase 19 all three said "Simplified Chinese" outright, which is the
// bug this guards: a pt-PT writer asking "porquê" would have been answered in
// Mandarin.
func TestPromptsNameTheWritersLanguage(t *testing.T) {
pt := LangFor("pt-PT")
collocation := CollocationMessages("The rain was strong.", "casual", pt)[0].Content
if !strings.Contains(collocation, "European Portuguese") {
t.Fatalf("collocation prompt doesn't ask for a pt-PT gloss:\n%s", collocation)
}
if strings.Contains(collocation, "Simplified Chinese") {
t.Fatalf("collocation prompt still hardcodes Chinese:\n%s", collocation)
}
// The tone steering must survive alongside the language — they share one
// format string, and getting the verbs in the wrong order silently drops one.
if !strings.Contains(collocation, "relaxed, friendly, and conversational") {
t.Fatalf("collocation prompt lost its tone guidance:\n%s", collocation)
}
translate := TranslateMessages("Try a shorter sentence here.", pt)[0].Content
if !strings.Contains(translate, "European Portuguese") || strings.Contains(translate, "Chinese") {
t.Fatalf("translate prompt targets the wrong language:\n%s", translate)
}
ask := AskPetalSystemPrompt("origin", "replacement", "grammar", "explanation", "paragraph", pt)
if !strings.Contains(ask, "European Portuguese") || strings.Contains(ask, "Mandarin") {
t.Fatalf("ask-petal prompt targets the wrong language:\n%s", ask)
}
if !strings.Contains(ask, "porquê") {
t.Fatalf("ask-petal prompt doesn't recognise her word for \"why\":\n%s", ask)
}
// The suggestion context is positional in that template; a mis-numbered
// verb would quietly blank one of these fields.
for _, want := range []string{"origin", "replacement", "grammar", "explanation", "paragraph"} {
if !strings.Contains(ask, want) {
t.Fatalf("ask-petal prompt dropped %q:\n%s", want, ask)
}
}
if strings.Contains(ask, "%!") {
t.Fatalf("ask-petal prompt has a formatting error:\n%s", ask)
}
}
// The zh pair is in daily use and must be untouched by the extraction: its
// prompts should read exactly as they did when they were hardcoded.
func TestDefaultPairStillReadsAsBefore(t *testing.T) {
zh := LangFor("zh")
if got := CollocationMessages("x", "", zh)[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") {
t.Fatalf("zh collocation gloss changed:\n%s", got)
}
if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") {
t.Fatalf("zh translate target changed:\n%s", got)
}
if got := AskPetalSystemPrompt("a", "b", "c", "d", "e", zh); !strings.Contains(got, "为什么") {
t.Fatalf("zh ask-petal lost its Mandarin \"why\":\n%s", got)
}
}
+30 -27
View File
@@ -97,8 +97,8 @@ func VoiceMessages(contentText string) []Message {
// just non-native ("do a decision" → "make a decision", "strong rain" → "heavy
// rain"), and explicitly DEFERS real grammar/spelling errors to the grammar
// checkpoint so the two families don't overlap. Every explanation is framed as a
// warm "natives usually say…" note with a short Mandarin gloss — never
// "error/wrong" — because these are stylistic, not mistakes. It is a distinct
// warm "natives usually say…" note with a short gloss in the writer's own
// language — never "error/wrong" — because these are stylistic, not mistakes. It is a distinct
// pass from the grammar checkpoint (do not bundle them). `replacement` carries
// the natural pairing the writer can accept in one tap.
const collocationSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
@@ -113,7 +113,7 @@ Identify up to 5 such non-native word pairings. For each, give the natural pairi
`Be gentle and specific. Do NOT flag grammar errors, spelling mistakes, or unclear sentences — those are handled ` +
`elsewhere. Only flag word pairings that are correct but sound non-native.%s
Phrase every explanation warmly as "Natives usually say…" and include a brief Simplified Chinese gloss in parentheses. ` +
Phrase every explanation warmly as "Natives usually say…" and include a brief %s gloss in parentheses. ` +
`Never use the words "error", "wrong", or "mistake" — these are friendly polish, not corrections.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
@@ -132,10 +132,11 @@ If every pairing already sounds natural, return: {"suggestions": []}`
// CollocationMessages builds the message array for a collocation pass over the
// WHOLE document (no truncation), gently steered toward the document's tone so a
// hint can prefer a register-appropriate pairing.
func CollocationMessages(contentText, tone string) []Message {
// hint can prefer a register-appropriate pairing. The parenthetical gloss is
// written in the writer's own language.
func CollocationMessages(contentText, tone string, lang Lang) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))},
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), lang.Name)},
{Role: "user", Content: contentText},
}
}
@@ -147,26 +148,27 @@ const askPetalSystemTemplate = `You are Petal, a warm and patient English writin
`as a second language. You are currently discussing a specific writing suggestion.
Suggestion context:
- Original text: "%s"
- Suggested replacement: "%s"
- Issue type: %s
- Initial explanation: "%s"
- Surrounding paragraph: "%s"
- Original text: "%[1]s"
- Suggested replacement: "%[2]s"
- Issue type: %[3]s
- Initial explanation: "%[4]s"
- Surrounding paragraph: "%[5]s"
The user wants to understand this suggestion better. Detect the language of the user's message ` +
`and respond in that same language. If they write in Mandarin Chinese, respond entirely in ` +
`Mandarin. If they write in English, respond in English. Never mix languages in a single response.
`and respond in that same language. If they write in %[6]s, respond entirely in ` +
`%[6]s. If they write in English, respond in English. Never mix languages in a single response.
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` +
`when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it. ` +
`when helpful. If they ask "why" (or "%[7]s"), explain the grammar rule or idiom behind it. ` +
`If they suggest an alternative phrasing, evaluate it honestly.
Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — ` +
`learning a language is hard and they're doing great.`
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context.
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string {
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph)
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and
// the writer's pair language, which is the one she may ask her question in.
func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string, lang Lang) string {
return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph, lang.Name, lang.Why)
}
// rewriteSystemTemplate drives the "say it more naturally" / tone-rewrite tool.
@@ -215,22 +217,23 @@ func RewriteMessages(text, style string) []Message {
}
// translateSystemPrompt drives the explanation translator: it renders a
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
// the "why" in her first language. Strict about returning ONLY the translation
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
// bubble. Kept warm and plain — these are short, friendly one-liners.
// suggestion's English explanation into the writer's own language so an ESL
// reader sees the "why" in her first language. Strict about returning ONLY the
// translation (no quotes, no romanisation, no English echo) so it can drop
// straight into the chat bubble. Kept warm and plain — these are short, friendly
// one-liners.
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` +
`sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` +
`suggestion, written for a native Chinese speaker learning English.
`sends into natural, friendly %[1]s. It is a short explanation of a writing ` +
`suggestion, written for a native %[1]s speaker learning English.
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
Respond with ONLY the %[1]s translation. No quotation marks, no romanisation, no English, no preamble — ` +
`just the translated sentence.`
// TranslateMessages builds the message array for translating one short English
// explanation into Simplified Chinese.
func TranslateMessages(text string) []Message {
// explanation into the writer's own language.
func TranslateMessages(text string, lang Lang) []Message {
return []Message{
{Role: "system", Content: translateSystemPrompt},
{Role: "system", Content: fmt.Sprintf(translateSystemPrompt, lang.Name)},
{Role: "user", Content: text},
}
}
+5 -5
View File
@@ -4,13 +4,13 @@ import (
"context"
)
// RunTranslate renders a short English explanation into Simplified Chinese. It
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
// temperature so the translation is faithful rather than creative. Output is
// RunTranslate renders a short English explanation into the writer's own
// language. It is a one-shot Complete (the result seeds the Ask Petal bubble),
// kept at a low temperature so the translation is faithful rather than creative. Output is
// trimmed of any stray surrounding quotes the model may add.
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
func RunTranslate(ctx context.Context, client LLMClient, text string, lang Lang) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{
Messages: TranslateMessages(text),
Messages: TranslateMessages(text, lang),
MaxTokens: 512,
Temperature: 0.2,
TopP: 0.9,
+1 -1
View File
@@ -19,7 +19,7 @@ const VoiceInterval = 20 * time.Second
// The tone argument is accepted for a uniform pass signature but ignored: voice
// consistency is judged against the document's own established voice, not an
// externally-chosen register.
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string, _ Lang) ([]RawSuggestion, error) {
raw, err := client.Complete(ctx, CompletionRequest{
Messages: VoiceMessages(contentText),
MaxTokens: 2048,