diff --git a/Prototyping/EnumExt50110.PVSCopilotCapabilities.al b/Prototyping/EnumExt50110.PVSCopilotCapabilities.al new file mode 100644 index 0000000..fc098b9 --- /dev/null +++ b/Prototyping/EnumExt50110.PVSCopilotCapabilities.al @@ -0,0 +1,7 @@ +enumextension 50110 "PVS Ticket Classifier Capability" extends "Copilot Capability" +{ + value(50110; "PVS Ticket Classifier") + { + Caption = 'PrintVis Ticket Classifier'; + } +} diff --git a/Prototyping/PVSSupportTools.Page.al b/Prototyping/PVSSupportTools.Page.al new file mode 100644 index 0000000..eaedadd --- /dev/null +++ b/Prototyping/PVSSupportTools.Page.al @@ -0,0 +1,59 @@ +/// +/// Standalone support tools page. Provides a central entry point for Copilot-assisted +/// support features when no suitable PrintVis Role Center is available to extend. +/// +page 50112 "PVS Support Tools" +{ + PageType = Card; + Caption = 'PVS Support Tools'; + ApplicationArea = All; + + layout + { + area(Content) + { + group(CopilotGroup) + { + Caption = 'Copilot Features'; + InstructionalText = 'Use the actions below to access AI-powered support tools for PrintVis.'; + } + } + } + + actions + { + area(Processing) + { + group(SupportGroup) + { + Caption = 'Support'; + + action(ClassifyTicket) + { + ApplicationArea = All; + Caption = 'Classify Support Ticket'; + Image = Sparkle; + ToolTip = 'Paste a support ticket description and let AI classify it into a standard PrintVis category.'; + + trigger OnAction() + begin + Page.RunModal(Page::"PVS Ticket Classifier"); + end; + } + action(ViewLog) + { + ApplicationArea = All; + Caption = 'View Classification Log'; + Image = Log; + ToolTip = 'View all previously classified support tickets.'; + RunObject = Page "PVS Ticket Classification Log"; + } + } + } + area(Promoted) + { + actionref(ClassifyTicket_Promoted; ClassifyTicket) { } + actionref(ViewLog_Promoted; ViewLog) { } + } + } +} diff --git a/Prototyping/PVSTicketCategory.Enum.al b/Prototyping/PVSTicketCategory.Enum.al new file mode 100644 index 0000000..bd998c2 --- /dev/null +++ b/Prototyping/PVSTicketCategory.Enum.al @@ -0,0 +1,44 @@ +/// +/// Defines the standard classification categories for PrintVis support tickets. +/// +enum 50110 "PVS Ticket Category" +{ + Extensible = false; + + value(0; Issue) + { + Caption = 'Issue'; + } + value(1; HowTo) + { + Caption = 'How To'; + } + value(2; EventRequest) + { + Caption = 'Event Request'; + } + value(3; Idea) + { + Caption = 'Idea'; + } + value(4; License) + { + Caption = 'License'; + } + value(5; BCRelated) + { + Caption = 'BC-Related'; + } + value(6; TechnicalQuestion) + { + Caption = 'Technical Question'; + } + value(7; ResourceRequest) + { + Caption = 'Resource Request'; + } + value(8; Other) + { + Caption = 'Other'; + } +} diff --git a/Prototyping/PVSTicketClassification.Table.al b/Prototyping/PVSTicketClassification.Table.al new file mode 100644 index 0000000..9b6c680 --- /dev/null +++ b/Prototyping/PVSTicketClassification.Table.al @@ -0,0 +1,56 @@ +/// +/// Stores the log of AI-classified support tickets. +/// +table 50110 "PVS Ticket Classification" +{ + Caption = 'PVS Ticket Classification'; + DataClassification = CustomerContent; + + fields + { + field(1; "Entry No."; Integer) + { + Caption = 'Entry No.'; + AutoIncrement = true; + } + field(2; "Ticket Text"; Blob) + { + Caption = 'Ticket Text'; + DataClassification = CustomerContent; + } + field(3; Category; Enum "PVS Ticket Category") + { + Caption = 'Category'; + } + field(4; Confidence; Integer) + { + Caption = 'Confidence'; + MinValue = 0; + MaxValue = 100; + } + field(5; Rationale; Text[2048]) + { + Caption = 'Rationale'; + } + field(6; "Suggested Action"; Text[1024]) + { + Caption = 'Suggested Action'; + } + field(7; "Classified At"; DateTime) + { + Caption = 'Classified At'; + } + field(8; "Classified By"; Code[50]) + { + Caption = 'Classified By'; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + } +} diff --git a/Prototyping/PVSTicketClassificationLog.Page.al b/Prototyping/PVSTicketClassificationLog.Page.al new file mode 100644 index 0000000..5ef0bab --- /dev/null +++ b/Prototyping/PVSTicketClassificationLog.Page.al @@ -0,0 +1,98 @@ +/// +/// Displays the log of all AI-classified support tickets. +/// +page 50111 "PVS Ticket Classification Log" +{ + PageType = List; + Caption = 'Ticket Classification Log'; + ApplicationArea = All; + SourceTable = "PVS Ticket Classification"; + Editable = false; + InsertAllowed = false; + ModifyAllowed = false; + + layout + { + area(Content) + { + repeater(LogLines) + { + field("Entry No."; Rec."Entry No.") + { + ApplicationArea = All; + ToolTip = 'The sequential log entry number.'; + } + field(Category; Rec.Category) + { + ApplicationArea = All; + StyleExpr = CategoryStyle; + ToolTip = 'The ticket category assigned by the AI classifier.'; + } + field(Confidence; Rec.Confidence) + { + ApplicationArea = All; + Caption = 'Confidence (%)'; + ToolTip = 'How confident the AI was in the classification (0–100).'; + } + field(Rationale; Rec.Rationale) + { + ApplicationArea = All; + ToolTip = 'Brief explanation of why the AI chose this category.'; + } + field("Suggested Action"; Rec."Suggested Action") + { + ApplicationArea = All; + ToolTip = 'Recommended next step for this ticket.'; + } + field("Classified At"; Rec."Classified At") + { + ApplicationArea = All; + ToolTip = 'Date and time when the ticket was classified.'; + } + field("Classified By"; Rec."Classified By") + { + ApplicationArea = All; + ToolTip = 'The user who classified the ticket.'; + } + } + } + } + + actions + { + area(Processing) + { + action(OpenClassifier) + { + ApplicationArea = All; + Caption = 'Open Classifier'; + Image = Sparkle; + ToolTip = 'Open the ticket classifier to classify a new support ticket.'; + + trigger OnAction() + begin + Page.RunModal(Page::"PVS Ticket Classifier"); + end; + } + } + area(Promoted) + { + actionref(OpenClassifier_Promoted; OpenClassifier) { } + } + } + + trigger OnAfterGetRecord() + begin + UpdateCategoryStyle(); + end; + + local procedure UpdateCategoryStyle() + var + TicketClassifierAI: Codeunit "PVS Ticket Classifier AI"; + begin + CategoryStyle := TicketClassifierAI.GetCategoryStyle(Rec.Category); + end; + + var + CategoryStyle: Text; +} diff --git a/Prototyping/PVSTicketClassifier.Page.al b/Prototyping/PVSTicketClassifier.Page.al new file mode 100644 index 0000000..63e379a --- /dev/null +++ b/Prototyping/PVSTicketClassifier.Page.al @@ -0,0 +1,175 @@ +/// +/// PromptDialog page that lets a support agent paste a raw ticket description +/// and have AI classify it into a standard PrintVis ticket category. +/// +page 50110 "PVS Ticket Classifier" +{ + PageType = PromptDialog; + Caption = 'Classify Support Ticket'; + ApplicationArea = All; + Extensible = false; + IsPreview = true; + PromptMode = Prompt; + + layout + { + area(Prompt) + { + field(TicketTextField; TicketText) + { + ApplicationArea = All; + Caption = 'Ticket Description'; + MultiLine = true; + NotBlank = true; + ToolTip = 'Paste the raw support ticket description here. The AI will classify it into a category and suggest a next action.'; + } + } + area(Content) + { + group(ResultGroup) + { + Caption = 'Classification Result'; + InstructionalText = 'Review the AI-generated classification below. Save to Log to record it, or Reclassify to adjust the input.'; + + field(CategoryFld; Category) + { + ApplicationArea = All; + Caption = 'Category'; + Editable = false; + StyleExpr = CategoryStyle; + ToolTip = 'The ticket category assigned by the AI classifier.'; + } + field(ConfidenceFld; Confidence) + { + ApplicationArea = All; + Caption = 'Confidence (%)'; + Editable = false; + ToolTip = 'How confident the AI is in the classification (0–100).'; + } + field(RationaleFld; Rationale) + { + ApplicationArea = All; + Caption = 'Rationale'; + MultiLine = true; + Editable = false; + ToolTip = 'Brief explanation of why the AI chose this category.'; + } + field(SuggestedActionFld; SuggestedAction) + { + ApplicationArea = All; + Caption = 'Suggested Action'; + MultiLine = true; + Editable = false; + ToolTip = 'Recommended next step for this ticket.'; + } + } + } + } + + actions + { + area(SystemActions) + { + systemaction(Generate) + { + Caption = 'Classify'; + ToolTip = 'Use AI to classify the ticket description.'; + + trigger OnAction() + begin + RunClassify(); + end; + } + systemaction(Regenerate) + { + Caption = 'Reclassify'; + ToolTip = 'Return to input mode to edit the ticket description and classify again.'; + + trigger OnAction() + begin + HasClassified := false; + Category := Enum::"PVS Ticket Category"::Other; + Confidence := 0; + Rationale := ''; + SuggestedAction := ''; + CategoryStyle := ''; + CurrPage.PromptMode := PromptMode::Prompt; + CurrPage.Update(false); + end; + } + systemaction(OK) + { + Caption = 'Save to Log'; + ToolTip = 'Save this classification result to the ticket classification log.'; + } + systemaction(Cancel) + { + Caption = 'Discard'; + ToolTip = 'Close without saving.'; + } + } + } + + trigger OnQueryClosePage(CloseAction: Action): Boolean + begin + if CloseAction = Action::OK then begin + if not HasClassified then + Error('Please use the Classify action to process the ticket before saving to the log.'); + SaveToLog(); + end; + exit(true); + end; + + local procedure RunClassify() + var + TicketClassifierAI: Codeunit "PVS Ticket Classifier AI"; + begin + if TicketText = '' then + Error('Please enter a ticket description before classifying.'); + + if not TicketClassifierAI.Classify(TicketText, Category, Confidence, Rationale, SuggestedAction) then + Error('Classification failed: %1', GetLastErrorText()); + + UpdateCategoryStyle(); + HasClassified := true; + CurrPage.PromptMode := PromptMode::Content; + CurrPage.Update(false); + end; + + local procedure SaveToLog() + var + TicketClassification: Record "PVS Ticket Classification"; + OutStr: OutStream; + begin + TicketClassification.Init(); + TicketClassification."Ticket Text".CreateOutStream(OutStr, TextEncoding::UTF8); + OutStr.WriteText(TicketText); + TicketClassification.Category := Category; + TicketClassification.Confidence := Confidence; + if StrLen(Rationale) > MaxStrLen(TicketClassification.Rationale) then + Message('Note: The AI rationale was longer than %1 characters and has been truncated.', MaxStrLen(TicketClassification.Rationale)); + TicketClassification.Rationale := CopyStr(Rationale, 1, MaxStrLen(TicketClassification.Rationale)); + if StrLen(SuggestedAction) > MaxStrLen(TicketClassification."Suggested Action") then + Message('Note: The AI suggested action was longer than %1 characters and has been truncated.', MaxStrLen(TicketClassification."Suggested Action")); + TicketClassification."Suggested Action" := CopyStr(SuggestedAction, 1, MaxStrLen(TicketClassification."Suggested Action")); + TicketClassification."Classified At" := CurrentDateTime(); + TicketClassification."Classified By" := CopyStr(UserId(), 1, MaxStrLen(TicketClassification."Classified By")); + TicketClassification.Insert(true); + end; + + local procedure UpdateCategoryStyle() + var + TicketClassifierAI: Codeunit "PVS Ticket Classifier AI"; + begin + CategoryStyle := TicketClassifierAI.GetCategoryStyle(Category); + end; + + var + TicketText: Text; + Category: Enum "PVS Ticket Category"; + Confidence: Integer; + Rationale: Text; + SuggestedAction: Text; + CategoryStyle: Text; + HasClassified: Boolean; +} diff --git a/Prototyping/PVSTicketClassifier.PermSet.al b/Prototyping/PVSTicketClassifier.PermSet.al new file mode 100644 index 0000000..6fdd0c3 --- /dev/null +++ b/Prototyping/PVSTicketClassifier.PermSet.al @@ -0,0 +1,12 @@ +permissionset 50110 "PVS Ticket Classifier" +{ + Caption = 'PVS Ticket Classifier'; + Assignable = true; + + Permissions = + tabledata "PVS Ticket Classification" = RIMD, + table "PVS Ticket Classification" = X, + codeunit "PVS Ticket Classifier AI" = X, + page "PVS Ticket Classifier" = X, + page "PVS Ticket Classification Log" = X; +} diff --git a/Prototyping/PVSTicketClassifierAI.Codeunit.al b/Prototyping/PVSTicketClassifierAI.Codeunit.al new file mode 100644 index 0000000..cdfe88c --- /dev/null +++ b/Prototyping/PVSTicketClassifierAI.Codeunit.al @@ -0,0 +1,147 @@ +/// +/// AI classification engine for PrintVis support tickets. +/// Calls Azure OpenAI via the BC Copilot toolkit to categorize a raw ticket description +/// and return a confidence score, rationale, and suggested next action. +/// +codeunit 50110 "PVS Ticket Classifier AI" +{ + Access = Public; + + /// + /// Classifies the supplied ticket text using Azure OpenAI. + /// Returns FALSE (and surfaces the error via GetLastErrorText) if the call fails. + /// + [TryFunction] + procedure Classify(TicketText: Text; var Category: Enum "PVS Ticket Category"; var Confidence: Integer; var Rationale: Text; var SuggestedAction: Text) + var + AzureOpenAI: Codeunit "Azure OpenAI"; + AOAIChatMessages: Codeunit "AOAI Chat Messages"; + AOAIChatCompletionParams: Codeunit "AOAI Chat Completion Params"; + AOAIOperationResponse: Codeunit "AOAI Operation Response"; + begin + if not AzureOpenAI.IsEnabled(Enum::"Copilot Capability"::"PVS Ticket Classifier") then + Error('The PVS Ticket Classifier Copilot capability is not enabled. Please enable it in Copilot & AI capabilities.'); + + AzureOpenAI.SetCopilotCapability(Enum::"Copilot Capability"::"PVS Ticket Classifier"); + + AOAIChatCompletionParams.SetMaxTokens(500); + AOAIChatCompletionParams.SetTemperature(0.2); + + AOAIChatMessages.AddSystemMessage(GetSystemPrompt()); + AOAIChatMessages.AddUserMessage(TicketText); + + AzureOpenAI.GenerateChatCompletion(AOAIChatMessages, AOAIChatCompletionParams, AOAIOperationResponse); + + if not AOAIOperationResponse.IsSuccess() then + Error('AI classification failed: %1', AOAIOperationResponse.GetError()); + + ParseResponse(AOAIChatMessages.GetLastMessage(), Category, Confidence, Rationale, SuggestedAction); + end; + + local procedure GetSystemPrompt(): Text + var + NL: Text[2]; + TypeHelper: Codeunit "Type Helper"; + begin + NL := TypeHelper.NewLine(); + exit( + 'You are a PrintVis support classification assistant.' + NL + + 'Given a raw support ticket description, classify it into exactly one of the following categories:' + NL + + ' "Issue" - Something that used to work or should work but does not.' + NL + + ' "How To" - Customer needs guidance on how to use an existing feature.' + NL + + ' "Event Request" - Request for a new BC event/publisher to be added to PrintVis.' + NL + + ' "Idea" - New functionality that does not exist yet.' + NL + + ' "License" - License activation, seat counts, expiry, or entitlement questions.' + NL + + ' "BC-Related" - Problem is in standard Business Central, not PrintVis-specific.' + NL + + ' "Technical Question"- Developer/partner asking about internals, API, or architecture.' + NL + + ' "Resource Request" - Request for documentation, training material, or sample code.' + NL + + ' "Other" - Does not fit the above; needs manual review.' + NL + + NL + + 'Respond ONLY with valid JSON in this exact format (no markdown, no extra text):' + NL + + '{' + NL + + ' "category": "", ' + NL + + ' "confidence": ,' + NL + + ' "rationale": "",' + NL + + ' "suggested_action": ""' + NL + + '}' + ); + end; + + local procedure ParseResponse(ResponseText: Text; var Category: Enum "PVS Ticket Category"; var Confidence: Integer; var Rationale: Text; var SuggestedAction: Text) + var + JsonObj: JsonObject; + JsonTok: JsonToken; + CleanedResponse: Text; + StartPos: Integer; + EndPos: Integer; + begin + StartPos := ResponseText.IndexOf('{'); + EndPos := ResponseText.LastIndexOf('}'); + if (StartPos > 0) and (EndPos > StartPos) then + CleanedResponse := ResponseText.Substring(StartPos, EndPos - StartPos + 1) + else + CleanedResponse := ResponseText; + + if not JsonObj.ReadFrom(CleanedResponse) then + Error('Could not parse AI response as JSON. Response: %1', ResponseText); + + if JsonObj.Get('category', JsonTok) then + Category := ParseCategory(JsonTok.AsValue().AsText()); + + if JsonObj.Get('confidence', JsonTok) then begin + Confidence := JsonTok.AsValue().AsInteger(); + if Confidence < 0 then + Confidence := 0; + if Confidence > 100 then + Confidence := 100; + end; + + if JsonObj.Get('rationale', JsonTok) then + Rationale := JsonTok.AsValue().AsText(); + + if JsonObj.Get('suggested_action', JsonTok) then + SuggestedAction := JsonTok.AsValue().AsText(); + end; + + /// + /// Returns the StyleExpr value for a given ticket category, used for consistent + /// color-coding across pages without duplicating the mapping logic. + /// + procedure GetCategoryStyle(Category: Enum "PVS Ticket Category"): Text + begin + case Category of + Enum::"PVS Ticket Category"::Issue: + exit('Unfavorable'); + Enum::"PVS Ticket Category"::EventRequest, + Enum::"PVS Ticket Category"::Idea: + exit('Favorable'); + else + exit('Ambiguous'); + end; + end; + + local procedure ParseCategory(CategoryText: Text): Enum "PVS Ticket Category" + begin + // Compare lower-cased to handle any AI response capitalization variations + case LowerCase(CategoryText) of + 'issue': + exit(Enum::"PVS Ticket Category"::Issue); + 'how to': + exit(Enum::"PVS Ticket Category"::HowTo); + 'event request': + exit(Enum::"PVS Ticket Category"::EventRequest); + 'idea': + exit(Enum::"PVS Ticket Category"::Idea); + 'license': + exit(Enum::"PVS Ticket Category"::License); + 'bc-related': + exit(Enum::"PVS Ticket Category"::BCRelated); + 'technical question': + exit(Enum::"PVS Ticket Category"::TechnicalQuestion); + 'resource request': + exit(Enum::"PVS Ticket Category"::ResourceRequest); + else + exit(Enum::"PVS Ticket Category"::Other); + end; + end; +} diff --git a/Prototyping/PVSTicketClassifierInstall.Codeunit.al b/Prototyping/PVSTicketClassifierInstall.Codeunit.al new file mode 100644 index 0000000..560054a --- /dev/null +++ b/Prototyping/PVSTicketClassifierInstall.Codeunit.al @@ -0,0 +1,25 @@ +/// +/// Registers the PVS Ticket Classifier Copilot capability on app install. +/// +codeunit 50111 "PVS Ticket Classifier Install" +{ + Subtype = Install; + Access = Internal; + + trigger OnInstallAppPerDatabase() + begin + RegisterCopilotCapability(); + end; + + local procedure RegisterCopilotCapability() + var + CopilotCapability: Codeunit "Copilot Capability"; + LearnMoreUrl: Text[2048]; + begin + LearnMoreUrl := 'https://www.printvis.com'; + if not CopilotCapability.IsCapabilityRegistered(Enum::"Copilot Capability"::"PVS Ticket Classifier") then + CopilotCapability.RegisterCapability( + Enum::"Copilot Capability"::"PVS Ticket Classifier", + LearnMoreUrl); + end; +}