Skip to content

Commit 23bb076

Browse files
authored
Merge pull request peopleworks#33 from peopleworks/feat/walkthrough-narration
Let a model explain the steps, and nothing else
2 parents f816bf5 + d4e6dc9 commit 23bb076

6 files changed

Lines changed: 501 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5555
guess whether it has them all — and the guess that stops one atom early produces a confident
5656
answer with a step missing from it.
5757

58+
- **`xaflogic walkthrough --narrate`** ([#23], phase 3). Opt-in prose over a walk that has already
59+
been computed: one paragraph on what the process is for, and a sentence or two under each step,
60+
each sitting directly beneath the citation it belongs to.
61+
**The model narrates; it does not discover.** It receives the numbered steps and the code behind
62+
them, and the only thing that reaches a reader is a paragraph it managed to key to a step that
63+
exists — a paragraph keyed to step 99 of a nine-step process is dropped before rendering, and so
64+
is fluent prose attached to no step at all. The point is not that such a sentence would probably
65+
be wrong; it is that nobody could check it, and an ordinary reader cannot tell a fluent sentence
66+
about real code from a fluent sentence about code that is not there.
67+
The model is also told what the walk could not follow, so it does not narrate its way over the one
68+
gap the analysis already knows about.
69+
Failure costs prose and not the document: no key, or a provider that does not answer, prints why
70+
and writes the walkthrough anyway. Phases 1 and 2 stand entirely on their own, which is what makes
71+
the model optional rather than load-bearing — and a test pins that a document generated with an
72+
empty narration is byte-for-byte the one generated with none.
73+
`XafLogicExplainer.Core` still references nothing but Roslyn: narration arrives at the generator
74+
as plain text keyed to steps that already exist.
75+
5876
### Fixed
5977

6078
- **An appearance rule written on a property is read** ([#21], thanks [@MBrekhof]).
@@ -127,7 +145,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
127145
- New fixture, `WalkthroughSolution`, whose proportions are its point: one action, one handler, and a
128146
`Recalculate` that two controllers override — so a walk reporting only what it can resolve
129147
produces a confident, complete-looking account of a process whose body it never saw. No existing
130-
fixture could reach that case. 373 tests.
148+
fixture could reach that case. 382 tests.
131149

132150
[#23]: https://github.com/peopleworks/XAFLogicExplainer/issues/23
133151
[#24]: https://github.com/peopleworks/XAFLogicExplainer/issues/24

README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,21 @@ Documentation is generated in **English or Spanish** (`--lang en|es`).
297297
Useful flags: `--orm auto\|xpo\|efcore`, `--lang en\|es`, `--enrich` (AI-generated business-logic
298298
summaries per controller and action), `--force`, `--all`.
299299

300-
`--enrich` needs a model, and **any of these is enough** — a key on the command line wins, then the
301-
environment, then a PeopleWorks Copilot account if you happen to have one:
300+
### Tracing one process
301+
302+
```bash
303+
xaflogic walkthrough --from ApproveOrder # to the screen, or > process.md
304+
xaflogic walkthrough --from ApproveOrder --depth 4 --out docs/approval.md
305+
```
306+
307+
What runs, in what order, which entities it touches and which rules govern them — every step citing
308+
`file:line`, with a Mermaid diagram **emitted from the trace itself, never drawn by a model.** Calls
309+
the trace could not follow are listed rather than skipped, so an empty list means the path really is
310+
complete. Add `--narrate` for prose over the steps; a paragraph that cannot name a real step is
311+
dropped before you see it.
312+
313+
`--enrich` and `--narrate` need a model, and **any of these is enough** — a key on the command line
314+
wins, then the environment, then a PeopleWorks Copilot account if you happen to have one:
302315

303316
```bash
304317
xaflogic extract --enrich --api-key sk-... # or any OpenAI-compatible endpoint:
@@ -308,7 +321,8 @@ export OPENAI_API_KEY=sk-... # picked up with no configuration at all
308321
export ANTHROPIC_API_KEY=sk-ant-...
309322
```
310323

311-
Everything else in this tool runs with no key, no account and no network.
324+
Everything else in this tool runs with no key, no account and no network — the walkthrough
325+
included, minus its prose.
312326

313327
Extraction is **incremental** — a SHA-256 over your `.cs` and `.xafml` files means an unchanged
314328
project is a no-op. There is an MSBuild `.targets` file if you want it to run on build.
@@ -332,7 +346,7 @@ applications. The agent-facing surface is what is landing now, in the open.
332346
|| Pluggable publishing targets (`IDocumentationSink`) |
333347
|| **MCP server** — 11 tools, live against your source |
334348
|| **Installable Claude Code plugin** with skill and MCP server |
335-
|| **373 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
349+
|| **382 tests** over synthetic XPO and EF Core fixtures — no DevExpress needed |
336350
|| **DevExpress ground-truth catalog**, generated locally by licensees |
337351

338352
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the

src/XafLogicExplainer.Cli/Program.cs

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,12 +1414,14 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Comparing sna
14141414
};
14151415
var depthOption = new Option<int>("--depth", () => 3, "How many hops from the seed to follow");
14161416
var walkthroughOutOption = new Option<string?>("--out", "Write the walkthrough to this file instead of the screen");
1417+
var narrateOption = new Option<bool>("--narrate", "Have a model explain each step in business terms");
14171418

14181419
walkthroughCommand.AddOption(fromOption);
14191420
walkthroughCommand.AddOption(depthOption);
14201421
walkthroughCommand.AddOption(walkthroughOutOption);
1422+
walkthroughCommand.AddOption(narrateOption);
14211423

1422-
walkthroughCommand.SetHandler((projectPath, language, orm, from, depth, outFile) =>
1424+
walkthroughCommand.SetHandler(async (projectPath, language, orm, from, depth, outFile, narrate) =>
14231425
{
14241426
var config = ConfigHelper.Load();
14251427
projectPath ??= config.ProjectPath;
@@ -1440,7 +1442,6 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Comparing sna
14401442
});
14411443

14421444
var slice = ProcessSlice.From(walked!, from!, depth);
1443-
var document = new WalkthroughGenerator(language).Generate(walked!, slice);
14441445

14451446
// A seed that matched nothing is a failed run, not a document with a sad paragraph in it. The
14461447
// slice already says what it looked for and what came closest, so print that and stop.
@@ -1450,6 +1451,13 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Comparing sna
14501451
return;
14511452
}
14521453

1454+
IReadOnlyDictionary<int, string>? narration = null;
1455+
1456+
if (narrate)
1457+
narration = await NarrateWalkthrough(walked!, slice, config, language, aiOverrides);
1458+
1459+
var document = new WalkthroughGenerator(language).Generate(walked!, slice, narration);
1460+
14531461
if (string.IsNullOrEmpty(outFile))
14541462
{
14551463
// Straight to stdout rather than through AnsiConsole, which wraps at the terminal width and
@@ -1473,7 +1481,7 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Comparing sna
14731481
AnsiConsole.MarkupLine(
14741482
$"[yellow]![/] {slice.Unresolved.Count} call(s) the walk could not follow — listed in the document.");
14751483
}
1476-
}, projectPathOption, languageOption, ormOption, fromOption, depthOption, walkthroughOutOption);
1484+
}, projectPathOption, languageOption, ormOption, fromOption, depthOption, walkthroughOutOption, narrateOption);
14771485

14781486
rootCommand.AddCommand(walkthroughCommand);
14791487

@@ -2124,4 +2132,59 @@ await enricher.EnrichAsync(project, language, msg =>
21242132
AnsiConsole.MarkupLine($"[green] Enriched {enrichedControllers}/{project.Controllers.Count} controllers, {enrichedActions} actions[/]");
21252133
}
21262134

2135+
// Asks a model to explain a walk that has already been computed.
2136+
//
2137+
// Returns nothing rather than failing the run. The document is worth reading with no narration in
2138+
// it at all -- the diagram, the steps and their citations are the part that was never going to be
2139+
// wrong -- so a missing key, or a provider that did not answer, costs prose and not the walkthrough.
2140+
static async Task<IReadOnlyDictionary<int, string>?> NarrateWalkthrough(
2141+
ExtractedProject project, ProcessSlice slice, CliConfig config, string language,
2142+
AiClientRequest aiOverrides)
2143+
{
2144+
var request = aiOverrides with
2145+
{
2146+
Copilot = new SyncConfiguration
2147+
{
2148+
CopilotApiBaseUrl = config.ApiUrl ?? "",
2149+
CopilotApiToken = config.Token ?? "",
2150+
UserName = config.UserName ?? "xaf-logic-explainer",
2151+
ResourceName = config.ResourceName ?? "",
2152+
},
2153+
};
2154+
2155+
var resolved = await AiClientResolver.ResolveAsync(request);
2156+
2157+
if (!resolved.Succeeded)
2158+
{
2159+
foreach (var line in (resolved.Problem ?? AiClientResolver.NothingConfigured).Split('\n'))
2160+
AnsiConsole.MarkupLine($"[yellow] {Markup.Escape(line)}[/]");
2161+
2162+
AnsiConsole.MarkupLine("[yellow] Writing the walkthrough without narration.[/]");
2163+
2164+
return null;
2165+
}
2166+
2167+
AnsiConsole.MarkupLine(
2168+
$"[blue]AI:[/] {Markup.Escape(resolved.ProviderName)} / {Markup.Escape(resolved.Model)}");
2169+
2170+
try
2171+
{
2172+
var narration = await new WalkthroughNarrator(resolved.Client!)
2173+
.NarrateAsync(project, slice, language);
2174+
2175+
AnsiConsole.MarkupLine(
2176+
$"[green] {narration.Count} of {slice.Edges.Count + 1} paragraphs kept[/] "
2177+
+ "[grey](a paragraph that cannot name a real step is dropped)[/]");
2178+
2179+
return narration;
2180+
}
2181+
catch (Exception ex)
2182+
{
2183+
AnsiConsole.MarkupLine($"[yellow] The model did not answer: {Markup.Escape(ex.Message)}[/]");
2184+
AnsiConsole.MarkupLine("[yellow] Writing the walkthrough without narration.[/]");
2185+
2186+
return null;
2187+
}
2188+
}
2189+
21272190
return await rootCommand.InvokeAsync(args);
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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

Comments
 (0)