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
4 changes: 4 additions & 0 deletions CHANGELOG-udi-yac-0.9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- Upgrade `udi-yac` (Say & See chat) from 0.5.0 to 0.10.0 and `udiagent` from 0.6.0 to 0.10.0. The chat now sends its active filters with each message, and the agent takes them into account, so it can answer "which filters are on?" and build on the current filters.
- Fix changing a field on a Say & See chart: the chat calls `/v1/yac/vis_instantiate` to rebuild the chart from its template, and the portal had no such route.
- Link Say & See table rows and chart points to their donor, sample or dataset page.
- Let Say & See users save sessions in the browser and load them later from the Session menu, kept separately for the public and consortium data, and for each signed-in user.
18 changes: 18 additions & 0 deletions context/app/routes_udi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from flask import current_app, jsonify, make_response, request, session

from udiagent import Orchestrator, UDIAgent
from udiagent.api import EndpointError, vis_instantiate

from .routes_api import (
_first_fields,
Expand Down Expand Up @@ -450,6 +451,8 @@ def yac_completions():
data_schema=data_schema,
data_domains=data_domains,
openai_api_key=openai_api_key,
# The chat's active filters; udiagent drops malformed entries.
filters=body.get('filters'),
)
except Exception as e:
current_app.logger.exception('UDIAgent orchestrator failed')
Expand All @@ -458,6 +461,21 @@ def yac_completions():
return jsonify(result.tool_calls)


# The chat calls this when the user swaps a field on a template-generated chart.
# No LLM call, so no OpenAI key is needed.
@blueprint.route('/v1/yac/vis_instantiate', methods=['POST'])
def yac_vis_instantiate():
try:
return jsonify(vis_instantiate(request.get_json(silent=True) or {}))
except EndpointError as e:
if e.status >= 500:
# udiagent logs only the message; keep the traceback of a template bug.
current_app.logger.exception('vis_instantiate failed: %s', e.code)
# udiagent's {code, error, errors} shape: the chat reads `code`, and drops a
# chart's tweak controls on `unknown_template`.
return jsonify(e.body()), e.status


