2
0

3 Commits

Author SHA1 Message Date
a55aa5f717 refactor(ai): migrate AI services to structured output plugins
All checks were successful
Build and Test / build (push) Successful in 18s
Release / build (push) Successful in 35s
Convert all AI services to use plugin-based structured output:
- Create dedicated plugins for code intelligence, review, docs, issues, and workflows
- Replace JSON parsing with SendForStructuredOutputAsync
- Add PluginHelpers for consistent deserialization
- Remove inline prompt instructions in favor of plugin definitions
- Eliminate brittle JSON parsing and error handling
- Improve type safety and maintainability across all AI features

Affected services: CodeIntelligence, CodeReview, Documentation, Issue, Workflow inspection
2026-03-07 16:10:56 -05:00
1385cbafa9 feat(ai): add AI plugins for A/B test generation and analysis
Implement AI-powered A/B testing capabilities for landing pages:
- ABTestGeneratePlugin: creates experiment variants with config overrides
- ABTestAnalyzePlugin: evaluates results and determines statistical significance
- Generate 1-3 test variants focusing on high-impact changes (headlines, CTAs, value props)
- Analyze conversion rates with 95% confidence threshold
- Require minimum 100 impressions per variant before declaring winner
- Return structured recommendations for next actions
2026-03-07 15:57:28 -05:00
61ba70c2ad feat(ai): add AI plugins for landing page content generation
Implement AI plugins for automated landing page creation:
- LandingPageContentPlugin: generates hero, features, stats, CTAs from repo metadata
- LandingPageTranslationPlugin: translates landing page content to target languages
- Shared model classes matching Go server's expected JSON structure
- Uses structured output via tool_choice for reliable parsing
- Supports 20+ icon types for features and value props
- Integrates with WorkflowService for task execution
2026-03-07 15:52:11 -05:00
17 changed files with 1807 additions and 241 deletions

View File

