Editor: document tone, right-click word lookup, expanded stats
Four enhancements to make the editor fit real school usage:
- Per-document tone (academic/professional/casual/humorous/creative/
persuasive/general): new documents.tone column (migration 0002), threaded
through the docs API, a bilingual ToneSelect dropdown on the title row, and
injected into the grammar-checkpoint LLM prompt so advice fits the register.
The voice pass stays tone-agnostic.
- Right-click word lookup: a new offline `lexicon` package serves definitions
(Wordset, modern ESL-friendly glosses) and synonyms (WordNet synsets first,
then frequency+stopword-ranked Moby for breadth) from gzipped embedded data,
behind /api/word/{word} with light morphology. The WordCard popover shows the
definition and tappable synonym pills that swap the word in place.
- Expanded writing stats: clicking the word count opens a StatsPanel with page
count, sentences, paragraphs, reading time, average word length, word variety,
and Flesch-Kincaid reading level — all computed client-side.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -169,6 +169,13 @@ CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
||||
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
|
||||
// the writer's target register (academic essay vs casual journal).
|
||||
// 'general' means no specific tone steering.
|
||||
name: "0002_document_tone",
|
||||
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type Document struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // Tiptap JSON
|
||||
ContentText string `json:"content_text"` // plain text for the LLM
|
||||
Tone string `json:"tone"` // target writing tone; steers LLM advice
|
||||
WordCount int `json:"word_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -81,11 +81,11 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`INSERT INTO documents (user_id) VALUES (?)
|
||||
RETURNING id, user_id, title, content, content_text, word_count, created_at, updated_at`,
|
||||
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
|
||||
db.LocalUserID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -115,6 +115,7 @@ type updateRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
ContentText *string `json:"content_text"`
|
||||
Tone *string `json:"tone"`
|
||||
WordCount *int `json:"word_count"`
|
||||
}
|
||||
|
||||
@@ -134,10 +135,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
SET title = COALESCE(?, title),
|
||||
content = COALESCE(?, content),
|
||||
content_text = COALESCE(?, content_text),
|
||||
tone = COALESCE(?, tone),
|
||||
word_count = COALESCE(?, word_count),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID,
|
||||
req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
|
||||
)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
@@ -177,13 +179,13 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) fetch(id string) (db.Document, error) {
|
||||
var doc db.Document
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT id, user_id, title, content, content_text, word_count, created_at, updated_at
|
||||
`SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
|
||||
FROM documents
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
id, db.LocalUserID,
|
||||
).Scan(
|
||||
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
|
||||
&doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
|
||||
)
|
||||
return doc, err
|
||||
}
|
||||
|
||||
21
internal/lexicon/data.go
Normal file
21
internal/lexicon/data.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Package lexicon serves offline word lookups — a definition and a list of
|
||||
// synonyms for a single word — from two public-domain datasets compiled into the
|
||||
// binary. Definitions come from the Wordset dictionary (modern, concise glosses
|
||||
// with a part of speech and example, which read kindly for an ESL writer);
|
||||
// synonyms come from the Moby Thesaurus. Both are gzipped JSON, decompressed
|
||||
// lazily on first use so a writer who never right-clicks a word pays nothing.
|
||||
package lexicon
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// definitionsGz is the gzipped Wordset definitions map: word → [[pos, def,
|
||||
// example], …], lowercase keys. Built by the data-prep step (see commit notes).
|
||||
//
|
||||
//go:embed data/definitions.json.gz
|
||||
var definitionsGz []byte
|
||||
|
||||
// synonymsGz is the gzipped Moby thesaurus map: headword → [synonym, …],
|
||||
// lowercase keys, capped per word to keep the popover (and the binary) small.
|
||||
//
|
||||
//go:embed data/synonyms.json.gz
|
||||
var synonymsGz []byte
|
||||
BIN
internal/lexicon/data/definitions.json.gz
Normal file
BIN
internal/lexicon/data/definitions.json.gz
Normal file
Binary file not shown.
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
BIN
internal/lexicon/data/synonyms.json.gz
Normal file
Binary file not shown.
50
internal/lexicon/handlers.go
Normal file
50
internal/lexicon/handlers.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler serves the word-lookup endpoint backed by a single shared Lexicon.
|
||||
type Handler struct {
|
||||
Lex *Lexicon
|
||||
}
|
||||
|
||||
// New constructs a Handler with a fresh (lazily-loaded) Lexicon.
|
||||
func NewHandler() *Handler { return &Handler{Lex: New()} }
|
||||
|
||||
// Routes returns the router mounted at /api/word. The word is a path segment so
|
||||
// "/api/word/happy" reads naturally; it's URL-decoded to tolerate the rare
|
||||
// punctuated token.
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/{word}", h.lookup)
|
||||
return r
|
||||
}
|
||||
|
||||
// lookup returns the definition + synonyms for one word. A word found in neither
|
||||
// dataset still returns 200 with empty lists, so the popover can show a friendly
|
||||
// "nothing found" rather than an error state.
|
||||
func (h *Handler) lookup(w http.ResponseWriter, r *http.Request) {
|
||||
word := chi.URLParam(r, "word")
|
||||
if decoded, err := url.PathUnescape(word); err == nil {
|
||||
word = decoded
|
||||
}
|
||||
|
||||
res, err := h.Lex.Lookup(word)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Word lookups are static for the life of the build; let the browser cache
|
||||
// them so repeated right-clicks on the same word are instant.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
205
internal/lexicon/lexicon.go
Normal file
205
internal/lexicon/lexicon.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Meaning is one sense of a word: its part of speech, the gloss, and an optional
|
||||
// usage example. The frontend renders a few of these in the word popover.
|
||||
type Meaning struct {
|
||||
PartOfSpeech string `json:"part_of_speech"`
|
||||
Definition string `json:"definition"`
|
||||
Example string `json:"example,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the full lookup for one word. Either list may be empty (the word
|
||||
// isn't a headword in that dataset); the frontend handles a partial or empty
|
||||
// result gracefully.
|
||||
type Result struct {
|
||||
Word string `json:"word"`
|
||||
Definitions []Meaning `json:"definitions"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
}
|
||||
|
||||
// maxSynonyms caps how many synonyms we hand the popover, even though the dataset
|
||||
// stores up to ~50 per word — a long flat wall of words overwhelms more than it
|
||||
// helps, especially for an ESL reader scanning for the right fit.
|
||||
const maxSynonyms = 16
|
||||
|
||||
// maxDefinitions caps the senses shown so the popover stays a glance, not an
|
||||
// essay.
|
||||
const maxDefinitions = 4
|
||||
|
||||
// Lexicon holds the lazily-loaded datasets. The maps are populated once, on the
|
||||
// first Lookup, behind a sync.Once so startup stays instant and a load error is
|
||||
// remembered rather than retried on every request.
|
||||
type Lexicon struct {
|
||||
once sync.Once
|
||||
loadErr error
|
||||
defs map[string][][]string // word → [[pos, def, example], …]
|
||||
synonyms map[string][]string // word → [synonym, …]
|
||||
}
|
||||
|
||||
// New returns a Lexicon. The datasets aren't read until the first Lookup.
|
||||
func New() *Lexicon { return &Lexicon{} }
|
||||
|
||||
func (l *Lexicon) load() {
|
||||
l.once.Do(func() {
|
||||
if err := gunzipJSON(definitionsGz, &l.defs); err != nil {
|
||||
l.loadErr = fmt.Errorf("load definitions: %w", err)
|
||||
return
|
||||
}
|
||||
if err := gunzipJSON(synonymsGz, &l.synonyms); err != nil {
|
||||
l.loadErr = fmt.Errorf("load synonyms: %w", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Lookup returns the definition senses and synonyms for word. It first tries the
|
||||
// word as written (lowercased), then a few simple morphological reductions
|
||||
// (plurals, -ed/-ing/-ly) so "running" or "happily" still resolve. A word found
|
||||
// in neither dataset yields a Result with empty lists (not an error).
|
||||
func (l *Lexicon) Lookup(word string) (Result, error) {
|
||||
l.load()
|
||||
if l.loadErr != nil {
|
||||
return Result{}, l.loadErr
|
||||
}
|
||||
|
||||
norm := strings.ToLower(strings.TrimSpace(word))
|
||||
res := Result{Word: word, Definitions: []Meaning{}, Synonyms: []string{}}
|
||||
if norm == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if raw := lookupDefs(l.defs, norm); raw != nil {
|
||||
for _, m := range raw {
|
||||
res.Definitions = append(res.Definitions, toMeaning(m))
|
||||
if len(res.Definitions) >= maxDefinitions {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if syns := lookupSyns(l.synonyms, norm); syns != nil {
|
||||
if len(syns) > maxSynonyms {
|
||||
syns = syns[:maxSynonyms]
|
||||
}
|
||||
res.Synonyms = append(res.Synonyms, syns...)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// lookupDefs / lookupSyns walk the candidate forms of a word and return the
|
||||
// first dataset hit. They're separate (rather than a generic helper) only
|
||||
// because the two maps have different value types.
|
||||
func lookupDefs(m map[string][][]string, word string) [][]string {
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupSyns(m map[string][]string, word string) []string {
|
||||
for _, c := range candidates(word) {
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// candidates returns the lemma forms to try, in priority order: the word itself,
|
||||
// then conservative de-inflections. This is deliberately lightweight — a full
|
||||
// stemmer would over-reduce ("business" → "busy") and surface wrong entries; a
|
||||
// handful of common English suffix rules covers the everyday cases without a
|
||||
// dependency. Duplicates are fine (map lookup is cheap); order is what matters.
|
||||
func candidates(word string) []string {
|
||||
out := []string{word}
|
||||
add := func(s string) {
|
||||
if len(s) >= 2 && s != word {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(word, "ies"): // studies → study
|
||||
add(word[:len(word)-3] + "y")
|
||||
case strings.HasSuffix(word, "es"): // boxes → box, wishes → wish
|
||||
add(word[:len(word)-2])
|
||||
add(word[:len(word)-1])
|
||||
case strings.HasSuffix(word, "s"): // cats → cat
|
||||
add(word[:len(word)-1])
|
||||
}
|
||||
|
||||
if strings.HasSuffix(word, "ing") { // running → run, making → make
|
||||
stem := word[:len(word)-3]
|
||||
add(stem)
|
||||
add(stem + "e")
|
||||
add(undouble(stem))
|
||||
}
|
||||
if strings.HasSuffix(word, "ed") { // hoped → hope, stopped → stop
|
||||
stem := word[:len(word)-2]
|
||||
add(stem)
|
||||
add(word[:len(word)-1])
|
||||
add(undouble(stem))
|
||||
}
|
||||
if strings.HasSuffix(word, "ly") { // happily handled above via ies path? no — quickly → quick
|
||||
add(word[:len(word)-2])
|
||||
}
|
||||
if strings.HasSuffix(word, "ily") { // happily → happy
|
||||
add(word[:len(word)-3] + "y")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// undouble collapses a doubled final consonant (stopp → stop, runn → run) so the
|
||||
// -ed/-ing stems of doubled-consonant verbs resolve to their base form.
|
||||
func undouble(stem string) string {
|
||||
n := len(stem)
|
||||
if n >= 2 && stem[n-1] == stem[n-2] {
|
||||
return stem[:n-1]
|
||||
}
|
||||
return stem
|
||||
}
|
||||
|
||||
// toMeaning maps a compact [pos, def, example] triple from the dataset onto the
|
||||
// JSON-friendly Meaning. The dataset always stores three elements, but we guard
|
||||
// the length so a malformed row can't panic.
|
||||
func toMeaning(m []string) Meaning {
|
||||
var out Meaning
|
||||
if len(m) > 0 {
|
||||
out.PartOfSpeech = m[0]
|
||||
}
|
||||
if len(m) > 1 {
|
||||
out.Definition = m[1]
|
||||
}
|
||||
if len(m) > 2 {
|
||||
out.Example = m[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gunzipJSON decompresses gz and decodes the JSON into v.
|
||||
func gunzipJSON(gz []byte, v any) error {
|
||||
r, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
76
internal/lexicon/lexicon_test.go
Normal file
76
internal/lexicon/lexicon_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package lexicon
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLookupKnownWord(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("happy")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(res.Definitions) == 0 {
|
||||
t.Errorf("expected definitions for %q, got none", "happy")
|
||||
}
|
||||
if len(res.Synonyms) == 0 {
|
||||
t.Errorf("expected synonyms for %q, got none", "happy")
|
||||
}
|
||||
if len(res.Synonyms) > maxSynonyms {
|
||||
t.Errorf("synonyms not capped: got %d, want <= %d", len(res.Synonyms), maxSynonyms)
|
||||
}
|
||||
if len(res.Definitions) > maxDefinitions {
|
||||
t.Errorf("definitions not capped: got %d, want <= %d", len(res.Definitions), maxDefinitions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupMorphology(t *testing.T) {
|
||||
l := New()
|
||||
// Inflected forms should resolve to their base entry via candidates().
|
||||
for _, w := range []string{"running", "studies", "boxes", "quickly", "stopped"} {
|
||||
res, err := l.Lookup(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%q): %v", w, err)
|
||||
}
|
||||
if len(res.Definitions) == 0 && len(res.Synonyms) == 0 {
|
||||
t.Errorf("expected some result for inflected %q, got nothing", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupUnknownWord(t *testing.T) {
|
||||
l := New()
|
||||
res, err := l.Lookup("zzzxqqq")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if len(res.Definitions) != 0 || len(res.Synonyms) != 0 {
|
||||
t.Errorf("expected empty result for nonsense word, got %+v", res)
|
||||
}
|
||||
// Empty result must still serialize as [] not null for the frontend.
|
||||
if res.Definitions == nil || res.Synonyms == nil {
|
||||
t.Errorf("empty slices must be non-nil for JSON []: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidates(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"cats": "cat",
|
||||
"studies": "study",
|
||||
"running": "run",
|
||||
"hoped": "hope",
|
||||
"quickly": "quick",
|
||||
"happily": "happy",
|
||||
}
|
||||
for word, want := range cases {
|
||||
got := candidates(word)
|
||||
found := false
|
||||
for _, c := range got {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("candidates(%q) = %v, missing expected base %q", word, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ type checkpointResponse struct {
|
||||
// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It
|
||||
// applies the latency-guard truncation and the checkpoint sampling parameters
|
||||
// from the spec.
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
||||
func RunCheckpoint(ctx context.Context, client LLMClient, contentText, tone string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText)),
|
||||
Messages: CheckpointMessages(TruncateDoc(contentText), tone),
|
||||
MaxTokens: 1024,
|
||||
Temperature: 0.3,
|
||||
RepetitionPenalty: 1.15,
|
||||
|
||||
@@ -8,7 +8,7 @@ const checkpointSystemPrompt = `You are a warm, encouraging writing assistant he
|
||||
`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.
|
||||
Be specific, friendly, and explain WHY each suggestion improves the writing.%s
|
||||
|
||||
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
{
|
||||
@@ -24,11 +24,32 @@ Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||
|
||||
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.
|
||||
func CheckpointMessages(contentText string) []Message {
|
||||
// given (already-truncated) document text, steered toward the document's tone.
|
||||
func CheckpointMessages(contentText, tone string) []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: checkpointSystemPrompt},
|
||||
{Role: "system", Content: fmt.Sprintf(checkpointSystemPrompt, toneGuidance(tone))},
|
||||
{Role: "user", Content: contentText},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ const VoiceInterval = 20 * time.Second
|
||||
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
|
||||
// tolerant parser and a larger token budget, since one pass may flag several
|
||||
// passages. Each flag carries a null replacement (awareness-only).
|
||||
func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
||||
// The tone argument is accepted for a uniform pass signature but ignored: voice
|
||||
// consistency is judged against the document's own established voice, not an
|
||||
// externally-chosen register.
|
||||
func RunVoice(ctx context.Context, client LLMClient, contentText, _ string) ([]RawSuggestion, error) {
|
||||
raw, err := client.Complete(ctx, CompletionRequest{
|
||||
Messages: VoiceMessages(contentText),
|
||||
MaxTokens: 2048,
|
||||
|
||||
@@ -69,8 +69,9 @@ func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||
// given the document text it returns the model's raw suggestions.
|
||||
type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
|
||||
// given the document text and the document's tone it returns the model's raw
|
||||
// suggestions. The voice pass ignores tone (see llm.RunVoice).
|
||||
type pass func(ctx context.Context, client llm.LLMClient, contentText, tone string) ([]llm.RawSuggestion, error)
|
||||
|
||||
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||
@@ -79,11 +80,11 @@ type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([
|
||||
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
var contentText string
|
||||
var contentText, tone string
|
||||
err := h.DB.QueryRow(
|
||||
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
|
||||
`SELECT content_text, tone FROM documents WHERE id = ? AND user_id = ?`,
|
||||
docID, db.LocalUserID,
|
||||
).Scan(&contentText)
|
||||
).Scan(&contentText, &tone)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
errorJSON(w, http.StatusNotFound, "document not found")
|
||||
return
|
||||
@@ -111,7 +112,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := run(r.Context(), h.Client, contentText)
|
||||
raw, err := run(r.Context(), h.Client, contentText, tone)
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user