@startsimpli/ui 0.4.71 → 0.4.73
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.
|
|
3
|
+
"version": "0.4.73",
|
|
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.45",
|
|
46
|
+
"@startsimpli/auth": "^0.4.31"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@startsimpli/api": {
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
isChosen,
|
|
27
27
|
gradeWords,
|
|
28
28
|
wordTargets,
|
|
29
|
+
sourceOverlapCheck,
|
|
30
|
+
pickChanges,
|
|
29
31
|
type DraftGroup,
|
|
30
32
|
type DraftSource,
|
|
31
33
|
type CheckStatus,
|
|
@@ -92,14 +94,7 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
92
94
|
async function pick(group: DraftGroup, cand: EntityRecord) {
|
|
93
95
|
setBusyKey(group.key);
|
|
94
96
|
try {
|
|
95
|
-
await Promise.all(
|
|
96
|
-
group.candidates.map((c) =>
|
|
97
|
-
writeEntity(c, {
|
|
98
|
-
chosen: String(c.id) === String(cand.id),
|
|
99
|
-
status: String(c.id) === String(cand.id) ? 'selected' : field(c, 'status') === 'ready' ? 'ready' : '',
|
|
100
|
-
}),
|
|
101
|
-
),
|
|
102
|
-
);
|
|
97
|
+
await Promise.all(group.candidates.map((c) => writeEntity(c, pickChanges(c.id, cand.id))));
|
|
103
98
|
await qc.invalidateQueries({ queryKey: ['entities', 'draft'] });
|
|
104
99
|
} catch (err) {
|
|
105
100
|
notify.error(err instanceof Error ? err.message : 'Could not save the pick.');
|
|
@@ -108,10 +103,16 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
108
103
|
}
|
|
109
104
|
}
|
|
110
105
|
|
|
111
|
-
async function markReady(record: EntityRecord) {
|
|
106
|
+
async function markReady(record: EntityRecord, reason?: string) {
|
|
112
107
|
setBusyKey(String(record.id));
|
|
113
108
|
try {
|
|
114
|
-
await writeEntity(record, {
|
|
109
|
+
await writeEntity(record, {
|
|
110
|
+
status: 'ready',
|
|
111
|
+
chosen: true,
|
|
112
|
+
// A source-overlap fail doesn't hard-block the accept — the reviewer can
|
|
113
|
+
// override with a reason (768w.18.16.2). Record it so the override is auditable.
|
|
114
|
+
...(reason ? { ready_override_reason: reason } : {}),
|
|
115
|
+
});
|
|
115
116
|
await qc.invalidateQueries({ queryKey: ['entities', 'draft'] });
|
|
116
117
|
} catch (err) {
|
|
117
118
|
notify.error(err instanceof Error ? err.message : 'Could not mark ready.');
|
|
@@ -185,7 +186,7 @@ function StoryGroup({
|
|
|
185
186
|
openId: string | null;
|
|
186
187
|
onToggleOpen: (id: string) => void;
|
|
187
188
|
onPick: (g: DraftGroup, c: EntityRecord) => Promise<void>;
|
|
188
|
-
onMarkReady: (c: EntityRecord) => Promise<void>;
|
|
189
|
+
onMarkReady: (c: EntityRecord, reason?: string) => Promise<void>;
|
|
189
190
|
busy: boolean;
|
|
190
191
|
busyId: string | null;
|
|
191
192
|
}) {
|
|
@@ -228,7 +229,7 @@ function StoryGroup({
|
|
|
228
229
|
key={`rev-${c.id}`}
|
|
229
230
|
record={c}
|
|
230
231
|
contentType={group.contentType}
|
|
231
|
-
onMarkReady={() => onMarkReady(c)}
|
|
232
|
+
onMarkReady={(reason) => onMarkReady(c, reason)}
|
|
232
233
|
marking={busyId === String(c.id)}
|
|
233
234
|
/>
|
|
234
235
|
))}
|
|
@@ -303,7 +304,7 @@ function ContentReview({
|
|
|
303
304
|
}: {
|
|
304
305
|
record: EntityRecord;
|
|
305
306
|
contentType: string;
|
|
306
|
-
onMarkReady: () => void;
|
|
307
|
+
onMarkReady: (reason?: string) => void;
|
|
307
308
|
marking: boolean;
|
|
308
309
|
}) {
|
|
309
310
|
const blog = field(record, 'blog');
|
|
@@ -313,6 +314,14 @@ function ContentReview({
|
|
|
313
314
|
const checks = obj(readData(record.data, 'auto_checks'));
|
|
314
315
|
const ready = field(record, 'status') === 'ready';
|
|
315
316
|
|
|
317
|
+
// Verbatim-overlap guardrail (768w.18.16.2): recompute the source-overlap check
|
|
318
|
+
// client-side against the cited article text on the record. Only surfaces when
|
|
319
|
+
// the draft actually carries source_texts. A fail gates the accept below.
|
|
320
|
+
const overlap = sourceOverlapCheck(record);
|
|
321
|
+
const overlapFail = overlap?.status === 'fail';
|
|
322
|
+
const [override, setOverride] = useState('');
|
|
323
|
+
const blocked = overlapFail && !ready && override.trim().length < 4;
|
|
324
|
+
|
|
316
325
|
const tags = ((): string[] => {
|
|
317
326
|
const t = seo.tags;
|
|
318
327
|
if (Array.isArray(t)) return t.map(String);
|
|
@@ -431,16 +440,55 @@ function ContentReview({
|
|
|
431
440
|
sources {approvedSources ? 'approved' : 'unverified'}
|
|
432
441
|
</Badge>
|
|
433
442
|
)}
|
|
443
|
+
{overlap && (
|
|
444
|
+
<Badge
|
|
445
|
+
variant="outline"
|
|
446
|
+
title={overlap.detail}
|
|
447
|
+
className={`text-[10px] ${
|
|
448
|
+
overlap.status === 'pass'
|
|
449
|
+
? 'text-emerald-700'
|
|
450
|
+
: overlap.status === 'warn'
|
|
451
|
+
? 'text-amber-700'
|
|
452
|
+
: 'text-rose-700'
|
|
453
|
+
}`}
|
|
454
|
+
>
|
|
455
|
+
{overlap.status === 'pass' ? 'original wording' : `source overlap: ${overlap.detail}`}
|
|
456
|
+
</Badge>
|
|
457
|
+
)}
|
|
434
458
|
</div>
|
|
435
459
|
</section>
|
|
436
460
|
|
|
437
|
-
|
|
461
|
+
{overlapFail && !ready && (
|
|
462
|
+
<section>
|
|
463
|
+
<div className="rounded-md border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800">
|
|
464
|
+
<p className="flex items-center gap-1 font-medium">
|
|
465
|
+
<AlertTriangle className="h-3.5 w-3.5" /> This draft copies too much from its source ({overlap?.detail}).
|
|
466
|
+
</p>
|
|
467
|
+
<p className="mt-1 text-rose-700">Paraphrase the flagged run, or give a reason to publish it anyway.</p>
|
|
468
|
+
<textarea
|
|
469
|
+
value={override}
|
|
470
|
+
onChange={(e) => setOverride(e.target.value)}
|
|
471
|
+
placeholder="Reason to publish despite the overlap…"
|
|
472
|
+
className="mt-2 w-full rounded border border-rose-200 bg-white p-2 text-xs text-gray-800"
|
|
473
|
+
rows={2}
|
|
474
|
+
/>
|
|
475
|
+
</div>
|
|
476
|
+
</section>
|
|
477
|
+
)}
|
|
478
|
+
|
|
479
|
+
<Button
|
|
480
|
+
onClick={() => onMarkReady(overlapFail ? override.trim() : undefined)}
|
|
481
|
+
disabled={marking || ready || blocked}
|
|
482
|
+
className="w-full"
|
|
483
|
+
>
|
|
438
484
|
{ready ? (
|
|
439
485
|
<>
|
|
440
486
|
<CheckCircle2 className="mr-1.5 h-4 w-4" /> Ready to post
|
|
441
487
|
</>
|
|
442
488
|
) : marking ? (
|
|
443
489
|
'Saving…'
|
|
490
|
+
) : overlapFail ? (
|
|
491
|
+
'Mark ready to post anyway'
|
|
444
492
|
) : (
|
|
445
493
|
'Mark ready to post'
|
|
446
494
|
)}
|
|
@@ -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, pickChanges } 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,48 @@ 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
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('pickChanges (startsim-yvd3 — pick must not write an invalid status)', () => {
|
|
126
|
+
it('marks the picked candidate chosen and writes NO status key', () => {
|
|
127
|
+
const picked = pickChanges('a', 'a');
|
|
128
|
+
expect(picked).toEqual({ chosen: true });
|
|
129
|
+
expect('status' in picked).toBe(false); // status enum is the tenant's; picking must not touch it
|
|
130
|
+
expect(pickChanges('b', 'a')).toEqual({ chosen: false });
|
|
131
|
+
});
|
|
132
|
+
it('compares ids as strings (handles number vs string ids)', () => {
|
|
133
|
+
expect(pickChanges(7 as unknown as string, '7')).toEqual({ chosen: true });
|
|
134
|
+
expect(pickChanges(7 as unknown as string, 8 as unknown as string)).toEqual({ chosen: false });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -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,31 @@ 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 record change a Pick applies to ONE candidate: mark it chosen (true for the
|
|
165
|
+
* picked candidate, false for the rest). Picking is a selection flag — it must NOT
|
|
166
|
+
* write a lifecycle `status`. The tenant constrains draft `status` to an enum, so
|
|
167
|
+
* writing 'selected' (or '') 400s the whole pick (startsim-yvd3); the "picked"
|
|
168
|
+
* badge already keys off `chosen` via isChosen(). Pure, so it's unit-tested.
|
|
169
|
+
*/
|
|
170
|
+
export function pickChanges(candidateId: EntityRecord['id'], pickedId: EntityRecord['id']): { chosen: boolean } {
|
|
171
|
+
return { chosen: String(candidateId) === String(pickedId) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The verbatim-overlap guardrail (768w.18.16.2) for a single draft. Runs the
|
|
176
|
+
* shared `source-overlap` check over the draft's blog + linkedin against the
|
|
177
|
+
* cited source article TEXT stamped on the record (`data.source_texts`, populated
|
|
178
|
+
* by the writer's Stamp Source Texts step). Returns null when the draft carries
|
|
179
|
+
* no source text — there is no ground truth to compare against, so there is
|
|
180
|
+
* nothing to surface. Pure, so the wiring is unit-testable without React.
|
|
181
|
+
*/
|
|
182
|
+
export function sourceOverlapCheck(r: EntityRecord): ContentCheck | null {
|
|
183
|
+
const raw = readData(r.data, 'source_texts');
|
|
184
|
+
const sourceTexts = Array.isArray(raw) ? raw.map(str).filter(Boolean) : [];
|
|
185
|
+
if (sourceTexts.length === 0) return null;
|
|
186
|
+
const blog = str(readData(r.data, 'blog'));
|
|
187
|
+
const linkedin = str(readData(r.data, 'linkedin'));
|
|
188
|
+
return runContentChecks({ blog, linkedin }, { sourceTexts }).find((c) => c.id === 'source-overlap') ?? null;
|
|
189
|
+
}
|