Files
petal/internal/llm/prompts.go
prosolis 978cb80642 Ask Petal answers in both languages, with room to read
The tutor prompt said "never mix languages in a single response" and
mirrored the language of the question, so asking in English — which she
does, because she is practising — returned the one explanation surface
that gives nothing in her own language. It now answers in both, pair
language first, halves separated by a blank line.

Which half is the safety net and which is the lesson depends on who is
writing: the pair is (English + X) and Petal is used from both ends, so
the prompt asks for both and says it doesn't know which way round.

The split is a rendering nicety, never a parse the reply depends on: a
half-streamed reply is all one half, a model that ignores the
instruction renders as one block, and nothing is ever dropped.

For the height, the first attempt clamped the box to the room left below
the anchored card so it could never overhang — measured, that gave 176px
against a 442px answer, worse than the 220px it replaced. The card's own
chrome spends ~290px of an 810px window, so "fits below the word" and
"room to read" are not both available. The ceiling is now a flat 50vh and
the overhang is made navigable instead, per item 4: the card reports its
reach like the rail already does, the column grows, and the page can
scroll to the actions below it.
2026-07-28 07:04:16 -07:00

265 lines
14 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."
}
// CheckpointMessages builds the message array for a grammar checkpoint over the
// given (already-truncated) document text, steered toward the document's tone.
func CheckpointMessages(contentText, tone string) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
{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": []}`
// 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.
func VoiceMessages(contentText string) []Message {
return []Message{
{Role: "system", Content: voiceSystemPrompt},
{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.
func CollocationMessages(contentText, tone string, lang Lang) []Message {
return []Message{
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone), lang.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},
}
}