Skip to content

Commit 46fa5b2

Browse files
authored
Merge pull request #52 from peopleworks/report-evidence-by-weight
Sort the evidence before cutting it, and stop the Markdown carrying live HTML
2 parents 020554d + 07c82f6 commit 46fa5b2

5 files changed

Lines changed: 321 additions & 16 deletions

File tree

src/SignsOfAI.Core/Reporting/EvidenceReport.cs

Lines changed: 130 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
4949
? text.Get(ReportMessages.DefaultTitle).Text
5050
: o.Title;
5151

52-
sb.Append("# ").Append(title).AppendLine();
52+
// Through Cell like everything else this report did not write: the title is a caller's string,
53+
// and every host that has one builds it from a filename.
54+
sb.Append("# ").Append(Cell(title)).AppendLine();
5355
sb.AppendLine();
5456
var fallbackNoticeAt = sb.Length;
5557
if (!string.IsNullOrWhiteSpace(o.DocumentName))
@@ -144,7 +146,7 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
144146
{
145147
AppendHeading(sb, text, 3, ReportMessages.SectionCharacters);
146148
sb.AppendLine();
147-
sb.AppendLine(result.Artifacts.Summary);
149+
sb.AppendLine(Cell(result.Artifacts.Summary));
148150
sb.AppendLine();
149151
// The heading used to say "characters writing does not produce", which is false for
150152
// half of what this table lists: Word makes soft hyphens on its own and a stray
@@ -155,7 +157,15 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
155157
sb.AppendLine();
156158
AppendBlock(sb, text, ReportMessages.CharactersTableHeader);
157159
sb.AppendLine("|---|---|---:|---:|");
158-
foreach (var occurrence in result.Artifacts.Occurrences.Take(o.MaxRows))
160+
// Strong kinds first, then by position. A file can hold two hundred soft hyphens —
161+
// Word inserts them unprompted — and one letter borrowed from another alphabet. In
162+
// document order the innocent two hundred fill the table and the one occurrence that
163+
// is hard to arrive at by accident falls off the end. IsStrong is the scanner's own
164+
// published distinction, not a new judgement invented for the page.
165+
foreach (var occurrence in result.Artifacts.Occurrences
166+
.OrderByDescending(a => a.IsStrong)
167+
.ThenBy(a => a.Line).ThenBy(a => a.Column)
168+
.Take(o.MaxRows))
159169
sb.Append("| ").Append(Cell(Describe(occurrence.Kind))).Append(" | `")
160170
.Append(occurrence.CodePoint).Append("` | ").Append(occurrence.Line)
161171
.Append(" | ").Append(occurrence.Column).AppendLine(" |");
@@ -172,10 +182,19 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
172182
{
173183
AppendHeading(sb, text, 3, ReportMessages.SectionCitations);
174184
sb.AppendLine();
175-
sb.AppendLine(result.Citations.Summary);
185+
sb.AppendLine(Cell(result.Citations.Summary));
176186
sb.AppendLine();
177187
foreach (var issue in result.Citations.Issues.Take(o.MaxRows))
178188
sb.Append("- ").AppendLine(Cell(issue.Message));
189+
// This list used to stop at forty in silence, alone among the four. A reader counting
190+
// the contradictions on the page against the number the summary above states would
191+
// find the page contradicting itself about a document accused of contradicting itself.
192+
if (result.Citations.Issues.Count > o.MaxRows)
193+
{
194+
sb.AppendLine();
195+
AppendBlock(sb, text, ReportMessages.MoreRows,
196+
result.Citations.Issues.Count - o.MaxRows);
197+
}
179198
sb.AppendLine();
180199
// Only claimed when something actually contradicts. The first version printed it
181200
// whenever there was anything to say about sources at all — including "no reference
@@ -199,7 +218,37 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
199218
}
200219
else
201220
{
202-
foreach (var f in result.Signals.Take(o.MaxRows))
221+
// Strongest first, and the page says so. The analyser returns findings in the order they
222+
// occur in the text, because that is what highlighting and the rewriter need; printing
223+
// them that way and then cutting at forty meant a long document could spend the whole
224+
// list on weak hits in its opening pages while the finding that did most to produce the
225+
// headline number sat in the last paragraph, omitted. The reader was then given a score
226+
// the visible evidence could not account for — in the one document this project builds
227+
// for somebody to take into a room where a decision is made about a person.
228+
//
229+
// Weight is the finding's own contribution to the score. Ties are broken by how many
230+
// times that rule has already appeared, and only then by position: sixteen English rules
231+
// share the weight 3.5, and in a tie the reader is better served by one occurrence of
232+
// each before any second occurrence than by one rule's run. It reorders strictly within
233+
// equal weight, so it costs the guarantee nothing — everything omitted still weighs no
234+
// more than everything shown, which is what the line below is entitled to say.
235+
//
236+
// A rule that genuinely outweighs the rest still fills the list with its own repeats,
237+
// and that is the honest picture: fourteen occurrences of one word are where such a
238+
// document's number actually comes from. Whether the section should collapse them into
239+
// a count, as the observations section does, is a question about its shape rather than
240+
// about which evidence it drops.
241+
AppendBlock(sb, text, ReportMessages.SignalsOrdered);
242+
sb.AppendLine();
243+
foreach (var f in result.Signals
244+
.GroupBy(f => f.RuleId)
245+
.SelectMany(g => g.OrderBy(f => f.Span.Start)
246+
.Select((f, rank) => (Finding: f, Rank: rank)))
247+
.OrderByDescending(x => x.Finding.Weight)
248+
.ThenBy(x => x.Rank)
249+
.ThenBy(x => x.Finding.Span.Start)
250+
.Select(x => x.Finding)
251+
.Take(o.MaxRows))
203252
{
204253
sb.Append("- **").Append(f.Category).Append("** — ");
205254
if (!string.IsNullOrWhiteSpace(f.MatchedText))
@@ -209,7 +258,10 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
209258
if (result.Signals.Count > o.MaxRows)
210259
{
211260
sb.AppendLine();
212-
AppendBlock(sb, text, ReportMessages.MoreRows, result.Signals.Count - o.MaxRows);
261+
// Not the generic "… and N more": what was cut is now a property of the evidence
262+
// rather than of where it happened to fall, and the reader is entitled to know that
263+
// nothing stronger than what they are looking at was left out.
264+
AppendBlock(sb, text, ReportMessages.SignalsMore, result.Signals.Count - o.MaxRows);
213265
}
214266
}
215267
sb.AppendLine();
@@ -220,11 +272,23 @@ public static string ToMarkdown(AnalysisResult result, ReportOptions? options =
220272
sb.AppendLine();
221273
AppendBlock(sb, text, ReportMessages.ObservationsIntro);
222274
sb.AppendLine();
223-
foreach (var group in result.Observations.GroupBy(f => f.RuleId).Take(o.MaxRows))
275+
// Most frequent first, for the same reason as the signals list: a rule used thirty times
276+
// is the one the reader wants to see, and in rule-id order it can be pushed out by
277+
// twenty rules that fired once. The id comes from a rule pack, which is JSON anyone can
278+
// contribute, so it goes through Cell like any other text this report did not write.
279+
var groups = result.Observations.GroupBy(f => f.RuleId)
280+
.OrderByDescending(g => g.Count()).ThenBy(g => g.Key, StringComparer.Ordinal)
281+
.ToList();
282+
foreach (var group in groups.Take(o.MaxRows))
224283
AppendBlock(sb, text, group.Count() == 1
225284
? ReportMessages.ObservationsRowOne
226285
: ReportMessages.ObservationsRowOther,
227-
group.Key, group.Count());
286+
Cell(group.Key), group.Count());
287+
if (groups.Count > o.MaxRows)
288+
{
289+
sb.AppendLine();
290+
AppendBlock(sb, text, ReportMessages.MoreRows, groups.Count - o.MaxRows);
291+
}
228292
sb.AppendLine();
229293
}
230294

@@ -293,7 +357,7 @@ public static string FolderToMarkdown(
293357
var unreadable = entries.Where(e => e.Error is not null).ToList();
294358
var scored = entries.Where(e => e.Error is null && e.Score is not null).ToList();
295359

296-
sb.Append("# ").AppendLine(title);
360+
sb.Append("# ").AppendLine(Cell(title));
297361
sb.AppendLine();
298362
var fallbackNoticeAt = sb.Length;
299363
AppendBlock(sb, text, ReportMessages.MetaFolder, Cell(folderName));
@@ -596,19 +660,35 @@ private static string Pct(double fraction) =>
596660
CultureInfo.InvariantCulture) + "%";
597661

598662
/// <summary>
599-
/// User content on its way into a Markdown line. Two things it must survive being given: a pipe,
663+
/// User content on its way into a Markdown line. Three things it must survive being given: a pipe,
600664
/// which would open an extra table cell and shift every number one column to the right in a table
601-
/// a teacher reads scores from; and a newline, which would end the list item and let whatever
665+
/// a teacher reads scores from; a newline, which would end the list item and let whatever
602666
/// followed become report prose — a line beginning "## " arrived as a heading, in the report's own
603-
/// voice, from a filename or an extractor's error message.
667+
/// voice, from a filename or an extractor's error message; and a <c>&lt;</c>.
668+
///
669+
/// The last one is why the Markdown form needs escaping at all. <see cref="ToHtml"/> escapes on
670+
/// its way out, so the HTML was never at risk — but Markdown is the form this file documents for
671+
/// pasting into an LMS comment box or a GitHub issue, and both of those render raw HTML embedded
672+
/// in Markdown. A document containing <c>&lt;img src=x onerror=…&gt;</c> reached them intact, and
673+
/// the person pasting it is a teacher who has been told the report is the safe thing to forward.
674+
/// Backslash rather than an entity, so the character survives one escaping and exactly one:
675+
/// <see cref="Inline"/> undoes it before escaping for HTML, and the reader sees what the document
676+
/// actually said. Reproducing the matched text exactly is the claim this product rests on.
604677
///
605-
/// Matched text is user content by definition, and so is anything a community rule pack matches,
606-
/// which is JSON anybody can contribute.
678+
/// Matched text is user content by definition, and so is anything a community rule pack matches
679+
/// or names, which is JSON anybody can contribute.
607680
/// </summary>
608681
private static string Cell(string? text) =>
609682
string.IsNullOrEmpty(text)
610683
? ""
611-
: text.ReplaceLineEndings(" ").Replace("|", "\\|").Trim();
684+
// The backslash goes first, and it is not decoration. Escaping only the bracket turns a
685+
// document that already contains \< into \\< , which Markdown reads as an escaped
686+
// backslash followed by a live bracket — the escape defeated with one extra character.
687+
: text.ReplaceLineEndings(" ")
688+
.Replace("\\", "\\\\")
689+
.Replace("|", "\\|")
690+
.Replace("<", "\\<")
691+
.Trim();
612692

613693
private static string Escape(string s) =>
614694
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
@@ -716,13 +796,41 @@ private static List<string> SplitRow(string line)
716796
/// <summary>Bold, italic and code, applied after escaping so a document cannot inject markup.</summary>
717797
private static string Inline(string text)
718798
{
719-
var s = Escape(text);
799+
var s = Escape(Unescape(text));
720800
s = Wrap(s, "**", "<strong>", "</strong>");
721801
s = Wrap(s, "`", "<code>", "</code>");
722802
s = Wrap(s, "*", "<em>", "</em>");
723803
return s;
724804
}
725805

806+
/// <summary>
807+
/// Undoes what <see cref="Cell"/> wrote, so the HTML shows the character and not the backslash
808+
/// that protected it in the Markdown. Only the three escapes this file emits: a backslash before
809+
/// anything else came from the document and stays.
810+
///
811+
/// Table cells already lose their <c>\|</c> in <see cref="SplitRow"/>, which has to resolve them
812+
/// before it can tell a real column boundary from a pipe inside a filename; the list items and
813+
/// paragraphs had no such step, so a citation message containing a pipe used to reach the HTML
814+
/// page as <c>\|</c>.
815+
/// </summary>
816+
private static string Unescape(string text)
817+
{
818+
if (!text.Contains('\\')) return text;
819+
820+
var sb = new StringBuilder(text.Length);
821+
for (int i = 0; i < text.Length; i++)
822+
{
823+
if (text[i] == '\\' && i + 1 < text.Length
824+
&& (text[i + 1] is '|' or '<' or '\\'))
825+
{
826+
sb.Append(text[++i]);
827+
continue;
828+
}
829+
sb.Append(text[i]);
830+
}
831+
return sb.ToString();
832+
}
833+
726834
private static string Wrap(string text, string marker, string open, string close)
727835
{
728836
var parts = text.Split(marker);
@@ -762,6 +870,12 @@ public sealed record ReportOptions
762870
/// <summary>
763871
/// Where each list stops. A report meant to be read by a person is worth less at four hundred rows
764872
/// than at forty, and the count of what was left out is printed rather than the rows themselves.
873+
///
874+
/// Every list that this cuts is sorted strongest first before it is cut, and every one of them
875+
/// says how many it left out. Truncating a list the analyser returns in document order silently
876+
/// discards the evidence with the most claim to be on the page — see the signals section. The
877+
/// folder table is deliberately exempt and prints every file: a scan of two hundred essays that
878+
/// omitted the low scorers would be withholding the result that settles a suspicion.
765879
/// </summary>
766880
public int MaxRows { get; init; } = 40;
767881

src/SignsOfAI.Core/Reporting/ReportMessages.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ public static class ReportMessages
8282
public const string CitationsIssuesNote = "citations.issues-note";
8383
public const string CitationsNoIssuesNote = "citations.no-issues-note";
8484
public const string SignalsNone = "signals.none";
85+
86+
/// <summary>
87+
/// Why the list is not in the order of the text. Printed whenever there are signals at all, not
88+
/// only when the list is cut: the order changed for every reader, and one following the report
89+
/// through their student's document would otherwise think the tool had lost its place.
90+
/// </summary>
91+
public const string SignalsOrdered = "signals.ordered";
92+
93+
/// <summary>
94+
/// What the cut left out. Separate from <see cref="MoreRows"/> because here it can say something
95+
/// the generic line cannot: not merely that there is more, but that none of it outweighs what is
96+
/// on the page. That is only true because the list is sorted first.
97+
/// </summary>
98+
public const string SignalsMore = "signals.more";
8599
public const string ObservationsIntro = "observations.intro";
86100
public const string ObservationsRowOne = "observations.row.one";
87101
public const string ObservationsRowOther = "observations.row.other";
@@ -158,6 +172,8 @@ public static class ReportMessages
158172
[CitationsIssuesNote] = 0,
159173
[CitationsNoIssuesNote] = 0,
160174
[SignalsNone] = 0,
175+
[SignalsOrdered] = 0,
176+
[SignalsMore] = 1, // {0} how many were left out
161177
[ObservationsIntro] = 0,
162178
[ObservationsRowOne] = 2,
163179
[ObservationsRowOther] = 2,
@@ -244,6 +260,8 @@ public static class ReportMessages
244260
[CitationsIssuesNote] = "> None of this needed the internet: the document disagrees with itself. It is a question to ask, not a conclusion — the answer is usually one sentence.",
245261
[CitationsNoIssuesNote] = "> Nothing here is a finding. It describes what could and could not be checked.",
246262
[SignalsNone] = "None.",
263+
[SignalsOrdered] = "Ordered by how much each one moved the score, strongest first, rather than by where it appears in the text.",
264+
[SignalsMore] = "… and {0} more, none of which moved the score as much as any of the above.",
247265
[ObservationsIntro] = "Measured against writing published before generative models existed. Shown because they are real, and counted for nothing because they are ordinary.",
248266
[ObservationsRowOne] = "- {0} — {1} occurrence",
249267
[ObservationsRowOther] = "- {0} — {1} occurrences",

src/SignsOfAI.Core/Reporting/report.en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
"citations.issues-note": { "text": "> None of this needed the internet: the document disagrees with itself. It is a question to ask, not a conclusion — the answer is usually one sentence." },
5353
"citations.no-issues-note": { "text": "> Nothing here is a finding. It describes what could and could not be checked." },
5454
"signals.none": { "text": "None." },
55+
"signals.ordered": { "text": "Ordered by how much each one moved the score, strongest first, rather than by where it appears in the text." },
56+
"signals.more": { "text": "… and {0} more, none of which moved the score as much as any of the above." },
5557
"observations.intro": { "text": "Measured against writing published before generative models existed. Shown because they are real, and counted for nothing because they are ordinary." },
5658
"observations.row.one": { "text": "- {0} — {1} occurrence" },
5759
"observations.row.other": { "text": "- {0} — {1} occurrences" },

src/SignsOfAI.Core/Reporting/report.es.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@
4242
"text": "Señales contabilizadas",
4343
"sourceHash": "078671a3b913dc8d830dc433445ca4b4cc401debf4b6fb8829ea9376ab658cc2"
4444
},
45+
"signals.ordered": {
46+
"text": "Ordenadas por cuánto movió cada una la puntuación, de mayor a menor, y no por el lugar que ocupan en el texto.",
47+
"sourceHash": "6328aa4d5b8ee1e8742dad32fd9d8a92fe928d606938710e706be22d0be26f15"
48+
},
49+
"signals.more": {
50+
"text": "… y {0} más, ninguna de las cuales movió la puntuación tanto como las de arriba.",
51+
"sourceHash": "754e025aefdb15ea0b58eb55aecaa82c9da6799259ed0f2c64d33ccac3cfb558"
52+
},
4553
"section.observations": {
4654
"text": "Encontrado, pero a una frecuencia habitual en textos humanos",
4755
"sourceHash": "828c9cc6ac44b392c9e7a358d18a7c9771519012e0c878ac3c02e367c5f75338"

0 commit comments

Comments
 (0)