Skip to content

Searching with filters but no query text silently shows the help card instead of results or an error #332

Description

@samuelvkwong

Motivation

From the September 2026 ADIT/RADIS brainstorming: "Searching without query text doesn't work (for all filters)". Users expect to list the reports of their group by metadata alone (all CT of last month, everything with label X, female patients aged 60-70). Today the search page accepts such a submission and then does nothing with it, without saying so.

Current behaviour

Verified at 30fa750 (origin/main).

  • SearchForm.query is required=False (radis/search/forms.py:21), so a filters-only submission validates.
  • QueryParser.parse returns None for an empty query (radis/search/utils/query_parser.py:310-311). The same happens for queries that collapse to nothing, e.g. body:pneumonia, AND OR, ().
  • SearchView calls the provider only inside if query_node is not None: (radis/search/views.py:62) and otherwise falls through to render (views.py:100) with just the bound form: no documents, no total_count, no message. The fixed_query notice is set inside the same block (views.py:63-64) and rendered by _search_results.html:13-17, which search.html:21-22 includes only when documents != None. So a collapsed query shows neither results nor the "Fixed invalid query" warning.
  • With no documents, search.html:19-25 renders _search_info.html, whose text (_search_info.html:4-7) tells the user to enter a query. The filters stay populated in the sidebar; nothing says they were ignored.
  • x-ignore-empty-inputs disables empty inputs on submit (radis/core/static/core/core.js:36-44), so the request is e.g. /search/?modalities=CT&study_date_from=2025-01-01 with no query key. Server-side this is indistinguishable from the landing GET /search/ (the form is always bound, views.py:25).
  • The only test for this case asserts HTTP 200 and nothing else (radis/search/tests/test_views.py:198-209).
  • The user guide states "A query is required (filters alone do not search)" (docs/user-docs/user-guide.md:47, added by Refresh stale documentation #283 on 2026-09-22). No issue or commit records this as a product decision. The gate has existed since the first search view (50e8c60, 2023-10-21) and survived the AST parser (7317ec8); a separate filter-only list at /reports/ came a year later (e66696c, 2024-09-07), and bfe6413 ("allow empty querys") added the filter-only pgsearch.providers.filter() for subscriptions only. "Search needs a query, browsing lives on /reports/" may therefore have been the intended split, but that split is not visible to users.

Why the provider cannot simply run with an empty query: Search.query is a non-optional QueryNode (radis/search/site.py:86); the FTS tsquery and the embedding text are both derived from it, _build_query_string(None) raises (radis/pgsearch/providers.py:130-131), an empty match_q would match every row (comment at providers.py:404-406), and ordering is RRF fusion of two ranked lists (providers.py:422-424), so a query-less search has no defined order. The structured filters, by contrast, are query-independent (_build_filter_query, providers.py:189-237).

What exists instead:

  • pgsearch.providers.filter() (providers.py:547-552): filter-only, but unordered, unpaginated, ids only; registered for subscription refresh.
  • ReportListView at /reports/ (radis/reports/views.py:12-23): filter-only, ordered -study_datetime, paginated, but only patient_id, modalities, study dates and study_description (radis/reports/filters.py:11-37), no language, sex, age or labels. It is not in the main menu; the only links to it are the patient-history arrows on report_detail.html:18-20.

Requested behaviour

At minimum: a filters-only submission must not be a silent no-op. Either the page lists the matching reports, or it says clearly that a query is needed and where to browse instead. A query that collapses to nothing should surface the parser's fix messages (e.g. "Stripped field-filter syntax (use the filter widgets instead)") rather than the help card.

Preferably: filter-only browsing with the full search filter set, group-scoped, paginated, in a defined order (question 5).

Open questions

  1. (main decision) Browse inside the search page (option C under Implementation notes) or promote and extend /reports/ (option B)? Given the history above, this is the maintainer's call before anyone implements C.
  2. Landing page: GET /search/ with no parameters must stay cheap. Proposal: browse only when at least one filter key is present; keep the help card otherwise. The query-syntax help then needs another home if browse results replace it.
  3. Collapsed queries (body:pneumonia): error with fixes, or fall into browse mode? Listing every report after the user typed a query seems worse than today; recommendation is an error plus the fixes.
  4. Count semantics: exact count() (slow on a multi-million corpus with the labels subquery) or capped at max_results (10,000: radis/pgsearch/apps.py:174, radis/settings/base.py:541-542) reported as at_least? The view already paginates at most min(total_count, max_results) (views.py:92), so an exact count above 10,000 would show a number the user cannot page through. Should be consistent with what Extraction jobs silently process at most ~10,100 reports regardless of how many match #290 settles on.
  5. Ordering: -study_datetime, -pk, or -created_at (ingest order, the model default at radis/reports/models.py:87-88)? A sort selector?
  6. Provider contract: overload search() with query=None, or add a separate browse(filters, offset, limit) callable on the provider protocol so the hybrid path stays untouched? The latter seems cleaner for future providers and avoids changing the type seen by ExtractionRetrievalProvider.count/retrieve (radis/extractions/site.py:20-21) and the three extraction Search(...) call sites (extractions/forms.py:135, extractions/views.py:408, extractions/tasks.py:83).
  7. Fate of /reports/: fold its patient_id filter and the report-detail arrows into the browse mode and retire ReportListView, or keep both? SearchForm has no patient_id field today (SearchFilters.patient_id exists but is unused by the view).

Implementation notes

Options, cheapest first:

A -- make the no-op visible (interim). In SearchView, when the trimmed query is empty and at least one filter key is in request.GET, add a non-field error ("Enter a search query; filters alone do not search") with a link to /reports/; when the query parsed to None with fixes, show the fixes. Do not flip required=True on the form field: the landing GET would then render a validation error. About ten lines plus a test; fixes the confusion, delivers no browsing.

B -- promote /reports/. Register report_list in the main menu, extend ReportFilter with language, sex, age range and labels to mirror SearchForm, add a "Browse without a query" link from the search page. No provider change, ordering and pagination already there, decoupled from the concurrent providers.py churn (#292, #285, #284, #308), and #308 already switches it to Report.objects.live(). Cost: two filter forms and two result templates to keep in sync (the brainstorming already complains about drifting filter UIs between subscription pages).

C -- filter-only branch in the search page and provider. Trigger only when the raw query is empty and a filter is set. Skip _fuse_hybrid entirely (no tsquery, no embedding call, no RRF); build the queryset from _build_filter_query so the group guard and, once #308 lands, the withdrawn=False predicate apply automatically. After #292 the natural table is ReportSearchIndex with the mirrored columns; its study_datetime is nullable (radis/pgsearch/models.py:38 on that branch) and has no index there (the only new indexes in migration 0005 are on group_ids and report_updated_at), and Report.study_datetime has no index either (no AddIndex/db_index in radis/reports/migrations/), so order with nulls_last and expect an index migration. Build ReportDocument directly (document_from_pgsearch_response expects an annotated row with .rank/.summary, radis/pgsearch/utils/document_utils.py:13-31); relevance is already float | None (site.py:11). Hide the FTS/cosine/RRF rows in _result_header.html:15-23 in browse mode and add a note naming the sort order so it is obvious no ranking happened. Update user-guide.md:47 and the "scores that determined its rank" paragraph; add a short section to docs/superpowers/specs/hybrid-search.md if the provider contract changes.

Not recommended: running _fuse_hybrid with an empty match_q. The comment at providers.py:404-406 warns against it; every row would rank 0 and the 10,000-row FTS cap plus DISTINCT over the whole group would be paid on every page.

Recommendation: ship A now regardless; then C on top of #292 if the maintainer wants one filter UI and one result UI, otherwise B. Extractions stay out of scope (they require a query by design, radis/extractions/forms.py:58,104-113; see #138, #290); subscriptions are already filter-only.

Tests either way: landing GET stays cheap (no provider call); filters-only GET returns group-scoped results in the chosen order (e2e style of _seed_report/_doc_ids in test_views_e2e.py); a foreign-group report stays invisible; EmbeddingClient is never called; collapsed query shows the fixes; page beyond max_results still 404s (views.py:48-50). test_search_view_empty_query should assert the chosen contract, not just 200.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions