Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,27 @@
service, and a reader opens a result far more often than they want one
written about. Asking on every view would spend the budget on people
who never read it and make the ones who do wait behind them. -->
@if (summarisable() && !summary.asking() && !summary.state() && !summary.challenge()) {
@if (offering()) {
<button type="button" class="ask" (click)="ask()">Summarise this result</button>
<!--
Before the click, not after. What the reader consents to is their
analysis result leaving Reactome for a third party, and "AI-generated"
under the finished text names a technology after the fact. The company
that ends up with the data is the thing to say, and to say first.
-->
<p class="pre-disclosure">
Sends this result to a third party to be summarised.
<button
type="button"
class="how"
[attr.aria-expanded]="howOpen()"
aria-controls="summary-how"
(click)="toggleHow()"
>
<span class="info" aria-hidden="true">i</span>
What gets sent, and to whom
</button>
</p>
}

@if (summary.challenge()) {
Expand Down Expand Up @@ -102,19 +121,24 @@ <h3>{{ head.title }}</h3>
How this was made
</button>
</p>
}

@if (howOpen()) {
<div class="how-note" id="summary-how">
<p>
This summary was written by a language model, not by a curator, and it has not been reviewed.
{{ provenance() }}
</p>
<p>
It can be wrong or incomplete. Check it against the pathways listed above, and in a publication cite those
pathways and the Reactome release rather than this text.
</p>
</div>
}
<!--
One note, read before the click and after it, which is why it sits outside
the summary block rather than under the prose. Consent after the fact is
not consent: this used to appear only beneath a finished summary, telling
the reader what had already left the building.
-->
@if (showHow()) {
<div class="how-note" id="summary-how">
<p>{{ recipientNote() }}</p>
<p>{{ provenance() }}</p>
<p>
A language model writes it, not a curator, and nobody reviews it before you see it. It can be wrong or
incomplete. Check it against the pathways it cites, and in a publication cite those pathways and the Reactome
release rather than this text.
</p>
</div>
}

@if (summary.expired()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,15 @@ h3 {
background: var(--tertiary-contrast-3);
color: white;
}

// Sits under the button it qualifies, and reads as information rather than as a
// warning: this is what pressing the button does, not a hazard notice.
.pre-disclosure {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
margin: 6px 0 0;
font-size: 0.8rem;
color: var(--tertiary-contrast-3);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
viewChild,
} from '@angular/core';
import { renderChallenge } from '../../../../website-angular/src/app/search/answer/turnstile';
import { provenance as describeProvenance, waitingMessage as describeWait } from './panel-copy';
import {
provenance as describeProvenance,
recipientNote as describeRecipient,
waitingMessage as describeWait,
} from './panel-copy';
import { SummaryService } from './summary.service';
import { type AnalysisType } from './summary-stream';

Expand Down Expand Up @@ -171,6 +175,35 @@ export class AnalysisSummaryComponent {
/** Whether the "how this was made" note is open. */
readonly howOpen = signal(false);

/**
* Whether the button that offers a summary is on screen.
*
* Its own name because two things need it: the button block, and the note
* below, which may only appear beside something it explains.
*/
readonly offering = computed(
() =>
this.summarisable() &&
!this.summary.asking() &&
!this.summary.state() &&
!this.summary.challenge()
);

/**
* Whether the note has anything to sit under.
*
* `howOpen` survives the thing that opened it -- a reader opens it beside the
* button, asks, and the request fails -- and without this the note rendered
* on its own: an explanation of what gets sent, floating above an error,
* with no button and no summary anywhere near it.
*/
readonly showHow = computed(
() => this.howOpen() && (this.offering() || (this.summary.visible() && !!this.heading()))
);

/** @see recipientNote -- names the third party, which "AI" does not. */
readonly recipientNote = describeRecipient;

/** @see provenance -- reads the *applied* tier, never the requested one. */
readonly provenance = computed(() => describeProvenance(this.summary.applied()));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { provenance, waitingMessage } from './panel-copy';
import { provenance, recipientNote, waitingMessage } from './panel-copy';

describe('what the wait says', () => {
it('claims nothing about the result before the service has said anything', () => {
Expand All @@ -24,14 +24,35 @@ describe('what the panel says the summary was made from', () => {
});

it('says they were not, for an aggregate summary', () => {
expect(provenance('aggregate')).toContain('not your identifiers');
expect(provenance('aggregate')).toContain('not the identifiers you uploaded');
});

it('does not claim identifiers were sent when nothing has said so', () => {
// The requested tier is not the applied one: asking to disclose and having
// the lookup fail gives the aggregate summary. Erring towards "we sent
// them" would tell a reader their identifiers left the browser when they
// did not, which is the worse of the two mistakes.
expect(provenance(null)).toContain('not your identifiers');
expect(provenance(null)).toContain('not the identifiers you uploaded');
});
});

describe('naming the third party', () => {
it('names the company, because "AI" names a technology', () => {
// The reader deciding whether to press the button is deciding whether their
// analysis result may leave Reactome. "Generated by AI" does not answer
// that; a company name does.
expect(recipientNote()).toContain('OpenAI');
expect(recipientNote()).toContain('third party');
});

it('claims nothing about what the provider does with it afterwards', () => {
// Retention and training are the provider's contract to state, not ours to
// summarise from memory -- and a wrong reassurance is worse than silence.
expect(recipientNote()).not.toMatch(/train|retain|delete|stored|privacy/i);
});

it('is written in a tense that is true on both sides of the click', () => {
// The note opens before the summary is asked for as well as after.
expect(`${recipientNote()} ${provenance(null)}`).not.toMatch(/\bwas given\b|\bwere sent\b/);
});
});
37 changes: 32 additions & 5 deletions projects/pathway-browser/src/app/analysis-summary/panel-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,39 @@ export function waitingMessage(started: boolean): string {
* reader's behalf, which is the one thing this sentence exists to be right
* about.
*
* Null -- no `start` seen -- reads as the aggregate case, because that is what
* is true of a summary nothing has told us otherwise about, and because the
* error worth avoiding is claiming identifiers were sent when they were not.
* Null -- no `start` seen, which includes every reader who has not yet clicked
* -- reads as the aggregate case. That is what the panel always asks for, and
* the error worth avoiding is telling somebody their identifiers were sent when
* they were not.
*/
export function provenance(applied: Disclosure | null): string {
// Present tense, because this note opens on both sides of the click: "was
// given" is a lie to somebody who has not pressed the button yet.
return applied === 'identifiers'
? 'It was given your analysis result and the identifiers from it that Reactome could not match.'
: 'It was given your analysis result — the pathways it found — and not your identifiers.';
? 'What is sent: your analysis result, and the identifiers from it that Reactome could not match.'
: 'What is sent: your analysis result — the pathways it found — and not the identifiers you uploaded.';
}

/**
* Who receives the result, named rather than implied.
*
* "AI-generated" describes a technology; a reader deciding whether to press the
* button is deciding whether their analysis result may leave Reactome, and the
* answer to "to whom" is a company. React-to-Me runs on Reactome's own
* infrastructure but calls OpenAI's models to write the text, so OpenAI is the
* third party even though the reader never talks to it.
*
* Present tense, and true both before the click and after it: the reader can
* open this note either side of pressing the button.
*
* Deliberately makes no claim about what the provider does with the data
* afterwards -- retention, training -- because that is their contract to state
* and not ours to summarise from memory.
*/
export function recipientNote(): string {
return (
'Summaries are written by React-to-Me, Reactome’s assistant. It runs on Reactome ' +
'infrastructure but sends the text of your request to OpenAI, which generates the summary. ' +
'That makes OpenAI a third party to this request.'
);
}
Loading