@startsimpli/ui 0.4.64 → 0.4.66
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/package.json +3 -3
- package/src/components/collection/InlineReviewActions.tsx +16 -5
- package/src/components/collection/__tests__/review.test.ts +15 -0
- package/src/components/collection/review.ts +5 -1
- package/src/components/validation/__tests__/checks.test.ts +33 -0
- package/src/components/validation/__tests__/overlap.test.ts +58 -0
- package/src/components/validation/checks.ts +62 -0
- package/src/components/validation/index.ts +2 -0
- package/src/components/validation/overlap.ts +91 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.66",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0",
|
|
44
44
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
45
|
-
"@startsimpli/
|
|
46
|
-
"@startsimpli/
|
|
45
|
+
"@startsimpli/api": "0.5.44",
|
|
46
|
+
"@startsimpli/auth": "0.4.30"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@startsimpli/api": {
|
|
@@ -67,15 +67,26 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
67
67
|
() => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
|
|
68
68
|
[cfg],
|
|
69
69
|
);
|
|
70
|
-
|
|
70
|
+
// Which decision the record currently reflects — by verdict when the type has
|
|
71
|
+
// one (topics), else by status (news: acceptable→approve, rejected→reject). The
|
|
72
|
+
// matching button shows active. Optimistically updated on click.
|
|
73
|
+
const initialKey = useMemo(() => {
|
|
74
|
+
const v = fieldStr(record.data, cfg.verdictAttr);
|
|
75
|
+
const s = fieldStr(record.data, cfg.statusName);
|
|
76
|
+
return (
|
|
77
|
+
decisions.find((d) => (d.verdict ? d.verdict === v : d.status ? d.status === s : false))?.key ??
|
|
78
|
+
null
|
|
79
|
+
);
|
|
80
|
+
}, [record.data, cfg, decisions]);
|
|
81
|
+
const [appliedKey, setAppliedKey] = useState<string | null>(initialKey);
|
|
71
82
|
const [busy, setBusy] = useState<string | null>(null);
|
|
72
83
|
const [noteOpen, setNoteOpen] = useState(false);
|
|
73
84
|
const [note, setNote] = useState('');
|
|
74
85
|
|
|
75
86
|
/** Apply a decision (status + verdict), optionally attaching a note. Optimistic. */
|
|
76
87
|
async function apply(d: ReviewDecision, noteText?: string) {
|
|
77
|
-
const prev =
|
|
78
|
-
|
|
88
|
+
const prev = appliedKey;
|
|
89
|
+
setAppliedKey(d.key); // optimistic
|
|
79
90
|
setBusy(d.key);
|
|
80
91
|
try {
|
|
81
92
|
const data = applyDecisionToData(record.data, d, cfg);
|
|
@@ -94,7 +105,7 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
94
105
|
onSaved?.();
|
|
95
106
|
return true;
|
|
96
107
|
} catch (err) {
|
|
97
|
-
|
|
108
|
+
setAppliedKey(prev); // roll back
|
|
98
109
|
notify.error(err instanceof Error ? err.message : 'Could not save.');
|
|
99
110
|
return false;
|
|
100
111
|
} finally {
|
|
@@ -104,7 +115,7 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
104
115
|
|
|
105
116
|
function iconButton(d: ReviewDecision, extraOnClick?: () => void) {
|
|
106
117
|
const Icon = ICON[d.tone];
|
|
107
|
-
const active =
|
|
118
|
+
const active = d.key === appliedKey;
|
|
108
119
|
return (
|
|
109
120
|
<button
|
|
110
121
|
type="button"
|
|
@@ -124,6 +124,21 @@ describe('reviewDecisions', () => {
|
|
|
124
124
|
expect(keys).not.toContain('reject');
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it('omitNeedsWork + custom transitions gives a binary accept/reject (news curation)', () => {
|
|
128
|
+
const news = type([enumAttr('status', ['new', 'surfaced', 'acceptable', 'rejected'])]);
|
|
129
|
+
const d = reviewDecisions(
|
|
130
|
+
resolveReviewConfig(news, {
|
|
131
|
+
approveStatus: 'acceptable',
|
|
132
|
+
rejectStatus: 'rejected',
|
|
133
|
+
verdicts: [],
|
|
134
|
+
omitNeedsWork: true,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
expect(d.map((x) => x.key).sort()).toEqual(['approve', 'reject']);
|
|
138
|
+
expect(d.find((x) => x.key === 'approve')).toMatchObject({ status: 'acceptable', verdict: null });
|
|
139
|
+
expect(d.find((x) => x.key === 'reject')).toMatchObject({ status: 'rejected', verdict: null });
|
|
140
|
+
});
|
|
141
|
+
|
|
127
142
|
it('uses custom verdict vocab tones', () => {
|
|
128
143
|
const d = reviewDecisions(
|
|
129
144
|
resolveReviewConfig(topic, {
|
|
@@ -167,6 +167,8 @@ export interface ReviewConfig {
|
|
|
167
167
|
metaAttrs?: string[];
|
|
168
168
|
/** Attrs holding source urls/lines (default: source_1..3 + "sources"). */
|
|
169
169
|
sourceAttrs?: string[];
|
|
170
|
+
/** Drop the middle "Needs work" decision — a binary accept/reject flow (default false). */
|
|
171
|
+
omitNeedsWork?: boolean;
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
export interface ResolvedReview {
|
|
@@ -182,6 +184,7 @@ export interface ResolvedReview {
|
|
|
182
184
|
rankAttr: string;
|
|
183
185
|
metaAttrs: string[];
|
|
184
186
|
sourceAttrs: string[];
|
|
187
|
+
omitNeedsWork: boolean;
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
|
|
@@ -212,6 +215,7 @@ export function resolveReviewConfig(
|
|
|
212
215
|
metaAttrs: (config.metaAttrs ?? DEFAULT_META_ATTRS).filter((a) => declared.has(a)),
|
|
213
216
|
// Keep source attrs the caller asked for; default to the ones this type declares.
|
|
214
217
|
sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
|
|
218
|
+
omitNeedsWork: config.omitNeedsWork ?? false,
|
|
215
219
|
};
|
|
216
220
|
}
|
|
217
221
|
|
|
@@ -269,7 +273,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
|
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
const editVerdict = verdictOf('neutral');
|
|
272
|
-
if (entry && (editVerdict || entry !== r.transitions.approve)) {
|
|
276
|
+
if (!r.omitNeedsWork && entry && (editVerdict || entry !== r.transitions.approve)) {
|
|
273
277
|
out.push({
|
|
274
278
|
key: 'needs_work',
|
|
275
279
|
label: 'Needs work',
|
|
@@ -284,3 +284,36 @@ describe('runContentChecks — locations (768w.16.15.3 jump-to-issue)', () => {
|
|
|
284
284
|
expect(checks.some((c) => c.status !== 'pass')).toBe(true);
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
describe('source-overlap check', () => {
|
|
289
|
+
const SRC =
|
|
290
|
+
'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported, adding another category to a tax architecture that already captures selected products across the Gulf region.';
|
|
291
|
+
|
|
292
|
+
it('is skipped when no sourceTexts are supplied', () => {
|
|
293
|
+
expect(find(runContentChecks({ blog: 'anything' }), 'source-overlap')).toBeUndefined();
|
|
294
|
+
expect(find(runContentChecks({ blog: 'anything' }, { sourceTexts: [] }), 'source-overlap')).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('fails when the blog copies a long verbatim run from a source', () => {
|
|
298
|
+
const c = find(
|
|
299
|
+
runContentChecks(
|
|
300
|
+
{ blog: 'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported.' },
|
|
301
|
+
{ sourceTexts: [SRC] },
|
|
302
|
+
),
|
|
303
|
+
'source-overlap',
|
|
304
|
+
);
|
|
305
|
+
expect(c!.status).toBe('fail');
|
|
306
|
+
expect(c!.locations?.[0].field).toBe('blog');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('passes a genuine paraphrase', () => {
|
|
310
|
+
const c = find(
|
|
311
|
+
runContentChecks(
|
|
312
|
+
{ blog: 'From 2026 the Kingdom widens its sugary-drink levy, a reminder Gulf tax rules vary by market.' },
|
|
313
|
+
{ sourceTexts: [SRC] },
|
|
314
|
+
),
|
|
315
|
+
'source-overlap',
|
|
316
|
+
);
|
|
317
|
+
expect(c!.status).toBe('pass');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { verbatimOverlap, normWords } from '../overlap';
|
|
2
|
+
|
|
3
|
+
const SOURCE = `Saudi Arabia will apply excise tax to sweetened drinks from January 2026,
|
|
4
|
+
Gulf News reported, adding another category to a tax architecture that already
|
|
5
|
+
captures selected products across the Gulf region. For businesses outside the region,
|
|
6
|
+
the broader lesson is that excise tax in the GCC should be viewed as a shared framework
|
|
7
|
+
with local variations that change launch economics.`;
|
|
8
|
+
|
|
9
|
+
describe('normWords', () => {
|
|
10
|
+
it('lowercases, strips punctuation, collapses whitespace', () => {
|
|
11
|
+
expect(normWords('Hello, WORLD! (2026)')).toEqual(['hello', 'world', '2026']);
|
|
12
|
+
expect(normWords('')).toEqual([]);
|
|
13
|
+
expect(normWords(null as unknown as string)).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('verbatimOverlap', () => {
|
|
18
|
+
it('flags a long verbatim copy (whole sentence lifted from the source)', () => {
|
|
19
|
+
const copied = 'Saudi Arabia will apply excise tax to sweetened drinks from January 2026, Gulf News reported.';
|
|
20
|
+
const r = verbatimOverlap(copied, [SOURCE]);
|
|
21
|
+
expect(r.longestRunWords).toBeGreaterThanOrEqual(12);
|
|
22
|
+
expect(r.overlapRatio).toBeGreaterThan(0.5);
|
|
23
|
+
expect(r.sample).toContain('excise tax to sweetened drinks');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('passes a genuine paraphrase of the same facts', () => {
|
|
27
|
+
const paraphrase =
|
|
28
|
+
'From 2026 the Kingdom broadens its excise regime to cover sugary beverages, a reminder that Gulf tax rules differ market by market.';
|
|
29
|
+
const r = verbatimOverlap(paraphrase, [SOURCE]);
|
|
30
|
+
expect(r.longestRunWords).toBeLessThan(6);
|
|
31
|
+
expect(r.overlapRatio).toBeLessThan(0.15);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('ignores a short shared phrase (< minRun words)', () => {
|
|
35
|
+
// "excise tax" is shared but only 2 words — below the 6-word shingle.
|
|
36
|
+
const r = verbatimOverlap('Companies weighing excise tax exposure should plan ahead.', [SOURCE]);
|
|
37
|
+
expect(r.longestRunWords).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is case- and punctuation-insensitive', () => {
|
|
41
|
+
const copied = 'SHOULD BE VIEWED AS A SHARED FRAMEWORK — with local variations!!!';
|
|
42
|
+
const r = verbatimOverlap(copied, [SOURCE]);
|
|
43
|
+
expect(r.longestRunWords).toBeGreaterThanOrEqual(6);
|
|
44
|
+
expect(r.sample).toContain('shared framework');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns zeros for empty candidate or no sources', () => {
|
|
48
|
+
expect(verbatimOverlap('', [SOURCE])).toEqual({ longestRunWords: 0, overlapRatio: 0, sample: '' });
|
|
49
|
+
expect(verbatimOverlap('anything here', [])).toEqual({ longestRunWords: 0, overlapRatio: 0, sample: '' });
|
|
50
|
+
expect(verbatimOverlap('anything here', [''])).toEqual({ longestRunWords: 0, overlapRatio: 0, sample: '' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('finds the copied span across multiple sources', () => {
|
|
54
|
+
const copied = 'a tax architecture that already captures selected products';
|
|
55
|
+
const r = verbatimOverlap(copied, ['unrelated text about camels', SOURCE]);
|
|
56
|
+
expect(r.longestRunWords).toBeGreaterThanOrEqual(7);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Keep this identical to the n8n judge's deterministic logic so client +
|
|
12
12
|
* pipeline always agree. No React, no DOM — importable from a Node/LLM route.
|
|
13
13
|
*/
|
|
14
|
+
import { verbatimOverlap } from './overlap';
|
|
14
15
|
|
|
15
16
|
/** A single check's outcome. Green / amber / red, encoded redundantly in the UI. */
|
|
16
17
|
export type CheckStatus = 'pass' | 'warn' | 'fail';
|
|
@@ -90,6 +91,18 @@ export interface ContentChecksConfig {
|
|
|
90
91
|
hypeWords?: string[];
|
|
91
92
|
/** Approved source hosts (no `www.`). REQUIRED to run the source check. */
|
|
92
93
|
approvedHosts?: string[];
|
|
94
|
+
/**
|
|
95
|
+
* The cited source articles' TEXT. REQUIRED to run the verbatim-overlap check —
|
|
96
|
+
* the guardrail against copying too much from a source. Omit it (or pass []) and
|
|
97
|
+
* the check is skipped (nothing to compare against).
|
|
98
|
+
*/
|
|
99
|
+
sourceTexts?: string[];
|
|
100
|
+
/** Longest verbatim run (words) that FAILS the overlap check (default 15). */
|
|
101
|
+
overlapFailRun?: number;
|
|
102
|
+
/** Longest verbatim run (words) that WARNS (default 8). */
|
|
103
|
+
overlapWarnRun?: number;
|
|
104
|
+
/** Copied fraction (0–1) that FAILS regardless of run length (default 0.25). */
|
|
105
|
+
overlapFailRatio?: number;
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
/**
|
|
@@ -186,6 +199,10 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
186
199
|
requireLink = true,
|
|
187
200
|
hypeWords = DEFAULT_HYPE_WORDS,
|
|
188
201
|
approvedHosts,
|
|
202
|
+
sourceTexts,
|
|
203
|
+
overlapFailRun = 15,
|
|
204
|
+
overlapWarnRun = 8,
|
|
205
|
+
overlapFailRatio = 0.25,
|
|
189
206
|
} = config;
|
|
190
207
|
|
|
191
208
|
// Word-count bands.
|
|
@@ -297,6 +314,51 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
297
314
|
}
|
|
298
315
|
}
|
|
299
316
|
|
|
317
|
+
// Verbatim overlap — the anti-plagiarism guardrail. Only when the caller supplied
|
|
318
|
+
// the cited source article TEXT. Scans blog + linkedin against the sources and
|
|
319
|
+
// reports the WORST offender (longest copied run / highest copied fraction).
|
|
320
|
+
if (sourceTexts !== undefined && sourceTexts.length > 0) {
|
|
321
|
+
const targets: Array<[keyof ContentFields, string | undefined]> = [
|
|
322
|
+
['blog', fields.blog],
|
|
323
|
+
['linkedin', fields.linkedin],
|
|
324
|
+
];
|
|
325
|
+
let worstRun = 0;
|
|
326
|
+
let worstRatio = 0;
|
|
327
|
+
let worstField: keyof ContentFields | null = null;
|
|
328
|
+
let worstSample = '';
|
|
329
|
+
for (const [field, text] of targets) {
|
|
330
|
+
if (typeof text !== 'string' || !text.trim()) continue;
|
|
331
|
+
if (worstField === null) worstField = field; // at least one field present → emit (even a clean pass)
|
|
332
|
+
const o = verbatimOverlap(text, sourceTexts);
|
|
333
|
+
if (o.longestRunWords > worstRun) {
|
|
334
|
+
worstRun = o.longestRunWords;
|
|
335
|
+
worstField = field;
|
|
336
|
+
worstSample = o.sample;
|
|
337
|
+
}
|
|
338
|
+
if (o.overlapRatio > worstRatio) worstRatio = o.overlapRatio;
|
|
339
|
+
}
|
|
340
|
+
if (worstField !== null) {
|
|
341
|
+
const status: CheckStatus =
|
|
342
|
+
worstRun >= overlapFailRun || worstRatio >= overlapFailRatio
|
|
343
|
+
? 'fail'
|
|
344
|
+
: worstRun >= overlapWarnRun
|
|
345
|
+
? 'warn'
|
|
346
|
+
: 'pass';
|
|
347
|
+
checks.push({
|
|
348
|
+
id: 'source-overlap',
|
|
349
|
+
label: 'Original wording (not copied from sources)',
|
|
350
|
+
status,
|
|
351
|
+
detail:
|
|
352
|
+
status === 'pass'
|
|
353
|
+
? `${worstRun}-word max run`
|
|
354
|
+
: `copies ${worstRun} consecutive words (${Math.round(worstRatio * 100)}% overlap)`,
|
|
355
|
+
...(status !== 'pass'
|
|
356
|
+
? { locations: [{ field: worstField, matches: worstSample ? [worstSample] : undefined }] }
|
|
357
|
+
: {}),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
300
362
|
return checks;
|
|
301
363
|
}
|
|
302
364
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verbatim-overlap detector (bd startsim-768w.18.16.1).
|
|
3
|
+
*
|
|
4
|
+
* Measures how much of a generated passage is copied VERBATIM from its source
|
|
5
|
+
* articles — the guardrail against plagiarism / SEO duplicate-content now that the
|
|
6
|
+
* writer is fed real article text. Pure + framework-free so it unit-tests in node.
|
|
7
|
+
*
|
|
8
|
+
* Method: word-shingle coverage. Both sides are normalised (lowercased, punctuation
|
|
9
|
+
* stripped, whitespace collapsed) to words; every `minRun`-word shingle of the
|
|
10
|
+
* sources goes in a set; each candidate position covered by an in-source shingle is
|
|
11
|
+
* marked "copied". From that we report the LONGEST consecutive copied run (words)
|
|
12
|
+
* and the fraction of the candidate that is copied. Short shared phrases (< minRun
|
|
13
|
+
* words) are deliberately invisible — quoting a handful of words is fine.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface OverlapResult {
|
|
17
|
+
/** Longest run of consecutive candidate words copied verbatim from a source. */
|
|
18
|
+
longestRunWords: number;
|
|
19
|
+
/** Fraction (0–1) of candidate words that sit inside a copied run. */
|
|
20
|
+
overlapRatio: number;
|
|
21
|
+
/** The longest copied span (normalised words), for a human detail. '' when none. */
|
|
22
|
+
sample: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Lowercase, strip punctuation, collapse whitespace → word list. */
|
|
26
|
+
export function normWords(s: string): string[] {
|
|
27
|
+
return String(s ?? '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Verbatim overlap of `candidate` against `sources`. `minRun` (default 6) is the
|
|
36
|
+
* shingle size — the shortest copied run that counts (so ≤5-word coincidences /
|
|
37
|
+
* short quotes are ignored). Returns zeros for empty candidate or no sources.
|
|
38
|
+
*/
|
|
39
|
+
export function verbatimOverlap(
|
|
40
|
+
candidate: string,
|
|
41
|
+
sources: string[],
|
|
42
|
+
minRun = 6,
|
|
43
|
+
): OverlapResult {
|
|
44
|
+
const cand = normWords(candidate);
|
|
45
|
+
const n = cand.length;
|
|
46
|
+
const empty: OverlapResult = { longestRunWords: 0, overlapRatio: 0, sample: '' };
|
|
47
|
+
if (n === 0 || minRun < 1) return empty;
|
|
48
|
+
|
|
49
|
+
const shingles = new Set<string>();
|
|
50
|
+
for (const src of sources ?? []) {
|
|
51
|
+
const w = normWords(src);
|
|
52
|
+
for (let i = 0; i + minRun <= w.length; i++) {
|
|
53
|
+
shingles.add(w.slice(i, i + minRun).join(' '));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (shingles.size === 0) return empty;
|
|
57
|
+
|
|
58
|
+
const covered = new Array<boolean>(n).fill(false);
|
|
59
|
+
for (let i = 0; i + minRun <= n; i++) {
|
|
60
|
+
if (shingles.has(cand.slice(i, i + minRun).join(' '))) {
|
|
61
|
+
for (let j = i; j < i + minRun; j++) covered[j] = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let longest = 0;
|
|
66
|
+
let coveredCount = 0;
|
|
67
|
+
let cur = 0;
|
|
68
|
+
let curStart = 0;
|
|
69
|
+
let bestStart = -1;
|
|
70
|
+
let bestEnd = -1;
|
|
71
|
+
for (let i = 0; i < n; i++) {
|
|
72
|
+
if (covered[i]) {
|
|
73
|
+
coveredCount++;
|
|
74
|
+
if (cur === 0) curStart = i;
|
|
75
|
+
cur++;
|
|
76
|
+
if (cur > longest) {
|
|
77
|
+
longest = cur;
|
|
78
|
+
bestStart = curStart;
|
|
79
|
+
bestEnd = i;
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
cur = 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
longestRunWords: longest,
|
|
88
|
+
overlapRatio: coveredCount / n,
|
|
89
|
+
sample: longest > 0 ? cand.slice(bestStart, bestEnd + 1).join(' ') : '',
|
|
90
|
+
};
|
|
91
|
+
}
|