@startsimpli/ui 0.4.71 → 0.4.72
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
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
isChosen,
|
|
27
27
|
gradeWords,
|
|
28
28
|
wordTargets,
|
|
29
|
+
sourceOverlapCheck,
|
|
29
30
|
type DraftGroup,
|
|
30
31
|
type DraftSource,
|
|
31
32
|
type CheckStatus,
|
|
@@ -108,10 +109,16 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
async function markReady(record: EntityRecord) {
|
|
112
|
+
async function markReady(record: EntityRecord, reason?: string) {
|
|
112
113
|
setBusyKey(String(record.id));
|
|
113
114
|
try {
|
|
114
|
-
await writeEntity(record, {
|
|
115
|
+
await writeEntity(record, {
|
|
116
|
+
status: 'ready',
|
|
117
|
+
chosen: true,
|
|
118
|
+
// A source-overlap fail doesn't hard-block the accept — the reviewer can
|
|
119
|
+
// override with a reason (768w.18.16.2). Record it so the override is auditable.
|
|
120
|
+
...(reason ? { ready_override_reason: reason } : {}),
|
|
121
|
+
});
|
|
115
122
|
await qc.invalidateQueries({ queryKey: ['entities', 'draft'] });
|
|
116
123
|
} catch (err) {
|
|
117
124
|
notify.error(err instanceof Error ? err.message : 'Could not mark ready.');
|
|
@@ -185,7 +192,7 @@ function StoryGroup({
|
|
|
185
192
|
openId: string | null;
|
|
186
193
|
onToggleOpen: (id: string) => void;
|
|
187
194
|
onPick: (g: DraftGroup, c: EntityRecord) => Promise<void>;
|
|
188
|
-
onMarkReady: (c: EntityRecord) => Promise<void>;
|
|
195
|
+
onMarkReady: (c: EntityRecord, reason?: string) => Promise<void>;
|
|
189
196
|
busy: boolean;
|
|
190
197
|
busyId: string | null;
|
|
191
198
|
}) {
|
|
@@ -228,7 +235,7 @@ function StoryGroup({
|
|
|
228
235
|
key={`rev-${c.id}`}
|
|
229
236
|
record={c}
|
|
230
237
|
contentType={group.contentType}
|
|
231
|
-
onMarkReady={() => onMarkReady(c)}
|
|
238
|
+
onMarkReady={(reason) => onMarkReady(c, reason)}
|
|
232
239
|
marking={busyId === String(c.id)}
|
|
233
240
|
/>
|
|
234
241
|
))}
|
|
@@ -303,7 +310,7 @@ function ContentReview({
|
|
|
303
310
|
}: {
|
|
304
311
|
record: EntityRecord;
|
|
305
312
|
contentType: string;
|
|
306
|
-
onMarkReady: () => void;
|
|
313
|
+
onMarkReady: (reason?: string) => void;
|
|
307
314
|
marking: boolean;
|
|
308
315
|
}) {
|
|
309
316
|
const blog = field(record, 'blog');
|
|
@@ -313,6 +320,14 @@ function ContentReview({
|
|
|
313
320
|
const checks = obj(readData(record.data, 'auto_checks'));
|
|
314
321
|
const ready = field(record, 'status') === 'ready';
|
|
315
322
|
|
|
323
|
+
// Verbatim-overlap guardrail (768w.18.16.2): recompute the source-overlap check
|
|
324
|
+
// client-side against the cited article text on the record. Only surfaces when
|
|
325
|
+
// the draft actually carries source_texts. A fail gates the accept below.
|
|
326
|
+
const overlap = sourceOverlapCheck(record);
|
|
327
|
+
const overlapFail = overlap?.status === 'fail';
|
|
328
|
+
const [override, setOverride] = useState('');
|
|
329
|
+
const blocked = overlapFail && !ready && override.trim().length < 4;
|
|
330
|
+
|
|
316
331
|
const tags = ((): string[] => {
|
|
317
332
|
const t = seo.tags;
|
|
318
333
|
if (Array.isArray(t)) return t.map(String);
|
|
@@ -431,16 +446,55 @@ function ContentReview({
|
|
|
431
446
|
sources {approvedSources ? 'approved' : 'unverified'}
|
|
432
447
|
</Badge>
|
|
433
448
|
)}
|
|
449
|
+
{overlap && (
|
|
450
|
+
<Badge
|
|
451
|
+
variant="outline"
|
|
452
|
+
title={overlap.detail}
|
|
453
|
+
className={`text-[10px] ${
|
|
454
|
+
overlap.status === 'pass'
|
|
455
|
+
? 'text-emerald-700'
|
|
456
|
+
: overlap.status === 'warn'
|
|
457
|
+
? 'text-amber-700'
|
|
458
|
+
: 'text-rose-700'
|
|
459
|
+
}`}
|
|
460
|
+
>
|
|
461
|
+
{overlap.status === 'pass' ? 'original wording' : `source overlap: ${overlap.detail}`}
|
|
462
|
+
</Badge>
|
|
463
|
+
)}
|
|
434
464
|
</div>
|
|
435
465
|
</section>
|
|
436
466
|
|
|
437
|
-
|
|
467
|
+
{overlapFail && !ready && (
|
|
468
|
+
<section>
|
|
469
|
+
<div className="rounded-md border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800">
|
|
470
|
+
<p className="flex items-center gap-1 font-medium">
|
|
471
|
+
<AlertTriangle className="h-3.5 w-3.5" /> This draft copies too much from its source ({overlap?.detail}).
|
|
472
|
+
</p>
|
|
473
|
+
<p className="mt-1 text-rose-700">Paraphrase the flagged run, or give a reason to publish it anyway.</p>
|
|
474
|
+
<textarea
|
|
475
|
+
value={override}
|
|
476
|
+
onChange={(e) => setOverride(e.target.value)}
|
|
477
|
+
placeholder="Reason to publish despite the overlap…"
|
|
478
|
+
className="mt-2 w-full rounded border border-rose-200 bg-white p-2 text-xs text-gray-800"
|
|
479
|
+
rows={2}
|
|
480
|
+
/>
|
|
481
|
+
</div>
|
|
482
|
+
</section>
|
|
483
|
+
)}
|
|
484
|
+
|
|
485
|
+
<Button
|
|
486
|
+
onClick={() => onMarkReady(overlapFail ? override.trim() : undefined)}
|
|
487
|
+
disabled={marking || ready || blocked}
|
|
488
|
+
className="w-full"
|
|
489
|
+
>
|
|
438
490
|
{ready ? (
|
|
439
491
|
<>
|
|
440
492
|
<CheckCircle2 className="mr-1.5 h-4 w-4" /> Ready to post
|
|
441
493
|
</>
|
|
442
494
|
) : marking ? (
|
|
443
495
|
'Saving…'
|
|
496
|
+
) : overlapFail ? (
|
|
497
|
+
'Mark ready to post anyway'
|
|
444
498
|
) : (
|
|
445
499
|
'Mark ready to post'
|
|
446
500
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets } from '../drafts';
|
|
1
|
+
import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck } from '../drafts';
|
|
2
2
|
import type { EntityRecord } from '../types';
|
|
3
3
|
|
|
4
4
|
function rec(id: string, name: string, data: Record<string, unknown>): EntityRecord {
|
|
@@ -89,3 +89,35 @@ describe('gradeWords', () => {
|
|
|
89
89
|
expect(gradeWords(100, wordTargets('lead_magnet').linkedin)).toBe('none');
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
describe('sourceOverlapCheck (768w.18.16.2 wiring)', () => {
|
|
94
|
+
const SOURCE =
|
|
95
|
+
'A Houthi threat to impose a naval blockade against Saudi Arabia in the Red Sea could significantly ' +
|
|
96
|
+
'widen the Iran war and strain a U.S. military already focused on stopping attacks across the region.';
|
|
97
|
+
|
|
98
|
+
it('returns null when the draft carries no source_texts', () => {
|
|
99
|
+
expect(sourceOverlapCheck(rec('d1', 'Draft', { blog: SOURCE }))).toBeNull();
|
|
100
|
+
expect(sourceOverlapCheck(rec('d2', 'Draft', { blog: SOURCE, source_texts: [] }))).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('FAILS when the blog copies a long verbatim run from a source_text', () => {
|
|
104
|
+
const check = sourceOverlapCheck(rec('d3', 'Draft', { blog: `Intro. ${SOURCE} Outro.`, source_texts: [SOURCE] }));
|
|
105
|
+
expect(check).not.toBeNull();
|
|
106
|
+
expect(check!.id).toBe('source-overlap');
|
|
107
|
+
expect(check!.status).toBe('fail');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('PASSES when the blog is original prose against the source_text', () => {
|
|
111
|
+
const original =
|
|
112
|
+
'Foreign operators with Gulf shipping exposure should reassess route reliability, war-risk premiums, ' +
|
|
113
|
+
'and contract terms as regional tensions evolve this quarter.';
|
|
114
|
+
const check = sourceOverlapCheck(rec('d4', 'Draft', { blog: original, source_texts: [SOURCE] }));
|
|
115
|
+
expect(check).not.toBeNull();
|
|
116
|
+
expect(check!.status).toBe('pass');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reads camelCased sourceTexts too', () => {
|
|
120
|
+
const check = sourceOverlapCheck(rec('d5', 'Draft', { blog: `x ${SOURCE}`, sourceTexts: [SOURCE] }));
|
|
121
|
+
expect(check!.status).toBe('fail');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { EntityRecord } from './types';
|
|
11
11
|
import { readData } from './data';
|
|
12
|
+
import { runContentChecks, type ContentCheck } from '../validation';
|
|
12
13
|
|
|
13
14
|
export interface DraftSource {
|
|
14
15
|
url: string;
|
|
@@ -158,3 +159,20 @@ export function gradeWords(count: number, [lo, hi]: [number, number]): CheckStat
|
|
|
158
159
|
if (count > hi) return 'high';
|
|
159
160
|
return 'ok';
|
|
160
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The verbatim-overlap guardrail (768w.18.16.2) for a single draft. Runs the
|
|
165
|
+
* shared `source-overlap` check over the draft's blog + linkedin against the
|
|
166
|
+
* cited source article TEXT stamped on the record (`data.source_texts`, populated
|
|
167
|
+
* by the writer's Stamp Source Texts step). Returns null when the draft carries
|
|
168
|
+
* no source text — there is no ground truth to compare against, so there is
|
|
169
|
+
* nothing to surface. Pure, so the wiring is unit-testable without React.
|
|
170
|
+
*/
|
|
171
|
+
export function sourceOverlapCheck(r: EntityRecord): ContentCheck | null {
|
|
172
|
+
const raw = readData(r.data, 'source_texts');
|
|
173
|
+
const sourceTexts = Array.isArray(raw) ? raw.map(str).filter(Boolean) : [];
|
|
174
|
+
if (sourceTexts.length === 0) return null;
|
|
175
|
+
const blog = str(readData(r.data, 'blog'));
|
|
176
|
+
const linkedin = str(readData(r.data, 'linkedin'));
|
|
177
|
+
return runContentChecks({ blog, linkedin }, { sourceTexts }).find((c) => c.id === 'source-overlap') ?? null;
|
|
178
|
+
}
|