Skip to content

feat(report-viewer): add embedded viewer service to chat#501

Open
andylassiter wants to merge 118 commits into
mainfrom
report-viewer-service
Open

feat(report-viewer): add embedded viewer service to chat#501
andylassiter wants to merge 118 commits into
mainfrom
report-viewer-service

Conversation

@andylassiter

Copy link
Copy Markdown
Contributor

Description

Product

Adds a new report-viewer service that replaces mcp-trino as Scout Chat's data path. When a user asks Scout Chat to find reports, the LLM calls into report-viewer and renders results in an embedded iframe inside the chat, instead of pulling report rows into the LLM's context window. Aggregate questions stay inline. Single-report reads return the full report content in the chat.

The viewer is a small React SPA hosted alongside a Python FastAPI backend, framed by OWUI. Users can filter, sort, resize columns, expand rows to see the full report text, download the search as CSV, and pull selected reports into the chat context.

Technical

  • New service under report-viewer/ (FastAPI + React SPA), Helm chart at helm/report-viewer/, Ansible role at ansible/roles/report_viewer/
  • New OWUI tool scout_report_viewer_tool.py
  • Trino identity propagation via service-principal impersonation per ADR 0022 (report_viewer_svc + X-Trino-User)
  • JWT audience validation, aud pinned to report-viewer
  • Ingress gated by oauth2-proxy + security-headers, external surface restricted to /api and /spa
  • yoyo migrations for the report-viewer Postgres schema
  • mcp-trino removed; Trino now JWT-only (http-server.authentication.type=JWT)
  • Grafana dashboard + Prometheus scrape
  • ADR 0029 documents the design; ADR 0022 updated to capture the new pattern
  • OWUI system prompt rewritten around the new three-tool interface
  • OWUI signup webhook auto-sets the iframeSandbox UI flags per user

Type of change

  • New feature

Impact

Security

Authorization

Per-user Trino AuthZ via X-Trino-User impersonation (ADR 0022), so OPA row filters and PHI masks still apply. Search access is owner-scoped in Postgres. The OWUI tool only forwards the caller's access token, no refresh token, no client-set impersonation.

Appsec

JWT validated (aud + iss). Trino params bound, identifiers allowlisted. External surface locked to /api and /spa behind oauth2-proxy. NetworkPolicy deny-default; pod runs non-root, RO rootfs, caps dropped. OWUI settings backfill role is column-scoped GRANT only.

Performance

Not blazing fast. Searches run one sample + one count query, reads paginate the saved SQL as a subquery (no materialization). Queries through reports_latest_epic_view are on the slower side, and single-report reads pull through reports_latest_epic_view to get resolved patient IDs.

Data

Flow: LLM authors SQL, report-viewer runs it through Trino (impersonating the caller) to validate + count + sample, then persists the search to its own Postgres. Report rows themselves live in Delta Lake and are re-queried through Trino on every read; nothing about the rows is cached or copied out. Report-viewer stores per search in its searches table: the SQL, the LLM's plain-language explanation, cached row count, the user, and optional match_terms/match_diagnoses. The OWUI signup webhook additionally writes iframe-sandbox UI defaults into OWUI's Postgres (would like to remove this when upgrading OWUI to 0.10.x if possible).

Backward compatibility

mcp-trino is removed and Trino no longer accepts password auth (JWT-only).

Testing

Backend unit tests under report-viewer/tests/, including JWT audience/iss regressions and Trino client cache single-flight. Frontend TypeScript typechecks via tsc --noEmit. New Playwright test at tests/auth/tests/owui-iframe-seed.spec.ts covers the OWUI iframe-sandbox seeding path. Deployed and exercised on the dev cluster. QA issue #483 tracks the full acceptance criteria checklist.

Note for reviewers

Sorry for the big PR!

#471 (Vega-Lite charts) is patched, not solved. Prompt tweaks nudge the model toward Vega-Lite but it still slips up. The real fix is probably a proper plotting feature but keeping Vega-Lite for now until we have a better solution.

Closes #463
Related to #483 (QA)
Related to #471 (partial)

