diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 8a23eb25f083426d7d8baf0056e9d6a584b69d92..b91d0ce9947f0b99fef34c7edfd9187cea7084fc 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -1,8 +1,8 @@ # ┌─────────────────────────────────────────────────────────────────────────┐ # │ Stage "builder" – install deps with build tools, cache pip packages │ # └─────────────────────────────────────────────────────────────────────────┘ -#FROM python:3.13-slim AS builder -FROM registry.gitlab.syncad.com/peerverity/ratings/python:3.13-slim AS builder +FROM python:3.13-slim AS builder +#FROM registry.gitlab.syncad.com/peerverity/ratings/python:3.13-slim AS builder # Set working directory WORKDIR /app @@ -22,8 +22,8 @@ RUN pip install --no-cache-dir --user -r requirements.txt # ┌─────────────────────────────────────────────────────────────────────────┐ # │ Stage "runner" – minimal runtime image with only runtime deps │ # └─────────────────────────────────────────────────────────────────────────┘ -#FROM python:3.13-slim AS runner -FROM registry.gitlab.syncad.com/peerverity/ratings/python:3.13-slim AS runner +FROM python:3.13-slim AS runner +#FROM registry.gitlab.syncad.com/peerverity/ratings/python:3.13-slim AS runner # Set working directory WORKDIR /app diff --git a/docker/db/Dockerfile b/docker/db/Dockerfile index e4f488bdfce9316748d9b54ebab5511c097e2e4d..a81e19e10eafb915e8662cd76a626c642ce7e2a9 100644 --- a/docker/db/Dockerfile +++ b/docker/db/Dockerfile @@ -1,6 +1,6 @@ # just use the latest paradedb based on postgres 17 -# FROM paradedb/paradedb:v0.15.26-pg17 -FROM registry.gitlab.syncad.com/peerverity/ratings/paradedb:v0.15.26-pg17 +FROM paradedb/paradedb:v0.15.26-pg17 +# FROM registry.gitlab.syncad.com/peerverity/ratings/paradedb:v0.15.26-pg17 # paradedb is postgresql + pg_cron, pgvector, pg_ivm, pg_search (was pg_bm25) USER root diff --git a/docker/flyway/Dockerfile b/docker/flyway/Dockerfile index 761fb24663747ea84c10769c3836956e9afcd682..c4a798648917c86c10774812d484a2bc2509b217 100644 --- a/docker/flyway/Dockerfile +++ b/docker/flyway/Dockerfile @@ -1,6 +1,6 @@ # just use the latest flyway, at the time it's 11.10.0 -# FROM flyway/flyway:11.10.0-alpine -FROM registry.gitlab.syncad.com/peerverity/ratings/flyway:11.10.0-alpine +FROM flyway/flyway:11.10.0-alpine +# FROM registry.gitlab.syncad.com/peerverity/ratings/flyway:11.10.0-alpine RUN apk add --no-cache jq COPY docker/flyway/entrypoint.sh / ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile index 10e4577277d8977b0be23a3c5f10959e7ff10674..d7f170c32b2f5245fae6c9aa6276def0c505e448 100644 --- a/docker/ui/Dockerfile +++ b/docker/ui/Dockerfile @@ -1,8 +1,9 @@ # ┌─────────────────────────────────────────────────────────────────────────┐ # │ Stage "builder" – install deps and build the Next.js app │ # └─────────────────────────────────────────────────────────────────────────┘ -# Node 24 Alpine (mirrored to GitLab registry) -FROM registry.gitlab.syncad.com/peerverity/ratings/node:24-alpine AS builder +# Node 24 Alpine (public image; GitLab mirror commented out) +FROM node:24-alpine AS builder +# FROM registry.gitlab.syncad.com/peerverity/ratings/node:24-alpine AS builder WORKDIR /app # Copy NPM files (including .npmrc for @peerverity registry) @@ -22,7 +23,8 @@ RUN npm run build # ┌─────────────────────────────────────────────────────────────────────────┐ # │ Stage "runner" – final, tiny image with only prod deps + build output │ # └─────────────────────────────────────────────────────────────────────────┘ -FROM registry.gitlab.syncad.com/peerverity/ratings/node:24-alpine AS runner +FROM node:24-alpine AS runner +# FROM registry.gitlab.syncad.com/peerverity/ratings/node:24-alpine AS runner WORKDIR /app ENV NODE_ENV=production diff --git a/ratings-sql/R__007_rating_calculation_functions.sql b/ratings-sql/R__007_rating_calculation_functions.sql index b4652fb389e990bd1ef6a2e9fc561d7315bbe6b7..024d5956845da222b1dc818987a7bdfdd103753d 100644 --- a/ratings-sql/R__007_rating_calculation_functions.sql +++ b/ratings-sql/R__007_rating_calculation_functions.sql @@ -9,6 +9,7 @@ AS $$ DECLARE r RECORD; BEGIN + -- Run all enabled rating calculation procedures FOR r IN SELECT rcp.procedure_name, @@ -24,6 +25,22 @@ BEGIN RAISE WARNING 'Error executing procedure %: %', r.procedure_name, SQLERRM; END; END LOOP; + + -- After all ratings are recalculated, evaluate statistical events + -- This checks for threshold crossings and creates occurrences + BEGIN + PERFORM api_v1.evaluate_statistical_events(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Error evaluating statistical events: %', SQLERRM; + END; + + -- Also evaluate AI custom events + -- This runs user-defined SQL queries and creates occurrences for new matches + BEGIN + PERFORM api_v1.evaluate_ai_custom_events(); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Error evaluating AI custom events: %', SQLERRM; + END; END; $$ LANGUAGE plpgsql; diff --git a/ratings-sql/R__011_api_v1.sql b/ratings-sql/R__011_api_v1.sql index 0c923c58056d1600f15beac53e6300f927e17fca..9a7f00cf6b72db68f3928bbea6cd414b0c5252a4 100644 --- a/ratings-sql/R__011_api_v1.sql +++ b/ratings-sql/R__011_api_v1.sql @@ -4122,8 +4122,1132 @@ $$; COMMENT ON FUNCTION api_v1.is_following_document IS 'Check if current user is following a document'; +-- Add FK constraint for rating_algorithm_id (deferred from V073 since rating_calculation_procedures is created in repeatable migration) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'statistical_event_definitions_rating_algorithm_id_fkey' + AND table_name = 'statistical_event_definitions' + ) THEN + ALTER TABLE statistical_event_definitions + ADD CONSTRAINT statistical_event_definitions_rating_algorithm_id_fkey + FOREIGN KEY (rating_algorithm_id) REFERENCES rating_calculation_procedures(id); + END IF; +END $$; -- ============================================================================ -- Notifications System (REMOVED - superseded by All User Activity page) -- ============================================================================ -- All notification-related infrastructure has been removed. -- Use the All User Activity page (/pages/all-user-activity) instead. +-- ============================================================================ +-- Statistical Event Definitions - UPDATED FOR TAGS + +-- Drop old statistical event function signatures (they have different parameters now) +DROP FUNCTION IF EXISTS api_v1.create_statistical_event_definition(integer, text, text, text, integer, numeric, text, boolean); +DROP FUNCTION IF EXISTS api_v1.update_statistical_event_definition(integer, integer, text, text, numeric, text, boolean); +DROP FUNCTION IF EXISTS api_v1.get_statistical_event_definitions(integer, boolean); +DROP FUNCTION IF EXISTS api_v1.follow_statistical_event(integer, integer, integer); +DROP FUNCTION IF EXISTS api_v1.unfollow_statistical_event(integer, integer, integer); +DROP FUNCTION IF EXISTS api_v1.get_followed_statistical_events(integer, integer); +DROP FUNCTION IF EXISTS api_v1.get_statistical_event_definitions_for_proposition(integer, integer); +DROP FUNCTION IF EXISTS api_v1.get_statistical_event_definitions_for_tag(integer, integer); +DROP FUNCTION IF EXISTS api_v1.evaluate_statistical_events(); + +-- ============================================================================ + +CREATE OR REPLACE FUNCTION api_v1.create_statistical_event_definition( + p_user_id integer, + p_name text, + p_description text DEFAULT NULL, + p_entity_type text DEFAULT 'proposition', + p_metric_type text DEFAULT 'aggregate_rating', + p_rating_algorithm_id integer DEFAULT NULL, + p_threshold_value numeric DEFAULT 0.9, + p_crossing_direction text DEFAULT 'up_only', + p_is_published boolean DEFAULT FALSE, + p_time_window_minutes integer DEFAULT NULL +) +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_definition_id integer; +BEGIN + -- Validate entity_type + IF p_entity_type NOT IN ('proposition', 'tag') THEN + RAISE EXCEPTION 'Invalid entity_type: %. Must be proposition or tag', p_entity_type; + END IF; + + -- Validate metric_type based on entity_type + IF p_entity_type = 'proposition' THEN + IF p_metric_type NOT IN ('rating_count', 'aggregate_rating') THEN + RAISE EXCEPTION 'Invalid metric_type for proposition: %. Must be rating_count or aggregate_rating', p_metric_type; + END IF; + ELSIF p_entity_type = 'tag' THEN + IF p_metric_type NOT IN ('tag_usage_count', 'tag_usage_count_period') THEN + RAISE EXCEPTION 'Invalid metric_type for tag: %. Must be tag_usage_count or tag_usage_count_period', p_metric_type; + END IF; + END IF; + + -- Validate crossing_direction + IF p_crossing_direction NOT IN ('up_only', 'both') THEN + RAISE EXCEPTION 'Invalid crossing_direction: %. Must be up_only or both', p_crossing_direction; + END IF; + + -- Validate rating_algorithm_id for aggregate_rating metric + IF p_metric_type = 'aggregate_rating' AND p_rating_algorithm_id IS NULL THEN + RAISE EXCEPTION 'rating_algorithm_id is required for aggregate_rating metric'; + END IF; + + -- Validate time_window_minutes for period-based metrics + IF p_metric_type = 'tag_usage_count_period' AND (p_time_window_minutes IS NULL OR p_time_window_minutes <= 0) THEN + RAISE EXCEPTION 'time_window_minutes is required and must be positive for tag_usage_count_period metric'; + END IF; + + -- Validate algorithm exists + IF p_rating_algorithm_id IS NOT NULL THEN + IF NOT EXISTS (SELECT 1 FROM rating_calculation_procedures WHERE id = p_rating_algorithm_id) THEN + RAISE EXCEPTION 'Rating algorithm with ID % does not exist', p_rating_algorithm_id; + END IF; + END IF; + + INSERT INTO statistical_event_definitions ( + creator_id, + name, + description, + entity_type, + metric_type, + rating_algorithm_id, + threshold_value, + crossing_direction, + is_published, + time_window_minutes + ) + VALUES ( + p_user_id, + p_name, + p_description, + p_entity_type, + p_metric_type::statistical_metric_type, + p_rating_algorithm_id, + p_threshold_value, + p_crossing_direction::crossing_direction, + p_is_published, + p_time_window_minutes + ) + RETURNING id INTO v_definition_id; + + RETURN v_definition_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.create_statistical_event_definition IS 'Create a statistical event definition for propositions or tags'; + +CREATE OR REPLACE FUNCTION api_v1.update_statistical_event_definition( + p_user_id integer, + p_definition_id integer, + p_name text DEFAULT NULL, + p_description text DEFAULT NULL, + p_threshold_value numeric DEFAULT NULL, + p_crossing_direction text DEFAULT NULL, + p_is_published boolean DEFAULT NULL, + p_time_window_minutes integer DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + -- Check ownership + IF NOT EXISTS ( + SELECT 1 FROM statistical_event_definitions + WHERE id = p_definition_id AND creator_id = p_user_id + ) THEN + RAISE EXCEPTION 'Definition with ID % not found or not owned by user', p_definition_id; + END IF; + + -- Validate crossing_direction if provided + IF p_crossing_direction IS NOT NULL AND p_crossing_direction NOT IN ('up_only', 'both') THEN + RAISE EXCEPTION 'Invalid crossing_direction: %. Must be up_only or both', p_crossing_direction; + END IF; + + UPDATE statistical_event_definitions + SET + name = COALESCE(p_name, name), + description = COALESCE(p_description, description), + threshold_value = COALESCE(p_threshold_value, threshold_value), + crossing_direction = COALESCE(p_crossing_direction::crossing_direction, crossing_direction), + is_published = COALESCE(p_is_published, is_published), + time_window_minutes = COALESCE(p_time_window_minutes, time_window_minutes) + WHERE id = p_definition_id AND creator_id = p_user_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.update_statistical_event_definition IS 'Update a statistical event definition (owner only)'; + +CREATE OR REPLACE FUNCTION api_v1.get_statistical_event_definitions( + p_user_id integer, + p_include_published boolean DEFAULT TRUE, + p_entity_type text DEFAULT NULL +) +RETURNS TABLE ( + id integer, + creator_id integer, + creator_username text, + name text, + description text, + entity_type text, + metric_type text, + rating_algorithm_id integer, + rating_algorithm_name text, + threshold_value numeric, + crossing_direction text, + is_published boolean, + creation_time timestamp with time zone, + is_own boolean, + time_window_minutes integer +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + sed.id, + sed.creator_id, + u.username::text as creator_username, + sed.name, + sed.description, + sed.entity_type, + sed.metric_type::text, + sed.rating_algorithm_id, + ra.name::text as rating_algorithm_name, + sed.threshold_value, + sed.crossing_direction::text, + sed.is_published, + sed.creation_time AT TIME ZONE 'UTC' as creation_time, + (sed.creator_id = p_user_id) as is_own, + sed.time_window_minutes + FROM statistical_event_definitions sed + INNER JOIN users u ON sed.creator_id = u.id + LEFT JOIN rating_calculation_procedures ra ON sed.rating_algorithm_id = ra.id + WHERE (sed.creator_id = p_user_id OR (p_include_published AND sed.is_published = TRUE)) + AND (p_entity_type IS NULL OR sed.entity_type = p_entity_type) + ORDER BY + (sed.creator_id = p_user_id) DESC, + sed.creation_time DESC; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_statistical_event_definitions IS 'Get statistical event definitions (own + optionally published), optionally filtered by entity type'; + +-- ============================================================================ +-- Statistical Event Subscriptions - UPDATED FOR TAGS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION api_v1.follow_statistical_event( + p_user_id integer, + p_definition_id integer, + p_proposition_id integer DEFAULT NULL, + p_tag_id integer DEFAULT NULL +) +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_subscription_id integer; + v_definition RECORD; + v_current_value numeric; +BEGIN + -- Validate definition exists and user can access it (own or published) + SELECT entity_type, metric_type, rating_algorithm_id, time_window_minutes INTO v_definition + FROM statistical_event_definitions + WHERE id = p_definition_id + AND (creator_id = p_user_id OR is_published = TRUE); + + IF v_definition IS NULL THEN + RAISE EXCEPTION 'Definition with ID % not found or not accessible', p_definition_id; + END IF; + + -- Validate entity ID based on definition's entity_type + IF v_definition.entity_type = 'proposition' THEN + IF p_proposition_id IS NULL THEN + RAISE EXCEPTION 'proposition_id is required for proposition-based definitions'; + END IF; + IF NOT EXISTS (SELECT 1 FROM propositions WHERE id = p_proposition_id) THEN + RAISE EXCEPTION 'Proposition with ID % does not exist', p_proposition_id; + END IF; + ELSIF v_definition.entity_type = 'tag' THEN + IF p_tag_id IS NULL THEN + RAISE EXCEPTION 'tag_id is required for tag-based definitions'; + END IF; + IF NOT EXISTS (SELECT 1 FROM tags WHERE id = p_tag_id) THEN + RAISE EXCEPTION 'Tag with ID % does not exist', p_tag_id; + END IF; + END IF; + + -- Get current metric value to initialize tracking + IF v_definition.metric_type = 'rating_count' THEN + SELECT COUNT(*)::numeric INTO v_current_value + FROM ratings + WHERE proposition_id = p_proposition_id; + ELSIF v_definition.metric_type = 'aggregate_rating' THEN + SELECT lar.aggregate_rating INTO v_current_value + FROM latest_aggregate_ratings_by_creator lar + INNER JOIN rating_calculation_procedures rcp ON rcp.creator_id = lar.creator_id + WHERE lar.proposition_id = p_proposition_id + AND rcp.id = v_definition.rating_algorithm_id; + ELSIF v_definition.metric_type = 'tag_usage_count' THEN + SELECT ( + (SELECT COUNT(*) FROM proposition_tag_links WHERE tag_id = p_tag_id) + + (SELECT COUNT(*) FROM document_tag_links WHERE tag_id = p_tag_id) + )::numeric INTO v_current_value; + ELSIF v_definition.metric_type = 'tag_usage_count_period' THEN + SELECT ( + (SELECT COUNT(*) FROM proposition_tag_links + WHERE tag_id = p_tag_id + AND creation_time >= NOW() - (v_definition.time_window_minutes || ' minutes')::interval) + + (SELECT COUNT(*) FROM document_tag_links + WHERE tag_id = p_tag_id + AND creation_time >= NOW() - (v_definition.time_window_minutes || ' minutes')::interval) + )::numeric INTO v_current_value; + END IF; + + -- Check if already subscribed + SELECT id INTO v_subscription_id + FROM followed_statistical_events + WHERE creator_id = p_user_id + AND definition_id = p_definition_id + AND COALESCE(proposition_id, -1) = COALESCE(p_proposition_id, -1) + AND COALESCE(tag_id, -1) = COALESCE(p_tag_id, -1); + + IF v_subscription_id IS NOT NULL THEN + -- Reactivate if inactive, reset tracking to current value + UPDATE followed_statistical_events + SET is_active = TRUE, + last_evaluated_at = NOW(), + last_metric_value = v_current_value + WHERE id = v_subscription_id; + + RETURN v_subscription_id; + END IF; + + -- Create new subscription with current metric value + -- Initialize last_metric_value to 0 so next evaluate can detect if already above threshold + INSERT INTO followed_statistical_events ( + creator_id, + definition_id, + proposition_id, + tag_id, + is_active, + last_metric_value, + last_evaluated_at + ) + VALUES ( + p_user_id, + p_definition_id, + p_proposition_id, + p_tag_id, + TRUE, + 0, -- Start at 0 to detect if already above threshold + NOW() + ) + RETURNING id INTO v_subscription_id; + + RETURN v_subscription_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.follow_statistical_event IS 'Subscribe to a statistical event for a proposition or tag'; + +CREATE OR REPLACE FUNCTION api_v1.unfollow_statistical_event( + p_user_id integer, + p_definition_id integer, + p_proposition_id integer DEFAULT NULL, + p_tag_id integer DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM followed_statistical_events + WHERE creator_id = p_user_id + AND definition_id = p_definition_id + AND COALESCE(proposition_id, -1) = COALESCE(p_proposition_id, -1) + AND COALESCE(tag_id, -1) = COALESCE(p_tag_id, -1); +END; +$$; + +COMMENT ON FUNCTION api_v1.unfollow_statistical_event IS 'Unsubscribe from a statistical event for a proposition or tag'; + +CREATE OR REPLACE FUNCTION api_v1.get_followed_statistical_events( + p_user_id integer, + p_proposition_id integer DEFAULT NULL, + p_tag_id integer DEFAULT NULL +) +RETURNS TABLE ( + id integer, + definition_id integer, + definition_name text, + proposition_id integer, + proposition_body text, + tag_id integer, + tag_name text, + last_metric_value numeric, + last_evaluated_at timestamp with time zone, + is_active boolean +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + fse.id, + fse.definition_id, + sed.name::text as definition_name, + fse.proposition_id, + p.body::text as proposition_body, + fse.tag_id, + t.name::text as tag_name, + fse.last_metric_value, + fse.last_evaluated_at, + fse.is_active + FROM followed_statistical_events fse + INNER JOIN statistical_event_definitions sed ON fse.definition_id = sed.id + LEFT JOIN propositions p ON fse.proposition_id = p.id + LEFT JOIN tags t ON fse.tag_id = t.id + WHERE fse.creator_id = p_user_id + AND (p_proposition_id IS NULL OR fse.proposition_id = p_proposition_id) + AND (p_tag_id IS NULL OR fse.tag_id = p_tag_id) + ORDER BY fse.creation_time DESC; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_followed_statistical_events IS 'Get followed statistical events for a user, optionally filtered by proposition or tag'; + +-- Get definitions available for a specific proposition (for proposition follow dialog) +CREATE OR REPLACE FUNCTION api_v1.get_statistical_event_definitions_for_proposition( + p_user_id integer, + p_proposition_id integer +) +RETURNS TABLE ( + definition_id integer, + definition_name text, + metric_type text, + threshold_value numeric, + crossing_direction text, + time_window_minutes integer, + is_subscribed boolean +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + sed.id as definition_id, + sed.name as definition_name, + sed.metric_type::text, + sed.threshold_value, + sed.crossing_direction::text, + sed.time_window_minutes, + EXISTS ( + SELECT 1 FROM followed_statistical_events fse + WHERE fse.creator_id = p_user_id + AND fse.definition_id = sed.id + AND fse.proposition_id = p_proposition_id + AND fse.is_active = TRUE + ) as is_subscribed + FROM statistical_event_definitions sed + WHERE sed.entity_type = 'proposition' + AND (sed.creator_id = p_user_id OR sed.is_published = TRUE) + ORDER BY + (sed.creator_id = p_user_id) DESC, + sed.name; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_statistical_event_definitions_for_proposition IS 'Get available proposition statistical event definitions with subscription status'; + +-- Get definitions available for a specific tag (for tag follow dialog) +CREATE OR REPLACE FUNCTION api_v1.get_statistical_event_definitions_for_tag( + p_user_id integer, + p_tag_id integer +) +RETURNS TABLE ( + definition_id integer, + definition_name text, + metric_type text, + threshold_value numeric, + crossing_direction text, + time_window_minutes integer, + is_subscribed boolean +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + sed.id as definition_id, + sed.name as definition_name, + sed.metric_type::text, + sed.threshold_value, + sed.crossing_direction::text, + sed.time_window_minutes, + EXISTS ( + SELECT 1 FROM followed_statistical_events fse + WHERE fse.creator_id = p_user_id + AND fse.definition_id = sed.id + AND fse.tag_id = p_tag_id + AND fse.is_active = TRUE + ) as is_subscribed + FROM statistical_event_definitions sed + WHERE sed.entity_type = 'tag' + AND (sed.creator_id = p_user_id OR sed.is_published = TRUE) + ORDER BY + (sed.creator_id = p_user_id) DESC, + sed.name; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_statistical_event_definitions_for_tag IS 'Get available statistical event definitions with subscription status for a tag'; + +-- ============================================================================ +-- Statistical Event Evaluation - UPDATED FOR TAGS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION api_v1.evaluate_statistical_events() +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_subscription RECORD; + v_current_value numeric; + v_threshold numeric; + v_direction crossing_direction; + v_crossed_up boolean; + v_crossed_down boolean; + v_events_created integer := 0; +BEGIN + -- Process all active subscriptions + FOR v_subscription IN + SELECT + fse.id as subscription_id, + fse.proposition_id, + fse.tag_id, + fse.last_metric_value, + sed.entity_type, + sed.metric_type, + sed.rating_algorithm_id, + sed.threshold_value, + sed.crossing_direction, + sed.time_window_minutes, + fse.creator_id + FROM followed_statistical_events fse + INNER JOIN statistical_event_definitions sed ON fse.definition_id = sed.id + WHERE fse.is_active = TRUE + LOOP + -- Get current metric value based on entity type and metric type + v_current_value := NULL; + + IF v_subscription.entity_type = 'proposition' THEN + IF v_subscription.metric_type = 'rating_count' THEN + SELECT COUNT(*)::numeric INTO v_current_value + FROM ratings + WHERE proposition_id = v_subscription.proposition_id; + ELSIF v_subscription.metric_type = 'aggregate_rating' THEN + SELECT lar.aggregate_rating INTO v_current_value + FROM latest_aggregate_ratings_by_creator lar + INNER JOIN rating_calculation_procedures rcp ON rcp.creator_id = lar.creator_id + WHERE lar.proposition_id = v_subscription.proposition_id + AND rcp.id = v_subscription.rating_algorithm_id; + END IF; + ELSIF v_subscription.entity_type = 'tag' THEN + IF v_subscription.metric_type = 'tag_usage_count' THEN + SELECT ( + (SELECT COUNT(*) FROM proposition_tag_links WHERE tag_id = v_subscription.tag_id) + + (SELECT COUNT(*) FROM document_tag_links WHERE tag_id = v_subscription.tag_id) + )::numeric INTO v_current_value; + ELSIF v_subscription.metric_type = 'tag_usage_count_period' THEN + SELECT ( + (SELECT COUNT(*) FROM proposition_tag_links + WHERE tag_id = v_subscription.tag_id + AND creation_time >= NOW() - (v_subscription.time_window_minutes || ' minutes')::interval) + + (SELECT COUNT(*) FROM document_tag_links + WHERE tag_id = v_subscription.tag_id + AND creation_time >= NOW() - (v_subscription.time_window_minutes || ' minutes')::interval) + )::numeric INTO v_current_value; + END IF; + END IF; + + -- Skip if we couldn't get a current value + IF v_current_value IS NULL THEN + UPDATE followed_statistical_events + SET last_evaluated_at = NOW() + WHERE id = v_subscription.subscription_id; + CONTINUE; + END IF; + + v_threshold := v_subscription.threshold_value; + v_direction := v_subscription.crossing_direction; + + -- Check for threshold crossings (only if we have a previous value) + IF v_subscription.last_metric_value IS NOT NULL THEN + v_crossed_up := v_subscription.last_metric_value < v_threshold AND v_current_value >= v_threshold; + v_crossed_down := v_subscription.last_metric_value >= v_threshold AND v_current_value < v_threshold; + + -- Create occurrence if threshold was crossed + IF v_crossed_up THEN + INSERT INTO statistical_event_occurrences ( + creator_id, + followed_event_id, + triggered_at, + previous_value, + new_value, + crossed_direction + ) + VALUES ( + v_subscription.creator_id, + v_subscription.subscription_id, + NOW(), + v_subscription.last_metric_value, + v_current_value, + 'up' + ); + v_events_created := v_events_created + 1; + ELSIF v_crossed_down AND v_direction = 'both' THEN + INSERT INTO statistical_event_occurrences ( + creator_id, + followed_event_id, + triggered_at, + previous_value, + new_value, + crossed_direction + ) + VALUES ( + v_subscription.creator_id, + v_subscription.subscription_id, + NOW(), + v_subscription.last_metric_value, + v_current_value, + 'down' + ); + v_events_created := v_events_created + 1; + END IF; + END IF; + + -- Update tracking + UPDATE followed_statistical_events + SET last_metric_value = v_current_value, + last_evaluated_at = NOW() + WHERE id = v_subscription.subscription_id; + END LOOP; + + RETURN v_events_created; +END; +$$; + +COMMENT ON FUNCTION api_v1.evaluate_statistical_events IS 'Evaluate all statistical event subscriptions (proposition and tag) and create occurrences for threshold crossings. Called by cron job.'; + +-- ============================================================================ +-- AI CUSTOM EVENT FUNCTIONS +-- ============================================================================ + +-- Drop existing functions to allow recreation +DROP FUNCTION IF EXISTS api_v1.create_ai_custom_event_definition(integer, text, text, text, text, boolean); +DROP FUNCTION IF EXISTS api_v1.update_ai_custom_event_definition(integer, integer, text, text, text, text, boolean, boolean); +DROP FUNCTION IF EXISTS api_v1.delete_ai_custom_event_definition(integer, integer); +DROP FUNCTION IF EXISTS api_v1.get_ai_custom_event_definitions(integer, boolean); +DROP FUNCTION IF EXISTS api_v1.get_ai_custom_event_definition(integer, integer); +DROP FUNCTION IF EXISTS api_v1.subscribe_ai_custom_event(integer, integer); +DROP FUNCTION IF EXISTS api_v1.unsubscribe_ai_custom_event(integer, integer); +DROP FUNCTION IF EXISTS api_v1.get_ai_custom_event_subscriptions(integer); +DROP FUNCTION IF EXISTS api_v1.test_ai_custom_event_query(text); +DROP FUNCTION IF EXISTS api_v1.evaluate_ai_custom_events(); + +-- Create an AI custom event definition +CREATE OR REPLACE FUNCTION api_v1.create_ai_custom_event_definition( + p_user_id integer, + p_name text, + p_description text DEFAULT NULL, + p_sql_query text DEFAULT NULL, + p_crossing_direction text DEFAULT 'up_only', + p_is_published boolean DEFAULT FALSE +) +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_definition_id integer; +BEGIN + -- Validate crossing_direction + IF p_crossing_direction NOT IN ('up_only', 'both') THEN + RAISE EXCEPTION 'Invalid crossing_direction: %. Must be up_only or both', p_crossing_direction; + END IF; + + -- Validate SQL query is provided + IF p_sql_query IS NULL OR TRIM(p_sql_query) = '' THEN + RAISE EXCEPTION 'sql_query is required'; + END IF; + + -- Basic SQL validation - must be a SELECT statement + IF NOT (UPPER(TRIM(p_sql_query)) LIKE 'SELECT%') THEN + RAISE EXCEPTION 'sql_query must be a SELECT statement'; + END IF; + + INSERT INTO ai_custom_event_definitions ( + creator_id, + name, + description, + sql_query, + crossing_direction, + is_published + ) + VALUES ( + p_user_id, + p_name, + p_description, + p_sql_query, + p_crossing_direction::crossing_direction, + p_is_published + ) + RETURNING id INTO v_definition_id; + + RETURN v_definition_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.create_ai_custom_event_definition IS 'Create an AI custom event definition with SQL query'; + +-- Update an AI custom event definition +CREATE OR REPLACE FUNCTION api_v1.update_ai_custom_event_definition( + p_user_id integer, + p_definition_id integer, + p_name text DEFAULT NULL, + p_description text DEFAULT NULL, + p_sql_query text DEFAULT NULL, + p_crossing_direction text DEFAULT NULL, + p_is_published boolean DEFAULT NULL, + p_is_active boolean DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + -- Check ownership + IF NOT EXISTS ( + SELECT 1 FROM ai_custom_event_definitions + WHERE id = p_definition_id AND creator_id = p_user_id + ) THEN + RAISE EXCEPTION 'Definition with ID % not found or not owned by user', p_definition_id; + END IF; + + -- Validate crossing_direction if provided + IF p_crossing_direction IS NOT NULL AND p_crossing_direction NOT IN ('up_only', 'both') THEN + RAISE EXCEPTION 'Invalid crossing_direction: %. Must be up_only or both', p_crossing_direction; + END IF; + + -- Validate SQL query if provided + IF p_sql_query IS NOT NULL AND NOT (UPPER(TRIM(p_sql_query)) LIKE 'SELECT%') THEN + RAISE EXCEPTION 'sql_query must be a SELECT statement'; + END IF; + + UPDATE ai_custom_event_definitions + SET + name = COALESCE(p_name, name), + description = COALESCE(p_description, description), + sql_query = COALESCE(p_sql_query, sql_query), + crossing_direction = COALESCE(p_crossing_direction::crossing_direction, crossing_direction), + is_published = COALESCE(p_is_published, is_published), + is_active = COALESCE(p_is_active, is_active) + WHERE id = p_definition_id AND creator_id = p_user_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.update_ai_custom_event_definition IS 'Update an AI custom event definition (owner only)'; + +-- Delete an AI custom event definition +CREATE OR REPLACE FUNCTION api_v1.delete_ai_custom_event_definition( + p_user_id integer, + p_definition_id integer +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + -- Check ownership + IF NOT EXISTS ( + SELECT 1 FROM ai_custom_event_definitions + WHERE id = p_definition_id AND creator_id = p_user_id + ) THEN + RAISE EXCEPTION 'Definition with ID % not found or not owned by user', p_definition_id; + END IF; + + -- Delete (cascades to subscriptions and occurrences) + DELETE FROM ai_custom_event_definitions + WHERE id = p_definition_id AND creator_id = p_user_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.delete_ai_custom_event_definition IS 'Delete an AI custom event definition (owner only)'; + +-- Get AI custom event definitions (own + optionally published) +CREATE OR REPLACE FUNCTION api_v1.get_ai_custom_event_definitions( + p_user_id integer, + p_include_published boolean DEFAULT TRUE +) +RETURNS TABLE ( + id integer, + creator_id integer, + creator_username text, + name text, + description text, + sql_query text, + crossing_direction text, + is_published boolean, + is_active boolean, + creation_time timestamp with time zone, + is_own boolean, + subscription_id integer, + is_subscribed boolean +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + acd.id, + acd.creator_id, + u.username::text as creator_username, + acd.name, + acd.description, + acd.sql_query, + acd.crossing_direction::text, + acd.is_published, + acd.is_active, + acd.creation_time, + (acd.creator_id = p_user_id) as is_own, + acs.id as subscription_id, + (acs.id IS NOT NULL AND acs.is_active) as is_subscribed + FROM ai_custom_event_definitions acd + JOIN users u ON u.id = acd.creator_id + LEFT JOIN ai_custom_event_subscriptions acs + ON acs.definition_id = acd.id AND acs.creator_id = p_user_id + WHERE acd.creator_id = p_user_id + OR (p_include_published AND acd.is_published = TRUE AND acd.is_active = TRUE) + ORDER BY acd.creation_time DESC; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_ai_custom_event_definitions IS 'Get AI custom event definitions (own and optionally published)'; + +-- Get a single AI custom event definition by ID +CREATE OR REPLACE FUNCTION api_v1.get_ai_custom_event_definition( + p_user_id integer, + p_definition_id integer +) +RETURNS TABLE ( + id integer, + creator_id integer, + creator_username text, + name text, + description text, + sql_query text, + crossing_direction text, + is_published boolean, + is_active boolean, + creation_time timestamp with time zone, + is_own boolean +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + acd.id, + acd.creator_id, + u.username::text as creator_username, + acd.name, + acd.description, + acd.sql_query, + acd.crossing_direction::text, + acd.is_published, + acd.is_active, + acd.creation_time, + (acd.creator_id = p_user_id) as is_own + FROM ai_custom_event_definitions acd + JOIN users u ON u.id = acd.creator_id + WHERE acd.id = p_definition_id + AND (acd.creator_id = p_user_id OR acd.is_published = TRUE); +END; +$$; + +COMMENT ON FUNCTION api_v1.get_ai_custom_event_definition IS 'Get a single AI custom event definition by ID'; + +-- Subscribe to an AI custom event definition +CREATE OR REPLACE FUNCTION api_v1.subscribe_ai_custom_event( + p_user_id integer, + p_definition_id integer +) +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_subscription_id integer; +BEGIN + -- Check definition exists and is accessible + IF NOT EXISTS ( + SELECT 1 FROM ai_custom_event_definitions + WHERE id = p_definition_id + AND (creator_id = p_user_id OR is_published = TRUE) + AND is_active = TRUE + ) THEN + RAISE EXCEPTION 'Definition with ID % not found or not accessible', p_definition_id; + END IF; + + -- Insert or update subscription + INSERT INTO ai_custom_event_subscriptions ( + creator_id, + definition_id, + is_active + ) + VALUES ( + p_user_id, + p_definition_id, + TRUE + ) + ON CONFLICT (creator_id, definition_id) + DO UPDATE SET is_active = TRUE + RETURNING id INTO v_subscription_id; + + RETURN v_subscription_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.subscribe_ai_custom_event IS 'Subscribe to an AI custom event definition'; + +-- Unsubscribe from an AI custom event definition +CREATE OR REPLACE FUNCTION api_v1.unsubscribe_ai_custom_event( + p_user_id integer, + p_definition_id integer +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE ai_custom_event_subscriptions + SET is_active = FALSE + WHERE creator_id = p_user_id AND definition_id = p_definition_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.unsubscribe_ai_custom_event IS 'Unsubscribe from an AI custom event definition'; + +-- Get user's AI custom event subscriptions +CREATE OR REPLACE FUNCTION api_v1.get_ai_custom_event_subscriptions( + p_user_id integer +) +RETURNS TABLE ( + subscription_id integer, + definition_id integer, + definition_name text, + definition_description text, + is_active boolean, + last_evaluated_at timestamp with time zone, + evaluation_error text, + creator_username text +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + acs.id as subscription_id, + acs.definition_id, + acd.name as definition_name, + acd.description as definition_description, + acs.is_active, + acs.last_evaluated_at, + acs.evaluation_error, + u.username::text as creator_username + FROM ai_custom_event_subscriptions acs + JOIN ai_custom_event_definitions acd ON acd.id = acs.definition_id + JOIN users u ON u.id = acd.creator_id + WHERE acs.creator_id = p_user_id + ORDER BY acs.creation_time DESC; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_ai_custom_event_subscriptions IS 'Get user subscriptions to AI custom events'; + +-- Test an AI custom event query (validates and runs with LIMIT) +CREATE OR REPLACE FUNCTION api_v1.test_ai_custom_event_query( + p_sql_query text +) +RETURNS TABLE ( + entity_type text, + entity_id integer, + display_text text +) +LANGUAGE plpgsql +AS $$ +BEGIN + -- Basic validation + IF NOT (UPPER(TRIM(p_sql_query)) LIKE 'SELECT%') THEN + RAISE EXCEPTION 'Query must be a SELECT statement'; + END IF; + + -- Execute with LIMIT to prevent runaway queries + RETURN QUERY EXECUTE format( + 'SELECT + COALESCE(entity_type::text, ''unknown'') as entity_type, + COALESCE(entity_id::integer, 0) as entity_id, + COALESCE(display_text::text, '''') as display_text + FROM (%s) subquery + LIMIT 100', + p_sql_query + ); +EXCEPTION + WHEN OTHERS THEN + RAISE EXCEPTION 'Query execution failed: %', SQLERRM; +END; +$$; + +COMMENT ON FUNCTION api_v1.test_ai_custom_event_query IS 'Test an AI custom event query - validates and returns up to 100 rows'; + +-- Evaluate all AI custom event subscriptions +CREATE OR REPLACE FUNCTION api_v1.evaluate_ai_custom_events() +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_subscription RECORD; + v_current_result JSONB; + v_previous_result JSONB; + v_new_entities JSONB; + v_removed_entities JSONB; + v_entity RECORD; + v_events_created integer := 0; + v_direction text; +BEGIN + -- Iterate through all active subscriptions + FOR v_subscription IN + SELECT + acs.id as subscription_id, + acs.creator_id, + acs.last_result, + acd.sql_query, + acd.crossing_direction::text as crossing_direction + FROM ai_custom_event_subscriptions acs + JOIN ai_custom_event_definitions acd ON acd.id = acs.definition_id + WHERE acs.is_active = TRUE AND acd.is_active = TRUE + LOOP + BEGIN + -- Execute the custom query and collect results as JSONB array + EXECUTE format( + 'SELECT COALESCE(jsonb_agg(jsonb_build_object( + ''entity_type'', COALESCE(entity_type::text, ''unknown''), + ''entity_id'', COALESCE(entity_id::integer, 0), + ''display_text'', COALESCE(display_text::text, '''') + )), ''[]''::jsonb) + FROM (%s) subquery', + v_subscription.sql_query + ) INTO v_current_result; + + v_previous_result := COALESCE(v_subscription.last_result, '[]'::jsonb); + v_direction := v_subscription.crossing_direction; + + -- Find new entities (in current but not in previous) + SELECT jsonb_agg(curr) + INTO v_new_entities + FROM jsonb_array_elements(v_current_result) curr + WHERE NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(v_previous_result) prev + WHERE (prev->>'entity_type') = (curr->>'entity_type') + AND (prev->>'entity_id')::integer = (curr->>'entity_id')::integer + ); + + -- Create events for new entities (upward crossing) + IF v_new_entities IS NOT NULL THEN + FOR v_entity IN SELECT * FROM jsonb_array_elements(v_new_entities) + LOOP + INSERT INTO ai_custom_event_occurrences ( + creator_id, + subscription_id, + triggered_at, + entity_type, + entity_id, + display_text, + crossed_direction + ) + VALUES ( + v_subscription.creator_id, + v_subscription.subscription_id, + NOW(), + v_entity.value->>'entity_type', + (v_entity.value->>'entity_id')::integer, + v_entity.value->>'display_text', + 'up' + ); + v_events_created := v_events_created + 1; + END LOOP; + END IF; + + -- Find removed entities (in previous but not in current) - only if direction is 'both' + IF v_direction = 'both' THEN + SELECT jsonb_agg(prev) + INTO v_removed_entities + FROM jsonb_array_elements(v_previous_result) prev + WHERE NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(v_current_result) curr + WHERE (curr->>'entity_type') = (prev->>'entity_type') + AND (curr->>'entity_id')::integer = (prev->>'entity_id')::integer + ); + + -- Create events for removed entities (downward crossing) + IF v_removed_entities IS NOT NULL THEN + FOR v_entity IN SELECT * FROM jsonb_array_elements(v_removed_entities) + LOOP + INSERT INTO ai_custom_event_occurrences ( + creator_id, + subscription_id, + triggered_at, + entity_type, + entity_id, + display_text, + crossed_direction + ) + VALUES ( + v_subscription.creator_id, + v_subscription.subscription_id, + NOW(), + v_entity.value->>'entity_type', + (v_entity.value->>'entity_id')::integer, + v_entity.value->>'display_text' || ' (no longer matches)', + 'down' + ); + v_events_created := v_events_created + 1; + END LOOP; + END IF; + END IF; + + -- Update tracking + UPDATE ai_custom_event_subscriptions + SET last_result = v_current_result, + last_evaluated_at = NOW(), + evaluation_error = NULL + WHERE id = v_subscription.subscription_id; + + EXCEPTION + WHEN OTHERS THEN + -- Log error but continue with other subscriptions + UPDATE ai_custom_event_subscriptions + SET last_evaluated_at = NOW(), + evaluation_error = SQLERRM + WHERE id = v_subscription.subscription_id; + END; + END LOOP; + + RETURN v_events_created; +END; +$$; + +COMMENT ON FUNCTION api_v1.evaluate_ai_custom_events IS 'Evaluate all AI custom event subscriptions and create occurrences for new/removed matches. Called by cron job.'; diff --git a/ratings-sql/R__031_default_pages.sql b/ratings-sql/R__031_default_pages.sql index 00508539f1517cb9adaa552197e9e0331271576e..26cf91454f338190ea7c1f7eb3318fe0b835b951 100644 --- a/ratings-sql/R__031_default_pages.sql +++ b/ratings-sql/R__031_default_pages.sql @@ -696,40 +696,37 @@ Manage your subscriptions to propositions, tags, and documents. --- -## Recent Events +## Statistical Event Definitions -View recent activity from the items you''re following. +Create and manage definitions for statistical events like "rating reaches 90%" or "gets 100 ratings". +These can be applied to any proposition you follow. -### Proposition Events + - +--- -### Tag Events +## AI-Assisted Event Builder - +Use natural language to describe custom events you want to track. The AI will generate the detection query, +which you can test and save as a reusable definition. -### Document Events + + +--- + + + Recent Events + + + +View recent activity from the items you''re following. ', true, 0, false), @@ -1351,9 +1348,13 @@ Click the gear icons in the upper right to configure the entity type, associatio ', true, 0, false), ('Feed Generators', 'feed-generators', ' - - - +## Create New Feed Generator + + + +--- + +## Your Feed Generators = $1', 'timeframe', 'Timeframe', 0), + ('unified_followed_event', 'event_before', 'event_time <= $1', 'datetime', 'To', 0), ('proposition', 'body_contains', 'body ILIKE ''%'' || $1 || ''%''', 'text', 'Body', 0), ('proposition', 'include_system', '($1 OR (NOT $1 AND creator_id <> 0))', 'boolean', 'Include System Propositions', 0), ('proposition', 'system', '(($1 AND creator_id = 0) OR (NOT $1 AND creator_id <> 0))', 'boolean_optional', 'System', 0), diff --git a/ratings-sql/R__060_feed_generators_api.sql b/ratings-sql/R__060_feed_generators_api.sql index 14f461ceeb8278a0c69d561f78857d6f2c0b86f0..33ffd1d839fe1bb2beda5d083f4fab8f2269e3ca 100644 --- a/ratings-sql/R__060_feed_generators_api.sql +++ b/ratings-sql/R__060_feed_generators_api.sql @@ -18,6 +18,7 @@ SELECT fg.parameters_schema, fg.default_parameters, fg.mdx_template, + fg.conversation_history, fg.creation_time FROM public.feed_generators fg JOIN public.users u ON u.id = fg.creator_id @@ -59,6 +60,7 @@ RETURNS TABLE( parameters_schema JSONB, default_parameters JSONB, mdx_template TEXT, + conversation_history JSONB, creation_time TIMESTAMP ) LANGUAGE SQL STABLE @@ -73,6 +75,7 @@ AS $$ fg.parameters_schema, fg.default_parameters, fg.mdx_template, + fg.conversation_history, fg.creation_time FROM public.feed_generators fg JOIN public.users u ON u.id = fg.creator_id @@ -89,7 +92,8 @@ CREATE OR REPLACE FUNCTION api_v1.create_feed_generator( p_sql_query TEXT, p_parameters_schema JSONB DEFAULT '{}', p_default_parameters JSONB DEFAULT '{}', - p_mdx_template TEXT DEFAULT '' + p_mdx_template TEXT DEFAULT '', + p_conversation_history JSONB DEFAULT '[]' ) RETURNS TABLE(id INTEGER, success BOOLEAN, message TEXT) LANGUAGE plpgsql @@ -116,11 +120,11 @@ BEGIN -- Insert the generator INSERT INTO public.feed_generators ( creator_id, name, description, sql_query, - parameters_schema, default_parameters, mdx_template + parameters_schema, default_parameters, mdx_template, conversation_history ) VALUES ( p_creator_id, TRIM(p_name), p_description, p_sql_query, COALESCE(p_parameters_schema, '{}'), COALESCE(p_default_parameters, '{}'), - COALESCE(p_mdx_template, '') + COALESCE(p_mdx_template, ''), COALESCE(p_conversation_history, '[]') ) RETURNING feed_generators.id INTO v_id; @@ -144,7 +148,8 @@ CREATE OR REPLACE FUNCTION api_v1.update_feed_generator( p_sql_query TEXT DEFAULT NULL, p_parameters_schema JSONB DEFAULT NULL, p_default_parameters JSONB DEFAULT NULL, - p_mdx_template TEXT DEFAULT NULL + p_mdx_template TEXT DEFAULT NULL, + p_conversation_history JSONB DEFAULT NULL ) RETURNS TABLE(success BOOLEAN, message TEXT) LANGUAGE plpgsql @@ -181,7 +186,8 @@ BEGIN sql_query = COALESCE(p_sql_query, fg.sql_query), parameters_schema = COALESCE(p_parameters_schema, fg.parameters_schema), default_parameters = COALESCE(p_default_parameters, fg.default_parameters), - mdx_template = COALESCE(p_mdx_template, fg.mdx_template) + mdx_template = COALESCE(p_mdx_template, fg.mdx_template), + conversation_history = COALESCE(p_conversation_history, fg.conversation_history) WHERE fg.id = p_id; RETURN QUERY SELECT TRUE, 'Feed generator updated successfully'::TEXT; @@ -478,3 +484,296 @@ END; $$; COMMENT ON FUNCTION api_v1.execute_feed_generator IS 'Execute a feed generator''s query with optional time filtering, pagination, and parameter substitution'; + +-- ============================================================================= +-- RPC Function: Get User Feed Events (Dynamic execution of ALL subscribed generators) +-- ============================================================================= + +-- Function: Get feed events for a user by dynamically executing all subscribed generators +CREATE OR REPLACE FUNCTION api_v1.get_user_feed_events( + p_user_id INTEGER, + p_limit INTEGER DEFAULT 20, + p_offset INTEGER DEFAULT 0, + p_since_time TIMESTAMP DEFAULT NULL +) +RETURNS TABLE( + event_id TEXT, + event_time TIMESTAMP, + event_type VARCHAR, + generator_id INTEGER, + generator_name VARCHAR, + primary_entity_type VARCHAR, + primary_entity_id INTEGER, + secondary_entity_type VARCHAR, + secondary_entity_id INTEGER, + actor_user_id INTEGER, + actor_username VARCHAR, + event_data JSONB, + mdx_template TEXT +) +LANGUAGE plpgsql STABLE +AS $$ +DECLARE + v_generator RECORD; + v_union_query TEXT := ''; + v_full_query TEXT; + v_generator_count INTEGER := 0; +BEGIN + -- Build a UNION ALL query from all enabled generators for this user + FOR v_generator IN + SELECT fg.id, fg.name, fg.sql_query, fg.parameters_schema, fg.mdx_template, ufg.parameters + FROM public.user_feed_generators ufg + JOIN public.feed_generators fg ON fg.id = ufg.generator_id + WHERE ufg.user_id = p_user_id + AND ufg.enabled = true + LOOP + IF v_generator_count > 0 THEN + v_union_query := v_union_query || ' UNION ALL '; + END IF; + + -- Each generator's query is wrapped to add generator info and standardize output + -- We generate a deterministic event_id from event_type + primary_entity_id + generator_id + v_union_query := v_union_query || format(' + SELECT + (inner_q.event_type || ''_'' || COALESCE(inner_q.primary_entity_id::text, ''0'') || ''_'' || %s::text)::text AS event_id, + inner_q.event_time, + inner_q.event_type::VARCHAR, + %s AS generator_id, + %L::VARCHAR AS generator_name, + inner_q.primary_entity_type::VARCHAR, + inner_q.primary_entity_id, + inner_q.secondary_entity_type::VARCHAR, + inner_q.secondary_entity_id, + inner_q.actor_user_id, + COALESCE(u.username, ''unknown'')::VARCHAR AS actor_username, + inner_q.event_data, + %L AS mdx_template + FROM (%s) AS inner_q + LEFT JOIN public.users u ON u.id = inner_q.actor_user_id + WHERE ($1 IS NULL OR inner_q.event_time > $1) + ', v_generator.id, v_generator.id, v_generator.name, v_generator.mdx_template, + public.substitute_feed_parameters(v_generator.sql_query, v_generator.parameters_schema, COALESCE(v_generator.parameters, '{}'), p_user_id)); + + v_generator_count := v_generator_count + 1; + END LOOP; + + -- If no generators, return empty result + IF v_generator_count = 0 THEN + RETURN; + END IF; + + -- Wrap with ORDER BY and pagination + v_full_query := format(' + SELECT * FROM ( + %s + ) AS combined_events + ORDER BY event_time DESC + LIMIT $2 OFFSET $3 + ', v_union_query); + + RETURN QUERY EXECUTE v_full_query USING p_since_time, p_limit, p_offset; +EXCEPTION + WHEN OTHERS THEN + RAISE WARNING 'Error executing user feed generators for user %: %', p_user_id, SQLERRM; + RETURN; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_user_feed_events IS 'Get feed events for a user by dynamically executing all subscribed and enabled generators'; + +-- Test feed generator query function for validating SQL before saving +CREATE OR REPLACE FUNCTION api_v1.test_feed_generator_query( + p_sql_query TEXT, + p_limit INTEGER DEFAULT 10, + p_parameters_schema JSONB DEFAULT '{}', + p_default_parameters JSONB DEFAULT '{}' +) +RETURNS JSON +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_result JSON; + v_columns JSON; + v_row_count INTEGER; + v_test_query TEXT; + v_temp_table_name TEXT; + v_sql_query TEXT; +BEGIN + -- Substitute parameters using defaults (use user_id = 0 for testing) + v_sql_query := public.substitute_feed_parameters( + p_sql_query, + p_parameters_schema, + p_default_parameters, + 0 -- Use 0 as test user_id + ); + + -- Security: Only allow SELECT queries + IF NOT (TRIM(UPPER(v_sql_query)) LIKE 'SELECT%' OR TRIM(UPPER(v_sql_query)) LIKE 'WITH%') THEN + RETURN json_build_object( + 'success', false, + 'error', 'Only SELECT queries are allowed' + ); + END IF; + + -- Block dangerous keywords + IF v_sql_query ~* '\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|CREATE)\b' THEN + RETURN json_build_object( + 'success', false, + 'error', 'Query contains disallowed keywords' + ); + END IF; + + -- Generate unique temp table name + v_temp_table_name := 'temp_feed_test_' || floor(random() * 1000000)::text; + + BEGIN + -- Create temp table with query structure (LIMIT 0 for schema only) + -- Wrap in subquery to handle queries that already have LIMIT or FETCH FIRST + EXECUTE format('CREATE TEMP TABLE %I AS SELECT * FROM (%s) AS q LIMIT 0', v_temp_table_name, v_sql_query); + + -- Get column info from system catalog (works even with no data) + EXECUTE format(' + SELECT json_agg(json_build_object( + ''name'', attname, + ''type'', format_type(atttypid, atttypmod) + ) ORDER BY attnum) + FROM pg_attribute + WHERE attrelid = %L::regclass + AND attnum > 0 + AND NOT attisdropped + ', v_temp_table_name) INTO v_columns; + + -- Drop temp table + EXECUTE format('DROP TABLE %I', v_temp_table_name); + + -- Now execute the actual query with limit + v_test_query := format('SELECT * FROM (%s) AS test_query LIMIT %s', v_sql_query, p_limit); + + EXECUTE format(' + SELECT json_agg(row_to_json(t)) + FROM (%s) t + ', v_test_query) INTO v_result; + + v_row_count := COALESCE(json_array_length(v_result), 0); + + RETURN json_build_object( + 'success', true, + 'columns', COALESCE(v_columns, '[]'::JSON), + 'rows', COALESCE(v_result, '[]'::JSON), + 'row_count', v_row_count + ); + + EXCEPTION WHEN OTHERS THEN + -- Clean up temp table if it exists + BEGIN + EXECUTE format('DROP TABLE IF EXISTS %I', v_temp_table_name); + EXCEPTION WHEN OTHERS THEN + -- Ignore cleanup errors + END; + + RETURN json_build_object( + 'success', false, + 'error', SQLERRM + ); + END; +END; +$$; + +COMMENT ON FUNCTION api_v1.test_feed_generator_query IS +'Tests a feed generator SQL query for validity and returns sample results. +Substitutes parameters using provided schema and default values before testing. +Only allows SELECT/WITH queries; blocks data modification statements. +Column info is extracted even when query returns no rows.'; + +-- ============================================================================= +-- RPC Functions: User AI Prompt Customizations +-- ============================================================================= + +-- Function: Get user's custom AI prompt for a specific type +CREATE OR REPLACE FUNCTION api_v1.get_user_ai_prompt( + p_user_id INTEGER, + p_prompt_type VARCHAR DEFAULT 'feed_generator' +) +RETURNS JSON +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_result RECORD; +BEGIN + SELECT custom_prompt, last_modified + INTO v_result + FROM public.user_ai_prompt_customizations + WHERE user_id = p_user_id AND prompt_type = p_prompt_type; + + IF NOT FOUND THEN + RETURN json_build_object( + 'success', true, + 'custom_prompt', NULL, + 'last_modified', NULL, + 'message', 'No custom prompt found, using base prompt' + ); + END IF; + + RETURN json_build_object( + 'success', true, + 'custom_prompt', v_result.custom_prompt, + 'last_modified', v_result.last_modified + ); +END; +$$; + +COMMENT ON FUNCTION api_v1.get_user_ai_prompt IS +'Gets the user''s custom AI prompt for a specific prompt type. Returns NULL custom_prompt if none set (meaning use base prompt).'; + +-- Function: Save user's custom AI prompt +CREATE OR REPLACE FUNCTION api_v1.save_user_ai_prompt( + p_user_id INTEGER, + p_custom_prompt TEXT, + p_prompt_type VARCHAR DEFAULT 'feed_generator' +) +RETURNS JSON +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.user_ai_prompt_customizations (user_id, prompt_type, custom_prompt, last_modified) + VALUES (p_user_id, p_prompt_type, p_custom_prompt, NOW()) + ON CONFLICT (user_id, prompt_type) + DO UPDATE SET + custom_prompt = EXCLUDED.custom_prompt, + last_modified = NOW(); + + RETURN json_build_object( + 'success', true, + 'message', 'Custom prompt saved successfully' + ); +END; +$$; + +COMMENT ON FUNCTION api_v1.save_user_ai_prompt IS +'Saves or updates the user''s custom AI prompt for a specific prompt type.'; + +-- Function: Reset user's AI prompt to base (delete customization) +CREATE OR REPLACE FUNCTION api_v1.reset_user_ai_prompt( + p_user_id INTEGER, + p_prompt_type VARCHAR DEFAULT 'feed_generator' +) +RETURNS JSON +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + DELETE FROM public.user_ai_prompt_customizations + WHERE user_id = p_user_id AND prompt_type = p_prompt_type; + + RETURN json_build_object( + 'success', true, + 'message', 'Prompt reset to base successfully' + ); +END; +$$; + +COMMENT ON FUNCTION api_v1.reset_user_ai_prompt IS +'Resets the user''s AI prompt to the base prompt by deleting their customization.'; diff --git a/ratings-sql/R__999_postgrest.sql b/ratings-sql/R__999_postgrest.sql index 9af5c95319ca09bf649087a0e87c433e68f03672..0a46b69e3abfc1bdbae26e83b171f76effffdf6c 100644 --- a/ratings-sql/R__999_postgrest.sql +++ b/ratings-sql/R__999_postgrest.sql @@ -17,3 +17,25 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON api_v1.saved_visualizations TO web_anon; -- Grant execute on all functions GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA api_v1 TO web_anon; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO web_anon; + +-- Explicit grants for follow list functions (R__065) +GRANT EXECUTE ON FUNCTION api_v1.create_follow_list(INTEGER, VARCHAR, TEXT) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.update_follow_list(INTEGER, INTEGER, VARCHAR, TEXT) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.delete_follow_list(INTEGER, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.get_follow_lists(INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.get_follow_list(INTEGER, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.add_to_follow_list(INTEGER, INTEGER, VARCHAR, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.remove_from_follow_list(INTEGER, INTEGER, VARCHAR, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.get_follow_list_entries(INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.quick_follow(INTEGER, VARCHAR, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.quick_unfollow(INTEGER, VARCHAR, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.activate_follow_list(INTEGER, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.deactivate_follow_list(INTEGER, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.get_active_follow_lists(INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.is_entity_followed(INTEGER, VARCHAR, INTEGER) TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.get_all_followed_entities(INTEGER) TO web_anon; + +-- User AI prompt customization functions +GRANT EXECUTE ON FUNCTION api_v1.get_user_ai_prompt TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.save_user_ai_prompt TO web_anon; +GRANT EXECUTE ON FUNCTION api_v1.reset_user_ai_prompt TO web_anon; diff --git a/ratings-sql/V079__add_statistical_events.sql b/ratings-sql/V079__add_statistical_events.sql new file mode 100644 index 0000000000000000000000000000000000000000..832b2e03fa8a31fdd4d6ce8544c6b11c70179bf6 --- /dev/null +++ b/ratings-sql/V079__add_statistical_events.sql @@ -0,0 +1,85 @@ +-- Statistical Events Framework +-- Allows users to define and follow aggregate/statistical events on propositions +-- (e.g., "rating reaches 90%", "gets 100 votes") + +-- Crossing direction options +CREATE TYPE crossing_direction AS ENUM ( + 'up_only', -- Only trigger when crossing upward (below to above threshold) + 'both' -- Trigger on both upward and downward crossings +); + +-- Metric types that can be tracked +CREATE TYPE statistical_metric_type AS ENUM ( + 'rating_count', -- Number of ratings received + 'aggregate_rating' -- Rating value using specified algorithm +); + +-- Statistical event definitions (user-created templates) +-- Users create these to define what constitutes a "statistical event" +CREATE TABLE statistical_event_definitions ( + proposition_id INTEGER REFERENCES propositions(id), -- NULL for proposition-agnostic definitions + name TEXT NOT NULL, + description TEXT, + entity_type TEXT NOT NULL DEFAULT 'proposition', + metric_type statistical_metric_type NOT NULL, + rating_algorithm_id INTEGER, -- References rating_calculation_procedures(id), FK added in repeatable migration + threshold_value NUMERIC NOT NULL, + crossing_direction crossing_direction NOT NULL DEFAULT 'up_only', + is_published BOOLEAN DEFAULT FALSE -- If true, visible to all users +) INHERITS (user_created); + +-- Add primary key and indexes +ALTER TABLE statistical_event_definitions ADD PRIMARY KEY (id); +CREATE INDEX idx_statistical_event_definitions_creator ON statistical_event_definitions(creator_id); +CREATE INDEX idx_statistical_event_definitions_published ON statistical_event_definitions(is_published) WHERE is_published = TRUE; +CREATE INDEX idx_statistical_event_definitions_entity_type ON statistical_event_definitions(entity_type); + +-- Subscriptions: links user + definition + specific proposition +-- When a user wants to follow a statistical event on a specific proposition +CREATE TABLE followed_statistical_events ( + definition_id INTEGER NOT NULL REFERENCES statistical_event_definitions(id) ON DELETE CASCADE, + proposition_id INTEGER NOT NULL REFERENCES propositions(id) ON DELETE CASCADE, + last_evaluated_at TIMESTAMP WITH TIME ZONE, + last_metric_value NUMERIC, -- For detecting threshold crossings + is_active BOOLEAN DEFAULT TRUE +) INHERITS (user_created); + +-- Add primary key and indexes +ALTER TABLE followed_statistical_events ADD PRIMARY KEY (id); +CREATE INDEX idx_followed_statistical_events_creator ON followed_statistical_events(creator_id); +CREATE INDEX idx_followed_statistical_events_definition ON followed_statistical_events(definition_id); +CREATE INDEX idx_followed_statistical_events_proposition ON followed_statistical_events(proposition_id); +CREATE INDEX idx_followed_statistical_events_active ON followed_statistical_events(is_active) WHERE is_active = TRUE; +CREATE UNIQUE INDEX idx_followed_statistical_events_unique + ON followed_statistical_events(creator_id, definition_id, proposition_id); + +-- Occurrence log: records when statistical events are triggered +-- These are what show up in the Following feed +CREATE TABLE statistical_event_occurrences ( + followed_event_id INTEGER NOT NULL REFERENCES followed_statistical_events(id) ON DELETE CASCADE, + triggered_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + previous_value NUMERIC, + new_value NUMERIC, + crossed_direction TEXT NOT NULL -- 'up' or 'down' +) INHERITS (user_created); + +-- Add primary key and indexes +ALTER TABLE statistical_event_occurrences ADD PRIMARY KEY (id); +CREATE INDEX idx_statistical_event_occurrences_followed ON statistical_event_occurrences(followed_event_id); +CREATE INDEX idx_statistical_event_occurrences_triggered ON statistical_event_occurrences(triggered_at); + +-- Add comments for documentation +COMMENT ON TABLE statistical_event_definitions IS 'User-defined templates for statistical events (e.g., "rating reaches 90%")'; +COMMENT ON TABLE followed_statistical_events IS 'Subscriptions linking users to statistical event definitions for specific propositions'; +COMMENT ON TABLE statistical_event_occurrences IS 'Log of triggered statistical events - appears in Following feed'; + +COMMENT ON COLUMN statistical_event_definitions.metric_type IS 'What metric to track: rating_count or aggregate_rating'; +COMMENT ON COLUMN statistical_event_definitions.rating_algorithm_id IS 'Which rating algorithm to use (required for aggregate_rating metric)'; +COMMENT ON COLUMN statistical_event_definitions.threshold_value IS 'The threshold value (0-1 for ratings, integer for counts)'; +COMMENT ON COLUMN statistical_event_definitions.crossing_direction IS 'Whether to trigger on upward crossing only, or both directions'; +COMMENT ON COLUMN statistical_event_definitions.is_published IS 'If true, other users can see and use this definition'; + +COMMENT ON COLUMN followed_statistical_events.last_metric_value IS 'Tracks previous value to detect threshold crossings'; +COMMENT ON COLUMN followed_statistical_events.last_evaluated_at IS 'When this subscription was last evaluated by the batch job'; + +COMMENT ON COLUMN statistical_event_occurrences.crossed_direction IS 'Direction of threshold crossing: up or down'; diff --git a/ratings-sql/V081__fix_proposition_id_nullable.sql b/ratings-sql/V081__fix_proposition_id_nullable.sql new file mode 100644 index 0000000000000000000000000000000000000000..5bec06226d9481e64e75d053179e326aeee3dc3c --- /dev/null +++ b/ratings-sql/V081__fix_proposition_id_nullable.sql @@ -0,0 +1,8 @@ +-- Fix: Make proposition_id nullable in followed_statistical_events +-- This allows tag-based statistical events where proposition_id is NULL + +ALTER TABLE followed_statistical_events + ALTER COLUMN proposition_id DROP NOT NULL; + +COMMENT ON COLUMN followed_statistical_events.proposition_id IS + 'For proposition-based statistical events, the proposition being monitored. NULL for tag-based events.'; diff --git a/ratings-sql/V082__add_ai_custom_events.sql b/ratings-sql/V082__add_ai_custom_events.sql new file mode 100644 index 0000000000000000000000000000000000000000..f081af781ce11c93692e43f06fdad1c42c1a6c02 --- /dev/null +++ b/ratings-sql/V082__add_ai_custom_events.sql @@ -0,0 +1,73 @@ +-- AI Custom Events Framework +-- Allows users to define custom event detection queries using AI-assisted SQL generation +-- These queries are run periodically and events are triggered when new rows appear + +-- AI Custom Event Definitions +-- Stores the user-defined SQL query and metadata +CREATE TABLE ai_custom_event_definitions ( + name TEXT NOT NULL, + description TEXT, -- User's original prompt or description + sql_query TEXT NOT NULL, -- The AI-generated (or user-edited) SQL query + crossing_direction crossing_direction NOT NULL DEFAULT 'up_only', + is_published BOOLEAN DEFAULT FALSE, -- If true, visible to all users + is_active BOOLEAN DEFAULT TRUE -- Can be disabled without deletion +) INHERITS (user_created); + +-- Add primary key and indexes +ALTER TABLE ai_custom_event_definitions ADD PRIMARY KEY (id); +CREATE INDEX idx_ai_custom_event_definitions_creator ON ai_custom_event_definitions(creator_id); +CREATE INDEX idx_ai_custom_event_definitions_published ON ai_custom_event_definitions(is_published) WHERE is_published = TRUE; +CREATE INDEX idx_ai_custom_event_definitions_active ON ai_custom_event_definitions(is_active) WHERE is_active = TRUE; + +-- AI Custom Event Subscriptions +-- Links a user to a definition - activates the definition for periodic evaluation +-- Note: Unlike statistical events, these don't target a specific entity (the query handles that) +CREATE TABLE ai_custom_event_subscriptions ( + definition_id INTEGER NOT NULL REFERENCES ai_custom_event_definitions(id) ON DELETE CASCADE, + is_active BOOLEAN DEFAULT TRUE, + last_evaluated_at TIMESTAMP WITH TIME ZONE, + last_result JSONB, -- Previous query result for detecting new rows + evaluation_error TEXT -- Last error if evaluation failed +) INHERITS (user_created); + +-- Add primary key and indexes +ALTER TABLE ai_custom_event_subscriptions ADD PRIMARY KEY (id); +CREATE INDEX idx_ai_custom_event_subscriptions_creator ON ai_custom_event_subscriptions(creator_id); +CREATE INDEX idx_ai_custom_event_subscriptions_definition ON ai_custom_event_subscriptions(definition_id); +CREATE INDEX idx_ai_custom_event_subscriptions_active ON ai_custom_event_subscriptions(is_active) WHERE is_active = TRUE; +CREATE UNIQUE INDEX idx_ai_custom_event_subscriptions_unique + ON ai_custom_event_subscriptions(creator_id, definition_id); + +-- AI Custom Event Occurrences +-- Records when custom events are triggered - appears in the Following feed +CREATE TABLE ai_custom_event_occurrences ( + subscription_id INTEGER NOT NULL REFERENCES ai_custom_event_subscriptions(id) ON DELETE CASCADE, + triggered_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + entity_type TEXT, -- e.g., 'proposition', 'user', 'tag' + entity_id INTEGER, -- ID of the entity that triggered + display_text TEXT, -- Human-readable description from query + crossed_direction TEXT NOT NULL DEFAULT 'up' -- 'up' (new row appeared) or 'down' (row disappeared) +) INHERITS (user_created); + +-- Add primary key and indexes +ALTER TABLE ai_custom_event_occurrences ADD PRIMARY KEY (id); +CREATE INDEX idx_ai_custom_event_occurrences_subscription ON ai_custom_event_occurrences(subscription_id); +CREATE INDEX idx_ai_custom_event_occurrences_triggered ON ai_custom_event_occurrences(triggered_at); +CREATE INDEX idx_ai_custom_event_occurrences_creator ON ai_custom_event_occurrences(creator_id); +CREATE INDEX idx_ai_custom_event_occurrences_entity ON ai_custom_event_occurrences(entity_type, entity_id); + +-- Add comments for documentation +COMMENT ON TABLE ai_custom_event_definitions IS 'User-defined custom event queries created with AI assistance'; +COMMENT ON TABLE ai_custom_event_subscriptions IS 'Activates a custom event definition for a user - triggers periodic evaluation'; +COMMENT ON TABLE ai_custom_event_occurrences IS 'Log of triggered custom events - appears in Following feed'; + +COMMENT ON COLUMN ai_custom_event_definitions.sql_query IS 'SQL query that returns (entity_type, entity_id, display_text) for matching entities'; +COMMENT ON COLUMN ai_custom_event_definitions.description IS 'Original natural language description or prompt used to generate the query'; +COMMENT ON COLUMN ai_custom_event_definitions.crossing_direction IS 'up_only: only new rows; both: also track when rows disappear'; + +COMMENT ON COLUMN ai_custom_event_subscriptions.last_result IS 'JSONB array of {entity_type, entity_id} from last evaluation for change detection'; +COMMENT ON COLUMN ai_custom_event_subscriptions.evaluation_error IS 'Error message if last evaluation failed (for debugging)'; + +COMMENT ON COLUMN ai_custom_event_occurrences.entity_type IS 'Type of entity that triggered the event (proposition, user, tag, etc.)'; +COMMENT ON COLUMN ai_custom_event_occurrences.entity_id IS 'ID of the entity that triggered the event'; +COMMENT ON COLUMN ai_custom_event_occurrences.display_text IS 'Human-readable notification text from the query result'; diff --git a/ratings-sql/V083__add_feed_generator_conversation_history.sql b/ratings-sql/V083__add_feed_generator_conversation_history.sql new file mode 100644 index 0000000000000000000000000000000000000000..22cc576dcb277f7ec0ad3056a0526d40afa86d2d --- /dev/null +++ b/ratings-sql/V083__add_feed_generator_conversation_history.sql @@ -0,0 +1,8 @@ +-- V083: Add conversation_history column to feed_generators table +-- This stores the AI assistant conversation used to create/refine the generator + +ALTER TABLE public.feed_generators + ADD COLUMN conversation_history JSONB DEFAULT '[]'; + +COMMENT ON COLUMN public.feed_generators.conversation_history IS + 'JSON array of conversation messages used to create/refine this generator. Each message has role, content, and timestamp fields.'; diff --git a/ratings-sql/V079__feed_generators_allow_parameterized_duplicates.sql b/ratings-sql/V084__feed_generators_allow_parameterized_duplicates.sql similarity index 100% rename from ratings-sql/V079__feed_generators_allow_parameterized_duplicates.sql rename to ratings-sql/V084__feed_generators_allow_parameterized_duplicates.sql diff --git a/ratings-sql/V085__user_ai_prompt_customizations.sql b/ratings-sql/V085__user_ai_prompt_customizations.sql new file mode 100644 index 0000000000000000000000000000000000000000..659e5757f36026d70f5103591df04a51b6fa3553 --- /dev/null +++ b/ratings-sql/V085__user_ai_prompt_customizations.sql @@ -0,0 +1,26 @@ +-- V085: Add table for user-customizable AI prompts +-- Allows users to customize the system prompt for AI assistants (e.g., feed generator builder) +-- Each user can have their own customized prompt that includes lessons learned, examples, etc. + +CREATE TABLE IF NOT EXISTS public.user_ai_prompt_customizations ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + prompt_type VARCHAR(50) NOT NULL DEFAULT 'feed_generator', + custom_prompt TEXT, -- The user's customized system prompt (null = use base) + creation_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_modified TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, prompt_type) +); + +-- Index for quick lookup by user +CREATE INDEX IF NOT EXISTS idx_user_ai_prompt_customizations_user_id +ON public.user_ai_prompt_customizations(user_id); + +COMMENT ON TABLE public.user_ai_prompt_customizations IS +'Stores user-customized AI assistant prompts. Each user can have a personalized version of the system prompt for each prompt type (e.g., feed_generator).'; + +COMMENT ON COLUMN public.user_ai_prompt_customizations.prompt_type IS +'Type of prompt being customized. Currently supported: feed_generator'; + +COMMENT ON COLUMN public.user_ai_prompt_customizations.custom_prompt IS +'The full customized system prompt. If NULL, the base prompt is used.'; diff --git a/ratings-sql/V086__extend_statistical_events_for_tags.sql b/ratings-sql/V086__extend_statistical_events_for_tags.sql new file mode 100644 index 0000000000000000000000000000000000000000..200fe9d377286554f4d17c3ca164be7404981d9b --- /dev/null +++ b/ratings-sql/V086__extend_statistical_events_for_tags.sql @@ -0,0 +1,34 @@ +-- Extend Statistical Events Framework for Tags +-- Adds support for tag-based statistical events with time windows + +-- Add new metric types for tags +ALTER TYPE statistical_metric_type ADD VALUE IF NOT EXISTS 'tag_usage_count'; +ALTER TYPE statistical_metric_type ADD VALUE IF NOT EXISTS 'tag_usage_count_period'; + +-- Add time window column for time-based metrics +ALTER TABLE statistical_event_definitions + ADD COLUMN IF NOT EXISTS time_window_minutes INTEGER; + +COMMENT ON COLUMN statistical_event_definitions.time_window_minutes IS + 'For time-based metrics, the number of minutes to look back (e.g., 60 for last hour)'; + +-- Add tag_id column to followed_statistical_events +-- Now subscriptions can be for either a proposition OR a tag +ALTER TABLE followed_statistical_events + ADD COLUMN IF NOT EXISTS tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE; + +-- Make proposition_id nullable (was NOT NULL implicitly via REFERENCES) +-- Actually, proposition_id was already nullable in the original schema +-- Just add the index for tag_id +CREATE INDEX IF NOT EXISTS idx_followed_statistical_events_tag + ON followed_statistical_events(tag_id) WHERE tag_id IS NOT NULL; + +-- Add constraint to ensure exactly one of proposition_id or tag_id is set +-- based on entity_type in the definition +COMMENT ON COLUMN followed_statistical_events.tag_id IS + 'For tag-based statistical events, the tag being monitored'; + +-- Update unique constraint to handle both proposition and tag subscriptions +DROP INDEX IF EXISTS idx_followed_statistical_events_unique; +CREATE UNIQUE INDEX idx_followed_statistical_events_unique + ON followed_statistical_events(creator_id, definition_id, COALESCE(proposition_id, -1), COALESCE(tag_id, -1)); diff --git a/ratings-ui-demo/package-lock.json b/ratings-ui-demo/package-lock.json index 86b7a62d29bcb4710d8f7706c682e1132ee5f7be..0e53293124362acba10c0d2629abea6378c72647 100644 --- a/ratings-ui-demo/package-lock.json +++ b/ratings-ui-demo/package-lock.json @@ -13,6 +13,7 @@ "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-sql": "^6.10.0", "@codemirror/lint": "^6.9.2", + "@codemirror/state": "^6.5.3", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -101,7 +102,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -691,7 +691,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -777,11 +776,10 @@ } }, "node_modules/@codemirror/state": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.3.tgz", + "integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -803,7 +801,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -974,7 +971,6 @@ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -1009,7 +1005,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1053,7 +1048,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2285,7 +2279,6 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -2580,7 +2573,6 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -2768,7 +2760,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.6", @@ -2920,7 +2911,6 @@ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz", "integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.6", @@ -4535,7 +4525,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4546,7 +4535,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4664,7 +4652,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -5215,7 +5202,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5891,7 +5877,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6931,7 +6916,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -6942,8 +6926,7 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -7252,7 +7235,6 @@ "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -7592,7 +7574,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7778,7 +7759,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10201,6 +10181,7 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", + "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -10459,6 +10440,7 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", "license": "MIT", + "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -12038,7 +12020,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.0.7", "@swc/helpers": "0.5.15", @@ -12592,7 +12573,6 @@ "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-3.3.0.tgz", "integrity": "sha512-3PT9dW7IbIfN7JWGr4YxxFQnbN5MRaB36qIKF/eF0iC9l0/MuGSlMlgRgI7Uu8vYuGxX6AjLwsBBRYTPG7NFSA==", "license": "MIT", - "peer": true, "dependencies": { "@plotly/d3": "3.8.2", "@plotly/d3-sankey": "0.7.2", @@ -12881,7 +12861,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12900,7 +12879,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14496,7 +14474,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14814,7 +14791,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14887,7 +14863,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -15536,7 +15511,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "devOptional": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ratings-ui-demo/package.json b/ratings-ui-demo/package.json index e451dea6dc6a2c5b194654e8cc1fa0e41632fe54..5e6484816066c8f46395e011b6bd0049e2d11d84 100644 --- a/ratings-ui-demo/package.json +++ b/ratings-ui-demo/package.json @@ -17,6 +17,7 @@ "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-sql": "^6.10.0", "@codemirror/lint": "^6.9.2", + "@codemirror/state": "^6.5.3", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/ratings-ui-demo/src/components/filtered-entity-list-entities.ts b/ratings-ui-demo/src/components/filtered-entity-list-entities.ts index e90f545738a8f2f666a49523ff2498e8cb2e4e87..430bb4351451988e0df9257faecde181dcaae047 100644 --- a/ratings-ui-demo/src/components/filtered-entity-list-entities.ts +++ b/ratings-ui-demo/src/components/filtered-entity-list-entities.ts @@ -405,3 +405,23 @@ export interface FollowListEntryEntity { entity_name: string | null; creation_time: string; } + +export interface UnifiedFollowedEventEntity { + event_id: string; + entity_type: 'proposition' | 'tag' | 'document' | 'statistical_event'; + followed_entity_id: number; + entity_id: number; + follower_user_id: number; + follower_username: string; + event_user_id: number; + event_username: string; + activity_type: string; + event_time: string; + event_description: string; + proposition_id_1?: number; + proposition_id_2?: number; + proposition_id_3?: number; + tag_id_1?: number; + content_text?: string; // proposition body, tag name, or document title + followed_event_type: string; +} diff --git a/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx b/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx index 6384cb4ff3e659e457692fc0c1d3fd4e1715feb6..fd2f27db327bfdc35be1e7597fb155c2a7359020 100644 --- a/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx +++ b/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx @@ -15,7 +15,10 @@ import { SelectChangeEvent, Stack, ListItemText, + ToggleButton, + ToggleButtonGroup, } from '@mui/material'; +import { subMonths, subDays, subHours, subMinutes } from 'date-fns'; // DatePicker/DateTimePicker require a `LocalizationProvider` with a date adapter // (e.g. AdapterDateFns) somewhere above this component in the tree. import { DatePicker } from '@mui/x-date-pickers/DatePicker'; @@ -39,6 +42,7 @@ interface CustomFilterProps { filter: EntityFilter; value: FilterInputSelection; onChange: (filter_name: string, value: FilterInputSelection | undefined) => void; + allValues?: FilterInputSelection[]; } // Built-in filter components @@ -550,6 +554,236 @@ const BooleanOptionalFilterInput: React.FC = ({ filter, value ); }; +// Unified event type options with entity type labels +const UNIFIED_EVENT_TYPE_OPTIONS = [ + { value: 'vote', label: 'Vote (Proposition)', entityTypes: ['proposition'] }, + { value: 'argument', label: 'Argument (Proposition)', entityTypes: ['proposition'] }, + { value: 'rewording', label: 'Rewording (Proposition)', entityTypes: ['proposition'] }, + { value: 'tag', label: 'Tag (Proposition/Document)', entityTypes: ['proposition', 'document'] }, + { value: 'document_link', label: 'Document Link (Proposition)', entityTypes: ['proposition'] }, + { value: 'tagging', label: 'Tagging (Tag)', entityTypes: ['tag'] }, + { value: 'vote_usefulness', label: 'Vote Usefulness (Tag)', entityTypes: ['tag'] }, + { value: 'vote_relevancy', label: 'Vote Relevancy (Tag)', entityTypes: ['tag'] }, + { value: 'subdocument', label: 'Subdocument (Document)', entityTypes: ['document'] }, + { value: 'edit', label: 'Edit (Document)', entityTypes: ['document'] }, + { value: 'proposition_link', label: 'Proposition Link (Document)', entityTypes: ['document'] }, +]; + +// Unified Event Type filter with labels showing which entity type each applies to +const UnifiedEventTypeFilterInput: React.FC = ({ filter, value, onChange, allValues }) => { + const selectedValue = value.values?.[0] || ''; + + // Get the currently selected entity_type from allValues + const entityTypeFilter = allValues?.find(f => f.name === 'entity_type'); + const selectedEntityType = entityTypeFilter?.values?.[0] || ''; + + // Check if selected event type is compatible with selected entity type + const selectedOption = UNIFIED_EVENT_TYPE_OPTIONS.find(opt => opt.value === selectedValue); + const isIncompatible = selectedEntityType && selectedOption && + !selectedOption.entityTypes.includes(selectedEntityType); + + const handleChange = (event: SelectChangeEvent) => { + const newValue = event.target.value; + if (!newValue) + onChange(filter.filter_name, undefined); + else + onChange(filter.filter_name, { + name: filter.filter_name, + values: [newValue], + not: value.not || false, + }); + }; + + return ( + + + {filter.filter_label} + + + {isIncompatible && ( + + Note: This event type applies to {selectedOption?.entityTypes.join('/')}, not {selectedEntityType}s. + + )} + + ); +}; + +// Timeframe presets for filtering events by time +type TimeframePreset = 'all' | 'month' | 'week' | 'day' | 'hours' | 'hour' | 'minutes10' | 'minute' | 'custom'; + +const getTimeframeRange = (preset: TimeframePreset): { from: Date | null; to: Date | null } => { + const now = new Date(); + switch (preset) { + case 'all': + return { from: null, to: null }; + case 'month': + return { from: subMonths(now, 1), to: null }; + case 'week': + return { from: subDays(now, 7), to: null }; + case 'day': + return { from: subDays(now, 1), to: null }; + case 'hours': + return { from: subHours(now, 4), to: null }; + case 'hour': + return { from: subHours(now, 1), to: null }; + case 'minutes10': + return { from: subMinutes(now, 10), to: null }; + case 'minute': + return { from: subMinutes(now, 1), to: null }; + case 'custom': + return { from: null, to: null }; + default: + return { from: null, to: null }; + } +}; + +// Timeframe filter with presets and custom date range +const TimeframeFilterInput: React.FC = ({ filter, value, onChange, allValues }) => { + const [selectedPreset, setSelectedPreset] = useState('all'); + const [customFrom, setCustomFrom] = useState(null); + const [customTo, setCustomTo] = useState(null); + + // Determine current preset from allValues on mount + useEffect(() => { + const eventAfter = allValues?.find(f => f.name === 'event_after')?.values?.[0]; + const eventBefore = allValues?.find(f => f.name === 'event_before')?.values?.[0]; + + if (!eventAfter && !eventBefore) { + setSelectedPreset('all'); + } else if (eventAfter || eventBefore) { + // If there are values set, assume custom mode + setSelectedPreset('custom'); + if (eventAfter) setCustomFrom(new Date(eventAfter)); + if (eventBefore) setCustomTo(new Date(eventBefore)); + } + }, []); + + const handlePresetChange = (_event: React.MouseEvent, newPreset: TimeframePreset | null) => { + if (!newPreset) return; + setSelectedPreset(newPreset); + + if (newPreset === 'custom') { + // Don't change filters yet, wait for user to pick dates + return; + } + + const range = getTimeframeRange(newPreset); + + // Check if event_before needs to be cleared (e.g., switching from custom range to preset) + const hasEventBefore = allValues?.some(f => f.name === 'event_before' && f.values?.length); + + // Update event_after filter + if (range.from) { + onChange('event_after', { + name: 'event_after', + values: [range.from.toISOString()], + not: false, + }); + } else { + onChange('event_after', undefined); + } + + // If there's an existing event_before that needs clearing, defer it to avoid + // React batching issues where multiple rapid onChange calls overwrite each other + if (hasEventBefore) { + setTimeout(() => { + onChange('event_before', undefined); + }, 0); + } + + // Clear custom dates when selecting a preset + setCustomFrom(null); + setCustomTo(null); + }; + + const handleCustomFromChange = (dt: Date | null) => { + setCustomFrom(dt); + if (dt) { + onChange('event_after', { + name: 'event_after', + values: [dt.toISOString()], + not: false, + }); + } else { + onChange('event_after', undefined); + } + }; + + const handleCustomToChange = (dt: Date | null) => { + setCustomTo(dt); + if (dt) { + onChange('event_before', { + name: 'event_before', + values: [dt.toISOString()], + not: false, + }); + } else { + onChange('event_before', undefined); + } + }; + + return ( + + + {filter.filter_label} + + + + All time + Last month + Last week + Last day + Last 4 hours + Last hour + Last 10 min + Last minute + Custom + + + {selectedPreset === 'custom' && ( + + + + to + + + + )} + + + ); +}; + // Default filter type components const defaultFilterComponents: Record> = { text: TextFilterInput, @@ -560,7 +794,9 @@ const defaultFilterComponents: Record = ({ @@ -658,6 +894,7 @@ export const FilteredEntityListFilters: React.FC filter={filter} value={currentValue} onChange={(filterName, newValue) => handleFilterChange(filterName, newValue)} + allValues={values} /> ); @@ -688,14 +925,31 @@ export const FilteredEntityListFilters: React.FC return null; } + // Separate timeframe filters from regular filters + const getFilterTypePrefix = (filterType: string) => { + const colon = filterType.indexOf(':'); + return colon > 0 ? filterType.substring(0, colon) : filterType; + }; + + const timeframeFilters = filters.filter(f => getFilterTypePrefix(f.filter_type) === 'timeframe'); + const regularFilters = filters.filter(f => getFilterTypePrefix(f.filter_type) !== 'timeframe'); + return ( - + <> {title && {title}} - {filters.map(renderFilter)} - + {/* Regular filters on their own row */} + {regularFilters.length > 0 && ( + + {regularFilters.map(renderFilter)} + + )} + {/* Timeframe filters on their own row */} + {timeframeFilters.map(renderFilter)} + ); }; diff --git a/ratings-ui-demo/src/components/filtered-entity-list.tsx b/ratings-ui-demo/src/components/filtered-entity-list.tsx index 03899003d8b56257d8a84895e78ade495fc288e7..ef2aa324684d7fa87a23f844b662a49d8c2933d6 100644 --- a/ratings-ui-demo/src/components/filtered-entity-list.tsx +++ b/ratings-ui-demo/src/components/filtered-entity-list.tsx @@ -95,6 +95,8 @@ export const FilteredEntityList: React.FC = ({ spacing={1} direction={sortFilterDirectionVertical ? "column" : "row"} alignItems={sortFilterDirectionVertical ? "start" : "center"} + useFlexGap + sx={{ flexWrap: 'wrap', rowGap: 1 }} > {showSort && { diff --git a/ratings-ui-demo/src/components/filteredentitylistitemrenderers/unified-followed-event-renderer.tsx b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/unified-followed-event-renderer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..94782a08e08d8d63f3c1fd94abc7314720d4fd77 --- /dev/null +++ b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/unified-followed-event-renderer.tsx @@ -0,0 +1,493 @@ +"use client"; + +import React from 'react'; +import { Chip, Link, Stack, Typography } from '@mui/material'; +import { FilterOutputData } from '@/services/api-v1'; +import { UnifiedFollowedEventEntity } from '../filtered-entity-list-entities'; + +export interface UnifiedFollowedEventRendererProps { + item: FilterOutputData; + index: number; +} + +const getEventTitle = (event: UnifiedFollowedEventEntity): string => { + const username = event.event_username || 'Someone'; + const content = event.content_text || 'unknown'; + + // Handle based on entity_type and followed_event_type + if (event.entity_type === 'proposition') { + switch (event.followed_event_type) { + case 'vote': + return `${username} voted on proposition "${content}"`; + case 'argument': + return `${username} added an argument to proposition "${content}"`; + case 'rewording': + return `${username} reworded proposition "${content}"`; + case 'tag': + return `${username} tagged proposition "${content}"`; + case 'document_link': + return `${username} linked a document to proposition "${content}"`; + default: + return `${username} interacted with proposition "${content}"`; + } + } else if (event.entity_type === 'tag') { + switch (event.followed_event_type) { + case 'tagging': + return `${username} used tag "${content}" on something`; + case 'vote_usefulness': + return `${username} voted on the usefulness of tag "${content}"`; + case 'vote_relevancy': + return `${username} voted on relevancy of tag "${content}"`; + default: + return `${username} interacted with tag "${content}"`; + } + } else if (event.entity_type === 'document') { + switch (event.followed_event_type) { + case 'tag': + return `${username} tagged document "${content}"`; + case 'subdocument': + return `${username} created a sub-document for "${content}"`; + case 'edit': + return `${username} edited document "${content}"`; + case 'proposition_link': + return `${username} linked a proposition to document "${content}"`; + default: + return `${username} interacted with document "${content}"`; + } + } else if (event.entity_type === 'statistical_event') { + // Statistical events use the event_description directly as it contains all details + return event.event_description || `Statistical event on "${content}"`; + } else { + // Fallback for unexpected entity_type + return `${username} performed action on "${content}"`; + } +}; + +const getEntityTypeColor = (entityType: string): 'primary' | 'secondary' | 'info' | 'success' | 'default' => { + switch (entityType) { + case 'proposition': + return 'primary'; + case 'tag': + return 'secondary'; + case 'document': + return 'info'; + case 'statistical_event': + return 'success'; + default: + return 'default'; + } +}; + +const getEntityTypeLabel = (entityType: string): string => { + switch (entityType) { + case 'proposition': + return 'Proposition'; + case 'tag': + return 'Tag'; + case 'document': + return 'Document'; + case 'statistical_event': + return 'Statistical'; + default: + return entityType; + } +}; + +// Convert database timestamp (UTC) to local time +const formatEventTime = (timestamp: string): string => { + // If timestamp doesn't have timezone info, treat it as UTC + let dateStr = timestamp; + if (!dateStr.endsWith('Z') && !dateStr.includes('+') && !dateStr.includes('-', 10)) { + dateStr = dateStr + 'Z'; + } + return new Date(dateStr).toLocaleString(); +}; + +export const UnifiedFollowedEventRenderer: React.FC = ({ item }) => { + const event = item.main_entity.data as UnifiedFollowedEventEntity; + + const getPropositionHref = (propositionId: number) => + `/pages/proposition?proposition_id=${propositionId}`; + + const getUserHref = (username: string) => + `/pages/user?username=${username}`; + + const renderEventLinks = () => { + const links: React.ReactNode[] = []; + + if (event.entity_type === 'proposition') { + // Proposition event links (from followed-event-renderer.tsx) + switch (event.followed_event_type) { + case 'vote': + links.push( + + View ratings + + ); + if (event.proposition_id_1) { + links.push( + + View proposition + + ); + } + break; + + case 'argument': + if (event.proposition_id_1) { + links.push( + + View argument + + ); + } + if (event.proposition_id_2) { + links.push( + + View original proposition + + ); + } + break; + + case 'rewording': + if (event.proposition_id_1) { + links.push( + + View rewording + + ); + } + if (event.proposition_id_2) { + links.push( + + View original proposition + + ); + } + break; + + case 'tag': + links.push( + + View tags + + ); + if (event.proposition_id_1) { + links.push( + + View proposition + + ); + } + break; + + case 'document_link': + links.push( + + View documents + + ); + if (event.proposition_id_1) { + links.push( + + View proposition + + ); + } + break; + } + } else if (event.entity_type === 'tag') { + // Tag event links (from followed-tag-event-renderer.tsx) + switch (event.followed_event_type) { + case 'tagging': + if (event.proposition_id_1) { + links.push( + + View tagged proposition + + ); + } + links.push( + + View all tags + + ); + break; + + case 'vote_usefulness': + links.push( + + View all tags + + ); + break; + + case 'vote_relevancy': + if (event.proposition_id_1) { + links.push( + + View relevancy proposition + + ); + } + if (event.proposition_id_2) { + links.push( + + View original proposition + + ); + } + links.push( + + View all tags + + ); + break; + } + } else if (event.entity_type === 'statistical_event') { + // Statistical event links + if (event.proposition_id_1) { + links.push( + + View proposition + + ); + } + links.push( + + Manage following + + ); + } else { + // Document event links (from followed-document-event-renderer.tsx) + switch (event.followed_event_type) { + case 'tag': + links.push( + + View all tags + + ); + links.push( + + View all documents + + ); + break; + + case 'subdocument': + case 'edit': + links.push( + + View all documents + + ); + break; + + case 'proposition_link': + if (event.proposition_id_1) { + links.push( + + View proposition + + ); + } + links.push( + + View all documents + + ); + break; + } + } + + return links; + }; + + const eventLinks = renderEventLinks(); + + return ( + + + + + {getEventTitle(event)} + + + + + {formatEventTime(event.event_time)} + {' • '} + + {event.event_username} + + {eventLinks.map((link, idx) => ( + + {' • '} + {link} + + ))} + + + ); +}; diff --git a/ratings-ui-demo/src/components/follow-proposition-dialog.tsx b/ratings-ui-demo/src/components/follow-proposition-dialog.tsx index 3a462ccf9e89f0a2bab6fbf824a64d18fff903ec..3362d4881bf1959a511988edd173485736ec2e94 100644 --- a/ratings-ui-demo/src/components/follow-proposition-dialog.tsx +++ b/ratings-ui-demo/src/components/follow-proposition-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Dialog, DialogActions, @@ -11,12 +11,19 @@ import { FormControlLabel, Checkbox, Typography, - Stack + Stack, + Divider, + Chip, + CircularProgress, + Box } from "@mui/material"; import { useApiClient } from "@/components/api-context"; import { useSession } from "@/hooks/use-session"; import useNotification from "@/hooks/use-notification"; -import { FollowedPropositionEventType } from "@/services/api-v1"; +import { + FollowedPropositionEventType, + StatisticalEventDefinitionForProposition +} from "@/services/api-v1"; export interface FollowPropositionDialogProps { open: boolean; @@ -40,6 +47,9 @@ export function FollowPropositionDialog({ const [selectedEventTypes, setSelectedEventTypes] = useState>( new Set(['vote', 'argument']) ); + const [statisticalDefinitions, setStatisticalDefinitions] = useState([]); + const [selectedStatisticalDefinitions, setSelectedStatisticalDefinitions] = useState>(new Set()); + const [loadingDefinitions, setLoadingDefinitions] = useState(false); const eventTypeLabels: Record = { 'vote': 'When someone votes on this proposition', @@ -49,12 +59,32 @@ export function FollowPropositionDialog({ 'document_link': 'When someone links a document to/from this proposition' }; + // Load statistical event definitions for this proposition + const loadStatisticalDefinitions = useCallback(async () => { + const userId = getUserId(); + if (!userId) return; + + try { + setLoadingDefinitions(true); + const defs = await api.getStatisticalEventDefinitionsForProposition(userId, propositionId); + setStatisticalDefinitions(defs); + // Pre-select already subscribed definitions + const alreadySubscribed = defs.filter(d => d.is_subscribed).map(d => d.definition_id); + setSelectedStatisticalDefinitions(new Set(alreadySubscribed)); + } catch (err) { + console.error('Failed to load statistical definitions:', err); + } finally { + setLoadingDefinitions(false); + } + }, [api, getUserId, propositionId]); + // Reset to defaults when dialog opens useEffect(() => { if (open) { setSelectedEventTypes(new Set(['vote', 'argument'])); + loadStatisticalDefinitions(); } - }, [open]); + }, [open, loadStatisticalDefinitions]); const handleToggleEventType = (eventType: FollowedPropositionEventType) => { const newSelection = new Set(selectedEventTypes); @@ -66,6 +96,23 @@ export function FollowPropositionDialog({ setSelectedEventTypes(newSelection); }; + const handleToggleStatisticalDefinition = (definitionId: number) => { + const newSelection = new Set(selectedStatisticalDefinitions); + if (newSelection.has(definitionId)) { + newSelection.delete(definitionId); + } else { + newSelection.add(definitionId); + } + setSelectedStatisticalDefinitions(newSelection); + }; + + const formatThreshold = (def: StatisticalEventDefinitionForProposition): string => { + if (def.metric_type === 'aggregate_rating') { + return `${Math.round(def.threshold_value * 100)}%`; + } + return `${def.threshold_value} ratings`; + }; + const handleFollow = async () => { const userId = getUserId(); if (!userId) { @@ -73,17 +120,34 @@ export function FollowPropositionDialog({ return; } - if (selectedEventTypes.size === 0) { + if (selectedEventTypes.size === 0 && selectedStatisticalDefinitions.size === 0) { notifyError('Please select at least one event type to follow'); return; } try { - await api.followProposition( - userId, - propositionId, - Array.from(selectedEventTypes) - ); + // Follow regular events if any selected + if (selectedEventTypes.size > 0) { + await api.followProposition( + userId, + propositionId, + Array.from(selectedEventTypes) + ); + } + + // Handle statistical event subscriptions + for (const def of statisticalDefinitions) { + const wasSubscribed = def.is_subscribed; + const isNowSelected = selectedStatisticalDefinitions.has(def.definition_id); + + if (!wasSubscribed && isNowSelected) { + // Subscribe + await api.followStatisticalEvent(userId, def.definition_id, propositionId); + } else if (wasSubscribed && !isNowSelected) { + // Unsubscribe + await api.unfollowStatisticalEvent(userId, def.definition_id, propositionId); + } + } notifySuccess('Now following proposition'); onClose(); @@ -114,11 +178,11 @@ export function FollowPropositionDialog({ borderColor: 'primary.main' }} > - "{propositionBody}" + "{propositionBody}" - Select events to follow: + Individual Events @@ -135,6 +199,54 @@ export function FollowPropositionDialog({ /> ))} + + {/* Statistical Events Section */} + + + + Statistical Events + + + {loadingDefinitions ? ( + + + + ) : statisticalDefinitions.length === 0 ? ( + + No statistical event definitions available. Create one from the Following page. + + ) : ( + + {statisticalDefinitions.map((def) => ( + handleToggleStatisticalDefinition(def.definition_id)} + /> + } + label={ + + + {def.definition_name} + + + + + } + /> + ))} + + )} @@ -142,7 +254,7 @@ export function FollowPropositionDialog({ diff --git a/ratings-ui-demo/src/components/follow-tag-dialog.tsx b/ratings-ui-demo/src/components/follow-tag-dialog.tsx index 4226880e12b907624d3dd179b7e290646748778a..7e499ec722331b9794e26ef80e6c265f22fd4191 100644 --- a/ratings-ui-demo/src/components/follow-tag-dialog.tsx +++ b/ratings-ui-demo/src/components/follow-tag-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Dialog, DialogActions, @@ -11,12 +11,16 @@ import { FormControlLabel, Checkbox, Typography, - Stack + Stack, + Divider, + Chip, + CircularProgress, + Box } from "@mui/material"; import { useApiClient } from "@/components/api-context"; import { useSession } from "@/hooks/use-session"; import useNotification from "@/hooks/use-notification"; -import { FollowedTagEventType } from "@/services/api-v1"; +import { FollowedTagEventType, StatisticalEventDefinitionForProposition } from "@/services/api-v1"; export interface FollowTagDialogProps { open: boolean; @@ -40,6 +44,9 @@ export function FollowTagDialog({ const [selectedEventTypes, setSelectedEventTypes] = useState>( new Set(['tagging', 'vote_usefulness']) ); + const [statisticalDefinitions, setStatisticalDefinitions] = useState([]); + const [selectedStatisticalDefinitions, setSelectedStatisticalDefinitions] = useState>(new Set()); + const [loadingDefinitions, setLoadingDefinitions] = useState(false); const eventTypeLabels: Record = { 'tagging': 'When someone tags a proposition or document with this tag', @@ -47,12 +54,32 @@ export function FollowTagDialog({ 'vote_relevancy': 'When someone votes on tag-relevancy propositions' }; + // Load statistical event definitions for this tag + const loadStatisticalDefinitions = useCallback(async () => { + const userId = getUserId(); + if (!userId) return; + + try { + setLoadingDefinitions(true); + const defs = await api.getStatisticalEventDefinitionsForTag(userId, tagId); + setStatisticalDefinitions(defs); + // Pre-select already subscribed definitions + const alreadySubscribed = defs.filter(d => d.is_subscribed).map(d => d.definition_id); + setSelectedStatisticalDefinitions(new Set(alreadySubscribed)); + } catch (err) { + console.error('Failed to load statistical definitions:', err); + } finally { + setLoadingDefinitions(false); + } + }, [api, getUserId, tagId]); + // Reset to defaults when dialog opens useEffect(() => { if (open) { setSelectedEventTypes(new Set(['tagging', 'vote_usefulness'])); + loadStatisticalDefinitions(); } - }, [open]); + }, [open, loadStatisticalDefinitions]); const handleToggleEventType = (eventType: FollowedTagEventType) => { const newSelection = new Set(selectedEventTypes); @@ -64,6 +91,27 @@ export function FollowTagDialog({ setSelectedEventTypes(newSelection); }; + const handleToggleStatisticalDefinition = (definitionId: number) => { + const newSelection = new Set(selectedStatisticalDefinitions); + if (newSelection.has(definitionId)) { + newSelection.delete(definitionId); + } else { + newSelection.add(definitionId); + } + setSelectedStatisticalDefinitions(newSelection); + }; + + const formatThreshold = (def: StatisticalEventDefinitionForProposition): string => { + if (def.metric_type === 'tag_usage_count') { + return `${def.threshold_value} uses`; + } else if (def.metric_type === 'tag_usage_count_period') { + return `${def.threshold_value} uses in ${def.time_window_minutes || '?'} min`; + } else if (def.metric_type === 'aggregate_rating') { + return `${Math.round(def.threshold_value * 100)}%`; + } + return `${def.threshold_value}`; + }; + const handleFollow = async () => { const userId = getUserId(); if (!userId) { @@ -71,17 +119,34 @@ export function FollowTagDialog({ return; } - if (selectedEventTypes.size === 0) { + if (selectedEventTypes.size === 0 && selectedStatisticalDefinitions.size === 0) { notifyError('Please select at least one event type to follow'); return; } try { - await api.followTag( - userId, - tagId, - Array.from(selectedEventTypes) - ); + // Follow regular events if any selected + if (selectedEventTypes.size > 0) { + await api.followTag( + userId, + tagId, + Array.from(selectedEventTypes) + ); + } + + // Handle statistical event subscriptions + for (const def of statisticalDefinitions) { + const wasSubscribed = def.is_subscribed; + const isNowSelected = selectedStatisticalDefinitions.has(def.definition_id); + + if (!wasSubscribed && isNowSelected) { + // Subscribe - pass undefined for propositionId, tagId for the tag + await api.followStatisticalEvent(userId, def.definition_id, undefined, tagId); + } else if (wasSubscribed && !isNowSelected) { + // Unsubscribe + await api.unfollowStatisticalEvent(userId, def.definition_id, undefined, tagId); + } + } notifySuccess('Now following tag'); onClose(); @@ -116,7 +181,7 @@ export function FollowTagDialog({ - Select events to follow: + Individual Events @@ -133,6 +198,54 @@ export function FollowTagDialog({ /> ))} + + {/* Statistical Events Section */} + + + + Statistical Events + + + {loadingDefinitions ? ( + + + + ) : statisticalDefinitions.length === 0 ? ( + + No statistical event definitions available for tags. Create one from the Following page (select "Tag" as entity type). + + ) : ( + + {statisticalDefinitions.map((def) => ( + handleToggleStatisticalDefinition(def.definition_id)} + /> + } + label={ + + + {def.definition_name} + + + + + } + /> + ))} + + )} @@ -140,7 +253,7 @@ export function FollowTagDialog({ diff --git a/ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx b/ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d5017d2205f3d287da185d32046a53b49c215507 --- /dev/null +++ b/ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx @@ -0,0 +1,484 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Button, + TextField, + FormControl, + FormControlLabel, + InputLabel, + Select, + MenuItem, + Switch, + Stack, + Typography, + Alert, + InputAdornment, + SelectChangeEvent +} from "@mui/material"; +import { useApiClient } from "@/components/api-context"; +import { useSession } from "@/hooks/use-session"; +import useNotification from "@/hooks/use-notification"; +import { RatingAlgorithm, StatisticalMetricType, StatisticalEntityType, CrossingDirection as ApiCrossingDirection } from "@/services/api-v1"; + +export type EntityType = StatisticalEntityType; +export type MetricType = StatisticalMetricType; +export type CrossingDirection = ApiCrossingDirection; + +export interface StatisticalEventDefinition { + id?: number; + name: string; + description?: string; + entity_type: EntityType; + metric_type: MetricType; + rating_algorithm_id?: number; + threshold_value: number; + crossing_direction: CrossingDirection; + is_published: boolean; + time_window_minutes?: number; +} + +export interface StatisticalEventDefinitionDialogProps { + open: boolean; + onClose: () => void; + onSaved?: (definitionId: number) => void; + editDefinition?: StatisticalEventDefinition; // If provided, dialog is in edit mode +} + +const entityTypeLabels: Record = { + 'proposition': 'Proposition', + 'tag': 'Tag' +}; + +const propositionMetricTypeLabels: Record = { + 'rating_count': 'Number of ratings', + 'aggregate_rating': 'Aggregate rating value' +}; + +const tagMetricTypeLabels: Record = { + 'tag_usage_count': 'Total usage count', + 'tag_usage_count_period': 'Usage count in time period' +}; + +const crossingDirectionLabels: Record = { + 'up_only': 'Upward crossing only', + 'both': 'Both directions (up and down)' +}; + +export function StatisticalEventDefinitionDialog({ + open, + onClose, + onSaved, + editDefinition +}: StatisticalEventDefinitionDialogProps) { + const api = useApiClient(); + const { getUserId } = useSession(); + const { notifySuccess, notifyError } = useNotification(); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [entityType, setEntityType] = useState('proposition'); + const [metricType, setMetricType] = useState('aggregate_rating'); + const [ratingAlgorithmId, setRatingAlgorithmId] = useState(''); + const [thresholdValue, setThresholdValue] = useState('90'); + const [crossingDirection, setCrossingDirection] = useState('up_only'); + const [isPublished, setIsPublished] = useState(false); + const [timeWindowMinutes, setTimeWindowMinutes] = useState('60'); + + const [algorithms, setAlgorithms] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const isEditMode = !!editDefinition?.id; + + // Load rating algorithms + useEffect(() => { + const loadAlgorithms = async () => { + try { + const algs = await api.getRatingAlgorithms(); + setAlgorithms(algs.filter(a => a.is_enabled)); + } catch (err) { + console.error('Failed to load algorithms:', err); + } + }; + if (open) { + loadAlgorithms(); + } + }, [open, api]); + + // Reset form when dialog opens + useEffect(() => { + if (open) { + if (editDefinition) { + setName(editDefinition.name); + setDescription(editDefinition.description || ''); + setEntityType(editDefinition.entity_type || 'proposition'); + setMetricType(editDefinition.metric_type); + setRatingAlgorithmId(editDefinition.rating_algorithm_id || ''); + // Convert threshold to percentage for display if it's a rating + if (editDefinition.metric_type === 'aggregate_rating') { + setThresholdValue(String(Math.round(editDefinition.threshold_value * 100))); + } else { + setThresholdValue(String(editDefinition.threshold_value)); + } + setCrossingDirection(editDefinition.crossing_direction); + setIsPublished(editDefinition.is_published); + setTimeWindowMinutes(String(editDefinition.time_window_minutes || 60)); + } else { + // Reset to defaults for new definition + setName(''); + setDescription(''); + setEntityType('proposition'); + setMetricType('aggregate_rating'); + setRatingAlgorithmId(''); + setThresholdValue('90'); + setCrossingDirection('up_only'); + setIsPublished(false); + setTimeWindowMinutes('60'); + } + setError(null); + } + }, [open, editDefinition]); + + // Set default algorithm when algorithms load + useEffect(() => { + if (algorithms.length > 0 && ratingAlgorithmId === '' && metricType === 'aggregate_rating') { + // Find "Mean" algorithm or use first one + const meanAlg = algorithms.find(a => a.name.toLowerCase().includes('mean')); + setRatingAlgorithmId(meanAlg?.id || algorithms[0].id); + } + }, [algorithms, ratingAlgorithmId, metricType]); + + const handleEntityTypeChange = (event: SelectChangeEvent) => { + const newType = event.target.value as EntityType; + setEntityType(newType); + // Reset metric type to default for new entity type + if (newType === 'proposition') { + setMetricType('aggregate_rating'); + setThresholdValue('90'); + } else { + setMetricType('tag_usage_count'); + setThresholdValue('10'); + } + }; + + const handleMetricTypeChange = (event: SelectChangeEvent) => { + const newType = event.target.value as MetricType; + setMetricType(newType); + // Reset threshold to sensible defaults + if (newType === 'aggregate_rating') { + setThresholdValue('90'); + } else if (newType === 'rating_count') { + setThresholdValue('10'); + } else if (newType === 'tag_usage_count') { + setThresholdValue('10'); + } else if (newType === 'tag_usage_count_period') { + setThresholdValue('5'); + setTimeWindowMinutes('60'); + } + }; + + const getMetricTypeLabels = () => { + return entityType === 'proposition' ? propositionMetricTypeLabels : tagMetricTypeLabels; + }; + + const validateForm = (): string | null => { + if (!name.trim()) { + return 'Name is required'; + } + if (metricType === 'aggregate_rating' && !ratingAlgorithmId) { + return 'Rating algorithm is required for aggregate rating metric'; + } + const threshold = parseFloat(thresholdValue); + if (isNaN(threshold)) { + return 'Threshold must be a valid number'; + } + if (metricType === 'aggregate_rating' && (threshold < 0 || threshold > 100)) { + return 'Rating threshold must be between 0 and 100'; + } + if (metricType === 'rating_count' && threshold < 1) { + return 'Rating count threshold must be at least 1'; + } + if ((metricType === 'tag_usage_count' || metricType === 'tag_usage_count_period') && threshold < 1) { + return 'Usage count threshold must be at least 1'; + } + if (metricType === 'tag_usage_count_period') { + const timeWindow = parseInt(timeWindowMinutes); + if (isNaN(timeWindow) || timeWindow < 1) { + return 'Time window must be at least 1 minute'; + } + } + return null; + }; + + const handleSave = async () => { + const userId = getUserId(); + if (!userId) { + notifyError('You must be logged in'); + return; + } + + const validationError = validateForm(); + if (validationError) { + setError(validationError); + return; + } + + setLoading(true); + setError(null); + + try { + // Convert percentage to decimal for aggregate_rating + let threshold = parseFloat(thresholdValue); + if (metricType === 'aggregate_rating') { + threshold = threshold / 100; + } + + const timeWindow = metricType === 'tag_usage_count_period' ? parseInt(timeWindowMinutes) : undefined; + + if (isEditMode && editDefinition?.id) { + await api.updateStatisticalEventDefinition( + userId, + editDefinition.id, + name, + description || undefined, + threshold, + crossingDirection, + isPublished, + timeWindow + ); + notifySuccess('Definition updated'); + onSaved?.(editDefinition.id); + } else { + const definitionId = await api.createStatisticalEventDefinition( + userId, + name, + description || undefined, + entityType, + metricType, + metricType === 'aggregate_rating' ? (ratingAlgorithmId as number) : undefined, + threshold, + crossingDirection, + isPublished, + timeWindow + ); + notifySuccess('Definition created'); + onSaved?.(definitionId); + } + onClose(); + } catch (err) { + console.error('Failed to save definition:', err); + setError('Failed to save definition'); + notifyError('Failed to save definition'); + } finally { + setLoading(false); + } + }; + + const generateDefaultName = () => { + if (entityType === 'proposition') { + if (metricType === 'aggregate_rating') { + const alg = algorithms.find(a => a.id === ratingAlgorithmId); + return `Rating reaches ${thresholdValue}% (${alg?.name || 'algorithm'})`; + } else { + return `Gets ${thresholdValue} ratings`; + } + } else { + // Tag metrics + if (metricType === 'tag_usage_count') { + return `Used ${thresholdValue} times`; + } else { + return `Used ${thresholdValue} times in ${timeWindowMinutes} min`; + } + } + }; + + const getThresholdHelperText = () => { + switch (metricType) { + case 'aggregate_rating': + return 'Rating percentage (0-100) at which to trigger'; + case 'rating_count': + return 'Number of ratings at which to trigger'; + case 'tag_usage_count': + return 'Total number of tag associations to trigger at'; + case 'tag_usage_count_period': + return 'Number of new tag associations in the time window'; + default: + return 'Threshold value'; + } + }; + + const getThresholdSuffix = () => { + switch (metricType) { + case 'aggregate_rating': + return '%'; + case 'rating_count': + return 'ratings'; + case 'tag_usage_count': + case 'tag_usage_count_period': + return 'uses'; + default: + return ''; + } + }; + + return ( + + + {isEditMode ? 'Edit Statistical Event Definition' : 'Create Statistical Event Definition'} + + + + {error && ( + {error} + )} + + setName(e.target.value)} + fullWidth + required + placeholder={generateDefaultName()} + helperText="A descriptive name for this event definition" + /> + + setDescription(e.target.value)} + fullWidth + multiline + rows={2} + helperText="Optional detailed description" + /> + + + Entity Type + + + + + Metric Type + + + + {metricType === 'aggregate_rating' && ( + + Rating Algorithm + + + )} + + setThresholdValue(e.target.value)} + fullWidth + required + InputProps={{ + endAdornment: ( + {getThresholdSuffix()} + ) + }} + helperText={getThresholdHelperText()} + /> + + {metricType === 'tag_usage_count_period' && ( + setTimeWindowMinutes(e.target.value)} + fullWidth + required + InputProps={{ + endAdornment: ( + minutes + ) + }} + helperText="Look back period for counting tag uses (e.g., 60 for last hour)" + /> + )} + + + Crossing Direction + + + + + {crossingDirection === 'up_only' + ? 'Event triggers when value crosses above the threshold' + : 'Event triggers when value crosses the threshold in either direction' + } + + + setIsPublished(e.target.checked)} + /> + } + label="Publish this definition (others can use it)" + /> + + + + + + + + ); +} diff --git a/ratings-ui-demo/src/hooks/use-session.ts b/ratings-ui-demo/src/hooks/use-session.ts index 0e71f0d51495fad5c2036b93093339d9edeb0c76..163537d3721aff8e3f923a2dc30b18bc9570e18f 100644 --- a/ratings-ui-demo/src/hooks/use-session.ts +++ b/ratings-ui-demo/src/hooks/use-session.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { getCookie, setCookie, @@ -5,28 +6,28 @@ import { deleteCookie } from 'cookies-next/client'; -export function useSession() { - const USER_ID_COOKIE_KEY: string = "user_id"; +const USER_ID_COOKIE_KEY: string = "user_id"; - const getUserId: () => number | null = () => { +export function useSession() { + const getUserId = useCallback((): number | null => { if (!hasCookie(USER_ID_COOKIE_KEY)) return null; const userId = getCookie(USER_ID_COOKIE_KEY); return Number(userId); - } + }, []); - const setUserId: (id: number) => void = (id: number) => { + const setUserId = useCallback((id: number): void => { setCookie( USER_ID_COOKIE_KEY, id ); - } + }, []); - const clearUserId: () => void = () => { + const clearUserId = useCallback((): void => { deleteCookie( USER_ID_COOKIE_KEY ); - } + }, []); return { getUserId, diff --git a/ratings-ui-demo/src/pvcomponents/pv-ai-event-builder.tsx b/ratings-ui-demo/src/pvcomponents/pv-ai-event-builder.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d538cddb0a2c757c4a4743d3d86b7cfc0993855 --- /dev/null +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-event-builder.tsx @@ -0,0 +1,839 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import Markdown from 'react-markdown'; +import { + Box, + Paper, + Stack, + Typography, + TextField, + Button, + Alert, + CircularProgress, + Collapse, + IconButton, + Chip, + FormControl, + InputLabel, + Select, + MenuItem, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + ExpandLess as ExpandLessIcon, + ContentCopy as ContentCopyIcon, + PlayArrow as PlayArrowIcon, + Save as SaveIcon, + Clear as ClearIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Visibility as VisibilityIcon, + NotificationsActive as NotificationsActiveIcon, + NotificationsOff as NotificationsOffIcon, + Refresh as RefreshIcon +} from '@mui/icons-material'; +import { useApiClient } from '@/components/api-context'; +import { useSession } from '@/hooks/use-session'; +import useNotification from '@/hooks/use-notification'; +import { AIService, Weight, getWeightOptions } from '@/services/ai-service'; +import { AiCustomEventDefinition } from '@/services/api-v1'; +import { ComponentContextProps } from './context'; + +export type PvAiEventBuilderProps = { + defaultExpanded?: boolean; +} & ComponentContextProps; + +interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: string; +} + +interface QueryResult { + success: boolean; + columns: Array<{ name: string; type: string }>; + rows: Array>; + row_count: number; + error?: string; +} + +export default function PvAiEventBuilder({ + defaultExpanded = true +}: PvAiEventBuilderProps) { + const api = useApiClient(); + const { getUserId } = useSession(); + const { notifySuccess, notifyError } = useNotification(); + const userId = getUserId(); + + // UI state + const [expanded, setExpanded] = useState(defaultExpanded); + const [selectedWeight, setSelectedWeight] = useState('moderate'); + + // Conversation state + const [conversationHistory, setConversationHistory] = useState([]); + const [userPrompt, setUserPrompt] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + + // SQL state + const [generatedSql, setGeneratedSql] = useState(''); + const [sqlError, setSqlError] = useState(null); + const [sqlSuccess, setSqlSuccess] = useState(null); + + // Test execution state + const [isExecuting, setIsExecuting] = useState(false); + const [queryResult, setQueryResult] = useState(null); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + + // Save dialog state + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [definitionName, setDefinitionName] = useState(''); + const [definitionDescription, setDefinitionDescription] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + // Saved definitions state + const [savedDefinitions, setSavedDefinitions] = useState([]); + const [isLoadingDefinitions, setIsLoadingDefinitions] = useState(false); + const [showDefinitionsSection, setShowDefinitionsSection] = useState(true); + const [editingDefinition, setEditingDefinition] = useState(null); + + // Example prompts for users + const examplePrompts = [ + "Create an event when any proposition I follow receives more than 10 ratings in an hour", + "Notify me when a proposition's aggregate rating using mean exceeds 80%", + "Alert me when a user I follow creates a new proposition", + "Create an event when a tag is used more than 5 times in a day", + "Notify me when a proposition about climate change gets highly rated" + ]; + + // Clear status messages after a delay + useEffect(() => { + if (sqlSuccess) { + const timer = setTimeout(() => setSqlSuccess(null), 5000); + return () => clearTimeout(timer); + } + }, [sqlSuccess]); + + useEffect(() => { + if (sqlError) { + const timer = setTimeout(() => setSqlError(null), 10000); + return () => clearTimeout(timer); + } + }, [sqlError]); + + // Load saved definitions on mount + const loadDefinitions = async () => { + if (!userId) return; + setIsLoadingDefinitions(true); + try { + const definitions = await api.getAiCustomEventDefinitions(userId, true); + setSavedDefinitions(definitions); + } catch (error) { + console.error('Error loading definitions:', error); + } finally { + setIsLoadingDefinitions(false); + } + }; + + useEffect(() => { + if (userId) { + loadDefinitions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); + + // Toggle subscription for a definition + const handleToggleSubscription = async (definition: AiCustomEventDefinition) => { + if (!userId) return; + try { + if (definition.is_subscribed) { + await api.unsubscribeAiCustomEvent(userId, definition.id); + notifySuccess(`Unsubscribed from "${definition.name}"`); + } else { + await api.subscribeAiCustomEvent(userId, definition.id); + notifySuccess(`Subscribed to "${definition.name}"`); + } + await loadDefinitions(); + } catch (error) { + console.error('Error toggling subscription:', error); + notifyError('Failed to update subscription'); + } + }; + + // Delete a definition + const handleDeleteDefinition = async (definition: AiCustomEventDefinition) => { + if (!userId) return; + if (!confirm(`Are you sure you want to delete "${definition.name}"? This cannot be undone.`)) return; + try { + await api.deleteAiCustomEventDefinition(userId, definition.id); + notifySuccess(`Deleted "${definition.name}"`); + await loadDefinitions(); + } catch (error) { + console.error('Error deleting definition:', error); + notifyError('Failed to delete definition'); + } + }; + + // Load a definition into the editor for viewing/editing + const handleViewDefinition = (definition: AiCustomEventDefinition) => { + setGeneratedSql(definition.sql_query); + setEditingDefinition(definition); + setDefinitionName(definition.name); + setDefinitionDescription(definition.description || ''); + }; + + const handleSendPrompt = async () => { + if (!userPrompt.trim() || isGenerating) return; + + const timestamp = new Date().toISOString(); + + // Add user message to conversation + const userMessage: ConversationMessage = { + role: 'user', + content: userPrompt, + timestamp + }; + setConversationHistory(prev => [...prev, userMessage]); + setUserPrompt(''); + setIsGenerating(true); + setSqlError(null); + + try { + // Generate event query SQL using AI + const result = await AIService.generateEventQuery( + userPrompt, + conversationHistory, + selectedWeight + ); + + const assistantMessage: ConversationMessage = { + role: 'assistant', + content: result.explanation || 'Here is the generated SQL query for your event definition.', + timestamp: new Date().toISOString() + }; + setConversationHistory(prev => [...prev, assistantMessage]); + + if (result.success && result.query) { + setGeneratedSql(result.query); + setSqlSuccess('SQL query generated successfully!'); + } else { + setSqlError(result.error || 'Failed to generate SQL query'); + } + } catch (error) { + console.error('Error generating event query:', error); + setSqlError(`Error: ${error instanceof Error ? error.message : String(error)}`); + + const errorMessage: ConversationMessage = { + role: 'assistant', + content: `I encountered an error while generating the query: ${error instanceof Error ? error.message : String(error)}. Please try again or rephrase your request.`, + timestamp: new Date().toISOString() + }; + setConversationHistory(prev => [...prev, errorMessage]); + } finally { + setIsGenerating(false); + } + }; + + const handleTestQuery = async () => { + if (!generatedSql.trim() || isExecuting) return; + + setIsExecuting(true); + setQueryResult(null); + setSqlError(null); + + try { + const result = await api.executeAnalysisQuery(generatedSql); + setQueryResult(result); + if (result.success) { + setSqlSuccess(`Query returned ${result.row_count} rows`); + } else { + setSqlError(result.error || 'Query execution failed'); + } + } catch (error) { + console.error('Error executing query:', error); + setSqlError(`Execution error: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsExecuting(false); + } + }; + + const handleSaveDefinition = async () => { + if (!definitionName.trim() || !generatedSql.trim()) { + notifyError('Please provide a name and ensure SQL is generated'); + return; + } + + if (!userId) { + notifyError('You must be logged in to save definitions'); + return; + } + + setIsSaving(true); + try { + // Create the definition + const definitionId = await api.createAiCustomEventDefinition( + userId, + definitionName, + definitionDescription || userPrompt, + generatedSql, + 'up_only', + false + ); + + // Automatically subscribe to the new definition + await api.subscribeAiCustomEvent(userId, definitionId); + + notifySuccess(`Event definition "${definitionName}" saved and activated!`); + setShowSaveDialog(false); + setDefinitionName(''); + setDefinitionDescription(''); + setEditingDefinition(null); + // Refresh the definitions list + await loadDefinitions(); + } catch (error) { + console.error('Error saving definition:', error); + notifyError(`Failed to save definition: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsSaving(false); + } + }; + + const handleNewConversation = () => { + setConversationHistory([]); + setGeneratedSql(''); + setQueryResult(null); + setSqlError(null); + setSqlSuccess(null); + setEditingDefinition(null); + setDefinitionName(''); + setDefinitionDescription(''); + }; + + const handleCopyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setSqlSuccess('Copied to clipboard!'); + } catch { + setSqlError('Failed to copy to clipboard'); + } + }; + + if (!userId) { + return ( + + You must be logged in to use the AI Event Builder. + + ); + } + + return ( + + + + {/* Header */} + + + AI-Assisted Event Builder + + + {conversationHistory.length > 0 && ( + + )} + setExpanded(!expanded)} + size="small" + > + {expanded ? : } + + + + + + + {/* Description */} + + Describe the event you want to track in natural language. The AI will generate + a SQL query that detects when your condition is met. You can test the query + and save it as a reusable event definition. + + + {/* Model Weight Selector */} + + AI Model Weight + + + + {/* Example Prompts */} + + + Example prompts (click to use): + + + {examplePrompts.map((example, index) => ( + 50 ? example.substring(0, 50) + '...' : example} + variant="outlined" + size="small" + onClick={() => setUserPrompt(example)} + sx={{ cursor: 'pointer' }} + /> + ))} + + + + {/* Conversation History */} + {conversationHistory.length > 0 && ( + + + {conversationHistory.map((msg, idx) => ( + + + {msg.role === 'user' ? 'You' : 'Assistant'} + + + + {msg.content} + + + + ))} + + + )} + + {/* User Input */} + setUserPrompt(e.target.value)} + disabled={isGenerating} + variant="outlined" + label="Your event description" + onKeyDown={(e) => { + if (e.key === 'Enter' && e.ctrlKey) { + handleSendPrompt(); + } + }} + /> + + {/* Action Buttons */} + + + + + + + Tip: Press Ctrl+Enter to send your message + + + {/* Status Messages */} + {sqlSuccess && ( + setSqlSuccess(null)}> + {sqlSuccess} + + )} + {sqlError && ( + setSqlError(null)}> + {sqlError} + + )} + + {/* Generated SQL Section */} + {generatedSql && ( + + + + + Generated Event Query + + + handleCopyToClipboard(generatedSql)} + > + + + + + + setGeneratedSql(e.target.value)} + disabled={isExecuting} + sx={{ + '& .MuiInputBase-root': { + fontFamily: 'monospace', + fontSize: '0.9rem' + } + }} + /> + + + + + + + + )} + + {/* Query Results */} + {queryResult && queryResult.success && ( + + + + Test Results ({queryResult.row_count} rows) + + + + + + + {queryResult.columns.map((col) => ( + + + {col.name} + + + ))} + + + + {queryResult.rows + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, rowIdx) => ( + + {queryResult.columns.map((col) => ( + + {String(row[col.name] ?? '')} + + ))} + + ))} + +
+
+ + {queryResult.row_count > 10 && ( + setPage(newPage)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[10, 25, 50]} + /> + )} +
+
+ )} + + {/* Saved Definitions Section */} + + + + + + Saved Event Definitions + + {savedDefinitions.length > 0 && ( + + )} + + + + + + setShowDefinitionsSection(!showDefinitionsSection)} + > + {showDefinitionsSection ? : } + + + + + + {isLoadingDefinitions ? ( + + + + ) : savedDefinitions.length === 0 ? ( + + No saved event definitions yet. Create one using the AI assistant above! + + ) : ( + + {savedDefinitions.map((def) => ( + + + + + + {def.name} + + {def.is_subscribed && ( + } + label="Active" + size="small" + color="success" + variant="outlined" + /> + )} + {!def.is_own && ( + + )} + + {def.description && ( + + {def.description} + + )} + + + + handleViewDefinition(def)} + > + + + + + handleToggleSubscription(def)} + color={def.is_subscribed ? "success" : "default"} + > + {def.is_subscribed ? ( + + ) : ( + + )} + + + {def.is_own && ( + + handleDeleteDefinition(def)} + color="error" + > + + + + )} + + + + ))} + + )} + + + +
+
+
+
+ + {/* Save Definition Dialog */} + setShowSaveDialog(false)} + maxWidth="sm" + fullWidth + > + Save Event Definition + + + setDefinitionName(e.target.value)} + placeholder="e.g., High Rating Alert" + required + /> + setDefinitionDescription(e.target.value)} + placeholder="Describe what this event tracks..." + /> + + + SQL Query: + + + {generatedSql} + + + + Saving will create the event definition and automatically subscribe you to it. + The system will periodically check for matching events and notify you in the feed. + + + + + + + + +
+ ); +} diff --git a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd505eafc6a685e0eae7076039a60867834cdaa6 --- /dev/null +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -0,0 +1,1404 @@ +"use client"; + +import React, { useState, useRef, useEffect } from 'react'; +import Markdown from 'react-markdown'; +import { + Box, + Paper, + Stack, + Typography, + TextField, + Button, + Alert, + CircularProgress, + Chip, + FormControl, + InputLabel, + Select, + MenuItem, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Checkbox, + FormControlLabel, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton +} from '@mui/material'; +import { + ContentCopy as ContentCopyIcon, + PlayArrow as PlayArrowIcon, + Save as SaveIcon, + Clear as ClearIcon, + Send as SendIcon, + ExpandMore as ExpandMoreIcon, + SmartToy as SmartToyIcon, + Visibility as VisibilityIcon, + AutoFixHigh as AutoFixHighIcon, + Edit as EditIcon, + FolderOpen as FolderOpenIcon, + Add as AddIcon, + Delete as DeleteIcon +} from '@mui/icons-material'; +import { useApiClient } from '@/components/api-context'; +import CodeEditor, { SqlValidationError } from '@/components/code-editor'; +import { useSession } from '@/hooks/use-session'; +import useNotification from '@/hooks/use-notification'; +import { AIService, Weight, getWeightOptions } from '@/services/ai-service'; +import { FeedGenerator } from '@/services/api-v1'; +import { ComponentContextProps } from './context'; + +export type PvAiFeedGeneratorBuilderProps = { + onGeneratorCreated?: (generatorId: number) => void; +} & ComponentContextProps; + +interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: string; +} + +interface QueryResult { + success: boolean; + columns: Array<{ name: string; type: string }>; + rows: Array>; + row_count: number; + error?: string; +} + +const REQUIRED_COLUMNS = [ + 'event_time', + 'event_type', + 'primary_entity_type', + 'primary_entity_id', + 'secondary_entity_type', + 'secondary_entity_id', + 'actor_user_id', + 'event_data' +]; + +interface ParameterDefinition { + name: string; + type: 'string' | 'number' | 'integer' | 'boolean'; + defaultValue: string; +} + +const PARAMETER_TYPES = ['string', 'number', 'integer', 'boolean'] as const; + +function parametersToSchema(parameters: ParameterDefinition[]): { + schema: Record; + defaults: Record; +} { + if (parameters.length === 0) { + return { schema: {}, defaults: {} }; + } + + const properties: Record = {}; + const defaults: Record = {}; + + for (const param of parameters) { + if (!param.name.trim()) continue; + + properties[param.name] = { type: param.type }; + + if (param.defaultValue !== '') { + switch (param.type) { + case 'boolean': + defaults[param.name] = param.defaultValue.toLowerCase() === 'true'; + break; + case 'number': + defaults[param.name] = parseFloat(param.defaultValue) || 0; + break; + case 'integer': + defaults[param.name] = parseInt(param.defaultValue, 10) || 0; + break; + default: + defaults[param.name] = param.defaultValue; + } + } + } + + const schema = Object.keys(properties).length > 0 + ? { type: 'object', properties } + : {}; + + return { schema, defaults }; +} + +function schemaToParameters(schema: Record, defaults: Record): ParameterDefinition[] { + const params: ParameterDefinition[] = []; + const properties = (schema as { properties?: Record }).properties; + + if (!properties) return params; + + for (const [name, prop] of Object.entries(properties)) { + const type = prop.type as ParameterDefinition['type']; + const defaultValue = defaults[name]; + params.push({ + name, + type: PARAMETER_TYPES.includes(type as typeof PARAMETER_TYPES[number]) ? type : 'string', + defaultValue: defaultValue !== undefined ? String(defaultValue) : '' + }); + } + + return params; +} + +const EXAMPLE_PROMPTS = [ + "Show new propositions created in the system", + "Alert when propositions reach above 80% rating", + "Track when users rate propositions", + "Show new arguments linking propositions", + "Track document uploads", + "Show delegation changes", + "Alert when tags are used on new content" +]; + +export default function PvAiFeedGeneratorBuilder({ + onGeneratorCreated +}: PvAiFeedGeneratorBuilderProps) { + const api = useApiClient(); + const { getUserId } = useSession(); + const { notifySuccess, notifyError } = useNotification(); + const userId = getUserId(); + + // Ref for conversation container to enable scroll-to-bottom + const conversationContainerRef = useRef(null); + + // UI state + const [selectedWeight, setSelectedWeight] = useState('heavy'); + const [aiExpanded, setAiExpanded] = useState(true); + + // Conversation state + const [conversationHistory, setConversationHistory] = useState([]); + const [userPrompt, setUserPrompt] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + + // Generated content state + const [generatedSql, setGeneratedSql] = useState(''); + const [generatedMdx, setGeneratedMdx] = useState(''); + const [explanation, setExplanation] = useState(''); + + // Parameter management + const [parameters, setParameters] = useState([]); + + // SQL validation errors from CodeEditor + const [sqlErrors, setSqlErrors] = useState([]); + + // Test execution state + const [isExecuting, setIsExecuting] = useState(false); + const [queryResult, setQueryResult] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + + // Save dialog state + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [generatorName, setGeneratorName] = useState(''); + const [generatorDescription, setGeneratorDescription] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + // Load dialog state + const [showLoadDialog, setShowLoadDialog] = useState(false); + const [availableGenerators, setAvailableGenerators] = useState([]); + const [loadingGenerators, setLoadingGenerators] = useState(false); + const [loadingGenerator, setLoadingGenerator] = useState(false); + + // Prompt preview state + const [showPromptPreview, setShowPromptPreview] = useState(false); + const [showSystemPromptDialog, setShowSystemPromptDialog] = useState(false); + const [generatedPrompt, setGeneratedPrompt] = useState(''); + const [customPrompt, setCustomPrompt] = useState(''); + const [useCustomPrompt, setUseCustomPrompt] = useState(false); + + // Custom base prompt (user's personalized system prompt) + const [customBasePrompt, setCustomBasePrompt] = useState(undefined); + const [isLoadingPrompt, setIsLoadingPrompt] = useState(false); + + // Scroll conversation to bottom when history changes (e.g., after loading a generator) + useEffect(() => { + if (conversationContainerRef.current) { + conversationContainerRef.current.scrollTop = conversationContainerRef.current.scrollHeight; + } + }, [conversationHistory]); + + // Load user's custom prompt on mount + useEffect(() => { + const loadCustomPrompt = async () => { + if (!userId) return; + setIsLoadingPrompt(true); + try { + const result = await api.getUserAiPrompt(userId); + if (result.success && result.custom_prompt) { + setCustomBasePrompt(result.custom_prompt); + } + } catch (error) { + console.error('Failed to load custom prompt:', error); + } finally { + setIsLoadingPrompt(false); + } + }; + loadCustomPrompt(); + }, [userId, api]); + + const handlePreviewPrompt = () => { + if (!userPrompt.trim()) return; + + const historyForAi = conversationHistory.map(msg => ({ + role: msg.role, + content: msg.content + })); + + const prompt = AIService.buildFeedGeneratorPrompt(userPrompt, historyForAi, customBasePrompt); + setGeneratedPrompt(prompt); + setCustomPrompt(prompt); + setUseCustomPrompt(false); + setShowPromptPreview(true); + }; + + const handleViewSystemPrompt = () => { + // Build the current system prompt with any customizations + const prompt = AIService.buildFeedGeneratorPrompt('', [], customBasePrompt); + setGeneratedPrompt(prompt); + setShowSystemPromptDialog(true); + }; + + const handleResetPrompt = async () => { + if (!userId) { + notifyError('Not logged in'); + return; + } + try { + const result = await api.resetUserAiPrompt(userId); + if (result.success) { + setCustomBasePrompt(undefined); + notifySuccess('Prompt reset to default'); + } else { + notifyError('Failed to reset prompt: ' + result.message); + } + } catch { + notifyError('Failed to reset prompt'); + } + }; + + const handleSaveCustomPrompt = async (promptContent: string) => { + if (!userId) { + notifyError('Not logged in'); + return; + } + try { + const result = await api.saveUserAiPrompt(userId, promptContent); + if (result.success) { + setCustomBasePrompt(promptContent); + notifySuccess('Custom prompt saved'); + } else { + notifyError('Failed to save prompt: ' + result.message); + } + } catch { + notifyError('Failed to save prompt'); + } + }; + + const handleSendFromPreview = () => { + setShowPromptPreview(false); + handleGenerate(useCustomPrompt ? customPrompt : undefined); + }; + + const handleGenerate = async (customPromptOverride?: string, promptTextOverride?: string) => { + const promptToSend = promptTextOverride || userPrompt; + if (!promptToSend.trim()) return; + + setIsGenerating(true); + setUserPrompt(''); + + // Add user message to conversation + const userMessage: ConversationMessage = { + role: 'user', + content: promptToSend, + timestamp: new Date().toISOString() + }; + setConversationHistory(prev => [...prev, userMessage]); + + try { + const historyForAi = conversationHistory.map(msg => ({ + role: msg.role, + content: msg.content + })); + + const result = await AIService.generateFeedGeneratorQuery( + promptToSend, + historyForAi, + selectedWeight, + customPromptOverride, + customBasePrompt + ); + + if (result.success) { + // Check if this is a prompt modification response + if (result.prompt_modification) { + // Save the modified prompt + if (userId) { + try { + // Append new customization to existing ones + const newCustomization = customBasePrompt + ? customBasePrompt + '\n\n' + result.prompt_modification + : result.prompt_modification; + const saveResult = await api.saveUserAiPrompt(userId, newCustomization); + if (saveResult.success) { + setCustomBasePrompt(newCustomization); + notifySuccess('Prompt customization saved. Click "View System Prompt" to see the result.'); + } else { + notifyError('Failed to save prompt: ' + saveResult.message); + } + } catch { + notifyError('Failed to save prompt modification'); + } + } + + // Add assistant response + const assistantMessage: ConversationMessage = { + role: 'assistant', + content: (result.explanation || 'Prompt customization has been saved.') + ' Click the "View System Prompt" button to see your customizations.', + timestamp: new Date().toISOString() + }; + setConversationHistory(prev => [...prev, assistantMessage]); + } + + // Update SQL/MDX if provided + if (result.sql_query) { + setGeneratedSql(result.sql_query); + setGeneratedMdx(result.mdx_template || ''); + setExplanation(result.explanation || ''); + + // Add assistant response (if not a prompt modification) + if (!result.prompt_modification) { + const assistantMessage: ConversationMessage = { + role: 'assistant', + content: result.explanation || 'Generated feed generator query and template.', + timestamp: new Date().toISOString() + }; + setConversationHistory(prev => [...prev, assistantMessage]); + } + + // Clear previous test results + setQueryResult(null); + setValidationErrors([]); + } + } else { + const errorMessage = result.error || 'Failed to generate feed generator'; + notifyError(errorMessage); + + const assistantMessage: ConversationMessage = { + role: 'assistant', + content: `Error: ${errorMessage}`, + timestamp: new Date().toISOString() + }; + setConversationHistory(prev => [...prev, assistantMessage]); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + notifyError(errorMessage); + + const assistantMessage: ConversationMessage = { + role: 'assistant', + content: `Error: ${errorMessage}`, + timestamp: new Date().toISOString() + }; + setConversationHistory(prev => [...prev, assistantMessage]); + } finally { + setIsGenerating(false); + } + }; + + const handleTestQuery = async () => { + if (!generatedSql.trim()) return; + + setIsExecuting(true); + setValidationErrors([]); + + try { + // Convert parameters to schema format for testing + const { schema, defaults } = parametersToSchema(parameters); + + const result = await api.testFeedGeneratorQuery(generatedSql, 10, schema, defaults); + + if (result.success) { + setQueryResult({ + success: true, + columns: result.columns || [], + rows: result.rows || [], + row_count: result.row_count || 0 + }); + + // Validate columns + const columnNames = (result.columns || []).map(c => c.name.toLowerCase()); + const errors: string[] = []; + + for (const required of REQUIRED_COLUMNS) { + if (!columnNames.includes(required)) { + errors.push(`Missing required column: ${required}`); + } + } + + setValidationErrors(errors); + + if (errors.length === 0) { + notifySuccess(`Query returned ${result.row_count} rows with all required columns`); + } else { + notifyError(`Query is missing ${errors.length} required column(s)`); + } + } else { + setQueryResult({ + success: false, + columns: [], + rows: [], + row_count: 0, + error: result.error + }); + notifyError(result.error || 'Query execution failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setQueryResult({ + success: false, + columns: [], + rows: [], + row_count: 0, + error: errorMessage + }); + notifyError(errorMessage); + } finally { + setIsExecuting(false); + } + }; + + const handleSave = async () => { + if (!userId) { + notifyError('Not logged in'); + return; + } + + if (!generatorName.trim()) { + notifyError('Name is required'); + return; + } + + if (!generatedSql.trim()) { + notifyError('SQL query is required'); + return; + } + + // Validate parameters have names + const invalidParams = parameters.filter(p => p.name.trim() === ''); + if (invalidParams.length > 0) { + notifyError('All parameters must have a name'); + return; + } + + // Check for duplicate parameter names + const names = parameters.map(p => p.name.trim()); + const duplicates = names.filter((n, i) => names.indexOf(n) !== i); + if (duplicates.length > 0) { + notifyError(`Duplicate parameter name: ${duplicates[0]}`); + return; + } + + // Convert parameters to schema format + const { schema, defaults } = parametersToSchema(parameters); + + setIsSaving(true); + try { + const result = await api.createFeedGenerator( + userId, + generatorName.trim(), + generatorDescription.trim(), + generatedSql.trim(), + schema, + defaults, + generatedMdx.trim() || undefined, + conversationHistory.length > 0 ? conversationHistory : undefined + ); + + if (result.success) { + notifySuccess(`Created feed generator "${generatorName}"`); + setShowSaveDialog(false); + setGeneratorName(''); + setGeneratorDescription(''); + if (result.id !== null) { + onGeneratorCreated?.(result.id); + } + } else { + notifyError(result.message); + } + } catch { + notifyError('Failed to create feed generator'); + } finally { + setIsSaving(false); + } + }; + + const handleCopyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + notifySuccess(`${label} copied to clipboard`); + }; + + const handleNewConversation = () => { + setConversationHistory([]); + setExplanation(''); + setUserPrompt(''); + }; + + const handleClearAll = () => { + setConversationHistory([]); + setGeneratedSql(''); + setGeneratedMdx(''); + setExplanation(''); + setQueryResult(null); + setValidationErrors([]); + setUserPrompt(''); + setParameters([]); + setSqlErrors([]); + }; + + const handleExampleClick = (example: string) => { + setUserPrompt(example); + }; + + // Parameter management + const addParameter = () => { + setParameters([...parameters, { name: '', type: 'string', defaultValue: '' }]); + }; + + const removeParameter = (index: number) => { + setParameters(parameters.filter((_, i) => i !== index)); + }; + + const updateParameter = (index: number, field: keyof ParameterDefinition, value: string) => { + const updated = [...parameters]; + if (field === 'type') { + updated[index] = { ...updated[index], type: value as ParameterDefinition['type'] }; + } else { + updated[index] = { ...updated[index], [field]: value }; + } + setParameters(updated); + }; + + const handleOpenLoadDialog = async () => { + setShowLoadDialog(true); + setLoadingGenerators(true); + try { + const generators = await api.getFeedGenerators(); + // Filter to show only user's own generators (or all if desired) + const userGenerators = userId ? generators.filter(g => g.creator_id === userId) : generators; + setAvailableGenerators(userGenerators); + } catch (error) { + notifyError('Failed to load generators'); + console.error(error); + } finally { + setLoadingGenerators(false); + } + }; + + const handleLoadGenerator = async (generatorId: number) => { + setLoadingGenerator(true); + try { + const generator = await api.getFeedGenerator(generatorId); + if (generator) { + // Populate the form fields + setGeneratedSql(generator.sql_query || ''); + setGeneratedMdx(generator.mdx_template || ''); + + // Convert schema/defaults to parameter array + const loadedParams = schemaToParameters( + generator.parameters_schema || {}, + generator.default_parameters || {} + ); + setParameters(loadedParams); + + // Load conversation history + if (generator.conversation_history && Array.isArray(generator.conversation_history)) { + const history = generator.conversation_history.map((msg) => ({ + role: msg.role as 'user' | 'assistant', + content: msg.content, + timestamp: msg.timestamp || new Date().toISOString() + })); + setConversationHistory(history); + } else { + setConversationHistory([]); + } + + // Pre-fill save dialog with original name/description + setGeneratorName(generator.name || ''); + setGeneratorDescription(generator.description || ''); + + // Clear test results and errors + setQueryResult(null); + setValidationErrors([]); + setSqlErrors([]); + + notifySuccess(`Loaded generator: ${generator.name}`); + setShowLoadDialog(false); + } else { + notifyError('Generator not found'); + } + } catch (error) { + notifyError('Failed to load generator'); + console.error(error); + } finally { + setLoadingGenerator(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + handleGenerate(); + } + }; + + // Format error message for AI to fix + const formatErrorForAI = (): string => { + const parts: string[] = []; + + if (queryResult?.error) { + parts.push(`SQL Error: ${queryResult.error}`); + } + + if (validationErrors.length > 0) { + parts.push(`Missing required columns: ${validationErrors.join(', ')}`); + } + + parts.push(`\nThe failing SQL query:\n\`\`\`sql\n${generatedSql}\n\`\`\``); + parts.push(`\nPlease fix the query to resolve this error.`); + + return parts.join('\n'); + }; + + // One-click fix: auto-send error to AI + const handleFixError = () => { + const errorMessage = formatErrorForAI(); + // Pass error message directly to handleGenerate to avoid state timing issues + handleGenerate(undefined, errorMessage); + }; + + // Populate prompt with error for user review before sending + const handlePopulateErrorPrompt = () => { + const errorMessage = formatErrorForAI(); + setUserPrompt(errorMessage); + // Expand AI section if collapsed + setAiExpanded(true); + }; + + return ( + + + {/* Header */} + + Feed Generator Builder + + + + + + + + {/* AI Assistant Section (Collapsible) */} + setAiExpanded(expanded)} + sx={{ '&:before': { display: 'none' } }} + > + }> + + + AI Assistant + + (optional - describe what you want to create) + + + + + + {/* Model selector and New Conversation */} + + + Model + + + + + + {/* Example prompts */} + + + Example prompts (click to use): + + + {EXAMPLE_PROMPTS.map((example, index) => ( + handleExampleClick(example)} + sx={{ cursor: 'pointer', mb: 0.5 }} + /> + ))} + + + + {/* Conversation history */} + {conversationHistory.length > 0 && ( + + + {conversationHistory.map((msg, index) => ( + + + {msg.role === 'user' ? 'You' : 'Assistant'}: + + + {msg.content} + + + ))} + + + )} + + {/* User input */} + + setUserPrompt(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isGenerating} + /> + + + + + + + + + + + + + + {/* SQL Query Editor (Always visible) */} + + + + {generatedSql && ( + + + + )} + + setGeneratedSql(e.target.value)} + language="sql" + required + rows={10} + helperText="SQL query returning: event_time, event_type, primary_entity_type, primary_entity_id, secondary_entity_type, secondary_entity_id, actor_user_id, event_data" + enableLinting + onValidationChange={setSqlErrors} + validParameters={['user_id', ...parameters.map(p => p.name).filter(n => n.trim() !== '')]} + /> + + + {/* MDX Template Editor (Always visible) */} + + + + {generatedMdx && ( + + + + )} + + setGeneratedMdx(e.target.value)} + language={["markdown", "javascript"]} + rows={5} + helperText="MDX template for rendering feed items (optional)" + /> + + + {/* Parameters Section */} + + + Parameters (optional) + + + {parameters.length > 0 ? ( + + + + + Name + Type + Default Value + + + + + {parameters.map((param, index) => ( + + + updateParameter(index, 'name', e.target.value)} + placeholder="parameter_name" + fullWidth + sx={{ '& .MuiInputBase-input': { fontFamily: 'monospace' } }} + /> + + + + + + updateParameter(index, 'defaultValue', e.target.value)} + placeholder={param.type === 'boolean' ? 'true/false' : ''} + fullWidth + sx={{ '& .MuiInputBase-input': { fontFamily: 'monospace' } }} + /> + + + removeParameter(index)} + color="error" + > + + + + + ))} + +
+
+ ) : ( + + No parameters defined. Click "Add Parameter" to add configurable parameters. Use $parameter_name$ syntax in SQL. + + )} +
+ + {/* Action buttons */} + + + + + + {/* SQL Validation Errors */} + {sqlErrors.length > 0 && ( + + SQL Syntax Errors: +
    + {sqlErrors.map((error, index) => ( +
  • + {error.line ? `Line ${error.line}: ` : ''}{error.message} +
  • + ))} +
+
+ )} + + {/* Validation errors */} + {validationErrors.length > 0 && ( + + Missing required columns: +
    + {validationErrors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} + + {/* Error reporting buttons */} + {(queryResult?.error || validationErrors.length > 0) && ( + + + + + )} + + {/* Query results */} + {queryResult && ( + + + Test Results ({queryResult.row_count} rows) + + {queryResult.error ? ( + {queryResult.error} + ) : queryResult.rows.length > 0 ? ( + <> + + + + + {queryResult.columns.map((col, index) => ( + + + {col.name} + + + {col.type} + + + ))} + + + + {queryResult.rows + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, rowIndex) => ( + + {queryResult.columns.map((col, colIndex) => ( + + + {typeof row[col.name] === 'object' + ? JSON.stringify(row[col.name]) + : String(row[col.name] ?? '')} + + + ))} + + ))} + +
+
+ setPage(newPage)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[5, 10, 25, 50]} + /> + + ) : ( + Query returned no rows + )} +
+ )} + + {/* Save Dialog */} + setShowSaveDialog(false)} maxWidth="sm" fullWidth> + Save Feed Generator + + + setGeneratorName(e.target.value)} + required + fullWidth + helperText="A unique name for this feed generator" + /> + setGeneratorDescription(e.target.value)} + fullWidth + multiline + rows={2} + helperText="Describe what events this generator produces" + /> + + + SQL Query: + + +
+                                        {generatedSql}
+                                    
+
+
+ {generatedMdx && ( + + + MDX Template: + + +
+                                            {generatedMdx}
+                                        
+
+
+ )} +
+
+ + + + +
+ + {/* Prompt Preview Dialog */} + setShowPromptPreview(false)} + maxWidth="lg" + fullWidth + > + + + Full Prompt to Claude + + {(useCustomPrompt ? customPrompt : generatedPrompt).length.toLocaleString()} characters + + + + + + setUseCustomPrompt(e.target.checked)} + /> + } + label="Use edited prompt instead of auto-generated" + /> + setCustomPrompt(e.target.value)} + disabled={!useCustomPrompt} + sx={{ + fontFamily: 'monospace', + '& .MuiInputBase-input': { + fontFamily: 'monospace', + fontSize: '0.8rem' + } + }} + /> + + This prompt includes: database schema context, feed generator contract, + example queries, conversation history (if any), and your request. + {customBasePrompt && ( + + (Using your customized base prompt) + + )} + + + Tip: Ask the AI to "record this lesson in the system prompt" to save learnings for future use. + + + + + {customBasePrompt && ( + + )} + + + + + + + {/* System Prompt Dialog */} + setShowSystemPromptDialog(false)} + maxWidth="lg" + fullWidth + > + + + + System Prompt + {customBasePrompt && ( + + )} + + + + + + This is the base system prompt that guides the AI assistant. + {customBasePrompt && ' Your customizations appear in the "USER CUSTOMIZATIONS" section.'} + + + + + + {customBasePrompt && ( + + )} + + + + + + {/* Load Generator Dialog */} + setShowLoadDialog(false)} + maxWidth="md" + fullWidth + > + Load Feed Generator + + {loadingGenerators ? ( + + + + ) : availableGenerators.length === 0 ? ( + + No saved generators found. Create a new generator using the AI assistant above. + + ) : ( + + + + + Name + Description + Created + Action + + + + {availableGenerators.map((generator) => ( + + + + {generator.name} + + + + + {generator.description || '-'} + + + + + {new Date(generator.creation_time).toLocaleDateString()} + + + + + + + ))} + +
+
+ )} +
+ + + +
+
+
+ ); +} diff --git a/ratings-ui-demo/src/pvcomponents/pv-current-time.tsx b/ratings-ui-demo/src/pvcomponents/pv-current-time.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dac811f2d8957919d35aaa03cc4e5bfc1f6f303a --- /dev/null +++ b/ratings-ui-demo/src/pvcomponents/pv-current-time.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Typography } from "@mui/material"; +import { useEffect, useState } from "react"; + +export default function PvCurrentTime({ + variant = "body2", + format = "datetime", + updateInterval = 1000 +}: { + variant?: "body1" | "body2" | "caption" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "subtitle1" | "subtitle2"; + format?: "datetime" | "date" | "time"; + updateInterval?: number; +}) { + const [currentTime, setCurrentTime] = useState(null); + + useEffect(() => { + // Set initial time on client + setCurrentTime(new Date()); + + const interval = setInterval(() => { + setCurrentTime(new Date()); + }, updateInterval); + + return () => clearInterval(interval); + }, [updateInterval]); + + const formatTime = (date: Date) => { + switch (format) { + case "date": + return date.toLocaleDateString(); + case "time": + return date.toLocaleTimeString(); + case "datetime": + default: + return date.toLocaleString(); + } + }; + + // Don't render until client-side to avoid hydration mismatch + if (!currentTime) { + return null; + } + + return ( + + {formatTime(currentTime)} + + ); +} diff --git a/ratings-ui-demo/src/pvcomponents/pv-feed-management.tsx b/ratings-ui-demo/src/pvcomponents/pv-feed-management.tsx index 9e86933f4e37422bd0c548487d67e14b81229d65..a4583a74a476d78f8f01a292a3344dd84551d4cc 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-feed-management.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-feed-management.tsx @@ -7,6 +7,7 @@ import { Button, Card, CardContent, + Checkbox, Dialog, DialogActions, DialogContent, @@ -54,6 +55,11 @@ export default function PvFeedManagement({ componentKey, userId }: PvFeedManagem const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteAssignment, setDeleteAssignment] = useState(null); + // Batch selection state + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectionMode, setSelectionMode] = useState(false); + const [deleteBatchDialogOpen, setDeleteBatchDialogOpen] = useState(false); + const fetchData = useCallback(async () => { if (!effectiveUserId) return; @@ -186,6 +192,70 @@ export default function PvFeedManagement({ componentKey, userId }: PvFeedManagem setAddDialogOpen(true); }; + // Selection helper functions + const toggleSelection = (assignmentId: number) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(assignmentId)) { + next.delete(assignmentId); + } else { + next.add(assignmentId); + } + return next; + }); + }; + + const selectAll = () => { + setSelectedIds(new Set(userGenerators.map(g => g.assignment_id))); + }; + + const deselectAll = () => { + setSelectedIds(new Set()); + }; + + const invertSelection = () => { + const allIds = new Set(userGenerators.map(g => g.assignment_id)); + setSelectedIds(prev => { + const next = new Set(); + allIds.forEach(id => { + if (!prev.has(id)) next.add(id); + }); + return next; + }); + }; + + // Handle batch delete + const handleBatchDelete = async () => { + if (!effectiveUserId || selectedIds.size === 0) return; + + try { + let successCount = 0; + let failCount = 0; + + for (const assignmentId of selectedIds) { + const result = await api.removeGeneratorFromFeed(assignmentId, effectiveUserId); + if (result.success) { + successCount++; + } else { + failCount++; + } + } + + if (failCount === 0) { + notifySuccess(`Removed ${successCount} generator(s) from your feed`); + } else { + notifyError(`Removed ${successCount}, failed to remove ${failCount}`); + } + + setSelectedIds(new Set()); + setDeleteBatchDialogOpen(false); + setSelectionMode(false); + fetchData(); + } catch { + notifyError('Failed to remove generators'); + } + }; + if (!effectiveUserId) { return No user ID available; } @@ -198,17 +268,55 @@ export default function PvFeedManagement({ componentKey, userId }: PvFeedManagem Your Feed Generators - + + {userGenerators.length > 0 && ( + + )} + + + {/* Selection toolbar */} + {selectionMode && userGenerators.length > 0 && ( + + + + + + + )} + {userGenerators.length === 0 ? ( No feed generators assigned. Click "Add Generator" to add some. @@ -223,6 +331,13 @@ export default function PvFeedManagement({ componentKey, userId }: PvFeedManagem + {selectionMode && ( + toggleSelection(assignment.assignment_id)} + sx={{ mt: -0.5, ml: -1, mr: 0.5 }} + /> + )} {assignment.generator_name} @@ -251,14 +366,16 @@ export default function PvFeedManagement({ componentKey, userId }: PvFeedManagem )} - openDeleteDialog(assignment)} - title="Remove from feed" - color="error" - > - - + {!selectionMode && ( + openDeleteDialog(assignment)} + title="Remove from feed" + color="error" + > + + + )} @@ -375,6 +492,22 @@ export default function PvFeedManagement({ componentKey, userId }: PvFeedManagem
+ + {/* Batch Delete Confirmation Dialog */} + setDeleteBatchDialogOpen(false)}> + Remove Generators + + + Are you sure you want to remove {selectedIds.size} generator(s) from your feed? + + + + + + + ); } diff --git a/ratings-ui-demo/src/pvcomponents/pv-filtered-entity-list.tsx b/ratings-ui-demo/src/pvcomponents/pv-filtered-entity-list.tsx index 58612d12c99436a71ae8b1876fcc2486cc744a47..0b0e4b4d6fd85bcc3818fb67dc036e37afd20272 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-filtered-entity-list.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-filtered-entity-list.tsx @@ -13,7 +13,7 @@ import useNotification from '@/hooks/use-notification'; import { useEventListener } from './events'; import { PropositionCreatedEvent, PropositionEventTypes, PropositionLinkedPropositionCreatedEvent, PropositionRewordingCreatedEvent, PropositionTagCreatedEvent } from './proposition-events'; import { FilteredEntityListViewHandle, ItemRendererProps, PagingMode } from '@/components/filtered-entity-list-view'; -import { CommunicationPathFilteredListItemRenderer, DefaultFilteredListItemRenderer, DefinitionFilteredListItemRenderer, DelegationFilteredListItemRenderer, DocumentFilteredListItemRenderer2, DocumentLinkedPropositionFilteredListItemRenderer, PropositionFilteredListItemRenderer2, RatingFilteredListItemRenderer, SourceFilteredListItemRenderer, TagFilteredListItemRenderer, TagWithDelegationFilteredListItemRenderer, TodoItemFilteredListItemRenderer, UserActivityFilteredListItemRenderer, UserFilteredListItemRenderer, EntityTypeFilteredListItemRenderer, EntityFilterFilteredListItemRenderer, EntityAssociationFilteredListItemRenderer, PropositionTemplateFilteredListItemRenderer, FollowedEventFilteredListItemRenderer, FollowedTagEventFilteredListItemRenderer, FollowedDocumentEventFilteredListItemRenderer, FeedGeneratorFilteredListItemRenderer } from '@/components/filteredentitylistitemrenderers'; +import { CommunicationPathFilteredListItemRenderer, DefaultFilteredListItemRenderer, DefinitionFilteredListItemRenderer, DelegationFilteredListItemRenderer, DocumentFilteredListItemRenderer2, DocumentLinkedPropositionFilteredListItemRenderer, PropositionFilteredListItemRenderer2, RatingFilteredListItemRenderer, SourceFilteredListItemRenderer, TagFilteredListItemRenderer, TagWithDelegationFilteredListItemRenderer, TodoItemFilteredListItemRenderer, UserActivityFilteredListItemRenderer, UserFilteredListItemRenderer, EntityTypeFilteredListItemRenderer, EntityFilterFilteredListItemRenderer, EntityAssociationFilteredListItemRenderer, PropositionTemplateFilteredListItemRenderer, FollowedEventFilteredListItemRenderer, FollowedTagEventFilteredListItemRenderer, FollowedDocumentEventFilteredListItemRenderer, FeedGeneratorFilteredListItemRenderer, UnifiedFollowedEventRenderer } from '@/components/filteredentitylistitemrenderers'; import { FilteredEntityListFilters } from '@/components/filtered-entity-list-filters'; import { FilteredEntityListSort } from '@/components/filtered-entity-list-sort'; import { usePageClientValue } from '@/components/dynamic-mdx-page-client-side-context'; @@ -225,6 +225,167 @@ export default function PvFilteredEntityList({ listRef.current?.refresh(); }); + const getItemRenderer = (item: FilterOutputData, index: number) => { + if (entityTypeProp === "proposition" || entityTypeProp === "proposition_linked_from_document") { + return ( + `/pages/proposition?proposition_id=${id}`} + /> + ); + } else if (entityTypeProp === "document" || entityTypeProp === "document_with_parent" || entityTypeProp === "document_with_child" || entityTypeProp === "proposition_linked_document") { + return ( + `/pages/document?document_id=${id}`} + newDocumentHref="/pages/new-document" + getDocumentTreeHref={(id) => `/pages/document-tree?document_id=${id}`} + /> + ); + } else if (entityTypeProp === "user") { + return ( + `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "user_activity") { + return ( + + ); + } else if (entityTypeProp === "tag") { + return ( + `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "definition") { + return ( + + ); + } else if (entityTypeProp === "rating") { + return ( + `/pages/proposition?proposition_id=${propositionId}`} + getUserHref={(userId, username) => `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "source" || entityTypeProp === "proposition_earliest_source" || entityTypeProp === "document_earliest_source") { + return ( + `/pages/source?source_id=${sourceId}`} + getUserHref={(userId, username) => `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "communication_path") { + return ( + `/pages/communication-path?communication_path_id=${pathId}`} + getUserHref={(userId, username) => `/pages/user?username=${username}`} + getSourceHref={(sourceId) => `/pages/source?source_id=${sourceId}`} + /> + ); + } else if (entityTypeProp === "todo_item") { + return ( + `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "delegation") { + return ( + `/pages/tag?tag_id=${tagId}`} + getUserHref={(userId, username) => `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "tag_with_delegation") { + return ( + `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "entity_type") { + return ( + `/pages/entity-type?entity_type_id=${id}`} + getUserHref={(userId, username) => `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "entity_filter") { + return ( + `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "entity_association") { + return ( + `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "proposition_template") { + return ( + `/pages/template?template_id=${templateId}`} + getUserHref={(userId, username) => `/pages/user?username=${username}`} + /> + ); + } else if (entityTypeProp === "document_linked_proposition") { + return ( + + ); + } else if (entityTypeProp === "followed_event") { + return ( + + ); + } else if (entityTypeProp === "followed_tag_event") { + return ( + + ); + } else if (entityTypeProp === "followed_document_event") { + return ( + + ); + } else if (entityTypeProp === "unified_followed_event") { + return ( + + ); + } else { + return (); + } + }; + return ( {componentKey && canSaveContext() && diff --git a/ratings-ui-demo/src/pvcomponents/pv-follow-manager.tsx b/ratings-ui-demo/src/pvcomponents/pv-follow-manager.tsx index 8924b82d70729d4b8d8e342ef91ba4d497708fbe..9fab22d29f9ada08eda9cdc759f5f48d09b30a14 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-follow-manager.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-follow-manager.tsx @@ -238,7 +238,7 @@ export default function PvFollowManager({ diff --git a/ratings-ui-demo/src/pvcomponents/pv-statistical-event-definitions.tsx b/ratings-ui-demo/src/pvcomponents/pv-statistical-event-definitions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..184f7bd75cc021d0077947bbd5bd049f9946f53a --- /dev/null +++ b/ratings-ui-demo/src/pvcomponents/pv-statistical-event-definitions.tsx @@ -0,0 +1,318 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + Box, + Button, + Card, + CardContent, + Chip, + IconButton, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Typography, + Tooltip, + CircularProgress, + Stack, + Alert +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import PublicIcon from "@mui/icons-material/Public"; +import LockIcon from "@mui/icons-material/Lock"; +import { useApiClient } from "@/components/api-context"; +import { useSession } from "@/hooks/use-session"; +import useNotification from "@/hooks/use-notification"; +import { StatisticalEventDefinition } from "@/services/api-v1"; +import { + StatisticalEventDefinitionDialog, + StatisticalEventDefinition as DialogDefinition +} from "@/components/statistical-event-definition-dialog"; + +export interface PvStatisticalEventDefinitionsProps { + showPublished?: boolean; +} + +export function PvStatisticalEventDefinitions({ + showPublished = true +}: PvStatisticalEventDefinitionsProps) { + const api = useApiClient(); + const { getUserId } = useSession(); + const { notifySuccess, notifyError } = useNotification(); + + const [definitions, setDefinitions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingDefinition, setEditingDefinition] = useState(undefined); + + const loadDefinitions = useCallback(async () => { + const userId = getUserId(); + if (!userId) { + setError('You must be logged in to view definitions'); + setLoading(false); + return; + } + + try { + setLoading(true); + const defs = await api.getStatisticalEventDefinitions(userId, showPublished); + setDefinitions(defs); + setError(null); + } catch (err) { + console.error('Failed to load definitions:', err); + setError('Failed to load definitions'); + } finally { + setLoading(false); + } + }, [api, getUserId, showPublished]); + + useEffect(() => { + loadDefinitions(); + }, [loadDefinitions]); + + const handleCreate = () => { + setEditingDefinition(undefined); + setDialogOpen(true); + }; + + const handleEdit = (def: StatisticalEventDefinition) => { + // Convert to dialog format + const dialogDef: DialogDefinition = { + id: def.id, + name: def.name, + description: def.description, + entity_type: (def.entity_type || 'proposition') as 'proposition' | 'tag', + metric_type: def.metric_type, + rating_algorithm_id: def.rating_algorithm_id, + threshold_value: def.threshold_value, + crossing_direction: def.crossing_direction, + is_published: def.is_published, + time_window_minutes: def.time_window_minutes + }; + setEditingDefinition(dialogDef); + setDialogOpen(true); + }; + + const handleDelete = async (def: StatisticalEventDefinition) => { + const userId = getUserId(); + if (!userId) { + notifyError('You must be logged in'); + return; + } + + if (!def.is_own) { + notifyError('You can only delete your own definitions'); + return; + } + + if (!confirm(`Delete definition "${def.name}"?`)) { + return; + } + + try { + await api.deleteStatisticalEventDefinition(userId, def.id); + notifySuccess('Definition deleted'); + loadDefinitions(); + } catch (err) { + console.error('Failed to delete definition:', err); + notifyError('Failed to delete definition'); + } + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setEditingDefinition(undefined); + }; + + const handleSaved = () => { + loadDefinitions(); + }; + + const formatThreshold = (def: StatisticalEventDefinition): string => { + if (def.metric_type === 'aggregate_rating') { + return `${Math.round(def.threshold_value * 100)}%`; + } + return `${def.threshold_value} ratings`; + }; + + const formatMetricType = (def: StatisticalEventDefinition): string => { + if (def.metric_type === 'aggregate_rating') { + return `Rating (${def.rating_algorithm_name || 'algorithm'})`; + } + return 'Rating count'; + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return {error}; + } + + const ownDefinitions = definitions.filter(d => d.is_own); + const publishedDefinitions = definitions.filter(d => !d.is_own && d.is_published); + + return ( + + + + Your Statistical Event Definitions + + + + + {ownDefinitions.length === 0 ? ( + + + + You haven't created any statistical event definitions yet. + Create one to track events like "rating reaches 90%" or "gets 100 ratings". + + + + ) : ( + + {ownDefinitions.map((def) => ( + + + + {def.name} + + + {def.is_published ? ( + + ) : ( + + )} + + + } + secondary={ + + + + + + } + /> + + + handleEdit(def)}> + + + + + handleDelete(def)}> + + + + + + ))} + + )} + + {showPublished && publishedDefinitions.length > 0 && ( + <> + + Published Definitions from Others + + + {publishedDefinitions.map((def) => ( + + + + {def.name} + + + + } + secondary={ + + + + + + } + /> + + ))} + + + )} + + + + ); +} diff --git a/ratings-ui-demo/src/pvcomponents/pv-user-feed-list.tsx b/ratings-ui-demo/src/pvcomponents/pv-user-feed-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..537e6e462720b7dbbcffad930e4c46ff85b6e1c3 --- /dev/null +++ b/ratings-ui-demo/src/pvcomponents/pv-user-feed-list.tsx @@ -0,0 +1,302 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from 'react'; +import { Box, Button, CircularProgress, Link, Stack, Typography, Alert } from '@mui/material'; +import { useApiClient } from '@/components/api-context'; +import { useSession } from '@/hooks/use-session'; +import { UserFeedEvent } from '@/services/api-v1'; + +export interface PvUserFeedListProps { + componentKey: string; + userId?: number; + pageSize?: number; +} + +const getEntityLink = (entityType: string, entityId: number): string => { + switch (entityType) { + case 'proposition': + return `/pages/proposition?proposition_id=${entityId}`; + case 'document': + return `/pages/document?document_id=${entityId}`; + case 'source': + return `/pages/source?source_id=${entityId}`; + case 'rating': + return `/pages/ratings`; + case 'communication_path': + return `/pages/communication-paths`; + default: + return '#'; + } +}; + +const formatEventType = (eventType: string): string => { + return eventType + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +const renderEventContent = (event: UserFeedEvent): React.ReactNode => { + const data = event.event_data; + + switch (event.event_type) { + case 'proposition_created': + return ( + + {String(data.body || 'Untitled proposition')} + + ); + + case 'proposition_rated': + return ( + + + rated {String(data.rating)}% on + + + {String(data.proposition_body || 'proposition')} + + + ); + + case 'linked_proposition': + return ( + + + + {String(data.origin_body || 'argument')} + + + + {String(data.link_type)}{' '} + + {String(data.target_body || 'target proposition')} + + + + ); + + case 'document_created': + return ( + + {String(data.title || 'Untitled document')} + + ); + + case 'source_used': + return ( + + + {String(data.source_name || 'source')} + + + via {String(data.medium_type)} + {data.receiver_name ? ` to ${String(data.receiver_name)}` : ''} + + + ); + + default: + // For custom generators, try to render event_data in a reasonable way + return ( + + {event.primary_entity_type && event.primary_entity_id && ( + + View {event.primary_entity_type} #{event.primary_entity_id} + + )} + {Object.keys(data).length > 0 && ( + + {Object.entries(data).map(([key, value]) => ( + {key}: {String(value)} + ))} + + )} + + ); + } +}; + +const FeedEventItem: React.FC<{ event: UserFeedEvent }> = ({ event }) => { + return ( + + + + + {formatEventType(event.event_type)} + + + {event.generator_name} + + + + + {renderEventContent(event)} + + + + By{' '} + + {event.actor_username} + + {' • '} + {new Date(event.event_time).toLocaleString()} + + + + ); +}; + +export const PvUserFeedList: React.FC = ({ + componentKey, + userId: propUserId, + pageSize = 20 +}) => { + const api = useApiClient(); + const session = useSession(); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + + const userId = propUserId ?? session.getUserId(); + + const loadEvents = useCallback(async (offset: number = 0) => { + if (!userId) { + setError('No user ID available'); + setLoading(false); + return; + } + + try { + const isInitialLoad = offset === 0; + if (isInitialLoad) { + setLoading(true); + } else { + setLoadingMore(true); + } + + const newEvents = await api.getUserFeedEvents(userId, pageSize, offset); + + if (isInitialLoad) { + setEvents(newEvents); + } else { + setEvents(prev => [...prev, ...newEvents]); + } + + setHasMore(newEvents.length === pageSize); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load feed events'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, [api, userId, pageSize]); + + useEffect(() => { + loadEvents(0); + }, [loadEvents]); + + const handleLoadMore = () => { + if (!loadingMore && hasMore) { + loadEvents(events.length); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (events.length === 0) { + return ( + + No feed events found. Add some generators to your feed in{' '} + Feed Management. + + ); + } + + return ( + + {events.map((event) => ( + + ))} + + {hasMore && ( + + + + )} + + ); +}; + +export default PvUserFeedList; diff --git a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts index fa3373e9d213f125be65f205208ec1fc6872f4fc..ddbb561e49d763d3985c9f5c2c22c3da3a46db42 100644 --- a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts +++ b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts @@ -64,6 +64,11 @@ import PvFeedEventList from "./pv-feed-event-list"; import PvFollowListManager from "./pv-follow-list-manager"; import PvFollowListDetail from "./pv-follow-list-detail"; import PvQuickFollowButton from "./pv-quick-follow-button"; +import PvUserFeedList from "./pv-user-feed-list"; +import PvCurrentTime from "./pv-current-time"; +import { PvStatisticalEventDefinitions } from "./pv-statistical-event-definitions"; +import PvAiEventBuilder from "./pv-ai-event-builder"; +import PvAiFeedGeneratorBuilder from "./pv-ai-feed-generator-builder"; const usePVComponents = () => { const components = { @@ -77,6 +82,11 @@ const usePVComponents = () => { PvFollowListManager, PvFollowListDetail, PvQuickFollowButton, + PvUserFeedList, + PvCurrentTime, + PvStatisticalEventDefinitions, + PvAiEventBuilder, + PvAiFeedGeneratorBuilder, PvPageDirectory, PvPageSearch, PvText, diff --git a/ratings-ui-demo/src/services/ai-service.ts b/ratings-ui-demo/src/services/ai-service.ts index 7d4c691876a0c1b2ae684af6878a60aa2da7469e..c9df4e7d6889c1d2ee6b2bff8e2cab92aff1dea5 100644 --- a/ratings-ui-demo/src/services/ai-service.ts +++ b/ratings-ui-demo/src/services/ai-service.ts @@ -63,6 +63,22 @@ export interface UserSelectionQueryResult { error?: string; } +export interface EventQueryResult { + success: boolean; + query?: string; + explanation?: string; + error?: string; +} + +export interface FeedGeneratorQueryResult { + prompt_modification?: string; // If set, contains the modified system prompt the user requested + success: boolean; + sql_query?: string; + mdx_template?: string; + explanation?: string; + error?: string; +} + /** * Exact port of Python AIService class */ @@ -153,8 +169,9 @@ Generate the SQL query (return ONLY the SQL, no explanations):`; generatedSql = codeBlockMatch[1].trim(); } - // Basic validation - if (!generatedSql.toUpperCase().startsWith('SELECT')) { + // Basic validation - allow SELECT or WITH (CTEs) + const upperSql = generatedSql.toUpperCase().trim(); + if (!upperSql.startsWith('SELECT') && !upperSql.startsWith('WITH')) { return { success: false, error: 'Generated query is not a SELECT statement', @@ -295,6 +312,32 @@ Generate the SQL query (return ONLY the SQL, no explanations):`; - creator_id (integer): User who created this source - creation_time (timestamp): When created + Table: cached_aggregate_ratings (pre-computed aggregate ratings per algorithm) + - id (integer, primary key): Unique record ID + - proposition_id (integer, foreign key to propositions.id): The proposition this rating is for + - creator_id (integer, foreign key to users.id): **The algorithm's user account** - links to rating_calculation_procedures + - aggregate_rating (numeric): The calculated rating value (0 = false, 1 = true) + - number_of_ratings (integer): Count of individual ratings used in calculation + - weight_of_ratings (numeric): Sum of weights used in calculation + - median_stat, mode_stat, stddev_stat, var_stat, min_stat, max_stat, range_stat, q1_stat, q3_stat, iqr_stat (numeric): Statistical values + + ⚠️ IMPORTANT: cached_aggregate_ratings.creator_id is the ALGORITHM's user ID, NOT a human user! + To find ratings by algorithm name, join to rating_calculation_procedures on creator_id. + + Table: rating_calculation_procedures (rating algorithms) + - id (integer, primary key): Algorithm ID + - name (text): Algorithm name displayed to users ('Mean', 'Median', 'Statistics', etc.) + - creator_id (integer, foreign key to users.id): **The algorithm's user account** - use this to join with cached_aggregate_ratings + - procedure_name (text): Internal procedure name + - is_enabled (boolean): Whether the algorithm is active + - description (text): Algorithm description + + ⚠️ CRITICAL JOIN PATTERN for aggregate ratings by algorithm: + SELECT car.aggregate_rating + FROM cached_aggregate_ratings car + JOIN rating_calculation_procedures rcp ON car.creator_id = rcp.creator_id + WHERE rcp.name = 'Mean' -- Use 'Mean', 'Median', 'Statistics', etc. + COMMON MISTAKES TO AVOID: ❌ WRONG: WHERE p.proposition = 'some text' ✅ RIGHT: WHERE p.body = 'some text' @@ -305,6 +348,13 @@ Generate the SQL query (return ONLY the SQL, no explanations):`; ❌ WRONG: WHERE t.tag = 'economics' ✅ RIGHT: WHERE t.name = 'economics' + ❌ WRONG: JOIN calculated_ratings cr ON cr.proposition_id = p.id (table doesn't exist!) + ✅ RIGHT: JOIN cached_aggregate_ratings car ON car.proposition_id = p.id + JOIN rating_calculation_procedures rcp ON car.creator_id = rcp.creator_id + + ❌ WRONG: WHERE cr.algorithm = 'mean' (no 'algorithm' column exists) + ✅ RIGHT: WHERE rcp.name = 'Mean' (use rating_calculation_procedures.name) + CORRECT SQL EXAMPLES: -- Finding users who rated a specific proposition by its text: SELECT DISTINCT lvr.creator_id AS user_id @@ -374,18 +424,27 @@ Generate the SQL query (return ONLY the SQL, no explanations):`; - proposition_id (integer): The proposition being rated - rating (numeric): Rating value (0 = false, 1 = true) - Table: rating_algorithms + Table: rating_calculation_procedures (rating algorithms) - id (integer): Algorithm ID - - name (text): Algorithm name ('mean', 'median', 'statistics', etc.) - - display_name (text): User-friendly name + - name (text): Algorithm name displayed to users ('Mean', 'Median', 'Statistics', etc.) + - creator_id (integer): **The algorithm's user account** - use this to join with cached_aggregate_ratings + - procedure_name (text): Internal procedure name + - is_enabled (boolean): Whether the algorithm is active - description (text): Algorithm description - View: calculated_ratings (aggregated ratings by algorithm) - - proposition_id (integer): Proposition ID - - algorithm (text): Algorithm name - - rating (numeric): Calculated rating value - - user_count (integer): Number of users included - - last_updated (timestamp): When last calculated + Table: cached_aggregate_ratings (aggregated ratings per algorithm) + - proposition_id (integer): The proposition this rating is for + - creator_id (integer): **The algorithm's user ID** - join to rating_calculation_procedures.creator_id + - aggregate_rating (numeric): Calculated rating value (0 to 1) + - number_of_ratings (integer): Number of ratings used + - weight_of_ratings (numeric): Sum of weights used + - median_stat, mode_stat, stddev_stat, var_stat, min_stat, max_stat, range_stat, q1_stat, q3_stat, iqr_stat (numeric): Statistical values + + ⚠️ CRITICAL JOIN PATTERN for aggregate ratings by algorithm: + SELECT car.aggregate_rating + FROM cached_aggregate_ratings car + JOIN rating_calculation_procedures rcp ON car.creator_id = rcp.creator_id + WHERE rcp.name = 'Mean' -- Use 'Mean', 'Median', 'Statistics', etc. Table: delegation_weights (calculated delegation weights) - delegator_id (integer): User delegating @@ -455,11 +514,12 @@ Generate the SQL query (return ONLY the SQL, no explanations):`; SELECT p.id AS proposition_id, p.body AS proposition, - MAX(CASE WHEN cr.algorithm = 'mean' THEN cr.rating END) AS mean_rating, - MAX(CASE WHEN cr.algorithm = 'median' THEN cr.rating END) AS median_rating, - MAX(CASE WHEN cr.algorithm = 'comrating3' THEN cr.rating END) AS comrating3_rating + MAX(CASE WHEN rcp.name = 'Mean' THEN car.aggregate_rating END) AS mean_rating, + MAX(CASE WHEN rcp.name = 'Median' THEN car.aggregate_rating END) AS median_rating, + MAX(CASE WHEN rcp.name = 'ComRating3' THEN car.aggregate_rating END) AS comrating3_rating FROM propositions p - LEFT JOIN calculated_ratings cr ON cr.proposition_id = p.id + LEFT JOIN cached_aggregate_ratings car ON car.proposition_id = p.id + LEFT JOIN rating_calculation_procedures rcp ON car.creator_id = rcp.creator_id GROUP BY p.id, p.body ORDER BY p.id @@ -488,8 +548,9 @@ Generate the SQL query (return ONLY the SQL, no explanations):`; - // Basic validation (Python lines 434-440) - if (!generatedSql.toUpperCase().startsWith('SELECT')) { + // Basic validation - allow SELECT or WITH (CTEs) + const upperSql = generatedSql.toUpperCase().trim(); + if (!upperSql.startsWith('SELECT') && !upperSql.startsWith('WITH')) { return { success: false, error: 'Generated query is not a SELECT statement', @@ -1054,6 +1115,639 @@ $$ LANGUAGE plpython3u; }; } } + + /** + * Generate SQL query for event detection from natural language. + * This is used by the AI Event Builder to create custom event definitions. + * + * @param naturalLanguage Natural language description of the event to detect + * @param conversationHistory Previous conversation messages for context + * @param weight AI model weight + * @returns Dictionary with 'success', 'query', 'explanation', and optional 'error' fields + */ + static async generateEventQuery( + naturalLanguage: string, + conversationHistory: Array<{ role: string; content: string }> = [], + weight: Weight = 'heavy' + ): Promise { + try { + const schemaContext = AIService.getDatabaseSchemaContext(); + + // Build conversation context if there's history + let conversationContext = ''; + if (conversationHistory.length > 0) { + conversationContext = ` +Previous conversation: +${conversationHistory.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`).join('\n')} + +Continue the conversation and generate the event query based on this context. +`; + } + + const prompt = `You are an AI assistant that helps users create custom event detection queries for the PeerVerity system. +Users want to receive notifications when certain conditions are met in the database. + +${schemaContext} + +ADDITIONAL CONTEXT FOR EVENT QUERIES: + +Table: followed_propositions +- id (integer): Follow record ID +- creator_id (integer): User who is following +- proposition_id (integer): Proposition being followed +- event_types (text[]): Types of events to track +- creation_time (timestamp): When follow was created + +Table: followed_tags +- id (integer): Follow record ID +- creator_id (integer): User who is following +- tag_id (integer): Tag being followed +- event_types (text[]): Types of events to track +- creation_time (timestamp): When follow was created + +Table: followed_documents +- id (integer): Follow record ID +- creator_id (integer): User who is following +- document_id (integer): Document being followed +- event_types (text[]): Types of events to track +- creation_time (timestamp): When follow was created + +EVENT QUERY CONTRACT: +The SQL query you generate should return rows that represent entities currently meeting the event condition. +Each row returned means "this entity currently triggers the event." + +Required output columns: +- entity_type (text): The type of entity (e.g., 'proposition', 'tag', 'user', 'document') +- entity_id (integer): The ID of the entity +- display_text (text): A human-readable description for the notification feed + +The system will: +1. Run this query periodically (e.g., every 10 seconds) +2. Compare results to previous run +3. Create event notifications for NEW rows (entities that newly meet the condition) +4. Optionally create notifications for rows that disappeared (entities that no longer meet the condition) + +EXAMPLES: + +-- Event: Proposition receives more than 10 ratings in the last hour +SELECT + 'proposition'::text AS entity_type, + p.id AS entity_id, + 'Proposition "' || LEFT(p.body, 50) || '..." received more than 10 ratings in the last hour' AS display_text +FROM propositions p +WHERE ( + SELECT COUNT(*) + FROM ratings r + WHERE r.proposition_id = p.id + AND r.creation_time > NOW() - INTERVAL '1 hour' +) > 10; + +-- Event: User creates a new proposition +SELECT + 'proposition'::text AS entity_type, + p.id AS entity_id, + 'User "' || u.username || '" created proposition: "' || LEFT(p.body, 50) || '..."' AS display_text +FROM propositions p +JOIN users u ON u.id = p.creator_id +WHERE p.creation_time > NOW() - INTERVAL '1 minute'; + +-- Event: Proposition aggregate rating (using Mean algorithm) exceeds threshold +SELECT + 'proposition'::text AS entity_type, + p.id AS entity_id, + 'Proposition "' || LEFT(p.body, 50) || '..." reached ' || ROUND(car.aggregate_rating * 100) || '% rating' AS display_text +FROM propositions p +JOIN cached_aggregate_ratings car ON car.proposition_id = p.id +JOIN rating_calculation_procedures rcp ON car.creator_id = rcp.creator_id +WHERE rcp.name = 'Mean' AND car.aggregate_rating > 0.8; + +-- Event: Tag is used more than 5 times in a day +SELECT + 'tag'::text AS entity_type, + t.id AS entity_id, + 'Tag "' || t.name || '" was used ' || COUNT(*) || ' times today' AS display_text +FROM tags t +JOIN proposition_tag_links ptl ON ptl.tag_id = t.id +WHERE ptl.creation_time > NOW() - INTERVAL '1 day' +GROUP BY t.id, t.name +HAVING COUNT(*) > 5; + +${conversationContext} + +User request: ${naturalLanguage} + +Generate a SQL query that detects this event condition. Return ONLY the SQL query, followed by a brief explanation of what it does. +Format your response as: +\`\`\`sql + +\`\`\` + +Explanation: `; + + await ensureAiDelegateInitialized(); + const response = (await AiDelegate.makeRequest({ + weight, + max_tokens: 2000, + temperature: 0, + messages: [{ role: 'user', content: prompt }], + })) as AiResponse; + + const responseContent = response.content.trim(); + + // Extract SQL from markdown code block + const codeBlockMatch = responseContent.match(/```(?:sql)?\s*\n?([\s\S]*?)\n?```/); + let generatedSql = ''; + let explanation = ''; + + if (codeBlockMatch) { + generatedSql = codeBlockMatch[1].trim(); + // Extract explanation after the code block + const afterCodeBlock = responseContent.substring(responseContent.indexOf('```', 3) + 3).trim(); + const explanationMatch = afterCodeBlock.match(/^Explanation:\s*([\s\S]*)/i); + if (explanationMatch) { + explanation = explanationMatch[1].trim(); + } else { + explanation = afterCodeBlock; + } + } else { + // No code block found, try to use the whole response as SQL + generatedSql = responseContent; + } + + // Basic validation - allow SELECT or WITH (CTEs) + const upperSql = generatedSql.toUpperCase().trim(); + if (!upperSql.startsWith('SELECT') && !upperSql.startsWith('WITH')) { + return { + success: false, + error: 'Generated query is not a SELECT statement. Please try rephrasing your request.', + query: generatedSql, + }; + } + + // Remove semicolon if present + generatedSql = generatedSql.replace(/;+$/, ''); + + return { + success: true, + query: generatedSql, + explanation: explanation || 'Event detection query generated successfully.', + }; + } catch (error) { + return { + success: false, + error: `Error generating event query: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Get the default base system prompt for feed generator creation. + * This is the instructional content that can be customized by users. + * + * @returns The base system prompt content (without conversation context or user request) + */ + static getBaseFeedGeneratorPrompt(): string { + return AIService.buildFeedGeneratorPrompt('', []); + } + + /** + * Build the full prompt for feed generator query generation. + * This is exposed so users can preview/edit the prompt before sending. + * + * @param naturalLanguage Natural language description of the feed generator to create + * @param conversationHistory Previous conversation messages for context + * @param customBasePrompt Optional custom base prompt to use instead of the default + * @returns The full prompt string that would be sent to Claude + */ + static buildFeedGeneratorPrompt( + naturalLanguage: string, + conversationHistory: Array<{ role: string; content: string }> = [], + customBasePrompt?: string + ): string { + const schemaContext = AIService.getDatabaseSchemaContext(); + + // Build conversation context if there's history + let conversationContext = ''; + if (conversationHistory.length > 0) { + conversationContext = ` +Previous conversation: +${conversationHistory.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`).join('\n')} + +Continue the conversation and generate the feed generator based on this context. +`; + } + + return `You are an AI assistant that helps users create feed generators for the PeerVerity system. +Feed generators produce events that appear in users' activity feeds. + +${schemaContext} + +FEED GENERATOR CONTRACT: +The SQL query MUST return exactly these 8 columns: +1. event_time (TIMESTAMP) - When the event occurred +2. event_type (VARCHAR) - A unique identifier for the event type (e.g., 'proposition_created', 'high_rating_alert') +3. primary_entity_type (VARCHAR) - The main entity type ('proposition', 'document', 'user', 'tag', 'source') +4. primary_entity_id (INTEGER) - ID of the primary entity +5. secondary_entity_type (VARCHAR) - Optional related entity type (can be NULL, use NULL::VARCHAR) +6. secondary_entity_id (INTEGER) - Optional related entity ID (can be NULL, use NULL::INTEGER) +7. actor_user_id (INTEGER) - The user who triggered the event +8. event_data (JSONB) - Custom data for the MDX template (use jsonb_build_object()) + +MDX TEMPLATE VARIABLES: +The MDX template renders each feed item. Available variables: +- {data.eventTime} - ISO timestamp string +- {new Date(data.eventTime).toLocaleString()} - Formatted date/time +- {data.eventType} - The event type string +- {data.primaryEntityType}, {data.primaryEntityId} - Primary entity info +- {data.secondaryEntityType}, {data.secondaryEntityId} - Secondary entity info (may be null) +- {data.actorUserId} - Actor user ID +- {data.eventData.fieldName} - Access custom fields from event_data JSONB + +MDX TEMPLATE BEST PRACTICES - FOLLOW THESE FOR CONSISTENCY: +All MDX templates MUST follow this exact 2-line format: + +Line 1: **Event Type Title** - {new Date(data.eventTime).toLocaleString()} +Line 2: + +STANDARD LINK PATTERNS (use JSX tags, NOT markdown [text](url)): +- Users: {data.eventData.username} +- Propositions: {data.eventData.body} +- Documents: {data.eventData.title} +- Tags: Use bold text **{data.eventData.tag_name}** (no dedicated page) + +ESSENTIAL DATA IN event_data FOR LINKING: +- Always include actor username (creator_username, rater_username) for user profile links +- Always include displayable text for primary entity (body, title, name) +- Include key metrics as numbers for bold formatting (rating, count) + +CORRECT MDX FORMAT EXAMPLES: + +Example - Proposition Created: +**Proposition Created** - {new Date(data.eventTime).toLocaleString()} + +{data.eventData.creator_username} created {data.eventData.body} + +Example - Proposition Rated: +**Proposition Rated** - {new Date(data.eventTime).toLocaleString()} + +{data.eventData.rater_username} rated **{data.eventData.rating}%** {data.eventData.proposition_body} + +Example - Linked Proposition: +**Linked Proposition** - {new Date(data.eventTime).toLocaleString()} + +{data.eventData.creator_username} linked {data.eventData.origin_body} **{data.eventData.link_type}** {data.eventData.target_body} + +Example - Rating Threshold: +**Rating Threshold Reached** - {new Date(data.eventTime).toLocaleString()} + +{data.eventData.proposition_body} reached **{data.eventData.avg_rating}%** average rating + +Example - Delegation: +**Delegation Received** - {new Date(data.eventTime).toLocaleString()} + +{data.eventData.delegator_username} delegated to {data.eventData.receiver_username} for **{data.eventData.tag_name}** with {data.eventData.weight}% weight + +AVOID THESE MDX MISTAKES: +- Do NOT use markdown [text](url) links - use JSX tags +- Do NOT create multi-paragraph templates - keep to 2 lines +- Do NOT omit entity links - always link users, propositions, documents +- Do NOT forget to bold key numeric values with **value** +- Do NOT include raw IDs without human-readable text + +CRITICAL: EACH ROW = ONE FEED EVENT +Each row your query returns becomes a separate feed event in the user's feed. +- If your query returns 3 rows, the user sees 3 feed events +- To show one event, your query must return exactly one row +- Use DISTINCT ON, LIMIT 1, or window functions to avoid duplicates +- The event_time should be the timestamp of the ACTION that triggered the event +- The actor_user_id should be the user who performed that action + +THRESHOLD-BASED ALERTS (IMPORTANT): +When alerting on conditions like "rating exceeds X%", you must: +1. Track the SPECIFIC RATING that pushed the average over the threshold +2. Use that rating's creation_time as event_time +3. Use that rater as actor_user_id +4. Return only ONE event per threshold crossing, not one per rating +5. Use window functions or DISTINCT ON to find the crossing point + +EXAMPLE FEED GENERATORS: + +Example 1 - New Propositions Created: +\`\`\`sql +SELECT + p.creation_time AS event_time, + 'proposition_created' AS event_type, + 'proposition' AS primary_entity_type, + p.id AS primary_entity_id, + NULL::VARCHAR AS secondary_entity_type, + NULL::INTEGER AS secondary_entity_id, + p.creator_id AS actor_user_id, + jsonb_build_object( + 'body', p.body, + 'creator_username', u.username + ) AS event_data +FROM public.propositions p +JOIN public.users u ON u.id = p.creator_id +ORDER BY p.creation_time DESC +\`\`\` + +\`\`\`mdx +**Proposition Created** - {new Date(data.eventTime).toLocaleString()} + +[{data.eventData.body}](/pages/proposition?proposition_id={data.primaryEntityId}) + +By [{data.eventData.creator_username}](/pages/user?username={data.eventData.creator_username}) +\`\`\` + +Example 2 - Threshold Alert (rating from specific group exceeds threshold): +This example alerts when oncologists' average rating for a proposition exceeds 80%. +Key: Track the specific rating that pushed the average over, not the cached aggregate. +IMPORTANT: Window functions cannot be nested! Compute running_avg in one CTE, then apply LAG in a second CTE. +\`\`\`sql +WITH oncologist_users AS ( + -- Users where "{username} is an oncologist" is rated > 90% by Mean algorithm + SELECT u.id AS user_id, u.username + FROM public.users u + JOIN public.propositions p_onc ON p_onc.body = u.username || ' is an oncologist' + JOIN public.cached_aggregate_ratings car ON car.proposition_id = p_onc.id + JOIN public.rating_calculation_procedures rcp ON car.creator_id = rcp.creator_id + WHERE rcp.name = 'Mean' AND car.aggregate_rating > 0.9 +), +ratings_with_running_avg AS ( + -- First CTE: compute running average (cannot nest window functions!) + SELECT + r.id AS rating_id, + r.creation_time, + r.creator_id, + r.proposition_id, + r.rating, + u.username AS rater_username, + AVG(r.rating) OVER ( + PARTITION BY r.proposition_id + ORDER BY r.creation_time + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running_avg + FROM public.ratings r + JOIN oncologist_users ou ON ou.user_id = r.creator_id + JOIN public.propositions p ON p.id = r.proposition_id + JOIN public.users u ON u.id = r.creator_id + WHERE p.body = 'Example proposition text' + AND r.rating IS NOT NULL +), +ratings_with_prev_avg AS ( + -- Second CTE: apply LAG on the already-computed running_avg + SELECT + *, + LAG(running_avg) OVER (PARTITION BY proposition_id ORDER BY creation_time) AS prev_avg + FROM ratings_with_running_avg +) +-- Return ONLY the rating that pushed the average over 80% (threshold crossing) +SELECT DISTINCT ON (proposition_id) + creation_time AS event_time, + 'threshold_crossed' AS event_type, + 'proposition' AS primary_entity_type, + proposition_id AS primary_entity_id, + 'rating' AS secondary_entity_type, + rating_id AS secondary_entity_id, + creator_id AS actor_user_id, + jsonb_build_object( + 'running_avg', ROUND(running_avg * 100), + 'threshold', 80, + 'triggering_rater', rater_username, + 'triggering_rating', ROUND(rating * 100) + ) AS event_data +FROM ratings_with_prev_avg +WHERE running_avg > 0.8 +AND (prev_avg IS NULL OR prev_avg <= 0.8) +ORDER BY proposition_id, creation_time +\`\`\` + +\`\`\`mdx +**Threshold Alert** - {new Date(data.eventTime).toLocaleString()} + +Oncologist average rating crossed **{data.eventData.threshold}%** (now at {data.eventData.running_avg}%) + +Triggered by [{data.eventData.triggering_rater}](/pages/user?username={data.eventData.triggering_rater}) rating **{data.eventData.triggering_rating}%** +\`\`\` + +Example 3 - Simple High Rating (using cached ratings, ONE event per proposition): +Use DISTINCT ON to get only one event per proposition, even if there are multiple algorithm rows. +\`\`\`sql +SELECT DISTINCT ON (p.id) + car.creation_time AS event_time, + 'high_rating_alert' AS event_type, + 'proposition' AS primary_entity_type, + p.id AS primary_entity_id, + NULL::VARCHAR AS secondary_entity_type, + NULL::INTEGER AS secondary_entity_id, + p.creator_id AS actor_user_id, + jsonb_build_object( + 'body', p.body, + 'rating', ROUND(car.aggregate_rating * 100), + 'algorithm', rcp.name + ) AS event_data +FROM public.propositions p +JOIN public.cached_aggregate_ratings car ON car.proposition_id = p.id +JOIN public.rating_calculation_procedures rcp ON car.creator_id = rcp.creator_id +WHERE rcp.name = 'Mean' AND car.aggregate_rating > 0.8 +ORDER BY p.id, car.creation_time DESC +\`\`\` + +\`\`\`mdx +**High Rating Alert** - {new Date(data.eventTime).toLocaleString()} + +[{data.eventData.body}](/pages/proposition?proposition_id={data.primaryEntityId}) + +Rating: **{data.eventData.rating}%** ({data.eventData.algorithm}) +\`\`\` + +Example 4 - Proposition Rated: +\`\`\`sql +SELECT + r.creation_time AS event_time, + 'proposition_rated' AS event_type, + 'proposition' AS primary_entity_type, + r.proposition_id AS primary_entity_id, + 'rating' AS secondary_entity_type, + r.id AS secondary_entity_id, + r.creator_id AS actor_user_id, + jsonb_build_object( + 'rating', ROUND(r.rating * 100), + 'proposition_body', p.body, + 'rater_username', u.username + ) AS event_data +FROM public.ratings r +JOIN public.propositions p ON p.id = r.proposition_id +JOIN public.users u ON u.id = r.creator_id +WHERE r.rating IS NOT NULL +ORDER BY r.creation_time DESC +\`\`\` + +\`\`\`mdx +**Proposition Rated** - {new Date(data.eventTime).toLocaleString()} + +[{data.eventData.rater_username}](/pages/user?username={data.eventData.rater_username}) rated **{data.eventData.rating}%** + +[{data.eventData.proposition_body}](/pages/proposition?proposition_id={data.primaryEntityId}) +\`\`\` + +PROMPT CUSTOMIZATION CAPABILITY: +If the user asks you to modify, update, or record something in the system prompt (e.g., "record this lesson in the system prompt", "add this example to the prompt", "update the instructions to remember this"), you should: +1. Generate ONLY the customization/addition to be appended to the base prompt +2. Output it in the special format shown below +3. The customization will be saved and appended to the base prompt for future interactions + +When creating customizations: +- Focus on the specific lesson, example, or instruction the user wants to record +- Keep it concise and actionable +- Use clear headings like "LESSON LEARNED:", "EXAMPLE:", or "REMEMBER:" +- Do NOT reproduce the entire system prompt - only output the new addition + +OUTPUT FORMAT FOR PROMPT CUSTOMIZATIONS: +If the user requests a prompt customization, format your response as: +\`\`\`prompt_customization + +\`\`\` + +Explanation: + +IMPORTANT: If the user is asking to modify/update the system prompt, ONLY output the prompt_customization block (no SQL or MDX). For feed generator requests, output SQL and MDX blocks (no prompt_customization block). + +${customBasePrompt ? ` +--- + +USER CUSTOMIZATIONS: +${customBasePrompt} +` : ''} + +--- + +${conversationContext} + +User request: ${naturalLanguage} + +INSTRUCTIONS: +- If this is a PROMPT CUSTOMIZATION request (user wants to update/modify/record something in the system prompt): Output ONLY a \`\`\`prompt_customization block with the addition to append. +- If this is a FEED GENERATOR request: Output BOTH \`\`\`sql and \`\`\`mdx blocks as shown below. + +For feed generator requests, format your response as: +\`\`\`sql + +\`\`\` + +\`\`\`mdx + +\`\`\` + +Explanation: `; + } + + /** + * Generate SQL query and MDX template for a feed generator from natural language. + * This is used by the AI Feed Generator Builder to create custom feed generators. + * + * @param naturalLanguage Natural language description of the feed generator to create + * @param conversationHistory Previous conversation messages for context + * @param weight AI model weight + * @param customPrompt Optional custom prompt to use instead of the auto-generated one + * @param customBasePrompt Optional user customizations to append to the base prompt + * @returns Dictionary with 'success', 'sql_query', 'mdx_template', 'explanation', and optional 'error' fields + */ + static async generateFeedGeneratorQuery( + naturalLanguage: string, + conversationHistory: Array<{ role: string; content: string }> = [], + weight: Weight = 'heavy', + customPrompt?: string, + customBasePrompt?: string + ): Promise { + try { + const prompt = customPrompt || AIService.buildFeedGeneratorPrompt(naturalLanguage, conversationHistory, customBasePrompt); + + await ensureAiDelegateInitialized(); + const response = (await AiDelegate.makeRequest({ + weight, + max_tokens: 3000, + temperature: 0, + messages: [{ role: 'user', content: prompt }], + })) as AiResponse; + + const responseContent = response.content.trim(); + + // Extract SQL from markdown code block + // Check for prompt modification response first + const promptModMatch = responseContent.match(/```prompt_customization\s*\n?([\s\S]*?)\n?```/); + + const sqlMatch = responseContent.match(/```sql\s*\n?([\s\S]*?)\n?```/); + const mdxMatch = responseContent.match(/```mdx\s*\n?([\s\S]*?)\n?```/); + + let generatedSql = ''; + let generatedMdx = ''; + let explanation = ''; + let promptModification: string | undefined; + + if (sqlMatch) { + generatedSql = sqlMatch[1].trim(); + } + + if (mdxMatch) { + generatedMdx = mdxMatch[1].trim(); + } + + // Check if this is a prompt modification response + if (promptModMatch) { + promptModification = promptModMatch[1].trim(); + } + + // Extract explanation after the code blocks + const explanationMatch = responseContent.match(/Explanation:\s*([\s\S]*?)(?:$)/i); + if (explanationMatch) { + explanation = explanationMatch[1].trim(); + } + + // If this is a prompt modification, return it (SQL is optional in this case) + if (promptModification) { + return { + success: true, + prompt_modification: promptModification, + sql_query: generatedSql || undefined, + mdx_template: generatedMdx || undefined, + explanation: explanation || 'Prompt customization has been saved.', + }; + } + + // Basic validation + if (!generatedSql) { + return { + success: false, + error: 'Could not extract SQL query from the response. Please try rephrasing your request.', + }; + } + + const upperSql = generatedSql.toUpperCase().trim(); + if (!upperSql.startsWith('SELECT') && !upperSql.startsWith('WITH')) { + return { + success: false, + error: 'Generated query is not a SELECT statement. Please try rephrasing your request.', + sql_query: generatedSql, + }; + } + + // Remove semicolon if present + generatedSql = generatedSql.replace(/;+$/, ''); + + return { + success: true, + sql_query: generatedSql, + mdx_template: generatedMdx || '', + explanation: explanation || 'Feed generator query generated successfully.', + }; + } catch (error) { + return { + success: false, + error: `Error generating feed generator: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } } /** diff --git a/ratings-ui-demo/src/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index 0e7f1f7fa1365f7c41f5d436a79b18f548f88a1d..8336b23d7771e82759255d86e156d1ed09bd1178 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -272,7 +272,9 @@ export type FollowedPropositionEventType = 'vote' | 'argument' | 'rewording' | ' export interface FollowedProposition { id: number; proposition_id: number; - proposition_body: string; + proposition_body?: string; + tag_id?: number; + tag_name?: string; event_types: FollowedPropositionEventType[]; } @@ -348,6 +350,95 @@ export interface FollowedDocumentEvent { followed_event_type: FollowedDocumentEventType; } +// Statistical Event Definitions +export type StatisticalMetricType = 'rating_count' | 'aggregate_rating' | 'tag_usage_count' | 'tag_usage_count_period'; +export type CrossingDirection = 'up_only' | 'both'; +export type StatisticalEntityType = 'proposition' | 'tag'; + +export interface StatisticalEventDefinition { + id: number; + creator_id: number; + creator_username: string; + name: string; + description?: string; + entity_type: string; + metric_type: StatisticalMetricType; + rating_algorithm_id?: number; + rating_algorithm_name?: string; + threshold_value: number; + crossing_direction: CrossingDirection; + is_published: boolean; + creation_time: string; + is_own: boolean; + time_window_minutes?: number; +} + +export interface FollowedStatisticalEvent { + id: number; + definition_id: number; + definition_name: string; + proposition_id?: number; + proposition_body?: string; + tag_id?: number; + tag_name?: string; + last_metric_value?: number; + last_evaluated_at?: string; + is_active: boolean; +} + +export interface StatisticalEventDefinitionForProposition { + definition_id: number; + definition_name: string; + metric_type: StatisticalMetricType; + threshold_value: number; + crossing_direction: CrossingDirection; + is_subscribed: boolean; + time_window_minutes?: number; +} + +export interface StatisticalEventOccurrence { + id: number; + followed_event_id: number; + triggered_at: string; + previous_value: number; + new_value: number; + crossed_direction: 'up' | 'down'; +} + +// AI Custom Event Definitions +export interface AiCustomEventDefinition { + id: number; + creator_id: number; + creator_username: string; + name: string; + description?: string; + sql_query: string; + crossing_direction: CrossingDirection; + is_published: boolean; + is_active: boolean; + creation_time: string; + is_own: boolean; + subscription_id?: number; + is_subscribed: boolean; +} + +export interface AiCustomEventSubscription { + subscription_id: number; + definition_id: number; + definition_name: string; + definition_description?: string; + is_active: boolean; + last_evaluated_at?: string; + evaluation_error?: string; + creator_username: string; +} + +export interface AiCustomEventTestResult { + entity_type: string; + entity_id: number; + display_text: string; +} + export interface SavedAnalysisQuery { id: number; creator_id: number; @@ -1118,9 +1209,16 @@ export interface FeedGenerator { creation_time: string; } +export interface ConversationMessage { + role: string; + content: string; + timestamp?: string; +} + export interface FeedGeneratorFull extends FeedGenerator { sql_query: string; mdx_template: string; + conversation_history: ConversationMessage[]; } export interface UserFeedGenerator { @@ -1146,6 +1244,22 @@ export interface FeedEvent { mdx_template: string; } +export interface UserFeedEvent { + event_id: string; + event_time: string; + event_type: string; + generator_id: number; + generator_name: string; + primary_entity_type: string; + primary_entity_id: number; + secondary_entity_type: string | null; + secondary_entity_id: number | null; + actor_user_id: number | null; + actor_username: string; + event_data: Record; + mdx_template: string; +} + export interface FeedGeneratorResult { id: number | null; success: boolean; @@ -1217,6 +1331,26 @@ export interface QuickFollowResult { list_id: number; } +export interface TestFeedGeneratorQueryResult { + success: boolean; + columns?: Array<{ name: string; type: string }>; + rows?: Record[]; + row_count?: number; + error?: string; +} + +export interface UserAiPromptResult { + success: boolean; + custom_prompt?: string | null; + last_modified?: string | null; + message?: string; +} + +export interface SaveUserAiPromptResult { + success: boolean; + message: string; +} + export interface IApiClient { getDatabaseTables(): Promise; getTableRows(table_name: string, columns?: string[]): Promise; @@ -1456,11 +1590,11 @@ export interface IApiClient { getFollowedDocuments(userId: number): Promise; isFollowingDocument(userId: number, documentId: number): Promise; - // Feed Generators +// Feed Generators getFeedGenerators(): Promise; getFeedGenerator(id: number): Promise; - createFeedGenerator(creatorId: number, name: string, description: string, sqlQuery: string, parametersSchema?: Record, defaultParameters?: Record, mdxTemplate?: string): Promise; - updateFeedGenerator(id: number, userId: number, updates: { name?: string; description?: string; sqlQuery?: string; parametersSchema?: Record; defaultParameters?: Record; mdxTemplate?: string }): Promise<{ success: boolean; message: string }>; + createFeedGenerator(creatorId: number, name: string, description: string, sqlQuery: string, parametersSchema?: Record, defaultParameters?: Record, mdxTemplate?: string, conversationHistory?: ConversationMessage[]): Promise; + updateFeedGenerator(id: number, userId: number, updates: { name?: string; description?: string; sqlQuery?: string; parametersSchema?: Record; defaultParameters?: Record; mdxTemplate?: string; conversationHistory?: ConversationMessage[] }): Promise<{ success: boolean; message: string }>; deleteFeedGenerator(id: number, userId: number): Promise<{ success: boolean; message: string }>; getUserFeedGenerators(userId: number): Promise; addGeneratorToFeed(userId: number, generatorId: number, parameters?: Record): Promise; @@ -1492,6 +1626,75 @@ export interface IApiClient { // Integration test cleanup cleanupIntegrationTestData(testUsername: string): Promise; + getUserFeedEvents(userId: number, limit?: number, offset?: number, sinceTime?: string): Promise; + testFeedGeneratorQuery(sqlQuery: string, limit?: number, parametersSchema?: Record, defaultParameters?: Record): Promise; + + // User AI Prompt Customizations + getUserAiPrompt(userId: number, promptType?: string): Promise; + saveUserAiPrompt(userId: number, customPrompt: string, promptType?: string): Promise; + resetUserAiPrompt(userId: number, promptType?: string): Promise; + + // Statistical Event Definitions + createStatisticalEventDefinition( + userId: number, + name: string, + description?: string, + entityType?: StatisticalEntityType, + metricType?: StatisticalMetricType, + ratingAlgorithmId?: number, + thresholdValue?: number, + crossingDirection?: CrossingDirection, + isPublished?: boolean, + timeWindowMinutes?: number + ): Promise; + updateStatisticalEventDefinition( + userId: number, + definitionId: number, + name?: string, + description?: string, + thresholdValue?: number, + crossingDirection?: CrossingDirection, + isPublished?: boolean, + timeWindowMinutes?: number + ): Promise; + deleteStatisticalEventDefinition(userId: number, definitionId: number): Promise; + getStatisticalEventDefinitions(userId: number, includePublished?: boolean, entityType?: StatisticalEntityType): Promise; + + // Statistical Event Subscriptions + followStatisticalEvent(userId: number, definitionId: number, propositionId?: number, tagId?: number): Promise; + unfollowStatisticalEvent(userId: number, definitionId: number, propositionId?: number, tagId?: number): Promise; + getFollowedStatisticalEvents(userId: number, propositionId?: number, tagId?: number): Promise; + getStatisticalEventDefinitionsForProposition(userId: number, propositionId: number): Promise; + getStatisticalEventDefinitionsForTag(userId: number, tagId: number): Promise; + + // AI Custom Event Definitions + createAiCustomEventDefinition( + userId: number, + name: string, + description?: string, + sqlQuery?: string, + crossingDirection?: CrossingDirection, + isPublished?: boolean + ): Promise; + updateAiCustomEventDefinition( + userId: number, + definitionId: number, + name?: string, + description?: string, + sqlQuery?: string, + crossingDirection?: CrossingDirection, + isPublished?: boolean, + isActive?: boolean + ): Promise; + deleteAiCustomEventDefinition(userId: number, definitionId: number): Promise; + getAiCustomEventDefinitions(userId: number, includePublished?: boolean): Promise; + getAiCustomEventDefinition(userId: number, definitionId: number): Promise; + testAiCustomEventQuery(sqlQuery: string): Promise; + + // AI Custom Event Subscriptions + subscribeAiCustomEvent(userId: number, definitionId: number): Promise; + unsubscribeAiCustomEvent(userId: number, definitionId: number): Promise; + getAiCustomEventSubscriptions(userId: number): Promise; } export class ApiClient implements IApiClient { @@ -3935,7 +4138,8 @@ export class ApiClient implements IApiClient { sqlQuery: string, parametersSchema?: Record, defaultParameters?: Record, - mdxTemplate?: string + mdxTemplate?: string, + conversationHistory?: ConversationMessage[] ): Promise { const response = await this.axios.post('/rpc/create_feed_generator', { p_creator_id: creatorId, @@ -3944,7 +4148,8 @@ export class ApiClient implements IApiClient { p_sql_query: sqlQuery, p_parameters_schema: parametersSchema || {}, p_default_parameters: defaultParameters || {}, - p_mdx_template: mdxTemplate || '' + p_mdx_template: mdxTemplate || '', + p_conversation_history: conversationHistory || [] }, { headers: getRequestHeader() }); @@ -3961,6 +4166,7 @@ export class ApiClient implements IApiClient { parametersSchema?: Record; defaultParameters?: Record; mdxTemplate?: string; + conversationHistory?: ConversationMessage[]; } ): Promise<{ success: boolean; message: string }> { const response = await this.axios.post<{ success: boolean; message: string }[]>('/rpc/update_feed_generator', { @@ -3971,7 +4177,8 @@ export class ApiClient implements IApiClient { p_sql_query: updates.sqlQuery, p_parameters_schema: updates.parametersSchema, p_default_parameters: updates.defaultParameters, - p_mdx_template: updates.mdxTemplate + p_mdx_template: updates.mdxTemplate, + p_conversation_history: updates.conversationHistory }, { headers: getRequestHeader() }); @@ -4256,4 +4463,299 @@ export class ApiClient implements IApiClient { // PostgREST returns JSON directly for scalar-returning functions return response.data; } + + async getUserFeedEvents( + userId: number, + limit?: number, + offset?: number, + sinceTime?: string + ): Promise { + const response = await this.axios.post('/rpc/get_user_feed_events', { + p_user_id: userId, + p_limit: limit || 20, + p_offset: offset || 0, + p_since_time: sinceTime || null + }); + return response.data; + } + + async testFeedGeneratorQuery( + sqlQuery: string, + limit: number = 10, + parametersSchema?: Record, + defaultParameters?: Record + ): Promise { + const response = await this.axios.post('/rpc/test_feed_generator_query', { + p_sql_query: sqlQuery, + p_limit: limit, + p_parameters_schema: parametersSchema || {}, + p_default_parameters: defaultParameters || {} + }, { + headers: getRequestHeader() + }); + return response.data; + } + + // ======================================================================== + // User AI Prompt Customizations + // ======================================================================== + + async getUserAiPrompt( + userId: number, + promptType: string = 'feed_generator' + ): Promise { + const response = await this.axios.post('/rpc/get_user_ai_prompt', { + p_user_id: userId, + p_prompt_type: promptType + }, { + headers: getRequestHeader() + }); + return response.data; + } + + async saveUserAiPrompt( + userId: number, + customPrompt: string, + promptType: string = 'feed_generator' + ): Promise { + const response = await this.axios.post('/rpc/save_user_ai_prompt', { + p_user_id: userId, + p_custom_prompt: customPrompt, + p_prompt_type: promptType + }, { + headers: getRequestHeader() + }); + return response.data; + } + + async resetUserAiPrompt( + userId: number, + promptType: string = 'feed_generator' + ): Promise { + const response = await this.axios.post('/rpc/reset_user_ai_prompt', { + p_user_id: userId, + p_prompt_type: promptType + }, { + headers: getRequestHeader() + }); + return response.data; + } + + // ======================================================================== + // Statistical Event Definitions + // ======================================================================== + + async createStatisticalEventDefinition( + userId: number, + name: string, + description?: string, + entityType: StatisticalEntityType = 'proposition', + metricType: StatisticalMetricType = 'aggregate_rating', + ratingAlgorithmId?: number, + thresholdValue: number = 0.9, + crossingDirection: CrossingDirection = 'up_only', + isPublished: boolean = false, + timeWindowMinutes?: number + ): Promise { + const response = await this.axios.post('/rpc/create_statistical_event_definition', { + p_user_id: userId, + p_name: name, + p_description: description, + p_entity_type: entityType, + p_metric_type: metricType, + p_rating_algorithm_id: ratingAlgorithmId, + p_threshold_value: thresholdValue, + p_crossing_direction: crossingDirection, + p_is_published: isPublished, + p_time_window_minutes: timeWindowMinutes + }); + return response.data; + } + + async updateStatisticalEventDefinition( + userId: number, + definitionId: number, + name?: string, + description?: string, + thresholdValue?: number, + crossingDirection?: CrossingDirection, + isPublished?: boolean, + timeWindowMinutes?: number + ): Promise { + await this.axios.post('/rpc/update_statistical_event_definition', { + p_user_id: userId, + p_definition_id: definitionId, + p_name: name, + p_description: description, + p_threshold_value: thresholdValue, + p_crossing_direction: crossingDirection, + p_is_published: isPublished, + p_time_window_minutes: timeWindowMinutes + }); + } + + async deleteStatisticalEventDefinition(userId: number, definitionId: number): Promise { + await this.axios.post('/rpc/delete_statistical_event_definition', { + p_user_id: userId, + p_definition_id: definitionId + }); + } + + async getStatisticalEventDefinitions(userId: number, includePublished: boolean = true, entityType?: StatisticalEntityType): Promise { + const response = await this.axios.post('/rpc/get_statistical_event_definitions', { + p_user_id: userId, + p_include_published: includePublished, + p_entity_type: entityType + }); + return response.data; + } + + // ======================================================================== + // Statistical Event Subscriptions + // ======================================================================== + + async followStatisticalEvent(userId: number, definitionId: number, propositionId?: number, tagId?: number): Promise { + const response = await this.axios.post('/rpc/follow_statistical_event', { + p_user_id: userId, + p_definition_id: definitionId, + p_proposition_id: propositionId ?? null, + p_tag_id: tagId ?? null + }); + return response.data; + } + + async unfollowStatisticalEvent(userId: number, definitionId: number, propositionId?: number, tagId?: number): Promise { + await this.axios.post('/rpc/unfollow_statistical_event', { + p_user_id: userId, + p_definition_id: definitionId, + p_proposition_id: propositionId ?? null, + p_tag_id: tagId ?? null + }); + } + + async getFollowedStatisticalEvents(userId: number, propositionId?: number, tagId?: number): Promise { + const response = await this.axios.post('/rpc/get_followed_statistical_events', { + p_user_id: userId, + p_proposition_id: propositionId ?? null, + p_tag_id: tagId ?? null + }); + return response.data; + } + + async getStatisticalEventDefinitionsForProposition(userId: number, propositionId: number): Promise { + const response = await this.axios.post('/rpc/get_statistical_event_definitions_for_proposition', { + p_user_id: userId, + p_proposition_id: propositionId + }); + return response.data; + } + + async getStatisticalEventDefinitionsForTag(userId: number, tagId: number): Promise { + const response = await this.axios.post('/rpc/get_statistical_event_definitions_for_tag', { + p_user_id: userId, + p_tag_id: tagId + }); + return response.data; + } + + // ======================================================================== + // AI Custom Event Definitions + // ======================================================================== + + async createAiCustomEventDefinition( + userId: number, + name: string, + description?: string, + sqlQuery?: string, + crossingDirection: CrossingDirection = 'up_only', + isPublished: boolean = false + ): Promise { + const response = await this.axios.post('/rpc/create_ai_custom_event_definition', { + p_user_id: userId, + p_name: name, + p_description: description, + p_sql_query: sqlQuery, + p_crossing_direction: crossingDirection, + p_is_published: isPublished + }); + return response.data; + } + + async updateAiCustomEventDefinition( + userId: number, + definitionId: number, + name?: string, + description?: string, + sqlQuery?: string, + crossingDirection?: CrossingDirection, + isPublished?: boolean, + isActive?: boolean + ): Promise { + await this.axios.post('/rpc/update_ai_custom_event_definition', { + p_user_id: userId, + p_definition_id: definitionId, + p_name: name, + p_description: description, + p_sql_query: sqlQuery, + p_crossing_direction: crossingDirection, + p_is_published: isPublished, + p_is_active: isActive + }); + } + + async deleteAiCustomEventDefinition(userId: number, definitionId: number): Promise { + await this.axios.post('/rpc/delete_ai_custom_event_definition', { + p_user_id: userId, + p_definition_id: definitionId + }); + } + + async getAiCustomEventDefinitions(userId: number, includePublished: boolean = true): Promise { + const response = await this.axios.post('/rpc/get_ai_custom_event_definitions', { + p_user_id: userId, + p_include_published: includePublished + }); + return response.data; + } + + async getAiCustomEventDefinition(userId: number, definitionId: number): Promise { + const response = await this.axios.post('/rpc/get_ai_custom_event_definition', { + p_user_id: userId, + p_definition_id: definitionId + }); + return response.data.length > 0 ? response.data[0] : null; + } + + async testAiCustomEventQuery(sqlQuery: string): Promise { + const response = await this.axios.post('/rpc/test_ai_custom_event_query', { + p_sql_query: sqlQuery + }); + return response.data; + } + + // ======================================================================== + // AI Custom Event Subscriptions + // ======================================================================== + + async subscribeAiCustomEvent(userId: number, definitionId: number): Promise { + const response = await this.axios.post('/rpc/subscribe_ai_custom_event', { + p_user_id: userId, + p_definition_id: definitionId + }); + return response.data; + } + + async unsubscribeAiCustomEvent(userId: number, definitionId: number): Promise { + await this.axios.post('/rpc/unsubscribe_ai_custom_event', { + p_user_id: userId, + p_definition_id: definitionId + }); + } + + async getAiCustomEventSubscriptions(userId: number): Promise { + const response = await this.axios.post('/rpc/get_ai_custom_event_subscriptions', { + p_user_id: userId + }); + return response.data; + } }