From d43d9642ef5016fc5f4d4e895af864e3a7215f1c Mon Sep 17 00:00:00 2001 From: pete Date: Mon, 22 Dec 2025 14:20:23 -0500 Subject: [PATCH 01/26] Fixed merge conflicts Fixed bug where new feed generator was not showing events --- ratings-sql/R__060_feed_generators_api.sql | 189 +++++++++++ .../src/pvcomponents/pv-user-feed-list.tsx | 302 ++++++++++++++++++ .../src/pvcomponents/use-pv-components.ts | 2 + ratings-ui-demo/src/services/api-v1.ts | 32 ++ 4 files changed, 525 insertions(+) create mode 100644 ratings-ui-demo/src/pvcomponents/pv-user-feed-list.tsx diff --git a/ratings-sql/R__060_feed_generators_api.sql b/ratings-sql/R__060_feed_generators_api.sql index 14f461c..1bed4d7 100644 --- a/ratings-sql/R__060_feed_generators_api.sql +++ b/ratings-sql/R__060_feed_generators_api.sql @@ -478,3 +478,192 @@ 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 +) +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; +BEGIN + -- Security: Only allow SELECT queries + IF NOT (TRIM(UPPER(p_sql_query)) LIKE 'SELECT%' OR TRIM(UPPER(p_sql_query)) LIKE 'WITH%') THEN + RETURN json_build_object( + 'success', false, + 'error', 'Only SELECT queries are allowed' + ); + END IF; + + -- Block dangerous keywords + IF p_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, p_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', p_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. +Only allows SELECT/WITH queries; blocks data modification statements. +Column info is extracted even when query returns no rows.'; 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 0000000..537e6e4 --- /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 fa3373e..bb3e2d6 100644 --- a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts +++ b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts @@ -64,6 +64,7 @@ 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"; const usePVComponents = () => { const components = { @@ -77,6 +78,7 @@ const usePVComponents = () => { PvFollowListManager, PvFollowListDetail, PvQuickFollowButton, + PvUserFeedList, PvPageDirectory, PvPageSearch, PvText, diff --git a/ratings-ui-demo/src/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index 0e7f1f7..ba20855 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -1146,6 +1146,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; @@ -1492,6 +1508,7 @@ export interface IApiClient { // Integration test cleanup cleanupIntegrationTestData(testUsername: string): Promise; + getUserFeedEvents(userId: number, limit?: number, offset?: number, sinceTime?: string): Promise; } export class ApiClient implements IApiClient { @@ -4256,4 +4273,19 @@ 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; + } } -- GitLab From ebae4fb5729e0833d79231bbddead51982219afd Mon Sep 17 00:00:00 2001 From: pete Date: Fri, 12 Dec 2025 17:17:47 -0500 Subject: [PATCH 02/26] Fixed merge conflict. Bugfix -- Added document and tag titles to following page renderer --- .../filteredentitylistitemrenderers/shared/renderer-map.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts index 5fbff5d..19e29f8 100644 --- a/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts +++ b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts @@ -22,6 +22,8 @@ import { FeedEventFilteredListItemRenderer } from '../feed-event-renderer'; import { FeedGeneratorFilteredListItemRenderer } from '../feed-generator-renderer'; import { FollowListFilteredListItemRenderer } from '../follow-list-renderer'; import { FollowListEntryFilteredListItemRenderer } from '../follow-list-entry-renderer'; +import { FollowedTagEventFilteredListItemRenderer } from '../followed-tag-event-renderer'; +import { FollowedDocumentEventFilteredListItemRenderer } from '../followed-document-event-renderer'; import { DefaultFilteredListItemRenderer } from '../default-renderer'; const filteredEntityListItemRendererMap: Record { -- GitLab From ca1fd380ac6c23133d53cd782a9c7b24464d8046 Mon Sep 17 00:00:00 2001 From: pete Date: Sun, 14 Dec 2025 09:20:42 -0500 Subject: [PATCH 03/26] Fixed merge conflicts Fixed merge conflict Created flat list of following events -- not categorized by proposition, document, etc --- ratings-sql/R__031_default_pages.sql | 30 +- ratings-sql/R__053_filters_v2_data.sql | 130 +++++ .../filtered-entity-list-entities.ts | 20 + .../filteredentitylistitemrenderers/index.ts | 1 + .../shared/renderer-map.ts | 2 + .../unified-followed-event-renderer.tsx | 450 ++++++++++++++++++ .../pvcomponents/pv-filtered-entity-list.tsx | 163 ++++++- 7 files changed, 768 insertions(+), 28 deletions(-) create mode 100644 ratings-ui-demo/src/components/filteredentitylistitemrenderers/unified-followed-event-renderer.tsx diff --git a/ratings-sql/R__031_default_pages.sql b/ratings-sql/R__031_default_pages.sql index 0050853..a5d8131 100644 --- a/ratings-sql/R__031_default_pages.sql +++ b/ratings-sql/R__031_default_pages.sql @@ -700,36 +700,12 @@ Manage your subscriptions to propositions, tags, and documents. View recent activity from the items you''re following. -### Proposition Events - - - -### Tag Events - - - -### Document Events - ', true, 0, false), diff --git a/ratings-sql/R__053_filters_v2_data.sql b/ratings-sql/R__053_filters_v2_data.sql index f7d3838..929db70 100644 --- a/ratings-sql/R__053_filters_v2_data.sql +++ b/ratings-sql/R__053_filters_v2_data.sql @@ -463,6 +463,133 @@ WHERE AND pdl.document_id = fd.document_id )) ', 'event_id', 0) + ,('unified_followed_event', ' +SELECT + ua.activity_id AS event_id, + ''proposition'' AS entity_type, + fp.id AS followed_entity_id, + fp.proposition_id AS entity_id, + fp.creator_id AS follower_user_id, + follower.username AS follower_username, + ua.user_id AS event_user_id, + event_user.username AS event_username, + ua.activity_type, + ua.activity_time AS event_time, + ua.activity_description AS event_description, + ua.proposition_id_1, + ua.proposition_id_2, + ua.proposition_id_3, + ua.tag_id_1, + p.body AS content_text, + fpe.event_type::text AS followed_event_type +FROM + followed_propositions fp + JOIN users follower ON fp.creator_id = follower.id + JOIN followed_proposition_events fpe ON fp.id = fpe.followed_proposition_id + JOIN user_activities ua ON ( + (fpe.event_type = ''vote'' AND ua.activity_type IN (''vote_truth'', ''vote_impact'', ''vote_tag_usefulness'', ''vote_tag_relevancy'')) OR + (fpe.event_type = ''argument'' AND ua.activity_type = ''inter_proposition_link_other'') OR + (fpe.event_type = ''rewording'' AND ua.activity_type = ''inter_proposition_link_rewording'') OR + (fpe.event_type = ''tag'' AND ua.activity_type = ''proposition_tag_link'') OR + (fpe.event_type = ''document_link'' AND ua.activity_type IN (''proposition_document_link'', ''document_proposition_link'')) + ) + JOIN users event_user ON ua.user_id = event_user.id + LEFT JOIN propositions p ON fp.proposition_id = p.id +WHERE + ua.proposition_id_1 = fp.proposition_id + OR ua.proposition_id_2 = fp.proposition_id + OR ua.proposition_id_3 = fp.proposition_id + +UNION ALL + +SELECT + ua.activity_id AS event_id, + ''tag'' AS entity_type, + ft.id AS followed_entity_id, + ft.tag_id AS entity_id, + ft.creator_id AS follower_user_id, + follower.username AS follower_username, + ua.user_id AS event_user_id, + event_user.username AS event_username, + ua.activity_type, + ua.activity_time AS event_time, + ua.activity_description AS event_description, + ua.proposition_id_1, + ua.proposition_id_2, + ua.proposition_id_3, + ua.tag_id_1, + t.name AS content_text, + fte.event_type::text AS followed_event_type +FROM + followed_tags ft + JOIN users follower ON ft.creator_id = follower.id + JOIN followed_tag_events fte ON ft.id = fte.followed_tag_id + JOIN user_activities ua ON ( + (fte.event_type = ''tagging'' AND ua.activity_type IN (''proposition_tag_link'', ''document_tag_link'')) OR + (fte.event_type = ''vote_usefulness'' AND ua.activity_type = ''vote_tag_usefulness'') OR + (fte.event_type = ''vote_relevancy'' AND ua.activity_type = ''vote_tag_relevancy'') + ) + JOIN users event_user ON ua.user_id = event_user.id + LEFT JOIN tags t ON ft.tag_id = t.id +WHERE + ua.tag_id_1 = ft.tag_id + +UNION ALL + +SELECT + ua.activity_id AS event_id, + ''document'' AS entity_type, + fd.id AS followed_entity_id, + fd.document_id AS entity_id, + fd.creator_id AS follower_user_id, + follower.username AS follower_username, + ua.user_id AS event_user_id, + event_user.username AS event_username, + ua.activity_type, + ua.activity_time AS event_time, + ua.activity_description AS event_description, + ua.proposition_id_1, + ua.proposition_id_2, + ua.proposition_id_3, + ua.tag_id_1, + d.title AS content_text, + fde.event_type::text AS followed_event_type +FROM + followed_documents fd + JOIN users follower ON fd.creator_id = follower.id + JOIN followed_document_events fde ON fd.id = fde.followed_document_id + JOIN user_activities ua ON ( + (fde.event_type = ''tag'' AND ua.activity_type = ''document_tag_link'') OR + (fde.event_type = ''subdocument'' AND ua.activity_type = ''document_link'') OR + (fde.event_type = ''edit'' AND ua.activity_type = ''document_link'') OR + (fde.event_type = ''proposition_link'' AND ua.activity_type IN (''document_proposition_link'', ''proposition_document_link'')) + ) + JOIN users event_user ON ua.user_id = event_user.id + LEFT JOIN documents d ON fd.document_id = d.id +WHERE + (fde.event_type = ''tag'' AND EXISTS ( + SELECT 1 FROM document_tag_links dtl + WHERE dtl.id::text = SUBSTRING(ua.activity_id FROM 19)::text + AND dtl.document_id = fd.document_id + )) + OR (fde.event_type = ''subdocument'' AND EXISTS ( + SELECT 1 FROM document_links dl + WHERE dl.id::text = SUBSTRING(ua.activity_id FROM 15)::text + AND dl.parent_id = fd.document_id + AND dl.link_type = ''comprises'' + )) + OR (fde.event_type = ''edit'' AND EXISTS ( + SELECT 1 FROM document_links dl + WHERE dl.id::text = SUBSTRING(ua.activity_id FROM 15)::text + AND dl.parent_id = fd.document_id + AND dl.link_type = ''replaces'' + )) + OR (fde.event_type = ''proposition_link'' AND EXISTS ( + SELECT 1 FROM proposition_document_links pdl + WHERE pdl.id::text = SUBSTRING(ua.activity_id FROM 27)::text + AND pdl.document_id = fd.document_id + )) + ', 'event_id', 0) ,('entity_type', 'SELECT id, creator_id, creation_time, entity_type, base_query, id_column FROM entity_types', 'id', 0) ,('entity_filter', 'SELECT id, creator_id, creation_time, entity_type, filter_name, filter_sql, filter_type, filter_label FROM entity_filters', 'id', 0) ,('entity_association', 'SELECT id, creator_id, creation_time, entity_type, association_name, associated_entity_type, join_query FROM entity_associations', 'id', 0) @@ -570,6 +697,9 @@ WITH default_entity_filters (entity_type, filter_name, filter_sql, filter_type, ('followed_document_event', 'follower_user_id', 'follower_user_id = $1', 'number', 'Follower User ID', 0), ('followed_document_event', 'document_id', 'followed_document_id = $1', 'number', 'Document ID', 0), ('followed_document_event', 'event_type', 'followed_event_type = $1', 'text', 'Event Type', 0), + ('unified_followed_event', 'follower_user_id', 'follower_user_id = $1', 'number', 'Follower User ID', 0), + ('unified_followed_event', 'entity_type', 'entity_type = $1', 'select:[proposition,tag,document]', 'Entity Type', 0), + ('unified_followed_event', 'event_type', 'followed_event_type = $1', 'text', 'Event Type', 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-ui-demo/src/components/filtered-entity-list-entities.ts b/ratings-ui-demo/src/components/filtered-entity-list-entities.ts index e90f545..ffcd309 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'; + 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/filteredentitylistitemrenderers/index.ts b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/index.ts index bc94381..2c4a447 100644 --- a/ratings-ui-demo/src/components/filteredentitylistitemrenderers/index.ts +++ b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/index.ts @@ -30,3 +30,4 @@ export * from './followed-document-event-renderer'; export * from './feed-generator-renderer'; export * from './follow-list-renderer'; export * from './follow-list-entry-renderer'; +export * from './unified-followed-event-renderer'; diff --git a/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts index 19e29f8..4bfbfbe 100644 --- a/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts +++ b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/shared/renderer-map.ts @@ -24,6 +24,7 @@ import { FollowListFilteredListItemRenderer } from '../follow-list-renderer'; import { FollowListEntryFilteredListItemRenderer } from '../follow-list-entry-renderer'; import { FollowedTagEventFilteredListItemRenderer } from '../followed-tag-event-renderer'; import { FollowedDocumentEventFilteredListItemRenderer } from '../followed-document-event-renderer'; +import { UnifiedFollowedEventRenderer } from '../unified-followed-event-renderer'; import { DefaultFilteredListItemRenderer } from '../default-renderer'; const filteredEntityListItemRendererMap: Record { 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 0000000..3dea18e --- /dev/null +++ b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/unified-followed-event-renderer.tsx @@ -0,0 +1,450 @@ +"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 { + // Fallback for unexpected entity_type + return `${username} performed action on "${content}"`; + } +}; + +const getEntityTypeColor = (entityType: string): 'primary' | 'secondary' | 'info' | 'default' => { + switch (entityType) { + case 'proposition': + return 'primary'; + case 'tag': + return 'secondary'; + case 'document': + return 'info'; + default: + return 'default'; + } +}; + +const getEntityTypeLabel = (entityType: string): string => { + switch (entityType) { + case 'proposition': + return 'Proposition'; + case 'tag': + return 'Tag'; + case 'document': + return 'Document'; + default: + return entityType; + } +}; + +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 { + // 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)} + + + + + {new Date(event.event_time).toLocaleString()} + {' • '} + + {event.event_username} + + {eventLinks.map((link, idx) => ( + + {' • '} + {link} + + ))} + + + ); +}; 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 58612d1..0b0e4b4 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() && -- GitLab From 4087b2e970cc42a6da5b80b06aa9ee816d951943 Mon Sep 17 00:00:00 2001 From: pete Date: Tue, 16 Dec 2025 11:37:22 -0500 Subject: [PATCH 04/26] Created event-type dropdown in following page to match entity types --- ratings-sql/R__053_filters_v2_data.sql | 2 +- .../filtered-entity-list-filters.tsx | 73 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/ratings-sql/R__053_filters_v2_data.sql b/ratings-sql/R__053_filters_v2_data.sql index 929db70..8ae6cf9 100644 --- a/ratings-sql/R__053_filters_v2_data.sql +++ b/ratings-sql/R__053_filters_v2_data.sql @@ -699,7 +699,7 @@ WITH default_entity_filters (entity_type, filter_name, filter_sql, filter_type, ('followed_document_event', 'event_type', 'followed_event_type = $1', 'text', 'Event Type', 0), ('unified_followed_event', 'follower_user_id', 'follower_user_id = $1', 'number', 'Follower User ID', 0), ('unified_followed_event', 'entity_type', 'entity_type = $1', 'select:[proposition,tag,document]', 'Entity Type', 0), - ('unified_followed_event', 'event_type', 'followed_event_type = $1', 'text', 'Event Type', 0), + ('unified_followed_event', 'event_type', 'followed_event_type = $1', 'unified_event_type', 'Event Type', 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-ui-demo/src/components/filtered-entity-list-filters.tsx b/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx index 6384cb4..a91e11c 100644 --- a/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx +++ b/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx @@ -39,6 +39,7 @@ interface CustomFilterProps { filter: EntityFilter; value: FilterInputSelection; onChange: (filter_name: string, value: FilterInputSelection | undefined) => void; + allValues?: FilterInputSelection[]; } // Built-in filter components @@ -550,6 +551,74 @@ 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. + + )} + + ); +}; + // Default filter type components const defaultFilterComponents: Record> = { text: TextFilterInput, @@ -560,7 +629,8 @@ const defaultFilterComponents: Record = ({ @@ -658,6 +728,7 @@ export const FilteredEntityListFilters: React.FC filter={filter} value={currentValue} onChange={(filterName, newValue) => handleFilterChange(filterName, newValue)} + allValues={values} /> ); -- GitLab From cfcfd6694e032150adf9a2f660d309e07973875d Mon Sep 17 00:00:00 2001 From: pete Date: Tue, 16 Dec 2025 16:01:22 -0500 Subject: [PATCH 05/26] Fixed merge conflict Created Timeframe filter for Recent Events on following page' --- ratings-sql/R__031_default_pages.sql | 7 +- ratings-sql/R__053_filters_v2_data.sql | 2 + .../filtered-entity-list-filters.tsx | 197 +++++++++++++++++- .../src/components/filtered-entity-list.tsx | 2 + .../unified-followed-event-renderer.tsx | 12 +- .../src/pvcomponents/pv-current-time.tsx | 50 +++++ .../src/pvcomponents/use-pv-components.ts | 2 + 7 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 ratings-ui-demo/src/pvcomponents/pv-current-time.tsx diff --git a/ratings-sql/R__031_default_pages.sql b/ratings-sql/R__031_default_pages.sql index a5d8131..1b7ad01 100644 --- a/ratings-sql/R__031_default_pages.sql +++ b/ratings-sql/R__031_default_pages.sql @@ -696,7 +696,10 @@ Manage your subscriptions to propositions, tags, and documents. --- -## Recent Events + + Recent Events + + View recent activity from the items you''re following. @@ -705,7 +708,7 @@ View recent activity from the items you''re following. entityType="unified_followed_event" showSort={true} showFilter={true} - inputFilterNames={["entity_type", "event_type"]} + inputFilterNames={["entity_type", "event_type", "event_after"]} secondarySort={[{"column": "event_time", "direction": "DESC"}]} /> ', true, 0, false), diff --git a/ratings-sql/R__053_filters_v2_data.sql b/ratings-sql/R__053_filters_v2_data.sql index 8ae6cf9..8eca702 100644 --- a/ratings-sql/R__053_filters_v2_data.sql +++ b/ratings-sql/R__053_filters_v2_data.sql @@ -700,6 +700,8 @@ WITH default_entity_filters (entity_type, filter_name, filter_sql, filter_type, ('unified_followed_event', 'follower_user_id', 'follower_user_id = $1', 'number', 'Follower User ID', 0), ('unified_followed_event', 'entity_type', 'entity_type = $1', 'select:[proposition,tag,document]', 'Entity Type', 0), ('unified_followed_event', 'event_type', 'followed_event_type = $1', 'unified_event_type', 'Event Type', 0), + ('unified_followed_event', 'event_after', 'event_time >= $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-ui-demo/src/components/filtered-entity-list-filters.tsx b/ratings-ui-demo/src/components/filtered-entity-list-filters.tsx index a91e11c..fd2f27d 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'; @@ -619,6 +622,168 @@ const UnifiedEventTypeFilterInput: React.FC = ({ filter, valu ); }; +// 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, @@ -631,6 +796,7 @@ const defaultFilterComponents: Record = ({ @@ -759,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 0389900..ef2aa32 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 && { } }; +// 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; @@ -424,7 +434,7 @@ export const UnifiedFollowedEventRenderer: React.FC - {new Date(event.event_time).toLocaleString()} + {formatEventTime(event.event_time)} {' • '} (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/use-pv-components.ts b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts index bb3e2d6..3d84446 100644 --- a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts +++ b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts @@ -65,6 +65,7 @@ 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"; const usePVComponents = () => { const components = { @@ -79,6 +80,7 @@ const usePVComponents = () => { PvFollowListDetail, PvQuickFollowButton, PvUserFeedList, + PvCurrentTime, PvPageDirectory, PvPageSearch, PvText, -- GitLab From 7ca055dc88b96ce32876db87e75d2091979c5d51 Mon Sep 17 00:00:00 2001 From: pete Date: Wed, 17 Dec 2025 17:10:56 -0500 Subject: [PATCH 06/26] Fixed merge conflict Created prototype capability for statistical feed/follow events -- only propositions for now --- .../R__007_rating_calculation_functions.sql | 9 + ratings-sql/R__011_api_v1.sql | 505 ++++++++++++++++++ ratings-sql/R__031_default_pages.sql | 9 + ratings-sql/R__053_filters_v2_data.sql | 57 +- ratings-sql/V073__add_statistical_events.sql | 85 +++ .../filtered-entity-list-entities.ts | 2 +- .../unified-followed-event-renderer.tsx | 35 +- .../components/follow-proposition-dialog.tsx | 138 ++++- .../statistical-event-definition-dialog.tsx | 365 +++++++++++++ ratings-ui-demo/src/hooks/use-session.ts | 17 +- .../pv-statistical-event-definitions.tsx | 316 +++++++++++ .../src/pvcomponents/use-pv-components.ts | 2 + ratings-ui-demo/src/services/api-v1.ts | 180 ++++++- 13 files changed, 1689 insertions(+), 31 deletions(-) create mode 100644 ratings-sql/V073__add_statistical_events.sql create mode 100644 ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx create mode 100644 ratings-ui-demo/src/pvcomponents/pv-statistical-event-definitions.tsx diff --git a/ratings-sql/R__007_rating_calculation_functions.sql b/ratings-sql/R__007_rating_calculation_functions.sql index b4652fb..f5d5d0f 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,14 @@ 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; END; $$ LANGUAGE plpgsql; diff --git a/ratings-sql/R__011_api_v1.sql b/ratings-sql/R__011_api_v1.sql index 0c923c5..1fa1e89 100644 --- a/ratings-sql/R__011_api_v1.sql +++ b/ratings-sql/R__011_api_v1.sql @@ -4122,6 +4122,511 @@ $$; COMMENT ON FUNCTION api_v1.is_following_document IS 'Check if current user is following a document'; +-- ============================================================================ +-- Statistical Event Definitions +-- ============================================================================ +-- Users can create definitions for aggregate/statistical events on propositions +-- (e.g., "rating reaches 90%", "gets 100 votes") + +CREATE OR REPLACE FUNCTION api_v1.create_statistical_event_definition( + p_user_id integer, + p_name text, + p_description text DEFAULT NULL, + 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 +) +RETURNS integer +LANGUAGE plpgsql +AS $$ +DECLARE + v_definition_id integer; +BEGIN + -- Validate metric_type + IF p_metric_type NOT IN ('rating_count', 'aggregate_rating') THEN + RAISE EXCEPTION 'Invalid metric_type: %. Must be rating_count or aggregate_rating', p_metric_type; + 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 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 + ) + VALUES ( + p_user_id, + p_name, + p_description, + 'proposition', + p_metric_type::statistical_metric_type, + p_rating_algorithm_id, + p_threshold_value, + 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_statistical_event_definition IS 'Create a statistical event definition for propositions'; + +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 +) +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) + 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.delete_statistical_event_definition( + p_user_id integer, + p_definition_id integer +) +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; + + -- Delete will cascade to followed_statistical_events and statistical_event_occurrences + DELETE FROM statistical_event_definitions + WHERE id = p_definition_id AND creator_id = p_user_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.delete_statistical_event_definition IS 'Delete a statistical event definition (owner only, cascades to subscriptions)'; + +CREATE OR REPLACE FUNCTION api_v1.get_statistical_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, + 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 +) +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 + 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) + ORDER BY + (sed.creator_id = p_user_id) DESC, -- Own definitions first + sed.creation_time DESC; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_statistical_event_definitions IS 'Get statistical event definitions (own + optionally published)'; + +-- ============================================================================ +-- Statistical Event Subscriptions +-- ============================================================================ +-- Users subscribe definitions to specific propositions + +CREATE OR REPLACE FUNCTION api_v1.follow_statistical_event( + p_user_id integer, + p_definition_id integer, + p_proposition_id integer +) +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 metric_type, rating_algorithm_id 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 proposition exists + 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; + + -- 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; + 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 proposition_id = p_proposition_id; + + 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 + INSERT INTO followed_statistical_events ( + creator_id, + definition_id, + proposition_id, + is_active, + last_metric_value, + last_evaluated_at + ) + VALUES ( + p_user_id, + p_definition_id, + p_proposition_id, + TRUE, + v_current_value, + 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 definition for a specific proposition'; + +CREATE OR REPLACE FUNCTION api_v1.unfollow_statistical_event( + p_user_id integer, + p_definition_id integer, + p_proposition_id integer +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM followed_statistical_events + WHERE creator_id = p_user_id + AND definition_id = p_definition_id + AND proposition_id = p_proposition_id; +END; +$$; + +COMMENT ON FUNCTION api_v1.unfollow_statistical_event IS 'Unsubscribe from a statistical event for a proposition'; + +CREATE OR REPLACE FUNCTION api_v1.get_followed_statistical_events( + p_user_id integer, + p_proposition_id integer DEFAULT NULL +) +RETURNS TABLE ( + id integer, + definition_id integer, + definition_name text, + proposition_id integer, + proposition_body 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.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 + INNER JOIN propositions p ON fse.proposition_id = p.id + WHERE fse.creator_id = p_user_id + AND (p_proposition_id IS NULL OR fse.proposition_id = p_proposition_id) + ORDER BY fse.creation_time DESC; +END; +$$; + +COMMENT ON FUNCTION api_v1.get_followed_statistical_events IS 'Get statistical event subscriptions for a user (optionally filtered by proposition)'; + +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, + is_subscribed boolean +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + sed.id as definition_id, + sed.name::text as definition_name, + sed.metric_type::text, + sed.threshold_value, + sed.crossing_direction::text, + 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.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 statistical event definitions with subscription status for a proposition'; + +-- ============================================================================ +-- Statistical Event Evaluation +-- ============================================================================ +-- Called by cron job to detect threshold crossings + +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.last_metric_value, + sed.metric_type, + sed.rating_algorithm_id, + sed.threshold_value, + sed.crossing_direction, + 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 + 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 + -- Get cached aggregate rating for the algorithm + -- rating_algorithm_id is the procedure id, we need to get the creator_id from it + 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; + + -- 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 and create occurrences for threshold crossings. Called by cron job.'; + +-- 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) -- ============================================================================ diff --git a/ratings-sql/R__031_default_pages.sql b/ratings-sql/R__031_default_pages.sql index 1b7ad01..79e88e4 100644 --- a/ratings-sql/R__031_default_pages.sql +++ b/ratings-sql/R__031_default_pages.sql @@ -696,6 +696,15 @@ Manage your subscriptions to propositions, tags, and documents. --- +## Statistical Event Definitions + +Create and manage definitions for statistical events like "rating reaches 90%" or "gets 100 ratings". +These can be applied to any proposition you follow. + + + +--- + Recent Events diff --git a/ratings-sql/R__053_filters_v2_data.sql b/ratings-sql/R__053_filters_v2_data.sql index 8eca702..70a5588 100644 --- a/ratings-sql/R__053_filters_v2_data.sql +++ b/ratings-sql/R__053_filters_v2_data.sql @@ -340,7 +340,7 @@ SELECT follower.username AS follower_username, ua.user_id AS event_user_id, event_user.username AS event_username, - ua.activity_type, + ua.activity_type::text AS activity_type, ua.activity_time AS event_time, ua.activity_description AS event_description, ua.proposition_id_1, @@ -377,7 +377,7 @@ SELECT follower.username AS follower_username, ua.user_id AS event_user_id, event_user.username AS event_username, - ua.activity_type, + ua.activity_type::text AS activity_type, ua.activity_time AS event_time, ua.activity_description AS event_description, ua.proposition_id_1, @@ -410,7 +410,7 @@ SELECT follower.username AS follower_username, ua.user_id AS event_user_id, event_user.username AS event_username, - ua.activity_type, + ua.activity_type::text AS activity_type, ua.activity_time AS event_time, ua.activity_description AS event_description, ua.proposition_id_1, @@ -473,7 +473,7 @@ SELECT follower.username AS follower_username, ua.user_id AS event_user_id, event_user.username AS event_username, - ua.activity_type, + ua.activity_type::text AS activity_type, ua.activity_time AS event_time, ua.activity_description AS event_description, ua.proposition_id_1, @@ -511,7 +511,7 @@ SELECT follower.username AS follower_username, ua.user_id AS event_user_id, event_user.username AS event_username, - ua.activity_type, + ua.activity_type::text AS activity_type, ua.activity_time AS event_time, ua.activity_description AS event_description, ua.proposition_id_1, @@ -545,7 +545,7 @@ SELECT follower.username AS follower_username, ua.user_id AS event_user_id, event_user.username AS event_username, - ua.activity_type, + ua.activity_type::text AS activity_type, ua.activity_time AS event_time, ua.activity_description AS event_description, ua.proposition_id_1, @@ -589,6 +589,49 @@ WHERE WHERE pdl.id::text = SUBSTRING(ua.activity_id FROM 27)::text AND pdl.document_id = fd.document_id )) + +UNION ALL + +-- Statistical event occurrences +SELECT + ''statistical_event_'' || seo.id AS event_id, + ''statistical_event'' AS entity_type, + fse.id AS followed_entity_id, + fse.proposition_id AS entity_id, + fse.creator_id AS follower_user_id, + follower.username AS follower_username, + fse.creator_id AS event_user_id, + follower.username AS event_username, + ''statistical_event'' AS activity_type, + seo.triggered_at AS event_time, + sed.name || '': '' || + CASE + WHEN seo.crossed_direction = ''up'' THEN ''crossed above '' + ELSE ''crossed below '' + END || + CASE + WHEN sed.metric_type::text = ''aggregate_rating'' THEN COALESCE(ROUND(sed.threshold_value * 100)::text, ''?'') || ''%'' + ELSE COALESCE(sed.threshold_value::text, ''?'') || '' ratings'' + END || + '' (was '' || + CASE + WHEN sed.metric_type::text = ''aggregate_rating'' THEN COALESCE(ROUND(COALESCE(seo.previous_value, 0) * 100)::text, ''?'') || ''%, now '' || COALESCE(ROUND(COALESCE(seo.new_value, 0) * 100)::text, ''?'') || ''%'' + ELSE COALESCE(seo.previous_value::text, ''?'') || '', now '' || COALESCE(seo.new_value::text, ''?'') + END || '')'' AS event_description, + fse.proposition_id AS proposition_id_1, + NULL::integer AS proposition_id_2, + NULL::integer AS proposition_id_3, + NULL::integer AS tag_id_1, + p.body AS content_text, + ''statistical'' AS followed_event_type +FROM + statistical_event_occurrences seo + JOIN followed_statistical_events fse ON seo.followed_event_id = fse.id + JOIN statistical_event_definitions sed ON fse.definition_id = sed.id + JOIN users follower ON fse.creator_id = follower.id + LEFT JOIN propositions p ON fse.proposition_id = p.id +WHERE + fse.is_active = TRUE ', 'event_id', 0) ,('entity_type', 'SELECT id, creator_id, creation_time, entity_type, base_query, id_column FROM entity_types', 'id', 0) ,('entity_filter', 'SELECT id, creator_id, creation_time, entity_type, filter_name, filter_sql, filter_type, filter_label FROM entity_filters', 'id', 0) @@ -698,7 +741,7 @@ WITH default_entity_filters (entity_type, filter_name, filter_sql, filter_type, ('followed_document_event', 'document_id', 'followed_document_id = $1', 'number', 'Document ID', 0), ('followed_document_event', 'event_type', 'followed_event_type = $1', 'text', 'Event Type', 0), ('unified_followed_event', 'follower_user_id', 'follower_user_id = $1', 'number', 'Follower User ID', 0), - ('unified_followed_event', 'entity_type', 'entity_type = $1', 'select:[proposition,tag,document]', 'Entity Type', 0), + ('unified_followed_event', 'entity_type', 'entity_type = $1', 'select:[proposition,tag,document,statistical_event]', 'Entity Type', 0), ('unified_followed_event', 'event_type', 'followed_event_type = $1', 'unified_event_type', 'Event Type', 0), ('unified_followed_event', 'event_after', 'event_time >= $1', 'timeframe', 'Timeframe', 0), ('unified_followed_event', 'event_before', 'event_time <= $1', 'datetime', 'To', 0), diff --git a/ratings-sql/V073__add_statistical_events.sql b/ratings-sql/V073__add_statistical_events.sql new file mode 100644 index 0000000..832b2e0 --- /dev/null +++ b/ratings-sql/V073__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-ui-demo/src/components/filtered-entity-list-entities.ts b/ratings-ui-demo/src/components/filtered-entity-list-entities.ts index ffcd309..430bb43 100644 --- a/ratings-ui-demo/src/components/filtered-entity-list-entities.ts +++ b/ratings-ui-demo/src/components/filtered-entity-list-entities.ts @@ -408,7 +408,7 @@ export interface FollowListEntryEntity { export interface UnifiedFollowedEventEntity { event_id: string; - entity_type: 'proposition' | 'tag' | 'document'; + entity_type: 'proposition' | 'tag' | 'document' | 'statistical_event'; followed_entity_id: number; entity_id: number; follower_user_id: number; 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 index ca6baf5..94782a0 100644 --- a/ratings-ui-demo/src/components/filteredentitylistitemrenderers/unified-followed-event-renderer.tsx +++ b/ratings-ui-demo/src/components/filteredentitylistitemrenderers/unified-followed-event-renderer.tsx @@ -54,13 +54,16 @@ const getEventTitle = (event: UnifiedFollowedEventEntity): string => { 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' | 'default' => { +const getEntityTypeColor = (entityType: string): 'primary' | 'secondary' | 'info' | 'success' | 'default' => { switch (entityType) { case 'proposition': return 'primary'; @@ -68,6 +71,8 @@ const getEntityTypeColor = (entityType: string): 'primary' | 'secondary' | 'info return 'secondary'; case 'document': return 'info'; + case 'statistical_event': + return 'success'; default: return 'default'; } @@ -81,6 +86,8 @@ const getEntityTypeLabel = (entityType: string): string => { return 'Tag'; case 'document': return 'Document'; + case 'statistical_event': + return 'Statistical'; default: return entityType; } @@ -334,6 +341,32 @@ export const UnifiedFollowedEventRenderer: React.FC + View proposition + + ); + } + links.push( + + Manage following + + ); } else { // Document event links (from followed-document-event-renderer.tsx) switch (event.followed_event_type) { diff --git a/ratings-ui-demo/src/components/follow-proposition-dialog.tsx b/ratings-ui-demo/src/components/follow-proposition-dialog.tsx index 3a462cc..3362d48 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/statistical-event-definition-dialog.tsx b/ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx new file mode 100644 index 0000000..f2e2fb8 --- /dev/null +++ b/ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx @@ -0,0 +1,365 @@ +"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 } from "@/services/api-v1"; + +export type MetricType = 'rating_count' | 'aggregate_rating'; +export type CrossingDirection = 'up_only' | 'both'; + +export interface StatisticalEventDefinition { + id?: number; + name: string; + description?: string; + metric_type: MetricType; + rating_algorithm_id?: number; + threshold_value: number; + crossing_direction: CrossingDirection; + is_published: boolean; +} + +export interface StatisticalEventDefinitionDialogProps { + open: boolean; + onClose: () => void; + onSaved?: (definitionId: number) => void; + editDefinition?: StatisticalEventDefinition; // If provided, dialog is in edit mode +} + +const metricTypeLabels: Record = { + 'rating_count': 'Number of ratings', + 'aggregate_rating': 'Aggregate rating value' +}; + +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 [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 [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 || ''); + 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); + } else { + // Reset to defaults for new definition + setName(''); + setDescription(''); + setMetricType('aggregate_rating'); + setRatingAlgorithmId(''); + setThresholdValue('90'); + setCrossingDirection('up_only'); + setIsPublished(false); + } + 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 handleMetricTypeChange = (event: SelectChangeEvent) => { + const newType = event.target.value as MetricType; + setMetricType(newType); + // Reset threshold to sensible defaults + if (newType === 'aggregate_rating') { + setThresholdValue('90'); + } else { + setThresholdValue('10'); + } + }; + + 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'; + } + 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; + } + + if (isEditMode && editDefinition?.id) { + await api.updateStatisticalEventDefinition( + userId, + editDefinition.id, + name, + description || undefined, + threshold, + crossingDirection, + isPublished + ); + notifySuccess('Definition updated'); + onSaved?.(editDefinition.id); + } else { + const definitionId = await api.createStatisticalEventDefinition( + userId, + name, + description || undefined, + metricType, + metricType === 'aggregate_rating' ? (ratingAlgorithmId as number) : undefined, + threshold, + crossingDirection, + isPublished + ); + 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 (metricType === 'aggregate_rating') { + const alg = algorithms.find(a => a.id === ratingAlgorithmId); + return `Rating reaches ${thresholdValue}% (${alg?.name || 'algorithm'})`; + } else { + return `Gets ${thresholdValue} ratings`; + } + }; + + 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" + /> + + + Metric Type + + + + {metricType === 'aggregate_rating' && ( + + Rating Algorithm + + + )} + + setThresholdValue(e.target.value)} + fullWidth + required + InputProps={{ + endAdornment: metricType === 'aggregate_rating' ? ( + % + ) : ( + ratings + ) + }} + helperText={ + metricType === 'aggregate_rating' + ? 'Rating percentage (0-100) at which to trigger' + : 'Number of ratings at which to trigger' + } + /> + + + 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 0e71f0d..163537d 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-statistical-event-definitions.tsx b/ratings-ui-demo/src/pvcomponents/pv-statistical-event-definitions.tsx new file mode 100644 index 0000000..bfbdcf4 --- /dev/null +++ b/ratings-ui-demo/src/pvcomponents/pv-statistical-event-definitions.tsx @@ -0,0 +1,316 @@ +"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, + 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 + }; + 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/use-pv-components.ts b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts index 3d84446..cbbeba5 100644 --- a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts +++ b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts @@ -66,6 +66,7 @@ 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"; const usePVComponents = () => { const components = { @@ -81,6 +82,7 @@ const usePVComponents = () => { PvQuickFollowButton, PvUserFeedList, PvCurrentTime, + PvStatisticalEventDefinitions, PvPageDirectory, PvPageSearch, PvText, diff --git a/ratings-ui-demo/src/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index ba20855..7f81315 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -348,6 +348,56 @@ export interface FollowedDocumentEvent { followed_event_type: FollowedDocumentEventType; } +// Statistical Event Definitions +export type StatisticalMetricType = 'rating_count' | 'aggregate_rating'; +export type CrossingDirection = 'up_only' | 'both'; + +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; +} + +export interface FollowedStatisticalEvent { + id: number; + definition_id: number; + definition_name: string; + proposition_id: number; + proposition_body: 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; +} + +export interface StatisticalEventOccurrence { + id: number; + followed_event_id: number; + triggered_at: string; + previous_value: number; + new_value: number; + crossed_direction: 'up' | 'down'; +} + export interface SavedAnalysisQuery { id: number; creator_id: number; @@ -1472,7 +1522,7 @@ 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; @@ -1509,6 +1559,35 @@ export interface IApiClient { // Integration test cleanup cleanupIntegrationTestData(testUsername: string): Promise; getUserFeedEvents(userId: number, limit?: number, offset?: number, sinceTime?: string): Promise; + + // Statistical Event Definitions + createStatisticalEventDefinition( + userId: number, + name: string, + description?: string, + metricType?: StatisticalMetricType, + ratingAlgorithmId?: number, + thresholdValue?: number, + crossingDirection?: CrossingDirection, + isPublished?: boolean + ): Promise; + updateStatisticalEventDefinition( + userId: number, + definitionId: number, + name?: string, + description?: string, + thresholdValue?: number, + crossingDirection?: CrossingDirection, + isPublished?: boolean + ): Promise; + deleteStatisticalEventDefinition(userId: number, definitionId: number): Promise; + getStatisticalEventDefinitions(userId: number, includePublished?: boolean): Promise; + + // Statistical Event Subscriptions + followStatisticalEvent(userId: number, definitionId: number, propositionId: number): Promise; + unfollowStatisticalEvent(userId: number, definitionId: number, propositionId: number): Promise; + getFollowedStatisticalEvents(userId: number, propositionId?: number): Promise; + getStatisticalEventDefinitionsForProposition(userId: number, propositionId: number): Promise; } export class ApiClient implements IApiClient { @@ -4288,4 +4367,103 @@ export class ApiClient implements IApiClient { }); return response.data; } + + // ======================================================================== + // Statistical Event Definitions + // ======================================================================== + + async createStatisticalEventDefinition( + userId: number, + name: string, + description?: string, + metricType: StatisticalMetricType = 'aggregate_rating', + ratingAlgorithmId?: number, + thresholdValue: number = 0.9, + crossingDirection: CrossingDirection = 'up_only', + isPublished: boolean = false + ): Promise { + const response = await this.axios.post('/rpc/create_statistical_event_definition', { + p_user_id: userId, + p_name: name, + p_description: description, + p_metric_type: metricType, + p_rating_algorithm_id: ratingAlgorithmId, + p_threshold_value: thresholdValue, + p_crossing_direction: crossingDirection, + p_is_published: isPublished + }); + return response.data; + } + + async updateStatisticalEventDefinition( + userId: number, + definitionId: number, + name?: string, + description?: string, + thresholdValue?: number, + crossingDirection?: CrossingDirection, + isPublished?: boolean + ): 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 + }); + } + + 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): Promise { + const response = await this.axios.post('/rpc/get_statistical_event_definitions', { + p_user_id: userId, + p_include_published: includePublished + }); + return response.data; + } + + // ======================================================================== + // Statistical Event Subscriptions + // ======================================================================== + + async followStatisticalEvent(userId: number, definitionId: number, propositionId: number): Promise { + const response = await this.axios.post('/rpc/follow_statistical_event', { + p_user_id: userId, + p_definition_id: definitionId, + p_proposition_id: propositionId + }); + return response.data; + } + + async unfollowStatisticalEvent(userId: number, definitionId: number, propositionId: number): Promise { + await this.axios.post('/rpc/unfollow_statistical_event', { + p_user_id: userId, + p_definition_id: definitionId, + p_proposition_id: propositionId + }); + } + + async getFollowedStatisticalEvents(userId: number, propositionId?: number): Promise { + const response = await this.axios.post('/rpc/get_followed_statistical_events', { + p_user_id: userId, + p_proposition_id: propositionId + }); + 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; + } } -- GitLab From e43cfa636c83e6dbda431ef40b84cd3d75df5095 Mon Sep 17 00:00:00 2001 From: pete Date: Thu, 18 Dec 2025 17:33:13 -0500 Subject: [PATCH 07/26] Extended statistical events to tags for prototype feed/follow feature --- ratings-sql/R__011_api_v1.sql | 314 ++++++++++++------ ratings-sql/R__053_filters_v2_data.sql | 15 +- ...74__extend_statistical_events_for_tags.sql | 34 ++ .../V075__fix_proposition_id_nullable.sql | 8 + .../src/components/follow-tag-dialog.tsx | 137 +++++++- .../statistical-event-definition-dialog.tsx | 163 +++++++-- .../src/pvcomponents/pv-follow-manager.tsx | 2 +- .../pv-statistical-event-definitions.tsx | 4 +- ratings-ui-demo/src/services/api-v1.ts | 73 ++-- 9 files changed, 587 insertions(+), 163 deletions(-) create mode 100644 ratings-sql/V074__extend_statistical_events_for_tags.sql create mode 100644 ratings-sql/V075__fix_proposition_id_nullable.sql diff --git a/ratings-sql/R__011_api_v1.sql b/ratings-sql/R__011_api_v1.sql index 1fa1e89..f562559 100644 --- a/ratings-sql/R__011_api_v1.sql +++ b/ratings-sql/R__011_api_v1.sql @@ -4122,21 +4122,51 @@ $$; 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 $$; -- ============================================================================ --- Statistical Event Definitions +-- 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(); + -- ============================================================================ --- Users can create definitions for aggregate/statistical events on propositions --- (e.g., "rating reaches 90%", "gets 100 votes") 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_is_published boolean DEFAULT FALSE, + p_time_window_minutes integer DEFAULT NULL ) RETURNS integer LANGUAGE plpgsql @@ -4144,9 +4174,20 @@ AS $$ DECLARE v_definition_id integer; BEGIN - -- Validate metric_type - IF p_metric_type NOT IN ('rating_count', 'aggregate_rating') THEN - RAISE EXCEPTION 'Invalid metric_type: %. Must be rating_count or aggregate_rating', p_metric_type; + -- 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 @@ -4159,6 +4200,11 @@ BEGIN 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 @@ -4175,18 +4221,20 @@ BEGIN rating_algorithm_id, threshold_value, crossing_direction, - is_published + is_published, + time_window_minutes ) VALUES ( p_user_id, p_name, p_description, - 'proposition', + 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_is_published, + p_time_window_minutes ) RETURNING id INTO v_definition_id; @@ -4194,7 +4242,7 @@ BEGIN END; $$; -COMMENT ON FUNCTION api_v1.create_statistical_event_definition IS 'Create a statistical event definition for propositions'; +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, @@ -4203,7 +4251,8 @@ CREATE OR REPLACE FUNCTION api_v1.update_statistical_event_definition( p_description text DEFAULT NULL, p_threshold_value numeric DEFAULT NULL, p_crossing_direction text DEFAULT NULL, - p_is_published boolean DEFAULT NULL + p_is_published boolean DEFAULT NULL, + p_time_window_minutes integer DEFAULT NULL ) RETURNS void LANGUAGE plpgsql @@ -4228,40 +4277,18 @@ BEGIN 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) + 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.delete_statistical_event_definition( - p_user_id integer, - p_definition_id integer -) -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; - - -- Delete will cascade to followed_statistical_events and statistical_event_occurrences - DELETE FROM statistical_event_definitions - WHERE id = p_definition_id AND creator_id = p_user_id; -END; -$$; - -COMMENT ON FUNCTION api_v1.delete_statistical_event_definition IS 'Delete a statistical event definition (owner only, cascades to subscriptions)'; - CREATE OR REPLACE FUNCTION api_v1.get_statistical_event_definitions( p_user_id integer, - p_include_published boolean DEFAULT TRUE + p_include_published boolean DEFAULT TRUE, + p_entity_type text DEFAULT NULL ) RETURNS TABLE ( id integer, @@ -4277,7 +4304,8 @@ RETURNS TABLE ( crossing_direction text, is_published boolean, creation_time timestamp with time zone, - is_own boolean + is_own boolean, + time_window_minutes integer ) LANGUAGE plpgsql AS $$ @@ -4297,29 +4325,30 @@ BEGIN 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.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) + 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, -- Own definitions first + (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)'; +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 +-- Statistical Event Subscriptions - UPDATED FOR TAGS -- ============================================================================ --- Users subscribe definitions to specific propositions CREATE OR REPLACE FUNCTION api_v1.follow_statistical_event( p_user_id integer, p_definition_id integer, - p_proposition_id integer + p_proposition_id integer DEFAULT NULL, + p_tag_id integer DEFAULT NULL ) RETURNS integer LANGUAGE plpgsql @@ -4330,7 +4359,7 @@ DECLARE v_current_value numeric; BEGIN -- Validate definition exists and user can access it (own or published) - SELECT metric_type, rating_algorithm_id INTO v_definition + 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); @@ -4339,9 +4368,21 @@ BEGIN RAISE EXCEPTION 'Definition with ID % not found or not accessible', p_definition_id; END IF; - -- Validate proposition exists - IF NOT EXISTS (SELECT 1 FROM propositions WHERE id = p_proposition_id) THEN - RAISE EXCEPTION 'Proposition with ID % does not exist', p_proposition_id; + -- 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 @@ -4355,6 +4396,20 @@ BEGIN 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 @@ -4362,7 +4417,8 @@ BEGIN FROM followed_statistical_events WHERE creator_id = p_user_id AND definition_id = p_definition_id - AND proposition_id = p_proposition_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 @@ -4376,10 +4432,12 @@ BEGIN 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 @@ -4388,8 +4446,9 @@ BEGIN p_user_id, p_definition_id, p_proposition_id, + p_tag_id, TRUE, - v_current_value, + 0, -- Start at 0 to detect if already above threshold NOW() ) RETURNING id INTO v_subscription_id; @@ -4398,12 +4457,13 @@ BEGIN END; $$; -COMMENT ON FUNCTION api_v1.follow_statistical_event IS 'Subscribe to a statistical event definition for a specific proposition'; +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 + p_proposition_id integer DEFAULT NULL, + p_tag_id integer DEFAULT NULL ) RETURNS void LANGUAGE plpgsql @@ -4412,15 +4472,17 @@ BEGIN DELETE FROM followed_statistical_events WHERE creator_id = p_user_id AND definition_id = p_definition_id - AND proposition_id = p_proposition_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'; +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_proposition_id integer DEFAULT NULL, + p_tag_id integer DEFAULT NULL ) RETURNS TABLE ( id integer, @@ -4428,6 +4490,8 @@ RETURNS TABLE ( 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 @@ -4442,20 +4506,25 @@ BEGIN 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 - INNER JOIN propositions p ON fse.proposition_id = p.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 statistical event subscriptions for a user (optionally filtered by proposition)'; +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 @@ -4466,6 +4535,7 @@ RETURNS TABLE ( metric_type text, threshold_value numeric, crossing_direction text, + time_window_minutes integer, is_subscribed boolean ) LANGUAGE plpgsql @@ -4474,10 +4544,11 @@ BEGIN RETURN QUERY SELECT sed.id as definition_id, - sed.name::text as definition_name, + 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 @@ -4486,20 +4557,62 @@ BEGIN AND fse.is_active = TRUE ) as is_subscribed FROM statistical_event_definitions sed - WHERE sed.creator_id = p_user_id - OR sed.is_published = TRUE + 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 statistical event definitions with subscription status for a proposition'; +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 +-- Statistical Event Evaluation - UPDATED FOR TAGS -- ============================================================================ --- Called by cron job to detect threshold crossings CREATE OR REPLACE FUNCTION api_v1.evaluate_statistical_events() RETURNS integer @@ -4519,29 +4632,50 @@ BEGIN 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 - 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 - -- Get cached aggregate rating for the algorithm - -- rating_algorithm_id is the procedure id, we need to get the creator_id from it - 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; + -- 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 @@ -4611,24 +4745,4 @@ BEGIN END; $$; -COMMENT ON FUNCTION api_v1.evaluate_statistical_events IS 'Evaluate all statistical event subscriptions and create occurrences for threshold crossings. Called by cron job.'; - --- 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. +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.'; diff --git a/ratings-sql/R__053_filters_v2_data.sql b/ratings-sql/R__053_filters_v2_data.sql index 70a5588..92b4eb8 100644 --- a/ratings-sql/R__053_filters_v2_data.sql +++ b/ratings-sql/R__053_filters_v2_data.sql @@ -590,14 +590,15 @@ WHERE AND pdl.document_id = fd.document_id )) + UNION ALL --- Statistical event occurrences +-- Statistical event occurrences (propositions and tags) SELECT ''statistical_event_'' || seo.id AS event_id, ''statistical_event'' AS entity_type, fse.id AS followed_entity_id, - fse.proposition_id AS entity_id, + COALESCE(fse.proposition_id, fse.tag_id) AS entity_id, fse.creator_id AS follower_user_id, follower.username AS follower_username, fse.creator_id AS event_user_id, @@ -611,7 +612,10 @@ SELECT END || CASE WHEN sed.metric_type::text = ''aggregate_rating'' THEN COALESCE(ROUND(sed.threshold_value * 100)::text, ''?'') || ''%'' - ELSE COALESCE(sed.threshold_value::text, ''?'') || '' ratings'' + WHEN sed.metric_type::text = ''rating_count'' THEN COALESCE(sed.threshold_value::text, ''?'') || '' ratings'' + WHEN sed.metric_type::text = ''tag_usage_count'' THEN COALESCE(sed.threshold_value::text, ''?'') || '' uses'' + WHEN sed.metric_type::text = ''tag_usage_count_period'' THEN COALESCE(sed.threshold_value::text, ''?'') || '' uses in '' || COALESCE(sed.time_window_minutes::text, ''?'') || '' min'' + ELSE COALESCE(sed.threshold_value::text, ''?'') || '' (unknown metric)'' END || '' (was '' || CASE @@ -621,8 +625,8 @@ SELECT fse.proposition_id AS proposition_id_1, NULL::integer AS proposition_id_2, NULL::integer AS proposition_id_3, - NULL::integer AS tag_id_1, - p.body AS content_text, + fse.tag_id AS tag_id_1, + COALESCE(p.body, t.name) AS content_text, ''statistical'' AS followed_event_type FROM statistical_event_occurrences seo @@ -630,6 +634,7 @@ FROM JOIN statistical_event_definitions sed ON fse.definition_id = sed.id JOIN users follower ON fse.creator_id = follower.id LEFT JOIN propositions p ON fse.proposition_id = p.id + LEFT JOIN tags t ON fse.tag_id = t.id WHERE fse.is_active = TRUE ', 'event_id', 0) diff --git a/ratings-sql/V074__extend_statistical_events_for_tags.sql b/ratings-sql/V074__extend_statistical_events_for_tags.sql new file mode 100644 index 0000000..200fe9d --- /dev/null +++ b/ratings-sql/V074__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-sql/V075__fix_proposition_id_nullable.sql b/ratings-sql/V075__fix_proposition_id_nullable.sql new file mode 100644 index 0000000..5bec062 --- /dev/null +++ b/ratings-sql/V075__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-ui-demo/src/components/follow-tag-dialog.tsx b/ratings-ui-demo/src/components/follow-tag-dialog.tsx index 4226880..7e499ec 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 index f2e2fb8..d5017d2 100644 --- a/ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx +++ b/ratings-ui-demo/src/components/statistical-event-definition-dialog.tsx @@ -23,20 +23,23 @@ import { import { useApiClient } from "@/components/api-context"; import { useSession } from "@/hooks/use-session"; import useNotification from "@/hooks/use-notification"; -import { RatingAlgorithm } from "@/services/api-v1"; +import { RatingAlgorithm, StatisticalMetricType, StatisticalEntityType, CrossingDirection as ApiCrossingDirection } from "@/services/api-v1"; -export type MetricType = 'rating_count' | 'aggregate_rating'; -export type CrossingDirection = 'up_only' | 'both'; +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 { @@ -46,11 +49,21 @@ export interface StatisticalEventDefinitionDialogProps { editDefinition?: StatisticalEventDefinition; // If provided, dialog is in edit mode } -const metricTypeLabels: Record = { +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)' @@ -68,11 +81,13 @@ export function StatisticalEventDefinitionDialog({ 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); @@ -101,6 +116,7 @@ export function StatisticalEventDefinitionDialog({ 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 @@ -111,15 +127,18 @@ export function StatisticalEventDefinitionDialog({ } 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); } @@ -134,17 +153,39 @@ export function StatisticalEventDefinitionDialog({ } }, [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 { + } 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'; @@ -162,6 +203,15 @@ export function StatisticalEventDefinitionDialog({ 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; }; @@ -188,6 +238,8 @@ export function StatisticalEventDefinitionDialog({ threshold = threshold / 100; } + const timeWindow = metricType === 'tag_usage_count_period' ? parseInt(timeWindowMinutes) : undefined; + if (isEditMode && editDefinition?.id) { await api.updateStatisticalEventDefinition( userId, @@ -196,7 +248,8 @@ export function StatisticalEventDefinitionDialog({ description || undefined, threshold, crossingDirection, - isPublished + isPublished, + timeWindow ); notifySuccess('Definition updated'); onSaved?.(editDefinition.id); @@ -205,11 +258,13 @@ export function StatisticalEventDefinitionDialog({ userId, name, description || undefined, + entityType, metricType, metricType === 'aggregate_rating' ? (ratingAlgorithmId as number) : undefined, threshold, crossingDirection, - isPublished + isPublished, + timeWindow ); notifySuccess('Definition created'); onSaved?.(definitionId); @@ -225,11 +280,49 @@ export function StatisticalEventDefinitionDialog({ }; const generateDefaultName = () => { - if (metricType === 'aggregate_rating') { - const alg = algorithms.find(a => a.id === ratingAlgorithmId); - return `Rating reaches ${thresholdValue}% (${alg?.name || 'algorithm'})`; + 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 { - return `Gets ${thresholdValue} ratings`; + // 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 ''; } }; @@ -264,6 +357,21 @@ export function StatisticalEventDefinitionDialog({ helperText="Optional detailed description" /> + + Entity Type + + + Metric Type @@ -304,19 +412,30 @@ export function StatisticalEventDefinitionDialog({ fullWidth required InputProps={{ - endAdornment: metricType === 'aggregate_rating' ? ( - % - ) : ( - ratings + endAdornment: ( + {getThresholdSuffix()} ) }} - helperText={ - metricType === 'aggregate_rating' - ? 'Rating percentage (0-100) at which to trigger' - : 'Number of ratings at which to trigger' - } + 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 setSelectedWeight(e.target.value as Weight)} + disabled={isGenerating} + > + {getWeightOptions().map((option) => ( + + + {option.label} + + {option.description} + + + + ))} + + + + {/* 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/use-pv-components.ts b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts index cbbeba5..b7824b8 100644 --- a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts +++ b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts @@ -67,6 +67,7 @@ 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"; const usePVComponents = () => { const components = { @@ -83,6 +84,7 @@ const usePVComponents = () => { PvUserFeedList, PvCurrentTime, PvStatisticalEventDefinitions, + PvAiEventBuilder, PvPageDirectory, PvPageSearch, PvText, diff --git a/ratings-ui-demo/src/services/ai-service.ts b/ratings-ui-demo/src/services/ai-service.ts index 7d4c691..1403354 100644 --- a/ratings-ui-demo/src/services/ai-service.ts +++ b/ratings-ui-demo/src/services/ai-service.ts @@ -63,6 +63,13 @@ export interface UserSelectionQueryResult { error?: string; } +export interface EventQueryResult { + success: boolean; + query?: string; + explanation?: string; + error?: string; +} + /** * Exact port of Python AIService class */ @@ -295,6 +302,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 +338,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 +414,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 +504,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 @@ -1054,6 +1104,189 @@ $$ 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 + if (!generatedSql.toUpperCase().startsWith('SELECT')) { + 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)}`, + }; + } + } } /** diff --git a/ratings-ui-demo/src/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index f68e54b..ca1d938 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -405,6 +405,40 @@ export interface StatisticalEventOccurrence { 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; @@ -1599,6 +1633,35 @@ export interface IApiClient { 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 { @@ -4495,4 +4558,105 @@ export class ApiClient implements IApiClient { }); 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; + } } -- GitLab From 83ec98ffe2d0c03fde94d011e4204a589599788e Mon Sep 17 00:00:00 2001 From: pete Date: Mon, 22 Dec 2025 14:47:28 -0500 Subject: [PATCH 09/26] Renamed conflicting V files --- ...dd_statistical_events.sql => V075__add_statistical_events.sql} | 0 ..._for_tags.sql => V076__extend_statistical_events_for_tags.sql} | 0 ...tion_id_nullable.sql => V077__fix_proposition_id_nullable.sql} | 0 ...6__add_ai_custom_events.sql => V078__add_ai_custom_events.sql} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename ratings-sql/{V073__add_statistical_events.sql => V075__add_statistical_events.sql} (100%) rename ratings-sql/{V074__extend_statistical_events_for_tags.sql => V076__extend_statistical_events_for_tags.sql} (100%) rename ratings-sql/{V075__fix_proposition_id_nullable.sql => V077__fix_proposition_id_nullable.sql} (100%) rename ratings-sql/{V076__add_ai_custom_events.sql => V078__add_ai_custom_events.sql} (100%) diff --git a/ratings-sql/V073__add_statistical_events.sql b/ratings-sql/V075__add_statistical_events.sql similarity index 100% rename from ratings-sql/V073__add_statistical_events.sql rename to ratings-sql/V075__add_statistical_events.sql diff --git a/ratings-sql/V074__extend_statistical_events_for_tags.sql b/ratings-sql/V076__extend_statistical_events_for_tags.sql similarity index 100% rename from ratings-sql/V074__extend_statistical_events_for_tags.sql rename to ratings-sql/V076__extend_statistical_events_for_tags.sql diff --git a/ratings-sql/V075__fix_proposition_id_nullable.sql b/ratings-sql/V077__fix_proposition_id_nullable.sql similarity index 100% rename from ratings-sql/V075__fix_proposition_id_nullable.sql rename to ratings-sql/V077__fix_proposition_id_nullable.sql diff --git a/ratings-sql/V076__add_ai_custom_events.sql b/ratings-sql/V078__add_ai_custom_events.sql similarity index 100% rename from ratings-sql/V076__add_ai_custom_events.sql rename to ratings-sql/V078__add_ai_custom_events.sql -- GitLab From 3736d971c1f0b604c7819c7b153dd2944c7f95b5 Mon Sep 17 00:00:00 2001 From: pete Date: Wed, 24 Dec 2025 11:12:46 -0500 Subject: [PATCH 10/26] Added AI-assisted SQL event generation to feed-generators page instead of feed generator dialog --- ratings-sql/R__031_default_pages.sql | 10 +- .../pv-ai-feed-generator-builder.tsx | 627 ++++++++++++++++++ .../src/pvcomponents/use-pv-components.ts | 2 + ratings-ui-demo/src/services/ai-service.ts | 234 +++++++ 4 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx diff --git a/ratings-sql/R__031_default_pages.sql b/ratings-sql/R__031_default_pages.sql index a3d15be..26cf914 100644 --- a/ratings-sql/R__031_default_pages.sql +++ b/ratings-sql/R__031_default_pages.sql @@ -1348,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 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' +]; + +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(); + + // UI state + const [selectedWeight, setSelectedWeight] = useState('heavy'); + + // 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(''); + + // 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); + + const handleGenerate = async () => { + if (!userPrompt.trim()) return; + + setIsGenerating(true); + const promptToSend = userPrompt; + 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 + ); + + if (result.success && result.sql_query) { + setGeneratedSql(result.sql_query); + setGeneratedMdx(result.mdx_template || ''); + setExplanation(result.explanation || ''); + + // Add assistant response + 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 { + const result = await api.executeAnalysisQuery(generatedSql); + + 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; + } + + setIsSaving(true); + try { + const result = await api.createFeedGenerator( + userId, + generatorName.trim(), + generatorDescription.trim(), + generatedSql.trim(), + {}, + {}, + generatedMdx.trim() || 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([]); + setGeneratedSql(''); + setGeneratedMdx(''); + setExplanation(''); + setQueryResult(null); + setValidationErrors([]); + setUserPrompt(''); + }; + + const handleExampleClick = (example: string) => { + setUserPrompt(example); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + handleGenerate(); + } + }; + + return ( + + + {/* Header */} + + AI Feed Generator Builder + + + 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} + /> + + + + {/* Generated SQL */} + {generatedSql && ( + <> + + + + Generated SQL Query + + + + + setGeneratedSql(e.target.value)} + sx={{ fontFamily: 'monospace', '& .MuiInputBase-input': { fontFamily: 'monospace' } }} + /> + + + )} + + {/* Generated MDX */} + {generatedSql && ( + + + Generated MDX Template + + + + + setGeneratedMdx(e.target.value)} + placeholder="MDX template for rendering feed items..." + sx={{ fontFamily: 'monospace', '& .MuiInputBase-input': { fontFamily: 'monospace' } }} + /> + + )} + + {/* Action buttons */} + {generatedSql && ( + + + + + )} + + {/* Validation errors */} + {validationErrors.length > 0 && ( + + Missing required columns: +
    + {validationErrors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} + + {/* 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}
+                                        
+
+
+ )} +
+
+ + + + +
+
+
+ ); +} diff --git a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts index b7824b8..ddbb561 100644 --- a/ratings-ui-demo/src/pvcomponents/use-pv-components.ts +++ b/ratings-ui-demo/src/pvcomponents/use-pv-components.ts @@ -68,6 +68,7 @@ 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 = { @@ -85,6 +86,7 @@ const usePVComponents = () => { 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 1403354..eba139b 100644 --- a/ratings-ui-demo/src/services/ai-service.ts +++ b/ratings-ui-demo/src/services/ai-service.ts @@ -70,6 +70,14 @@ export interface EventQueryResult { error?: string; } +export interface FeedGeneratorQueryResult { + success: boolean; + sql_query?: string; + mdx_template?: string; + explanation?: string; + error?: string; +} + /** * Exact port of Python AIService class */ @@ -1287,6 +1295,232 @@ 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 + * @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' + ): 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 feed generator based on this context. +`; + } + + const prompt = `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 +- Use Markdown formatting: **bold**, *italic*, [link text](url) +- Links format: [text](/pages/entity?id={data.primaryEntityId}) + +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 - High Rating Alert (propositions exceeding threshold): +\`\`\`sql +SELECT + 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, + 0 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 car.aggregate_rating 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 3 - 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}) +\`\`\` + +${conversationContext} + +User request: ${naturalLanguage} + +Generate BOTH the SQL query AND the MDX template for this feed generator. +Format your response as: +\`\`\`sql + +\`\`\` + +\`\`\`mdx + +\`\`\` + +Explanation: `; + + 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 + 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 = ''; + + if (sqlMatch) { + generatedSql = sqlMatch[1].trim(); + } + + if (mdxMatch) { + generatedMdx = mdxMatch[1].trim(); + } + + // Extract explanation after the code blocks + const explanationMatch = responseContent.match(/Explanation:\s*([\s\S]*?)(?:$)/i); + if (explanationMatch) { + explanation = explanationMatch[1].trim(); + } + + // Basic validation + if (!generatedSql) { + return { + success: false, + error: 'Could not extract SQL query from the response. Please try rephrasing your request.', + }; + } + + if (!generatedSql.toUpperCase().startsWith('SELECT')) { + 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)}`, + }; + } + } } /** -- GitLab From d26a2388b3e2a6ba88998eff27ef391cb950e28f Mon Sep 17 00:00:00 2001 From: pete Date: Fri, 26 Dec 2025 14:05:52 -0500 Subject: [PATCH 11/26] Fixed merge conflicts. Improved AI assistant to handle a wider variety of prompts --- .../pv-ai-feed-generator-builder.tsx | 2 +- ratings-ui-demo/src/services/api-v1.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) 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 index 0595a71..e20d6ab 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -192,7 +192,7 @@ export default function PvAiFeedGeneratorBuilder({ setValidationErrors([]); try { - const result = await api.executeAnalysisQuery(generatedSql); + const result = await api.testFeedGeneratorQuery(generatedSql, 10); if (result.success) { setQueryResult({ diff --git a/ratings-ui-demo/src/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index ca1d938..f29606e 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -1324,6 +1324,14 @@ 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 IApiClient { getDatabaseTables(): Promise; getTableRows(table_name: string, columns?: string[]): Promise; @@ -1600,6 +1608,7 @@ 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): Promise; // Statistical Event Definitions createStatisticalEventDefinition( @@ -4442,6 +4451,16 @@ export class ApiClient implements IApiClient { return response.data; } + async testFeedGeneratorQuery(sqlQuery: string, limit: number = 10): Promise { + const response = await this.axios.post('/rpc/test_feed_generator_query', { + p_sql_query: sqlQuery, + p_limit: limit + }, { + headers: getRequestHeader() + }); + return response.data; + } + // ======================================================================== // Statistical Event Definitions // ======================================================================== -- GitLab From 656aefb48473dd2080261d4dd454c4a39c0b2823 Mon Sep 17 00:00:00 2001 From: pete Date: Fri, 26 Dec 2025 16:25:50 -0500 Subject: [PATCH 12/26] Allowed bypass of AI assistant to enable pure-manual setup of generator --- .../pv-ai-feed-generator-builder.tsx | 395 +++++++++++------- 1 file changed, 251 insertions(+), 144 deletions(-) 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 index e20d6ab..34851f2 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -28,14 +28,20 @@ import { TableHead, TableRow, TablePagination, - Divider + Divider, + Accordion, + AccordionSummary, + AccordionDetails } from '@mui/material'; import { ContentCopy as ContentCopyIcon, PlayArrow as PlayArrowIcon, Save as SaveIcon, Clear as ClearIcon, - Send as SendIcon + Send as SendIcon, + ExpandMore as ExpandMoreIcon, + SmartToy as SmartToyIcon, + Settings as SettingsIcon } from '@mui/icons-material'; import { useApiClient } from '@/components/api-context'; import { useSession } from '@/hooks/use-session'; @@ -92,6 +98,8 @@ export default function PvAiFeedGeneratorBuilder({ // UI state const [selectedWeight, setSelectedWeight] = useState('heavy'); + const [aiExpanded, setAiExpanded] = useState(true); + const [advancedExpanded, setAdvancedExpanded] = useState(false); // Conversation state const [conversationHistory, setConversationHistory] = useState([]); @@ -103,6 +111,10 @@ export default function PvAiFeedGeneratorBuilder({ const [generatedMdx, setGeneratedMdx] = useState(''); const [explanation, setExplanation] = useState(''); + // Advanced options (manual entry) + const [parametersSchema, setParametersSchema] = useState('{}'); + const [defaultParameters, setDefaultParameters] = useState('{}'); + // Test execution state const [isExecuting, setIsExecuting] = useState(false); const [queryResult, setQueryResult] = useState(null); @@ -260,6 +272,24 @@ export default function PvAiFeedGeneratorBuilder({ return; } + // Parse JSON fields + let parsedParametersSchema: Record; + let parsedDefaultParameters: Record; + + try { + parsedParametersSchema = JSON.parse(parametersSchema); + } catch { + notifyError('Invalid JSON in Parameters Schema'); + return; + } + + try { + parsedDefaultParameters = JSON.parse(defaultParameters); + } catch { + notifyError('Invalid JSON in Default Parameters'); + return; + } + setIsSaving(true); try { const result = await api.createFeedGenerator( @@ -267,8 +297,8 @@ export default function PvAiFeedGeneratorBuilder({ generatorName.trim(), generatorDescription.trim(), generatedSql.trim(), - {}, - {}, + parsedParametersSchema, + parsedDefaultParameters, generatedMdx.trim() || undefined ); @@ -296,6 +326,12 @@ export default function PvAiFeedGeneratorBuilder({ }; const handleNewConversation = () => { + setConversationHistory([]); + setExplanation(''); + setUserPrompt(''); + }; + + const handleClearAll = () => { setConversationHistory([]); setGeneratedSql(''); setGeneratedMdx(''); @@ -303,6 +339,8 @@ export default function PvAiFeedGeneratorBuilder({ setQueryResult(null); setValidationErrors([]); setUserPrompt(''); + setParametersSchema('{}'); + setDefaultParameters('{}'); }; const handleExampleClick = (example: string) => { @@ -321,127 +359,157 @@ export default function PvAiFeedGeneratorBuilder({ {/* Header */} - AI Feed Generator Builder - - - Model - - - - + Feed Generator Builder + - {/* Example prompts */} - - - Example prompts (click to use): - - - {EXAMPLE_PROMPTS.map((example, index) => ( - handleExampleClick(example)} - sx={{ cursor: 'pointer', mb: 0.5 }} - /> - ))} - - + {/* 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} - + {/* 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} + /> + + - - )} + + + + - {/* User input */} - + {/* SQL Query Editor (Always visible) */} + + + + SQL Query * + + {generatedSql && ( + + + + )} + setUserPrompt(e.target.value)} - onKeyDown={handleKeyDown} - disabled={isGenerating} + rows={8} + value={generatedSql} + onChange={(e) => setGeneratedSql(e.target.value)} + placeholder="Enter SQL query returning: event_time, event_type, primary_entity_type, primary_entity_id, secondary_entity_type, secondary_entity_id, actor_user_id, event_data" + sx={{ fontFamily: 'monospace', '& .MuiInputBase-input': { fontFamily: 'monospace' } }} /> - - - - {/* Generated SQL */} - {generatedSql && ( - <> - - - - Generated SQL Query - - - - - setGeneratedSql(e.target.value)} - sx={{ fontFamily: 'monospace', '& .MuiInputBase-input': { fontFamily: 'monospace' } }} - /> - - - )} + - {/* Generated MDX */} - {generatedSql && ( - - - Generated MDX Template + {/* MDX Template Editor (Always visible) */} + + + MDX Template (optional) + {generatedMdx && ( - - - )} + + + + {/* Validation errors */} {validationErrors.length > 0 && ( -- GitLab From 3d5622b29d5fade5c3786c9db5461269d2b12715 Mon Sep 17 00:00:00 2001 From: pete Date: Sat, 27 Dec 2025 15:08:36 -0500 Subject: [PATCH 13/26] Improved AI assistant in feed generator to handle more complex prompts --- ratings-ui-demo/src/services/ai-service.ts | 116 ++++++++++++++++++--- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/ratings-ui-demo/src/services/ai-service.ts b/ratings-ui-demo/src/services/ai-service.ts index eba139b..cf6c22d 100644 --- a/ratings-ui-demo/src/services/ai-service.ts +++ b/ratings-ui-demo/src/services/ai-service.ts @@ -168,8 +168,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', @@ -546,8 +547,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', @@ -1271,8 +1273,9 @@ Explanation: `; generatedSql = responseContent; } - // 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. Please try rephrasing your request.', @@ -1352,6 +1355,22 @@ The MDX template renders each feed item. Available variables: - Use Markdown formatting: **bold**, *italic*, [link text](url) - Links format: [text](/pages/entity?id={data.primaryEntityId}) +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: @@ -1381,16 +1400,88 @@ ORDER BY p.creation_time DESC By [{data.eventData.creator_username}](/pages/user?username={data.eventData.creator_username}) \`\`\` -Example 2 - High Rating Alert (propositions exceeding threshold): +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 -SELECT +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, - 0 AS actor_user_id, + p.creator_id AS actor_user_id, jsonb_build_object( 'body', p.body, 'rating', ROUND(car.aggregate_rating * 100), @@ -1400,7 +1491,7 @@ 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 car.aggregate_rating DESC +ORDER BY p.id, car.creation_time DESC \`\`\` \`\`\`mdx @@ -1411,7 +1502,7 @@ ORDER BY car.aggregate_rating DESC Rating: **{data.eventData.rating}%** ({data.eventData.algorithm}) \`\`\` -Example 3 - Proposition Rated: +Example 4 - Proposition Rated: \`\`\`sql SELECT r.creation_time AS event_time, @@ -1497,7 +1588,8 @@ Explanation: `; }; } - if (!generatedSql.toUpperCase().startsWith('SELECT')) { + 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.', -- GitLab From 0bebf47fac7e56a695709027dcc4c8e14ccf0318 Mon Sep 17 00:00:00 2001 From: pete Date: Sun, 28 Dec 2025 12:03:05 -0500 Subject: [PATCH 14/26] Renamed conflicting V files --- ...dd_statistical_events.sql => V079__add_statistical_events.sql} | 0 ..._for_tags.sql => V080__extend_statistical_events_for_tags.sql} | 0 ...tion_id_nullable.sql => V081__fix_proposition_id_nullable.sql} | 0 ...8__add_ai_custom_events.sql => V082__add_ai_custom_events.sql} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename ratings-sql/{V075__add_statistical_events.sql => V079__add_statistical_events.sql} (100%) rename ratings-sql/{V076__extend_statistical_events_for_tags.sql => V080__extend_statistical_events_for_tags.sql} (100%) rename ratings-sql/{V077__fix_proposition_id_nullable.sql => V081__fix_proposition_id_nullable.sql} (100%) rename ratings-sql/{V078__add_ai_custom_events.sql => V082__add_ai_custom_events.sql} (100%) diff --git a/ratings-sql/V075__add_statistical_events.sql b/ratings-sql/V079__add_statistical_events.sql similarity index 100% rename from ratings-sql/V075__add_statistical_events.sql rename to ratings-sql/V079__add_statistical_events.sql diff --git a/ratings-sql/V076__extend_statistical_events_for_tags.sql b/ratings-sql/V080__extend_statistical_events_for_tags.sql similarity index 100% rename from ratings-sql/V076__extend_statistical_events_for_tags.sql rename to ratings-sql/V080__extend_statistical_events_for_tags.sql diff --git a/ratings-sql/V077__fix_proposition_id_nullable.sql b/ratings-sql/V081__fix_proposition_id_nullable.sql similarity index 100% rename from ratings-sql/V077__fix_proposition_id_nullable.sql rename to ratings-sql/V081__fix_proposition_id_nullable.sql diff --git a/ratings-sql/V078__add_ai_custom_events.sql b/ratings-sql/V082__add_ai_custom_events.sql similarity index 100% rename from ratings-sql/V078__add_ai_custom_events.sql rename to ratings-sql/V082__add_ai_custom_events.sql -- GitLab From 875dd0da948a15797dc9ef6708417acc0f47ff8b Mon Sep 17 00:00:00 2001 From: pete Date: Mon, 29 Dec 2025 09:48:00 -0500 Subject: [PATCH 15/26] Added batch delete feature to feed-management page, based on checkbox selection --- .../src/pvcomponents/pv-feed-management.tsx | 167 ++++++++++++++++-- 1 file changed, 150 insertions(+), 17 deletions(-) diff --git a/ratings-ui-demo/src/pvcomponents/pv-feed-management.tsx b/ratings-ui-demo/src/pvcomponents/pv-feed-management.tsx index 9e86933..a4583a7 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? + + + + + + + ); } -- GitLab From 68fb089b02ccb94a6e6207ee440d0ec26138c09a Mon Sep 17 00:00:00 2001 From: pete Date: Tue, 30 Dec 2025 14:20:27 -0500 Subject: [PATCH 16/26] Added GRANT EXECUTE for some of the PVQuickFollowButton functions --- ratings-sql/R__999_postgrest.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ratings-sql/R__999_postgrest.sql b/ratings-sql/R__999_postgrest.sql index 9af5c95..65f27ae 100644 --- a/ratings-sql/R__999_postgrest.sql +++ b/ratings-sql/R__999_postgrest.sql @@ -17,3 +17,20 @@ 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; -- GitLab From 6bea245a1f3dc052984c0b78591c13905e2a2eb3 Mon Sep 17 00:00:00 2001 From: pete Date: Tue, 30 Dec 2025 16:05:34 -0500 Subject: [PATCH 17/26] Added Preview feature to see system + user prompt before sending to Claude for AI asst. Also modified dockerfiles to pull public images instead of from gitlab for when gitlab is down --- docker/api/Dockerfile | 8 +- docker/db/Dockerfile | 4 +- docker/flyway/Dockerfile | 4 +- docker/ui/Dockerfile | 8 +- .../pv-ai-feed-generator-builder.tsx | 132 ++++++++++++++++-- ratings-ui-demo/src/services/ai-service.ts | 51 ++++--- 6 files changed, 168 insertions(+), 39 deletions(-) diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 8a23eb2..b91d0ce 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 e4f488b..a81e19e 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 761fb24..c4a7986 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 10e4577..d7f170c 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-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx index 34851f2..a18b737 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -21,6 +21,8 @@ import { DialogTitle, DialogContent, DialogActions, + Checkbox, + FormControlLabel, Table, TableBody, TableCell, @@ -41,7 +43,8 @@ import { Send as SendIcon, ExpandMore as ExpandMoreIcon, SmartToy as SmartToyIcon, - Settings as SettingsIcon + Settings as SettingsIcon, + Visibility as VisibilityIcon } from '@mui/icons-material'; import { useApiClient } from '@/components/api-context'; import { useSession } from '@/hooks/use-session'; @@ -128,7 +131,33 @@ export default function PvAiFeedGeneratorBuilder({ const [generatorDescription, setGeneratorDescription] = useState(''); const [isSaving, setIsSaving] = useState(false); - const handleGenerate = async () => { + // Prompt preview state + const [showPromptPreview, setShowPromptPreview] = useState(false); + const [generatedPrompt, setGeneratedPrompt] = useState(''); + const [customPrompt, setCustomPrompt] = useState(''); + const [useCustomPrompt, setUseCustomPrompt] = useState(false); + + const handlePreviewPrompt = () => { + if (!userPrompt.trim()) return; + + const historyForAi = conversationHistory.map(msg => ({ + role: msg.role, + content: msg.content + })); + + const prompt = AIService.buildFeedGeneratorPrompt(userPrompt, historyForAi); + setGeneratedPrompt(prompt); + setCustomPrompt(prompt); + setUseCustomPrompt(false); + setShowPromptPreview(true); + }; + + const handleSendFromPreview = () => { + setShowPromptPreview(false); + handleGenerate(useCustomPrompt ? customPrompt : undefined); + }; + + const handleGenerate = async (customPromptOverride?: string) => { if (!userPrompt.trim()) return; setIsGenerating(true); @@ -152,7 +181,8 @@ export default function PvAiFeedGeneratorBuilder({ const result = await AIService.generateFeedGeneratorQuery( promptToSend, historyForAi, - selectedWeight + selectedWeight, + customPromptOverride ); if (result.success && result.sql_query) { @@ -461,14 +491,27 @@ export default function PvAiFeedGeneratorBuilder({ onKeyDown={handleKeyDown} disabled={isGenerating} /> - + + + + + + @@ -728,6 +771,73 @@ export default function PvAiFeedGeneratorBuilder({
+ + {/* 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. + + + + + + + + + ); diff --git a/ratings-ui-demo/src/services/ai-service.ts b/ratings-ui-demo/src/services/ai-service.ts index cf6c22d..2316796 100644 --- a/ratings-ui-demo/src/services/ai-service.ts +++ b/ratings-ui-demo/src/services/ai-service.ts @@ -1300,34 +1300,31 @@ 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. + * 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 weight AI model weight - * @returns Dictionary with 'success', 'sql_query', 'mdx_template', 'explanation', and optional 'error' fields + * @returns The full prompt string that would be sent to Claude */ - static async generateFeedGeneratorQuery( + static buildFeedGeneratorPrompt( 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 = ` + conversationHistory: Array<{ role: string; content: 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. `; - } + } - const prompt = `You are an AI assistant that helps users create feed generators for the PeerVerity system. + 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} @@ -1547,6 +1544,26 @@ Format your response as: \`\`\` 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 + * @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 + ): Promise { + try { + const prompt = customPrompt || AIService.buildFeedGeneratorPrompt(naturalLanguage, conversationHistory); await ensureAiDelegateInitialized(); const response = (await AiDelegate.makeRequest({ -- GitLab From 7144cde154cb2955d91082ec173ae02641d66ffe Mon Sep 17 00:00:00 2001 From: pete Date: Thu, 1 Jan 2026 16:30:36 -0500 Subject: [PATCH 18/26] Added ability to fix errors in AI-assisted feed generators --- .../pv-ai-feed-generator-builder.tsx | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) 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 index a18b737..bae4d11 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -44,7 +44,9 @@ import { ExpandMore as ExpandMoreIcon, SmartToy as SmartToyIcon, Settings as SettingsIcon, - Visibility as VisibilityIcon + Visibility as VisibilityIcon, + AutoFixHigh as AutoFixHighIcon, + Edit as EditIcon } from '@mui/icons-material'; import { useApiClient } from '@/components/api-context'; import { useSession } from '@/hooks/use-session'; @@ -157,11 +159,11 @@ export default function PvAiFeedGeneratorBuilder({ handleGenerate(useCustomPrompt ? customPrompt : undefined); }; - const handleGenerate = async (customPromptOverride?: string) => { - if (!userPrompt.trim()) return; + const handleGenerate = async (customPromptOverride?: string, promptTextOverride?: string) => { + const promptToSend = promptTextOverride || userPrompt; + if (!promptToSend.trim()) return; setIsGenerating(true); - const promptToSend = userPrompt; setUserPrompt(''); // Add user message to conversation @@ -384,6 +386,39 @@ export default function PvAiFeedGeneratorBuilder({ } }; + // 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 ( @@ -648,6 +683,31 @@ export default function PvAiFeedGeneratorBuilder({ )} + {/* Error reporting buttons */} + {(queryResult?.error || validationErrors.length > 0) && ( + + + + + )} + {/* Query results */} {queryResult && ( -- GitLab From c2b64f7d39e44f47686aacb4e34ecbc3633c70d9 Mon Sep 17 00:00:00 2001 From: pete Date: Thu, 1 Jan 2026 18:15:34 -0500 Subject: [PATCH 19/26] Ability to load saved feed generator along with conversation history --- ratings-sql/R__060_feed_generators_api.sql | 16 +- ...dd_feed_generator_conversation_history.sql | 8 + .../pv-ai-feed-generator-builder.tsx | 169 ++++++++++++++++-- ratings-ui-demo/src/services/api-v1.ts | 21 ++- 4 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 ratings-sql/V083__add_feed_generator_conversation_history.sql diff --git a/ratings-sql/R__060_feed_generators_api.sql b/ratings-sql/R__060_feed_generators_api.sql index 1bed4d7..f67cd11 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; 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 0000000..22cc576 --- /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-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx index bae4d11..388ef74 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -46,12 +46,14 @@ import { Settings as SettingsIcon, Visibility as VisibilityIcon, AutoFixHigh as AutoFixHighIcon, - Edit as EditIcon + Edit as EditIcon, + FolderOpen as FolderOpenIcon } 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 { FeedGenerator } from '@/services/api-v1'; import { ComponentContextProps } from './context'; export type PvAiFeedGeneratorBuilderProps = { @@ -133,6 +135,12 @@ export default function PvAiFeedGeneratorBuilder({ 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 [generatedPrompt, setGeneratedPrompt] = useState(''); @@ -331,7 +339,8 @@ export default function PvAiFeedGeneratorBuilder({ generatedSql.trim(), parsedParametersSchema, parsedDefaultParameters, - generatedMdx.trim() || undefined + generatedMdx.trim() || undefined, + conversationHistory.length > 0 ? conversationHistory : undefined ); if (result.success) { @@ -379,6 +388,66 @@ export default function PvAiFeedGeneratorBuilder({ setUserPrompt(example); }; + 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 || ''); + setParametersSchema(JSON.stringify(generator.parameters_schema || {}, null, 2)); + setDefaultParameters(JSON.stringify(generator.default_parameters || {}, null, 2)); + + // 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 + setQueryResult(null); + setValidationErrors([]); + + 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(); @@ -425,14 +494,24 @@ export default function PvAiFeedGeneratorBuilder({ {/* Header */} Feed Generator Builder - + + + + {/* AI Assistant Section (Collapsible) */} @@ -898,6 +977,76 @@ export default function PvAiFeedGeneratorBuilder({ + + {/* 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/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index f29606e..61469c1 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -1209,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 { @@ -1574,8 +1581,8 @@ export interface IApiClient { // 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; @@ -4114,7 +4121,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, @@ -4123,7 +4131,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() }); @@ -4140,6 +4149,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', { @@ -4150,7 +4160,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() }); -- GitLab From 7a771294e513b9a6f0de7301ffc8e867f0429378 Mon Sep 17 00:00:00 2001 From: pete Date: Sat, 3 Jan 2026 16:52:14 -0500 Subject: [PATCH 20/26] Integration changes from main into branch for feed feature --- ...rators_allow_parameterized_duplicates.sql} | 0 ratings-ui-demo/package-lock.json | 40 +-- ratings-ui-demo/package.json | 1 + .../pv-ai-feed-generator-builder.tsx | 316 +++++++++++++----- 4 files changed, 240 insertions(+), 117 deletions(-) rename ratings-sql/{V079__feed_generators_allow_parameterized_duplicates.sql => V084__feed_generators_allow_parameterized_duplicates.sql} (100%) 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-ui-demo/package-lock.json b/ratings-ui-demo/package-lock.json index 86b7a62..0e53293 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 e451dea..5e64848 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/pvcomponents/pv-ai-feed-generator-builder.tsx b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx index 388ef74..be7dcba 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -33,7 +33,8 @@ import { Divider, Accordion, AccordionSummary, - AccordionDetails + AccordionDetails, + IconButton } from '@mui/material'; import { ContentCopy as ContentCopyIcon, @@ -43,13 +44,15 @@ import { Send as SendIcon, ExpandMore as ExpandMoreIcon, SmartToy as SmartToyIcon, - Settings as SettingsIcon, Visibility as VisibilityIcon, AutoFixHigh as AutoFixHighIcon, Edit as EditIcon, - FolderOpen as FolderOpenIcon + 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'; @@ -85,6 +88,73 @@ const REQUIRED_COLUMNS = [ '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", @@ -106,7 +176,6 @@ export default function PvAiFeedGeneratorBuilder({ // UI state const [selectedWeight, setSelectedWeight] = useState('heavy'); const [aiExpanded, setAiExpanded] = useState(true); - const [advancedExpanded, setAdvancedExpanded] = useState(false); // Conversation state const [conversationHistory, setConversationHistory] = useState([]); @@ -118,9 +187,11 @@ export default function PvAiFeedGeneratorBuilder({ const [generatedMdx, setGeneratedMdx] = useState(''); const [explanation, setExplanation] = useState(''); - // Advanced options (manual entry) - const [parametersSchema, setParametersSchema] = useState('{}'); - const [defaultParameters, setDefaultParameters] = useState('{}'); + // Parameter management + const [parameters, setParameters] = useState([]); + + // SQL validation errors from CodeEditor + const [sqlErrors, setSqlErrors] = useState([]); // Test execution state const [isExecuting, setIsExecuting] = useState(false); @@ -312,24 +383,24 @@ export default function PvAiFeedGeneratorBuilder({ return; } - // Parse JSON fields - let parsedParametersSchema: Record; - let parsedDefaultParameters: Record; - - try { - parsedParametersSchema = JSON.parse(parametersSchema); - } catch { - notifyError('Invalid JSON in Parameters Schema'); + // Validate parameters have names + const invalidParams = parameters.filter(p => p.name.trim() === ''); + if (invalidParams.length > 0) { + notifyError('All parameters must have a name'); return; } - try { - parsedDefaultParameters = JSON.parse(defaultParameters); - } catch { - notifyError('Invalid JSON in Default Parameters'); + // 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( @@ -337,8 +408,8 @@ export default function PvAiFeedGeneratorBuilder({ generatorName.trim(), generatorDescription.trim(), generatedSql.trim(), - parsedParametersSchema, - parsedDefaultParameters, + schema, + defaults, generatedMdx.trim() || undefined, conversationHistory.length > 0 ? conversationHistory : undefined ); @@ -380,14 +451,33 @@ export default function PvAiFeedGeneratorBuilder({ setQueryResult(null); setValidationErrors([]); setUserPrompt(''); - setParametersSchema('{}'); - setDefaultParameters('{}'); + 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); @@ -412,8 +502,13 @@ export default function PvAiFeedGeneratorBuilder({ // Populate the form fields setGeneratedSql(generator.sql_query || ''); setGeneratedMdx(generator.mdx_template || ''); - setParametersSchema(JSON.stringify(generator.parameters_schema || {}, null, 2)); - setDefaultParameters(JSON.stringify(generator.default_parameters || {}, null, 2)); + + // 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)) { @@ -431,9 +526,10 @@ export default function PvAiFeedGeneratorBuilder({ setGeneratorName(generator.name || ''); setGeneratorDescription(generator.description || ''); - // Clear test results + // Clear test results and errors setQueryResult(null); setValidationErrors([]); + setSqlErrors([]); notifySuccess(`Loaded generator: ${generator.name}`); setShowLoadDialog(false); @@ -636,9 +732,7 @@ export default function PvAiFeedGeneratorBuilder({ {/* SQL Query Editor (Always visible) */} - - SQL Query * - + {generatedSql && ( + + {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 */} @@ -736,7 +870,7 @@ export default function PvAiFeedGeneratorBuilder({ variant="outlined" startIcon={isExecuting ? : } onClick={handleTestQuery} - disabled={isExecuting || !generatedSql.trim()} + disabled={isExecuting || !generatedSql.trim() || sqlErrors.length > 0} > Test Query @@ -744,12 +878,26 @@ export default function PvAiFeedGeneratorBuilder({ variant="contained" startIcon={} onClick={() => setShowSaveDialog(true)} - disabled={!generatedSql.trim() || validationErrors.length > 0} + disabled={!generatedSql.trim() || validationErrors.length > 0 || sqlErrors.length > 0} > Save Feed Generator + {/* 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 && ( -- GitLab From 2646682d8fb8ca1cba382e7d5acf66fe586ce0f9 Mon Sep 17 00:00:00 2001 From: pete Date: Sat, 3 Jan 2026 18:24:50 -0500 Subject: [PATCH 21/26] Fixed bug so TEST QUERY does not fail when parameters are present in the feed generator --- ratings-sql/R__060_feed_generators_api.sql | 22 ++++++++++++++----- .../pv-ai-feed-generator-builder.tsx | 5 ++++- ratings-ui-demo/src/services/api-v1.ts | 13 ++++++++--- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/ratings-sql/R__060_feed_generators_api.sql b/ratings-sql/R__060_feed_generators_api.sql index f67cd11..de7c126 100644 --- a/ratings-sql/R__060_feed_generators_api.sql +++ b/ratings-sql/R__060_feed_generators_api.sql @@ -584,7 +584,9 @@ COMMENT ON FUNCTION api_v1.get_user_feed_events IS 'Get feed events for a user b -- 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_limit INTEGER DEFAULT 10, + p_parameters_schema JSONB DEFAULT '{}', + p_default_parameters JSONB DEFAULT '{}' ) RETURNS JSON LANGUAGE plpgsql @@ -596,9 +598,18 @@ DECLARE 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(p_sql_query)) LIKE 'SELECT%' OR TRIM(UPPER(p_sql_query)) LIKE 'WITH%') THEN + 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' @@ -606,7 +617,7 @@ BEGIN END IF; -- Block dangerous keywords - IF p_sql_query ~* '\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|CREATE)\b' THEN + 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' @@ -619,7 +630,7 @@ BEGIN 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, p_sql_query); + 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(' @@ -637,7 +648,7 @@ BEGIN 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', p_sql_query, p_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)) @@ -671,5 +682,6 @@ $$; 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.'; 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 index be7dcba..88f23d0 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -315,7 +315,10 @@ export default function PvAiFeedGeneratorBuilder({ setValidationErrors([]); try { - const result = await api.testFeedGeneratorQuery(generatedSql, 10); + // 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({ diff --git a/ratings-ui-demo/src/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index 61469c1..0ccab21 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -1615,7 +1615,7 @@ 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): Promise; + testFeedGeneratorQuery(sqlQuery: string, limit?: number, parametersSchema?: Record, defaultParameters?: Record): Promise; // Statistical Event Definitions createStatisticalEventDefinition( @@ -4462,10 +4462,17 @@ export class ApiClient implements IApiClient { return response.data; } - async testFeedGeneratorQuery(sqlQuery: string, limit: number = 10): Promise { + 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_limit: limit, + p_parameters_schema: parametersSchema || {}, + p_default_parameters: defaultParameters || {} }, { headers: getRequestHeader() }); -- GitLab From 657ad0293adc74c93f99dad4cdc24c07edebcbb2 Mon Sep 17 00:00:00 2001 From: pete Date: Sun, 4 Jan 2026 12:49:02 -0500 Subject: [PATCH 22/26] Made MDX generated from AI in feed generator more consistent, same size, etc --- ratings-ui-demo/src/services/ai-service.ts | 53 +++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/ratings-ui-demo/src/services/ai-service.ts b/ratings-ui-demo/src/services/ai-service.ts index 2316796..e5f7f2b 100644 --- a/ratings-ui-demo/src/services/ai-service.ts +++ b/ratings-ui-demo/src/services/ai-service.ts @@ -1349,8 +1349,57 @@ The MDX template renders each feed item. Available variables: - {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 -- Use Markdown formatting: **bold**, *italic*, [link text](url) -- Links format: [text](/pages/entity?id={data.primaryEntityId}) + +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. -- GitLab From 8974c93573d2e9c8e93b07115c9679c204f14665 Mon Sep 17 00:00:00 2001 From: pete Date: Sun, 4 Jan 2026 14:48:32 -0500 Subject: [PATCH 23/26] Made AI-assistant conversation for feed generator scroll to bottom when reloading it --- .../pvcomponents/pv-ai-feed-generator-builder.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 index 88f23d0..0a26191 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import Markdown from 'react-markdown'; import { Box, @@ -173,6 +173,9 @@ export default function PvAiFeedGeneratorBuilder({ 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); @@ -218,6 +221,13 @@ export default function PvAiFeedGeneratorBuilder({ const [customPrompt, setCustomPrompt] = useState(''); const [useCustomPrompt, setUseCustomPrompt] = 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]); + const handlePreviewPrompt = () => { if (!userPrompt.trim()) return; @@ -676,7 +686,7 @@ export default function PvAiFeedGeneratorBuilder({ {/* Conversation history */} {conversationHistory.length > 0 && ( - + {conversationHistory.map((msg, index) => ( -- GitLab From 4da6309757aad9c4236537745afe6e13914cf6eb Mon Sep 17 00:00:00 2001 From: pete Date: Mon, 5 Jan 2026 10:41:48 -0500 Subject: [PATCH 24/26] Added system prompt modifications through AI-assistant in feed generator --- ratings-sql/R__060_feed_generators_api.sql | 92 ++++++++ ratings-sql/R__999_postgrest.sql | 5 + .../V085__user_ai_prompt_customizations.sql | 26 +++ .../pv-ai-feed-generator-builder.tsx | 219 ++++++++++++++++-- ratings-ui-demo/src/services/ai-service.ts | 73 +++++- ratings-ui-demo/src/services/api-v1.ts | 62 +++++ 6 files changed, 459 insertions(+), 18 deletions(-) create mode 100644 ratings-sql/V085__user_ai_prompt_customizations.sql diff --git a/ratings-sql/R__060_feed_generators_api.sql b/ratings-sql/R__060_feed_generators_api.sql index de7c126..33ffd1d 100644 --- a/ratings-sql/R__060_feed_generators_api.sql +++ b/ratings-sql/R__060_feed_generators_api.sql @@ -685,3 +685,95 @@ COMMENT ON FUNCTION api_v1.test_feed_generator_query IS 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 65f27ae..0a46b69 100644 --- a/ratings-sql/R__999_postgrest.sql +++ b/ratings-sql/R__999_postgrest.sql @@ -34,3 +34,8 @@ GRANT EXECUTE ON FUNCTION api_v1.deactivate_follow_list(INTEGER, INTEGER) TO web 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/V085__user_ai_prompt_customizations.sql b/ratings-sql/V085__user_ai_prompt_customizations.sql new file mode 100644 index 0000000..659e575 --- /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-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx index 0a26191..5a1e972 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -217,10 +217,15 @@ export default function PvAiFeedGeneratorBuilder({ // 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) { @@ -228,6 +233,25 @@ export default function PvAiFeedGeneratorBuilder({ } }, [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; @@ -236,13 +260,56 @@ export default function PvAiFeedGeneratorBuilder({ content: msg.content })); - const prompt = AIService.buildFeedGeneratorPrompt(userPrompt, historyForAi); + 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); @@ -276,22 +343,57 @@ export default function PvAiFeedGeneratorBuilder({ customPromptOverride ); - if (result.success && result.sql_query) { - setGeneratedSql(result.sql_query); - setGeneratedMdx(result.mdx_template || ''); - setExplanation(result.explanation || ''); + 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 || 'Generated feed generator query and template.', - timestamp: new Date().toISOString() - }; - setConversationHistory(prev => [...prev, assistantMessage]); + // 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]); + } - // Clear previous test results - setQueryResult(null); - setValidationErrors([]); + // 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); @@ -612,6 +714,14 @@ export default function PvAiFeedGeneratorBuilder({ > Load Generator + + )} + )} + + + + + {/* Load Generator Dialog */} `; } } + /** + * 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 }> = [] + conversationHistory: Array<{ role: string; content: string }> = [], + customBasePrompt?: string ): string { const schemaContext = AIService.getDatabaseSchemaContext(); @@ -1578,12 +1591,46 @@ ORDER BY r.creation_time DESC [{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} -Generate BOTH the SQL query AND the MDX template for this feed generator. -Format your response as: +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 \`\`\` @@ -1625,12 +1672,16 @@ Explanation: `; 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(); @@ -1640,12 +1691,28 @@ Explanation: `; 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 { diff --git a/ratings-ui-demo/src/services/api-v1.ts b/ratings-ui-demo/src/services/api-v1.ts index 0ccab21..8336b23 100644 --- a/ratings-ui-demo/src/services/api-v1.ts +++ b/ratings-ui-demo/src/services/api-v1.ts @@ -1339,6 +1339,18 @@ export interface TestFeedGeneratorQueryResult { 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; @@ -1617,6 +1629,11 @@ export interface IApiClient { 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, @@ -4479,6 +4496,51 @@ export class ApiClient implements IApiClient { 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 // ======================================================================== -- GitLab From 5476bc402798c88e0e7c28e506ce5d292345080f Mon Sep 17 00:00:00 2001 From: pete Date: Mon, 5 Jan 2026 11:18:49 -0500 Subject: [PATCH 25/26] Bugfix for system prompt user modifications not being actually sent to Claude --- .../src/pvcomponents/pv-ai-feed-generator-builder.tsx | 3 ++- ratings-ui-demo/src/services/ai-service.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 index 5a1e972..cd505ea 100644 --- a/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx +++ b/ratings-ui-demo/src/pvcomponents/pv-ai-feed-generator-builder.tsx @@ -340,7 +340,8 @@ export default function PvAiFeedGeneratorBuilder({ promptToSend, historyForAi, selectedWeight, - customPromptOverride + customPromptOverride, + customBasePrompt ); if (result.success) { diff --git a/ratings-ui-demo/src/services/ai-service.ts b/ratings-ui-demo/src/services/ai-service.ts index c727d99..c9df4e7 100644 --- a/ratings-ui-demo/src/services/ai-service.ts +++ b/ratings-ui-demo/src/services/ai-service.ts @@ -1650,16 +1650,18 @@ Explanation: `; * @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 + customPrompt?: string, + customBasePrompt?: string ): Promise { try { - const prompt = customPrompt || AIService.buildFeedGeneratorPrompt(naturalLanguage, conversationHistory); + const prompt = customPrompt || AIService.buildFeedGeneratorPrompt(naturalLanguage, conversationHistory, customBasePrompt); await ensureAiDelegateInitialized(); const response = (await AiDelegate.makeRequest({ -- GitLab From 1039fb1176131fc3e3242b0238a4392325f60291 Mon Sep 17 00:00:00 2001 From: pete Date: Mon, 5 Jan 2026 12:03:46 -0500 Subject: [PATCH 26/26] Renamed conflicting V files --- ..._for_tags.sql => V086__extend_statistical_events_for_tags.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ratings-sql/{V080__extend_statistical_events_for_tags.sql => V086__extend_statistical_events_for_tags.sql} (100%) diff --git a/ratings-sql/V080__extend_statistical_events_for_tags.sql b/ratings-sql/V086__extend_statistical_events_for_tags.sql similarity index 100% rename from ratings-sql/V080__extend_statistical_events_for_tags.sql rename to ratings-sql/V086__extend_statistical_events_for_tags.sql -- GitLab