Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
189 lines
9.1 KiB
Go
189 lines
9.1 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},
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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: "%s"
|
|
- Suggested replacement: "%s"
|
|
- Issue type: %s
|
|
- Initial explanation: "%s"
|
|
- Surrounding paragraph: "%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.
|
|
|
|
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. ` +
|
|
`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)
|
|
}
|
|
|
|
// 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 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.
|
|
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.
|
|
|
|
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, 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 {
|
|
return []Message{
|
|
{Role: "system", Content: translateSystemPrompt},
|
|
{Role: "user", Content: text},
|
|
}
|
|
}
|