diff --git a/.gitignore b/.gitignore index 6f50c5abf8..106e6dddb1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ auth/keycloak/config/*.log /services/net/image/keys /services/net/image/data +**/[Bb]in/ +**/[Oo]bj/ + # TLS certificates - managed separately, not in git openshift/kustomize/.tls-certs/*.crt openshift/kustomize/.tls-certs/*.key diff --git a/.vscode/launch.json b/.vscode/launch.json index 55e1c5dcdb..a92157fa1f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,8 +1,6 @@ { "version": "0.2.0", "configurations": [ - - { // Use IntelliSense to find out which attributes exist for C# debugging // Use hover for the description of the existing attributes @@ -105,6 +103,23 @@ "stopAtEntry": false, "envFile": "${workspaceFolder}/services/net/transcription/.env" }, + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "name": "Run Auto Clipper Service", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-auto-clipper", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/services/net/auto-clipper/bin/Debug/net9.0/TNO.Services.AutoClipper.dll", + "args": [], + "cwd": "${workspaceFolder}/services/net/auto-clipper", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false, + "envFile": "${workspaceFolder}/services/net/auto-clipper/.env" + }, { // Use IntelliSense to find out which attributes exist for C# debugging // Use hover for the description of the existing attributes diff --git a/.vscode/settings.json b/.vscode/settings.json index d04a5fd689..2300553d3d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ "CHES", "datalabels", "formik", + "healthcheck", "Idir", "insertable", "Keycloak", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 96f51d018c..42ec776286 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -29,12 +29,7 @@ "label": "watch", "command": "dotnet", "type": "process", - "args": [ - "watch", - "run", - "--project", - "${workspaceFolder}/api/net/TNO.API.csproj" - ], + "args": ["watch", "run", "--project", "${workspaceFolder}/api/net/TNO.API.csproj"], "problemMatcher": "$msCompile" }, { @@ -213,7 +208,43 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/transcription/TNO.Services.Transcription.csproj" + "${workspaceFolder}/services/net/atranscription/TNO.Services.Transcription.csproj" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "build-auto-clipper", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/services/net/auto-clipper/TNO.Services.AutoClipper.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish-auto-clipper", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/services/net/auto-clipper/TNO.Services.AutoClipper.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch-auto-clipper", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/services/net/auto-clipper/TNO.Services.TranAutoClipperscription.csproj" ], "problemMatcher": "$msCompile" }, @@ -357,7 +388,7 @@ "watch", "run", "--project", - "${workspaceFolder}/tools/elastic/migration/TNO.Elastic.Migration.csproj", + "${workspaceFolder}/tools/elastic/migration/TNO.Elastic.Migration.csproj" ], "problemMatcher": "$msCompile" }, @@ -393,7 +424,7 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/notification/TNO.Services.Notification.csproj", + "${workspaceFolder}/services/net/notification/TNO.Services.Notification.csproj" ], "problemMatcher": "$msCompile" }, @@ -429,7 +460,7 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/reporting/TNO.Services.Reporting.csproj", + "${workspaceFolder}/services/net/reporting/TNO.Services.Reporting.csproj" ], "problemMatcher": "$msCompile" }, @@ -465,7 +496,7 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/scheduler/TNO.Services.Scheduler.csproj", + "${workspaceFolder}/services/net/scheduler/TNO.Services.Scheduler.csproj" ], "problemMatcher": "$msCompile" }, @@ -501,7 +532,7 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/folder-collection/TNO.Services.FolderCollection.csproj", + "${workspaceFolder}/services/net/folder-collection/TNO.Services.FolderCollection.csproj" ], "problemMatcher": "$msCompile" }, @@ -537,7 +568,7 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/ffmpeg/TNO.Services.FFmpeg.csproj", + "${workspaceFolder}/services/net/ffmpeg/TNO.Services.FFmpeg.csproj" ], "problemMatcher": "$msCompile" }, @@ -573,7 +604,7 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/extract-quotes/TNO.Services.ExtractQuotes.csproj", + "${workspaceFolder}/services/net/extract-quotes/TNO.Services.ExtractQuotes.csproj" ], "problemMatcher": "$msCompile" }, @@ -609,7 +640,7 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/event-handler/TNO.Services.EventHandler.csproj", + "${workspaceFolder}/services/net/event-handler/TNO.Services.EventHandler.csproj" ], "problemMatcher": "$msCompile" }, @@ -645,11 +676,10 @@ "watch", "run", "--project", - "${workspaceFolder}/services/net/ches-retry/TNO.Services.ChesRetry.csproj", + "${workspaceFolder}/services/net/ches-retry/TNO.Services.ChesRetry.csproj" ], "problemMatcher": "$msCompile" - } - , + }, { "label": "build-elastic-indexer", "command": "dotnet", @@ -682,7 +712,7 @@ "watch", "run", "--project", - "${workspaceFolder}/tools/indexer/TNO.Tools.ElasticIndexer.csproj", + "${workspaceFolder}/tools/indexer/TNO.Tools.ElasticIndexer.csproj" ], "problemMatcher": "$msCompile" } diff --git a/TNO.sln b/TNO.sln index 0a696cf823..7fee31e681 100644 --- a/TNO.sln +++ b/TNO.sln @@ -71,8 +71,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.TemplateEngine", "libs\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.Services.Scheduler", "services\net\scheduler\TNO.Services.Scheduler.csproj", "{A2DD9547-A4AA-4E07-9239-77D689F49C47}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.Services.ContentMigration", "services\net\contentmigration\TNO.Services.ContentMigration.csproj", "{7D0917C1-DFE3-420E-9980-D81947AC405F}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.Services.FolderCollection", "services\net\folder-collection\TNO.Services.FolderCollection.csproj", "{B559B641-F1F0-41D6-9938-A23EC06542A2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.Services.ExtractQuotes", "services\net\extract-quotes\TNO.Services.ExtractQuotes.csproj", "{9BC92B16-7AF9-4B45-BA18-C6A98E2AD87E}" @@ -85,6 +83,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.Services.EventHandler", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.Services.ChesRetry", "services\net\ches-retry\TNO.Services.ChesRetry.csproj", "{067EA7C3-A816-406B-B36A-09FC05A427A1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TNO.Services.AutoClipper", "services\net\auto-clipper\TNO.Services.AutoClipper.csproj", "{7B8BF924-36BA-422E-85FD-1C590B092F7B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -198,10 +198,6 @@ Global {A2DD9547-A4AA-4E07-9239-77D689F49C47}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2DD9547-A4AA-4E07-9239-77D689F49C47}.Release|Any CPU.ActiveCfg = Release|Any CPU {A2DD9547-A4AA-4E07-9239-77D689F49C47}.Release|Any CPU.Build.0 = Release|Any CPU - {7D0917C1-DFE3-420E-9980-D81947AC405F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7D0917C1-DFE3-420E-9980-D81947AC405F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7D0917C1-DFE3-420E-9980-D81947AC405F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7D0917C1-DFE3-420E-9980-D81947AC405F}.Release|Any CPU.Build.0 = Release|Any CPU {B559B641-F1F0-41D6-9938-A23EC06542A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B559B641-F1F0-41D6-9938-A23EC06542A2}.Debug|Any CPU.Build.0 = Debug|Any CPU {B559B641-F1F0-41D6-9938-A23EC06542A2}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -226,6 +222,10 @@ Global {067EA7C3-A816-406B-B36A-09FC05A427A1}.Debug|Any CPU.Build.0 = Debug|Any CPU {067EA7C3-A816-406B-B36A-09FC05A427A1}.Release|Any CPU.ActiveCfg = Release|Any CPU {067EA7C3-A816-406B-B36A-09FC05A427A1}.Release|Any CPU.Build.0 = Release|Any CPU + {7B8BF924-36BA-422E-85FD-1C590B092F7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B8BF924-36BA-422E-85FD-1C590B092F7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B8BF924-36BA-422E-85FD-1C590B092F7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B8BF924-36BA-422E-85FD-1C590B092F7B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {16EA028B-B4C8-416D-BE54-D73D75483668} = {F627B24A-217D-4BF1-BC77-E1A92DBCD07F} @@ -258,12 +258,12 @@ Global {65185F9A-73C0-4C59-8DC4-892616963E43} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} {CE993EF7-A38F-4563-837B-1194375D459B} = {890D13F9-A1ED-4B00-8E69-A1AB620F31A9} {A2DD9547-A4AA-4E07-9239-77D689F49C47} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} - {7D0917C1-DFE3-420E-9980-D81947AC405F} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} {B559B641-F1F0-41D6-9938-A23EC06542A2} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} {9BC92B16-7AF9-4B45-BA18-C6A98E2AD87E} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} {E7444ADF-0137-439B-8E20-917CF2FAFA45} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} {2D455400-0E86-476E-8C42-532D32C10107} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} {6F1F9B85-B155-4A5A-BB36-10F734F96A12} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} {067EA7C3-A816-406B-B36A-09FC05A427A1} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} + {7B8BF924-36BA-422E-85FD-1C590B092F7B} = {448D6DE6-6887-48EC-A202-18C7EB428ACD} EndGlobalSection EndGlobal diff --git a/api/net/Areas/Editor/Controllers/WorkOrderController.cs b/api/net/Areas/Editor/Controllers/WorkOrderController.cs index 4cb1705989..d54f954c21 100644 --- a/api/net/Areas/Editor/Controllers/WorkOrderController.cs +++ b/api/net/Areas/Editor/Controllers/WorkOrderController.cs @@ -147,6 +147,29 @@ public async Task RequestTranscriptionAsync(long contentId) return new JsonResult(new WorkOrderMessageModel(workOrder, _serializerOptions)); } + /// + /// Request an auto clip for the content for the specified 'contentId'. + /// Publish message to kafka to request an auto clip. + /// + /// + /// + [HttpPost("auto-clip/{contentId}")] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(typeof(WorkOrderMessageModel), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [SwaggerOperation(Tags = new[] { "WorkOrder" })] + public async Task RequestAutoClipAsync(long contentId) + { + var workOrder = await _workOrderHelper.RequestAutoClipAsync(contentId, true); + if (workOrder.Status != WorkOrderStatus.Submitted) + return new JsonResult(new WorkOrderMessageModel(workOrder, _serializerOptions)) + { + StatusCode = (int)HttpStatusCode.AlreadyReported + }; + + return new JsonResult(new WorkOrderMessageModel(workOrder, _serializerOptions)); + } + /// /// Request a Natural Language Processing for the content for the specified 'contentId'. /// Publish message to kafka to request a NLP. diff --git a/api/net/Areas/Helpers/IWorkOrderHelper.cs b/api/net/Areas/Helpers/IWorkOrderHelper.cs index 62251de1d3..f03ee532fa 100644 --- a/api/net/Areas/Helpers/IWorkOrderHelper.cs +++ b/api/net/Areas/Helpers/IWorkOrderHelper.cs @@ -34,6 +34,18 @@ public interface IWorkOrderHelper /// Task RequestTranscriptionAsync(long contentId, bool force = false); + /// + /// Request a auto clip for the specified 'contentId'. + /// Only allow one active auto clip request. + /// + /// + /// Whether to force a request regardless of the prior requests state + /// + /// + /// + /// + Task RequestAutoClipAsync(long contentId, bool force = false); + /// /// Request a transcript for the specified 'contentId'. /// Only allow one active transcript request. @@ -47,6 +59,19 @@ public interface IWorkOrderHelper /// Task RequestTranscriptionAsync(long contentId, Entities.User requestor, bool force = false); + /// + /// Request a auto clip for the specified 'contentId'. + /// Only allow one active auto clip request. + /// + /// + /// + /// Whether to force a request regardless of the prior requests state + /// + /// + /// + /// + Task RequestAutoClipAsync(long contentId, Entities.User requestor, bool force = false); + /// /// Request a natural language processing for the specified 'contentId'. /// Only allow one active nlp request. diff --git a/api/net/Areas/Helpers/WorkOrderHelper.cs b/api/net/Areas/Helpers/WorkOrderHelper.cs index 5b721663da..92a56a54d4 100644 --- a/api/net/Areas/Helpers/WorkOrderHelper.cs +++ b/api/net/Areas/Helpers/WorkOrderHelper.cs @@ -136,6 +136,24 @@ public bool ShouldAutoTranscribe(long contentId) return await RequestTranscriptionAsync(contentId, user, force); } + /// + /// Request a auto clip for the specified 'contentId'. + /// Only allow one active auto clip request. + /// + /// + /// Whether to force a request regardless of the prior requests state + /// + /// + /// + /// + public async Task RequestAutoClipAsync(long contentId, bool force = false) + { + string username = _principal.GetUsername() ?? throw new NotAuthorizedException("Username is missing"); + var user = _userService.FindByUsername(username) ?? throw new NotAuthorizedException("User is missing"); + + return await RequestAutoClipAsync(contentId, user, force); + } + /// /// Determine if the content has an existing transcript. /// @@ -195,6 +213,51 @@ public bool HasExistingTranscript(long contentId) return workOrders.OrderByDescending(w => w.CreatedOn).First(); } + /// + /// Request a auto clip for the specified 'contentId'. + /// Only allow one active auto clip request. + /// + /// + /// + /// Whether to force a request regardless of the prior requests state + /// + /// + /// + /// + /// + public async Task RequestAutoClipAsync(long contentId, Entities.User requestor, bool force = false) + { + if (this.Content == null || this.Content.Id != contentId) + this.Content = _contentService.FindById(contentId) ?? throw new NoContentException("Content does not exist"); + if (String.IsNullOrWhiteSpace(_kafkaOptions.AutoClipTopic)) throw new ConfigurationException("Kafka auto clip topic not configured."); + + if (this.Content.IsApproved && force == false) throw new InvalidOperationException("Content is already approved"); + // Only allow one work order auto clip request at a time. + // TODO: Handle blocked work orders stuck in progress. + var workOrders = _workOrderService.FindByContentId(contentId); + + // Add the user to the content notification. + _notificationService.SubscriberUserToContent(requestor.Id, contentId); + + if (force || !workOrders.Any(o => o.WorkType == Entities.WorkOrderType.AutoClip || !WorkLimiterStatus.Contains(o.Status))) + { + var headlineString = $"{{ \"headline\": \"{this.Content.Headline.Replace("\n", "")}\" }}"; + var configuration = JsonDocument.Parse(headlineString); + var workOrder = _workOrderService.AddAndSave( + new Entities.WorkOrder( + Entities.WorkOrderType.AutoClip, + requestor, + "", + this.Content, + configuration + )); + + await _kafkaMessenger.SendMessageAsync(_kafkaOptions.AutoClipTopic, new TNO.Kafka.Models.ClipRequestModel(workOrder)); + return workOrder; + } + return workOrders.OrderByDescending(w => w.CreatedOn).First(); + } + /// /// Request a natural language processing for the specified 'contentId'. /// Only allow one active nlp request. diff --git a/api/net/Config/KafkaOptions.cs b/api/net/Config/KafkaOptions.cs index 877dde1320..fb7e014a79 100644 --- a/api/net/Config/KafkaOptions.cs +++ b/api/net/Config/KafkaOptions.cs @@ -16,6 +16,11 @@ public class KafkaOptions /// public string TranscriptionTopic { get; set; } = ""; + /// + /// get/set - The Kafka topic name to request auto clips. + /// + public string AutoClipTopic { get; set; } = ""; + /// /// get/set - The Kafka topic name to request NLP. /// diff --git a/api/net/TNO.API.csproj b/api/net/TNO.API.csproj index 541e065147..97669bb747 100644 --- a/api/net/TNO.API.csproj +++ b/api/net/TNO.API.csproj @@ -15,13 +15,14 @@ + - + diff --git a/api/net/appsettings.json b/api/net/appsettings.json index fa2350c1b7..9a3c58c5ac 100644 --- a/api/net/appsettings.json +++ b/api/net/appsettings.json @@ -62,6 +62,7 @@ "Kafka": { "IndexingTopic": "index", "TranscriptionTopic": "transcribe", + "AutoClipTopic": "request-clips", "NLPTopic": "nlp", "FileRequestTopic": "file-request", "NotificationTopic": "notify", @@ -117,5 +118,11 @@ "Watch": { "IsEnabled": false, "SendTo": "" + }, + "Azure": { + "AI": { + "ProjectEndpoint": "", + "ModelDeploymentName": "" + } } } diff --git a/app/editor/.yarn/cache/tno-core-npm-1.0.29-b0ccc3fe82-277dacbb50.zip b/app/editor/.yarn/cache/tno-core-npm-1.0.31-a8a0b59ae3-57f0f47bb6.zip similarity index 87% rename from app/editor/.yarn/cache/tno-core-npm-1.0.29-b0ccc3fe82-277dacbb50.zip rename to app/editor/.yarn/cache/tno-core-npm-1.0.31-a8a0b59ae3-57f0f47bb6.zip index 3204dd13bb..ec119abc01 100644 Binary files a/app/editor/.yarn/cache/tno-core-npm-1.0.29-b0ccc3fe82-277dacbb50.zip and b/app/editor/.yarn/cache/tno-core-npm-1.0.31-a8a0b59ae3-57f0f47bb6.zip differ diff --git a/app/editor/package.json b/app/editor/package.json index c656da9b28..4bd9a5b890 100644 --- a/app/editor/package.json +++ b/app/editor/package.json @@ -60,7 +60,7 @@ "redux-logger": "3.0.6", "styled-components": "6.1.11", "stylis": "4.3.2", - "tno-core": "1.0.29" + "tno-core": "1.0.31" }, "devDependencies": { "@simbathesailor/use-what-changed": "2.0.0", diff --git a/app/editor/src/features/admin/report-templates/templates/body/CustomReport.cshtml b/app/editor/src/features/admin/report-templates/templates/body/CustomReport.cshtml index 15cee65cd4..7b9acce275 100644 --- a/app/editor/src/features/admin/report-templates/templates/body/CustomReport.cshtml +++ b/app/editor/src/features/admin/report-templates/templates/body/CustomReport.cshtml @@ -364,6 +364,14 @@ @(section.Value.Data) } + else if (section.Value.SectionType == ReportSectionType.AI) + { + @* AI SECTION *@ + var alt = section.Value.Settings.Label; +
+ @(section.Value.Data) +
+ } @if (!horizontalCharts && !endChartGroup) { diff --git a/app/editor/src/features/admin/reports/ReportFormSections.tsx b/app/editor/src/features/admin/reports/ReportFormSections.tsx index 062045f965..e8ef817f8d 100644 --- a/app/editor/src/features/admin/reports/ReportFormSections.tsx +++ b/app/editor/src/features/admin/reports/ReportFormSections.tsx @@ -19,6 +19,7 @@ import { ReportContentOptions, ReportHeadlineOptions, ReportOptions, + ReportSectionAI, ReportSectionContent, ReportSectionData, ReportSectionGallery, @@ -160,6 +161,12 @@ export const ReportFormSections = () => { > Text + + = ({ } }} /> + { + try { + await handleAutoClip(props.values, props); + } finally { + toggleAutoClip(); + } + }} + /> ) => { + try { + // TODO: Only save when required. + // Save before submitting request. + const content = await handleSubmit(values, formikHelpers); + const response = await autoClip(toModel(values)); + setForm({ ...content, workOrders: [response.data, ...form.workOrders] }); + + if (response.status === 200) toast.success('An auto clip has been requested'); + else if (response.status === 208) { + if (response.data.status === WorkOrderStatusName.Completed) + toast.warn('Content has already been auto clipped'); + else toast.warn(`An active request for auto clipping already exists`); + } + } catch { + // Ignore this failure it is handled by our global ajax requests. + } + }, + [form.workOrders, handleSubmit, autoClip], + ); + const handleNLP = React.useCallback( async (values: IContentForm, formikHelpers: FormikHelpers) => { try { @@ -434,6 +456,7 @@ export const useContentForm = ({ handlePublish, handleUnpublish, handleTranscribe, + handleAutoClip, handleNLP, handleFFmpeg, goToNext, diff --git a/app/editor/src/features/content/utils/findWorkOrder.ts b/app/editor/src/features/content/utils/findWorkOrder.ts index d12e5cbccb..26fa5bfab1 100644 --- a/app/editor/src/features/content/utils/findWorkOrder.ts +++ b/app/editor/src/features/content/utils/findWorkOrder.ts @@ -8,7 +8,10 @@ import { IWorkOrderModel, WorkOrderTypeName } from 'tno-core'; */ export const findWorkOrder = ( workOrders: IWorkOrderModel[] | undefined, - type: WorkOrderTypeName, + type: WorkOrderTypeName | WorkOrderTypeName[], ) => { + if (Array.isArray(type)) { + return workOrders?.find((i) => type.includes(i.workType)); + } return workOrders?.find((i) => i.workType === type); }; diff --git a/app/editor/src/features/content/utils/isWorkOrderStatus.ts b/app/editor/src/features/content/utils/isWorkOrderStatus.ts index 0b568cda2a..68b25afd8f 100644 --- a/app/editor/src/features/content/utils/isWorkOrderStatus.ts +++ b/app/editor/src/features/content/utils/isWorkOrderStatus.ts @@ -9,8 +9,10 @@ import { IWorkOrderModel, WorkOrderStatusName, WorkOrderTypeName } from 'tno-cor */ export const isWorkOrderStatus = ( workOrders: IWorkOrderModel[] | undefined, - type: WorkOrderTypeName, + type: WorkOrderTypeName | WorkOrderTypeName[], status: WorkOrderStatusName[], ) => { + if (Array.isArray(type)) + return workOrders?.some((i) => type.includes(i.workType) && status.includes(i.status)) ?? false; return workOrders?.some((i) => i.workType === type && status.includes(i.status)) ?? false; }; diff --git a/app/editor/src/store/hooks/editor/useWorkOrders.ts b/app/editor/src/store/hooks/editor/useWorkOrders.ts index 63e5f8777a..f15dc4bb72 100644 --- a/app/editor/src/store/hooks/editor/useWorkOrders.ts +++ b/app/editor/src/store/hooks/editor/useWorkOrders.ts @@ -17,6 +17,7 @@ interface IWorkOrderController { findWorkOrders: (filter: IWorkOrderFilter) => Promise>>; updateWorkOrder: (workOrder: IWorkOrderModel) => Promise>; transcribe: (content: IContentModel) => Promise>; + autoClip: (content: IContentModel) => Promise>; nlp: (content: IContentModel) => Promise>; requestFile: (locationId: number, path: string) => Promise>; ffmpeg: (content: IContentModel) => Promise>; @@ -44,6 +45,9 @@ export const useWorkOrders = (): [IWorkOrderState, IWorkOrderController] => { transcribe: async (content: IContentModel) => { return await dispatch('transcribe-content', () => api.transcribe(content)); }, + autoClip: async (content: IContentModel) => { + return await dispatch('auto-clip-content', () => api.autoClip(content)); + }, nlp: async (content: IContentModel) => { return await dispatch('nlp-content', () => api.nlp(content)); }, diff --git a/app/editor/tno-core-1.0.28.tgz b/app/editor/tno-core-1.0.28.tgz deleted file mode 100644 index 21c9866e5b..0000000000 Binary files a/app/editor/tno-core-1.0.28.tgz and /dev/null differ diff --git a/app/editor/yarn.lock b/app/editor/yarn.lock index 401b8c6091..0efc63aa30 100644 --- a/app/editor/yarn.lock +++ b/app/editor/yarn.lock @@ -12209,7 +12209,7 @@ __metadata: sass-extract-loader: 1.1.0 styled-components: 6.1.11 stylis: 4.3.2 - tno-core: 1.0.29 + tno-core: 1.0.31 typescript: 4.9.5 vitest: 3.0.7 languageName: unknown @@ -16674,9 +16674,9 @@ __metadata: languageName: node linkType: hard -"tno-core@npm:1.0.29": - version: 1.0.29 - resolution: "tno-core@npm:1.0.29" +"tno-core@npm:1.0.31": + version: 1.0.31 + resolution: "tno-core@npm:1.0.31" dependencies: "@elastic/elasticsearch": ^8.13.1 "@fortawesome/free-solid-svg-icons": ^6.4.2 @@ -16709,7 +16709,7 @@ __metadata: styled-components: ^6.1.11 stylis: ^4.3.2 yup: ^1.1.1 - checksum: 277dacbb5080703241317cb524e6faa7f7dd6e9af7b8bfb7e247d72f185c344cf2a486d6349960a2cf239c06f41c1124242d3e1c962ca37cee9e57de70e2ec62 + checksum: 57f0f47bb6479e419dc71531fbf328f3a14e6ed0afdebc28c33c9dd5433ed3293f857e812220629aa30288c484323affff930191fd5e925b1696a402dbdeaf86 languageName: node linkType: hard diff --git a/app/subscriber/.yarn/cache/tno-core-npm-1.0.29-b0ccc3fe82-277dacbb50.zip b/app/subscriber/.yarn/cache/tno-core-npm-1.0.31-a8a0b59ae3-57f0f47bb6.zip similarity index 87% rename from app/subscriber/.yarn/cache/tno-core-npm-1.0.29-b0ccc3fe82-277dacbb50.zip rename to app/subscriber/.yarn/cache/tno-core-npm-1.0.31-a8a0b59ae3-57f0f47bb6.zip index 3204dd13bb..ec119abc01 100644 Binary files a/app/subscriber/.yarn/cache/tno-core-npm-1.0.29-b0ccc3fe82-277dacbb50.zip and b/app/subscriber/.yarn/cache/tno-core-npm-1.0.31-a8a0b59ae3-57f0f47bb6.zip differ diff --git a/app/subscriber/Dockerfile.open b/app/subscriber/Dockerfile.open index 149d6772c3..2937b33f35 100644 --- a/app/subscriber/Dockerfile.open +++ b/app/subscriber/Dockerfile.open @@ -1,4 +1,4 @@ -FROM image-registry.openshift-image-registry.svc:5000/9b301c-tools/node:18-bullseye as build-deps +FROM image-registry.openshift-image-registry.svc:5000/9b301c-tools/node:18-bullseye AS build-deps USER root diff --git a/app/subscriber/package.json b/app/subscriber/package.json index 52ba193330..daefe83396 100644 --- a/app/subscriber/package.json +++ b/app/subscriber/package.json @@ -48,7 +48,7 @@ "sheetjs": "file:packages/xlsx-0.20.1.tgz", "styled-components": "6.1.11", "stylis": "4.3.2", - "tno-core": "1.0.29" + "tno-core": "1.0.31" }, "devDependencies": { "@testing-library/jest-dom": "6.6.3", diff --git a/app/subscriber/src/features/my-reports/components/SectionIcon.tsx b/app/subscriber/src/features/my-reports/components/SectionIcon.tsx index a27ded7cf3..2b46cf891f 100644 --- a/app/subscriber/src/features/my-reports/components/SectionIcon.tsx +++ b/app/subscriber/src/features/my-reports/components/SectionIcon.tsx @@ -1,5 +1,5 @@ import { BiSolidFileJson } from 'react-icons/bi'; -import { FaAlignJustify, FaChartPie, FaImage, FaList, FaNewspaper } from 'react-icons/fa6'; +import { FaAlignJustify, FaBrain, FaChartPie, FaImage, FaList, FaNewspaper } from 'react-icons/fa6'; import { ReportSectionTypeName } from 'tno-core'; export interface ISectionIconProps { @@ -21,5 +21,7 @@ export const SectionIcon = ({ type }: ISectionIconProps) => { return ; } else if (type === ReportSectionTypeName.Data) { return ; + } else if (type === ReportSectionTypeName.AI) { + return ; } else return null; }; diff --git a/app/subscriber/src/features/my-reports/components/SectionLabel.tsx b/app/subscriber/src/features/my-reports/components/SectionLabel.tsx index af558dfd6e..91c35be7d7 100644 --- a/app/subscriber/src/features/my-reports/components/SectionLabel.tsx +++ b/app/subscriber/src/features/my-reports/components/SectionLabel.tsx @@ -75,5 +75,13 @@ export const SectionLabel = ({ section, showIcon = true, showTotal }: ISectionLa {section.settings.label} ); + } else if (section.sectionType === ReportSectionTypeName.AI) { + return ( + + {showIcon && } + AI: + {section.settings.label} + + ); } else return <>Unknown; }; diff --git a/app/subscriber/src/features/my-reports/edit/settings/ReportEditTemplateForm.tsx b/app/subscriber/src/features/my-reports/edit/settings/ReportEditTemplateForm.tsx index 890a524507..e3ff471932 100644 --- a/app/subscriber/src/features/my-reports/edit/settings/ReportEditTemplateForm.tsx +++ b/app/subscriber/src/features/my-reports/edit/settings/ReportEditTemplateForm.tsx @@ -16,6 +16,7 @@ import { useReportEditContext } from '../ReportEditContext'; import * as styled from './styled'; import { AddSectionBar, + ReportSectionAI, ReportSectionContent, ReportSectionData, ReportSectionGallery, @@ -219,6 +220,10 @@ export const ReportEditTemplateForm = () => { + {/* AI */} + + + )} diff --git a/app/subscriber/src/features/my-reports/edit/settings/template/AddSectionBar.tsx b/app/subscriber/src/features/my-reports/edit/settings/template/AddSectionBar.tsx index 7001042286..4d9b20b1ff 100644 --- a/app/subscriber/src/features/my-reports/edit/settings/template/AddSectionBar.tsx +++ b/app/subscriber/src/features/my-reports/edit/settings/template/AddSectionBar.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { BiSolidFileJson } from 'react-icons/bi'; import { FaAlignJustify, + FaBrain, FaChartPie, FaImage, FaImages, @@ -84,6 +85,15 @@ export const AddSectionBar = () => { +
Data = 6, + + /// + /// This section displays an AI summary + /// + AI = 7, } diff --git a/libs/net/entities/WorkOrderType.cs b/libs/net/entities/WorkOrderType.cs index fa16cd685b..89d292005d 100644 --- a/libs/net/entities/WorkOrderType.cs +++ b/libs/net/entities/WorkOrderType.cs @@ -24,4 +24,9 @@ public enum WorkOrderType /// A request to process file with FFmpeg. ///
FFmpeg = 3, + + /// + /// A request to generate clips and transcripts via the auto clipper pipeline. + /// + AutoClip = 4, } diff --git a/libs/net/kafka/Interfaces/IKafkaMessenger.cs b/libs/net/kafka/Interfaces/IKafkaMessenger.cs index 07f18a0219..58769e22de 100644 --- a/libs/net/kafka/Interfaces/IKafkaMessenger.cs +++ b/libs/net/kafka/Interfaces/IKafkaMessenger.cs @@ -36,6 +36,14 @@ public interface IKafkaMessenger /// public Task?> SendMessageAsync(string topic, TranscriptRequestModel request); + /// + /// Send a message to Kafka. + /// + /// + /// + /// + public Task?> SendMessageAsync(string topic, ClipRequestModel request); + /// /// Send a message to Kafka. /// diff --git a/libs/net/kafka/KafkaMessenger.cs b/libs/net/kafka/KafkaMessenger.cs index 5482a39ba9..65f711929c 100644 --- a/libs/net/kafka/KafkaMessenger.cs +++ b/libs/net/kafka/KafkaMessenger.cs @@ -92,6 +92,19 @@ public KafkaMessenger(IOptions serializerOptions, IOption return await SendMessageAsync(topic, $"{request.ContentId}", request); } + /// + /// Send a message to to Kafka. + /// + /// + /// + /// + public async Task?> SendMessageAsync(string topic, ClipRequestModel request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + + return await SendMessageAsync(topic, $"{request.ContentId}", request); + } + /// /// Send a message to to Kafka. /// diff --git a/libs/net/kafka/Models/ClipRequestModel.cs b/libs/net/kafka/Models/ClipRequestModel.cs new file mode 100644 index 0000000000..1e7da45cad --- /dev/null +++ b/libs/net/kafka/Models/ClipRequestModel.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using TNO.API.Areas.Services.Models.Content; +using TNO.Entities; + +namespace TNO.Kafka.Models; + +/// +/// ClipRequestModel class, provides a model for requesting automatic clip generation via Azure Video Analyzer. +/// +public class ClipRequestModel : WorkOrderModel +{ + #region Properties + /// + /// get/set - The content Id to process. + /// + public long ContentId { get; set; } + + /// + /// get/set - Preferred language for the transcript generation. + /// + public string Language { get; set; } = "en-US"; + #endregion + + #region Constructors + /// + /// Creates a new instance of a ClipRequestModel object. + /// + public ClipRequestModel() : base(WorkOrderType.AutoClip) { } + + /// + /// Creates a new instance of a ClipRequestModel object, initializes with specified parameters. + /// + /// + /// + /// + /// + /// + public ClipRequestModel(long workOrderId, long contentId, int? requestorId, string requestor, string language = "en-US") + : base(workOrderId, WorkOrderType.AutoClip, requestorId, requestor, DateTime.UtcNow) + { + this.ContentId = contentId; + if (!string.IsNullOrWhiteSpace(language)) this.Language = language; + } + + /// + /// Creates a new instance of a ClipRequestModel object for the specified content model. + /// + /// + /// + /// + /// + public ClipRequestModel(ContentModel content, int? requestorId, string requestor, string language = "en-US") + : this(0, content.Id, requestorId, requestor, language) + { + } + + /// + /// Creates a new instance of a ClipRequestModel object, initializes with specified parameters. + /// + /// + public ClipRequestModel(WorkOrder workOrder) : base(workOrder) + { + if (workOrder.ContentId.HasValue) + this.ContentId = workOrder.ContentId.Value; + else if (workOrder.Configuration.RootElement.TryGetProperty("contentId", out JsonElement element) && element.TryGetInt64(out long contentId)) + this.ContentId = contentId; + else throw new ArgumentException("Work order must be for an auto clipper request and contain 'contentId' property."); + + if (workOrder.Configuration.RootElement.TryGetProperty("language", out JsonElement languageElement) && languageElement.ValueKind == JsonValueKind.String) + { + var language = languageElement.GetString(); + if (!string.IsNullOrWhiteSpace(language)) this.Language = language!; + } + } + #endregion +} + diff --git a/libs/net/kafka/Models/TrascriptRequestModel.cs b/libs/net/kafka/Models/TranscriptRequestModel.cs similarity index 100% rename from libs/net/kafka/Models/TrascriptRequestModel.cs rename to libs/net/kafka/Models/TranscriptRequestModel.cs diff --git a/libs/net/models/Areas/Services/Models/Content/ContentTagModel.cs b/libs/net/models/Areas/Services/Models/Content/ContentTagModel.cs index da99b3e419..099e057459 100644 --- a/libs/net/models/Areas/Services/Models/Content/ContentTagModel.cs +++ b/libs/net/models/Areas/Services/Models/Content/ContentTagModel.cs @@ -47,6 +47,20 @@ public ContentTagModel(string code) this.Code = code; } + /// + /// Creates a new instance of an ContentTagModel, initializes with specified parameter. + /// + /// + /// + /// + /// + public ContentTagModel(int tagId, string code, string name) + { + this.Id = tagId; + this.Code = code; + this.Name = name; + } + /// /// Creates a new instance of an ContentTagModel, initializes with specified parameter. /// diff --git a/libs/net/models/Azure/ChatCompletionResponse.cs b/libs/net/models/Azure/ChatCompletionResponse.cs new file mode 100644 index 0000000000..5aa9ef56c1 --- /dev/null +++ b/libs/net/models/Azure/ChatCompletionResponse.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace TNO.Models.Azure; + +/// +/// ChatCompletionResponse class, provides a simple model for Azure AI chat complete response model. +/// +public class ChatCompletionResponse +{ + /// + /// get/set - + /// + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("object")] + public string Object { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("model")] + public string Model { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("created")] + public long Created { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("choices")] + public List Choices { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("usage")] + public Usage Usage { get; set; } = default!; +} diff --git a/libs/net/models/Azure/Choice.cs b/libs/net/models/Azure/Choice.cs new file mode 100644 index 0000000000..71a6769de6 --- /dev/null +++ b/libs/net/models/Azure/Choice.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace TNO.Models.Azure; + +/// +/// Choice class, provides a response from Azure AI. +/// +public class Choice +{ + /// + /// get/set - + /// + [JsonPropertyName("index")] + public int Index { get; set; } + + /// + /// get/set - + /// + [JsonPropertyName("finish_reason")] + public string FinishReason { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("message")] + public Message Message { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("content_filter_results")] + public object ContentFilterResults { get; set; } = default!; +} diff --git a/libs/net/models/Azure/Message.cs b/libs/net/models/Azure/Message.cs new file mode 100644 index 0000000000..86f0671563 --- /dev/null +++ b/libs/net/models/Azure/Message.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace TNO.Models.Azure; + +/// +/// Message class, provides the message from Azure AI response. +/// +public class Message +{ + /// + /// get/set - + /// + [JsonPropertyName("role")] + public string Role { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("content")] + public string Content { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("refusal")] + public object Refusal { get; set; } = default!; + + /// + /// get/set - + /// + [JsonPropertyName("annotations")] + public List Annotations { get; set; } = default!; +} diff --git a/libs/net/models/Azure/Usage.cs b/libs/net/models/Azure/Usage.cs new file mode 100644 index 0000000000..78cdca0b97 --- /dev/null +++ b/libs/net/models/Azure/Usage.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace TNO.Models.Azure; + +/// +/// Usage class, provides use information. +/// +public class Usage +{ + /// + /// get/set - + /// + [JsonPropertyName("prompt_tokens")] + public int PromptTokens { get; set; } + + /// + /// get/set - + /// + [JsonPropertyName("completion_tokens")] + public int CompletionTokens { get; set; } + + /// + /// get/set - + /// + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } +} diff --git a/libs/net/models/Settings/ReportSectionSettingsModel.cs b/libs/net/models/Settings/ReportSectionSettingsModel.cs index eb5ce367a5..076ed732a9 100644 --- a/libs/net/models/Settings/ReportSectionSettingsModel.cs +++ b/libs/net/models/Settings/ReportSectionSettingsModel.cs @@ -29,6 +29,12 @@ public class ReportSectionSettingsModel public string? DataType { get; set; } public string? DataProperty { get; set; } public string? DataTemplate { get; set; } + public string? DeploymentName { get; set; } + public string? SystemPrompt { get; set; } + public string? UserPrompt { get; set; } + public int? ChoiceIndex { get; set; } + public int? ChoiceQty { get; set; } + public float? Temperature { get; set; } #endregion #region Constructors @@ -58,6 +64,12 @@ public ReportSectionSettingsModel(Dictionary settings, JsonSeria this.DataType = settings.GetDictionaryJsonValue("dataType", null, options)!; this.DataProperty = settings.GetDictionaryJsonValue("dataProperty", null, options)!; this.DataTemplate = settings.GetDictionaryJsonValue("dataTemplate", null, options)!; + this.DeploymentName = settings.GetDictionaryJsonValue("deploymentName", null, options)!; + this.SystemPrompt = settings.GetDictionaryJsonValue("systemPrompt", null, options)!; + this.UserPrompt = settings.GetDictionaryJsonValue("userPrompt", null, options)!; + this.ChoiceIndex = settings.GetDictionaryJsonValue("choiceIndex", null, options)!; + this.ChoiceQty = settings.GetDictionaryJsonValue("choiceQty", null, options)!; + this.Temperature = settings.GetDictionaryJsonValue("temperature", null, options)!; } public ReportSectionSettingsModel(JsonDocument settings, JsonSerializerOptions options) @@ -84,6 +96,12 @@ public ReportSectionSettingsModel(JsonDocument settings, JsonSerializerOptions o this.DataType = settings.GetElementValue("dataType", null, options)!; this.DataProperty = settings.GetElementValue("dataProperty", null, options)!; this.DataTemplate = settings.GetElementValue("dataTemplate", null, options)!; + this.DeploymentName = settings.GetElementValue("deploymentName", null, options)!; + this.SystemPrompt = settings.GetElementValue("systemPrompt", null, options)!; + this.UserPrompt = settings.GetElementValue("userPrompt", null, options)!; + this.ChoiceIndex = settings.GetElementValue("choiceIndex", null, options)!; + this.ChoiceQty = settings.GetElementValue("choiceQty", null, options)!; + this.Temperature = settings.GetElementValue("temperature", null, options)!; } #endregion } diff --git a/libs/net/services/Helpers/ApiService.cs b/libs/net/services/Helpers/ApiService.cs index ca94b9ce2a..061c0154c9 100644 --- a/libs/net/services/Helpers/ApiService.cs +++ b/libs/net/services/Helpers/ApiService.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Text; using System.Text.Json; using FTTLib; using Microsoft.AspNetCore.WebUtilities; @@ -250,6 +249,14 @@ public async Task GetLookupsResponseWithEtagAsync(string et } #endregion + #region Tags + public async Task GetTagsResponseWithEtagAsync(string etag) + { + var url = this.Options.ApiUrl.Append($"editor/tags"); + return await RetryRequestAsync(async () => await this.OpenClient.GetAsync(url, etag)); + } + #endregion + #region Data Location Methods /// /// Make an HTTP request to the api to get the data location for the specified 'id'. diff --git a/libs/net/services/Helpers/IApiService.cs b/libs/net/services/Helpers/IApiService.cs index 96240deb1f..4be65e8c72 100644 --- a/libs/net/services/Helpers/IApiService.cs +++ b/libs/net/services/Helpers/IApiService.cs @@ -75,6 +75,10 @@ public interface IApiService public Task GetLookupsResponseWithEtagAsync(string etag); #endregion + #region Tags + Task GetTagsResponseWithEtagAsync(string etag); + #endregion + #region Sources /// /// Make a request to the API to fetch all sources. diff --git a/libs/net/template/Config/AzureAIOptions.cs b/libs/net/template/Config/AzureAIOptions.cs new file mode 100644 index 0000000000..1d37c88f2e --- /dev/null +++ b/libs/net/template/Config/AzureAIOptions.cs @@ -0,0 +1,39 @@ +namespace TNO.TemplateEngine.Config; + +/// +/// AzureAIOptions class, provides a way to configure Azure AI settings +/// +public class AzureAIOptions +{ + #region Properties + /// + /// get/set - The URL to the Azure AI API + /// + public Uri ProjectEndpoint { get; set; } = default!; + + /// + /// get/set - The API key. + /// + public string ApiKey { get; set; } = ""; + + /// + /// get/set - Name of the model deployment (i.e. gpt-5.1-chat) + /// + public string DefaultModelDeploymentName { get; set; } = ""; + + /// + /// get/set - Name of the deployed agent + /// + public string? DefaultAgentName { get; set; } + + /// + /// get/set - Default system prompt for report AI summary. + /// + public string? DefaultSystemPrompt { get; set; } + + /// + /// get/set - Default user prompt for report AI summary. + /// + public string? DefaultUserPrompt { get; set; } + #endregion +} diff --git a/libs/net/template/Config/AzureOptions.cs b/libs/net/template/Config/AzureOptions.cs new file mode 100644 index 0000000000..801e864c52 --- /dev/null +++ b/libs/net/template/Config/AzureOptions.cs @@ -0,0 +1,14 @@ +namespace TNO.TemplateEngine.Config; + +/// +/// AzureOptions class, provides a way to configure Azure settings. +/// +public class AzureOptions +{ + #region Properties + /// + /// get/set - Azure AI configuration settings. + /// + public AzureAIOptions? AI { get; set; } + #endregion +} diff --git a/libs/net/template/ReportEngine.cs b/libs/net/template/ReportEngine.cs index 567f894f97..a06eb95222 100644 --- a/libs/net/template/ReportEngine.cs +++ b/libs/net/template/ReportEngine.cs @@ -5,6 +5,7 @@ using System.Xml; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using TNO.Core.Exceptions; using TNO.Core.Extensions; using TNO.Core.Http; using TNO.TemplateEngine.Config; @@ -13,6 +14,8 @@ using TNO.TemplateEngine.Models.Charts.Options; using TNO.TemplateEngine.Models.Reports; +#pragma warning disable OPENAI001 + namespace TNO.TemplateEngine; /// @@ -57,6 +60,11 @@ public class ReportEngine : IReportEngine /// protected ChartsOptions ChartsOptions { get; } + /// + /// get - Azure options. + /// + protected AzureOptions AzureOptions { get; } + /// /// get - Serialization options. /// @@ -79,6 +87,7 @@ public class ReportEngine : IReportEngine /// /// /// + /// /// /// public ReportEngine( @@ -89,6 +98,7 @@ public ReportEngine( IHttpRequestClient httpClient, IOptions templateOptions, IOptions chartsOptions, + IOptions azureOptions, IOptions serializerOptions, ILogger logger) { @@ -99,6 +109,7 @@ public ReportEngine( this.HttpClient = httpClient; this.TemplateOptions = templateOptions.Value; this.ChartsOptions = chartsOptions.Value; + this.AzureOptions = azureOptions.Value; this.SerializerOptions = serializerOptions.Value; this.Logger = logger; } @@ -389,185 +400,13 @@ public async Task GenerateReportBodyAsync( // The viewOnWebOnly flag means the email will only include a link to the report. if (!viewOnWebOnly) { - if (!String.IsNullOrWhiteSpace(pathToFiles)) - { - var currentDate = DateTime.UtcNow; - // For each section that is an image from 3rd party, fetch the image and cache it. - await report.Sections - .Where(section => section.SectionType == Entities.ReportSectionType.Image && section.IsEnabled && section.Settings.CacheData == true) - .ForEachAsync(async section => - { - var sectionData = sectionContent[section.Name]; - var settings = section.Settings; - var url = !String.IsNullOrWhiteSpace(settings.Url) ? new Uri(settings.Url) : null; - if (url != null) - { - var response = await this.HttpClient.GetAsync(url); - if (response.IsSuccessStatusCode) - { - // Get the Content-Type header (e.g., "image/jpeg", "image/png") - var contentType = response.Content.Headers.ContentType?.MediaType; - var fileExtension = GetFileExtension(contentType) ?? ".bin"; - using (Stream imageStream = await response.Content.ReadAsStreamAsync()) - { - byte[] imageBytes; - using (var memoryStream = new MemoryStream()) - { - await imageStream.CopyToAsync(memoryStream, 81920); - imageBytes = memoryStream.ToArray(); - } + await GenerateReportImageSectionsAsync(report, sectionContent, pathToFiles); - var pathToImage = $"reports/{report.Id}-{report.Name}/image-{sectionData.Id}-{currentDate:yyyy-MM-dd}{fileExtension}"; - var fullPath = Path.Combine(pathToFiles, pathToImage); - var directory = Path.GetDirectoryName(fullPath); - if (!string.IsNullOrEmpty(directory)) - Directory.CreateDirectory(directory); - - await File.WriteAllBytesAsync(fullPath, imageBytes); - - // Update the section to include the new image. - sectionData.Settings.UrlCache = this.TemplateOptions.SubscriberAppUrl?.Append($"api/subscriber/contents/download?path={pathToImage}").AbsoluteUri; - } - } - else - this.Logger.LogError("Failed to fetch data from {url}, {status}", url, response.StatusCode); - } - }); - } + // Perform AI processes on the report. + await GenerateReportAISectionsAsync(report, sectionContent); // For each section that is JSON from 3rd party, fetch the JSON and save it. - await report.Sections - .Where(section => section.SectionType == Entities.ReportSectionType.Data && section.IsEnabled) - .ForEachAsync(async section => - { - var sectionData = sectionContent[section.Name]; - var settings = section.Settings; - var url = !String.IsNullOrWhiteSpace(settings.Url) ? new Uri(settings.Url) : null; - if (url != null) - { - var response = await this.HttpClient.GetAsync(url); - if (response.IsSuccessStatusCode) - { - if (settings.DataType?.Equals("json", StringComparison.InvariantCultureIgnoreCase) == true) - { - // The URL points to a JSON file. - try - { - var data = await response.Content.ReadAsStringAsync(); - var json = JsonDocument.Parse(data); - if (json != null && !String.IsNullOrWhiteSpace(settings.DataProperty)) - { - var value = json.GetElementValue(settings.DataProperty); - sectionData.Data = value; - } - else if (json != null && !String.IsNullOrWhiteSpace(settings.DataTemplate)) - { - var dataTemplateKey = $"{report.Id}-{section.Id}"; - var dataTemplate = this.ReportEngineData.GetOrAddTemplateInMemory(dataTemplateKey, settings.DataTemplate); - var dataEngineModel = new ReportEngineDataModel(json); - var dataHtml = await dataTemplate.RunAsync(instance => - { - instance.Model = dataEngineModel; - instance.Data = json; - }); - sectionData.Data = dataHtml; - } - else - sectionData.Data = data; - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to parse data from {url}", url); - sectionData.Data = ex.GetAllMessages(); - } - } - else if (settings.DataType?.Equals("xml", StringComparison.InvariantCultureIgnoreCase) == true) - { - // The URL points to an XML file. - try - { - var data = await response.Content.ReadAsStringAsync(); - var xml = new XmlDocument(); - xml.LoadXml(data); - if (xml != null && !String.IsNullOrWhiteSpace(settings.DataProperty)) - { - var node = xml.SelectSingleNode(settings.DataProperty); - if (node != null) - { - sectionData.Data = node.InnerText; - } - } - else if (xml != null && !String.IsNullOrWhiteSpace(settings.DataTemplate)) - { - var dataTemplateKey = $"{report.Id}-{section.Id}"; - var dataTemplate = this.ReportEngineData.GetOrAddTemplateInMemory(dataTemplateKey, settings.DataTemplate); - var dataEngineModel = new ReportEngineDataModel(xml); - var dataHtml = await dataTemplate.RunAsync(instance => - { - instance.Model = dataEngineModel; - instance.Data = xml; - }); - sectionData.Data = dataHtml; - } - else - sectionData.Data = data; - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to parse data from {url}", url); - sectionData.Data = ex.GetAllMessages(); - } - } - else if (settings.DataType?.Equals("csv", StringComparison.InvariantCultureIgnoreCase) == true) - { - // The URL points to a CSV file. - try - { - if (response.Content.Headers.ContentType?.MediaType?.Contains("text/csv") == true) - { - var data = await response.Content.ReadAsStreamAsync(); - using var reader = new StreamReader(data); - using var csv = new CsvHelper.CsvReader(reader, System.Globalization.CultureInfo.InvariantCulture); - var records = csv.GetRecords().ToList(); - var dataEngineModel = new ReportEngineDataModel(records); - if (!String.IsNullOrWhiteSpace(settings.DataTemplate)) - { - var dataTemplateKey = $"{report.Id}-{section.Id}"; - var dataTemplate = this.ReportEngineData.GetOrAddTemplateInMemory(dataTemplateKey, settings.DataTemplate); - var dataHtml = await dataTemplate.RunAsync(instance => - { - instance.Model = dataEngineModel; - instance.Data = records; - }); - sectionData.Data = dataHtml; - } - else - { - sectionData.Data = JsonSerializer.Serialize(records, this.SerializerOptions); - } - } - else - { - var data = await response.Content.ReadAsStringAsync(); - sectionData.Data = data; - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to parse CSV data from {url}", url); - sectionData.Data = ex.GetAllMessages(); - } - } - else - { - var data = await response.Content.ReadAsStringAsync(); - sectionData.Data = data; - } - } - else - this.Logger.LogError("Failed to fetch data from {url}, {status}", url, response.StatusCode); - } - }); + await GenerateReportDataSectionsAsync(report, sectionContent); } var model = new ReportEngineContentModel(report, reportInstance, sectionContent, this.TemplateOptions, pathToFiles); @@ -642,6 +481,360 @@ await section.ChartTemplates.ForEachAsync(async chart => return body.RemoveInvalidUtf8Characters().RemoveInvalidUnicodeCharacters(); } + /// + /// Generate the image section so that they can display image files. + /// + /// + /// + /// + /// + private async Task GenerateReportImageSectionsAsync( + API.Areas.Services.Models.Report.ReportModel report, + Dictionary sectionContent, + string? pathToFiles = null) + { + if (!String.IsNullOrWhiteSpace(pathToFiles)) + { + var currentDate = DateTime.UtcNow; + // For each section that is an image from 3rd party, fetch the image and cache it. + await report.Sections + .Where(section => section.SectionType == Entities.ReportSectionType.Image && section.IsEnabled && section.Settings.CacheData == true) + .ForEachAsync(async section => + { + var sectionData = sectionContent[section.Name]; + var settings = section.Settings; + var url = !String.IsNullOrWhiteSpace(settings.Url) ? new Uri(settings.Url) : null; + if (url != null) + { + var response = await this.HttpClient.GetAsync(url); + if (response.IsSuccessStatusCode) + { + // Get the Content-Type header (e.g., "image/jpeg", "image/png") + var contentType = response.Content.Headers.ContentType?.MediaType; + var fileExtension = GetFileExtension(contentType) ?? ".bin"; + using (Stream imageStream = await response.Content.ReadAsStreamAsync()) + { + byte[] imageBytes; + using (var memoryStream = new MemoryStream()) + { + await imageStream.CopyToAsync(memoryStream, 81920); + imageBytes = memoryStream.ToArray(); + } + + var pathToImage = $"reports/{report.Id}-{report.Name}/image-{sectionData.Id}-{currentDate:yyyy-MM-dd}{fileExtension}"; + var fullPath = Path.Combine(pathToFiles, pathToImage); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + await File.WriteAllBytesAsync(fullPath, imageBytes); + + // Update the section to include the new image. + sectionData.Settings.UrlCache = this.TemplateOptions.SubscriberAppUrl?.Append($"api/subscriber/contents/download?path={pathToImage}").AbsoluteUri; + } + } + else + this.Logger.LogError("Failed to fetch data from {url}, {status}", url, response.StatusCode); + } + }); + } + } + + + /// + /// Generate the data sections in the report. + /// + /// + /// + /// + private async Task GenerateReportDataSectionsAsync( + API.Areas.Services.Models.Report.ReportModel report, + Dictionary sectionContent) + { + await report.Sections + .Where(section => section.SectionType == Entities.ReportSectionType.Data && section.IsEnabled) + .ForEachAsync(async section => + { + var sectionData = sectionContent[section.Name]; + var settings = section.Settings; + var url = !String.IsNullOrWhiteSpace(settings.Url) ? new Uri(settings.Url) : null; + if (url != null) + { + var response = await this.HttpClient.GetAsync(url); + if (response.IsSuccessStatusCode) + { + if (settings.DataType?.Equals("json", StringComparison.InvariantCultureIgnoreCase) == true) + { + // The URL points to a JSON file. + try + { + var data = await response.Content.ReadAsStringAsync(); + var json = JsonDocument.Parse(data); + if (json != null && !String.IsNullOrWhiteSpace(settings.DataProperty)) + { + var value = json.GetElementValue(settings.DataProperty); + sectionData.Data = value; + } + else if (json != null && !String.IsNullOrWhiteSpace(settings.DataTemplate)) + { + var dataTemplateKey = $"{report.Id}-{section.Id}"; + var dataTemplate = this.ReportEngineData.GetOrAddTemplateInMemory(dataTemplateKey, settings.DataTemplate); + var dataEngineModel = new ReportEngineDataModel(json); + var dataHtml = await dataTemplate.RunAsync(instance => + { + instance.Model = dataEngineModel; + instance.Data = json; + }); + sectionData.Data = dataHtml; + } + else + sectionData.Data = data; + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to parse data from {url}", url); + sectionData.Data = ex.GetAllMessages(); + } + } + else if (settings.DataType?.Equals("xml", StringComparison.InvariantCultureIgnoreCase) == true) + { + // The URL points to an XML file. + try + { + var data = await response.Content.ReadAsStringAsync(); + var xml = new XmlDocument(); + xml.LoadXml(data); + if (xml != null && !String.IsNullOrWhiteSpace(settings.DataProperty)) + { + var node = xml.SelectSingleNode(settings.DataProperty); + if (node != null) + { + sectionData.Data = node.InnerText; + } + } + else if (xml != null && !String.IsNullOrWhiteSpace(settings.DataTemplate)) + { + var dataTemplateKey = $"{report.Id}-{section.Id}"; + var dataTemplate = this.ReportEngineData.GetOrAddTemplateInMemory(dataTemplateKey, settings.DataTemplate); + var dataEngineModel = new ReportEngineDataModel(xml); + var dataHtml = await dataTemplate.RunAsync(instance => + { + instance.Model = dataEngineModel; + instance.Data = xml; + }); + sectionData.Data = dataHtml; + } + else + sectionData.Data = data; + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to parse data from {url}", url); + sectionData.Data = ex.GetAllMessages(); + } + } + else if (settings.DataType?.Equals("csv", StringComparison.InvariantCultureIgnoreCase) == true) + { + // The URL points to a CSV file. + try + { + if (response.Content.Headers.ContentType?.MediaType?.Contains("text/csv") == true) + { + var data = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(data); + using var csv = new CsvHelper.CsvReader(reader, System.Globalization.CultureInfo.InvariantCulture); + var records = csv.GetRecords().ToList(); + var dataEngineModel = new ReportEngineDataModel(records); + if (!String.IsNullOrWhiteSpace(settings.DataTemplate)) + { + var dataTemplateKey = $"{report.Id}-{section.Id}"; + var dataTemplate = this.ReportEngineData.GetOrAddTemplateInMemory(dataTemplateKey, settings.DataTemplate); + var dataHtml = await dataTemplate.RunAsync(instance => + { + instance.Model = dataEngineModel; + instance.Data = records; + }); + sectionData.Data = dataHtml; + } + else + { + sectionData.Data = JsonSerializer.Serialize(records, this.SerializerOptions); + } + } + else + { + var data = await response.Content.ReadAsStringAsync(); + sectionData.Data = data; + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to parse CSV data from {url}", url); + sectionData.Data = ex.GetAllMessages(); + } + } + else + { + var data = await response.Content.ReadAsStringAsync(); + sectionData.Data = data; + } + } + else + this.Logger.LogError("Failed to fetch data from {url}, {status}", url, response.StatusCode); + } + }); + } + + /// + /// Generate the AI sections of the report. + /// + /// + /// + /// + /// + private async Task GenerateReportAISectionsAsync( + API.Areas.Services.Models.Report.ReportModel report, + Dictionary sectionContent) + { + var includesAI = report.Sections.Any(s => s.SectionType == Entities.ReportSectionType.AI && s.IsEnabled); + if (includesAI) + { + var projectEndpoint = this.AzureOptions.AI?.ProjectEndpoint; + var apiKey = this.AzureOptions.AI?.ApiKey; + var defaultAgentName = this.AzureOptions.AI?.DefaultAgentName; + + if (projectEndpoint == null) + { + this.Logger.LogError("Azure AI configuration 'Azure.AI.ProjectEndpoint' is required."); + } + else if (String.IsNullOrWhiteSpace(apiKey)) + { + this.Logger.LogError("Azure AI configuration 'Azure.AI.ApiKey' is required."); + } + else + { + var allContent = new StringBuilder("## Data\n### Sections"); + var serializer = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + foreach (var section in sectionContent.Where(sc => sc.Value.Content.Any())) + { + allContent.AppendLine($"#### {section.Value.Settings.Label}"); + var sectionContentJson = section.Value.Content.Select(c => new + { + headline = c.Headline, + text = !String.IsNullOrWhiteSpace(c.Body) ? c.Body : c.Summary, + byline = c.Byline, + source = c.Source?.Name ?? c.OtherSource, + publishedOn = c.PublishedOn, + mediaType = c.MediaType?.Name, + series = c.Series?.Name ?? c.OtherSeries, + sentiment = c.TonePools.FirstOrDefault()?.Value, + tags = c.Tags.Select(t => t.Code).ToArray(), + actions = c.Actions.Select(a => a.Name), + }).ToArray(); + allContent.AppendLine($"```json\n{JsonSerializer.Serialize(sectionContentJson, serializer)}\n```"); + } + + // Generate AI results. + await report.Sections + .Where(section => section.SectionType == Entities.ReportSectionType.AI && section.IsEnabled) + .ForEachAsync(async section => + { + var sectionData = sectionContent[section.Name]; + var settings = section.Settings; + var deploymentModelName = !String.IsNullOrWhiteSpace(settings.DeploymentName) ? settings.DeploymentName : this.AzureOptions.AI?.DefaultModelDeploymentName; + var systemPrompt = !String.IsNullOrWhiteSpace(settings.SystemPrompt) ? settings.SystemPrompt : this.AzureOptions.AI?.DefaultSystemPrompt; + var userPrompt = new StringBuilder(!String.IsNullOrWhiteSpace(settings.UserPrompt) ? settings.UserPrompt : this.AzureOptions.AI?.DefaultUserPrompt); + var choiceIndex = settings.ChoiceIndex; + var temperature = settings.Temperature; + var resultCount = settings.ChoiceQty; + + if (String.IsNullOrWhiteSpace(deploymentModelName)) + { + this.Logger.LogError("Azure AI deployment model name is required for report: {ReportId} and section: {SectionId}", report.Id, section.Settings.Label); + sectionData.Data = $"Azure AI deployment model name is required"; + } + else if (String.IsNullOrWhiteSpace(userPrompt.ToString())) + { + this.Logger.LogError("Azure AI user prompt is required for report: {ReportId} and section: {SectionId}", report.Id, section.Settings.Label); + sectionData.Data = $"Azure AI user prompt is required"; + } + else + { + this.Logger.LogDebug("Starting AI summary for section {Section}", section.Settings.Label); + var requestBody = new + { + model = deploymentModelName, + temperature = temperature, + n = resultCount, + messages = new object[] + { + new + { + role = "system", + content = new object[] { new { type = "text", text = systemPrompt }}, + }, + new + { + role = "user", + content = new object[] + { + new { type = "text", text = userPrompt.ToString() }, + new { type = "text", text = allContent.ToString() }, + } + } + } + }; + var jsonBody = JsonSerializer.Serialize(requestBody, serializer); + var requestMessage = new HttpRequestMessage(HttpMethod.Post, projectEndpoint); + requestMessage.Headers.Add("api-key", apiKey); + requestMessage.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + this.Logger.LogDebug("HTTP request made: {method}:{uri}", requestMessage.Method, requestMessage.RequestUri); + var response = await this.HttpClient.Client.SendAsync(requestMessage); + + if (response.IsSuccessStatusCode) + { + var responseJson = await response.Content.ReadAsStringAsync(); + var responseData = JsonSerializer.Deserialize(responseJson); + if (responseData != null) + { + if (choiceIndex.HasValue && choiceIndex.Value == -1) + { + // Return all choices. + var choices = new StringBuilder(); + for (var i = 0; i < responseData.Choices.Count; i++) + { + choices.AppendLine($"## Choice {i + 1}"); + choices.AppendLine(responseData.Choices[i].Message.Content); + } + } + else + { + var choice = responseData.Choices.Count > choiceIndex ? responseData.Choices[choiceIndex ?? 0] : responseData.Choices.FirstOrDefault(); + if (choice != null) + { + sectionData.Data = choice.Message.Content; + } + } + } + } + else + { + var responseJson = await response.Content.ReadAsStringAsync(); + var ex = new HttpClientRequestException(response); + this.Logger.LogError(ex, "Failed to generate AI response for report: {ReportId} and section: {SectionId}.", report.Id, section.Settings.Label); + sectionData.Data = $"{ex.GetAllMessages()}\n{responseJson}"; + } + } + }); + } + } + } + /// /// Each chart template has its own default settings. /// A section can override those setting options. diff --git a/libs/net/template/ServiceCollectionExtensions.cs b/libs/net/template/ServiceCollectionExtensions.cs index bfbd495a9c..7f70bd725b 100644 --- a/libs/net/template/ServiceCollectionExtensions.cs +++ b/libs/net/template/ServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ public static IServiceCollection AddTemplateEngine(this IServiceCollection servi return services .Configure(config.GetSection("Charts")) .Configure(config.GetSection("Reporting")) + .Configure(config.GetSection("Azure")) .AddScoped() .AddScoped, TemplateEngine>() .AddScoped, TemplateEngine>() @@ -40,6 +41,7 @@ public static IServiceCollection AddTemplateEngineSingleton(this IServiceCollect return services .Configure(config.GetSection("Charts")) .Configure(config.GetSection("Reporting")) + .Configure(config.GetSection("Azure")) .AddSingleton() .AddSingleton, TemplateEngine>() .AddSingleton, TemplateEngine>() diff --git a/libs/npm/core/.yarn/install-state.gz b/libs/npm/core/.yarn/install-state.gz index 8c29767829..d29681f7b2 100644 Binary files a/libs/npm/core/.yarn/install-state.gz and b/libs/npm/core/.yarn/install-state.gz differ diff --git a/libs/npm/core/package.json b/libs/npm/core/package.json index 9d299b795c..8afcdbcdd4 100644 --- a/libs/npm/core/package.json +++ b/libs/npm/core/package.json @@ -1,7 +1,7 @@ { "name": "tno-core", "description": "TNO shared library", - "version": "1.0.29", + "version": "1.0.31", "homepage": "https://github.com/bcgov/tno", "license": "Apache-2.0", "files": [ diff --git a/libs/npm/core/src/components/form/wysiwyg/Wysiwyg.tsx b/libs/npm/core/src/components/form/wysiwyg/Wysiwyg.tsx index 7c78b09bae..39146faabb 100644 --- a/libs/npm/core/src/components/form/wysiwyg/Wysiwyg.tsx +++ b/libs/npm/core/src/components/form/wysiwyg/Wysiwyg.tsx @@ -52,6 +52,8 @@ export interface IWysiwygProps { height?: string; /** className */ className?: string; + /** placeholder text */ + placeholder?: string; onKeyDown?: (event: React.KeyboardEvent) => void; onChange?: (text: string, editor?: any) => void; onBlur?: ( @@ -235,6 +237,7 @@ export const Wysiwyg: React.FC = (props) => { onBlur={props.onBlur} readOnly={props.disabled} onKeyDown={props.onKeyDown} + placeholder={props.placeholder} />