Skip to content

Commit f999717

Browse files
committed
feat: add rules engine with dynamic rule loading from rules/ directory
- Rule system: types, loader, engine in src/rules/ - Rules loaded dynamically from rules/*.ts at startup - 5 rule files by category: validity, media, spam, content, scoring - Rule severities: reject (instant invalid), require, penalize, flag - Rules evaluated in pipeline before LLM gate, results injected into LLM prompt - API endpoints: GET /api/v1/rules (list), POST /api/v1/rules/reload (hot-reload) - formatRulesForPrompt() passes rule results to LLM for consideration
1 parent 288afeb commit f999717

11 files changed

Lines changed: 792 additions & 6 deletions

File tree

rules/content.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Content quality rules — structural and content quality checks.
3+
*
4+
* Category: content
5+
* Evaluated alongside validity rules.
6+
*/
7+
8+
import type { Rule } from '../src/rules/types.js';
9+
10+
const rules: Rule[] = [
11+
{
12+
id: 'content.no-profanity',
13+
description: 'Issue should not contain excessive profanity or abusive language',
14+
category: 'content',
15+
severity: 'flag',
16+
failureMessage: 'Issue contains potentially abusive language.',
17+
evaluate: (ctx) => {
18+
const profanity = ['fuck', 'shit', 'damn', 'idiot', 'stupid'];
19+
const lower = (ctx.title + ' ' + ctx.body).toLowerCase();
20+
const count = profanity.reduce(
21+
(acc, word) => acc + (lower.split(word).length - 1),
22+
0,
23+
);
24+
return count < 3;
25+
},
26+
},
27+
{
28+
id: 'content.reasonable-length',
29+
description: 'Issue body should not exceed 15000 characters (possible spam dump)',
30+
category: 'content',
31+
severity: 'flag',
32+
failureMessage: 'Issue body is excessively long (> 15000 chars), which may indicate pasted logs or spam.',
33+
evaluate: (ctx) => ctx.body.length <= 15000,
34+
},
35+
{
36+
id: 'content.has-context',
37+
description: 'Issue should mention the affected page, component, or URL',
38+
category: 'content',
39+
severity: 'penalize',
40+
weight: 0.2,
41+
failureMessage: 'Issue does not reference a specific page, URL, or component where the bug occurs.',
42+
evaluate: (ctx) => {
43+
const lower = ctx.body.toLowerCase();
44+
const hasUrl = /https?:\/\//.test(ctx.body);
45+
const hasPage = /page|screen|component|section|tab|modal|button|form|menu/.test(lower);
46+
const hasRoute = /\/[a-z]/.test(ctx.body);
47+
return hasUrl || hasPage || hasRoute;
48+
},
49+
},
50+
];
51+
52+
export default rules;

rules/media.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Media rules — evidence requirements for bounty issues.
3+
*
4+
* Category: media
5+
* Evaluated after the media check phase.
6+
*/
7+
8+
import type { Rule } from '../src/rules/types.js';
9+
10+
const rules: Rule[] = [
11+
{
12+
id: 'media.require-evidence',
13+
description: 'Issue must include at least one screenshot or video URL',
14+
category: 'media',
15+
severity: 'require',
16+
failureMessage: 'No media evidence found. Attach a screenshot or video showing the bug.',
17+
evaluate: (ctx) => ctx.mediaUrls.length > 0,
18+
},
19+
{
20+
id: 'media.must-be-accessible',
21+
description: 'All media URLs must be publicly accessible (HTTP 200)',
22+
category: 'media',
23+
severity: 'require',
24+
failureMessage: 'Media URLs are not accessible. Ensure images/videos are publicly viewable.',
25+
evaluate: (ctx) => {
26+
if (ctx.mediaUrls.length === 0) return true;
27+
return ctx.mediaAccessible;
28+
},
29+
},
30+
{
31+
id: 'media.no-placeholder-urls',
32+
description: 'Media URLs should not be placeholder or example URLs',
33+
category: 'media',
34+
severity: 'reject',
35+
failureMessage: 'Detected placeholder/example media URLs instead of real evidence.',
36+
evaluate: (ctx) => {
37+
const placeholders = ['example.com', 'placeholder', 'lorem', 'test.png', 'screenshot.png'];
38+
return !ctx.mediaUrls.some((url) =>
39+
placeholders.some((p) => url.toLowerCase().includes(p)),
40+
);
41+
},
42+
},
43+
];
44+
45+
export default rules;

