Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client work for AI Expression Summary on gene page #1330

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
945caa8
WIP wired up to reporter service call
bobular Feb 20, 2025
d5506fa
Merge remote-tracking branch 'origin/main' into ai-expression
bobular Feb 23, 2025
8979816
WIP
bobular Feb 23, 2025
01a4ec9
bare bones UI is working, yay
bobular Feb 24, 2025
3e57501
unrelated import path fix in @veupathdb/components
bobular Feb 24, 2025
a40901b
basic table done - needs expandable child rows
bobular Feb 24, 2025
99bcada
loading flow improvements, number of experiments column
bobular Feb 25, 2025
4dfb84f
div height consistency when clicking button
bobular Feb 25, 2025
98c1e1e
expandable rows and some tweaks
bobular Feb 25, 2025
26e3cd8
SCSS styling the structured summary
bobular Feb 25, 2025
64438fa
equal margins in expandable rows
bobular Feb 25, 2025
3ac1f94
Merge remote-tracking branch 'origin/main' into ai-expression
bobular Feb 25, 2025
48b1016
remove failed debugging
bobular Feb 25, 2025
c316147
refactor expression graph opener into util function
bobular Feb 25, 2025
6e9d617
wired in scroll-to and redux
bobular Feb 25, 2025
7899216
use numeric rowIds
bobular Feb 25, 2025
eed0427
add clickable rows to toggle child row expansion
bobular Feb 25, 2025
59ed257
US spelling
bobular Feb 27, 2025
5165e0d
rename sections to topics
bobular Feb 28, 2025
27c9013
can take up to three minutes
bobular Feb 28, 2025
8a8065d
fix mouse pointer for experiment links
bobular Feb 28, 2025
f8c10f2
add gate for less than 5 datasets
bobular Feb 28, 2025
0713886
improve scroll-to behaviour
bobular Feb 28, 2025
d6fae35
Merge remote-tracking branch 'origin/main' into ai-expression
bobular Mar 1, 2025
9ae9932
Merge remote-tracking branch 'origin/ai-expression' into ai-expressio…
bobular Mar 1, 2025
8a9cf8a
pre-open transcript expression table when ai results render
bobular Mar 2, 2025
eb7adf1
Merge pull request #1338 from VEuPathDB/ai-expression-topics
bobular Mar 2, 2025
fc37dbb
Merge remote-tracking branch 'origin/main' into ai-expression
bobular Mar 6, 2025
032f4f1
first stab at this
bobular Mar 6, 2025
8bf5bb1
handle initial loading properly
bobular Mar 6, 2025
ad4c07e
fixed up Loading spinner behaviour
bobular Mar 7, 2025
ccfb767
Merge pull request #1340 from VEuPathDB/progress-bar
bobular Mar 7, 2025
b3a5764
final progress styling tweaks
bobular Mar 8, 2025
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,7 +4,7 @@ import {
HorizontalDendrogramProps,
} from '../../components/tidytree/HorizontalDendrogram';
import Mesa from '@veupathdb/coreui/lib/components/Mesa';
import { MesaStateProps } from '../../../../coreui/lib/components/Mesa/types';
import { MesaStateProps } from '@veupathdb/coreui/lib/components/Mesa/types';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just a tiny fix while in the neighbourhood. Has no impact on the AI stuff!


import './TreeTable.scss';

