Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion docs/my-words-interaction-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,60 @@ in the prompt — they crowd out the room the model needs to *be* a partner. Tel
the **stance** ("one small move, then listen; name what you'd do next") and let
`validateText` be the cop.

### 4.5 Responses API — a separate experiment
### 4.5 A failed move is news for one turn, not for the session

Every path where an edit doesn't land raises one writer-visible notice — a
refusal has to be *legible*, or the partner just going quiet reads as broken.
But the strip that carries it is about the present attempt only. It is retracted
as soon as the writer takes the floor again or the partner lands an edit
(`session.ts` invariant 6); the debug log keeps the history. Before that, a
notice cleared only on replacement or manual dismissal, so a single word-bank
rejection sat on screen through many successful turns — and a stale "that was
blocked" is indistinguishable from a live one, which turns a principled refusal
back into the impression of a broken session.

### 4.6 Tolerate the writer's typography, not the AI's vocabulary

The word-bank constraint is about *which words*, and it should not be enforcing a
particular spelling of them. Three sources have to agree on a phrase — what the
writer typed, what the word processor autocorrected it into, and what the model
heard or read back — and they routinely disagree over hyphenation ("well-being" /
"well being"), curly vs. straight apostrophes, and spacing. Treating those as a
mismatch failed edits the model had got right, and rejected the writer's own
words as not theirs.

So both gates are tolerant of typography and strict about vocabulary:

- **Locating** a phrase runs a leniency ladder (`frontend/src/utilities/
textMatching.ts`), exhausting each rung document-wide before loosening, so an
exact hit always beats a folded one elsewhere. Spans are then sliced from the
*source* text, never the needle.
- **Validating** treats an intra-word hyphen as a word separator, and offers both
readings of a hyphenated compound (`corpus.ts`). This doesn't loosen the phrase
rule: "well being" must still appear contiguously in the corpus for
"well-being" to pass. The AI gains only the freedom to hyphenate — or unhyphenate
— the writer's own adjacent words.

**Where the leniency stops, and why it isn't where you'd guess.** "email" /
"e-mail" is the same ASR ambiguity as "well-being" / "well being" — one utterance,
and the transcriber picks a spelling — so it gets the same treatment. What makes
it need more care is *how* it's matched, not whether it should be: dropping the
separator means letters re-segment, and that can find text no reader would call
the same phrase.

- In the **locator**, "email" would otherwise match inside "the ache mail" and
splice over the wrong words. So that rung — and only that rung — requires the
hit to begin and end on a word boundary in the source.
- In the **word bank**, re-segmentation is worse than a bad span: it invents
meaning. "notable" → "not able" is a claim the writer never made. So the closed
form is offered only where a hyphen actually appears, on one side or the other;
a plain space never binds. A hyphen is a mark the sources disagree about, a
space is not.

The same rule makes a hyphen bind tighter than the glue-word rule: "in-depth" is
one compound to be lifted whole, not "in" (glue) plus a stray "depth".

### 4.7 Responses API — a separate experiment

Worth doing for persisted reasoning, but change the loop *first*. Doing both at
once means we won't know which fixed it, and the loop is the bigger lever.
Expand Down
44 changes: 27 additions & 17 deletions frontend/src/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
} from 'lexical';
import { useEffect } from 'react';

import { firstMatch, MATCH_TIERS } from '@/utilities/textMatching';

import classes from './editor.module.css';