rules/scoring.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Scoring adjustment rules — modify the final score based on heuristics.
3+
*
4+
* Category: scoring
5+
* These apply penalty weights that the engine sums into penaltyScore.
6+
*/
7+
8+
import type { Rule } from '../src/rules/types.js';
9+
10+
const rules: Rule[] = [
11+
{
12+
id: 'scoring.duplicate-threshold',
13+
description: 'Penalize issues with moderate duplicate similarity (0.5-0.75)',
14+
category: 'scoring',
15+
severity: 'penalize',
16+
weight: 0.3,
17+
failureMessage: 'Issue has moderate similarity to existing issues, suggesting partial overlap.',
18+
evaluate: (ctx) => ctx.duplicateScore < 0.5,
19+
},
20+
{
21+
id: 'scoring.suspicious-edits',
22+
description: 'Penalize issues with concerning edit history (fraud score > 0.3)',
23+
category: 'scoring',
24+
severity: 'penalize',
25+
weight: 0.25,
26+
failureMessage: 'Issue has a concerning edit history pattern.',
27+
evaluate: (ctx) => ctx.editFraudScore < 0.3,
28+
},
29+
];
30+
31+
export default rules;

rules/spam.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Spam rules — patterns that indicate fraudulent or low-quality submissions.
3+
*
4+
* Category: spam
5+
* Evaluated after the spam scoring phase.
6+
*/
7+
8+
import type { Rule } from '../src/rules/types.js';
9+
10+
const rules: Rule[] = [
11+
{
12+
id: 'spam.high-score-reject',
13+
description: 'Reject issues with spam score above 0.85',
14+
category: 'spam',
15+
severity: 'reject',
16+
failureMessage: 'Issue has a very high spam score (> 0.85), indicating template-farmed or automated content.',
17+
evaluate: (ctx) => ctx.spamScore < 0.85,
18+
},
19+
{
20+
id: 'spam.generic-title',
21+
description: 'Title should not be a generic/template title',
22+
category: 'spam',
23+
severity: 'penalize',
24+
weight: 0.4,
25+
failureMessage: 'Issue title appears to be generic or template-generated.',
26+
evaluate: (ctx) => {
27+
const generic = [
28+
'bug found', 'bug report', 'issue found', 'error found',
29+
'problem found', 'bug', 'error', 'issue', 'problem',
30+
'found a bug', 'found an issue', 'found error',
31+
];
32+
return !generic.includes(ctx.title.trim().toLowerCase());
33+
},
34+
},
35+
{
36+
id: 'spam.body-is-title-repeat',
37+
description: 'Body should not be a simple repetition of the title',
38+
category: 'spam',
39+
severity: 'penalize',
40+
weight: 0.5,
41+
failureMessage: 'Issue body is essentially a copy of the title with no additional detail.',
42+
evaluate: (ctx) => {
43+
const titleNorm = ctx.title.toLowerCase().replace(/[^a-z0-9]/g, '');
44+
const bodyNorm = ctx.body.toLowerCase().replace(/[^a-z0-9]/g, '');
45+
if (bodyNorm.length === 0) return false;
46+
return titleNorm !== bodyNorm && !bodyNorm.startsWith(titleNorm);
47+
},
48+
},
49+
{
50+
id: 'spam.no-ai-filler',
51+
description: 'Body should not contain obvious AI-generated filler phrases',
52+
category: 'spam',
53+
severity: 'flag',
54+
failureMessage: 'Issue body contains phrases typical of AI-generated filler content.',
55+
evaluate: (ctx) => {
56+
const aiPhrases = [
57+
'as an ai', 'i cannot', 'delve into', 'it is important to note',
58+
'in conclusion,', 'furthermore,', 'in summary,',
59+
'this comprehensive', 'it\'s worth noting',
60+
];
61+
const lower = ctx.body.toLowerCase();
62+
return !aiPhrases.some((p) => lower.includes(p));
63+
},
64+
},
65+
];
66+
67+
export default rules;