Expand Down
2 changes: 1 addition & 1 deletion packages/libs/coreui/src/components/Mesa/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ interface MesaAction<Row, Key = DefaultColumnKey<Row>> {

type DefaultColumnValue<Row, Key> = Key extends keyof Row ? Row[Key] : unknown;

interface CellProps<
export interface CellProps<
Row,
Key = DefaultColumnKey<Row>,
Value = DefaultColumnValue<Row, Key>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
div.ai-summary {
p {
ul {
margin-top: 0.5em;
margin-bottom: 1em;
}
}
}

a.javascript-link {
cursor: grab;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import {
CollapsibleSection,
Loading,
} from '@veupathdb/wdk-client/lib/Components';
import { Props } from '@veupathdb/wdk-client/lib/Views/Records/RecordAttributes/RecordAttributeSection';

import { DefaultSectionTitle } from '@veupathdb/wdk-client/lib/Views/Records/SectionTitle';
import { ErrorBoundary } from '@veupathdb/wdk-client/lib/Controllers';
import { useWdkService } from '@veupathdb/wdk-client/lib/Hooks/WdkServiceHook';
import { isGenomicsService } from '../../wrapWdkService';
import {
AiExpressionSummary,
AiExpressionSummaryResponse,
AiExpressionSummarySection,
} from '../../types/aiExpressionTypes';
import { safeHtml } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils';
import { AttributeValue } from '@veupathdb/wdk-client/lib/Utils/WdkModel';
import Mesa from '@veupathdb/coreui/lib/components/Mesa';
import {
MesaStateProps,
CellProps,
} from '@veupathdb/coreui/lib/components/Mesa/types';
import { RecordActions } from '@veupathdb/wdk-client/lib/Actions';
import { DEFAULT_TABLE_STATE } from '@veupathdb/wdk-client/lib/StoreModules/RecordStoreModule';
import { State as ReduxState } from '@veupathdb/wdk-client/lib/StoreModules/RecordStoreModule';
import { scrollToAndOpenExpressionGraph } from './utils';

// Styles
import './AiExpressionSummary.scss';

const MIN_DATASETS_FOR_AI_SUMMARY = 5;

/** Display AI Expression Summary UI and results in a collapsible section */
export function AiExpressionSummary(props: Props) {
const { attribute, record, isCollapsed, onCollapsedChange, title } = props;
const { displayName, help, name } = attribute;

const headerContent = title ?? (
<DefaultSectionTitle displayName={displayName} help={help} />
);

const microarrayDatasetCount = props.record.attributes[
'microarray_dataset_count'
]
? Number(props.record.attributes['microarray_dataset_count'].toString())
: 0;
const rnaseqDatasetCount = props.record.attributes['rnaseq_dataset_count']
? Number(props.record.attributes['rnaseq_dataset_count'].toString())
: 0;
const datasetCount = microarrayDatasetCount + rnaseqDatasetCount;

return (
<CollapsibleSection
id={name}
className={`wdk-RecordAttributeSectionItem`}
headerContent={headerContent}
isCollapsed={isCollapsed}
onCollapsedChange={onCollapsedChange}
>
<ErrorBoundary>
{record.attributes['ai_expression'] == 'YES' ? (
datasetCount < MIN_DATASETS_FOR_AI_SUMMARY ? (
<div>
The AI Expression Summary feature is not available for genes with
fewer than {MIN_DATASETS_FOR_AI_SUMMARY} transcriptomics datasets.
</div>
) : (
<div style={{ minHeight: '8em' }}>
<AiSummaryGate {...props} />
</div>
)
) : (
<div>Sorry, this feature is not currently available.</div>
)}
</ErrorBoundary>
</CollapsibleSection>
);
}

// If the AI expression summary is cached, render it
// otherwise render a "gate" where the user is given some verbiage
// about the request taking a minute or two and a button to initiate
// the request.

function AiSummaryGate(props: Props) {
const geneId = props.record.attributes['source_id']?.toString();
if (geneId == null) throw new Error('geneId should not be missing');

const [shouldPopulateCache, setShouldPopulateCache] = useState(false);

const aiExpressionSummary = useAiExpressionSummary(
geneId,
shouldPopulateCache
);

if (aiExpressionSummary) {
if (aiExpressionSummary[geneId]?.cacheStatus === 'hit') {
const summary = aiExpressionSummary[geneId].expressionSummary;
return <AiExpressionResult summary={summary} {...props} />;
} else if (!shouldPopulateCache) {
// Cache miss: render button to populate cache
return (
<div>
<p>
Click below to request an AI summary of this gene. It could take up
to three minutes. When complete it will be cached for all users.
</p>
<button onClick={() => setShouldPopulateCache(true)}>
Start AI Summary
</button>

{/* Debugging: Display cache miss reason if present */}
{aiExpressionSummary[geneId]?.reason && (
<p style={{ color: 'red' }}>
Debug: Cache miss reason - {aiExpressionSummary[geneId].reason}
</p>
)}
</div>
);
}
}
if (shouldPopulateCache) {
return (
<div>
<p>🤖 Summarizing... (can take up to three minutes) 🤖</p>
<Loading />
</div>
);
} else {
return <div>Loading...</div>;
}
}

type RowType = AiExpressionSummarySection & { rowId: number };

// Jump through some hoops to connect the redux store
const mapState = (record: ReduxState) => ({
expressionGraphsTableState:
record.tableStates?.ExpressionGraphs ?? DEFAULT_TABLE_STATE,
});
const mapDispatch = {
updateSectionVisibility: RecordActions.updateSectionVisibility,
updateTableState: RecordActions.updateTableState,
};
const connector = connect(mapState, mapDispatch);
type PropsFromRedux = ConnectedProps<typeof connector>;

type AiExpressionResultProps = Props & {
summary: AiExpressionSummary;
} & PropsFromRedux;

const AiExpressionResult = connector((props: AiExpressionResultProps) => {
const {
record,
summary: { headline, one_paragraph_summary, topics },
} = props;

// make a lookup from dataset_id to the experiment info (display_name, assay_type) etc
const expressionGraphs = record.tables['ExpressionGraphs'];
const experiments = expressionGraphs.reduce<
Record<string, Record<string, AttributeValue>>
>((result, current) => {
const dataset_id = current['dataset_id'] as string;
result[dataset_id] = { ...current };
return result;
}, {});

// pre-open the main expression table so the links to it work reliably
useEffect(() => {
props.updateSectionVisibility('ExpressionGraphs', true);
}, []);

// custom renderer (to handle <i>, <ul>, <li> and <strong> tags, mainly)
// and provide click to toggle row expansion functionality

const [expandedRows, setExpandedRows] = useState<number[]>([]);

const RenderCellWithHtmlAndClickHandler = (props: CellProps<RowType>) => {
const myRowId = props.row.rowId;
const handleClick = () => {
setExpandedRows(
(prevRows) =>
prevRows.includes(myRowId)
? prevRows.filter((id) => id !== myRowId) // Remove if already expanded
: [...prevRows, myRowId] // Add if not expanded
);
};
return <div onClick={handleClick}>{safeHtml(props.value.toString())}</div>;
};

// Note that `safeHtml()` does NOT sanitise dangerous HTML elements and attributes.
// for example, this would render and the JavaScript will execute:
// const danger = `<img src="x" onerror="alert('XSS!')" />`;
// See https://github.com/VEuPathDB/web-monorepo/issues/1170

const numberedTopics = topics.map((topic, index) => ({
...topic,
rowId: index,
}));

// create the topics table
const mainTableState: MesaStateProps<RowType> = {
rows: numberedTopics,
columns: [
{
key: 'headline',
name: 'Topic',
renderCell: RenderCellWithHtmlAndClickHandler,
style: { fontWeight: 'bold' },
},
{
key: 'one_sentence_summary',
name: 'Summary',
renderCell: RenderCellWithHtmlAndClickHandler,
// style: { maxWidth: '30em' },
},
{
key: 'summaries',
name: `#\u00A0Datasets`, // non-breaking space
renderCell: (cellProps) => cellProps.row.summaries.length,
style: { textAlign: 'right' },
},
],
options: {
childRow: (badProps) => {
// NOTE: the typing of `ChildRowProps` seems wrong
// as it is called with two args, not one, see
// https://github.com/VEuPathDB/web-monorepo/blob/d1d03fcd051cd7a54706fe879e4af4b1fc220d88/packages/libs/coreui/src/components/Mesa/Ui/DataCell.jsx#L26
const rowIndex = badProps as unknown as number;
const rowData = topics[rowIndex];
return (
<ErrorBoundary>
<ul>
{rowData.summaries.map((summary) => {
return (
<li
key={summary.dataset_id}
style={{
marginBottom: '0.5em',
marginLeft: '4em',
marginRight: '4em',
}}
>
<>
<a
className="javascript-link"
onClick={() =>
scrollToAndOpenExpressionGraph({
expressionGraphs: expressionGraphs,
findIndexFn: ({
dataset_id,
}: {
dataset_id: string;
}) => dataset_id === summary.dataset_id,
tableId: 'ExpressionGraphs',
updateSectionVisibility:
props.updateSectionVisibility,
updateTableState: props.updateTableState,
tableState: props.expressionGraphsTableState,
})
}
>
{experiments[summary.dataset_id].display_name as string}
</a>{' '}
({experiments[summary.dataset_id].assay_type})
<br />
{safeHtml(summary.one_sentence_summary)}
</>
</li>
);
})}
</ul>
</ErrorBoundary>
);
},
getRowId: (row) => row.rowId,
},
eventHandlers: {
onExpandedRowsChange: (rowIndexes) => setExpandedRows(rowIndexes),
},
uiState: {
expandedRows,
},
};

return (
<div className="ai-generated">
<div
className="ai-summary"
style={{ marginLeft: '15px', maxWidth: '50em' }}
>
{safeHtml(headline, undefined, 'h4')}
{safeHtml(one_paragraph_summary, undefined, 'p')}
<p>
<i>
The results from {expressionGraphs.length} experiments have been
organized into the {topics.length} topics below. The AI was
instructed to present the most biologically relevant information
first. As this method is still evolving, results may vary.
</i>
</p>
</div>
<Mesa state={mainTableState} />
</div>
);
});

function useAiExpressionSummary(
geneId: string,
shouldPopulateCache: boolean
): AiExpressionSummaryResponse | undefined {
return useWdkService(
async (wdkService) => {
if (!isGenomicsService(wdkService)) throw new Error('nasty');
const { projectId } = await wdkService.getConfig();
const answerSpec = {
searchName: 'single_record_question_GeneRecordClasses_GeneRecordClass',
searchConfig: {
parameters: {
primaryKeys: `${geneId},${projectId}`,
},
},
};
const formatting = {
format: 'aiExpression',
formatConfig: {
populateIfNotPresent: shouldPopulateCache,
},
};
return await wdkService.getAnswer<AiExpressionSummaryResponse>(
answerSpec,
formatting
);
},
[geneId, shouldPopulateCache]
);
}
Loading
Loading