|
| 1 | +using System.Globalization; |
| 2 | +using System.Text; |
| 3 | +using Microsoft.Extensions.AI; |
| 4 | +using XafLogicExplainer.Core.Models; |
| 5 | +using XafLogicExplainer.Core.Walkthrough; |
| 6 | + |
| 7 | +namespace XafLogicExplainer.CopilotSync.Ai; |
| 8 | + |
| 9 | +/// <summary> |
| 10 | +/// Writes prose over a walk that has already been computed. |
| 11 | +/// </summary> |
| 12 | +/// <remarks> |
| 13 | +/// The model narrates; it does not discover. It receives the steps the walk found, in order, with |
| 14 | +/// the code behind them, and is asked what each one means in business terms. It cannot add a step, |
| 15 | +/// because the only thing that reaches the document is a line it managed to key to a step that |
| 16 | +/// exists — anything else is dropped here, before it can be read. |
| 17 | +/// <para> |
| 18 | +/// That is the difference between prose over a structure and prose instead of one. A model asked to |
| 19 | +/// explain "the approval process" from scratch will produce something fluent, complete-looking and |
| 20 | +/// unverifiable. Given a fixed set of numbered steps, the worst it can do is describe one of them |
| 21 | +/// badly, which a reader can see and correct. |
| 22 | +/// </para> |
| 23 | +/// <para> |
| 24 | +/// Failure is graceful and silent in only one direction: no narration, structure intact. The |
| 25 | +/// document is worth reading without a word of this. |
| 26 | +/// </para> |
| 27 | +/// </remarks> |
| 28 | +public sealed class WalkthroughNarrator |
| 29 | +{ |
| 30 | + /// <summary>Longest method body sent for one step.</summary> |
| 31 | + private const int MaxBodyLength = 2000; |
| 32 | + |
| 33 | + private readonly IChatClient _chatClient; |
| 34 | + |
| 35 | + /// <summary>Creates the narrator over a resolved chat client.</summary> |
| 36 | + public WalkthroughNarrator(IChatClient chatClient) => _chatClient = chatClient; |
| 37 | + |
| 38 | + /// <summary> |
| 39 | + /// Returns prose keyed by step number, with <c>0</c> being the opening paragraph. |
| 40 | + /// </summary> |
| 41 | + /// <remarks> |
| 42 | + /// Empty when the model said nothing usable, which the caller renders as a document with no |
| 43 | + /// narration rather than as an error. |
| 44 | + /// </remarks> |
| 45 | + public async Task<IReadOnlyDictionary<int, string>> NarrateAsync( |
| 46 | + ExtractedProject project, |
| 47 | + ProcessSlice slice, |
| 48 | + string languageCode = "es", |
| 49 | + CancellationToken cancellationToken = default) |
| 50 | + { |
| 51 | + if (!slice.Found || slice.Edges.Count == 0) |
| 52 | + return new Dictionary<int, string>(); |
| 53 | + |
| 54 | + var response = await _chatClient.GetResponseAsync( |
| 55 | + Prompt(project, slice, languageCode), cancellationToken: cancellationToken); |
| 56 | + |
| 57 | + return Parse(response.Text, slice.Edges.Count); |
| 58 | + } |
| 59 | + |
| 60 | + private static string Prompt(ExtractedProject project, ProcessSlice slice, string languageCode) |
| 61 | + { |
| 62 | + var language = languageCode == "en" ? "English" : "Spanish"; |
| 63 | + var byId = slice.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); |
| 64 | + var sb = new StringBuilder(); |
| 65 | + |
| 66 | + sb.AppendLine($"You are documenting one business process in the XAF application \"{project.ProjectName}\"."); |
| 67 | + sb.AppendLine(); |
| 68 | + sb.AppendLine("A static analysis already traced the process. Below are its steps, in order, with the"); |
| 69 | + sb.AppendLine("code behind them. Explain what each step means in business terms — what it is for, what"); |
| 70 | + sb.AppendLine("it decides, what it would mean for a user if it were removed."); |
| 71 | + sb.AppendLine(); |
| 72 | + sb.AppendLine("RULES, all of them strict:"); |
| 73 | + sb.AppendLine("- Write one line per step, in this exact format: N| your sentence or two"); |
| 74 | + sb.AppendLine("- N must be a step number from the list. Never invent a number."); |
| 75 | + sb.AppendLine("- Line 0 is one short paragraph on what the whole process is for."); |
| 76 | + sb.AppendLine("- Say nothing the code below does not support. No speculation about intent."); |
| 77 | + sb.AppendLine("- Skip a step you have nothing useful to say about. A missing line is fine."); |
| 78 | + sb.AppendLine("- No markdown, no bullet points, no headings, no code. Plain sentences."); |
| 79 | + sb.AppendLine($"- Write in {language}."); |
| 80 | + sb.AppendLine(); |
| 81 | + sb.AppendLine($"PROCESS: {slice.Seed}"); |
| 82 | + sb.AppendLine(); |
| 83 | + |
| 84 | + var step = 0; |
| 85 | + |
| 86 | + foreach (var edge in slice.Edges) |
| 87 | + { |
| 88 | + if (!byId.TryGetValue(edge.From, out var from) || !byId.TryGetValue(edge.To, out var to)) |
| 89 | + continue; |
| 90 | + |
| 91 | + sb.AppendLine($"{++step}. {from.Name} — {edge.Kind} → {to.Name} ({to.Kind})"); |
| 92 | + |
| 93 | + if (Detail(project, to) is { Length: > 0 } detail) |
| 94 | + sb.AppendLine(detail); |
| 95 | + } |
| 96 | + |
| 97 | + if (slice.Unresolved.Count > 0) |
| 98 | + { |
| 99 | + sb.AppendLine(); |
| 100 | + sb.AppendLine("The analysis could not follow these calls, so do not claim to know what they do:"); |
| 101 | + |
| 102 | + foreach (var call in slice.Unresolved) |
| 103 | + sb.AppendLine($"- {call.CallName}: {string.Join(", ", call.Candidates)}"); |
| 104 | + } |
| 105 | + |
| 106 | + return sb.ToString(); |
| 107 | + } |
| 108 | + |
| 109 | + /// <summary>The code or the declaration behind one node, for the model to read.</summary> |
| 110 | + private static string Detail(ExtractedProject project, SliceNode node) |
| 111 | + { |
| 112 | + switch (node.Kind) |
| 113 | + { |
| 114 | + case SliceNodeKind.Method when node.Owner is { Length: > 0 } owner: |
| 115 | + { |
| 116 | + var method = project.Controllers |
| 117 | + .FirstOrDefault(controller => controller.ClassName == owner)?.Methods |
| 118 | + .FirstOrDefault(m => $"{owner}.{m.Name}" == node.Name); |
| 119 | + |
| 120 | + return method is null || method.Body.Length == 0 |
| 121 | + ? "" |
| 122 | + : " ```\n " + Cap(method.Body).Replace("\n", "\n ", StringComparison.Ordinal) + "\n ```"; |
| 123 | + } |
| 124 | + |
| 125 | + case SliceNodeKind.Entity: |
| 126 | + { |
| 127 | + var entity = project.Entities.FirstOrDefault(e => e.ClassName == node.Name); |
| 128 | + |
| 129 | + return entity is null |
| 130 | + ? "" |
| 131 | + : " properties: " + string.Join(", ", |
| 132 | + entity.Properties.Take(15).Select(p => $"{p.Name} {p.TypeName}")); |
| 133 | + } |
| 134 | + |
| 135 | + case SliceNodeKind.ValidationRule or SliceNodeKind.AppearanceRule when node.Owner is { Length: > 0 } owner: |
| 136 | + { |
| 137 | + var entity = project.Entities.FirstOrDefault(e => e.ClassName == owner); |
| 138 | + var criteria = entity?.ValidationRules |
| 139 | + .FirstOrDefault(rule => rule.Id == node.Name)?.TargetCriteria; |
| 140 | + |
| 141 | + return criteria is { Length: > 0 } ? $" criteria: {criteria}" : ""; |
| 142 | + } |
| 143 | + |
| 144 | + default: |
| 145 | + return ""; |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + private static string Cap(string code) => |
| 150 | + code.Length <= MaxBodyLength ? code : code[..MaxBodyLength]; |
| 151 | + |
| 152 | + /// <summary> |
| 153 | + /// Keeps the lines that name a step that exists, and drops everything else. |
| 154 | + /// </summary> |
| 155 | + /// <remarks> |
| 156 | + /// The enforcement, and the reason the model cannot widen the account it was given. A paragraph |
| 157 | + /// keyed to step 12 of an eight-step process is not a step this walk found, so whatever it says |
| 158 | + /// never reaches a reader — the point is not that the sentence is probably wrong, it is that |
| 159 | + /// nobody could check it. |
| 160 | + /// </remarks> |
| 161 | + private static Dictionary<int, string> Parse(string? text, int steps) |
| 162 | + { |
| 163 | + var narration = new Dictionary<int, string>(); |
| 164 | + |
| 165 | + if (string.IsNullOrWhiteSpace(text)) |
| 166 | + return narration; |
| 167 | + |
| 168 | + foreach (var line in text.Split('\n')) |
| 169 | + { |
| 170 | + var separator = line.IndexOf('|'); |
| 171 | + |
| 172 | + if (separator <= 0) |
| 173 | + continue; |
| 174 | + |
| 175 | + if (!int.TryParse(line[..separator].Trim(), NumberStyles.Integer, |
| 176 | + CultureInfo.InvariantCulture, out var step)) |
| 177 | + { |
| 178 | + continue; |
| 179 | + } |
| 180 | + |
| 181 | + if (step < 0 || step > steps) |
| 182 | + continue; |
| 183 | + |
| 184 | + var prose = line[(separator + 1)..].Trim(); |
| 185 | + |
| 186 | + // First line wins, so a model that repeats itself cannot append to a step. |
| 187 | + if (prose.Length > 0) |
| 188 | + narration.TryAdd(step, prose); |
| 189 | + } |
| 190 | + |
| 191 | + return narration; |
| 192 | + } |
| 193 | +} |
0 commit comments