@@ -4,6 +4,7 @@
using GitCaddy.AI.Service.Services;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Licensing;
using GitCaddy.AI.Service.Plugins;
using MarketAlly.AIPlugin.Conversation;
using Microsoft.AspNetCore.Mvc;
@@ -429,15 +430,8 @@ public class AIController : ControllerBase
- Performance issues (unnecessary steps, missing caching)
- Compatibility issues with the available runner labels
- Best practice violations
Respond with a JSON object containing:
{
"valid": true/false,
"issues": [{"line": 0, "severity": "error|warning|info", "message": "...", "fix": "..."}],
"suggestions": ["..."]
}
Only respond with the JSON object, no other text.
""")
.RegisterPlugin<InspectWorkflowPlugin>()
.Build();
var prompt = $"Workflow file: {request.FilePath}\n\n```yaml\n{request.Content}\n```";
@@ -446,45 +440,26 @@ public class AIController : ControllerBase
prompt += $"\n\nAvailable runner labels: {string.Join(", ", request.RunnerLabels)}";
}
var aiResponse = await conversation.SendAsync(prompt, cancellationToken);
var responseText = aiResponse.FinalMessage ?? "{}";
var aiResponse = await conversation.SendForStructuredOutputAsync<InspectWorkflowPluginResult>(
prompt, "InspectWorkflow", cancellationToken);
// Parse the AI response as JSON
try
{
var result = System.Text.Json.JsonSerializer.Deserialize<InspectWorkflowResult>(
responseText,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
var result = PluginHelpers.DeserializeOutput<InspectWorkflowPluginResult>(aiResponse.StructuredOutput);
return Ok(new
{
valid = result?.Valid ?? true,
issues = result?.Issues?.Select(i => new
{
line = i.Line,
severity = i.Severity,
message = i.Message,
fix = i.Fix
}).ToList() ?? [],
suggestions = result?.Suggestions ?? [],
confidence = 0.8,
input_tokens = 0,
output_tokens = 0
});
}
catch (System.Text.Json.JsonException)
return Ok(new
{
// If AI didn't return valid JSON, wrap the text response
return Ok(new
valid = result.Valid,
issues = result.Issues?.Select(i => new
{
valid = true,
issues = Array.Empty<object>(),
suggestions = new[] { responseText },
confidence = 0.5,
input_tokens = 0,
output_tokens = 0
});
}
line = i.Line,
severity = i.Severity,
message = i.Message,
fix = i.Fix
}).ToList() ?? [],
suggestions = result.Suggestions ?? [],
confidence = 0.8,
input_tokens = 0,
output_tokens = 0
});
}
catch (Exception ex)
{
@@ -722,18 +697,3 @@ public class InspectWorkflowDto
public List<string>? RunnerLabels { get; set; }
}
// Internal model for parsing AI response
internal class InspectWorkflowResult
{
public bool Valid { get; set; }
public List<InspectWorkflowIssue>? Issues { get; set; }
public List<string>? Suggestions { get; set; }
}
internal class InspectWorkflowIssue
{
public int Line { get; set; }
public string? Severity { get; set; }
public string? Message { get; set; }
public string? Fix { get; set; }
}

View File

@@ -0,0 +1,79 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
/// <summary>
/// Result DTO for A/B test analysis.
/// </summary>
public class ABTestAnalyzeResult
{
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("winner_variant_id")]
public long WinnerVariantId { get; set; }
[JsonPropertyName("confidence")]
public double Confidence { get; set; }
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("recommendation")]
public string Recommendation { get; set; }
}
/// <summary>
/// AI plugin that analyzes A/B test experiment results.
/// Evaluates conversion rates, impression counts, and event distributions
/// to determine if there is a statistically significant winner.
/// </summary>
[AIPlugin("AnalyzeABTestResults",
"Analyze A/B test experiment results and determine the outcome. " +
"Evaluate conversion rates, impression counts, and statistical significance. " +
"Require at least 100 impressions per variant before declaring a winner. " +
"Use a minimum 95% confidence threshold.")]
public class ABTestAnalyzePlugin : IAIPlugin
{
[AIParameter("Analysis status: must be exactly one of 'winner', 'needs_more_data', or 'no_difference'", required: true)]
public string Status { get; set; }
[AIParameter("ID of the winning variant (use 0 if no winner)", required: true)]
public long WinnerVariantId { get; set; }
[AIParameter("Statistical confidence level from 0.0 to 1.0", required: true)]
public double Confidence { get; set; }
[AIParameter("Brief human-readable summary of the results", required: true)]
public string Summary { get; set; }
[AIParameter("Actionable recommendation for what to do next", required: true)]
public string Recommendation { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["status"] = typeof(string),
["winnervariantid"] = typeof(long),
["confidence"] = typeof(double),
["summary"] = typeof(string),
["recommendation"] = typeof(string)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ABTestAnalyzeResult
{
Status = Status,
WinnerVariantId = WinnerVariantId,
Confidence = Confidence,
Summary = Summary,
Recommendation = Recommendation
};
return Task.FromResult(new AIPluginResult(result, "A/B test analysis complete"));
}
}

View File

@@ -0,0 +1,74 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
/// <summary>
/// A/B test variant generated by AI, with a partial config override.
/// </summary>
public class ABTestVariant
{
[JsonPropertyName("name")]
public string Name { get; set; }
/// <summary>
/// Partial LandingConfig fields to override for this variant.
/// Stored as object to preserve arbitrary nested JSON structure.
/// </summary>
[JsonPropertyName("config_override")]
public object ConfigOverride { get; set; }
[JsonPropertyName("weight")]
public int Weight { get; set; }
}
/// <summary>
/// Result DTO for A/B test experiment generation.
/// </summary>
public class ABTestGenerateResult
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("variants")]
public List<ABTestVariant> Variants { get; set; }
}
/// <summary>
/// AI plugin that generates A/B test experiment variants for a landing page.
/// The AI analyzes the current landing page config and produces meaningful
/// variant overrides to test (headlines, CTAs, value props, etc.).
/// </summary>
[AIPlugin("GenerateABTestExperiment",
"Generate an A/B test experiment for a landing page. " +
"Analyze the current landing page config and create meaningful test variants " +
"that focus on high-impact changes like headlines, CTAs, and value propositions. " +
"Do NOT include a control variant — it is added automatically.")]
public class ABTestGeneratePlugin : IAIPlugin
{
[AIParameter("Short descriptive name for the experiment (e.g. 'Headline Impact Test')", required: true)]
public string ExperimentName { get; set; }
[AIParameter("Test variants to create. Each must have 'name' (string), 'config_override' (object with partial landing config fields to override), and 'weight' (integer traffic percentage, should sum to ~50 since control gets 50%). Generate 1-3 variants with meaningfully different but plausible changes.", required: true)]
public List<ABTestVariant> Variants { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["experimentname"] = typeof(string),
["variants"] = typeof(List<ABTestVariant>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ABTestGenerateResult
{
Name = ExperimentName,
Variants = Variants ?? new List<ABTestVariant>()
};
return Task.FromResult(new AIPluginResult(result, "A/B test experiment generated"));
}
}

View File

@@ -0,0 +1,162 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class SummarizeChangesResult
{
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("bullet_points")]
public List<string> BulletPoints { get; set; }
[JsonPropertyName("impact_assessment")]
public string ImpactAssessment { get; set; }
}
public class CodeReferenceItem
{
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
}
public class ExplainCodeResult
{
[JsonPropertyName("explanation")]
public string Explanation { get; set; }
[JsonPropertyName("key_concepts")]
public List<string> KeyConcepts { get; set; }
[JsonPropertyName("references")]
public List<CodeReferenceItem> References { get; set; }
}
public class SuggestFixResult
{
[JsonPropertyName("explanation")]
public string Explanation { get; set; }
[JsonPropertyName("suggested_code")]
public string SuggestedCode { get; set; }
[JsonPropertyName("alternative_fixes")]
public List<string> AlternativeFixes { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("SummarizeChanges",
"Summarize code changes with a brief paragraph, key bullet points, " +
"and an impact assessment.")]
public class SummarizeChangesPlugin : IAIPlugin
{
[AIParameter("Brief summary paragraph describing the overall changes", required: true)]
public string Summary { get; set; }
[AIParameter("Key changes as a list of concise bullet point strings", required: true)]
public List<string> BulletPoints { get; set; }
[AIParameter("Assessment of the potential impact of these changes", required: true)]
public string ImpactAssessment { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["summary"] = typeof(string),
["bulletpoints"] = typeof(List<string>),
["impactassessment"] = typeof(string)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new SummarizeChangesResult
{
Summary = Summary,
BulletPoints = BulletPoints ?? [],
ImpactAssessment = ImpactAssessment ?? ""
};
return Task.FromResult(new AIPluginResult(result, "Changes summarized"));
}
}
[AIPlugin("ExplainCode",
"Explain what code does, including key concepts, patterns used, " +
"and relevant documentation references.")]
public class ExplainCodePlugin : IAIPlugin
{
[AIParameter("Clear explanation of what the code does, how it works, and any notable patterns", required: true)]
public string Explanation { get; set; }
[AIParameter("Key programming concepts and patterns found in the code, as a list of strings", required: true)]
public List<string> KeyConcepts { get; set; }
[AIParameter("Relevant documentation references. Each item must have 'description' (string) and 'url' (string). Use empty array if no relevant references.", required: true)]
public List<CodeReferenceItem> References { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["explanation"] = typeof(string),
["keyconcepts"] = typeof(List<string>),
["references"] = typeof(List<CodeReferenceItem>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ExplainCodeResult
{
Explanation = Explanation,
KeyConcepts = KeyConcepts ?? [],
References = References ?? []
};
return Task.FromResult(new AIPluginResult(result, "Code explained"));
}
}
[AIPlugin("SuggestFix",
"Analyze code with an error and suggest a fix, including an explanation " +
"of the cause, corrected code, and alternative solutions.")]
public class SuggestFixPlugin : IAIPlugin
{
[AIParameter("Explanation of what is causing the error", required: true)]
public string Explanation { get; set; }
[AIParameter("The corrected code that fixes the error", required: true)]
public string SuggestedCode { get; set; }
[AIParameter("Alternative fix approaches as a list of description strings", required: true)]
public List<string> AlternativeFixes { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["explanation"] = typeof(string),
["suggestedcode"] = typeof(string),
["alternativefixes"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new SuggestFixResult
{
Explanation = Explanation,
SuggestedCode = SuggestedCode ?? "",
AlternativeFixes = AlternativeFixes ?? []
};
return Task.FromResult(new AIPluginResult(result, "Fix suggested"));
}
}

View File

@@ -0,0 +1,192 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class ReviewCommentItem
{
[JsonPropertyName("path")]
public string Path { get; set; }
[JsonPropertyName("line")]
public int Line { get; set; }
[JsonPropertyName("end_line")]
public int EndLine { get; set; }
[JsonPropertyName("body")]
public string Body { get; set; }
[JsonPropertyName("severity")]
public string Severity { get; set; }
[JsonPropertyName("category")]
public string Category { get; set; }
[JsonPropertyName("suggested_fix")]
public string SuggestedFix { get; set; }
}
public class SecurityIssueItem
{
[JsonPropertyName("path")]
public string Path { get; set; }
[JsonPropertyName("line")]
public int Line { get; set; }
[JsonPropertyName("issue_type")]
public string IssueType { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("severity")]
public string Severity { get; set; }
[JsonPropertyName("remediation")]
public string Remediation { get; set; }
}
public class ReviewPullRequestResult
{
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("verdict")]
public string Verdict { get; set; }
[JsonPropertyName("comments")]
public List<ReviewCommentItem> Comments { get; set; }
[JsonPropertyName("suggestions")]
public List<string> Suggestions { get; set; }
[JsonPropertyName("security_issues")]
public List<SecurityIssueItem> SecurityIssues { get; set; }
[JsonPropertyName("security_risk_score")]
public int SecurityRiskScore { get; set; }
[JsonPropertyName("security_summary")]
public string SecuritySummary { get; set; }
[JsonPropertyName("estimated_review_minutes")]
public int EstimatedReviewMinutes { get; set; }
}
public class ReviewCommitResult
{
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("comments")]
public List<ReviewCommentItem> Comments { get; set; }
[JsonPropertyName("suggestions")]
public List<string> Suggestions { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("ReviewPullRequest",
"Review a pull request and provide structured feedback including a verdict, " +
"line-by-line comments with severity levels, security analysis, and improvement suggestions.")]
public class ReviewPullRequestPlugin : IAIPlugin
{
[AIParameter("Brief overview of the changes and overall assessment", required: true)]
public string Summary { get; set; }
[AIParameter("Review verdict: must be exactly 'approve', 'request_changes', or 'comment'", required: true)]
public string Verdict { get; set; }
[AIParameter("Line-by-line review comments. Each item must have 'path' (string, file path), 'line' (int), 'end_line' (int, 0 if single line), 'body' (string, the comment), 'severity' (string: 'info', 'warning', 'error', or 'critical'), 'category' (string: 'security', 'performance', 'style', 'bug', etc.), 'suggested_fix' (string, code fix or empty)", required: true)]
public List<ReviewCommentItem> Comments { get; set; }
[AIParameter("General improvement suggestions as a list of strings", required: true)]
public List<string> Suggestions { get; set; }
[AIParameter("Security issues found. Each item must have 'path' (string), 'line' (int), 'issue_type' (string: 'sql_injection', 'xss', 'hardcoded_secret', etc.), 'description' (string), 'severity' (string), 'remediation' (string). Use empty array if no issues.", required: true)]
public List<SecurityIssueItem> SecurityIssues { get; set; }
[AIParameter("Security risk score from 0 (no risk) to 100 (critical risk)", required: true)]
public int SecurityRiskScore { get; set; }
[AIParameter("Brief summary of security analysis findings", required: true)]
public string SecuritySummary { get; set; }
[AIParameter("Estimated time in minutes to review these changes", required: true)]
public int EstimatedReviewMinutes { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["summary"] = typeof(string),
["verdict"] = typeof(string),
["comments"] = typeof(List<ReviewCommentItem>),
["suggestions"] = typeof(List<string>),
["securityissues"] = typeof(List<SecurityIssueItem>),
["securityriskscore"] = typeof(int),
["securitysummary"] = typeof(string),
["estimatedreviewminutes"] = typeof(int)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ReviewPullRequestResult
{
Summary = Summary,
Verdict = Verdict,
Comments = Comments ?? [],
Suggestions = Suggestions ?? [],
SecurityIssues = SecurityIssues ?? [],
SecurityRiskScore = SecurityRiskScore,
SecuritySummary = SecuritySummary ?? "",
EstimatedReviewMinutes = EstimatedReviewMinutes
};
return Task.FromResult(new AIPluginResult(result, "Pull request review complete"));
}
}
[AIPlugin("ReviewCommit",
"Review a single commit and provide structured feedback including comments " +
"with severity levels and improvement suggestions.")]
public class ReviewCommitPlugin : IAIPlugin
{
[AIParameter("Brief overview of the commit and overall assessment", required: true)]
public string Summary { get; set; }
[AIParameter("Line-by-line review comments. Each item must have 'path' (string), 'line' (int), 'end_line' (int, 0 if single line), 'body' (string), 'severity' (string: 'info', 'warning', 'error', or 'critical'), 'category' (string), 'suggested_fix' (string or empty)", required: true)]
public List<ReviewCommentItem> Comments { get; set; }
[AIParameter("General improvement suggestions as a list of strings", required: true)]
public List<string> Suggestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["summary"] = typeof(string),
["comments"] = typeof(List<ReviewCommentItem>),
["suggestions"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new ReviewCommitResult
{
Summary = Summary,
Comments = Comments ?? [],
Suggestions = Suggestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Commit review complete"));
}
}

View File

@@ -0,0 +1,100 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class DocumentationSectionItem
{
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
}
public class GenerateDocumentationResult
{
[JsonPropertyName("documentation")]
public string Documentation { get; set; }
[JsonPropertyName("sections")]
public List<DocumentationSectionItem> Sections { get; set; }
}
public class GenerateCommitMessageResult
{
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("alternatives")]
public List<string> Alternatives { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("GenerateDocumentation",
"Generate comprehensive code documentation including a full documentation " +
"string and organized sections for different aspects of the code.")]
public class GenerateDocumentationPlugin : IAIPlugin
{
[AIParameter("The complete documentation text in the requested format (jsdoc, docstring, xml, or markdown)", required: true)]
public string Documentation { get; set; }
[AIParameter("Documentation broken into sections. Each item must have 'title' (string, section heading like 'Description', 'Parameters', 'Returns', 'Examples') and 'content' (string, section body)", required: true)]
public List<DocumentationSectionItem> Sections { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["documentation"] = typeof(string),
["sections"] = typeof(List<DocumentationSectionItem>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new GenerateDocumentationResult
{
Documentation = Documentation,
Sections = Sections ?? []
};
return Task.FromResult(new AIPluginResult(result, "Documentation generated"));
}
}
[AIPlugin("GenerateCommitMessage",
"Generate a commit message for code changes, including a primary message " +
"and 2-3 alternative options.")]
public class GenerateCommitMessagePlugin : IAIPlugin
{
[AIParameter("The primary commit message following the requested style", required: true)]
public string Message { get; set; }
[AIParameter("2-3 alternative commit message options", required: true)]
public List<string> Alternatives { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["message"] = typeof(string),
["alternatives"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new GenerateCommitMessageResult
{
Message = Message,
Alternatives = Alternatives ?? []
};
return Task.FromResult(new AIPluginResult(result, "Commit message generated"));
}
}

View File

@@ -0,0 +1,173 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class TriageIssueResult
{
[JsonPropertyName("priority")]
public string Priority { get; set; }
[JsonPropertyName("category")]
public string Category { get; set; }
[JsonPropertyName("suggested_labels")]
public List<string> SuggestedLabels { get; set; }
[JsonPropertyName("suggested_assignees")]
public List<string> SuggestedAssignees { get; set; }
[JsonPropertyName("summary")]
public string Summary { get; set; }
[JsonPropertyName("is_duplicate")]
public bool IsDuplicate { get; set; }
[JsonPropertyName("duplicate_of")]
public long DuplicateOf { get; set; }
}
public class LabelSuggestionItem
{
[JsonPropertyName("label")]
public string Label { get; set; }
[JsonPropertyName("confidence")]
public float Confidence { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; }
}
public class SuggestLabelsResult
{
[JsonPropertyName("suggestions")]
public List<LabelSuggestionItem> Suggestions { get; set; }
}
public class GenerateIssueResponseResult
{
[JsonPropertyName("response")]
public string Response { get; set; }
[JsonPropertyName("follow_up_questions")]
public List<string> FollowUpQuestions { get; set; }
}
// ============================================================================
// Plugins
// ============================================================================
[AIPlugin("TriageIssue",
"Triage a software issue by determining its priority, category, " +
"suggested labels, and whether it is a duplicate.")]
public class TriageIssuePlugin : IAIPlugin
{
[AIParameter("Issue priority: must be exactly 'critical', 'high', 'medium', or 'low'", required: true)]
public string Priority { get; set; }
[AIParameter("Issue category: e.g. 'bug', 'feature', 'question', 'docs', 'enhancement'", required: true)]
public string Category { get; set; }
[AIParameter("Suggested labels from the available labels list", required: true)]
public List<string> SuggestedLabels { get; set; }
[AIParameter("Suggested assignees (usernames) who might be best to handle this. Use empty array if unknown.", required: true)]
public List<string> SuggestedAssignees { get; set; }
[AIParameter("Brief summary of the issue and recommended actions", required: true)]
public string Summary { get; set; }
[AIParameter("Whether this issue appears to be a duplicate of an existing issue", required: true)]
public bool IsDuplicate { get; set; }
[AIParameter("Issue ID of the original if this is a duplicate, or 0 if not a duplicate", required: true)]
public long DuplicateOf { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["priority"] = typeof(string),
["category"] = typeof(string),
["suggestedlabels"] = typeof(List<string>),
["suggestedassignees"] = typeof(List<string>),
["summary"] = typeof(string),
["isduplicate"] = typeof(bool),
["duplicateof"] = typeof(long)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new TriageIssueResult
{
Priority = Priority,
Category = Category,
SuggestedLabels = SuggestedLabels ?? [],
SuggestedAssignees = SuggestedAssignees ?? [],
Summary = Summary,
IsDuplicate = IsDuplicate,
DuplicateOf = DuplicateOf
};
return Task.FromResult(new AIPluginResult(result, "Issue triaged"));
}
}
[AIPlugin("SuggestLabels",
"Suggest appropriate labels for an issue from the available label options, " +
"with confidence scores and reasoning.")]
public class SuggestLabelsPlugin : IAIPlugin
{
[AIParameter("Label suggestions. Each item must have 'label' (string, from available labels), 'confidence' (float, 0.0-1.0), 'reason' (string, why this label applies)", required: true)]
public List<LabelSuggestionItem> Suggestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["suggestions"] = typeof(List<LabelSuggestionItem>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new SuggestLabelsResult
{
Suggestions = Suggestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Labels suggested"));
}
}
[AIPlugin("GenerateIssueResponse",
"Generate a professional response to a software issue, " +
"including optional follow-up questions for clarification.")]
public class GenerateIssueResponsePlugin : IAIPlugin
{
[AIParameter("The response text to post as a comment on the issue", required: true)]
public string Response { get; set; }
[AIParameter("Follow-up questions to ask the reporter for clarification. Use empty array if none needed.", required: true)]
public List<string> FollowUpQuestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["response"] = typeof(string),
["followupquestions"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new GenerateIssueResponseResult
{
Response = Response,
FollowUpQuestions = FollowUpQuestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Issue response generated"));
}
}

View File

@@ -0,0 +1,115 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
/// <summary>
/// AI plugin that captures structured landing page content generated by the AI model.
/// Used with SendForStructuredOutputAsync to force the AI to return data via tool_choice
/// rather than free-form text that requires parsing.
/// </summary>
[AIPlugin("GenerateLandingPageContent",
"Generate compelling landing page content for a software repository. " +
"Analyze the repository metadata provided and fill in all fields with professional, " +
"benefit-focused marketing copy.")]
public class LandingPageContentPlugin : IAIPlugin
{
[AIParameter("Project/brand name", required: true)]
public string BrandName { get; set; }
[AIParameter("Short brand tagline (one line)", required: true)]
public string BrandTagline { get; set; }
[AIParameter("Compelling hero headline (max 10 words)", required: true)]
public string HeroHeadline { get; set; }
[AIParameter("Supporting hero subheadline explaining the value (1-2 sentences)", required: true)]
public string HeroSubheadline { get; set; }
[AIParameter("Primary call-to-action button label (e.g. 'Get Started')", required: true)]
public string PrimaryCtaLabel { get; set; }
[AIParameter("Secondary call-to-action button label (e.g. 'View Source')", required: true)]
public string SecondaryCtaLabel { get; set; }
[AIParameter("Statistics to display. Each must have 'value' and 'label' string properties. Generate 3-4 stats based on repo data or compelling metrics.", required: true)]
public List<LandingPageStat> Stats { get; set; }
[AIParameter("Key value propositions. Each must have 'title', 'description', and 'icon' properties. Icon must be one of: zap, shield, rocket, check, star, heart, lock, globe, clock, gear, code, terminal, package, database, cloud, cpu, graph, people, tools, light-bulb. Generate exactly 3.", required: true)]
public List<LandingPageValueProp> ValueProps { get; set; }
[AIParameter("Features to highlight. Each must have 'title', 'description', and 'icon' properties (same icon options as value props). Generate 3-6 based on what the README describes.", required: true)]
public List<LandingPageFeature> Features { get; set; }
[AIParameter("Bottom CTA section headline (e.g. 'Ready to get started?')", required: true)]
public string CtaHeadline { get; set; }
[AIParameter("Bottom CTA section subheadline", required: true)]
public string CtaSubheadline { get; set; }
[AIParameter("Bottom CTA section button label (e.g. 'Get Started Free')", required: true)]
public string CtaButtonLabel { get; set; }
[AIParameter("SEO page title (50-60 characters)", required: true)]
public string SeoTitle { get; set; }
[AIParameter("SEO meta description (150-160 characters)", required: true)]
public string SeoDescription { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["brandname"] = typeof(string),
["brandtagline"] = typeof(string),
["heroheadline"] = typeof(string),
["herosubheadline"] = typeof(string),
["primaryctalabel"] = typeof(string),
["secondaryctalabel"] = typeof(string),
["stats"] = typeof(List<LandingPageStat>),
["valueprops"] = typeof(List<LandingPageValueProp>),
["features"] = typeof(List<LandingPageFeature>),
["ctaheadline"] = typeof(string),
["ctasubheadline"] = typeof(string),
["ctabuttonlabel"] = typeof(string),
["seotitle"] = typeof(string),
["seodescription"] = typeof(string)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
// Properties are auto-populated by the framework before this runs.
// Assemble the result DTO matching the Go server's expected JSON structure.
var result = new LandingPageContentResult
{
Brand = new LandingPageBrand
{
Name = BrandName,
Tagline = BrandTagline
},
Hero = new LandingPageHero
{
Headline = HeroHeadline,
Subheadline = HeroSubheadline,
PrimaryCta = new LandingPageCtaLabel { Label = PrimaryCtaLabel },
SecondaryCta = new LandingPageCtaLabel { Label = SecondaryCtaLabel }
},
Stats = Stats ?? new List<LandingPageStat>(),
ValueProps = ValueProps ?? new List<LandingPageValueProp>(),
Features = Features ?? new List<LandingPageFeature>(),
CtaSection = new LandingPageCtaSection
{
Headline = CtaHeadline,
Subheadline = CtaSubheadline,
ButtonLabel = CtaButtonLabel
},
Seo = new LandingPageSeo
{
Title = SeoTitle,
Description = SeoDescription
}
};
return Task.FromResult(new AIPluginResult(result, "Landing page content generated"));
}
}

View File

@@ -0,0 +1,186 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
namespace GitCaddy.AI.Service.Plugins;
// --- Shared model classes for AI-generated landing page content ---
/// <summary>
/// A statistic/metric to display on the landing page (e.g., "100+ Stars").
/// </summary>
public class LandingPageStat
{
[JsonPropertyName("value")]
public string Value { get; set; }
[JsonPropertyName("label")]
public string Label { get; set; }
}
/// <summary>
/// A value proposition with icon for the landing page.
/// </summary>
public class LandingPageValueProp
{
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("icon")]
public string Icon { get; set; }
}
/// <summary>
/// A feature with icon for the landing page.
/// </summary>
public class LandingPageFeature
{
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("icon")]
public string Icon { get; set; }
}
// --- Result DTOs for content generation (matches Go aiGeneratedConfig struct) ---
public class LandingPageContentResult
{
[JsonPropertyName("brand")]
public LandingPageBrand Brand { get; set; }
[JsonPropertyName("hero")]
public LandingPageHero Hero { get; set; }
[JsonPropertyName("stats")]
public List<LandingPageStat> Stats { get; set; }
[JsonPropertyName("value_props")]
public List<LandingPageValueProp> ValueProps { get; set; }
[JsonPropertyName("features")]
public List<LandingPageFeature> Features { get; set; }
[JsonPropertyName("cta_section")]
public LandingPageCtaSection CtaSection { get; set; }
[JsonPropertyName("seo")]
public LandingPageSeo Seo { get; set; }
}
public class LandingPageBrand
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("tagline")]
public string Tagline { get; set; }
}
public class LandingPageHero
{
[JsonPropertyName("headline")]
public string Headline { get; set; }
[JsonPropertyName("subheadline")]
public string Subheadline { get; set; }
[JsonPropertyName("primary_cta")]
public LandingPageCtaLabel PrimaryCta { get; set; }
[JsonPropertyName("secondary_cta")]
public LandingPageCtaLabel SecondaryCta { get; set; }
}
public class LandingPageCtaLabel
{
[JsonPropertyName("label")]
public string Label { get; set; }
}
public class LandingPageCtaSection
{
[JsonPropertyName("headline")]
public string Headline { get; set; }
[JsonPropertyName("subheadline")]
public string Subheadline { get; set; }
[JsonPropertyName("button_label")]
public string ButtonLabel { get; set; }
}
public class LandingPageSeo
{
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
}
// --- Result DTOs for translation overlay (deep-merged onto base config) ---
public class LandingPageTranslationResult
{
[JsonPropertyName("hero")]
public LandingPageTranslationHero Hero { get; set; }
[JsonPropertyName("stats")]
public List<LandingPageStat> Stats { get; set; }
[JsonPropertyName("value_props")]
public List<LandingPageTranslationSection> ValueProps { get; set; }
[JsonPropertyName("features")]
public List<LandingPageTranslationSection> Features { get; set; }
[JsonPropertyName("cta_section")]
public LandingPageTranslationCta CtaSection { get; set; }
[JsonPropertyName("seo")]
public LandingPageSeo Seo { get; set; }
}
public class LandingPageTranslationHero
{
[JsonPropertyName("headline")]
public string Headline { get; set; }
[JsonPropertyName("subheadline")]
public string Subheadline { get; set; }
[JsonPropertyName("primary_cta")]
public LandingPageCtaLabel PrimaryCta { get; set; }
[JsonPropertyName("secondary_cta")]
public LandingPageCtaLabel SecondaryCta { get; set; }
}
public class LandingPageTranslationSection
{
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
}
public class LandingPageTranslationCta
{
[JsonPropertyName("headline")]
public string Headline { get; set; }
[JsonPropertyName("subheadline")]
public string Subheadline { get; set; }
[JsonPropertyName("button")]
public LandingPageCtaLabel Button { get; set; }
}

View File

@@ -0,0 +1,102 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
/// <summary>
/// AI plugin that captures translated landing page content.
/// Used with SendForStructuredOutputAsync to force the AI to return a translation
/// overlay that gets deep-merged onto the base landing page config.
/// </summary>
[AIPlugin("TranslateLandingPageContent",
"Translate landing page content to the target language. " +
"Provide natural, marketing-quality translations. Keep technical terms, " +
"brand names, and URLs untranslated. Maintain the same number of items in arrays.")]
public class LandingPageTranslationPlugin : IAIPlugin
{
[AIParameter("Translated hero headline", required: true)]
public string HeroHeadline { get; set; }
[AIParameter("Translated hero subheadline", required: true)]
public string HeroSubheadline { get; set; }
[AIParameter("Translated primary CTA button label", required: true)]
public string PrimaryCtaLabel { get; set; }
[AIParameter("Translated secondary CTA button label", required: true)]
public string SecondaryCtaLabel { get; set; }
[AIParameter("Translated stats. Each must have 'value' (keep original numbers/values) and 'label' (translated) string properties. Must have the same number of items as the source.", required: true)]
public List<LandingPageStat> Stats { get; set; }
[AIParameter("Translated value propositions. Each must have 'title' and 'description' string properties (translated). Must have the same number of items as the source.", required: true)]
public List<LandingPageTranslationSection> ValueProps { get; set; }
[AIParameter("Translated features. Each must have 'title' and 'description' string properties (translated). Must have the same number of items as the source.", required: true)]
public List<LandingPageTranslationSection> Features { get; set; }
[AIParameter("Translated bottom CTA section headline", required: true)]
public string CtaHeadline { get; set; }
[AIParameter("Translated bottom CTA section subheadline", required: true)]
public string CtaSubheadline { get; set; }
[AIParameter("Translated bottom CTA button label", required: true)]
public string CtaButtonLabel { get; set; }
[AIParameter("Translated SEO page title", required: true)]
public string SeoTitle { get; set; }
[AIParameter("Translated SEO meta description", required: true)]
public string SeoDescription { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["heroheadline"] = typeof(string),
["herosubheadline"] = typeof(string),
["primaryctalabel"] = typeof(string),
["secondaryctalabel"] = typeof(string),
["stats"] = typeof(List<LandingPageStat>),
["valueprops"] = typeof(List<LandingPageTranslationSection>),
["features"] = typeof(List<LandingPageTranslationSection>),
["ctaheadline"] = typeof(string),
["ctasubheadline"] = typeof(string),
["ctabuttonlabel"] = typeof(string),
["seotitle"] = typeof(string),
["seodescription"] = typeof(string)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
// Properties are auto-populated by the framework.
// Assemble the translation overlay DTO matching the expected deep-merge structure.
var result = new LandingPageTranslationResult
{
Hero = new LandingPageTranslationHero
{
Headline = HeroHeadline,
Subheadline = HeroSubheadline,
PrimaryCta = new LandingPageCtaLabel { Label = PrimaryCtaLabel },
SecondaryCta = new LandingPageCtaLabel { Label = SecondaryCtaLabel }
},
Stats = Stats ?? new List<LandingPageStat>(),
ValueProps = ValueProps ?? new List<LandingPageTranslationSection>(),
Features = Features ?? new List<LandingPageTranslationSection>(),
CtaSection = new LandingPageTranslationCta
{
Headline = CtaHeadline,
Subheadline = CtaSubheadline,
Button = new LandingPageCtaLabel { Label = CtaButtonLabel }
},
Seo = new LandingPageSeo
{
Title = SeoTitle,
Description = SeoDescription
}
};
return Task.FromResult(new AIPluginResult(result, "Landing page content translated"));
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json;
namespace GitCaddy.AI.Service.Plugins;
/// <summary>
/// Shared helpers for working with AIPlugin structured output.
/// </summary>
public static class PluginHelpers
{
/// <summary>
/// Safely deserializes StructuredOutput to the target type, handling the various
/// runtime types that the framework may return (typed object, JsonElement, or string).
/// </summary>
public static T DeserializeOutput<T>(object? structuredOutput) where T : class, new()
{
if (structuredOutput is T typed)
return typed;
string json;
if (structuredOutput is string str)
json = str;
else if (structuredOutput is JsonElement jsonElement)
json = jsonElement.GetRawText();
else if (structuredOutput != null)
json = JsonSerializer.Serialize(structuredOutput, structuredOutput.GetType());
else
return new T();
return JsonSerializer.Deserialize<T>(json) ?? new T();
}
}

View File

@@ -0,0 +1,76 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json.Serialization;
using MarketAlly.AIPlugin;
namespace GitCaddy.AI.Service.Plugins;
// ============================================================================
// Result DTOs
// ============================================================================
public class WorkflowIssueItem
{
[JsonPropertyName("line")]
public int Line { get; set; }
[JsonPropertyName("severity")]
public string Severity { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; }
[JsonPropertyName("fix")]
public string Fix { get; set; }
}
public class InspectWorkflowPluginResult
{
[JsonPropertyName("valid")]
public bool Valid { get; set; }
[JsonPropertyName("issues")]
public List<WorkflowIssueItem> Issues { get; set; }
[JsonPropertyName("suggestions")]
public List<string> Suggestions { get; set; }
}
// ============================================================================
// Plugin
// ============================================================================
[AIPlugin("InspectWorkflow",
"Inspect a CI/CD workflow YAML file for issues including syntax errors, " +
"missing fields, security problems, performance issues, and best practice violations.")]
public class InspectWorkflowPlugin : IAIPlugin
{
[AIParameter("Whether the workflow file is valid (no errors found)", required: true)]
public bool Valid { get; set; }
[AIParameter("Issues found in the workflow. Each item must have 'line' (int, line number or 0), 'severity' (string: 'error', 'warning', or 'info'), 'message' (string, description of the issue), 'fix' (string, suggested fix)", required: true)]
public List<WorkflowIssueItem> Issues { get; set; }
[AIParameter("General improvement suggestions as a list of strings", required: true)]
public List<string> Suggestions { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["valid"] = typeof(bool),
["issues"] = typeof(List<WorkflowIssueItem>),
["suggestions"] = typeof(List<string>)
};
public Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
var result = new InspectWorkflowPluginResult
{
Valid = Valid,
Issues = Issues ?? [],
Suggestions = Suggestions ?? []
};
return Task.FromResult(new AIPluginResult(result, "Workflow inspection complete"));
}
}

View File

@@ -3,12 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered code intelligence.
/// Implementation of AI-powered code intelligence using MarketAlly.AIPlugin structured output.
/// </summary>
public class CodeIntelligenceService : ICodeIntelligenceService
{
@@ -33,16 +34,28 @@ public class CodeIntelligenceService : ICodeIntelligenceService
.WithSystemPrompt("""
You are an expert at summarizing code changes.
Provide clear, concise summaries that help developers understand what changed and why.
Output a brief summary paragraph followed by bullet points of key changes.
Also assess the potential impact of these changes.
""")
.RegisterPlugin<SummarizeChangesPlugin>()
.Build();
var prompt = BuildSummarizePrompt(request);
var response = await conversation.SendAsync(prompt, cancellationToken);
return ParseSummarizeResponse(response.FinalMessage ?? "");
var response = await conversation.SendForStructuredOutputAsync<SummarizeChangesResult>(
prompt, "SummarizeChanges", cancellationToken);
var result = PluginHelpers.DeserializeOutput<SummarizeChangesResult>(response.StructuredOutput);
var protoResponse = new SummarizeChangesResponse
{
Summary = result.Summary ?? "",
ImpactAssessment = result.ImpactAssessment ?? ""
};
if (result.BulletPoints != null)
protoResponse.BulletPoints.AddRange(result.BulletPoints);
return protoResponse;
}
public async Task<ExplainCodeResponse> ExplainCodeAsync(
@@ -60,6 +73,7 @@ public class CodeIntelligenceService : ICodeIntelligenceService
Adjust your explanation depth based on the code complexity.
""")
.RegisterPlugin<ExplainCodePlugin>()
.Build();
var prompt = $"""
@@ -72,12 +86,32 @@ public class CodeIntelligenceService : ICodeIntelligenceService
{(string.IsNullOrEmpty(request.Question) ? "" : $"Specific question: {request.Question}")}
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<ExplainCodeResult>(
prompt, "ExplainCode", cancellationToken);
return new ExplainCodeResponse
var result = PluginHelpers.DeserializeOutput<ExplainCodeResult>(response.StructuredOutput);
var protoResponse = new ExplainCodeResponse
{
Explanation = response.FinalMessage ?? ""
Explanation = result.Explanation ?? ""
};
if (result.KeyConcepts != null)
protoResponse.KeyConcepts.AddRange(result.KeyConcepts);
if (result.References != null)
{
foreach (var r in result.References)
{
protoResponse.References.Add(new CodeReference
{
Description = r.Description ?? "",
Url = r.Url ?? ""
});
}
}
return protoResponse;
}
public async Task<SuggestFixResponse> SuggestFixAsync(
@@ -94,6 +128,7 @@ public class CodeIntelligenceService : ICodeIntelligenceService
Be specific and provide working code.
""")
.RegisterPlugin<SuggestFixPlugin>()
.Build();
var prompt = $"""
@@ -111,12 +146,21 @@ public class CodeIntelligenceService : ICodeIntelligenceService
Please suggest a fix.
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<SuggestFixResult>(
prompt, "SuggestFix", cancellationToken);
return new SuggestFixResponse
var result = PluginHelpers.DeserializeOutput<SuggestFixResult>(response.StructuredOutput);
var protoResponse = new SuggestFixResponse
{
Explanation = response.FinalMessage ?? ""
Explanation = result.Explanation ?? "",
SuggestedCode = result.SuggestedCode ?? ""
};
if (result.AlternativeFixes != null)
protoResponse.AlternativeFixes.AddRange(result.AlternativeFixes);
return protoResponse;
}
private static string BuildSummarizePrompt(SummarizeChangesRequest request)
@@ -149,25 +193,4 @@ public class CodeIntelligenceService : ICodeIntelligenceService
return sb.ToString();
}
private static SummarizeChangesResponse ParseSummarizeResponse(string response)
{
var result = new SummarizeChangesResponse
{
Summary = response
};
// Extract bullet points (lines starting with - or *)
var lines = response.Split('\n');
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("- ") || trimmed.StartsWith("* "))
{
result.BulletPoints.Add(trimmed[2..]);
}
}
return result;
}
}

View File

@@ -3,14 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using MarketAlly.AIPlugin;
using MarketAlly.AIPlugin.Conversation;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered code review using MarketAlly.AIPlugin.
/// Implementation of AI-powered code review using MarketAlly.AIPlugin structured output.
/// </summary>
public class CodeReviewService : ICodeReviewService
{
@@ -33,16 +32,16 @@ public class CodeReviewService : ICodeReviewService
{
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt(GetCodeReviewSystemPrompt(request.Options))
.RegisterPlugin<ReviewPullRequestPlugin>()
.Build();
// Build the review prompt
var prompt = BuildPullRequestReviewPrompt(request);
// Get AI response
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<ReviewPullRequestResult>(
prompt, "ReviewPullRequest", cancellationToken);
// Parse and return structured response
return ParsePullRequestReviewResponse(response.FinalMessage ?? "");
var result = PluginHelpers.DeserializeOutput<ReviewPullRequestResult>(response.StructuredOutput);
return MapPullRequestResult(result);
}
public async Task<ReviewCommitResponse> ReviewCommitAsync(
@@ -50,12 +49,16 @@ public class CodeReviewService : ICodeReviewService
{
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt(GetCodeReviewSystemPrompt(request.Options))
.RegisterPlugin<ReviewCommitPlugin>()
.Build();
var prompt = BuildCommitReviewPrompt(request);
var response = await conversation.SendAsync(prompt, cancellationToken);
return ParseCommitReviewResponse(response.FinalMessage ?? "");
var response = await conversation.SendForStructuredOutputAsync<ReviewCommitResult>(
prompt, "ReviewCommit", cancellationToken);
var result = PluginHelpers.DeserializeOutput<ReviewCommitResult>(response.StructuredOutput);
return MapCommitResult(result);
}
private static string GetCodeReviewSystemPrompt(ReviewOptions? options)
@@ -92,14 +95,6 @@ public class CodeReviewService : ICodeReviewService
5. Consider the broader context and impact of changes
{focusText}
Output Format:
Provide your review in a structured format:
- SUMMARY: A brief overview of the changes and overall assessment
- VERDICT: APPROVE, REQUEST_CHANGES, or COMMENT
- COMMENTS: Specific line-by-line feedback with severity levels
- SECURITY: Any security concerns found
- SUGGESTIONS: General improvement suggestions
""";
}
@@ -154,36 +149,105 @@ public class CodeReviewService : ICodeReviewService
return sb.ToString();
}
private static ReviewPullRequestResponse ParsePullRequestReviewResponse(string response)
private static ReviewPullRequestResponse MapPullRequestResult(ReviewPullRequestResult result)
{
// TODO: Implement structured parsing using JSON mode or regex extraction
// For now, return a basic response
var result = new ReviewPullRequestResponse
var response = new ReviewPullRequestResponse
{
Summary = response,
Verdict = ReviewVerdict.Comment,
EstimatedReviewMinutes = 5
Summary = result.Summary ?? "",
Verdict = ParseVerdict(result.Verdict),
EstimatedReviewMinutes = result.EstimatedReviewMinutes
};
// Parse verdict from response
if (response.Contains("APPROVE", StringComparison.OrdinalIgnoreCase) &&
!response.Contains("REQUEST_CHANGES", StringComparison.OrdinalIgnoreCase))
if (result.Comments != null)
{
result.Verdict = ReviewVerdict.Approve;
}
else if (response.Contains("REQUEST_CHANGES", StringComparison.OrdinalIgnoreCase))
{
result.Verdict = ReviewVerdict.RequestChanges;
foreach (var c in result.Comments)
{
response.Comments.Add(new ReviewComment
{
Path = c.Path ?? "",
Line = c.Line,
EndLine = c.EndLine,
Body = c.Body ?? "",
Severity = ParseSeverity(c.Severity),
Category = c.Category ?? "",
SuggestedFix = c.SuggestedFix ?? ""
});
}
}
return result;
if (result.Suggestions != null)
response.Suggestions.AddRange(result.Suggestions);
if (result.SecurityIssues is { Count: > 0 })
{
response.Security = new SecurityAnalysis
{
RiskScore = result.SecurityRiskScore,
Summary = result.SecuritySummary ?? ""
};
foreach (var si in result.SecurityIssues)
{
response.Security.Issues.Add(new SecurityIssue
{
Path = si.Path ?? "",
Line = si.Line,
IssueType = si.IssueType ?? "",
Description = si.Description ?? "",
Severity = si.Severity ?? "",
Remediation = si.Remediation ?? ""
});
}
}
return response;
}
private static ReviewCommitResponse ParseCommitReviewResponse(string response)
private static ReviewCommitResponse MapCommitResult(ReviewCommitResult result)
{
return new ReviewCommitResponse
var response = new ReviewCommitResponse
{
Summary = response
Summary = result.Summary ?? ""
};
if (result.Comments != null)
{
foreach (var c in result.Comments)
{
response.Comments.Add(new ReviewComment
{
Path = c.Path ?? "",
Line = c.Line,
EndLine = c.EndLine,
Body = c.Body ?? "",
Severity = ParseSeverity(c.Severity),
Category = c.Category ?? "",
SuggestedFix = c.SuggestedFix ?? ""
});
}
}
if (result.Suggestions != null)
response.Suggestions.AddRange(result.Suggestions);
return response;
}
private static ReviewVerdict ParseVerdict(string? verdict) =>
verdict?.ToLowerInvariant() switch
{
"approve" => ReviewVerdict.Approve,
"request_changes" => ReviewVerdict.RequestChanges,
"comment" => ReviewVerdict.Comment,
_ => ReviewVerdict.Comment
};
private static CommentSeverity ParseSeverity(string? severity) =>
severity?.ToLowerInvariant() switch
{
"critical" => CommentSeverity.Critical,
"error" => CommentSeverity.Error,
"warning" => CommentSeverity.Warning,
"info" => CommentSeverity.Info,
_ => CommentSeverity.Info
};
}

