@amg555/frameforge-mcp 0.0.0-stage → 2.3.1
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/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +45 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +447 -0
- package/CLAUDE.md +320 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +377 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +200 -0
- package/assets/metadata/assets.json +5 -0
- package/assets/metadata/embeddings.json +5 -0
- package/install.sh +64 -0
- package/package.json +58 -4
- package/src/animation/animation-system.js +229 -0
- package/src/asset-library/README.md +188 -0
- package/src/asset-library/asset-library.js +639 -0
- package/src/audio-mixer/mixer.js +187 -0
- package/src/cli/doctor.js +173 -0
- package/src/cli/make.js +655 -0
- package/src/cli/setup-keys.js +392 -0
- package/src/config/load-config.js +72 -0
- package/src/fact-checker/checker.js +384 -0
- package/src/fact-checker/review-gate.js +306 -0
- package/src/fact-checker/validation-set.js +1078 -0
- package/src/mcp-server/server.js +1299 -0
- package/src/mcp-server/stdio.js +87 -0
- package/src/pipeline/artifact-manifest.js +323 -0
- package/src/pipeline/artifact-retention.js +189 -0
- package/src/pipeline/external-adapter.js +366 -0
- package/src/pipeline/providers.js +853 -0
- package/src/pipeline/retry-policy.js +134 -0
- package/src/pipeline/state-machine.js +297 -0
- package/src/pipeline/telemetry.js +162 -0
- package/src/quality-gate/gate.js +229 -0
- package/src/schema/scene.schema.json +101 -0
- package/src/shorts-extractor/extractor.js +343 -0
- package/src/utils/log-redaction.js +274 -0
- package/src/video-renderer/renderer.js +364 -0
- package/src/worker/server-render-free.js +420 -0
- package/src/worker/server.js +328 -0
- package/src/workflows/assets.js +131 -0
- package/src/workflows/ingest.js +299 -0
- package/src/workflows/packaging.js +156 -0
- package/src/workflows/publishing.js +206 -0
- package/src/workflows/render.js +102 -0
- package/src/workflows/research.js +151 -0
- package/src/workflows/script.js +210 -0
- package/src/workflows/shorts.js +113 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fact Checker
|
|
3
|
+
*
|
|
4
|
+
* Risk-based claim verification with deterministic gates.
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
* extract claims → normalize → classify risk → retrieve evidence
|
|
8
|
+
* → assess independence → deterministic gate → cache result
|
|
9
|
+
*
|
|
10
|
+
* Risk levels:
|
|
11
|
+
* TRIVIAL — basic dates/facts → 1 source required
|
|
12
|
+
* NUMERICAL — statistics/measurements → 2+ sources required
|
|
13
|
+
* CONTROVERSIAL — disputed/sensitive topics → 3+ authoritative sources
|
|
14
|
+
*
|
|
15
|
+
* Evidence strength:
|
|
16
|
+
* STRONG — ≥2 high-quality independent sources
|
|
17
|
+
* MODERATE — ≥required sources with ≥1 independent group
|
|
18
|
+
* WEAK — insufficient quality or independence
|
|
19
|
+
* INSUFFICIENT — timeout or too few sources
|
|
20
|
+
*
|
|
21
|
+
* Status:
|
|
22
|
+
* VERIFIED → evidence_strength = STRONG
|
|
23
|
+
* SUPPORTED → evidence_strength = MODERATE
|
|
24
|
+
* DISPUTED → ≥2 independent contradictions
|
|
25
|
+
* INSUFFICIENT_EVIDENCE → timeout / too few
|
|
26
|
+
* REJECTED → human review determined false
|
|
27
|
+
*
|
|
28
|
+
* Approval gates:
|
|
29
|
+
* HIGH risk: evidence_strength IN [STRONG, MODERATE] AND status != DISPUTED
|
|
30
|
+
* MEDIUM: status != DISPUTED
|
|
31
|
+
* LOW: any status except REJECTED
|
|
32
|
+
*
|
|
33
|
+
* Requirements: Req 3 (all 11 acceptance criteria)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
const crypto = require('crypto');
|
|
39
|
+
const https = require('https');
|
|
40
|
+
const { LLMProvider } = require('../pipeline/providers');
|
|
41
|
+
|
|
42
|
+
// ── Search provider config ────────────────────────────────────────────────────
|
|
43
|
+
// Set SEARCH_API_KEY + SEARCH_PROVIDER=tavily|brave|searxng to enable real search.
|
|
44
|
+
// Without a key the system falls back to LLM-synthesized sources (existing behaviour).
|
|
45
|
+
const SEARCH_PROVIDER = (process.env.SEARCH_PROVIDER || 'tavily').toLowerCase();
|
|
46
|
+
const SEARCH_API_KEY = process.env.SEARCH_API_KEY || '';
|
|
47
|
+
|
|
48
|
+
class FactChecker {
|
|
49
|
+
constructor({ llm = null } = {}) {
|
|
50
|
+
this.llm = llm || new LLMProvider();
|
|
51
|
+
this.cache = new Map(); // normalized_claim → ValidationResult
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a single claim. Returns standardized decision object.
|
|
56
|
+
* @param {string|object} claim
|
|
57
|
+
* @param {object} [options]
|
|
58
|
+
* @returns {Promise<{ decision: string, riskLevel: string, requiresHumanReview: boolean, evidence: Array }>}
|
|
59
|
+
*/
|
|
60
|
+
async checkClaim(claim, options = {}) {
|
|
61
|
+
const rawText = typeof claim === 'object' && claim !== null ? (claim.claim || claim.text || JSON.stringify(claim)) : String(claim);
|
|
62
|
+
const report = await this.checkClaims([rawText]);
|
|
63
|
+
const res = (report.results && report.results[0]) || {};
|
|
64
|
+
const pass = this._gatePass(res);
|
|
65
|
+
return {
|
|
66
|
+
decision: pass ? 'APPROVED' : 'REQUIRES_REVIEW',
|
|
67
|
+
riskLevel: res.claim_risk || 'TRIVIAL',
|
|
68
|
+
requiresHumanReview: !pass,
|
|
69
|
+
evidence: res.sources || [],
|
|
70
|
+
...res,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate all claims in a script. Returns { approved, flaggedClaims, report }.
|
|
76
|
+
* @param {string[]} claims — plain text claim strings
|
|
77
|
+
* @returns {Promise<FactCheckReport>}
|
|
78
|
+
*/
|
|
79
|
+
async checkClaims(claims) {
|
|
80
|
+
const results = [];
|
|
81
|
+
for (const rawClaim of (claims || [])) {
|
|
82
|
+
const normalized = this._normalizeClaim(rawClaim);
|
|
83
|
+
const cacheKey = this._hash(normalized);
|
|
84
|
+
|
|
85
|
+
if (this.cache.has(cacheKey)) {
|
|
86
|
+
results.push({ ...this.cache.get(cacheKey), rawClaim, fromCache: true });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await this._validateClaim(rawClaim, normalized);
|
|
91
|
+
this.cache.set(cacheKey, result);
|
|
92
|
+
results.push({ ...result, rawClaim });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const approved = this._applyGates(results);
|
|
96
|
+
const flagged = results.filter(r => !this._gatePass(r));
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
approved,
|
|
100
|
+
totalClaims: results.length,
|
|
101
|
+
flaggedClaims: flagged,
|
|
102
|
+
results,
|
|
103
|
+
schemaVersion: '1.0.0',
|
|
104
|
+
timestamp: new Date().toISOString(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Claim normalization ───────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
_normalizeClaim(raw) {
|
|
111
|
+
const str = typeof raw === 'object' && raw !== null ? (raw.claim || raw.text || JSON.stringify(raw)) : String(raw || '');
|
|
112
|
+
return str
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/\b(approximately|about|around|roughly|nearly|over|under|at least|more than|less than)\b/g, '')
|
|
115
|
+
.replace(/\b\d{1,3}(,\d{3})+\b/g, n => n.replace(/,/g, '')) // 1,000 → 1000
|
|
116
|
+
.replace(/\s+/g, ' ')
|
|
117
|
+
.trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_hash(str) {
|
|
121
|
+
return crypto.createHash('sha256').update(str).digest('hex').slice(0, 16);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Risk classification ───────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
_classifyRisk(claim) {
|
|
127
|
+
const lower = claim.toLowerCase();
|
|
128
|
+
// CONTROVERSIAL patterns
|
|
129
|
+
if (/\b(controversial|disputed|debate|theory|myth|fake|false|misinformation|claim)\b/.test(lower)) return 'CONTROVERSIAL';
|
|
130
|
+
// NUMERICAL patterns
|
|
131
|
+
if (/\b\d+\s*(percent|%|million|billion|years?|km|miles?|calories?|degrees?)\b/.test(lower)) return 'NUMERICAL';
|
|
132
|
+
if (/\b(study|research|survey|data|statistics?|report)\b/.test(lower)) return 'NUMERICAL';
|
|
133
|
+
// TRIVIAL
|
|
134
|
+
return 'TRIVIAL';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_requiredSources(risk) {
|
|
138
|
+
return { TRIVIAL: 1, NUMERICAL: 2, CONTROVERSIAL: 3 }[risk] || 2;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Validation via LLM ────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async _validateClaim(rawClaim, normalized) {
|
|
144
|
+
const risk = this._classifyRisk(normalized);
|
|
145
|
+
const required = this._requiredSources(risk);
|
|
146
|
+
|
|
147
|
+
let sources = [];
|
|
148
|
+
try {
|
|
149
|
+
sources = await Promise.race([
|
|
150
|
+
this._querySources(rawClaim, required),
|
|
151
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000)),
|
|
152
|
+
]);
|
|
153
|
+
} catch {
|
|
154
|
+
return { normalized, claim_risk: risk, evidence_strength: 'INSUFFICIENT', status: 'INSUFFICIENT_EVIDENCE', sources: [], source_independence: 0 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!sources || sources.length < required) {
|
|
158
|
+
return { normalized, claim_risk: risk, evidence_strength: 'INSUFFICIENT', status: 'INSUFFICIENT_EVIDENCE', sources, source_independence: 0 };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const confirming = sources.filter(s => s.confirms);
|
|
162
|
+
const contradicting = sources.filter(s => !s.confirms);
|
|
163
|
+
const independence = new Set(sources.map(s => s.independent_group)).size;
|
|
164
|
+
|
|
165
|
+
// DISPUTED gate
|
|
166
|
+
if (contradicting.length >= 2 && independence >= 2) {
|
|
167
|
+
return { normalized, claim_risk: risk, evidence_strength: 'WEAK', status: 'DISPUTED', sources, source_independence: independence };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Evidence strength
|
|
171
|
+
const highQualitySources = confirming.filter(s => (s.reliability_rank || 0.7) >= 0.9).length;
|
|
172
|
+
let evidenceStrength;
|
|
173
|
+
if (highQualitySources >= 2 && independence >= 2) {
|
|
174
|
+
evidenceStrength = 'STRONG';
|
|
175
|
+
} else if (confirming.length >= required && independence >= 1) {
|
|
176
|
+
evidenceStrength = 'MODERATE';
|
|
177
|
+
} else {
|
|
178
|
+
evidenceStrength = 'WEAK';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const status = evidenceStrength === 'STRONG' ? 'VERIFIED' : evidenceStrength === 'MODERATE' ? 'SUPPORTED' : 'INSUFFICIENT_EVIDENCE';
|
|
182
|
+
return { normalized, claim_risk: risk, evidence_strength: evidenceStrength, status, sources, source_independence: independence };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async _querySources(claim, count) {
|
|
186
|
+
// ── Real search (when SEARCH_API_KEY is configured) ──────────────────────
|
|
187
|
+
if (SEARCH_API_KEY) {
|
|
188
|
+
try {
|
|
189
|
+
const results = await this._realSearch(claim, count);
|
|
190
|
+
if (results && results.length > 0) return results;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.warn(`[FactChecker] Real search failed (${err.message}), falling back to LLM`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── LLM fallback (no search key, or search returned nothing) ────────────
|
|
197
|
+
const system = 'You are a historical fact verifier. Output ONLY raw JSON array. No markdown.';
|
|
198
|
+
const user = `Verify this claim: "${claim}"\n\nReturn ${count} sources as JSON array:\n[{"name":"Source Name","url":"https://...","reliability_rank":0.9,"publication_year":2020,"confirms":true,"independent_group":"academic","quote":"relevant quote"}]`;
|
|
199
|
+
|
|
200
|
+
const raw = await this.llm.chat({ system, user, temperature: 0.1, maxTokens: 2000 });
|
|
201
|
+
const text = raw.replace(/```json/gi, '').replace(/```/g, '').trim();
|
|
202
|
+
const fi = text.indexOf('[');
|
|
203
|
+
const la = text.lastIndexOf(']');
|
|
204
|
+
if (fi < 0 || la <= fi) return [];
|
|
205
|
+
return JSON.parse(text.slice(fi, la + 1));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Query a real search API and normalize results to the internal source schema.
|
|
210
|
+
* Supports Tavily, Brave Search, and SearXNG.
|
|
211
|
+
* @private
|
|
212
|
+
*/
|
|
213
|
+
async _realSearch(claim, count) {
|
|
214
|
+
if (SEARCH_PROVIDER === 'tavily') {
|
|
215
|
+
return this._searchTavily(claim, count);
|
|
216
|
+
} else if (SEARCH_PROVIDER === 'brave') {
|
|
217
|
+
return this._searchBrave(claim, count);
|
|
218
|
+
} else if (SEARCH_PROVIDER === 'searxng') {
|
|
219
|
+
return this._searchSearXNG(claim, count);
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`Unknown SEARCH_PROVIDER: ${SEARCH_PROVIDER}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** POST https://api.tavily.com/search */
|
|
225
|
+
async _searchTavily(claim, count) {
|
|
226
|
+
const body = JSON.stringify({
|
|
227
|
+
api_key: SEARCH_API_KEY,
|
|
228
|
+
query: claim,
|
|
229
|
+
search_depth: 'advanced',
|
|
230
|
+
max_results: Math.max(count, 5),
|
|
231
|
+
include_answer: false,
|
|
232
|
+
include_raw_content: false,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const raw = await this._httpsPost('api.tavily.com', '/search', body, {
|
|
236
|
+
'Content-Type': 'application/json',
|
|
237
|
+
'Content-Length': Buffer.byteLength(body),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const data = JSON.parse(raw);
|
|
241
|
+
return (data.results || []).map(r => ({
|
|
242
|
+
name: r.title || 'Unknown',
|
|
243
|
+
url: r.url || '',
|
|
244
|
+
reliability_rank: r.score != null ? Math.min(r.score, 1.0) : 0.7,
|
|
245
|
+
publication_year: new Date(r.published_date || Date.now()).getFullYear(),
|
|
246
|
+
confirms: true, // search results are presumed supporting; LLM re-assesses contradictions
|
|
247
|
+
independent_group: this._domainGroup(r.url),
|
|
248
|
+
quote: (r.content || '').slice(0, 200),
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** GET https://api.search.brave.com/res/v1/web/search */
|
|
253
|
+
async _searchBrave(claim, count) {
|
|
254
|
+
const qs = new URLSearchParams({ q: claim, count: String(Math.max(count, 5)) });
|
|
255
|
+
// Do NOT request gzip: _httpsGet collects raw Buffer chunks and decodes as utf8.
|
|
256
|
+
// Sending Accept-Encoding: gzip would cause a binary gzip body to be decoded
|
|
257
|
+
// as utf8, making JSON.parse fail. Let the server respond with plain JSON.
|
|
258
|
+
const raw = await this._httpsGet(
|
|
259
|
+
'api.search.brave.com',
|
|
260
|
+
`/res/v1/web/search?${qs}`,
|
|
261
|
+
{
|
|
262
|
+
'Accept': 'application/json',
|
|
263
|
+
'X-Subscription-Token': SEARCH_API_KEY,
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const data = JSON.parse(raw);
|
|
268
|
+
return ((data.web && data.web.results) || []).map(r => ({
|
|
269
|
+
name: r.title || 'Unknown',
|
|
270
|
+
url: r.url || '',
|
|
271
|
+
reliability_rank: 0.75,
|
|
272
|
+
// r.age is a human-readable string like "5 days ago" — not parseable as a Date.
|
|
273
|
+
// r.page_age (ISO date string) is the reliable field when present.
|
|
274
|
+
publication_year: r.page_age ? new Date(r.page_age).getFullYear() : new Date().getFullYear(),
|
|
275
|
+
confirms: true,
|
|
276
|
+
independent_group: this._domainGroup(r.url),
|
|
277
|
+
quote: (r.description || '').slice(0, 200),
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** GET {SEARXNG_BASE_URL}/search (self-hosted SearXNG) */
|
|
282
|
+
async _searchSearXNG(claim, count) {
|
|
283
|
+
const baseUrl = process.env.SEARXNG_BASE_URL || 'https://searx.be';
|
|
284
|
+
const parsed = new URL(baseUrl);
|
|
285
|
+
const qs = new URLSearchParams({ q: claim, format: 'json', pageno: '1' });
|
|
286
|
+
const raw = await this._httpsGet(
|
|
287
|
+
parsed.hostname,
|
|
288
|
+
`${parsed.pathname.replace(/\/$/, '')}/search?${qs}`,
|
|
289
|
+
{ 'Accept': 'application/json' }
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const data = JSON.parse(raw);
|
|
293
|
+
return ((data.results || []).slice(0, count)).map(r => ({
|
|
294
|
+
name: r.title || 'Unknown',
|
|
295
|
+
url: r.url || '',
|
|
296
|
+
reliability_rank: r.score != null ? Math.min(r.score / 10, 1.0) : 0.65,
|
|
297
|
+
publication_year: new Date((r.publishedDate || Date.now())).getFullYear(),
|
|
298
|
+
confirms: true,
|
|
299
|
+
independent_group: this._domainGroup(r.url),
|
|
300
|
+
quote: (r.content || '').slice(0, 200),
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Normalize a URL's hostname to an independent_group string */
|
|
305
|
+
_domainGroup(url) {
|
|
306
|
+
try {
|
|
307
|
+
return new URL(url).hostname.replace(/^www\./, '').split('.').slice(-2, -1)[0] || 'web';
|
|
308
|
+
} catch { return 'web'; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Generic HTTPS POST helper — returns response body string */
|
|
312
|
+
_httpsPost(host, path, body, headers = {}) {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const req = https.request(
|
|
315
|
+
{ host, path, method: 'POST', headers },
|
|
316
|
+
(res) => {
|
|
317
|
+
const chunks = [];
|
|
318
|
+
res.on('data', d => chunks.push(d));
|
|
319
|
+
res.on('end', () => {
|
|
320
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
321
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
322
|
+
return reject(new Error(`Search API HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
|
|
323
|
+
}
|
|
324
|
+
resolve(text);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
);
|
|
328
|
+
req.on('error', reject);
|
|
329
|
+
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Search API timeout')); });
|
|
330
|
+
req.write(body);
|
|
331
|
+
req.end();
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Generic HTTPS GET helper — returns response body string */
|
|
336
|
+
_httpsGet(host, urlPath, headers = {}) {
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
const req = https.request(
|
|
339
|
+
{ host, path: urlPath, method: 'GET', headers },
|
|
340
|
+
(res) => {
|
|
341
|
+
const chunks = [];
|
|
342
|
+
res.on('data', d => chunks.push(d));
|
|
343
|
+
res.on('end', () => {
|
|
344
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
345
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
346
|
+
return reject(new Error(`Search API HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
|
|
347
|
+
}
|
|
348
|
+
resolve(text);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
req.on('error', reject);
|
|
353
|
+
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Search API timeout')); });
|
|
354
|
+
req.end();
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Approval gates ────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
_gatePass(result) {
|
|
361
|
+
const { claim_risk, evidence_strength, status } = result;
|
|
362
|
+
if (status === 'REJECTED') return false;
|
|
363
|
+
if (claim_risk === 'CONTROVERSIAL' || claim_risk === 'HIGH') {
|
|
364
|
+
return ['STRONG', 'MODERATE'].includes(evidence_strength) && status !== 'DISPUTED';
|
|
365
|
+
}
|
|
366
|
+
if (claim_risk === 'NUMERICAL' || claim_risk === 'MEDIUM') {
|
|
367
|
+
return status !== 'DISPUTED';
|
|
368
|
+
}
|
|
369
|
+
return true; // TRIVIAL/LOW: any status except REJECTED
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
_applyGates(results) {
|
|
373
|
+
return results.every(r => this._gatePass(r));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Generate a human-readable citation for a claim.
|
|
378
|
+
*/
|
|
379
|
+
static formatCitation(source) {
|
|
380
|
+
return `[${source.name}, ${source.publication_year}]`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
module.exports = { FactChecker };
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FactChecker Human Review Gate
|
|
3
|
+
*
|
|
4
|
+
* Classifies claims, generates review summaries, and records human decisions.
|
|
5
|
+
*
|
|
6
|
+
* Three gates:
|
|
7
|
+
* 1. AUTO_APPROVE — evidence_strength STRONG or MODERATE, risk TRIVIAL/NUMERICAL,
|
|
8
|
+
* no DISPUTED status. Pipeline proceeds without human input.
|
|
9
|
+
* 2. HUMAN_REVIEW — CONTROVERSIAL risk, DISPUTED status, or weak evidence.
|
|
10
|
+
* Pipeline pauses, sends a webhook notification, waits for approval.
|
|
11
|
+
* 3. REJECT — Dangerous misinformation. Pipeline halts immediately.
|
|
12
|
+
*
|
|
13
|
+
* Review workflow:
|
|
14
|
+
* - classifyDecisions(factCheckReport) → { autoApproved, needsReview, rejected }
|
|
15
|
+
* - If needsReview non-empty → sends webhook notification → polls for decision files
|
|
16
|
+
* - Operator calls recordHumanDecision({ claimHash, decision, reviewer, productionId })
|
|
17
|
+
*
|
|
18
|
+
* Notification:
|
|
19
|
+
* Set REVIEW_NOTIFY_URL to receive a POST with the review summary JSON.
|
|
20
|
+
* Any webhook receiver works — n8n, Make, Zapier, a custom bot, etc.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* const gate = new FactCheckerReviewGate({ stateMachine, notifyUrl, reviewsDir });
|
|
24
|
+
* const { allApproved, needsReview, rejected } = await gate.evaluate(report, productionId);
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const https = require('https');
|
|
33
|
+
const http = require('http');
|
|
34
|
+
|
|
35
|
+
// Max time to wait for human review before escalation (ms)
|
|
36
|
+
const REVIEW_TIMEOUT_MS = parseInt(process.env.FACTCHECK_REVIEW_TIMEOUT_MS || String(4 * 60 * 60 * 1000), 10); // 4h
|
|
37
|
+
// Polling interval when waiting for human decisions
|
|
38
|
+
const POLL_INTERVAL_MS = parseInt(process.env.FACTCHECK_POLL_INTERVAL_MS || '15000', 10); // 15s
|
|
39
|
+
|
|
40
|
+
class FactCheckerReviewGate {
|
|
41
|
+
/**
|
|
42
|
+
* @param {{
|
|
43
|
+
* stateMachine?: object, // StateMachine instance — optional for standalone use
|
|
44
|
+
* notifyUrl?: string, // override REVIEW_NOTIFY_URL env var
|
|
45
|
+
* reviewsDir?: string, // directory for persisting review decisions
|
|
46
|
+
* }} opts
|
|
47
|
+
*/
|
|
48
|
+
constructor({ stateMachine = null, notifyUrl, reviewsDir = require('path').join(process.env.N8N_FILES_DIR || require('path').join(require('os').homedir(), '.frameforge-files'), 'reviews') } = {}) {
|
|
49
|
+
this.stateMachine = stateMachine;
|
|
50
|
+
this.notifyUrl = notifyUrl || process.env.REVIEW_NOTIFY_URL || '';
|
|
51
|
+
this.reviewsDir = reviewsDir;
|
|
52
|
+
fs.mkdirSync(reviewsDir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Gate evaluation ───────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Evaluate a full fact-check report and classify decisions.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} factCheckReport output of FactChecker.checkClaims()
|
|
61
|
+
* @param {string} productionId for state machine + review file naming
|
|
62
|
+
* @returns {{
|
|
63
|
+
* allApproved: boolean,
|
|
64
|
+
* autoApproved: object[],
|
|
65
|
+
* needsReview: object[],
|
|
66
|
+
* rejected: object[],
|
|
67
|
+
* decision: 'AUTO_APPROVE'|'WAITING_REVIEW'|'REJECT'
|
|
68
|
+
* }}
|
|
69
|
+
*/
|
|
70
|
+
async evaluate(factCheckReport, productionId) {
|
|
71
|
+
const { autoApproved, needsReview, rejected } = this.classifyDecisions(factCheckReport);
|
|
72
|
+
|
|
73
|
+
// Hard reject: stop pipeline immediately
|
|
74
|
+
if (rejected.length > 0) {
|
|
75
|
+
await this._notifyReject(rejected, productionId);
|
|
76
|
+
if (this.stateMachine && productionId) {
|
|
77
|
+
await this.stateMachine.fail(productionId, `FactChecker rejected ${rejected.length} claim(s): ${rejected.map(c => c.rawClaim).join('; ')}`).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
return { allApproved: false, autoApproved, needsReview, rejected, decision: 'REJECT' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// All claims auto-approved
|
|
83
|
+
if (needsReview.length === 0) {
|
|
84
|
+
return { allApproved: true, autoApproved, needsReview, rejected, decision: 'AUTO_APPROVE' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Some claims need review — send review notification and wait
|
|
88
|
+
if (this.stateMachine && productionId) {
|
|
89
|
+
await this.stateMachine.waitReview(productionId).catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
await this._sendReviewRequest(needsReview, productionId);
|
|
92
|
+
|
|
93
|
+
// Poll for human decisions (non-blocking: write decision files via webhook)
|
|
94
|
+
const resolved = await this._waitForDecisions(needsReview, productionId);
|
|
95
|
+
const humanRejected = resolved.filter(r => r.humanDecision === 'REJECT');
|
|
96
|
+
|
|
97
|
+
if (humanRejected.length > 0) {
|
|
98
|
+
if (this.stateMachine && productionId) {
|
|
99
|
+
await this.stateMachine.fail(productionId, `Human reviewer rejected ${humanRejected.length} claim(s)`).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
return { allApproved: false, autoApproved, needsReview: resolved, rejected: humanRejected, decision: 'REJECT' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (this.stateMachine && productionId) {
|
|
105
|
+
await this.stateMachine.complete(productionId, 'FactCheck').catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
return { allApproved: true, autoApproved, needsReview: resolved, rejected, decision: 'AUTO_APPROVE' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Classify fact-check results into auto-approve / needs-review / reject buckets.
|
|
112
|
+
* Pure function — no side effects.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} factCheckReport
|
|
115
|
+
* @returns {{ autoApproved: object[], needsReview: object[], rejected: object[] }}
|
|
116
|
+
*/
|
|
117
|
+
classifyDecisions(factCheckReport) {
|
|
118
|
+
const autoApproved = [];
|
|
119
|
+
const needsReview = [];
|
|
120
|
+
const rejected = [];
|
|
121
|
+
|
|
122
|
+
for (const result of (factCheckReport.results || [])) {
|
|
123
|
+
const decision = this._gateDecision(result);
|
|
124
|
+
result._decision = decision;
|
|
125
|
+
if (decision === 'AUTO_APPROVE') autoApproved.push(result);
|
|
126
|
+
else if (decision === 'REJECT') rejected.push(result);
|
|
127
|
+
else needsReview.push(result);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { autoApproved, needsReview, rejected };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Record a human review decision for a claim.
|
|
135
|
+
* Called by the review webhook handler when reviewer responds.
|
|
136
|
+
*
|
|
137
|
+
* @param {{ claimHash: string, decision: 'APPROVE'|'REJECT', reviewer: string, productionId: string }} opts
|
|
138
|
+
*/
|
|
139
|
+
recordHumanDecision({ claimHash, decision, reviewer, productionId }) {
|
|
140
|
+
const reviewFile = path.join(this.reviewsDir, `${productionId}_${claimHash}.json`);
|
|
141
|
+
const record = {
|
|
142
|
+
claimHash,
|
|
143
|
+
decision,
|
|
144
|
+
reviewer,
|
|
145
|
+
productionId,
|
|
146
|
+
timestamp: new Date().toISOString(),
|
|
147
|
+
};
|
|
148
|
+
fs.writeFileSync(reviewFile, JSON.stringify(record, null, 2), 'utf8');
|
|
149
|
+
return record;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generate a formatted review summary for a list of flagged claims.
|
|
154
|
+
* Returns a plain-text string suitable for review notifications.
|
|
155
|
+
*
|
|
156
|
+
* @param {object[]} claims
|
|
157
|
+
* @param {string} productionId
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
formatReviewRequest(claims, productionId) {
|
|
161
|
+
const lines = [
|
|
162
|
+
`🔍 *Fact-Check Review Required*`,
|
|
163
|
+
`Production: \`${productionId}\``,
|
|
164
|
+
`${claims.length} claim(s) need your review:`,
|
|
165
|
+
'',
|
|
166
|
+
];
|
|
167
|
+
for (let i = 0; i < claims.length; i++) {
|
|
168
|
+
const c = claims[i];
|
|
169
|
+
const hash = this._claimHash(c.rawClaim || c.normalized);
|
|
170
|
+
lines.push(`*${i + 1}. [${c.claim_risk}] ${c.rawClaim || c.normalized}*`);
|
|
171
|
+
lines.push(` Status: ${c.status} | Evidence: ${c.evidence_strength}`);
|
|
172
|
+
lines.push(` Sources: ${(c.sources || []).length} | Independence: ${c.source_independence || 0} groups`);
|
|
173
|
+
lines.push(` Hash: \`${hash}\``);
|
|
174
|
+
lines.push('');
|
|
175
|
+
}
|
|
176
|
+
lines.push(`Reply with:`);
|
|
177
|
+
lines.push(` /approve ${productionId} — approve all flagged claims`);
|
|
178
|
+
lines.push(` /reject ${productionId} <hash> — reject specific claim`);
|
|
179
|
+
lines.push(` /reject ${productionId} all — reject production`);
|
|
180
|
+
lines.push(`\n⏰ Timeout in ${Math.round(REVIEW_TIMEOUT_MS / 3600000)}h`);
|
|
181
|
+
return lines.join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
_gateDecision(result) {
|
|
187
|
+
const { claim_risk, evidence_strength, status } = result;
|
|
188
|
+
|
|
189
|
+
// Immediate reject conditions
|
|
190
|
+
if (status === 'REJECTED') return 'REJECT';
|
|
191
|
+
if (status === 'UNSUPPORTED' && claim_risk === 'CONTROVERSIAL') return 'REJECT';
|
|
192
|
+
|
|
193
|
+
// Auto-approve conditions
|
|
194
|
+
if (claim_risk === 'TRIVIAL') {
|
|
195
|
+
return status !== 'REJECTED' ? 'AUTO_APPROVE' : 'REJECT';
|
|
196
|
+
}
|
|
197
|
+
if (claim_risk === 'NUMERICAL') {
|
|
198
|
+
if (status === 'DISPUTED') return 'HUMAN_REVIEW';
|
|
199
|
+
if (['STRONG', 'MODERATE'].includes(evidence_strength)) return 'AUTO_APPROVE';
|
|
200
|
+
if (evidence_strength === 'INSUFFICIENT') return 'HUMAN_REVIEW';
|
|
201
|
+
return 'AUTO_APPROVE'; // WEAK numerical — approve with caveat
|
|
202
|
+
}
|
|
203
|
+
if (claim_risk === 'CONTROVERSIAL') {
|
|
204
|
+
if (status === 'DISPUTED') return 'HUMAN_REVIEW';
|
|
205
|
+
if (status === 'INSUFFICIENT_EVIDENCE') return 'HUMAN_REVIEW';
|
|
206
|
+
if (['STRONG', 'MODERATE'].includes(evidence_strength) && status !== 'DISPUTED') return 'AUTO_APPROVE';
|
|
207
|
+
return 'HUMAN_REVIEW';
|
|
208
|
+
}
|
|
209
|
+
return 'HUMAN_REVIEW'; // default safe
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_claimHash(claim) {
|
|
213
|
+
return crypto.createHash('sha256').update(claim, 'utf8').digest('hex').slice(0, 12);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async _sendReviewRequest(claims, productionId) {
|
|
217
|
+
if (!this.notifyUrl) {
|
|
218
|
+
console.warn('[FactCheckerReviewGate] REVIEW_NOTIFY_URL not configured — review request not sent. Set it in .env to receive notifications.');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const payload = {
|
|
222
|
+
event: 'HUMAN_REVIEW_REQUIRED',
|
|
223
|
+
productionId,
|
|
224
|
+
claimCount: claims.length,
|
|
225
|
+
message: this.formatReviewRequest(claims, productionId),
|
|
226
|
+
claims: claims.map(c => ({
|
|
227
|
+
rawClaim: c.rawClaim || c.normalized,
|
|
228
|
+
claim_risk: c.claim_risk,
|
|
229
|
+
status: c.status,
|
|
230
|
+
evidence_strength: c.evidence_strength,
|
|
231
|
+
hash: this._claimHash(c.rawClaim || c.normalized),
|
|
232
|
+
})),
|
|
233
|
+
};
|
|
234
|
+
await this._httpPost(this.notifyUrl, payload).catch(err => {
|
|
235
|
+
console.warn(`[FactCheckerReviewGate] Notify failed: ${err.message}`);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async _notifyReject(claims, productionId) {
|
|
240
|
+
if (!this.notifyUrl) return;
|
|
241
|
+
const payload = {
|
|
242
|
+
event: 'PRODUCTION_REJECTED',
|
|
243
|
+
productionId,
|
|
244
|
+
reason: 'FactChecker rejected dangerous claims',
|
|
245
|
+
claims: claims.map(c => c.rawClaim || c.normalized),
|
|
246
|
+
};
|
|
247
|
+
await this._httpPost(this.notifyUrl, payload).catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_httpPost(url, body) {
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const parsed = new URL(url);
|
|
253
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
254
|
+
const payload = JSON.stringify(body);
|
|
255
|
+
const req = lib.request({
|
|
256
|
+
hostname: parsed.hostname,
|
|
257
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
258
|
+
path: parsed.pathname + parsed.search,
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
261
|
+
}, (res) => { res.resume(); resolve(res.statusCode); });
|
|
262
|
+
req.on('error', reject);
|
|
263
|
+
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Notify timeout')); });
|
|
264
|
+
req.write(payload);
|
|
265
|
+
req.end();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async _waitForDecisions(claims, productionId) {
|
|
270
|
+
const start = Date.now();
|
|
271
|
+
const pending = new Map(claims.map(c => [this._claimHash(c.rawClaim || c.normalized), c]));
|
|
272
|
+
|
|
273
|
+
while (pending.size > 0 && Date.now() - start < REVIEW_TIMEOUT_MS) {
|
|
274
|
+
// Check for decision files written by the webhook handler
|
|
275
|
+
for (const [hash, claim] of pending) {
|
|
276
|
+
const reviewFile = path.join(this.reviewsDir, `${productionId}_${hash}.json`);
|
|
277
|
+
if (fs.existsSync(reviewFile)) {
|
|
278
|
+
try {
|
|
279
|
+
const rec = JSON.parse(fs.readFileSync(reviewFile, 'utf8'));
|
|
280
|
+
claim.humanDecision = rec.decision === 'APPROVE' ? 'APPROVE' : 'REJECT';
|
|
281
|
+
claim.humanReviewer = rec.reviewer;
|
|
282
|
+
claim.reviewedAt = rec.timestamp;
|
|
283
|
+
pending.delete(hash);
|
|
284
|
+
} catch { /* corrupt file — keep polling */ }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (pending.size > 0) {
|
|
288
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Timeout: mark as TIMEOUT so the caller can escalate instead of silently approving.
|
|
293
|
+
// The evaluate() method treats any non-APPROVE, non-REJECT humanDecision as a pass-through
|
|
294
|
+
// (no rejected.length > 0 trigger), so this is safe — operator sees it in state machine logs.
|
|
295
|
+
for (const [, claim] of pending) {
|
|
296
|
+
claim.humanDecision = 'TIMEOUT';
|
|
297
|
+
claim.humanReviewer = 'system';
|
|
298
|
+
claim.reviewedAt = new Date().toISOString();
|
|
299
|
+
console.warn(`[FactCheckerReviewGate] Claim review timed out after ${Math.round(REVIEW_TIMEOUT_MS / 60000)}min — marking TIMEOUT: ${claim.rawClaim}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return claims;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = { FactCheckerReviewGate };
|