Files
petal/internal/llm/prompts.go
T
prosolis 76dede8856 Correct the language she wrote in, not the one she was practising
Every pass was English-shaped: CheckpointMessages took the text and the tone and
nothing else, so there was never a language decision to get wrong. On the live
build two pt-PT sentences drew no cards at all — Petal read the Portuguese, said
nothing about it, and filed a mechanics note about the one English line.

The rule is two decisions reading different state. What gets corrected follows
the document. What language the explanation is written in follows the writer —
the half of her pair she is not learning, from users.direction — because an
explanation is teaching, and teaching lands in the language she reads most
easily. Those coincide for every account that exists today (learnerPairs is
{"zh"}), which is a fact about the roster and not about the design, so Target
keeps them apart. It carries a third language too: the collocation gloss is
addressed to her rather than to the document, and folding it into Explain would
have quietly moved it into English on every English document.

The document verdict is a proportion, not a presence — one Portuguese quotation
must not flip an English essay. Per sentence, three-way: pair, English, or no
answer. The third value is the load-bearing one; counting the undecided as
English is exactly what would hold a journal of short Portuguese sentences in
English forever, so the Latin pairs needed an englishMarkers list curated against
pt/fr/es as carefully as latinMarkers was curated against English. Hysteresis at
70/40 because a bilingual paragraph would otherwise alternate its cards' language
every few keystrokes, and hysteresis needs a yesterday — hence the column. Plus a
corroboration floor: a ratio computed over "Não. Eu." is 100% of nothing, and a
flip rewrites every card in the document.

The verdict folds into the chunk salt beside the tone, so a document that changes
language re-opens every sentence rather than serving back cards in a language it
no longer speaks.

checkpointSystemPrompt could not simply take a language — it opens by naming the
reader an ESL learner, and appending "explain in Portuguese" hands the model two
contradictory framings. Separate constants, sharing the JSON contract below the
framing. Both carry a "never translate it into English" line, which is the
instruction the model will most want to disobey. The English prompts are
untouched byte for byte, and a golden says so out loud.

Collocation deliberately did not move: its prompt is per-language knowledge, not
framing, and "natives usually say" for Portuguese is a claim Petal cannot back.

Not deployed and not smoked against a real model. The tests drive the real router
and a real DB; what none of them prove is how Qwen behaves on a Portuguese
document, in particular whether the never-translate line holds.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:20:53 -07:00

352 lines
18 KiB
Go

