diff --git a/Cargo.lock b/Cargo.lock index 793561d..b0fc4d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,143 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tropius-cli" version = "0.1.0" @@ -9,3 +146,22 @@ version = "0.1.0" [[package]] name = "tropius-core" version = "0.1.0" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/README.md b/README.md index 0c4ea89..6966fa2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ # tropius `tropius` is a CLI to detect AI tropes in prose. + +## How + +We use a pattern dictionary in TOML based on a [trope list](./meta/tropes.md) +from [Tropes.fyi](https://tropes.fyi) + +```text +Text + ↓ +Normalize text + ↓ +Aho-Corasick phrase matcher + ↓ +Regex / parser structural rules + ↓ +Feature scoring + ↓ +Report +``` + +## Inspiration + +I got nerd-sniped on [BlueSky](https://bsky.app/profile/samuel.fm/post/3mp3l3cxg622y) + +> [Samuel - @samuel.fm](https://bsky.app/profile/samuel.fm) **2026-06-25 04:16** +> +> “Claudesmell” labeller for [standard.site](https://standard.site) records wen + +> [owais - @desertthunder.dev](https://bsky.app/profile/desertthunder.dev) **2026-06-25 04:18** +> +> what does claudesmell mean here + +> [Samuel - @samuel.fm](https://bsky.app/profile/samuel.fm) **2026-06-25 04:22** +> +> smells of claude, e.g. short, punchy yet needlessly florid prose, excessive claudeisms, it’s not just x it’s y etc + +> [owais - @desertthunder.dev](https://bsky.app/profile/desertthunder.dev) **2026-06-25 04:26** +> +> You know that feeling of nerd-sniping about to happen? I gotta use aho-corasick + [tropes.fyi](https://tropes.fyi) in some way + +## License + +[unlicense](./LICENSE) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d4dbe7a..e81f5d4 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,3 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/crates/core/src/patterns.rs b/crates/core/src/patterns.rs new file mode 100644 index 0000000..ab6ce34 --- /dev/null +++ b/crates/core/src/patterns.rs @@ -0,0 +1,108 @@ +pub const BUNDLED_PATTERN_FILES: &[(&str, &str)] = &[ + ("formatting.toml", include_str!("patterns/formatting.toml")), + ( + "sentence-structure.toml", + include_str!("patterns/sentence-structure.toml"), + ), + ("tone.toml", include_str!("patterns/tone.toml")), + ( + "word-choice.toml", + include_str!("patterns/word-choice.toml"), + ), +]; + +pub fn bundled_patterns() -> Result, toml::de::Error> { + let mut patterns = Vec::new(); + + for (_, input) in BUNDLED_PATTERN_FILES { + patterns.extend(PatternFile::from_toml(input)?.patterns); + } + + Ok(patterns) +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct PatternFile { + pub patterns: Vec, +} + +impl PatternFile { + pub fn from_toml(input: &str) -> Result { + toml::from_str(input) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct Pattern { + pub id: String, + pub name: String, + pub severity: Severity, + pub phrases: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Low, + Medium, + High, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_pattern_file() { + let file = PatternFile::from_toml( + r#" +[[patterns]] +id = "word_choice.delve" +name = "Delve and Friends" +severity = "medium" +phrases = ["delve into", "robust"] +"#, + ) + .unwrap(); + + assert_eq!(file.patterns.len(), 1); + + let pattern = &file.patterns[0]; + assert_eq!(pattern.id, "word_choice.delve"); + assert_eq!(pattern.name, "Delve and Friends"); + assert_eq!(pattern.severity, Severity::Medium); + assert_eq!(pattern.phrases, ["delve into", "robust"]); + } + + #[test] + fn rejects_unknown_severity() { + let error = PatternFile::from_toml( + r#" +[[patterns]] +id = "word_choice.delve" +name = "Delve and Friends" +severity = "extreme" +phrases = ["delve into"] +"#, + ) + .unwrap_err(); + + assert!(error.to_string().contains("unknown variant")); + } + + #[test] + fn deserializes_bundled_pattern_files() { + let patterns = bundled_patterns().unwrap(); + + assert!( + patterns + .iter() + .any(|pattern| pattern.id == "word_choice.delve") + ); + assert!( + patterns + .iter() + .any(|pattern| pattern.id == "sentence_structure.negative_parallelism") + ); + } +} diff --git a/crates/core/src/patterns/formatting.toml b/crates/core/src/patterns/formatting.toml new file mode 100644 index 0000000..4569262 --- /dev/null +++ b/crates/core/src/patterns/formatting.toml @@ -0,0 +1,18 @@ +[[patterns]] +id = "formatting.em_dash_addiction" +name = "Em-Dash Addiction" +severity = "medium" +phrases = [ + " -- ", + " — " +] + +[[patterns]] +id = "formatting.signposted_conclusion" +name = "The Signposted Conclusion" +severity = "low" +phrases = [ + "in conclusion", + "to sum up", + "in summary", +] diff --git a/crates/core/src/patterns/sentence-structure.toml b/crates/core/src/patterns/sentence-structure.toml new file mode 100644 index 0000000..ecf3a08 --- /dev/null +++ b/crates/core/src/patterns/sentence-structure.toml @@ -0,0 +1,33 @@ +[[patterns]] +id = "sentence_structure.negative_parallelism" +name = "Negative Parallelism" +severity = "high" +phrases = [ + "it's not", + "it is not", + "not because", + "the question isn't", + "the question is", +] + +[[patterns]] +id = "sentence_structure.question_answer" +name = "The X? A Y." +severity = "medium" +phrases = [ + "the result?", + "the worst part?", + "the scary part?", +] + +[[patterns]] +id = "sentence_structure.filler_transitions" +name = "It's Worth Noting" +severity = "medium" +phrases = [ + "it's worth noting", + "it bears mentioning", + "importantly", + "interestingly", + "notably", +] diff --git a/crates/core/src/patterns/tone.toml b/crates/core/src/patterns/tone.toml new file mode 100644 index 0000000..817986b --- /dev/null +++ b/crates/core/src/patterns/tone.toml @@ -0,0 +1,33 @@ +[[patterns]] +id = "tone.false_suspense" +name = "Here's the Kicker" +severity = "medium" +phrases = [ + "here's the kicker", + "here's the thing", + "here's where it gets interesting", + "here's what most people miss", + "here's the starting point", + "here's the deal", +] + +[[patterns]] +id = "tone.teacher_voice" +name = "Let's Break This Down" +severity = "medium" +phrases = [ + "let's break this down", + "let's unpack", + "let's explore", + "let's dive in", +] + +[[patterns]] +id = "tone.think_of_it_as" +name = "Think of It As" +severity = "medium" +phrases = [ + "think of it as", + "think of it like", + "it's like asking", +] diff --git a/crates/core/src/patterns/word-choice.toml b/crates/core/src/patterns/word-choice.toml new file mode 100644 index 0000000..33c86bf --- /dev/null +++ b/crates/core/src/patterns/word-choice.toml @@ -0,0 +1,50 @@ +[[patterns]] +id = "word_choice.magic_adverbs" +name = "Quietly and Other Magic Adverbs" +severity = "medium" +phrases = [ + "quietly", + "deeply", + "fundamentally", + "remarkably", + "arguably", +] + +[[patterns]] +id = "word_choice.delve" +name = "Delve and Friends" +severity = "medium" +phrases = [ + "delve into", + "delving deeper", + "certainly", + "utilize", + "leverage", + "robust", + "streamline", + "harness", +] + +[[patterns]] +id = "word_choice.grandiose_nouns" +name = "Tapestry and Landscape" +severity = "medium" +phrases = [ + "tapestry", + "landscape", + "paradigm", + "synergy", + "ecosystem", + "framework", +] + +[[patterns]] +id = "word_choice.serves_as" +name = "The Serves As Dodge" +severity = "medium" +phrases = [ + "serves as", + "stands as", + "marks a pivotal", + "represents", +] diff --git a/meta/tropes.md b/meta/tropes.md index bf405d2..a16630d 100644 --- a/meta/tropes.md +++ b/meta/tropes.md @@ -3,8 +3,6 @@ Add this file to your AI assistant's system prompt or context to help it avoid common AI writing patterns. Source: [tropes.fyi](https://tropes.fyi) by [ossama.is](https://ossama.is) ---- - ## Word Choice ### "Quietly" and Other Magic Adverbs @@ -47,8 +45,6 @@ Replacing simple "is" or "are" with pompous alternatives like "serves as", "stan - "Gallery 825 serves as LAAA's exhibition space for contemporary art." - "The station marks a pivotal moment in the evolution of regional transit." ---- - ## Sentence Structure ### Negative Parallelism @@ -131,8 +127,6 @@ Using "from X to Y" constructions where X and Y aren't on any real scale. In leg - "From the singularity of the Big Bang to the grand cosmic web." - "From problem-solving and tool-making to scientific discovery, artistic expression, and technological innovation." ---- - ## Paragraph Structure ### Short Punchy Fragments @@ -154,8 +148,6 @@ Numbered or labeled points dressed up as continuous prose. The model writes what - "The first wall is the absence of a free, scoped API... The second wall is the lack of delegated access... The third wall is the absence of scoped permissions..." - "The second takeaway is that... The third takeaway is that... The fourth takeaway is that..." ---- - ## Tone ### "Here's the Kicker" @@ -247,8 +239,6 @@ AI clusters invented compound labels that sound analytical without being grounde - "the acceleration trap" - "workload creep" ---- - ## Formatting ### Em-Dash Addiction @@ -281,8 +271,6 @@ Use of unicode arrows (->), smart/curly quotes, and other special characters tha - "This leads to better outcomes → which means higher engagement" - "“Smart quotes” instead of straight "quotes" that you’d actually type" ---- - ## Composition ### Fractal Summaries @@ -353,8 +341,6 @@ The rigid formula where AI acknowledges problems only to immediately dismiss the - "Despite its industrial and residential prosperity, Korattur faces challenges typical of urban areas." - "Despite their promising applications, pyroelectric materials face several challenges that must be addressed for broader adoption." ---- - Remember: any of these patterns used once might be fine. The problem is when multiple tropes appear together or when a single trope is used repeatedly. Write like a human: varied, imperfect, specific. diff --git a/todo.md b/todo.md index e69de29..d202d3b 100644 --- a/todo.md +++ b/todo.md @@ -0,0 +1,99 @@ +# CLI / Matcher Plan + +## Shape + +- `meta/tropes.md` is source material only. Do not parse it in the runtime + pipeline. +- Pattern dictionaries live in `crates/core/src/patterns/*.toml` so contributors + can add focused rule files without editing one giant dictionary. +- `crates/core` owns: + - loading all TOML pattern files + - validating pattern ids and phrases + - building one Aho-Corasick matcher from all phrase patterns + - scanning text and returning plain finding structs +- `crates/cli` owns: + - `clap` argument parsing + - reading a file or stdin + - display with the bundled core patterns by default + - display with `owo_colors`, respecting `NO_COLOR` + - exit code behavior + +## Pattern TOML + +Example file: `crates/core/src/patterns/word-choice.toml` + +```toml +[[patterns]] +id = "word_choice.delve" +name = "Delve and Friends" +severity = "medium" +phrases = [ + "delve into", + "delving deeper", + "certainly", + "utilize", + "leverage", + "robust", + "streamline", + "harness", +] +``` + +Keep the first pass phrase-only. Add structural rules later only when phrase +matching proves insufficient. + +## CLI + +Initial command: + +```text +tropius [FILE] +``` + +Behavior: + +- read stdin when `FILE` is omitted +- print each finding with pattern name, matched phrase, and byte range +- return `0` when no findings are found +- return `1` when findings are found +- return `2` for usage/configuration errors + +## Test Bed + +Add examples that exercise both obvious matches and normal prose. Keep them as +plain text fixtures so tests do not need network access. + +Suggested layout: + +```text +meta/examples/ + ai-slop/ + clean/ +``` + +Use the local `lectito` CLI to extract article text into fixtures when useful: + +```text +lectito inspect --text > meta/examples/clean/example.txt +``` + +Do not make tests depend on `lectito` or the network. Use it only to prepare +fixtures that are checked into the repo. + +Core tests should cover: + +- loading multiple `crates/core/src/patterns/*.toml` files +- detecting phrases across files with one matcher +- case-insensitive matching +- duplicate pattern ids fail validation +- duplicate phrases either fail validation or are reported deterministically +- clean examples produce no findings, or only expected low-noise findings +- slop examples produce expected pattern ids + +CLI smoke tests should cover: + +- file input +- stdin input +- `NO_COLOR` +- exit code `0` for clean input +- exit code `1` for matched input