Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
+ collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
(SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
collocating/runCollocation in useCheckpoint, StatusBar dot
Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
"again", no streak-shaming), handlers (capture-upsert/list/due/
review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
footer; opened from a global 🌷 header button
Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
237 lines
12 KiB
Go
237 lines
12 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 Mandarin gloss — 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 Simplified Chinese 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.
|
|
func CollocationMessages(contentText, tone string) []Message {
|
|
return []Message{
|
|
{Role: "system", Content: fmt.Sprintf(collocationSystemPrompt, toneGuidance(tone))},
|
|
{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},
|
|
}
|
|
}
|