@kensio/pangram-check 1.13.1
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.
- package/.claude-plugin/plugin.json +14 -0
- package/README.md +147 -0
- package/package.json +38 -0
- package/skills/pangram-check/SKILL.md +125 -0
- package/skills/pangram-check/references/configuration.md +120 -0
- package/skills/pangram-check/references/reading-results.md +69 -0
- package/skills/pangram-check/scripts/pangram-check.mjs +807 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
|
+
"name": "pangram-check",
|
|
4
|
+
"version": "1.13.1",
|
|
5
|
+
"description": "Check finished writing against the Pangram AI-text detector.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Kensio Software",
|
|
8
|
+
"email": "hugh@kensiosoftware.co.uk"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://kensio.ai",
|
|
11
|
+
"repository": "https://github.com/KensioSoftware/kensio.ai",
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"keywords": ["writing", "pangram", "ai-detection", "prose", "markdown"]
|
|
14
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @kensio/pangram-check
|
|
2
|
+
|
|
3
|
+
Runs a finished document past [Pangram](https://www.pangram.com), a commercial AI-text detector, and
|
|
4
|
+
reports which passages read as machine-drafted. Packaged as an agent skill.
|
|
5
|
+
|
|
6
|
+
Only the prose is sent. The script strips frontmatter, code, HTML, shortcodes, tables and headings
|
|
7
|
+
first, then maps every result Pangram returns back to the source line it came from, so a flagged
|
|
8
|
+
passage is a `file:line` reference and not a paragraph to go hunting for.
|
|
9
|
+
|
|
10
|
+
Pangram is a paid service and the skill needs an API key. It runs when a user asks for it by name.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Into any agent that reads `SKILL.md`:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @kensio/skills add pangram-check
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That copies the skill directory into `.agents/skills/`, where Codex, Cursor, Copilot, Gemini CLI and
|
|
21
|
+
the other implementations of the specification look for one. Pass `--agent claude` for
|
|
22
|
+
`.claude/skills/`, `--agent copilot` for `.github/skills/`, and `--user` to install it for every
|
|
23
|
+
project at once.
|
|
24
|
+
|
|
25
|
+
Claude Code also takes it as a plugin:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
claude plugin marketplace add KensioSoftware/kensio.ai
|
|
29
|
+
claude plugin install pangram-check@kensio
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or pin it in a repository as a dependency:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install @kensio/pangram-check
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every skill is also published as a zip on each
|
|
39
|
+
[release](https://github.com/KensioSoftware/kensio.ai/releases), for a machine with no npm reach.
|
|
40
|
+
Unzip it into `.agents/skills/` and it is installed.
|
|
41
|
+
|
|
42
|
+
## Set up the key
|
|
43
|
+
|
|
44
|
+
Get one from the Pangram dashboard, then put it where every repository on the machine can reach it:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
mkdir -p ~/.config/pangram
|
|
48
|
+
printf 'PANGRAM_API_KEY=%s\n' 'the-key' > ~/.config/pangram/.env
|
|
49
|
+
chmod 600 ~/.config/pangram/.env
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then check it. This hits `GET /models` and spends no detection call:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node skills/pangram-check/scripts/pangram-check.mjs --check-key
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`$PANGRAM_API_KEY` and `$PANGRAM_ENV_FILE` both work too, and a `./.env` in the working directory is
|
|
59
|
+
the last place searched. The key is never printed, and it is redacted from any error text.
|
|
60
|
+
|
|
61
|
+
## Use it
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
node skills/pangram-check/scripts/pangram-check.mjs post.md
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
post.md
|
|
69
|
+
742 words of prose, an estimated 1 billable unit.
|
|
70
|
+
|
|
71
|
+
AI Detected
|
|
72
|
+
This document was likely written with the help of a large language model.
|
|
73
|
+
42% AI · 35% AI-assisted · 23% human
|
|
74
|
+
segments: 2 AI, 1 AI-assisted, 1 human
|
|
75
|
+
|
|
76
|
+
reading order ▁▂▇▄▁▁▃ one cell per window, start to end
|
|
77
|
+
|
|
78
|
+
7 windows, worst 3 first:
|
|
79
|
+
|
|
80
|
+
█████████░ 0.91 AI, high confidence post.md:112 humanized 0.73
|
|
81
|
+
"the opening of the passage…"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Scores are colour-coded on a terminal. `--format markdown` gives a table to paste into a report, and
|
|
85
|
+
`--format json` carries every field with the line references added.
|
|
86
|
+
|
|
87
|
+
## What it will refuse to do
|
|
88
|
+
|
|
89
|
+
Every run costs money, so the script checks the document is worth a call before making one.
|
|
90
|
+
|
|
91
|
+
- **Under 50 words of prose:** Pangram's own
|
|
92
|
+
[documented floor](https://www.pangram.com/blog/why-does-pangram-have-a-minimum-word-count).
|
|
93
|
+
- **Under 300 words**, which is this skill's own default and is loosened with `--min-words`. Pangram
|
|
94
|
+
predicts from 50 words up, with less confidence the shorter the text.
|
|
95
|
+
- **Over a cost ceiling** set with `--max-units`. One billable unit is each started block of 1000
|
|
96
|
+
words.
|
|
97
|
+
- **Any pattern a repository has told it to refuse:** `--reject-todo` covers the common case, where
|
|
98
|
+
a `TODO` marker means a paragraph is still to be written. `--reject <regex>` takes anything else.
|
|
99
|
+
|
|
100
|
+
`--dry-run` runs all of that without sending the document. `--print-prose` prints the exact string
|
|
101
|
+
that would go out.
|
|
102
|
+
|
|
103
|
+
Results are cached by a hash of the extracted prose, so re-running an unchanged document is free.
|
|
104
|
+
There is deliberately no score history and no before-and-after delta, because both invite treating
|
|
105
|
+
the number as a target.
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
A `.pangram-check.json` beside the document (or anywhere above it) sets the defaults for a
|
|
110
|
+
repository, and flags override it:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"minWords": 400,
|
|
115
|
+
"rejectTodo": true,
|
|
116
|
+
"format": "markdown",
|
|
117
|
+
"windows": 3
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Full flag list, extraction rules and cache locations are in
|
|
122
|
+
[`reference/configuration.md`](skills/pangram-check/reference/configuration.md). What each API field
|
|
123
|
+
means, and which signals to act on, are in
|
|
124
|
+
[`reference/reading-results.md`](skills/pangram-check/reference/reading-results.md).
|
|
125
|
+
|
|
126
|
+
## What a score is for
|
|
127
|
+
|
|
128
|
+
A high score marks a passage worth rereading. It is not a number to drive down. Editing to move a
|
|
129
|
+
detector score is a different activity from writing in your own voice, and the two come apart
|
|
130
|
+
quickly.
|
|
131
|
+
|
|
132
|
+
Pangram reports whether text reads as machine-generated, and it has no opinion on whether the
|
|
133
|
+
writing is any good. Detectors also carry false positives, which published audits have found falling
|
|
134
|
+
hardest on writers working in a second language. One verdict is one input to a rereading, never
|
|
135
|
+
evidence about a person.
|
|
136
|
+
|
|
137
|
+
## Related
|
|
138
|
+
|
|
139
|
+
[`@kensio/technical-prose-style`](https://www.npmjs.com/package/@kensio/technical-prose-style)
|
|
140
|
+
measures the constructions that make prose tiring to read. Its own measurements show text that
|
|
141
|
+
follows every one of its rules still being identified as machine-written, which is the clearest
|
|
142
|
+
demonstration that style and provenance are separate signals. Anyone after concealment is holding
|
|
143
|
+
the wrong tool.
|
|
144
|
+
|
|
145
|
+
Part of [kensio.ai](https://github.com/KensioSoftware/kensio.ai). Licensed under the Apache License
|
|
146
|
+
2.0. See the [LICENSE](https://github.com/KensioSoftware/kensio.ai/blob/main/LICENSE) in the
|
|
147
|
+
repository root.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kensio/pangram-check",
|
|
3
|
+
"version": "1.13.1",
|
|
4
|
+
"description": "Check finished writing against the Pangram AI-text detector.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent-skills",
|
|
7
|
+
"ai-detection",
|
|
8
|
+
"claude",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"claude-code-plugin",
|
|
11
|
+
"codex",
|
|
12
|
+
"copilot",
|
|
13
|
+
"cursor",
|
|
14
|
+
"kensio",
|
|
15
|
+
"markdown",
|
|
16
|
+
"pangram",
|
|
17
|
+
"prose",
|
|
18
|
+
"skill",
|
|
19
|
+
"skill-md",
|
|
20
|
+
"writing"
|
|
21
|
+
],
|
|
22
|
+
"homepage": "https://kensio.ai",
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"author": "Kensio Software <hugh@kensiosoftware.co.uk>",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/KensioSoftware/kensio.ai.git",
|
|
28
|
+
"directory": "plugins/pangram-check"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
".claude-plugin",
|
|
32
|
+
"skills",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pangram-check
|
|
3
|
+
description: Send the prose of a finished document to Pangram, a commercial AI-text detector, and report which passages read as machine-drafted, with the source line each one starts on. Use when the user asks to "check this with Pangram", "run the AI detector over this", "see if this still reads as AI", or wants a final sweep after rewriting a draft. Every run is paid and sends the writing to a third party, so this skill is invoked by name.
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
compatibility: Needs a Pangram API key and network access. Every run is billed.
|
|
6
|
+
metadata:
|
|
7
|
+
version: "1.13.1"
|
|
8
|
+
disable-model-invocation: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Pangram check
|
|
12
|
+
|
|
13
|
+
A final sweep over a finished document. Pangram scores the text in windows of a few hundred words
|
|
14
|
+
and reports which parts read as machine-drafted, so a long document gets a passage-by-passage
|
|
15
|
+
answer.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node scripts/pangram-check.mjs path/to/file.md
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Full flag list and config file in [references/configuration.md](references/configuration.md). Field
|
|
22
|
+
meanings and how to read a verdict in
|
|
23
|
+
[references/reading-results.md](references/reading-results.md).
|
|
24
|
+
|
|
25
|
+
## Confirm before spending a call
|
|
26
|
+
|
|
27
|
+
Every run costs the user money and sends their writing to a third party. Before the first call of a
|
|
28
|
+
session:
|
|
29
|
+
|
|
30
|
+
1. Name the file that is about to go out.
|
|
31
|
+
2. Give the word count and the billable estimate. `--dry-run` prints both and sends nothing.
|
|
32
|
+
3. Wait for a yes.
|
|
33
|
+
|
|
34
|
+
This skill sets `disable-model-invocation: true`. It runs when a user asks for it by name, and never
|
|
35
|
+
as a step some other task decided to take. A skill that drafts or rewrites text may mention it and
|
|
36
|
+
stop there.
|
|
37
|
+
|
|
38
|
+
## Check the key first
|
|
39
|
+
|
|
40
|
+
On a machine that has not run this before:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
node scripts/pangram-check.mjs --check-key
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That checks the key against `GET /models` and spends no detection call. With no key found, the
|
|
47
|
+
script prints where it looked and how to store one. Pass that message on as it stands. Never print,
|
|
48
|
+
echo or paste the key itself, and never put it in a shell command the user will see.
|
|
49
|
+
|
|
50
|
+
The key is read from `$PANGRAM_API_KEY`, then `$PANGRAM_ENV_FILE`, then `~/.config/pangram/.env`,
|
|
51
|
+
then `./.env`. Recommend the third one, which every repository on the machine can reach.
|
|
52
|
+
|
|
53
|
+
## Only the prose is sent
|
|
54
|
+
|
|
55
|
+
The document is reduced to its prose before the call, and that reduced text is what Pangram
|
|
56
|
+
receives. Frontmatter, code blocks, HTML comments, images, shortcodes, raw HTML, tables, headings
|
|
57
|
+
and URLs are all removed. Link text and inline code keep their words.
|
|
58
|
+
|
|
59
|
+
Check what would go out with `--print-prose`. The word count the script reports is that same string,
|
|
60
|
+
so a document reporting `512 words of prose` sent exactly those 512 words whatever the file length.
|
|
61
|
+
|
|
62
|
+
This matters for reading the verdict. A page that is mostly code would otherwise have its bash and
|
|
63
|
+
JSON scored alongside the writing, which pulls the fractions towards human for reasons unrelated to
|
|
64
|
+
how the prose reads.
|
|
65
|
+
|
|
66
|
+
Wrap quoted material in `<!-- pangram-check:off -->` and `<!-- pangram-check:on -->` to keep someone
|
|
67
|
+
else's writing out of the score.
|
|
68
|
+
|
|
69
|
+
## The guards
|
|
70
|
+
|
|
71
|
+
Two of them refuse to send anything.
|
|
72
|
+
|
|
73
|
+
- **Under 50 words of prose:** Pangram's own documented floor, below which it declines to predict.
|
|
74
|
+
- **Under `--min-words`** (300 by default). Pangram predicts from 50 words up with less confidence
|
|
75
|
+
the shorter the text. Lower the floor for a short document, and treat the verdict as weaker.
|
|
76
|
+
|
|
77
|
+
A repository can add its own with `rejectPatterns` in `.pangram-check.json`, or `--reject <regex>`
|
|
78
|
+
for one run. `--reject-todo` is the preset for the common case, where a `TODO` marker means a
|
|
79
|
+
paragraph is still to be written and scoring it would spend a paid call on a draft.
|
|
80
|
+
|
|
81
|
+
`--max-units <n>` refuses a document over a cost ceiling. One billable unit is each started block of
|
|
82
|
+
1000 words.
|
|
83
|
+
|
|
84
|
+
## Repeat runs are free
|
|
85
|
+
|
|
86
|
+
Results are cached by a hash of the extracted prose, so running the same unchanged document again
|
|
87
|
+
reads the cache and calls nothing. Edit the prose and the next run is a fresh call. `--refresh`
|
|
88
|
+
forces one, `--no-cache` skips the cache in both directions.
|
|
89
|
+
|
|
90
|
+
There is deliberately no score history and no before-and-after delta. Both would invite treating the
|
|
91
|
+
number as a target.
|
|
92
|
+
|
|
93
|
+
## Output
|
|
94
|
+
|
|
95
|
+
`--format text` (the default) colour-codes scores on a terminal and draws the windows in reading
|
|
96
|
+
order. `--format markdown` gives a table to paste into a report. `--format json` carries every field
|
|
97
|
+
plus the source line for each window.
|
|
98
|
+
|
|
99
|
+
## Reporting back
|
|
100
|
+
|
|
101
|
+
Give the user the headline, the three fractions, and the worst two or three windows with their
|
|
102
|
+
scores and line references. Point at the passages by line so they can open them.
|
|
103
|
+
|
|
104
|
+
Leave the judgement about rewriting to the user. A high score marks a passage worth rereading, and
|
|
105
|
+
it is not a number to drive down. Editing to move a detector score is a different activity from
|
|
106
|
+
writing in your own voice, and the two come apart quickly.
|
|
107
|
+
|
|
108
|
+
## Limits
|
|
109
|
+
|
|
110
|
+
Pangram reports whether text reads as machine-generated. It has no opinion on whether the writing is
|
|
111
|
+
any good, and a document can come back fully human while being badly organised.
|
|
112
|
+
|
|
113
|
+
A `is_humanized` flag on a window is a stronger signal than a high score by itself, because it means
|
|
114
|
+
the passage looks like text that has been worked over to read as human.
|
|
115
|
+
|
|
116
|
+
Detectors carry false positives, which fall hardest on writers working in a second language. Treat
|
|
117
|
+
one verdict as one input to a rereading, never as proof of authorship, and say so if a user starts
|
|
118
|
+
treating it as proof.
|
|
119
|
+
|
|
120
|
+
## Related skills
|
|
121
|
+
|
|
122
|
+
`technical-prose-style` covers a different question. It measures the constructions that make prose
|
|
123
|
+
tiring to read, and its own measurements show that text following every one of its rules is still
|
|
124
|
+
identified as machine-written. Style and provenance are separate signals. Anyone after concealment
|
|
125
|
+
is holding the wrong tool.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Every setting has a flag. The ones a repository wants every time belong in a config file.
|
|
4
|
+
|
|
5
|
+
## Flags
|
|
6
|
+
|
|
7
|
+
| Flag | Effect |
|
|
8
|
+
| ------------------ | ---------------------------------------------------------------- |
|
|
9
|
+
| `--dry-run` | Runs the guards, prints the cost estimate, sends nothing |
|
|
10
|
+
| `--print-prose` | Prints the extracted prose and exits |
|
|
11
|
+
| `--format <f>` | `text` (default), `markdown` or `json` |
|
|
12
|
+
| `--windows <n>` | How many windows to detail, or `all` (default 5) |
|
|
13
|
+
| `--min-words <n>` | Refuses below this many words of prose (default 300) |
|
|
14
|
+
| `--max-units <n>` | Refuses over this many billable units |
|
|
15
|
+
| `--reject-todo` | Refuses while the file holds `TODO` markers |
|
|
16
|
+
| `--reject <regex>` | Refuses when the pattern matches the file. Repeatable |
|
|
17
|
+
| `--skip-quotes` | Leaves blockquoted material out of the prose |
|
|
18
|
+
| `--plain` | Treats the file as plain text, skipping the markdown rules |
|
|
19
|
+
| `--model <name>` | Pangram model selector, passed through as `model` |
|
|
20
|
+
| `--list-models` | Lists the selectors the key allows, then exits |
|
|
21
|
+
| `--check-key` | Checks the key is accepted, then exits. Spends no detection call |
|
|
22
|
+
| `--refresh` | Ignores any cached result for this text |
|
|
23
|
+
| `--no-cache` | Neither reads nor writes the cache |
|
|
24
|
+
| `--config <path>` | Uses this config file |
|
|
25
|
+
| `--no-config` | Ignores any `.pangram-check.json` |
|
|
26
|
+
| `--no-color` | Plain output on a terminal |
|
|
27
|
+
|
|
28
|
+
Exit codes: `0` ran, `1` a guard refused or Pangram failed, `2` a usage, config or key problem.
|
|
29
|
+
|
|
30
|
+
## The config file
|
|
31
|
+
|
|
32
|
+
`.pangram-check.json`, found by walking up from the file being checked. The first one found wins,
|
|
33
|
+
and flags override it. An unknown key prints a warning and is ignored.
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"minWords": 300,
|
|
38
|
+
"maxUnits": 2,
|
|
39
|
+
"rejectTodo": true,
|
|
40
|
+
"rejectPatterns": ["<!--\\s*FIXME", "^\\s*XXX\\b"],
|
|
41
|
+
"skipQuotes": false,
|
|
42
|
+
"format": "markdown",
|
|
43
|
+
"windows": 3,
|
|
44
|
+
"model": "default",
|
|
45
|
+
"cache": true
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Patterns are JavaScript regular expressions, matched against the raw file with the `g` and `m`
|
|
50
|
+
flags, so `^` and `$` anchor to a line. Backslashes need escaping for JSON. Every match is reported
|
|
51
|
+
with its line number.
|
|
52
|
+
|
|
53
|
+
`rejectTodo` is the preset behind `--reject-todo`, and matches a `TODO` opening an HTML comment or a
|
|
54
|
+
line.
|
|
55
|
+
|
|
56
|
+
## The API key
|
|
57
|
+
|
|
58
|
+
Read from the first of these that carries `PANGRAM_API_KEY`, and never printed:
|
|
59
|
+
|
|
60
|
+
1. `$PANGRAM_API_KEY` in the environment
|
|
61
|
+
2. `$PANGRAM_ENV_FILE`, a path to a dotenv file
|
|
62
|
+
3. `~/.config/pangram/.env` (or `$XDG_CONFIG_HOME/pangram/.env`)
|
|
63
|
+
4. `./.env` in the working directory
|
|
64
|
+
|
|
65
|
+
The third is the one to recommend. It sits outside any repository and every project on the machine
|
|
66
|
+
can reach it.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
mkdir -p ~/.config/pangram
|
|
70
|
+
printf 'PANGRAM_API_KEY=%s\n' 'the-key' > ~/.config/pangram/.env
|
|
71
|
+
chmod 600 ~/.config/pangram/.env
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A placeholder value (anything starting `replace`, `your` or `<`) counts as absent. The key is
|
|
75
|
+
redacted from any error text the script prints.
|
|
76
|
+
|
|
77
|
+
`$PANGRAM_API_BASE` overrides the API host. The test stub in this repository uses it.
|
|
78
|
+
|
|
79
|
+
## What counts as prose
|
|
80
|
+
|
|
81
|
+
These are removed entirely:
|
|
82
|
+
|
|
83
|
+
- YAML and TOML frontmatter
|
|
84
|
+
- Fenced and indented code blocks
|
|
85
|
+
- HTML comments
|
|
86
|
+
- Images, tables, headings and horizontal rules
|
|
87
|
+
- Link reference definitions
|
|
88
|
+
- Hugo shortcodes and Liquid tags
|
|
89
|
+
- HTML and JSX tags
|
|
90
|
+
- Bare URLs
|
|
91
|
+
|
|
92
|
+
These keep their words and lose their markup:
|
|
93
|
+
|
|
94
|
+
- Link text
|
|
95
|
+
- Inline code
|
|
96
|
+
- Bold and emphasis
|
|
97
|
+
- Footnote text
|
|
98
|
+
- List item text
|
|
99
|
+
- Blockquotes (unless `--skip-quotes`)
|
|
100
|
+
|
|
101
|
+
Each paragraph and each list item becomes one block. Blocks are joined with a space, and a block
|
|
102
|
+
with no terminal punctuation gains a full stop. Without it the detector reads a run of list items as
|
|
103
|
+
one sentence.
|
|
104
|
+
|
|
105
|
+
Regions between `<!-- pangram-check:off -->` and `<!-- pangram-check:on -->` are dropped. Use them
|
|
106
|
+
for quoted material.
|
|
107
|
+
|
|
108
|
+
`.md`, `.markdown`, `.mdx` and `.mdoc` get the markdown rules. Everything else is treated as plain
|
|
109
|
+
text, and `--plain` forces that.
|
|
110
|
+
|
|
111
|
+
Every block records the source line it started on. That is where the `file:line` reference on each
|
|
112
|
+
reported window comes from.
|
|
113
|
+
|
|
114
|
+
## The cache
|
|
115
|
+
|
|
116
|
+
`$XDG_CACHE_HOME/pangram-check/` or `~/.cache/pangram-check/`, one JSON file per result, keyed by a
|
|
117
|
+
SHA-256 of the model selector and the extracted prose. The file holds the full API response,
|
|
118
|
+
including the submitted text.
|
|
119
|
+
|
|
120
|
+
Delete the directory to clear it. `--no-cache` writes nothing there.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Reading a result
|
|
2
|
+
|
|
3
|
+
Pangram scores the whole submission and then scores it again in windows, so the useful part of a
|
|
4
|
+
result is the window list.
|
|
5
|
+
|
|
6
|
+
## The document level
|
|
7
|
+
|
|
8
|
+
| Field | What it carries |
|
|
9
|
+
| -------------------------- | --------------------------------------------------------- |
|
|
10
|
+
| `headline` | A short verdict, such as `AI Detected` or `Human Written` |
|
|
11
|
+
| `prediction` | A sentence of detail behind the headline |
|
|
12
|
+
| `prediction_short` | The same in a word or two |
|
|
13
|
+
| `fraction_ai` | Share of the text scored as machine-written, 0 to 1 |
|
|
14
|
+
| `fraction_ai_assisted` | Share scored as written with machine help |
|
|
15
|
+
| `fraction_human` | Share scored as human-written |
|
|
16
|
+
| `num_ai_segments` | Count of segments in each class, alongside the two below |
|
|
17
|
+
| `num_ai_assisted_segments` | |
|
|
18
|
+
| `num_human_segments` | |
|
|
19
|
+
| `version` | The detector version behind the verdict |
|
|
20
|
+
|
|
21
|
+
The three fractions and the three segment counts answer different questions. A document can be 20%
|
|
22
|
+
AI by volume while only one segment out of nine is the source of it, which points at a passage. The
|
|
23
|
+
same 20% spread evenly across every segment points at the whole document.
|
|
24
|
+
|
|
25
|
+
## The window level
|
|
26
|
+
|
|
27
|
+
| Field | What it carries |
|
|
28
|
+
| ---------------------------- | ------------------------------------------------- |
|
|
29
|
+
| `ai_assistance_score` | 0 for human, 1 for machine. The number to sort by |
|
|
30
|
+
| `label` | `Human`, `AI` or `AI Assisted` |
|
|
31
|
+
| `confidence` | How sure the detector is of that label |
|
|
32
|
+
| `text` | The passage that was scored |
|
|
33
|
+
| `start_index`, `end_index` | Character offsets into the submitted prose |
|
|
34
|
+
| `word_count`, `token_length` | The size of the window |
|
|
35
|
+
| `is_humanized` | The passage looks worked over to read as human |
|
|
36
|
+
| `humanizer_score` | How strongly, 0 to 1 |
|
|
37
|
+
|
|
38
|
+
The script turns `start_index` into a `file:line` reference by mapping it back through the
|
|
39
|
+
extraction, so a flagged window points at the paragraph in the source file. Line references are
|
|
40
|
+
accurate to the block, and the column is not tracked.
|
|
41
|
+
|
|
42
|
+
## Which signals to act on
|
|
43
|
+
|
|
44
|
+
**Sort by `ai_assistance_score` and read the top two or three passages.** Everything else in the
|
|
45
|
+
result is context for those.
|
|
46
|
+
|
|
47
|
+
**`is_humanized` outranks a high score.** A high score says a passage reads as machine-written. The
|
|
48
|
+
humanized flag says it reads as machine-written text that has since been edited to look human, which
|
|
49
|
+
is a claim about the editing as well as the drafting.
|
|
50
|
+
|
|
51
|
+
**`confidence` qualifies the label, and it qualifies a low score too.** A `Human` label at low
|
|
52
|
+
confidence is a weaker result than a `Human` label at high confidence.
|
|
53
|
+
|
|
54
|
+
**Short windows are noisier.** A window of 60 words carries less signal than one of 300. Check
|
|
55
|
+
`word_count` before acting on an outlier.
|
|
56
|
+
|
|
57
|
+
## The limits of a verdict
|
|
58
|
+
|
|
59
|
+
Pangram reports whether text reads as machine-generated. Whether the writing is any good is a
|
|
60
|
+
separate question, and a document can come back fully human while being badly organised, wrong, or
|
|
61
|
+
dull.
|
|
62
|
+
|
|
63
|
+
Detectors carry false positives. Published audits have found high false-positive rates on writing by
|
|
64
|
+
non-native English speakers, which is the failure mode to hold in mind before treating a score as
|
|
65
|
+
evidence about a person. One verdict is one input to a rereading.
|
|
66
|
+
|
|
67
|
+
Scores also move for reasons that have nothing to do with quality. Quoted material, standard
|
|
68
|
+
technical phrasing and heavily edited passages all shift the number. Read the passage the score
|
|
69
|
+
points at, and judge the passage.
|
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sends the prose of one document to Pangram, a commercial AI-text detector, and
|
|
3
|
+
// reports which passages read as machine-drafted.
|
|
4
|
+
//
|
|
5
|
+
// node pangram-check.mjs post.md
|
|
6
|
+
// node pangram-check.mjs post.md --dry-run guards and cost, send nothing
|
|
7
|
+
// node pangram-check.mjs post.md --print-prose exactly what would be sent
|
|
8
|
+
// node pangram-check.mjs post.md --format markdown
|
|
9
|
+
// node pangram-check.mjs --check-key free, spends no detection call
|
|
10
|
+
//
|
|
11
|
+
// Only the prose goes out. Frontmatter, code, HTML, shortcodes, tables, headings
|
|
12
|
+
// and URLs are removed first, and every reported window carries the source line
|
|
13
|
+
// its passage starts on. Results are cached by content hash, so re-running on an
|
|
14
|
+
// unchanged document costs nothing.
|
|
15
|
+
//
|
|
16
|
+
// The API key is read from the environment or a dotenv file, is never printed,
|
|
17
|
+
// and is redacted from any error text.
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { basename, dirname, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
const HELP = `usage: pangram-check.mjs [options] <file>
|
|
25
|
+
|
|
26
|
+
--dry-run run the guards, print the cost estimate, send nothing
|
|
27
|
+
--print-prose print the extracted prose and exit
|
|
28
|
+
--format <f> text, markdown or json (default: text)
|
|
29
|
+
--windows <n> how many windows to detail, or "all" (default: 5)
|
|
30
|
+
--min-words <n> refuse below this many words of prose (default: 300)
|
|
31
|
+
--max-units <n> refuse when the estimate exceeds n billable units
|
|
32
|
+
--reject-todo refuse while the file holds TODO markers
|
|
33
|
+
--reject <regex> refuse when this pattern matches the file (repeatable)
|
|
34
|
+
--skip-quotes leave blockquoted material out of the prose
|
|
35
|
+
--plain treat the file as plain text, not markdown
|
|
36
|
+
--model <name> Pangram model selector
|
|
37
|
+
--list-models list the model selectors the key allows, then exit
|
|
38
|
+
--check-key check the key is accepted, then exit
|
|
39
|
+
--refresh ignore any cached result for this text
|
|
40
|
+
--no-cache neither read nor write the cache
|
|
41
|
+
--config <path> use this config file
|
|
42
|
+
--no-config ignore any .pangram-check.json
|
|
43
|
+
--no-color plain output on a terminal
|
|
44
|
+
|
|
45
|
+
Config: .pangram-check.json, found by walking up from the file. Keys are
|
|
46
|
+
minWords, maxUnits, rejectTodo, rejectPatterns, skipQuotes, format, windows,
|
|
47
|
+
model and cache. Flags win over the file.`;
|
|
48
|
+
|
|
49
|
+
const CAUTION = [
|
|
50
|
+
"A high score marks a passage worth rereading. It is not a number to drive down.",
|
|
51
|
+
"Editing to move a detector score is a different activity from writing in your own voice.",
|
|
52
|
+
"Pangram reports whether text reads as machine-generated. It has no opinion on whether the writing is any good.",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// ---- arguments --------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
const VALUED = new Set([
|
|
59
|
+
"--format",
|
|
60
|
+
"--windows",
|
|
61
|
+
"--min-words",
|
|
62
|
+
"--max-units",
|
|
63
|
+
"--reject",
|
|
64
|
+
"--model",
|
|
65
|
+
"--config",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const flags = { reject: [] };
|
|
69
|
+
const positional = [];
|
|
70
|
+
const camel = (name) => name.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < argv.length; i++) {
|
|
73
|
+
const arg = argv[i];
|
|
74
|
+
if (!arg.startsWith("--")) {
|
|
75
|
+
positional.push(arg);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const [name, inline] = arg.includes("=")
|
|
79
|
+
? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)]
|
|
80
|
+
: [arg, null];
|
|
81
|
+
if (VALUED.has(name)) {
|
|
82
|
+
const value = inline ?? argv[++i];
|
|
83
|
+
if (value === undefined) fail(2, `${name} needs a value`);
|
|
84
|
+
if (name === "--reject") flags.reject.push(value);
|
|
85
|
+
else flags[camel(name)] = value;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (inline !== null) fail(2, `${name} takes no value`);
|
|
89
|
+
flags[camel(name)] = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fail(code, message) {
|
|
93
|
+
console.error(`✖ ${message}`);
|
|
94
|
+
process.exit(code);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (flags.help || argv.includes("-h")) {
|
|
98
|
+
console.log(HELP);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const BASE = process.env.PANGRAM_API_BASE?.trim() || "https://text.external-api.pangram.com";
|
|
103
|
+
|
|
104
|
+
// ---- key -------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
const KEY_SOURCES = [
|
|
107
|
+
["$PANGRAM_API_KEY", () => process.env.PANGRAM_API_KEY?.trim() || null],
|
|
108
|
+
["$PANGRAM_ENV_FILE", () => fromDotenv(process.env.PANGRAM_ENV_FILE)],
|
|
109
|
+
["~/.config/pangram/.env", () => fromDotenv(configHome())],
|
|
110
|
+
["./.env", () => fromDotenv(resolve(process.cwd(), ".env"))],
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
function configHome() {
|
|
114
|
+
const base = process.env.XDG_CONFIG_HOME?.trim() || resolve(homedir(), ".config");
|
|
115
|
+
return resolve(base, "pangram/.env");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fromDotenv(path) {
|
|
119
|
+
if (!path || !existsSync(path)) return null;
|
|
120
|
+
const line = readFileSync(path, "utf8")
|
|
121
|
+
.split("\n")
|
|
122
|
+
.find((l) =>
|
|
123
|
+
l
|
|
124
|
+
.trim()
|
|
125
|
+
.replace(/^export\s+/, "")
|
|
126
|
+
.startsWith("PANGRAM_API_KEY="),
|
|
127
|
+
);
|
|
128
|
+
if (!line) return null;
|
|
129
|
+
const value = line
|
|
130
|
+
.split("=")
|
|
131
|
+
.slice(1)
|
|
132
|
+
.join("=")
|
|
133
|
+
.trim()
|
|
134
|
+
.replace(/^["']|["']$/g, "");
|
|
135
|
+
return value && !/^(replace|your|<)/i.test(value) ? value : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readKey() {
|
|
139
|
+
for (const [, read] of KEY_SOURCES) {
|
|
140
|
+
const value = read();
|
|
141
|
+
if (value) return value;
|
|
142
|
+
}
|
|
143
|
+
console.error("✖ No Pangram API key found. Looked in, in order:\n");
|
|
144
|
+
for (const [label] of KEY_SOURCES) console.error(` ${label}`);
|
|
145
|
+
console.error(`
|
|
146
|
+
Pangram is a paid commercial service. Get a key from https://www.pangram.com
|
|
147
|
+
(dashboard, then API keys), then store it where every repository can reach it:
|
|
148
|
+
|
|
149
|
+
mkdir -p ~/.config/pangram
|
|
150
|
+
printf 'PANGRAM_API_KEY=%s\\n' 'the-key' > ~/.config/pangram/.env
|
|
151
|
+
chmod 600 ~/.config/pangram/.env
|
|
152
|
+
|
|
153
|
+
Or export PANGRAM_API_KEY in the shell, or point $PANGRAM_ENV_FILE at a
|
|
154
|
+
dotenv file that holds it. Check it with:
|
|
155
|
+
|
|
156
|
+
node pangram-check.mjs --check-key
|
|
157
|
+
`);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Resolved on the first call and never before it, so --dry-run, --print-prose
|
|
162
|
+
// and every guard work on a machine that has no key at all.
|
|
163
|
+
let KEY = null;
|
|
164
|
+
const key = () => (KEY ??= readKey());
|
|
165
|
+
const redact = (text) => (KEY ? String(text).split(KEY).join("[REDACTED]") : String(text));
|
|
166
|
+
|
|
167
|
+
// ---- http ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const STATUS_HELP = {
|
|
170
|
+
400: "Pangram rejected the request as malformed.",
|
|
171
|
+
401: "Pangram rejected the API key. Check it with --check-key.",
|
|
172
|
+
403: "Pangram rejected the API key. Check it with --check-key.",
|
|
173
|
+
402: "The Pangram account is out of credits. Top it up at https://www.pangram.com",
|
|
174
|
+
413: "The text is too large for one request. Split the document.",
|
|
175
|
+
429: "Rate limited by Pangram. Wait and run it again.",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
async function call(path, init) {
|
|
179
|
+
let response;
|
|
180
|
+
try {
|
|
181
|
+
response = await fetch(`${BASE}${path}`, {
|
|
182
|
+
...init,
|
|
183
|
+
headers: { "Content-Type": "application/json", "x-api-key": key(), ...(init?.headers ?? {}) },
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new Error(`Could not reach ${BASE}: ${redact(error.message)}`);
|
|
187
|
+
}
|
|
188
|
+
const body = await response.text();
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
const help = STATUS_HELP[response.status] ?? `Pangram returned ${response.status}.`;
|
|
191
|
+
throw new Error(`${help}\n ${redact(body.slice(0, 300))}`);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(body);
|
|
195
|
+
} catch {
|
|
196
|
+
throw new Error(`Pangram returned a body that is not JSON: ${redact(body.slice(0, 200))}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- key check and model list ----------------------------------------------
|
|
201
|
+
|
|
202
|
+
if (flags.checkKey || flags.listModels) {
|
|
203
|
+
try {
|
|
204
|
+
const models = await call("/models", { method: "GET" });
|
|
205
|
+
const names = Array.isArray(models) ? models : (models.models ?? models.data ?? []);
|
|
206
|
+
console.log("✔ Pangram accepted the API key. No detection call was made.");
|
|
207
|
+
const labels = names.map((m) =>
|
|
208
|
+
typeof m === "string" ? m : (m.name ?? m.id ?? JSON.stringify(m)),
|
|
209
|
+
);
|
|
210
|
+
if (labels.length > 0) console.log(` Model selectors: ${labels.join(", ")}`);
|
|
211
|
+
process.exit(0);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const message = redact(error.message);
|
|
214
|
+
if (/\b(404|405)\b/.test(message)) {
|
|
215
|
+
console.log("· This deployment does not serve /models, so the key could not be checked");
|
|
216
|
+
console.log(" without spending a detection call. The key was found and looks well formed.");
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
fail(1, message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- config ----------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const target = positional[0];
|
|
226
|
+
if (!target) fail(2, `no file given.\n\n${HELP}`);
|
|
227
|
+
if (!existsSync(target)) fail(2, `no such file: ${target}`);
|
|
228
|
+
|
|
229
|
+
const CONFIG_NAME = ".pangram-check.json";
|
|
230
|
+
const KNOWN_KEYS = new Set([
|
|
231
|
+
"minWords",
|
|
232
|
+
"maxUnits",
|
|
233
|
+
"rejectTodo",
|
|
234
|
+
"rejectPatterns",
|
|
235
|
+
"skipQuotes",
|
|
236
|
+
"format",
|
|
237
|
+
"windows",
|
|
238
|
+
"model",
|
|
239
|
+
"cache",
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
function findConfig() {
|
|
243
|
+
if (flags.noConfig) return { path: null, values: {} };
|
|
244
|
+
if (flags.config) {
|
|
245
|
+
if (!existsSync(flags.config)) fail(2, `no such config file: ${flags.config}`);
|
|
246
|
+
return { path: flags.config, values: readConfig(flags.config) };
|
|
247
|
+
}
|
|
248
|
+
let dir = dirname(resolve(target));
|
|
249
|
+
for (;;) {
|
|
250
|
+
const candidate = resolve(dir, CONFIG_NAME);
|
|
251
|
+
if (existsSync(candidate)) return { path: candidate, values: readConfig(candidate) };
|
|
252
|
+
const parent = dirname(dir);
|
|
253
|
+
if (parent === dir) return { path: null, values: {} };
|
|
254
|
+
dir = parent;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readConfig(path) {
|
|
259
|
+
let values;
|
|
260
|
+
try {
|
|
261
|
+
values = JSON.parse(readFileSync(path, "utf8"));
|
|
262
|
+
} catch (error) {
|
|
263
|
+
fail(2, `${path} is not valid JSON: ${error.message}`);
|
|
264
|
+
}
|
|
265
|
+
for (const key of Object.keys(values)) {
|
|
266
|
+
if (!KNOWN_KEYS.has(key)) console.error(`· ${path}: ignoring unknown key "${key}"`);
|
|
267
|
+
}
|
|
268
|
+
return values;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const config = findConfig();
|
|
272
|
+
const number = (value, name) => {
|
|
273
|
+
const parsed = Number(value);
|
|
274
|
+
if (!Number.isFinite(parsed) || parsed < 0) fail(2, `${name} must be a non-negative number`);
|
|
275
|
+
return parsed;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const settings = {
|
|
279
|
+
minWords:
|
|
280
|
+
flags.minWords !== undefined
|
|
281
|
+
? number(flags.minWords, "--min-words")
|
|
282
|
+
: (config.values.minWords ?? 300),
|
|
283
|
+
maxUnits:
|
|
284
|
+
flags.maxUnits !== undefined
|
|
285
|
+
? number(flags.maxUnits, "--max-units")
|
|
286
|
+
: (config.values.maxUnits ?? null),
|
|
287
|
+
rejectTodo: flags.rejectTodo || config.values.rejectTodo === true,
|
|
288
|
+
rejectPatterns: [...(config.values.rejectPatterns ?? []), ...flags.reject],
|
|
289
|
+
skipQuotes: flags.skipQuotes || config.values.skipQuotes === true,
|
|
290
|
+
format: flags.format ?? config.values.format ?? "text",
|
|
291
|
+
windows: flags.windows ?? String(config.values.windows ?? 5),
|
|
292
|
+
model: flags.model ?? config.values.model ?? null,
|
|
293
|
+
cache: flags.noCache ? false : (config.values.cache ?? true),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (!["text", "markdown", "json"].includes(settings.format)) {
|
|
297
|
+
fail(2, `unknown format "${settings.format}". Use text, markdown or json.`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// TODO markers are the reason this preset exists: a wrapped paragraph is prose
|
|
301
|
+
// that has not been written yet, so scoring it spends a paid call on a draft.
|
|
302
|
+
if (settings.rejectTodo) settings.rejectPatterns.unshift("(?:<!--\\s*|^\\s*)TODO\\b");
|
|
303
|
+
|
|
304
|
+
const detail = settings.windows === "all" ? Infinity : number(settings.windows, "--windows");
|
|
305
|
+
|
|
306
|
+
// ---- prose -----------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
const MARKDOWN = /\.(md|markdown|mdx|mdoc)$/i;
|
|
309
|
+
const isMarkdown = !flags.plain && MARKDOWN.test(target);
|
|
310
|
+
const source = readFileSync(target, "utf8");
|
|
311
|
+
|
|
312
|
+
const INLINE = [
|
|
313
|
+
[/!\[[^\]]*\]\([^)]*\)/g, ""], // images
|
|
314
|
+
[/!\[[^\]]*\]\[[^\]]*\]/g, ""],
|
|
315
|
+
[/\[([^\]]*)\]\([^)]*\)/g, "$1"], // links keep their text
|
|
316
|
+
[/\[([^\]]*)\]\[[^\]]*\]/g, "$1"],
|
|
317
|
+
[/\{\{[<%][\s\S]*?[%>]\}\}/g, ""], // Hugo shortcodes
|
|
318
|
+
[/\{%[\s\S]*?%\}/g, ""], // Liquid and Jekyll tags
|
|
319
|
+
[/`+([^`]*)`+/g, "$1"], // inline code keeps its words
|
|
320
|
+
[/<[^>\n]+>/g, ""], // HTML, JSX and autolinks
|
|
321
|
+
[/https?:\/\/\S+/g, ""],
|
|
322
|
+
[/\[\^[^\]]*\]/g, ""], // footnote references
|
|
323
|
+
[/\{#[^}]*\}/g, ""], // heading anchors
|
|
324
|
+
[/\*\*([^*]+)\*\*/g, "$1"],
|
|
325
|
+
[/(?<![\w*])\*([^*\n]+)\*(?![\w*])/g, "$1"],
|
|
326
|
+
[/~~([^~]+)~~/g, "$1"],
|
|
327
|
+
[/\\([*_`[\]#])/g, "$1"], // escapes
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
function extract(text) {
|
|
331
|
+
const lines = text.split("\n");
|
|
332
|
+
const blocks = [];
|
|
333
|
+
let current = [];
|
|
334
|
+
|
|
335
|
+
const flush = () => {
|
|
336
|
+
if (current.length === 0) return;
|
|
337
|
+
const joined = current
|
|
338
|
+
.map((part) => part.text)
|
|
339
|
+
.join(" ")
|
|
340
|
+
.replace(/\s+/g, " ")
|
|
341
|
+
.trim();
|
|
342
|
+
if (joined) {
|
|
343
|
+
blocks.push({ text: /[.!?:]$/.test(joined) ? joined : `${joined}.`, line: current[0].line });
|
|
344
|
+
}
|
|
345
|
+
current = [];
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
let i = 0;
|
|
349
|
+
if (isMarkdown && (lines[0] === "---" || lines[0] === "+++")) {
|
|
350
|
+
const closer = lines[0];
|
|
351
|
+
for (i = 1; i < lines.length && lines[i].trimEnd() !== closer; i++);
|
|
352
|
+
i++;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let fence = null;
|
|
356
|
+
let comment = false;
|
|
357
|
+
let excluded = false;
|
|
358
|
+
let inList = false;
|
|
359
|
+
let inIndentedCode = false;
|
|
360
|
+
let prevBlank = true;
|
|
361
|
+
|
|
362
|
+
for (; i < lines.length; i++) {
|
|
363
|
+
const raw = lines[i];
|
|
364
|
+
|
|
365
|
+
if (/pangram-check:\s*off/.test(raw)) {
|
|
366
|
+
excluded = true;
|
|
367
|
+
flush();
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (/pangram-check:\s*on/.test(raw)) {
|
|
371
|
+
excluded = false;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (isMarkdown && !comment) {
|
|
376
|
+
if (fence) {
|
|
377
|
+
if (new RegExp(`^ {0,3}\\${fence.char}{${fence.length},}\\s*$`).test(raw)) fence = null;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
|
|
381
|
+
if (opening) {
|
|
382
|
+
flush();
|
|
383
|
+
fence = { char: opening[1][0], length: opening[1].length };
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let text = raw;
|
|
389
|
+
if (isMarkdown) {
|
|
390
|
+
const kept = [];
|
|
391
|
+
let rest = text;
|
|
392
|
+
for (;;) {
|
|
393
|
+
if (comment) {
|
|
394
|
+
const end = rest.indexOf("-->");
|
|
395
|
+
if (end === -1) break;
|
|
396
|
+
comment = false;
|
|
397
|
+
rest = rest.slice(end + 3);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const start = rest.indexOf("<!--");
|
|
401
|
+
if (start === -1) {
|
|
402
|
+
kept.push(rest);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
kept.push(rest.slice(0, start));
|
|
406
|
+
comment = true;
|
|
407
|
+
rest = rest.slice(start + 4);
|
|
408
|
+
}
|
|
409
|
+
text = kept.join("");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (excluded) {
|
|
413
|
+
flush();
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let line = text.trim();
|
|
418
|
+
|
|
419
|
+
if (!line) {
|
|
420
|
+
flush();
|
|
421
|
+
inList = false;
|
|
422
|
+
prevBlank = true;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (isMarkdown) {
|
|
427
|
+
const next = lines[i + 1] ?? "";
|
|
428
|
+
// An indented block runs until the next blank line, which is where the
|
|
429
|
+
// blank branch above clears the flag.
|
|
430
|
+
inIndentedCode = /^ {4,}\S/.test(text) && (inIndentedCode || (prevBlank && !inList));
|
|
431
|
+
const drop =
|
|
432
|
+
/^#{1,6}\s/.test(line) || // ATX heading
|
|
433
|
+
/^ {0,3}=+\s*$/.test(text) || // setext underline
|
|
434
|
+
/^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(text) || // horizontal rule
|
|
435
|
+
/^\|/.test(line) || // table row
|
|
436
|
+
/^\[[^^\]]+\]:\s*\S/.test(line) || // link reference definition
|
|
437
|
+
inIndentedCode ||
|
|
438
|
+
/^ {0,3}(=+|-+)\s*$/.test(next); // this line is a setext heading
|
|
439
|
+
|
|
440
|
+
if (drop) {
|
|
441
|
+
flush();
|
|
442
|
+
prevBlank = false;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const item = /^ {0,8}([-*+]|\d+[.)])\s+/.exec(line);
|
|
447
|
+
if (item) {
|
|
448
|
+
flush();
|
|
449
|
+
line = line.slice(item[0].length).replace(/^\[[ xX]\]\s*/, "");
|
|
450
|
+
inList = true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (/^ {0,3}>/.test(line)) {
|
|
454
|
+
if (settings.skipQuotes) {
|
|
455
|
+
flush();
|
|
456
|
+
prevBlank = false;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
line = line.replace(/^ {0,3}(>\s?)+/, "");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
line = line.replace(/^\[\^[^\]]+\]:\s*/, ""); // footnote definition
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
prevBlank = false;
|
|
466
|
+
|
|
467
|
+
for (const [pattern, replacement] of INLINE) line = line.replace(pattern, replacement);
|
|
468
|
+
line = line.replace(/\s+/g, " ").trim();
|
|
469
|
+
if (line) current.push({ text: line, line: i + 1 });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
flush();
|
|
473
|
+
|
|
474
|
+
let prose = "";
|
|
475
|
+
const map = [];
|
|
476
|
+
for (const block of blocks) {
|
|
477
|
+
if (prose) prose += " ";
|
|
478
|
+
const start = prose.length;
|
|
479
|
+
prose += block.text;
|
|
480
|
+
map.push({ start, end: prose.length, line: block.line });
|
|
481
|
+
}
|
|
482
|
+
return { prose, map };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const { prose, map } = extract(source);
|
|
486
|
+
const words = prose ? prose.split(" ").length : 0;
|
|
487
|
+
const units = Math.max(1, Math.ceil(words / 1000));
|
|
488
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
489
|
+
const lineFor = (index) => (map.find((b) => b.end > index) ?? map.at(-1))?.line ?? 1;
|
|
490
|
+
|
|
491
|
+
if (flags.printProse) {
|
|
492
|
+
process.stdout.write(`${prose}\n`);
|
|
493
|
+
process.exit(0);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---- guards ----------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
const rejections = [];
|
|
499
|
+
for (const pattern of settings.rejectPatterns) {
|
|
500
|
+
let regex;
|
|
501
|
+
try {
|
|
502
|
+
regex = new RegExp(pattern, "gm");
|
|
503
|
+
} catch (error) {
|
|
504
|
+
fail(2, `reject pattern ${pattern} is not a valid regular expression: ${error.message}`);
|
|
505
|
+
}
|
|
506
|
+
const lines = [];
|
|
507
|
+
for (const match of source.matchAll(regex)) {
|
|
508
|
+
lines.push(source.slice(0, match.index).split("\n").length);
|
|
509
|
+
}
|
|
510
|
+
if (lines.length > 0) rejections.push({ pattern, lines });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (rejections.length > 0) {
|
|
514
|
+
console.error(`✖ ${target} matches a reject pattern. Nothing was sent.\n`);
|
|
515
|
+
for (const { pattern, lines } of rejections) {
|
|
516
|
+
const shown = lines.slice(0, 8).join(", ");
|
|
517
|
+
const more = lines.length > 8 ? `, and ${lines.length - 8} more` : "";
|
|
518
|
+
console.error(` ${lines.length} × /${pattern}/ on line ${shown}${more}`);
|
|
519
|
+
}
|
|
520
|
+
console.error(`
|
|
521
|
+
Every run costs money, so the document is checked once it is finished. Clear
|
|
522
|
+
the matches, or drop the pattern for this run${config.path ? " with --no-config." : "."}
|
|
523
|
+
`);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Pangram's own documented floor. Below it the API declines to predict, so the
|
|
528
|
+
// call is spent for nothing. https://www.pangram.com/blog/why-does-pangram-have-a-minimum-word-count
|
|
529
|
+
const PANGRAM_FLOOR = 50;
|
|
530
|
+
|
|
531
|
+
if (words < PANGRAM_FLOOR) {
|
|
532
|
+
fail(
|
|
533
|
+
1,
|
|
534
|
+
`${target} has ${words} words of prose. Pangram needs ${PANGRAM_FLOOR} to predict, so nothing was sent.`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (words < settings.minWords) {
|
|
538
|
+
console.error(
|
|
539
|
+
`✖ ${target} has ${words} words of prose, under the ${settings.minWords} word floor. Nothing was sent.`,
|
|
540
|
+
);
|
|
541
|
+
console.error(
|
|
542
|
+
` Pangram predicts from ${PANGRAM_FLOOR} words up, with less confidence the shorter the text.`,
|
|
543
|
+
);
|
|
544
|
+
console.error(
|
|
545
|
+
` Lower the floor with --min-words ${Math.max(PANGRAM_FLOOR, words)} to check it anyway.`,
|
|
546
|
+
);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
if (settings.maxUnits !== null && units > settings.maxUnits) {
|
|
550
|
+
fail(
|
|
551
|
+
1,
|
|
552
|
+
`${target} is ${words} words, an estimated ${plural(units, "billable unit")}, over the --max-units ${settings.maxUnits} ceiling. Nothing was sent.`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---- cache -----------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
const cacheDir = resolve(
|
|
559
|
+
process.env.XDG_CACHE_HOME?.trim() || resolve(homedir(), ".cache"),
|
|
560
|
+
"pangram-check",
|
|
561
|
+
);
|
|
562
|
+
const digest = createHash("sha256")
|
|
563
|
+
.update(`${settings.model ?? "default"}\n${prose}`)
|
|
564
|
+
.digest("hex");
|
|
565
|
+
const cachePath = resolve(cacheDir, `${digest}.json`);
|
|
566
|
+
|
|
567
|
+
const readCache = () => {
|
|
568
|
+
if (!settings.cache || flags.refresh || !existsSync(cachePath)) return null;
|
|
569
|
+
try {
|
|
570
|
+
return JSON.parse(readFileSync(cachePath, "utf8"));
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const writeCache = (result) => {
|
|
577
|
+
if (!settings.cache) return;
|
|
578
|
+
try {
|
|
579
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
580
|
+
writeFileSync(
|
|
581
|
+
cachePath,
|
|
582
|
+
JSON.stringify({ savedAt: new Date().toISOString(), result }, null, 2),
|
|
583
|
+
);
|
|
584
|
+
} catch {
|
|
585
|
+
// A cache that cannot be written changes nothing about the result.
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// ---- run -------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
const name = basename(target);
|
|
592
|
+
const context = {
|
|
593
|
+
file: target,
|
|
594
|
+
words,
|
|
595
|
+
units,
|
|
596
|
+
model: settings.model ?? "default",
|
|
597
|
+
config: config.path,
|
|
598
|
+
minWords: settings.minWords,
|
|
599
|
+
patterns: settings.rejectPatterns.length,
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
if (flags.dryRun) {
|
|
603
|
+
report(null, { ...context, dryRun: true, cached: Boolean(readCache()) });
|
|
604
|
+
process.exit(0);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const cached = readCache();
|
|
608
|
+
let result = cached?.result ?? null;
|
|
609
|
+
|
|
610
|
+
if (!result) {
|
|
611
|
+
key(); // Resolved before anything is announced, so a missing key reports first.
|
|
612
|
+
if (settings.format === "text") {
|
|
613
|
+
process.stderr.write(
|
|
614
|
+
` Sending ${words} words to Pangram, an estimated ${plural(units, "billable unit")}…\n`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
618
|
+
try {
|
|
619
|
+
const body = { text: prose, public_dashboard_link: false };
|
|
620
|
+
if (settings.model) body.model = settings.model;
|
|
621
|
+
result = await call("/task", { method: "POST", body: JSON.stringify(body) });
|
|
622
|
+
|
|
623
|
+
for (let attempt = 0; result.stage !== "STAGE_SUCCESS" && attempt < 60; attempt++) {
|
|
624
|
+
if (result.stage === "STAGE_FAILED") {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`Pangram reported STAGE_FAILED: ${redact(result.error ?? "no reason given")}`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
await sleep(attempt === 0 ? 1000 : 2000);
|
|
630
|
+
result = await call(`/task/${result.task_id}`, { method: "GET" });
|
|
631
|
+
}
|
|
632
|
+
if (result.stage !== "STAGE_SUCCESS")
|
|
633
|
+
throw new Error("Pangram did not finish within two minutes.");
|
|
634
|
+
} catch (error) {
|
|
635
|
+
fail(1, redact(error.message));
|
|
636
|
+
}
|
|
637
|
+
writeCache(result);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
report(result, { ...context, cached: Boolean(cached), savedAt: cached?.savedAt ?? null });
|
|
641
|
+
|
|
642
|
+
// ---- reporting -------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
function windowsOf(result) {
|
|
645
|
+
return [...(result.windows ?? [])].map((w, index) => ({
|
|
646
|
+
index,
|
|
647
|
+
score: w.ai_assistance_score ?? 0,
|
|
648
|
+
label: w.label ?? "unknown",
|
|
649
|
+
confidence: w.confidence ?? "unknown",
|
|
650
|
+
words: w.word_count ?? null,
|
|
651
|
+
humanized: w.is_humanized ? (w.humanizer_score ?? null) : null,
|
|
652
|
+
line: lineFor(w.start_index ?? 0),
|
|
653
|
+
excerpt: (w.text ?? "").replace(/\s+/g, " ").trim(),
|
|
654
|
+
}));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function report(result, meta) {
|
|
658
|
+
if (settings.format === "json") return json(result, meta);
|
|
659
|
+
if (settings.format === "markdown") return markdown(result, meta);
|
|
660
|
+
return text(result, meta);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function pct(n) {
|
|
664
|
+
return `${Math.round((n ?? 0) * 100)}%`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function json(result, meta) {
|
|
668
|
+
const payload = { ...meta, floor: PANGRAM_FLOOR };
|
|
669
|
+
if (result) {
|
|
670
|
+
payload.headline = result.headline ?? null;
|
|
671
|
+
payload.prediction = result.prediction ?? null;
|
|
672
|
+
payload.prediction_short = result.prediction_short ?? null;
|
|
673
|
+
payload.fractions = {
|
|
674
|
+
ai: result.fraction_ai ?? null,
|
|
675
|
+
ai_assisted: result.fraction_ai_assisted ?? null,
|
|
676
|
+
human: result.fraction_human ?? null,
|
|
677
|
+
};
|
|
678
|
+
payload.segments = {
|
|
679
|
+
ai: result.num_ai_segments ?? null,
|
|
680
|
+
ai_assisted: result.num_ai_assisted_segments ?? null,
|
|
681
|
+
human: result.num_human_segments ?? null,
|
|
682
|
+
};
|
|
683
|
+
payload.version = result.version ?? null;
|
|
684
|
+
payload.windows = windowsOf(result);
|
|
685
|
+
}
|
|
686
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function markdown(result, meta) {
|
|
690
|
+
const out = [];
|
|
691
|
+
out.push(`## Pangram check: \`${meta.file}\``);
|
|
692
|
+
out.push("");
|
|
693
|
+
if (!result) {
|
|
694
|
+
out.push(
|
|
695
|
+
`Dry run. ${meta.words} words of prose, an estimated ${plural(meta.units, "billable unit")}. Nothing was sent.`,
|
|
696
|
+
);
|
|
697
|
+
console.log(out.join("\n"));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
out.push(`**${result.headline ?? "No headline"}.** ${result.prediction ?? ""}`.trim());
|
|
701
|
+
out.push("");
|
|
702
|
+
out.push("| | |");
|
|
703
|
+
out.push("| --- | --- |");
|
|
704
|
+
out.push(`| Prose sent | ${meta.words} words, ${plural(meta.units, "billable unit")} |`);
|
|
705
|
+
out.push(
|
|
706
|
+
`| Fractions | ${pct(result.fraction_ai)} AI, ${pct(result.fraction_ai_assisted)} AI-assisted, ${pct(result.fraction_human)} human |`,
|
|
707
|
+
);
|
|
708
|
+
out.push(
|
|
709
|
+
`| Segments | ${result.num_ai_segments ?? 0} AI, ${result.num_ai_assisted_segments ?? 0} AI-assisted, ${result.num_human_segments ?? 0} human |`,
|
|
710
|
+
);
|
|
711
|
+
out.push(`| Model | ${meta.model} |`);
|
|
712
|
+
out.push(`| Source | ${meta.cached ? `cached result from ${meta.savedAt}` : "live call"} |`);
|
|
713
|
+
out.push("");
|
|
714
|
+
|
|
715
|
+
const windows = windowsOf(result).sort((a, b) => b.score - a.score);
|
|
716
|
+
if (windows.length > 0) {
|
|
717
|
+
out.push(`### ${plural(windows.length, "window")}, worst first`);
|
|
718
|
+
out.push("");
|
|
719
|
+
out.push("| Score | Label | Confidence | Words | Humanized | Location |");
|
|
720
|
+
out.push("| ----- | ----- | ---------- | ----- | --------- | -------- |");
|
|
721
|
+
for (const w of windows) {
|
|
722
|
+
out.push(
|
|
723
|
+
`| ${w.score.toFixed(2)} | ${w.label} | ${w.confidence} | ${w.words ?? "?"} | ${w.humanized === null ? "no" : w.humanized.toFixed(2)} | \`${name}:${w.line}\` |`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
out.push("");
|
|
727
|
+
for (const w of windows.slice(0, detail)) {
|
|
728
|
+
out.push(`**${w.score.toFixed(2)} at \`${name}:${w.line}\`**`);
|
|
729
|
+
out.push("");
|
|
730
|
+
out.push(`> ${w.excerpt.slice(0, 220)}…`);
|
|
731
|
+
out.push("");
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
for (const line of CAUTION) out.push(`${line}`);
|
|
735
|
+
console.log(out.join("\n"));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function text(result, meta) {
|
|
739
|
+
const colour = process.stdout.isTTY && !process.env.NO_COLOR && !flags.noColor;
|
|
740
|
+
const paint = (code, s) => (colour ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
741
|
+
const dim = (s) => paint(2, s);
|
|
742
|
+
const bold = (s) => paint(1, s);
|
|
743
|
+
const forScore = (score, s) => paint(score < 0.2 ? 32 : score < 0.6 ? 33 : 31, s);
|
|
744
|
+
const bar = (score) => {
|
|
745
|
+
const filled = Math.max(0, Math.min(10, Math.round(score * 10)));
|
|
746
|
+
return forScore(score, `${"█".repeat(filled)}${dim("░".repeat(10 - filled))}`);
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
console.log(bold(meta.file));
|
|
750
|
+
console.log(
|
|
751
|
+
` ${meta.words} words of prose, an estimated ${plural(meta.units, "billable unit")}.`,
|
|
752
|
+
);
|
|
753
|
+
if (meta.patterns > 0)
|
|
754
|
+
console.log(` ${plural(meta.patterns, "reject pattern")} matched nothing.`);
|
|
755
|
+
if (meta.config) console.log(dim(` config: ${meta.config}`));
|
|
756
|
+
|
|
757
|
+
if (!result) {
|
|
758
|
+
console.log(
|
|
759
|
+
` Dry run, so nothing was sent.${meta.cached ? " A cached result for this text exists." : ""}`,
|
|
760
|
+
);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
console.log("");
|
|
765
|
+
console.log(` ${bold(result.headline ?? "No headline")}`);
|
|
766
|
+
if (result.prediction) console.log(` ${result.prediction}`);
|
|
767
|
+
console.log(
|
|
768
|
+
` ${pct(result.fraction_ai)} AI · ${pct(result.fraction_ai_assisted)} AI-assisted · ${pct(result.fraction_human)} human`,
|
|
769
|
+
);
|
|
770
|
+
console.log(
|
|
771
|
+
dim(
|
|
772
|
+
` segments: ${result.num_ai_segments ?? 0} AI, ${result.num_ai_assisted_segments ?? 0} AI-assisted, ${result.num_human_segments ?? 0} human`,
|
|
773
|
+
),
|
|
774
|
+
);
|
|
775
|
+
if (meta.cached) console.log(dim(` cached result from ${meta.savedAt}, no call was made`));
|
|
776
|
+
|
|
777
|
+
const windows = windowsOf(result);
|
|
778
|
+
if (windows.length > 2) {
|
|
779
|
+
const strip = windows
|
|
780
|
+
.map((w) => forScore(w.score, "▁▁▂▃▄▅▆▇█"[Math.round(w.score * 8)] || "▁"))
|
|
781
|
+
.join("");
|
|
782
|
+
console.log("");
|
|
783
|
+
console.log(` reading order ${strip} ${dim("one cell per window, start to end")}`);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const worst = [...windows].sort((a, b) => b.score - a.score);
|
|
787
|
+
if (worst.length > 0) {
|
|
788
|
+
const shown = worst.slice(0, detail);
|
|
789
|
+
console.log("");
|
|
790
|
+
const heading =
|
|
791
|
+
shown.length === windows.length
|
|
792
|
+
? `${plural(windows.length, "window")}, worst first:`
|
|
793
|
+
: `${plural(windows.length, "window")}, worst ${shown.length} first:`;
|
|
794
|
+
console.log(` ${heading}`);
|
|
795
|
+
console.log("");
|
|
796
|
+
for (const w of shown) {
|
|
797
|
+
const flag = w.humanized === null ? "" : paint(35, ` humanized ${w.humanized.toFixed(2)}`);
|
|
798
|
+
console.log(
|
|
799
|
+
` ${bar(w.score)} ${forScore(w.score, w.score.toFixed(2))} ${w.label}, ${w.confidence} confidence ${dim(`${name}:${w.line}`)}${flag}`,
|
|
800
|
+
);
|
|
801
|
+
console.log(dim(` "${w.excerpt.slice(0, 150)}…"`));
|
|
802
|
+
console.log("");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
for (const line of CAUTION.slice(0, 2)) console.log(dim(` ${line}`));
|
|
807
|
+
}
|