/**
Expand Down Expand Up @@ -198,23 +200,31 @@ function ControlsPlugin({
};
collect($getRoot());

const needle = phrase.toLowerCase();
for (const node of textNodes) {
const idx = node
.getTextContent()
.toLowerCase()
.indexOf(needle);
if (idx === -1) continue;
const selection = $createRangeSelection();
selection.anchor.set(node.getKey(), idx, 'text');
selection.focus.set(
node.getKey(),
idx + phrase.length,
'text',
);
$setSelection(selection);
found = true;
return;
// Exhaust each rung of the leniency ladder across every node
// before loosening, so an exact hit later in the document
// still beats a folded hit in the first node. The offsets
// are source offsets, not needle lengths — a fold can drop
// or merge characters, and the selection has to land on the
// writer's real text.
for (const tier of MATCH_TIERS) {
for (const node of textNodes) {
const hit = firstMatch(
tier,
node.getTextContent(),
phrase,
);
if (!hit) continue;
const selection = $createRangeSelection();
selection.anchor.set(
node.getKey(),
hit.start,
'text',
);
selection.focus.set(node.getKey(), hit.end, 'text');
$setSelection(selection);
found = true;
return;
}
}
});
return found;
Expand Down
73 changes: 73 additions & 0 deletions frontend/src/pages/my-words/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# My Words

A map of this page, for finding the right file before reading any of them. Each
module's own header comment is the real documentation — this is only an index to
them.

**The concept:** the AI may edit the writer's document but never *originate*
words. Everything it places is lifted from the writer's own corpus. See
`docs/my-words-interaction-design.md` for the why.

## Two turn-loops, one edit path

The page has three tabs (`index.tsx`). Walkthrough and Propose share a
text-model loop; Voice runs its own, because the realtime model drives turns
itself. Only the *turn-taking* differs — an edit is an edit in both.

```
text tabs voice tab
───────── ─────────
liveResponder.ts (model, 1 step) voice/realtime.ts (OpenAI Realtime)
→ strategies/{walkthrough,propose} → voice/session.ts (tools + invariants)
→ interaction/shared.ts ───┐ ┌───┘
↓ ↓
interaction/ops.ts lowerOp: EditOp → ParagraphSplice[]
interaction/editor.ts reveal beat, veto, splice → host
EditorAPI (Word / Google Docs / Lexical / MockEditor)
```

- `interaction/types.ts` — `EditOp`, `Responder`, `AssistantMove`. Start here.
- `interaction/ops.ts` — **every** op lowers to a `ParagraphSplice`. Pure, so
preview and apply can't drift. Paragraphs are the coordinate system `view`
numbers and inserts index into.
- `interaction/editor.ts` — reveal-then-apply, and the adapter for hosts without
a native `applySplice`.
- `voice/session.ts` — the six tools' dispatcher, and the numbered **control
invariants**. Change one and `__tests__/voiceControl.test.ts` should be how you
find out.
- `voice/tools.ts` — tool schemas; the descriptions are the model's only guidance
on paragraph targeting.
- `corpus.ts` — the word-bank rule. The riskiest file here, unit-tested directly.

## Where text gets matched against the document

Four places, and they should agree. A phrase the model utters has to be found in
text some host stores, and they disagree constantly over hyphens, curly quotes,
and spacing — see `@/utilities/textMatching`, which owns the fold ladder all of
these use or should use. Its last rung drops separators entirely ("email" finds
"e-mail") and is the only one anchored to word boundaries; loosening it further,
or dropping the anchor, is how a match starts landing on text no reader would
call the same phrase.

| What | Where |
| --- | --- |
| Locating an op's target span | `interaction/ops.ts` → `findSpan` |
| Word-bank validation (tokenizer) | `corpus.ts` → `tokenize` |
| `highlight` / reveal, standalone editor | `@/editor/editor.tsx` → `selectPhrase` |
| `highlight` / reveal, Word | `@/api/wordEditorAPI.ts` → `selectPhrase` |

Google Docs (`@/api/googleDocsEditorAPI.ts`) delegates to Apps Script and is not
yet folded; `demo/mockEditor.ts` always succeeds, so a miss there is invisible in
tests.

## Tests

`__tests__/` splits by class, and the split is deliberate:

- `ops.test.ts`, `corpus.test.ts`, `spliceAdapter.test.ts` — pure functions.
- `voiceControl.test.ts` — *sequencing*: a whole model turn through the real
session, against `MockEditor` and a fake transport.

`demo/` is a standalone harness (`DemoApp.tsx`, scripted responder, mock editor)
for driving the interaction without a model or an Office host.
65 changes: 65 additions & 0 deletions frontend/src/pages/my-words/__tests__/corpus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,71 @@ describe('validateText — phrase-level rule', () => {
expect(validateText('don’t', corpus).ok).toBe(true);
});

it('treats hyphenation as spelling, not as a different word', () => {
// The writer typed it hyphenated; the model (or the transcriber) didn't.
const hyphenated = corpusOf('I care about well-being here');
expect(validateText('well being', hyphenated).ok).toBe(true);
expect(validateText('well-being', hyphenated).ok).toBe(true);

// And the other direction, which is the one voice hits constantly.
const spaced = corpusOf('I care about well being here');
expect(validateText('well-being', spaced).ok).toBe(true);
});

it('splits every hyphen in a chain', () => {
const corpus = corpusOf('a state-of-the-art result');
expect(validateText('state of the art', corpus).ok).toBe(true);
expect(validateText('state-of-the-art', corpus).ok).toBe(true);
});

it('still requires the hyphenated words to be adjacent in the corpus', () => {
// Splitting hyphens must not become a way to invent an adjacency: a
// hyphen is spelling, not a bridge the AI may introduce.
const corpus = corpusOf('the big cat and the small dog');
expect(validateText('big-dog', corpus).ok).toBe(false);
});

it('keeps a standalone dash as a bridge, not a word joiner', () => {
const corpus = corpusOf('the big cat and the small dog');
expect(validateText('big — dog', corpus).ok).toBe(true);
});

it('reads a hyphenated compound closed up, and vice versa', () => {
// Same ASR ambiguity as "well-being"/"well being", with the separator
// gone entirely: the transcriber picks a spelling and it need not be the
// document's.
const hyphenated = corpusOf('I sent an e-mail yesterday');
expect(validateText('email', hyphenated).ok).toBe(true);
expect(validateText('email yesterday', hyphenated).ok).toBe(true);

const closed = corpusOf('I sent an email yesterday');
expect(validateText('e-mail', closed).ok).toBe(true);
expect(validateText('e-mail yesterday', closed).ok).toBe(true);
});

it('will not re-segment across a plain space', () => {
// The line this leniency stops at. A hyphen is a mark the sources
// disagree about; a space is not, and joining across one invents a word
// the writer never wrote.
const corpus = corpusOf('she was not able to finish');
expect(validateText('notable', corpus).ok).toBe(false);

// Nor the reverse: splitting one of the writer's words into two.
const closed = corpusOf('a notable essay');
expect(validateText('not able', closed).ok).toBe(false);
});

it('lets a hyphen bind tighter than the glue rule', () => {
// "in" is a glue word, but "in-depth" is one compound — reading it as
// glue + "depth" would let the AI assemble it from any stray "depth".
const corpus = corpusOf('we need more depth here');
expect(validateText('in-depth', corpus).ok).toBe(false);
expect(validateText('in depth', corpus).ok).toBe(true);

const wrote = corpusOf('we need an in-depth look');
expect(validateText('in-depth', wrote).ok).toBe(true);
});

it('reports a segmentation that labels lifted / glue / punct parts', () => {
const corpus = corpusOf('honesty hard work');
const result = validateText('honesty and hard work, please', corpus);
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/pages/my-words/__tests__/ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,97 @@ describe('applyOp', () => {
});
});

describe('tolerant targeting', () => {
// The model utters the writer's phrase back with its own typography — a
// straight apostrophe where Word autocorrected a curly one, a hyphen where
// the transcriber heard two words. Those are the same phrase, and treating
// them as a miss was costing edits that were otherwise right.
it('finds a hyphenated phrase the model spelled with a space', () => {
expect(
applyOp(['their well-being matters most'], {
kind: 'str_replace',
oldStr: 'well being',
newStr: 'well-being',
}),
).toEqual(['their well-being matters most']);
});

it('finds a spaced phrase the model spelled with a hyphen', () => {
expect(
applyOp(['a state of the art result'], {
kind: 'str_replace',
oldStr: 'state-of-the-art',
newStr: 'state of the art',
}),
).toEqual(['a state of the art result']);
});

it('finds a curly apostrophe behind a straight one', () => {
expect(
applyOp(['I don’t think so'], {
kind: 'str_replace',
oldStr: "don't think",
newStr: 'think',
}),
).toEqual(['I think so']);
});

it('replaces the writer’s real characters, not the needle’s', () => {
// The span is located leniently but sliced from the source, so the en
// dash goes away with the rest of the matched text.
expect(
applyOp(['the well–being section'], {
kind: 'str_replace',
oldStr: 'well-being',
newStr: 'wellness',
}),
).toEqual(['the wellness section']);
});

it('still prefers an exact match elsewhere over a folded one', () => {
expect(
applyOp(['a well-being note', 'a well being note'], {
kind: 'str_replace',
oldStr: 'well being',
newStr: 'health',
}),
).toEqual(['a well-being note', 'a health note']);
});

it('move carries the writer’s characters, not the needle’s', () => {
// A move adds no words; it must not restyle the ones it relocates.
expect(
applyOp(['keep this', 'their well-being'], {
kind: 'move',
phrase: 'well being',
paragraph: 1,
position: 'before',
}),
).toEqual(['well-being', 'keep this', 'their ']);
});

it('keeps a genuine miss a miss', () => {
expect(() =>
applyOp(['the cat sat'], {
kind: 'str_replace',
oldStr: 'the dog sat',
newStr: 'x',
}),
).toThrow(/not found in the document/);
});

it('honors paragraph scoping when matching leniently', () => {
expect(() =>
applyOp(['well-being', 'nothing here'], {
kind: 'str_replace',
oldStr: 'well being',
newStr: 'health',
paragraph: 2,
}),
).toThrow(/paragraph 2/);
});
});

describe('newline lowering (splits and merges)', () => {
it('a newline in newStr splits the paragraph', () => {
expect(
Expand Down
Loading
Loading