@lacspace/screen 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,51 @@
1
+ Lacspace Free Licence
2
+ Version 1.0, August 2026
3
+
4
+ Copyright (c) 2026 Lacspace
5
+
6
+ PREAMBLE
7
+
8
+ This software is published by Lacspace under the Lacspace Free Licence — a free,
9
+ permissive licence that lets you use this software for any purpose, including in
10
+ commercial products and services, at no cost. It grants the same freedoms as
11
+ common permissive open-source licences; the only condition is that this notice
12
+ travels with the software. The canonical, always-current text of this licence is
13
+ maintained at https://lacspace.com/licenses/lacspace-free-1.0
14
+
15
+ GRANT OF RIGHTS
16
+
17
+ Permission is hereby granted, free of charge, to any person or organisation
18
+ obtaining a copy of this software and its associated documentation and data files
19
+ (the "Software"), to deal in the Software without restriction, including without
20
+ limitation the rights to use, copy, modify, merge, publish, distribute,
21
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the
22
+ Software is furnished to do so, subject to the conditions below. These rights are
23
+ granted for any purpose, personal or commercial, and are perpetual, worldwide,
24
+ non-exclusive, and royalty-free.
25
+
26
+ CONDITIONS
27
+
28
+ The above copyright notice, this permission notice, and the name of this licence
29
+ ("Lacspace Free Licence") shall be included in all copies or substantial portions
30
+ of the Software.
31
+
32
+ TRADEMARKS
33
+
34
+ This licence does not grant permission to use the trade names, trademarks, service
35
+ marks, logos, or product names of Lacspace, except as required to reproduce the
36
+ notice above or to describe the origin of the Software in a truthful manner.
37
+
38
+ DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
42
+ FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
43
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
44
+ AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
45
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
46
+
47
+ ---
48
+
49
+ The Lacspace Free Licence is a source-available, permissive licence and is not (as
50
+ of this version) an OSI-approved licence. In substance it grants the same freedoms
51
+ as the MIT Licence. Learn more at https://lacspace.com/licenses
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @lacspace/screen
2
+
3
+ **Don't pay a model to read what a word list can judge.** A cheap, deterministic lexical screener that scores text against your own weighted term lexicons — multi-language, with negation and proximity windows and an optional named-entity gazetteer — and returns a **clear / review / block** decision. Send only the ambiguous middle to an expensive LLM; let obviously-clean and obviously-flagged text skip it entirely.
4
+
5
+ Zero dependencies, isomorphic, and every decision comes with an auditable "held because" trail.
6
+
7
+ ```bash
8
+ npm i @lacspace/screen
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```ts
14
+ import { createScreen } from "@lacspace/screen";
15
+
16
+ const screen = createScreen({
17
+ dimensions: {
18
+ death: { terms: ["died", "killed", "मृत्यु"] },
19
+ court: { terms: ["court", "verdict", "अदालत"] },
20
+ minor: { terms: ["child", "minor", "बालबालिका"], forceReview: true },
21
+ election: { terms: ["election", "vote", "निर्वाचन"], forceReview: true },
22
+ hate: { terms: ["<your slur list>"], weight: 3, forceBlock: true },
23
+ },
24
+ negations: ["no", "not", "denied", "-न", "-नन्"], // "-x" = suffix, for Nepali verb negation
25
+ gazetteer: ["Sher Bahadur Deuba", "काठमाडौं"],
26
+ thresholds: { clear: 0, review: 1, block: 5 },
27
+ });
28
+
29
+ const r = screen(storyText);
30
+ r.decision; // "clear" | "review" | "block"
31
+ r.scores; // { death: 1, court: 2, ... }
32
+ r.hits; // [{ dim, term, pos, weight, negated }]
33
+ r.reasons; // ["total score 3 ≥ review 1", "minor: force-review hit"]
34
+
35
+ if (r.decision === "review") await askTheModel(storyText); // only the uncertain ones
36
+ ```
37
+
38
+ ## Why
39
+
40
+ Running an LLM sensitivity/moderation pass on every candidate item is where token budgets die — most items are plainly fine or plainly blocked. `screen` triages first: in real newsroom use only the ambiguous fraction needs the model, cutting those calls by the large majority. Because it's deterministic and returns its reasons, the result is safe to log and defend.
41
+
42
+ ## Features
43
+
44
+ - **Weighted dimensions** — each dimension has its own term list and `weight`; the total score drives the decision.
45
+ - **`forceReview` / `forceBlock`** — a single hit on a sensitive dimension (minors, elections) can force at least `review`, or `block` outright, regardless of score.
46
+ - **Negation** — a nearby negator marks a hit `negated` and drops it from the score. Whole words, `prefix*` patterns, and `-suffix` patterns (for languages where negation is a verb suffix, e.g. Nepali `-न`, `-नन्`).
47
+ - **Proximity window** — negation only counts within `contextWindow` tokens of the hit.
48
+ - **Gazetteer** — a named-person/place hit adds a boost, so "X died" scores higher than "someone died".
49
+ - **Multi-language** — Latin terms match whole words case-insensitively; Devanagari and other non-Latin terms match as substrings, positioned to the nearest token.
50
+ - **Auditable** — `hits` and `reasons` explain every decision.
51
+
52
+ ## API
53
+
54
+ ```ts
55
+ createScreen(config: ScreenConfig): Screen // reusable, compiles matchers once
56
+ screenText(text: string, config: ScreenConfig): ScreenResult // one-shot
57
+
58
+ interface ScreenConfig {
59
+ dimensions: Record<string, { terms: string[]; weight?: number; forceReview?: boolean; forceBlock?: boolean }>;
60
+ negations?: string[]; // "word" | "prefix*" | "-suffix"
61
+ contextWindow?: number; // default 4
62
+ gazetteer?: string[];
63
+ gazetteerBoost?: number; // default 0.5
64
+ thresholds?: { clear?: number; review?: number; block?: number };
65
+ }
66
+
67
+ interface ScreenResult {
68
+ decision: "clear" | "review" | "block";
69
+ score: number;
70
+ scores: Record<string, number>;
71
+ hits: { dim: string; term: string; pos: number; weight: number; negated: boolean }[];
72
+ entity: boolean;
73
+ reasons: string[];
74
+ }
75
+ ```
76
+
77
+ You bring the lexicons — `screen` never ships opinions about what is sensitive, only the engine that applies yours.
78
+
79
+ ## Licence
80
+
81
+ [Lacspace Free Licence v1.0](https://developer.lacspace.com/licenses/lacspace-free-1.0) — free for personal and commercial use.
package/dist/index.cjs ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ var WORD_RE = /[\p{L}\p{N}][\p{L}\p{N}‌‍]*/gu;
5
+ function tokenize(text) {
6
+ const out = [];
7
+ let m;
8
+ WORD_RE.lastIndex = 0;
9
+ while ((m = WORD_RE.exec(text)) !== null) out.push({ raw: m[0], norm: m[0].toLowerCase(), start: m.index });
10
+ return out;
11
+ }
12
+ function termWords(term) {
13
+ const out = [];
14
+ let m;
15
+ WORD_RE.lastIndex = 0;
16
+ while ((m = WORD_RE.exec(term)) !== null) out.push(m[0].toLowerCase());
17
+ return out;
18
+ }
19
+ function isLatin(s) {
20
+ return !/[^\u0000-ɏ]/.test(s);
21
+ }
22
+ function parseNegations(neg) {
23
+ return neg.map((n) => {
24
+ if (n.startsWith("-")) return { kind: "suffix", value: n.slice(1).toLowerCase() };
25
+ if (n.endsWith("*")) return { kind: "prefix", value: n.slice(0, -1).toLowerCase() };
26
+ return { kind: "word", value: n.toLowerCase() };
27
+ });
28
+ }
29
+ function tokenMatchesNeg(tok, rules) {
30
+ for (const r of rules) {
31
+ if (r.kind === "word" && tok.norm === r.value) return true;
32
+ if (r.kind === "prefix" && tok.norm.startsWith(r.value)) return true;
33
+ if (r.kind === "suffix" && tok.norm.endsWith(r.value) && tok.norm.length > r.value.length) return true;
34
+ }
35
+ return false;
36
+ }
37
+ function createScreen(config) {
38
+ const window = config.contextWindow ?? 4;
39
+ const negRules = parseNegations(config.negations ?? []);
40
+ const gaz = (config.gazetteer ?? []).filter(Boolean);
41
+ const gazLatin = gaz.filter(isLatin).map((g) => g.toLowerCase());
42
+ const gazOther = gaz.filter((g) => !isLatin(g));
43
+ const gazBoost = config.gazetteerBoost ?? 0.5;
44
+ const th = { review: 1, block: Infinity, ...config.thresholds ?? {} };
45
+ const dims = Object.entries(config.dimensions).map(([name, spec]) => ({
46
+ name,
47
+ weight: spec.weight ?? 1,
48
+ forceReview: spec.forceReview ?? false,
49
+ forceBlock: spec.forceBlock ?? false,
50
+ latin: spec.terms.filter(isLatin).map((t) => ({ term: t, words: termWords(t) })),
51
+ other: spec.terms.filter((t) => !isLatin(t))
52
+ }));
53
+ const screen = ((text) => {
54
+ const toks = tokenize(text);
55
+ const lower = text.toLowerCase();
56
+ const hits = [];
57
+ const scores = {};
58
+ for (const d of dims) scores[d.name] = 0;
59
+ const entity = gazLatin.some((g) => new RegExp(`(?:^|[^a-z0-9])${escapeRe(g)}(?:[^a-z0-9]|$)`).test(lower)) || gazOther.some((g) => text.includes(g));
60
+ const negatedNear = (pos) => {
61
+ const from = Math.max(0, pos - window);
62
+ const to = Math.min(toks.length - 1, pos + window);
63
+ for (let i = from; i <= to; i++) if (i !== pos && tokenMatchesNeg(toks[i], negRules)) return true;
64
+ return false;
65
+ };
66
+ const activeByDim = /* @__PURE__ */ new Map();
67
+ for (const d of dims) {
68
+ let anyActive = false;
69
+ for (const { term, words } of d.latin) {
70
+ if (words.length === 0) continue;
71
+ for (let i = 0; i + words.length <= toks.length; i++) {
72
+ let ok = true;
73
+ for (let j = 0; j < words.length; j++) if (toks[i + j].norm !== words[j]) {
74
+ ok = false;
75
+ break;
76
+ }
77
+ if (!ok) continue;
78
+ const neg = negatedNear(i) || negatedNear(i + words.length - 1);
79
+ hits.push({ dim: d.name, term, pos: i, weight: d.weight, negated: neg });
80
+ if (!neg) {
81
+ scores[d.name] += d.weight;
82
+ anyActive = true;
83
+ }
84
+ }
85
+ }
86
+ for (const term of d.other) {
87
+ let idx = text.indexOf(term);
88
+ while (idx !== -1) {
89
+ const pos = nearestToken(toks, idx);
90
+ const neg = pos >= 0 ? negatedNear(pos) : false;
91
+ hits.push({ dim: d.name, term, pos, weight: d.weight, negated: neg });
92
+ if (!neg) {
93
+ scores[d.name] += d.weight;
94
+ anyActive = true;
95
+ }
96
+ idx = text.indexOf(term, idx + term.length);
97
+ }
98
+ }
99
+ if (anyActive && entity) scores[d.name] += gazBoost;
100
+ activeByDim.set(d.name, anyActive);
101
+ }
102
+ let total = 0;
103
+ for (const d of dims) total += scores[d.name];
104
+ const reasons = [];
105
+ let decision = "clear";
106
+ for (const d of dims) {
107
+ if (d.forceBlock && activeByDim.get(d.name)) {
108
+ decision = "block";
109
+ reasons.push(`${d.name}: force-block hit`);
110
+ }
111
+ }
112
+ if (decision !== "block") {
113
+ if (total >= th.block) {
114
+ decision = "block";
115
+ reasons.push(`total score ${round(total)} \u2265 block ${th.block}`);
116
+ } else if (total >= th.review) {
117
+ decision = "review";
118
+ reasons.push(`total score ${round(total)} \u2265 review ${th.review}`);
119
+ }
120
+ for (const d of dims) {
121
+ if (d.forceReview && activeByDim.get(d.name) && decision === "clear") {
122
+ decision = "review";
123
+ reasons.push(`${d.name}: force-review hit`);
124
+ }
125
+ }
126
+ }
127
+ if (decision === "clear") reasons.push("below review threshold");
128
+ hits.sort((a, b) => a.pos - b.pos || a.dim.localeCompare(b.dim) || a.term.localeCompare(b.term));
129
+ return { decision, score: round(total), scores: roundAll(scores), hits, entity, reasons };
130
+ });
131
+ Object.defineProperty(screen, "config", { value: config, enumerable: true });
132
+ return screen;
133
+ }
134
+ function screenText(text, config) {
135
+ return createScreen(config)(text);
136
+ }
137
+ function nearestToken(toks, charPos) {
138
+ let best = -1;
139
+ let bestD = Infinity;
140
+ for (let i = 0; i < toks.length; i++) {
141
+ const d = Math.abs(toks[i].start - charPos);
142
+ if (d < bestD) {
143
+ bestD = d;
144
+ best = i;
145
+ }
146
+ }
147
+ return best;
148
+ }
149
+ function escapeRe(s) {
150
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
151
+ }
152
+ function round(n) {
153
+ return Math.round(n * 1e3) / 1e3;
154
+ }
155
+ function roundAll(o) {
156
+ const out = {};
157
+ for (const k of Object.keys(o)) out[k] = round(o[k]);
158
+ return out;
159
+ }
160
+
161
+ exports.createScreen = createScreen;
162
+ exports.screenText = screenText;
163
+ //# sourceMappingURL=index.cjs.map
164
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/screen — a cheap lexical pre-screen that decides whether text even\n * needs an expensive model. You supply weighted term lexicons per dimension\n * (multi-language), optional negation patterns and a named-entity gazetteer;\n * `screen(text)` returns per-dimension scores, the matched terms, and a\n * clear / review / block decision. Deterministic and zero-dependency.\n */\n\nexport interface DimensionSpec {\n /** Terms/phrases that signal this dimension. Latin matched case-insensitively on word boundaries; non-Latin (e.g. Devanagari) matched as substrings. */\n terms: string[];\n /** Score added per matched term. Default 1. */\n weight?: number;\n /** If any single hit on this dimension appears, the decision is at least \"review\" (never \"clear\"). Good for minor / election-sensitive. */\n forceReview?: boolean;\n /** If any single (non-negated) hit appears, the decision is \"block\". Use sparingly. */\n forceBlock?: boolean;\n}\n\nexport interface ScreenConfig {\n dimensions: Record<string, DimensionSpec>;\n /**\n * Words/patterns that flip a nearby hit to \"negated\" (not counted toward the score).\n * Latin words match whole; a pattern ending in \"*\" is a prefix, and a value beginning\n * with \"-\" is a SUFFIX pattern (for languages like Nepali where negation is a verb\n * suffix, e.g. \"-न\", \"-नन्\").\n */\n negations?: string[];\n /** How many tokens on each side of a hit are scanned for a negation. Default 4. */\n contextWindow?: number;\n /** Named entities (people/places) — a hit near one adds `gazetteerBoost` to every active dimension. */\n gazetteer?: string[];\n /** Score added to a dimension when the text also contains a gazetteer entity. Default 0.5. */\n gazetteerBoost?: number;\n /** Total-score thresholds. `clear`: at or below → clear. `review`: at or above (and below block) → review. */\n thresholds?: { clear?: number; review?: number; block?: number };\n}\n\nexport type Decision = \"clear\" | \"review\" | \"block\";\n\nexport interface ScreenHit {\n dim: string;\n term: string;\n /** Token position of the hit. */\n pos: number;\n weight: number;\n negated: boolean;\n}\n\nexport interface ScreenResult {\n decision: Decision;\n /** Total score across dimensions (negated and gazetteer-only hits excluded from term totals). */\n score: number;\n /** Per-dimension score (non-negated hits × weight, plus gazetteer boost when present). */\n scores: Record<string, number>;\n hits: ScreenHit[];\n /** Whether a gazetteer entity was present. */\n entity: boolean;\n /** Human-readable \"held because\" reasons for an audit log. */\n reasons: string[];\n}\n\ninterface Token {\n raw: string;\n norm: string; // lowercased\n start: number;\n}\n\nconst WORD_RE = /[\\p{L}\\p{N}][\\p{L}\\p{N}‌‍]*/gu;\n\nfunction tokenize(text: string): Token[] {\n const out: Token[] = [];\n let m: RegExpExecArray | null;\n WORD_RE.lastIndex = 0;\n while ((m = WORD_RE.exec(text)) !== null) out.push({ raw: m[0], norm: m[0].toLowerCase(), start: m.index });\n return out;\n}\n\nfunction termWords(term: string): string[] {\n const out: string[] = [];\n let m: RegExpExecArray | null;\n WORD_RE.lastIndex = 0;\n while ((m = WORD_RE.exec(term)) !== null) out.push(m[0].toLowerCase());\n return out;\n}\n\nfunction isLatin(s: string): boolean {\n return !/[^\\u0000-ɏ]/.test(s);\n}\n\ninterface NegRule {\n kind: \"word\" | \"prefix\" | \"suffix\";\n value: string;\n}\n\nfunction parseNegations(neg: string[]): NegRule[] {\n return neg.map((n) => {\n if (n.startsWith(\"-\")) return { kind: \"suffix\" as const, value: n.slice(1).toLowerCase() };\n if (n.endsWith(\"*\")) return { kind: \"prefix\" as const, value: n.slice(0, -1).toLowerCase() };\n return { kind: \"word\" as const, value: n.toLowerCase() };\n });\n}\n\nfunction tokenMatchesNeg(tok: Token, rules: NegRule[]): boolean {\n for (const r of rules) {\n if (r.kind === \"word\" && tok.norm === r.value) return true;\n if (r.kind === \"prefix\" && tok.norm.startsWith(r.value)) return true;\n if (r.kind === \"suffix\" && tok.norm.endsWith(r.value) && tok.norm.length > r.value.length) return true;\n }\n return false;\n}\n\nexport interface Screen {\n (text: string): ScreenResult;\n readonly config: ScreenConfig;\n}\n\n/** Build a reusable screener from a config. Compiles term matchers once. */\nexport function createScreen(config: ScreenConfig): Screen {\n const window = config.contextWindow ?? 4;\n const negRules = parseNegations(config.negations ?? []);\n const gaz = (config.gazetteer ?? []).filter(Boolean);\n const gazLatin = gaz.filter(isLatin).map((g) => g.toLowerCase());\n const gazOther = gaz.filter((g) => !isLatin(g));\n const gazBoost = config.gazetteerBoost ?? 0.5;\n const th = { clear: 0, review: 1, block: Infinity, ...(config.thresholds ?? {}) };\n\n // Pre-split each dimension's terms into latin (token match) and substring (non-latin).\n const dims = Object.entries(config.dimensions).map(([name, spec]) => ({\n name,\n weight: spec.weight ?? 1,\n forceReview: spec.forceReview ?? false,\n forceBlock: spec.forceBlock ?? false,\n latin: spec.terms.filter(isLatin).map((t) => ({ term: t, words: termWords(t) })),\n other: spec.terms.filter((t) => !isLatin(t)),\n }));\n\n const screen = ((text: string): ScreenResult => {\n const toks = tokenize(text);\n const lower = text.toLowerCase();\n const hits: ScreenHit[] = [];\n const scores: Record<string, number> = {};\n for (const d of dims) scores[d.name] = 0;\n\n const entity =\n gazLatin.some((g) => new RegExp(`(?:^|[^a-z0-9])${escapeRe(g)}(?:[^a-z0-9]|$)`).test(lower)) ||\n gazOther.some((g) => text.includes(g));\n\n const negatedNear = (pos: number): boolean => {\n const from = Math.max(0, pos - window);\n const to = Math.min(toks.length - 1, pos + window);\n for (let i = from; i <= to; i++) if (i !== pos && tokenMatchesNeg(toks[i]!, negRules)) return true;\n return false;\n };\n\n const activeByDim = new Map<string, boolean>();\n for (const d of dims) {\n let anyActive = false;\n // Latin multi-word phrase matching over the token stream.\n for (const { term, words } of d.latin) {\n if (words.length === 0) continue;\n for (let i = 0; i + words.length <= toks.length; i++) {\n let ok = true;\n for (let j = 0; j < words.length; j++) if (toks[i + j]!.norm !== words[j]) { ok = false; break; }\n if (!ok) continue;\n const neg = negatedNear(i) || negatedNear(i + words.length - 1);\n hits.push({ dim: d.name, term, pos: i, weight: d.weight, negated: neg });\n if (!neg) {\n scores[d.name]! += d.weight;\n anyActive = true;\n }\n }\n }\n // Non-Latin substring matching (Devanagari etc.), position by nearest token.\n for (const term of d.other) {\n let idx = text.indexOf(term);\n while (idx !== -1) {\n const pos = nearestToken(toks, idx);\n const neg = pos >= 0 ? negatedNear(pos) : false;\n hits.push({ dim: d.name, term, pos, weight: d.weight, negated: neg });\n if (!neg) {\n scores[d.name]! += d.weight;\n anyActive = true;\n }\n idx = text.indexOf(term, idx + term.length);\n }\n }\n if (anyActive && entity) scores[d.name]! += gazBoost;\n activeByDim.set(d.name, anyActive);\n }\n\n let total = 0;\n for (const d of dims) total += scores[d.name]!;\n\n // Decision.\n const reasons: string[] = [];\n let decision: Decision = \"clear\";\n for (const d of dims) {\n if (d.forceBlock && activeByDim.get(d.name)) {\n decision = \"block\";\n reasons.push(`${d.name}: force-block hit`);\n }\n }\n if (decision !== \"block\") {\n if (total >= th.block) {\n decision = \"block\";\n reasons.push(`total score ${round(total)} ≥ block ${th.block}`);\n } else if (total >= th.review) {\n decision = \"review\";\n reasons.push(`total score ${round(total)} ≥ review ${th.review}`);\n }\n for (const d of dims) {\n if (d.forceReview && activeByDim.get(d.name) && decision === \"clear\") {\n decision = \"review\";\n reasons.push(`${d.name}: force-review hit`);\n }\n }\n }\n if (decision === \"clear\") reasons.push(\"below review threshold\");\n\n // Order hits deterministically.\n hits.sort((a, b) => a.pos - b.pos || a.dim.localeCompare(b.dim) || a.term.localeCompare(b.term));\n\n return { decision, score: round(total), scores: roundAll(scores), hits, entity, reasons };\n }) as Screen;\n\n Object.defineProperty(screen, \"config\", { value: config, enumerable: true });\n return screen;\n}\n\n/** One-shot convenience: build and run in a single call. */\nexport function screenText(text: string, config: ScreenConfig): ScreenResult {\n return createScreen(config)(text);\n}\n\nfunction nearestToken(toks: Token[], charPos: number): number {\n // binary-ish: find the token whose start is closest to charPos\n let best = -1;\n let bestD = Infinity;\n for (let i = 0; i < toks.length; i++) {\n const d = Math.abs(toks[i]!.start - charPos);\n if (d < bestD) {\n bestD = d;\n best = i;\n }\n }\n return best;\n}\n\nfunction escapeRe(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\nfunction round(n: number): number {\n return Math.round(n * 1000) / 1000;\n}\nfunction roundAll(o: Record<string, number>): Record<string, number> {\n const out: Record<string, number> = {};\n for (const k of Object.keys(o)) out[k] = round(o[k]!);\n return out;\n}\n"]}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @lacspace/screen — a cheap lexical pre-screen that decides whether text even
3
+ * needs an expensive model. You supply weighted term lexicons per dimension
4
+ * (multi-language), optional negation patterns and a named-entity gazetteer;
5
+ * `screen(text)` returns per-dimension scores, the matched terms, and a
6
+ * clear / review / block decision. Deterministic and zero-dependency.
7
+ */
8
+ interface DimensionSpec {
9
+ /** Terms/phrases that signal this dimension. Latin matched case-insensitively on word boundaries; non-Latin (e.g. Devanagari) matched as substrings. */
10
+ terms: string[];
11
+ /** Score added per matched term. Default 1. */
12
+ weight?: number;
13
+ /** If any single hit on this dimension appears, the decision is at least "review" (never "clear"). Good for minor / election-sensitive. */
14
+ forceReview?: boolean;
15
+ /** If any single (non-negated) hit appears, the decision is "block". Use sparingly. */
16
+ forceBlock?: boolean;
17
+ }
18
+ interface ScreenConfig {
19
+ dimensions: Record<string, DimensionSpec>;
20
+ /**
21
+ * Words/patterns that flip a nearby hit to "negated" (not counted toward the score).
22
+ * Latin words match whole; a pattern ending in "*" is a prefix, and a value beginning
23
+ * with "-" is a SUFFIX pattern (for languages like Nepali where negation is a verb
24
+ * suffix, e.g. "-न", "-नन्").
25
+ */
26
+ negations?: string[];
27
+ /** How many tokens on each side of a hit are scanned for a negation. Default 4. */
28
+ contextWindow?: number;
29
+ /** Named entities (people/places) — a hit near one adds `gazetteerBoost` to every active dimension. */
30
+ gazetteer?: string[];
31
+ /** Score added to a dimension when the text also contains a gazetteer entity. Default 0.5. */
32
+ gazetteerBoost?: number;
33
+ /** Total-score thresholds. `clear`: at or below → clear. `review`: at or above (and below block) → review. */
34
+ thresholds?: {
35
+ clear?: number;
36
+ review?: number;
37
+ block?: number;
38
+ };
39
+ }
40
+ type Decision = "clear" | "review" | "block";
41
+ interface ScreenHit {
42
+ dim: string;
43
+ term: string;
44
+ /** Token position of the hit. */
45
+ pos: number;
46
+ weight: number;
47
+ negated: boolean;
48
+ }
49
+ interface ScreenResult {
50
+ decision: Decision;
51
+ /** Total score across dimensions (negated and gazetteer-only hits excluded from term totals). */
52
+ score: number;
53
+ /** Per-dimension score (non-negated hits × weight, plus gazetteer boost when present). */
54
+ scores: Record<string, number>;
55
+ hits: ScreenHit[];
56
+ /** Whether a gazetteer entity was present. */
57
+ entity: boolean;
58
+ /** Human-readable "held because" reasons for an audit log. */
59
+ reasons: string[];
60
+ }
61
+ interface Screen {
62
+ (text: string): ScreenResult;
63
+ readonly config: ScreenConfig;
64
+ }
65
+ /** Build a reusable screener from a config. Compiles term matchers once. */
66
+ declare function createScreen(config: ScreenConfig): Screen;
67
+ /** One-shot convenience: build and run in a single call. */
68
+ declare function screenText(text: string, config: ScreenConfig): ScreenResult;
69
+
70
+ export { type Decision, type DimensionSpec, type Screen, type ScreenConfig, type ScreenHit, type ScreenResult, createScreen, screenText };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @lacspace/screen — a cheap lexical pre-screen that decides whether text even
3
+ * needs an expensive model. You supply weighted term lexicons per dimension
4
+ * (multi-language), optional negation patterns and a named-entity gazetteer;
5
+ * `screen(text)` returns per-dimension scores, the matched terms, and a
6
+ * clear / review / block decision. Deterministic and zero-dependency.
7
+ */
8
+ interface DimensionSpec {
9
+ /** Terms/phrases that signal this dimension. Latin matched case-insensitively on word boundaries; non-Latin (e.g. Devanagari) matched as substrings. */
10
+ terms: string[];
11
+ /** Score added per matched term. Default 1. */
12
+ weight?: number;
13
+ /** If any single hit on this dimension appears, the decision is at least "review" (never "clear"). Good for minor / election-sensitive. */
14
+ forceReview?: boolean;
15
+ /** If any single (non-negated) hit appears, the decision is "block". Use sparingly. */
16
+ forceBlock?: boolean;
17
+ }
18
+ interface ScreenConfig {
19
+ dimensions: Record<string, DimensionSpec>;
20
+ /**
21
+ * Words/patterns that flip a nearby hit to "negated" (not counted toward the score).
22
+ * Latin words match whole; a pattern ending in "*" is a prefix, and a value beginning
23
+ * with "-" is a SUFFIX pattern (for languages like Nepali where negation is a verb
24
+ * suffix, e.g. "-न", "-नन्").
25
+ */
26
+ negations?: string[];
27
+ /** How many tokens on each side of a hit are scanned for a negation. Default 4. */
28
+ contextWindow?: number;
29
+ /** Named entities (people/places) — a hit near one adds `gazetteerBoost` to every active dimension. */
30
+ gazetteer?: string[];
31
+ /** Score added to a dimension when the text also contains a gazetteer entity. Default 0.5. */
32
+ gazetteerBoost?: number;
33
+ /** Total-score thresholds. `clear`: at or below → clear. `review`: at or above (and below block) → review. */
34
+ thresholds?: {
35
+ clear?: number;
36
+ review?: number;
37
+ block?: number;
38
+ };
39
+ }
40
+ type Decision = "clear" | "review" | "block";
41
+ interface ScreenHit {
42
+ dim: string;
43
+ term: string;
44
+ /** Token position of the hit. */
45
+ pos: number;
46
+ weight: number;
47
+ negated: boolean;
48
+ }
49
+ interface ScreenResult {
50
+ decision: Decision;
51
+ /** Total score across dimensions (negated and gazetteer-only hits excluded from term totals). */
52
+ score: number;
53
+ /** Per-dimension score (non-negated hits × weight, plus gazetteer boost when present). */
54
+ scores: Record<string, number>;
55
+ hits: ScreenHit[];
56
+ /** Whether a gazetteer entity was present. */
57
+ entity: boolean;
58
+ /** Human-readable "held because" reasons for an audit log. */
59
+ reasons: string[];
60
+ }
61
+ interface Screen {
62
+ (text: string): ScreenResult;
63
+ readonly config: ScreenConfig;
64
+ }
65
+ /** Build a reusable screener from a config. Compiles term matchers once. */
66
+ declare function createScreen(config: ScreenConfig): Screen;
67
+ /** One-shot convenience: build and run in a single call. */
68
+ declare function screenText(text: string, config: ScreenConfig): ScreenResult;
69
+
70
+ export { type Decision, type DimensionSpec, type Screen, type ScreenConfig, type ScreenHit, type ScreenResult, createScreen, screenText };
package/dist/index.js ADDED
@@ -0,0 +1,161 @@
1
+ // src/index.ts
2
+ var WORD_RE = /[\p{L}\p{N}][\p{L}\p{N}‌‍]*/gu;
3
+ function tokenize(text) {
4
+ const out = [];
5
+ let m;
6
+ WORD_RE.lastIndex = 0;
7
+ while ((m = WORD_RE.exec(text)) !== null) out.push({ raw: m[0], norm: m[0].toLowerCase(), start: m.index });
8
+ return out;
9
+ }
10
+ function termWords(term) {
11
+ const out = [];
12
+ let m;
13
+ WORD_RE.lastIndex = 0;
14
+ while ((m = WORD_RE.exec(term)) !== null) out.push(m[0].toLowerCase());
15
+ return out;
16
+ }
17
+ function isLatin(s) {
18
+ return !/[^\u0000-ɏ]/.test(s);
19
+ }
20
+ function parseNegations(neg) {
21
+ return neg.map((n) => {
22
+ if (n.startsWith("-")) return { kind: "suffix", value: n.slice(1).toLowerCase() };
23
+ if (n.endsWith("*")) return { kind: "prefix", value: n.slice(0, -1).toLowerCase() };
24
+ return { kind: "word", value: n.toLowerCase() };
25
+ });
26
+ }
27
+ function tokenMatchesNeg(tok, rules) {
28
+ for (const r of rules) {
29
+ if (r.kind === "word" && tok.norm === r.value) return true;
30
+ if (r.kind === "prefix" && tok.norm.startsWith(r.value)) return true;
31
+ if (r.kind === "suffix" && tok.norm.endsWith(r.value) && tok.norm.length > r.value.length) return true;
32
+ }
33
+ return false;
34
+ }
35
+ function createScreen(config) {
36
+ const window = config.contextWindow ?? 4;
37
+ const negRules = parseNegations(config.negations ?? []);
38
+ const gaz = (config.gazetteer ?? []).filter(Boolean);
39
+ const gazLatin = gaz.filter(isLatin).map((g) => g.toLowerCase());
40
+ const gazOther = gaz.filter((g) => !isLatin(g));
41
+ const gazBoost = config.gazetteerBoost ?? 0.5;
42
+ const th = { review: 1, block: Infinity, ...config.thresholds ?? {} };
43
+ const dims = Object.entries(config.dimensions).map(([name, spec]) => ({
44
+ name,
45
+ weight: spec.weight ?? 1,
46
+ forceReview: spec.forceReview ?? false,
47
+ forceBlock: spec.forceBlock ?? false,
48
+ latin: spec.terms.filter(isLatin).map((t) => ({ term: t, words: termWords(t) })),
49
+ other: spec.terms.filter((t) => !isLatin(t))
50
+ }));
51
+ const screen = ((text) => {
52
+ const toks = tokenize(text);
53
+ const lower = text.toLowerCase();
54
+ const hits = [];
55
+ const scores = {};
56
+ for (const d of dims) scores[d.name] = 0;
57
+ const entity = gazLatin.some((g) => new RegExp(`(?:^|[^a-z0-9])${escapeRe(g)}(?:[^a-z0-9]|$)`).test(lower)) || gazOther.some((g) => text.includes(g));
58
+ const negatedNear = (pos) => {
59
+ const from = Math.max(0, pos - window);
60
+ const to = Math.min(toks.length - 1, pos + window);
61
+ for (let i = from; i <= to; i++) if (i !== pos && tokenMatchesNeg(toks[i], negRules)) return true;
62
+ return false;
63
+ };
64
+ const activeByDim = /* @__PURE__ */ new Map();
65
+ for (const d of dims) {
66
+ let anyActive = false;
67
+ for (const { term, words } of d.latin) {
68
+ if (words.length === 0) continue;
69
+ for (let i = 0; i + words.length <= toks.length; i++) {
70
+ let ok = true;
71
+ for (let j = 0; j < words.length; j++) if (toks[i + j].norm !== words[j]) {
72
+ ok = false;
73
+ break;
74
+ }
75
+ if (!ok) continue;
76
+ const neg = negatedNear(i) || negatedNear(i + words.length - 1);
77
+ hits.push({ dim: d.name, term, pos: i, weight: d.weight, negated: neg });
78
+ if (!neg) {
79
+ scores[d.name] += d.weight;
80
+ anyActive = true;
81
+ }
82
+ }
83
+ }
84
+ for (const term of d.other) {
85
+ let idx = text.indexOf(term);
86
+ while (idx !== -1) {
87
+ const pos = nearestToken(toks, idx);
88
+ const neg = pos >= 0 ? negatedNear(pos) : false;
89
+ hits.push({ dim: d.name, term, pos, weight: d.weight, negated: neg });
90
+ if (!neg) {
91
+ scores[d.name] += d.weight;
92
+ anyActive = true;
93
+ }
94
+ idx = text.indexOf(term, idx + term.length);
95
+ }
96
+ }
97
+ if (anyActive && entity) scores[d.name] += gazBoost;
98
+ activeByDim.set(d.name, anyActive);
99
+ }
100
+ let total = 0;
101
+ for (const d of dims) total += scores[d.name];
102
+ const reasons = [];
103
+ let decision = "clear";
104
+ for (const d of dims) {
105
+ if (d.forceBlock && activeByDim.get(d.name)) {
106
+ decision = "block";
107
+ reasons.push(`${d.name}: force-block hit`);
108
+ }
109
+ }
110
+ if (decision !== "block") {
111
+ if (total >= th.block) {
112
+ decision = "block";
113
+ reasons.push(`total score ${round(total)} \u2265 block ${th.block}`);
114
+ } else if (total >= th.review) {
115
+ decision = "review";
116
+ reasons.push(`total score ${round(total)} \u2265 review ${th.review}`);
117
+ }
118
+ for (const d of dims) {
119
+ if (d.forceReview && activeByDim.get(d.name) && decision === "clear") {
120
+ decision = "review";
121
+ reasons.push(`${d.name}: force-review hit`);
122
+ }
123
+ }
124
+ }
125
+ if (decision === "clear") reasons.push("below review threshold");
126
+ hits.sort((a, b) => a.pos - b.pos || a.dim.localeCompare(b.dim) || a.term.localeCompare(b.term));
127
+ return { decision, score: round(total), scores: roundAll(scores), hits, entity, reasons };
128
+ });
129
+ Object.defineProperty(screen, "config", { value: config, enumerable: true });
130
+ return screen;
131
+ }
132
+ function screenText(text, config) {
133
+ return createScreen(config)(text);
134
+ }
135
+ function nearestToken(toks, charPos) {
136
+ let best = -1;
137
+ let bestD = Infinity;
138
+ for (let i = 0; i < toks.length; i++) {
139
+ const d = Math.abs(toks[i].start - charPos);
140
+ if (d < bestD) {
141
+ bestD = d;
142
+ best = i;
143
+ }
144
+ }
145
+ return best;
146
+ }
147
+ function escapeRe(s) {
148
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+ function round(n) {
151
+ return Math.round(n * 1e3) / 1e3;
152
+ }
153
+ function roundAll(o) {
154
+ const out = {};
155
+ for (const k of Object.keys(o)) out[k] = round(o[k]);
156
+ return out;
157
+ }
158
+
159
+ export { createScreen, screenText };
160
+ //# sourceMappingURL=index.js.map
161
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/screen — a cheap lexical pre-screen that decides whether text even\n * needs an expensive model. You supply weighted term lexicons per dimension\n * (multi-language), optional negation patterns and a named-entity gazetteer;\n * `screen(text)` returns per-dimension scores, the matched terms, and a\n * clear / review / block decision. Deterministic and zero-dependency.\n */\n\nexport interface DimensionSpec {\n /** Terms/phrases that signal this dimension. Latin matched case-insensitively on word boundaries; non-Latin (e.g. Devanagari) matched as substrings. */\n terms: string[];\n /** Score added per matched term. Default 1. */\n weight?: number;\n /** If any single hit on this dimension appears, the decision is at least \"review\" (never \"clear\"). Good for minor / election-sensitive. */\n forceReview?: boolean;\n /** If any single (non-negated) hit appears, the decision is \"block\". Use sparingly. */\n forceBlock?: boolean;\n}\n\nexport interface ScreenConfig {\n dimensions: Record<string, DimensionSpec>;\n /**\n * Words/patterns that flip a nearby hit to \"negated\" (not counted toward the score).\n * Latin words match whole; a pattern ending in \"*\" is a prefix, and a value beginning\n * with \"-\" is a SUFFIX pattern (for languages like Nepali where negation is a verb\n * suffix, e.g. \"-न\", \"-नन्\").\n */\n negations?: string[];\n /** How many tokens on each side of a hit are scanned for a negation. Default 4. */\n contextWindow?: number;\n /** Named entities (people/places) — a hit near one adds `gazetteerBoost` to every active dimension. */\n gazetteer?: string[];\n /** Score added to a dimension when the text also contains a gazetteer entity. Default 0.5. */\n gazetteerBoost?: number;\n /** Total-score thresholds. `clear`: at or below → clear. `review`: at or above (and below block) → review. */\n thresholds?: { clear?: number; review?: number; block?: number };\n}\n\nexport type Decision = \"clear\" | \"review\" | \"block\";\n\nexport interface ScreenHit {\n dim: string;\n term: string;\n /** Token position of the hit. */\n pos: number;\n weight: number;\n negated: boolean;\n}\n\nexport interface ScreenResult {\n decision: Decision;\n /** Total score across dimensions (negated and gazetteer-only hits excluded from term totals). */\n score: number;\n /** Per-dimension score (non-negated hits × weight, plus gazetteer boost when present). */\n scores: Record<string, number>;\n hits: ScreenHit[];\n /** Whether a gazetteer entity was present. */\n entity: boolean;\n /** Human-readable \"held because\" reasons for an audit log. */\n reasons: string[];\n}\n\ninterface Token {\n raw: string;\n norm: string; // lowercased\n start: number;\n}\n\nconst WORD_RE = /[\\p{L}\\p{N}][\\p{L}\\p{N}‌‍]*/gu;\n\nfunction tokenize(text: string): Token[] {\n const out: Token[] = [];\n let m: RegExpExecArray | null;\n WORD_RE.lastIndex = 0;\n while ((m = WORD_RE.exec(text)) !== null) out.push({ raw: m[0], norm: m[0].toLowerCase(), start: m.index });\n return out;\n}\n\nfunction termWords(term: string): string[] {\n const out: string[] = [];\n let m: RegExpExecArray | null;\n WORD_RE.lastIndex = 0;\n while ((m = WORD_RE.exec(term)) !== null) out.push(m[0].toLowerCase());\n return out;\n}\n\nfunction isLatin(s: string): boolean {\n return !/[^\\u0000-ɏ]/.test(s);\n}\n\ninterface NegRule {\n kind: \"word\" | \"prefix\" | \"suffix\";\n value: string;\n}\n\nfunction parseNegations(neg: string[]): NegRule[] {\n return neg.map((n) => {\n if (n.startsWith(\"-\")) return { kind: \"suffix\" as const, value: n.slice(1).toLowerCase() };\n if (n.endsWith(\"*\")) return { kind: \"prefix\" as const, value: n.slice(0, -1).toLowerCase() };\n return { kind: \"word\" as const, value: n.toLowerCase() };\n });\n}\n\nfunction tokenMatchesNeg(tok: Token, rules: NegRule[]): boolean {\n for (const r of rules) {\n if (r.kind === \"word\" && tok.norm === r.value) return true;\n if (r.kind === \"prefix\" && tok.norm.startsWith(r.value)) return true;\n if (r.kind === \"suffix\" && tok.norm.endsWith(r.value) && tok.norm.length > r.value.length) return true;\n }\n return false;\n}\n\nexport interface Screen {\n (text: string): ScreenResult;\n readonly config: ScreenConfig;\n}\n\n/** Build a reusable screener from a config. Compiles term matchers once. */\nexport function createScreen(config: ScreenConfig): Screen {\n const window = config.contextWindow ?? 4;\n const negRules = parseNegations(config.negations ?? []);\n const gaz = (config.gazetteer ?? []).filter(Boolean);\n const gazLatin = gaz.filter(isLatin).map((g) => g.toLowerCase());\n const gazOther = gaz.filter((g) => !isLatin(g));\n const gazBoost = config.gazetteerBoost ?? 0.5;\n const th = { clear: 0, review: 1, block: Infinity, ...(config.thresholds ?? {}) };\n\n // Pre-split each dimension's terms into latin (token match) and substring (non-latin).\n const dims = Object.entries(config.dimensions).map(([name, spec]) => ({\n name,\n weight: spec.weight ?? 1,\n forceReview: spec.forceReview ?? false,\n forceBlock: spec.forceBlock ?? false,\n latin: spec.terms.filter(isLatin).map((t) => ({ term: t, words: termWords(t) })),\n other: spec.terms.filter((t) => !isLatin(t)),\n }));\n\n const screen = ((text: string): ScreenResult => {\n const toks = tokenize(text);\n const lower = text.toLowerCase();\n const hits: ScreenHit[] = [];\n const scores: Record<string, number> = {};\n for (const d of dims) scores[d.name] = 0;\n\n const entity =\n gazLatin.some((g) => new RegExp(`(?:^|[^a-z0-9])${escapeRe(g)}(?:[^a-z0-9]|$)`).test(lower)) ||\n gazOther.some((g) => text.includes(g));\n\n const negatedNear = (pos: number): boolean => {\n const from = Math.max(0, pos - window);\n const to = Math.min(toks.length - 1, pos + window);\n for (let i = from; i <= to; i++) if (i !== pos && tokenMatchesNeg(toks[i]!, negRules)) return true;\n return false;\n };\n\n const activeByDim = new Map<string, boolean>();\n for (const d of dims) {\n let anyActive = false;\n // Latin multi-word phrase matching over the token stream.\n for (const { term, words } of d.latin) {\n if (words.length === 0) continue;\n for (let i = 0; i + words.length <= toks.length; i++) {\n let ok = true;\n for (let j = 0; j < words.length; j++) if (toks[i + j]!.norm !== words[j]) { ok = false; break; }\n if (!ok) continue;\n const neg = negatedNear(i) || negatedNear(i + words.length - 1);\n hits.push({ dim: d.name, term, pos: i, weight: d.weight, negated: neg });\n if (!neg) {\n scores[d.name]! += d.weight;\n anyActive = true;\n }\n }\n }\n // Non-Latin substring matching (Devanagari etc.), position by nearest token.\n for (const term of d.other) {\n let idx = text.indexOf(term);\n while (idx !== -1) {\n const pos = nearestToken(toks, idx);\n const neg = pos >= 0 ? negatedNear(pos) : false;\n hits.push({ dim: d.name, term, pos, weight: d.weight, negated: neg });\n if (!neg) {\n scores[d.name]! += d.weight;\n anyActive = true;\n }\n idx = text.indexOf(term, idx + term.length);\n }\n }\n if (anyActive && entity) scores[d.name]! += gazBoost;\n activeByDim.set(d.name, anyActive);\n }\n\n let total = 0;\n for (const d of dims) total += scores[d.name]!;\n\n // Decision.\n const reasons: string[] = [];\n let decision: Decision = \"clear\";\n for (const d of dims) {\n if (d.forceBlock && activeByDim.get(d.name)) {\n decision = \"block\";\n reasons.push(`${d.name}: force-block hit`);\n }\n }\n if (decision !== \"block\") {\n if (total >= th.block) {\n decision = \"block\";\n reasons.push(`total score ${round(total)} ≥ block ${th.block}`);\n } else if (total >= th.review) {\n decision = \"review\";\n reasons.push(`total score ${round(total)} ≥ review ${th.review}`);\n }\n for (const d of dims) {\n if (d.forceReview && activeByDim.get(d.name) && decision === \"clear\") {\n decision = \"review\";\n reasons.push(`${d.name}: force-review hit`);\n }\n }\n }\n if (decision === \"clear\") reasons.push(\"below review threshold\");\n\n // Order hits deterministically.\n hits.sort((a, b) => a.pos - b.pos || a.dim.localeCompare(b.dim) || a.term.localeCompare(b.term));\n\n return { decision, score: round(total), scores: roundAll(scores), hits, entity, reasons };\n }) as Screen;\n\n Object.defineProperty(screen, \"config\", { value: config, enumerable: true });\n return screen;\n}\n\n/** One-shot convenience: build and run in a single call. */\nexport function screenText(text: string, config: ScreenConfig): ScreenResult {\n return createScreen(config)(text);\n}\n\nfunction nearestToken(toks: Token[], charPos: number): number {\n // binary-ish: find the token whose start is closest to charPos\n let best = -1;\n let bestD = Infinity;\n for (let i = 0; i < toks.length; i++) {\n const d = Math.abs(toks[i]!.start - charPos);\n if (d < bestD) {\n bestD = d;\n best = i;\n }\n }\n return best;\n}\n\nfunction escapeRe(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\nfunction round(n: number): number {\n return Math.round(n * 1000) / 1000;\n}\nfunction roundAll(o: Record<string, number>): Record<string, number> {\n const out: Record<string, number> = {};\n for (const k of Object.keys(o)) out[k] = round(o[k]!);\n return out;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@lacspace/screen",
3
+ "version": "1.0.0",
4
+ "description": "A cheap, zero-dependency lexical content screener and LLM admission gate — score text against your own weighted term lexicons (multi-language, with negation and proximity windows and an optional named-entity gazetteer) and get back a clear / review / block decision, so obviously-clean and obviously-flagged text never has to reach an expensive model. Deterministic, isomorphic, auditable.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
12
+ "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
13
+ }
14
+ },
15
+ "files": ["dist"],
16
+ "sideEffects": false,
17
+ "scripts": { "build": "tsup", "prepublishOnly": "npm run build" },
18
+ "keywords": ["content-moderation", "lexical", "screener", "triage", "llm-gate", "admission-control", "classifier", "keyword-scoring", "negation", "gazetteer", "sensitivity", "nepali", "devanagari", "zero-dependency", "isomorphic", "typescript"],
19
+ "author": "Lacspace <contact@lacspace.com>",
20
+ "license": "SEE LICENSE IN LICENSE",
21
+ "homepage": "https://developer.lacspace.com/packages/screen",
22
+ "repository": { "type": "git", "url": "git+https://github.com/lacspace/npm-packages.git", "directory": "screen" },
23
+ "bugs": { "url": "https://github.com/lacspace/npm-packages/issues" },
24
+ "engines": { "node": ">=18" },
25
+ "publishConfig": { "access": "public" }
26
+ }