@opacedev/astro-ai-content-checker 0.3.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/CHANGELOG.md +138 -0
- package/LICENSE +9 -0
- package/README.md +236 -0
- package/SBOM.cdx.json +172 -0
- package/SECURITY.md +9 -0
- package/THIRD_PARTY_NOTICES.md +36 -0
- package/dist/build-report-html.d.ts +3 -0
- package/dist/highlight.js +155 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +43 -0
- package/dist/options.d.ts +21 -0
- package/dist/options.js +6 -0
- package/dist/receipt.d.ts +15 -0
- package/dist/receipt.js +100 -0
- package/dist/report.d.ts +55 -0
- package/dist/report.js +12 -0
- package/dist/sections.js +181 -0
- package/dist/share.d.ts +47 -0
- package/dist/share.js +72 -0
- package/dist/shared-EX54KI6H.js +1503 -0
- package/dist/shared-MMXWTX2U.js +43 -0
- package/dist/toolbar.js +13478 -0
- package/node_modules/@opacedev/ai-content-checker-browser/LICENSE +21 -0
- package/node_modules/@opacedev/ai-content-checker-browser/README.md +145 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.d.ts +14 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/dom/visible-text.js +47 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/index.d.ts +3 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/index.js +99 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.d.ts +12 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/client.js +17 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.d.ts +1 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/entry.js +3984 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.d.ts +30 -0
- package/node_modules/@opacedev/ai-content-checker-browser/dist/worker/protocol.js +1 -0
- package/node_modules/@opacedev/ai-content-checker-browser/package.json +60 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/LICENSE +21 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/README.md +93 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.d.ts +58 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/dist/index.js +17 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/package.json +57 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-request.schema.d.ts +44 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/analysis-result.schema.d.ts +122 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/candidate.schema.d.ts +81 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/capabilities.schema.d.ts +24 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/checker-result.schema.d.ts +342 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/common.schema.d.ts +10 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/envelope.schema.d.ts +14 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/gate-result.schema.d.ts +19 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/integrity-receipt.schema.d.ts +122 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/job.schema.d.ts +188 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/method-result.schema.d.ts +39 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/pattern-finding.schema.d.ts +26 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/protected-span.schema.d.ts +35 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/generated/rewrite-request.schema.d.ts +65 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/index.ts +63 -0
- package/node_modules/@opacedev/ai-content-checker-contracts/src/semantic-validation.mjs +32 -0
- package/node_modules/@opacedev/ai-content-checker-core/LICENSE +21 -0
- package/node_modules/@opacedev/ai-content-checker-core/README.md +186 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/bundle.js +4744 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.d.ts +16 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/capabilities.js +7 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.d.ts +17 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/diff/diff.js +76 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.d.ts +27 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/fixes/preview.js +47 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.d.ts +14 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/gates/policy.js +16 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/index.d.ts +15 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/index.js +15 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.d.ts +8 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/inspect.js +84 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.d.ts +12 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-gb-v1.js +68 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.d.ts +73 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2-data.js +499 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.d.ts +76 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v2.js +1067 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.d.ts +165 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3-data.js +614 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.d.ts +19 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v3.js +542 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.d.ts +40 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-corpus.js +40 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.d.ts +138 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4-data.js +200 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.d.ts +71 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/patterns/en-signals-v4.js +388 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.d.ts +17 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/extract.js +75 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.d.ts +3 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/protected/validate.js +17 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.d.ts +55 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/provenance/c2pa-text.js +86 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.d.ts +48 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/receipts/build.js +61 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.d.ts +241 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/report/checker-result.js +419 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.d.ts +34 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/offsets.js +51 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.d.ts +3 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/utf8.js +58 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.d.ts +12 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/source/visible-text.js +30 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.d.ts +22 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/data.js +137 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.d.ts +16 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/unicode/inspect.js +84 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.d.ts +162 -0
- package/node_modules/@opacedev/ai-content-checker-core/dist/verdict/combine.js +501 -0
- package/node_modules/@opacedev/ai-content-checker-core/package.json +62 -0
- package/node_modules/canonicalize/LICENSE +201 -0
- package/node_modules/canonicalize/README.md +65 -0
- package/node_modules/canonicalize/bin/canonicalize.js +17 -0
- package/node_modules/canonicalize/lib/canonicalize.d.ts +2 -0
- package/node_modules/canonicalize/lib/canonicalize.js +74 -0
- package/node_modules/canonicalize/package.json +64 -0
- package/node_modules/entities/LICENSE +11 -0
- package/node_modules/entities/lib/decode.d.ts +211 -0
- package/node_modules/entities/lib/decode.d.ts.map +1 -0
- package/node_modules/entities/lib/decode.js +536 -0
- package/node_modules/entities/lib/decode.js.map +1 -0
- package/node_modules/entities/lib/decode_codepoint.d.ts +19 -0
- package/node_modules/entities/lib/decode_codepoint.d.ts.map +1 -0
- package/node_modules/entities/lib/decode_codepoint.js +76 -0
- package/node_modules/entities/lib/decode_codepoint.js.map +1 -0
- package/node_modules/entities/lib/encode.d.ts +22 -0
- package/node_modules/entities/lib/encode.d.ts.map +1 -0
- package/node_modules/entities/lib/encode.js +77 -0
- package/node_modules/entities/lib/encode.js.map +1 -0
- package/node_modules/entities/lib/escape.d.ts +43 -0
- package/node_modules/entities/lib/escape.d.ts.map +1 -0
- package/node_modules/entities/lib/escape.js +122 -0
- package/node_modules/entities/lib/escape.js.map +1 -0
- package/node_modules/entities/lib/esm/decode.d.ts +211 -0
- package/node_modules/entities/lib/esm/decode.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/decode.js +496 -0
- package/node_modules/entities/lib/esm/decode.js.map +1 -0
- package/node_modules/entities/lib/esm/decode_codepoint.d.ts +19 -0
- package/node_modules/entities/lib/esm/decode_codepoint.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/decode_codepoint.js +71 -0
- package/node_modules/entities/lib/esm/decode_codepoint.js.map +1 -0
- package/node_modules/entities/lib/esm/encode.d.ts +22 -0
- package/node_modules/entities/lib/esm/encode.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/encode.js +69 -0
- package/node_modules/entities/lib/esm/encode.js.map +1 -0
- package/node_modules/entities/lib/esm/escape.d.ts +43 -0
- package/node_modules/entities/lib/esm/escape.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/escape.js +116 -0
- package/node_modules/entities/lib/esm/escape.js.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts +3 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.js +7 -0
- package/node_modules/entities/lib/esm/generated/decode-data-html.js.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts +3 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.js +7 -0
- package/node_modules/entities/lib/esm/generated/decode-data-xml.js.map +1 -0
- package/node_modules/entities/lib/esm/generated/encode-html.d.ts +8 -0
- package/node_modules/entities/lib/esm/generated/encode-html.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/generated/encode-html.js +10 -0
- package/node_modules/entities/lib/esm/generated/encode-html.js.map +1 -0
- package/node_modules/entities/lib/esm/index.d.ts +96 -0
- package/node_modules/entities/lib/esm/index.d.ts.map +1 -0
- package/node_modules/entities/lib/esm/index.js +99 -0
- package/node_modules/entities/lib/esm/index.js.map +1 -0
- package/node_modules/entities/lib/esm/package.json +1 -0
- package/node_modules/entities/lib/generated/decode-data-html.d.ts +3 -0
- package/node_modules/entities/lib/generated/decode-data-html.d.ts.map +1 -0
- package/node_modules/entities/lib/generated/decode-data-html.js +9 -0
- package/node_modules/entities/lib/generated/decode-data-html.js.map +1 -0
- package/node_modules/entities/lib/generated/decode-data-xml.d.ts +3 -0
- package/node_modules/entities/lib/generated/decode-data-xml.d.ts.map +1 -0
- package/node_modules/entities/lib/generated/decode-data-xml.js +9 -0
- package/node_modules/entities/lib/generated/decode-data-xml.js.map +1 -0
- package/node_modules/entities/lib/generated/encode-html.d.ts +8 -0
- package/node_modules/entities/lib/generated/encode-html.d.ts.map +1 -0
- package/node_modules/entities/lib/generated/encode-html.js +12 -0
- package/node_modules/entities/lib/generated/encode-html.js.map +1 -0
- package/node_modules/entities/lib/index.d.ts +96 -0
- package/node_modules/entities/lib/index.d.ts.map +1 -0
- package/node_modules/entities/lib/index.js +126 -0
- package/node_modules/entities/lib/index.js.map +1 -0
- package/node_modules/entities/package.json +90 -0
- package/node_modules/entities/readme.md +122 -0
- package/package.json +91 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { ABSTRACT_CLAIM_RE, ABSTRACT_PUNCH_RE, CONCRETE_ACTION_VERB_RE, CONTRAST_VARIANT_RES, MIC_DROP_CONTRAST_RE, REGISTER_FUNCTION_WORDS, REGISTER_LONG_WORD_LEN, V4_THRESHOLDS, } from "./en-signals-v4-data.js";
|
|
2
|
+
import { REFERENCE_CORPUS } from "./en-signals-v4-corpus.js";
|
|
3
|
+
import { NOT_JUST_CONTRAST } from "./en-signals-v2-data.js";
|
|
4
|
+
import { NEG_PARALLELISM_RE } from "./en-signals-v3-data.js";
|
|
5
|
+
const T = V4_THRESHOLDS;
|
|
6
|
+
function countWords(text) {
|
|
7
|
+
return (text.match(/\S+/g) ?? []).length;
|
|
8
|
+
}
|
|
9
|
+
function countMatches(re, text) {
|
|
10
|
+
const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
11
|
+
let n = 0;
|
|
12
|
+
while (g.exec(text) !== null) {
|
|
13
|
+
n += 1;
|
|
14
|
+
if (g.lastIndex === 0)
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
return n;
|
|
18
|
+
}
|
|
19
|
+
/** Terminator-preserving sentence split inside one paragraph (same split the
|
|
20
|
+
* v3 staccato rule uses, so the two layers agree on sentence boundaries). */
|
|
21
|
+
function paragraphSentences(paraText) {
|
|
22
|
+
return paraText.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
23
|
+
}
|
|
24
|
+
/** Digit / currency / percent token — the concrete-specific marker. */
|
|
25
|
+
const NUMERIC_RE = /[£$€]?\d|\d%/;
|
|
26
|
+
/** Capitalised word after a lowercase word — proper-noun-mid-sentence proxy. */
|
|
27
|
+
const MID_CAP_RE = /(?<=[a-z][,;]?\s)[A-Z][a-z]{2,}/;
|
|
28
|
+
function isAbstractShort(sentence) {
|
|
29
|
+
if (NUMERIC_RE.test(sentence))
|
|
30
|
+
return false;
|
|
31
|
+
if (MID_CAP_RE.test(sentence))
|
|
32
|
+
return false;
|
|
33
|
+
return ABSTRACT_PUNCH_RE.test(sentence);
|
|
34
|
+
}
|
|
35
|
+
// ─── Sentence-length spectral flatness (windowed DFT) ────────────────
|
|
36
|
+
// The empiricist's slFlat signal with the plan's length-artefact correction:
|
|
37
|
+
// flatness is computed on fixed 12-sentence windows only, then averaged, so
|
|
38
|
+
// series length never enters the statistic and short texts are exempt.
|
|
39
|
+
export function spectralFlatness(sentenceWordCounts) {
|
|
40
|
+
const W = T.spectralWindowSentences;
|
|
41
|
+
const windows = Math.floor(sentenceWordCounts.length / W);
|
|
42
|
+
if (windows < T.spectralMinWindows)
|
|
43
|
+
return null;
|
|
44
|
+
const EPS = 1e-9;
|
|
45
|
+
let sum = 0;
|
|
46
|
+
for (let w = 0; w < windows; w += 1) {
|
|
47
|
+
const seg = sentenceWordCounts.slice(w * W, (w + 1) * W);
|
|
48
|
+
const mean = seg.reduce((a, b) => a + b, 0) / W;
|
|
49
|
+
const x = seg.map((v) => v - mean);
|
|
50
|
+
let logSum = 0;
|
|
51
|
+
let linSum = 0;
|
|
52
|
+
const bins = Math.floor(W / 2);
|
|
53
|
+
for (let k = 1; k <= bins; k += 1) {
|
|
54
|
+
let re = 0;
|
|
55
|
+
let im = 0;
|
|
56
|
+
for (let n = 0; n < W; n += 1) {
|
|
57
|
+
const ang = (2 * Math.PI * k * n) / W;
|
|
58
|
+
re += x[n] * Math.cos(ang);
|
|
59
|
+
im -= x[n] * Math.sin(ang);
|
|
60
|
+
}
|
|
61
|
+
const p = re * re + im * im + EPS;
|
|
62
|
+
logSum += Math.log(p);
|
|
63
|
+
linSum += p;
|
|
64
|
+
}
|
|
65
|
+
const flat = Math.exp(logSum / bins) / (linSum / bins);
|
|
66
|
+
sum += flat;
|
|
67
|
+
}
|
|
68
|
+
return sum / windows;
|
|
69
|
+
}
|
|
70
|
+
// ─── Conditional-compression estimator ───────────────────────────────
|
|
71
|
+
// Deterministic LZ77 bit-cost estimate: greedy longest match (min 4, max 258
|
|
72
|
+
// chars) against a hash-chain index over [dictionary + already-scanned
|
|
73
|
+
// target]; literals cost 9 bits, a match costs 13 + log2(distance) bits —
|
|
74
|
+
// a deflate-shaped model, not deflate itself (see module header).
|
|
75
|
+
const HASH_SPAN = 4;
|
|
76
|
+
function hashAt(s, i) {
|
|
77
|
+
return (((s.charCodeAt(i) * 131 + s.charCodeAt(i + 1)) * 131 + s.charCodeAt(i + 2)) * 131 +
|
|
78
|
+
s.charCodeAt(i + 3)) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
const MAX_CHAIN = 32;
|
|
81
|
+
const MAX_MATCH = 258;
|
|
82
|
+
function indexInto(index, s, from, to) {
|
|
83
|
+
for (let i = from; i <= to - HASH_SPAN; i += 1) {
|
|
84
|
+
const h = hashAt(s, i);
|
|
85
|
+
let chain = index.get(h);
|
|
86
|
+
if (!chain) {
|
|
87
|
+
chain = [];
|
|
88
|
+
index.set(h, chain);
|
|
89
|
+
}
|
|
90
|
+
chain.push(i);
|
|
91
|
+
if (chain.length > MAX_CHAIN * 2)
|
|
92
|
+
chain.splice(0, chain.length - MAX_CHAIN);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Cached hash-chain index of the shipped reference corpus (built once). */
|
|
96
|
+
let corpusIndex = null;
|
|
97
|
+
function getCorpusIndex() {
|
|
98
|
+
if (corpusIndex === null) {
|
|
99
|
+
corpusIndex = new Map();
|
|
100
|
+
indexInto(corpusIndex, REFERENCE_CORPUS, 0, REFERENCE_CORPUS.length);
|
|
101
|
+
}
|
|
102
|
+
return corpusIndex;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Estimated LZ77 cost in bits of `target`, optionally conditioned on the
|
|
106
|
+
* shipped reference corpus as a pre-loaded dictionary.
|
|
107
|
+
*/
|
|
108
|
+
export function lzCostBits(target, withCorpusPrior) {
|
|
109
|
+
const dict = withCorpusPrior ? REFERENCE_CORPUS : "";
|
|
110
|
+
const combined = withCorpusPrior ? dict + target : target;
|
|
111
|
+
const base = dict.length;
|
|
112
|
+
// Self-referential chains for the target region, built as we scan.
|
|
113
|
+
const selfIndex = new Map();
|
|
114
|
+
const dictIndex = withCorpusPrior ? getCorpusIndex() : null;
|
|
115
|
+
let bits = 0;
|
|
116
|
+
let i = base;
|
|
117
|
+
while (i < combined.length) {
|
|
118
|
+
let bestLen = 0;
|
|
119
|
+
let bestDist = 0;
|
|
120
|
+
if (i + HASH_SPAN <= combined.length) {
|
|
121
|
+
const h = hashAt(combined, i);
|
|
122
|
+
const tryChain = (chain, offset) => {
|
|
123
|
+
if (!chain)
|
|
124
|
+
return;
|
|
125
|
+
const start = Math.max(0, chain.length - MAX_CHAIN);
|
|
126
|
+
for (let c = chain.length - 1; c >= start; c -= 1) {
|
|
127
|
+
const j = chain[c] + offset;
|
|
128
|
+
if (j >= i)
|
|
129
|
+
continue;
|
|
130
|
+
let len = 0;
|
|
131
|
+
const maxLen = Math.min(MAX_MATCH, combined.length - i);
|
|
132
|
+
while (len < maxLen && combined.charCodeAt(j + len) === combined.charCodeAt(i + len))
|
|
133
|
+
len += 1;
|
|
134
|
+
if (len > bestLen) {
|
|
135
|
+
bestLen = len;
|
|
136
|
+
bestDist = i - j;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
tryChain(selfIndex.get(h), base);
|
|
141
|
+
if (dictIndex)
|
|
142
|
+
tryChain(dictIndex.get(h), 0);
|
|
143
|
+
}
|
|
144
|
+
if (bestLen >= HASH_SPAN) {
|
|
145
|
+
bits += 13 + Math.log2(bestDist);
|
|
146
|
+
// Index the covered positions (sampled every 2 to bound cost).
|
|
147
|
+
for (let k = i; k < i + bestLen && k + HASH_SPAN <= combined.length; k += 2) {
|
|
148
|
+
const h2 = hashAt(combined, k);
|
|
149
|
+
let chain = selfIndex.get(h2);
|
|
150
|
+
if (!chain) {
|
|
151
|
+
chain = [];
|
|
152
|
+
selfIndex.set(h2, chain);
|
|
153
|
+
}
|
|
154
|
+
chain.push(k - base);
|
|
155
|
+
if (chain.length > MAX_CHAIN * 2)
|
|
156
|
+
chain.splice(0, chain.length - MAX_CHAIN);
|
|
157
|
+
}
|
|
158
|
+
i += bestLen;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
bits += 9;
|
|
162
|
+
if (i + HASH_SPAN <= combined.length) {
|
|
163
|
+
const h2 = hashAt(combined, i);
|
|
164
|
+
let chain = selfIndex.get(h2);
|
|
165
|
+
if (!chain) {
|
|
166
|
+
chain = [];
|
|
167
|
+
selfIndex.set(h2, chain);
|
|
168
|
+
}
|
|
169
|
+
chain.push(i - base);
|
|
170
|
+
if (chain.length > MAX_CHAIN * 2)
|
|
171
|
+
chain.splice(0, chain.length - MAX_CHAIN);
|
|
172
|
+
}
|
|
173
|
+
i += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return bits;
|
|
177
|
+
}
|
|
178
|
+
/** Relative compression gain from the human-corpus prior (0 = no gain). */
|
|
179
|
+
export function compressionGain(text) {
|
|
180
|
+
const solo = lzCostBits(text, false);
|
|
181
|
+
if (solo <= 0)
|
|
182
|
+
return 0;
|
|
183
|
+
const cond = lzCostBits(text, true);
|
|
184
|
+
return 1 - cond / solo;
|
|
185
|
+
}
|
|
186
|
+
export function registerProfile(text) {
|
|
187
|
+
const tokens = text.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
|
|
188
|
+
const counts = new Map();
|
|
189
|
+
let long = 0;
|
|
190
|
+
for (const t of tokens) {
|
|
191
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
192
|
+
if (t.length >= REGISTER_LONG_WORD_LEN)
|
|
193
|
+
long += 1;
|
|
194
|
+
}
|
|
195
|
+
const n = Math.max(1, tokens.length);
|
|
196
|
+
return {
|
|
197
|
+
func: REGISTER_FUNCTION_WORDS.map((w) => (counts.get(w) ?? 0) / n),
|
|
198
|
+
longWordShare: long / n,
|
|
199
|
+
tokenCount: tokens.length,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
let refProfile = null;
|
|
203
|
+
export function referenceRegisterProfile() {
|
|
204
|
+
if (refProfile === null)
|
|
205
|
+
refProfile = registerProfile(REFERENCE_CORPUS);
|
|
206
|
+
return refProfile;
|
|
207
|
+
}
|
|
208
|
+
export function registerDistance(text) {
|
|
209
|
+
const p = registerProfile(text);
|
|
210
|
+
const r = referenceRegisterProfile();
|
|
211
|
+
let l1 = 0;
|
|
212
|
+
for (let i = 0; i < p.func.length; i += 1)
|
|
213
|
+
l1 += Math.abs(p.func[i] - r.func[i]);
|
|
214
|
+
return { funcL1: l1, longWordDelta: p.longWordShare - r.longWordShare };
|
|
215
|
+
}
|
|
216
|
+
function collectMetrics(doc) {
|
|
217
|
+
const { text, wordCount, paragraphs } = doc;
|
|
218
|
+
// Sentence series for the spectral estimator: terminator-split across the
|
|
219
|
+
// whole document (paragraph splits preserved via per-paragraph splitting).
|
|
220
|
+
const allSentences = [];
|
|
221
|
+
for (const p of paragraphs)
|
|
222
|
+
allSentences.push(...paragraphSentences(p.text));
|
|
223
|
+
const counts = allSentences.map(countWords).filter((c) => c > 0);
|
|
224
|
+
const flat = spectralFlatness(counts);
|
|
225
|
+
const gain = wordCount >= T.compressionMinWords && wordCount <= T.compressionMaxWords
|
|
226
|
+
? compressionGain(text)
|
|
227
|
+
: null;
|
|
228
|
+
const reg = registerDistance(text);
|
|
229
|
+
// Punchline fragments.
|
|
230
|
+
let punchCount = 0;
|
|
231
|
+
let punchFinal = 0;
|
|
232
|
+
for (const p of paragraphs) {
|
|
233
|
+
const sents = paragraphSentences(p.text);
|
|
234
|
+
for (let i = 0; i < sents.length; i += 1) {
|
|
235
|
+
const s = sents[i];
|
|
236
|
+
const wc = countWords(s);
|
|
237
|
+
if (wc > 0 && wc <= 8 && /[.!?]$/.test(s) && isAbstractShort(s)) {
|
|
238
|
+
punchCount += 1;
|
|
239
|
+
if (i === sents.length - 1)
|
|
240
|
+
punchFinal += 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const punchRate = allSentences.length > 0 ? punchCount / allSentences.length : 0;
|
|
245
|
+
// Mic-drop paragraphs.
|
|
246
|
+
let micDrops = 0;
|
|
247
|
+
for (const p of paragraphs) {
|
|
248
|
+
const sents = paragraphSentences(p.text);
|
|
249
|
+
if (sents.length < 4)
|
|
250
|
+
continue;
|
|
251
|
+
const closer = sents[sents.length - 1];
|
|
252
|
+
const closerWc = countWords(closer);
|
|
253
|
+
const setupWcs = sents.slice(0, -1).map(countWords).filter((c) => c >= 12);
|
|
254
|
+
if (setupWcs.length < 3)
|
|
255
|
+
continue;
|
|
256
|
+
const setupMean = setupWcs.reduce((a, b) => a + b, 0) / setupWcs.length;
|
|
257
|
+
if (closerWc > 0 && closerWc <= 8 && closerWc <= 0.45 * setupMean &&
|
|
258
|
+
!NUMERIC_RE.test(closer) && !MID_CAP_RE.test(closer) &&
|
|
259
|
+
MIC_DROP_CONTRAST_RE.test(closer)) {
|
|
260
|
+
micDrops += 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Contrast constructions (rate layer over the existing detections).
|
|
264
|
+
let contrastCount = 0;
|
|
265
|
+
for (const re of NOT_JUST_CONTRAST)
|
|
266
|
+
contrastCount += countMatches(re, text);
|
|
267
|
+
contrastCount += countMatches(NEG_PARALLELISM_RE, text);
|
|
268
|
+
for (const re of CONTRAST_VARIANT_RES)
|
|
269
|
+
contrastCount += countMatches(re, text);
|
|
270
|
+
const contrastPer1000 = wordCount > 0 ? contrastCount / (wordCount / 1000) : 0;
|
|
271
|
+
// Rhetorical vs procedural. Heuristic (documented): CONCRETE = the sentence
|
|
272
|
+
// contains a number/currency/percent token, a mid-sentence capitalised word
|
|
273
|
+
// (proper-noun proxy) or a specific action verb; ABSTRACT = not concrete
|
|
274
|
+
// AND (linking-verb + intangible pay-off word, or a ≤8-word declarative).
|
|
275
|
+
let abstract = 0;
|
|
276
|
+
let concrete = 0;
|
|
277
|
+
for (const s of allSentences) {
|
|
278
|
+
const isConcrete = NUMERIC_RE.test(s) || MID_CAP_RE.test(s) || CONCRETE_ACTION_VERB_RE.test(s);
|
|
279
|
+
if (isConcrete) {
|
|
280
|
+
concrete += 1;
|
|
281
|
+
}
|
|
282
|
+
else if (ABSTRACT_CLAIM_RE.test(s) || countWords(s) <= 8) {
|
|
283
|
+
abstract += 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const share = allSentences.length > 0 ? abstract / allSentences.length : 0;
|
|
287
|
+
return {
|
|
288
|
+
wordCount,
|
|
289
|
+
sentenceCount: allSentences.length,
|
|
290
|
+
spectralFlatness: flat,
|
|
291
|
+
compressionGain: gain,
|
|
292
|
+
registerFuncL1: reg.funcL1,
|
|
293
|
+
registerLongWordDelta: reg.longWordDelta,
|
|
294
|
+
punchlineCount: punchCount,
|
|
295
|
+
punchlineRate: punchRate,
|
|
296
|
+
punchlineParagraphFinal: punchFinal,
|
|
297
|
+
micDropParagraphs: micDrops,
|
|
298
|
+
contrastCount,
|
|
299
|
+
contrastPer1000,
|
|
300
|
+
ratioSentences: allSentences.length,
|
|
301
|
+
ratioAbstract: abstract,
|
|
302
|
+
ratioConcrete: concrete,
|
|
303
|
+
ratioAbstractShare: share,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Standalone metric computation for the calibration script
|
|
308
|
+
* (tests/battery/calibrate.mjs). Mirrors exactly what collectV4Issues
|
|
309
|
+
* measures; not part of the public package API.
|
|
310
|
+
*/
|
|
311
|
+
export function computeV4Metrics(text) {
|
|
312
|
+
const paragraphs = [];
|
|
313
|
+
const re = /\n\s*\n/g;
|
|
314
|
+
let last = 0;
|
|
315
|
+
let m;
|
|
316
|
+
while ((m = re.exec(text)) !== null) {
|
|
317
|
+
paragraphs.push({ text: text.slice(last, m.index), start: last });
|
|
318
|
+
last = m.index + m[0].length;
|
|
319
|
+
}
|
|
320
|
+
paragraphs.push({ text: text.slice(last), start: last });
|
|
321
|
+
return collectMetrics({
|
|
322
|
+
text,
|
|
323
|
+
wordCount: countWords(text),
|
|
324
|
+
sentences: [],
|
|
325
|
+
paragraphs: paragraphs.filter((p) => p.text.trim().length > 0),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// ─── Rule evaluation ─────────────────────────────────────────────────
|
|
329
|
+
const round = (v, dp = 3) => Math.round(v * 10 ** dp) / 10 ** dp;
|
|
330
|
+
export function collectV4Issues(ctx) {
|
|
331
|
+
const m = collectMetrics(ctx);
|
|
332
|
+
const { pushEx } = ctx;
|
|
333
|
+
if (m.spectralFlatness !== null && m.spectralFlatness < T.spectralFlatnessMax) {
|
|
334
|
+
pushEx("sentence-length-spectral-flatness", `window-averaged spectral flatness ${round(m.spectralFlatness)} (threshold ${T.spectralFlatnessMax})`, null, null, {
|
|
335
|
+
extra: {
|
|
336
|
+
spectral_flatness: round(m.spectralFlatness),
|
|
337
|
+
window_sentences: T.spectralWindowSentences,
|
|
338
|
+
sentence_count: m.sentenceCount,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (m.compressionGain !== null && m.compressionGain < T.compressionGainMin) {
|
|
343
|
+
pushEx("conditional-compression", `human-prior compression gain ${round(m.compressionGain)} (threshold ${T.compressionGainMin})`, null, null, {
|
|
344
|
+
extra: { compression_gain: round(m.compressionGain), reference_corpus: "en-signals-v4-corpus 2026.08.5 (public-domain, pre-1929)" },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (m.wordCount >= T.registerMinWords &&
|
|
348
|
+
m.registerFuncL1 > T.registerFuncL1Min &&
|
|
349
|
+
m.registerLongWordDelta > T.registerLongWordDeltaMin) {
|
|
350
|
+
pushEx("lexical-register-distance", `function-word L1 ${round(m.registerFuncL1)} + long-word share +${round(m.registerLongWordDelta)} vs human reference`, null, null, {
|
|
351
|
+
extra: {
|
|
352
|
+
function_word_l1: round(m.registerFuncL1),
|
|
353
|
+
long_word_share_delta: round(m.registerLongWordDelta),
|
|
354
|
+
genre_caveat: "specialised genres legitimately measure as distant; corroboration only",
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (m.punchlineCount >= T.punchlineMinCount &&
|
|
359
|
+
m.punchlineRate >= T.punchlineMinRate &&
|
|
360
|
+
m.punchlineParagraphFinal >= T.punchlineMinParagraphFinal) {
|
|
361
|
+
pushEx("punchline-fragment-density", `${m.punchlineCount} abstract punchline fragments in ${m.sentenceCount} sentences (${m.punchlineParagraphFinal} paragraph-final)`, null, null, {
|
|
362
|
+
count: m.punchlineCount,
|
|
363
|
+
extra: { punchline_rate: round(m.punchlineRate), paragraph_final: m.punchlineParagraphFinal },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
if (m.micDropParagraphs >= T.micDropMinParagraphs) {
|
|
367
|
+
pushEx("mic-drop-paragraph", `${m.micDropParagraphs} paragraphs end in a short abstract contrast closer`, null, null, { count: m.micDropParagraphs });
|
|
368
|
+
}
|
|
369
|
+
if (m.contrastCount >= T.contrastMinCount && m.contrastPer1000 >= T.contrastMinPer1000) {
|
|
370
|
+
pushEx("contrast-density", `${m.contrastCount} contrast constructions (${round(m.contrastPer1000, 1)}/1000 words)`, null, null, {
|
|
371
|
+
count: m.contrastCount,
|
|
372
|
+
extra: { rate_per_1000_words: round(m.contrastPer1000, 1) },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (m.ratioSentences >= T.ratioMinSentences &&
|
|
376
|
+
m.ratioAbstract >= T.ratioMinAbstract &&
|
|
377
|
+
m.ratioAbstractShare >= T.ratioMinShare &&
|
|
378
|
+
m.ratioConcrete <= T.ratioMaxConcrete) {
|
|
379
|
+
pushEx("rhetorical-procedural-ratio", `${m.ratioAbstract} abstract-claim vs ${m.ratioConcrete} concrete-action sentences of ${m.ratioSentences}`, null, null, {
|
|
380
|
+
count: m.ratioAbstract,
|
|
381
|
+
extra: {
|
|
382
|
+
abstract_sentences: m.ratioAbstract,
|
|
383
|
+
concrete_sentences: m.ratioConcrete,
|
|
384
|
+
abstract_share: round(m.ratioAbstractShare),
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProtectedSpan } from "@opacedev/ai-content-checker-contracts";
|
|
2
|
+
type Source = {
|
|
3
|
+
content: string;
|
|
4
|
+
content_hash?: string;
|
|
5
|
+
};
|
|
6
|
+
type Policy = {
|
|
7
|
+
configured_terms?: Array<{
|
|
8
|
+
text: string;
|
|
9
|
+
kind?: "name" | "organisation";
|
|
10
|
+
}>;
|
|
11
|
+
user_spans?: Array<{
|
|
12
|
+
start_utf16: number;
|
|
13
|
+
end_utf16: number;
|
|
14
|
+
}>;
|
|
15
|
+
};
|
|
16
|
+
export declare function extractProtectedSpans(source: Source, policy?: Policy): ProtectedSpan[];
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { rangeFromUtf16 } from "../source/offsets.js";
|
|
2
|
+
import { prefixedSha256 } from "../source/utf8.js";
|
|
3
|
+
const RULES = [
|
|
4
|
+
["code", /```[\s\S]*?```|`[^`\n]+`/g], ["url", /https?:\/\/[^\s<>)\]]+/g], ["email", /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi],
|
|
5
|
+
["citation", /\[[0-9]+\]|\((?:[A-Z][A-Za-z-]+(?:\s+(?:and|&)\s+[A-Z][A-Za-z-]+)*(?:\s+et al\.)?),?\s+\d{4}[a-z]?\)|\b[A-Z][A-Za-z-]+(?:\s+(?:and|&)\s+[A-Z][A-Za-z-]+)*\s+et al\.,?\s*\(?\d{4}[a-z]?\)?/g], ["quote", /[“"][^”"\n]+[”"]/g],
|
|
6
|
+
["currency", /(?:£|\$|€)\s?\d[\d,]*(?:\.\d+)?/g], ["date", /\b(?:\d{1,2}\s+[A-Z][a-z]+\s+\d{4}|\d{4}-\d{2}-\d{2})\b/g],
|
|
7
|
+
["time", /\b\d{1,2}:\d{2}(?:\s?[ap]m)?\b/gi], ["unit", /\b\d+(?:\.\d+)?\s?(?:kg|g|km|m|cm|mm|GB|MB|%|°C)\b/g], ["number", /\b\d[\d,]*(?:\.\d+)?%?\b/g]
|
|
8
|
+
];
|
|
9
|
+
// Deterministic, precision-first entity rules. It is acceptable to miss a name; it is not acceptable to flag ordinary sentence-start words.
|
|
10
|
+
const HONORIFIC_NAME = /\b(?:Dr|Mr|Mrs|Ms|Prof|Sir|Dame)\.?\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}/g;
|
|
11
|
+
const NAME_RUN = /\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2}\b/g;
|
|
12
|
+
const ORG_SUFFIXED = /\b(?:[A-Z][A-Za-z&.'-]*\s+){1,5}(?:Ltd\.?|Limited|LLC|Inc\.?|plc|GmbH|&\s?Co\.?|Agency|Council|University)(?!\w)/g;
|
|
13
|
+
const ORG_ACRONYM = /\b[A-Z]{2,6}(?:\.[A-Z]{2,6})*\b/g;
|
|
14
|
+
const NAME_STOPLIST = new Set("The A An This That These Those It He She They We You I In On At For To From With By Of And But Or Nor If As Is Are Was Were Be Been Not No Yes See Run New Our Your Their His Her Its My Do Does Did Will Would Can Could Should May Might Must Have Has Had So Then There Here What Which Who Whose When Where Why How All Any Each Per Both More Most Some Such Other Also Just Only Now Today Yesterday Tomorrow Please Note".split(" "));
|
|
15
|
+
const ACRONYM_STOPLIST = new Set(["AM", "PM", "GMT", "UTC", "BST", "CET", "CEST", "EST", "EDT", "PST", "PDT", "EUR", "USD", "GBP", "JPY", "CHF", "AI", "IT", "TV", "OK", "PS", "NB", "ID", "IP", "FAQ", "API", "URL", "URI", "HTML", "CSS", "SQL", "PDF", "HTTP", "HTTPS", "VAT", "ASAP", "DIY", "CEO", "CTO", "CFO", "COO", "UK", "EU", "US", "USA", "RSVP", "ETA", "FYI", "QA", "DNA", "GPS", "SMS"]);
|
|
16
|
+
function atSentenceStart(content, index) { const before = content.slice(0, index).replace(/[\s"“”'‘’(\[]+$/, ""); return before === "" || /[.!?:;…]$/.test(before); }
|
|
17
|
+
function extractEntitySpans(content) {
|
|
18
|
+
const found = [];
|
|
19
|
+
const overlaps = (start, end) => found.some(x => start < x.start + x.text.length && x.start < end);
|
|
20
|
+
for (const m of content.matchAll(ORG_SUFFIXED)) {
|
|
21
|
+
let text = m[0], start = m.index;
|
|
22
|
+
for (;;) {
|
|
23
|
+
const lead = /^([A-Z][A-Za-z&.'-]*)\s+/.exec(text);
|
|
24
|
+
if (lead && NAME_STOPLIST.has(lead[1])) {
|
|
25
|
+
start += lead[0].length;
|
|
26
|
+
text = text.slice(lead[0].length);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
if (/\s/.test(text))
|
|
32
|
+
found.push({ kind: "organisation", text, start });
|
|
33
|
+
}
|
|
34
|
+
for (const m of content.matchAll(ORG_ACRONYM)) {
|
|
35
|
+
if (!ACRONYM_STOPLIST.has(m[0]) && !overlaps(m.index, m.index + m[0].length))
|
|
36
|
+
found.push({ kind: "organisation", text: m[0], start: m.index });
|
|
37
|
+
}
|
|
38
|
+
for (const m of content.matchAll(HONORIFIC_NAME)) {
|
|
39
|
+
if (!overlaps(m.index, m.index + m[0].length))
|
|
40
|
+
found.push({ kind: "name", text: m[0], start: m.index });
|
|
41
|
+
}
|
|
42
|
+
for (const m of content.matchAll(NAME_RUN)) {
|
|
43
|
+
if (atSentenceStart(content, m.index) || overlaps(m.index, m.index + m[0].length))
|
|
44
|
+
continue;
|
|
45
|
+
if (m[0].split(/\s+/).some(token => NAME_STOPLIST.has(token)))
|
|
46
|
+
continue;
|
|
47
|
+
found.push({ kind: "name", text: m[0], start: m.index });
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
export function extractProtectedSpans(source, policy = {}) {
|
|
52
|
+
const hash = source.content_hash ?? prefixedSha256(source.content);
|
|
53
|
+
const spans = [];
|
|
54
|
+
const add = (text, start, kind, origin, protection = "exact") => { const r = rangeFromUtf16(source.content, start, start + text.length); spans.push({ id: `ps_${kind}_${start}_${prefixedSha256(text).slice(7, 15)}`, kind, text, ...r, normalised_value: text, policy: protection, source: origin, confidence: null, content_hash: hash }); };
|
|
55
|
+
for (const [kind, regex] of RULES) {
|
|
56
|
+
regex.lastIndex = 0;
|
|
57
|
+
for (const m of source.content.matchAll(regex))
|
|
58
|
+
add(m[0], m.index, kind, "deterministic", kind === "date" || kind === "number" ? "equivalent_format" : "exact");
|
|
59
|
+
}
|
|
60
|
+
for (const entity of extractEntitySpans(source.content))
|
|
61
|
+
add(entity.text, entity.start, entity.kind, "deterministic");
|
|
62
|
+
for (const term of policy.configured_terms ?? []) {
|
|
63
|
+
let at = 0;
|
|
64
|
+
while ((at = source.content.indexOf(term.text, at)) >= 0) {
|
|
65
|
+
add(term.text, at, term.kind ?? "name", "user");
|
|
66
|
+
at += term.text.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const selected of policy.user_spans ?? [])
|
|
70
|
+
add(source.content.slice(selected.start_utf16, selected.end_utf16), selected.start_utf16, "user_selected", "user");
|
|
71
|
+
const unique = new Map();
|
|
72
|
+
for (const span of spans)
|
|
73
|
+
unique.set(`${span.start_utf16}:${span.end_utf16}:${span.kind}:${span.policy}`, span);
|
|
74
|
+
return [...unique.values()].sort((a, b) => a.start_utf16 - b.start_utf16 || (b.end_utf16 - b.start_utf16) - (a.end_utf16 - a.start_utf16) || a.id.localeCompare(b.id));
|
|
75
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { GateResult, ProtectedSpan } from "@opacedev/ai-content-checker-contracts";
|
|
2
|
+
export declare function validateProtected(source: string, candidate: string, spans: ProtectedSpan[]): GateResult;
|
|
3
|
+
export declare function validateAdditions(source: string, candidate: string): GateResult;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function validateProtected(source, candidate, spans) {
|
|
2
|
+
const failures = [];
|
|
3
|
+
for (const span of spans) {
|
|
4
|
+
const count = (candidate.split(span.text).length - 1);
|
|
5
|
+
if (count === 0)
|
|
6
|
+
failures.push({ protected_span_id: span.id, expected_hash: span.content_hash, observed: "missing" });
|
|
7
|
+
else if (count > source.split(span.text).length - 1)
|
|
8
|
+
failures.push({ protected_span_id: span.id, expected_hash: span.content_hash, observed: "duplicated" });
|
|
9
|
+
}
|
|
10
|
+
return { id: "protected_spans.exact", version: "1.0.0", status: failures.length ? "fail" : "pass", hard: true, summary: failures.length ? `${failures.length} protected item(s) changed` : "Protected items remain present", failures, limitations: [] };
|
|
11
|
+
}
|
|
12
|
+
export function validateAdditions(source, candidate) {
|
|
13
|
+
const pattern = /https?:\/\/[^\s<>)\]]+|```[\s\S]*?```|`[^`\n]+`|\[[0-9]+\]|[“"][^”"\n]+[”"]/g;
|
|
14
|
+
const original = new Set(source.match(pattern) ?? []);
|
|
15
|
+
const added = (candidate.match(pattern) ?? []).filter(x => !original.has(x));
|
|
16
|
+
return { id: "unsupported_additions", version: "1.0.0", status: added.length ? "fail" : "pass", hard: true, summary: added.length ? `${added.length} unsupported reference(s) added` : "No unsupported URLs, citations, quotations or code added", failures: added.map(observed => ({ observed })), limitations: [] };
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2PA text content credentials — detection only, never removal.
|
|
3
|
+
*
|
|
4
|
+
* C2PA Specification 2.3 (December 2025) added §A.8 "Embedding Manifests into
|
|
5
|
+
* Unstructured Text": a C2PA Manifest Store carried inside the text itself as
|
|
6
|
+
* non-rendering Unicode variation selectors, so that Content Credentials
|
|
7
|
+
* survive a copy and paste. The spec is explicit about the carrier set:
|
|
8
|
+
*
|
|
9
|
+
* "Unicode variation selectors (U+FE00-U+FE0F and U+E0100-U+E01EF) are used
|
|
10
|
+
* because they are specifically designed to be visually non-rendering while
|
|
11
|
+
* remaining part of the valid Unicode character set."
|
|
12
|
+
* — C2PA Specification 2.4, §A.8.2
|
|
13
|
+
*
|
|
14
|
+
* Those are the same code points this tool's hidden-character check flags, and
|
|
15
|
+
* U+FE00-U+FE0F carry fix:"remove", so the safe-fix path would strip every byte
|
|
16
|
+
* of the manifest whose value is 0-15. That silently breaks the magic number
|
|
17
|
+
* and the credential stops being detectable at all — not reported as corrupt,
|
|
18
|
+
* simply gone. This module exists so the checker can see a credential before it
|
|
19
|
+
* is offered a fix that would destroy it.
|
|
20
|
+
*
|
|
21
|
+
* Nothing here weakens the hidden-character detection. Every carrier is still
|
|
22
|
+
* found, still counted and still shown. The credential's characters are made
|
|
23
|
+
* ineligible for the automatic fix, and removing them becomes a separate,
|
|
24
|
+
* deliberate choice.
|
|
25
|
+
*/
|
|
26
|
+
/** §A.8.3.2 — variationSelectorToByte. Returns null for anything else. */
|
|
27
|
+
export declare const variationSelectorToByte: (codePoint: number) => number | null;
|
|
28
|
+
/** True for any code point §A.8 can use as a manifest carrier, sentinel included. */
|
|
29
|
+
export declare const isCredentialCarrier: (codePoint: number) => boolean;
|
|
30
|
+
export interface C2paTextCredential {
|
|
31
|
+
/** UTF-16 offsets covering the U+FEFF sentinel and the whole wrapper. */
|
|
32
|
+
start_utf16: number;
|
|
33
|
+
end_utf16: number;
|
|
34
|
+
/** §A.8.2.3 version field. */
|
|
35
|
+
version: number;
|
|
36
|
+
/** §A.8.2.3 manifestLength, in bytes. */
|
|
37
|
+
manifest_length: number;
|
|
38
|
+
/**
|
|
39
|
+
* "ok" when the declared manifest is complete;
|
|
40
|
+
* "truncated" maps to the spec's manifest.text.corruptedWrapper condition.
|
|
41
|
+
*/
|
|
42
|
+
status: "ok" | "truncated";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* §A.8.4.2 detection algorithm: scan for U+FEFF, read the contiguous run of
|
|
46
|
+
* variation selectors that follows, decode the first eight bytes and compare
|
|
47
|
+
* them with the magic number.
|
|
48
|
+
*/
|
|
49
|
+
export declare function detectC2paTextCredentials(text: string): C2paTextCredential[];
|
|
50
|
+
/** True when the span touches any detected credential. */
|
|
51
|
+
export declare const withinCredential: (credentials: readonly C2paTextCredential[], span: {
|
|
52
|
+
start_utf16: number;
|
|
53
|
+
end_utf16: number;
|
|
54
|
+
}) => boolean;
|
|
55
|
+
export declare const credentialNotice: (credentials: readonly C2paTextCredential[]) => string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2PA text content credentials — detection only, never removal.
|
|
3
|
+
*
|
|
4
|
+
* C2PA Specification 2.3 (December 2025) added §A.8 "Embedding Manifests into
|
|
5
|
+
* Unstructured Text": a C2PA Manifest Store carried inside the text itself as
|
|
6
|
+
* non-rendering Unicode variation selectors, so that Content Credentials
|
|
7
|
+
* survive a copy and paste. The spec is explicit about the carrier set:
|
|
8
|
+
*
|
|
9
|
+
* "Unicode variation selectors (U+FE00-U+FE0F and U+E0100-U+E01EF) are used
|
|
10
|
+
* because they are specifically designed to be visually non-rendering while
|
|
11
|
+
* remaining part of the valid Unicode character set."
|
|
12
|
+
* — C2PA Specification 2.4, §A.8.2
|
|
13
|
+
*
|
|
14
|
+
* Those are the same code points this tool's hidden-character check flags, and
|
|
15
|
+
* U+FE00-U+FE0F carry fix:"remove", so the safe-fix path would strip every byte
|
|
16
|
+
* of the manifest whose value is 0-15. That silently breaks the magic number
|
|
17
|
+
* and the credential stops being detectable at all — not reported as corrupt,
|
|
18
|
+
* simply gone. This module exists so the checker can see a credential before it
|
|
19
|
+
* is offered a fix that would destroy it.
|
|
20
|
+
*
|
|
21
|
+
* Nothing here weakens the hidden-character detection. Every carrier is still
|
|
22
|
+
* found, still counted and still shown. The credential's characters are made
|
|
23
|
+
* ineligible for the automatic fix, and removing them becomes a separate,
|
|
24
|
+
* deliberate choice.
|
|
25
|
+
*/
|
|
26
|
+
/** §A.8.2.2 — magic = 0x4332504154585400, "C2PATXT\0". */
|
|
27
|
+
const MAGIC = [0x43, 0x32, 0x50, 0x41, 0x54, 0x58, 0x54, 0x00];
|
|
28
|
+
/** §A.8.2.2 — 8-byte magic, 1-byte version, 4-byte big-endian manifestLength. */
|
|
29
|
+
const HEADER_BYTES = 13;
|
|
30
|
+
/** §A.8.4.1 — the wrapper is prefixed with a single U+FEFF. */
|
|
31
|
+
const SENTINEL = 0xfeff;
|
|
32
|
+
/** §A.8.3.2 — variationSelectorToByte. Returns null for anything else. */
|
|
33
|
+
export const variationSelectorToByte = (codePoint) => codePoint >= 0xfe00 && codePoint <= 0xfe0f
|
|
34
|
+
? codePoint - 0xfe00
|
|
35
|
+
: codePoint >= 0xe0100 && codePoint <= 0xe01ef
|
|
36
|
+
? codePoint - 0xe0100 + 16
|
|
37
|
+
: null;
|
|
38
|
+
/** True for any code point §A.8 can use as a manifest carrier, sentinel included. */
|
|
39
|
+
export const isCredentialCarrier = (codePoint) => codePoint === SENTINEL || variationSelectorToByte(codePoint) !== null;
|
|
40
|
+
/**
|
|
41
|
+
* §A.8.4.2 detection algorithm: scan for U+FEFF, read the contiguous run of
|
|
42
|
+
* variation selectors that follows, decode the first eight bytes and compare
|
|
43
|
+
* them with the magic number.
|
|
44
|
+
*/
|
|
45
|
+
export function detectC2paTextCredentials(text) {
|
|
46
|
+
const found = [];
|
|
47
|
+
for (let i = 0; i < text.length;) {
|
|
48
|
+
const codePoint = text.codePointAt(i);
|
|
49
|
+
const width = codePoint > 0xffff ? 2 : 1;
|
|
50
|
+
if (codePoint !== SENTINEL) {
|
|
51
|
+
i += width;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const bytes = [];
|
|
55
|
+
let cursor = i + width;
|
|
56
|
+
while (cursor < text.length) {
|
|
57
|
+
const next = text.codePointAt(cursor);
|
|
58
|
+
const byte = variationSelectorToByte(next);
|
|
59
|
+
if (byte === null)
|
|
60
|
+
break;
|
|
61
|
+
bytes.push(byte);
|
|
62
|
+
cursor += next > 0xffff ? 2 : 1;
|
|
63
|
+
}
|
|
64
|
+
if (bytes.length < HEADER_BYTES || !MAGIC.every((value, index) => bytes[index] === value)) {
|
|
65
|
+
i += width;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const manifestLength = ((bytes[9] << 24) | (bytes[10] << 16) | (bytes[11] << 8) | bytes[12]) >>> 0;
|
|
69
|
+
found.push({
|
|
70
|
+
start_utf16: i,
|
|
71
|
+
end_utf16: cursor,
|
|
72
|
+
version: bytes[8],
|
|
73
|
+
manifest_length: manifestLength,
|
|
74
|
+
status: bytes.length >= HEADER_BYTES + manifestLength ? "ok" : "truncated",
|
|
75
|
+
});
|
|
76
|
+
i = cursor;
|
|
77
|
+
}
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
/** True when the span touches any detected credential. */
|
|
81
|
+
export const withinCredential = (credentials, span) => credentials.some((credential) => span.start_utf16 < credential.end_utf16 && span.end_utf16 > credential.start_utf16);
|
|
82
|
+
export const credentialNotice = (credentials) => {
|
|
83
|
+
const one = credentials.length === 1;
|
|
84
|
+
const truncated = credentials.some((credential) => credential.status === "truncated");
|
|
85
|
+
return `This draft carries ${one ? "a C2PA content credential" : `${credentials.length} C2PA content credentials`} embedded in the text itself (C2PA 2.3 §A.8, Unicode variation selectors)${truncated ? ", at least one of which is already incomplete" : ""}. The hidden characters listed below include ${one ? "its" : "their"} bytes. Removing them destroys the credential permanently, and it cannot be rebuilt from the visible text, so they are excluded from the automatic fix.`;
|
|
86
|
+
};
|