Skip to content

Commit 8d364a4

Browse files
peopleworksclaude
andcommitted
Extensible catalogs, Humanize diff, and shareable card
The "plus": make detection extensible + two demo money-shots. - Extensible catalogs: RulePack is now composable — Analyze(text, lang, extraPacks) merges custom rule-packs on top of built-ins (override by id). RulePack.FromJson/ ToJson/Merge. Web: a "Custom catalogs" panel — quick-add banned words or import a rule-pack JSON, stored in localStorage, applied live; export the EN pack as a template. CLI: --rules <file.json> (repeatable). 4 new Core tests (19 total). - Humanize before/after: word-level WordDiff (LCS) shown side-by-side (removed red / added green) with the AI score dropping before → after. - Shareable card: JS canvas renderer (score ring, category pills, sentence-rhythm sparkline, PeopleWorks branding) → downloadable PNG. Privacy-preserving (summary only, never the text). All verified in-browser. Build clean, 19/19 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bb2595e commit 8d364a4

14 files changed

Lines changed: 768 additions & 25 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ an original derivation of AI-writing markers for Spanish.
4848

4949
Credentials are stored only in your browser and sent **directly** to the provider — never to us
5050
(there is no backend).
51+
- **Custom catalogs (bring your own)** — extend the detector with your own rules: paste your team's
52+
banned words for an instant catalog, or import a full rule-pack JSON. Catalogs are stored only in
53+
your browser, merge on top of the built-in packs (overriding by rule id), and apply **live**. The
54+
CLI takes them too: `signsofai check post.md --rules my-style.json`.
55+
- **Before/after diff** — after Humanize, see a word-level diff (removed in red, added in green) and
56+
the AI score dropping from before → after.
57+
- **Shareable result card** — one click renders a clean PNG summary card (score, categories, sentence
58+
rhythm, branding) you can post — privacy-preserving, it never includes your text.
5159
- **Catalog** — a searchable library of every AI-writing sign the analyzer knows, in both languages,
5260
with explanations and fixes. Ranked with an in-browser BM25 index. A study aid for students.
5361

src/SignsOfAI.Cli/Program.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using SignsOfAI.Core;
33
using SignsOfAI.Core.Documents;
44
using SignsOfAI.Core.Model;
5+
using SignsOfAI.Core.Rules;
56

67
// ── signsofai: lint prose for the signs of AI writing ────────────────────────
78
const string Version = "0.1.0";
@@ -29,6 +30,7 @@
2930

3031
// ── parse `check <path> [options]` ───────────────────────────────────────────
3132
var positionals = new List<string>();
33+
var ruleFiles = new List<string>();
3234
string language = "auto";
3335
bool json = false, noColor = false;
3436
double? maxScore = null;
@@ -42,6 +44,7 @@
4244
case "--lang": language = Next(); break;
4345
case "--json": json = true; break;
4446
case "--no-color": noColor = true; break;
47+
case "--rules": ruleFiles.Add(Next()); break;
4548
case "--max-score": maxScore = double.Parse(Next(), System.Globalization.CultureInfo.InvariantCulture); break;
4649
case "--top": top = int.Parse(Next()); break;
4750
default:
@@ -78,7 +81,16 @@
7881
return 2;
7982
}
8083

81-
var result = new AiWritingAnalyzer().Analyze(text, language);
84+
// Load any custom catalogs (--rules file.json, repeatable).
85+
var extraPacks = new List<RulePack>();
86+
foreach (var rf in ruleFiles)
87+
{
88+
if (!File.Exists(rf)) { Console.Error.WriteLine($"Rule-pack not found: {rf}"); return 2; }
89+
try { extraPacks.Add(RulePack.FromJson(await File.ReadAllTextAsync(rf))); }
90+
catch (Exception ex) { Console.Error.WriteLine($"Invalid rule-pack '{rf}': {ex.Message}"); return 2; }
91+
}
92+
93+
var result = new AiWritingAnalyzer().Analyze(text, language, extraPacks);
8294

