diff --git a/README.md b/README.md index aa9a200..51f7026 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,16 @@ statistics, and per-finding fixes update **as you write**. - **Sentence-rhythm visualization** — a per-sentence bar chart that makes *burstiness* visible. - **Per-finding recommendations** — every flagged tell carries a concrete fix and the research behind it. +- **Live rewrite (on-device, no key)** — your text and a de-AI-ified version side by side, rebuilt on + every keystroke, with the score dropping as you go. It runs off the rule-pack — no model, no network, + no API key — so it is instant and free. Every change is listed with alternatives to pick from and a + one-click *leave this one alone*. Three strengths, from *only the strongest tells* to *delete the + empty intensifiers too*. + + It only does what a word swap can honestly do, and **declines the edits it would get wrong**: it + won't turn "delve into" into "examine into", won't drop the "just" that a *"not just X, it's Y"* + construction depends on, and won't put "el" in front of a feminine noun. Rhythm and rhetorical + structure need real rewriting, so those stay in the recommendations — and the panel says how many. - **Humanize (optional, BYOK)** — connect an AI provider and rewrite the flagged text in one click. Anthropic (`claude-opus-4-8`, works from the browser), OpenAI / DeepSeek, Azure OpenAI, or **Ollama** (local, no key). Credentials live only in your browser and are sent **directly** to the provider. @@ -173,6 +183,7 @@ SignsOfAI.slnx │ │ ├─ Analyzers/ # Lexical, Pattern, Burstiness (IAnalyzer) │ │ ├─ Originality/ # OriginalityChecker (shingles+tiling), ParaphraseFinder, │ │ │ # DistinctivePhraseExtractor +│ │ ├─ Rewriting/ # LocalRewriter — on-device de-AI-ifying, no model or network │ │ ├─ Rules/Packs/ # rules.en.json, rules.es.json (embedded, community-extensible) │ │ ├─ Text/ # Tokenizer, sentence splitter, language detector, statistics │ │ └─ AiWritingAnalyzer # Public facade: Analyze(text, language) @@ -184,7 +195,7 @@ SignsOfAI.slnx │ ├─ Engine/ # OnnxPerplexityEngine, OnnxEmbeddingEngine (lazy-load + idle-unload) │ └─ Config/ # model profiles, calibration, embedding + web-search options └─ tests/ - └─ SignsOfAI.Core.Tests # xUnit (80+, incl. guards for the community locale files) + └─ SignsOfAI.Core.Tests # xUnit (120+, incl. guards for the community locale files) ``` The Core engines are decoupled from the UI and server — the CLI, the Blazor app, and the API all reuse them. @@ -258,6 +269,23 @@ falls back to the manual one-click searches — it never breaks. Add entries to `src/SignsOfAI.Core/Rules/Packs/rules..json` — **lexical** rules match single word tokens, **pattern** rules are regexes for multi-word tells. Each sets a `weight`, `severity`, and `suggestion`. +A lexical rule can also tell the **live rewriter** what to do, which `suggestion` cannot: that field is +prose for a person ("mix, blend, range — or just name the thing"), and a program shouldn't be reading +intent out of prose. + +```jsonc +{ "id": "lex.utilize", "terms": ["utilize", "utilizes"], "weight": 3.5, "severity": "Medium", + "suggestion": "use", "replacements": ["use"] }, // what to substitute, best first +{ "id": "lex.just", "terms": ["just"], "weight": 1.0, "severity": "Info", + "suggestion": "empty intensifier — usually deletable", "delete": true } // remove the word instead +``` + +Both are optional. Without them the rewriter falls back to reading a comma-separated list off +`suggestion`, and refuses to guess at anything else — a lone term could be a replacement ("use") or a +description ("muletilla"), and telling them apart needs to know the language. So a rule with no explicit +field is simply reported and never auto-edited, which is why every built-in rule states its fix outright +(there's a test that keeps it that way). + ## Translating the interface **If you speak a language this tool doesn't, you can add it — and you don't need to know C#.** diff --git a/src/SignsOfAI.Core/AiWritingAnalyzer.cs b/src/SignsOfAI.Core/AiWritingAnalyzer.cs index 06f83e5..40fe8a3 100644 --- a/src/SignsOfAI.Core/AiWritingAnalyzer.cs +++ b/src/SignsOfAI.Core/AiWritingAnalyzer.cs @@ -45,11 +45,7 @@ public AnalysisResult Analyze(string text, string? language = null, IReadOnlyLis var document = new TextDocument(text); var statistics = StatisticsCalculator.Compute(document); - var builtIn = RulePackLoader.Load(lang); - var applicable = extraPacks?.Where(p => p.AppliesTo(lang)).ToList(); - var rulePack = applicable is { Count: > 0 } - ? RulePack.Merge(lang, [builtIn, .. applicable]) - : builtIn; + var rulePack = ResolvePack(lang, extraPacks); var context = new AnalysisContext { @@ -76,4 +72,21 @@ public AnalysisResult Analyze(string text, string? language = null, IReadOnlyLis Statistics = statistics, }; } + + /// + /// The rule-pack an analysis of actually runs against: the built-in + /// pack with any applicable custom catalogs merged over it. + /// + /// Public because a caller that wants to act on findings — the live rewriter needs each rule's + /// replacements — has to consult the very same merged pack. Re-deriving it at the call site is how + /// the two drift apart. + /// + public static RulePack ResolvePack(string language, IReadOnlyList? extraPacks = null) + { + var builtIn = RulePackLoader.Load(language); + var applicable = extraPacks?.Where(p => p.AppliesTo(language)).ToList(); + return applicable is { Count: > 0 } + ? RulePack.Merge(language, [builtIn, .. applicable]) + : builtIn; + } } diff --git a/src/SignsOfAI.Core/Rewriting/LocalRewriter.cs b/src/SignsOfAI.Core/Rewriting/LocalRewriter.cs new file mode 100644 index 0000000..658871f --- /dev/null +++ b/src/SignsOfAI.Core/Rewriting/LocalRewriter.cs @@ -0,0 +1,482 @@ +using SignsOfAI.Core.Model; +using SignsOfAI.Core.Rules; + +namespace SignsOfAI.Core.Rewriting; + +/// How much of the rule-pack the rewriter is allowed to touch. +public enum RewriteStrength +{ + /// Only the strongest tells. Changes little, and what it changes is hard to argue with. + Light, + + /// The strong and moderate tells — the default. + Standard, + + /// Everything mechanical, down to deleting empty intensifiers. + Thorough, +} + +/// +/// One proposed change to the text: what it covers, and what could go there instead. +/// Spans are positions in the original text, so a plan stays valid while the user +/// accepts and rejects edits in any order. +/// +public sealed record RewriteEdit +{ + public required string RuleId { get; init; } + + /// Where in the original text this applies. + public required TextSpan Span { get; init; } + + /// The exact text being replaced. + public required string Original { get; init; } + + /// + /// Candidate replacements, best first, already case-matched to . + /// Empty when is true. + /// + public required IReadOnlyList Options { get; init; } + + /// The fix is to remove the word, not swap it. + public required bool IsDeletion { get; init; } + + /// + /// Whether this edit is safe to apply without the writer choosing. + /// + /// False when the matched word is an inflected form of the rule's canonical term — a rule listing + /// "delve, delves, delving" carries one replacement list, and substituting "examine" into "delving" + /// would produce broken grammar. Those edits are still offered, with their options, but a tool that + /// silently mangles tenses has no business advising anyone on writing. + /// + public required bool AutoApply { get; init; } + + public required Severity Severity { get; init; } + + public required double Weight { get; init; } + + /// The default replacement, or the empty string for a deletion. + public string Preferred => Options.Count > 0 ? Options[0] : string.Empty; +} + +/// +/// Rewrites AI tells out of text using nothing but the rule-pack — no model, no network, no API key. +/// It is deterministic and instant, which is what makes a live side-by-side view possible: every +/// keystroke can re-plan and re-apply. +/// +/// It only handles what substitution can honestly fix: the lexical rules (overused words). +/// Rhetorical and syntactic tells — negative parallelisms, copula avoidance, robotic rhythm — are +/// structural rewrites that need real language ability, so they are left for the optional LLM pass +/// and merely reported here. +/// +public static class LocalRewriter +{ + /// + /// Works out the non-overlapping set of changes available for . + /// Pure: call it as often as you like. + /// + public static IReadOnlyList Plan( + string text, + IReadOnlyList findings, + RulePack pack, + RewriteStrength strength = RewriteStrength.Standard) + { + if (string.IsNullOrEmpty(text) || findings.Count == 0) return []; + + var rules = new Dictionary(StringComparer.Ordinal); + foreach (var rule in pack.Lexical ?? []) rules[rule.Id] = rule; + + var minimum = Floor(strength); + var edits = new List(); + var cursor = 0; + + // Spans of the rhetorical / syntactic constructions in this text. A word sitting inside one is + // not free-standing: it is holding that construction up. + var constructions = findings + .Where(f => f.Category is SignCategory.Rhetorical or SignCategory.Syntactic && f.Span.Length > 0) + .Select(f => f.Span) + .ToList(); + + foreach (var finding in findings.OrderBy(f => f.Span.Start).ThenByDescending(f => f.Weight)) + { + if (finding.Span.Length == 0 || finding.Span.End > text.Length) continue; // document-level + if (finding.Span.Start < cursor) continue; // overlaps a kept edit + if (finding.Severity < minimum) continue; + if (!rules.TryGetValue(finding.RuleId, out var rule)) continue; // not mechanically fixable + + var original = finding.Span.Slice(text); + + if (rule.Delete) + { + // "It's not just a tool, it's a solution" — "just" is an empty intensifier in general, + // but not here: the negative parallelism around it depends on the word, and deleting it + // flips the sentence into "it's not a tool". Whenever a deletion falls inside a flagged + // construction the premise "this word carries no meaning" is simply false, so the + // construction is left for a real rewrite. Substitutions are unaffected — swapping a + // synonym preserves the sense, while removing a word can invert it. + if (constructions.Any(c => finding.Span.Start >= c.Start && finding.Span.End <= c.End)) + continue; + + // Deleting a word is immune to inflection, so it is always safe to apply. + edits.Add(new RewriteEdit + { + RuleId = rule.Id, + Span = finding.Span, + Original = original, + Options = [], + IsDeletion = true, + AutoApply = true, + Severity = finding.Severity, + Weight = finding.Weight, + }); + cursor = finding.Span.End; + continue; + } + + var options = rule.RewriteOptions(); + if (options.Count == 0) continue; + + // Spanish articles carry gender, so "el panorama" → "el situación" is wrong. The gender to + // match is taken from the article already in the sentence rather than guessed from the + // word — "panorama" and "problema" end in -a and are masculine, so an ending-based guess + // fails on exactly the words that matter. Only alternatives that agree survive; if none + // do, the swap would need the article rebuilt and is left alone. + if (pack.Language == "es" && ArticleGender(text, finding.Span.Start) is { } gender) + { + options = [.. options.Where(o => ApparentGender(o) == gender)]; + if (options.Count == 0) continue; + } + + // "delve into", "a testament to", "embark on" — the word governs the particle after it, and + // a one-word swap gets the pairing wrong ("examine into", "proof to"). Offering alternatives + // doesn't rescue it either: choosing "look into" would produce "look into into". So the + // rewriter declines the edit; the finding still tells the writer what to consider. + if (IsFollowedByGovernedParticle(text, finding.Span.End, pack.Language)) continue; + + // "a plethora of options" → "a many of options". In the "a ___ of" frame the word is doing + // quantifier duty, and swapping it needs the determiner restructured too, which is beyond a + // substitution. "the rich tapestry of" is untouched by this: it isn't an indefinite article. + if (IsQuantifierFrame(text, finding.Span, pack.Language)) continue; + + var canonical = rule.Terms.Length > 0 ? rule.Terms[0] : original; + + edits.Add(new RewriteEdit + { + RuleId = rule.Id, + Span = finding.Span, + Original = original, + Options = [.. options.Select(option => MatchCase(original, option))], + IsDeletion = false, + AutoApply = string.Equals(original, canonical, StringComparison.OrdinalIgnoreCase), + Severity = finding.Severity, + Weight = finding.Weight, + }); + cursor = finding.Span.End; + } + + return edits; + } + + /// + /// Applies a plan to the text. + /// + /// + /// Span start → the replacement to use, overriding . This is how + /// the UI records "I picked the third alternative". + /// + /// + /// Span starts to leave untouched. Also how an edit needing a decision stays out until the writer + /// makes one. + /// + /// + /// Used only for English "a"/"an" agreement, which depends on the alternative the writer picked and + /// so cannot be decided when the plan is built. + /// + public static string Apply( + string text, + IReadOnlyList edits, + IReadOnlyDictionary? chosen = null, + IReadOnlySet? rejected = null, + string language = "en") + { + if (string.IsNullOrEmpty(text) || edits.Count == 0) return text ?? string.Empty; + + var result = new System.Text.StringBuilder(text.Length); + var copied = 0; // how much of the original has been written out + var capitalizePending = false; + + // Capitalization is deferred rather than applied to the character right after a deletion: + // that character may itself be the start of the next edit, and consuming it here would drop + // that edit on the floor. Instead the flag travels until an actual letter gets written. + void Emit(string chunk) + { + if (!capitalizePending || chunk.Length == 0) + { + result.Append(chunk); + return; + } + for (var i = 0; i < chunk.Length; i++) + { + if (!char.IsLetter(chunk[i])) continue; + result.Append(chunk, 0, i); + result.Append(char.ToUpperInvariant(chunk[i])); + result.Append(chunk, i + 1, chunk.Length - i - 1); + capitalizePending = false; + return; + } + result.Append(chunk); // nothing but punctuation/space so far — keep waiting for a letter + } + + foreach (var edit in edits.OrderBy(e => e.Span.Start)) + { + if (rejected is not null && rejected.Contains(edit.Span.Start)) continue; + if (edit.Span.Start < copied) continue; // an earlier deletion already swallowed this + + var replacement = edit.IsDeletion + ? string.Empty + : chosen is not null && chosen.TryGetValue(edit.Span.Start, out var pick) && pick.Length > 0 + ? pick + : edit.Preferred; + + if (!edit.IsDeletion && replacement.Length == 0) continue; + + var (from, to, capitalizeNext) = edit.IsDeletion + ? DeletionRange(text, edit.Span, copied) + : (edit.Span.Start, edit.Span.End, false); + + var gap = text[copied..from]; + if (!edit.IsDeletion && language != "es") + gap = AgreeArticle(gap, replacement); + + Emit(gap); + Emit(replacement); + copied = to; + + if (capitalizeNext) capitalizePending = true; + } + + Emit(text[copied..]); + return result.ToString(); + } + + // Particles a preceding word governs, so swapping that word changes which particle is correct. + // The genitive ("of" / "de") is deliberately absent: it survives a noun swap unharmed — + // "rich tapestry of" → "rich mix of" reads fine, and excluding it would suppress good edits. + private static readonly HashSet EnglishParticles = new(StringComparer.OrdinalIgnoreCase) + { + "into", "to", "on", "onto", "upon", "in", "with", "for", "from", "at", "about", + "through", "over", "toward", "towards", "against", "as", + }; + + private static readonly HashSet SpanishParticles = new(StringComparer.OrdinalIgnoreCase) + { + "en", "a", "al", "con", "para", "por", "sobre", "hacia", "desde", "hasta", "ante", "como", + }; + + /// + /// Whether the match sits in an "a ___ of" frame, where the word is acting as a quantifier and a + /// swap would need the determiner rebuilt. + /// + private static bool IsQuantifierFrame(string text, TextSpan span, string language) + { + var genitive = language == "es" ? "de" : "of"; + if (!IsNextWord(text, span.End, genitive)) return false; + + var articles = language == "es" + ? new[] { "un", "una" } + : ["a", "an"]; + return articles.Any(article => IsPreviousWord(text, span.Start, article)); + } + + private static bool IsNextWord(string text, int at, string word) + { + var i = at; + while (i < text.Length && text[i] == ' ') i++; + if (i == at || i + word.Length > text.Length) return false; + if (!text.AsSpan(i, word.Length).Equals(word, StringComparison.OrdinalIgnoreCase)) return false; + var after = i + word.Length; + return after == text.Length || !char.IsLetter(text[after]); + } + + private static bool IsPreviousWord(string text, int at, string word) + { + var i = at; + while (i > 0 && text[i - 1] == ' ') i--; + if (i == at || i - word.Length < 0) return false; + if (!text.AsSpan(i - word.Length, word.Length).Equals(word, StringComparison.OrdinalIgnoreCase)) return false; + var before = i - word.Length - 1; + return before < 0 || !char.IsLetter(text[before]); + } + + /// + /// The gender the sentence already commits to, read off the article in front of the word, or null + /// when there is no article to agree with. Ground truth rather than a guess — which is the point, + /// since the word itself may be one of the -a masculines. + /// + private static char? ArticleGender(string text, int at) + { + if (IsPreviousWord(text, at, "el") || IsPreviousWord(text, at, "los") + || IsPreviousWord(text, at, "un") || IsPreviousWord(text, at, "unos") + || IsPreviousWord(text, at, "del") || IsPreviousWord(text, at, "al")) return 'm'; + + if (IsPreviousWord(text, at, "la") || IsPreviousWord(text, at, "las") + || IsPreviousWord(text, at, "una") || IsPreviousWord(text, at, "unas")) return 'f'; + + return null; + } + + /// + /// Gender guessed from a replacement's ending: the -ción/-sión/-dad/-tad/-tud/-umbre families are + /// reliably feminine, a final -a usually is, everything else reads masculine. Only ever applied to + /// the *replacement*, so a wrong guess costs a declined edit rather than producing broken agreement. + /// + private static char ApparentGender(string word) + { + var w = word.Trim().ToLowerInvariant(); + if (w.Length == 0) return 'm'; + + // Judge on the last word, so "lo más avanzado" is read as "avanzado". + var space = w.LastIndexOf(' '); + if (space >= 0) w = w[(space + 1)..]; + + if (w.EndsWith("ción") || w.EndsWith("sión") || w.EndsWith("ión") + || w.EndsWith("dad") || w.EndsWith("tad") || w.EndsWith("tud") || w.EndsWith("umbre")) + return 'f'; + + return w.EndsWith('a') ? 'f' : 'm'; + } + + /// Whether the word right after is a particle governed by what precedes it. + private static bool IsFollowedByGovernedParticle(string text, int at, string language) + { + var i = at; + while (i < text.Length && text[i] == ' ') i++; + if (i >= text.Length || i == at) return false; // must be separated by a space + + var start = i; + while (i < text.Length && char.IsLetter(text[i])) i++; + if (i == start) return false; + + var word = text[start..i]; + var particles = language == "es" ? SpanishParticles : EnglishParticles; + return particles.Contains(word); + } + + /// Severity at or above which a strength setting acts. + private static Severity Floor(RewriteStrength strength) => strength switch + { + RewriteStrength.Light => Severity.High, + RewriteStrength.Standard => Severity.Medium, + _ => Severity.Info, + }; + + /// + /// Widens a deletion to take the punctuation and spacing that only existed to hold the word, + /// so removing it leaves clean prose instead of a double space or an orphaned comma. + /// + private static (int From, int To, bool CapitalizeNext) DeletionRange(string text, TextSpan span, int floor) + { + int from = span.Start, to = span.End; + + var sentenceInitial = IsSentenceInitial(text, from, floor); + var precededBySpace = from - 1 >= floor && char.IsWhiteSpace(text[from - 1]); + + // ", actually," — the commas were bracketing this word, so both go with it. + if (from - 2 >= floor && text[from - 2] == ',' && text[from - 1] == ' ' + && to < text.Length && text[to] == ',') + { + return (from - 2, to + 1, false); + } + + // "Moreover, x" / "actually, x" — a trailing comma belonged to the word. + if (to < text.Length && text[to] == ',') to++; + + // "…late, truly." — the word runs into punctuation, so the space in front of it is the one + // that has to go. Taking the trailing side instead would leave " .". + if (to < text.Length && text[to] is '.' or '!' or '?' or ';' or ':' or ')' && precededBySpace) + { + while (from - 1 >= floor && text[from - 1] == ' ') from--; + // The word was the whole tail of a clause, so the comma introducing it goes too: + // "late, truly." → "late." rather than "late,.". + if (from - 1 >= floor && text[from - 1] == ',') from--; + return (from, to, false); + } + + if (sentenceInitial || precededBySpace) + { + // Leave exactly one separator: either the space already before the word, or none at all + // when the word opened the sentence. + while (to < text.Length && text[to] == ' ') to++; + } + + return (from, to, sentenceInitial); + } + + /// + /// Whether a capital letter belongs at this position. Only true after a real sentence end — + /// a semicolon or colon continues the sentence, so recapitalizing there would be wrong. + /// + private static bool IsSentenceInitial(string text, int index, int floor) + { + for (var i = index - 1; i >= floor; i--) + { + if (char.IsWhiteSpace(text[i])) continue; + return text[i] is '.' or '!' or '?' or '\n'; + } + return index <= floor; // nothing but whitespace before it + } + + /// + /// Fixes the indefinite article when a swap changes the initial sound: "a crucial step" becomes + /// "an essential step", not "a essential step". Only touches an article immediately before the + /// replacement, so nothing else in the sentence can be affected. + /// + private static string AgreeArticle(string gap, string replacement) + { + if (replacement.Length == 0) return gap; + + // The gap must end with the article plus its separating whitespace. + var end = gap.Length; + while (end > 0 && char.IsWhiteSpace(gap[end - 1])) end--; + if (end == gap.Length) return gap; // no whitespace: not "a "/"an " right before the word + + var start = end; + while (start > 0 && char.IsLetter(gap[start - 1])) start--; + + var article = gap[start..end]; + if (!article.Equals("a", StringComparison.OrdinalIgnoreCase) + && !article.Equals("an", StringComparison.OrdinalIgnoreCase)) return gap; + if (start > 0 && char.IsLetter(gap[start - 1])) return gap; // part of a longer word + + var wanted = StartsWithVowelSound(replacement) ? "an" : "a"; + if (article.Equals(wanted, StringComparison.OrdinalIgnoreCase)) return gap; + + if (char.IsUpper(article[0])) wanted = char.ToUpperInvariant(wanted[0]) + wanted[1..]; + return gap[..start] + wanted + gap[end..]; + } + + /// + /// A spelling-based approximation, which is what the article rule mostly follows. It gets the + /// well-known exceptions ("hour", "university") wrong in one direction or the other, but every + /// replacement in the built-in packs is an ordinary word where the letter is the right signal. + /// + private static bool StartsWithVowelSound(string word) => + word.Length > 0 && word[0] is 'a' or 'e' or 'i' or 'o' or 'u' or 'A' or 'E' or 'I' or 'O' or 'U'; + + /// + /// Carries the original's capitalization onto the replacement, so "Delve" becomes "Examine" + /// rather than "examine" mid-sentence. + /// + private static string MatchCase(string original, string replacement) + { + if (original.Length == 0 || replacement.Length == 0) return replacement; + + var letters = original.Where(char.IsLetter).ToList(); + if (letters.Count > 1 && letters.All(char.IsUpper)) + return replacement.ToUpperInvariant(); + + if (char.IsUpper(original[0])) + return char.ToUpperInvariant(replacement[0]) + replacement[1..]; + + return replacement; + } +} diff --git a/src/SignsOfAI.Core/Rules/Packs/rules.en.json b/src/SignsOfAI.Core/Rules/Packs/rules.en.json index 6a57087..91a35dc 100644 --- a/src/SignsOfAI.Core/Rules/Packs/rules.en.json +++ b/src/SignsOfAI.Core/Rules/Packs/rules.en.json @@ -1,58 +1,58 @@ { "language": "en", "lexical": [ - { "id": "lex.delve", "terms": ["delve", "delves", "delving", "delved"], "weight": 6.0, "severity": "High", "suggestion": "examine, explore, look into, dig into", "evidence": "48× more frequent post-ChatGPT (excess ratio r=28)" }, - { "id": "lex.tapestry", "terms": ["tapestry"], "weight": 5.5, "severity": "High", "suggestion": "mix, blend, range — or just name the thing", "evidence": "35× more common post-ChatGPT" }, - { "id": "lex.multifaceted", "terms": ["multifaceted", "multi-faceted"], "weight": 4.0, "severity": "Medium", "suggestion": "complex, or specify the actual facets", "evidence": "28× more common post-ChatGPT" }, - { "id": "lex.nuanced", "terms": ["nuanced"], "weight": 4.0, "severity": "Medium", "suggestion": "subtle, detailed — or state the specific distinction", "evidence": "22× more common post-ChatGPT" }, - { "id": "lex.pivotal", "terms": ["pivotal"], "weight": 4.0, "severity": "Medium", "suggestion": "key, central, important", "evidence": "16× more common post-ChatGPT" }, - { "id": "lex.underscore", "terms": ["underscore", "underscores", "underscoring", "underscored"], "weight": 4.0, "severity": "Medium", "suggestion": "show, highlight, stress", "evidence": "10–20× more common (r=13.8)" }, - { "id": "lex.showcase", "terms": ["showcase", "showcases", "showcasing", "showcased"], "weight": 3.5, "severity": "Medium", "suggestion": "show, present, display", "evidence": "10–20× more common (r=10.7)" }, - { "id": "lex.crucial", "terms": ["crucial"], "weight": 3.5, "severity": "Medium", "suggestion": "important, essential, key", "evidence": "14× more common post-ChatGPT (δ=0.037)" }, - { "id": "lex.intricate", "terms": ["intricate", "intricacies", "intricately"], "weight": 4.0, "severity": "Medium", "suggestion": "complex, detailed", "evidence": "RLHF-reinforced 'complexity' word" }, - { "id": "lex.testament", "terms": ["testament"], "weight": 4.5, "severity": "High", "suggestion": "proof, evidence, sign — or drop the flourish", "evidence": "Common in copula-avoidance ('a testament to')" }, - { "id": "lex.realm", "terms": ["realm"], "weight": 3.5, "severity": "Medium", "suggestion": "field, area, world", "evidence": "Filler noun ('in the realm of')" }, - { "id": "lex.robust", "terms": ["robust"], "weight": 3.0, "severity": "Low", "suggestion": "strong, reliable, solid", "evidence": "RLHF style word" }, - { "id": "lex.foster", "terms": ["foster", "fosters", "fostering", "fostered"], "weight": 3.0, "severity": "Low", "suggestion": "encourage, support, build", "evidence": "RLHF style verb" }, - { "id": "lex.leverage", "terms": ["leverage", "leverages", "leveraging", "leveraged"], "weight": 3.5, "severity": "Medium", "suggestion": "use, apply, draw on", "evidence": "RLHF style verb" }, - { "id": "lex.seamless", "terms": ["seamless", "seamlessly"], "weight": 3.5, "severity": "Medium", "suggestion": "smooth, smoothly — or show it, don't claim it" }, - { "id": "lex.meticulous", "terms": ["meticulous", "meticulously"], "weight": 3.5, "severity": "Medium", "suggestion": "careful, carefully, thorough" }, - { "id": "lex.myriad", "terms": ["myriad"], "weight": 3.5, "severity": "Medium", "suggestion": "many, countless — or give the number" }, - { "id": "lex.plethora", "terms": ["plethora"], "weight": 3.5, "severity": "Medium", "suggestion": "many, plenty, a lot of" }, - { "id": "lex.transformative","terms": ["transformative"], "weight": 3.5, "severity": "Medium", "suggestion": "major, far-reaching — or show the change" }, - { "id": "lex.vibrant", "terms": ["vibrant"], "weight": 3.0, "severity": "Low", "suggestion": "lively, colorful — or a concrete detail" }, - { "id": "lex.bustling", "terms": ["bustling"], "weight": 3.5, "severity": "Medium", "suggestion": "busy, crowded — or describe the scene" }, - { "id": "lex.embark", "terms": ["embark", "embarks", "embarking", "embarked"], "weight": 3.5, "severity": "Medium", "suggestion": "start, begin, set out" }, - { "id": "lex.harness", "terms": ["harness", "harnessing", "harnesses"], "weight": 3.0, "severity": "Low", "suggestion": "use, tap, apply" }, - { "id": "lex.elevate", "terms": ["elevate", "elevates", "elevating"], "weight": 3.0, "severity": "Low", "suggestion": "raise, improve, lift" }, - { "id": "lex.unlock", "terms": ["unlock", "unlocks", "unlocking"], "weight": 3.0, "severity": "Low", "suggestion": "enable, open up, allow" }, - { "id": "lex.paramount", "terms": ["paramount"], "weight": 3.5, "severity": "Medium", "suggestion": "essential, most important" }, - { "id": "lex.holistic", "terms": ["holistic"], "weight": 3.0, "severity": "Low", "suggestion": "whole, complete, overall" }, - { "id": "lex.profound", "terms": ["profound", "profoundly"], "weight": 3.0, "severity": "Low", "suggestion": "deep, deeply, major" }, - { "id": "lex.comprehensive", "terms": ["comprehensive"], "weight": 2.5, "severity": "Low", "suggestion": "complete, thorough, full" }, - { "id": "lex.evolving", "terms": ["ever-evolving", "ever-changing"], "weight": 3.5, "severity": "Medium", "suggestion": "changing — or say how it changes" }, - { "id": "lex.cuttingedge", "terms": ["cutting-edge"], "weight": 3.0, "severity": "Low", "suggestion": "latest, advanced, new" }, - { "id": "lex.gamechanger", "terms": ["game-changer", "game-changing"], "weight": 3.0, "severity": "Low", "suggestion": "major shift — or state the actual impact" }, - { "id": "lex.moreover", "terms": ["moreover"], "weight": 2.0, "severity": "Info", "suggestion": "also, and, besides" }, - { "id": "lex.furthermore", "terms": ["furthermore"], "weight": 2.0, "severity": "Info", "suggestion": "also, and, plus" }, - { "id": "lex.notably", "terms": ["notably"], "weight": 1.5, "severity": "Info", "suggestion": "often removable" }, - { "id": "lex.utilize", "terms": ["utilize", "utilizes", "utilizing", "utilized", "utilization"], "weight": 3.5, "severity": "Medium", "suggestion": "use", "evidence": "Textbook AI inflation of “use”" }, - { "id": "lex.facilitate", "terms": ["facilitate", "facilitates", "facilitating", "facilitated"], "weight": 3.0, "severity": "Low", "suggestion": "help, ease, run" }, - { "id": "lex.streamline", "terms": ["streamline", "streamlines", "streamlining", "streamlined"], "weight": 3.0, "severity": "Low", "suggestion": "simplify, speed up" }, - { "id": "lex.empower", "terms": ["empower", "empowers", "empowering", "empowerment"], "weight": 3.0, "severity": "Low", "suggestion": "enable, equip, let" }, - { "id": "lex.beacon", "terms": ["beacon"], "weight": 3.5, "severity": "Medium", "suggestion": "example, model — or drop the metaphor" }, - { "id": "lex.supercharge", "terms": ["supercharge", "supercharges", "supercharging", "supercharged"], "weight": 3.5, "severity": "Medium", "suggestion": "boost, speed up" }, - { "id": "lex.just", "terms": ["just"], "weight": 1.0, "severity": "Info", "suggestion": "empty intensifier — usually deletable" }, - { "id": "lex.simply", "terms": ["simply"], "weight": 1.0, "severity": "Info", "suggestion": "empty intensifier — usually deletable" }, - { "id": "lex.actually", "terms": ["actually"], "weight": 1.0, "severity": "Info", "suggestion": "empty intensifier — usually deletable" }, - { "id": "lex.literally", "terms": ["literally"], "weight": 1.5, "severity": "Info", "suggestion": "empty intensifier — cut unless it's meant literally" }, - { "id": "lex.honestly", "terms": ["honestly"], "weight": 1.5, "severity": "Info", "suggestion": "empty intensifier — cut it" }, - { "id": "lex.truly", "terms": ["truly"], "weight": 1.5, "severity": "Info", "suggestion": "empty intensifier — cut it" }, - { "id": "lex.importantly", "terms": ["importantly"], "weight": 1.5, "severity": "Info", "suggestion": "often removable" }, - { "id": "lex.fundamentally", "terms": ["fundamentally"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it" }, - { "id": "lex.crucially", "terms": ["crucially"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it" }, - { "id": "lex.inherently", "terms": ["inherently"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it" }, - { "id": "lex.inevitably", "terms": ["inevitably"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it" } + { "id": "lex.delve", "terms": ["delve", "delves", "delving", "delved"], "weight": 6.0, "severity": "High", "suggestion": "examine, explore, look into, dig into", "replacements": ["examine", "explore", "look into", "dig into"], "evidence": "48× more frequent post-ChatGPT (excess ratio r=28)" }, + { "id": "lex.tapestry", "terms": ["tapestry"], "weight": 5.5, "severity": "High", "suggestion": "mix, blend, range — or just name the thing", "replacements": ["mix", "blend", "range"], "evidence": "35× more common post-ChatGPT" }, + { "id": "lex.multifaceted", "terms": ["multifaceted", "multi-faceted"], "weight": 4.0, "severity": "Medium", "suggestion": "complex, or specify the actual facets", "replacements": ["complex"], "evidence": "28× more common post-ChatGPT" }, + { "id": "lex.nuanced", "terms": ["nuanced"], "weight": 4.0, "severity": "Medium", "suggestion": "subtle, detailed — or state the specific distinction", "replacements": ["subtle", "detailed"], "evidence": "22× more common post-ChatGPT" }, + { "id": "lex.pivotal", "terms": ["pivotal"], "weight": 4.0, "severity": "Medium", "suggestion": "key, central, important", "replacements": ["key", "central", "important"], "evidence": "16× more common post-ChatGPT" }, + { "id": "lex.underscore", "terms": ["underscore", "underscores", "underscoring", "underscored"], "weight": 4.0, "severity": "Medium", "suggestion": "show, highlight, stress", "replacements": ["show", "highlight", "stress"], "evidence": "10–20× more common (r=13.8)" }, + { "id": "lex.showcase", "terms": ["showcase", "showcases", "showcasing", "showcased"], "weight": 3.5, "severity": "Medium", "suggestion": "show, present, display", "replacements": ["show", "present", "display"], "evidence": "10–20× more common (r=10.7)" }, + { "id": "lex.crucial", "terms": ["crucial"], "weight": 3.5, "severity": "Medium", "suggestion": "important, essential, key", "replacements": ["important", "essential", "key"], "evidence": "14× more common post-ChatGPT (δ=0.037)" }, + { "id": "lex.intricate", "terms": ["intricate", "intricacies", "intricately"], "weight": 4.0, "severity": "Medium", "suggestion": "complex, detailed", "replacements": ["complex", "detailed"], "evidence": "RLHF-reinforced 'complexity' word" }, + { "id": "lex.testament", "terms": ["testament"], "weight": 4.5, "severity": "High", "suggestion": "proof, evidence, sign — or drop the flourish", "replacements": ["proof", "evidence", "sign"], "evidence": "Common in copula-avoidance ('a testament to')" }, + { "id": "lex.realm", "terms": ["realm"], "weight": 3.5, "severity": "Medium", "suggestion": "field, area, world", "replacements": ["field", "area", "world"], "evidence": "Filler noun ('in the realm of')" }, + { "id": "lex.robust", "terms": ["robust"], "weight": 3.0, "severity": "Low", "suggestion": "strong, reliable, solid", "replacements": ["strong", "reliable", "solid"], "evidence": "RLHF style word" }, + { "id": "lex.foster", "terms": ["foster", "fosters", "fostering", "fostered"], "weight": 3.0, "severity": "Low", "suggestion": "encourage, support, build", "replacements": ["encourage", "support", "build"], "evidence": "RLHF style verb" }, + { "id": "lex.leverage", "terms": ["leverage", "leverages", "leveraging", "leveraged"], "weight": 3.5, "severity": "Medium", "suggestion": "use, apply, draw on", "replacements": ["use", "apply", "draw on"], "evidence": "RLHF style verb" }, + { "id": "lex.seamless", "terms": ["seamless", "seamlessly"], "weight": 3.5, "severity": "Medium", "suggestion": "smooth, smoothly — or show it, don't claim it", "replacements": ["smooth", "smoothly"] }, + { "id": "lex.meticulous", "terms": ["meticulous", "meticulously"], "weight": 3.5, "severity": "Medium", "suggestion": "careful, carefully, thorough", "replacements": ["careful", "carefully", "thorough"] }, + { "id": "lex.myriad", "terms": ["myriad"], "weight": 3.5, "severity": "Medium", "suggestion": "many, countless — or give the number", "replacements": ["many", "countless"] }, + { "id": "lex.plethora", "terms": ["plethora"], "weight": 3.5, "severity": "Medium", "suggestion": "many, plenty, a lot of", "replacements": ["many", "plenty", "a lot of"] }, + { "id": "lex.transformative","terms": ["transformative"], "weight": 3.5, "severity": "Medium", "suggestion": "major, far-reaching — or show the change", "replacements": ["major", "far-reaching"] }, + { "id": "lex.vibrant", "terms": ["vibrant"], "weight": 3.0, "severity": "Low", "suggestion": "lively, colorful — or a concrete detail", "replacements": ["lively", "colorful"] }, + { "id": "lex.bustling", "terms": ["bustling"], "weight": 3.5, "severity": "Medium", "suggestion": "busy, crowded — or describe the scene", "replacements": ["busy", "crowded"] }, + { "id": "lex.embark", "terms": ["embark", "embarks", "embarking", "embarked"], "weight": 3.5, "severity": "Medium", "suggestion": "start, begin, set out", "replacements": ["start", "begin", "set out"] }, + { "id": "lex.harness", "terms": ["harness", "harnessing", "harnesses"], "weight": 3.0, "severity": "Low", "suggestion": "use, tap, apply", "replacements": ["use", "tap", "apply"] }, + { "id": "lex.elevate", "terms": ["elevate", "elevates", "elevating"], "weight": 3.0, "severity": "Low", "suggestion": "raise, improve, lift", "replacements": ["raise", "improve", "lift"] }, + { "id": "lex.unlock", "terms": ["unlock", "unlocks", "unlocking"], "weight": 3.0, "severity": "Low", "suggestion": "enable, open up, allow", "replacements": ["enable", "open up", "allow"] }, + { "id": "lex.paramount", "terms": ["paramount"], "weight": 3.5, "severity": "Medium", "suggestion": "essential, most important", "replacements": ["essential", "most important"] }, + { "id": "lex.holistic", "terms": ["holistic"], "weight": 3.0, "severity": "Low", "suggestion": "whole, complete, overall", "replacements": ["whole", "complete", "overall"] }, + { "id": "lex.profound", "terms": ["profound", "profoundly"], "weight": 3.0, "severity": "Low", "suggestion": "deep, deeply, major", "replacements": ["deep", "deeply", "major"] }, + { "id": "lex.comprehensive", "terms": ["comprehensive"], "weight": 2.5, "severity": "Low", "suggestion": "complete, thorough, full", "replacements": ["complete", "thorough", "full"] }, + { "id": "lex.evolving", "terms": ["ever-evolving", "ever-changing"], "weight": 3.5, "severity": "Medium", "suggestion": "changing — or say how it changes", "replacements": ["changing"] }, + { "id": "lex.cuttingedge", "terms": ["cutting-edge"], "weight": 3.0, "severity": "Low", "suggestion": "advanced, new, the latest", "replacements": ["advanced", "new"] }, + { "id": "lex.gamechanger", "terms": ["game-changer", "game-changing"], "weight": 3.0, "severity": "Low", "suggestion": "major shift — or state the actual impact", "replacements": ["major shift"] }, + { "id": "lex.moreover", "terms": ["moreover"], "weight": 2.0, "severity": "Info", "suggestion": "also, and, besides", "replacements": ["also", "and", "besides"] }, + { "id": "lex.furthermore", "terms": ["furthermore"], "weight": 2.0, "severity": "Info", "suggestion": "also, and, plus", "replacements": ["also", "and", "plus"] }, + { "id": "lex.notably", "terms": ["notably"], "weight": 1.5, "severity": "Info", "suggestion": "often removable", "delete": true }, + { "id": "lex.utilize", "terms": ["utilize", "utilizes", "utilizing", "utilized", "utilization"], "weight": 3.5, "severity": "Medium", "suggestion": "use", "replacements": ["use"], "evidence": "Textbook AI inflation of “use”" }, + { "id": "lex.facilitate", "terms": ["facilitate", "facilitates", "facilitating", "facilitated"], "weight": 3.0, "severity": "Low", "suggestion": "help, ease, run", "replacements": ["help", "ease", "run"] }, + { "id": "lex.streamline", "terms": ["streamline", "streamlines", "streamlining", "streamlined"], "weight": 3.0, "severity": "Low", "suggestion": "simplify, speed up", "replacements": ["simplify", "speed up"] }, + { "id": "lex.empower", "terms": ["empower", "empowers", "empowering", "empowerment"], "weight": 3.0, "severity": "Low", "suggestion": "enable, equip, let", "replacements": ["enable", "equip", "let"] }, + { "id": "lex.beacon", "terms": ["beacon"], "weight": 3.5, "severity": "Medium", "suggestion": "example, model — or drop the metaphor", "replacements": ["example", "model"] }, + { "id": "lex.supercharge", "terms": ["supercharge", "supercharges", "supercharging", "supercharged"], "weight": 3.5, "severity": "Medium", "suggestion": "boost, speed up", "replacements": ["boost", "speed up"] }, + { "id": "lex.just", "terms": ["just"], "weight": 1.0, "severity": "Info", "suggestion": "empty intensifier — usually deletable", "delete": true }, + { "id": "lex.simply", "terms": ["simply"], "weight": 1.0, "severity": "Info", "suggestion": "empty intensifier — usually deletable", "delete": true }, + { "id": "lex.actually", "terms": ["actually"], "weight": 1.0, "severity": "Info", "suggestion": "empty intensifier — usually deletable", "delete": true }, + { "id": "lex.literally", "terms": ["literally"], "weight": 1.5, "severity": "Info", "suggestion": "empty intensifier — cut unless it's meant literally", "delete": true }, + { "id": "lex.honestly", "terms": ["honestly"], "weight": 1.5, "severity": "Info", "suggestion": "empty intensifier — cut it", "delete": true }, + { "id": "lex.truly", "terms": ["truly"], "weight": 1.5, "severity": "Info", "suggestion": "empty intensifier — cut it", "delete": true }, + { "id": "lex.importantly", "terms": ["importantly"], "weight": 1.5, "severity": "Info", "suggestion": "often removable", "delete": true }, + { "id": "lex.fundamentally", "terms": ["fundamentally"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it", "delete": true }, + { "id": "lex.crucially", "terms": ["crucially"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it", "delete": true }, + { "id": "lex.inherently", "terms": ["inherently"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it", "delete": true }, + { "id": "lex.inevitably", "terms": ["inevitably"], "weight": 2.0, "severity": "Low", "suggestion": "empty intensifier — cut it", "delete": true } ], "patterns": [ { "id": "rhet.not-just", "category": "Rhetorical", "regex": "\\bit'?s not (just|only|merely|about)\\b[^.?!\\n]{1,60}?,\\s*it'?s\\b", "weight": 6.0, "severity": "High", "message": "Negative parallelism (“it's not just X, it's Y”) — feigns depth.", "suggestion": "State the claim directly. Cut the “not just… it's…” frame." }, diff --git a/src/SignsOfAI.Core/Rules/Packs/rules.es.json b/src/SignsOfAI.Core/Rules/Packs/rules.es.json index 5adf70b..b3b60e9 100644 --- a/src/SignsOfAI.Core/Rules/Packs/rules.es.json +++ b/src/SignsOfAI.Core/Rules/Packs/rules.es.json @@ -1,46 +1,46 @@ { "language": "es", "lexical": [ - { "id": "lex.sumergir", "terms": ["sumérgete", "sumergirse", "adentrémonos", "adéntrate", "adentrarse"], "weight": 5.5, "severity": "High", "suggestion": "explora, analiza, examina", "evidence": "Análogo directo de “delve”: apertura típica de IA" }, - { "id": "lex.abordar", "terms": ["abordar", "aborda", "abordando"], "weight": 3.5, "severity": "Medium", "suggestion": "tratar, resolver, atender" }, - { "id": "lex.fomentar", "terms": ["fomentar", "fomenta", "fomentando", "fomento"], "weight": 3.0, "severity": "Low", "suggestion": "impulsar, promover, apoyar", "evidence": "Verbo de estilo reforzado por RLHF" }, - { "id": "lex.aprovechar", "terms": ["aprovechar", "aprovecha", "aprovechando"], "weight": 3.0, "severity": "Low", "suggestion": "usar, emplear, sacar partido de", "evidence": "Análogo de “leverage”" }, - { "id": "lex.robusto", "terms": ["robusto", "robusta", "robustas", "robustos"], "weight": 3.5, "severity": "Medium", "suggestion": "sólido, fuerte, fiable", "evidence": "Análogo de “robust”" }, - { "id": "lex.solido", "terms": ["sólido", "sólida", "sólidas", "sólidos"], "weight": 3.0, "severity": "Low", "suggestion": "firme, fiable, consistente" }, - { "id": "lex.multifacetico","terms": ["multifacético", "multifacética", "polifacético"], "weight": 4.0, "severity": "Medium", "suggestion": "complejo — o nombra las facetas reales", "evidence": "Análogo de “multifaceted”" }, - { "id": "lex.matizado", "terms": ["matizado", "matizada", "matices"], "weight": 3.5, "severity": "Medium", "suggestion": "sutil, detallado — o precisa la distinción", "evidence": "Análogo de “nuanced”" }, - { "id": "lex.integral", "terms": ["integral", "integrales"], "weight": 2.5, "severity": "Low", "suggestion": "completo, global" }, - { "id": "lex.holistico", "terms": ["holístico", "holística"], "weight": 3.5, "severity": "Medium", "suggestion": "global, de conjunto" }, - { "id": "lex.sinergia", "terms": ["sinergia", "sinergias"], "weight": 3.5, "severity": "Medium", "suggestion": "colaboración, refuerzo mutuo" }, - { "id": "lex.vasto", "terms": ["vasto", "vasta", "vastas", "vastos"], "weight": 3.5, "severity": "Medium", "suggestion": "amplio, extenso, enorme" }, - { "id": "lex.panorama", "terms": ["panorama"], "weight": 3.0, "severity": "Low", "suggestion": "situación, contexto, campo", "evidence": "Análogo de “landscape”" }, - { "id": "lex.crucial", "terms": ["crucial", "cruciales"], "weight": 3.5, "severity": "Medium", "suggestion": "importante, esencial, clave", "evidence": "Análogo de “crucial”" }, - { "id": "lex.primordial", "terms": ["primordial", "primordiales"], "weight": 3.5, "severity": "Medium", "suggestion": "esencial, básico, principal" }, - { "id": "lex.fundamental", "terms": ["fundamental", "fundamentales"], "weight": 2.5, "severity": "Low", "suggestion": "clave, esencial, básico" }, - { "id": "lex.pivotal", "terms": ["pivotal", "pivotales"], "weight": 4.0, "severity": "Medium", "suggestion": "central, decisivo, clave" }, - { "id": "lex.resaltar", "terms": ["resaltar", "subrayar", "recalcar"], "weight": 3.0, "severity": "Low", "suggestion": "mostrar, señalar, destacar", "evidence": "Análogo de “underscore”" }, - { "id": "lex.vibrante", "terms": ["vibrante", "vibrantes"], "weight": 3.0, "severity": "Low", "suggestion": "animado, lleno de vida — o un detalle concreto" }, - { "id": "lex.meticuloso", "terms": ["meticuloso", "meticulosa", "meticulosamente"], "weight": 3.5, "severity": "Medium", "suggestion": "cuidadoso, minucioso, con cuidado" }, - { "id": "lex.pletora", "terms": ["plétora", "sinnúmero", "sinfín", "miríada"], "weight": 3.5, "severity": "Medium", "suggestion": "muchos, montones — o da la cifra" }, - { "id": "lex.transformador","terms": ["transformador", "transformadora", "transformadoras"], "weight": 3.5, "severity": "Medium", "suggestion": "de gran impacto — o muestra el cambio" }, - { "id": "lex.empoderar", "terms": ["empoderar", "empodera", "empoderando", "empoderamiento"], "weight": 3.5, "severity": "Medium", "suggestion": "dar poder a, capacitar, habilitar" }, - { "id": "lex.desbloquear", "terms": ["desbloquear", "desbloquea", "desbloqueando"], "weight": 3.0, "severity": "Low", "suggestion": "habilitar, abrir, permitir", "evidence": "Análogo de “unlock”" }, - { "id": "lex.elevar", "terms": ["elevar", "eleva", "elevando"], "weight": 2.5, "severity": "Low", "suggestion": "subir, mejorar" }, - { "id": "lex.vanguardia", "terms": ["vanguardia"], "weight": 3.0, "severity": "Low", "suggestion": "lo más avanzado, lo último en" }, - { "id": "lex.innovador", "terms": ["innovador", "innovadora", "innovadoras", "innovadores"], "weight": 2.5, "severity": "Low", "suggestion": "nuevo — o di qué lo hace nuevo" }, - { "id": "lex.profundo", "terms": ["profundo", "profunda", "profundamente"], "weight": 2.5, "severity": "Low", "suggestion": "hondo, marcado, intenso" }, - { "id": "lex.ademas", "terms": ["además"], "weight": 1.5, "severity": "Info", "suggestion": "y, también (con moderación)" }, - { "id": "lex.asimismo", "terms": ["asimismo"], "weight": 2.0, "severity": "Info", "suggestion": "también, igualmente" }, - { "id": "lex.utilizar", "terms": ["utilizar", "utiliza", "utilizando", "utilizamos"], "weight": 3.0, "severity": "Low", "suggestion": "usar", "evidence": "Inflación de “usar”" }, - { "id": "lex.agilizar", "terms": ["agilizar", "agiliza", "agilizando"], "weight": 3.0, "severity": "Low", "suggestion": "acelerar, simplificar", "evidence": "Análogo de “streamline”" }, - { "id": "lex.simplemente", "terms": ["simplemente"], "weight": 1.0, "severity": "Info", "suggestion": "muletilla — suele sobrar" }, - { "id": "lex.realmente", "terms": ["realmente"], "weight": 1.0, "severity": "Info", "suggestion": "muletilla — suele sobrar" }, - { "id": "lex.basicamente", "terms": ["básicamente", "basicamente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — suele sobrar" }, - { "id": "lex.esencialmente","terms": ["esencialmente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — suele sobrar" }, - { "id": "lex.honestamente", "terms": ["honestamente", "sinceramente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — córtala" }, - { "id": "lex.literalmente", "terms": ["literalmente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — córtala salvo sentido literal" }, - { "id": "lex.fundamentalmente", "terms": ["fundamentalmente"], "weight": 2.0, "severity": "Low", "suggestion": "muletilla — córtala" }, - { "id": "lex.inevitablemente", "terms": ["inevitablemente"], "weight": 2.0, "severity": "Low", "suggestion": "muletilla — córtala" } + { "id": "lex.sumergir", "terms": ["sumérgete", "sumergirse", "adentrémonos", "adéntrate", "adentrarse"], "weight": 5.5, "severity": "High", "suggestion": "explora, analiza, examina", "replacements": ["explora", "analiza", "examina"], "evidence": "Análogo directo de “delve”: apertura típica de IA" }, + { "id": "lex.abordar", "terms": ["abordar", "aborda", "abordando"], "weight": 3.5, "severity": "Medium", "suggestion": "tratar, resolver, atender", "replacements": ["tratar", "resolver", "atender"] }, + { "id": "lex.fomentar", "terms": ["fomentar", "fomenta", "fomentando", "fomento"], "weight": 3.0, "severity": "Low", "suggestion": "impulsar, promover, apoyar", "replacements": ["impulsar", "promover", "apoyar"], "evidence": "Verbo de estilo reforzado por RLHF" }, + { "id": "lex.aprovechar", "terms": ["aprovechar", "aprovecha", "aprovechando"], "weight": 3.0, "severity": "Low", "suggestion": "usar, emplear, sacar partido de", "replacements": ["usar", "emplear", "sacar partido de"], "evidence": "Análogo de “leverage”" }, + { "id": "lex.robusto", "terms": ["robusto", "robusta", "robustas", "robustos"], "weight": 3.5, "severity": "Medium", "suggestion": "sólido, fuerte, fiable", "replacements": ["sólido", "fuerte", "fiable"], "evidence": "Análogo de “robust”" }, + { "id": "lex.solido", "terms": ["sólido", "sólida", "sólidas", "sólidos"], "weight": 3.0, "severity": "Low", "suggestion": "firme, fiable, consistente", "replacements": ["firme", "fiable", "consistente"] }, + { "id": "lex.multifacetico","terms": ["multifacético", "multifacética", "polifacético"], "weight": 4.0, "severity": "Medium", "suggestion": "complejo — o nombra las facetas reales", "replacements": ["complejo"], "evidence": "Análogo de “multifaceted”" }, + { "id": "lex.matizado", "terms": ["matizado", "matizada", "matices"], "weight": 3.5, "severity": "Medium", "suggestion": "sutil, detallado — o precisa la distinción", "replacements": ["sutil", "detallado"], "evidence": "Análogo de “nuanced”" }, + { "id": "lex.integral", "terms": ["integral", "integrales"], "weight": 2.5, "severity": "Low", "suggestion": "completo, global", "replacements": ["completo", "global"] }, + { "id": "lex.holistico", "terms": ["holístico", "holística"], "weight": 3.5, "severity": "Medium", "suggestion": "global, de conjunto", "replacements": ["global", "de conjunto"] }, + { "id": "lex.sinergia", "terms": ["sinergia", "sinergias"], "weight": 3.5, "severity": "Medium", "suggestion": "colaboración, refuerzo mutuo", "replacements": ["colaboración", "refuerzo mutuo"] }, + { "id": "lex.vasto", "terms": ["vasto", "vasta", "vastas", "vastos"], "weight": 3.5, "severity": "Medium", "suggestion": "amplio, extenso, enorme", "replacements": ["amplio", "extenso", "enorme"] }, + { "id": "lex.panorama", "terms": ["panorama"], "weight": 3.0, "severity": "Low", "suggestion": "situación, contexto, campo", "replacements": ["situación", "contexto", "campo"], "evidence": "Análogo de “landscape”" }, + { "id": "lex.crucial", "terms": ["crucial", "cruciales"], "weight": 3.5, "severity": "Medium", "suggestion": "importante, esencial, clave", "replacements": ["importante", "esencial", "clave"], "evidence": "Análogo de “crucial”" }, + { "id": "lex.primordial", "terms": ["primordial", "primordiales"], "weight": 3.5, "severity": "Medium", "suggestion": "esencial, básico, principal", "replacements": ["esencial", "básico", "principal"] }, + { "id": "lex.fundamental", "terms": ["fundamental", "fundamentales"], "weight": 2.5, "severity": "Low", "suggestion": "clave, esencial, básico", "replacements": ["clave", "esencial", "básico"] }, + { "id": "lex.pivotal", "terms": ["pivotal", "pivotales"], "weight": 4.0, "severity": "Medium", "suggestion": "central, decisivo, clave", "replacements": ["central", "decisivo", "clave"] }, + { "id": "lex.resaltar", "terms": ["resaltar", "subrayar", "recalcar"], "weight": 3.0, "severity": "Low", "suggestion": "mostrar, señalar, destacar", "replacements": ["mostrar", "señalar", "destacar"], "evidence": "Análogo de “underscore”" }, + { "id": "lex.vibrante", "terms": ["vibrante", "vibrantes"], "weight": 3.0, "severity": "Low", "suggestion": "animado, lleno de vida — o un detalle concreto", "replacements": ["animado", "lleno de vida"] }, + { "id": "lex.meticuloso", "terms": ["meticuloso", "meticulosa", "meticulosamente"], "weight": 3.5, "severity": "Medium", "suggestion": "cuidadoso, minucioso, con cuidado", "replacements": ["cuidadoso", "minucioso", "con cuidado"] }, + { "id": "lex.pletora", "terms": ["plétora", "sinnúmero", "sinfín", "miríada"], "weight": 3.5, "severity": "Medium", "suggestion": "muchos, montones — o da la cifra", "replacements": ["muchos", "montones"] }, + { "id": "lex.transformador","terms": ["transformador", "transformadora", "transformadoras"], "weight": 3.5, "severity": "Medium", "suggestion": "de gran impacto — o muestra el cambio", "replacements": ["de gran impacto"] }, + { "id": "lex.empoderar", "terms": ["empoderar", "empodera", "empoderando", "empoderamiento"], "weight": 3.5, "severity": "Medium", "suggestion": "dar poder a, capacitar, habilitar", "replacements": ["dar poder a", "capacitar", "habilitar"] }, + { "id": "lex.desbloquear", "terms": ["desbloquear", "desbloquea", "desbloqueando"], "weight": 3.0, "severity": "Low", "suggestion": "habilitar, abrir, permitir", "replacements": ["habilitar", "abrir", "permitir"], "evidence": "Análogo de “unlock”" }, + { "id": "lex.elevar", "terms": ["elevar", "eleva", "elevando"], "weight": 2.5, "severity": "Low", "suggestion": "subir, mejorar", "replacements": ["subir", "mejorar"] }, + { "id": "lex.vanguardia", "terms": ["vanguardia"], "weight": 3.0, "severity": "Low", "suggestion": "de punta, lo más avanzado", "replacements": ["punta"] }, + { "id": "lex.innovador", "terms": ["innovador", "innovadora", "innovadoras", "innovadores"], "weight": 2.5, "severity": "Low", "suggestion": "nuevo — o di qué lo hace nuevo", "replacements": ["nuevo"] }, + { "id": "lex.profundo", "terms": ["profundo", "profunda", "profundamente"], "weight": 2.5, "severity": "Low", "suggestion": "hondo, marcado, intenso", "replacements": ["hondo", "marcado", "intenso"] }, + { "id": "lex.ademas", "terms": ["además"], "weight": 1.5, "severity": "Info", "suggestion": "y, también (con moderación)", "replacements": ["y", "también"] }, + { "id": "lex.asimismo", "terms": ["asimismo"], "weight": 2.0, "severity": "Info", "suggestion": "también, igualmente", "replacements": ["también", "igualmente"] }, + { "id": "lex.utilizar", "terms": ["utilizar", "utiliza", "utilizando", "utilizamos"], "weight": 3.0, "severity": "Low", "suggestion": "usar", "replacements": ["usar"], "evidence": "Inflación de “usar”" }, + { "id": "lex.agilizar", "terms": ["agilizar", "agiliza", "agilizando"], "weight": 3.0, "severity": "Low", "suggestion": "acelerar, simplificar", "replacements": ["acelerar", "simplificar"], "evidence": "Análogo de “streamline”" }, + { "id": "lex.simplemente", "terms": ["simplemente"], "weight": 1.0, "severity": "Info", "suggestion": "muletilla — suele sobrar", "delete": true }, + { "id": "lex.realmente", "terms": ["realmente"], "weight": 1.0, "severity": "Info", "suggestion": "muletilla — suele sobrar", "delete": true }, + { "id": "lex.basicamente", "terms": ["básicamente", "basicamente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — suele sobrar", "delete": true }, + { "id": "lex.esencialmente","terms": ["esencialmente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — suele sobrar", "delete": true }, + { "id": "lex.honestamente", "terms": ["honestamente", "sinceramente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — córtala", "delete": true }, + { "id": "lex.literalmente", "terms": ["literalmente"], "weight": 1.5, "severity": "Info", "suggestion": "muletilla — córtala salvo sentido literal", "delete": true }, + { "id": "lex.fundamentalmente", "terms": ["fundamentalmente"], "weight": 2.0, "severity": "Low", "suggestion": "muletilla — córtala", "delete": true }, + { "id": "lex.inevitablemente", "terms": ["inevitablemente"], "weight": 2.0, "severity": "Low", "suggestion": "muletilla — córtala", "delete": true } ], "patterns": [ { "id": "rhet.no-solo-sino", "category": "Rhetorical", "regex": "\\bno s[oó]lo\\b[^.?!\\n]{1,80}?\\bsino (también|que también|que)\\b", "weight": 5.0, "severity": "High", "message": "Paralelismo negativo (“no solo… sino también…”).", "suggestion": "Divídelo en dos frases claras o empieza por lo más fuerte." }, diff --git a/src/SignsOfAI.Core/Rules/RulePack.cs b/src/SignsOfAI.Core/Rules/RulePack.cs index 8e998f0..389e0ca 100644 --- a/src/SignsOfAI.Core/Rules/RulePack.cs +++ b/src/SignsOfAI.Core/Rules/RulePack.cs @@ -19,8 +19,80 @@ public sealed class LexicalRule /// Comma-separated human-friendly alternatives. public required string Suggestion { get; init; } + /// + /// Machine-applicable replacements, best first, for the live rewriter. is + /// prose written for a person ("mix, blend, range — or just name the thing"); this is the subset a + /// program can actually substitute. Optional: when omitted, the rewriter falls back to reading the + /// leading comma-separated terms out of , so third-party catalogs written + /// before this field existed still work. + /// + public string[]? Replacements { get; init; } + + /// + /// True when the fix is to delete the word rather than swap it — the empty intensifiers ("just", + /// "simply", "realmente"). Deliberately explicit rather than inferred from , + /// whose wording is language-specific ("usually deletable" / "suele sobrar") and would silently + /// fail for any language the packs don't ship. + /// + public bool Delete { get; init; } + /// Optional supporting evidence shown to the user. public string? Evidence { get; init; } + + /// + /// What the live rewriter can substitute for a match, best first. Empty when this rule has no + /// mechanical fix (the writer has to make a judgement call), which the rewriter treats as + /// "highlight it, don't touch it". + /// + public IReadOnlyList RewriteOptions() => + Replacements is { Length: > 0 } explicitOnes + ? explicitOnes + : SuggestionParser.LeadingTerms(Suggestion); +} + +/// +/// Salvages machine-applicable replacements from a prose suggestion, for catalogs that predate +/// — including ones contributed by users. +/// +/// Deliberately conservative and language-neutral: it takes the leading comma-separated terms and +/// stops at the first aside, and it never infers a *deletion*. Guessing wrong here would silently +/// change someone's prose in a way they didn't ask for, so anything ambiguous yields nothing and the +/// rewriter leaves the word alone. +/// +public static class SuggestionParser +{ + // An aside begins at a dash ("mix, blend — or just name the thing") or at an "or"-clause + // ("complex, or specify the actual facets"). Everything from there on is advice, not a term. + private static readonly string[] AsideMarkers = + ["—", " – ", " -- ", ", or ", ", o ", " or just ", " o just "]; + + private const int MaxWordsPerTerm = 4; // "a lot of", "lo más avanzado" — beyond this it's prose + + public static IReadOnlyList LeadingTerms(string? suggestion) + { + if (string.IsNullOrWhiteSpace(suggestion)) return []; + + var head = suggestion; + foreach (var marker in AsideMarkers) + { + var at = head.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (at >= 0) head = head[..at]; + } + + var terms = head + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(term => term.Length > 0 + && term.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= MaxWordsPerTerm + && !term.Contains('(') && !term.Contains(':')) + .ToList(); + + // Only a comma-separated list of alternatives is unmistakable. A single term is not: nothing + // short of knowing the language separates the replacement "use" from the description + // "muletilla" (Spanish for "filler word"), and substituting the latter into someone's sentence + // would be far worse than leaving the word alone. So a lone term is refused here, and any rule + // wanting a single replacement states it in `replacements` — which every built-in rule does. + return terms.Count >= 2 ? terms : []; + } } /// A regex rule for rhetorical/syntactic patterns spanning multiple words. diff --git a/src/SignsOfAI.Web/Components/Icon.razor b/src/SignsOfAI.Web/Components/Icon.razor index 65591be..e86e1a2 100644 --- a/src/SignsOfAI.Web/Components/Icon.razor +++ b/src/SignsOfAI.Web/Components/Icon.razor @@ -33,6 +33,8 @@ "lightbulb" => "", "pen" => "", "check" => "", + "wand" => "", + "copy" => "", _ => "", }; } diff --git a/src/SignsOfAI.Web/Components/LiveRewritePanel.razor b/src/SignsOfAI.Web/Components/LiveRewritePanel.razor new file mode 100644 index 0000000..27c3c5b --- /dev/null +++ b/src/SignsOfAI.Web/Components/LiveRewritePanel.razor @@ -0,0 +1,279 @@ +@* The live rewrite: your text on the left, the de-AI-ified version on the right, rebuilt on every + keystroke. No model, no network, no API key — it runs off the rule-pack, which is what makes it + instant enough to keep up with typing. + + Only substitutions the rules can make honestly appear here. Structural tells (negative + parallelisms, copula avoidance, robotic rhythm) need real rewriting and are left to the optional + AI pass; the footer says so rather than pretending they were handled. *@ +@using SignsOfAI.Core +@using SignsOfAI.Core.Model +@using SignsOfAI.Core.Rewriting +@using SignsOfAI.Core.Rules +@inherits LocalizedComponent +@inject AiWritingAnalyzer Analyzer +@inject IJSRuntime JS + +
+
+

@L["rw.h"]

+ @L["rw.ondevice"] +
+ @foreach (var level in new[] { RewriteStrength.Light, RewriteStrength.Standard, RewriteStrength.Thorough }) + { + + } +
+
+ + @if (_plan.Count == 0) + { +

@L["rw.nothing"]

+ } + else + { +
+ + @Math.Round(BeforeScore) + + @Math.Round(_afterScore) + @{ var delta = BeforeScore - _afterScore; } + @if (delta > 0.5) { −@Math.Round(delta) } + + @L.P(AppliedCount, "rw.applied") + @if (PendingCount > 0) + { + @L.P(PendingCount, "rw.pending") + } +
+ + +
+
+ +
+
+

@L["home.original"]

+
+ @foreach (var seg in OriginalSegments()) + { + if (seg.Edit is { } edit) + { + @seg.Text + } + else { @seg.Text } + } +
+
+
+

@L["rw.rewritten"]

+
@_rewritten
+
+
+ +
    + @foreach (var edit in _plan) + { + var key = KeyOf(edit); + var applied = IsApplied(edit); +
  • + +
    +
    + @edit.Original + + @if (edit.IsDeletion) + { + @L["rw.deleted"] + } + else + { + @Chosen(edit) + } + @if (!edit.AutoApply) + { + @L["rw.pick"] + } +
    + @if (edit.Options.Count > 1) + { +
    + @foreach (var option in edit.Options) + { + + } +
    + } +
    +
  • + } +
+ } + + @if (StructuralCount > 0) + { +

+ @L.P(StructuralCount, "rw.structural") +

+ } +
+ +@code { + /// The text the findings were produced from. + [Parameter, EditorRequired] public string Text { get; set; } = ""; + + /// The analysis of . + [Parameter, EditorRequired] public AnalysisResult Result { get; set; } = default!; + + /// Custom catalogs in play, so the rewriter reads the same merged rules as the analyzer. + [Parameter] public IReadOnlyList ExtraPacks { get; set; } = []; + + [Parameter] public EventCallback OnUseAsInput { get; set; } + + // Decisions are keyed by rule + word rather than by position, so they survive the user typing + // somewhere else in the document: a position key would silently land on a different word. + private readonly HashSet _skipped = new(StringComparer.Ordinal); + private readonly Dictionary _picked = new(StringComparer.Ordinal); + + private RewriteStrength _strength = RewriteStrength.Standard; + private IReadOnlyList _plan = []; + private string _rewritten = ""; + private double _afterScore; + private bool _copied; + private string _lastText = ""; + + private double BeforeScore => Result?.OverallScore ?? 0; + private int AppliedCount => _plan.Count(IsApplied); + private int PendingCount => _plan.Count(e => !IsApplied(e)); + + /// Findings the rewriter deliberately doesn't touch, because substitution can't fix them. + private int StructuralCount => Result is null + ? 0 + : Result.Findings.Count(f => f.Category is SignCategory.Rhetorical or SignCategory.Syntactic); + + protected override void OnParametersSet() => Recompute(); + + private void SetStrength(RewriteStrength level) + { + if (_strength == level) return; + _strength = level; + Recompute(); + } + + private void Recompute() + { + if (Result is null || string.IsNullOrWhiteSpace(Text)) + { + _plan = []; + _rewritten = ""; + _afterScore = 0; + return; + } + + if (Text != _lastText) + { + _lastText = Text; + _copied = false; + } + + var pack = AiWritingAnalyzer.ResolvePack(Result.Language, ExtraPacks); + _plan = LocalRewriter.Plan(Text, Result.Findings, pack, _strength); + + var rejected = _plan.Where(e => !IsApplied(e)).Select(e => e.Span.Start).ToHashSet(); + var chosen = _plan + .Where(e => !e.IsDeletion && _picked.TryGetValue(KeyOf(e), out _)) + .ToDictionary(e => e.Span.Start, e => _picked[KeyOf(e)]); + + _rewritten = LocalRewriter.Apply(Text, _plan, chosen, rejected, Result.Language); + _afterScore = _rewritten == Text + ? BeforeScore + : Analyzer.Analyze(_rewritten, Result.Language, ExtraPacks).OverallScore; + } + + /// Rule + word, lower-cased: every occurrence of the same tell behaves as one decision. + private static string KeyOf(RewriteEdit edit) => edit.RuleId + "|" + edit.Original.ToLowerInvariant(); + + /// + /// An edit counts as applied unless it was skipped, or it needs a decision the writer hasn't made. + /// Nothing that could mangle grammar is applied on our own initiative. + /// + private bool IsApplied(RewriteEdit edit) + { + var key = KeyOf(edit); + if (_skipped.Contains(key)) return false; + return edit.AutoApply || _picked.ContainsKey(key); + } + + private string Chosen(RewriteEdit edit) => + _picked.TryGetValue(KeyOf(edit), out var pick) ? pick : edit.Preferred; + + private void Toggle(RewriteEdit edit) + { + var key = KeyOf(edit); + if (IsApplied(edit)) + { + _skipped.Add(key); + } + else + { + _skipped.Remove(key); + // Accepting an edit that needed a decision means taking its default. + if (!edit.AutoApply && !edit.IsDeletion && !_picked.ContainsKey(key)) + _picked[key] = edit.Preferred; + } + Recompute(); + } + + private void Choose(RewriteEdit edit, string option) + { + var key = KeyOf(edit); + _picked[key] = option; + _skipped.Remove(key); // picking an alternative is an acceptance + Recompute(); + } + + private string EditTitle(RewriteEdit edit) => + edit.IsDeletion + ? L.F("rw.title.delete", edit.Original) + : L.F("rw.title.swap", edit.Original, Chosen(edit)); + + private async Task Copy() + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", _rewritten); + _copied = true; + } + + // ── highlighting the original ──────────────────────────────────────────── + private readonly record struct Seg(string Text, RewriteEdit? Edit); + + private IEnumerable OriginalSegments() + { + var cursor = 0; + foreach (var edit in _plan.OrderBy(e => e.Span.Start)) + { + if (edit.Span.Start < cursor) continue; + if (edit.Span.Start > cursor) yield return new Seg(Text[cursor..edit.Span.Start], null); + yield return new Seg(edit.Span.Slice(Text), edit); + cursor = edit.Span.End; + } + if (cursor < Text.Length) yield return new Seg(Text[cursor..], null); + } + + private static string ScoreClass(double score) => score switch + { + >= 70 => "danger", + >= 45 => "warn", + >= 20 => "notice", + _ => "good", + }; +} diff --git a/src/SignsOfAI.Web/Pages/Home.razor b/src/SignsOfAI.Web/Pages/Home.razor index c3076c8..4c520e4 100644 --- a/src/SignsOfAI.Web/Pages/Home.razor +++ b/src/SignsOfAI.Web/Pages/Home.razor @@ -198,7 +198,10 @@ }
- + @@ -234,6 +237,12 @@
@L["home.stat.diversity"]@r.Statistics.LexicalDiversity.ToString("0.00")
+ @if (_showRewrite) + { + + } +

@L["home.rhythm"]

@L.M("home.rhythm.hint")

@@ -361,6 +370,10 @@ else private HumanizeSettings _settings = new(); private bool _settingsSaved; + // Sticky across keystrokes: once the split view is open the user is working in it, so it must not + // collapse every time the analysis re-runs. + private bool _showRewrite; + private bool _humanizing; private string? _humanized; private AnalysisResult? _humanizedResult; @@ -575,6 +588,15 @@ else RunAnalysis(); } + /// Pull the locally-rewritten text back into the editor and score it for real. + private void UseRewritten(string rewritten) + { + if (string.IsNullOrWhiteSpace(rewritten)) return; + _text = rewritten; + _fileNote = null; + RunAnalysis(); + } + // ── share card ────────────────────────────────────────────────── private void ShowCard() { diff --git a/src/SignsOfAI.Web/wwwroot/css/app.css b/src/SignsOfAI.Web/wwwroot/css/app.css index a2b3d92..0f43660 100644 --- a/src/SignsOfAI.Web/wwwroot/css/app.css +++ b/src/SignsOfAI.Web/wwwroot/css/app.css @@ -413,6 +413,74 @@ button:disabled { opacity: .45; cursor: not-allowed; } .d-del { background: color-mix(in srgb, var(--danger) 16%, transparent); text-decoration: line-through; color: var(--text-muted); border-radius: 3px; } .d-add { background: color-mix(in srgb, var(--good) 20%, transparent); box-shadow: inset 0 -2px 0 var(--good); border-radius: 3px; } +/* ---- live rewrite ---- */ +.rewrite-btn.on { box-shadow: inset 0 0 0 2px var(--brand-ink); } +.rewrite-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .7rem; } +.rewrite-head h3 { margin: 0; font-size: 1.1rem; display: inline-flex; align-items: center; gap: .4rem; } +.rw-badge { + display: inline-flex; align-items: center; gap: .3rem; font-size: .7rem; font-weight: 700; + color: var(--good); background: color-mix(in srgb, var(--good) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--good) 30%, transparent); + padding: .15rem .5rem; border-radius: 999px; +} +.rw-strength { display: flex; gap: .3rem; margin-left: auto; } + +.rw-summary { display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin-bottom: .8rem; } +.rw-count { font-size: .84rem; font-weight: 600; color: var(--text-muted); } +.rw-pending { + font-size: .76rem; font-weight: 700; color: var(--notice); cursor: help; + background: color-mix(in srgb, var(--notice) 14%, transparent); + padding: .12rem .5rem; border-radius: 999px; +} +.rw-actions { margin-left: auto; display: flex; gap: .4rem; } + +.rw-panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 760px) { .rw-panes { grid-template-columns: 1fr; } } +.rw-pane h4 { + margin: 0 0 .4rem; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; + color: var(--text-muted); display: inline-flex; align-items: center; gap: .35rem; +} +.rw-body { max-height: 22rem; overflow-y: auto; font-size: .97rem; } +.rw-out { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--good) 35%, transparent); } + +/* Applied edits read as resolved (green); skipped ones stay flagged as the tell they are. */ +.rw-hl { border-radius: 4px; padding: .05em .15em; cursor: help; } +.rw-hl.on { background: color-mix(in srgb, var(--good) 20%, transparent); box-shadow: inset 0 -2px 0 var(--good); } +.rw-hl.off { background: color-mix(in srgb, var(--lex) 20%, transparent); box-shadow: inset 0 -2px 0 var(--lex); } + +.rw-list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .4rem; } +.rw-item { + display: flex; gap: .6rem; align-items: flex-start; + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 8px; padding: .5rem .6rem; +} +.rw-item.skipped { opacity: .62; } +.rw-toggle { + flex: 0 0 auto; width: 1.65rem; height: 1.65rem; padding: 0; border-radius: 6px; + display: inline-flex; align-items: center; justify-content: center; cursor: pointer; + background: var(--surface); border: 1px solid var(--border); color: var(--text-muted); +} +.rw-toggle.on { background: var(--good); border-color: transparent; color: #fff; } +.rw-item-body { min-width: 0; display: flex; flex-direction: column; gap: .35rem; } +.rw-change { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; font-size: .92rem; } +.rw-change del { color: var(--text-muted); } +.rw-change ins { text-decoration: none; font-weight: 700; color: var(--good); } +.rw-arrow { color: var(--text-muted); } +.rw-deleted { color: var(--text-muted); font-size: .84rem; } +.rw-needspick { + font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; + color: var(--notice); background: color-mix(in srgb, var(--notice) 14%, transparent); + padding: .1rem .4rem; border-radius: 4px; cursor: help; +} +.rw-alts { display: flex; gap: .3rem; flex-wrap: wrap; } +.rw-alt { + font-size: .78rem; padding: .18rem .5rem; border-radius: 999px; cursor: pointer; + background: var(--surface); border: 1px solid var(--border); color: var(--text-muted); +} +.rw-alt:hover { color: var(--text); } +.rw-alt.on { background: var(--brand); border-color: transparent; color: var(--brand-ink); font-weight: 700; } +.rw-structural { margin: .9rem 0 0; } + /* ---- share card ---- */ .share-canvas { width: 100%; max-width: 640px; height: auto; display: block; margin: .3rem auto 0; border: 1px solid var(--border); border-radius: 10px; } diff --git a/src/SignsOfAI.Web/wwwroot/i18n/en.json b/src/SignsOfAI.Web/wwwroot/i18n/en.json index 819edf8..15e7c7d 100644 --- a/src/SignsOfAI.Web/wwwroot/i18n/en.json +++ b/src/SignsOfAI.Web/wwwroot/i18n/en.json @@ -240,5 +240,29 @@ "drawer.web.auto.note": "Automatic search isn't switched on for this server, so you'll only see the manual links below — and they find exactly the same thing.", "drawer.web.bottom": "Both find the same thing — pages that contain your passage word-for-word. The only difference is who does the clicking, and how much text leaves your device to get there.", "notfound.h": "Not found", - "notfound.p": "Sorry, the content you are looking for does not exist." + "notfound.p": "Sorry, the content you are looking for does not exist.", + "rw.h": "Live rewrite", + "rw.ondevice": "on your device", + "rw.strength": "Rewrite strength", + "rw.strength.light": "Light", + "rw.strength.standard": "Standard", + "rw.strength.thorough": "Thorough", + "rw.nothing": "Nothing here needs a mechanical fix at this strength.", + "rw.rewritten": "Rewritten", + "rw.applied.one": "{0} change applied", + "rw.applied.other": "{0} changes applied", + "rw.pending.one": "{0} left to you", + "rw.pending.other": "{0} left to you", + "rw.pending.why": "The rules match this word in a different form, so swapping it automatically would break the grammar. Pick the wording you want.", + "rw.pick": "your call", + "rw.apply": "Apply this change", + "rw.skip": "Leave this one alone", + "rw.deleted": "removed", + "rw.copied": "Copied", + "rw.title.delete": "“{0}” is removed", + "rw.title.swap": "“{0}” becomes “{1}”", + "rw.structural.one": "{0} tell needs real rewriting, not a word swap — rhythm and rhetorical structure are what the optional AI pass is for.", + "rw.structural.other": "{0} tells need real rewriting, not a word swap — rhythm and rhetorical structure are what the optional AI pass is for.", + "home.rewrite.show": "Live rewrite", + "home.rewrite.hide": "Hide live rewrite" } diff --git a/src/SignsOfAI.Web/wwwroot/i18n/es.json b/src/SignsOfAI.Web/wwwroot/i18n/es.json index c96d16d..47c6858 100644 --- a/src/SignsOfAI.Web/wwwroot/i18n/es.json +++ b/src/SignsOfAI.Web/wwwroot/i18n/es.json @@ -240,5 +240,29 @@ "drawer.web.auto.note": "La búsqueda automática no está activada en este servidor, así que solo verás los enlaces manuales de abajo, y encuentran exactamente lo mismo.", "drawer.web.bottom": "Ambas encuentran lo mismo: páginas que contienen tu pasaje palabra por palabra. La única diferencia es quién hace los clics y cuánto texto sale de tu dispositivo para llegar allí.", "notfound.h": "No encontrado", - "notfound.p": "Lo sentimos, el contenido que buscas no existe." + "notfound.p": "Lo sentimos, el contenido que buscas no existe.", + "rw.h": "Reescritura en vivo", + "rw.ondevice": "en tu dispositivo", + "rw.strength": "Intensidad de la reescritura", + "rw.strength.light": "Suave", + "rw.strength.standard": "Normal", + "rw.strength.thorough": "A fondo", + "rw.nothing": "Aquí no hay nada que corregir mecánicamente con esta intensidad.", + "rw.rewritten": "Reescrito", + "rw.applied.one": "{0} cambio aplicado", + "rw.applied.other": "{0} cambios aplicados", + "rw.pending.one": "{0} queda a tu criterio", + "rw.pending.other": "{0} quedan a tu criterio", + "rw.pending.why": "Las reglas coinciden con esta palabra en otra forma, así que cambiarla automáticamente rompería la gramática. Elige tú la opción.", + "rw.pick": "tú decides", + "rw.apply": "Aplicar este cambio", + "rw.skip": "Dejar esta como está", + "rw.deleted": "eliminada", + "rw.copied": "Copiado", + "rw.title.delete": "Se elimina “{0}”", + "rw.title.swap": "“{0}” pasa a “{1}”", + "rw.structural.one": "{0} señal necesita una reescritura real, no un cambio de palabra: el ritmo y la estructura retórica son el trabajo del pase opcional con IA.", + "rw.structural.other": "{0} señales necesitan una reescritura real, no un cambio de palabra: el ritmo y la estructura retórica son el trabajo del pase opcional con IA.", + "home.rewrite.show": "Reescritura en vivo", + "home.rewrite.hide": "Ocultar reescritura en vivo" } diff --git a/tests/SignsOfAI.Core.Tests/LocalRewriterTests.cs b/tests/SignsOfAI.Core.Tests/LocalRewriterTests.cs new file mode 100644 index 0000000..bdc1774 --- /dev/null +++ b/tests/SignsOfAI.Core.Tests/LocalRewriterTests.cs @@ -0,0 +1,396 @@ +using SignsOfAI.Core; +using SignsOfAI.Core.Model; +using SignsOfAI.Core.Rewriting; +using SignsOfAI.Core.Rules; +using Xunit; + +namespace SignsOfAI.Core.Tests; + +/// +/// The rewriter edits someone's prose, so the bar is higher than "it compiles": a wrong edit is worse +/// than no edit. These tests pin the mechanics that decide whether the output reads like English — +/// capitalization, spacing around a removed word, and refusing to conjugate. +/// +public class LocalRewriterTests +{ + private readonly AiWritingAnalyzer _analyzer = new(); + + private (string Text, IReadOnlyList Edits, RulePack Pack) PlanFor( + string text, string language = "en", RewriteStrength strength = RewriteStrength.Thorough) + { + var pack = RulePackLoader.Load(language); + var result = _analyzer.Analyze(text, language); + return (text, LocalRewriter.Plan(text, result.Findings, pack, strength), pack); + } + + private string Rewrite(string text, string language = "en", RewriteStrength strength = RewriteStrength.Thorough) + { + var (_, edits, _) = PlanFor(text, language, strength); + return LocalRewriter.Apply(text, edits); + } + + // ── substitution ───────────────────────────────────────────────────────── + + [Fact] + public void Replaces_an_overused_word_with_its_first_alternative() + { + Assert.Equal("We examine the data.", Rewrite("We delve the data.")); + } + + [Fact] + public void Carries_the_original_capitalization_onto_the_replacement() + { + Assert.Equal("Examine the data.", Rewrite("Delve the data.")); + } + + [Fact] + public void Uppercases_a_replacement_for_an_all_caps_original() + { + Assert.Equal("EXAMINE the data.", Rewrite("DELVE the data.")); + } + + [Fact] + public void Leaves_an_inflected_form_alone_but_still_reports_it() + { + // "showcased" is in the rule's terms, but the replacements fit "showcase" — substituting would + // produce "The report show results". The edit is offered, not applied. + var (text, edits, _) = PlanFor("The report showcased strong results."); + + var edit = Assert.Single(edits, e => e.RuleId == "lex.showcase"); + Assert.False(edit.AutoApply); + Assert.NotEmpty(edit.Options); + Assert.Equal(text, LocalRewriter.Apply(text, edits.Where(e => e.AutoApply).ToList())); + } + + [Theory] + [InlineData("We must delve into the data.", "delve")] // phrasal verb: "examine into" is wrong + [InlineData("It is a testament to progress.", "testament")] // governed preposition: "proof to" is wrong + [InlineData("Let us embark on a project.", "embark")] + public void Declines_to_swap_a_word_that_governs_the_particle_after_it(string text, string word) + { + // Alternatives can't rescue these either — picking "look into" would yield "look into into" — + // so the honest move is to leave the sentence alone and let the finding advise the writer. + var (_, edits, _) = PlanFor(text); + + Assert.DoesNotContain(edits, e => e.Original.Equals(word, StringComparison.OrdinalIgnoreCase)); + Assert.Equal(text, LocalRewriter.Apply(text, edits)); + } + + [Fact] + public void Fixes_the_indefinite_article_when_the_sound_changes() + { + // "crucial" → "important" flips the initial sound; leaving "a" would read as a typo. + Assert.Equal("It is an important step.", Rewrite("It is a crucial step.")); + } + + [Fact] + public void Declines_a_swap_in_the_quantifier_frame() + { + // "a plethora of options" → "a many of options" is not English. + const string text = "There is a plethora of options."; + Assert.Equal(text, Rewrite(text)); + } + + [Fact] + public void Never_deletes_a_word_that_holds_up_a_flagged_construction() + { + // "just" is an empty intensifier in general, but here it carries the whole negative + // parallelism: dropping it turns "more than a tool" into "not a tool". + var rewritten = Rewrite("It's not just a tool, it's a solution."); + Assert.Contains("not just a tool", rewritten); + } + + [Fact] + public void Still_swaps_a_noun_before_the_genitive() + { + // "of" survives a noun swap untouched, so suppressing this case would cost a good edit. + Assert.Equal("the rich mix of innovation", Rewrite("the rich tapestry of innovation")); + } + + [Fact] + public void Applies_a_chosen_alternative_over_the_default() + { + var (text, edits, _) = PlanFor("We delve the data."); + var edit = Assert.Single(edits); + + var chosen = new Dictionary { [edit.Span.Start] = "explore" }; + Assert.Equal("We explore the data.", LocalRewriter.Apply(text, edits, chosen)); + } + + [Fact] + public void Leaves_a_rejected_edit_untouched() + { + var (text, edits, _) = PlanFor("We delve the data."); + var rejected = new HashSet { edits[0].Span.Start }; + + Assert.Equal(text, LocalRewriter.Apply(text, edits, rejected: rejected)); + } + + // ── deletion ───────────────────────────────────────────────────────────── + + [Fact] + public void Deleting_a_mid_sentence_word_leaves_one_space() + { + Assert.Equal("It's a tool.", Rewrite("It's just a tool.")); + } + + [Fact] + public void Deleting_a_sentence_opener_recapitalizes_what_follows() + { + Assert.Equal("The bus was late.", Rewrite("Actually, the bus was late.")); + } + + [Fact] + public void Deleting_a_comma_wrapped_word_takes_both_commas() + { + Assert.Equal("The bus was late.", Rewrite("The bus was, actually, late.")); + } + + [Fact] + public void Deleting_a_word_before_punctuation_does_not_leave_a_gap() + { + Assert.Equal("The bus was late.", Rewrite("The bus was late, truly.")); + } + + [Fact] + public void Handles_two_deletions_in_a_row_without_losing_one() + { + // Regression: capitalizing straight after the first deletion used to consume the second + // edit's first character, silently dropping it. + Assert.Equal("Do it.", Rewrite("Just simply do it.")); + } + + [Fact] + public void Does_not_capitalize_after_a_semicolon() + { + Assert.Equal("He left; it rained.", Rewrite("He left; actually, it rained.")); + } + + // ── planning ───────────────────────────────────────────────────────────── + + [Fact] + public void Plans_no_edits_for_text_with_no_lexical_tells() + { + var (_, edits, _) = PlanFor("The bus was late again and my shoes are wet."); + Assert.Empty(edits); + } + + [Fact] + public void Edits_never_overlap() + { + var (_, edits, _) = PlanFor( + "In today's digital age we must delve into the rich tapestry of multifaceted innovation, " + + "which is truly a testament to progress and simply showcases robust synergy."); + + var ordered = edits.OrderBy(e => e.Span.Start).ToList(); + for (var i = 1; i < ordered.Count; i++) + Assert.True(ordered[i].Span.Start >= ordered[i - 1].Span.End, + $"'{ordered[i - 1].Original}' and '{ordered[i].Original}' overlap."); + } + + [Fact] + public void Light_touches_less_than_thorough() + { + const string text = + "We must delve into this multifaceted approach, which is simply a robust testament to progress."; + + var light = LocalRewriter.Plan(text, _analyzer.Analyze(text, "en").Findings, + RulePackLoader.Load("en"), RewriteStrength.Light); + var thorough = LocalRewriter.Plan(text, _analyzer.Analyze(text, "en").Findings, + RulePackLoader.Load("en"), RewriteStrength.Thorough); + + Assert.True(light.Count < thorough.Count); + Assert.All(light, e => Assert.Equal(Severity.High, e.Severity)); + } + + [Fact] + public void Ignores_findings_that_have_no_mechanical_fix() + { + // A negative parallelism needs a structural rewrite, so the rewriter must not touch it. + var (text, edits, _) = PlanFor("It's not just a tool, it's a solution."); + + Assert.DoesNotContain(edits, e => e.RuleId.StartsWith("rhet.") || e.RuleId.StartsWith("syn.")); + Assert.Contains(text, text); // sanity: the pattern finding exists but yields no edit + } + + // ── the point of the whole thing ───────────────────────────────────────── + + [Fact] + public void Rewriting_lowers_the_ai_score() + { + const string text = + "In today's digital age, we must delve into the rich tapestry of modern innovation. " + + "This multifaceted and nuanced approach is simply a robust testament to human progress. " + + "Moreover, by leveraging cutting-edge technology, organizations can showcase excellence."; + + var before = _analyzer.Analyze(text, "en"); + var rewritten = LocalRewriter.Apply( + text, LocalRewriter.Plan(text, before.Findings, RulePackLoader.Load("en"), RewriteStrength.Thorough)); + var after = _analyzer.Analyze(rewritten, "en"); + + Assert.True(after.OverallScore < before.OverallScore, + $"score did not drop: {before.OverallScore} → {after.OverallScore}"); + Assert.True(after.Findings.Count < before.Findings.Count); + } + + [Fact] + public void Works_in_spanish_too() + { + var rewritten = Rewrite("Debemos utilizar un enfoque robusto y crucial.", "es"); + + Assert.DoesNotContain("utilizar", rewritten); + Assert.DoesNotContain("robusto", rewritten); + } + + [Fact] + public void Keeps_spanish_gender_agreement_with_the_article() + { + // "panorama" is masculine, "situación" feminine: swapping it under "el" would give + // "el situación". The mismatched alternative is withheld rather than the article guessed at. + const string text = "Las cifras cambian en el panorama actual."; + var rewritten = Rewrite(text, "es"); + + Assert.DoesNotContain("el situación", rewritten); + Assert.DoesNotContain("el situacion", rewritten); + } + + [Fact] + public void Picks_a_gender_matching_alternative_rather_than_declining() + { + // "panorama" is masculine despite its -a, which the article makes plain. "situación" is + // dropped from the options and a masculine one is used, so the edit still happens. + var rewritten = Rewrite("Las cifras cambian en el panorama actual.", "es"); + + Assert.DoesNotContain("panorama", rewritten); + Assert.DoesNotContain("el situación", rewritten); + } + + [Fact] + public void Leaves_a_swap_alone_when_no_alternative_agrees() + { + // Every option reads feminine, the article is masculine: there is nothing safe to substitute. + const string text = "Vemos el panorama actual."; + var pack = RulePack.FromJson( + """ + { + "language": "es", + "lexical": [ + { "id": "custom.g", "terms": ["panorama"], "weight": 6, "severity": "High", + "suggestion": "x", "replacements": ["situación", "perspectiva"] } + ] + } + """); + + var findings = _analyzer.Analyze(text, "es", [pack]).Findings; + var rewritten = LocalRewriter.Apply( + text, LocalRewriter.Plan(text, findings, pack, RewriteStrength.Thorough), language: "es"); + + Assert.Equal(text, rewritten); + } + + [Fact] + public void Gender_agreement_only_applies_where_there_is_an_article() + { + // No article in front means nothing to disagree with, so the swap proceeds normally. + var rewritten = Rewrite("Debemos utilizar herramientas nuevas.", "es"); + Assert.DoesNotContain("utilizar", rewritten); + } + + [Fact] + public void Spanish_deletion_markers_are_honoured() + { + // "simplemente" is a delete rule in the Spanish pack; its prose reads "muletilla — suele sobrar", + // which no English marker would ever match. This is why the field is explicit. + Assert.Equal("Es una herramienta.", Rewrite("Es simplemente una herramienta.", "es")); + } + + // ── the fallback parser, for catalogs written before the field existed ──── + + [Theory] + [InlineData("examine, explore, look into", new[] { "examine", "explore", "look into" })] + [InlineData("mix, blend, range — or just name the thing", new[] { "mix", "blend", "range" })] + [InlineData("strong, reliable, solid", new[] { "strong", "reliable", "solid" })] + public void Salvages_a_comma_separated_list_from_a_prose_suggestion(string suggestion, string[] expected) + { + Assert.Equal(expected, SuggestionParser.LeadingTerms(suggestion)); + } + + [Theory] + [InlineData("empty intensifier — cut it")] // describes the problem, is not a replacement + [InlineData("often removable")] + [InlineData("muletilla — suele sobrar")] // "filler word" — a description, in Spanish + [InlineData("usually deletable")] + [InlineData("use")] // plausible, but a lone term is never trusted + [InlineData("complex, or specify the actual facets")] + [InlineData("")] + public void Never_guesses_from_anything_but_an_unmistakable_list(string suggestion) + { + // Telling the replacement "use" from the description "muletilla" needs to know the language. + // Rather than guess, a lone term yields nothing and the word is left alone; catalogs wanting a + // single replacement say so in `replacements`. + Assert.Empty(SuggestionParser.LeadingTerms(suggestion)); + } + + [Theory] + [InlineData("en")] + [InlineData("es")] + public void Every_built_in_lexical_rule_states_its_fix_explicitly(string language) + { + // The fallback parser exists for third-party catalogs only. If a built-in rule ever leaned on + // it, tightening the parser would quietly degrade the shipped experience. + var vague = RulePackLoader.Load(language).Lexical + .Where(r => !r.Delete && r.Replacements is not { Length: > 0 }) + .Select(r => r.Id) + .ToList(); + + Assert.True(vague.Count == 0, + $"rules.{language}.json needs \"replacements\" or \"delete\" on: {string.Join(", ", vague)}"); + } + + [Fact] + public void Never_infers_a_deletion_from_prose() + { + // A catalog whose suggestion only says "cut it" must not cause a silent deletion: guessing + // wrong would change someone's prose in a way they never asked for. + var pack = RulePack.FromJson( + """ + { + "language": "*", + "lexical": [ + { "id": "custom.filler", "terms": ["verily"], "weight": 3, "severity": "High", + "suggestion": "empty intensifier — cut it" } + ] + } + """); + + var rule = pack.Lexical[0]; + Assert.False(rule.Delete); + Assert.Empty(rule.RewriteOptions()); + + const string text = "It was verily late."; + var findings = _analyzer.Analyze(text, "en", [pack]).Findings; + Assert.Equal(text, LocalRewriter.Apply(text, LocalRewriter.Plan(text, findings, pack, RewriteStrength.Thorough))); + } + + [Fact] + public void Explicit_replacements_win_over_the_prose_suggestion() + { + var pack = RulePack.FromJson( + """ + { + "language": "*", + "lexical": [ + { "id": "custom.synergy", "terms": ["synergy"], "weight": 6, "severity": "High", + "suggestion": "prose nobody should parse", "replacements": ["teamwork", "cooperation"] } + ] + } + """); + + Assert.Equal(["teamwork", "cooperation"], pack.Lexical[0].RewriteOptions()); + + const string text = "We need synergy here."; + var findings = _analyzer.Analyze(text, "en", [pack]).Findings; + var rewritten = LocalRewriter.Apply(text, LocalRewriter.Plan(text, findings, pack, RewriteStrength.Thorough)); + Assert.Equal("We need teamwork here.", rewritten); + } +}