Merge: the advice tap points both ways

Claude-Session: https://claude.ai/code/session_01KGACAtTPjvZ2PipDZ5qD99
This commit is contained in:
prosolis
2026-08-02 12:03:26 -07:00
8 changed files with 88 additions and 59 deletions
+2 -2
View File
@@ -44,7 +44,7 @@ func TestPromptsNameTheWritersLanguage(t *testing.T) {
t.Fatalf("collocation prompt lost its tone guidance:\n%s", collocation) t.Fatalf("collocation prompt lost its tone guidance:\n%s", collocation)
} }
translate := TranslateMessages("Try a shorter sentence here.", pt)[0].Content translate := TranslateMessages("Try a shorter sentence here.", English, pt)[0].Content
if !strings.Contains(translate, "European Portuguese") || strings.Contains(translate, "Chinese") { if !strings.Contains(translate, "European Portuguese") || strings.Contains(translate, "Chinese") {
t.Fatalf("translate prompt targets the wrong language:\n%s", translate) t.Fatalf("translate prompt targets the wrong language:\n%s", translate)
} }
@@ -76,7 +76,7 @@ func TestDefaultPairStillReadsAsBefore(t *testing.T) {
if got := CollocationMessages("x", "", EnglishTarget(zh))[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") { if got := CollocationMessages("x", "", EnglishTarget(zh))[0].Content; !strings.Contains(got, "Simplified Chinese (Mandarin) gloss in parentheses") {
t.Fatalf("zh collocation gloss changed:\n%s", got) t.Fatalf("zh collocation gloss changed:\n%s", got)
} }
if got := TranslateMessages("x", zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") { if got := TranslateMessages("x", English, zh)[0].Content; !strings.Contains(got, "natural, friendly Simplified Chinese (Mandarin)") {
t.Fatalf("zh translate target changed:\n%s", got) t.Fatalf("zh translate target changed:\n%s", got)
} }
if got := AskPetalSystemPrompt("a", "b", "c", "d", "e", zh); !strings.Contains(got, "为什么") { if got := AskPetalSystemPrompt("a", "b", "c", "d", "e", zh); !strings.Contains(got, "为什么") {
+19 -13
View File
@@ -329,23 +329,29 @@ func RewriteMessages(text, style string) []Message {
} }
// translateSystemPrompt drives the explanation translator: it renders a // translateSystemPrompt drives the explanation translator: it renders a
// suggestion's English explanation into the writer's own language so an ESL // suggestion's explanation into the half of the pair the explanation is not
// reader sees the "why" in her first language. Strict about returning ONLY the // already in, so the "why" is readable from both sides. Strict about returning
// translation (no quotes, no romanisation, no English echo) so it can drop // ONLY the translation (no quotes, no romanisation, no echo of the source) so it
// straight into the chat bubble. Kept warm and plain — these are short, friendly // can drop straight into the chat bubble. Kept warm and plain — these are short,
// one-liners. // 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 ` + // Both languages are parameters because neither end is a constant. Until Phase
`suggestion, written for a native %[1]s speaker learning English. // 28 the source was always English and the destination always hers; a document
// written in her own language is explained in her own language, and then the tap
// runs the other way, into the English she is practising.
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the %[1]s text the user ` +
`sends into natural, friendly %[2]s. It is a short explanation of a writing ` +
`suggestion, written for someone who is learning one of %[1]s and %[2]s and reads the other most easily.
Respond with ONLY the %[1]s translation. No quotation marks, no romanisation, no English, no preamble — ` + Respond with ONLY the %[2]s translation. No quotation marks, no romanisation, no %[1]s, no preamble — ` +
`just the translated sentence.` `just the translated sentence.`
// TranslateMessages builds the message array for translating one short English // TranslateMessages builds the message array for rendering one short
// explanation into the writer's own language. // explanation out of the language it arrived in and into the other half of the
func TranslateMessages(text string, lang Lang) []Message { // writer's pair.
func TranslateMessages(text string, from, to Lang) []Message {
return []Message{ return []Message{
{Role: "system", Content: fmt.Sprintf(translateSystemPrompt, lang.Name)}, {Role: "system", Content: fmt.Sprintf(translateSystemPrompt, from.Name, to.Name)},
{Role: "user", Content: text}, {Role: "user", Content: text},
} }
} }
+7 -6
View File
@@ -4,13 +4,14 @@ import (
"context" "context"
) )
// RunTranslate renders a short English explanation into the writer's own // RunTranslate renders a short explanation out of the language it was written
// language. It is a one-shot Complete (the result seeds the Ask Petal bubble), // in and into the other half of the writer's pair. It is a one-shot Complete
// kept at a low temperature so the translation is faithful rather than creative. Output is // (the result seeds the Ask Petal bubble), kept at a low temperature so the
// trimmed of any stray surrounding quotes the model may add. // translation is faithful rather than creative. Output is trimmed of any stray
func RunTranslate(ctx context.Context, client LLMClient, text string, lang Lang) (string, error) { // surrounding quotes the model may add.
func RunTranslate(ctx context.Context, client LLMClient, text string, from, to Lang) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{ out, err := client.Complete(ctx, CompletionRequest{
Messages: TranslateMessages(text, lang), Messages: TranslateMessages(text, from, to),
MaxTokens: 512, MaxTokens: 512,
Temperature: 0.2, Temperature: 0.2,
TopP: 0.9, TopP: 0.9,
+16 -8
View File
@@ -371,10 +371,15 @@ func seedExplanation(t *testing.T, h *Handler, docID, explanation string) string
// The tap-through has to read the same decision the card was written under. On a // The tap-through has to read the same decision the card was written under. On a
// Portuguese document by a Portuguese writer the explanation already arrived in // Portuguese document by a Portuguese writer the explanation already arrived in
// Portuguese, and the old endpoint would have sent it to the model to be // Portuguese, so the destination is the other half of the pair — the English she
// rendered into Portuguese again. // is practising — and never Portuguese into Portuguese again.
func TestTranslateSkipsWhenTheExplanationIsAlreadyHers(t *testing.T) { //
client := &stubClient{response: "Não devia ser chamado."} // This pins the report that "Petal presents the Ask Petal advice in both
// sections as Portuguese": the endpoint used to answer "" here, which left the
// card Portuguese, the bubble beneath it the same Portuguese, and no English on
// the card at all for a writer whose whole reason for the pair is English.
func TestTranslateRendersHerExplanationIntoTheEnglishSheIsLearning(t *testing.T) {
client := &stubClient{response: "Use this one here."}
srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument) srv, docID, h := newDirectedServer(t, client, "pt-PT", auth.DirectionLearningEn, ptDocument)
setDocLang(t, h, docID, docLangPair) setDocLang(t, h, docID, docLangPair)
sugID := seedExplanation(t, h, docID, "Aqui usa-se isto.") sugID := seedExplanation(t, h, docID, "Aqui usa-se isto.")
@@ -387,11 +392,14 @@ func TestTranslateSkipsWhenTheExplanationIsAlreadyHers(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
if out.Translation != "" { if out.Translation == "" {
t.Fatalf("translation = %q, want empty: the bubble seeds from the explanation itself", out.Translation) t.Fatal("a Portuguese explanation left the tap with nowhere to go")
} }
if client.calls != 0 { if !strings.Contains(client.lastPrompt, "into natural, friendly English") {
t.Fatal("the model was asked to render Portuguese into Portuguese") t.Fatalf("translate didn't render into English:\n%s", client.lastPrompt)
}
if strings.Contains(client.lastPrompt, "into natural, friendly European Portuguese") {
t.Fatalf("the model was asked to render Portuguese into Portuguese:\n%s", client.lastPrompt)
} }
} }
+18 -12
View File
@@ -30,11 +30,18 @@ type translateResponse struct {
// this endpoint has to read the same decision back, or it round-trips Portuguese // this endpoint has to read the same decision back, or it round-trips Portuguese
// into Portuguese and calls it a translation. // into Portuguese and calls it a translation.
// //
// So: render into whichever half the explanation is NOT already in, and when the // So: render into whichever half the explanation is NOT already in and that
// explanation already arrived in the language this bubble exists to reach her // is the whole rule, in both directions. When it first shipped this endpoint
// in, skip the model call and answer "". The client seeds the bubble with the // answered "" for a Portuguese explanation on the reasoning that an English
// explanation itself when the translation comes back empty, which is exactly // rendering she hadn't asked for was noise. It was reported as the opposite: a
// right — there is nothing to add. // writer whose pair is Portuguese and English, learning English, met a
// Portuguese card with a Portuguese bubble under it and no English anywhere on
// the card. The tap is the one place the other language was promised, and the
// half she is *practising* is exactly the half worth a tap.
//
// The bubble sits directly beneath the explanation inside the card, so between
// the two the writer always has both languages, whichever way round the document
// put them.
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) { func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
sugID := chi.URLParam(r, "id") sugID := chi.URLParam(r, "id")
@@ -69,15 +76,14 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
// today; the alternative is a language column on every suggestion row, and the // today; the alternative is a language column on every suggestion row, and the
// cost of being wrong is one bubble seeded in the language it was already in. // cost of being wrong is one bubble seeded in the language it was already in.
target := targetFor(pairLang, direction, docLang) target := targetFor(pairLang, direction, docLang)
if target.Explain.Code == target.Pair.Code { from, to := target.Explain, target.Pair
// Already in her language. The other half is English — the language she is if from.Code == to.Code {
// practising — and an unasked-for English rendering of an explanation she // The explanation is already in her language, so the half this tap has to
// can already read is not a seed, it's noise. // reach is the other one: the English she is practising.
httputil.WriteJSON(w, http.StatusOK, translateResponse{Translation: ""}) to = llm.English
return
} }
out, err := llm.RunTranslate(r.Context(), h.Client, explanation, target.Pair) out, err := llm.RunTranslate(r.Context(), h.Client, explanation, from, to)
if err != nil { if err != nil {
httputil.UpstreamError(w, "translate", err) httputil.UpstreamError(w, "translate", err)
return return
+24 -16
View File
@@ -5,12 +5,11 @@ import { splitBilingual } from './bilingualReply'
import { fromIME } from '../../lib/ime' import { fromIME } from '../../lib/ime'
interface Props { interface Props {
// The card's explanation is deliberately NOT passed in. Petal's opening
// bubble is that explanation rendered into the other half of the pair,
// fetched on open from this id — and when that can't be had, the panel has
// nothing to say rather than a second copy of the card.
suggestionId: string suggestionId: string
// The English explanation (shown in the card body). Petal's opening bubble is
// its translation into the pair language, fetched on open — so the panel
// doesn't just repeat the same English text twice. Falls back to this on
// failure.
explanation: string
} }
// CJK fallback stack — Nunito has no Chinese glyphs, and on the zh pair both // CJK fallback stack — Nunito has no Chinese glyphs, and on the zh pair both
@@ -44,10 +43,11 @@ const CHAT_MIN_PX = 160
// conversation lives in this component's state — nothing is persisted; closing // conversation lives in this component's state — nothing is persisted; closing
// the card (unmounting) clears it. Each send streams Petal's reply token-by- // the card (unmounting) clears it. Each send streams Petal's reply token-by-
// token into the latest assistant bubble. // token into the latest assistant bubble.
export function AskPetal({ suggestionId, explanation }: Props) { export function AskPetal({ suggestionId }: Props) {
const t = usePack() const t = usePack()
// Opening bubble starts empty (caret-only) and fills with the pair-language // Opening bubble starts empty (caret-only) and fills with the other half of
// translation once it lands; `seeding` drives that loading caret. // the pair once it lands; `seeding` drives that loading caret, and the bubble
// goes away entirely if there turns out to be nothing to put in it.
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }]) const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
const [seeding, setSeeding] = useState(true) const [seeding, setSeeding] = useState(true)
const [input, setInput] = useState('') const [input, setInput] = useState('')
@@ -82,23 +82,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus({ preventScroll: true }) inputRef.current?.focus({ preventScroll: true })
}, []) }, [])
// Fetch the pair-language translation of the explanation to seed the first // Fetch the explanation rendered into the other half of her pair to seed the
// bubble. // first bubble — English under a Portuguese card, her language under an
// English one; the server decides which way round (suggestions/translate.go).
// Only replaces the seed bubble if the user hasn't started chatting yet (the // Only replaces the seed bubble if the user hasn't started chatting yet (the
// conversation always opens with this one assistant turn). Falls back to the // conversation opens with this one assistant turn and nothing else).
// English explanation if the translation can't be fetched. //
// When it can't be fetched the panel opens empty rather than falling back to
// the explanation, which sits two lines higher in the card: a bubble that
// repeats the card verbatim reads as Petal answering in the language the tap
// was pressed to escape. Nothing to add, nothing shown.
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const seed = (text: string) =>
setMessages((prev) =>
prev.length !== 1 ? prev : text === '' ? [] : [{ role: 'assistant', content: text }],
)
api api
.translateSuggestion(suggestionId) .translateSuggestion(suggestionId)
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
const text = res.translation.trim() || explanation seed(res.translation.trim())
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
}) })
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev)) seed('')
}) })
.finally(() => { .finally(() => {
if (!cancelled) setSeeding(false) if (!cancelled) setSeeding(false)
@@ -106,7 +114,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [suggestionId, explanation]) }, [suggestionId])
async function send() { async function send() {
const text = input.trim() const text = input.trim()
+1 -1
View File
@@ -197,7 +197,7 @@ export function SuggestionCard({
{asking ? 'Hide Petal' : 'Ask Petal ✨'} {asking ? 'Hide Petal' : 'Ask Petal ✨'}
</button> </button>
{asking && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />} {asking && <AskPetal suggestionId={suggestion.id} />}
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
{hasReplacement && ( {hasReplacement && (
+1 -1
View File
@@ -217,7 +217,7 @@ const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
</span> </span>
</button> </button>
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />} {expanded && <AskPetal suggestionId={suggestion.id} />}
<div className="mt-2.5 flex items-center gap-2"> <div className="mt-2.5 flex items-center gap-2">
{hasReplacement && ( {hasReplacement && (