From 7af4a35cb24c8dd6f07ebef531cf8c93a649d577 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Fri, 4 Sep 2026 12:19:17 +0530 Subject: [PATCH 1/7] docs: flatten AI resolver `context` in schema and fix deprecated examples The AI resolver has always declared `explore`, `dimensions` and `measures` at the top level of the `ai` block, as decided during review of #8673, but the JSON schema and the docs generated from it still nested them under `context`. Since unknown resolver properties only produce a warning, the documented example silently dropped the explore. Also replaces the `iso_duration`/`iso_offset` examples, which the resolver rejects, with Rill time expressions, and documents the `time_zone` and `where` properties. Co-Authored-By: Claude Fable 5.1 --- docs/docs/reference/project-files/alerts.md | 24 +++---- docs/docs/reference/project-files/models.md | 48 +++++--------- docs/docs/reference/project-files/reports.md | 27 +++----- runtime/parser/schema/project.schema.yaml | 69 ++++++++------------ 4 files changed, 65 insertions(+), 103 deletions(-) diff --git a/docs/docs/reference/project-files/alerts.md b/docs/docs/reference/project-files/alerts.md index 5f95fe9e29ef..29c9dfd7d9ab 100644 --- a/docs/docs/reference/project-files/alerts.md +++ b/docs/docs/reference/project-files/alerts.md @@ -119,37 +119,31 @@ _[oneOf]_ - Data source for the alert _(required)_ - **`prompt`** - _[string]_ - Custom prompt to guide the AI analysis. If not provided, a default analysis prompt is used. - - **`time_range`** - _[object]_ - Time range for the analysis period + - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_duration`** - _[string]_ - ISO 8601 duration (e.g., P7D for 7 days, P1M for 1 month) - - - **`iso_offset`** - _[string]_ - ISO 8601 offset from current time (e.g., P1D to start from yesterday) + - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression (e.g., 'last 7 days', 'this month') - - - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis - - - **`iso_duration`** - _[string]_ - ISO 8601 duration for comparison period + - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_offset`** - _[string]_ - ISO 8601 offset for comparison period (e.g., P7D to compare with previous week) + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression for comparison period + - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`context`** - _[object]_ - Context to constrain the AI analysis + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze + - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis - - **`dimensions`** - _[array of string]_ - List of dimensions to include in analysis + - **`measures`** - _[array of string]_ - List of measures to include in the analysis - - **`measures`** - _[array of string]_ - List of measures to include in analysis + - **`where`** - _[object]_ - Optional filter expression to apply to the analysis, in the same format as metrics view query filters ### `for` diff --git a/docs/docs/reference/project-files/models.md b/docs/docs/reference/project-files/models.md index d7f3b73a74e2..5fec121acc01 100644 --- a/docs/docs/reference/project-files/models.md +++ b/docs/docs/reference/project-files/models.md @@ -182,37 +182,31 @@ _[oneOf]_ - Refers to the explicitly defined state of your model, cannot be used - **`prompt`** - _[string]_ - Custom prompt to guide the AI analysis. If not provided, a default analysis prompt is used. - - **`time_range`** - _[object]_ - Time range for the analysis period + - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_duration`** - _[string]_ - ISO 8601 duration (e.g., P7D for 7 days, P1M for 1 month) - - - **`iso_offset`** - _[string]_ - ISO 8601 offset from current time (e.g., P1D to start from yesterday) + - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression (e.g., 'last 7 days', 'this month') - - - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis - - - **`iso_duration`** - _[string]_ - ISO 8601 duration for comparison period + - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_offset`** - _[string]_ - ISO 8601 offset for comparison period (e.g., P7D to compare with previous week) + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression for comparison period + - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`context`** - _[object]_ - Context to constrain the AI analysis + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze + - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis - - **`dimensions`** - _[array of string]_ - List of dimensions to include in analysis + - **`measures`** - _[array of string]_ - List of measures to include in the analysis - - **`measures`** - _[array of string]_ - List of measures to include in analysis + - **`where`** - _[object]_ - Optional filter expression to apply to the analysis, in the same format as metrics view query filters ```yaml state: @@ -280,37 +274,31 @@ _[oneOf]_ - Refers to how your data is partitioned; cannot be used with state. ( - **`prompt`** - _[string]_ - Custom prompt to guide the AI analysis. If not provided, a default analysis prompt is used. - - **`time_range`** - _[object]_ - Time range for the analysis period + - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_duration`** - _[string]_ - ISO 8601 duration (e.g., P7D for 7 days, P1M for 1 month) - - - **`iso_offset`** - _[string]_ - ISO 8601 offset from current time (e.g., P1D to start from yesterday) + - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression (e.g., 'last 7 days', 'this month') - - - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis - - - **`iso_duration`** - _[string]_ - ISO 8601 duration for comparison period + - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_offset`** - _[string]_ - ISO 8601 offset for comparison period (e.g., P7D to compare with previous week) + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression for comparison period + - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`context`** - _[object]_ - Context to constrain the AI analysis + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze + - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis - - **`dimensions`** - _[array of string]_ - List of dimensions to include in analysis + - **`measures`** - _[array of string]_ - List of measures to include in the analysis - - **`measures`** - _[array of string]_ - List of measures to include in analysis + - **`where`** - _[object]_ - Optional filter expression to apply to the analysis, in the same format as metrics view query filters ```yaml partitions: diff --git a/docs/docs/reference/project-files/reports.md b/docs/docs/reference/project-files/reports.md index 06b5fc2df153..98228573aa7c 100644 --- a/docs/docs/reference/project-files/reports.md +++ b/docs/docs/reference/project-files/reports.md @@ -121,37 +121,31 @@ Supports ai resolvers only as of now. - **`prompt`** - _[string]_ - Custom prompt to guide the AI analysis. If not provided, a default analysis prompt is used. - - **`time_range`** - _[object]_ - Time range for the analysis period + - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_duration`** - _[string]_ - ISO 8601 duration (e.g., P7D for 7 days, P1M for 1 month) - - - **`iso_offset`** - _[string]_ - ISO 8601 offset from current time (e.g., P1D to start from yesterday) + - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression (e.g., 'last 7 days', 'this month') - - - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis - - - **`iso_duration`** - _[string]_ - ISO 8601 duration for comparison period + - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`iso_offset`** - _[string]_ - ISO 8601 offset for comparison period (e.g., P7D to compare with previous week) + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format - **`end`** - _[string]_ - End timestamp in ISO 8601 format - - **`expression`** - _[string]_ - Rill time expression for comparison period + - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`context`** - _[object]_ - Context to constrain the AI analysis + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze + - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis - - **`dimensions`** - _[array of string]_ - List of dimensions to include in analysis + - **`measures`** - _[array of string]_ - List of measures to include in the analysis - - **`measures`** - _[array of string]_ - List of measures to include in analysis + - **`where`** - _[object]_ - Optional filter expression to apply to the analysis, in the same format as metrics view query filters ### `query` @@ -257,8 +251,7 @@ data: expression: "1D as of latest/D" comparison_time_range: expression: "1D as of latest/D offset -1D" - context: - explore: my_explore + explore: my_explore notify: email: recipients: diff --git a/runtime/parser/schema/project.schema.yaml b/runtime/parser/schema/project.schema.yaml index eaac919df70e..cce5ecba331d 100644 --- a/runtime/parser/schema/project.schema.yaml +++ b/runtime/parser/schema/project.schema.yaml @@ -2881,8 +2881,7 @@ definitions: expression: "1D as of latest/D" comparison_time_range: expression: "1D as of latest/D offset -1D" - context: - explore: my_explore + explore: my_explore notify: email: recipients: @@ -3764,71 +3763,59 @@ definitions: description: Custom prompt to guide the AI analysis. If not provided, a default analysis prompt is used. time_range: type: object - description: Time range for the analysis period + description: Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. properties: - iso_duration: - type: string - description: ISO 8601 duration (e.g., P7D for 7 days, P1M for 1 month) - iso_offset: + expression: type: string - description: ISO 8601 offset from current time (e.g., P1D to start from yesterday) + description: Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') start: type: string description: Start timestamp in ISO 8601 format end: type: string description: End timestamp in ISO 8601 format - expression: - type: string - description: Rill time expression (e.g., 'last 7 days', 'this month') comparison_time_range: type: object - description: Optional comparison time range for period-over-period analysis + description: Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. properties: - iso_duration: - type: string - description: ISO 8601 duration for comparison period - iso_offset: + expression: type: string - description: ISO 8601 offset for comparison period (e.g., P7D to compare with previous week) + description: Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') start: type: string description: Start timestamp in ISO 8601 format end: type: string description: End timestamp in ISO 8601 format - expression: - type: string - description: Rill time expression for comparison period - context: + time_zone: + type: string + description: IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. + explore: + type: string + description: Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. + dimensions: + type: array + items: + type: string + description: List of dimensions to include in the analysis + measures: + type: array + items: + type: string + description: List of measures to include in the analysis + where: type: object - description: Context to constrain the AI analysis - properties: - explore: - type: string - description: Name of the explore dashboard to analyze - dimensions: - type: array - items: - type: string - description: List of dimensions to include in analysis - measures: - type: array - items: - type: string - description: List of measures to include in analysis + description: Optional filter expression to apply to the analysis, in the same format as metrics view query filters required: - ai examples: - ai: prompt: "Analyze revenue trends and identify top performing regions" time_range: - iso_duration: P7D + expression: "7D as of latest/D" comparison_time_range: - iso_duration: P7D - iso_offset: P7D - context: - explore: sales_explore + expression: "7D as of latest/D offset -7D" + explore: sales_explore explore_time_range_properties: oneOf: From 2b18a63dd5df33338c949269fe1ca5c2aa135326 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Fri, 4 Sep 2026 12:24:11 +0530 Subject: [PATCH 2/7] feat: resolve AI report time ranges against latest data with `watermark: inherit` Reports already support `watermark: inherit`, which uses the lowest watermark of the report's refs as the execution time so that `latest` in time range expressions means the latest data rather than the trigger time. AI reports could not use it because they had no refs, so the reconciler silently fell back to the trigger time. - Add the `explore` of an AI report as a ref. This also makes the report wait for the explore to reconcile before running. - In the report reconciler, resolve explore refs to their metrics view before querying the watermark. This keeps the `ResourceWatermark` query keyed on the metrics view, whose cache key tracks the underlying data, rather than on the explore, which does not change when data refreshes. - Document the behavior on the `explore` property of the AI resolver. Co-Authored-By: Claude Fable 5.1 --- docs/docs/reference/project-files/alerts.md | 2 +- docs/docs/reference/project-files/models.md | 4 +- docs/docs/reference/project-files/reports.md | 2 +- runtime/parser/parse_partial_data.go | 5 ++ runtime/parser/parser_test.go | 88 ++++++++++++++++++++ runtime/parser/schema/project.schema.yaml | 2 +- runtime/reconcilers/report.go | 14 ++++ runtime/reconcilers/report_test.go | 79 ++++++++++++++++++ 8 files changed, 191 insertions(+), 5 deletions(-) diff --git a/docs/docs/reference/project-files/alerts.md b/docs/docs/reference/project-files/alerts.md index 29c9dfd7d9ab..87f7febb21b6 100644 --- a/docs/docs/reference/project-files/alerts.md +++ b/docs/docs/reference/project-files/alerts.md @@ -137,7 +137,7 @@ _[oneOf]_ - Data source for the alert _(required)_ - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard. Combined with `watermark: inherit` on the report, time range expressions are resolved against the latest data in the metrics view instead of the report's trigger time. - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis diff --git a/docs/docs/reference/project-files/models.md b/docs/docs/reference/project-files/models.md index 5fec121acc01..3e3358aec2d5 100644 --- a/docs/docs/reference/project-files/models.md +++ b/docs/docs/reference/project-files/models.md @@ -200,7 +200,7 @@ _[oneOf]_ - Refers to the explicitly defined state of your model, cannot be used - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard. Combined with `watermark: inherit` on the report, time range expressions are resolved against the latest data in the metrics view instead of the report's trigger time. - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis @@ -292,7 +292,7 @@ _[oneOf]_ - Refers to how your data is partitioned; cannot be used with state. ( - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard. Combined with `watermark: inherit` on the report, time range expressions are resolved against the latest data in the metrics view instead of the report's trigger time. - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis diff --git a/docs/docs/reference/project-files/reports.md b/docs/docs/reference/project-files/reports.md index 98228573aa7c..4359e3deb329 100644 --- a/docs/docs/reference/project-files/reports.md +++ b/docs/docs/reference/project-files/reports.md @@ -139,7 +139,7 @@ Supports ai resolvers only as of now. - **`time_zone`** - _[string]_ - IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. - - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. + - **`explore`** - _[string]_ - Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard. Combined with `watermark: inherit` on the report, time range expressions are resolved against the latest data in the metrics view instead of the report's trigger time. - **`dimensions`** - _[array of string]_ - List of dimensions to include in the analysis diff --git a/runtime/parser/parse_partial_data.go b/runtime/parser/parse_partial_data.go index 484ec1e3a8ba..b0c6f5afc196 100644 --- a/runtime/parser/parse_partial_data.go +++ b/runtime/parser/parse_partial_data.go @@ -116,6 +116,11 @@ func (p *Parser) parseDataYAML(paths []string, raw *DataYAML, contextualConnecto count++ resolver = "ai" resolverProps = raw.AI + // The agent can access any metrics view, so the explore is the only dependency known up front. + // Adding it as a ref makes reports wait for it to reconcile and lets watermark: inherit resolve time ranges against its data. + if explore, ok := raw.AI["explore"].(string); ok && explore != "" { + refs = append(refs, ResourceName{Kind: ResourceKindExplore, Name: explore}) + } } // Handle union resolver diff --git a/runtime/parser/parser_test.go b/runtime/parser/parser_test.go index d8b28163a898..01154d26fca2 100644 --- a/runtime/parser/parser_test.go +++ b/runtime/parser/parser_test.go @@ -1524,6 +1524,94 @@ annotations: requireResourcesAndErrors(t, p, resources, nil) } +func TestReportAIExploreRef(t *testing.T) { + ctx := context.Background() + repo := makeRepo(t, map[string]string{ + `rill.yaml`: ``, + `reports/r1.yaml`: ` +type: report +display_name: AI Report + +refresh: + cron: 0 8 * * * + +watermark: inherit + +data: + ai: + prompt: Analyze key metrics + time_range: + expression: 1D as of latest/D + explore: e1 + +notify: + email: + recipients: + - user_1@example.com +`, + // Without an explore, the report has no refs. + `reports/r2.yaml`: ` +type: report +display_name: AI Report + +refresh: + cron: 0 8 * * * + +data: + ai: + prompt: Analyze key metrics + +notify: + email: + recipients: + - user_1@example.com +`, + }) + + resources := []*Resource{ + { + Name: ResourceName{Kind: ResourceKindReport, Name: "r1"}, + Paths: []string{"/reports/r1.yaml"}, + Refs: []ResourceName{{Kind: ResourceKindExplore, Name: "e1"}}, + ReportSpec: &runtimev1.ReportSpec{ + DisplayName: "AI Report", + RefreshSchedule: &runtimev1.Schedule{Cron: "0 8 * * *"}, + Resolver: "ai", + ResolverProperties: must(structpb.NewStruct(map[string]any{ + "prompt": "Analyze key metrics", + "time_range": map[string]any{"expression": "1D as of latest/D"}, + "explore": "e1", + })), + Notifiers: []*runtimev1.Notifier{{ + Connector: "email", + Properties: must(structpb.NewStruct(map[string]any{"recipients": []any{"user_1@example.com"}})), + }}, + WatermarkInherit: true, + }, + }, + { + Name: ResourceName{Kind: ResourceKindReport, Name: "r2"}, + Paths: []string{"/reports/r2.yaml"}, + ReportSpec: &runtimev1.ReportSpec{ + DisplayName: "AI Report", + RefreshSchedule: &runtimev1.Schedule{Cron: "0 8 * * *"}, + Resolver: "ai", + ResolverProperties: must(structpb.NewStruct(map[string]any{ + "prompt": "Analyze key metrics", + })), + Notifiers: []*runtimev1.Notifier{{ + Connector: "email", + Properties: must(structpb.NewStruct(map[string]any{"recipients": []any{"user_1@example.com"}})), + }}, + }, + }, + } + + p, err := Parse(ctx, repo, "", "", "duckdb", true) + require.NoError(t, err) + requireResourcesAndErrors(t, p, resources, nil) +} + func TestReportPdfValidation(t *testing.T) { ctx := context.Background() repo := makeRepo(t, map[string]string{ diff --git a/runtime/parser/schema/project.schema.yaml b/runtime/parser/schema/project.schema.yaml index cce5ecba331d..43a970965520 100644 --- a/runtime/parser/schema/project.schema.yaml +++ b/runtime/parser/schema/project.schema.yaml @@ -3792,7 +3792,7 @@ definitions: description: IANA time zone used to evaluate the time range expressions (e.g., 'America/New_York'). Defaults to UTC. explore: type: string - description: Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard, and the time range is resolved against it. + description: "Name of the explore dashboard to analyze. If provided, the analysis is limited to the metrics view of this dashboard. Combined with `watermark: inherit` on the report, time range expressions are resolved against the latest data in the metrics view instead of the report's trigger time." dimensions: type: array items: diff --git a/runtime/reconcilers/report.go b/runtime/reconcilers/report.go index 59e896685993..9fbaa3bf67d3 100644 --- a/runtime/reconcilers/report.go +++ b/runtime/reconcilers/report.go @@ -887,6 +887,20 @@ func formatExportFormat(f runtimev1.ExportFormat) string { func (r *ReportReconciler) computeInheritedWatermark(ctx context.Context, refs []*runtimev1.ResourceName) (time.Time, bool, error) { var t time.Time for _, ref := range refs { + // Explores (referenced by AI reports) inherit the watermark of their metrics view. + // Resolving the metrics view here instead of in the watermark query keeps the query's cache keyed on the metrics view's data. + if ref.Kind == runtime.ResourceKindExplore { + res, err := r.C.Get(ctx, ref, false) + if err != nil { + return t, false, fmt.Errorf("failed to get explore %q: %w", ref.Name, err) + } + spec := res.GetExplore().State.ValidSpec + if spec == nil { + return t, false, fmt.Errorf("explore %q is not valid", ref.Name) + } + ref = &runtimev1.ResourceName{Kind: runtime.ResourceKindMetricsView, Name: spec.MetricsView} + } + q := &queries.ResourceWatermark{ ResourceKind: ref.Kind, ResourceName: ref.Name, diff --git a/runtime/reconcilers/report_test.go b/runtime/reconcilers/report_test.go index 876e50778adb..3ddb1f57c930 100644 --- a/runtime/reconcilers/report_test.go +++ b/runtime/reconcilers/report_test.go @@ -2,6 +2,7 @@ package reconcilers_test import ( "testing" + "time" runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" "github.com/rilldata/rill/runtime" @@ -9,6 +10,84 @@ import ( "github.com/stretchr/testify/require" ) +func TestReportAIExploreWatermarkInherit(t *testing.T) { + rt, id := testruntime.NewInstance(t) + testruntime.PutFiles(t, rt, id, map[string]string{ + "/models/bar.sql": ` +SELECT '2024-01-01T00:00:00Z'::TIMESTAMP as __time, 'Denmark' as country +`, + "/metrics/mv1.yaml": ` +version: 1 +type: metrics_view +model: bar +timeseries: __time +dimensions: +- column: country +measures: +- expression: count(*) +`, + "/explores/e1.yaml": ` +type: explore +metrics_view: mv1 +`, + // AI report that inherits its watermark through the explore + "/reports/r1.yaml": ` +type: report +display_name: AI Report +refresh: + cron: 0 8 * * * +watermark: inherit +data: + ai: + prompt: Analyze key metrics + explore: e1 +notify: + email: + recipients: + - somebody@example.com +`, + // Same report without watermark: inherit, which should use the trigger time + "/reports/r2.yaml": ` +type: report +display_name: AI Report +refresh: + cron: 0 8 * * * +data: + ai: + prompt: Analyze key metrics + explore: e1 +notify: + email: + recipients: + - somebody@example.com +`, + }) + testruntime.ReconcileParserAndWait(t, rt, id) + testruntime.RequireReconcileState(t, rt, id, 6, 0, 0) + + r1 := testruntime.GetResource(t, rt, id, runtime.ResourceKindReport, "r1") + require.Len(t, r1.Meta.Refs, 1) + require.Equal(t, runtime.ResourceKindExplore, r1.Meta.Refs[0].Kind) + require.Equal(t, "e1", r1.Meta.Refs[0].Name) + + // Trigger both reports. + // The execution's report time is recorded before the report is sent, so it is available regardless of whether the AI session could be created. + triggerTime := time.Now() + testruntime.RefreshAndWait(t, rt, id, &runtimev1.ResourceName{Kind: runtime.ResourceKindReport, Name: "r1"}) + testruntime.RefreshAndWait(t, rt, id, &runtimev1.ResourceName{Kind: runtime.ResourceKindReport, Name: "r2"}) + + // r1 should have resolved the watermark of the explore's metrics view. + // The watermark is max(__time) plus one second, since it's used as an exclusive upper bound (see ResourceWatermark). + r1 = testruntime.GetResource(t, rt, id, runtime.ResourceKindReport, "r1") + require.Len(t, r1.GetReport().State.ExecutionHistory, 1) + require.Equal(t, time.Date(2024, 1, 1, 0, 0, 1, 0, time.UTC), r1.GetReport().State.ExecutionHistory[0].ReportTime.AsTime()) + + // r2 should have used the trigger time + r2 := testruntime.GetResource(t, rt, id, runtime.ResourceKindReport, "r2") + require.Len(t, r2.GetReport().State.ExecutionHistory, 1) + require.WithinDuration(t, triggerTime, r2.GetReport().State.ExecutionHistory[0].ReportTime.AsTime(), time.Minute) +} + func TestReportCanvasResolveTransitiveAccess(t *testing.T) { rt, id := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ Files: map[string]string{"rill.yaml": ""}, From f431629cc442ffb4e35ac54a49326ba0b1bc784d Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Tue, 8 Sep 2026 12:23:27 +0530 Subject: [PATCH 3/7] docs: use `watermark: inherit` in the AI report example The example uses `latest` expressions, so without `watermark: inherit` they resolve against the trigger time rather than the explore's data. Also uses `1D as of latest/D+1D` for the current period. Snapping excludes the period containing the reference point, so `1D as of latest/D` is the day before the latest data; `/D+1D` includes the last day with data, matching the legacy `iso_duration` behavior the example was migrated from. Co-Authored-By: Claude Fable 5.1 --- docs/docs/reference/project-files/alerts.md | 4 ++-- docs/docs/reference/project-files/models.md | 8 ++++---- docs/docs/reference/project-files/reports.md | 9 +++++---- runtime/parser/schema/project.schema.yaml | 13 +++++++------ 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/docs/reference/project-files/alerts.md b/docs/docs/reference/project-files/alerts.md index 87f7febb21b6..56f08f220b6b 100644 --- a/docs/docs/reference/project-files/alerts.md +++ b/docs/docs/reference/project-files/alerts.md @@ -121,7 +121,7 @@ _[oneOf]_ - Data source for the alert _(required)_ - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') + - **`expression`** - _[string]_ - Rill time expression. Note that snapping excludes the period containing the reference point, so '1D as of latest/D' is the day before the latest data; use '1D as of latest/D+1D' for the last day with data. - **`start`** - _[string]_ - Start timestamp in ISO 8601 format @@ -129,7 +129,7 @@ _[oneOf]_ - Data source for the alert _(required)_ - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format diff --git a/docs/docs/reference/project-files/models.md b/docs/docs/reference/project-files/models.md index 3e3358aec2d5..739b9a5cd84b 100644 --- a/docs/docs/reference/project-files/models.md +++ b/docs/docs/reference/project-files/models.md @@ -184,7 +184,7 @@ _[oneOf]_ - Refers to the explicitly defined state of your model, cannot be used - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') + - **`expression`** - _[string]_ - Rill time expression. Note that snapping excludes the period containing the reference point, so '1D as of latest/D' is the day before the latest data; use '1D as of latest/D+1D' for the last day with data. - **`start`** - _[string]_ - Start timestamp in ISO 8601 format @@ -192,7 +192,7 @@ _[oneOf]_ - Refers to the explicitly defined state of your model, cannot be used - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format @@ -276,7 +276,7 @@ _[oneOf]_ - Refers to how your data is partitioned; cannot be used with state. ( - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') + - **`expression`** - _[string]_ - Rill time expression. Note that snapping excludes the period containing the reference point, so '1D as of latest/D' is the day before the latest data; use '1D as of latest/D+1D' for the last day with data. - **`start`** - _[string]_ - Start timestamp in ISO 8601 format @@ -284,7 +284,7 @@ _[oneOf]_ - Refers to how your data is partitioned; cannot be used with state. ( - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format diff --git a/docs/docs/reference/project-files/reports.md b/docs/docs/reference/project-files/reports.md index 4359e3deb329..7cbf6b9643b9 100644 --- a/docs/docs/reference/project-files/reports.md +++ b/docs/docs/reference/project-files/reports.md @@ -123,7 +123,7 @@ Supports ai resolvers only as of now. - **`time_range`** - _[object]_ - Time range for the analysis period. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') + - **`expression`** - _[string]_ - Rill time expression. Note that snapping excludes the period containing the reference point, so '1D as of latest/D' is the day before the latest data; use '1D as of latest/D+1D' for the last day with data. - **`start`** - _[string]_ - Start timestamp in ISO 8601 format @@ -131,7 +131,7 @@ Supports ai resolvers only as of now. - **`comparison_time_range`** - _[object]_ - Optional comparison time range for period-over-period analysis. Use either a Rill time expression or fixed start and end timestamps. - - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') + - **`expression`** - _[string]_ - Rill time expression for the comparison period (e.g., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`start`** - _[string]_ - Start timestamp in ISO 8601 format @@ -244,13 +244,14 @@ type: report display_name: Daily AI Insights refresh: cron: "0 8 * * *" +watermark: inherit # resolve "latest" against the explore's data instead of the trigger time data: ai: prompt: "Analyze key metrics and identify significant changes" time_range: - expression: "1D as of latest/D" + expression: "1D as of latest/D+1D" # the last day with data comparison_time_range: - expression: "1D as of latest/D offset -1D" + expression: "1D as of latest/D" # the day before it explore: my_explore notify: email: diff --git a/runtime/parser/schema/project.schema.yaml b/runtime/parser/schema/project.schema.yaml index 43a970965520..5d4843ec9be0 100644 --- a/runtime/parser/schema/project.schema.yaml +++ b/runtime/parser/schema/project.schema.yaml @@ -2874,13 +2874,14 @@ definitions: display_name: Daily AI Insights refresh: cron: "0 8 * * *" + watermark: inherit # resolve "latest" against the explore's data instead of the trigger time data: ai: prompt: "Analyze key metrics and identify significant changes" time_range: - expression: "1D as of latest/D" + expression: "1D as of latest/D+1D" # the last day with data comparison_time_range: - expression: "1D as of latest/D offset -1D" + expression: "1D as of latest/D" # the day before it explore: my_explore notify: email: @@ -3767,7 +3768,7 @@ definitions: properties: expression: type: string - description: Rill time expression (e.g., '7D as of latest/D', '1M as of latest/M') + description: Rill time expression. Note that snapping excludes the period containing the reference point, so '1D as of latest/D' is the day before the latest data; use '1D as of latest/D+1D' for the last day with data. start: type: string description: Start timestamp in ISO 8601 format @@ -3780,7 +3781,7 @@ definitions: properties: expression: type: string - description: Rill time expression for the comparison period (e.g., '7D as of latest/D offset -7D') + description: Rill time expression for the comparison period (e.g., '1D as of latest/D' when the time range is '1D as of latest/D+1D') start: type: string description: Start timestamp in ISO 8601 format @@ -3812,9 +3813,9 @@ definitions: - ai: prompt: "Analyze revenue trends and identify top performing regions" time_range: - expression: "7D as of latest/D" + expression: "7D as of latest/D+1D" # the last 7 days with data comparison_time_range: - expression: "7D as of latest/D offset -7D" + expression: "7D as of latest/D+1D offset -7D" explore: sales_explore explore_time_range_properties: From 5971cc0b0ead72d1ae8c9c32824c9d9c254322ae Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Thu, 10 Sep 2026 19:40:39 +0530 Subject: [PATCH 4/7] use analyst agent as default if no specified --- runtime/reconcilers/report.go | 14 ++++++-- runtime/reconcilers/report_test.go | 57 ++++++++++++++++++++++++++++++ runtime/resolvers/ai.go | 4 +++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/runtime/reconcilers/report.go b/runtime/reconcilers/report.go index 9fbaa3bf67d3..a5b5e5c60979 100644 --- a/runtime/reconcilers/report.go +++ b/runtime/reconcilers/report.go @@ -207,7 +207,7 @@ func (r *ReportReconciler) ResolveTransitiveAccess(ctx context.Context, claims * resolver, err := initializer(ctx, &runtime.ResolverOptions{ Runtime: r.C.Runtime, InstanceID: r.C.InstanceID, - Properties: spec.ResolverProperties.AsMap(), + Properties: resolverProperties(spec), Claims: claims, ForExport: false, }) @@ -798,7 +798,7 @@ func (r *ReportReconciler) triggerAIReport(ctx context.Context, self *runtimev1. result, info, err := r.C.Runtime.Resolve(ctx, &runtime.ResolveOptions{ InstanceID: r.C.InstanceID, Resolver: "ai", - ResolverProperties: props, + ResolverProperties: resolverProperties(rep.Spec), Args: map[string]any{ "execution_time": t, "create_shared_session": webOpenMode == "creator", // if creator mode, create a shared session @@ -882,6 +882,16 @@ func formatExportFormat(f runtimev1.ExportFormat) string { } } +// resolverProperties returns the report's resolver properties as they should be passed to the resolver. +// For AI reports, it marks the properties as belonging to a report, which lets the resolver's validation pass (e.g. a prompt is optional). +func resolverProperties(spec *runtimev1.ReportSpec) map[string]any { + props := spec.ResolverProperties.AsMap() + if spec.Resolver == "ai" { + props["is_report"] = true + } + return props +} + // computeInheritedWatermark computes the inherited watermark for the report. // It returns false if the watermark could not be computed. func (r *ReportReconciler) computeInheritedWatermark(ctx context.Context, refs []*runtimev1.ResourceName) (time.Time, bool, error) { diff --git a/runtime/reconcilers/report_test.go b/runtime/reconcilers/report_test.go index 3ddb1f57c930..b23a88e8e18e 100644 --- a/runtime/reconcilers/report_test.go +++ b/runtime/reconcilers/report_test.go @@ -88,6 +88,63 @@ notify: require.WithinDuration(t, triggerTime, r2.GetReport().State.ExecutionHistory[0].ReportTime.AsTime(), time.Minute) } +func TestReportAIResolveTransitiveAccess(t *testing.T) { + rt, id := testruntime.NewInstance(t) + testruntime.PutFiles(t, rt, id, map[string]string{ + "/models/bar.sql": ` +SELECT '2024-01-01T00:00:00Z'::TIMESTAMP as __time, 'Denmark' as country +`, + "/metrics/mv1.yaml": ` +version: 1 +type: metrics_view +model: bar +timeseries: __time +dimensions: +- column: country +measures: +- expression: count(*) +`, + "/explores/e1.yaml": ` +type: explore +metrics_view: mv1 +`, + // A minimal AI report as it may be written by hand: no agent (defaults to the analyst agent) and no prompt (reports have a default prompt). + "/reports/r1.yaml": ` +type: report +display_name: AI Report +refresh: + cron: 0 8 * * * +data: + ai: + explore: e1 +notify: + email: + recipients: + - somebody@example.com +`, + }) + testruntime.ReconcileParserAndWait(t, rt, id) + testruntime.RequireReconcileState(t, rt, id, 5, 0, 0) + + // Opening a report resolves transitive access through the report, which initializes its resolver. + // This must apply the same defaults as executing the report, or the resolver rejects the properties and the report cannot be opened. + claims := &runtime.SecurityClaims{ + AdditionalRules: []*runtimev1.SecurityRule{ + { + Rule: &runtimev1.SecurityRule_TransitiveAccess{ + TransitiveAccess: &runtimev1.SecurityRuleTransitiveAccess{ + Resource: &runtimev1.ResourceName{Kind: runtime.ResourceKindReport, Name: "r1"}, + }, + }, + }, + }, + } + r1 := testruntime.GetResource(t, rt, id, runtime.ResourceKindReport, "r1") + sec, err := rt.ResolveSecurity(t.Context(), id, claims, r1) + require.NoError(t, err) + require.True(t, sec.CanAccess()) +} + func TestReportCanvasResolveTransitiveAccess(t *testing.T) { rt, id := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ Files: map[string]string{"rill.yaml": ""}, diff --git a/runtime/resolvers/ai.go b/runtime/resolvers/ai.go index 916f47bebb73..944da521312b 100644 --- a/runtime/resolvers/ai.go +++ b/runtime/resolvers/ai.go @@ -67,6 +67,10 @@ func newAI(ctx context.Context, opts *runtime.ResolverOptions) (runtime.Resolver args.ExecutionTime = time.Now() } + // Default to the analyst agent, which is the only agent supported as of now + if props.Agent == "" { + props.Agent = ai.AnalystAgentName + } if props.Agent != ai.AnalystAgentName { return nil, errors.New("only 'analyst_agent' is supported as agent as of now") } From e0df379c5aeb1c1be99758b09b3ac57b39f7b739 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Thu, 10 Sep 2026 23:23:50 +0530 Subject: [PATCH 5/7] fix: open AI reports in the shared AI conversation Opening an AI report failed with "Required parameters are missing" because the report open page only knew canvas and query reports and fed AI reports to the explore query mapper. The runtime already appends `session_id` to the open link, and the UI-side redirect agreed on in #8673 was never added. - Redirect AI reports from the open page to `/-/ai/{session_id}`, forwarding the magic token in creator mode so the shared conversation can be read. - Treat `/-/ai/[conversationId]?token=...` as a public URL page and read the token from the URL there, mirroring report and alert pages, so anonymous recipients can authenticate with the token. - Give anonymous visitors a read-only view of the conversation: hide the conversation sidebar and header actions, and replace the input with a notice asking them to log in. - When a logged-in user continues a shared conversation opened with a magic token, switch the runtime client to their own credentials right before forking, so the fork and its analyses run with their access instead of the token's. - Describe a report's opening message by its explore and time range instead of rendering the analyst agent's raw arguments, which is what showed when a report had no user prompt. - Skip the cookie-authenticated project query in the AI layout on public URL pages; it only feeds the MCP dialog. - On the report detail page, link AI reports to the explore they analyze and hide the export format and row limit, which do not apply. - Add `isAIReportSpec` and resolve the dashboard name from the AI resolver's `explore` property. - Show the full session title as a tooltip on conversation items in the sidebar. Co-Authored-By: Claude Fable 5.1 --- .../src/features/navigation/nav-utils.spec.ts | 52 ++++++++++++++ .../src/features/navigation/nav-utils.ts | 13 +++- .../projects/header/ProjectHeader.svelte | 10 +-- .../metadata/ReportMetadata.svelte | 36 +++++++--- .../[organization]/[project]/+layout.svelte | 9 ++- .../[project]/-/ai/+layout.svelte | 59 +++++++++++++++- .../-/reports/[report]/open/+page.ts | 18 ++++- .../src/features/chat/ProjectChat.svelte | 5 +- .../src/features/chat/core/conversation.ts | 9 ++- .../features/chat/core/input/ChatInput.svelte | 3 + .../chat/core/input/ReadOnlyChatInput.svelte | 23 +++++++ .../src/features/chat/core/utils.spec.ts | 60 ++++++++++++++++ web-common/src/features/chat/core/utils.ts | 68 ++++++++++++++++++- .../fullpage/ConversationSidebar.svelte | 1 + .../chat/layouts/fullpage/FullPageChat.svelte | 66 +++++++++++------- .../features/scheduled-reports/utils.spec.ts | 27 ++++++++ .../src/features/scheduled-reports/utils.ts | 10 +++ web-common/src/lib/i18n/messages/en.json | 6 ++ web-common/src/lib/i18n/messages/es.json | 6 ++ 19 files changed, 429 insertions(+), 52 deletions(-) create mode 100644 web-admin/src/features/navigation/nav-utils.spec.ts create mode 100644 web-common/src/features/chat/core/input/ReadOnlyChatInput.svelte create mode 100644 web-common/src/features/chat/core/utils.spec.ts diff --git a/web-admin/src/features/navigation/nav-utils.spec.ts b/web-admin/src/features/navigation/nav-utils.spec.ts new file mode 100644 index 000000000000..9135138fd680 --- /dev/null +++ b/web-admin/src/features/navigation/nav-utils.spec.ts @@ -0,0 +1,52 @@ +import { + isPublicAIPage, + isPublicURLPage, +} from "@rilldata/web-admin/features/navigation/nav-utils"; +import type { Page } from "@sveltejs/kit"; +import { describe, expect, it } from "vitest"; + +function pageFor(routeId: string, url: string): Page { + return { route: { id: routeId }, url: new URL(url) } as Page; +} + +describe("isPublicURLPage", () => { + it("treats share token routes as public", () => { + expect( + isPublicURLPage( + pageFor( + "/[organization]/[project]/-/share/[token]/explore/[dashboard]", + "https://ui.rilldata.com/org/proj/-/share/abc/explore/e1", + ), + ), + ).toBe(true); + }); + + it("treats an AI conversation opened with a token as public", () => { + const page = pageFor( + "/[organization]/[project]/-/ai/[conversationId]", + "https://ui.rilldata.com/org/proj/-/ai/session-1?token=abc", + ); + expect(isPublicAIPage(page)).toBe(true); + expect(isPublicURLPage(page)).toBe(true); + }); + + it("does not treat an AI conversation without a token as public", () => { + const page = pageFor( + "/[organization]/[project]/-/ai/[conversationId]", + "https://ui.rilldata.com/org/proj/-/ai/session-1", + ); + expect(isPublicAIPage(page)).toBe(false); + expect(isPublicURLPage(page)).toBe(false); + }); + + it("does not treat the AI landing page as public even with a token", () => { + expect( + isPublicAIPage( + pageFor( + "/[organization]/[project]/-/ai", + "https://ui.rilldata.com/org/proj/-/ai?token=abc", + ), + ), + ).toBe(false); + }); +}); diff --git a/web-admin/src/features/navigation/nav-utils.ts b/web-admin/src/features/navigation/nav-utils.ts index adfbcee96f72..8e3403a73ae7 100644 --- a/web-admin/src/features/navigation/nav-utils.ts +++ b/web-admin/src/features/navigation/nav-utils.ts @@ -84,7 +84,8 @@ export function isPublicURLPage(page: Page): boolean { return ( page.route.id.startsWith("/[organization]/[project]/-/share/[token]") || isPublicReportPage(page) || - isPublicAlertPage(page) + isPublicAlertPage(page) || + isPublicAIPage(page) ); } @@ -103,6 +104,16 @@ export function isPublicAlertPage(page: Page): boolean { ); } +// AI reports in creator mode link recipients to the shared AI conversation with a magic token, +// so the conversation page is a public URL page when a token is present. +export function isPublicAIPage(page: Page): boolean { + return ( + !!page.route.id?.startsWith( + "/[organization]/[project]/-/ai/[conversationId]", + ) && page.url.searchParams.has("token") + ); +} + export function isEditPage({ route }: Pick): boolean { return !!route?.id?.startsWith("/[organization]/[project]/-/edit"); } diff --git a/web-admin/src/features/projects/header/ProjectHeader.svelte b/web-admin/src/features/projects/header/ProjectHeader.svelte index 1f35ee907455..0ade41186137 100644 --- a/web-admin/src/features/projects/header/ProjectHeader.svelte +++ b/web-admin/src/features/projects/header/ProjectHeader.svelte @@ -181,10 +181,12 @@ !!onPublicURLPage, }); - $: publicURLDashboardTitle = onCanvasDashboardPage - ? $canvasQuery.data?.canvas?.displayName || dashboard - : $exploreQuery.data?.explore?.explore?.state?.validSpec?.displayName || - dashboard; + // Public URL pages without a dashboard (e.g. a shared AI conversation) render no title. + $: publicURLDashboardTitle = + (onCanvasDashboardPage + ? $canvasQuery.data?.canvas?.displayName || dashboard + : $exploreQuery.data?.explore?.explore?.state?.validSpec?.displayName || + dashboard) ?? ""; $: currentPath = [organization, project, dashboard, report || alert]; diff --git a/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte b/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte index 1e5d4f7e318b..ccfd39f6c3a7 100644 --- a/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte +++ b/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte @@ -13,7 +13,10 @@ import { getMappedExploreUrl } from "@rilldata/web-common/features/explore-mappers/get-mapped-explore-url.ts"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors"; import ScheduledReportDialog from "@rilldata/web-common/features/scheduled-reports/ScheduledReportDialog.svelte"; - import { stripInternalReportParams } from "@rilldata/web-common/features/scheduled-reports/utils"; + import { + isAIReportSpec, + stripInternalReportParams, + } from "@rilldata/web-common/features/scheduled-reports/utils"; import { ResourceKind, useResource, @@ -54,6 +57,7 @@ $: reportSpec = $reportQuery.data?.resource?.report?.spec; $: isCanvasReport = !!reportSpec?.annotations?.canvas; + $: isAIReport = !!reportSpec && isAIReportSpec(reportSpec); // Get dashboard $: dashboardName = useReportDashboardName(runtimeClient, report); @@ -127,7 +131,15 @@ ).toString(); return search ? `${path}?${search}` : path; })(); - $: dashboardUrl = isCanvasReport ? canvasUrl : $exploreUrl; + // AI reports have no query to map into dashboard state, so they link to the explore they analyze as is. + $: aiExploreUrl = $dashboardName.data + ? `/${organization}/${project}/explore/${$dashboardName.data}` + : ""; + $: dashboardUrl = isCanvasReport + ? canvasUrl + : isAIReport + ? aiExploreUrl + : $exploreUrl; // Actions const queryClient = useQueryClient(); @@ -168,17 +180,19 @@ /> - - - {exportFormatToPrettyString(reportSpec.exportFormat)} - - - {#if reportSpec.exportFormat !== V1ExportFormat.EXPORT_FORMAT_PDF} + + {#if !isAIReport} - • {reportSpec.exportLimit === "0" - ? m.report_no_row_limit() - : m.report_row_limit({ count: reportSpec.exportLimit })} + {exportFormatToPrettyString(reportSpec.exportFormat)} + + {#if reportSpec.exportFormat !== V1ExportFormat.EXPORT_FORMAT_PDF} + + • {reportSpec.exportLimit === "0" + ? m.report_no_row_limit() + : m.report_row_limit({ count: reportSpec.exportLimit })} + + {/if} {/if}
diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 8cd82df4239f..cbd0224f18f0 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -36,6 +36,7 @@ isProjectPage, isPublicAlertPage, isPublicReportPage, + isPublicAIPage, isPublicURLPage, isProjectWelcomePage, } from "@rilldata/web-admin/features/navigation/nav-utils"; @@ -80,9 +81,13 @@ handleBranchNavigation(nav, activeBranch, organization, project, goto), ); - // Token: from route params, or from search params on report/alert pages + // Token: from route params, or from search params on report/alert/AI conversation pages let token = $derived.by(() => { - if (isPublicReportPage(page) || isPublicAlertPage(page)) { + if ( + isPublicReportPage(page) || + isPublicAlertPage(page) || + isPublicAIPage(page) + ) { return page.url.searchParams.get("token") ?? page.params.token; } return page.params.token; diff --git a/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte b/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte index 1ba08191e5ea..e430782eac44 100644 --- a/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte @@ -2,21 +2,71 @@ This layout wraps the chat page to provide proper height constraints -->
- + +
+
+ + diff --git a/web-common/src/features/chat/core/utils.spec.ts b/web-common/src/features/chat/core/utils.spec.ts new file mode 100644 index 000000000000..e9251a35e588 --- /dev/null +++ b/web-common/src/features/chat/core/utils.spec.ts @@ -0,0 +1,60 @@ +import { extractMessageText } from "@rilldata/web-common/features/chat/core/utils"; +import { + MessageContentType, + ToolName, +} from "@rilldata/web-common/features/chat/core/types"; +import type { V1Message } from "@rilldata/web-common/runtime-client"; +import { describe, expect, it } from "vitest"; + +function routerCall(content: object): V1Message { + return { + tool: ToolName.ROUTER_AGENT, + contentType: MessageContentType.JSON, + contentData: JSON.stringify(content), + }; +} + +describe("extractMessageText", () => { + it("returns the user prompt for router calls", () => { + expect( + extractMessageText(routerCall({ prompt: "Why did revenue drop?" })), + ).toEqual("Why did revenue drop?"); + }); + + it("describes a report's opening call by its explore and time range", () => { + const text = extractMessageText( + routerCall({ + prompt: "", + agent: "analyst_agent", + analyst_agent_args: { + prompt: "", + explore: "requests", + time_start: "2026-09-09T00:00:00Z", + time_end: "2026-09-10T00:00:00Z", + comparison_time_start: "2026-09-08T00:00:00Z", + comparison_time_end: "2026-09-09T00:00:00Z", + is_report: true, + }, + }), + ); + expect(text).toContain("AI report for requests"); + expect(text).toContain("2026"); + expect(text).toContain("compared with"); + expect(text).not.toContain("analyst_agent_args"); + }); + + it("describes a report without an explore or time range", () => { + expect( + extractMessageText( + routerCall({ prompt: "", analyst_agent_args: { is_report: true } }), + ), + ).toEqual("AI report"); + }); + + it("falls back to the raw content for non-report calls without a prompt", () => { + const content = { prompt: "", analyst_agent_args: { explore: "requests" } }; + expect(extractMessageText(routerCall(content))).toEqual( + JSON.stringify(content), + ); + }); +}); diff --git a/web-common/src/features/chat/core/utils.ts b/web-common/src/features/chat/core/utils.ts index cf299b8cab9a..ab27cefbd4bb 100644 --- a/web-common/src/features/chat/core/utils.ts +++ b/web-common/src/features/chat/core/utils.ts @@ -12,6 +12,9 @@ import { type V1Message, } from "@rilldata/web-common/runtime-client"; import { MessageContentType, ToolName } from "./types"; +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { prettyFormatTimeRange } from "@rilldata/web-common/lib/time/ranges/formatter"; +import { DateTime, Interval } from "luxon"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { derived } from "svelte/store"; import { createQuery } from "@tanstack/svelte-query"; @@ -47,7 +50,12 @@ export function extractMessageText(message: V1Message): string { if (message.tool === ToolName.ROUTER_AGENT) { try { const parsed = JSON.parse(rawContent); - return parsed.prompt || parsed.response || rawContent; + return ( + parsed.prompt || + parsed.response || + describeReportPrompt(parsed) || + rawContent + ); } catch { return rawContent; } @@ -67,6 +75,64 @@ export function extractMessageText(message: V1Message): string { } } +/** + * Scheduled AI reports start a conversation without a user prompt; the router call only carries the analyst agent's arguments. + * Describe the report's scope (explore and time range) instead of showing the raw arguments. + * Returns undefined if the message is not a report's opening call. + */ +function describeReportPrompt(routerArgs: { + analyst_agent_args?: { + is_report?: boolean; + explore?: string; + time_start?: string; + time_end?: string; + comparison_time_start?: string; + comparison_time_end?: string; + }; +}): string | undefined { + const args = routerArgs.analyst_agent_args; + if (!args?.is_report) return undefined; + + const explore = args.explore ?? ""; + const timeRange = + args.time_start && args.time_end + ? formatReportTimeRange(args.time_start, args.time_end) + : ""; + + let prompt: string; + if (explore && timeRange) { + prompt = m.chat_report_prompt_explore_time_range({ explore, timeRange }); + } else if (explore) { + prompt = m.chat_report_prompt_explore({ explore }); + } else if (timeRange) { + prompt = m.chat_report_prompt_time_range({ timeRange }); + } else { + prompt = m.chat_report_prompt(); + } + + if (args.comparison_time_start && args.comparison_time_end) { + prompt = m.chat_report_prompt_comparison({ + prompt, + comparisonTimeRange: formatReportTimeRange( + args.comparison_time_start, + args.comparison_time_end, + ), + }); + } + return prompt; +} + +// Report time ranges are resolved in the report's time zone (UTC by default) and are aligned to day boundaries there. +// Format them in UTC so the boundaries stay on whole days instead of picking up the viewer's offset. +function formatReportTimeRange(start: string, end: string): string { + return prettyFormatTimeRange( + Interval.fromDateTimes( + DateTime.fromISO(start, { zone: "utc" }), + DateTime.fromISO(end, { zone: "utc" }), + ), + ); +} + export function invalidateConversationsList(instanceId: string) { const listConversationsKey = getRuntimeServiceListConversationsQueryKey( instanceId, diff --git a/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte b/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte index e3c658d6e748..33190a6b3e7d 100644 --- a/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte +++ b/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte @@ -106,6 +106,7 @@ class:active={conversation.id === currentConversationDto?.id} data-testid="conversation-item" data-conversation-id={conversation.id} + title={conversation.title || m.chat_new_conversation()} onclick={handleConversationItemClick} >
diff --git a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte index 8277441c379f..cc8f11624b17 100644 --- a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte +++ b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte @@ -10,6 +10,7 @@ getConversationManager, } from "../../core/conversation-manager"; import ChatInput from "../../core/input/ChatInput.svelte"; + import ReadOnlyChatInput from "../../core/input/ReadOnlyChatInput.svelte"; import Messages from "../../core/messages/Messages.svelte"; import ConnectClientPopover from "../../connect/ConnectClientPopover.svelte"; import ShareChatPopover from "../../share/ShareChatPopover.svelte"; @@ -19,6 +20,12 @@ toggleConversationSidebar, } from "./fullpage-store"; + // Read-only mode is for visitors who can't send messages, e.g. anonymous recipients of an AI report opened with a magic token. + // It hides the conversation sidebar and the header actions, and replaces the input with a notice. + export let readOnly = false; + // Awaited before a shared conversation is forked (see Conversation.sendMessage). + export let beforeFork: (() => Promise | void) | undefined = undefined; + const { adminServer } = featureFlags; const runtimeClient = useRuntimeClient(); @@ -59,29 +66,31 @@
- { - chatInputComponent?.focusInput(); - }} - onNewConversationClick={() => { - chatInputComponent?.focusInput(); - }} - > - - - - - - - + {#if !readOnly} + { + chatInputComponent?.focusInput(); + }} + onNewConversationClick={() => { + chatInputComponent?.focusInput(); + }} + > + + + + + + + + {/if}
- {#if $adminServer} + {#if $adminServer && !readOnly}
{#if currentConversation?.id} @@ -105,12 +114,17 @@
- + {#if readOnly} + + {:else} + + {/if}
diff --git a/web-common/src/features/scheduled-reports/utils.spec.ts b/web-common/src/features/scheduled-reports/utils.spec.ts index bb3a1b4ab4c1..09a204c271f1 100644 --- a/web-common/src/features/scheduled-reports/utils.spec.ts +++ b/web-common/src/features/scheduled-reports/utils.spec.ts @@ -2,6 +2,7 @@ import { getDashboardNameFromReport, getExistingReportInitialFormValues, getNewCanvasReportInitialFormValues, + isAIReportSpec, isCanvasReportSpec, } from "@rilldata/web-common/features/scheduled-reports/utils"; import { @@ -33,6 +34,24 @@ describe("getDashboardNameFromReport", () => { expect(getDashboardNameFromReport(reportSpec)).toEqual("my_metrics_view"); }); + it("returns the explore from the resolver properties for AI reports", () => { + const reportSpec: V1ReportSpec = { + resolver: "ai", + resolverProperties: { prompt: "Analyze", explore: "my_explore" }, + annotations: {}, + }; + expect(getDashboardNameFromReport(reportSpec)).toEqual("my_explore"); + }); + + it("returns an empty name for AI reports without an explore", () => { + const reportSpec: V1ReportSpec = { + resolver: "ai", + resolverProperties: { prompt: "Analyze" }, + annotations: {}, + }; + expect(getDashboardNameFromReport(reportSpec)).toEqual(""); + }); + it("does not throw for query-less reports with empty queryArgsJson", () => { // Proto string fields default to "", which must not be passed to JSON.parse. const reportSpec: V1ReportSpec = { @@ -51,6 +70,14 @@ describe("isCanvasReportSpec", () => { }); }); +describe("isAIReportSpec", () => { + it("detects AI reports via the resolver", () => { + expect(isAIReportSpec({ resolver: "ai" })).toBe(true); + expect(isAIReportSpec({ resolver: "legacy_metrics" })).toBe(false); + expect(isAIReportSpec({})).toBe(false); + }); +}); + describe("getNewCanvasReportInitialFormValues", () => { it("defaults to PDF format with all PDF options enabled", () => { const values = getNewCanvasReportInitialFormValues("user@example.com"); diff --git a/web-common/src/features/scheduled-reports/utils.ts b/web-common/src/features/scheduled-reports/utils.ts index 82bac0643cdd..6972661a138a 100644 --- a/web-common/src/features/scheduled-reports/utils.ts +++ b/web-common/src/features/scheduled-reports/utils.ts @@ -137,11 +137,21 @@ export function isCanvasReportSpec(reportSpec: V1ReportSpec): boolean { return !!reportSpec.annotations?.canvas; } +// AI reports run the AI resolver and deliver a link to an AI conversation instead of a dashboard export. +export function isAIReportSpec(reportSpec: V1ReportSpec): boolean { + return reportSpec.resolver === "ai"; +} + export function getDashboardNameFromReport(reportSpec: V1ReportSpec): string { if (reportSpec.annotations?.canvas) return reportSpec.annotations.canvas; if (reportSpec.annotations?.explore) return reportSpec.annotations.explore; + // AI reports optionally scope the analysis to an explore via the resolver's `explore` property. + if (isAIReportSpec(reportSpec)) { + return (reportSpec.resolverProperties?.explore as string | undefined) ?? ""; + } + if (reportSpec.annotations?.web_open_path) return getExploreName(reportSpec.annotations.web_open_path); diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index 814248eab124..44dddceb7629 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -610,6 +610,12 @@ "chat_new_conversation": "New conversation", "chat_no_conversations": "No conversations yet.", "chat_placeholder_analyst": "Type a question, or press @ to insert a metric, dimension, or measure.", + "chat_read_only_notice": "Log in to continue this conversation. Only members of this project can send messages.", + "chat_report_prompt": "AI report", + "chat_report_prompt_comparison": "{prompt}, compared with {comparisonTimeRange}", + "chat_report_prompt_explore": "AI report for {explore}", + "chat_report_prompt_explore_time_range": "AI report for {explore} covering {timeRange}", + "chat_report_prompt_time_range": "AI report covering {timeRange}", "chat_send_message": "Send message", "chat_share_conversation": "Share conversation", "chat_share_copied": "Copied!", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index f924d2871eb5..fa9b4e4ae5e0 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -610,6 +610,12 @@ "chat_new_conversation": "Nueva conversación", "chat_no_conversations": "Aún no hay conversaciones.", "chat_placeholder_analyst": "Escribe una pregunta, o presiona @ para insertar una métrica, dimensión o medida.", + "chat_read_only_notice": "Inicia sesión para continuar esta conversación. Solo los miembros de este proyecto pueden enviar mensajes.", + "chat_report_prompt": "Informe de IA", + "chat_report_prompt_comparison": "{prompt}, comparado con {comparisonTimeRange}", + "chat_report_prompt_explore": "Informe de IA para {explore}", + "chat_report_prompt_explore_time_range": "Informe de IA para {explore} que cubre {timeRange}", + "chat_report_prompt_time_range": "Informe de IA que cubre {timeRange}", "chat_send_message": "Enviar mensaje", "chat_share_conversation": "Compartir conversación", "chat_share_copied": "¡Copiado!", From defa6dc25dcf03bc33a5e9bcbb90045be0e6d524 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Fri, 11 Sep 2026 17:08:59 +0530 Subject: [PATCH 6/7] feat: apply an AI report's scope to its dashboard link on the report page The report detail page linked AI reports to the bare explore. Map the `ai` resolver props (dimensions, measures, time ranges, time zone, filter) onto a metrics resolver query and reuse `mapMetricsResolverQueryToDashboard`, so the link opens the explore in the state the report analyzed. Reports that only name an explore still get the plain explore URL. Co-Authored-By: Claude Fable 5.1 --- .../metadata/ReportMetadata.svelte | 18 ++-- .../explore-mappers/get-mapped-explore-url.ts | 86 ++++++++++++++++++- ...er-props-to-metrics-resolver-query.spec.ts | 55 ++++++++++++ ...esolver-props-to-metrics-resolver-query.ts | 44 ++++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.spec.ts create mode 100644 web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.ts diff --git a/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte b/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte index ccfd39f6c3a7..3d1bbc8201b1 100644 --- a/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte +++ b/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte @@ -10,7 +10,11 @@ import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte"; import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte"; import { hasValidMetricsViewTimeRange } from "@rilldata/web-common/features/dashboards/selectors.ts"; - import { getMappedExploreUrl } from "@rilldata/web-common/features/explore-mappers/get-mapped-explore-url.ts"; + import { + getMappedAIExploreUrl, + getMappedExploreUrl, + } from "@rilldata/web-common/features/explore-mappers/get-mapped-explore-url.ts"; + import type { AIResolverProps } from "@rilldata/web-common/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.ts"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors"; import ScheduledReportDialog from "@rilldata/web-common/features/scheduled-reports/ScheduledReportDialog.svelte"; import { @@ -131,14 +135,16 @@ ).toString(); return search ? `${path}?${search}` : path; })(); - // AI reports have no query to map into dashboard state, so they link to the explore they analyze as is. - $: aiExploreUrl = $dashboardName.data - ? `/${organization}/${project}/explore/${$dashboardName.data}` - : ""; + // AI reports link to the explore they analyze, with the report's scope (dimensions, measures, time ranges, filter) applied. + $: aiExploreUrl = getMappedAIExploreUrl( + (reportSpec?.resolverProperties ?? {}) as AIResolverProps, + isAIReport ? ($dashboardName.data ?? "") : "", + { client: runtimeClient, organization, project }, + ); $: dashboardUrl = isCanvasReport ? canvasUrl : isAIReport - ? aiExploreUrl + ? $aiExploreUrl : $exploreUrl; // Actions diff --git a/web-common/src/features/explore-mappers/get-mapped-explore-url.ts b/web-common/src/features/explore-mappers/get-mapped-explore-url.ts index 183ee3e3c2fe..ad1c4689a2ab 100644 --- a/web-common/src/features/explore-mappers/get-mapped-explore-url.ts +++ b/web-common/src/features/explore-mappers/get-mapped-explore-url.ts @@ -2,6 +2,11 @@ import { page } from "$app/stores"; import { useMetricsViewTimeRange } from "@rilldata/web-common/features/dashboards/selectors.ts"; import { getTimeControlState } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store.ts"; import { convertPartialExploreStateToUrlParams } from "@rilldata/web-common/features/dashboards/url-state/convert-partial-explore-state-to-url-params.ts"; +import { + type AIResolverProps, + mapAIResolverPropsToMetricsResolverQuery, +} from "@rilldata/web-common/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.ts"; +import { mapMetricsResolverQueryToDashboard } from "@rilldata/web-common/features/explore-mappers/map-metrics-resolver-query-to-dashboard.ts"; import { type MapQueryRequest, type MapQueryStateOptions, @@ -10,7 +15,8 @@ import { import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors.ts"; import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; -import { derived, readable } from "svelte/store"; +import { derived, readable, type Readable } from "svelte/store"; +import type { V1MetricsViewTimeRangeResponse } from "@rilldata/web-common/runtime-client"; export type MapExploreUrlContext = { client: RuntimeClient; @@ -75,3 +81,81 @@ export function getMappedExploreUrl( }, ); } + +/** + * Returns a store of the explore URL for an AI report, with the report's scope (dimensions, measures, time ranges and filter) + * applied as explore state. Reports that only name an explore get the plain explore URL. + */ +export function getMappedAIExploreUrl( + props: AIResolverProps, + exploreName: string, + { client, organization, project, token }: MapExploreUrlContext, +) { + if (!exploreName) return readable(""); + + const validSpec = useExploreValidSpec( + client, + exploreName, + undefined, + queryClient, + ); + // The metrics view is only known once the explore's spec has loaded. + const timeRangeSummary: Readable = + derived(validSpec, (validSpecResp, set) => { + const metricsViewName = validSpecResp.data?.explore?.metricsView; + if (!metricsViewName) { + set(undefined); + return; + } + return useMetricsViewTimeRange( + client, + metricsViewName, + undefined, + queryClient, + ).subscribe((resp) => set(resp.data)); + }); + + return derived( + [validSpec, timeRangeSummary, page], + ([validSpecResp, timeRangeSummaryResp, pageState]) => { + const url = new URL(pageState.url); + if (token) { + url.pathname = `/${organization}/${project}/-/share/${token}/explore/${exploreName}`; + } else { + url.pathname = `/${organization}/${project}/explore/${exploreName}`; + } + url.search = ""; + + const metricsViewSpec = validSpecResp.data?.metricsView; + const exploreSpec = validSpecResp.data?.explore; + if (!metricsViewSpec || !exploreSpec?.metricsView) { + return url.toString(); + } + + const partialExploreState = mapMetricsResolverQueryToDashboard( + metricsViewSpec, + exploreSpec, + { + query: mapAIResolverPropsToMetricsResolverQuery( + props, + exploreSpec.metricsView, + ), + }, + ); + const searchParams = convertPartialExploreStateToUrlParams( + exploreSpec, + metricsViewSpec, + partialExploreState, + getTimeControlState( + metricsViewSpec, + exploreSpec, + timeRangeSummaryResp?.timeRangeSummary, + partialExploreState, + ), + ); + url.search = searchParams.toString(); + + return url.toString(); + }, + ); +} diff --git a/web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.spec.ts b/web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.spec.ts new file mode 100644 index 000000000000..8cf8ee382537 --- /dev/null +++ b/web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.spec.ts @@ -0,0 +1,55 @@ +import { mapAIResolverPropsToMetricsResolverQuery } from "@rilldata/web-common/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.ts"; +import type { Expression } from "@rilldata/web-common/runtime-client/gen/resolvers/metrics/schema.ts"; +import { describe, expect, it } from "vitest"; + +describe("mapAIResolverPropsToMetricsResolverQuery", () => { + it("maps the full scope of an AI report", () => { + const where: Expression = { + // The generated type declares `val` as an object, but scalar values are what the resolver accepts. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cond: { op: "eq", exprs: [{ name: "country" }, { val: "US" as any }] }, + }; + expect( + mapAIResolverPropsToMetricsResolverQuery( + { + explore: "sales", + dimensions: ["country", "product"], + measures: ["revenue"], + time_range: { expression: "7D as of latest/D+1D" }, + comparison_time_range: { + expression: "7D as of latest/D+1D offset -7D", + }, + time_zone: "America/New_York", + where, + }, + "sales_metrics", + ), + ).toEqual({ + metrics_view: "sales_metrics", + dimensions: [{ name: "country" }, { name: "product" }], + measures: [{ name: "revenue" }], + time_range: { expression: "7D as of latest/D+1D" }, + comparison_time_range: { expression: "7D as of latest/D+1D offset -7D" }, + time_zone: "America/New_York", + where, + }); + }); + + it("only sets the metrics view when the report has no scope", () => { + expect( + mapAIResolverPropsToMetricsResolverQuery( + { explore: "sales" }, + "sales_metrics", + ), + ).toEqual({ metrics_view: "sales_metrics" }); + }); + + it("ignores empty dimension and measure lists", () => { + expect( + mapAIResolverPropsToMetricsResolverQuery( + { dimensions: [], measures: [] }, + "sales_metrics", + ), + ).toEqual({ metrics_view: "sales_metrics" }); + }); +}); diff --git a/web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.ts b/web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.ts new file mode 100644 index 000000000000..9d4759cbb458 --- /dev/null +++ b/web-common/src/features/explore-mappers/map-ai-resolver-props-to-metrics-resolver-query.ts @@ -0,0 +1,44 @@ +import type { + Expression, + Schema as MetricsResolverQuery, + TimeRange, +} from "@rilldata/web-common/runtime-client/gen/resolvers/metrics/schema.ts"; + +/** + * Properties of the `ai` resolver that scope a report's analysis (see `runtime/resolvers/ai.go`). + * All of them are optional. + */ +export type AIResolverProps = { + explore?: string; + dimensions?: string[]; + measures?: string[]; + time_range?: TimeRange; + comparison_time_range?: TimeRange; + time_zone?: string; + where?: Expression; +}; + +/** + * Maps the scope of an AI report onto a metrics resolver query, so that {@link mapMetricsResolverQueryToDashboard} + * can turn it into explore state. The `ai` resolver shares the metrics resolver's time range and filter formats; + * it only names dimensions and measures as plain strings and points at an explore instead of a metrics view. + */ +export function mapAIResolverPropsToMetricsResolverQuery( + props: AIResolverProps, + metricsViewName: string, +): MetricsResolverQuery { + const query: MetricsResolverQuery = { metrics_view: metricsViewName }; + if (props.dimensions?.length) { + query.dimensions = props.dimensions.map((name) => ({ name })); + } + if (props.measures?.length) { + query.measures = props.measures.map((name) => ({ name })); + } + if (props.time_range) query.time_range = props.time_range; + if (props.comparison_time_range) { + query.comparison_time_range = props.comparison_time_range; + } + if (props.time_zone) query.time_zone = props.time_zone; + if (props.where) query.where = props.where; + return query; +} From d3fb28e42b08e4026b633556e20153ccd2d13346 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Fri, 11 Sep 2026 18:09:25 +0530 Subject: [PATCH 7/7] review comments --- runtime/reconcilers/report.go | 8 ------ .../[project]/-/ai/+layout.svelte | 20 +++++++------- .../src/features/chat/core/utils.spec.ts | 26 +++++++++++++++++++ web-common/src/features/chat/core/utils.ts | 18 +++++++------ ...etrics-resolver-query-to-dashboard.spec.ts | 19 ++++++++++++++ ...map-metrics-resolver-query-to-dashboard.ts | 3 ++- 6 files changed, 67 insertions(+), 27 deletions(-) diff --git a/runtime/reconcilers/report.go b/runtime/reconcilers/report.go index a5b5e5c60979..07aed647c68b 100644 --- a/runtime/reconcilers/report.go +++ b/runtime/reconcilers/report.go @@ -10,7 +10,6 @@ import ( runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" "github.com/rilldata/rill/runtime" - "github.com/rilldata/rill/runtime/ai" "github.com/rilldata/rill/runtime/drivers" "github.com/rilldata/rill/runtime/pkg/duration" "github.com/rilldata/rill/runtime/pkg/email" @@ -787,13 +786,6 @@ func (r *ReportReconciler) triggerAIReport(ctx context.Context, self *runtimev1. Permissions: []runtime.Permission{runtime.ReadObjects, runtime.ReadMetrics, runtime.UseAI}, } - // Get resolver properties from spec and add is_report flag - props := rep.Spec.ResolverProperties.AsMap() - props["is_report"] = true - if props["agent"] == nil { - props["agent"] = ai.AnalystAgentName - } - // Execute AI resolver result, info, err := r.C.Runtime.Resolve(ctx, &runtime.ResolveOptions{ InstanceID: r.C.InstanceID, diff --git a/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte b/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte index e430782eac44..3b0a187a7236 100644 --- a/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte @@ -23,20 +23,23 @@ $: organization = $page.params.organization; $: project = $page.params.project; - // Anonymous visitors of a shared conversation (public URL page) are not logged in, - // so the cookie-authenticated project query would fail; it's only needed for the MCP dialog. $: onPublicURLPage = isPublicURLPage($page); + const user = createAdminServiceGetCurrentUser(); + $: loggedIn = !!$user.data?.user; + + // Cookie-authenticated project query. On a public URL page (a shared conversation opened with a magic token) it tells us + // whether the logged-in visitor has access to the project in their own right; anonymous visitors can't run it. $: projectQuery = createAdminServiceGetProject( organization, project, undefined, - { query: { enabled: !onPublicURLPage } }, + { query: { enabled: loggedIn } }, ); $: isPublic = $projectQuery.data?.project?.public ?? true; - // Anonymous visitors (e.g. recipients of an AI report opened with a magic token) get a read-only view of the conversation. - const user = createAdminServiceGetCurrentUser(); - $: loggedIn = !!$user.data?.user; + // Visitors who can't continue the conversation get a read-only view: anonymous visitors, and logged-in visitors of a shared + // conversation who don't have access to the project (continuing forks the conversation under their own credentials). + $: readOnly = !loggedIn || (onPublicURLPage && !$projectQuery.isSuccess); // A logged-in user opening a shared conversation with a magic token reads it through the token, // but the token only grants read access to the conversation. Continuing it forks the conversation, @@ -74,10 +77,7 @@
- +