Added a SWOT analysis assistant (#1122)

Co-authored-by: hart_s3 <sabrina.hartmann@dlr.de>
This commit is contained in:
Thorsten Sommerandhart_s3 authored and GitHub committed 2026-10-28 08:51:30 +01:00
1 parent 6cb20fd75a
commit 060a2a5734
87 files changed
+4601 -45

No files matched your search

@@ -0,0 +1,176 @@
using System.Text.Json;
using AIStudio.Assistants.SWOTAnalysis;
using AIStudio.Chat;
using AIStudio.Tools;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotAnalysisContentPresentationTests
{
[Test]
public void PresentationSurvivesCloningAndWorkspaceSerialization()
{
var content = new ContentText
{
Text = "Complete SWOT analysis in Markdown",
Presentation = SwotAnalysisContentPresentation.Create(CreateResult()),
};
var clone = (ContentText)content.DeepClone();
var thread = new ChatThread
{
Blocks =
[
new ContentBlock
{
ContentType = ContentType.TEXT,
Role = ChatRole.AI,
Content = clone,
},
],
};
var json = JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS);
var restoredThread = JsonSerializer.Deserialize<ChatThread>(json, WorkspaceBehaviour.JSON_OPTIONS);
var restoredContent = (ContentText)restoredThread!.Blocks.Single().Content!;
var presentationWasRead = SwotAnalysisContentPresentation.TryRead(restoredContent.Presentation, out var restoredResult);
Assert.Multiple(() =>
{
Assert.That(clone.Presentation, Is.Not.SameAs(content.Presentation));
Assert.That(clone.Text, Is.EqualTo(content.Text));
Assert.That(restoredContent.Presentation?.Kind, Is.EqualTo(ContentPresentationKind.SWOT_ANALYSIS));
Assert.That(presentationWasRead, Is.True);
Assert.That(restoredResult.MatrixHeading, Is.EqualTo("SWOT Matrix"));
Assert.That(restoredResult.Strengths.Findings.Single().Summary, Is.EqualTo("Experienced team"));
Assert.That(restoredResult.PrioritizedActions.Items.Single().Action, Is.EqualTo("Prepare expansion"));
});
}
[Test]
public void InvalidPresentationDataFallsBackToMarkdown()
{
var content = new ContentText
{
Text = "Fallback Markdown",
Presentation = ContentPresentation.Create(ContentPresentationKind.SWOT_ANALYSIS, "not a SWOT result"),
};
var presentationWasRead = SwotAnalysisContentPresentation.TryRead(content.Presentation, out _);
Assert.Multiple(() =>
{
Assert.That(presentationWasRead, Is.False);
Assert.That(content.Text, Is.EqualTo("Fallback Markdown"));
});
}
[Test]
public void TextWithoutPresentationUsesMarkdown()
{
var content = new ContentText { Text = "Ordinary Markdown" };
Assert.That(SwotAnalysisContentPresentation.TryRead(content.Presentation, out _), Is.False);
}
[Test]
public void AnAnalysisWithoutFindingsAndActionsIsStillComplete()
{
var result = CreateResult();
foreach (var category in result.Categories)
{
category.Findings.Clear();
category.EmptyMessage = $"No evidence for {category.Label}.";
}
result.PrioritizedActions.Items.Clear();
result.PrioritizedActions.EmptyMessage = "No supported actions.";
var content = new ContentText { Presentation = SwotAnalysisContentPresentation.Create(result) };
var presentationWasRead = SwotAnalysisContentPresentation.TryRead(content.Presentation, out var readResult);
Assert.Multiple(() =>
{
Assert.That(presentationWasRead, Is.True);
Assert.That(readResult.Categories.All(category => category.Findings.Count == 0), Is.True);
Assert.That(readResult.PrioritizedActions.Items, Is.Empty);
});
}
[Test]
public void AStoredFindingWithoutAnExplanationFallsBackToMarkdown()
{
var result = CreateResult();
result.Strengths.Findings[0].Explanation = string.Empty;
var content = new ContentText { Presentation = SwotAnalysisContentPresentation.Create(result) };
Assert.That(SwotAnalysisContentPresentation.TryRead(content.Presentation, out _), Is.False);
}
[Test]
public void StoredDataWithANullFallsBackToMarkdown()
{
var data = JsonSerializer.SerializeToNode(CreateResult())!;
data["strengths"]!["findings"] = null;
var content = new ContentText
{
Presentation = new ContentPresentation
{
Kind = ContentPresentationKind.SWOT_ANALYSIS,
Data = JsonSerializer.SerializeToElement(data),
},
};
Assert.That(SwotAnalysisContentPresentation.TryRead(content.Presentation, out _), Is.False);
}
private static SwotAnalysisResult CreateResult() => new()
{
MatrixHeading = "SWOT Matrix",
InternalLabel = "Internal",
ExternalLabel = "External",
PositiveLabel = "Positive",
NegativeLabel = "Negative",
Strengths = Category("Strengths", "Experienced team", "The documents describe long employee tenure."),
Weaknesses = Category("Weaknesses", "Legacy system", "The documents name an unsupported application."),
Opportunities = Category("Opportunities", "Growing market", "The supplied forecast shows increasing demand."),
Threats = Category("Threats", "New competitor", "The material announces a funded market entrant."),
OverallAssessment = new()
{
Label = "Overall Assessment",
Text = "The position is solid.",
},
PrioritizedActions = new()
{
Label = "Prioritized Actions",
Items =
[
new()
{
Action = "Prepare expansion",
Rationale = "Use the team's experience to address demand.",
AddressedFactors = ["Experienced team", "Growing market"],
},
],
},
ModerationNotes = new()
{
Label = "Reconciliation of the Quadrants",
EmptyMessage = "The four categories needed no changes.",
},
};
private static SwotCategory Category(string label, string summary, string explanation) => new()
{
Label = label,
Findings =
[
new()
{
Summary = summary,
Explanation = explanation,
},
],
};
}
@@ -0,0 +1,113 @@
using System.Text.RegularExpressions;
using AIStudio.Assistants.SWOTAnalysis;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotAnalysisMarkdownFormatterTests
{
[Test]
public void EveryFindingAppearsInTheDetailAndInTheFinalMatrix()
{
var result = CreateResult();
for (var index = 2; index <= 6; index++)
result.Strengths.Findings.Add(new() { Summary = $"Strength {index}", Explanation = $"Evidence {index}" });
var markdown = SwotAnalysisMarkdownFormatter.Format(result);
Assert.Multiple(() =>
{
Assert.That(markdown, Does.Contain("| | Positive | Negative |"));
Assert.That(Count(markdown, "Experienced team"), Is.GreaterThanOrEqualTo(2));
Assert.That(Count(markdown, "Strength 6"), Is.EqualTo(2), "The matrix must not cap the number of findings.");
Assert.That(Count(markdown, "Evidence 6"), Is.EqualTo(1), "Explanations belong to the detailed section, not the matrix.");
Assert.That(markdown, Does.StartWith("## SWOT Matrix"));
Assert.That(markdown.IndexOf("## SWOT Matrix", StringComparison.Ordinal), Is.LessThan(markdown.IndexOf("## Strengths", StringComparison.Ordinal)));
Assert.That(markdown.IndexOf("## SWOT Matrix", StringComparison.Ordinal), Is.LessThan(markdown.IndexOf("## Prioritized Actions", StringComparison.Ordinal)));
Assert.That(markdown, Does.Contain("| **External** | **Opportunities:** Growing market | **Threats:** New competitor |"));
});
}
[Test]
public void TheSectionsFollowTheOrderOfTheResultView()
{
var result = CreateResult();
result.OverallAssessment.Text = "The position is solid.\n\nThe competitor is the main risk.";
result.ModerationNotes = new()
{
Label = "Reconciliation of the Quadrants",
Items = [new() { Change = "Moved the new competitor to the threats.", Reason = "It is external and negative." }],
};
var markdown = SwotAnalysisMarkdownFormatter.Format(result).ReplaceLineEndings("\n");
var positions = new[] { "## SWOT Matrix", "## Overall Assessment", "## Prioritized Actions", "## Strengths", "## Threats", "## Reconciliation of the Quadrants" }
.Select(heading => markdown.IndexOf(heading, StringComparison.Ordinal))
.ToList();
Assert.Multiple(() =>
{
Assert.That(positions, Is.All.GreaterThanOrEqualTo(0));
Assert.That(positions, Is.Ordered);
Assert.That(markdown, Does.Contain("The position is solid\\.\n\nThe competitor is the main risk\\."));
Assert.That(markdown, Does.EndWith("- **Moved the new competitor to the threats\\.** — It is external and negative\\."));
});
}
[Test]
public void MatrixCellsEscapePipesFromModelText()
{
var result = CreateResult();
result.Strengths.Findings[0].Summary = "Fast | reliable";
var markdown = SwotAnalysisMarkdownFormatter.Format(result);
Assert.That(markdown, Does.Contain("Fast \\| reliable"));
}
private static int Count(string value, string search) => Regex.Matches(value, Regex.Escape(search)).Count;
private static SwotAnalysisResult CreateResult() => new()
{
MatrixHeading = "SWOT Matrix",
InternalLabel = "Internal",
ExternalLabel = "External",
PositiveLabel = "Positive",
NegativeLabel = "Negative",
Strengths = Category("Strengths", "Experienced team", "The documents describe long employee tenure."),
Weaknesses = Category("Weaknesses", "Legacy system", "The documents name an unsupported application."),
Opportunities = Category("Opportunities", "Growing market", "The supplied forecast shows increasing demand."),
Threats = Category("Threats", "New competitor", "The material announces a funded market entrant."),
OverallAssessment = new()
{
Label = "Overall Assessment",
Text = "The position is solid.",
},
PrioritizedActions = new()
{
Label = "Prioritized Actions",
EmptyMessage = string.Empty,
Items =
[
new()
{
Action = "Prepare expansion",
Rationale = "Use the team's experience to address demand.",
AddressedFactors = ["Experienced team", "Growing market"],
},
],
},
ModerationNotes = new()
{
Label = "Reconciliation of the Quadrants",
EmptyMessage = "The four categories needed no changes.",
},
};
private static SwotCategory Category(string label, string summary, string explanation) => new()
{
Label = label,
EmptyMessage = string.Empty,
Findings = [new() { Summary = summary, Explanation = explanation }],
};
}
@@ -0,0 +1,209 @@
using System.Text.Json;
using AIStudio.Assistants.SWOTAnalysis;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotAnalysisModerationTests
{
[Test]
public void AValidAnswerIsAssembledIntoTheResult()
{
var parsed = SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(CreateModeration()), out var moderation, out var issue);
var result = SwotAnalysisModeration.Assemble(moderation);
Assert.Multiple(() =>
{
Assert.That(parsed, Is.True);
Assert.That(issue, Is.Empty);
Assert.That(result.Strengths.Label, Is.EqualTo("Stärken"));
Assert.That(result.Strengths.Findings.Single().Summary, Is.EqualTo("Experienced team"));
Assert.That(result.Threats.Findings.Single().Explanation, Is.EqualTo("The material announces a funded market entrant."));
Assert.That(result.OverallAssessment.Paragraphs, Is.EqualTo(new[] { "The position is solid.", "The competitor is the main risk." }));
Assert.That(result.ModerationNotes.Items.Single().Change, Is.EqualTo("Merged two strengths about the team."));
Assert.That(result.PrioritizedActions.Items.Single().AddressedFactors, Is.EqualTo(new[] { "Experienced team", "Growing market" }));
});
}
[Test]
public void FactorIdsAreMatchedWithoutCaseSensitivity()
{
var moderation = CreateModeration();
moderation.PrioritizedActions.Items.Single().AddressedFactorIds = ["s1", "o1"];
var parsed = SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(moderation), out var parsedModeration, out _);
Assert.Multiple(() =>
{
Assert.That(parsed, Is.True);
Assert.That(SwotAnalysisModeration.Assemble(parsedModeration).PrioritizedActions.Items.Single().AddressedFactors, Is.EqualTo(new[] { "Experienced team", "Growing market" }));
});
}
[Test]
public void AFactorIdOfAnotherCategoryIsRejected()
{
var moderation = CreateModeration();
moderation.Strengths.Findings.Single().FactorId = "W2";
Assert.Multiple(() =>
{
Assert.That(SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(moderation), out _, out var issue), Is.False);
Assert.That(issue, Is.EqualTo("Finding 1 of strengths has the factor_id \"W2\", which does not start with \"S\"."));
});
}
[Test]
public void AFactorIdUsedTwiceIsRejected()
{
var moderation = CreateModeration();
moderation.Strengths.Findings.Add(Finding("s1", "Loyal customers", "The survey shows long customer relationships."));
Assert.Multiple(() =>
{
Assert.That(SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(moderation), out _, out var issue), Is.False);
Assert.That(issue, Does.Contain("\"s1\" is used more than once"));
});
}
[Test]
public void AnActionForAnUnknownFactorIsRejected()
{
var moderation = CreateModeration();
moderation.PrioritizedActions.Items.Single().AddressedFactorIds = ["S1", "X9"];
Assert.Multiple(() =>
{
Assert.That(SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(moderation), out _, out var issue), Is.False);
Assert.That(issue, Does.Contain("\"X9\""));
});
}
[Test]
public void AnEmptyOverallAssessmentIsRejected()
{
var moderation = CreateModeration();
moderation.OverallAssessment.Text = " ";
Assert.Multiple(() =>
{
Assert.That(SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(moderation), out _, out var issue), Is.False);
Assert.That(issue, Is.EqualTo("The field overall_assessment.text is empty."));
});
}
[Test]
public void NotesWithoutChangesExplainWhy()
{
var withoutReason = CreateModeration();
withoutReason.ModerationNotes.Items.Clear();
var withReason = CreateModeration();
withReason.ModerationNotes.Items.Clear();
withReason.ModerationNotes.EmptyMessage = "The four categories needed no changes.";
Assert.Multiple(() =>
{
Assert.That(SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(withoutReason), out _, out var issue), Is.False);
Assert.That(issue, Does.StartWith("moderation_notes has no items"));
Assert.That(SwotAnalysisModerationResponseParser.TryParse(JsonSerializer.Serialize(withReason), out _, out _), Is.True);
});
}
[Test]
public void AnAnswerWithoutJsonNamesTheProblemForTheModel()
{
Assert.Multiple(() =>
{
Assert.That(SwotAnalysisModerationResponseParser.TryParse("Here is my assessment.", out _, out var issue), Is.False);
Assert.That(issue, Does.Contain("no complete JSON object"));
});
}
[Test]
public void TheRequestHoldsTheFourCategoriesTheGoalAndTheResearch()
{
var request = SwotAnalysisModeration.BuildUserRequest(CreateCategories(), "Support the investment decision", string.Empty, "A market report documents increasing demand.");
Assert.Multiple(() =>
{
foreach (var category in new[] { "strengths", "weaknesses", "opportunities", "threats" })
Assert.That(request, Does.Contain($"\"{category}\""));
Assert.That(request, Does.Contain("\"factor_id\": \"S1\""));
Assert.That(request, Does.Contain("\"factor_id\": \"O1\""));
Assert.That(request, Does.Contain("The material names no threats."));
Assert.That(request, Does.Match("(?s)<analysis-goal>\\s*Support the investment decision\\s*</analysis-goal>"));
Assert.That(request, Does.Match("(?s)<analysis-focus>\\s*No additional analysis focus was provided\\.\\s*</analysis-focus>"));
Assert.That(request, Does.Match("(?s)<shared-research>\\s*A market report documents increasing demand\\.\\s*</shared-research>"));
Assert.That(request, Does.Not.Contain("<analysis-subject>"), "The moderator works on the results, not on the material.");
});
}
[Test]
public void TheRequestOmitsEmptyResearch() =>
Assert.That(SwotAnalysisModeration.BuildUserRequest(CreateCategories(), "Support the investment decision", string.Empty), Does.Not.Contain("<shared-research>"));
[Test]
public void ThePromptAsksForTheWholeModeratorSchema()
{
var prompt = SwotAnalysisModeration.BuildSystemPrompt("Answer in German.");
Assert.Multiple(() =>
{
foreach (var key in new[] { "\"moderation_notes\"", "\"overall_assessment\"", "\"prioritized_actions\"", "\"addressed_factor_ids\"", "\"factor_id\"" })
Assert.That(prompt, Does.Contain(key));
Assert.That(prompt, Does.EndWith("Answer in German."));
});
}
private static Dictionary<SwotAnalysisCategory, SwotCategoryAnalysisResult> CreateCategories() => new()
{
[SwotAnalysisCategory.STRENGTHS] = new() { Findings = [new() { Summary = "Experienced team", Explanation = "The documents describe long employee tenure." }] },
[SwotAnalysisCategory.WEAKNESSES] = new() { Findings = [new() { Summary = "Legacy system", Explanation = "The documents name an unsupported application." }] },
[SwotAnalysisCategory.OPPORTUNITIES] = new() { Findings = [new() { Summary = "Growing market", Explanation = "The supplied forecast shows increasing demand." }] },
[SwotAnalysisCategory.THREATS] = new() { EmptyMessage = "The material names no threats." },
};
private static SwotModerationResult CreateModeration() => new()
{
MatrixHeading = "SWOT-Matrix",
InternalLabel = "Intern",
ExternalLabel = "Extern",
PositiveLabel = "Positiv",
NegativeLabel = "Negativ",
Strengths = Category("Stärken", Finding("S1", "Experienced team", "The documents describe long employee tenure.")),
Weaknesses = Category("Schwächen", Finding("W1", "Legacy system", "The documents name an unsupported application.")),
Opportunities = Category("Chancen", Finding("O1", "Growing market", "The supplied forecast shows increasing demand.")),
Threats = Category("Risiken", Finding("T1", "New competitor", "The material announces a funded market entrant.")),
ModerationNotes = new()
{
Label = "Abgleich der Quadranten",
Items = [new() { Change = "Merged two strengths about the team.", Reason = "Both described the same tenure." }],
},
OverallAssessment = new()
{
Label = "Gesamtbewertung",
Text = "The position is solid.\n\nThe competitor is the main risk.",
},
PrioritizedActions = new()
{
Label = "Maßnahmen",
Items = [new() { Action = "Prepare expansion", Rationale = "Use the team's experience to address demand.", AddressedFactorIds = ["S1", "O1"] }],
},
};
private static SwotModeratedCategory Category(string label, SwotModeratedFinding finding) => new()
{
Label = label,
Findings = [finding],
};
private static SwotModeratedFinding Finding(string factorId, string summary, string explanation) => new()
{
FactorId = factorId,
Summary = summary,
Explanation = explanation,
};
}
@@ -0,0 +1,107 @@
using AIStudio.Assistants.SWOTAnalysis;
using AIStudio.Chat;
using AIStudio.Provider;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotAnalysisSendToChatTests
{
private const string ADDRESS_A_MODEL_CHOSE = "https://research.example.org/report";
[Test]
public void TheChatGetsTheVisibleRequestAndTheVisibleResultOnly()
{
var assistantThread = CreateAssistantThread();
var result = assistantThread.Blocks.Last();
var visiblePrompt = VisiblePrompt(result.Time.AddTicks(-1));
var chatThread = AssistantSWOTAnalysis.CreateChatThreadForResult(assistantThread, visiblePrompt, result);
Assert.Multiple(() =>
{
Assert.That(chatThread.Blocks, Has.Count.EqualTo(2));
Assert.That(chatThread.Blocks[0], Is.SameAs(visiblePrompt));
Assert.That(chatThread.Blocks[1].Role, Is.EqualTo(ChatRole.AI));
Assert.That(chatThread.Blocks[1].HideFromUser, Is.False);
Assert.That(((ContentText)chatThread.Blocks[1].Content!).Text, Is.EqualTo("## SWOT Matrix"));
Assert.That(chatThread.Blocks[1].Content, Is.Not.SameAs(result.Content));
Assert.That(chatThread.SystemPrompt, Is.EqualTo(SystemPrompts.DEFAULT));
});
}
[Test]
public void AnAddressAModelWroteIsNotGivenInTheChat()
{
var assistantThread = CreateAssistantThread();
var result = assistantThread.Blocks.Last();
var chatThread = AssistantSWOTAnalysis.CreateChatThreadForResult(assistantThread, VisiblePrompt(result.Time.AddTicks(-1)), result);
Assert.Multiple(() =>
{
Assert.That(assistantThread.IsWebAddressGivenToTheModel(new Uri(ADDRESS_A_MODEL_CHOSE)), Is.True, "The hidden request counts as a message of the user.");
Assert.That(chatThread.IsWebAddressGivenToTheModel(new Uri(ADDRESS_A_MODEL_CHOSE)), Is.False);
});
}
[Test]
public void TheChatKeepsTheIdAndTheRestrictionsOfTheAssistant()
{
var assistantThread = CreateAssistantThread();
assistantThread.RequireProviderConfidence(ConfidenceLevel.HIGH);
var result = assistantThread.Blocks.Last();
var chatThread = AssistantSWOTAnalysis.CreateChatThreadForResult(assistantThread, VisiblePrompt(result.Time.AddTicks(-1)), result);
Assert.Multiple(() =>
{
Assert.That(chatThread.ChatId, Is.EqualTo(assistantThread.ChatId));
Assert.That(chatThread.RequiredProviderConfidence, Is.EqualTo(ConfidenceLevel.HIGH));
});
}
[Test]
public void WithoutAResultTheChatStartsEmpty()
{
var chatThread = AssistantSWOTAnalysis.CreateChatThreadForResult(CreateAssistantThread(), null, null);
Assert.That(chatThread.Blocks, Is.Empty);
}
private static ChatThread CreateAssistantThread()
{
var time = DateTimeOffset.Now;
return new ChatThread
{
ChatId = Guid.NewGuid(),
Blocks =
[
new ContentBlock
{
Time = time,
Role = ChatRole.USER,
ContentType = ContentType.TEXT,
HideFromUser = true,
Content = new ContentText { Text = $"The opportunities rest on {ADDRESS_A_MODEL_CHOSE}." },
},
new ContentBlock
{
Time = time.AddSeconds(1),
Role = ChatRole.AI,
ContentType = ContentType.TEXT,
HideFromUser = true,
Content = new ContentText { Text = "## SWOT Matrix" },
},
],
};
}
private static ContentBlock VisiblePrompt(DateTimeOffset time) => new()
{
Time = time,
Role = ChatRole.USER,
ContentType = ContentType.TEXT,
Content = new ContentText { Text = "Create a SWOT analysis of my content" },
};
}
@@ -0,0 +1,60 @@
using AIStudio.Assistants.SWOTAnalysis;
using AIStudio.Tools.AssistantSessions;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotAnalysisSessionStateTests
{
[Test]
public void TheStructuredResultSurvivesAnAssistantSessionSnapshot()
{
var key = new AssistantSessionStateKey<SwotAnalysisResult?>("analysisResult");
var writer = new AssistantSessionStateWriter();
writer.Set(key, new SwotAnalysisResult
{
MatrixHeading = "SWOT Matrix",
Strengths = new() { Findings = [Finding()] },
});
SwotAnalysisResult? restored = null;
var reader = new AssistantSessionStateReader(writer.ToDictionary(), "SWOT Analysis");
reader.Restore(key, value => restored = value);
Assert.Multiple(() =>
{
Assert.That(restored, Is.Not.Null);
Assert.That(restored!.MatrixHeading, Is.EqualTo("SWOT Matrix"));
Assert.That(restored.Strengths.Findings.Single().Summary, Is.EqualTo("Experienced team"));
});
}
[Test]
public void CategoryProgressSurvivesAnAssistantSessionSnapshot()
{
var key = new AssistantSessionStateKey<Dictionary<SwotAnalysisCategory, SwotCategoryAnalysisProgress>>("categoryAnalysisProgress");
var writer = new AssistantSessionStateWriter();
writer.SetDictionary(key, new Dictionary<SwotAnalysisCategory, SwotCategoryAnalysisProgress>
{
[SwotAnalysisCategory.STRENGTHS] = new(SwotCategoryAnalysisStatus.COMPLETED, new SwotCategoryAnalysisResult { Findings = [Finding()] }),
[SwotAnalysisCategory.WEAKNESSES] = new(SwotCategoryAnalysisStatus.RUNNING),
});
Dictionary<SwotAnalysisCategory, SwotCategoryAnalysisProgress> restored = [];
var reader = new AssistantSessionStateReader(writer.ToDictionary(), "SWOT Analysis");
reader.RestoreDictionary(key, restored);
Assert.Multiple(() =>
{
Assert.That(restored[SwotAnalysisCategory.STRENGTHS].Status, Is.EqualTo(SwotCategoryAnalysisStatus.COMPLETED));
Assert.That(restored[SwotAnalysisCategory.STRENGTHS].Result!.Findings.Single().Summary, Is.EqualTo("Experienced team"));
Assert.That(restored[SwotAnalysisCategory.WEAKNESSES].Status, Is.EqualTo(SwotCategoryAnalysisStatus.RUNNING));
});
}
private static SwotFinding Finding() => new()
{
Summary = "Experienced team",
Explanation = "The documents describe long employee tenure.",
};
}
@@ -0,0 +1,181 @@
using System.Text.Json;
using AIStudio.Assistants.SWOTAnalysis;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotCategoryAnalysisResponseTests
{
[Test]
public void TheFindingsOfACategoryAreReadAndTrimmed()
{
var result = CreateResult();
result.Findings[0].Summary = " Experienced team ";
var parsed = SwotCategoryAnalysisResponseParser.TryParse(JsonSerializer.Serialize(result), out var parsedResult, out var issue);
Assert.Multiple(() =>
{
Assert.That(parsed, Is.True);
Assert.That(issue, Is.Empty);
Assert.That(parsedResult.EmptyMessage, Is.Empty);
Assert.That(parsedResult.Findings.Single().Summary, Is.EqualTo("Experienced team"));
});
}
[Test]
public void AnAnswerWithoutJsonNamesTheProblemForTheModel()
{
var parsed = SwotCategoryAnalysisResponseParser.TryParse("I cannot analyze this category.", out _, out var issue);
Assert.Multiple(() =>
{
Assert.That(parsed, Is.False);
Assert.That(issue, Does.Contain("no complete JSON object"));
});
}
[Test]
public void BrokenJsonNamesTheProblemForTheModel()
{
var parsed = SwotCategoryAnalysisResponseParser.TryParse("""{"findings": [{"summary": "Experienced team",}""", out _, out var issue);
Assert.Multiple(() =>
{
Assert.That(parsed, Is.False);
Assert.That(issue, Is.Not.Empty);
});
}
[Test]
public void ANullFieldIsRejectedRatherThanReadAsEmpty()
{
var parsed = SwotCategoryAnalysisResponseParser.TryParse("""{"empty_message": null, "findings": []}""", out _, out var issue);
Assert.Multiple(() =>
{
Assert.That(parsed, Is.False);
Assert.That(issue, Does.StartWith("The JSON object cannot be read:"));
});
}
[Test]
public void ANullFindingIsLeftOut()
{
var parsed = SwotCategoryAnalysisResponseParser.TryParse("""{"empty_message": "", "findings": [null, {"summary": "Experienced team", "explanation": "Long tenure."}]}""", out var result, out _);
Assert.Multiple(() =>
{
Assert.That(parsed, Is.True);
Assert.That(result.Findings.Single().Summary, Is.EqualTo("Experienced team"));
});
}
[Test]
public void AnUnsupportedCategoryRequiresAnExplanation()
{
var validEmptyResponse = JsonSerializer.Serialize(new SwotCategoryAnalysisResult
{
EmptyMessage = "The supplied material contains no evidence for this category.",
});
var invalidEmptyResponse = JsonSerializer.Serialize(new SwotCategoryAnalysisResult());
Assert.Multiple(() =>
{
Assert.That(SwotCategoryAnalysisResponseParser.TryParse(validEmptyResponse, out var result, out _), Is.True);
Assert.That(result.Findings, Is.Empty);
Assert.That(SwotCategoryAnalysisResponseParser.TryParse(invalidEmptyResponse, out _, out var issue), Is.False);
Assert.That(issue, Does.Contain("empty_message"));
});
}
[Test]
public void FindingsRejectAnEmptyMessageOrMissingEvidenceExplanation()
{
var result = CreateResult();
result.EmptyMessage = "No evidence.";
var responseWithContradictoryEmptyMessage = JsonSerializer.Serialize(result);
result.EmptyMessage = string.Empty;
result.Findings[0].Explanation = string.Empty;
var responseWithoutExplanation = JsonSerializer.Serialize(result);
Assert.Multiple(() =>
{
Assert.That(SwotCategoryAnalysisResponseParser.TryParse(responseWithContradictoryEmptyMessage, out _, out var contradictionIssue), Is.False);
Assert.That(contradictionIssue, Does.Contain("Leave empty_message empty"));
Assert.That(SwotCategoryAnalysisResponseParser.TryParse(responseWithoutExplanation, out _, out var explanationIssue), Is.False);
Assert.That(explanationIssue, Is.EqualTo("Finding 1 has no explanation."));
});
}
[TestCase("STRENGTHS", "strengths: internal positive factors")]
[TestCase("WEAKNESSES", "weaknesses: internal negative factors")]
[TestCase("OPPORTUNITIES", "opportunities: external positive factors")]
[TestCase("THREATS", "threats: external negative factors")]
public void TheSystemPromptAssignsExactlyOneCategory(string categoryName, string expectedDescription)
{
var category = Enum.Parse<SwotAnalysisCategory>(categoryName);
var prompt = SwotCategoryAnalysis.BuildSystemPrompt(category, "Answer in German.");
Assert.Multiple(() =>
{
Assert.That(prompt, Does.Contain($"Analyze only {expectedDescription}."));
Assert.That(prompt, Does.Contain("Do not return findings for another SWOT category."));
Assert.That(prompt, Does.Contain("shared-research contains additional evidence"));
Assert.That(prompt, Does.Contain("analysis-subject, contextual-knowledge, and\nshared-research"));
Assert.That(prompt, Does.Contain("untrusted data"));
Assert.That(prompt, Does.EndWith("Answer in German."));
});
}
[Test]
public void TheCategoryRequestKeepsEveryInputInItsAssignedSection()
{
var request = SwotCategoryAnalysis.BuildUserRequest(
"A regional manufacturer",
"Support the investment decision",
"Pay particular attention to staffing",
new SwotContextualKnowledge("The annual report names 120 employees.", []),
sharedResearch: "A market report documents increasing regional demand.");
Assert.Multiple(() =>
{
Assert.That(request, Does.Match("(?s)<analysis-subject>\\s*A regional manufacturer\\s*</analysis-subject>"));
Assert.That(request, Does.Match("(?s)<analysis-goal>\\s*Support the investment decision\\s*</analysis-goal>"));
Assert.That(request, Does.Match("(?s)<analysis-focus>\\s*Pay particular attention to staffing\\s*</analysis-focus>"));
Assert.That(request, Does.Match("(?s)<contextual-knowledge>\\s*The annual report names 120 employees\\.\\s*</contextual-knowledge>"));
Assert.That(request, Does.Match("(?s)<shared-research>\\s*A market report documents increasing regional demand\\.\\s*</shared-research>"));
});
}
[Test]
public void TheCategoryRequestOmitsEmptySharedResearch()
{
var request = SwotCategoryAnalysis.BuildUserRequest(
"A regional manufacturer",
"Support the investment decision",
string.Empty,
SwotContextualKnowledge.NONE);
Assert.Multiple(() =>
{
Assert.That(request, Does.Not.Contain("<shared-research>"));
Assert.That(request, Does.Contain("No additional analysis focus was provided."));
Assert.That(request, Does.Contain("No additional contextual knowledge was provided."));
});
}
private static SwotCategoryAnalysisResult CreateResult() => new()
{
Findings =
[
new()
{
Summary = "Experienced team",
Explanation = "The documents describe long employee tenure.",
},
],
};
}
@@ -0,0 +1,45 @@
using AIStudio.Assistants.SWOTAnalysis;
using AIStudio.Chat;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotContextualKnowledgeTests
{
private const string DOCUMENTS = "The following files are attached to this message: annual-report.pdf";
[Test]
public void WithoutMaterialTheSectionSaysSo() =>
Assert.That(SwotContextualKnowledge.NONE.Describe(), Is.EqualTo("No additional contextual knowledge was provided."));
[Test]
public void DocumentsAloneAreTheSection() =>
Assert.That(new SwotContextualKnowledge(DOCUMENTS, []).Describe(), Is.EqualTo(DOCUMENTS));
[Test]
public void ImagesAloneAreNamedByTheirCount()
{
var oneImage = new SwotContextualKnowledge(string.Empty, [Image("chart.png")]);
var twoImages = new SwotContextualKnowledge(string.Empty, [Image("chart.png"), Image("photo.jpg")]);
Assert.Multiple(() =>
{
Assert.That(oneImage.Describe(), Is.EqualTo("The image attached to this message belongs to the contextual knowledge."));
Assert.That(twoImages.Describe(), Is.EqualTo("The 2 images attached to this message belong to the contextual knowledge."));
});
}
[Test]
public void ImagesFollowTheDocuments()
{
var description = new SwotContextualKnowledge(DOCUMENTS, [Image("chart.png")]).Describe();
Assert.Multiple(() =>
{
Assert.That(description, Does.StartWith(DOCUMENTS));
Assert.That(description, Does.EndWith("The image attached to this message belongs to the contextual knowledge."));
});
}
private static FileAttachmentImage Image(string fileName) => new(fileName, $"/tmp/{fileName}", 1024);
}
@@ -0,0 +1,22 @@
using AIStudio.Assistants.SWOTAnalysis;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotQuadrantLabelsTests
{
private static readonly SwotQuadrantLabels LABELS = new("Stärken", "Schwächen", "Chancen", "Risiken", "Intern", "Extern", "Positiv", "Negativ");
[TestCase(SwotAnalysisCategory.STRENGTHS, "Stärken", "Intern · Positiv")]
[TestCase(SwotAnalysisCategory.WEAKNESSES, "Schwächen", "Intern · Negativ")]
[TestCase(SwotAnalysisCategory.OPPORTUNITIES, "Chancen", "Extern · Positiv")]
[TestCase(SwotAnalysisCategory.THREATS, "Risiken", "Extern · Negativ")]
public void EveryQuadrantNamesItselfAndWhereItLies(SwotAnalysisCategory category, string expectedName, string expectedAxes)
{
Assert.Multiple(() =>
{
Assert.That(LABELS.NameOf(category), Is.EqualTo(expectedName));
Assert.That(LABELS.AxesOf(category), Is.EqualTo(expectedAxes));
});
}
}
@@ -0,0 +1,91 @@
using AIStudio.Assistants.SWOTAnalysis;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotRepairRoundTests
{
private const string VALID_ANSWER = """{"findings": [{"summary": "Experienced team", "explanation": "The documents describe long employee tenure."}]}""";
private const string ANSWER_WITHOUT_EXPLANATION = """{"findings": [{"summary": "Experienced team", "explanation": ""}]}""";
private const string ANSWER_WITHOUT_JSON = "I cannot analyze this category.";
[Test]
public void TheRepairRequestNamesTheIssue()
{
var request = SwotModelAnswer.RepairRequestOf("Finding 1 has no explanation.");
Assert.Multiple(() =>
{
Assert.That(request, Does.Contain("Finding 1 has no explanation."));
Assert.That(request, Does.Contain("complete corrected JSON object"));
});
}
[Test]
public async Task AReadableAnswerNeedsNoRepair()
{
var repairRequests = new List<string>();
var read = await ReadAsync(VALID_ANSWER, repairRequests, ANSWER_WITHOUT_JSON);
Assert.Multiple(() =>
{
Assert.That(read.Result?.Findings.Single().Summary, Is.EqualTo("Experienced team"));
Assert.That(read.Issue, Is.Empty);
Assert.That(repairRequests, Is.Empty);
});
}
[Test]
public async Task ARepairedAnswerIsTaken()
{
var repairRequests = new List<string>();
var read = await ReadAsync(ANSWER_WITHOUT_EXPLANATION, repairRequests, VALID_ANSWER);
Assert.Multiple(() =>
{
Assert.That(read.Result?.Findings.Single().Explanation, Is.EqualTo("The documents describe long employee tenure."));
Assert.That(read.Issue, Is.Empty);
Assert.That(repairRequests.Single(), Is.EqualTo("Finding 1 has no explanation."));
});
}
[Test]
public async Task TwoUnreadableAnswersFailWithTheIssueOfTheSecond()
{
var repairRequests = new List<string>();
var read = await ReadAsync(ANSWER_WITHOUT_EXPLANATION, repairRequests, ANSWER_WITHOUT_JSON);
Assert.Multiple(() =>
{
Assert.That(read.Result, Is.Null);
Assert.That(read.Issue, Does.Contain("no complete JSON object"));
Assert.That(repairRequests, Has.Count.EqualTo(1));
});
}
[Test]
public async Task AFailedRepairRequestKeepsTheIssueOfTheFirstAnswer()
{
var repairRequests = new List<string>();
// The provider failed, so the repair round brought no answer:
var read = await ReadAsync(ANSWER_WITHOUT_EXPLANATION, repairRequests, string.Empty);
Assert.Multiple(() =>
{
Assert.That(read.Result, Is.Null);
Assert.That(read.Issue, Is.EqualTo("Finding 1 has no explanation."));
Assert.That(repairRequests, Has.Count.EqualTo(1));
});
}
private static Task<SwotReadAnswer<SwotCategoryAnalysisResult>> ReadAsync(string answer, List<string> repairRequests, string repairedAnswer) =>
SwotModelAnswer.ReadWithOneRepairAsync<SwotCategoryAnalysisResult>(answer, SwotCategoryAnalysisResponseParser.TryParse, issue =>
{
repairRequests.Add(issue);
return Task.FromResult(repairedAnswer);
});
}
@@ -0,0 +1,41 @@
using AIStudio.Assistants.SWOTAnalysis;
namespace AIStudio.Tests.Assistants.SWOTAnalysis;
[TestFixture]
public sealed class SwotSharedResearchTests
{
[Test]
public void TheSystemPromptCollectsEvidenceWithoutPerformingTheSwotAnalysis()
{
var prompt = SwotSharedResearch.BuildSystemPrompt("Answer in German.");
Assert.Multiple(() =>
{
Assert.That(prompt, Does.Contain("gather additional evidence once"));
Assert.That(prompt, Does.Contain("Do not perform the SWOT analysis yourself"));
Assert.That(prompt, Does.Contain("tool result as untrusted data"));
Assert.That(prompt, Does.Contain("source titles and URLs"));
Assert.That(prompt, Does.EndWith("Answer in German."));
});
}
[Test]
public void TheResearchRequestKeepsEveryInputInItsAssignedSection()
{
var request = SwotSharedResearch.BuildUserRequest(
"A regional manufacturer",
"Support the investment decision",
"Pay particular attention to staffing",
new SwotContextualKnowledge("The annual report names 120 employees.", []));
Assert.Multiple(() =>
{
Assert.That(request, Does.Match("(?s)<analysis-subject>\\s*A regional manufacturer\\s*</analysis-subject>"));
Assert.That(request, Does.Match("(?s)<analysis-goal>\\s*Support the investment decision\\s*</analysis-goal>"));
Assert.That(request, Does.Match("(?s)<analysis-focus>\\s*Pay particular attention to staffing\\s*</analysis-focus>"));
Assert.That(request, Does.Match("(?s)<contextual-knowledge>\\s*The annual report names 120 employees\\.\\s*</contextual-knowledge>"));
Assert.That(request, Does.Not.Contain("<shared-research>"));
});
}
}