rules/validity.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Validity rules — basic requirements for a bounty issue to be considered.
3+
*
4+
* Category: validity
5+
* These are evaluated first in the pipeline.
6+
*/
7+
8+
import type { Rule } from '../src/rules/types.js';
9+
10+
const rules: Rule[] = [
11+
{
12+
id: 'validity.min-body-length',
13+
description: 'Issue body must be at least 50 characters',
14+
category: 'validity',
15+
severity: 'reject',
16+
failureMessage: 'Issue body is too short (< 50 characters). A valid bug report needs a proper description.',
17+
evaluate: (ctx) => ctx.body.trim().length >= 50,
18+
},
19+
{
20+
id: 'validity.min-title-length',
21+
description: 'Issue title must be at least 10 characters',
22+
category: 'validity',
23+
severity: 'reject',
24+
failureMessage: 'Issue title is too short (< 10 characters). Use a descriptive title.',
25+
evaluate: (ctx) => ctx.title.trim().length >= 10,
26+
},
27+
{
28+
id: 'validity.no-empty-body',
29+
description: 'Issue body must not be empty or only whitespace',
30+
category: 'validity',
31+
severity: 'reject',
32+
failureMessage: 'Issue body is empty. Provide a description with steps to reproduce.',
33+
evaluate: (ctx) => ctx.body.trim().length > 0,
34+
},
35+
{
36+
id: 'validity.has-steps-or-description',
37+
description: 'Issue body should contain structured content (steps, expected/actual behavior)',
38+
category: 'validity',
39+
severity: 'penalize',
40+
weight: 0.3,
41+
failureMessage: 'Issue body lacks structured steps to reproduce or expected/actual behavior description.',
42+
evaluate: (ctx) => {
43+
const lower = ctx.body.toLowerCase();
44+
const hasSteps = /step|reproduce|how to|1\.|2\.|3\.|\d\)/.test(lower);
45+
const hasBehavior = /expect|actual|should|instead|but|however/.test(lower);
46+
return hasSteps || hasBehavior;
47+
},
48+
},
49+
{
50+
id: 'validity.not-a-feature-request',
51+
description: 'Issue should describe a bug, not a feature request',
52+
category: 'validity',
53+
severity: 'flag',
54+
failureMessage: 'Issue appears to be a feature request rather than a bug report.',
55+
evaluate: (ctx) => {
56+
const lower = (ctx.title + ' ' + ctx.body).toLowerCase();
57+
const featureSignals = ['feature request', 'suggestion', 'it would be nice', 'please add', 'can you add'];
58+
return !featureSignals.some((s) => lower.includes(s));
59+
},
60+
},
61+
];
62+
63+
export default rules;

src/index.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { startRequeueRecovery, stopRequeueRecovery } from './queue/requeue-recov
2828
import { getIssue } from './github/client.js';
2929
import { handleRequeue, handleForceRelease } from './api/requeue.js';
3030
import { getProcessingStatus, getDeadLetterList, recoverDeadLetterItem } from './api/status.js';
31+
import { loadRules } from './rules/index.js';
3132

3233
/**
3334
* Ensure the data directory exists for SQLite persistence.
@@ -209,6 +210,53 @@ export function createApp(): express.Express {
209210
}
210211
});
211212

213+
// POST /api/v1/rules/reload — hot-reload rules from disk
214+
apiRouter.post('/rules/reload', (_req, res) => {
215+
import('./rules/index.js')
216+
.then((mod) => mod.reloadRules())
217+
.then((reloaded) => {
218+
logger.info({ count: reloaded.length }, 'Rules hot-reloaded');
219+
res.status(200).json({
220+
status: 'reloaded',
221+
count: reloaded.length,
222+
rules: reloaded.map((r) => ({
223+
id: r.id,
224+
category: r.category,
225+
severity: r.severity,
226+
enabled: r.enabled !== false,
227+
})),
228+
});
229+
})
230+
.catch((err: unknown) => {
231+
const msg = err instanceof Error ? err.message : String(err);
232+
logger.error({ err: msg }, 'Rules reload failed');
233+
res.status(500).json({ error: 'reload_failed', message: msg });
234+
});
235+
});
236+
237+
// GET /api/v1/rules — list loaded rules
238+
apiRouter.get('/rules', (_req, res) => {
239+
import('./rules/index.js')
240+
.then((mod) => {
241+
const rules = mod.getRules();
242+
res.status(200).json({
243+
count: rules.length,
244+
rules: rules.map((r) => ({
245+
id: r.id,
246+
category: r.category,
247+
severity: r.severity,
248+
description: r.description,
249+
enabled: r.enabled !== false,
250+
weight: r.weight ?? 1.0,
251+
})),
252+
});
253+
})
254+
.catch((err: unknown) => {
255+
const msg = err instanceof Error ? err.message : String(err);
256+
res.status(500).json({ error: 'rules_error', message: msg });
257+
});
258+
});
259+
212260
app.use('/api/v1', apiRouter);
213261

214262
return app;
@@ -270,6 +318,10 @@ async function main(): Promise<void> {
270318
}
271319
});
272320

321+
// Load validation rules from rules/*.ts
322+
const rules = await loadRules();
323+
logger.info({ count: rules.length }, 'Validation rules loaded');
324+
273325
// Start background services
274326
startPoller();
275327
startQueueProcessor();

0 commit comments

Comments
 (0)