@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.71",
3
+ "version": "0.4.72",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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, { status: 'ready', chosen: true });
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
- <Button onClick={onMarkReady} disabled={marking || ready} className="w-full">
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
+ }