Phase 3: LLM grammar checkpoint
Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.
internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.
Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.
Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -28,6 +28,25 @@ export interface DocUpdate {
|
||||
word_count?: number
|
||||
}
|
||||
|
||||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||
|
||||
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||
// the editor re-anchors by matching this string in the live document (spec
|
||||
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
|
||||
// empty for voice flags (awareness-only, no correction to apply).
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
doc_id: string
|
||||
from_pos: number
|
||||
to_pos: number
|
||||
original: string
|
||||
replacement: string
|
||||
explanation: string
|
||||
type: SuggestionType
|
||||
status: 'pending' | 'accepted' | 'rejected'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -48,4 +67,14 @@ export const api = {
|
||||
updateDoc: (id: string, body: DocUpdate) =>
|
||||
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deleteDoc: (id: string) => req<void>(`/docs/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Grammar checkpoint: runs an LLM pass and returns the fresh pending set.
|
||||
// Rate-limited per document server-side (returns the existing set if too soon).
|
||||
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
||||
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||
acceptSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||
dismissSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user