8395
if (json)
8496
{
@@ -157,6 +169,7 @@ signsofai check <path> [options]
157169
158170
OPTIONS
159171
--lang <auto|en|es> Language of the text (default: auto-detect)
172+
--rules <file.json> Add a custom catalog (rule-pack). Repeatable.
160173
--json Emit a JSON report instead of the pretty report
161174
--max-score <N> Exit with code 1 if the overall score exceeds N (for CI gating)
162175
--top <N> Show at most N findings in the pretty report (default: 10)

src/SignsOfAI.Core/AiWritingAnalyzer.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ public static IReadOnlyList<IAnalyzer> DefaultAnalyzers() =>
2828

2929
/// <param name="text">The text to analyze.</param>
3030
/// <param name="language">"en", "es", or null/"auto" to detect.</param>
31-
public AnalysisResult Analyze(string text, string? language = null)
31+
/// <param name="extraPacks">
32+
/// Optional custom catalogs, merged on top of the built-in pack for the detected language.
33+
/// A pack applies when its <c>Language</c> matches (or is "*"/"all"/empty); rules override
34+
/// built-ins by id.
35+
/// </param>
36+
public AnalysisResult Analyze(string text, string? language = null, IReadOnlyList<RulePack>? extraPacks = null)
3237
{
3338
text ??= string.Empty;
3439

@@ -38,7 +43,12 @@ public AnalysisResult Analyze(string text, string? language = null)
3843

3944
var document = new TextDocument(text);
4045
var statistics = StatisticsCalculator.Compute(document);
41-
var rulePack = RulePackLoader.Load(lang);
46+
47+
var builtIn = RulePackLoader.Load(lang);
48+
var applicable = extraPacks?.Where(p => p.AppliesTo(lang)).ToList();
49+
var rulePack = applicable is { Count: > 0 }
50+
? RulePack.Merge(lang, [builtIn, .. applicable])
51+
: builtIn;
4252

4353
var context = new AnalysisContext
4454
{

src/SignsOfAI.Core/Rules/RulePack.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,49 @@ public sealed class PatternRule
4444
public string? Evidence { get; init; }
4545
}
4646

47-
/// <summary>A full language rule-pack, deserialized from an embedded JSON resource.</summary>
47+
/// <summary>A full rule-pack (a "catalog") — built-in or supplied by the user.</summary>
4848
public sealed class RulePack
4949
{
50+
/// <summary>"en", "es", or "*"/"all"/empty for a language-agnostic custom catalog.</summary>
5051
public required string Language { get; init; }
5152

5253
public LexicalRule[] Lexical { get; init; } = [];
5354

5455
public PatternRule[] Patterns { get; init; } = [];
56+
57+
/// <summary>Parse a rule-pack from JSON (the same schema as the built-in packs).</summary>
58+
public static RulePack FromJson(string json) =>
59+
JsonSerializer.Deserialize(json, RulePackJsonContext.Default.RulePack)
60+
?? throw new InvalidOperationException("Rule-pack JSON deserialized to null.");
61+
62+
public string ToJson() => JsonSerializer.Serialize(this, RulePackJsonContext.Default.RulePack);
63+
64+
/// <summary>
65+
/// Combine several catalogs into one. Rules are keyed by <c>Id</c>, so a later pack overrides an
66+
/// earlier one with the same id — this lets a custom catalog tweak or replace a built-in rule.
67+
/// </summary>
68+
public static RulePack Merge(string language, IEnumerable<RulePack> packs)
69+
{
70+
var lexical = new Dictionary<string, LexicalRule>(StringComparer.Ordinal);
71+
var patterns = new Dictionary<string, PatternRule>(StringComparer.Ordinal);
72+
foreach (var pack in packs)
73+
{
74+
// A custom pack parsed from JSON may omit a section, leaving the array null under source-gen.
75+
foreach (var rule in pack.Lexical ?? []) lexical[rule.Id] = rule;
76+
foreach (var rule in pack.Patterns ?? []) patterns[rule.Id] = rule;
77+
}
78+
return new RulePack
79+
{
80+
Language = language,
81+
Lexical = [.. lexical.Values],
82+
Patterns = [.. patterns.Values],
83+
};
84+
}
85+
86+
/// <summary>Whether this catalog applies to a given detected language.</summary>
87+
public bool AppliesTo(string language) =>
88+
string.IsNullOrWhiteSpace(Language) || Language is "*" or "all"
89+
|| Language.Equals(language, StringComparison.OrdinalIgnoreCase);
5590
}
5691

5792
[JsonSourceGenerationOptions(

0 commit comments

Comments
 (0)