package llm
import "fmt"
// checkpointSystemPrompt is the grammar-checkpoint instruction. It asks for
// strict JSON (no fences, no preamble) so Complete's output parses directly.
const checkpointSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
`Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
`incorrect idiom usage, or unclear sentences that are common ESL patterns.
Be specific, friendly, and explain WHY each suggestion improves the writing.%s
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact text from the document that needs fixing",
"replacement": "corrected version",
"explanation": "friendly one-sentence explanation",
"type": "grammar|phrasing|idiom|clarity"
}
]
}
If the writing looks good, return: {"suggestions": []}`
// toneGuidance returns a sentence steering the checkpoint toward the writer's
// chosen tone, or "" for the neutral default. The clause is appended to the
// checkpoint instructions so the model's phrasing suggestions fit the target
// register (e.g. an academic essay vs a casual journal). Unknown values fall
// back to no steering, so a stray tone string is harmless.
func toneGuidance(tone string) string {
clause, ok := map[string]string{
"academic": "formal, academic, and objective — suited to a school essay or research paper",
"professional": "polished and professional — suited to a workplace email or report",
"casual": "relaxed, friendly, and conversational",
"humorous": "light, playful, and good-humored",
"creative": "vivid, expressive, and imaginative — suited to a story or personal narrative",
"persuasive": "confident and persuasive — suited to an argument or opinion piece",
}[tone]
if !ok {
return ""
}
return "\n\nThe writer wants this document to read as " + clause + ". When phrasing could " +
"be improved, prefer suggestions that fit that tone, and gently flag wording that clashes with it."
}
// pairCheckpointSystemPrompt is the grammar checkpoint for a document written in
// the writer's own language rather than in English.
//
// It is a separate constant rather than a language clause appended to
// checkpointSystemPrompt, because that prompt opens by naming the reader as an
// ESL learner and asks for "common ESL patterns" — appending "and explain in
// Portuguese" would hand the model two contradictory framings. Only the framing
// differs; the JSON contract and the tone clause below it are the same
// instructions in the same order, so the two prompts stay comparable.
//
// The "never translate" line is the one the model most wants to disobey: asked
// to improve Portuguese while being an English writing assistant by training, it
// will happily hand back an English rendering, which is a translation card
// (Phase 25's `isTranslation`) and not a correction.
const pairCheckpointSystemPrompt = `You are a warm, encouraging writing assistant. The person you are helping is ` +
`writing in %[1]s, and the text below is %[1]s. ` +
`Analyze it and identify up to 5 issues: grammar errors, unnatural phrasing, ` +
`incorrect idiom usage, or unclear sentences.
Both "original" and "replacement" must be written in %[1]s. You are improving their %[1]s writing — ` +
`never translate it into English, and never suggest they write in English instead.
Write every "explanation" in %[2]s.
Be specific, friendly, and explain WHY each suggestion improves the writing.%[3]s
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact text from the document that needs fixing",
"replacement": "corrected version",
"explanation": "friendly one-sentence explanation",
"type": "grammar|phrasing|idiom|clarity"
}
]
}
If the writing looks good, return: {"suggestions": []}`
// CheckpointMessages builds the message array for a grammar checkpoint over the
// given (already-truncated) document text, steered toward the document's tone
// and aimed at the language the document is actually written in.
func CheckpointMessages(contentText, tone string, t Target) []Message {
system := fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))
if t.Flipped() {
system = fmt.Sprintf(pairCheckpointSystemPrompt, t.Correct.Name, t.Explain.Name, toneGuidance(tone))
}
return []Message{
{Role: "system", Content: system},
{Role: "user", Content: contentText},
}
}
// voiceSystemPrompt drives the Tier-1 voice-consistency pass. It is a distinct
// pass from the grammar checkpoint (spec: "do not bundle them") — the model
// reads the whole document to learn the writer's natural voice, then flags
// passages that read as tonally out of place. `replacement` is null: these are
// awareness-only, with no correction to apply.
const voiceSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
`You are reviewing a COMPLETE document for VOICE CONSISTENCY only — not grammar.
Read the whole document to learn the writer's natural voice, then identify any passages (2 or more sentences) ` +
`that feel tonally inconsistent with the surrounding writing — unusually formal, unusually polished, or phrased ` +
`in a way that differs from the writer's established voice elsewhere in the document. These often signal text ` +
`that was paraphrased too closely from another source. Do not flag the first paragraph (there is no baseline yet). ` +
`Do not flag grammar or spelling mistakes — only voice.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact passage from the document that feels inconsistent",
"replacement": null,
"explanation": "friendly one-sentence note, e.g. 'This passage sounds more formal than the rest of your writing — worth reviewing.'",
"type": "voice"
}
]
}
If the voice is consistent throughout, return: {"suggestions": []}`
// pairVoiceSystemPrompt is the voice pass for a document in the writer's own
// language. Voice consistency is the one pass that transfers across languages
// unchanged — a paragraph that reads as pasted from elsewhere reads that way in
// any language — so only the framing and the explanation language move.
const pairVoiceSystemPrompt = `You are a warm, encouraging writing assistant. The person you are helping is writing ` +
`in %[1]s. You are reviewing a COMPLETE %[1]s document for VOICE CONSISTENCY only — not grammar.
Read the whole document to learn the writer's natural voice, then identify any passages (2 or more sentences) ` +
`that feel tonally inconsistent with the surrounding writing — unusually formal, unusually polished, or phrased ` +
`in a way that differs from the writer's established voice elsewhere in the document. These often signal text ` +
`that was paraphrased too closely from another source. Do not flag the first paragraph (there is no baseline yet). ` +
`Do not flag grammar or spelling mistakes — only voice.
Quote each passage exactly as it appears, in %[1]s. Write every "explanation" in %[2]s.
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
{
"suggestions": [
{
"original": "exact passage from the document that feels inconsistent",
"replacement": null,
"explanation": "friendly one-sentence note about why this passage sounds unlike the rest",
"type": "voice"
}
]
}
If the voice is consistent throughout, return: {"suggestions": []}`
// VoiceMessages builds the message array for a voice-consistency pass. Unlike
// the checkpoint, the caller passes the WHOLE document (no truncation) — voice
// consistency is judged against the established voice everywhere else.
//
// The pass had no language argument at all before Phase 28, which was the same
// English assumption the checkpoint made, just unstated.
func VoiceMessages(contentText string, t Target) []Message {
system := voiceSystemPrompt
if t.Flipped() {
system = fmt.Sprintf(pairVoiceSystemPrompt, t.Correct.Name, t.Explain.Name)
}
return []Message{
{Role: "system", Content: system},
{Role: "user", Content: contentText},
}
}
// collocationSystemPrompt drives the collocation coach — the single most
// valuable polish for an ESL writer. It flags word PAIRINGS that are not wrong,
// 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 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. ` +
`You are reviewing a COMPLETE document for COLLOCATIONS only — the natural word pairings native speakers use.
A collocation is a pair or short group of words that native speakers habitually say together. ESL writers ` +
`often choose words that are grammatically correct but sound non-native: "do a decision" instead of "make a decision", ` +
`"strong rain" instead of "heavy rain", "say a joke" instead of "tell a joke". These are NOT grammar mistakes — they ` +
`are just not what a native speaker would naturally say.
Identify up to 5 such non-native word pairings. For each, give the natural pairing a native speaker would use. ` +
`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 %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:
{
"suggestions": [
{
"original": "exact word pairing from the document",
"replacement": "the natural native pairing",
"explanation": "friendly note, e.g. 'Natives usually say \"make a decision\" rather than \"do a decision\" (native usage / 地道说法).'",
"type": "collocation"
}
]
}
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. The parenthetical gloss is
// written in the writer's own language — `Pair`, not `Explain`: the gloss is
// addressed to her rather than to the document.
//
// The coach itself remains English-only. Collocation lists are the one thing
// here that is genuinely per-language knowledge rather than framing, and
// "natives usually say" for Portuguese is a claim this prompt has no grounds to
// make yet; a flipped document simply gets the pass it always got. (Phase 28
// moved the checkpoint and the voice pass; this one waits for evidence.)
func CollocationMessages(contentText, tone string, t Target) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), t.Pair.Name)},
{Role: "user", Content: contentText},
}
}
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
// is interpolated in; the user's own messages are appended after this system
// turn by the caller.
//
// The reply is bilingual, the pair language first. Until UX item 6 it mirrored
// the language of the question instead — self-consistent, but it meant asking in
// one language cost you the other, and the writer doesn't always know which one
// the answer will be clearer in. Which half is the safety net and which is the
// lesson depends on who is writing: the pair is (English + X) either way, and an
// English speaker learning French wants the French half for the same reason a
// Mandarin speaker learning English wants the English one. Petal cannot tell
// them apart from a chat message, and doesn't need to — every other explanation
// surface already gives both (the card's English body, the seeded bubble in the
// pair language). The answer that goes deepest into the "why" was the one place
// that didn't.
//
// The blank line between the halves is a contract with the client: AskPetal.tsx
// splits on the first one to render her language prominently and the English
// beneath it, mirroring the companion's bubble. A model that ignores the
// instruction and writes one language degrades to a single plain block — the
// answer is still readable, which is why the split is a rendering nicety and
// never a parse the reply depends on.
const askPetalSystemTemplate = `You are Petal, a warm and patient English writing tutor helping someone who is learning English ` +
`as a second language. You are currently discussing a specific writing suggestion.
Suggestion context:
- 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. Answer in BOTH languages, every time, ` +
`whichever language they asked their question in: first the whole answer in %[6]s, then the ` +
`same answer again in English. Separate the two with a single blank line. Do not label them, ` +
`do not use a blank line anywhere else, and do not mix the two languages within one half — ` +
`each half is complete on its own.
One of those two languages is the one they are surest in and the other is the one they are ` +
`working in — you do not know which way round, so give both and let them choose. Both halves ` +
`say the same thing: do not put a point in one that is missing from the other.
Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` +
`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 each half concise (2-3 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 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.
// The writer selects a passage and picks a style; the model rewrites that
// passage in place. The instruction is deliberately strict about returning ONLY
// the rewritten passage so the result can be dropped straight into the editor —
// no quotes, no preamble, no commentary to strip.
const rewriteSystemTemplate = `You are Petal, a warm English writing assistant helping someone who speaks English ` +
`as a second language. Rewrite the passage the user sends so that it %s, while preserving its original ` +
`meaning. Fix any grammar mistakes and awkward phrasing along the way. Keep it about the same length — ` +
`do not add new ideas, explanations, or commentary.
Respond with ONLY the rewritten passage. No quotation marks around it, no preamble, no notes — just the ` +
`rewritten English text, ready to drop back into the document.`
// styleGuidance maps a rewrite style onto the clause describing the target
// register. "natural" is the default "say it more naturally" action; the rest
// mirror the document-tone vocabulary (see toneGuidance / the ToneSelect UI).
// An unknown style falls back to the natural rewrite.
func styleGuidance(style string) string {
switch style {
case "academic":
return "reads as formal, academic English suited to a school essay or research paper"
case "professional":
return "reads as polished, professional English suited to a workplace email or report"
case "casual":
return "sounds relaxed, friendly, and conversational"
case "humorous":
return "has a light, playful, good-humored tone"
case "creative":
return "is vivid, expressive, and imaginative"
case "persuasive":
return "is confident and persuasive"
default: // "natural"
return "sounds natural and fluent, the way a native English speaker would naturally say it"
}
}
// RewriteMessages builds the message array for a tone-rewrite: the styled system
// instruction plus the passage to rewrite as the user turn.
func RewriteMessages(text, style string) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(rewriteSystemTemplate, styleGuidance(style))},
{Role: "user", Content: text},
}
}
// translateSystemPrompt drives the explanation translator: it renders a
// 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 %[1]s. It is a short explanation of a writing ` +
`suggestion, written for a native %[1]s speaker learning English.
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 the writer's own language.
func TranslateMessages(text string, lang Lang) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(translateSystemPrompt, lang.Name)},
{Role: "user", Content: text},
}
}