-
Notifications
You must be signed in to change notification settings - Fork 1
Add article quality validation gate to generate-news-enhanced.ts pipeline #469
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f962c83
4f89246
d170c95
c47743b
c36cb05
6e69b92
78a6649
1fb96c3
07e4738
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -244,6 +244,8 @@ const dryRunArg: boolean = args.includes('--dry-run'); | |||||||||||||||||||||||||
| const batchSizeArg: string | undefined = args.find(arg => arg.startsWith('--batch-size=')); | ||||||||||||||||||||||||||
| const skipExistingArg: boolean = args.includes('--skip-existing'); | ||||||||||||||||||||||||||
| const batchSize: number = batchSizeArg ? parseInt(batchSizeArg.split('=')[1] ?? '0', 10) : 0; | ||||||||||||||||||||||||||
| const qualityThresholdArg: string | undefined = args.find(arg => arg.startsWith('--quality-threshold=')); | ||||||||||||||||||||||||||
| const qualityThreshold: number = qualityThresholdArg ? parseInt(qualityThresholdArg.split('=')[1] ?? '40', 10) : 40; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --require-mcp flag: when true (default), abort if MCP server is unreachable after all retries. | ||||||||||||||||||||||||||
| // Set --require-mcp=false for local development/testing without a live MCP server. | ||||||||||||||||||||||||||
|
|
@@ -417,6 +419,124 @@ const stats: { generated: number; errors: number; articles: string[]; timestamp: | |||||||||||||||||||||||||
| timestamp: new Date().toISOString() | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Track quality scores for all articles generated in this run | ||||||||||||||||||||||||||
| const qualityScores: number[] = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --------------------------------------------------------------------------- | ||||||||||||||||||||||||||
| // Article quality validation | ||||||||||||||||||||||||||
| // --------------------------------------------------------------------------- | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** Quality metrics and score for a single generated article. */ | ||||||||||||||||||||||||||
| export interface ArticleQualityReport { | ||||||||||||||||||||||||||
| readonly articleId: string; | ||||||||||||||||||||||||||
| readonly wordCount: number; | ||||||||||||||||||||||||||
| readonly unknownAuthorCount: number; | ||||||||||||||||||||||||||
| readonly totalEntryCount: number; | ||||||||||||||||||||||||||
| readonly untranslatedSpanCount: number; | ||||||||||||||||||||||||||
| readonly analyticalSectionCount: number; | ||||||||||||||||||||||||||
| readonly score: number; | ||||||||||||||||||||||||||
| readonly passed: boolean; | ||||||||||||||||||||||||||
| readonly issues: string[]; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Validate the quality of a generated HTML article. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Scoring (100 pts total): | ||||||||||||||||||||||||||
| * - Word count 25 pts (>= 500 full, >= 300 partial, < 300 = REJECT) | ||||||||||||||||||||||||||
| * - Unknown authors 25 pts (0% full, <= 50% partial, > 50% = 0) | ||||||||||||||||||||||||||
| * - Untranslated spans 25 pts (sv always full; non-sv: 0 spans full, <= 10 partial, > 10 = 0) | ||||||||||||||||||||||||||
| * - Analytical sections 25 pts (>= 3 full, >= 1 partial, 0 = 0) | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * @param html - Full HTML content of the article | ||||||||||||||||||||||||||
| * @param lang - Language code (e.g. 'en', 'sv') | ||||||||||||||||||||||||||
| * @param articleType - Article type label for reporting (e.g. 'motions') | ||||||||||||||||||||||||||
| * @returns Quality report with score and per-metric details | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function validateArticleQuality(html: string, lang: string, articleType: string): ArticleQualityReport { | ||||||||||||||||||||||||||
| const articleId = `${articleType}-${lang}`; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Word count: strip tags and count whitespace-separated tokens | ||||||||||||||||||||||||||
| const textContent = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); | ||||||||||||||||||||||||||
| const wordCount = textContent.length === 0 ? 0 : textContent.split(' ').filter(w => w.length > 0).length; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Count "Unknown (Unknown)" sentinel entries (used when author/party is missing) | ||||||||||||||||||||||||||
| const unknownAuthorCount = (html.match(/Unknown \(Unknown\)/g) ?? []).length; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Use list items as a proxy for total document entries | ||||||||||||||||||||||||||
| const listItemCount = (html.match(/<li[^>]*>/g) ?? []).length; | ||||||||||||||||||||||||||
| const totalEntryCount = Math.max(listItemCount, unknownAuthorCount); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
Comment on lines
+466
to
+469
|
||||||||||||||||||||||||||
| // Use list items as a proxy for total document entries | |
| const listItemCount = (html.match(/<li[^>]*>/g) ?? []).length; | |
| const totalEntryCount = Math.max(listItemCount, unknownAuthorCount); | |
| // Prefer explicit document links as a proxy for total document entries | |
| const documentLinkCount = (html.match(/class=["']document-link["']/g) ?? []).length; | |
| // Fallback: use list items when no explicit document links are present | |
| const listItemCount = (html.match(/<li[^>]*>/g) ?? []).length; | |
| const baseEntryCount = documentLinkCount > 0 ? documentLinkCount : listItemCount; | |
| // Ensure total entries are never less than the number of unknown-author entries | |
| const totalEntryCount = Math.max(baseEntryCount, unknownAuthorCount); |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Articles flagged with Word count: < 300 β REJECT can still end up with passed=true if the other dimensions score high, because passed only checks score >= qualityThreshold. This contradicts the βREJECTβ semantics and can prevent the run-level soft-failure from triggering. Consider forcing passed=false (and optionally treating the score as below threshold) whenever the reject condition is hit (e.g., wordCount < 300).
| const passed = score >= qualityThreshold; | |
| const hardRejected = wordCount < 300; | |
| const passed = !hardRejected && score >= qualityThreshold; |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
validateArticleQuality is exported but its passed value depends on the module-level qualityThreshold parsed from CLI args at import time. This makes the functionβs behavior context-dependent for external callers (and for tests/tools importing the module), which is surprising for a reusable API. Consider making the threshold an explicit parameter (or returning score/metrics only and letting callers decide pass/fail).
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quality validation currently runs only via writeSingleArticle, but several article types in this pipeline are generated by news-types/* modules that call the provided writeArticle(html, filename) callback directly (e.g., month-ahead/weekly-review/monthly-review/breaking). Those outputs wonβt be validated and wonβt contribute to qualityScores, so the new gate/exit-code behavior is incomplete relative to the PRβs stated goal. Consider moving validation into writeArticle (deriving lang/articleType from the filename/slug) or providing a validating wrapper callback to those generators.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--quality-threshold=parsing can yieldNaN(e.g., empty value--quality-threshold=or non-numeric input). ANaNthreshold makes allscore >= qualityThresholdcomparisons false and also prevents theevery(s < qualityThreshold)soft-failure check from ever triggering. Consider validating the parsed value (e.g.,Number.isFinite) and falling back to the default or exiting with a clear error when invalid.