Skip to content

Commit e2827ea

Browse files
Copilotpethers
andcommitted
feat(journalism): Improve article quality with Economist-style editorial standards
- Raise minQualityScore from 0.75 to 0.80 - Raise minPartySources from 4 to 6 for better 8-party Swedish coverage - Add requireHistoricalContext: true as mandatory blocking threshold - Update party normalization to /6 in calculateQualityScore - Add INTER_PILLAR_TRANSITIONS and getPillarTransition() for all 14 languages - Improve lede generation in generateGenericContent to lead with most significant document type (inverted pyramid) - Update test to reflect new 6-party normalization baseline Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
1 parent 5eb5554 commit e2827ea

5 files changed

Lines changed: 145 additions & 19 deletions

File tree

scripts/article-quality-enhancer.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ const DOCUMENT_ID_PATTERNS: readonly RegExp[] = [
4949
* Default quality thresholds based on The Economist standards
5050
*/
5151
const DEFAULT_THRESHOLDS: QualityThresholds = {
52-
minQualityScore: 0.75,
52+
minQualityScore: 0.80,
5353
minAnalyticalDepth: 0.6,
54-
minPartySources: 4,
54+
minPartySources: 6,
5555
minCrossReferences: 3,
5656
requireWhyThisMatters: true,
57+
requireHistoricalContext: true,
5758
recommendHistoricalContext: true,
5859
recommendInternationalComparison: false,
5960
};
@@ -250,8 +251,8 @@ function calculateQualityScore(metrics: QualityMetrics): number {
250251
// Analytical depth (already 0-1)
251252
score += metrics.analyticalDepth * weights.analyticalDepth;
252253

253-
// Party perspectives (normalize: 4+ parties = 1.0)
254-
score += Math.min(metrics.partyCount / 4, 1.0) * weights.partyPerspectives;
254+
// Party perspectives (normalize: 6+ parties = 1.0, reflecting 8-party Swedish system)
255+
score += Math.min(metrics.partyCount / 6, 1.0) * weights.partyPerspectives;
255256

256257
// Cross-references (normalize: 3+ refs = 1.0)
257258
score += Math.min(metrics.crossReferences / 3, 1.0) * weights.crossReferences;
@@ -323,6 +324,10 @@ export async function enhanceArticleQuality(
323324
issues.push('Missing "Why This Matters" section');
324325
}
325326

327+
if (options.requireHistoricalContext && !metrics.hasHistoricalContext) {
328+
issues.push('Missing required historical context (at least one historical comparison required)');
329+
}
330+
326331
// Separate warnings (recommendations) from blocking failures
327332
const warnings: string[] = [];
328333

scripts/data-transformers/content-generators.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -569,30 +569,46 @@ export function generateGenericContent(data: ArticleContentData, lang: Language
569569
const cia = data.ciaContext;
570570
let content = '';
571571

572-
// ── Overview lede (from document count) ────────────────────────────────
573-
const overviewFn = L(lang, 'genericOverview') as string | ((n: number) => string);
574-
const overview = typeof overviewFn === 'function'
575-
? overviewFn(docs.length)
576-
: `During this period, ${docs.length} documents were processed in parliament.`;
577-
content += `<p class="article-lede">${escapeHtml(String(overview))}</p>\n`;
578-
579-
// ── Group by document type ───────────────────────────────────────────────
572+
// ── Inverted-pyramid lede: lead with most significant document type ──────
573+
// Group by document type first to identify the most newsworthy lead
580574
const byType: Record<string, RawDocument[]> = {};
581575
docs.forEach(doc => {
582576
const docType = doc.doktyp || doc.documentType || 'other';
583577
if (!byType[docType]) byType[docType] = [];
584578
byType[docType].push(doc);
585579
});
586580

587-
content += `\n <h2>${L(lang, 'thematicAnalysis')}</h2>\n`;
588-
589-
// Render in significance order: propositions → committee reports → motions → rest
581+
// Significance order: propositions → committee reports → government comms → motions → rest
590582
const typeOrder = ['prop', 'bet', 'skr', 'mot', 'other'];
591583
const sortedTypes = [...Object.keys(byType)].sort((a, b) => {
592584
const ai = typeOrder.indexOf(a); const bi = typeOrder.indexOf(b);
593585
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
594586
});
595587

588+
// Lead with the most significant type rather than a raw count
589+
const leadType = sortedTypes[0];
590+
const leadDocs = leadType ? (byType[leadType] ?? []) : [];
591+
const leadTitle = leadDocs[0] ? (leadDocs[0].titel || leadDocs[0].title || '') : '';
592+
593+
let ledeText: string;
594+
if (leadType === 'prop' && leadDocs.length > 0) {
595+
ledeText = lang === 'sv'
596+
? `Riksdagen behandlar ${leadDocs.length} proposition${leadDocs.length !== 1 ? 'er' : ''}${leadTitle ? ` — inklusive "${leadTitle}"` : ''} under denna period.`
597+
: `Parliament is considering ${leadDocs.length} government proposition${leadDocs.length !== 1 ? 's' : ''}${leadTitle ? ` — including "${leadTitle}"` : ''} during this period.`;
598+
} else if (leadType === 'bet' && leadDocs.length > 0) {
599+
ledeText = lang === 'sv'
600+
? `Utskotten har lämnat ${leadDocs.length} betänkande${leadDocs.length !== 1 ? 'n' : ''}${leadTitle ? ` — ledda av "${leadTitle}"` : ''} för riksdagens beslut.`
601+
: `Committees have delivered ${leadDocs.length} report${leadDocs.length !== 1 ? 's' : ''}${leadTitle ? ` — led by "${leadTitle}"` : ''} for parliamentary decision.`;
602+
} else {
603+
const overviewFn = L(lang, 'genericOverview') as string | ((n: number) => string);
604+
ledeText = typeof overviewFn === 'function'
605+
? overviewFn(docs.length)
606+
: `During this period, ${docs.length} documents were processed in parliament.`;
607+
}
608+
content += `<p class="article-lede">${escapeHtml(ledeText)}</p>\n`;
609+
610+
content += `\n <h2>${L(lang, 'thematicAnalysis')}</h2>\n`;
611+
596612
for (const docType of sortedTypes) {
597613
const typeDocs = byType[docType] ?? [];
598614
const otherDocsVal = L(lang, 'otherDocuments');

scripts/editorial-pillars.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,93 @@ export function getLocalizedHeading(lang: Language | string, pillar: EditorialPi
132132
EDITORIAL_PILLAR_HEADINGS[lang as Language] ?? EDITORIAL_PILLAR_HEADINGS.en;
133133
return headings[pillar];
134134
}
135+
136+
/**
137+
* Analytical transition phrases bridging adjacent editorial pillars.
138+
* Separated from EDITORIAL_PILLAR_HEADINGS to maintain pillar structure integrity.
139+
*/
140+
export const INTER_PILLAR_TRANSITIONS: Readonly<Record<Language, Readonly<Record<string, string>>>> = {
141+
en: {
142+
pulseToWatch: 'While parliament deliberates these legislative matters, the executive branch has been equally active.',
143+
watchToOpposition: 'While the government advances its agenda, opposition parties have mounted coordinated responses.',
144+
oppositionToAhead: 'These competing dynamics set the stage for tomorrow\'s parliamentary business.',
145+
},
146+
sv: {
147+
pulseToWatch: 'Medan riksdagen behandlar dessa lagstiftningsfrågor har regeringen också varit aktiv.',
148+
watchToOpposition: 'Medan regeringen driver sin agenda har oppositionspartierna samordnat sina svar.',
149+
oppositionToAhead: 'Dessa konkurrerande dynamiker sätter scenen för morgondagens riksdagsarbete.',
150+
},
151+
da: {
152+
pulseToWatch: 'Mens parlamentet behandler disse lovgivningsspørgsmål, har den udøvende magt også været aktiv.',
153+
watchToOpposition: 'Mens regeringen fremmer sin dagsorden, har oppositionspartierne koordineret svar.',
154+
oppositionToAhead: 'Disse konkurrerende dynamikker danner baggrund for morgendagens parlamentariske forretning.',
155+
},
156+
no: {
157+
pulseToWatch: 'Mens Stortinget behandler disse lovgivningsspørsmålene, har regjeringen også vært aktiv.',
158+
watchToOpposition: 'Mens regjeringen fremmer sin agenda, har opposisjonspartiene koordinert svar.',
159+
oppositionToAhead: 'Disse konkurrerende dynamikkene danner bakteppet for morgendagens stortingsvirksomhet.',
160+
},
161+
fi: {
162+
pulseToWatch: 'Samalla kun eduskunta käsittelee näitä lainsäädäntöasioita, toimeenpanovalta on ollut yhtä aktiivinen.',
163+
watchToOpposition: 'Samalla kun hallitus edistää ohjelmaansa, oppositiopuolueet ovat koordinoineet vastauksensa.',
164+
oppositionToAhead: 'Nämä kilpailevat dynamiikat asettavat näyttämön huomiselle eduskuntatyölle.',
165+
},
166+
de: {
167+
pulseToWatch: 'Während das Parlament diese Gesetzgebungsfragen berät, war auch die Exekutive aktiv.',
168+
watchToOpposition: 'Während die Regierung ihre Agenda vorantreibt, haben die Oppositionsparteien koordinierte Antworten entwickelt.',
169+
oppositionToAhead: 'Diese konkurrierenden Dynamiken bereiten die Bühne für die parlamentarischen Geschäfte von morgen.',
170+
},
171+
fr: {
172+
pulseToWatch: 'Alors que le parlement délibère sur ces questions législatives, le pouvoir exécutif a été tout aussi actif.',
173+
watchToOpposition: "Tandis que le gouvernement fait avancer son programme, les partis d'opposition ont coordonné leurs réponses.",
174+
oppositionToAhead: 'Ces dynamiques concurrentes préparent le terrain pour les travaux parlementaires de demain.',
175+
},
176+
es: {
177+
pulseToWatch: 'Mientras el parlamento delibera sobre estos asuntos legislativos, el poder ejecutivo también ha estado activo.',
178+
watchToOpposition: 'Mientras el gobierno avanza en su agenda, los partidos de la oposición han coordinado respuestas.',
179+
oppositionToAhead: 'Estas dinámicas competidoras preparan el escenario para los asuntos parlamentarios de mañana.',
180+
},
181+
nl: {
182+
pulseToWatch: 'Terwijl het parlement over deze wetgevende kwesties beraadslaagt, is ook de uitvoerende macht actief geweest.',
183+
watchToOpposition: 'Terwijl de regering haar agenda voortzet, hebben de oppositiepartijen gecoördineerde reacties gemount.',
184+
oppositionToAhead: 'Deze concurrerende dynamieken bereiden het toneel voor de parlementaire werkzaamheden van morgen.',
185+
},
186+
ar: {
187+
pulseToWatch: 'بينما يتداول البرلمان في هذه المسائل التشريعية، كانت السلطة التنفيذية نشطة بالقدر ذاته.',
188+
watchToOpposition: 'بينما تُقدِّم الحكومة أجندتها، نسّقت أحزاب المعارضة ردودها.',
189+
oppositionToAhead: 'تُهيئ هذه الديناميكيات المتنافسة المسرح لأعمال البرلمان في الغد.',
190+
},
191+
he: {
192+
pulseToWatch: 'בעוד הפרלמנט דן בעניינים מחוקקים אלה, הרשות המבצעת הייתה פעילה לא פחות.',
193+
watchToOpposition: 'בעוד הממשלה מקדמת את סדר יומה, תיאמו מפלגות האופוזיציה תגובות מתואמות.',
194+
oppositionToAhead: 'הדינמיקות המתחרות הללו מכינות את הקרקע לעסקי הפרלמנט של מחר.',
195+
},
196+
ja: {
197+
pulseToWatch: '議会がこれらの立法事項を審議する一方、行政府も同様に活発に活動しています。',
198+
watchToOpposition: '政府がその政策を推し進める一方、野党は協調した対応を行っています。',
199+
oppositionToAhead: 'これらの競合するダイナミクスが明日の議会業務の舞台を整えています。',
200+
},
201+
ko: {
202+
pulseToWatch: '의회가 이러한 입법 사안을 심의하는 동안 행정부도 마찬가지로 활발히 활동했습니다.',
203+
watchToOpposition: '정부가 의제를 추진하는 동안 야당은 협력된 대응을 구성했습니다.',
204+
oppositionToAhead: '이러한 경쟁하는 역학들이 내일의 의회 업무를 위한 무대를 설정합니다.',
205+
},
206+
zh: {
207+
pulseToWatch: '当议会审议这些立法事务时,行政部门同样积极活跃。',
208+
watchToOpposition: '在政府推进其议程的同时,反对党已协调一致地作出回应。',
209+
oppositionToAhead: '这些相互竞争的动态为明天的议会事务奠定了基础。',
210+
},
211+
} as const;
212+
213+
/**
214+
* Get a localized inter-pillar transition phrase.
215+
*
216+
* @param lang - Language code
217+
* @param transition - Transition key (e.g. 'pulseToWatch', 'watchToOpposition', 'oppositionToAhead')
218+
* @returns Localized transition string, falls back to English
219+
*/
220+
export function getPillarTransition(lang: Language | string, transition: string): string {
221+
const langTransitions =
222+
INTER_PILLAR_TRANSITIONS[lang as Language] ?? INTER_PILLAR_TRANSITIONS.en;
223+
return langTransitions[transition] ?? INTER_PILLAR_TRANSITIONS.en[transition] ?? '';
224+
}

scripts/types/validation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface QualityThresholds {
6363
minPartySources: number;
6464
minCrossReferences: number;
6565
requireWhyThisMatters: boolean;
66+
requireHistoricalContext: boolean;
6667
recommendHistoricalContext: boolean;
6768
recommendInternationalComparison: boolean;
6869
}

tests/news-realtime-monitor.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -421,18 +421,32 @@ describe('News Realtime Monitor - Quality Framework', () => {
421421
});
422422

423423
it('should normalize party count correctly', () => {
424-
const metricsWithFourParties: QualityMetrics = {
424+
const metricsWithSixParties: QualityMetrics = {
425425
analyticalDepth: 0,
426-
partyCount: 4,
426+
partyCount: 6,
427427
crossReferences: 0,
428428
hasWhyThisMatters: false,
429429
hasHistoricalContext: false,
430430
hasInternationalComparison: false
431431
};
432-
const score = qualityModule.calculateQualityScore(metricsWithFourParties);
433-
// 4 parties should contribute 0.25 (25% weight)
432+
const score = qualityModule.calculateQualityScore(metricsWithSixParties);
433+
// 6 parties (full quota) should contribute 0.25 (25% weight)
434434
expect(score).toBeGreaterThanOrEqual(0.24);
435435
expect(score).toBeLessThanOrEqual(0.26);
436+
437+
// 4 parties should contribute less than full weight (4/6 * 0.25 ≈ 0.167)
438+
const metricsWithFourParties: QualityMetrics = {
439+
analyticalDepth: 0,
440+
partyCount: 4,
441+
crossReferences: 0,
442+
hasWhyThisMatters: false,
443+
hasHistoricalContext: false,
444+
hasInternationalComparison: false
445+
};
446+
const scoreFour = qualityModule.calculateQualityScore(metricsWithFourParties);
447+
expect(scoreFour).toBeLessThan(score);
448+
expect(scoreFour).toBeGreaterThan(0.15);
449+
expect(scoreFour).toBeLessThan(0.18);
436450
});
437451

438452
it('should cap score at 1.0', () => {

0 commit comments

Comments
 (0)