@startsimpli/ui 0.4.52 → 0.4.53
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/catalog/catalog.ts +9 -0
- package/src/components/index.ts +5 -0
- package/src/components/validation/ValidationChecklist.tsx +270 -0
- package/src/components/validation/__tests__/ValidationChecklist.test.tsx +84 -0
- package/src/components/validation/__tests__/checks.test.ts +191 -0
- package/src/components/validation/checks.ts +240 -0
- package/src/components/validation/index.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.53",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
42
42
|
"react": "^18.0.0 || ^19.0.0",
|
|
43
43
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
44
|
-
"@startsimpli/
|
|
45
|
-
"@startsimpli/
|
|
44
|
+
"@startsimpli/api": "0.5.44",
|
|
45
|
+
"@startsimpli/auth": "0.4.29"
|
|
46
46
|
},
|
|
47
47
|
"peerDependenciesMeta": {
|
|
48
48
|
"@startsimpli/api": {
|
package/src/catalog/catalog.ts
CHANGED
|
@@ -181,6 +181,15 @@ export const UI_CATALOG: CatalogEntry[] = [
|
|
|
181
181
|
dataNeeds: 'A type with long-text / structured (json) fields to edit (optionally a source-list field).',
|
|
182
182
|
source: { export: 'DocumentEditor', from: '@startsimpli/ui/document-editor' },
|
|
183
183
|
},
|
|
184
|
+
{
|
|
185
|
+
id: 'validation-checklist',
|
|
186
|
+
category: 'component',
|
|
187
|
+
title: 'Validation checklist',
|
|
188
|
+
description:
|
|
189
|
+
"A pass/warn/fail review panel for an AI-generated content record: renders the deterministic auto-checks (word-count bands, hype-word scan, approved-source allow-list) as a green/amber/red checklist, surfaces the buried AI judge verdict (per-dimension scores + an expandable issues list), and supports a reasoned reviewer override. Its runContentChecks util RE-RUNS the deterministic checks over the reviewer's *edited* fields, so validation tracks the current draft, not the stale original — pair it with the document-editor to review-and-accept a content draft.",
|
|
190
|
+
dataNeeds: 'A content type carrying a judge verdict + auto-checks (and the editable content fields to recheck).',
|
|
191
|
+
source: { export: 'ValidationChecklist', from: '@startsimpli/ui' },
|
|
192
|
+
},
|
|
184
193
|
{
|
|
185
194
|
id: 'chat',
|
|
186
195
|
category: 'component',
|
package/src/components/index.ts
CHANGED
|
@@ -168,3 +168,8 @@ export type {
|
|
|
168
168
|
|
|
169
169
|
// Record detail read-view
|
|
170
170
|
export * from './record-detail'
|
|
171
|
+
|
|
172
|
+
// Content validation toolkit (768w.16.10.1) — ValidationChecklist + the pure
|
|
173
|
+
// runContentChecks recompute util. Light (React + lucide only), so it ships on
|
|
174
|
+
// the main barrel rather than a subpath.
|
|
175
|
+
export * from './validation'
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
AlertTriangle,
|
|
5
|
+
XCircle,
|
|
6
|
+
ChevronDown,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
ShieldCheck,
|
|
9
|
+
ShieldAlert,
|
|
10
|
+
} from 'lucide-react';
|
|
11
|
+
import { cn } from '../../lib/utils';
|
|
12
|
+
import { overallStatus, type CheckStatus, type ContentCheck } from './checks';
|
|
13
|
+
|
|
14
|
+
/** Redundant (color + icon + label) tone per check status — never color alone. */
|
|
15
|
+
const CHECK_TONE: Record<CheckStatus, { label: string; dot: string; text: string; softBg: string; Icon: typeof CheckCircle2 }> = {
|
|
16
|
+
pass: { label: 'Passing', dot: 'bg-emerald-500', text: 'text-emerald-700', softBg: 'bg-emerald-50', Icon: CheckCircle2 },
|
|
17
|
+
warn: { label: 'Warnings', dot: 'bg-amber-500', text: 'text-amber-700', softBg: 'bg-amber-50', Icon: AlertTriangle },
|
|
18
|
+
fail: { label: 'Failing', dot: 'bg-red-500', text: 'text-red-700', softBg: 'bg-red-50', Icon: XCircle },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** One issue the AI judge raised against a guardrail. */
|
|
22
|
+
export interface JudgeIssue {
|
|
23
|
+
guardrail?: string;
|
|
24
|
+
severity?: string;
|
|
25
|
+
problem?: string;
|
|
26
|
+
fix?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The AI judge's output stored alongside the content record. */
|
|
30
|
+
export interface JudgeVerdict {
|
|
31
|
+
/** Overall verdict word (e.g. "approve", "revise", "reject"). */
|
|
32
|
+
verdict?: string;
|
|
33
|
+
/** Per-dimension scores, keyed by dimension name. */
|
|
34
|
+
scores?: Record<string, unknown>;
|
|
35
|
+
/** Specific problems the judge flagged. */
|
|
36
|
+
issues?: JudgeIssue[];
|
|
37
|
+
/** One-line prose summary. */
|
|
38
|
+
summary?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reviewer's manual override of the automated checks. */
|
|
42
|
+
export interface ValidationOverride {
|
|
43
|
+
overridden: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ValidationChecklistProps {
|
|
48
|
+
/** The (recomputed) deterministic checks to render. */
|
|
49
|
+
checks: ContentCheck[];
|
|
50
|
+
/** The AI judge output — renders a verdict badge, scores, and issues. */
|
|
51
|
+
judgeVerdict?: JudgeVerdict;
|
|
52
|
+
/** Current override state. When set, the overall pill reflects it. */
|
|
53
|
+
override?: ValidationOverride;
|
|
54
|
+
/** Provide to make the override affordance interactive. Omit → read-only. */
|
|
55
|
+
onOverride?: (next: ValidationOverride) => void;
|
|
56
|
+
title?: string;
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A tone pill naming the roll-up state. */
|
|
61
|
+
function StatusPill({ status, label }: { status: CheckStatus; label?: string }) {
|
|
62
|
+
const tone = CHECK_TONE[status];
|
|
63
|
+
const Icon = tone.Icon;
|
|
64
|
+
return (
|
|
65
|
+
<span className={cn('inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium', tone.softBg, tone.text)}>
|
|
66
|
+
<Icon className="w-3.5 h-3.5" aria-hidden="true" />
|
|
67
|
+
{label ?? tone.label}
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Colour a judge verdict word without needing to know the exact vocabulary. */
|
|
73
|
+
function verdictStatus(verdict?: string): CheckStatus {
|
|
74
|
+
const v = (verdict ?? '').toLowerCase();
|
|
75
|
+
if (/pass|approve|accept|ok|good/.test(v)) return 'pass';
|
|
76
|
+
if (/fail|reject|block|deny|bad/.test(v)) return 'fail';
|
|
77
|
+
return 'warn';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Presentational, bring-your-own-data validation panel (768w.16.10.1).
|
|
82
|
+
*
|
|
83
|
+
* Surfaces the buried machine review of an AI-generated content record as a
|
|
84
|
+
* scannable pass/warn/fail checklist plus the AI judge's verdict — so a Foundry
|
|
85
|
+
* tenant can accept/reject in one glance. Stateless except for local UI toggles
|
|
86
|
+
* (expand issues, edit override reason); the app owns the data and persistence.
|
|
87
|
+
*/
|
|
88
|
+
export function ValidationChecklist({
|
|
89
|
+
checks,
|
|
90
|
+
judgeVerdict,
|
|
91
|
+
override,
|
|
92
|
+
onOverride,
|
|
93
|
+
title = 'Validation',
|
|
94
|
+
className,
|
|
95
|
+
}: ValidationChecklistProps) {
|
|
96
|
+
const overall = overallStatus(checks);
|
|
97
|
+
const [issuesOpen, setIssuesOpen] = React.useState(false);
|
|
98
|
+
const [overrideOpen, setOverrideOpen] = React.useState(false);
|
|
99
|
+
const [reason, setReason] = React.useState(override?.reason ?? '');
|
|
100
|
+
|
|
101
|
+
const counts = React.useMemo(
|
|
102
|
+
() => ({
|
|
103
|
+
pass: checks.filter((c) => c.status === 'pass').length,
|
|
104
|
+
warn: checks.filter((c) => c.status === 'warn').length,
|
|
105
|
+
fail: checks.filter((c) => c.status === 'fail').length,
|
|
106
|
+
}),
|
|
107
|
+
[checks],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const issues = judgeVerdict?.issues ?? [];
|
|
111
|
+
const scoreEntries = judgeVerdict?.scores ? Object.entries(judgeVerdict.scores) : [];
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn('rounded-xl border bg-card text-card-foreground shadow p-5 space-y-4', className)}>
|
|
115
|
+
{/* Header: title + overall pill */}
|
|
116
|
+
<div className="flex items-center justify-between gap-3">
|
|
117
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
118
|
+
<ShieldCheck className="w-4 h-4 text-gray-400 flex-shrink-0" aria-hidden="true" />
|
|
119
|
+
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
122
|
+
{override?.overridden && (
|
|
123
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
|
124
|
+
<ShieldAlert className="w-3 h-3" aria-hidden="true" />
|
|
125
|
+
Overridden
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
<StatusPill status={override?.overridden ? 'pass' : overall} label={`${counts.pass}/${checks.length} passing`} />
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Checks list */}
|
|
133
|
+
{checks.length === 0 ? (
|
|
134
|
+
<p className="text-sm text-gray-500 py-2 text-center">No checks to show.</p>
|
|
135
|
+
) : (
|
|
136
|
+
<ul className="space-y-1.5">
|
|
137
|
+
{checks.map((check) => {
|
|
138
|
+
const tone = CHECK_TONE[check.status];
|
|
139
|
+
const Icon = tone.Icon;
|
|
140
|
+
return (
|
|
141
|
+
<li key={check.id} className="flex items-center gap-2.5 text-sm">
|
|
142
|
+
<span className={cn('inline-block w-2 h-2 rounded-full flex-shrink-0', tone.dot)} aria-hidden="true" />
|
|
143
|
+
<Icon className={cn('w-4 h-4 flex-shrink-0', tone.text)} aria-hidden="true" />
|
|
144
|
+
<span className="text-gray-900">{check.label}</span>
|
|
145
|
+
{check.detail && <span className="text-gray-500 truncate">— {check.detail}</span>}
|
|
146
|
+
<span className="sr-only">{tone.label}</span>
|
|
147
|
+
</li>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</ul>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* AI judge section */}
|
|
154
|
+
{judgeVerdict && (
|
|
155
|
+
<div className="rounded-lg border bg-gray-50/60 p-3 space-y-3">
|
|
156
|
+
<div className="flex items-center justify-between gap-3">
|
|
157
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">AI judge</span>
|
|
158
|
+
{judgeVerdict.verdict && <StatusPill status={verdictStatus(judgeVerdict.verdict)} label={judgeVerdict.verdict} />}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{judgeVerdict.summary && <p className="text-sm text-gray-700">{judgeVerdict.summary}</p>}
|
|
162
|
+
|
|
163
|
+
{scoreEntries.length > 0 && (
|
|
164
|
+
<dl className="flex flex-wrap gap-2">
|
|
165
|
+
{scoreEntries.map(([dim, val]) => (
|
|
166
|
+
<div key={dim} className="rounded-md bg-white border px-2 py-1 text-xs">
|
|
167
|
+
<dt className="text-gray-500">{dim}</dt>
|
|
168
|
+
<dd className="font-medium text-gray-900">{String(val)}</dd>
|
|
169
|
+
</div>
|
|
170
|
+
))}
|
|
171
|
+
</dl>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{issues.length > 0 && (
|
|
175
|
+
<div>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={() => setIssuesOpen((o) => !o)}
|
|
179
|
+
className="flex items-center gap-1 text-xs font-medium text-gray-600 hover:text-gray-900"
|
|
180
|
+
aria-expanded={issuesOpen}
|
|
181
|
+
>
|
|
182
|
+
{issuesOpen ? <ChevronDown className="w-3.5 h-3.5" aria-hidden="true" /> : <ChevronRight className="w-3.5 h-3.5" aria-hidden="true" />}
|
|
183
|
+
{issues.length} issue{issues.length === 1 ? '' : 's'}
|
|
184
|
+
</button>
|
|
185
|
+
{issuesOpen && (
|
|
186
|
+
<ul className="mt-2 space-y-2">
|
|
187
|
+
{issues.map((issue, i) => (
|
|
188
|
+
<li key={i} className="rounded-md border bg-white p-2 text-xs space-y-1">
|
|
189
|
+
<div className="flex items-center gap-2">
|
|
190
|
+
{issue.guardrail && <span className="font-medium text-gray-900">{issue.guardrail}</span>}
|
|
191
|
+
{issue.severity && (
|
|
192
|
+
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">{issue.severity}</span>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
{issue.problem && <p className="text-gray-700">{issue.problem}</p>}
|
|
196
|
+
{issue.fix && <p className="text-gray-500">Fix: {issue.fix}</p>}
|
|
197
|
+
</li>
|
|
198
|
+
))}
|
|
199
|
+
</ul>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{/* Override affordance — interactive only when onOverride is provided */}
|
|
207
|
+
{onOverride && (
|
|
208
|
+
<div className="border-t pt-3">
|
|
209
|
+
{override?.overridden ? (
|
|
210
|
+
<div className="flex items-start justify-between gap-3 text-sm">
|
|
211
|
+
<div className="min-w-0">
|
|
212
|
+
<p className="font-medium text-gray-900">Checks overridden</p>
|
|
213
|
+
{override.reason && <p className="text-gray-500 break-words">{override.reason}</p>}
|
|
214
|
+
</div>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => onOverride({ overridden: false })}
|
|
218
|
+
className="text-xs font-medium text-gray-500 hover:text-gray-900 flex-shrink-0"
|
|
219
|
+
>
|
|
220
|
+
Clear
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
) : overrideOpen ? (
|
|
224
|
+
<div className="space-y-2">
|
|
225
|
+
<label className="text-xs font-medium text-gray-600" htmlFor="validation-override-reason">
|
|
226
|
+
Reason for overriding checks
|
|
227
|
+
</label>
|
|
228
|
+
<textarea
|
|
229
|
+
id="validation-override-reason"
|
|
230
|
+
value={reason}
|
|
231
|
+
onChange={(e) => setReason(e.target.value)}
|
|
232
|
+
rows={2}
|
|
233
|
+
className="w-full rounded-md border px-2 py-1 text-sm"
|
|
234
|
+
placeholder="Why is this content acceptable despite the failing checks?"
|
|
235
|
+
/>
|
|
236
|
+
<div className="flex items-center gap-2">
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
disabled={reason.trim().length === 0}
|
|
240
|
+
onClick={() => {
|
|
241
|
+
onOverride({ overridden: true, reason: reason.trim() });
|
|
242
|
+
setOverrideOpen(false);
|
|
243
|
+
}}
|
|
244
|
+
className="rounded-md bg-gray-900 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
|
|
245
|
+
>
|
|
246
|
+
Override
|
|
247
|
+
</button>
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={() => setOverrideOpen(false)}
|
|
251
|
+
className="text-xs font-medium text-gray-500 hover:text-gray-900"
|
|
252
|
+
>
|
|
253
|
+
Cancel
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
) : (
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
onClick={() => setOverrideOpen(true)}
|
|
261
|
+
className="text-xs font-medium text-gray-600 hover:text-gray-900"
|
|
262
|
+
>
|
|
263
|
+
Override checks (with reason)
|
|
264
|
+
</button>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { ValidationChecklist } from '../ValidationChecklist';
|
|
4
|
+
import type { ContentCheck } from '../checks';
|
|
5
|
+
|
|
6
|
+
const checks: ContentCheck[] = [
|
|
7
|
+
{ id: 'blog-words', label: 'Blog word count', status: 'pass', detail: '450 words' },
|
|
8
|
+
{ id: 'linkedin-words', label: 'LinkedIn word count', status: 'warn', detail: '100 words' },
|
|
9
|
+
{ id: 'no-hype', label: 'No hype words', status: 'fail', detail: 'revolutionary' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('ValidationChecklist', () => {
|
|
13
|
+
it('renders every check with its label and detail', () => {
|
|
14
|
+
render(<ValidationChecklist checks={checks} />);
|
|
15
|
+
expect(screen.getByText('Blog word count')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('— 450 words')).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('No hype words')).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('summarises passing count in the overall pill', () => {
|
|
21
|
+
render(<ValidationChecklist checks={checks} />);
|
|
22
|
+
expect(screen.getByText('1/3 passing')).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders the judge verdict, scores, and expandable issues', () => {
|
|
26
|
+
render(
|
|
27
|
+
<ValidationChecklist
|
|
28
|
+
checks={checks}
|
|
29
|
+
judgeVerdict={{
|
|
30
|
+
verdict: 'revise',
|
|
31
|
+
summary: 'Reads well but sourcing is thin.',
|
|
32
|
+
scores: { clarity: 8, accuracy: 6 },
|
|
33
|
+
issues: [{ guardrail: 'sourcing', severity: 'high', problem: 'Unsupported claim', fix: 'Add a citation' }],
|
|
34
|
+
}}
|
|
35
|
+
/>,
|
|
36
|
+
);
|
|
37
|
+
expect(screen.getByText('AI judge')).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText('revise')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('Reads well but sourcing is thin.')).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText('clarity')).toBeInTheDocument();
|
|
41
|
+
|
|
42
|
+
// issues collapsed by default, expand on click
|
|
43
|
+
expect(screen.queryByText('Unsupported claim')).not.toBeInTheDocument();
|
|
44
|
+
fireEvent.click(screen.getByText('1 issue'));
|
|
45
|
+
expect(screen.getByText('Unsupported claim')).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByText('Fix: Add a citation')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is read-only when no onOverride is provided', () => {
|
|
50
|
+
render(<ValidationChecklist checks={checks} />);
|
|
51
|
+
expect(screen.queryByText('Override checks (with reason)')).not.toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('drives the override flow when onOverride is provided', () => {
|
|
55
|
+
const onOverride = jest.fn();
|
|
56
|
+
render(<ValidationChecklist checks={checks} onOverride={onOverride} />);
|
|
57
|
+
|
|
58
|
+
fireEvent.click(screen.getByText('Override checks (with reason)'));
|
|
59
|
+
const textarea = screen.getByLabelText('Reason for overriding checks');
|
|
60
|
+
// empty reason keeps the confirm disabled
|
|
61
|
+
expect(screen.getByRole('button', { name: 'Override' })).toBeDisabled();
|
|
62
|
+
|
|
63
|
+
fireEvent.change(textarea, { target: { value: 'Client approved the exception.' } });
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: 'Override' }));
|
|
65
|
+
expect(onOverride).toHaveBeenCalledWith({ overridden: true, reason: 'Client approved the exception.' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('shows an overridden banner and can clear the override', () => {
|
|
69
|
+
const onOverride = jest.fn();
|
|
70
|
+
render(
|
|
71
|
+
<ValidationChecklist
|
|
72
|
+
checks={checks}
|
|
73
|
+
override={{ overridden: true, reason: 'Approved by editor.' }}
|
|
74
|
+
onOverride={onOverride}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
expect(screen.getByText('Checks overridden')).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText('Approved by editor.')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText('Overridden')).toBeInTheDocument();
|
|
80
|
+
|
|
81
|
+
fireEvent.click(screen.getByText('Clear'));
|
|
82
|
+
expect(onOverride).toHaveBeenCalledWith({ overridden: false });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runContentChecks,
|
|
3
|
+
overallStatus,
|
|
4
|
+
wordCount,
|
|
5
|
+
tagCount,
|
|
6
|
+
extractUrls,
|
|
7
|
+
hostOf,
|
|
8
|
+
DEFAULT_HYPE_WORDS,
|
|
9
|
+
type ContentCheck,
|
|
10
|
+
} from '../checks';
|
|
11
|
+
|
|
12
|
+
/** Build a string of exactly `n` whitespace-delimited words. */
|
|
13
|
+
const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
|
14
|
+
const find = (checks: ContentCheck[], id: string) => checks.find((c) => c.id === id);
|
|
15
|
+
|
|
16
|
+
describe('word/tag helpers', () => {
|
|
17
|
+
it('wordCount handles empty, whitespace, and normal input', () => {
|
|
18
|
+
expect(wordCount(undefined)).toBe(0);
|
|
19
|
+
expect(wordCount('')).toBe(0);
|
|
20
|
+
expect(wordCount(' ')).toBe(0);
|
|
21
|
+
expect(wordCount('one two three')).toBe(3);
|
|
22
|
+
expect(wordCount(words(462))).toBe(462);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('tagCount splits on commas and ignores empties', () => {
|
|
26
|
+
expect(tagCount(undefined)).toBe(0);
|
|
27
|
+
expect(tagCount('')).toBe(0);
|
|
28
|
+
expect(tagCount('a, b, c')).toBe(3);
|
|
29
|
+
expect(tagCount('a,,b, ,c,')).toBe(3);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('runContentChecks — field presence', () => {
|
|
34
|
+
it('emits no checks for an empty fields object (no approvedHosts)', () => {
|
|
35
|
+
expect(runContentChecks({})).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('only emits a check for a field that is present', () => {
|
|
39
|
+
const checks = runContentChecks({ blog: words(450) });
|
|
40
|
+
expect(checks.map((c) => c.id).sort()).toEqual(['blog-words', 'no-hype']);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('word-count bands', () => {
|
|
45
|
+
it('blog: in-band 400–500 passes, outside warns, with a "N words" detail', () => {
|
|
46
|
+
expect(find(runContentChecks({ blog: words(450) }), 'blog-words')).toMatchObject({ status: 'pass', detail: '450 words' });
|
|
47
|
+
expect(find(runContentChecks({ blog: words(300) }), 'blog-words')).toMatchObject({ status: 'warn', detail: '300 words' });
|
|
48
|
+
expect(find(runContentChecks({ blog: words(600) }), 'blog-words')!.status).toBe('warn');
|
|
49
|
+
// inclusive edges
|
|
50
|
+
expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.status).toBe('pass');
|
|
51
|
+
expect(find(runContentChecks({ blog: words(500) }), 'blog-words')!.status).toBe('pass');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('linkedin: default band 150–250', () => {
|
|
55
|
+
expect(find(runContentChecks({ linkedin: words(200) }), 'linkedin-words')!.status).toBe('pass');
|
|
56
|
+
expect(find(runContentChecks({ linkedin: words(100) }), 'linkedin-words')!.status).toBe('warn');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('headline: default band 8–12 words', () => {
|
|
60
|
+
expect(find(runContentChecks({ headline: words(10) }), 'headline-words')!.status).toBe('pass');
|
|
61
|
+
expect(find(runContentChecks({ headline: words(5) }), 'headline-words')!.status).toBe('warn');
|
|
62
|
+
expect(find(runContentChecks({ headline: words(20) }), 'headline-words')!.status).toBe('warn');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('honours a custom band override', () => {
|
|
66
|
+
const c = find(runContentChecks({ blog: words(100) }, { blogWords: [50, 150] }), 'blog-words');
|
|
67
|
+
expect(c!.status).toBe('pass');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('tag count and meta length', () => {
|
|
72
|
+
it('tags: default band 4–6', () => {
|
|
73
|
+
expect(find(runContentChecks({ tags: 'a,b,c,d,e' }), 'tags-count')).toMatchObject({ status: 'pass', detail: '5 tags' });
|
|
74
|
+
expect(find(runContentChecks({ tags: 'a,b' }), 'tags-count')!.status).toBe('warn');
|
|
75
|
+
expect(find(runContentChecks({ tags: 'a,b,c,d,e,f,g' }), 'tags-count')!.status).toBe('warn');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('meta description: default band 150–160 chars', () => {
|
|
79
|
+
const inBand = 'x'.repeat(155);
|
|
80
|
+
const tooShort = 'x'.repeat(120);
|
|
81
|
+
expect(find(runContentChecks({ metaDescription: inBand }), 'meta-length')).toMatchObject({ status: 'pass', detail: '155 chars' });
|
|
82
|
+
expect(find(runContentChecks({ metaDescription: tooShort }), 'meta-length')!.status).toBe('warn');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('[link] placeholder', () => {
|
|
87
|
+
it('passes when present, warns when absent', () => {
|
|
88
|
+
expect(find(runContentChecks({ linkedin: 'read more [link] now' }), 'link-placeholder')!.status).toBe('pass');
|
|
89
|
+
expect(find(runContentChecks({ linkedin: 'no placeholder here' }), 'link-placeholder')).toMatchObject({
|
|
90
|
+
status: 'warn',
|
|
91
|
+
detail: 'no [link] found',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is skipped when requireLink is false', () => {
|
|
96
|
+
expect(find(runContentChecks({ linkedin: 'no placeholder' }, { requireLink: false }), 'link-placeholder')).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('no-hype', () => {
|
|
101
|
+
it('fails when a hype word appears (case-insensitive) across headline/blog/linkedin', () => {
|
|
102
|
+
const c = find(runContentChecks({ headline: 'A REVOLUTIONARY launch', blog: words(450) }), 'no-hype');
|
|
103
|
+
expect(c!.status).toBe('fail');
|
|
104
|
+
expect(c!.detail).toContain('revolutionary');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('detects multi-word and hyphenated hype phrases', () => {
|
|
108
|
+
expect(find(runContentChecks({ blog: 'the best-in-class widget' }), 'no-hype')!.status).toBe('fail');
|
|
109
|
+
expect(find(runContentChecks({ blog: 'the most attractive option' }), 'no-hype')!.status).toBe('fail');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('passes clean prose', () => {
|
|
113
|
+
expect(find(runContentChecks({ blog: 'a measured, factual paragraph about widgets' }), 'no-hype')!.status).toBe('pass');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('honours a custom hype list', () => {
|
|
117
|
+
expect(find(runContentChecks({ blog: 'totally banana content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('fail');
|
|
118
|
+
// default hype words no longer apply when overridden
|
|
119
|
+
expect(find(runContentChecks({ blog: 'revolutionary content' }, { hypeWords: ['banana'] }), 'no-hype')!.status).toBe('pass');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('ships a non-empty default hype list', () => {
|
|
123
|
+
expect(DEFAULT_HYPE_WORDS.length).toBeGreaterThan(5);
|
|
124
|
+
expect(DEFAULT_HYPE_WORDS).toContain('game-changing');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('approved sources', () => {
|
|
129
|
+
const approvedHosts = ['example.com', 'gov.uk'];
|
|
130
|
+
|
|
131
|
+
it('is skipped entirely when approvedHosts is omitted', () => {
|
|
132
|
+
expect(find(runContentChecks({ sources: 'https://example.com/a' }), 'approved-sources')).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('passes when every host is approved (www. stripped)', () => {
|
|
136
|
+
const c = find(runContentChecks({ sources: 'https://www.example.com/a https://gov.uk/b' }, { approvedHosts }), 'approved-sources');
|
|
137
|
+
expect(c!.status).toBe('pass');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('fails when any host is not on the allow-list', () => {
|
|
141
|
+
const c = find(runContentChecks({ sources: 'https://example.com/a https://sketchy.io/x' }, { approvedHosts }), 'approved-sources');
|
|
142
|
+
expect(c!.status).toBe('fail');
|
|
143
|
+
expect(c!.detail).toContain('sketchy.io');
|
|
144
|
+
expect(c!.detail).not.toContain('example.com');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('fails forum/social hosts even if listed', () => {
|
|
148
|
+
const c = find(
|
|
149
|
+
runContentChecks({ sources: 'https://reddit.com/r/x' }, { approvedHosts: ['reddit.com'] }),
|
|
150
|
+
'approved-sources',
|
|
151
|
+
);
|
|
152
|
+
expect(c!.status).toBe('fail');
|
|
153
|
+
expect(c!.detail).toContain('reddit.com');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('warns when there is no URL to check', () => {
|
|
157
|
+
expect(find(runContentChecks({ sources: 'no links here' }, { approvedHosts }), 'approved-sources')).toMatchObject({
|
|
158
|
+
status: 'warn',
|
|
159
|
+
detail: 'no sources to check',
|
|
160
|
+
});
|
|
161
|
+
// sources field absent but allow-list present → still a warn (nothing to check)
|
|
162
|
+
expect(find(runContentChecks({}, { approvedHosts }), 'approved-sources')!.status).toBe('warn');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('extractUrls + hostOf normalise correctly', () => {
|
|
166
|
+
expect(extractUrls('see https://a.com/x and http://b.org')).toEqual(['https://a.com/x', 'http://b.org']);
|
|
167
|
+
expect(extractUrls('none')).toEqual([]);
|
|
168
|
+
expect(hostOf('https://www.Example.com/path')).toBe('example.com');
|
|
169
|
+
expect(hostOf('not a url')).toBe('');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('overallStatus', () => {
|
|
174
|
+
const mk = (status: ContentCheck['status']): ContentCheck => ({ id: status, label: status, status });
|
|
175
|
+
|
|
176
|
+
it('fail beats warn beats pass; empty is pass', () => {
|
|
177
|
+
expect(overallStatus([])).toBe('pass');
|
|
178
|
+
expect(overallStatus([mk('pass'), mk('pass')])).toBe('pass');
|
|
179
|
+
expect(overallStatus([mk('pass'), mk('warn')])).toBe('warn');
|
|
180
|
+
expect(overallStatus([mk('warn'), mk('fail')])).toBe('fail');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rolls up a realistic mixed run', () => {
|
|
184
|
+
const checks = runContentChecks(
|
|
185
|
+
{ blog: words(450), linkedin: 'a revolutionary post [link]', tags: 'a,b,c,d,e' },
|
|
186
|
+
{},
|
|
187
|
+
);
|
|
188
|
+
// blog pass, linkedin word-count warn (2 words), link pass, hype fail, tags pass
|
|
189
|
+
expect(overallStatus(checks)).toBe('fail');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic content checks (768w.16.10.1) — pure + framework-free.
|
|
3
|
+
*
|
|
4
|
+
* A Foundry tenant reviews AI-generated content before accepting it. The n8n
|
|
5
|
+
* pipeline that produced the content also ran a set of deterministic
|
|
6
|
+
* auto-checks (word-count bands, hype-word scan, approved-source allow-list)
|
|
7
|
+
* and stored their result on the record. This module re-implements the SAME
|
|
8
|
+
* logic so the client can RECOMPUTE the checks against the reviewer's *edited*
|
|
9
|
+
* content — validation reflects the current draft, not the stale original.
|
|
10
|
+
*
|
|
11
|
+
* Keep this identical to the n8n judge's deterministic logic so client +
|
|
12
|
+
* pipeline always agree. No React, no DOM — importable from a Node/LLM route.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A single check's outcome. Green / amber / red, encoded redundantly in the UI. */
|
|
16
|
+
export type CheckStatus = 'pass' | 'warn' | 'fail';
|
|
17
|
+
|
|
18
|
+
/** One deterministic check result. */
|
|
19
|
+
export interface ContentCheck {
|
|
20
|
+
/** Stable id for the check (e.g. `blog-words`, `no-hype`). */
|
|
21
|
+
id: string;
|
|
22
|
+
/** Human label shown in the checklist. */
|
|
23
|
+
label: string;
|
|
24
|
+
status: CheckStatus;
|
|
25
|
+
/** Short human explanation (e.g. "462 words", "unapproved: reddit.com"). */
|
|
26
|
+
detail?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The content fields a record can expose for checking. All optional — a check
|
|
31
|
+
* is only emitted for a field that is present (an app without a `blog` field
|
|
32
|
+
* never gets a spurious blog check). `tags` and `sources` are flat strings
|
|
33
|
+
* (comma-separated tags, whitespace/URL-bearing sources) to match how the
|
|
34
|
+
* pipeline stores them.
|
|
35
|
+
*/
|
|
36
|
+
export interface ContentFields {
|
|
37
|
+
blog?: string;
|
|
38
|
+
linkedin?: string;
|
|
39
|
+
headline?: string;
|
|
40
|
+
tags?: string;
|
|
41
|
+
metaDescription?: string;
|
|
42
|
+
sources?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An inclusive `[min, max]` band. */
|
|
46
|
+
export type Band = [min: number, max: number];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tunable thresholds. Everything has a sensible default EXCEPT `approvedHosts`:
|
|
50
|
+
* the source allow-list is deliberately app-injected (no hardcoded list here),
|
|
51
|
+
* so the approved-sources check is skipped entirely when it is omitted.
|
|
52
|
+
*/
|
|
53
|
+
export interface ContentChecksConfig {
|
|
54
|
+
/** Blog word-count band (default 400–500). */
|
|
55
|
+
blogWords?: Band;
|
|
56
|
+
/** LinkedIn word-count band (default 150–250). */
|
|
57
|
+
linkedinWords?: Band;
|
|
58
|
+
/** Headline word-count band (default 8–12). */
|
|
59
|
+
headlineWords?: Band;
|
|
60
|
+
/** Tag-count band (default 4–6). */
|
|
61
|
+
tagsCount?: Band;
|
|
62
|
+
/** Meta-description character band (default 150–160). */
|
|
63
|
+
metaChars?: Band;
|
|
64
|
+
/** Require a `[link]` placeholder in the LinkedIn post (default true). */
|
|
65
|
+
requireLink?: boolean;
|
|
66
|
+
/** Override the hype-word blocklist. */
|
|
67
|
+
hypeWords?: string[];
|
|
68
|
+
/** Approved source hosts (no `www.`). REQUIRED to run the source check. */
|
|
69
|
+
approvedHosts?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The default hype/marketing-fluff blocklist. If ANY appears in the content the
|
|
74
|
+
* hype check FAILS. Matches the n8n judge's list — keep the two in sync.
|
|
75
|
+
*/
|
|
76
|
+
export const DEFAULT_HYPE_WORDS: string[] = [
|
|
77
|
+
'game-changing',
|
|
78
|
+
'gamechanging',
|
|
79
|
+
'revolutionary',
|
|
80
|
+
'world-leading',
|
|
81
|
+
'world leading',
|
|
82
|
+
'unprecedented',
|
|
83
|
+
'undisputed',
|
|
84
|
+
'transformational',
|
|
85
|
+
'groundbreaking',
|
|
86
|
+
'ground-breaking',
|
|
87
|
+
'best-in-class',
|
|
88
|
+
'unmatched',
|
|
89
|
+
'unrivalled',
|
|
90
|
+
'unrivaled',
|
|
91
|
+
'on earth',
|
|
92
|
+
'most attractive',
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** Forum / social hosts that are never an acceptable source, allow-list or not. */
|
|
96
|
+
const FORUM_SOCIAL = /reddit|quora|facebook|twitter|forum/i;
|
|
97
|
+
|
|
98
|
+
const DEFAULTS = {
|
|
99
|
+
blogWords: [400, 500] as Band,
|
|
100
|
+
linkedinWords: [150, 250] as Band,
|
|
101
|
+
headlineWords: [8, 12] as Band,
|
|
102
|
+
tagsCount: [4, 6] as Band,
|
|
103
|
+
metaChars: [150, 160] as Band,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Count words in a string — whitespace-delimited, empty → 0. */
|
|
107
|
+
export function wordCount(text: string | null | undefined): number {
|
|
108
|
+
if (!text) return 0;
|
|
109
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Count comma-separated tags in a flat string, ignoring empties. */
|
|
113
|
+
export function tagCount(tags: string | null | undefined): number {
|
|
114
|
+
if (!tags) return 0;
|
|
115
|
+
return tags
|
|
116
|
+
.split(',')
|
|
117
|
+
.map((t) => t.trim())
|
|
118
|
+
.filter(Boolean).length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** pass when `n` is inside the inclusive band, else warn. */
|
|
122
|
+
function bandStatus(n: number, [min, max]: Band): CheckStatus {
|
|
123
|
+
return n >= min && n <= max ? 'pass' : 'warn';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Pull http(s) URLs out of a free-text sources string. */
|
|
127
|
+
export function extractUrls(text: string | null | undefined): string[] {
|
|
128
|
+
if (!text) return [];
|
|
129
|
+
return text.match(/https?:\/\/[^\s"'<>)\]]+/gi) ?? [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Normalise a URL to its bare host (lowercased, `www.` stripped). '' if unparseable. */
|
|
133
|
+
export function hostOf(url: string): string {
|
|
134
|
+
try {
|
|
135
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
|
136
|
+
} catch {
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Recompute every deterministic content check over `fields`. Pure. A check is
|
|
143
|
+
* emitted only when its underlying field is present; the approved-sources check
|
|
144
|
+
* is emitted only when `config.approvedHosts` is supplied.
|
|
145
|
+
*/
|
|
146
|
+
export function runContentChecks(fields: ContentFields, config: ContentChecksConfig = {}): ContentCheck[] {
|
|
147
|
+
const checks: ContentCheck[] = [];
|
|
148
|
+
const {
|
|
149
|
+
blogWords = DEFAULTS.blogWords,
|
|
150
|
+
linkedinWords = DEFAULTS.linkedinWords,
|
|
151
|
+
headlineWords = DEFAULTS.headlineWords,
|
|
152
|
+
tagsCount = DEFAULTS.tagsCount,
|
|
153
|
+
metaChars = DEFAULTS.metaChars,
|
|
154
|
+
requireLink = true,
|
|
155
|
+
hypeWords = DEFAULT_HYPE_WORDS,
|
|
156
|
+
approvedHosts,
|
|
157
|
+
} = config;
|
|
158
|
+
|
|
159
|
+
// Word-count bands.
|
|
160
|
+
if (fields.headline !== undefined) {
|
|
161
|
+
const n = wordCount(fields.headline);
|
|
162
|
+
checks.push({ id: 'headline-words', label: 'Headline length', status: bandStatus(n, headlineWords), detail: `${n} words` });
|
|
163
|
+
}
|
|
164
|
+
if (fields.blog !== undefined) {
|
|
165
|
+
const n = wordCount(fields.blog);
|
|
166
|
+
checks.push({ id: 'blog-words', label: 'Blog word count', status: bandStatus(n, blogWords), detail: `${n} words` });
|
|
167
|
+
}
|
|
168
|
+
if (fields.linkedin !== undefined) {
|
|
169
|
+
const n = wordCount(fields.linkedin);
|
|
170
|
+
checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status: bandStatus(n, linkedinWords), detail: `${n} words` });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Tag count.
|
|
174
|
+
if (fields.tags !== undefined) {
|
|
175
|
+
const n = tagCount(fields.tags);
|
|
176
|
+
checks.push({ id: 'tags-count', label: 'Tag count', status: bandStatus(n, tagsCount), detail: `${n} tags` });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Meta description length (characters).
|
|
180
|
+
if (fields.metaDescription !== undefined) {
|
|
181
|
+
const len = fields.metaDescription.trim().length;
|
|
182
|
+
checks.push({ id: 'meta-length', label: 'Meta description length', status: bandStatus(len, metaChars), detail: `${len} chars` });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// `[link]` placeholder present in the LinkedIn post.
|
|
186
|
+
if (requireLink && fields.linkedin !== undefined) {
|
|
187
|
+
const has = fields.linkedin.includes('[link]');
|
|
188
|
+
checks.push({
|
|
189
|
+
id: 'link-placeholder',
|
|
190
|
+
label: '[link] placeholder in LinkedIn',
|
|
191
|
+
status: has ? 'pass' : 'warn',
|
|
192
|
+
detail: has ? undefined : 'no [link] found',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// No hype words — scan headline + blog + linkedin, lowercased.
|
|
197
|
+
const hasProse = fields.headline !== undefined || fields.blog !== undefined || fields.linkedin !== undefined;
|
|
198
|
+
if (hasProse) {
|
|
199
|
+
const haystack = [fields.headline, fields.blog, fields.linkedin]
|
|
200
|
+
.filter((s): s is string => typeof s === 'string')
|
|
201
|
+
.join('\n')
|
|
202
|
+
.toLowerCase();
|
|
203
|
+
const found = hypeWords.filter((w) => haystack.includes(w.toLowerCase()));
|
|
204
|
+
checks.push({
|
|
205
|
+
id: 'no-hype',
|
|
206
|
+
label: 'No hype words',
|
|
207
|
+
status: found.length > 0 ? 'fail' : 'pass',
|
|
208
|
+
detail: found.length > 0 ? found.join(', ') : undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Approved sources — only when the app supplied an allow-list.
|
|
213
|
+
if (approvedHosts !== undefined) {
|
|
214
|
+
const approved = new Set(approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, '')));
|
|
215
|
+
const hosts = extractUrls(fields.sources).map(hostOf).filter(Boolean);
|
|
216
|
+
if (hosts.length === 0) {
|
|
217
|
+
checks.push({ id: 'approved-sources', label: 'Approved sources', status: 'warn', detail: 'no sources to check' });
|
|
218
|
+
} else {
|
|
219
|
+
const bad = Array.from(new Set(hosts.filter((h) => !approved.has(h) || FORUM_SOCIAL.test(h))));
|
|
220
|
+
checks.push({
|
|
221
|
+
id: 'approved-sources',
|
|
222
|
+
label: 'Approved sources',
|
|
223
|
+
status: bad.length > 0 ? 'fail' : 'pass',
|
|
224
|
+
detail: bad.length > 0 ? `unapproved: ${bad.join(', ')}` : `${hosts.length} source${hosts.length === 1 ? '' : 's'} ok`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return checks;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Roll a list of checks up to one status: fail if any fails, else warn if any
|
|
234
|
+
* warns, else pass (empty → pass).
|
|
235
|
+
*/
|
|
236
|
+
export function overallStatus(checks: ContentCheck[]): CheckStatus {
|
|
237
|
+
if (checks.some((c) => c.status === 'fail')) return 'fail';
|
|
238
|
+
if (checks.some((c) => c.status === 'warn')) return 'warn';
|
|
239
|
+
return 'pass';
|
|
240
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui validation toolkit (768w.16.10.1).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces a Foundry tenant's machine review of AI-generated content — the AI
|
|
5
|
+
* judge verdict plus deterministic auto-checks — as a pass/warn/fail checklist,
|
|
6
|
+
* and RECOMPUTES the deterministic checks over the reviewer's *edited* content
|
|
7
|
+
* (so validation tracks the current draft, not the stale original).
|
|
8
|
+
*
|
|
9
|
+
* `checks.ts` is pure + framework-free (importable in a Node/LLM/n8n context);
|
|
10
|
+
* `ValidationChecklist` is presentational + BYO-data. Light-weight (React +
|
|
11
|
+
* lucide only), so it rides the main components barrel like the health blocks —
|
|
12
|
+
* no dedicated subpath (unlike document-editor, which is subpathed only because
|
|
13
|
+
* it drags react-markdown).
|
|
14
|
+
*/
|
|
15
|
+
export {
|
|
16
|
+
runContentChecks,
|
|
17
|
+
overallStatus,
|
|
18
|
+
wordCount,
|
|
19
|
+
tagCount,
|
|
20
|
+
extractUrls,
|
|
21
|
+
hostOf,
|
|
22
|
+
DEFAULT_HYPE_WORDS,
|
|
23
|
+
type ContentCheck,
|
|
24
|
+
type ContentFields,
|
|
25
|
+
type ContentChecksConfig,
|
|
26
|
+
type CheckStatus,
|
|
27
|
+
type Band,
|
|
28
|
+
} from './checks';
|
|
29
|
+
|
|
30
|
+
export { ValidationChecklist } from './ValidationChecklist';
|
|
31
|
+
export type {
|
|
32
|
+
ValidationChecklistProps,
|
|
33
|
+
JudgeVerdict,
|
|
34
|
+
JudgeIssue,
|
|
35
|
+
ValidationOverride,
|
|
36
|
+
} from './ValidationChecklist';
|