diff --git a/docs/docs/reference/project-files/alerts.md b/docs/docs/reference/project-files/alerts.md index 5f95fe9e29ef..56f08f220b6b 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. 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 - **`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., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`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. 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. - - **`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..739b9a5cd84b 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. 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 - **`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., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`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. 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. - - **`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. 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 - **`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., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`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. 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. - - **`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..7cbf6b9643b9 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. 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 - **`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., '1D as of latest/D' when the time range is '1D as of latest/D+1D') - **`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. 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. - - **`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` @@ -250,15 +244,15 @@ 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" - context: - explore: my_explore + expression: "1D as of latest/D" # the day before it + explore: my_explore notify: email: recipients: 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 eaac919df70e..5d4843ec9be0 100644 --- a/runtime/parser/schema/project.schema.yaml +++ b/runtime/parser/schema/project.schema.yaml @@ -2874,15 +2874,15 @@ 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" - context: - explore: my_explore + expression: "1D as of latest/D" # the day before it + explore: my_explore notify: email: recipients: @@ -3764,71 +3764,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. 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 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., '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 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. 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: + 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+1D" # the last 7 days with data comparison_time_range: - iso_duration: P7D - iso_offset: P7D - context: - explore: sales_explore + expression: "7D as of latest/D+1D offset -7D" + explore: sales_explore explore_time_range_properties: oneOf: diff --git a/runtime/reconcilers/report.go b/runtime/reconcilers/report.go index 59e896685993..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" @@ -207,7 +206,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, }) @@ -787,18 +786,11 @@ 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, 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,11 +874,35 @@ 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) { 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..b23a88e8e18e 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,141 @@ 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 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") } 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..3d1bbc8201b1 100644 --- a/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte +++ b/web-admin/src/features/scheduled-reports/metadata/ReportMetadata.svelte @@ -10,10 +10,17 @@ 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 { 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 +61,7 @@ $: reportSpec = $reportQuery.data?.resource?.report?.spec; $: isCanvasReport = !!reportSpec?.annotations?.canvas; + $: isAIReport = !!reportSpec && isAIReportSpec(reportSpec); // Get dashboard $: dashboardName = useReportDashboardName(runtimeClient, report); @@ -127,7 +135,17 @@ ).toString(); return search ? `${path}?${search}` : path; })(); - $: dashboardUrl = isCanvasReport ? canvasUrl : $exploreUrl; + // 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 + : $exploreUrl; // Actions const queryClient = useQueryClient(); @@ -168,17 +186,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..3b0a187a7236 100644 --- a/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/ai/+layout.svelte @@ -2,21 +2,74 @@ 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..e25a6537a5a8 --- /dev/null +++ b/web-common/src/features/chat/core/utils.spec.ts @@ -0,0 +1,86 @@ +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 the report before its configured prompt", () => { + const text = extractMessageText( + routerCall({ + prompt: "Analyze key metrics", + analyst_agent_args: { explore: "requests", is_report: true }, + }), + ); + expect(text).toEqual("AI report for requests\n\nAnalyze key metrics"); + }); + + it("keeps day boundaries in the report's time zone", () => { + const text = extractMessageText( + routerCall({ + prompt: "", + analyst_agent_args: { + is_report: true, + time_start: "2026-09-09T00:00:00-04:00", + time_end: "2026-09-10T00:00:00-04:00", + }, + }), + ); + // Locale-independent: the days stay 9 and 10 and no time suffix is added. + expect(text).toMatch(/^AI report covering .*9.*10.*2026$/); + expect(text).not.toContain("("); + }); + + 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..2867912babe1 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 ( + describeReportPrompt(parsed) || + parsed.prompt || + parsed.response || + rawContent + ); } catch { return rawContent; } @@ -67,6 +75,66 @@ export function extractMessageText(message: V1Message): string { } } +/** + * The router call that opens a scheduled AI report carries the analyst agent's arguments and, optionally, the report's configured prompt. + * Describe the report's scope (explore and time range) instead of showing the raw arguments, followed by the prompt if there is one. + * Returns undefined if the message is not a report's opening call. + */ +function describeReportPrompt(routerArgs: { + prompt?: string; + 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 routerArgs.prompt ? `${prompt}\n\n${routerArgs.prompt}` : prompt; +} + +// Report time ranges are resolved in the report's time zone and serialized with that zone's offset, +// aligned to day boundaries there. Keep the offset when formatting 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, { setZone: true }), + DateTime.fromISO(end, { setZone: true }), + ), + ); +} + 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/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; +} diff --git a/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.spec.ts b/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.spec.ts index 871f6b84a80e..6564a6ff64ba 100644 --- a/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.spec.ts +++ b/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.spec.ts @@ -439,6 +439,25 @@ describe("mapResolverExpressionToV1Expression", () => { "Facebook", ]), }, + { + title: "falsy scalar values are kept", + expression: { + cond: { + op: "in", + exprs: [ + { name: AD_BIDS_PUBLISHER_DIMENSION }, + { val: 0 as any }, + { val: false as any }, + { val: "" as any }, + ], + }, + }, + expectedExpression: createInExpression(AD_BIDS_PUBLISHER_DIMENSION, [ + 0, + false, + "", + ]), + }, ]; for (const { title, expression, expectedExpression } of TestCases) { diff --git a/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.ts b/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.ts index 4f2dea14036b..efce145567eb 100644 --- a/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.ts +++ b/web-common/src/features/explore-mappers/map-metrics-resolver-query-to-dashboard.ts @@ -135,7 +135,8 @@ export function mapResolverExpressionToV1Expression( return { ident: expr.name }; } - if (expr.val) { + // Check presence rather than truthiness: 0, false and "" are valid filter values. + if (expr.val !== undefined) { return { val: expr.val }; } 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!",