View File

@@ -3,12 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered documentation generation.
/// Implementation of AI-powered documentation generation using MarketAlly.AIPlugin structured output.
/// </summary>
public class DocumentationService : IDocumentationService
{
@@ -61,6 +62,7 @@ public class DocumentationService : IDocumentationService
Be concise but thorough.
""")
.RegisterPlugin<GenerateDocumentationPlugin>()
.Build();
var prompt = $"""
@@ -71,12 +73,29 @@ public class DocumentationService : IDocumentationService
```
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<GenerateDocumentationResult>(
prompt, "GenerateDocumentation", cancellationToken);
return new GenerateDocumentationResponse
var result = PluginHelpers.DeserializeOutput<GenerateDocumentationResult>(response.StructuredOutput);
var protoResponse = new GenerateDocumentationResponse
{
Documentation = response.FinalMessage ?? ""
Documentation = result.Documentation ?? ""
};
if (result.Sections != null)
{
foreach (var s in result.Sections)
{
protoResponse.Sections.Add(new DocumentationSection
{
Title = s.Title ?? "",
Content = s.Content ?? ""
});
}
}
return protoResponse;
}
public async Task<GenerateCommitMessageResponse> GenerateCommitMessageAsync(
@@ -110,9 +129,8 @@ public class DocumentationService : IDocumentationService
Analyze the changes and write an appropriate commit message.
Focus on WHAT changed and WHY, not HOW.
Also provide 2-3 alternative messages.
""")
.RegisterPlugin<GenerateCommitMessagePlugin>()
.Build();
var sb = new System.Text.StringBuilder();
@@ -128,26 +146,19 @@ public class DocumentationService : IDocumentationService
sb.AppendLine();
}
var response = await conversation.SendAsync(sb.ToString(), cancellationToken);
var responseContent = response.FinalMessage ?? "";
var response = await conversation.SendForStructuredOutputAsync<GenerateCommitMessageResult>(
sb.ToString(), "GenerateCommitMessage", cancellationToken);
// Parse response - first line is primary, rest are alternatives
var lines = responseContent.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var result = new GenerateCommitMessageResponse
var result = PluginHelpers.DeserializeOutput<GenerateCommitMessageResult>(response.StructuredOutput);
var protoResponse = new GenerateCommitMessageResponse
{
Message = lines.Length > 0 ? lines[0].Trim() : responseContent.Trim()
Message = result.Message ?? ""
};
// Extract alternatives if present
for (var i = 1; i < lines.Length && result.Alternatives.Count < 3; i++)
{
var line = lines[i].Trim();
if (!string.IsNullOrEmpty(line) && !line.StartsWith('#') && !line.StartsWith('-'))
{
result.Alternatives.Add(line);
}
}
if (result.Alternatives != null)
protoResponse.Alternatives.AddRange(result.Alternatives);
return result;
return protoResponse;
}
}

View File

@@ -3,12 +3,13 @@
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Plugins;
using Microsoft.Extensions.Options;
namespace GitCaddy.AI.Service.Services;
/// <summary>
/// Implementation of AI-powered issue management.
/// Implementation of AI-powered issue management using MarketAlly.AIPlugin structured output.
/// </summary>
public class IssueService : IIssueService
{
@@ -40,15 +41,9 @@ public class IssueService : IIssueService
2. Category: bug, feature, question, docs, enhancement, etc.
3. Suggested labels from the available labels: {{availableLabels}}
4. A brief summary of the issue
Respond in JSON format:
{
"priority": "...",
"category": "...",
"suggested_labels": ["..."],
"summary": "..."
}
5. Whether this is a duplicate of an existing issue
""")
.RegisterPlugin<TriageIssuePlugin>()
.Build();
var prompt = $"""
@@ -61,10 +56,27 @@ public class IssueService : IIssueService
Existing labels: {string.Join(", ", request.ExistingLabels)}
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<TriageIssueResult>(
prompt, "TriageIssue", cancellationToken);
// Parse JSON response
return ParseTriageResponse(response.FinalMessage ?? "");
var result = PluginHelpers.DeserializeOutput<TriageIssueResult>(response.StructuredOutput);
var protoResponse = new TriageIssueResponse
{
Priority = result.Priority ?? "medium",
Category = result.Category ?? "bug",
Summary = result.Summary ?? "",
IsDuplicate = result.IsDuplicate,
DuplicateOf = result.DuplicateOf
};
if (result.SuggestedLabels != null)
protoResponse.SuggestedLabels.AddRange(result.SuggestedLabels);
if (result.SuggestedAssignees != null)
protoResponse.SuggestedAssignees.AddRange(result.SuggestedAssignees);
return protoResponse;
}
public async Task<SuggestLabelsResponse> SuggestLabelsAsync(
@@ -78,14 +90,8 @@ public class IssueService : IIssueService
Available labels: {{string.Join(", ", request.AvailableLabels)}}
For each suggested label, provide a confidence score (0.0-1.0) and reason.
Respond in JSON format:
{
"suggestions": [
{"label": "...", "confidence": 0.9, "reason": "..."}
]
}
""")
.RegisterPlugin<SuggestLabelsPlugin>()
.Build();
var prompt = $"""
@@ -95,9 +101,27 @@ public class IssueService : IIssueService
{request.Body}
""";
var response = await conversation.SendAsync(prompt, cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<SuggestLabelsResult>(
prompt, "SuggestLabels", cancellationToken);
return ParseSuggestLabelsResponse(response.FinalMessage ?? "", request.AvailableLabels);
var result = PluginHelpers.DeserializeOutput<SuggestLabelsResult>(response.StructuredOutput);
var protoResponse = new SuggestLabelsResponse();
if (result.Suggestions != null)
{
foreach (var s in result.Suggestions)
{
protoResponse.Suggestions.Add(new LabelSuggestion
{
Label = s.Label ?? "",
Confidence = s.Confidence,
Reason = s.Reason ?? ""
});
}
}
return protoResponse;
}
public async Task<GenerateIssueResponseResponse> GenerateResponseAsync(
@@ -120,6 +144,7 @@ public class IssueService : IIssueService
If you need more information, include follow-up questions.
""")
.RegisterPlugin<GenerateIssueResponsePlugin>()
.Build();
var sb = new System.Text.StringBuilder();
@@ -140,56 +165,19 @@ public class IssueService : IIssueService
sb.AppendLine();
sb.AppendLine("Please generate an appropriate response.");
var response = await conversation.SendAsync(sb.ToString(), cancellationToken);
var response = await conversation.SendForStructuredOutputAsync<GenerateIssueResponseResult>(
sb.ToString(), "GenerateIssueResponse", cancellationToken);
return new GenerateIssueResponseResponse
{
Response = response.FinalMessage ?? ""
};
}
var result = PluginHelpers.DeserializeOutput<GenerateIssueResponseResult>(response.StructuredOutput);
private static TriageIssueResponse ParseTriageResponse(string response)
{
// TODO: Implement proper JSON parsing
var result = new TriageIssueResponse
var protoResponse = new GenerateIssueResponseResponse
{
Priority = "medium",
Category = "bug",
Summary = response
Response = result.Response ?? ""
};
// Simple extraction (improve with JSON parsing)
if (response.Contains("\"priority\":", StringComparison.OrdinalIgnoreCase))
{
if (response.Contains("critical", StringComparison.OrdinalIgnoreCase))
result.Priority = "critical";
else if (response.Contains("high", StringComparison.OrdinalIgnoreCase))
result.Priority = "high";
else if (response.Contains("low", StringComparison.OrdinalIgnoreCase))
result.Priority = "low";
}
if (result.FollowUpQuestions != null)
protoResponse.FollowUpQuestions.AddRange(result.FollowUpQuestions);
return result;
}
private static SuggestLabelsResponse ParseSuggestLabelsResponse(string response, IEnumerable<string> availableLabels)
{
var result = new SuggestLabelsResponse();
// Simple heuristic: check which available labels appear in response
foreach (var label in availableLabels)
{
if (response.Contains(label, StringComparison.OrdinalIgnoreCase))
{
result.Suggestions.Add(new LabelSuggestion
{
Label = label,
Confidence = 0.8f,
Reason = "Label mentioned in AI analysis"
});
}
}
return result;
return protoResponse;
}
}

View File

@@ -1,8 +1,10 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: BSL-1.1
using System.Text.Json;
using GitCaddy.AI.Proto;
using GitCaddy.AI.Service.Configuration;
using GitCaddy.AI.Service.Plugins;
using Grpc.Core;
using Microsoft.Extensions.Options;
@@ -118,37 +120,18 @@ public class WorkflowService : IWorkflowService
try
{
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt("""
You are a helpful assistant executing tasks for a software development workflow.
Complete the requested task and provide clear results.
If you cannot complete the task, explain why.
""")
.Build();
var context = string.Join("\n", request.Context.Select(c => $"{c.Key}: {c.Value}"));
var prompt = $"""
Task: {request.Task}
Context:
{context}
Allowed tools: {string.Join(", ", request.AllowedTools)}
Please complete this task.
""";
var aiResponse = await conversation.SendAsync(prompt, cancellationToken);
return new ExecuteTaskResponse
return request.Task switch
{
Success = true,
Result = aiResponse.FinalMessage ?? ""
"landing_page_generate" => await ExecuteLandingPageGenerateAsync(request, cancellationToken),
"landing_page_translate" => await ExecuteLandingPageTranslateAsync(request, cancellationToken),
"ab_test_generate" => await ExecuteABTestGenerateAsync(request, cancellationToken),
"ab_test_analyze" => await ExecuteABTestAnalyzeAsync(request, cancellationToken),
_ => await ExecuteGenericTaskAsync(request, cancellationToken)
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Task execution failed");
_logger.LogError(ex, "Task execution failed: {Task}", request.Task);
return new ExecuteTaskResponse
{
@@ -158,6 +141,250 @@ public class WorkflowService : IWorkflowService
}
}
/// <summary>
/// Generates landing page content using structured output via the LandingPageContentPlugin.
/// The AI is forced (via tool_choice) to call the plugin, ensuring clean structured data
/// instead of free-form text that requires parsing.
/// </summary>
private async Task<ExecuteTaskResponse> ExecuteLandingPageGenerateAsync(
ExecuteTaskRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation("Generating landing page content via structured output plugin");
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt("""
You are a landing page copywriter for open-source software projects.
Analyze the repository metadata and generate compelling, professional landing page content.
Use action-oriented, benefit-focused copy. Keep it professional but engaging.
""")
.RegisterPlugin<LandingPageContentPlugin>()
.Build();
var ctx = request.Context;
var prompt = $"""
Generate landing page content for this repository:
Name: {GetContextValue(ctx, "repo_name")}
Description: {GetContextValue(ctx, "repo_description")}
Topics: {GetContextValue(ctx, "topics")}
Primary Language: {GetContextValue(ctx, "primary_language")}
Stars: {GetContextValue(ctx, "stars", "0")}
Forks: {GetContextValue(ctx, "forks", "0")}
README:
{GetContextValue(ctx, "readme")}
""";
var response = await conversation.SendForStructuredOutputAsync<LandingPageContentResult>(
prompt, "GenerateLandingPageContent", cancellationToken);
var json = SerializeStructuredOutput(response.StructuredOutput);
return new ExecuteTaskResponse
{
Success = true,
Result = json
};
}
/// <summary>
/// Translates landing page content using structured output via the LandingPageTranslationPlugin.
/// The AI is forced (via tool_choice) to call the plugin, ensuring clean structured data.
/// </summary>
private async Task<ExecuteTaskResponse> ExecuteLandingPageTranslateAsync(
ExecuteTaskRequest request, CancellationToken cancellationToken)
{
var ctx = request.Context;
var targetLanguage = GetContextValue(ctx, "target_language");
var targetCode = GetContextValue(ctx, "target_code");
_logger.LogInformation("Translating landing page content to {Language} ({Code}) via structured output plugin",
targetLanguage, targetCode);
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt($"""
You are a professional translator specializing in marketing copy.
Translate landing page content to {targetLanguage} ({targetCode}) with natural,
culturally-adapted phrasing. Do NOT translate brand names, technical terms, or URLs.
Use marketing-quality translations, not literal/robotic ones.
""")
.RegisterPlugin<LandingPageTranslationPlugin>()
.Build();
var prompt = $"""
Translate the following landing page content to {targetLanguage} ({targetCode}).
Maintain the exact same number of items in arrays (stats, value_props, features).
Adapt idioms and expressions for the target culture.
Source content:
{GetContextValue(ctx, "source_content")}
""";
var response = await conversation.SendForStructuredOutputAsync<LandingPageTranslationResult>(
prompt, "TranslateLandingPageContent", cancellationToken);
var json = SerializeStructuredOutput(response.StructuredOutput);
return new ExecuteTaskResponse
{
Success = true,
Result = json
};
}
/// <summary>
/// Generates A/B test experiment variants using structured output via the ABTestGeneratePlugin.
/// </summary>
private async Task<ExecuteTaskResponse> ExecuteABTestGenerateAsync(
ExecuteTaskRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation("Generating A/B test experiment via structured output plugin");
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt("""
You are a conversion rate optimization expert. Analyze landing page configurations
and create meaningful A/B test experiments that focus on high-impact changes.
Generate variants that test different headlines, CTAs, value propositions, or layouts.
Each variant should be meaningfully different but plausible.
The control variant (original config) is added automatically do NOT include it.
""")
.RegisterPlugin<ABTestGeneratePlugin>()
.Build();
var ctx = request.Context;
var prompt = $"""
Analyze this landing page config and create an A/B test experiment.
Focus on high-impact changes: headlines, CTAs, value propositions.
Generate 1-3 test variants. Each variant's config_override should be a partial
LandingConfig with only the fields that differ from the control.
Repository: {GetContextValue(ctx, "repo_name")}
Description: {GetContextValue(ctx, "repo_description")}
Current landing page config:
{GetContextValue(ctx, "landing_config")}
""";
var response = await conversation.SendForStructuredOutputAsync<ABTestGenerateResult>(
prompt, "GenerateABTestExperiment", cancellationToken);
var json = SerializeStructuredOutput(response.StructuredOutput);
return new ExecuteTaskResponse
{
Success = true,
Result = json
};
}
/// <summary>
/// Analyzes A/B test experiment results using structured output via the ABTestAnalyzePlugin.
/// </summary>
private async Task<ExecuteTaskResponse> ExecuteABTestAnalyzeAsync(
ExecuteTaskRequest request, CancellationToken cancellationToken)
{
_logger.LogInformation("Analyzing A/B test results via structured output plugin");
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt("""
You are a data analyst specializing in A/B test evaluation.
Analyze experiment results using statistical methods to determine significance.
Require at least 100 impressions per variant before declaring a winner.
Use a minimum 95% confidence threshold for statistical significance.
Be conservative only declare a winner when the data clearly supports it.
""")
.RegisterPlugin<ABTestAnalyzePlugin>()
.Build();
var ctx = request.Context;
var prompt = $"""
Analyze these A/B test results. Look at conversion rates, impression counts,
and event distributions across variants. Determine if there is a statistically
significant winner.
Experiment:
{GetContextValue(ctx, "experiment")}
Variants with metrics:
{GetContextValue(ctx, "variants")}
""";
var response = await conversation.SendForStructuredOutputAsync<ABTestAnalyzeResult>(
prompt, "AnalyzeABTestResults", cancellationToken);
var json = SerializeStructuredOutput(response.StructuredOutput);
return new ExecuteTaskResponse
{
Success = true,
Result = json
};
}
/// <summary>
/// Fallback for tasks that don't have a dedicated plugin.
/// Uses generic SendAsync with free-form text response.
/// </summary>
private async Task<ExecuteTaskResponse> ExecuteGenericTaskAsync(
ExecuteTaskRequest request, CancellationToken cancellationToken)
{
var conversation = _providerFactory.CreateConversation()
.WithSystemPrompt("""
You are a helpful assistant executing tasks for a software development workflow.
Complete the requested task and provide clear results.
If you cannot complete the task, explain why.
""")
.Build();
var context = string.Join("\n", request.Context.Select(c => $"{c.Key}: {c.Value}"));
var prompt = $"""
Task: {request.Task}
Context:
{context}
Allowed tools: {string.Join(", ", request.AllowedTools)}
Please complete this task.
""";
var aiResponse = await conversation.SendAsync(prompt, cancellationToken);
return new ExecuteTaskResponse
{
Success = true,
Result = aiResponse.FinalMessage ?? ""
};
}
/// <summary>
/// Safely serializes StructuredOutput to JSON regardless of its runtime type.
/// </summary>
private static string SerializeStructuredOutput(object structuredOutput)
{
if (structuredOutput == null)
return "{}";
if (structuredOutput is string str)
return str;
if (structuredOutput is JsonElement jsonElement)
return jsonElement.GetRawText();
return JsonSerializer.Serialize(structuredOutput, structuredOutput.GetType());
}
/// <summary>
/// Gets a value from the request context map with a default fallback.
/// </summary>
private static string GetContextValue(
Google.Protobuf.Collections.MapField<string, string> context,
string key, string defaultValue = "")
{
return context.TryGetValue(key, out var value) ? value : defaultValue;
}
private static string GetWorkflowSystemPrompt(string workflowType)
{
return workflowType switch