Skip to content

Commit d4e1caf

Browse files
NiallJoeMaherclaude
andcommitted
feat(admin): Phase 2 — nightly AI content review cron + metadata
Adds the AI content pipeline backing the admin cockpit: Schema (migration 0038, additive + idempotent topic seed): - post_metadata (1:1): sentiment, sentimentScore, qualityScore, modelId, analyzedAt watermark, schemaVersion. - topic / post_topic: controlled vocab + provenance-aware edges (ai|manual); the cron only ever rewrites its own 'ai' edges. - comments.moderatedAt watermark; reports.source (user|system) + nullable reporter so AI flags share the existing moderation queue. server/lib/contentAnalysis.ts: one Bedrock call per post returns topics + sentiment + quality + a moderation verdict. Gated + fail-open like autoReview; heuristic fallback when Bedrock is off. Unit-tested (7 cases). app/api/cron/daily-review: incremental, capped, per-item-isolated cron doing four passes (tag/sentiment, quality, re-screen posts+comments, digest email). Auth via CRON_SECRET. Wired into CDK via a new dailyReview invoker Lambda + daily EventBridge rule (cron-stack.ts), mirroring promoteScheduled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 69feb35 commit d4e1caf

11 files changed

Lines changed: 8362 additions & 4 deletions

File tree

app/api/cron/daily-review/route.ts

Lines changed: 446 additions & 0 deletions
Large diffs are not rendered by default.

cdk/lambdas/dailyReview/index.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
2+
3+
// Thin invoker: on a daily schedule, POST the app's /api/cron/daily-review route
4+
// (which does the actual topic tagging / sentiment / quality scoring / moderation
5+
// re-screen / digest). This Lambda only authenticates with CRON_SECRET and
6+
// reports back. Mirrors promoteScheduled/index.ts.
7+
8+
const ssmClient = new SSMClient({ region: "eu-west-1" });
9+
10+
// Read a decrypted SecureString from SSM (matches rssFetcher's `/env/...` convention).
11+
async function getSsmValue(secretName: string): Promise<string> {
12+
const params = {
13+
Name: secretName,
14+
WithDecryption: true,
15+
};
16+
17+
try {
18+
const command = new GetParameterCommand(params);
19+
const response = await ssmClient.send(command);
20+
if (!response.Parameter || !response.Parameter.Value) {
21+
throw new Error(`Parameter not found: ${secretName}`);
22+
}
23+
return response.Parameter.Value;
24+
} catch (error) {
25+
console.error(`Error retrieving secret: ${error}`);
26+
throw error;
27+
}
28+
}
29+
30+
// Site base URL from the required `/env/siteUrl` param. FAIL CLOSED: a missing
31+
// or unreadable param throws (visible Lambda failure) rather than falling back
32+
// to production — otherwise a non-prod deploy would silently POST prod.
33+
async function getBaseUrl(): Promise<string> {
34+
const value = await getSsmValue("/env/siteUrl");
35+
return value.replace(/\/+$/, "");
36+
}
37+
38+
// Main Lambda handler
39+
exports.handler = async function () {
40+
console.log("Daily Review invoker Lambda running");
41+
42+
const secret = await getSsmValue("/env/cronSecret");
43+
const base = await getBaseUrl();
44+
const url = `${base}/api/cron/daily-review`;
45+
46+
console.log(`Invoking ${url}`);
47+
48+
// Node 20 runtime ships a global fetch — no node-fetch dependency needed.
49+
const response = await fetch(url, {
50+
method: "POST",
51+
headers: {
52+
Authorization: `Bearer ${secret}`,
53+
},
54+
});
55+
56+
const body = await response.text();
57+
console.log(`Response ${response.status}: ${body}`);
58+
59+
// Surface failures in CloudWatch so a broken route / bad secret is visible.
60+
if (!response.ok) {
61+
throw new Error(`daily-review returned ${response.status}: ${body}`);
62+
}
63+
64+
return {
65+
statusCode: 200,
66+
headers: { "Content-Type": "application/json" },
67+
body,
68+
};
69+
};

0 commit comments

Comments
 (0)