Delete petal-spec.md

This commit is contained in:
2026-06-26 06:00:21 +00:00
parent 95123e8c49
commit 0d59b645bb

View File

@@ -1,778 +0,0 @@
# Petal — AI Writing Assistant
## Project Specification for Claude Code
---
## Overview
Petal is a self-hosted, privacy-first writing editor for an ESL user. It replaces Grammarly with a warm, bubbly web app that combines Tiptap rich text editing, auto-save cloud storage, and periodic AI-powered grammar/ESL suggestions backed by a local vLLM inference endpoint.
Single-user initially (wife's Authentik account). Deployed to `write.parodia.dev`.
---
## Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Frontend | React + Vite | Tiptap is React-native; best extension ecosystem |
| Editor | Tiptap v2 | ProseMirror with sane API; supports custom marks/decorations |
| Styling | Tailwind v4 | Utility-first; pairs well with custom design tokens |
| Backend | Go + chi | Consistent with rest of stack (PGX Comics, Veola) |
| Database | SQLite (modernc — pure Go, no cgo) | Simple, single-binary friendly |
| Spell check | nspell (JS, browser-side) | Zero latency, no backend call, loads hunspell dictionaries |
| AI suggestions | vLLM HTTP endpoint | Already running; configurable model + base URL |
| Auth | Authentik OIDC | Already running on parodia.dev |
| Deployment | Docker, Traefik | Consistent with existing infra |
---
## Design System
**Aesthetic:** soft, warm, bubbly. The UI should feel like a cozy stationery app, not a productivity tool.
### Color Palette
```
--color-bg: #FDF6F0 /* warm cream canvas */
--color-surface: #FFFFFF /* card/panel surfaces */
--color-surface-alt: #FFF0F5 /* rose-tinted alt surface */
--color-border: #F0E0EB /* soft pink-grey border */
--color-text: #3D2E39 /* warm dark plum, not harsh black */
--color-text-muted: #9B8CA3 /* muted lavender-grey */
--color-accent: #E8A0BF /* soft rose — primary interactive */
--color-accent-hover: #D98AAF /* slightly deeper on hover */
--color-mint: #A8D8C8 /* suggestion type: grammar */
--color-peach: #F4B8A0 /* suggestion type: phrasing */
--color-lavender: #C5B4E8 /* suggestion type: idiom */
--color-sky: #A8CCE8 /* suggestion type: clarity */
--color-honey: #CE9B4F /* suggestion type: voice — deepened amber for underline contrast on cream */
--color-success: #8FCFA8 /* saved, accepted */
--color-shadow: rgba(180, 130, 160, 0.12) /* warm-tinted shadow */
```
### Typography
- **UI / Headings:** Nunito (Google Fonts) — rounded, bubbly, friendly
- **Editor body:** Lora (Google Fonts) — warm serif, reads beautifully at paragraph length
- **Monospace / data:** JetBrains Mono (used sparingly for word counts etc.)
### Shape Language
- Border radius: `16px` on cards, `24px` on modals, `999px` on buttons/pills, `12px` on input fields
- Shadows: `0 4px 20px var(--color-shadow)` — soft, warm, lifted
- Transitions: `200ms ease` across all interactive elements
### Signature Element
Suggestion decorations animate in with a gentle fade + slight upward float (translateY 4px → 0). The acceptance animation plays a tiny confetti burst (CSS only, 34 colored dots). The pulsing LLM checkpoint indicator is a soft rose dot that breathes (opacity 0.4 → 1 → 0.4 in 2s).
---
## Directory Structure
```
petal/
├── cmd/
│ └── server/
│ └── main.go # Entry point; embeds /web/dist
├── internal/
│ ├── config/
│ │ └── config.go # Env var loading
│ ├── db/
│ │ ├── db.go # SQLite init + migrations
│ │ └── models.go # Document, Suggestion, User types
│ ├── auth/
│ │ ├── oidc.go # Authentik OIDC flow
│ │ └── middleware.go # Session auth middleware
│ ├── docs/
│ │ └── handlers.go # Document CRUD handlers
│ ├── plagiarism/
│ │ ├── copyleaks.go # Copyleaks API client
│ │ └── voice.go # Local LLM voice consistency check
│ └── llm/
│ ├── client.go # LLMClient interface + factory function
│ ├── vllm.go # vLLM concrete implementation (OpenAI-compat)
│ ├── ollama.go # Ollama concrete implementation (native API)
│ ├── checkpoint.go # Checkpoint trigger + debounce logic
│ └── prompts.go # System prompts
├── web/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Editor/
│ │ │ │ ├── EditorCore.tsx # Tiptap instance + config
│ │ │ │ ├── SuggestionMark.tsx # Custom Tiptap mark extension
│ │ │ │ ├── SuggestionCard.tsx # Hover card (suggestion + explanation)
│ │ │ │ └── CheckpointIndicator.tsx
│ │ │ ├── DocList/
│ │ │ │ ├── DocList.tsx # Document browser sidebar
│ │ │ │ └── DocListItem.tsx
│ │ │ ├── Toolbar/
│ │ │ │ └── Toolbar.tsx # Formatting toolbar
│ │ │ └── StatusBar/
│ │ │ └── StatusBar.tsx # Word count, save status, checkpoint dot
│ │ ├── hooks/
│ │ │ ├── useAutoSave.ts # Debounced save (1.5s)
│ │ │ └── useCheckpoint.ts # LLM checkpoint trigger (4s pause)
│ │ ├── api/
│ │ │ └── client.ts # Fetch wrappers for all API routes
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── index.html
│ ├── package.json
│ ├── vite.config.ts
│ └── tailwind.config.ts
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md
```
---
## Database Schema
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY, -- Authentik subject claim
email TEXT NOT NULL,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE documents (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT 'Untitled',
content TEXT NOT NULL DEFAULT '{}', -- Tiptap JSON
content_text TEXT NOT NULL DEFAULT '', -- Plain text for LLM
word_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE suggestions (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
from_pos INTEGER NOT NULL,
to_pos INTEGER NOT NULL,
original TEXT NOT NULL,
replacement TEXT NOT NULL,
explanation TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE plagiarism_reports (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
backend TEXT NOT NULL DEFAULT 'copyleaks',
similarity_pct REAL, -- overall similarity score 0100
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','complete','error')),
result_json TEXT, -- full Copyleaks response, stored raw
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
```
---
## API Routes
All routes under `/api/`. Auth middleware validates session cookie on all except `/api/auth/*`.
```
GET /api/me → current user info
POST /api/auth/login → redirect to Authentik
GET /api/auth/callback → OIDC callback, set session cookie
POST /api/auth/logout → clear session
GET /api/docs → list user's documents (id, title, word_count, updated_at)
POST /api/docs → create new document → returns full document
GET /api/docs/:id → get document + pending suggestions
PUT /api/docs/:id → update document content (auto-save endpoint)
DELETE /api/docs/:id → delete document
POST /api/docs/:id/check → run grammar checkpoint (returns new suggestions)
POST /api/docs/:id/voice → run voice-consistency pass (returns voice suggestions)
POST /api/docs/:id/plagiarism → trigger Copyleaks check (returns report id)
GET /api/docs/:id/plagiarism/latest → get latest report for document
PUT /api/suggestions/:id → update suggestion status (accepted/rejected)
DELETE /api/docs/:id/suggestions → clear all suggestions for a doc
POST /api/suggestions/:id/chat → Ask Petal conversational follow-up (SSE stream)
```
---
## Plagiarism Detection
### Architecture: Two Tiers
**Tier 1 — Voice Consistency Check (local, LLM, separate pass)**
The LLM reviews the document for passages that feel tonally inconsistent with the rest of her writing — formulaic phrasing, suspiciously polished sentences, register shifts. Surfaces these as a new suggestion type (`type: "voice"`) with a warm honey-amber decoration (`--color-honey`, distinct from the lavender used for `idiom`; deepened from a pale yellow so the underline stays legible against the cream canvas). This is not plagiarism detection; it's a writing consistency tool. It runs locally with no privacy cost and is useful for ESL writers who may have paraphrased too closely from a source.
**This is a distinct pass from the grammar checkpoint — do not bundle them.** Voice-consistency detection is inherently whole-document (it compares a passage against the established voice *everywhere else*), so it needs the full document as context and the full window is available (see Context Window Management). That makes it too expensive to fire on the fast 4s checkpoint cadence. Run it instead either:
- on a slow cadence (every 3060s of idle), separate from the grammar checkpoint debounce, or
- on an explicit "Check my voice" action in the toolbar.
Send the full `content_text` (no trailing-window truncation — the model window is 256K, the document will fit). Use a higher `max_tokens` than the grammar checkpoint since it may flag several passages. The grammar checkpoint stays fast and local-context; voice runs slow and whole-document. Each uses the context size and cadence appropriate to its job.
**Tier 2 — Academic Plagiarism Check (Copyleaks, opt-in, manual)**
A dedicated "Check for plagiarism" button in the editor toolbar. On first use, shows a consent modal explaining that document text will be sent to Copyleaks servers for comparison against web and academic databases. User must explicitly confirm. After confirmation, preference is saved per-user and the modal does not reappear.
Copyleaks checks against web content and academic paper databases — the same corpus type schools use with Turnitin. Not identical to Turnitin, but meaningful for pre-submission review.
### Copyleaks Integration
**Env vars:**
```
COPYLEAKS_ENABLED=false # off by default
COPYLEAKS_API_KEY=
COPYLEAKS_EMAIL= # account email, required by their API
```
**API flow:**
Copyleaks uses an async model — you submit text, get a scan ID, then poll or receive webhook callback when complete.
```
1. POST https://api.copyleaks.com/v3/education/submit/url/{scanId}
(or text submission endpoint)
→ 201 Accepted, scanId stored in plagiarism_reports
2. Copyleaks calls webhook: POST /api/webhooks/copyleaks
→ Go handler updates plagiarism_reports with result_json + similarity_pct + status=complete
3. Frontend polls GET /api/docs/:id/plagiarism/latest every 5s
while status=pending, renders results when complete
```
**Webhook endpoint:** `POST /api/webhooks/copyleaks` — must be publicly reachable (it is, via write.parodia.dev). No auth middleware on this route; validate using Copyleaks HMAC signature header instead.
**Results display (`PlagiarismPanel` component):**
- Slide-in right panel (same pattern as AskPetal expanded card but full height)
- Top: large similarity percentage with color coding (green <15%, amber 1530%, red >30%)
- Below: list of matched sources with URL, matched percentage, and matched passage
- Matched passages highlighted in the editor with a new red-tinted decoration type
- "Last checked: X minutes ago" + "Re-check" button
- Privacy reminder at panel footer: "Text was sent to Copyleaks for this check"
### Voice Consistency Prompt Addition
Add to the checkpoint system prompt (after existing ESL instructions):
```
Also identify any passages (2+ 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.
Flag these with type "voice". Do not flag the first paragraph (no baseline yet).
```
Return format addition:
```json
{ "original": "...", "replacement": null, "explanation": "This passage sounds more formal than the rest of your writing — worth reviewing.", "type": "voice" }
```
`replacement` is `null` for voice flags — there is no correction, just awareness. The frontend SuggestionCard handles `null` replacement by hiding the replacement row and showing only the explanation + Dismiss.
---
## LLM Integration
### Client Config (env vars)
```
LLM_BACKEND=vllm # vllm | ollama — default: vllm
LLM_ENDPOINT=http://192.168.x.x:8000 # vLLM default port 8000, Ollama default 11434
LLM_MODEL=gemma4 # Checkpoint model — small, fast. Treat as config, never hardcode.
LLM_CHAT_MODEL=gemma4 # Ask Petal model — can be much larger. Defaults to LLM_MODEL if unset.
LLM_TIMEOUT=30s
```
**Model sizing guidance:**
Checkpoint runs every 4 seconds while the user types — latency is UX. Keep `LLM_MODEL` at 4B7B. Gemma4 is a solid default.
Ask Petal is a deliberate conversational turn with small input context (~2,000 tokens) and short output (24 sentences). Since the user asks questions in Mandarin, `LLM_CHAT_MODEL` should be a Chinese-native model — **Qwen3.5 9B** is the right pick. Native Mandarin capability rather than learned multilingual coverage makes a real difference for grammar explanation quality. With 64GB VRAM you have plenty of headroom for a larger Qwen3 variant.
If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL`. Single-model setups work fine.
### Backend Abstraction
Define a `LLMClient` interface in `internal/llm/client.go`. All handler code calls the interface only — never a concrete type directly.
```go
// internal/llm/client.go
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type CompletionRequest struct {
Messages []Message
MaxTokens int
Temperature float64
RepetitionPenalty float64
TopP float64
Stop []string
Stream bool
}
type LLMClient interface {
// Complete returns the full response in one shot (used for checkpoint JSON)
Complete(ctx context.Context, req CompletionRequest) (string, error)
// Stream returns a channel of text chunks (used for Ask Petal SSE)
Stream(ctx context.Context, req CompletionRequest) (<-chan string, error)
}
// Factory — selects backend from config
func NewLLMClient(cfg *config.Config) LLMClient {
// LLM_CHAT_MODEL falls back to LLM_MODEL if unset
chatModel := cfg.LLMChatModel
if chatModel == "" {
chatModel = cfg.LLMModel
}
switch cfg.LLMBackend {
case "ollama":
return &OllamaClient{
endpoint: cfg.LLMEndpoint,
checkpointModel: cfg.LLMModel,
chatModel: chatModel,
timeout: cfg.LLMTimeout,
}
default: // "vllm"
return &VLLMClient{
endpoint: cfg.LLMEndpoint,
checkpointModel: cfg.LLMModel,
chatModel: chatModel,
timeout: cfg.LLMTimeout,
}
}
}
```
**`internal/llm/vllm.go`** — OpenAI-compatible endpoint:
```
POST {endpoint}/v1/chat/completions
Content-Type: application/json
{
"model": "{model}",
"messages": [...],
"max_tokens": {MaxTokens},
"temperature": {Temperature},
"repetition_penalty": {RepetitionPenalty},
"top_p": {TopP},
"stop": [...],
"stream": {Stream}
}
```
Streaming: SSE, parse `data:` lines, extract `choices[0].delta.content`, stop on `data: [DONE]`.
**`internal/llm/ollama.go`** — Ollama native API:
```
POST {endpoint}/api/chat
Content-Type: application/json
{
"model": "{model}",
"messages": [...],
"stream": {Stream},
"options": {
"num_predict": {MaxTokens},
"temperature": {Temperature},
"repeat_penalty": {RepetitionPenalty},
"top_p": {TopP},
"stop": [...]
}
}
```
Streaming: newline-delimited JSON objects, extract `message.content`, stop when `done: true`.
**Parameter mapping cheatsheet:**
| CompletionRequest field | vLLM key | Ollama key |
|---|---|---|
| `MaxTokens` | `max_tokens` | `options.num_predict` |
| `Temperature` | `temperature` | `options.temperature` |
| `RepetitionPenalty` | `repetition_penalty` | `options.repeat_penalty` |
| `TopP` | `top_p` | `options.top_p` |
| `Stop` | `stop` | `options.stop` |
Both backends live behind the same interface — checkpoint.go and the Ask Petal handler call `client.Complete()` and `client.Stream()` with no awareness of which backend is active.
### Checkpoint Logic
- **Trigger:** user stops typing for 4 seconds (debounced in frontend via `useCheckpoint.ts`)
- **Rate limit:** backend enforces minimum 30s between checks per document
- **Payload:** full `content_text` of the document (not just the changed section — context matters for ESL)
- **Max tokens:** 1024 (suggestions are structured JSON, should be compact)
- **Visual feedback:** pulsing rose dot in StatusBar while check in flight
### System Prompt
```
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.
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": []}
```
### Ask Petal: Conversational Follow-Up
When a user doesn't understand a suggestion, they tap **"Ask Petal ✨"** inside the suggestion card. This opens a mini chat panel anchored to the suggestion card (expands downward, max-height 320px, scrollable). The conversation is maintained in React state — not persisted to the DB. Closing the card clears the history.
**API route:** `POST /api/suggestions/:id/chat`
**Request payload:**
```json
{
"messages": [
{ "role": "user", "content": "why is this wrong?" }
]
}
```
**Server behavior:** The Go handler fetches the suggestion record (original, replacement, explanation, type) and the surrounding paragraph from the parent document's `content_text`. It injects these as system context, then appends the user's message history and streams the response back via SSE.
**Response:** SSE stream, `data:` events containing response text chunks. Frontend accumulates into assistant message bubble.
**Ask Petal System Prompt:**
```
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: "{{original}}"
- Suggested replacement: "{{replacement}}"
- Issue type: {{type}}
- Initial explanation: "{{explanation}}"
- Surrounding paragraph: "{{paragraph}}"
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.
```
**AskPetal Component (`web/src/components/Editor/AskPetal.tsx`):**
- Renders inside the expanded SuggestionCard
- Input field at bottom, message bubbles above (Petal messages: rose-tinted left-aligned; user messages: right-aligned lavender)
- "Ask Petal ✨" trigger button is a small pill link below the suggestion explanation
- Petal's first message pre-populates with the suggestion's explanation so context is immediate
- Streaming response renders token-by-token into the latest assistant bubble
- No conversation persistence — state lives in the SuggestionCard component
### LLM Sampling Parameters & Stability
vLLM is stateless per request. There are no server-side sessions — conversation history for Ask Petal is managed entirely at the app layer (client sends full message array each request). This is correct behavior; do not attempt to implement vLLM-side session persistence.
**Checkpoint requests (structured JSON output):**
```json
{
"model": "${LLM_MODEL}",
"messages": [...],
"max_tokens": 1024,
"temperature": 0.3,
"repetition_penalty": 1.15,
"top_p": 0.9,
"stop": ["```", "\n\n\n\n"]
}
```
**Ask Petal chat requests (conversational):**
```json
{
"model": "${LLM_MODEL}",
"messages": [...],
"max_tokens": 512,
"temperature": 0.7,
"repetition_penalty": 1.15,
"top_p": 0.92,
"stop": ["\n\n\n"]
}
```
**Why these values:**
- `temperature: 0.3` for checkpoint — low enough to produce structured JSON reliably, but not zero. Zero temperature means greedy decoding, which is paradoxically *more* prone to repetition loops in smaller models, not less.
- `temperature: 0.7` for chat — conversational range; allows natural variation without going incoherent.
- `repetition_penalty: 1.15` on both — primary defense against repetition breakdown. Applies a multiplicative penalty to already-seen tokens. 1.11.2 is the safe range; above 1.3 starts degrading output quality.
- `stop` sequences — catches runaway generation before it fills the output buffer. Triple newline is a reliable signal the model has looped past the end of a coherent response.
- `max_tokens: 512` for chat — enforces conciseness and prevents the model from rambling into a degraded state mid-response.
### Context Window Management
The model window is **not** the binding constraint here — Qwen 3.5 ships a 256K context window, and on a 64GB dual-GPU setup there is ample KV-cache headroom. The truncation caps below exist to protect **checkpoint latency** (prefill time scales with input length, and the grammar checkpoint must feel responsive), not memory. Documents will almost always fit uncut; the cap only fires on unusually long ones.
**Grammar checkpoint** (fast, latency-sensitive — keep input modest):
| Component | Token Budget |
|---|---|
| System prompt | ~300 |
| Document text | ~10,000 (hard cap; trailing window) |
| Response (JSON) | ~1,024 |
| Buffer | rest of window (ample) |
**Voice-consistency pass** (slow cadence — send the whole document, no trailing-window truncation; see Plagiarism Detection → Tier 1).
**Ask Petal chat:**
| Component | Token Budget |
|---|---|
| System prompt + suggestion context | ~600 |
| Conversation history (rolling) | ~3,000 |
| Latest user message | ~200 |
| Response | ~512 |
| Buffer | ~3,880 |
**Truncation logic (implement in `internal/llm/client.go`):**
```go
const maxDocChars = 40000 // ~10000 tokens at ~4 chars/token — grammar checkpoint only
const maxHistoryMsgs = 10 // 5 turns; drop oldest pairs first
// Document truncation (grammar checkpoint) — keep the recent end (user is actively writing there).
// The cap is a latency guard, not a window limit; the model window (256K) is far larger.
// The voice-consistency pass does NOT apply this truncation — it sends the full content_text.
if len(contentText) > maxDocChars {
contentText = contentText[len(contentText)-maxDocChars:]
}
// Conversation history truncation — drop oldest messages first
if len(messages) > maxHistoryMsgs {
messages = messages[len(messages)-maxHistoryMsgs:]
}
```
Always log a `WARN` when truncation fires so it's visible in production. Never fail silently.
The Go handler receives the LLM suggestions (which reference `original` text strings) and records the plain-text offsets via `strings.Index(content_text, original)` as `from_pos` / `to_pos`. **These stored offsets are for server-side use only** (paragraph extraction for Ask Petal context) and are *not* ProseMirror positions — see Note #6. The frontend anchors decorations by searching the live document for the `original` string in ProseMirror coordinates at render time, not by trusting a numeric position. This is the single most important correctness detail in the suggestion pipeline; get it wrong and every multi-block document mis-renders.
---
## Frontend: Tiptap Configuration
### Extensions to enable
```typescript
StarterKit, // Bold, italic, headings, lists, paragraphs
Underline,
TextAlign,
Placeholder.configure({ placeholder: 'Start writing...' }),
SpellChecker, // Custom extension wrapping nspell
SuggestionMark, // Custom extension for AI suggestion decorations
CharacterCount, // For word count in StatusBar
```
### SuggestionMark Extension
Custom Tiptap mark that:
- Accepts `{ suggestionId, type }` attributes
- **Anchors by string match, not stored position** — when suggestions arrive, walk the live ProseMirror document, find the `original` text in PM coordinates, and apply the mark over that range. Do not use the server's `from_pos`/`to_pos` (those are plain-text offsets, not PM positions — see Note #6). If `original` isn't found in the current document (already edited), skip that suggestion silently.
- Renders as a colored underline (color varies by type using CSS vars)
- On hover, shows `<SuggestionCard>` positioned absolutely above the text
- SuggestionCard shows: original → replacement, explanation, Accept button, Dismiss button
- Accept → calls `PUT /api/suggestions/:id` with `status: accepted`, applies replacement via Tiptap command
- Dismiss → calls `PUT /api/suggestions/:id` with `status: rejected`, removes mark
### useAutoSave Hook
```typescript
// Debounce 1500ms after last content change
// Calls PUT /api/docs/:id with { content, content_text, word_count }
// StatusBar shows: "Saving..." → "Saved just now" → fades to nothing after 3s
```
### useCheckpoint Hook
```typescript
// Debounce 4000ms after last content change
// Fires POST /api/docs/:id/check
// On response: dispatch suggestion decorations to Tiptap editor
// Sets checkpointActive state → StatusBar indicator
```
---
## Layout
```
┌─────────────────────────────────────────────────────────────┐
│ [🌸 Petal] [New Doc] [User Avatar] │ ← Header (48px)
├──────────────┬──────────────────────────────────────────────┤
│ │ │
│ Doc List │ Editor Canvas │
│ (260px) │ (centered, max-width 720px) │
│ │ │
│ [Doc 1] │ ┌─────────────────────────────┐ │
│ [Doc 2] ← │ │ [B] [I] [U] [H1] [H2] [≡] │ │ ← Toolbar
│ [Doc 3] │ └─────────────────────────────┘ │
│ │ │
│ + New │ Title (editable, large Nunito) │
│ │ │
│ │ Body text in Lora... │
│ │ ~~~~~~ suggestion underline ~~~~~~ │
│ │ │
│ │ │
├──────────────┴──────────────────────────────────────────────┤
│ 342 words · ● Checking... · Saved just now │ ← StatusBar (36px)
└─────────────────────────────────────────────────────────────┘
```
Distraction-free mode: clicking into the editor collapses the doc list sidebar (slides left), expands editor canvas full width. Click outside canvas or press Escape to restore.
---
## Environment Config
```bash
# .env.example
# Server
PORT=8080
BASE_URL=https://write.parodia.dev
SESSION_SECRET=change-me-to-random-64-char-string
# Database
DATABASE_PATH=/data/petal.db
# Authentik OIDC
AUTHENTIK_URL=https://auth.parodia.dev
AUTHENTIK_CLIENT_ID=petal
AUTHENTIK_CLIENT_SECRET=
# LLM
LLM_BACKEND=vllm # vllm | ollama
LLM_ENDPOINT=http://192.168.x.x:8000
LLM_MODEL=gemma4 # Checkpoint: small and fast (4B7B)
LLM_CHAT_MODEL=qwen3.5:9b # Ask Petal: Qwen3.5 9B recommended for native Mandarin support
LLM_TIMEOUT=30s
```
---
## Dockerfile
```dockerfile
# Stage 1: Build frontend
FROM node:22-alpine AS frontend
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Stage 2: Build Go binary
FROM golang:1.23-alpine AS backend
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
COPY --from=frontend /app/web/dist ./web/dist
RUN CGO_ENABLED=0 go build -o petal ./cmd/server
# Stage 3: Runtime
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=backend /app/petal .
VOLUME ["/data"]
EXPOSE 8080
CMD ["./petal"]
```
---
## docker-compose.yml
```yaml
services:
petal:
image: petal:latest
restart: unless-stopped
volumes:
- petal_data:/data
env_file: .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.petal.rule=Host(`write.parodia.dev`)"
- "traefik.http.routers.petal.entrypoints=web-secure"
- "traefik.http.routers.petal.tls.certresolver=default"
- "traefik.http.services.petal.loadbalancer.server.port=8080"
networks:
- traefik
volumes:
petal_data:
networks:
traefik:
external: true
```
---
## v1 Scope (Ship This)
- [x] Tiptap rich text editor (bold, italic, underline, headings H1/H2, ordered/unordered lists)
- [x] Document list sidebar (create, rename, delete)
- [x] Auto-save to SQLite (1.5s debounce)
- [x] LLM checkpoint every 4s idle → inline suggestion decorations
- [x] Suggestion hover card (replacement + explanation + accept/dismiss)
- [x] nspell browser-side spell check (English dictionary)
- [x] Word count + save status + checkpoint indicator in StatusBar
- [x] Authentik OIDC auth
- [x] Plagiarism check (two-tier: local voice consistency + opt-in Copyleaks academic check)
- [x] Ask Petal conversational follow-up chat on any suggestion
- [x] Distraction-free mode
- [x] Soft/bubbly pastel design system
- [x] Single Docker binary deployment to write.parodia.dev
## Out of Scope for v1
- Export to DOCX / PDF (v1.1)
- Mobile layout (desktop first)
- Real-time collaboration
- Version history / change tracking
- Multiple language support (English first)
- Offline mode
---
## Notes for Claude Code
1. **Model names are always config values**`LLM_MODEL` drives checkpoint, `LLM_CHAT_MODEL` drives Ask Petal. Both read from env vars. Never hardcode either. If `LLM_CHAT_MODEL` is unset, fall back to `LLM_MODEL` — handle this in the factory, not in individual handlers.
2. **modernc sqlite** (`modernc.org/sqlite`) — pure Go, no cgo, no build friction.
3. **Tiptap JSON format** is stored verbatim in `documents.content`. `content_text` is the plain text extracted for the LLM — keep these in sync on every save.
4. **nspell dictionaries** — load `en-US` dictionary files from CDN or vendor them into `web/public/dictionaries/`. Don't shell out to hunspell binary.
5. **Go embeds frontend** — use `//go:embed web/dist` in `cmd/server/main.go`. Single binary deployment.
6. **Suggestion anchoring — resolve by string, not by stored position.** The `original` text string is the source of truth for where a suggestion belongs, **not** a stored numeric position. Two reasons:
- **Coordinate mismatch:** `strings.Index(contentText, original)` returns a *plain-text* character offset. Tiptap/ProseMirror positions are **not** plain-text offsets — every node boundary (paragraph, heading, list item) consumes position units, so a plaintext offset of N does not equal ProseMirror position N. Handing a plaintext offset to a Tiptap decoration places it in the wrong spot, and the error grows with each block in the document.
- **Staleness:** the user keeps typing (auto-save every 1.5s) after a checkpoint fires. Any position captured at checkpoint time is stale by the time the suggestion is stored and rendered.
**Therefore:** the frontend resolves each suggestion at *render time* by searching the live ProseMirror document for the `original` string (in PM coordinates) and applying the decoration there. The Go handler still runs `strings.Index(contentText, original)` and stores `from_pos`/`to_pos`, but these are **plain-text offsets for server-side use only** (e.g. extracting the surrounding paragraph for Ask Petal context — see Note #10), never shipped to the client as ProseMirror positions. If `original` appears multiple times, take the first occurrence; if not found at all, discard the suggestion rather than erroring. On the client, if `original` is no longer present in the live document (the user already edited that text), silently drop the suggestion — it's obsolete.
7. **Session management** — use `gorilla/sessions` with a cookie store. Session key is `petal_session`. Store `user_id` in session after OIDC callback.
8. **Authentik app** — needs to be created in Authentik with redirect URI `https://write.parodia.dev/api/auth/callback` and the OIDC client credentials added to `.env`.
9. **Ask Petal SSE streaming** — use `text/event-stream` response in Go, flush after each token chunk. Frontend uses `fetch` with `ReadableStream` (not `EventSource` — needs POST) to accumulate tokens into the assistant bubble in real time. Don't buffer the full response.
10. **Ask Petal context injection** — the Go handler for `/api/suggestions/:id/chat` fetches the suggestion + parent document in one query, extracts the paragraph containing `from_pos` from `content_text`, and injects all of it into the system prompt before the user messages. Never trust the client to send this context — always load it server-side.
11. **LLM backend abstraction**`NewLLMClient` is the only place that references `LLM_BACKEND`. Every other package receives an `LLMClient` interface value. `checkpoint.go` and the Ask Petal handler must import only the interface, not either concrete type. This keeps adding a third backend (e.g. LM Studio) to a single new file in the future.
12. **Ollama streaming format differs from vLLM** — Ollama streams newline-delimited JSON objects (`{"message":{"content":"..."},"done":false}`), not SSE `data:` lines. The `OllamaClient.Stream()` method must handle this format; do not reuse the vLLM SSE parser for Ollama.
13. **Copyleaks async flow** — submission returns immediately with a scan ID. Results arrive via webhook, not the submission response. Store the scan ID in `plagiarism_reports` immediately on submission, update the row when the webhook fires. Frontend polls `/api/docs/:id/plagiarism/latest` every 5s while `status=pending` — don't try to make this synchronous.
14. **Copyleaks webhook HMAC** — validate the `X-Copyleaks-Signature` header on every webhook call. Reject without processing if invalid. Never skip this in production.
15. **`replacement: null` in voice suggestions** — the SuggestionCard must check for null replacement and conditionally render. Don't assume replacement is always a string.
17. **CJK font fallback in Ask Petal chat** — Nunito has no CJK coverage. The chat bubble `font-family` must include system CJK fallbacks: `'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif`. Apply this specifically to the AskPetal message bubbles, not the editor body.
18. **Mandarin detection is the model's job** — do not attempt language detection in Go or the frontend. The prompt instructs the model to detect and match. This works fine with Gemma4 which has solid Mandarin capability. If a future model swap degrades Mandarin quality, that's a prompt/model problem, not an architecture problem.