humanizer-rb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/bin/humanizer +388 -0
- data/lib/humanizer/analyzer.rb +319 -0
- data/lib/humanizer/humanizer_engine.rb +192 -0
- data/lib/humanizer/patterns.rb +637 -0
- data/lib/humanizer/stats.rb +198 -0
- data/lib/humanizer/text_utils.rb +53 -0
- data/lib/humanizer/version.rb +5 -0
- data/lib/humanizer/vocabulary.rb +260 -0
- data/lib/humanizer.rb +33 -0
- metadata +61 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 26174e5aca0e9bd2fe7400e1fef9a27270bcbbf1f84c2141cc9d3fd6f4a6ecf3
|
|
4
|
+
data.tar.gz: a81821a2382d75b218dcd2f4cc31d6c68c6dcbc68460e6145bc2f7c8002ed3a3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0ec0e195c1451518845b46ee08a4288b6db23f23243cc4e7c6bb32152e30c4c6428eafcd910a08d468f9b310f86e26f280e128a96e9ddf0ef6e6760a8c0e58c4
|
|
7
|
+
data.tar.gz: 71c83a9b6fe14ac98ef978d078b14798c2c39a0fee42be3c5960b800abac5bd6bad91a53b81a752843bd4664f5fc263dbb59cb978782f0315164ef928caca331
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-03-16)
|
|
4
|
+
|
|
5
|
+
- Initial release — Ruby port of [humanizer](https://github.com/christiangenco/humanizer) (Node.js) v2.2.0
|
|
6
|
+
- 28 pattern detectors across 5 categories
|
|
7
|
+
- 500+ AI vocabulary terms in 3 tiers
|
|
8
|
+
- Statistical text analysis (burstiness, TTR, Flesch-Kincaid, trigram repetition)
|
|
9
|
+
- Composite scoring engine (0-100)
|
|
10
|
+
- CLI with `analyze`, `score`, `humanize`, `suggest`, `stats`, `report` commands
|
|
11
|
+
- Humanization engine with auto-fix and suggestion prioritization
|
|
12
|
+
- Zero runtime dependencies — pure Ruby
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Christian Genco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# humanizer-rb
|
|
2
|
+
|
|
3
|
+
Detect AI-generated writing patterns. Scores text 0-100 using 28 pattern detectors, 500+ vocabulary terms, and statistical text analysis.
|
|
4
|
+
|
|
5
|
+
> **Ruby port of [humanizer](https://github.com/christiangenco/humanizer)** (Node.js). Same detection engine, same scoring algorithm, zero dependencies.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "humanizer-rb"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install directly:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
gem install humanizer-rb
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Ruby API
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "humanizer"
|
|
27
|
+
|
|
28
|
+
# Quick score (0-100, higher = more AI-like)
|
|
29
|
+
Humanizer.score("Your text here")
|
|
30
|
+
# => 42
|
|
31
|
+
|
|
32
|
+
# Full analysis
|
|
33
|
+
result = Humanizer.analyze("Your text here")
|
|
34
|
+
result.score # => 42
|
|
35
|
+
result.pattern_score # => 48
|
|
36
|
+
result.uniformity_score # => 30
|
|
37
|
+
result.total_matches # => 7
|
|
38
|
+
result.word_count # => 156
|
|
39
|
+
result.findings # => [{ pattern_id: 7, pattern_name: "AI vocabulary", ... }]
|
|
40
|
+
result.categories # => { content: { matches: 2, ... }, language: { matches: 5, ... } }
|
|
41
|
+
result.stats # => #<Humanizer::Stats::Result burstiness: 0.31, ...>
|
|
42
|
+
result.summary # => "Score: 42/100 (moderately AI-influenced)..."
|
|
43
|
+
|
|
44
|
+
# Humanization suggestions with priorities
|
|
45
|
+
suggestions = Humanizer.humanize("Your text here")
|
|
46
|
+
suggestions[:critical] # Dead giveaways (weight 4-5)
|
|
47
|
+
suggestions[:important] # Noticeable patterns (weight 2-3)
|
|
48
|
+
suggestions[:minor] # Subtle tells (weight 1)
|
|
49
|
+
suggestions[:guidance] # Actionable writing tips
|
|
50
|
+
suggestions[:style_tips] # Statistical improvement suggestions
|
|
51
|
+
|
|
52
|
+
# Safe mechanical auto-fixes
|
|
53
|
+
fixed = Humanizer.auto_fix("In order to utilize this...")
|
|
54
|
+
fixed[:text] # => "to use this..."
|
|
55
|
+
fixed[:fixes] # => ['"in order to" → "to"', '"utilize" → "use"']
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### CLI
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
# Quick score
|
|
62
|
+
echo "This is a testament to..." | humanizer score
|
|
63
|
+
# => 🟡 38/100
|
|
64
|
+
|
|
65
|
+
# Full analysis
|
|
66
|
+
humanizer analyze essay.txt
|
|
67
|
+
|
|
68
|
+
# Markdown report
|
|
69
|
+
humanizer report article.txt > report.md
|
|
70
|
+
|
|
71
|
+
# Humanization suggestions
|
|
72
|
+
humanizer suggest article.txt
|
|
73
|
+
|
|
74
|
+
# Auto-fix + suggestions
|
|
75
|
+
humanizer humanize --autofix -f article.txt
|
|
76
|
+
|
|
77
|
+
# Statistical analysis only
|
|
78
|
+
humanizer stats essay.txt
|
|
79
|
+
|
|
80
|
+
# JSON output
|
|
81
|
+
humanizer analyze --json essay.txt
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Score badges
|
|
85
|
+
|
|
86
|
+
| Score | Badge | Label |
|
|
87
|
+
|-------|-------|-------|
|
|
88
|
+
| 0-25 | 🟢 | Mostly human-sounding |
|
|
89
|
+
| 26-50 | 🟡 | Lightly AI-touched |
|
|
90
|
+
| 51-75 | 🟠 | Moderately AI-influenced |
|
|
91
|
+
| 76-100 | 🔴 | Heavily AI-generated |
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
The score combines three signals:
|
|
96
|
+
|
|
97
|
+
1. **Pattern matches (70%)** — 28 detectors scan for AI writing patterns across 5 categories:
|
|
98
|
+
- **Content**: significance inflation, promotional language, vague attributions
|
|
99
|
+
- **Language**: AI vocabulary (500+ words in 3 tiers), copula avoidance, synonym cycling
|
|
100
|
+
- **Style**: em dash overuse, boldface overuse, emoji decoration, curly quotes
|
|
101
|
+
- **Communication**: chatbot artifacts, sycophantic tone, reasoning chain artifacts
|
|
102
|
+
- **Filler**: wordy phrases, excessive hedging, generic conclusions
|
|
103
|
+
|
|
104
|
+
2. **Statistical uniformity (30%)** — measures how "robotic" the text structure is:
|
|
105
|
+
- Burstiness (sentence length variation between consecutive sentences)
|
|
106
|
+
- Type-token ratio (vocabulary diversity)
|
|
107
|
+
- Trigram repetition
|
|
108
|
+
- Sentence length standard deviation
|
|
109
|
+
|
|
110
|
+
3. **Category breadth** — more diverse AI signals = higher score
|
|
111
|
+
|
|
112
|
+
## Rails integration
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# Gemfile
|
|
116
|
+
gem "humanizer-rb"
|
|
117
|
+
|
|
118
|
+
# app/models/email.rb
|
|
119
|
+
class Email < ApplicationRecord
|
|
120
|
+
before_save :calculate_humanizer_score,
|
|
121
|
+
if: -> { body_changed? && body.present? }
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def calculate_humanizer_score
|
|
126
|
+
self.humanizer_score = Humanizer.score(body)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT — see [LICENSE](LICENSE).
|
data/bin/humanizer
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/humanizer"
|
|
5
|
+
|
|
6
|
+
# ── Color Helpers ────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
SUPPORTS_COLOR = $stdout.tty? && !ENV["NO_COLOR"]
|
|
9
|
+
|
|
10
|
+
def color(code, s)
|
|
11
|
+
SUPPORTS_COLOR ? "\e[#{code}m#{s}\e[0m" : s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def red(s) = color(31, s)
|
|
15
|
+
def green(s) = color(32, s)
|
|
16
|
+
def yellow(s) = color(33, s)
|
|
17
|
+
def cyan(s) = color(36, s)
|
|
18
|
+
def magenta(s) = color(35, s)
|
|
19
|
+
def bold(s) = color(1, s)
|
|
20
|
+
def dim(s) = color(2, s)
|
|
21
|
+
|
|
22
|
+
def score_badge(s)
|
|
23
|
+
if s <= 25 then green("🟢 #{s}/100")
|
|
24
|
+
elsif s <= 50 then yellow("🟡 #{s}/100")
|
|
25
|
+
elsif s <= 75 then magenta("🟠 #{s}/100")
|
|
26
|
+
else red("🔴 #{s}/100")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def score_label(s)
|
|
31
|
+
if s <= 19 then "Mostly human-sounding"
|
|
32
|
+
elsif s <= 44 then "Lightly AI-touched"
|
|
33
|
+
elsif s <= 69 then "Moderately AI-influenced"
|
|
34
|
+
else "Heavily AI-generated"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def burstiness_label(b)
|
|
39
|
+
if b >= 0.7 then green("(high — human-like)")
|
|
40
|
+
elsif b >= 0.45 then yellow("(moderate)")
|
|
41
|
+
elsif b >= 0.25 then yellow("(low — somewhat uniform)")
|
|
42
|
+
else red("(very low — AI-like)")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ttr_label(ttr, wc)
|
|
47
|
+
if wc < 100 then dim("(too short to assess)")
|
|
48
|
+
elsif ttr >= 0.6 then green("(high — diverse)")
|
|
49
|
+
elsif ttr >= 0.45 then yellow("(moderate)")
|
|
50
|
+
else red("(low — repetitive)")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def truncate(str, len)
|
|
55
|
+
return "" unless str.is_a?(String)
|
|
56
|
+
str.length > len ? "#{str[0, len]}..." : str
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ── Arg Parsing ──────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
args = ARGV.dup
|
|
62
|
+
|
|
63
|
+
# Handle top-level --help before command parsing
|
|
64
|
+
if args.include?("--help") || args.include?("-h")
|
|
65
|
+
args.delete("--help")
|
|
66
|
+
args.delete("-h")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
command = args.shift
|
|
70
|
+
|
|
71
|
+
flags = {
|
|
72
|
+
json: args.delete("--json"),
|
|
73
|
+
verbose: args.delete("--verbose") || args.delete("-v"),
|
|
74
|
+
autofix: args.delete("--autofix"),
|
|
75
|
+
help: ARGV.include?("--help") || ARGV.include?("-h") || command.nil?,
|
|
76
|
+
file: nil,
|
|
77
|
+
patterns: nil,
|
|
78
|
+
threshold: nil,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Parse -f / --file
|
|
82
|
+
if (idx = args.index("-f") || args.index("--file"))
|
|
83
|
+
flags[:file] = args[idx + 1]
|
|
84
|
+
args.slice!(idx, 2)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Parse --patterns
|
|
88
|
+
if (idx = args.index("--patterns"))
|
|
89
|
+
flags[:patterns] = args[idx + 1]&.split(",")&.map(&:to_i)&.select { |n| n > 0 }
|
|
90
|
+
args.slice!(idx, 2)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parse --threshold
|
|
94
|
+
if (idx = args.index("--threshold"))
|
|
95
|
+
flags[:threshold] = args[idx + 1]&.to_i
|
|
96
|
+
args.slice!(idx, 2)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Positional file argument
|
|
100
|
+
flags[:file] ||= args.first unless args.first&.start_with?("-")
|
|
101
|
+
|
|
102
|
+
# ── Help ─────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
HELP = <<~HELP
|
|
105
|
+
#{bold('humanizer')} — Detect and remove AI writing patterns
|
|
106
|
+
|
|
107
|
+
#{bold('Usage:')}
|
|
108
|
+
humanizer <command> [file] [options]
|
|
109
|
+
|
|
110
|
+
#{bold('Commands:')}
|
|
111
|
+
#{cyan('analyze')} Full analysis report with pattern matches
|
|
112
|
+
#{cyan('score')} Quick score (0-100, higher = more AI-like)
|
|
113
|
+
#{cyan('humanize')} Humanization suggestions with guidance
|
|
114
|
+
#{cyan('report')} Full markdown report (for piping to files)
|
|
115
|
+
#{cyan('suggest')} Show only suggestions, grouped by priority
|
|
116
|
+
#{cyan('stats')} Show statistical text analysis only
|
|
117
|
+
|
|
118
|
+
#{bold('Options:')}
|
|
119
|
+
-f, --file <path> Read text from file (otherwise reads stdin)
|
|
120
|
+
--json Output as JSON
|
|
121
|
+
--verbose, -v Show all matches (not just top 5 per pattern)
|
|
122
|
+
--autofix Apply safe mechanical fixes (humanize only)
|
|
123
|
+
--patterns <ids> Only check specific pattern IDs (comma-separated)
|
|
124
|
+
--threshold <n> Only show patterns with weight above threshold
|
|
125
|
+
--help, -h Show this help
|
|
126
|
+
|
|
127
|
+
#{bold('Examples:')}
|
|
128
|
+
#{dim('# Quick score')}
|
|
129
|
+
echo "This is a testament to..." | humanizer score
|
|
130
|
+
|
|
131
|
+
#{dim('# Analyze a file')}
|
|
132
|
+
humanizer analyze essay.txt
|
|
133
|
+
|
|
134
|
+
#{dim('# Full markdown report')}
|
|
135
|
+
humanizer report article.txt > report.md
|
|
136
|
+
|
|
137
|
+
#{dim('# Humanize with auto-fixes')}
|
|
138
|
+
humanizer humanize --autofix -f article.txt
|
|
139
|
+
|
|
140
|
+
#{bold('Score badges:')}
|
|
141
|
+
🟢 0-25 Mostly human-sounding
|
|
142
|
+
🟡 26-50 Lightly AI-touched
|
|
143
|
+
🟠 51-75 Moderately AI-influenced
|
|
144
|
+
🔴 76-100 Heavily AI-generated
|
|
145
|
+
HELP
|
|
146
|
+
|
|
147
|
+
# ── Read Input ───────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def read_input(flags)
|
|
150
|
+
if flags[:file]
|
|
151
|
+
File.read(flags[:file])
|
|
152
|
+
elsif !$stdin.tty?
|
|
153
|
+
$stdin.read
|
|
154
|
+
else
|
|
155
|
+
$stderr.puts red("Error: No input. Pipe text or use -f <file>. Run with --help for usage.")
|
|
156
|
+
exit 1
|
|
157
|
+
end
|
|
158
|
+
rescue Errno::ENOENT => e
|
|
159
|
+
$stderr.puts red("Error: #{e.message}")
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ── Formatters ───────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def format_colored_report(result, threshold: nil)
|
|
166
|
+
lines = []
|
|
167
|
+
lines << ""
|
|
168
|
+
lines << bold(" ┌──────────────────────────────────────────────┐")
|
|
169
|
+
lines << bold(" │ AI WRITING PATTERN ANALYSIS │")
|
|
170
|
+
lines << bold(" └──────────────────────────────────────────────┘")
|
|
171
|
+
lines << ""
|
|
172
|
+
|
|
173
|
+
filled = (result.score / 5.0).round
|
|
174
|
+
bar_color_fn = result.score <= 25 ? method(:green) : result.score <= 50 ? method(:yellow) : result.score <= 75 ? method(:magenta) : method(:red)
|
|
175
|
+
bar = bar_color_fn.call("█" * filled) + dim("░" * (20 - filled))
|
|
176
|
+
lines << " Score: #{score_badge(result.score)} [#{bar}]"
|
|
177
|
+
lines << " #{dim("Words: #{result.word_count} | Matches: #{result.total_matches} | Pattern: #{result.pattern_score} | Uniformity: #{result.uniformity_score}")}"
|
|
178
|
+
lines << ""
|
|
179
|
+
lines << " #{result.summary}"
|
|
180
|
+
lines << ""
|
|
181
|
+
|
|
182
|
+
if result.stats
|
|
183
|
+
s = result.stats
|
|
184
|
+
lines << bold(" ── Statistics ──────────────────────────────────")
|
|
185
|
+
lines << " Burstiness: #{s.burstiness} #{burstiness_label(s.burstiness)}"
|
|
186
|
+
lines << " Type-token ratio: #{s.type_token_ratio} #{ttr_label(s.type_token_ratio, s.word_count)}"
|
|
187
|
+
lines << " Trigram repetition: #{s.trigram_repetition}"
|
|
188
|
+
lines << " Readability: #{s.flesch_kincaid} grade level"
|
|
189
|
+
lines << ""
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
lines << bold(" ── Categories ──────────────────────────────────")
|
|
193
|
+
result.categories.each do |_, data|
|
|
194
|
+
if data[:matches] > 0
|
|
195
|
+
lines << " #{cyan(data[:label])}: #{data[:matches]} matches #{dim("(#{data[:patterns_detected].join(', ')})")}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
lines << ""
|
|
199
|
+
|
|
200
|
+
if result.findings.any?
|
|
201
|
+
lines << bold(" ── Findings ──────────────────────────────────")
|
|
202
|
+
result.findings.each do |finding|
|
|
203
|
+
next if threshold && finding[:weight] < threshold
|
|
204
|
+
|
|
205
|
+
weight_color = finding[:weight] >= 4 ? method(:red) : finding[:weight] >= 2 ? method(:yellow) : method(:cyan)
|
|
206
|
+
lines << ""
|
|
207
|
+
lines << " #{weight_color.call("[#{finding[:pattern_id]}]")} #{bold(finding[:pattern_name])} #{dim("(×#{finding[:match_count]}, weight: #{finding[:weight]})")}"
|
|
208
|
+
lines << " #{dim(finding[:description])}"
|
|
209
|
+
finding[:matches].each do |match|
|
|
210
|
+
loc = match[:line] ? "L#{match[:line]}" : ""
|
|
211
|
+
preview = match[:match].is_a?(String) ? match[:match][0, 80] : ""
|
|
212
|
+
lines << " #{dim(loc)}: \"#{preview}\""
|
|
213
|
+
lines << " #{green('→')} #{match[:suggestion]}" if match[:suggestion]
|
|
214
|
+
end
|
|
215
|
+
if finding[:truncated]
|
|
216
|
+
lines << " #{dim("... and #{finding[:match_count] - finding[:matches].length} more")}"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
lines << ""
|
|
222
|
+
lines << dim(" ──────────────────────────────────────────────")
|
|
223
|
+
lines.join("\n")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def format_stats_report(stats)
|
|
227
|
+
lines = []
|
|
228
|
+
lines << ""
|
|
229
|
+
lines << bold(" ┌──────────────────────────────────────────────┐")
|
|
230
|
+
lines << bold(" │ TEXT STATISTICS ANALYSIS │")
|
|
231
|
+
lines << bold(" └──────────────────────────────────────────────┘")
|
|
232
|
+
lines << ""
|
|
233
|
+
lines << bold(" ── Sentences ──────────────────────────────────")
|
|
234
|
+
lines << " Count: #{stats.sentence_count}"
|
|
235
|
+
lines << " Avg length: #{stats.avg_sentence_length} words"
|
|
236
|
+
lines << " Std deviation: #{stats.sentence_length_std_dev}"
|
|
237
|
+
lines << " Burstiness: #{stats.burstiness} #{burstiness_label(stats.burstiness)}"
|
|
238
|
+
lines << ""
|
|
239
|
+
lines << bold(" ── Vocabulary ─────────────────────────────────")
|
|
240
|
+
lines << " Total words: #{stats.word_count}"
|
|
241
|
+
lines << " Unique words: #{stats.unique_word_count}"
|
|
242
|
+
lines << " Type-token ratio: #{stats.type_token_ratio} #{ttr_label(stats.type_token_ratio, stats.word_count)}"
|
|
243
|
+
lines << " Avg word length: #{stats.avg_word_length}"
|
|
244
|
+
lines << ""
|
|
245
|
+
lines << bold(" ── Structure ──────────────────────────────────")
|
|
246
|
+
lines << " Paragraphs: #{stats.paragraph_count}"
|
|
247
|
+
lines << " Avg para length: #{stats.avg_paragraph_length} words"
|
|
248
|
+
lines << " Trigram repeat: #{stats.trigram_repetition}"
|
|
249
|
+
lines << ""
|
|
250
|
+
lines << bold(" ── Readability ────────────────────────────────")
|
|
251
|
+
lines << " Flesch-Kincaid: #{stats.flesch_kincaid} grade level"
|
|
252
|
+
lines << " Function words: #{stats.function_word_ratio} (#{(stats.function_word_ratio * 100).round(1)}%)"
|
|
253
|
+
lines << ""
|
|
254
|
+
lines.join("\n")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def format_grouped_suggestions(result)
|
|
258
|
+
lines = []
|
|
259
|
+
lines << ""
|
|
260
|
+
lines << bold(" Score: #{score_badge(result[:score])} (#{score_label(result[:score])})")
|
|
261
|
+
lines << " #{dim("#{result[:total_issues]} issues found in #{result[:word_count]} words")}"
|
|
262
|
+
lines << ""
|
|
263
|
+
|
|
264
|
+
if result[:critical].any?
|
|
265
|
+
lines << red(bold(" ━━ CRITICAL (remove these first) ━━━━━━━━━━━━"))
|
|
266
|
+
result[:critical].each do |s|
|
|
267
|
+
lines << " #{red('●')} L#{s[:line]}: #{bold(s[:pattern])}"
|
|
268
|
+
lines << " #{dim(truncate(s[:text], 60))}"
|
|
269
|
+
lines << " #{green('→')} #{s[:suggestion]}"
|
|
270
|
+
end
|
|
271
|
+
lines << ""
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
if result[:important].any?
|
|
275
|
+
lines << yellow(bold(" ━━ IMPORTANT (noticeable AI patterns) ━━━━━━━"))
|
|
276
|
+
result[:important].each do |s|
|
|
277
|
+
lines << " #{yellow('●')} L#{s[:line]}: #{bold(s[:pattern])}"
|
|
278
|
+
lines << " #{dim(truncate(s[:text], 60))}"
|
|
279
|
+
lines << " #{green('→')} #{s[:suggestion]}"
|
|
280
|
+
end
|
|
281
|
+
lines << ""
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
if result[:minor].any?
|
|
285
|
+
lines << cyan(bold(" ━━ MINOR (subtle tells) ━━━━━━━━━━━━━━━━━━━━"))
|
|
286
|
+
result[:minor].each do |s|
|
|
287
|
+
lines << " #{cyan('●')} L#{s[:line]}: #{bold(s[:pattern])}"
|
|
288
|
+
lines << " #{dim(truncate(s[:text], 60))}"
|
|
289
|
+
lines << " #{green('→')} #{s[:suggestion]}"
|
|
290
|
+
end
|
|
291
|
+
lines << ""
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if result[:guidance].any?
|
|
295
|
+
lines << cyan(bold(" ━━ GUIDANCE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))
|
|
296
|
+
result[:guidance].each { |tip| lines << " #{cyan('•')} #{tip}" }
|
|
297
|
+
lines << ""
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
if result[:style_tips]&.any?
|
|
301
|
+
lines << magenta(bold(" ━━ STYLE TIPS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))
|
|
302
|
+
result[:style_tips].each { |t| lines << " #{magenta('◦')} #{t[:tip]}" }
|
|
303
|
+
lines << ""
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
lines.join("\n")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# ── Main ─────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
if flags[:help] || command.nil?
|
|
312
|
+
puts HELP
|
|
313
|
+
exit(command ? 0 : 1)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
text = read_input(flags)
|
|
317
|
+
if text.strip.empty?
|
|
318
|
+
$stderr.puts red("Error: Empty input.")
|
|
319
|
+
exit 1
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
opts = {
|
|
323
|
+
verbose: !!flags[:verbose],
|
|
324
|
+
patterns_to_check: flags[:patterns],
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case command
|
|
328
|
+
when "analyze"
|
|
329
|
+
result = Humanizer.analyze(text, **opts)
|
|
330
|
+
if flags[:json]
|
|
331
|
+
puts Humanizer::Analyzer.format_json(result)
|
|
332
|
+
else
|
|
333
|
+
puts format_colored_report(result, threshold: flags[:threshold])
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
when "score"
|
|
337
|
+
s = Humanizer.score(text)
|
|
338
|
+
if flags[:json]
|
|
339
|
+
require "json"
|
|
340
|
+
puts JSON.generate({ score: s })
|
|
341
|
+
else
|
|
342
|
+
puts score_badge(s)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
when "humanize"
|
|
346
|
+
result = Humanizer.humanize(text, autofix: !!flags[:autofix], verbose: !!flags[:verbose])
|
|
347
|
+
if flags[:json]
|
|
348
|
+
require "json"
|
|
349
|
+
puts JSON.pretty_generate(result)
|
|
350
|
+
else
|
|
351
|
+
# Simple formatted output
|
|
352
|
+
puts format_grouped_suggestions(result)
|
|
353
|
+
if flags[:autofix] && result[:autofix]
|
|
354
|
+
puts ""
|
|
355
|
+
puts bold("── AUTO-FIXED TEXT ──────────────────────────────")
|
|
356
|
+
puts ""
|
|
357
|
+
puts result[:autofix][:text]
|
|
358
|
+
puts ""
|
|
359
|
+
puts dim("════════════════════════════════════════════════")
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
when "report"
|
|
364
|
+
result = Humanizer.analyze(text, verbose: true, patterns_to_check: flags[:patterns])
|
|
365
|
+
puts Humanizer::Analyzer.format_markdown(result)
|
|
366
|
+
|
|
367
|
+
when "suggest"
|
|
368
|
+
result = Humanizer.humanize(text, verbose: !!flags[:verbose])
|
|
369
|
+
if flags[:json]
|
|
370
|
+
require "json"
|
|
371
|
+
puts JSON.pretty_generate(result)
|
|
372
|
+
else
|
|
373
|
+
puts format_grouped_suggestions(result)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
when "stats"
|
|
377
|
+
stats = Humanizer::Stats.compute(text)
|
|
378
|
+
if flags[:json]
|
|
379
|
+
require "json"
|
|
380
|
+
puts JSON.pretty_generate(stats.to_h)
|
|
381
|
+
else
|
|
382
|
+
puts format_stats_report(stats)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
else
|
|
386
|
+
$stderr.puts red("Unknown command: #{command}. Run with --help for usage.")
|
|
387
|
+
exit 1
|
|
388
|
+
end
|