diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/ClassificationReportModal.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/ClassificationReportModal.tsx new file mode 100644 index 0000000000..c0b61a11c8 --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/ClassificationReportModal.tsx @@ -0,0 +1,284 @@ +import { + Alert, + Card, + Collapse, + Descriptions, + Flex, + Modal, + Progress, + Space, + Table, + Tag, + Tooltip, + Typography, +} from "fidesui"; + +import { + useGetClassificationReportQuery, + WebsiteClassificationReport, +} from "./action-center.slice"; + +const { Text, Title } = Typography; + +interface ClassificationReportModalProps { + monitorId: string; + open: boolean; + onClose: () => void; +} + +const formatPercent = (value: number): string => `${(value * 100).toFixed(1)}%`; + +const getCategoryColor = (category: string): string => { + const colors: Record = { + advertising: "red", + analytics: "blue", + social_media: "purple", + functional: "green", + essential: "cyan", + unknown: "default", + }; + return colors[category.toLowerCase()] || "default"; +}; + +const getConfidenceColor = (score: number): string => { + if (score >= 4) { + return "green"; + } + if (score >= 3) { + return "orange"; + } + return "red"; +}; + +export const ClassificationReportModal = ({ + monitorId, + open, + onClose, +}: ClassificationReportModalProps) => { + const { data: report, isLoading, error } = useGetClassificationReportQuery( + { monitor_config_key: monitorId, sample_size: 20 }, + { skip: !open }, + ); + + if (error) { + return ( + + + + ); + } + + return ( + + {report && } + + ); +}; + +const ReportContent = ({ report }: { report: WebsiteClassificationReport }) => { + const { coverage, category_distribution, confidence_distribution, vendor_stats, flagged_resources, sample_classifications, by_resource_type } = report; + + return ( + + {/* Coverage Overview */} + + +
+ Total resources +
{coverage.total_resources}
+
+
+ Classified by Compass +
{coverage.classified_by_compass}
+
+
+ Classified by LLM +
{coverage.classified_by_llm}
+
+
+ Unclassified +
0 ? "#faad14" : undefined }}>{coverage.unclassified}
+
+
+ `${formatPercent(coverage.coverage_rate)} coverage`} + className="mt-4" + /> +
+ + {/* Category Distribution */} + {category_distribution.length > 0 && ( + + + {category_distribution.map((cat) => ( + + + {cat.category}: {cat.count} + + + ))} + + + )} + + {/* Confidence Distribution */} + {confidence_distribution.length > 0 && ( + + + {confidence_distribution.map((conf) => ( + + Score {conf.score}: {conf.count} ({formatPercent(conf.percentage)}) + + ))} + + + )} + + {/* Vendor Matching Stats */} + + + + {vendor_stats.total_with_vendor_name} + + + {vendor_stats.matched_to_compass} ({formatPercent(vendor_stats.match_rate)}) + + + 0 ? "danger" : undefined}> + {vendor_stats.unregistered_ad_vendors} + + + + {vendor_stats.top_unmatched_vendors.length > 0 && ( +
+ Top unmatched vendors: + {vendor_stats.top_unmatched_vendors.map((name) => ( + {name} + ))} +
+ )} +
+ + {/* Resource Type Breakdown */} + {Object.keys(by_resource_type).length > 0 && ( + + + {Object.entries(by_resource_type).map(([type, count]) => ( + + {type}: {count} + + ))} + + + )} + + {/* Flagged Resources */} + {flagged_resources.length > 0 && ( + + Flagged resources + {flagged_resources.length} +
+ ), + children: ( + {cat} + }, + { title: "Flag reason", dataIndex: "flag_reason", key: "flag_reason" }, + ]} + /> + ), + }, + ]} + /> + )} + + {/* Sample Classifications */} + {sample_classifications.length > 0 && ( + + Sample classifications + {sample_classifications.length} + + ), + children: ( +
cat ? {cat} : "-" + }, + { title: "Conf.", dataIndex: "confidence", key: "confidence", width: 60, + render: (score: number) => score ? {score} : "-" + }, + { title: "Compass", dataIndex: "compass_matched", key: "compass_matched", width: 80, + render: (matched: boolean) => matched ? Yes : No + }, + { title: "Rationale", dataIndex: "rationale", key: "rationale", ellipsis: true, + render: (text: string) => ( + + {text || "-"} + + ) + }, + ]} + /> + ), + }, + ]} + /> + )} + + {/* Placeholder for Golden Set */} + + + ); +}; + +export default ClassificationReportModal; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts index 49fc62578e..f06b41cd23 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts @@ -51,6 +51,61 @@ interface DiscoveredAssetsFilterValues { consent_aggregated?: string[]; } +// Classification report types +interface CategoryDistribution { + category: string; + count: number; + percentage: number; +} + +interface ConfidenceDistribution { + score: number; + count: number; + percentage: number; +} + +interface VendorMatchStats { + total_with_vendor_name: number; + matched_to_compass: number; + match_rate: number; + unregistered_ad_vendors: number; + top_unmatched_vendors: string[]; +} + +interface ClassificationCoverage { + total_resources: number; + classified_by_compass: number; + classified_by_llm: number; + unclassified: number; + coverage_rate: number; +} + +interface LlmClassificationSample { + urn: string; + domain: string; + name?: string; + resource_type: string; + vendor_name?: string; + category?: string; + confidence?: number; + rationale?: string; + compass_matched: boolean; + flagged: boolean; + flag_reason?: string; +} + +export interface WebsiteClassificationReport { + monitor_config_key: string; + generated_at: string; + coverage: ClassificationCoverage; + category_distribution: CategoryDistribution[]; + confidence_distribution: ConfidenceDistribution[]; + vendor_stats: VendorMatchStats; + by_resource_type: Record; + sample_classifications: LlmClassificationSample[]; + flagged_resources: LlmClassificationSample[]; +} + const actionCenterApi = baseApi.injectEndpoints({ endpoints: (build) => ({ getAggregateMonitorResults: build.query< @@ -561,6 +616,27 @@ const actionCenterApi = baseApi.injectEndpoints({ }), invalidatesTags: ["Monitor Field Results", "Monitor Field Details"], }), + classifyWebsiteAssets: build.mutation< + MonitorActionResponse, + { monitor_config_key: string; staged_resource_urns?: string[] } + >({ + query: ({ monitor_config_key, staged_resource_urns }) => ({ + url: `/plus/discovery-monitor/${monitor_config_key}/classify-assets`, + method: "POST", + body: staged_resource_urns ? { staged_resource_urns } : {}, + }), + invalidatesTags: ["Discovery Monitor Results"], + }), + getClassificationReport: build.query< + WebsiteClassificationReport, + { monitor_config_key: string; sample_size?: number } + >({ + query: ({ monitor_config_key, sample_size = 10 }) => ({ + url: `/plus/discovery-monitor/${monitor_config_key}/classification-report`, + params: { sample_size }, + }), + providesTags: ["Discovery Monitor Results"], + }), }), }); @@ -591,4 +667,6 @@ export const { useGetStagedResourceDetailsQuery, useLazyGetStagedResourceDetailsQuery, usePromoteRemovalStagedResourcesMutation, + useClassifyWebsiteAssetsMutation, + useGetClassificationReportQuery, } = actionCenterApi; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useDiscoveredAssetsTable.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useDiscoveredAssetsTable.tsx index 372369c67d..eff8755c9c 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useDiscoveredAssetsTable.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/hooks/useDiscoveredAssetsTable.tsx @@ -38,6 +38,7 @@ import { import { useAddMonitorResultAssetsMutation, useAddMonitorResultSystemsMutation, + useClassifyWebsiteAssetsMutation, useGetDiscoveredAssetsQuery, useGetWebsiteMonitorResourceFiltersQuery, useIgnoreMonitorResultAssetsMutation, @@ -139,6 +140,8 @@ export const useDiscoveredAssetsTable = ({ restoreMonitorResultAssetsMutation, { isLoading: isRestoringResults }, ] = useRestoreMonitorResultAssetsMutation(); + const [classifyWebsiteAssetsMutation, { isLoading: isClassifyingAssets }] = + useClassifyWebsiteAssetsMutation(); const anyBulkActionIsLoading = isAddingResults || @@ -146,7 +149,8 @@ export const useDiscoveredAssetsTable = ({ isAddingAllResults || isBulkUpdatingSystem || isRestoringResults || - isBulkAddingDataUses; + isBulkAddingDataUses || + isClassifyingAssets; const disableAddAll = anyBulkActionIsLoading || systemId === UNCATEGORIZED_SEGMENT; @@ -612,6 +616,31 @@ export const useDiscoveredAssetsTable = ({ resetSelections, ]); + const handleClassifyWithAI = useCallback(async () => { + const result = await classifyWebsiteAssetsMutation({ + monitor_config_key: monitorId, + staged_resource_urns: selectedUrns.length > 0 ? selectedUrns : undefined, + }); + if (isErrorResult(result)) { + toast(errorToastParams(getErrorMessage(result.error))); + } else { + const count = selectedUrns.length > 0 ? selectedUrns.length : "all"; + toast( + successToastParams( + `Classification complete for ${count} uncategorized assets.`, + `Confirmed`, + ), + ); + resetSelections(); + } + }, [ + classifyWebsiteAssetsMutation, + monitorId, + selectedUrns, + toast, + resetSelections, + ]); + const handleAddAll = useCallback(async () => { const assetCount = data?.items.length || 0; const result = await addMonitorResultSystemsMutation({ @@ -699,6 +728,7 @@ export const useDiscoveredAssetsTable = ({ handleBulkIgnore, handleBulkRestore, handleAddAll, + handleClassifyWithAI, // Loading states anyBulkActionIsLoading, @@ -708,6 +738,7 @@ export const useDiscoveredAssetsTable = ({ isBulkUpdatingSystem, isBulkAddingDataUses, isRestoringResults, + isClassifyingAssets, disableAddAll, }; }; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredAssetsTable.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredAssetsTable.tsx index 4465c7f89e..124bc3fdcf 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredAssetsTable.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/tables/DiscoveredAssetsTable.tsx @@ -12,6 +12,7 @@ import { } from "fidesui"; import { useState } from "react"; +import { useFlags } from "~/features/common/features/features.slice"; import { SelectedText } from "~/features/common/table/SelectedText"; import { ConsentAlertInfo, @@ -22,6 +23,7 @@ import { import { DebouncedSearchInput } from "../../../common/DebouncedSearchInput"; import AddDataUsesModal from "../AddDataUsesModal"; import { AssignSystemModal } from "../AssignSystemModal"; +import { ClassificationReportModal } from "../ClassificationReportModal"; import { ConsentBreakdownModal } from "../ConsentBreakdownModal"; import { ActionCenterTabHash } from "../hooks/useActionCenterTabs"; import { useDiscoveredAssetsTable } from "../hooks/useDiscoveredAssetsTable"; @@ -37,6 +39,10 @@ export const DiscoveredAssetsTable = ({ systemId, consentStatus, }: DiscoveredAssetsTableProps) => { + // Feature flags + const { flags } = useFlags(); + const showLlmClassification = flags.alphaWebMonitorLlmClassification; + // Modal state const [isAssignSystemModalOpen, setIsAssignSystemModalOpen] = useState(false); @@ -44,6 +50,8 @@ export const DiscoveredAssetsTable = ({ useState(false); const [consentBreakdownModalData, setConsentBreakdownModalData] = useState(null); + const [isClassificationReportOpen, setIsClassificationReportOpen] = + useState(false); const handleShowBreakdown = (stagedResource: StagedResourceAPIResponse) => { setConsentBreakdownModalData(stagedResource); @@ -83,12 +91,14 @@ export const DiscoveredAssetsTable = ({ handleBulkIgnore, handleBulkRestore, handleAddAll, + handleClassifyWithAI, // Loading states anyBulkActionIsLoading, isAddingAllResults, isBulkUpdatingSystem, isBulkAddingDataUses, + isClassifyingAssets, disableAddAll, } = useDiscoveredAssetsTable({ monitorId, @@ -186,6 +196,18 @@ export const DiscoveredAssetsTable = ({ label: "Ignore", onClick: handleBulkIgnore, }, + ...(showLlmClassification + ? [ + { + type: "divider" as const, + }, + { + key: "classify-ai", + label: "Classify with AI", + onClick: handleClassifyWithAI, + }, + ] + : []), ]), ], }} @@ -205,6 +227,24 @@ export const DiscoveredAssetsTable = ({ + {showLlmClassification && ( + <> + + + + )} )} + {showLlmClassification && ( + setIsClassificationReportOpen(false)} + /> + )} ); }; diff --git a/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureWebsiteMonitorForm.tsx b/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureWebsiteMonitorForm.tsx index f1ebe42b39..80f7d649b2 100644 --- a/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureWebsiteMonitorForm.tsx +++ b/clients/admin-ui/src/features/integrations/configure-monitor/ConfigureWebsiteMonitorForm.tsx @@ -6,12 +6,14 @@ import { Flex, Form, isoCodesToOptions, + Tooltip, } from "fidesui"; import { getIn, useFormik } from "formik"; import { useRouter } from "next/router"; import { useState } from "react"; import * as Yup from "yup"; +import { useFlags } from "~/features/common/features/features.slice"; import { FormikDateTimeInput } from "~/features/common/form/FormikDateTimeInput"; import { FormikLocationSelect } from "~/features/common/form/FormikLocationSelect"; import { FormikSelect } from "~/features/common/form/FormikSelect"; @@ -19,6 +21,7 @@ import { FormikTextInput } from "~/features/common/form/FormikTextInput"; import { enumToOptions } from "~/features/common/helpers"; import FormInfoBox from "~/features/common/modals/FormInfoBox"; import { PRIVACY_NOTICE_REGION_RECORD } from "~/features/common/privacy-notice-regions"; +import { useGetConfigurationSettingsQuery } from "~/features/config-settings/config-settings.slice"; import { useGetOnlyCountryLocationsQuery } from "~/features/locations/locations.slice"; import { getSelectedRegionIds } from "~/features/privacy-experience/form/helpers"; import { @@ -33,6 +36,7 @@ interface WebsiteMonitorConfig extends Omit { datasource_params?: WebsiteMonitorParams; url: string; + llm_model_override?: string; } const FORM_COPY = `This monitor allows you to simulate and verify user consent actions, such as 'accept,' 'reject,' or 'opt-out,' on consent experiences. For each detected activity, the monitor will record whether it occurred before or after the configured user actions, ensuring compliance with user consent choices.`; @@ -65,6 +69,20 @@ const ConfigureWebsiteMonitorForm = ({ }) => { const [isSubmitting, setIsSubmitting] = useState(false); const router = useRouter(); + const { flags } = useFlags(); + + // Feature flag for LLM classification in website monitors + const llmClassifierFeatureEnabled = !!flags.alphaWebMonitorLlmClassification; + + const { data: appConfig } = useGetConfigurationSettingsQuery( + { api_set: false }, + { skip: !llmClassifierFeatureEnabled }, + ); + + // Server-side LLM classifier capability + const serverSupportsLlmClassifier = + !!appConfig?.detection_discovery?.llm_classifier_enabled; + const integrationId = Array.isArray(router.query.id) ? router.query.id[0] : (router.query.id as string); @@ -97,6 +115,7 @@ const ConfigureWebsiteMonitorForm = ({ locations: [], exclude_domains: [], }, + llm_model_override: monitor?.classify_params?.llm_model_override ?? "", }; const handleSubmit = async (values: WebsiteMonitorConfig) => { @@ -114,12 +133,20 @@ const ConfigureWebsiteMonitorForm = ({ execution_start_date: undefined, }; + // Build classify_params with llm_model_override if provided + const classifyParams = { + ...(monitor?.classify_params || {}), + ...(values.llm_model_override + ? { llm_model_override: values.llm_model_override } + : { llm_model_override: undefined }), + }; + const payload: WebsiteMonitorConfig = { ...monitor, ...values, ...executionInfo, key: monitor?.key, - classify_params: monitor?.classify_params || {}, + classify_params: classifyParams, datasource_params: values.datasource_params || {}, connection_config_key: integrationId, }; @@ -233,6 +260,33 @@ const ConfigureWebsiteMonitorForm = ({ onBlur={formik.handleBlur} value={values.shared_config_id} /> + {llmClassifierFeatureEnabled && ( + + LLM model override + + } + > + + + )}