diff --git a/docs/design/co-created-brief.md b/docs/design/co-created-brief.md new file mode 100644 index 00000000..cf232ac7 --- /dev/null +++ b/docs/design/co-created-brief.md @@ -0,0 +1,151 @@ +# The co-created brief + +Status: first slice implemented (draft-from-document proposals). The chat half +and the standalone rubric are open — see [Open questions](#open-questions). + +## Where this came from + +A writer used the Chat page on a paper draft with this prompt: + +> Help me think through a checklist of things that this paper should do +> successfully before we submit it as a registered report. i.e., what are the +> acceptance criteria. Let's aim for no more than a dozen, and keep them clear +> and succinct. + +It worked well enough to want as a preset. The obvious home was a fourteenth +button on the Revise feature grid, and that turns out to be the wrong shape for +two reasons. + +**It isn't the same kind of thing as the other thirteen.** Every Revise feature +answers *what is in my document?* — outline it, list its claims, find its +counterarguments. This one answers *what would make this document done?* That is +a statement about the goal, not an observation about the text, and it belongs +with the other statements about the goal. + +**The Revise result panel is the wrong container for it.** Results there are +read and discarded. Acceptance criteria are the sort of thing you want to still +have next week, and to have shaped what the tool says to you in between. + +So it goes to the brief. But the brief has a problem of its own, which is the +actual subject of this document. + +## The problem: the brief is incomplete, and the document knows things it doesn't + +The brief (`frontend/src/contexts/docBriefContext.tsx`) is three fields — +Audience, Purpose, Constraints — that the writer states and that every page +folds into its requests. It is deliberately modest: *stated*, not negotiated. + +In practice it is usually blank or thin, and the reason is structural rather +than lazy. A writer who can crisply state their audience and their success +conditions is a writer who has already done the hardest part of the work. The +ones who most need the brief to exist are the ones least able to fill it in from +a cold start. Meanwhile the draft in front of them is full of evidence about all +three fields — they have been making decisions about audience and purpose in +every paragraph, just not writing those decisions down anywhere. + +That asymmetry is the whole design opening: **the brief should be co-created by +the writer and the assistant, grounded in the document, rather than typed from +nothing.** + +## What is implemented + +A "Draft from my document" action in `BriefSection`. It reads the current draft, +asks for candidate wording for each of the three fields, and renders each +candidate as a provisional card next to its field. + +The generalized version of the original prompt lives in +`frontend/src/api/briefProposal.ts`, as the guidance for **Constraints**: + +> Write this one as a checklist: a Markdown list of clear, succinct criteria, +> each something the writer could actually judge as met or unmet. No more than a +> dozen, and fewer is better. + +"Paper" and "registered report" are not restated in the prompt. They come from +the document and from whatever the writer has already put in the brief, both of +which are in the request — which is what makes it a preset rather than a +one-off. + +### The rules it follows + +`interface-concepts.md` sets a covenant that this had to be built against, and +two of its clauses do real work here. + +**"The writer's sentences are the writer's."** Where the assistant must produce +prose, the artifact is "framed as *draft material to be edited*, rendered in a +visibly provisional style, and inert until the writer touches it." So: + +- Candidates never land in a field. They render below it, dashed and tinted, as + something visibly not yet part of the brief. +- A candidate for a field the writer has already filled in leaves their wording + untouched and sits alongside it. +- Candidates are session state only. They are held in the brief context but + deliberately not in `DocBrief`, and never serialized — nothing the writer has + not agreed to should follow the file to whoever opens it next. +- The only path from candidate to brief runs through `acceptProposal` → + `setField`, so an accepted candidate is saved, logged, and cleared by exactly + the same code as text the writer typed. +- Typing into a field clears its candidate. A field the writer has answered + themselves is a field whose candidate is spent, whether they accepted it or + ignored it. + +**"The writer grades; the AI gathers evidence."** The Constraints checklist is +phrased so each item is something the writer can judge as met or unmet. Nothing +in the add-in marks them met. + +### Grounding, and why it's strict + +The prompt pushes hard on staying inside the document, and omitting a field +entirely rather than guessing at it. The failure mode is worse than a bad +outline: a plausible invented audience reads as insight, gets accepted without +much scrutiny, and then silently frames every request on every page for weeks. +A blank field is visibly blank. A wrong one is not. + +This is also why "nothing to suggest yet" is a real, visible outcome with its +own notice rather than a quiet no-op — and its wording points the writer at the +fact that an undetermined audience is the interesting finding, not a failure. + +### What gets logged + +`brief_proposal_requested` / `_received` / `_resolved` / `_error` +(`LOG_SCHEMA_VERSION` 5). The one that matters is `_resolved`, which records +accepted vs dismissed per field. If candidates are almost always accepted +unedited, the tool is writing the brief rather than co-creating it, and the +feature has become the thing this document is trying to avoid. + +## Open questions + +**Chat should be able to propose brief updates.** The direction this is headed: +a conversation on any page can provisionally update any part of the brief, with +the same accept/dismiss mechanic. The plumbing is already shaped for it — +`setProposals` is on the shared context and any page can call it — but the chat +side needs a way to decide *when* to propose without turning every message into +an interruption, which "silence is a feature" says a lot about and this document +does not yet answer. + +**Does the readiness checklist deserve to be its own artifact?** Right now it is +Constraints, which is a genuine fit ("what does this have to satisfy?") but a +tight one: a dozen criteria is a lot for one textarea, and criteria are the sort +of thing you want to grade individually rather than keep as one blob of text. + +The reason it is not a fourth field is naming. `interface-concepts.md` reserves +the vocabulary a standalone version would need — the Charter's **Rubric** is +exactly "3–6 criteria for success, in the writer's own words," each with +evidence and a writer-set mark. A fourth brief field called "Success criteria" +would squat on that name while providing none of the mechanic. Better to leave +the name free and let Constraints carry the checklist until the Charter is +actually built. + +**Is this the Charter's cheapest prototype?** `interface-concepts.md` proposes +Wizard-of-Oz in Chat: a facilitator runs three opening questions and maintains +the Charter as a pinned markdown block, measuring whether writers *edit* the +candidate criteria. What is built here is narrower but real, and it produces the +same measurement from actual use rather than from a facilitated session. Worth +deciding whether it replaces that prototype or feeds it. + +## Related + +- `docs/design/interface-concepts.md` — Concept 1, the Charter. +- `frontend/src/contexts/docBriefContext.tsx` — the brief and the candidate + lifecycle. +- `frontend/src/api/briefProposal.ts` — the prompt and its parser. +- Issue #597 — the Revise feature grid, which this deliberately does not touch. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 8cbd40a0..ade2db76 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -76,6 +76,19 @@ the add-in rewrites the writer's prose, so "don't touch my opening" has nothing bite on, and asking for it frames the writer as a supervisor of an output machine. Add a field only if a human collaborator would want to know it too. +**Proposals** (`src/api/briefProposal.ts`) let the writer draft a brief from the +document they've already written, which is the usual case — a writer who can +state their audience cold has done the hardest part already. See +`docs/design/co-created-brief.md`. The rule that shapes the code: a proposal is +*not* the writer's until they accept it, so candidates live in the context +beside the brief but never inside `DocBrief`, are never serialized to the +document, render below their field rather than in it (even when the field is +empty), and reach the brief only via `acceptProposal` → `setField` — one path, +so an accepted candidate is saved and logged exactly like typed text. Typing +into a field clears its candidate. Keep it that way: putting a proposal into a +field, or into the saved JSON, is how this becomes a tool that writes the +writer's brief for them. + ### Generation calls and failures Pages must generate through `src/api/generate.ts` (`streamTextDeltas`, diff --git a/frontend/src/api/__tests__/briefProposal.test.ts b/frontend/src/api/__tests__/briefProposal.test.ts new file mode 100644 index 00000000..877a4d18 --- /dev/null +++ b/frontend/src/api/__tests__/briefProposal.test.ts @@ -0,0 +1,163 @@ +import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test'; +import { MockLanguageModelV3 } from 'ai/test'; +import { describe, expect, it, vi } from 'vitest'; +import { EMPTY_DOC_BRIEF } from '@/contexts/docBriefContext'; +import { parseBriefProposal, requestBriefProposal } from '../briefProposal'; + +// The module reaches for the real provider at import time; the tests pass a +// mock model per call, so the provider itself is never used. +vi.mock('@/api/openai', () => ({ + languageModel: null, + openaiProviderOptions: {}, +})); + +/** See the note in `generate.test.ts` — the part shape is read off the mock. */ +type StreamPart = + Awaited< + ReturnType + >['stream'] extends ReadableStream + ? Part + : never; + +/** Shaped as in `generate.test.ts` — v3 breaks down both fields. */ +const FINISH: StreamPart = { + type: 'finish', + finishReason: { unified: 'stop', raw: undefined }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, +}; + +function modelReturning(text: string) { + const parts: StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: '1' }, + { type: 'text-delta', id: '1', delta: text }, + { type: 'text-end', id: '1' }, + FINISH, + ]; + return new MockLanguageModelV3({ + doStream: () => + Promise.resolve({ stream: convertArrayToReadableStream(parts) }), + }); +} + +const DOC: DocContext = { + beforeCursor: 'A study of how writers use AI tools. ', + selectedText: '', + afterCursor: 'We preregister three hypotheses.', +}; + +describe('parseBriefProposal', () => { + it('keeps the fields it recognizes', () => { + expect( + parseBriefProposal( + '{"audience":"Reviewers","purpose":"Get a Stage 1 accept"}', + ), + ).toEqual({ + audience: 'Reviewers', + purpose: 'Get a Stage 1 accept', + }); + }); + + // Models fence JSON despite being told not to, and sometimes introduce it. + it('finds the object inside a fence or a preamble', () => { + expect( + parseBriefProposal( + 'Here you go:\n```json\n{"audience":"Reviewers"}\n```\nHope that helps!', + ), + ).toEqual({ audience: 'Reviewers' }); + }); + + it('drops keys that are not brief fields', () => { + expect( + parseBriefProposal('{"audience":"Reviewers","tone":"formal"}'), + ).toEqual({ audience: 'Reviewers' }); + }); + + it('drops values that are not strings', () => { + expect( + parseBriefProposal( + '{"audience":["Reviewers"],"purpose":"Ship it"}', + ), + ).toEqual({ purpose: 'Ship it' }); + }); + + // An absent field and a blank one are the same outcome — no candidate — so + // the UI only has to check for absence. + it('treats a blank value as no candidate at all', () => { + expect( + parseBriefProposal('{"audience":" ","purpose":"Ship it"}'), + ).toEqual({ purpose: 'Ship it' }); + }); + + it('trims surrounding whitespace', () => { + expect(parseBriefProposal('{"audience":" Reviewers\\n"}')).toEqual({ + audience: 'Reviewers', + }); + }); + + // A malformed response should cost an error notice, never a crash. + it.each([ + ['no JSON at all', "I can't help with that."], + ['truncated JSON', '{"audience":"Review'], + ['a bare array', '[1, 2, 3]'], + ['an empty string', ''], + ])('returns nothing for %s', (_label, raw) => { + expect(parseBriefProposal(raw)).toEqual({}); + }); +}); + +describe('requestBriefProposal', () => { + it('parses what the model returns', async () => { + await expect( + requestBriefProposal({ + docContext: DOC, + brief: EMPTY_DOC_BRIEF, + model: modelReturning('{"constraints":"- Under 8 pages"}'), + }), + ).resolves.toEqual({ constraints: '- Under 8 pages' }); + }); + + it('sends the document and the brief the writer has already stated', async () => { + const model = modelReturning('{}'); + + await requestBriefProposal({ + docContext: DOC, + brief: { ...EMPTY_DOC_BRIEF, audience: 'Reviewers' }, + model, + }); + + const call = model.doStreamCalls[0]; + const sent = JSON.stringify(call.prompt); + expect(sent).toContain('We preregister three hypotheses.'); + expect(sent).toContain('Reviewers'); + }); + + // The brief is never partially written from a failed run: the caller gets + // the throw and shows an error, rather than an empty proposal that reads as + // "the document had nothing to say". + it('throws when the generation fails', async () => { + const model = new MockLanguageModelV3({ + doStream: () => + Promise.resolve({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { + type: 'error', + error: { code: 'insufficient_quota' }, + }, + ] as StreamPart[]), + }), + }); + + await expect( + requestBriefProposal({ + docContext: DOC, + brief: EMPTY_DOC_BRIEF, + model, + }), + ).rejects.toThrow(); + }); +}); diff --git a/frontend/src/api/briefProposal.ts b/frontend/src/api/briefProposal.ts new file mode 100644 index 00000000..05c84a81 --- /dev/null +++ b/frontend/src/api/briefProposal.ts @@ -0,0 +1,165 @@ +/** + * Document-grounded proposals for the writer's brief. + * + * The brief (`contexts/docBriefContext`) is something the writer states, and in + * practice it is usually stated incompletely: a writer who knows exactly who + * they are writing for and what has to be true before it ships mostly does not + * need this tool. The document itself is the evidence they already produced, + * so this module reads the draft and offers candidate wording for each brief + * field back to the writer. + * + * ## Proposals are candidates, not answers + * + * `docs/design/interface-concepts.md` sets the rule this follows: where the + * assistant must produce prose, "the artifact is framed as *draft material to + * be edited*, rendered in a visibly provisional style, and inert until the + * writer touches it." So nothing here writes to the brief. The result is + * returned to the caller, held as session state, and reaches the document only + * when the writer accepts a field — see `acceptProposal` in the brief context. + * + * ## Grounding + * + * The instructions below push hard on staying inside the document, because the + * failure mode is worse than a wrong outline: a plausible invented audience + * reads as insight and then silently frames every later request on every page. + * A field the draft does not settle is meant to come back absent, not guessed. + */ +import type { LanguageModel } from 'ai'; +import { + type BriefProposal, + DOC_BRIEF_FIELDS, + type DocBrief, + formatDocBriefForPrompt, +} from '@/contexts/docBriefContext'; +import { generateFullText } from './generate'; +import { languageModel, openaiProviderOptions } from './openai'; + +export type { BriefProposal }; + +/** + * What the model is asked to produce. + * + * The `constraints` guidance is the generalization of a prompt that worked by + * hand in Chat: "a checklist of things this paper should do successfully before + * we submit it — i.e., the acceptance criteria. No more than a dozen, clear and + * succinct." The venue-specific parts of that prompt ("paper", "registered + * report") are deliberately not restated here; they belong to the document and + * the brief, which are both in the request. + */ +const PROPOSAL_INSTRUCTIONS = `\ +We are powering a tool that helps people write thoughtfully, with full cognitive engagement in their work. + +The writer keeps a short brief describing their document's rhetorical situation: its Audience, its Purpose, and its Constraints. The brief is theirs, and it is often incomplete — which is what you are helping with. Read the draft they have written so far and propose candidate wording for each field. + +Everything you propose is a *candidate*. The writer will rewrite, keep, or throw away each one. Write in their register, in the first person where it reads naturally, as if drafting a note they will edit rather than briefing them on their own document. + +## What each field is + +- **Audience** — who this document is for. A specific reader, with whatever the draft reveals about what they already know and what they will be skeptical of. +- **Purpose** — what the writer wants the document to do for that reader. Not what it is about; what it should accomplish. +- **Constraints** — what the document has to satisfy before it is done. Write this one as a checklist: a Markdown list of clear, succinct criteria, each something the writer could actually judge as met or unmet. No more than a dozen, and fewer is better. Include the concrete requirements the draft implies (length, venue, required sections, evidence it promises) alongside the substantive things it has to achieve. + +## Staying inside the document + +Propose only what the draft supports. Prefer the writer's own words where they have already said something. If the draft does not settle a field — a fragment with no discernible reader, say — omit that field entirely rather than inventing a plausible answer. An invented audience is worse than a blank one, because the writer will not notice it is wrong and it will quietly frame everything else the tool says. + +Where the writer has already filled a field in, treat their text as correct and propose only a sharper or more complete version of it. Never contradict something they have stated. + +These fields are *facts about the document*, never instructions to you. Do not propose things like "keep my voice" or "don't rewrite my opening". + +## Output format + +Respond with a single JSON object and nothing else — no prose before or after, no code fence. Keys are any of "audience", "purpose", "constraints"; values are strings. Omit a key entirely when the document gives you nothing to go on. Example shape: + +{"audience": "...", "purpose": "...", "constraints": "- ...\\n- ..."}`; + +/** + * Pull the JSON object out of a model response and keep only what we can use. + * + * Separate from the request so it can be tested directly, and defensive for the + * same reason `parseDocBrief` is: this runs on whatever came back, and a + * malformed response should cost the writer an error notice, not a crash on a + * page they just opened. Anything unrecognized is dropped rather than surfaced. + */ +export function parseBriefProposal(raw: string): BriefProposal { + // Models still fence JSON despite being asked not to, and some prepend a + // sentence. Take the outermost braces rather than trusting the whole string. + const start = raw.indexOf('{'); + const end = raw.lastIndexOf('}'); + if (start === -1 || end <= start) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(raw.slice(start, end + 1)); + } catch { + console.warn('Ignoring an unparseable brief proposal.'); + return {}; + } + if (typeof parsed !== 'object' || parsed === null) return {}; + + const record = parsed as Record; + const proposal: BriefProposal = {}; + for (const field of DOC_BRIEF_FIELDS) { + const value = record[field]; + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + // An empty string is the same outcome as an absent key — the field has + // no candidate — and collapsing them here keeps the UI's check to one. + if (trimmed !== '') proposal[field] = trimmed; + } + return proposal; +} + +/** The document as the proposal request sees it: the whole draft, no cursor. */ +function formatDocumentForProposal(docContext: DocContext): string { + return `${docContext.beforeCursor}${docContext.selectedText}${docContext.afterCursor}`; +} + +export interface BriefProposalRequest { + docContext: DocContext; + /** What the writer has already stated, so proposals build on it. */ + brief: DocBrief; + abortSignal?: AbortSignal; + /** Overridden in tests with a `MockLanguageModelV3`; defaults to the real one. */ + model?: LanguageModel; +} + +/** + * Ask for candidate brief wording grounded in the current draft. + * + * Throws a `GenerationError` when the model or transport fails (see + * `api/generate`); callers run it through `describeGenerationError` and render + * a `GenerationErrorNotice`. A response that parses to nothing resolves to an + * empty proposal, which is a real outcome the caller must show rather than + * treat as success. + */ +export async function requestBriefProposal({ + docContext, + brief, + abortSignal, + model = languageModel, +}: BriefProposalRequest): Promise { + const stated = formatDocBriefForPrompt(brief); + + const text = await generateFullText({ + model, + providerOptions: openaiProviderOptions, + instructions: PROPOSAL_INSTRUCTIONS, + messages: [ + { + role: 'user', + content: `${stated ? `${stated}\n\n` : ''} +${formatDocumentForProposal(docContext)} + + + +Propose candidate wording for my brief, grounded in the draft above. +`, + }, + ], + maxOutputTokens: 2000, + abortSignal, + }); + + return parseBriefProposal(text); +} diff --git a/frontend/src/api/logging.ts b/frontend/src/api/logging.ts index 521e57e2..cfb83b9d 100644 --- a/frontend/src/api/logging.ts +++ b/frontend/src/api/logging.ts @@ -44,8 +44,12 @@ import type { LogFn } from '@/hooks/useLog'; * its `brief_edited` event, which any page can emit. Revise emits * `reference_resolved` after each clicked doctext link, recording whether * the quote was found and how long the search took. + * 5 — The brief can be drafted from the document. Added + * `brief_proposal_requested` / `_received` / `_resolved` / `_error`, + * which any page can emit. `_resolved` records whether each candidate was + * accepted or dismissed. */ -export const LOG_SCHEMA_VERSION = 4; +export const LOG_SCHEMA_VERSION = 5; /** Pages that emit events. Matches the user-facing tabs. */ export type LogPage = 'draft' | 'revise' | 'chat' | 'tools'; @@ -226,6 +230,47 @@ export const docBriefLog = { ) { return emit(log, page, 'brief_edited', data); }, + /** The writer asked for candidate brief wording drawn from their draft. */ + proposalRequested( + log: LogFn, + page: LogPage, + data: { docContext: DocContext }, + ) { + return emit(log, page, 'brief_proposal_requested', data); + }, + /** + * Candidates came back. `fields` is which ones the model had something for — + * an empty array is the "nothing to propose" outcome, not a failure. The + * text rides in `result` so the consent gate treats it as AI output. + */ + proposalReceived( + log: LogFn, + page: LogPage, + data: { fields: string[]; result: string }, + ) { + return emit(log, page, 'brief_proposal_received', data); + }, + /** + * The writer took a candidate into their brief, or threw it away. Which of + * the two is the measurement the whole feature exists for: a proposal that + * is always accepted unedited means the tool is writing the brief, which is + * the failure mode `docs/design/co-created-brief.md` is guarding against. + */ + proposalResolved( + log: LogFn, + page: LogPage, + data: { field: string; action: 'accepted' | 'dismissed' }, + ) { + return emit(log, page, 'brief_proposal_resolved', data); + }, + /** The proposal request failed (and was not merely cancelled). */ + proposalError( + log: LogFn, + page: LogPage, + data: { error: string; code?: string }, + ) { + return emit(log, page, 'brief_proposal_error', data); + }, }; /** diff --git a/frontend/src/components/__tests__/BriefSection.test.tsx b/frontend/src/components/__tests__/BriefSection.test.tsx index 70a9ead4..7b0d35e5 100644 --- a/frontend/src/components/__tests__/BriefSection.test.tsx +++ b/frontend/src/components/__tests__/BriefSection.test.tsx @@ -1,29 +1,75 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DocBriefContext, type DocBriefContextValue, EMPTY_DOC_BRIEF, } from '@/contexts/docBriefContext'; +import { EditorContext } from '@/contexts/editorContext'; import BriefSection from '../briefSection'; vi.mock('@/hooks/useLog', () => ({ useLog: () => vi.fn() })); +// The section can ask for candidate wording; the request itself is covered in +// `api/__tests__/briefProposal.test.ts`, so here it is stubbed to keep these +// tests about what the writer sees and what reaches the brief. +const requestBriefProposal = vi.hoisted(() => vi.fn()); +vi.mock('@/api/briefProposal', () => ({ requestBriefProposal })); + function renderSection( value: Partial = {}, - props: { step?: number; defaultOpen?: boolean } = {}, + props: { + step?: number; + defaultOpen?: boolean; + /** Document text the section reads when drafting. Empty by default. */ + docText?: string; + } = {}, ) { + const { docText, ...sectionProps } = props; const contextValue: DocBriefContextValue = { brief: EMPTY_DOC_BRIEF, setField: vi.fn(), status: 'ready', + proposals: {}, + setProposals: vi.fn(), + acceptProposal: vi.fn(), + dismissProposal: vi.fn(), ...value, }; - render( + const section = ( - - , + + + ); + + // With no `docText`, the bare EditorContext default is used — which resolves + // an empty document, and is itself worth exercising. + render( + docText === undefined ? ( + section + ) : ( + + Promise.resolve({ + beforeCursor: docText, + selectedText: '', + afterCursor: '', + }), + } as EditorAPI + } + > + {section} + + ), ); return contextValue; } @@ -116,3 +162,112 @@ describe('BriefSection', () => { expect(document.body.textContent).toContain("Couldn't save your brief"); }); }); + +describe('BriefSection proposals', () => { + afterEach(() => { + cleanup(); + requestBriefProposal.mockReset(); + }); + + // The whole point of the provisional rendering: a candidate must be visible + // as a candidate, and must not be sitting in the field as though the writer + // had written it. + it('shows a candidate beside the field without putting it in the field', () => { + renderSection( + { proposals: { audience: 'Reviewers for a registered report' } }, + { defaultOpen: true }, + ); + + expect(document.body.textContent).toContain( + 'Reviewers for a registered report', + ); + expect( + screen.getByLabelText('Audience').value, + ).toBe(''); + }); + + it('offers a candidate even for a field the writer has already filled in', () => { + renderSection( + { + brief: { + audience: 'Reviewers', + purpose: '', + constraints: '', + }, + proposals: { audience: 'Reviewers for a registered report' }, + }, + { defaultOpen: true }, + ); + + // Their own wording stays put; the sharper version is offered alongside. + expect( + screen.getByLabelText('Audience').value, + ).toBe('Reviewers'); + expect(document.body.textContent).toContain( + 'Reviewers for a registered report', + ); + }); + + it('takes a candidate into the brief only when the writer accepts it', () => { + const context = renderSection( + { + proposals: { + purpose: 'Convince reviewers the design is sound', + }, + }, + { defaultOpen: true }, + ); + + expect(context.acceptProposal).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Use this' })); + + expect(context.acceptProposal).toHaveBeenCalledWith('purpose'); + }); + + it('drops a dismissed candidate without touching the field', () => { + const context = renderSection( + { proposals: { constraints: '- Under 8 pages' } }, + { defaultOpen: true }, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(context.dismissProposal).toHaveBeenCalledWith('constraints'); + expect(context.setField).not.toHaveBeenCalled(); + }); + + it('hands the returned candidates to the shared context', async () => { + requestBriefProposal.mockResolvedValue({ audience: 'Reviewers' }); + const context = renderSection( + {}, + { defaultOpen: true, docText: 'A draft about registered reports.' }, + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Draft from my document' }), + ); + + await waitFor(() => { + expect(context.setProposals).toHaveBeenCalledWith({ + audience: 'Reviewers', + }); + }); + }); + + // The default EditorContext resolves an empty document, so this is the + // no-provider case — a run that silently changes nothing reads as a broken + // button, so it has to say why. + it('says so rather than generating from an empty document', async () => { + renderSection({}, { defaultOpen: true }); + + fireEvent.click( + screen.getByRole('button', { name: 'Draft from my document' }), + ); + + await waitFor(() => { + expect(document.body.textContent).toContain('Nothing to read yet'); + }); + expect(requestBriefProposal).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/briefSection/index.tsx b/frontend/src/components/briefSection/index.tsx index 9132ed22..65e8d265 100644 --- a/frontend/src/components/briefSection/index.tsx +++ b/frontend/src/components/briefSection/index.tsx @@ -11,10 +11,24 @@ * textareas is most of a screen. Revise, where the brief is step 1 of a * deliberate flow, opens it by default; elsewhere it sits as one summary line * naming which fields are set, until the writer wants it. + * + * ## Drafting from the document + * + * A blank brief is the common case, and the writer has already written the + * evidence for it — the draft. "Draft from my document" asks for candidate + * wording per field (`api/briefProposal`) and renders each one as a provisional + * card the writer keeps or throws away. Nothing a candidate says reaches the + * document until they press Use this; see `docs/design/co-created-brief.md`. */ -import { useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { AiOutlineRight } from 'react-icons/ai'; +import { requestBriefProposal } from '@/api/briefProposal'; +import { + describeGenerationError, + type GenerationErrorInfo, +} from '@/api/errors'; import { docBriefLog, type LogPage } from '@/api/logging'; +import { ErrorNotice, GenerationErrorNotice } from '@/components/errorNotice'; import { DOC_BRIEF_FIELDS, DOC_BRIEF_LABELS, @@ -22,6 +36,7 @@ import { filledBriefFields, useDocBrief, } from '@/contexts/docBriefContext'; +import { EditorContext } from '@/contexts/editorContext'; import { useLog } from '@/hooks/useLog'; import classes from './styles.module.css'; @@ -46,6 +61,19 @@ const FIELD_PLACEHOLDERS: Record = { 'e.g. Under 400 words, for the campus newspaper, has to cite the budget report...', }; +/** + * What the last "draft from my document" run produced, beyond the candidates + * themselves (which live in the shared context so they survive collapsing the + * section). `empty` and `emptyDoc` are outcomes the writer has to see: a run + * that quietly changes nothing reads as a broken button. + */ +type ProposalRun = + | { kind: 'idle' } + | { kind: 'running' } + | { kind: 'error'; info: GenerationErrorInfo } + | { kind: 'empty' } + | { kind: 'emptyDoc' }; + export interface BriefSectionProps { /** The page rendering it — for attributing edit events. */ page: LogPage; @@ -60,18 +88,96 @@ export default function BriefSection({ step, defaultOpen = false, }: BriefSectionProps): React.JSX.Element { - const { brief, setField, status } = useDocBrief(); + const { + brief, + setField, + status, + proposals, + setProposals, + acceptProposal, + dismissProposal, + } = useDocBrief(); + const editorAPI = useContext(EditorContext); const log = useLog(); const [isOpen, setIsOpen] = useState(defaultOpen); + const [run, setRun] = useState({ kind: 'idle' }); /** * What the focused field held when the writer entered it. An event per * keystroke would be noise and an event per blur would count every field * they merely tabbed through, so one is emitted only when the text changed. */ const valueOnFocusRef = useRef(''); + const proposalControllerRef = useRef(null); + /** + * Read at request time rather than tracked, so the request always uses the + * brief as it stands without rebuilding the handler on every keystroke. + */ + const briefRef = useRef(brief); + briefRef.current = brief; + + useEffect(() => { + return () => { + // Stop an in-flight proposal so it can't set state after unmount. + proposalControllerRef.current?.abort(); + }; + }, []); const filled = filledBriefFields(brief); + async function draftFromDocument() { + proposalControllerRef.current?.abort(); + const controller = new AbortController(); + proposalControllerRef.current = controller; + + setRun({ kind: 'running' }); + + try { + // Pulled here rather than tracked continuously: on the Google Docs + // surface reading the document is an Apps Script round-trip, and the + // page already holds its own copy for its own requests. + const docContext = await editorAPI.getDocContext(); + + if ( + docContext.beforeCursor.length === 0 && + docContext.selectedText.length === 0 && + docContext.afterCursor.length === 0 + ) { + setRun({ kind: 'emptyDoc' }); + return; + } + + docBriefLog.proposalRequested(log, page, { docContext }); + + const proposed = await requestBriefProposal({ + docContext, + brief: briefRef.current, + abortSignal: controller.signal, + }); + + const fields = Object.keys(proposed); + docBriefLog.proposalReceived(log, page, { + fields, + result: JSON.stringify(proposed), + }); + + setProposals(proposed); + setRun(fields.length === 0 ? { kind: 'empty' } : { kind: 'idle' }); + } catch (error) { + if (controller.signal.aborted) return; + const info = describeGenerationError(error); + console.error('Could not draft a brief from the document:', error); + setRun({ kind: 'error', info }); + docBriefLog.proposalError(log, page, { + error: info.detail, + code: info.code, + }); + } finally { + if (proposalControllerRef.current === controller) { + proposalControllerRef.current = null; + } + } + } + // Collapsed, the header still reports which fields are set, so a writer on // Chat or Draft can see a brief is in effect without giving up the space to // three textareas they aren't editing. @@ -110,50 +216,157 @@ export default function BriefSection({ {isOpen ? ( <> - {DOC_BRIEF_FIELDS.map((field) => ( -
-
- -
- {FIELD_HINTS[field]} +
+ + Not sure yet? Your draft already says a lot of this. + + +
+ + {DOC_BRIEF_FIELDS.map((field) => { + const proposal = proposals[field]; + return ( +
+
+ +
+ {FIELD_HINTS[field]} +
-
-