Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,15 @@
using Unity.AI.Models;
using Unity.AI.Requests;
using Unity.AI.Runtime;
using Unity.GrantManager.Applications;
using Volo.Abp.DependencyInjection;

namespace Unity.AI.Operations
{
public class ApplicationAnalysisService(
IApplicationRepository applicationRepository,
IAIService aiService,
IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator) : IApplicationAnalysisService, ITransientDependency
{
public async Task<string> RegenerateAndSaveAsync(ApplicationAnalysisOperationInputDto input, CancellationToken cancellationToken = default)
public async Task<string> RegenerateAsync(ApplicationAnalysisOperationInputDto input, CancellationToken cancellationToken = default)
{
await aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(input.ApplicationId);

Expand All @@ -28,9 +26,6 @@ public async Task<string> RegenerateAndSaveAsync(ApplicationAnalysisOperationInp
}, cancellationToken);

var analysisJson = JsonSerializer.Serialize(analysis, AIJsonDefaults.Indented);
var application = await applicationRepository.GetAsync(input.ApplicationId);
application.AIAnalysis = analysisJson;
await applicationRepository.UpdateAsync(application);
return analysisJson;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,16 @@
using Unity.AI.Models;
using Unity.AI.Requests;
using Unity.AI.Runtime;
using Unity.GrantManager.Applications;
using Volo.Abp.DependencyInjection;

namespace Unity.AI.Operations
{
public class ApplicationScoringService(
IApplicationRepository applicationRepository,
IAIService aiService,
AIExecutionModeResolver executionModeResolver,
ILogger<ApplicationScoringService> logger) : IApplicationScoringService, ITransientDependency
{
public async Task<string> RegenerateAndSaveAsync(ApplicationScoringOperationInputDto input, CancellationToken cancellationToken = default)
public async Task<string> RegenerateAsync(ApplicationScoringOperationInputDto input, CancellationToken cancellationToken = default)
{
var sections = input.Sections;
var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.ApplicationScoringOperation);
Expand All @@ -42,9 +40,6 @@ public async Task<string> RegenerateAndSaveAsync(ApplicationScoringOperationInpu

var combinedResults = JsonSerializer.Serialize(allSectionResults, AIJsonDefaults.Indented);
var validatedJson = ValidateApplicationScoringJson(combinedResults);
var application = await applicationRepository.GetAsync(input.ApplicationId);
application.AIScoresheetAnswers = validatedJson;
await applicationRepository.UpdateAsync(application);
return validatedJson;
}

Expand Down Expand Up @@ -140,6 +135,12 @@ private static JsonElement BuildBatchSectionSchema(IReadOnlyCollection<Applicati
var questions = new List<JsonElement>();
foreach (var section in sections)
{
if (section.SectionSchema.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException(
$"Section schema for '{section.SectionName}' must be a JSON array.");
}

foreach (var question in section.SectionSchema.EnumerateArray())
{
questions.Add(question.Clone());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ namespace Unity.AI.Operations
{
public interface IApplicationAnalysisService
{
Task<string> RegenerateAndSaveAsync(ApplicationAnalysisOperationInputDto input, CancellationToken cancellationToken = default);
Task<string> RegenerateAsync(ApplicationAnalysisOperationInputDto input, CancellationToken cancellationToken = default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ namespace Unity.AI.Operations
{
public interface IApplicationScoringService
{
Task<string> RegenerateAndSaveAsync(ApplicationScoringOperationInputDto input, CancellationToken cancellationToken = default);
Task<string> RegenerateAsync(ApplicationScoringOperationInputDto input, CancellationToken cancellationToken = default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Unity.AI.Models;
using Unity.GrantManager.Applications;

Expand Down Expand Up @@ -91,7 +90,7 @@ public static List<AIAttachmentItem> BuildAttachmentSummaries(
.ToList();
}

public static object BuildFormFieldConfigurationAsync(
public static object BuildFormFieldConfiguration(
string? formSchema,
ILogger logger)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,19 @@ public enum AIOperationOutcome
InvalidOutput
}

public enum AIFailureCategory
{
None,
ProviderUnavailable,
TransientProviderFailure,
PermanentProviderFailure,
InvalidOutput
}

public sealed record AIOperationResult(
AIOperationOutcome Outcome,
AIProviderResult Response)
AIProviderResult Response,
AIFailureCategory FailureCategory = AIFailureCategory.None)
{
public string Content => Response.Content;

Expand All @@ -20,14 +30,30 @@ public static AIOperationResult Success(AIProviderResult? response = null) =>
new(AIOperationOutcome.Success, response ?? AIProviderResult.Empty);

public static AIOperationResult TransientFailure(AIProviderResult? response = null) =>
new(AIOperationOutcome.TransientFailure, response ?? AIProviderResult.Empty);
new(AIOperationOutcome.TransientFailure, response ?? AIProviderResult.Empty, AIFailureCategory.TransientProviderFailure);

public static AIOperationResult PermanentFailure(AIProviderResult? response = null) =>
new(AIOperationOutcome.PermanentFailure, response ?? AIProviderResult.Empty);
new(AIOperationOutcome.PermanentFailure, response ?? AIProviderResult.Empty, AIFailureCategory.PermanentProviderFailure);

public static AIOperationResult ProviderUnavailable(AIProviderResult? response = null) =>
new(AIOperationOutcome.PermanentFailure, response ?? AIProviderResult.Empty, AIFailureCategory.ProviderUnavailable);

public static AIOperationResult InvalidOutput(AIProviderResult? response = null) =>
new(AIOperationOutcome.InvalidOutput, response ?? AIProviderResult.Empty);
new(AIOperationOutcome.InvalidOutput, response ?? AIProviderResult.Empty, AIFailureCategory.InvalidOutput);

public AIOperationResult WithOutcome(AIOperationOutcome outcome, AIFailureCategory? failureCategory = null) =>
new(outcome, Response, failureCategory ?? ResolveFailureCategory(outcome));

public AIOperationResult WithOutcome(AIOperationOutcome outcome) => new(outcome, Response);
private static AIFailureCategory ResolveFailureCategory(AIOperationOutcome outcome)
{
return outcome switch
{
AIOperationOutcome.Success => AIFailureCategory.None,
AIOperationOutcome.TransientFailure => AIFailureCategory.TransientProviderFailure,
AIOperationOutcome.PermanentFailure => AIFailureCategory.PermanentProviderFailure,
AIOperationOutcome.InvalidOutput => AIFailureCategory.InvalidOutput,
_ => AIFailureCategory.None
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,94 +5,92 @@

namespace Unity.AI.Runtime
{
internal static class AIProviderPayloadValidator
public static class AIProviderPayloadValidator
{
public static bool IsValidAttachmentSummaryText(string response)
public static AIResponseValidationResult ValidateAttachmentSummaryText(string response)
{
return !string.IsNullOrWhiteSpace(response);
return !string.IsNullOrWhiteSpace(response)
? AIResponseValidationResult.Success()
: AIResponseValidationResult.Invalid("Attachment summary response was empty.");
}

public static bool IsValidApplicationAnalysisJson(string response)
public static AIResponseValidationResult ValidateApplicationAnalysisJson(string response)
{
if (!TryParseRootObject(response, out var root))
{
return false;
return AIResponseValidationResult.Invalid("Application analysis response was not valid JSON.");
}

if (!root.TryGetProperty(AIJsonKeys.Decision, out var decision) || decision.ValueKind != JsonValueKind.String)
{
return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Decision}' (expected string).");
}

if (!root.TryGetProperty(AIJsonKeys.Errors, out var errors) || errors.ValueKind != JsonValueKind.Array)
{
return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Errors}' (expected array).");
}

if (!root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) || warnings.ValueKind != JsonValueKind.Array)
{
return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Warnings}' (expected array).");
}

return HasStringProperty(root, AIJsonKeys.Decision) &&
HasArrayProperty(root, AIJsonKeys.Errors) &&
HasArrayProperty(root, AIJsonKeys.Warnings) &&
HasArrayProperty(root, AIJsonKeys.Summaries) &&
HasArrayProperty(root, AIJsonKeys.Recommendations);
if (!root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) || summaries.ValueKind != JsonValueKind.Array)
{
return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Summaries}' (expected array).");
}

if (!root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) || recommendations.ValueKind != JsonValueKind.Array)
{
return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Recommendations}' (expected array).");
}

return AIResponseValidationResult.Success();
}

public static bool IsValidApplicationScoringJson(string response, string sectionJson)
public static AIResponseValidationResult ValidateApplicationScoringJson(string response, string sectionJson)
{
if (!TryParseRootObject(response, out var root))
{
return false;
return AIResponseValidationResult.Invalid("Application scoring response was not valid JSON.");
}

var expectedQuestionIds = ExtractQuestionIds(sectionJson);
if (expectedQuestionIds.Count == 0)
{
return false;
return AIResponseValidationResult.Invalid("Application scoring section schema could not be parsed or did not contain any question ids.");
}

foreach (var questionId in expectedQuestionIds)
{
if (!TryGetRequiredObject(root, questionId, out var answerObject))
if (!root.TryGetProperty(questionId, out var answerObject) || answerObject.ValueKind != JsonValueKind.Object)
{
return false;
return AIResponseValidationResult.Invalid(
$"Application scoring response is missing required answer object for question id '{questionId}'.");
}

if (!HasPrimitiveProperty(answerObject, AIJsonKeys.Answer))
if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerValue)
|| answerValue.ValueKind == JsonValueKind.Null
|| answerValue.ValueKind == JsonValueKind.Object
|| answerValue.ValueKind == JsonValueKind.Array)
{
return false;
return AIResponseValidationResult.Invalid(
$"Application scoring response is missing a valid answer for question id '{questionId}'.");
}

if (!IsValidConfidenceProperty(answerObject, AIJsonKeys.Confidence))
if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue)
|| confidenceValue.ValueKind != JsonValueKind.Number
|| !confidenceValue.TryGetInt32(out var confidence)
|| confidence < 0
|| confidence > 100)
{
return false;
return AIResponseValidationResult.Invalid(
$"Application scoring response is missing a valid confidence score for question id '{questionId}'.");
}
}

return true;
}

private static bool HasStringProperty(JsonElement element, string name)
{
return element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.String;
}

private static bool HasArrayProperty(JsonElement element, string name)
{
return element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.Array;
}

private static bool TryGetRequiredObject(JsonElement element, string name, out JsonElement value)
{
return element.TryGetProperty(name, out value) &&
value.ValueKind == JsonValueKind.Object;
}

private static bool HasPrimitiveProperty(JsonElement element, string name)
{
return element.TryGetProperty(name, out var property) &&
property.ValueKind != JsonValueKind.Null &&
property.ValueKind != JsonValueKind.Object &&
property.ValueKind != JsonValueKind.Array;
}

private static bool IsValidConfidenceProperty(JsonElement element, string name)
{
return element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.Number &&
property.TryGetInt32(out var confidence) &&
confidence >= 0 &&
confidence <= 100;
return AIResponseValidationResult.Success();
}

private static HashSet<string> ExtractQuestionIds(string sectionJson)
Expand Down Expand Up @@ -169,5 +167,6 @@ private static bool TryParseRootObject(string response, out JsonElement root)
return false;
}
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Unity.AI.Runtime
{
public sealed record AIResponseValidationResult(
bool IsValid,
AIFailureCategory FailureCategory = AIFailureCategory.None,
string? Reason = null)
{
public static AIResponseValidationResult Success() =>
new(true);

public static AIResponseValidationResult Invalid(
string reason,
AIFailureCategory failureCategory = AIFailureCategory.InvalidOutput) =>
new(false, failureCategory, reason);
}
}
Loading
Loading