@blueprint.route('/v1/yac/examples', methods=['GET'])
def yac_examples():
examples_path = os.path.join(os.path.dirname(__file__), 'udi_example_prompts.json')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,21 @@ vi.mock('udi-yac', () => ({
dataPackagePath,
onEvent,
palette,
recordUrl,
savedSessionsKey,
}: {
dataPackagePath: string;
savedSessionsKey?: string;
onEvent?: (name: string, properties?: Record<string, unknown>) => void;
palette?: { category?: unknown[]; mark?: string };
recordUrl?: (record: Record<string, unknown>, source: string) => string | null | undefined;
}) => (
<div
data-testid="udi-chat"
data-path={dataPackagePath}
data-saved-sessions-key={savedSessionsKey}
data-record-url={recordUrl?.({ uuid: 'abc' }, 'datasets') ?? undefined}
data-aggregate-record-url={String(recordUrl?.({ count: 3 }, 'datasets'))}
data-palette-count={palette?.category?.length}
data-palette-mark={palette?.mark}
>
Expand Down Expand Up @@ -67,6 +74,7 @@ interface AppContextOverrides {
isAuthenticated?: boolean;
isHubmapUser?: boolean;
isWorkspacesUser?: boolean;
userEmail?: string;
}

interface PrefsOverrides {
Expand Down Expand Up @@ -167,4 +175,22 @@ describe('SaySeePanel', () => {
expect(chat).toHaveAttribute('data-palette-count', '18');
expect(chat).toHaveAttribute('data-palette-mark', theme.palette.primary.main);
});

it('keeps saved sessions per user', async () => {
setup({ isAuthenticated: true, isHubmapUser: true, userEmail: 'a@example.org' }, { scope: 'authenticated' });
render(<SaySeePanel />, { wrapper: Wrapper });
const chat = await screen.findByTestId('udi-chat');
expect(chat).toHaveAttribute(
'data-saved-sessions-key',
'/metadata/v0/udi/consortium/datapackage.json:a@example.org',
);
});

it('links records to their detail page, and aggregated rows to nothing', async () => {
setup({ isAuthenticated: true, isHubmapUser: true });
render(<SaySeePanel />, { wrapper: Wrapper });
const chat = await screen.findByTestId('udi-chat');
expect(chat).toHaveAttribute('data-record-url', '/browse/dataset/abc');
expect(chat).toHaveAttribute('data-aggregate-record-url', 'null');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ const ENTITY_ICONS = {
donors: DonorIcon,
};

const ENTITY_TYPES: Record<string, string> = {
datasets: 'dataset',
samples: 'sample',
donors: 'donor',
};

// Links a chart point or table row to its detail page. Aggregated rows carry no
// `uuid`, so they get no link.
function recordUrl(record: Record<string, unknown>, source: string) {
const type = ENTITY_TYPES[source];
return type && typeof record.uuid === 'string' ? `/browse/${type}/${record.uuid}` : null;
}

function visualizationSelector(store: VisualizationStore) {
return {
fullscreenVizId: store.fullscreenVizId,
Expand All @@ -63,7 +76,7 @@ function ChatFallback() {
}

function SaySeePanel() {
const { isHubmapUser, isWorkspacesUser } = useAppContext();
const { isHubmapUser, isWorkspacesUser, userEmail } = useAppContext();
const { fullscreenVizId, theme, expandViz, collapseViz } = useVisualizationStore(useShallow(visualizationSelector));
const isFullscreen = fullscreenVizId === SAY_SEE_VIZ_ID;
// Subscribe to the shared Escape-collapses-fullscreen listener; the
Expand Down Expand Up @@ -154,6 +167,10 @@ function SaySeePanel() {
palette={palette}
downloadActions={downloadActions}
downloadButtonLabel="Download Metadata"
recordUrl={recordUrl}
// One saved-session store per data package, as udi-yac requires, and per
// user, so people sharing a browser don't see each other's sessions.
savedSessionsKey={userEmail ? `${dataPackagePath}:${userEmail}` : dataPackagePath}
/>
)}
</Suspense>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ export default function SaySeePanelDescription() {
<Typography variant="body1">
Explore HuBMAP&apos;s public data through a natural language chat interface. Ask questions about donor,
sample, and dataset metadata and get visualizations back. Results are independent from the Filter &amp; Browse
Mode and do not link to individual dataset, sample, or donor pages. <ContactUsLink capitalize /> to report an
issue or share feedback.
Mode. Table rows and chart points link to their dataset, sample, or donor pages. <ContactUsLink capitalize />{' '}
to report an issue or share feedback.
</Typography>
<div>
<Typography variant="subtitle1">How to get access?</Typography>
Expand Down
67 changes: 67 additions & 0 deletions context/app/test_routes_udi.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from udiagent.api import EndpointError

from . import routes_udi
from .main import create_app
Expand Down Expand Up @@ -290,6 +291,28 @@ def test_yac_completions_anon_with_header(client, mocker):
assert response.get_json() == [{'name': 'CreateVisualization', 'arguments': {'title': 'test'}}]
assert captured['self'] is routes_udi._get_byok_orchestrator()
assert captured['openai_api_key'] == 'sk-anon'
# udi-yac < 0.9 sends no filters; udiagent treats None as "no active filters".
assert captured['filters'] is None


def test_yac_completions_forwards_filters(client, mocker):
captured = _capture_run(mocker)
filters = [
{
'id': 'f1',
'entity': 'datasets',
'field': 'organ',
'filterType': 'point',
'pointValues': ['Kidney'],
}
]
response = client.post(
'/v1/yac/completions',
json={**_sample_completion_body, 'filters': filters},
headers={'X-OpenAI-Key': 'sk-anon'},
)
assert response.status == '200 OK'
assert captured['filters'] == filters


def test_yac_completions_hubmap_user_uses_config_key(client, mocker):
Expand Down Expand Up @@ -836,3 +859,47 @@ def test_build_resource_survives_a_nested_field(client, mocker):
entities = [{'hubmap_id': 'HBM1', 'nested': {'entity_type': ['Dataset']}}]
with pytest.raises(TypeError):
build_resource('datasets', entities, {}, {}, ['hubmap_id'])


@pytest.mark.parametrize(
'body, status, code',
[
({'toolArgs': {}}, 404, 'unknown_template'),
({'toolArgs': None}, 422, 'bad_request'),
],
)
def test_yac_vis_instantiate_answers_like_udiagent(client, body, status, code):
"""A real udiagent call, so an upstream rename or contract change fails here.
An unknown template is answered before the schema is read, so no real
template name (which udiagent renumbers) is needed."""
response = client.post(
'/v1/yac/vis_instantiate',
json={'tool': 'vis_999_no_such_template', 'dataSchema': '{}', **body},
)
assert response.status_code == status
# udi-yac reads the top-level `code`; `unknown_template` drops the tweak UI.
assert response.get_json()['code'] == code


def test_yac_vis_instantiate_returns_the_spec(client, mocker):
payload = {'spec': {'source': []}, 'toolArgs': {'field': 'organ'}, 'params': []}
instantiate = mocker.patch('app.routes_udi.vis_instantiate', return_value=payload)
body = {'tool': 'vis_001', 'toolArgs': {'field': 'organ'}, 'dataSchema': '{}'}
response = client.post('/v1/yac/vis_instantiate', json=body)
assert response.status_code == 200
assert response.get_json() == payload
instantiate.assert_called_once_with(body)


def test_yac_vis_instantiate_logs_server_errors(client, mocker, caplog):
mocker.patch(
'app.routes_udi.vis_instantiate',
side_effect=EndpointError(500, 'instantiate_failed', 'Could not build a spec.'),
)
response = client.post('/v1/yac/vis_instantiate', json={})
assert response.status_code == 500
assert response.get_json() == {
'code': 'instantiate_failed',
'error': 'Could not build a spec.',
}
assert any(r.exc_info for r in caplog.records)
2 changes: 1 addition & 1 deletion context/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"rehype-raw": "^7.0.0",
"scellop": "^0.3.0",
"swr": "^2.4.1",
"udi-yac": "^0.5.0",
"udi-yac": "^0.10.0",
"universal-cookie": "^7.1.2",
"use-resize-observer": "^9.1.0",
"uuid": "^14.0.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like the only real lockfile change is udi-yac 0.5.0 → 0.9.0. The removed libc: lines make me think it was regenerated with a different pnpm than the pinned pnpm@10.27.0. Could you regenerate it via corepack so the next install doesn't add them back?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Turns out the pinned pnpm 10.27.0 is what drops those lines. The libc: entries came in with dependabot's vite bump (#4105), which runs a newer pnpm, and 10.27.0 removes them on any re-resolve. I rebuilt the lock as main's plus only the udi-yac change (now 0.5.0 → 0.10.0), so the libc lines are back. It passes --frozen-lockfile with 10.27.0 and pnpm install --lockfile-only leaves it unchanged. This will recur between dependabot and 10.27.0 until we bump packageManager; I'll do that separately.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ dependencies = [
"python-frontmatter>=1.1.0",
"hubmap-commons>=2.1.20",
"portal-visualization[full]>=0.5.13",
"udiagent>=0.6.0",
"udiagent[langfuse]>=0.6.0",
"udiagent>=0.10.0",
"udiagent[langfuse]>=0.10.0",
"gunicorn>=23.0.0",
"whitenoise[brotli]>=6.9.0",
]
Expand Down
Loading
Loading