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;
+}