Checklist

  • My code adheres to the coding and style guidelines of the project (and I've run pre-commit run --all-files)
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated user documentation, if appropriate
  • I have added or updated technical documentation, including an architectural decision record, if appropriate
  • I have added unit tests, if appropriate
  • I have added end-to-end tests, if appropriate

Direct write to OWUI's user.settings on signup so the in-chat
iframe renders with allow-same-origin from the first search. OWUI
has no admin API for per-user settings; the webhook is awaited in
the OAuth callback so this lands before the browser redirect.
Half-finished split before, /searches/* and /reports/* lived at the root,
which is why chat was 404ing. Rename /export.csv to /csv to mirror /rows.
Chat-alias ingress proxies /api/searches and /api/reports so iframe
fetches stay same-origin for now.
The chat-host same-origin alias existed so the SPA could resize via
window.frameElement. OWUI 0.9.5+ already listens for cross-origin
{type:'iframe:height'} postMessages, so the alias is dead weight.
Iframe now loads from report-viewer.<env>, fitParentIframe posts a
fixed 500px to the parent, and the sameOriginAlias ingress block plus
the chat_alias_enabled variable are gone.
Matches the Voila/Superset/OWUI MCP pattern in ADR 0022. Adds the
report_viewer_svc Keycloak client, grants it ImpersonateUser in the
OPA policy, swaps trino_client from JWT pass-through to
client_credentials + X-Trino-User. Drops user.token from auth.py since
the user JWT no longer goes outbound.

Reverts oauth2-proxy back to its ADR 0003 shape: drops pass_access_token
and cookie_refresh, removes X-Auth-Request-Access-Token from the
forwardAuth middleware, drops trino-audience from the oauth2-proxy
Keycloak client. The token broker pattern is gone, oauth2-proxy is the
approval gate again and never participates in the Trino-trust chain.
Also ignore the whole vite build output dir, not just assets, so static/index.html stops dirtying on every rebuild.
Wire build, scan, publish, and smoke-test analytics for the image. Add pytest
for the service and the OWUI scout_report_viewer tool. Multistage Dockerfile
so Vite builds in node, runtime stays on python:3.12-slim. VERSION goes to
latest.

Python package scout_report_viewer, REPORT_VIEWER env prefix, and the
report_viewer_svc keycloak client are already short and stay unchanged.
Scout standard middleware chain on the report-viewer subdomain per ADR
0003. New embeddable-security-headers Traefik middleware mirrors the
global one but with frameDeny off and frame-ancestors parameterized so
the chat iframe is not blocked. Two middleware definitions because
browsers intersect CSP headers, so an app-layer override of
frame-ancestors cannot loosen the global 'none'.

Drop the FastAPI SecurityHeadersMiddleware. The browser identity path
already reads X-Auth-Request-Preferred-Username from oauth2-proxy, no
app code change needed. Add auth-curl smoke tests for the report-viewer
subdomain.
Drops /webhooks, /metrics, /healthz, /docs from external reachability
so the webhook is in cluster only.
scout_get_reports used to require a prior search_id, so reading one report by ID needed either an LLM-built SELECT or a two-tool find/get dance, and the model kept slipping on which column to use. New shape takes ids and id_column directly, hits /api/reports/read, defaults id_column to primary_report_identifier so the lake file path from the Discuss in Chat handoff just works.

Report-scoped IDs hit reports_latest directly to keep the hot path off the epic_view JOIN. Patient-scoped IDs (epic_mrn, mpi, scout_patient_id) route through reports_latest_epic_view with mrn and mpi transparently mapping to the resolved_* columns.

Discuss in Chat uses OWUI input:prompt (not :submit) because OWUI gates auto-submit on real same-origin, not on iframeSandboxAllowSameOrigin, and Scout's iframe is on a dedicated subdomain. User hits Enter to send.

Other polish: row vs card visual contrast, spinner in a reserved slot, SQL becomes Explain Search, CSV becomes Download CSV.
Add report-viewer-audience client scope and attach it to the OWUI
client, then flip verify_aud=True. Today any realm token validates,
which is the gap. Drop trino-audience from the OWUI client since
report-viewer no longer forwards user JWTs to Trino.
Trino emits timestamp-with-timezone as YYYY-MM-DDTHH:MM:SS+00:00 and the lake is UTC, so the offset is always noise. Card already trimmed it via a helper; the table didn't. Lifted the helper to module scope, swapped the regex for Date + toISOString, and routed the Date cell through it.
Drops the Helm release, openwebui_mcp_svc Keycloak client and
password.db, and flips Trino to JWT-only.
Bumps search ID entropy from 6 to 16 base62 chars while here.
…ailable columns

FiltersModal used to render all inputs unconditionally, so filtering on
a column the search SQL did not SELECT would 502 from Trino. Each row
is now gated on the returned columns, and the new MRN, accession, and
facility inputs slot in the same way. Also show MRN by default and add
an MPI column.
…nement

The OWUI tool used to parse CSVs and post a JSON list of IDs. Now it
forwards the file as multipart. Parsing, header inference, dedup, and
column resolution live in report-viewer.

Fixes the display-blank MRN bug: from-file filtered on raw epic_mrn
but selected resolved_epic_mrn AS epic_mrn, so v2.4 historical rows
came back with a blank MRN. Both validate and saved SQL now target
reports_latest_epic_view and translate patient-scoped inputs through
PATIENT_ID_COLUMNS. Also fixes the count, was len(matched_ids), now a
real SELECT COUNT(*).

Adds {{cohort}} placeholder so the LLM can refine a file-mode search
or run a cohort aggregate without ever seeing the ID list. Same
mechanic on scout_find_reports (optional custom sql) and
scout_query_sql (new /api/reports/query/from-file endpoint).
Placeholder must appear exactly once, checked before any Trino call.
@andylassiter andylassiter requested a review from a team as a code owner July 9, 2026 22:28
@andylassiter andylassiter linked an issue Jul 9, 2026 that may be closed by this pull request
@andylassiter andylassiter added this to the v4.1.0 milestone Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Dependency Review

The following issues were found:

  • ✅ 0 vulnerable package(s)
  • ⚠️ 6 packages with OpenSSF Scorecard issues.

View full job summary

Comment on lines +101 to +103
_cols, vrows = await trino_client.execute(
validate_sql, user=user.sub, params=[cleaned]
)
Comment on lines +177 to +179
columns, rows = await trino_client.execute(
sql, user=user.sub, params=[[str(i) for i in body.ids]]
)
sample_sql = f"SELECT s.* FROM ({sql}) s LIMIT {_LLM_SAMPLE_ROWS}"
try:
with metrics.time_trino("create_sample_query"):
columns, sample_rows = await trino_client.execute(sample_sql, user=user.sub)
count_sql = f"SELECT COUNT(*) AS n FROM ({sql}) s"
try:
with metrics.time_trino("create_count_query"):
_cols, count_rows = await trino_client.execute(count_sql, user=user.sub)
Comment on lines +359 to +361
_cols, rows = await trino_client.execute(
validate_sql, user=user.sub, params=[chunk]
)
Comment thread .github/dependabot.yml Fixed
Comment thread .github/dependabot.yml Fixed
Comment thread .github/workflows/ci.yaml Fixed
Comment thread .github/workflows/ci.yaml Fixed
.map((t) => t.trim())
.filter((t) => t.length >= 2)
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const highlightRe = escaped.length ? new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi') : null;
new_user_email = user_obj.get("email") or body.get("email")

if not new_user_id:
log.info("webhook fired with no user id", extra={"action": action})
metrics.OWUI_WEBHOOK_EVENTS.labels(action="other", result="skipped").inc()
return None
if action and "signup" not in action.lower() and "new" not in action.lower():
log.info("ignoring non-signup webhook", extra={"action": action})
metrics.OWUI_WEBHOOK_EVENTS.labels(action="signup", result="enabled").inc()
log.info(
"iframe-defaults applied",
extra={"user_id": new_user_id, "email": new_user_email},
except Exception:
log.exception(
"iframe-defaults apply failed",
extra={"user_id": new_user_id, "email": new_user_email},
await cur.execute('SELECT settings FROM "user" WHERE id = %s', (user_id,))
row = await cur.fetchone()
if row is None:
log.warning("user not in OWUI DB yet", extra={"user_id": user_id})
Comment on lines +432 to +441
extra={
"search_id": stored["id"],
"id_column": resolved_id_column,
"column_inferred": column_inferred,
"unique_ids": len(cleaned),
"matched_ids": len(final_ids),
"unmatched_ids": len(unmatched),
"report_count": row_count,
"custom_sql": bool(sql),
},
@andylassiter andylassiter self-assigned this Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scout Chat does not readily generate the Vega-Lite charts Add an embedded report viewer tool to Chat

2 participants