Skip to content
Merged

Dev #2139

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@ public class AIPromptCaptureResponse
[JsonPropertyName("userPrompt")]
public string UserPrompt { get; set; } = string.Empty;

[JsonPropertyName("rawOutput")]
public string RawOutput { get; set; } = string.Empty;

[JsonPropertyName("formattedOutput")]
public string FormattedOutput { get; set; } = string.Empty;
[JsonPropertyName("output")]
public string Output { get; set; } = string.Empty;

[JsonPropertyName("capturedAt")]
public DateTime CapturedAt { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Unity.GrantManager.AI
{
internal enum AIOperationOutcome
{
Success,
TransientFailure,
PermanentFailure,
InvalidOutput
}

internal sealed record AIOperationResult(
AIOperationOutcome Outcome,
AIProviderResponse Response)
{
public string Content => Response.Content;

public string CaptureOutput => Response.CaptureOutput;

public static AIOperationResult Success(AIProviderResponse? response = null) =>
new(AIOperationOutcome.Success, response ?? AIProviderResponse.Empty);

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

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

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

public AIOperationResult WithOutcome(AIOperationOutcome outcome) => new(outcome, Response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Unity.GrantManager.AI
{
internal sealed record AIProviderResponse(
string Content,
string RawResponse = "",
string? Model = null,
string? FinishReason = null,
int? PromptTokens = null,
int? CompletionTokens = null,
int? TotalTokens = null,
int? ReasoningTokens = null)
{
public static AIProviderResponse Empty { get; } = new(string.Empty);

public string CaptureOutput => string.IsNullOrWhiteSpace(RawResponse) ? Content : RawResponse;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Unity.GrantManager.AI
{
internal sealed record AIProviderResponseMetadata(
string? Model,
string? FinishReason,
int? PromptTokens,
int? CompletionTokens,
int? TotalTokens,
int? ReasoningTokens);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System;

namespace Unity.GrantManager.AI
{
internal static class AIResponseJson
{
public static string CleanJsonResponse(string response)
{
if (string.IsNullOrWhiteSpace(response))
{
return string.Empty;
}

var cleaned = response.Trim();

if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```"))
{
var startIndex = cleaned.IndexOf('\n');
if (startIndex >= 0)
{
cleaned = cleaned[(startIndex + 1)..];
}
else
{
var jsonStart = FindFirstJsonTokenIndex(cleaned);
if (jsonStart > 0)
{
cleaned = cleaned[jsonStart..];
}
}
}

if (cleaned.EndsWith("```", StringComparison.Ordinal))
{
var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal);
if (lastIndex > 0)
{
cleaned = cleaned[..lastIndex];
}
}

return cleaned.Trim();
}

private static int FindFirstJsonTokenIndex(string value)
{
var objectStart = value.IndexOf('{');
var arrayStart = value.IndexOf('[');

if (objectStart >= 0 && arrayStart >= 0)
{
return Math.Min(objectStart, arrayStart);
}

if (objectStart >= 0)
{
return objectStart;
}

return arrayStart;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Text.Json;

namespace Unity.GrantManager.AI
{
internal static class AIResponseValidator
{
public static bool IsValidAttachmentSummaryText(string response)
{
return !string.IsNullOrWhiteSpace(response);
}

public static bool IsValidApplicationAnalysisJson(string response)
{
if (!TryParseRootObject(response, out var root))
{
return false;
}

return root.TryGetProperty(AIJsonKeys.Rating, out var rating)
&& rating.ValueKind == JsonValueKind.String
&& root.TryGetProperty(AIJsonKeys.Errors, out var errors)
&& errors.ValueKind == JsonValueKind.Array
&& root.TryGetProperty(AIJsonKeys.Warnings, out var warnings)
&& warnings.ValueKind == JsonValueKind.Array
&& root.TryGetProperty(AIJsonKeys.Summaries, out var summaries)
&& summaries.ValueKind == JsonValueKind.Array
&& root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps)
&& nextSteps.ValueKind == JsonValueKind.Array;
}

public static bool IsValidScoresheetSectionJson(string response, string sectionJson)
{
if (!TryParseRootObject(response, out var root))
{
return false;
}

var expectedQuestionIds = ExtractQuestionIds(sectionJson);
if (expectedQuestionIds.Count == 0)
{
return false;
}

foreach (var questionId in expectedQuestionIds)
{
if (!root.TryGetProperty(questionId, out var answerObject) || answerObject.ValueKind != JsonValueKind.Object)
{
return false;
}

if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerValue)
|| answerValue.ValueKind == JsonValueKind.Null
|| answerValue.ValueKind == JsonValueKind.Object
|| answerValue.ValueKind == JsonValueKind.Array)
{
return false;
}

if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue)
|| confidenceValue.ValueKind != JsonValueKind.Number
|| !confidenceValue.TryGetInt32(out var confidence)
|| confidence < 0
|| confidence > 100)
{
return false;
}
}

return true;
}

private static HashSet<string> ExtractQuestionIds(string sectionJson)
{
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

try
{
using var jsonDoc = JsonDocument.Parse(sectionJson);
var root = jsonDoc.RootElement;

if (root.ValueKind == JsonValueKind.Array)
{
AddQuestionIds(root, ids);
return ids;
}

if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("questions", out var questionsElement) &&
questionsElement.ValueKind == JsonValueKind.Array)
{
AddQuestionIds(questionsElement, ids);
}
}
catch
{
return ids;
}

return ids;
}

private static void AddQuestionIds(JsonElement questionsArray, HashSet<string> ids)
{
foreach (var item in questionsArray.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object ||
!item.TryGetProperty("id", out var idProperty) ||
idProperty.ValueKind != JsonValueKind.String)
{
continue;
}

var id = idProperty.GetString();
if (!string.IsNullOrWhiteSpace(id))
{
ids.Add(id);
}
}
}

private static bool TryParseRootObject(string response, out JsonElement root)
{
root = default;

if (string.IsNullOrWhiteSpace(response))
{
return false;
}

try
{
using var jsonDoc = JsonDocument.Parse(AIResponseJson.CleanJsonResponse(response));
if (jsonDoc.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}

root = jsonDoc.RootElement.Clone();
return true;
}
catch
{
return false;
}
}

}
}
Loading
Loading