Phase 5: voice consistency pass

Tier-1 voice-consistency pass: whole-document LLM review surfacing passages
that read tonally out of place (formal/over-polished/paraphrased-too-closely),
as honey-decorated `voice` flags with no correction (awareness-only).

- internal/llm/voice.go: RunVoice sends the whole document (no TruncateDoc),
  MaxTokens 2048, 20s per-doc floor (VoiceInterval). Standalone voice prompt
  in prompts.go (not bundled with the grammar checkpoint, per spec).
- internal/suggestions: POST /api/docs/:id/voice. replacePending is now
  family-scoped (pendingScope) so grammar and voice never clobber each other's
  pending flags; both passes return the unified pending set. check/voice share
  one runPass helper. TestVoicePassCoexists covers both directions.
- Frontend: api.voiceDoc, useCheckpoint voicing/runVoice, honey "Check my
  voice" toolbar pill, breathing honey dot in StatusBar.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 21:16:53 -07:00
parent 3c5f3ecb96
commit 0fa70979a0
12 changed files with 342 additions and 59 deletions

View File

@@ -26,6 +26,10 @@ interface Props {
suggestions: Suggestion[]
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
// Triggers the whole-document voice-consistency pass; `voicing` is true while
// it runs (drives the toolbar button's loading state).
onVoiceCheck: () => void
voicing: boolean
}
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
@@ -50,7 +54,16 @@ interface HoverState {
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
// alignment, a placeholder, character counting, and the suggestion-highlight
// decoration layer. Hovering a highlight opens its SuggestionCard.
export function EditorCore({ docId, initialContent, onChange, suggestions, onAccept, onDismiss }: Props) {
export function EditorCore({
docId,
initialContent,
onChange,
suggestions,
onAccept,
onDismiss,
onVoiceCheck,
voicing,
}: Props) {
const wrapperRef = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState<HoverState | null>(null)
// Delays card close so the pointer can travel from highlight to card.
@@ -195,7 +208,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
return (
<div className="flex flex-1 flex-col">
<Toolbar editor={editor} />
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
<div
ref={wrapperRef}
className="relative flex-1"

View File

@@ -5,6 +5,8 @@ interface Props {
saveStatus: SaveStatus
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
checking: boolean
// True while a whole-document voice pass runs — shows a breathing honey dot.
voicing: boolean
}
const SAVE_LABEL: Record<SaveStatus, string> = {
@@ -18,7 +20,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
// StatusBar is the slim footer: word count on the left, save state and the
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
// circle that breathes while a check is in flight (spec → Signature animations).
export function StatusBar({ wordCount, saveStatus, checking }: Props) {
export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
const label = SAVE_LABEL[saveStatus]
return (
<footer
@@ -40,6 +42,18 @@ export function StatusBar({ wordCount, saveStatus, checking }: Props) {
</span>
</>
)}
{voicing && (
<>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
<span
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
style={{ background: 'var(--color-honey)' }}
/>
Reading your voice
</span>
</>
)}
{label && (
<>
<span aria-hidden>·</span>

View File

@@ -3,6 +3,9 @@ import { useEditorState } from '@tiptap/react'
interface Props {
editor: Editor | null
// Runs the whole-document voice-consistency pass; `voicing` shows its progress.
onVoiceCheck: () => void
voicing: boolean
}
// A formatting button. `active` gets the rose pill treatment so the writer can
@@ -47,7 +50,7 @@ const Divider = () => (
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
// useEditorState subscribes to just the flags it reads, so the buttons reflect
// the current selection without re-rendering the whole tree on every keystroke.
export function Toolbar({ editor }: Props) {
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
const state = useEditorState({
editor,
selector: ({ editor }) =>
@@ -134,6 +137,28 @@ export function Toolbar({ editor }: Props) {
>
</TBtn>
<Divider />
<button
type="button"
aria-label="Check my voice"
disabled={voicing}
onMouseDown={(e) => e.preventDefault()} // keep editor selection
onClick={onVoiceCheck}
className="ml-0.5 inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-xs font-bold transition-colors disabled:opacity-70"
style={{
borderRadius: 'var(--radius-pill)',
color: 'var(--color-plum)',
background: 'var(--color-honey)',
}}
title="Read the whole document for passages that don't sound like you"
>
<span
className={voicing ? 'petal-checkpoint-dot inline-block h-2 w-2 rounded-full' : 'hidden'}
style={{ background: 'var(--color-plum)' }}
aria-hidden
/>
{voicing ? 'Reading…' : 'Check my voice 🍯'}
</button>
</div>
)
}